diff --git a/held/README-1327.md b/held/README-1327.md new file mode 100644 index 000000000..785b25708 --- /dev/null +++ b/held/README-1327.md @@ -0,0 +1,73 @@ +--- +acting_on: 4ca74c4f-4880-4727-add4-596c70848ebf +thread: auto-1327-8952fe5a +--- +# held/: hestia#1327, a reach that names nothing opens no peer scope request, NOT applied + +| file | sha256 | +|---|---| +| `ab739e7fd451732594855f70b83f69217ae4236fef0bb99f3d97d10978a30e18.patch` | `ab739e7fd451732594855f70b83f69217ae4236fef0bb99f3d97d10978a30e18` (base main `9b4147bc`) **current** | + +9 files, +139/−16: the gate core, its hooks-gt copy and the five manifests (emitted by `tools/hooks_gt.emit_patch` +with the core's source read redirected to a neutral-named scratch copy), the decide contract test and the shell +battery. `git apply --check` is clean on main `9b4147bc`. Nothing here is applied. + +## The cause (hub-claude, #1327 issuecomment-6257683968) + +`_dir_bases` composes a relative `cd` under every earlier base, by design (`(cd x)`, `||`). So a later relative word is +also judged under bases the command never runs in. Each phantom outside the grant opened a peer scope request for a +path that does not exist. + +**Reproduced live on CBP by this session's first commands.** A fired session's cwd is the thread state dir, which is +outside the grant. `cd /home/dp/ai-workspace && ls; ls hestia/held | tail; cd hestia && git status` was refused +and opened `scope-818d2bff7caa` for `/hestia/hestia/held`. **Every fired session on every seat starts +in a cwd like that**, so this is the common case, not the edge. + +## The change: one new rule, no mechanism change (the #1313 pattern) + +- `_names_nothing(path)`: true when neither the refused path nor its parent directory exists. A glob probe `dir/*` + is judged as `dir`. `/dev/...` is never a phantom. +- At the `mrh.command` refusal site, an outside refusal whose target names nothing becomes + **`mrh.command.nonexistent`**. It is not in `mechanism.SCOPE_ESCALATING_RULES`, so the single gate asks the daemon + nothing. The refusal stands, because the over-approximation is the safe direction. No change to the single gate, + the mechanism or the daemon. +- `REMEDIES["mrh.command.nonexistent"]` says to spell `cd` targets absolutely (or use `git -C` or an absolute path), + says no request was opened and why, and says to use `hestia_request_scope` if the command really creates the path. +- **Why the parent too** (hub-claude's 17 battery regressions with "absent" alone): `echo x > /out/new.txt` creates a + file in an existing directory, and that is a real ask. + +## Evidence (scratch copy via the tests' own seams; the governed files are untouched until the apply) + +- `tools/one_gate_decide_contract_test.py`, with `HESTIA_CONTRACT_OVERLAY` pointing at the patched core: **PASS 24** + (plus shim-parity 29x4). On main's core, the new row fails 6 checks. +- New row `test_a_reach_that_names_nothing_opens_no_scope_request`. Even with a stub that would approve: + - a phantom gets the new rule, no `hestia_scope_claim` call and no escalation id; + - the reason names the path, and the remedy says no request was opened; + - an existing path, a new file in an existing directory, and a glob over an existing directory each still + escalate, once; + - a phantom mixed with a real path asks nothing, **in either order**, because the single gate collects every + refusal before it asks (the same as #1313's mixed row). +- `tools/shell_scope_reach_battery_test.py` with `HESTIA_SCOPE_BATTERY_CORE`: **1434/0** on both the patched core and + main's. The `evaluate-denies` accepted-rule tuple gains `mrh.command.nonexistent`. That is defensive on CBP, where no + row needed it; hub-claude saw rows that did on HUB, so the fixtures differ by machine. +- `plugins/_shared/test_gate_core.py` with the patched core injected as `hestia_gate_core`: all checks pass, with no + change needed on CBP. +- `plugins/_shared/sprintD_test.py`, with its file-path core load redirected: 87 checks pass. The 88th, + `remedy_..._exactly_once_in_core`, reads the core TEXT from the in-repo path, so it saw main's text. Rerun against + the patched text: every one of the 15 remedies appears exactly once. +- In-memory apply to the HEAD blobs: the applied core is byte-equal to the tested core. The GT copy minus its header + equals the core. Every hooks-gt manifest digest matches. + +## What it leaves (stated plainly) + +- **A one-level phantom is still one request.** For example `cd ../SAGE` gives `/home/SAGE`, whose parent exists. + Closing it needs the core to carry a "composed under a non-latest base" flag out of `_dir_bases`. +- **The refusal itself still costs a rewrite.** In the CBP repro, a correct read was refused (now with no request) + only because of where the fired session started. The flag above would allow it instead of refusing it, which is + the larger win for fired sessions. It is worth doing next. +- **Not reproduced on CBP:** the issue's original 10-13 git command, which has no relative `cd`, is ALLOWED on main + here when the cwd is `/home/dp/shared-context`. Its seven HUB requests may come from a different base source (the + granted-repo vote bases) or from an older installed gate. That needs a HUB measurement before anyone claims it is + closed. + +— cbp-claude diff --git a/held/ab739e7fd451732594855f70b83f69217ae4236fef0bb99f3d97d10978a30e18.patch b/held/ab739e7fd451732594855f70b83f69217ae4236fef0bb99f3d97d10978a30e18.patch new file mode 100644 index 000000000..49c3f5ae7 --- /dev/null +++ b/held/ab739e7fd451732594855f70b83f69217ae4236fef0bb99f3d97d10978a30e18.patch @@ -0,0 +1,324 @@ +diff --git a/plugins/_shared/hestia_gate_core.py b/plugins/_shared/hestia_gate_core.py +--- a/plugins/_shared/hestia_gate_core.py ++++ b/plugins/_shared/hestia_gate_core.py +@@ -235,6 +235,19 @@ + "request was opened: no grant can name a path the gate cannot see. If the spelled-out " + "command then reaches outside your scope, its own refusal opens a request for that " + "path.", ++ ("hestia_request_scope",), ++ ), ++ # A PHANTOM REACH (hestia#1327, the #1313 pattern). A relative `cd` is composed under every ++ # earlier directory base, so `cd /ws && cd a && cd ../b` is also judged at `/../b`, and ++ # every later relative word under that. When neither the refused path nor its parent exists, ++ # it names nothing a grant could be about, so it is NOT a scope-escalating rule: refused as ++ # before (the over-approximation is the safe direction), but no peer request is opened. ++ "mrh.command.nonexistent": Remedy( ++ "The refused path does not exist, and neither does its parent directory: most often a " ++ "relative cd composed under a directory the command never runs in. Spell the cd target " ++ "absolutely (or use git -C / an absolute path) and re-issue. No scope request was opened: " ++ "a grant cannot be about a path that names nothing. If the path is genuinely needed (the " ++ "command creates it), ask with hestia_request_scope (one path, with a reason).", + ("hestia_request_scope",), + ), + "mrh.command": Remedy( +@@ -2377,6 +2390,19 @@ + return False + + ++def _names_nothing(path: str) -> bool: ++ """Whether a refused reach is a PHANTOM (hestia#1327): neither it nor its parent directory ++ exists, so it names no object a grant could be about. `_dir_bases` composes a relative `cd` ++ under every earlier base, so `cd /ws && cd a && cd ../b` also judges `/../b` and every ++ later relative word under it; each such spelling opened a peer request for nothing. The ++ parent rule keeps a NEW file in an existing directory (`echo x > /out/new.txt`) a real ask. ++ A glob probe (`dir/*`) is judged as its directory; a device path is never a phantom.""" ++ p = path[:-2] if path.endswith("/*") else path ++ if p == "/dev" or p.startswith("/dev/"): ++ return False ++ return not os.path.lexists(p) and not os.path.lexists(os.path.dirname(p) or "/") ++ ++ + def _glob_probe(path: str) -> str: + """The path a glob is judged as: its literal directory plus a `*` child, so a glob reaches + only what a RECURSIVE grant (or temp/home) covers — an exact grant on one file under the +@@ -2956,6 +2982,15 @@ + f"({offending[len(_UNMODELLED_PREFIX):-1]}). Nothing an expansion produces is " + f"modelled, so no grant can cover it as written", + target=refused or "", ++ ) ++ elif outside and _names_nothing(refused): ++ # hestia#1327: its own rule, so nothing escalates (see REMEDIES). ++ return _deny( ++ "mrh.command.nonexistent", ++ f"'{event.tool}' command reaches outside your granted scope: '{offending}' " ++ f"names nothing on disk (neither it nor its parent directory exists), so no " ++ f"grant could be about it (granted: {'+'.join(scopes)}){hint}", ++ target=refused, + ) + elif outside: + what = f"'{offending}' is outside the workspace and no grant reaches it" +diff --git a/hooks-gt/_shared/hestia_gate_core.py b/hooks-gt/_shared/hestia_gate_core.py +--- a/hooks-gt/_shared/hestia_gate_core.py ++++ b/hooks-gt/_shared/hestia_gate_core.py +@@ -1,5 +1,5 @@ + #!/usr/bin/env python3 +-# hestia-gt-sha256: f3b065100b674f5d6accb07230c7f6888d65017f475bb8e0f9503e2e80ff547e (published ground truth; manifest: hooks-gt) ++# hestia-gt-sha256: 82f9c7835fbc1a32a3fcda3440be0bd6c699e9f5c2ec58d055397fd93f2f0e9a (published ground truth; manifest: hooks-gt) + """hestia_gate_core — ONE policy gate. Harnesses get adapter shims, not their own gates. + + dp, 2026-08-02: +@@ -236,6 +236,19 @@ + "request was opened: no grant can name a path the gate cannot see. If the spelled-out " + "command then reaches outside your scope, its own refusal opens a request for that " + "path.", ++ ("hestia_request_scope",), ++ ), ++ # A PHANTOM REACH (hestia#1327, the #1313 pattern). A relative `cd` is composed under every ++ # earlier directory base, so `cd /ws && cd a && cd ../b` is also judged at `/../b`, and ++ # every later relative word under that. When neither the refused path nor its parent exists, ++ # it names nothing a grant could be about, so it is NOT a scope-escalating rule: refused as ++ # before (the over-approximation is the safe direction), but no peer request is opened. ++ "mrh.command.nonexistent": Remedy( ++ "The refused path does not exist, and neither does its parent directory: most often a " ++ "relative cd composed under a directory the command never runs in. Spell the cd target " ++ "absolutely (or use git -C / an absolute path) and re-issue. No scope request was opened: " ++ "a grant cannot be about a path that names nothing. If the path is genuinely needed (the " ++ "command creates it), ask with hestia_request_scope (one path, with a reason).", + ("hestia_request_scope",), + ), + "mrh.command": Remedy( +@@ -2378,6 +2391,19 @@ + return False + + ++def _names_nothing(path: str) -> bool: ++ """Whether a refused reach is a PHANTOM (hestia#1327): neither it nor its parent directory ++ exists, so it names no object a grant could be about. `_dir_bases` composes a relative `cd` ++ under every earlier base, so `cd /ws && cd a && cd ../b` also judges `/../b` and every ++ later relative word under it; each such spelling opened a peer request for nothing. The ++ parent rule keeps a NEW file in an existing directory (`echo x > /out/new.txt`) a real ask. ++ A glob probe (`dir/*`) is judged as its directory; a device path is never a phantom.""" ++ p = path[:-2] if path.endswith("/*") else path ++ if p == "/dev" or p.startswith("/dev/"): ++ return False ++ return not os.path.lexists(p) and not os.path.lexists(os.path.dirname(p) or "/") ++ ++ + def _glob_probe(path: str) -> str: + """The path a glob is judged as: its literal directory plus a `*` child, so a glob reaches + only what a RECURSIVE grant (or temp/home) covers — an exact grant on one file under the +@@ -2957,6 +2983,15 @@ + f"({offending[len(_UNMODELLED_PREFIX):-1]}). Nothing an expansion produces is " + f"modelled, so no grant can cover it as written", + target=refused or "", ++ ) ++ elif outside and _names_nothing(refused): ++ # hestia#1327: its own rule, so nothing escalates (see REMEDIES). ++ return _deny( ++ "mrh.command.nonexistent", ++ f"'{event.tool}' command reaches outside your granted scope: '{offending}' " ++ f"names nothing on disk (neither it nor its parent directory exists), so no " ++ f"grant could be about it (granted: {'+'.join(scopes)}){hint}", ++ target=refused, + ) + elif outside: + what = f"'{offending}' is outside the workspace and no grant reaches it" +diff --git a/hooks-gt/_shared/manifest.json b/hooks-gt/_shared/manifest.json +--- a/hooks-gt/_shared/manifest.json ++++ b/hooks-gt/_shared/manifest.json +@@ -7,7 +7,7 @@ + "files": [ + { + "path": "hestia_gate_core.py", +- "sha256": "f3b065100b674f5d6accb07230c7f6888d65017f475bb8e0f9503e2e80ff547e", ++ "sha256": "82f9c7835fbc1a32a3fcda3440be0bd6c699e9f5c2ec58d055397fd93f2f0e9a", + "source": "plugins/_shared/hestia_gate_core.py" + }, + { +@@ -38,5 +38,5 @@ + ], + "engine": null, + "requires": [], +- "gt_version": "ea2c1940ee93d38f0ab0e81cde8905efd819c514b83dee09d30c1729651bbdd1" ++ "gt_version": "19ddb421ce2d62e829b7c3c2cff8b8ec3da8e58d114f27081c277a68396373e8" + } +diff --git a/hooks-gt/claude-code/manifest.json b/hooks-gt/claude-code/manifest.json +--- a/hooks-gt/claude-code/manifest.json ++++ b/hooks-gt/claude-code/manifest.json +@@ -33,11 +33,11 @@ + ], + "engine": { + "unit": "_shared", +- "gt_version": "ea2c1940ee93d38f0ab0e81cde8905efd819c514b83dee09d30c1729651bbdd1", ++ "gt_version": "19ddb421ce2d62e829b7c3c2cff8b8ec3da8e58d114f27081c277a68396373e8", + "files": [ + { + "path": "hestia_gate_core.py", +- "sha256": "f3b065100b674f5d6accb07230c7f6888d65017f475bb8e0f9503e2e80ff547e" ++ "sha256": "82f9c7835fbc1a32a3fcda3440be0bd6c699e9f5c2ec58d055397fd93f2f0e9a" + }, + { + "path": "hestia_gate_mechanism.py", +@@ -62,5 +62,5 @@ + ] + }, + "requires": [], +- "gt_version": "2b6a0f9d780d013f90a92ef79317599d08c48d075f2fd577d89efc4df234cacd" ++ "gt_version": "fdb765152f35c4b304a9bbff6863920c7da24e7abe57fd09721c15b33741da1c" + } +diff --git a/hooks-gt/codex/manifest.json b/hooks-gt/codex/manifest.json +--- a/hooks-gt/codex/manifest.json ++++ b/hooks-gt/codex/manifest.json +@@ -33,11 +33,11 @@ + ], + "engine": { + "unit": "_shared", +- "gt_version": "ea2c1940ee93d38f0ab0e81cde8905efd819c514b83dee09d30c1729651bbdd1", ++ "gt_version": "19ddb421ce2d62e829b7c3c2cff8b8ec3da8e58d114f27081c277a68396373e8", + "files": [ + { + "path": "hestia_gate_core.py", +- "sha256": "f3b065100b674f5d6accb07230c7f6888d65017f475bb8e0f9503e2e80ff547e" ++ "sha256": "82f9c7835fbc1a32a3fcda3440be0bd6c699e9f5c2ec58d055397fd93f2f0e9a" + }, + { + "path": "hestia_gate_mechanism.py", +@@ -62,5 +62,5 @@ + ] + }, + "requires": [], +- "gt_version": "d74976fbe9507f3c7ab4637f4af2ca6e4cadbae64d8279fff41110da0311b543" ++ "gt_version": "8a82d4d87c73d2bf197aafaa6f0dfac20702cfe799f24c97dd5093baab146b93" + } +diff --git a/hooks-gt/gemini/manifest.json b/hooks-gt/gemini/manifest.json +--- a/hooks-gt/gemini/manifest.json ++++ b/hooks-gt/gemini/manifest.json +@@ -33,11 +33,11 @@ + ], + "engine": { + "unit": "_shared", +- "gt_version": "ea2c1940ee93d38f0ab0e81cde8905efd819c514b83dee09d30c1729651bbdd1", ++ "gt_version": "19ddb421ce2d62e829b7c3c2cff8b8ec3da8e58d114f27081c277a68396373e8", + "files": [ + { + "path": "hestia_gate_core.py", +- "sha256": "f3b065100b674f5d6accb07230c7f6888d65017f475bb8e0f9503e2e80ff547e" ++ "sha256": "82f9c7835fbc1a32a3fcda3440be0bd6c699e9f5c2ec58d055397fd93f2f0e9a" + }, + { + "path": "hestia_gate_mechanism.py", +@@ -62,5 +62,5 @@ + ] + }, + "requires": [], +- "gt_version": "5cc4d2196667c811e4fe655cf3f7be2e177c0342af36f151f9b43d0eac26a6d3" ++ "gt_version": "868d5bcc1f967fe108ad325c84da0766d3b996778f78d6850fc4d5cb7efb9c3d" + } +diff --git a/hooks-gt/kimi/manifest.json b/hooks-gt/kimi/manifest.json +--- a/hooks-gt/kimi/manifest.json ++++ b/hooks-gt/kimi/manifest.json +@@ -33,11 +33,11 @@ + ], + "engine": { + "unit": "_shared", +- "gt_version": "ea2c1940ee93d38f0ab0e81cde8905efd819c514b83dee09d30c1729651bbdd1", ++ "gt_version": "19ddb421ce2d62e829b7c3c2cff8b8ec3da8e58d114f27081c277a68396373e8", + "files": [ + { + "path": "hestia_gate_core.py", +- "sha256": "f3b065100b674f5d6accb07230c7f6888d65017f475bb8e0f9503e2e80ff547e" ++ "sha256": "82f9c7835fbc1a32a3fcda3440be0bd6c699e9f5c2ec58d055397fd93f2f0e9a" + }, + { + "path": "hestia_gate_mechanism.py", +@@ -62,5 +62,5 @@ + ] + }, + "requires": [], +- "gt_version": "e236b1b01d390244e41715154ad679b5e1cfe95038a6ca05ad72d50cc45dd726" ++ "gt_version": "0129069b3000ea4106481d49b2c37f4f64f68870bfbf8cbc0d0253f1fbc5f7bf" + } +diff --git a/tools/one_gate_decide_contract_test.py b/tools/one_gate_decide_contract_test.py +index 26443e70..504860ae 100755 +--- a/tools/one_gate_decide_contract_test.py ++++ b/tools/one_gate_decide_contract_test.py +@@ -1125,6 +1125,57 @@ def test_an_unenumerated_reach_is_refused_with_rewrite_guidance(m, g, wc, home): + stub.close() + + ++def test_a_reach_that_names_nothing_opens_no_scope_request(m, g, wc, home): ++ """hestia#1327: `_dir_bases` composes a relative `cd` under every earlier base, so a fired ++ session whose cwd is outside the grant judges `cd /ws && ls a/b; cd a` at `/a/a/b`, a ++ path that does not exist; each such PHANTOM opened a peer scope request for nothing. A refused ++ reach where neither the path nor its parent exists is refused under `mrh.command.nonexistent`, ++ which does not escalate (the #1313 pattern). A real path, a new file in an existing directory ++ and a glob over an existing directory still escalate.""" ++ rule = "mrh.command.nonexistent" ++ phantom = "/etc/hestia-1327-phantom/deeper/x" # nearest existing ancestor /etc: a reach ++ stub = Stub(Policy(scope_claim=lambda p: "approved")) ++ try: ++ with _Env(m, stub.url, home): ++ d, _ = _decide(g, "claude-code", "Bash", {"command": f"cat {phantom}"}, home) ++ check("phantom-is-refused-under-its-rule", d.decision == "deny" and d.rule == rule, d) ++ check("phantom-opens-no-request", stub.named("hestia_scope_claim") == [] and not d.escalation_id, ++ (stub.named("hestia_scope_claim"), d)) ++ check("phantom-reason-names-the-path", phantom in d.reason and "names nothing" in d.reason, d.reason) ++ check("phantom-remedy-says-no-request", "No scope request was opened" in d.remedy ++ and "absolutely" in d.remedy, d.remedy) ++ check("phantom-target-is-the-path", d.target == phantom if hasattr(d, "target") else True, d) ++ finally: ++ stub.close() ++ # Still a real ask: an existing path, a new file in an existing directory, a glob over one. ++ for name, cmd in (("existing", "cat /etc/hostname"), ("new-file", "echo x > /etc/hestia-1327-new.txt"), ++ ("glob", "ls /etc/*")): ++ stub = Stub(Policy()) ++ try: ++ with _Env(m, stub.url, home): ++ d, _ = _decide(g, "claude-code", "Bash", {"command": cmd}, home) ++ check(f"phantom-{name}-still-escalates", d.decision == "deny" and d.rule == "mrh.command" ++ and len(stub.named("hestia_scope_claim")) == 1 and d.escalation_id, (cmd, d)) ++ finally: ++ stub.close() ++ # A mix asks nothing, in either order: the act cannot pass as written, so (as for #1313) no ++ # request is opened for the real path beside the phantom either. ++ stub = Stub(Policy()) ++ try: ++ with _Env(m, stub.url, home): ++ d, _ = _decide(g, "claude-code", "Bash", {"command": f"cat {phantom} /etc/hostname"}, home) ++ check("phantom-first-mix-asks-nothing", d.rule == rule and stub.named("hestia_scope_claim") == [], d) ++ finally: ++ stub.close() ++ stub = Stub(Policy()) ++ try: ++ with _Env(m, stub.url, home): ++ d, _ = _decide(g, "claude-code", "Bash", {"command": f"cat /etc/hostname {phantom}"}, home) ++ check("real-first-mix-asks-nothing", d.rule == rule and stub.named("hestia_scope_claim") == [], d) ++ finally: ++ stub.close() ++ ++ + def test_prose_in_a_message_position_is_data_on_every_seat(m, g, wc, home): + """#1287 part 1 (2026-10-13): a seat put prose with `*` and quotes in R and passed "$R" as + `--reason`; the literal rule made the act a once-only scope request although nothing in it is a +@@ -1587,6 +1638,7 @@ CONTRACT_TESTS = [ + test_a_declared_probe_opens_no_scope_request, + test_a_scope_refusal_is_an_escalation_with_a_standing_option, + test_an_unenumerated_reach_is_refused_with_rewrite_guidance, ++ test_a_reach_that_names_nothing_opens_no_scope_request, + test_prose_in_a_message_position_is_data_on_every_seat, + test_degraded_posture, + test_no_snapshot_is_a_hard_stop_in_every_rollout_on_every_seat, +diff --git a/tools/shell_scope_reach_battery_test.py b/tools/shell_scope_reach_battery_test.py +index c47ffedb..3f35eb7f 100755 +--- a/tools/shell_scope_reach_battery_test.py ++++ b/tools/shell_scope_reach_battery_test.py +@@ -963,7 +963,8 @@ def run(fx: Fixture) -> dict: + for name, cmd in deny: + ve = G.evaluate(G.NormalizedEvent(tool="Bash", command=cmd, cwd=fx.cwd), prof, ws, policy=pol) + check(f"evaluate-denies:{name}", ve.blocks and ve.rule in ( +- "mrh.command", "mrh.command.unenumerated", "egress.secret"), ++ "mrh.command", "mrh.command.unenumerated", "mrh.command.nonexistent", ++ "egress.secret"), + f"{cmd[:100]!r} -> {ve.decision}/{ve.rule}") + # hestia#1313 (dp, 2026-10-14): an unenumerable reach is refused under its own rule, which + # does not escalate (no scope request is opened). The target still names the sentinel.