From a95d057795b13392406f80cd4c1626aaac195f80 Mon Sep 17 00:00:00 2001 From: Dennis Palatov Date: Wed, 21 Oct 2026 13:15:02 -0700 Subject: [PATCH 1/2] closure: a being's composed reads (search, git_read) are reads (#1361) SAGE's being_gate_client sends search and git_read to the gate under their own names, with {pattern, path} / {op, rev, path}. Neither was in _READ_PATH_TOOLS, so the unknown-tool rule treated `path` as a write destination. A being's search of the read-only governance snapshot was refused as gate.self_access and auto-opened escalations described as "approving authorises this one write" (cbp-being, 2026-10-21: 08f7aa13417d5c50, 4161b0b8e92c5d2a; 46 search denies in its log). - _READ_PATH_TOOLS gains "search" and "git_read" (both closure copies). The seat composes the actual command, and the law judges that command. - tools/closure_read_tool_names_test.py: new test, the composed read verbs classify as read on a gate file and on its directory. It fails on main. - hooks-gt: sha header and manifests regenerated by hooks_gt.py publish. Stopgap, as #1361 proposes. Closing the class (verbs declare their path mode, or the closure judges the composed command) is #1353. Signed: cbp-claude Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH --- hooks-gt/_shared/hestia_governance_closure.py | 8 ++++++-- hooks-gt/_shared/manifest.json | 4 ++-- hooks-gt/claude-code/manifest.json | 6 +++--- hooks-gt/codex/manifest.json | 6 +++--- hooks-gt/gemini/manifest.json | 6 +++--- hooks-gt/kimi/manifest.json | 6 +++--- plugins/_shared/hestia_governance_closure.py | 6 +++++- tools/closure_read_tool_names_test.py | 12 ++++++++++++ 8 files changed, 37 insertions(+), 17 deletions(-) diff --git a/hooks-gt/_shared/hestia_governance_closure.py b/hooks-gt/_shared/hestia_governance_closure.py index 3f098ea02..4eb29aff1 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: 86d7664624946f36d77935168ac82dcfc8ee5bd5bf025cbe7ed1f4ce31dd5fc1 (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 @@ -1712,7 +1712,11 @@ def _bash_write_targets(command: str) -> list: # A being's scope ASK: its path is what it asks for, not a # destination. Filing records a petition; it does not write the # requested file (cbp-being, 2026-10-19). - "request_scope"}) + "request_scope", + # A being's composed reads (SAGE being_gate_client): the seat + # builds a grep / git read from {pattern, path}; the law judges + # that command and nothing is written (cbp-being, 2026-10-21, #1361). + "search", "git_read"}) def _write_position_targets(tool_name: str, tool_input: Any) -> tuple: diff --git a/hooks-gt/_shared/manifest.json b/hooks-gt/_shared/manifest.json index b3108133b..ee88a346e 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": "86d7664624946f36d77935168ac82dcfc8ee5bd5bf025cbe7ed1f4ce31dd5fc1", "source": "plugins/_shared/hestia_governance_closure.py" }, { @@ -38,5 +38,5 @@ ], "engine": null, "requires": [], - "gt_version": "a76ba6abad4ad7f14c0a07835a4bf201e6d16eaf07140afe7822df243694896c" + "gt_version": "f22f6cb765fc43a0e33224a99dc5e42bd5da33032335504c8c684ca80e7f09d1" } diff --git a/hooks-gt/claude-code/manifest.json b/hooks-gt/claude-code/manifest.json index cdd845095..9e3443f40 100644 --- a/hooks-gt/claude-code/manifest.json +++ b/hooks-gt/claude-code/manifest.json @@ -33,7 +33,7 @@ ], "engine": { "unit": "_shared", - "gt_version": "a76ba6abad4ad7f14c0a07835a4bf201e6d16eaf07140afe7822df243694896c", + "gt_version": "f22f6cb765fc43a0e33224a99dc5e42bd5da33032335504c8c684ca80e7f09d1", "files": [ { "path": "hestia_gate_core.py", @@ -45,7 +45,7 @@ }, { "path": "hestia_governance_closure.py", - "sha256": "f32a4241f27f3f89f7d7391d9c02bbe1e1965c0d876c0ffd03377abb6e58687e" + "sha256": "86d7664624946f36d77935168ac82dcfc8ee5bd5bf025cbe7ed1f4ce31dd5fc1" }, { "path": "hestia_shell_classifier.py", @@ -62,5 +62,5 @@ ] }, "requires": [], - "gt_version": "7af1e610574e5dd7f77b21025356286a9b23b2e47c78a251fb1ca1face5d0c2a" + "gt_version": "d83e8d84654ec6ee30ef3d6275ab821f67ba07fd855f7a420a9f5f12ae221c06" } diff --git a/hooks-gt/codex/manifest.json b/hooks-gt/codex/manifest.json index f748327f7..817de349f 100644 --- a/hooks-gt/codex/manifest.json +++ b/hooks-gt/codex/manifest.json @@ -33,7 +33,7 @@ ], "engine": { "unit": "_shared", - "gt_version": "a76ba6abad4ad7f14c0a07835a4bf201e6d16eaf07140afe7822df243694896c", + "gt_version": "f22f6cb765fc43a0e33224a99dc5e42bd5da33032335504c8c684ca80e7f09d1", "files": [ { "path": "hestia_gate_core.py", @@ -45,7 +45,7 @@ }, { "path": "hestia_governance_closure.py", - "sha256": "f32a4241f27f3f89f7d7391d9c02bbe1e1965c0d876c0ffd03377abb6e58687e" + "sha256": "86d7664624946f36d77935168ac82dcfc8ee5bd5bf025cbe7ed1f4ce31dd5fc1" }, { "path": "hestia_shell_classifier.py", @@ -62,5 +62,5 @@ ] }, "requires": [], - "gt_version": "de7cb28e9bff27bc3ac33be49a9b36812650f8882c85d203d16b3a327cc1f0da" + "gt_version": "cce9aace5e851b05f8e7909076258e38b6e1a7f8d30941a957f37ca334e22d30" } diff --git a/hooks-gt/gemini/manifest.json b/hooks-gt/gemini/manifest.json index 1a69e5c2b..cd0982a0d 100644 --- a/hooks-gt/gemini/manifest.json +++ b/hooks-gt/gemini/manifest.json @@ -33,7 +33,7 @@ ], "engine": { "unit": "_shared", - "gt_version": "a76ba6abad4ad7f14c0a07835a4bf201e6d16eaf07140afe7822df243694896c", + "gt_version": "f22f6cb765fc43a0e33224a99dc5e42bd5da33032335504c8c684ca80e7f09d1", "files": [ { "path": "hestia_gate_core.py", @@ -45,7 +45,7 @@ }, { "path": "hestia_governance_closure.py", - "sha256": "f32a4241f27f3f89f7d7391d9c02bbe1e1965c0d876c0ffd03377abb6e58687e" + "sha256": "86d7664624946f36d77935168ac82dcfc8ee5bd5bf025cbe7ed1f4ce31dd5fc1" }, { "path": "hestia_shell_classifier.py", @@ -62,5 +62,5 @@ ] }, "requires": [], - "gt_version": "e09df83c6f8e03ce97d2759bbc50d6f047ee195ea352e339eb6ded8ab6b964e6" + "gt_version": "aa88591430eeec75969186687d2b52169cf40b3d9b03ea2b351648027eb80e37" } diff --git a/hooks-gt/kimi/manifest.json b/hooks-gt/kimi/manifest.json index 488ba033a..97f771eaa 100644 --- a/hooks-gt/kimi/manifest.json +++ b/hooks-gt/kimi/manifest.json @@ -33,7 +33,7 @@ ], "engine": { "unit": "_shared", - "gt_version": "a76ba6abad4ad7f14c0a07835a4bf201e6d16eaf07140afe7822df243694896c", + "gt_version": "f22f6cb765fc43a0e33224a99dc5e42bd5da33032335504c8c684ca80e7f09d1", "files": [ { "path": "hestia_gate_core.py", @@ -45,7 +45,7 @@ }, { "path": "hestia_governance_closure.py", - "sha256": "f32a4241f27f3f89f7d7391d9c02bbe1e1965c0d876c0ffd03377abb6e58687e" + "sha256": "86d7664624946f36d77935168ac82dcfc8ee5bd5bf025cbe7ed1f4ce31dd5fc1" }, { "path": "hestia_shell_classifier.py", @@ -62,5 +62,5 @@ ] }, "requires": [], - "gt_version": "1174b3843662f8c4a0872ebed15eadc6a81d190ca0a8ba23945ea3fa227f8d6f" + "gt_version": "96637cb1a5216fce682c471801ac3a17e2c50c9c7e9f6a416f097bf017c62ced" } diff --git a/plugins/_shared/hestia_governance_closure.py b/plugins/_shared/hestia_governance_closure.py index c91ecc9a9..c4f079c89 100755 --- a/plugins/_shared/hestia_governance_closure.py +++ b/plugins/_shared/hestia_governance_closure.py @@ -1711,7 +1711,11 @@ def _bash_write_targets(command: str) -> list: # A being's scope ASK: its path is what it asks for, not a # destination. Filing records a petition; it does not write the # requested file (cbp-being, 2026-10-19). - "request_scope"}) + "request_scope", + # A being's composed reads (SAGE being_gate_client): the seat + # builds a grep / git read from {pattern, path}; the law judges + # that command and nothing is written (cbp-being, 2026-10-21, #1361). + "search", "git_read"}) def _write_position_targets(tool_name: str, tool_input: Any) -> tuple: diff --git a/tools/closure_read_tool_names_test.py b/tools/closure_read_tool_names_test.py index 6af9cc56b..cf0bfb420 100755 --- a/tools/closure_read_tool_names_test.py +++ b/tools/closure_read_tool_names_test.py @@ -68,6 +68,17 @@ 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)) + + 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 +108,7 @@ 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_writers_and_unknown_tools_still_write, test_gemini_readers_are_closure_readers, test_witness_read_names_are_closure_readers, From 64f7921fc8544adcbd39fe49392bdb3a34958fb6 Mon Sep 17 00:00:00 2001 From: Dennis Palatov Date: Wed, 21 Oct 2026 15:20:59 -0700 Subject: [PATCH 2/2] closure: a composed-read name does not launder a write (#1362 review hold) At a95d0577, search and git_read sat in _READ_PATH_TOOLS, so _write_position_targets returned no write positions for them by NAME alone. An event under either name carrying command "tee " classified as a read. SAGE sends no command key for them today, but the closure is a cross-harness boundary. - search and git_read move to their own _COMPOSED_READ_TOOLS. Their `path` is the read target only. A `command` they carry is classified for write positions exactly as a Bash command (out-of-grammar and opaque writers fail closed as for Bash). file_path / notebook_path keep the unknown-tool (write) reading. - _read_position_mentions tokenizes their command as it does for Bash, so a reading grep / git log over the gate stays a witnessed read. - New test test_a_composed_read_name_does_not_launder_a_write: tee, sed -i, a > redirect and && cp to the gate, under either name, are WRITE, and so is a file_path key. It fails at a95d0577. The being's real {pattern, path} events and reading commands stay READ. - hooks-gt regenerated (hooks_gt.py publish). Signed: cbp-claude Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH --- hooks-gt/_shared/hestia_governance_closure.py | 22 +++++++++++++------ hooks-gt/_shared/manifest.json | 4 ++-- hooks-gt/claude-code/manifest.json | 6 ++--- hooks-gt/codex/manifest.json | 6 ++--- hooks-gt/gemini/manifest.json | 6 ++--- hooks-gt/kimi/manifest.json | 6 ++--- plugins/_shared/hestia_governance_closure.py | 20 ++++++++++++----- tools/closure_read_tool_names_test.py | 19 ++++++++++++++++ 8 files changed, 62 insertions(+), 27 deletions(-) diff --git a/hooks-gt/_shared/hestia_governance_closure.py b/hooks-gt/_shared/hestia_governance_closure.py index 4eb29aff1..e3eb55990 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: 86d7664624946f36d77935168ac82dcfc8ee5bd5bf025cbe7ed1f4ce31dd5fc1 (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 @@ -1712,11 +1712,12 @@ def _bash_write_targets(command: str) -> list: # A being's scope ASK: its path is what it asks for, not a # 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): the seat - # builds a grep / git read from {pattern, path}; the law judges - # that command and nothing is written (cbp-being, 2026-10-21, #1361). - "search", "git_read"}) + "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: @@ -1726,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(): @@ -1756,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 ee88a346e..3ed63c532 100644 --- a/hooks-gt/_shared/manifest.json +++ b/hooks-gt/_shared/manifest.json @@ -17,7 +17,7 @@ }, { "path": "hestia_governance_closure.py", - "sha256": "86d7664624946f36d77935168ac82dcfc8ee5bd5bf025cbe7ed1f4ce31dd5fc1", + "sha256": "1c5c413502e82fe3e84e19959f32ce378f69a3a08661172df9e9e020407359c7", "source": "plugins/_shared/hestia_governance_closure.py" }, { @@ -38,5 +38,5 @@ ], "engine": null, "requires": [], - "gt_version": "f22f6cb765fc43a0e33224a99dc5e42bd5da33032335504c8c684ca80e7f09d1" + "gt_version": "5f9d5db5087ae3491981564c7c9c369060c12e823ccbf79cb960bb3b9e3cae5e" } diff --git a/hooks-gt/claude-code/manifest.json b/hooks-gt/claude-code/manifest.json index 9e3443f40..de3642c86 100644 --- a/hooks-gt/claude-code/manifest.json +++ b/hooks-gt/claude-code/manifest.json @@ -33,7 +33,7 @@ ], "engine": { "unit": "_shared", - "gt_version": "f22f6cb765fc43a0e33224a99dc5e42bd5da33032335504c8c684ca80e7f09d1", + "gt_version": "5f9d5db5087ae3491981564c7c9c369060c12e823ccbf79cb960bb3b9e3cae5e", "files": [ { "path": "hestia_gate_core.py", @@ -45,7 +45,7 @@ }, { "path": "hestia_governance_closure.py", - "sha256": "86d7664624946f36d77935168ac82dcfc8ee5bd5bf025cbe7ed1f4ce31dd5fc1" + "sha256": "1c5c413502e82fe3e84e19959f32ce378f69a3a08661172df9e9e020407359c7" }, { "path": "hestia_shell_classifier.py", @@ -62,5 +62,5 @@ ] }, "requires": [], - "gt_version": "d83e8d84654ec6ee30ef3d6275ab821f67ba07fd855f7a420a9f5f12ae221c06" + "gt_version": "eaeaa1f4d965565797a6ff30a5d6b3fa5fc6e624d6a487e4e2d6251c5bb62be9" } diff --git a/hooks-gt/codex/manifest.json b/hooks-gt/codex/manifest.json index 817de349f..45fa41765 100644 --- a/hooks-gt/codex/manifest.json +++ b/hooks-gt/codex/manifest.json @@ -33,7 +33,7 @@ ], "engine": { "unit": "_shared", - "gt_version": "f22f6cb765fc43a0e33224a99dc5e42bd5da33032335504c8c684ca80e7f09d1", + "gt_version": "5f9d5db5087ae3491981564c7c9c369060c12e823ccbf79cb960bb3b9e3cae5e", "files": [ { "path": "hestia_gate_core.py", @@ -45,7 +45,7 @@ }, { "path": "hestia_governance_closure.py", - "sha256": "86d7664624946f36d77935168ac82dcfc8ee5bd5bf025cbe7ed1f4ce31dd5fc1" + "sha256": "1c5c413502e82fe3e84e19959f32ce378f69a3a08661172df9e9e020407359c7" }, { "path": "hestia_shell_classifier.py", @@ -62,5 +62,5 @@ ] }, "requires": [], - "gt_version": "cce9aace5e851b05f8e7909076258e38b6e1a7f8d30941a957f37ca334e22d30" + "gt_version": "911ffec6a5b87ac618c4f8d838d5aa477f548cb9422c6aa4ab9bc605a0e610ec" } diff --git a/hooks-gt/gemini/manifest.json b/hooks-gt/gemini/manifest.json index cd0982a0d..fe6938abd 100644 --- a/hooks-gt/gemini/manifest.json +++ b/hooks-gt/gemini/manifest.json @@ -33,7 +33,7 @@ ], "engine": { "unit": "_shared", - "gt_version": "f22f6cb765fc43a0e33224a99dc5e42bd5da33032335504c8c684ca80e7f09d1", + "gt_version": "5f9d5db5087ae3491981564c7c9c369060c12e823ccbf79cb960bb3b9e3cae5e", "files": [ { "path": "hestia_gate_core.py", @@ -45,7 +45,7 @@ }, { "path": "hestia_governance_closure.py", - "sha256": "86d7664624946f36d77935168ac82dcfc8ee5bd5bf025cbe7ed1f4ce31dd5fc1" + "sha256": "1c5c413502e82fe3e84e19959f32ce378f69a3a08661172df9e9e020407359c7" }, { "path": "hestia_shell_classifier.py", @@ -62,5 +62,5 @@ ] }, "requires": [], - "gt_version": "aa88591430eeec75969186687d2b52169cf40b3d9b03ea2b351648027eb80e37" + "gt_version": "22b46fa14a69d02ad66f835d0143d113fa6b31e28afad7acc8409702932283b6" } diff --git a/hooks-gt/kimi/manifest.json b/hooks-gt/kimi/manifest.json index 97f771eaa..9fb7bdefc 100644 --- a/hooks-gt/kimi/manifest.json +++ b/hooks-gt/kimi/manifest.json @@ -33,7 +33,7 @@ ], "engine": { "unit": "_shared", - "gt_version": "f22f6cb765fc43a0e33224a99dc5e42bd5da33032335504c8c684ca80e7f09d1", + "gt_version": "5f9d5db5087ae3491981564c7c9c369060c12e823ccbf79cb960bb3b9e3cae5e", "files": [ { "path": "hestia_gate_core.py", @@ -45,7 +45,7 @@ }, { "path": "hestia_governance_closure.py", - "sha256": "86d7664624946f36d77935168ac82dcfc8ee5bd5bf025cbe7ed1f4ce31dd5fc1" + "sha256": "1c5c413502e82fe3e84e19959f32ce378f69a3a08661172df9e9e020407359c7" }, { "path": "hestia_shell_classifier.py", @@ -62,5 +62,5 @@ ] }, "requires": [], - "gt_version": "96637cb1a5216fce682c471801ac3a17e2c50c9c7e9f6a416f097bf017c62ced" + "gt_version": "caea2b661ea8e389e8e835a80c07697bca9312c2024f90c99f17e2580b35f1eb" } diff --git a/plugins/_shared/hestia_governance_closure.py b/plugins/_shared/hestia_governance_closure.py index c4f079c89..5fc754085 100755 --- a/plugins/_shared/hestia_governance_closure.py +++ b/plugins/_shared/hestia_governance_closure.py @@ -1711,11 +1711,12 @@ def _bash_write_targets(command: str) -> list: # A being's scope ASK: its path is what it asks for, not a # 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): the seat - # builds a grep / git read from {pattern, path}; the law judges - # that command and nothing is written (cbp-being, 2026-10-21, #1361). - "search", "git_read"}) + "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: @@ -1725,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(): @@ -1755,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 cf0bfb420..b18c9b9a3 100755 --- a/tools/closure_read_tool_names_test.py +++ b/tools/closure_read_tool_names_test.py @@ -77,6 +77,24 @@ def test_a_being_composed_read_verb_is_a_read(): ("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(): @@ -109,6 +127,7 @@ def test_witness_read_names_are_closure_readers(): 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,