Skip to content

Commit a442af1

Browse files
solomonneascodex
andcommitted
fix(hooks): detect mutations from observed state
Co-Authored-By: Codex <codex@openai.com>
1 parent 8c9357b commit a442af1

2 files changed

Lines changed: 67 additions & 14 deletions

File tree

src/brigade/claude_hooks/runtime.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"node_modules",
3939
}
4040
_SNAPSHOT_GIT_TIMEOUT_SECONDS = 3
41+
_UNAVAILABLE_FINGERPRINT = "unavailable"
4142
_BASH_WRITE_COMMANDS = {
4243
"apply_patch",
4344
"cp",
@@ -1588,8 +1589,12 @@ def _bash_write_detected(
15881589
) -> bool:
15891590
if not isinstance(baseline, str) or not baseline:
15901591
return False
1592+
if baseline == _UNAVAILABLE_FINGERPRINT:
1593+
return True
15911594
current = repo_worktree_fingerprint(target)
1592-
if current is None or current == baseline:
1595+
if current is None:
1596+
return True
1597+
if current == baseline:
15931598
return False
15941599
started = localio.parse_iso_datetime(started_at)
15951600
if started is None:
@@ -1757,10 +1762,9 @@ def handle_payload(event: str, payload: dict[str, Any]) -> dict[str, Any] | None
17571762
tool_input: dict[str, Any] = raw_tool_input if isinstance(raw_tool_input, dict) else {}
17581763
command = tool_input.get("command")
17591764
baseline = repo_worktree_fingerprint(target)
1760-
if baseline is not None:
1761-
state["pending_bash_fingerprint"] = baseline
1762-
state["pending_bash_started_at"] = localio.utc_now_iso()
1763-
write_session_state(target, session_id, state)
1765+
state["pending_bash_fingerprint"] = baseline or _UNAVAILABLE_FINGERPRINT
1766+
state["pending_bash_started_at"] = localio.utc_now_iso()
1767+
write_session_state(target, session_id, state)
17641768
if not is_raw_verification(command):
17651769
return None
17661770
state["verify_denied_count"] = int(state.get("verify_denied_count") or 0) + 1

tests/test_claude_hooks_runtime.py

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ def test_posttooluse_ignores_concurrent_session_write_during_read_only_bash(tmp_
412412
)
413413

414414

415-
def test_posttooluse_snapshot_fails_open_when_state_cannot_be_inspected(tmp_path: Path, monkeypatch):
415+
def test_posttooluse_snapshot_fails_closed_when_state_cannot_be_inspected(tmp_path: Path, monkeypatch):
416416
target = _wired_claude(tmp_path)
417417
session_id = "snapshot-unavailable"
418418
monkeypatch.setattr(runtime, "repo_worktree_fingerprint", lambda repo: None)
@@ -424,11 +424,61 @@ def test_posttooluse_snapshot_fails_open_when_state_cannot_be_inspected(tmp_path
424424
tool_input={"command": f"{sys.executable} -c \"print('noop')\""},
425425
)
426426
assert runtime.handle_payload("PreToolUse", pretool) is None
427-
assert runtime.read_session_state(target, session_id).get("pending_bash_fingerprint") is None
427+
assert runtime.read_session_state(target, session_id).get("pending_bash_fingerprint") == "unavailable"
428428

429429
succeeded = {**pretool, "hook_event_name": "PostToolUse"}
430430
assert runtime.handle_payload("PostToolUse", succeeded) is None
431-
assert runtime.read_session_state(target, session_id)["write_observed"] is False
431+
assert runtime.read_session_state(target, session_id)["write_observed"] is True
432+
blocked = runtime.handle_payload("Stop", _payload(target, "Stop", session_id=session_id, stop_hook_active=False))
433+
assert blocked["decision"] == "block"
434+
435+
436+
def test_posttooluse_fails_closed_when_post_command_snapshot_is_unavailable(tmp_path: Path, monkeypatch):
437+
target = _wired_claude(tmp_path)
438+
session_id = "post-snapshot-unavailable"
439+
fingerprint_calls = 0
440+
441+
def sequenced_fingerprint(repo: Path) -> str | None:
442+
nonlocal fingerprint_calls
443+
fingerprint_calls += 1
444+
return "baseline" if fingerprint_calls < 4 else None
445+
446+
monkeypatch.setattr(runtime, "repo_worktree_fingerprint", sequenced_fingerprint)
447+
pretool = _payload(
448+
target,
449+
"PreToolUse",
450+
session_id=session_id,
451+
tool_name="Bash",
452+
tool_input={"command": f"{sys.executable} -c \"print('noop')\""},
453+
)
454+
455+
assert runtime.handle_payload("PreToolUse", pretool) is None
456+
assert runtime.read_session_state(target, session_id)["pending_bash_fingerprint"] == "baseline"
457+
assert runtime.handle_payload("PostToolUse", {**pretool, "hook_event_name": "PostToolUse"}) is None
458+
459+
assert runtime.read_session_state(target, session_id)["write_observed"] is True
460+
blocked = runtime.handle_payload("Stop", _payload(target, "Stop", session_id=session_id, stop_hook_active=False))
461+
assert blocked["decision"] == "block"
462+
463+
464+
@pytest.mark.parametrize("command", ["gh --version", "jq --version", "rg --version"])
465+
def test_posttooluse_unlisted_read_only_command_does_not_observe_write(tmp_path: Path, command: str):
466+
target = _git_wired_claude(tmp_path)
467+
session_id = f"read-only-{command.split()[0]}"
468+
pretool = _payload(
469+
target,
470+
"PreToolUse",
471+
session_id=session_id,
472+
tool_name="Bash",
473+
tool_input={"command": command},
474+
)
475+
476+
assert runtime.handle_payload("PreToolUse", pretool) is None
477+
assert runtime.handle_payload("PostToolUse", {**pretool, "hook_event_name": "PostToolUse"}) is None
478+
479+
state = runtime.read_session_state(target, session_id)
480+
assert state["write_observed"] is False
481+
assert "pending_bash_fingerprint" not in state
432482
assert (
433483
runtime.handle_payload("Stop", _payload(target, "Stop", session_id=session_id, stop_hook_active=False)) is None
434484
)
@@ -840,7 +890,7 @@ def fake_run(repo: Path, *git_args: str):
840890
assert runtime.repo_worktree_fingerprint(target) is None
841891

842892

843-
def test_posttooluse_does_not_record_bash_write_when_hash_object_fails_for_untracked(tmp_path: Path, monkeypatch):
893+
def test_posttooluse_fails_closed_when_untracked_state_check_fails(tmp_path: Path, monkeypatch):
844894
target = _git_wired_claude(tmp_path)
845895
session_id = "hash-object-fail"
846896
out_file = target / "new.txt"
@@ -864,15 +914,14 @@ def fake_run(repo: Path, *git_args: str):
864914
assert runtime.handle_payload("PreToolUse", pretool) is None
865915
state = runtime.read_session_state(target, session_id)
866916
assert state["write_observed"] is False
867-
assert "pending_bash_fingerprint" not in state
917+
assert state["pending_bash_fingerprint"] == "unavailable"
868918

869919
out_file.write_text("after")
870920
succeeded = {**pretool, "hook_event_name": "PostToolUse"}
871921
assert runtime.handle_payload("PostToolUse", succeeded) is None
872-
assert runtime.read_session_state(target, session_id)["write_observed"] is False
873-
assert (
874-
runtime.handle_payload("Stop", _payload(target, "Stop", session_id=session_id, stop_hook_active=False)) is None
875-
)
922+
assert runtime.read_session_state(target, session_id)["write_observed"] is True
923+
blocked = runtime.handle_payload("Stop", _payload(target, "Stop", session_id=session_id, stop_hook_active=False))
924+
assert blocked["decision"] == "block"
876925

877926

878927
def test_posttooluse_records_bash_write_on_dirty_tracked_same_size_rewrite(tmp_path: Path):

0 commit comments

Comments
 (0)