diff --git a/hooks-gt/_shared/hestia_governance_closure.py b/hooks-gt/_shared/hestia_governance_closure.py index 3f098ea0..e3eb5599 100755 --- a/hooks-gt/_shared/hestia_governance_closure.py +++ b/hooks-gt/_shared/hestia_governance_closure.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# hestia-gt-sha256: f32a4241f27f3f89f7d7391d9c02bbe1e1965c0d876c0ffd03377abb6e58687e (published ground truth; manifest: hooks-gt) +# hestia-gt-sha256: 1c5c413502e82fe3e84e19959f32ce378f69a3a08661172df9e9e020407359c7 (published ground truth; manifest: hooks-gt) """hestia_governance_closure — ONE governance-access classifier for every harness shim. PRD gate-consolidation §5 / §6.B / §7.3(8): the effective governance CLOSURE — the files that @@ -1713,6 +1713,11 @@ def _bash_write_targets(command: str) -> list: # destination. Filing records a petition; it does not write the # requested file (cbp-being, 2026-10-19). "request_scope"}) +# A being's COMPOSED reads (SAGE being_gate_client, #1361): the seat builds a grep / git read from +# {pattern, path}, so their `path` is the read target, never a destination. The NAME buys nothing +# else (review hold on #1362): a `command` such an event carries is classified for write positions +# exactly as a Bash command is, and file_path / notebook_path keep the unknown-tool reading. +_COMPOSED_READ_TOOLS = frozenset({"search", "git_read"}) def _write_position_targets(tool_name: str, tool_input: Any) -> tuple: @@ -1722,6 +1727,13 @@ def _write_position_targets(tool_name: str, tool_input: Any) -> tuple: return [], None if tool_name in _READ_PATH_TOOLS: return [], None + if tool_name in _COMPOSED_READ_TOOLS: + targets, note = [], None + if isinstance(tool_input.get("command"), str) and tool_input["command"].strip(): + targets, note = _write_position_targets("Bash", {"command": tool_input["command"]}) + rest = [v for k in _PATH_KEYS + if k != "path" and isinstance((v := tool_input.get(k)), str) and v] + return list(targets) + rest, note if tool_name in _BASH_TOOLS: cmd = tool_input.get("command") if not isinstance(cmd, str) or not cmd.strip(): @@ -1752,7 +1764,7 @@ def _read_position_mentions(tool_name: str, tool_input: Any) -> list: if not isinstance(tool_input, dict): return [] out = [] - if tool_name in _BASH_TOOLS: + if tool_name in _BASH_TOOLS or tool_name in _COMPOSED_READ_TOOLS: cmd = tool_input.get("command") if isinstance(cmd, str) and cmd.strip(): try: diff --git a/hooks-gt/_shared/manifest.json b/hooks-gt/_shared/manifest.json index 89b532b1..bf7d84aa 100644 --- a/hooks-gt/_shared/manifest.json +++ b/hooks-gt/_shared/manifest.json @@ -17,7 +17,7 @@ }, { "path": "hestia_governance_closure.py", - "sha256": "f32a4241f27f3f89f7d7391d9c02bbe1e1965c0d876c0ffd03377abb6e58687e", + "sha256": "1c5c413502e82fe3e84e19959f32ce378f69a3a08661172df9e9e020407359c7", "source": "plugins/_shared/hestia_governance_closure.py" }, { @@ -38,5 +38,5 @@ ], "engine": null, "requires": [], - "gt_version": "0582b82b2310e67e6e121174327bf3d6f50b01b14f9d621ec1fff89d004a74c5" + "gt_version": "f97e63f0a21def86534f0aa486a6f32a6e16159bd3be8c3cd81202d61541093b" } diff --git a/hooks-gt/claude-code/manifest.json b/hooks-gt/claude-code/manifest.json index f5106f5c..aa9bb4a0 100644 --- a/hooks-gt/claude-code/manifest.json +++ b/hooks-gt/claude-code/manifest.json @@ -33,7 +33,7 @@ ], "engine": { "unit": "_shared", - "gt_version": "0582b82b2310e67e6e121174327bf3d6f50b01b14f9d621ec1fff89d004a74c5", + "gt_version": "f97e63f0a21def86534f0aa486a6f32a6e16159bd3be8c3cd81202d61541093b", "files": [ { "path": "hestia_gate_core.py", @@ -45,7 +45,7 @@ }, { "path": "hestia_governance_closure.py", - "sha256": "f32a4241f27f3f89f7d7391d9c02bbe1e1965c0d876c0ffd03377abb6e58687e" + "sha256": "1c5c413502e82fe3e84e19959f32ce378f69a3a08661172df9e9e020407359c7" }, { "path": "hestia_shell_classifier.py", @@ -62,5 +62,5 @@ ] }, "requires": [], - "gt_version": "369734a7467ee2e381c947aec5d3733f8c9e97e57d92f878d7893549c85e2000" + "gt_version": "b1662c83d9c4cc12fd7d23ee192586802880388316821076bb0bbab1bf8e4e28" } diff --git a/hooks-gt/codex/manifest.json b/hooks-gt/codex/manifest.json index ee571ac6..b8b6ba4f 100644 --- a/hooks-gt/codex/manifest.json +++ b/hooks-gt/codex/manifest.json @@ -33,7 +33,7 @@ ], "engine": { "unit": "_shared", - "gt_version": "0582b82b2310e67e6e121174327bf3d6f50b01b14f9d621ec1fff89d004a74c5", + "gt_version": "f97e63f0a21def86534f0aa486a6f32a6e16159bd3be8c3cd81202d61541093b", "files": [ { "path": "hestia_gate_core.py", @@ -45,7 +45,7 @@ }, { "path": "hestia_governance_closure.py", - "sha256": "f32a4241f27f3f89f7d7391d9c02bbe1e1965c0d876c0ffd03377abb6e58687e" + "sha256": "1c5c413502e82fe3e84e19959f32ce378f69a3a08661172df9e9e020407359c7" }, { "path": "hestia_shell_classifier.py", @@ -62,5 +62,5 @@ ] }, "requires": [], - "gt_version": "e71a8d2fbbf130ef19240296c23ea9c98b308ff39fd638325faadff38c32f425" + "gt_version": "726f554213431a316ebc331fa7bdd545f363aeb332a350fbc8c2f022031f7615" } diff --git a/hooks-gt/gemini/manifest.json b/hooks-gt/gemini/manifest.json index 58dabbb9..06eaa09c 100644 --- a/hooks-gt/gemini/manifest.json +++ b/hooks-gt/gemini/manifest.json @@ -33,7 +33,7 @@ ], "engine": { "unit": "_shared", - "gt_version": "0582b82b2310e67e6e121174327bf3d6f50b01b14f9d621ec1fff89d004a74c5", + "gt_version": "f97e63f0a21def86534f0aa486a6f32a6e16159bd3be8c3cd81202d61541093b", "files": [ { "path": "hestia_gate_core.py", @@ -45,7 +45,7 @@ }, { "path": "hestia_governance_closure.py", - "sha256": "f32a4241f27f3f89f7d7391d9c02bbe1e1965c0d876c0ffd03377abb6e58687e" + "sha256": "1c5c413502e82fe3e84e19959f32ce378f69a3a08661172df9e9e020407359c7" }, { "path": "hestia_shell_classifier.py", @@ -62,5 +62,5 @@ ] }, "requires": [], - "gt_version": "23a31fd6e2ffe46e6617c1ccd04dd47247c77a7e806ec327c9663b4b86de092e" + "gt_version": "75f580643a40dbc06dc9849c95dd2d07aee9a6756b44ecb393a967b32ddfe9b5" } diff --git a/hooks-gt/kimi/manifest.json b/hooks-gt/kimi/manifest.json index 5feb8c96..355ee5af 100644 --- a/hooks-gt/kimi/manifest.json +++ b/hooks-gt/kimi/manifest.json @@ -33,7 +33,7 @@ ], "engine": { "unit": "_shared", - "gt_version": "0582b82b2310e67e6e121174327bf3d6f50b01b14f9d621ec1fff89d004a74c5", + "gt_version": "f97e63f0a21def86534f0aa486a6f32a6e16159bd3be8c3cd81202d61541093b", "files": [ { "path": "hestia_gate_core.py", @@ -45,7 +45,7 @@ }, { "path": "hestia_governance_closure.py", - "sha256": "f32a4241f27f3f89f7d7391d9c02bbe1e1965c0d876c0ffd03377abb6e58687e" + "sha256": "1c5c413502e82fe3e84e19959f32ce378f69a3a08661172df9e9e020407359c7" }, { "path": "hestia_shell_classifier.py", @@ -62,5 +62,5 @@ ] }, "requires": [], - "gt_version": "70c7b764ce150a6c8b599e5f8bfb4fc318c800b513521a1f213998e03e6b1516" + "gt_version": "4cc3378842c36bc9d8a17c319ceab5713c8a2282255bbf302285a384b60bf1f0" } diff --git a/plugins/_shared/hestia_governance_closure.py b/plugins/_shared/hestia_governance_closure.py index c91ecc9a..5fc75408 100755 --- a/plugins/_shared/hestia_governance_closure.py +++ b/plugins/_shared/hestia_governance_closure.py @@ -1712,6 +1712,11 @@ def _bash_write_targets(command: str) -> list: # destination. Filing records a petition; it does not write the # requested file (cbp-being, 2026-10-19). "request_scope"}) +# A being's COMPOSED reads (SAGE being_gate_client, #1361): the seat builds a grep / git read from +# {pattern, path}, so their `path` is the read target, never a destination. The NAME buys nothing +# else (review hold on #1362): a `command` such an event carries is classified for write positions +# exactly as a Bash command is, and file_path / notebook_path keep the unknown-tool reading. +_COMPOSED_READ_TOOLS = frozenset({"search", "git_read"}) def _write_position_targets(tool_name: str, tool_input: Any) -> tuple: @@ -1721,6 +1726,13 @@ def _write_position_targets(tool_name: str, tool_input: Any) -> tuple: return [], None if tool_name in _READ_PATH_TOOLS: return [], None + if tool_name in _COMPOSED_READ_TOOLS: + targets, note = [], None + if isinstance(tool_input.get("command"), str) and tool_input["command"].strip(): + targets, note = _write_position_targets("Bash", {"command": tool_input["command"]}) + rest = [v for k in _PATH_KEYS + if k != "path" and isinstance((v := tool_input.get(k)), str) and v] + return list(targets) + rest, note if tool_name in _BASH_TOOLS: cmd = tool_input.get("command") if not isinstance(cmd, str) or not cmd.strip(): @@ -1751,7 +1763,7 @@ def _read_position_mentions(tool_name: str, tool_input: Any) -> list: if not isinstance(tool_input, dict): return [] out = [] - if tool_name in _BASH_TOOLS: + if tool_name in _BASH_TOOLS or tool_name in _COMPOSED_READ_TOOLS: cmd = tool_input.get("command") if isinstance(cmd, str) and cmd.strip(): try: diff --git a/tools/closure_read_tool_names_test.py b/tools/closure_read_tool_names_test.py index 6af9cc56..b18c9b9a 100755 --- a/tools/closure_read_tool_names_test.py +++ b/tools/closure_read_tool_names_test.py @@ -68,6 +68,35 @@ def test_a_scope_request_naming_a_gate_file_is_not_a_write(): check("request_scope_is_not_write", v.classification != "write", str(v)) +def test_a_being_composed_read_verb_is_a_read(): + # SAGE's search and git_read reach the gate under their own names with {pattern, path} / + # {op, rev, path}; the seat composes a grep / git read from them. Both were refused as + # governance WRITES on the snapshot (cbp-being, 2026-10-21: 08f7aa13417d5c50, #1361). + for tool, args in (("search", {"pattern": "verdict_available", "path": GATE_FILE}), + ("search", {"pattern": "x", "path": os.path.dirname(GATE_FILE)}), + ("git_read", {"op": "log", "path": GATE_FILE})): + v = g.classify(tool, args, cwd=NEUTRAL_CWD) + check(f"{tool}_is_read", v.classification == "read", str(v)) + # A composed command that only READS keeps it a read. + for tool, cmd in (("search", f"grep -r -n -I -E -s --max-count=20 -e verdict_available -- {GATE_FILE}"), + ("git_read", f"git -C /tmp/wt log --oneline -- {GATE_FILE}")): + v = g.classify(tool, {"path": GATE_FILE, "command": cmd}, cwd=NEUTRAL_CWD) + check(f"{tool}_reading_command_is_read", v.classification == "read", str(v)) + + +def test_a_composed_read_name_does_not_launder_a_write(): + # Review hold on #1362: the NAME must not be what makes it a read. A command such an event + # carries is classified for write positions as Bash is, and the other destination keys keep + # the unknown-tool reading. Each of these was a read at a95d0577. + for tool in ("search", "git_read"): + for cmd in (f"tee {GATE_FILE}", f"sed -i s/a/b/ {GATE_FILE}", f"cat x > {GATE_FILE}", + f"grep -r x -- /tmp && cp /tmp/evil {GATE_FILE}"): + v = g.classify(tool, {"path": GATE_FILE, "command": cmd}, cwd=NEUTRAL_CWD) + check(f"{tool}_command_write::{cmd}", v.classification == "write", str(v)) + v = g.classify(tool, {"pattern": "x", "file_path": GATE_FILE}, cwd=NEUTRAL_CWD) + check(f"{tool}_file_path_is_write", v.classification == "write", str(v)) + + def test_writers_and_unknown_tools_still_write(): # Controls: the fix must not widen into the write names, and an unknown tool keeps the # conservative default (its path keys are destinations). @@ -97,6 +126,8 @@ def test_witness_read_names_are_closure_readers(): test_a_being_read_of_a_gate_file_is_a_read, test_every_lowercase_reader_is_a_read, test_a_scope_request_naming_a_gate_file_is_not_a_write, + test_a_being_composed_read_verb_is_a_read, + test_a_composed_read_name_does_not_launder_a_write, test_writers_and_unknown_tools_still_write, test_gemini_readers_are_closure_readers, test_witness_read_names_are_closure_readers,