Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions guard/hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,20 @@ STATE="$HOME/.claude/session-state.json"
NOW=$(date +%s)

# Read thresholds safely — no eval, one jq call for numeric values
VALS=$(jq -r '[.max_epoch, .warn_epoch, (.wind_down_epoch // 0), .end_allowed_epoch, .enforcement, (.blocked_periods | length)] | @tsv' "$STATE" 2>/dev/null) || exit 0
read -r MAX_EPOCH WARN_EPOCH WIND_DOWN_EPOCH END_EPOCH ENFORCEMENT BP_COUNT <<< "$VALS"
VALS=$(jq -r '[.max_epoch, .warn_epoch, (.wind_down_epoch // 0), .end_allowed_epoch, .enforcement, (.blocked_periods | length), (.start_epoch // 0), (.session_id // "-"), (.min_break_seconds // 900), (.min_activity_gap_seconds // 0)] | @tsv' "$STATE" 2>/dev/null) || exit 0
read -r MAX_EPOCH WARN_EPOCH WIND_DOWN_EPOCH END_EPOCH ENFORCEMENT BP_COUNT START_EPOCH STATE_SID MIN_BREAK_SECS MIN_ACTIVITY_GAP <<< "$VALS"

# If jq failed or values empty, bail gracefully
[ -z "$MAX_EPOCH" ] && exit 0

# --- One-shot notification helpers ---
# Marker files prevent the same informational message from firing on every tool use.
# Scoped by session ID so multiple terminals don't interfere with each other.
# Only active when launched via the wrapper (HUMAN_GUARD_SESSION_ID set).
# Without a managed session, notifications fire every time (legacy behavior).
# Falls back to session_id from session-state.json when wrapper didn't set env var
# (e.g., Claude launched from IDE or via `command claude`).
NOTIFY_DIR="$HOME/.claude/human-guard"
SID="${HUMAN_GUARD_SESSION_ID:-}"
[ -z "$SID" ] && [ "$STATE_SID" != "-" ] && SID="$STATE_SID"

# Touch session activity + detect intra-session breaks
if [ -n "$SID" ]; then
Expand All @@ -40,8 +41,6 @@ if [ -n "$SID" ]; then
PREV_EPOCH=$(cat "$ACTIVITY_FILE" 2>/dev/null || echo 0)
if [ "$PREV_EPOCH" -gt 0 ] 2>/dev/null; then
GAP=$(( NOW - PREV_EPOCH ))
MIN_BREAK_SECS=$(jq -r '.min_break_seconds // 900' "$STATE" 2>/dev/null || echo 900)
MIN_ACTIVITY_GAP=$(jq -r '.min_activity_gap_seconds // 0' "$STATE" 2>/dev/null || echo 0)
PREV_WSB=$(cat "$WSB_FILE" 2>/dev/null || echo 0)
if [ "$GAP" -ge "$MIN_BREAK_SECS" ]; then
# Intra-session break detected — reset work counter
Expand All @@ -68,8 +67,8 @@ if [ -n "$SID" ]; then
fi
fi

