Skip to content

Commit 73cf521

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/582-provenance-envelope
2 parents 0547e43 + 0260985 commit 73cf521

5 files changed

Lines changed: 243 additions & 62 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

src/brigade/model_inventory.py

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from __future__ import annotations
44

55
import re
6+
import subprocess
7+
import tempfile
68
from dataclasses import dataclass
79
from typing import Literal
810

@@ -15,6 +17,8 @@
1517
"grok": ["grok", "models"],
1618
"ollama": ["ollama", "list"],
1719
}
20+
_INVENTORY_TIMEOUT_SECONDS = 15.0
21+
_CURSOR_COMPLETION_PREFIX = "Tip: use --model <id>"
1822
_EFFORT_SUFFIX = re.compile(r"-(?:none|low|medium|high|xhigh|extra-high|max)$")
1923
_OLLAMA_OPERATIONAL_ERRORS = (
2024
"authentication",
@@ -107,12 +111,17 @@ def _inventory(self, cli_ref: str) -> _HarnessInventory:
107111
cached = self._inventories.get(cli_ref)
108112
if cached is not None:
109113
return cached
110-
result = proc.run(_LIST_COMMANDS[cli_ref], timeout=15.0)
114+
result = (
115+
_run_cursor_inventory()
116+
if cli_ref == "cursor"
117+
else proc.run(_LIST_COMMANDS[cli_ref], timeout=_INVENTORY_TIMEOUT_SECONDS)
118+
)
111119
if result.code != 0:
112120
diagnostic = result.stderr.strip() or result.stdout.strip() or f"exit {result.code}"
113121
inventory = _HarnessInventory(error=diagnostic[:160])
114122
else:
115-
recognized, models = _parse_model_list(cli_ref, f"{result.stdout}\n{result.stderr}")
123+
output = result.stdout if cli_ref == "cursor" else f"{result.stdout}\n{result.stderr}"
124+
recognized, models = _parse_model_list(cli_ref, output)
116125
if not recognized:
117126
inventory = _HarnessInventory(error="command returned an unrecognized inventory shape")
118127
elif not models and cli_ref != "ollama":
@@ -161,17 +170,61 @@ def _inspect_ollama(self, requested: str) -> ModelInventoryResult:
161170
return ModelInventoryResult(state, requested, (), diagnostic[:200])
162171

163172

173+
def _run_cursor_inventory() -> proc.Result:
174+
"""Capture Cursor inventory through a file because its pipe output truncates at 8 KiB."""
175+
with tempfile.TemporaryFile() as stdout:
176+
try:
177+
completed = subprocess.run(
178+
_LIST_COMMANDS["cursor"],
179+
stdout=stdout,
180+
stderr=subprocess.PIPE,
181+
stdin=subprocess.DEVNULL,
182+
timeout=_INVENTORY_TIMEOUT_SECONDS,
183+
check=False,
184+
)
185+
except subprocess.TimeoutExpired as exc:
186+
stdout.seek(0)
187+
output = stdout.read().decode("utf-8", errors="replace")
188+
diagnostic = _decode_subprocess_output(exc.stderr)
189+
if diagnostic and not diagnostic.endswith("\n"):
190+
diagnostic += "\n"
191+
return proc.Result(
192+
124,
193+
output,
194+
f"{diagnostic}timeout after {_INVENTORY_TIMEOUT_SECONDS}s",
195+
)
196+
except OSError as exc:
197+
return proc.Result(127 if isinstance(exc, FileNotFoundError) else 126, "", str(exc))
198+
stdout.seek(0)
199+
output = stdout.read().decode("utf-8", errors="replace")
200+
return proc.Result(completed.returncode, output, _decode_subprocess_output(completed.stderr))
201+
202+
203+
def _decode_subprocess_output(value: str | bytes | None) -> str:
204+
if value is None:
205+
return ""
206+
if isinstance(value, str):
207+
return value
208+
return value.decode("utf-8", errors="replace")
209+
210+
164211
def _parse_model_list(cli_ref: str, output: str) -> tuple[bool, tuple[str, ...]]:
165212
lines = output.splitlines()
166213
header_index = _inventory_header_index(cli_ref, lines)
167214
if header_index is None:
168215
return False, ()
169216
models: set[str] = set()
217+
complete = cli_ref != "cursor"
170218
for line in lines[header_index + 1 :]:
171219
if cli_ref == "cursor":
172-
if line.startswith("Tip:"):
220+
if not line.strip():
221+
continue
222+
if line.startswith(_CURSOR_COMPLETION_PREFIX):
223+
complete = True
173224
break
174225
match = re.match(r"^([a-z0-9][a-z0-9._:/\[\],=-]*)\s+-\s+.+$", line)
226+
if match is None:
227+
return False, ()
175228
elif cli_ref == "grok":
176229
match = re.match(r"^\s*\*\s+([^\s(]+)", line)
177230
else:
@@ -184,7 +237,7 @@ def _parse_model_list(cli_ref: str, output: str) -> tuple[bool, tuple[str, ...]]
184237
continue
185238
if match is not None:
186239
models.add(match.group(1))
187-
return True, tuple(sorted(models))
240+
return complete, tuple(sorted(models))
188241

189242

190243
def _inventory_header_index(cli_ref: str, lines: list[str]) -> int | None:

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)