# Emit a one-shot systemMessage (only when session-managed).
# Without SID, always emits (no suppression).
# Emit a one-shot systemMessage.
# SID comes from wrapper env var or session-state.json fallback.
# Uses mkdir for atomic check+create (POSIX guarantee: mkdir fails if exists).
_notify_once() {
local key="$1" msg="$2"
Expand All @@ -80,6 +79,14 @@ _notify_once() {
return 0
}

# Stale session-state guard: if past max_epoch + min_break, the state is from a
# previous session that has ended — skip ALL enforcement (including blocking checks).
# Must run before blocked_periods/outside_hours to avoid hard-blocking with stale epochs.
if [ "$NOW" -ge "$MAX_EPOCH" ] && [ "$START_EPOCH" -gt 0 ] 2>/dev/null; then
STALE_LIMIT=$(( MAX_EPOCH + MIN_BREAK_SECS ))
[ "$NOW" -ge "$STALE_LIMIT" ] && exit 0
fi

# Check blocked periods
if [ "$BP_COUNT" -gt 0 ] 2>/dev/null; then
BP_DATA=$(jq -r '.blocked_periods[] | "\(.start_epoch) \(.end_epoch)"' "$STATE" 2>/dev/null)
Expand Down
226 changes: 226 additions & 0 deletions tests/test_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -2410,3 +2410,229 @@ def test_iso_activity_sentinel_reinitializes_activity(self, hook_env):
assert abs(stored - now) < 10, (
f"reinitialized epoch {stored} should be close to now {now}"
)


# ===========================================================================
# 9. Stale session-state guard & SID fallback
# ===========================================================================

class TestStaleStateAndSidFallback:
"""Hook integration: stale session-state detection and SID fallback."""

@pytest.fixture
def hook_env(self, tmp_path):
"""Isolated hook environment with full stdout capture and no-SID support."""
import os
home = tmp_path / "home"
claude_dir = home / ".claude"
guard_dir = claude_dir / "human-guard"
guard_dir.mkdir(parents=True)

hook_path = Path(__file__).resolve().parent.parent / "guard" / "hook.sh"

def run_hook(state, sid=None, env_extra=None):
"""Run hook.sh and return (stdout, returncode).

If sid is None, HUMAN_GUARD_SESSION_ID is NOT set (simulates
Claude launched outside the wrapper).
"""
state_path = claude_dir / "session-state.json"
state_path.write_text(json.dumps(state))

env = {**os.environ, "HOME": str(home)}
if sid is not None:
env["HUMAN_GUARD_SESSION_ID"] = sid
else:
env.pop("HUMAN_GUARD_SESSION_ID", None)
if env_extra:
env.update(env_extra)

import subprocess
result = subprocess.run(
["bash", str(hook_path)],
input="{}",
capture_output=True,
text=True,
env=env,
)
return result.stdout.strip(), result.returncode

return {
"run_hook": run_hook,
"guard_dir": guard_dir,
}

def _make_state(self, **overrides):
"""Create minimal session-state with configurable epochs."""
import time as t
now = int(t.time())
state = {
"session_id": "state-sid-01",
"start_epoch": now - 3600,
"max_epoch": now + 3600,
"warn_epoch": now + 2880,
"wind_down_epoch": 0,
"end_allowed_epoch": now + 7200,
"min_break_seconds": 900,
"min_activity_gap_seconds": 0,
"blocked_periods": [],
"enforcement": "soft",
"messages": {
"session_limit": "Llevas 2h30.",
"wind_down": "",
"blocked_period": "",
"break_reminder": "",
"outside_hours": "",
},
}
state.update(overrides)
return state

# --- Staleness guard ---

def test_stale_session_state_no_output(self, hook_env):
"""Past max_epoch + min_break → stale state → no message, exit 0."""
import time as t
now = int(t.time())
state = self._make_state(
start_epoch=now - 14400,
max_epoch=now - 5400,
warn_epoch=now - 7200,
end_allowed_epoch=now + 3600,
min_break_seconds=900,
)
stdout, rc = hook_env["run_hook"](state, sid="active-sid")
assert rc == 0
assert stdout == "", (
f"stale session-state should produce no output, got: {stdout!r}"
)

def test_fresh_over_limit_still_fires(self, hook_env):
"""Just past max_epoch but within min_break window → session_limit fires."""
import time as t
now = int(t.time())
state = self._make_state(
start_epoch=now - 9300,
max_epoch=now - 300,
warn_epoch=now - 2100,
end_allowed_epoch=now + 3600,
min_break_seconds=900,
)
stdout, rc = hook_env["run_hook"](state, sid="active-sid")
assert rc == 0
assert "Llevas 2h30" in stdout, (
"session_limit should fire when just past max_epoch"
)

# --- SID fallback ---

def test_no_sid_env_falls_back_to_state_session_id(self, hook_env):
"""Without HUMAN_GUARD_SESSION_ID, notification marker uses state's session_id."""
import time as t
now = int(t.time())
state = self._make_state(
session_id="fallback-sid-99",
start_epoch=now - 9300,
max_epoch=now - 300,
warn_epoch=now - 2100,
end_allowed_epoch=now + 3600,
min_break_seconds=900,
)
stdout, rc = hook_env["run_hook"](state, sid=None)
assert rc == 0
assert "Llevas 2h30" in stdout

marker = hook_env["guard_dir"] / ".notified.session_limit.fallback-sid-99"
assert marker.exists(), (
"notification marker should use session_id from session-state.json"
)

def test_no_sid_notification_fires_once_not_every_call(self, hook_env):
"""Two calls without SID env → message only on first call."""
import time as t
now = int(t.time())
state = self._make_state(
session_id="dedup-test-01",
start_epoch=now - 9300,
max_epoch=now - 300,
warn_epoch=now - 2100,
end_allowed_epoch=now + 3600,
min_break_seconds=900,
)
stdout1, rc1 = hook_env["run_hook"](state, sid=None)
assert rc1 == 0
assert "Llevas 2h30" in stdout1

stdout2, rc2 = hook_env["run_hook"](state, sid=None)
assert rc2 == 0
assert stdout2 == "", (
f"second call should be suppressed by _notify_once, got: {stdout2!r}"
)

def test_with_sid_env_still_works(self, hook_env):
"""With HUMAN_GUARD_SESSION_ID set, behavior unchanged."""
import time as t
now = int(t.time())
state = self._make_state(
session_id="state-sid-xx",
start_epoch=now - 9300,
max_epoch=now - 300,
warn_epoch=now - 2100,
end_allowed_epoch=now + 3600,
min_break_seconds=900,
)
stdout1, rc1 = hook_env["run_hook"](state, sid="env-sid-01")
assert rc1 == 0
assert "Llevas 2h30" in stdout1

marker_env = hook_env["guard_dir"] / ".notified.session_limit.env-sid-01"
marker_state = hook_env["guard_dir"] / ".notified.session_limit.state-sid-xx"
assert marker_env.exists(), "marker should use env SID"
assert not marker_state.exists(), "marker should NOT use state session_id"

stdout2, rc2 = hook_env["run_hook"](state, sid="env-sid-01")
assert rc2 == 0
assert stdout2 == ""
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# --- Staleness guard must precede blocking checks ---

def test_stale_outside_hours_does_not_block(self, hook_env):
"""Stale state with end_allowed_epoch in the past must NOT exit 2."""
import time as t
now = int(t.time())
state = self._make_state(
start_epoch=now - 14400,
max_epoch=now - 5400,
warn_epoch=now - 7200,
end_allowed_epoch=now - 3600,
min_break_seconds=900,
)
stdout, rc = hook_env["run_hook"](state, sid="active-sid")
assert rc == 0, (
f"stale state should not hard-block; got exit {rc}"
)
assert stdout == ""

def test_stale_blocked_period_does_not_block(self, hook_env):
"""Stale state with active blocked_period must NOT exit 2."""
import time as t
now = int(t.time())
state = self._make_state(
start_epoch=now - 14400,
max_epoch=now - 5400,
warn_epoch=now - 7200,
end_allowed_epoch=now + 7200,
min_break_seconds=900,
blocked_periods=[{
"name": "family",
"start_epoch": now - 3600,
"end_epoch": now + 3600,
}],
)
stdout, rc = hook_env["run_hook"](state, sid="active-sid")
assert rc == 0, (
f"stale state should not hard-block via blocked_period; got exit {rc}"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
assert stdout == "", (
f"stale state should produce no output, got: {stdout!r}"
)
2 changes: 2 additions & 0 deletions tests/test_install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,8 @@ test_hook_no_sid_no_suppression() {
}
JSON
# First call without SID: emits
# Explicitly unset to avoid inheriting from an active wrapper session
unset HUMAN_GUARD_SESSION_ID
local out1
out1=$(echo '{}' | bash "$HOME/.claude/human-guard/hook.sh" 2>/dev/null)
echo "$out1" | jq -e '.systemMessage' > /dev/null
Expand Down
Loading