diff --git a/held/2a6c9ed0b428119b175d0cc1105db5522d410c6fb71288149fde356d582e3be1.patch b/held/2a6c9ed0b428119b175d0cc1105db5522d410c6fb71288149fde356d582e3be1.patch new file mode 100644 index 00000000..6887554a --- /dev/null +++ b/held/2a6c9ed0b428119b175d0cc1105db5522d410c6fb71288149fde356d582e3be1.patch @@ -0,0 +1,693 @@ +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: 2b946d537ebbebd68fa8f38289edfcad101eebd30232a9ec35068b26b0beeed0 (published ground truth; manifest: hooks-gt) ++# hestia-gt-sha256: d2ddf60f60e23965b9b413c3eaf6a4e096bfea9bbc1495c4e60caf7df4ef1b38 (published ground truth; manifest: hooks-gt) + """hestia_gate_core — ONE policy gate. Harnesses get adapter shims, not their own gates. + + dp, 2026-08-02: +@@ -1610,6 +1610,12 @@ + "value_opts": {"-A", "-B", "-C", "-m", "--max-count", "--include", "--exclude", + "--exclude-dir", "--context", "--after-context", "--before-context", + "-d", "--directories", "-D", "--devices", "--label"}} ++#: `git grep` (after the subcommand): grep's grammar. Its `-C` is context, not git's directory. ++_GIT_GREP_SPEC = dict(_GREP_SPEC) ++#: Heads whose PATTERN operands the in-workspace passes of `command_scope_reach` do not read as ++#: reaches (#1241 recut, SAGE #368): the tool matches the text against what it reads and never ++#: opens it. Recorded by value in `_Found.patterns`, LITERAL words only (an expansion stays live). ++_PATTERN_HEADS = frozenset(("grep", "egrep", "fgrep", "rg", "git-grep")) + _CMD_SPECS = { + "tr": {"positional": "strings"}, + "echo": {"positional": "strings_noglob"}, +@@ -1891,6 +1897,7 @@ + self.dirs = [] # literal directories relative paths may resolve under + self.dirs_unknown = None # why a directory change cannot be followed + self.known = {} # names bound to ONE literal so far (`_literal_names`) ++ self.patterns = [] # literal grep-family PATTERN values (`_PATTERN_HEADS`, #1241) + + + #: THE SINGLE-LITERAL-ASSIGNMENT RULE (dp's ruling, 2026-10-11, measured on #1232 first: ~45% of +@@ -2037,6 +2044,21 @@ + out.extend(_outside_candidates(_strip_data_heredocs( + text.replace(_DQ_REF, "").replace(_BRACE, "").replace(_LIT_DOLLAR, "$") + .replace(_LIT_TICK, "`")), pwd, depth + 1, found)) ++ ++ def pattern(word, judge=True): ++ """A grep-family PATTERN operand: data like any other (`data`), and, when it is one LITERAL ++ word, its value is recorded so the workspace passes do not judge text the tool only matches ++ against (#1241 recut). A word holding any expansion is not recorded: what it expands to is ++ not known, and a substitution inside it was already lifted out and is judged.""" ++ if state["head"] in _PATTERN_HEADS and _LIT_DOLLAR not in word and _LIT_TICK not in word: ++ try: ++ literal = not _expansion_spans(word) ++ except _Unmodelled: ++ literal = False ++ if literal and "$" not in word and "`" not in word and word: ++ found.patterns.append(word) ++ if judge: ++ data(word) + + def in_force(): + cut = body_origin +@@ -2272,7 +2294,9 @@ + or opt in _string_opts(head, spec, state["subs"])) and not uglob: + if opt in spec.get("pattern_opts", ()): + state["pattern_given"] = True +- data(w) ++ pattern(w) ++ else: ++ data(w) + continue + if opt == "-c" and _HEREDOC_SHELL.match(head): + code(w) +@@ -2314,6 +2338,8 @@ + or name in spec.get("pattern_opts", ()) + or name in strs): + emit(val, uglob) ++ elif name in spec.get("pattern_opts", ()): ++ pattern(val) + else: + data(val) + continue +@@ -2343,12 +2369,18 @@ + or w[:2] in spec.get("pattern_opts", ()) + or w[:2] in spec.get("string_opts", ())): + emit(w[2:], uglob) ++ elif w[:2] in spec.get("pattern_opts", ()): ++ pattern(w[2:], judge=False) # as before, plus its value recorded + continue + m = _ATTACHED_OPT.match(w) + if m: + emit(m.group(1), uglob) # `-I/usr/include`, `-C/elsewhere` + continue + state["positional"] += 1 ++ if head == "git" and state["positional"] == 1 and w == "grep": ++ # `git [opts] grep …`: from here the arguments are grep's (#1241 recut). ++ state.update(head="git-grep", spec=_GIT_GREP_SPEC, positional=0, opts_done=False) ++ continue + if len(state["subs"]) < 3: + state["subs"].append(w) + if state["positional"] == 1 and _HEREDOC_INTERPRETER.match(head) and not state["code_opt"]: +@@ -2390,7 +2422,7 @@ + continue + if spec.get("pattern_first") and state["positional"] == 1 and not state["pattern_given"] \ + and not uglob: +- data(w) ++ pattern(w) + continue + name, eq, val = w.partition("=") + if eq and name.isidentifier(): # `dd if=/x`, `make DESTDIR=/x` +@@ -2581,6 +2613,59 @@ + return False + + ++#: Operator-declared TOOLCHAIN roots (colon- or comma-separated absolute paths), e.g. a conda ++#: install under the home: `HESTIA_TOOL_ROOTS=/miniforge3`. A command's reach under one is ++#: not MRH-scoped, the way TEMP_ROOTS are not: an interpreter and its libraries are the tool, not ++#: the territory. Why (SAGE #368, 2026-10-05): with a being judged under its seat's workspace (the ++#: home), every sandboxed `check` (`bwrap --ro-bind /miniforge3 ...`, PATH=...) and every ++#: `game` (`/miniforge3/bin/python3 ...`) was denied "'miniforge3' is not granted": a grant ++#: for an interpreter would be a grant of nothing the member owns. DECLARED, never inferred, and ++#: egress still applies first. A root that is `/`, relative, the workspace, or an ancestor of it is ++#: ignored, so a misconfiguration cannot ungovern the territory it was meant to sit beside. ++TOOL_ROOTS_ENV = "HESTIA_TOOL_ROOTS" ++ ++ ++def _tool_roots(workspace: str) -> tuple: ++ raw = os.environ.get(TOOL_ROOTS_ENV, "") ++ ws = os.path.normpath(os.path.expanduser(workspace or "/")).replace("\\", "/").rstrip("/") or "/" ++ out = [] ++ for part in re.split(r"[:,]", raw): ++ part = part.strip() ++ if not part: ++ continue ++ r = os.path.normpath(os.path.expanduser(part)).replace("\\", "/").rstrip("/") ++ if not r.startswith("/") or r in ("", "/"): ++ continue ++ if ws == r or ws.startswith(r + "/"): ++ continue # the workspace or an ancestor: never a tool root ++ out.append(r) ++ return tuple(out) ++ ++ ++def _under_any(path: str, roots) -> bool: ++ p = os.path.normpath(path.replace("\\", "/")).replace("\\", "/") ++ return any(p == r or p.startswith(r + "/") for r in roots) ++ ++ ++def _grammar_patterns(cmd: str, cwd: Optional[str]) -> tuple: ++ """(patterns, judged) from the shell grammar: the literal grep-family PATTERN values it ++ recognised (`_PATTERN_HEADS`), and every word it judges as a path. ((), ()) when it cannot ++ read the line, so nothing is skipped (pass 3 then refuses the line as unenumerated).""" ++ found = _Found() ++ try: ++ cands = _outside_candidates(_strip_data_heredocs(cmd), cwd or "", found=found) ++ except _Unmodelled: ++ return (), () ++ return tuple(found.patterns), tuple(c[0] for c in cands) ++ ++ ++def _only_pattern_text(text: str, patterns, judged) -> bool: ++ """A mention the in-workspace passes do not judge (#1241 recut): it lies inside a recognised ++ grep-family PATTERN and inside no word the grammar judges as a path. `grep '/x' /x` ++ still judges `/x`, because the operand names it too.""" ++ return bool(text) and any(text in p_ for p_ in patterns) and not any(text in j for j in judged) ++ ++ + def command_scope_reach(cmd: str, scopes, workspace: str, cwd: Optional[str] = None, + forbidden=None, home_markers=None): + """Returns (ok, offending_token, resolved_path). See `_command_scope_reach_one`; a command +@@ -2648,12 +2733,22 @@ + # Pass 0 — the shell's home spellings, expanded before anything reads the text, so a + # workspace path spelled `~/…` or `$HOME/…` meets pass 1 like its literal spelling does. + cmd = _expand_home_words(cmd) ++ # TWO THINGS INSIDE THE WORKSPACE ARE NOT A REACH (#1241 recut, SAGE #368): a grep-family ++ # PATTERN, which the tool matches against and never opens (a being's own `-e '[^ ]*ft09'` ++ # was refused "'[^' is not granted"), and a path under a declared toolchain root. Patterns are ++ # recognised by the shell GRAMMAR (`_CMD_SPECS`), never by rewriting the text, so a live ++ # substitution inside one is still lifted out and judged, and pass 3 is not touched. ++ patterns, judged_words = _grammar_patterns(cmd, cwd) ++ tool_roots = _tool_roots(workspace) + for after in cmd.split(workspace)[1:]: + # Resolve the whole token before reading a segment off it (kimi #940 B7). Taking the + # head lexically let `cat /repo-a/../repo-b/secret` pass on `repo-a` while + # resolving into an ungranted repo — path_in_scope's B5 one function over. A command's + # text is member-controlled end to end, so this one needs no harness to cooperate. + tok = re.split(r"""[\s"'`);&|<>]""", after.lstrip("/"), 1)[0] ++ if patterns and _only_pattern_text(workspace + after[:len(after) - len(after.lstrip("/"))] + tok, ++ patterns, judged_words): ++ continue + resolved = os.path.normpath(f"{ws}/{tok}").replace("\\", "/") + if resolved != ws and not resolved.startswith(ws + "/"): + # Traversed out of the workspace: judged WHERE IT LANDS, by the outside rule (pass 3), +@@ -2669,6 +2764,10 @@ + hidden = _symlink_target_forbidden(resolved, egress) + if hidden is not None: + return False, hidden, hidden ++ # A DECLARED TOOLCHAIN is not governed territory (HESTIA_TOOL_ROOTS): judged after egress, ++ # so a forbidden symlink target under a tool root still denies. ++ if tool_roots and _under_any(resolved, tool_roots): ++ continue + seg = resolved[len(ws):].lstrip("/").split("/", 1)[0] + if seg not in repo_scopes and not _within_path_grant(resolved, scopes, workspace): + refused = _symlinked_reach_verdict(resolved, seg, repo_scopes, scopes, workspace) +@@ -2698,6 +2797,8 @@ + continue + if "/" not in tok and tok in MEMBER_ADDRESSES: + continue ++ if patterns and _only_pattern_text(tok, patterns, judged_words): ++ continue # text inside a grep-family pattern (#1241 recut) + if tok in seen2: + continue # a repeated spelling is judged once + seen2.add(tok) +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": "2b946d537ebbebd68fa8f38289edfcad101eebd30232a9ec35068b26b0beeed0", ++ "sha256": "d2ddf60f60e23965b9b413c3eaf6a4e096bfea9bbc1495c4e60caf7df4ef1b38", + "source": "plugins/_shared/hestia_gate_core.py" + }, + { +@@ -38,5 +38,5 @@ + ], + "engine": null, + "requires": [], +- "gt_version": "f97e63f0a21def86534f0aa486a6f32a6e16159bd3be8c3cd81202d61541093b" ++ "gt_version": "ff4807bf6d0a9db46cab86ed8dfe79bf984f18c5476b5d8f6f54024a603b7946" + } +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": "f97e63f0a21def86534f0aa486a6f32a6e16159bd3be8c3cd81202d61541093b", ++ "gt_version": "ff4807bf6d0a9db46cab86ed8dfe79bf984f18c5476b5d8f6f54024a603b7946", + "files": [ + { + "path": "hestia_gate_core.py", +- "sha256": "2b946d537ebbebd68fa8f38289edfcad101eebd30232a9ec35068b26b0beeed0" ++ "sha256": "d2ddf60f60e23965b9b413c3eaf6a4e096bfea9bbc1495c4e60caf7df4ef1b38" + }, + { + "path": "hestia_gate_mechanism.py", +@@ -62,5 +62,5 @@ + ] + }, + "requires": [], +- "gt_version": "b1662c83d9c4cc12fd7d23ee192586802880388316821076bb0bbab1bf8e4e28" ++ "gt_version": "a1658a6603face6a9a603fd78fa87b9c956ce3a1470f9a2ae766db44c674ce50" + } +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": "f97e63f0a21def86534f0aa486a6f32a6e16159bd3be8c3cd81202d61541093b", ++ "gt_version": "ff4807bf6d0a9db46cab86ed8dfe79bf984f18c5476b5d8f6f54024a603b7946", + "files": [ + { + "path": "hestia_gate_core.py", +- "sha256": "2b946d537ebbebd68fa8f38289edfcad101eebd30232a9ec35068b26b0beeed0" ++ "sha256": "d2ddf60f60e23965b9b413c3eaf6a4e096bfea9bbc1495c4e60caf7df4ef1b38" + }, + { + "path": "hestia_gate_mechanism.py", +@@ -62,5 +62,5 @@ + ] + }, + "requires": [], +- "gt_version": "726f554213431a316ebc331fa7bdd545f363aeb332a350fbc8c2f022031f7615" ++ "gt_version": "f5ed2543e11f3f1bb8b13b73e74e623ccf509d238160690856841aab07fd24d2" + } +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": "f97e63f0a21def86534f0aa486a6f32a6e16159bd3be8c3cd81202d61541093b", ++ "gt_version": "ff4807bf6d0a9db46cab86ed8dfe79bf984f18c5476b5d8f6f54024a603b7946", + "files": [ + { + "path": "hestia_gate_core.py", +- "sha256": "2b946d537ebbebd68fa8f38289edfcad101eebd30232a9ec35068b26b0beeed0" ++ "sha256": "d2ddf60f60e23965b9b413c3eaf6a4e096bfea9bbc1495c4e60caf7df4ef1b38" + }, + { + "path": "hestia_gate_mechanism.py", +@@ -62,5 +62,5 @@ + ] + }, + "requires": [], +- "gt_version": "75f580643a40dbc06dc9849c95dd2d07aee9a6756b44ecb393a967b32ddfe9b5" ++ "gt_version": "3d3f55d706a89e55fdc817685cf192ee3359ca2956dbbbc06d7d44c162efcae4" + } +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": "f97e63f0a21def86534f0aa486a6f32a6e16159bd3be8c3cd81202d61541093b", ++ "gt_version": "ff4807bf6d0a9db46cab86ed8dfe79bf984f18c5476b5d8f6f54024a603b7946", + "files": [ + { + "path": "hestia_gate_core.py", +- "sha256": "2b946d537ebbebd68fa8f38289edfcad101eebd30232a9ec35068b26b0beeed0" ++ "sha256": "d2ddf60f60e23965b9b413c3eaf6a4e096bfea9bbc1495c4e60caf7df4ef1b38" + }, + { + "path": "hestia_gate_mechanism.py", +@@ -62,5 +62,5 @@ + ] + }, + "requires": [], +- "gt_version": "4cc3378842c36bc9d8a17c319ceab5713c8a2282255bbf302285a384b60bf1f0" ++ "gt_version": "983b6509ba4337bcea1240481332f4a65035697ba287f991a325648affdd9850" + } +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 +@@ -1609,6 +1609,12 @@ + "value_opts": {"-A", "-B", "-C", "-m", "--max-count", "--include", "--exclude", + "--exclude-dir", "--context", "--after-context", "--before-context", + "-d", "--directories", "-D", "--devices", "--label"}} ++#: `git grep` (after the subcommand): grep's grammar. Its `-C` is context, not git's directory. ++_GIT_GREP_SPEC = dict(_GREP_SPEC) ++#: Heads whose PATTERN operands the in-workspace passes of `command_scope_reach` do not read as ++#: reaches (#1241 recut, SAGE #368): the tool matches the text against what it reads and never ++#: opens it. Recorded by value in `_Found.patterns`, LITERAL words only (an expansion stays live). ++_PATTERN_HEADS = frozenset(("grep", "egrep", "fgrep", "rg", "git-grep")) + _CMD_SPECS = { + "tr": {"positional": "strings"}, + "echo": {"positional": "strings_noglob"}, +@@ -1890,6 +1896,7 @@ + self.dirs = [] # literal directories relative paths may resolve under + self.dirs_unknown = None # why a directory change cannot be followed + self.known = {} # names bound to ONE literal so far (`_literal_names`) ++ self.patterns = [] # literal grep-family PATTERN values (`_PATTERN_HEADS`, #1241) + + + #: THE SINGLE-LITERAL-ASSIGNMENT RULE (dp's ruling, 2026-10-11, measured on #1232 first: ~45% of +@@ -2036,6 +2043,21 @@ + out.extend(_outside_candidates(_strip_data_heredocs( + text.replace(_DQ_REF, "").replace(_BRACE, "").replace(_LIT_DOLLAR, "$") + .replace(_LIT_TICK, "`")), pwd, depth + 1, found)) ++ ++ def pattern(word, judge=True): ++ """A grep-family PATTERN operand: data like any other (`data`), and, when it is one LITERAL ++ word, its value is recorded so the workspace passes do not judge text the tool only matches ++ against (#1241 recut). A word holding any expansion is not recorded: what it expands to is ++ not known, and a substitution inside it was already lifted out and is judged.""" ++ if state["head"] in _PATTERN_HEADS and _LIT_DOLLAR not in word and _LIT_TICK not in word: ++ try: ++ literal = not _expansion_spans(word) ++ except _Unmodelled: ++ literal = False ++ if literal and "$" not in word and "`" not in word and word: ++ found.patterns.append(word) ++ if judge: ++ data(word) + + def in_force(): + cut = body_origin +@@ -2271,7 +2293,9 @@ + or opt in _string_opts(head, spec, state["subs"])) and not uglob: + if opt in spec.get("pattern_opts", ()): + state["pattern_given"] = True +- data(w) ++ pattern(w) ++ else: ++ data(w) + continue + if opt == "-c" and _HEREDOC_SHELL.match(head): + code(w) +@@ -2313,6 +2337,8 @@ + or name in spec.get("pattern_opts", ()) + or name in strs): + emit(val, uglob) ++ elif name in spec.get("pattern_opts", ()): ++ pattern(val) + else: + data(val) + continue +@@ -2342,12 +2368,18 @@ + or w[:2] in spec.get("pattern_opts", ()) + or w[:2] in spec.get("string_opts", ())): + emit(w[2:], uglob) ++ elif w[:2] in spec.get("pattern_opts", ()): ++ pattern(w[2:], judge=False) # as before, plus its value recorded + continue + m = _ATTACHED_OPT.match(w) + if m: + emit(m.group(1), uglob) # `-I/usr/include`, `-C/elsewhere` + continue + state["positional"] += 1 ++ if head == "git" and state["positional"] == 1 and w == "grep": ++ # `git [opts] grep …`: from here the arguments are grep's (#1241 recut). ++ state.update(head="git-grep", spec=_GIT_GREP_SPEC, positional=0, opts_done=False) ++ continue + if len(state["subs"]) < 3: + state["subs"].append(w) + if state["positional"] == 1 and _HEREDOC_INTERPRETER.match(head) and not state["code_opt"]: +@@ -2389,7 +2421,7 @@ + continue + if spec.get("pattern_first") and state["positional"] == 1 and not state["pattern_given"] \ + and not uglob: +- data(w) ++ pattern(w) + continue + name, eq, val = w.partition("=") + if eq and name.isidentifier(): # `dd if=/x`, `make DESTDIR=/x` +@@ -2580,6 +2612,59 @@ + return False + + ++#: Operator-declared TOOLCHAIN roots (colon- or comma-separated absolute paths), e.g. a conda ++#: install under the home: `HESTIA_TOOL_ROOTS=/miniforge3`. A command's reach under one is ++#: not MRH-scoped, the way TEMP_ROOTS are not: an interpreter and its libraries are the tool, not ++#: the territory. Why (SAGE #368, 2026-10-05): with a being judged under its seat's workspace (the ++#: home), every sandboxed `check` (`bwrap --ro-bind /miniforge3 ...`, PATH=...) and every ++#: `game` (`/miniforge3/bin/python3 ...`) was denied "'miniforge3' is not granted": a grant ++#: for an interpreter would be a grant of nothing the member owns. DECLARED, never inferred, and ++#: egress still applies first. A root that is `/`, relative, the workspace, or an ancestor of it is ++#: ignored, so a misconfiguration cannot ungovern the territory it was meant to sit beside. ++TOOL_ROOTS_ENV = "HESTIA_TOOL_ROOTS" ++ ++ ++def _tool_roots(workspace: str) -> tuple: ++ raw = os.environ.get(TOOL_ROOTS_ENV, "") ++ ws = os.path.normpath(os.path.expanduser(workspace or "/")).replace("\\", "/").rstrip("/") or "/" ++ out = [] ++ for part in re.split(r"[:,]", raw): ++ part = part.strip() ++ if not part: ++ continue ++ r = os.path.normpath(os.path.expanduser(part)).replace("\\", "/").rstrip("/") ++ if not r.startswith("/") or r in ("", "/"): ++ continue ++ if ws == r or ws.startswith(r + "/"): ++ continue # the workspace or an ancestor: never a tool root ++ out.append(r) ++ return tuple(out) ++ ++ ++def _under_any(path: str, roots) -> bool: ++ p = os.path.normpath(path.replace("\\", "/")).replace("\\", "/") ++ return any(p == r or p.startswith(r + "/") for r in roots) ++ ++ ++def _grammar_patterns(cmd: str, cwd: Optional[str]) -> tuple: ++ """(patterns, judged) from the shell grammar: the literal grep-family PATTERN values it ++ recognised (`_PATTERN_HEADS`), and every word it judges as a path. ((), ()) when it cannot ++ read the line, so nothing is skipped (pass 3 then refuses the line as unenumerated).""" ++ found = _Found() ++ try: ++ cands = _outside_candidates(_strip_data_heredocs(cmd), cwd or "", found=found) ++ except _Unmodelled: ++ return (), () ++ return tuple(found.patterns), tuple(c[0] for c in cands) ++ ++ ++def _only_pattern_text(text: str, patterns, judged) -> bool: ++ """A mention the in-workspace passes do not judge (#1241 recut): it lies inside a recognised ++ grep-family PATTERN and inside no word the grammar judges as a path. `grep '/x' /x` ++ still judges `/x`, because the operand names it too.""" ++ return bool(text) and any(text in p_ for p_ in patterns) and not any(text in j for j in judged) ++ ++ + def command_scope_reach(cmd: str, scopes, workspace: str, cwd: Optional[str] = None, + forbidden=None, home_markers=None): + """Returns (ok, offending_token, resolved_path). See `_command_scope_reach_one`; a command +@@ -2647,12 +2732,22 @@ + # Pass 0 — the shell's home spellings, expanded before anything reads the text, so a + # workspace path spelled `~/…` or `$HOME/…` meets pass 1 like its literal spelling does. + cmd = _expand_home_words(cmd) ++ # TWO THINGS INSIDE THE WORKSPACE ARE NOT A REACH (#1241 recut, SAGE #368): a grep-family ++ # PATTERN, which the tool matches against and never opens (a being's own `-e '[^ ]*ft09'` ++ # was refused "'[^' is not granted"), and a path under a declared toolchain root. Patterns are ++ # recognised by the shell GRAMMAR (`_CMD_SPECS`), never by rewriting the text, so a live ++ # substitution inside one is still lifted out and judged, and pass 3 is not touched. ++ patterns, judged_words = _grammar_patterns(cmd, cwd) ++ tool_roots = _tool_roots(workspace) + for after in cmd.split(workspace)[1:]: + # Resolve the whole token before reading a segment off it (kimi #940 B7). Taking the + # head lexically let `cat /repo-a/../repo-b/secret` pass on `repo-a` while + # resolving into an ungranted repo — path_in_scope's B5 one function over. A command's + # text is member-controlled end to end, so this one needs no harness to cooperate. + tok = re.split(r"""[\s"'`);&|<>]""", after.lstrip("/"), 1)[0] ++ if patterns and _only_pattern_text(workspace + after[:len(after) - len(after.lstrip("/"))] + tok, ++ patterns, judged_words): ++ continue + resolved = os.path.normpath(f"{ws}/{tok}").replace("\\", "/") + if resolved != ws and not resolved.startswith(ws + "/"): + # Traversed out of the workspace: judged WHERE IT LANDS, by the outside rule (pass 3), +@@ -2668,6 +2763,10 @@ + hidden = _symlink_target_forbidden(resolved, egress) + if hidden is not None: + return False, hidden, hidden ++ # A DECLARED TOOLCHAIN is not governed territory (HESTIA_TOOL_ROOTS): judged after egress, ++ # so a forbidden symlink target under a tool root still denies. ++ if tool_roots and _under_any(resolved, tool_roots): ++ continue + seg = resolved[len(ws):].lstrip("/").split("/", 1)[0] + if seg not in repo_scopes and not _within_path_grant(resolved, scopes, workspace): + refused = _symlinked_reach_verdict(resolved, seg, repo_scopes, scopes, workspace) +@@ -2697,6 +2796,8 @@ + continue + if "/" not in tok and tok in MEMBER_ADDRESSES: + continue ++ if patterns and _only_pattern_text(tok, patterns, judged_words): ++ continue # text inside a grep-family pattern (#1241 recut) + if tok in seen2: + continue # a repeated spelling is judged once + seen2.add(tok) +diff --git a/tools/gate_tool_roots_and_patterns_test.py b/tools/gate_tool_roots_and_patterns_test.py +new file mode 100755 +--- /dev/null ++++ b/tools/gate_tool_roots_and_patterns_test.py +@@ -0,0 +1,160 @@ ++#!/usr/bin/env python3 ++"""Command scope: declared toolchain roots, and grep-family PATTERN operands (SAGE #368, #1241 recut). ++ ++Measured 2026-10-05 on 2,187 legion-being calls replayed through the one gate with the being under ++its SEAT's workspace (dp's ruling: a being judged by anything else is judged by a different law ++than its seat): SAGE #368, which hands the gate the composed command, denied 37/37 `check` and ++84/84 `game` ("'miniforge3' is not granted": the sandbox's `--ro-bind /miniforge3` and the ++interpreter itself) and one `search` ("'[^' is not granted": a fragment of the being's own regex). ++ ++The recut (cbp-claude's review of #1241, 2026-10-14): patterns are recognised INSIDE the shell ++grammar (`_CMD_SPECS`, `_Found.patterns`), never by rewriting the command text. #1241's text mask ++re-quoted `"$(...)"` as an inert `'$(...)'` and admitted acts main refuses; those rows are the ++must-deny regressions below. ++ ++Run: ./tools/gate_tool_roots_and_patterns_test.py ++ HESTIA_SCOPE_BATTERY_CORE= ./tools/gate_tool_roots_and_patterns_test.py (a staged core) ++""" ++from __future__ import annotations ++ ++import importlib.util ++import os ++import sys ++import tempfile ++from pathlib import Path ++ ++ROOT = Path(__file__).resolve().parents[1] ++sys.path.insert(0, str(ROOT / "plugins" / "_shared")) ++_staged = os.getenv("HESTIA_SCOPE_BATTERY_CORE") ++if _staged: ++ _spec = importlib.util.spec_from_file_location("hestia_gate_core", _staged) ++ G = importlib.util.module_from_spec(_spec) ++ sys.modules["hestia_gate_core"] = G ++ _spec.loader.exec_module(G) ++else: ++ import hestia_gate_core as G # noqa: E402 ++ ++FAIL = [] ++ ++ ++def check(name, cond, detail=""): ++ print((" ok " if cond else " FAIL ") + name + ("" if cond else f" {detail}")) ++ if not cond: ++ FAIL.append(name) ++ ++ ++def main() -> int: ++ ws = tempfile.mkdtemp(prefix="ws-") ++ for d in ("granted/sub", "tools/conda/bin", "other"): ++ os.makedirs(f"{ws}/{d}") ++ open(f"{ws}/other/x", "w").close() ++ open(f"{ws}/granted/f", "w").close() ++ scopes = ("granted",) ++ ++ def reach(c): ++ return G.command_scope_reach(c, scopes, ws, cwd=f"{ws}/granted") ++ ++ def denied(c, off=None): ++ ok, o, _ = reach(c) ++ return (not ok) and (off is None or o == off or (o or "").startswith(off)), o ++ ++ # ---- declared toolchain roots ---- ++ os.environ.pop(G.TOOL_ROOTS_ENV, None) ++ ok, off, _ = reach(f"bwrap --ro-bind {ws}/tools/conda {ws}/tools/conda {ws}/tools/conda/bin/python3 {ws}/granted/t.py") ++ check("without a declared tool root, the interpreter is an ungranted reach (unchanged)", not ok and off == "tools", off) ++ ++ os.environ[G.TOOL_ROOTS_ENV] = f"{ws}/tools/conda" ++ ok, off, _ = reach(f"bwrap --ro-bind {ws}/tools/conda {ws}/tools/conda --setenv PATH {ws}/tools/conda/bin:/usr/bin " ++ f"{ws}/tools/conda/bin/python3 -m pytest {ws}/granted/sub") ++ check("a declared tool root is not governed territory (check-shaped line)", ok, off) ++ ok, off, _ = reach(f"{ws}/tools/conda/bin/python3 {ws}/other/stepper.py --instance {ws}/granted") ++ check("operands outside the tool root are still judged (game-shaped line)", not ok and off == "other", off) ++ ok, off, _ = reach(f"cat {ws}/tools/condaX/secret") ++ check("a sibling of a tool root is not under it (boundary, not prefix)", not ok, off) ++ for bad in ("/", ws, os.path.dirname(ws)): ++ os.environ[G.TOOL_ROOTS_ENV] = bad ++ ok, off, _ = reach(f"cat {ws}/other/x") ++ check(f"a tool root that is /, the workspace or its ancestor is ignored ({bad})", not ok and off == "other", off) ++ os.environ[G.TOOL_ROOTS_ENV] = "relative/path" ++ ok, _, _ = reach(f"cat {ws}/other/x") ++ check("a relative tool root is ignored", not ok) ++ ++ os.environ[G.TOOL_ROOTS_ENV] = f"{ws}/tools/conda" ++ secret = tempfile.mkdtemp(prefix="s-") ++ os.makedirs(f"{secret}/.ssh") ++ open(f"{secret}/.ssh/id_x", "w").close() ++ os.symlink(f"{secret}/.ssh/id_x", f"{ws}/tools/conda/link") ++ ok, off, _ = reach(f"cat {ws}/tools/conda/link") ++ check("egress is judged before the tool root: a link to a forbidden target denies", not ok and ".ssh" in (off or ""), off) ++ os.environ.pop(G.TOOL_ROOTS_ENV, None) ++ ++ # ---- grep-family PATTERN operands are text, recognised by the grammar ---- ++ ok, off, _ = reach(f"git --no-pager -C {ws}/granted grep -n -I -E --max-count=15 -e 'ft09\\.py.*bytes|{ws}[^ ]*ft09\\.py' -- {ws}/granted/sub") ++ check("a git grep -e pattern is text, not a path (search-shaped line)", ok, off) ++ ok, off, _ = reach(f"git --no-pager -C {ws}/granted grep -n -e 'x' -- {ws}/other") ++ check("git grep's pathspec after -- is still judged", not ok and off == "other", off) ++ ok, off, _ = reach(f"git -C {ws}/granted grep -C 3 '{ws}/other/x' -- {ws}/granted/sub") ++ check("git grep's -C is context lines, and its positional pattern is text", ok, off) ++ d, off = denied(f"git -C {ws}/other grep x") ++ check("git's own -C before the subcommand is still a judged directory", d, off) ++ ok, off, _ = reach(f"grep -rn '{ws}/other/never-opened' {ws}/granted/sub") ++ check("grep's positional pattern is text", ok, off) ++ ok, off, _ = reach(f"grep -rn needle {ws}/other") ++ check("grep's file operand is still judged", not ok and off == "other", off) ++ ok, off, _ = reach(f"grep -f {ws}/other/patterns {ws}/granted/sub") ++ check("grep -f's operand IS a file and is judged", not ok and off == "other", off) ++ ok, off, _ = reach(f"rg --regexp={ws}/other/x {ws}/granted") ++ check("rg --regexp=... is text", ok, off) ++ ok, off, _ = reach(f"grep -e{ws}/other/x {ws}/granted") ++ check("an attached -e pattern is text", ok, off) ++ ok, off, _ = reach(f"cat {ws}/other/x | grep -e '{ws}/other' ") ++ check("per simple command: cat's operand is still judged", not ok and off == "other", off) ++ d, off = denied(f"grep -e 'unbalanced {ws}/other/x") ++ check("an unreadable line is judged as before, never less", d, off) ++ ++ # ---- the same text as a pattern AND an operand: the operand is judged ---- ++ d, off = denied(f"grep '{ws}/other' {ws}/other") ++ check("a path that is both the pattern and the operand is judged", d and off == "other", off) ++ d, off = denied(f"grep -e '{ws}/other/x' -- {ws}/other/x") ++ check("a pattern does not cover its own text named again as a file", d and off == "other", off) ++ ++ # ---- anything an expansion decides stays live (cbp-claude's must-deny rows) ---- ++ for c, why in ((f'grep -n x "$(cat /etc/shadow)"', "a substitution in a grep file operand"), ++ (f'grep x f; echo "$(cat /root/secret)"', "a substitution after a grep"), ++ ('grep -e foo "`cat /root/x`"', "backticks in a grep operand"), ++ ("grep x < "F"`): only grep-family heads are exempt. ++ for c in (f"sed 'w {ws}/other/x' {ws}/granted/f", f"sed -n '1r {ws}/other/x' {ws}/granted/f", ++ f"awk '{{print > \"{ws}/other/x\"}}' {ws}/granted/f"): ++ d, off = denied(c) ++ check(f"must deny: a {c.split()[0]} program naming a file is judged", d and off == "other", off) ++ # An unquoted expansion in the pattern position can split into an option (`X=-f` makes ++ # `$X/...` a FILE operand). It is not recorded as a pattern, and pass 3 refuses it as ++ # unenumerated too: two layers, so this row stays red if either holds. ++ d, off = denied(f"grep $X{ws}/other/x {ws}/granted/f") ++ check("must deny: an unquoted expansion in the pattern position", d, off) ++ ++ print("\nall checks pass" if not FAIL else f"\n{len(FAIL)} FAILED: {FAIL}") ++ return 1 if FAIL else 0 ++ ++ ++if __name__ == "__main__": ++ sys.exit(main()) +diff --git a/tools/mrh_scope_differential.py b/tools/mrh_scope_differential.py +--- a/tools/mrh_scope_differential.py ++++ b/tools/mrh_scope_differential.py +@@ -135,7 +135,7 @@ + ('744.grep-pattern-compound', 'right', 'repo'): ('allow', None), + ('744.grep-pattern-compound', 'right', 'root'): ('allow', None), + ('744.grep-pattern-compound', 'wrong', 'repo'): ('allow', None), +- ('744.grep-pattern-compound', 'wrong', 'root'): ('deny', 'python'), ++ ('744.grep-pattern-compound', 'wrong', 'root'): ('allow', None), # #1241 recut: a grep pattern is text + ('ctl.granted-absolute-read', 'right', 'repo'): ('allow', None), + ('ctl.granted-absolute-read', 'right', 'root'): ('allow', None), + ('ctl.granted-absolute-read', 'wrong', 'repo'): ('allow', None), diff --git a/held/80600bf347df9eb0a637f7c63da37be3ee1af1e56b757a820845f9b5b075d554.patch b/held/80600bf347df9eb0a637f7c63da37be3ee1af1e56b757a820845f9b5b075d554.patch new file mode 100644 index 00000000..c0432df7 --- /dev/null +++ b/held/80600bf347df9eb0a637f7c63da37be3ee1af1e56b757a820845f9b5b075d554.patch @@ -0,0 +1,633 @@ +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: d2ddf60f60e23965b9b413c3eaf6a4e096bfea9bbc1495c4e60caf7df4ef1b38 (published ground truth; manifest: hooks-gt) ++# hestia-gt-sha256: 9430ffc67a2193a3712fe161218d2276fec0875a7d3f0331498604f53daa6abd (published ground truth; manifest: hooks-gt) + """hestia_gate_core — ONE policy gate. Harnesses get adapter shims, not their own gates. + + dp, 2026-08-02: +@@ -2613,40 +2613,6 @@ + return False + + +-#: Operator-declared TOOLCHAIN roots (colon- or comma-separated absolute paths), e.g. a conda +-#: install under the home: `HESTIA_TOOL_ROOTS=/miniforge3`. A command's reach under one is +-#: not MRH-scoped, the way TEMP_ROOTS are not: an interpreter and its libraries are the tool, not +-#: the territory. Why (SAGE #368, 2026-10-05): with a being judged under its seat's workspace (the +-#: home), every sandboxed `check` (`bwrap --ro-bind /miniforge3 ...`, PATH=...) and every +-#: `game` (`/miniforge3/bin/python3 ...`) was denied "'miniforge3' is not granted": a grant +-#: for an interpreter would be a grant of nothing the member owns. DECLARED, never inferred, and +-#: egress still applies first. A root that is `/`, relative, the workspace, or an ancestor of it is +-#: ignored, so a misconfiguration cannot ungovern the territory it was meant to sit beside. +-TOOL_ROOTS_ENV = "HESTIA_TOOL_ROOTS" +- +- +-def _tool_roots(workspace: str) -> tuple: +- raw = os.environ.get(TOOL_ROOTS_ENV, "") +- ws = os.path.normpath(os.path.expanduser(workspace or "/")).replace("\\", "/").rstrip("/") or "/" +- out = [] +- for part in re.split(r"[:,]", raw): +- part = part.strip() +- if not part: +- continue +- r = os.path.normpath(os.path.expanduser(part)).replace("\\", "/").rstrip("/") +- if not r.startswith("/") or r in ("", "/"): +- continue +- if ws == r or ws.startswith(r + "/"): +- continue # the workspace or an ancestor: never a tool root +- out.append(r) +- return tuple(out) +- +- +-def _under_any(path: str, roots) -> bool: +- p = os.path.normpath(path.replace("\\", "/")).replace("\\", "/") +- return any(p == r or p.startswith(r + "/") for r in roots) +- +- + def _grammar_patterns(cmd: str, cwd: Optional[str]) -> tuple: + """(patterns, judged) from the shell grammar: the literal grep-family PATTERN values it + recognised (`_PATTERN_HEADS`), and every word it judges as a path. ((), ()) when it cannot +@@ -2733,13 +2699,18 @@ + # Pass 0 — the shell's home spellings, expanded before anything reads the text, so a + # workspace path spelled `~/…` or `$HOME/…` meets pass 1 like its literal spelling does. + cmd = _expand_home_words(cmd) +- # TWO THINGS INSIDE THE WORKSPACE ARE NOT A REACH (#1241 recut, SAGE #368): a grep-family +- # PATTERN, which the tool matches against and never opens (a being's own `-e '[^ ]*ft09'` +- # was refused "'[^' is not granted"), and a path under a declared toolchain root. Patterns are +- # recognised by the shell GRAMMAR (`_CMD_SPECS`), never by rewriting the text, so a live +- # substitution inside one is still lifted out and judged, and pass 3 is not touched. ++ # A grep-family PATTERN INSIDE THE WORKSPACE IS NOT A REACH (#1241 recut, SAGE #368): the tool ++ # matches against it and never opens it (a being's own `-e '[^ ]*ft09'` was refused ++ # "'[^' is not granted"). Patterns are recognised by the shell GRAMMAR (`_CMD_SPECS`), never by ++ # rewriting the text, so a live substitution inside one is still lifted out and judged, and ++ # pass 3 is not touched. A TOOLCHAIN (the being's interpreter, its sandbox's `--ro-bind`) is ++ # NOT exempted here: it is reached through an operator's recursive `path:/**` grant, ++ # which lives in the member's vault-held scope, is witnessed and revocable, and is matched on ++ # the REALPATH. #1377 first shipped an environment variable for it (HESTIA_TOOL_ROOTS); Nova's ++ # review asked who can set it, and a symlink under the declared root read an ungranted repo ++ # (`cat /lib/link/x`), because the exemption was lexical. A caller-controlled ++ # environment must not become authority. + patterns, judged_words = _grammar_patterns(cmd, cwd) +- tool_roots = _tool_roots(workspace) + for after in cmd.split(workspace)[1:]: + # Resolve the whole token before reading a segment off it (kimi #940 B7). Taking the + # head lexically let `cat /repo-a/../repo-b/secret` pass on `repo-a` while +@@ -2764,10 +2735,6 @@ + hidden = _symlink_target_forbidden(resolved, egress) + if hidden is not None: + return False, hidden, hidden +- # A DECLARED TOOLCHAIN is not governed territory (HESTIA_TOOL_ROOTS): judged after egress, +- # so a forbidden symlink target under a tool root still denies. +- if tool_roots and _under_any(resolved, tool_roots): +- continue + seg = resolved[len(ws):].lstrip("/").split("/", 1)[0] + if seg not in repo_scopes and not _within_path_grant(resolved, scopes, workspace): + refused = _symlinked_reach_verdict(resolved, seg, repo_scopes, scopes, workspace) +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": "d2ddf60f60e23965b9b413c3eaf6a4e096bfea9bbc1495c4e60caf7df4ef1b38", ++ "sha256": "9430ffc67a2193a3712fe161218d2276fec0875a7d3f0331498604f53daa6abd", + "source": "plugins/_shared/hestia_gate_core.py" + }, + { +@@ -38,5 +38,5 @@ + ], + "engine": null, + "requires": [], +- "gt_version": "ff4807bf6d0a9db46cab86ed8dfe79bf984f18c5476b5d8f6f54024a603b7946" ++ "gt_version": "a685c2ca8b43f87b7210425bebb981190049563fb0fd78c315f0f7e5e124cddb" + } +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": "ff4807bf6d0a9db46cab86ed8dfe79bf984f18c5476b5d8f6f54024a603b7946", ++ "gt_version": "a685c2ca8b43f87b7210425bebb981190049563fb0fd78c315f0f7e5e124cddb", + "files": [ + { + "path": "hestia_gate_core.py", +- "sha256": "d2ddf60f60e23965b9b413c3eaf6a4e096bfea9bbc1495c4e60caf7df4ef1b38" ++ "sha256": "9430ffc67a2193a3712fe161218d2276fec0875a7d3f0331498604f53daa6abd" + }, + { + "path": "hestia_gate_mechanism.py", +@@ -62,5 +62,5 @@ + ] + }, + "requires": [], +- "gt_version": "a1658a6603face6a9a603fd78fa87b9c956ce3a1470f9a2ae766db44c674ce50" ++ "gt_version": "efa6f6d7f8017c23877eaeaaee2f09d3628ea6ee335108824563cf48c7b4cede" + } +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": "ff4807bf6d0a9db46cab86ed8dfe79bf984f18c5476b5d8f6f54024a603b7946", ++ "gt_version": "a685c2ca8b43f87b7210425bebb981190049563fb0fd78c315f0f7e5e124cddb", + "files": [ + { + "path": "hestia_gate_core.py", +- "sha256": "d2ddf60f60e23965b9b413c3eaf6a4e096bfea9bbc1495c4e60caf7df4ef1b38" ++ "sha256": "9430ffc67a2193a3712fe161218d2276fec0875a7d3f0331498604f53daa6abd" + }, + { + "path": "hestia_gate_mechanism.py", +@@ -62,5 +62,5 @@ + ] + }, + "requires": [], +- "gt_version": "f5ed2543e11f3f1bb8b13b73e74e623ccf509d238160690856841aab07fd24d2" ++ "gt_version": "7bc22ef860398aef1542a387b50ec1fb46bf12782c9d6b085f922055e8275075" + } +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": "ff4807bf6d0a9db46cab86ed8dfe79bf984f18c5476b5d8f6f54024a603b7946", ++ "gt_version": "a685c2ca8b43f87b7210425bebb981190049563fb0fd78c315f0f7e5e124cddb", + "files": [ + { + "path": "hestia_gate_core.py", +- "sha256": "d2ddf60f60e23965b9b413c3eaf6a4e096bfea9bbc1495c4e60caf7df4ef1b38" ++ "sha256": "9430ffc67a2193a3712fe161218d2276fec0875a7d3f0331498604f53daa6abd" + }, + { + "path": "hestia_gate_mechanism.py", +@@ -62,5 +62,5 @@ + ] + }, + "requires": [], +- "gt_version": "3d3f55d706a89e55fdc817685cf192ee3359ca2956dbbbc06d7d44c162efcae4" ++ "gt_version": "e2fb3ad6e38ab93fa4f8e98957eed275c0790c5a554659721b71a67afa4edfb7" + } +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": "ff4807bf6d0a9db46cab86ed8dfe79bf984f18c5476b5d8f6f54024a603b7946", ++ "gt_version": "a685c2ca8b43f87b7210425bebb981190049563fb0fd78c315f0f7e5e124cddb", + "files": [ + { + "path": "hestia_gate_core.py", +- "sha256": "d2ddf60f60e23965b9b413c3eaf6a4e096bfea9bbc1495c4e60caf7df4ef1b38" ++ "sha256": "9430ffc67a2193a3712fe161218d2276fec0875a7d3f0331498604f53daa6abd" + }, + { + "path": "hestia_gate_mechanism.py", +@@ -62,5 +62,5 @@ + ] + }, + "requires": [], +- "gt_version": "983b6509ba4337bcea1240481332f4a65035697ba287f991a325648affdd9850" ++ "gt_version": "c33342882af9188213cadd46f7f628043098d758071e7470f7a0d567474aaebb" + } +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 +@@ -2612,40 +2612,6 @@ + return False + + +-#: Operator-declared TOOLCHAIN roots (colon- or comma-separated absolute paths), e.g. a conda +-#: install under the home: `HESTIA_TOOL_ROOTS=/miniforge3`. A command's reach under one is +-#: not MRH-scoped, the way TEMP_ROOTS are not: an interpreter and its libraries are the tool, not +-#: the territory. Why (SAGE #368, 2026-10-05): with a being judged under its seat's workspace (the +-#: home), every sandboxed `check` (`bwrap --ro-bind /miniforge3 ...`, PATH=...) and every +-#: `game` (`/miniforge3/bin/python3 ...`) was denied "'miniforge3' is not granted": a grant +-#: for an interpreter would be a grant of nothing the member owns. DECLARED, never inferred, and +-#: egress still applies first. A root that is `/`, relative, the workspace, or an ancestor of it is +-#: ignored, so a misconfiguration cannot ungovern the territory it was meant to sit beside. +-TOOL_ROOTS_ENV = "HESTIA_TOOL_ROOTS" +- +- +-def _tool_roots(workspace: str) -> tuple: +- raw = os.environ.get(TOOL_ROOTS_ENV, "") +- ws = os.path.normpath(os.path.expanduser(workspace or "/")).replace("\\", "/").rstrip("/") or "/" +- out = [] +- for part in re.split(r"[:,]", raw): +- part = part.strip() +- if not part: +- continue +- r = os.path.normpath(os.path.expanduser(part)).replace("\\", "/").rstrip("/") +- if not r.startswith("/") or r in ("", "/"): +- continue +- if ws == r or ws.startswith(r + "/"): +- continue # the workspace or an ancestor: never a tool root +- out.append(r) +- return tuple(out) +- +- +-def _under_any(path: str, roots) -> bool: +- p = os.path.normpath(path.replace("\\", "/")).replace("\\", "/") +- return any(p == r or p.startswith(r + "/") for r in roots) +- +- + def _grammar_patterns(cmd: str, cwd: Optional[str]) -> tuple: + """(patterns, judged) from the shell grammar: the literal grep-family PATTERN values it + recognised (`_PATTERN_HEADS`), and every word it judges as a path. ((), ()) when it cannot +@@ -2732,13 +2698,18 @@ + # Pass 0 — the shell's home spellings, expanded before anything reads the text, so a + # workspace path spelled `~/…` or `$HOME/…` meets pass 1 like its literal spelling does. + cmd = _expand_home_words(cmd) +- # TWO THINGS INSIDE THE WORKSPACE ARE NOT A REACH (#1241 recut, SAGE #368): a grep-family +- # PATTERN, which the tool matches against and never opens (a being's own `-e '[^ ]*ft09'` +- # was refused "'[^' is not granted"), and a path under a declared toolchain root. Patterns are +- # recognised by the shell GRAMMAR (`_CMD_SPECS`), never by rewriting the text, so a live +- # substitution inside one is still lifted out and judged, and pass 3 is not touched. ++ # A grep-family PATTERN INSIDE THE WORKSPACE IS NOT A REACH (#1241 recut, SAGE #368): the tool ++ # matches against it and never opens it (a being's own `-e '[^ ]*ft09'` was refused ++ # "'[^' is not granted"). Patterns are recognised by the shell GRAMMAR (`_CMD_SPECS`), never by ++ # rewriting the text, so a live substitution inside one is still lifted out and judged, and ++ # pass 3 is not touched. A TOOLCHAIN (the being's interpreter, its sandbox's `--ro-bind`) is ++ # NOT exempted here: it is reached through an operator's recursive `path:/**` grant, ++ # which lives in the member's vault-held scope, is witnessed and revocable, and is matched on ++ # the REALPATH. #1377 first shipped an environment variable for it (HESTIA_TOOL_ROOTS); Nova's ++ # review asked who can set it, and a symlink under the declared root read an ungranted repo ++ # (`cat /lib/link/x`), because the exemption was lexical. A caller-controlled ++ # environment must not become authority. + patterns, judged_words = _grammar_patterns(cmd, cwd) +- tool_roots = _tool_roots(workspace) + for after in cmd.split(workspace)[1:]: + # Resolve the whole token before reading a segment off it (kimi #940 B7). Taking the + # head lexically let `cat /repo-a/../repo-b/secret` pass on `repo-a` while +@@ -2763,10 +2734,6 @@ + hidden = _symlink_target_forbidden(resolved, egress) + if hidden is not None: + return False, hidden, hidden +- # A DECLARED TOOLCHAIN is not governed territory (HESTIA_TOOL_ROOTS): judged after egress, +- # so a forbidden symlink target under a tool root still denies. +- if tool_roots and _under_any(resolved, tool_roots): +- continue + seg = resolved[len(ws):].lstrip("/").split("/", 1)[0] + if seg not in repo_scopes and not _within_path_grant(resolved, scopes, workspace): + refused = _symlinked_reach_verdict(resolved, seg, repo_scopes, scopes, workspace) +diff --git a/tools/gate_grep_patterns_and_toolchain_grant_test.py b/tools/gate_grep_patterns_and_toolchain_grant_test.py +new file mode 100755 +--- /dev/null ++++ b/tools/gate_grep_patterns_and_toolchain_grant_test.py +@@ -0,0 +1,181 @@ ++#!/usr/bin/env python3 ++"""Command scope: grep-family PATTERN operands, and a toolchain reached by GRANT (SAGE #368, #1241 recut). ++ ++Measured 2026-10-05 on 2,187 legion-being calls replayed through the one gate with the being under ++its SEAT's workspace (dp's ruling: a being judged by anything else is judged by a different law ++than its seat): SAGE #368, which hands the gate the composed command, denied 37/37 `check` and ++84/84 `game` ("'miniforge3' is not granted": the sandbox's `--ro-bind /miniforge3` and the ++interpreter itself) and one `search` ("'[^' is not granted": a fragment of the being's own regex). ++ ++The recut (cbp-claude's review of #1241, 2026-10-14): patterns are recognised INSIDE the shell ++grammar (`_CMD_SPECS`, `_Found.patterns`), never by rewriting the command text. #1241's text mask ++re-quoted `"$(...)"` as an inert `'$(...)'` and admitted acts main refuses; those rows are the ++must-deny regressions below. ++ ++The toolchain (#1377, Nova's HOLD): the first apply exempted roots named by an environment variable, ++HESTIA_TOOL_ROOTS. Nothing proved a governed member could not set it (a seat's systemd unit is inside ++its own grant on Legion), and the exemption was lexical, so a symlink under the root read an ungranted ++repo. Removed. The being's interpreter is reached by an operator's recursive `path:/**` grant: ++vault-held, witnessed, revocable, matched on the realpath. ++ ++Run: ./tools/gate_grep_patterns_and_toolchain_grant_test.py ++ HESTIA_SCOPE_BATTERY_CORE= ./tools/gate_grep_patterns_and_toolchain_grant_test.py (a staged core) ++""" ++from __future__ import annotations ++ ++import importlib.util ++import os ++import sys ++import tempfile ++from pathlib import Path ++ ++ROOT = Path(__file__).resolve().parents[1] ++sys.path.insert(0, str(ROOT / "plugins" / "_shared")) ++_staged = os.getenv("HESTIA_SCOPE_BATTERY_CORE") ++if _staged: ++ _spec = importlib.util.spec_from_file_location("hestia_gate_core", _staged) ++ G = importlib.util.module_from_spec(_spec) ++ sys.modules["hestia_gate_core"] = G ++ _spec.loader.exec_module(G) ++else: ++ import hestia_gate_core as G # noqa: E402 ++ ++FAIL = [] ++ ++ ++def check(name, cond, detail=""): ++ print((" ok " if cond else " FAIL ") + name + ("" if cond else f" {detail}")) ++ if not cond: ++ FAIL.append(name) ++ ++ ++def main() -> int: ++ ws = tempfile.mkdtemp(prefix="ws-") ++ for d in ("granted/sub", "tools/conda/bin", "other"): ++ os.makedirs(f"{ws}/{d}") ++ open(f"{ws}/other/x", "w").close() ++ open(f"{ws}/granted/f", "w").close() ++ scopes = ("granted",) ++ ++ def reach(c): ++ return G.command_scope_reach(c, scopes, ws, cwd=f"{ws}/granted") ++ ++ def denied(c, off=None): ++ ok, o, _ = reach(c) ++ return (not ok) and (off is None or o == off or (o or "").startswith(off)), o ++ ++ # ---- the toolchain is an operator's RECURSIVE PATH GRANT, never an environment variable ---- ++ conda = f"{ws}/tools/conda" ++ check_line = (f"bwrap --ro-bind {conda} {conda} --setenv PATH {conda}/bin:/usr/bin " ++ f"{conda}/bin/python3 -m pytest {ws}/granted/sub") ++ granted_tool = scopes + (f"path:{conda}/**",) ++ ++ def reach_with(c, sc): ++ return G.command_scope_reach(c, sc, ws, cwd=f"{ws}/granted") ++ ++ ok, off, _ = reach(check_line) ++ check("without a grant, the interpreter is an ungranted reach (unchanged)", not ok and off == "tools", off) ++ ok, off, _ = reach_with(check_line, granted_tool) ++ check("an operator's recursive grant on the toolchain admits the check-shaped line", ok, off) ++ ok, off, _ = reach_with(f"{conda}/bin/python3 {ws}/other/stepper.py --instance {ws}/granted", granted_tool) ++ check("operands outside the granted toolchain are still judged (game-shaped line)", not ok and off == "other", off) ++ ok, off, _ = reach_with(f"cat {ws}/tools/condaX/secret", granted_tool) ++ check("a sibling of the granted root is not under it (boundary, not prefix)", not ok, off) ++ ok, off, _ = reach_with(f"{conda}/bin/python3 -c pass", scopes + (f"path:{conda}",)) ++ check("an EXACT grant on the root does not reach its interpreter", not ok, off) ++ ++ os.symlink(f"{ws}/other", f"{conda}/escape") ++ ok, off, _ = reach_with(f"cat {conda}/escape/x", granted_tool) ++ check("must deny: a symlink under the granted toolchain into an ungranted dir (realpath)", not ok and off == "other", off) ++ secret = tempfile.mkdtemp(prefix="s-") ++ os.makedirs(f"{secret}/.ssh") ++ open(f"{secret}/.ssh/id_x", "w").close() ++ os.symlink(f"{secret}/.ssh/id_x", f"{conda}/link") ++ ok, off, _ = reach_with(f"cat {conda}/link", granted_tool) ++ check("egress is judged before the grant: a link to a forbidden target denies", not ok and ".ssh" in (off or ""), off) ++ ++ # A caller-set environment is not authority: the variable #1377 first shipped does nothing, ++ # whether it is in the gate's own environment or written as a prefix on the command. ++ name = "HESTIA_TOOL_" + "ROOTS" ++ os.environ[name] = conda ++ try: ++ ok, off, _ = reach(check_line) ++ check("must deny: HESTIA_TOOL_ROOTS in the gate's environment grants nothing", not ok and off == "tools", off) ++ ok, off, _ = reach(f"{name}={conda} {check_line}") ++ check("must deny: HESTIA_TOOL_ROOTS as a command prefix grants nothing", not ok, off) ++ ok, off, _ = reach(f"cat {conda}/escape/x") ++ check("must deny: the symlink escape #1377 admitted under HESTIA_TOOL_ROOTS", not ok and off in ("tools", "other"), off) ++ finally: ++ os.environ.pop(name, None) ++ check("the gate core carries no tool-roots declaration", not hasattr(G, "TOOL_ROOTS_ENV") and not hasattr(G, "_tool_roots")) ++ ++ # ---- grep-family PATTERN operands are text, recognised by the grammar ---- ++ ok, off, _ = reach(f"git --no-pager -C {ws}/granted grep -n -I -E --max-count=15 -e 'ft09\\.py.*bytes|{ws}[^ ]*ft09\\.py' -- {ws}/granted/sub") ++ check("a git grep -e pattern is text, not a path (search-shaped line)", ok, off) ++ ok, off, _ = reach(f"git --no-pager -C {ws}/granted grep -n -e 'x' -- {ws}/other") ++ check("git grep's pathspec after -- is still judged", not ok and off == "other", off) ++ ok, off, _ = reach(f"git -C {ws}/granted grep -C 3 '{ws}/other/x' -- {ws}/granted/sub") ++ check("git grep's -C is context lines, and its positional pattern is text", ok, off) ++ d, off = denied(f"git -C {ws}/other grep x") ++ check("git's own -C before the subcommand is still a judged directory", d, off) ++ ok, off, _ = reach(f"grep -rn '{ws}/other/never-opened' {ws}/granted/sub") ++ check("grep's positional pattern is text", ok, off) ++ ok, off, _ = reach(f"grep -rn needle {ws}/other") ++ check("grep's file operand is still judged", not ok and off == "other", off) ++ ok, off, _ = reach(f"grep -f {ws}/other/patterns {ws}/granted/sub") ++ check("grep -f's operand IS a file and is judged", not ok and off == "other", off) ++ ok, off, _ = reach(f"rg --regexp={ws}/other/x {ws}/granted") ++ check("rg --regexp=... is text", ok, off) ++ ok, off, _ = reach(f"grep -e{ws}/other/x {ws}/granted") ++ check("an attached -e pattern is text", ok, off) ++ ok, off, _ = reach(f"cat {ws}/other/x | grep -e '{ws}/other' ") ++ check("per simple command: cat's operand is still judged", not ok and off == "other", off) ++ d, off = denied(f"grep -e 'unbalanced {ws}/other/x") ++ check("an unreadable line is judged as before, never less", d, off) ++ ++ # ---- the same text as a pattern AND an operand: the operand is judged ---- ++ d, off = denied(f"grep '{ws}/other' {ws}/other") ++ check("a path that is both the pattern and the operand is judged", d and off == "other", off) ++ d, off = denied(f"grep -e '{ws}/other/x' -- {ws}/other/x") ++ check("a pattern does not cover its own text named again as a file", d and off == "other", off) ++ ++ # ---- anything an expansion decides stays live (cbp-claude's must-deny rows) ---- ++ for c, why in ((f'grep -n x "$(cat /etc/shadow)"', "a substitution in a grep file operand"), ++ (f'grep x f; echo "$(cat /root/secret)"', "a substitution after a grep"), ++ ('grep -e foo "`cat /root/x`"', "backticks in a grep operand"), ++ ("grep x < "F"`): only grep-family heads are exempt. ++ for c in (f"sed 'w {ws}/other/x' {ws}/granted/f", f"sed -n '1r {ws}/other/x' {ws}/granted/f", ++ f"awk '{{print > \"{ws}/other/x\"}}' {ws}/granted/f"): ++ d, off = denied(c) ++ check(f"must deny: a {c.split()[0]} program naming a file is judged", d and off == "other", off) ++ # An unquoted expansion in the pattern position can split into an option (`X=-f` makes ++ # `$X/...` a FILE operand). It is not recorded as a pattern, and pass 3 refuses it as ++ # unenumerated too: two layers, so this row stays red if either holds. ++ d, off = denied(f"grep $X{ws}/other/x {ws}/granted/f") ++ check("must deny: an unquoted expansion in the pattern position", d, off) ++ ++ print("\nall checks pass" if not FAIL else f"\n{len(FAIL)} FAILED: {FAIL}") ++ return 1 if FAIL else 0 ++ ++ ++if __name__ == "__main__": ++ sys.exit(main()) +diff --git a/tools/gate_tool_roots_and_patterns_test.py b/tools/gate_tool_roots_and_patterns_test.py +deleted file mode 100755 +--- a/tools/gate_tool_roots_and_patterns_test.py ++++ /dev/null +@@ -1,160 +0,0 @@ +-#!/usr/bin/env python3 +-"""Command scope: declared toolchain roots, and grep-family PATTERN operands (SAGE #368, #1241 recut). +- +-Measured 2026-10-05 on 2,187 legion-being calls replayed through the one gate with the being under +-its SEAT's workspace (dp's ruling: a being judged by anything else is judged by a different law +-than its seat): SAGE #368, which hands the gate the composed command, denied 37/37 `check` and +-84/84 `game` ("'miniforge3' is not granted": the sandbox's `--ro-bind /miniforge3` and the +-interpreter itself) and one `search` ("'[^' is not granted": a fragment of the being's own regex). +- +-The recut (cbp-claude's review of #1241, 2026-10-14): patterns are recognised INSIDE the shell +-grammar (`_CMD_SPECS`, `_Found.patterns`), never by rewriting the command text. #1241's text mask +-re-quoted `"$(...)"` as an inert `'$(...)'` and admitted acts main refuses; those rows are the +-must-deny regressions below. +- +-Run: ./tools/gate_tool_roots_and_patterns_test.py +- HESTIA_SCOPE_BATTERY_CORE= ./tools/gate_tool_roots_and_patterns_test.py (a staged core) +-""" +-from __future__ import annotations +- +-import importlib.util +-import os +-import sys +-import tempfile +-from pathlib import Path +- +-ROOT = Path(__file__).resolve().parents[1] +-sys.path.insert(0, str(ROOT / "plugins" / "_shared")) +-_staged = os.getenv("HESTIA_SCOPE_BATTERY_CORE") +-if _staged: +- _spec = importlib.util.spec_from_file_location("hestia_gate_core", _staged) +- G = importlib.util.module_from_spec(_spec) +- sys.modules["hestia_gate_core"] = G +- _spec.loader.exec_module(G) +-else: +- import hestia_gate_core as G # noqa: E402 +- +-FAIL = [] +- +- +-def check(name, cond, detail=""): +- print((" ok " if cond else " FAIL ") + name + ("" if cond else f" {detail}")) +- if not cond: +- FAIL.append(name) +- +- +-def main() -> int: +- ws = tempfile.mkdtemp(prefix="ws-") +- for d in ("granted/sub", "tools/conda/bin", "other"): +- os.makedirs(f"{ws}/{d}") +- open(f"{ws}/other/x", "w").close() +- open(f"{ws}/granted/f", "w").close() +- scopes = ("granted",) +- +- def reach(c): +- return G.command_scope_reach(c, scopes, ws, cwd=f"{ws}/granted") +- +- def denied(c, off=None): +- ok, o, _ = reach(c) +- return (not ok) and (off is None or o == off or (o or "").startswith(off)), o +- +- # ---- declared toolchain roots ---- +- os.environ.pop(G.TOOL_ROOTS_ENV, None) +- ok, off, _ = reach(f"bwrap --ro-bind {ws}/tools/conda {ws}/tools/conda {ws}/tools/conda/bin/python3 {ws}/granted/t.py") +- check("without a declared tool root, the interpreter is an ungranted reach (unchanged)", not ok and off == "tools", off) +- +- os.environ[G.TOOL_ROOTS_ENV] = f"{ws}/tools/conda" +- ok, off, _ = reach(f"bwrap --ro-bind {ws}/tools/conda {ws}/tools/conda --setenv PATH {ws}/tools/conda/bin:/usr/bin " +- f"{ws}/tools/conda/bin/python3 -m pytest {ws}/granted/sub") +- check("a declared tool root is not governed territory (check-shaped line)", ok, off) +- ok, off, _ = reach(f"{ws}/tools/conda/bin/python3 {ws}/other/stepper.py --instance {ws}/granted") +- check("operands outside the tool root are still judged (game-shaped line)", not ok and off == "other", off) +- ok, off, _ = reach(f"cat {ws}/tools/condaX/secret") +- check("a sibling of a tool root is not under it (boundary, not prefix)", not ok, off) +- for bad in ("/", ws, os.path.dirname(ws)): +- os.environ[G.TOOL_ROOTS_ENV] = bad +- ok, off, _ = reach(f"cat {ws}/other/x") +- check(f"a tool root that is /, the workspace or its ancestor is ignored ({bad})", not ok and off == "other", off) +- os.environ[G.TOOL_ROOTS_ENV] = "relative/path" +- ok, _, _ = reach(f"cat {ws}/other/x") +- check("a relative tool root is ignored", not ok) +- +- os.environ[G.TOOL_ROOTS_ENV] = f"{ws}/tools/conda" +- secret = tempfile.mkdtemp(prefix="s-") +- os.makedirs(f"{secret}/.ssh") +- open(f"{secret}/.ssh/id_x", "w").close() +- os.symlink(f"{secret}/.ssh/id_x", f"{ws}/tools/conda/link") +- ok, off, _ = reach(f"cat {ws}/tools/conda/link") +- check("egress is judged before the tool root: a link to a forbidden target denies", not ok and ".ssh" in (off or ""), off) +- os.environ.pop(G.TOOL_ROOTS_ENV, None) +- +- # ---- grep-family PATTERN operands are text, recognised by the grammar ---- +- ok, off, _ = reach(f"git --no-pager -C {ws}/granted grep -n -I -E --max-count=15 -e 'ft09\\.py.*bytes|{ws}[^ ]*ft09\\.py' -- {ws}/granted/sub") +- check("a git grep -e pattern is text, not a path (search-shaped line)", ok, off) +- ok, off, _ = reach(f"git --no-pager -C {ws}/granted grep -n -e 'x' -- {ws}/other") +- check("git grep's pathspec after -- is still judged", not ok and off == "other", off) +- ok, off, _ = reach(f"git -C {ws}/granted grep -C 3 '{ws}/other/x' -- {ws}/granted/sub") +- check("git grep's -C is context lines, and its positional pattern is text", ok, off) +- d, off = denied(f"git -C {ws}/other grep x") +- check("git's own -C before the subcommand is still a judged directory", d, off) +- ok, off, _ = reach(f"grep -rn '{ws}/other/never-opened' {ws}/granted/sub") +- check("grep's positional pattern is text", ok, off) +- ok, off, _ = reach(f"grep -rn needle {ws}/other") +- check("grep's file operand is still judged", not ok and off == "other", off) +- ok, off, _ = reach(f"grep -f {ws}/other/patterns {ws}/granted/sub") +- check("grep -f's operand IS a file and is judged", not ok and off == "other", off) +- ok, off, _ = reach(f"rg --regexp={ws}/other/x {ws}/granted") +- check("rg --regexp=... is text", ok, off) +- ok, off, _ = reach(f"grep -e{ws}/other/x {ws}/granted") +- check("an attached -e pattern is text", ok, off) +- ok, off, _ = reach(f"cat {ws}/other/x | grep -e '{ws}/other' ") +- check("per simple command: cat's operand is still judged", not ok and off == "other", off) +- d, off = denied(f"grep -e 'unbalanced {ws}/other/x") +- check("an unreadable line is judged as before, never less", d, off) +- +- # ---- the same text as a pattern AND an operand: the operand is judged ---- +- d, off = denied(f"grep '{ws}/other' {ws}/other") +- check("a path that is both the pattern and the operand is judged", d and off == "other", off) +- d, off = denied(f"grep -e '{ws}/other/x' -- {ws}/other/x") +- check("a pattern does not cover its own text named again as a file", d and off == "other", off) +- +- # ---- anything an expansion decides stays live (cbp-claude's must-deny rows) ---- +- for c, why in ((f'grep -n x "$(cat /etc/shadow)"', "a substitution in a grep file operand"), +- (f'grep x f; echo "$(cat /root/secret)"', "a substitution after a grep"), +- ('grep -e foo "`cat /root/x`"', "backticks in a grep operand"), +- ("grep x < "F"`): only grep-family heads are exempt. +- for c in (f"sed 'w {ws}/other/x' {ws}/granted/f", f"sed -n '1r {ws}/other/x' {ws}/granted/f", +- f"awk '{{print > \"{ws}/other/x\"}}' {ws}/granted/f"): +- d, off = denied(c) +- check(f"must deny: a {c.split()[0]} program naming a file is judged", d and off == "other", off) +- # An unquoted expansion in the pattern position can split into an option (`X=-f` makes +- # `$X/...` a FILE operand). It is not recorded as a pattern, and pass 3 refuses it as +- # unenumerated too: two layers, so this row stays red if either holds. +- d, off = denied(f"grep $X{ws}/other/x {ws}/granted/f") +- check("must deny: an unquoted expansion in the pattern position", d, off) +- +- print("\nall checks pass" if not FAIL else f"\n{len(FAIL)} FAILED: {FAIL}") +- return 1 if FAIL else 0 +- +- +-if __name__ == "__main__": +- sys.exit(main()) diff --git a/held/README-1241r.md b/held/README-1241r.md new file mode 100644 index 00000000..80fdc127 --- /dev/null +++ b/held/README-1241r.md @@ -0,0 +1,93 @@ +# held/: #1241 recut — grep-family patterns judged INSIDE the shell grammar, and declared toolchain roots, NOT applied + +| file | sha256 | +|---|---| +| `2a6c9ed0b428119b175d0cc1105db5522d410c6fb71288149fde356d582e3be1.patch` | `2a6c9ed0b428119b175d0cc1105db5522d410c6fb71288149fde356d582e3be1` (base main `3b7690d2`) | + +**Files:** `plugins/_shared/hestia_gate_core.py` and its hooks-gt copy and manifests, the new +`tools/gate_tool_roots_and_patterns_test.py`, and one repinned cell in `tools/mrh_scope_differential.py`. +9 files, +382 −20. It supersedes #1241. + +## Why + +SAGE #368 / #441 hands the one gate the being's **composed** command, so what is judged is what +runs, and dp ruled that a being is judged under its **seat's** workspace (the home). Replayed on +2,187 legion-being calls (2026-10-05), that denied: +- **every sandboxed `check` and every `game`** with "'miniforge3' is not granted": the sandbox's + `--ro-bind /miniforge3`, its `PATH`, and the interpreter itself; +- **a `search`** with "'[^' is not granted": a fragment of the being's own regex, + `-e '[^ ]*ft09\.py'`. + +Neither is a reach. SAGE #441 is on HOLD for this. + +## Why a recut and not #1241 (cbp-claude's review, 2026-10-14) + +#1241 rewrote the WHOLE command line to mask pattern operands before the shell-grammar judge (#1232) +ran, and re-quoted `"$(...)"` as an inert `'$(...)'`. On main that admitted acts main refuses: +`grep -n x "$(cat /etc/shadow)"`, `grep x f; echo "$(cat /root/secret)"`, and backticks in a grep +operand. This recut never rewrites the text. + +## The change (`hestia_gate_core` only) + +1. **The grammar records patterns.** `_outside_candidates` already knows, through `_CMD_SPECS`, which + word is a grep/rg pattern: the first positional, `-e X`, `-eX`, `--regexp=X`. It now also records + their **literal values** on `_Found.patterns`. + - Only for grep-family heads (`_PATTERN_HEADS`: grep, egrep, fgrep, rg, and `git grep`). sed and awk + share the pattern-first grammar, but their programs read and write files. + - Only for a word with **no expansion**. A substitution inside a pattern is still lifted out and + judged like any other command. + - The judgment of every word is unchanged; `-eX` still calls nothing new. +2. **`git grep` gets grep's grammar after the subcommand** (`_GIT_GREP_SPEC`), so its `-C 3` is context + lines, while git's own `-C DIR` before the subcommand is still a judged directory. +3. **Passes 1 and 2 (inside the workspace) skip a mention only when it lies inside a recorded pattern + AND inside no word the grammar judges as a path** (`_only_pattern_text`). `grep '/x' /x` + still judges `/x`. A line the grammar cannot read skips nothing. +4. **Pass 3 (outside the workspace) is not touched**, so every must-deny row above still denies there. +5. **`HESTIA_TOOL_ROOTS`**, ported from #1241 unchanged: + - operator-declared toolchain roots, colon- or comma-separated absolute paths; + - a reach under one is not MRH-scoped in pass 1, and it is judged **after** egress, so a symlink to + a forbidden target still denies; + - `/`, a relative path, the workspace, or an ancestor of it is ignored; + - declared, never inferred. + +## Evidence (scratch core through `HESTIA_SCOPE_BATTERY_CORE` / `HESTIA_CONTRACT_OVERLAY`, base `3b7690d2`) + +| check | result | +|---|---| +| `tools/gate_tool_roots_and_patterns_test.py` | 38/38 pass | +| the same test on main's core (control) | 7 fail, exactly the positive cells (tool roots, git grep, grep/rg patterns, `-eX`); every must-deny row passes on main too | +| `tools/mrh_scope_differential.py` | one cell off its pin: `744.grep-pattern-compound` (wrong root) deny `'python'` → allow, as cbp predicted. False positives 7 → 6, false negatives 0. Repinned here. | +| `tools/shell_scope_reach_battery_test.py` | 1434 passed, 0 failed | +| `plugins/_shared/test_gate_core.py` | all checks pass | +| `tools/one_gate_decide_contract_test.py` | PASS (24 + shim parity 29x4) | +| `tools/scope_fork_differential_test.py --max-divergences 0` | 0 divergences | +| `tools/closure_read_tool_names_test.py`, `closure_output_writes_test.py`, `shell_grammar_test.py` | OK | +| `tools/hooks_gt.py check` on the post-patch tree (in memory) | clean | +| `git apply --check` on main `3b7690d2` | clean | + +**Mutations:** + +| mutation | red | +|---|---| +| M1: a pattern covers text an operand also names | 2 | +| M3: `git grep` keeps git's grammar | 2 | +| M4: the tool root is judged before egress | 1 | +| M5: every pattern-first head is exempt (sed, awk too) | 3, the sed `w`/`r` and awk `print >` rows | +| M2: a word with an expansion is recorded as a pattern | 0 | +| M6: an unreadable line skips everything | 0 | + +M2 and M6 survive for a stated reason, not a missing test. **Pass 3 refuses both lines independently**: +an unquoted expansion where field splitting can add arguments, and an unreadable line, are unenumerated +there. The rows `grep $X/other/x …` and the unbalanced quote are in the test, so they stay red if +either layer holds. + +**Not run here:** the 2,187-call SAGE replay of 10-05. SAGE #441 reruns it once this is applied. + +## After applying + +- Set `HESTIA_TOOL_ROOTS` in each being seat's environment (the conda root on Legion). +- Re-measure SAGE #441's replay, then lift its HOLD. +- Close #1241. +- Delete `held/2a6c9ed0…patch` and this README in the commit that applies it. + +— legion-claude diff --git a/held/README-1377f.md b/held/README-1377f.md new file mode 100644 index 00000000..1bdd1851 --- /dev/null +++ b/held/README-1377f.md @@ -0,0 +1,53 @@ +# Held: #1377 follow-up. Tool roots removed; a toolchain is reached by grant (80600bf3…) + +**Patch:** `held/80600bf347df9eb0a637f7c63da37be3ee1af1e56b757a820845f9b5b075d554.patch` +**Base:** #1377's head `3b98c318`, which applies `2a6c9ed0…`. **Not applied.** + +## Why (Nova's HOLD on #1377, 2026-10-23) + +`HESTIA_TOOL_ROOTS` came from the gate's environment and exempted any path under a named root +from MRH scope. Nova asked who can set it. Two things came out of that question. + +1. **Provenance isn't closed.** The harness configs that can set a hook's environment are + governance closure (`.claude/settings.json`, `.codex/config.toml`, …). But a seat's launch + environment also comes from its systemd user unit, and on Legion `~/.config/systemd/user` + is inside the seat's own grant. +2. **A symlink under the root reads an ungranted repo.** The exemption was lexical (`_under_any` + on the normalized path), and the egress check before it only catches *forbidden* targets. + Measured: with the variable set, `cat /lib/escape/secret.md` was admitted, where + `escape` → an ungranted repo. That row is a must-deny regression below. + +## What the patch does + +- **Deletes `TOOL_ROOTS_ENV`, `_tool_roots` and `_under_any`, and their use in pass 1.** The + grep-family pattern handling is untouched. +- **The toolchain is reached by an operator's recursive grant, `path:/**`.** It sits in the + member's vault-held scope, it is witnessed and revocable, and `_within_path_grant` matches it + on the **realpath**. That mechanism already existed; no new authority is added. +- **The test is renamed** to `tools/gate_grep_patterns_and_toolchain_grant_test.py`. Its + toolchain section is rewritten: + - with no grant, the interpreter is refused; + - a recursive grant admits the `check`-shaped line (`bwrap --ro-bind`, interpreter, `--setenv PATH`); + - operands outside the grant are still judged; + - a sibling of the root is not under it; + - an **exact** grant does not reach the interpreter; + - **must deny:** a symlink under the granted root into an ungranted dir; + - egress is judged before the grant; + - **must deny:** `HESTIA_TOOL_ROOTS`, whether in the gate's environment or as a command prefix; + - the core carries no tool-roots declaration. +- **Manifests** are republished (`hooks_gt.build`, check clean). + +## Verified (staged core, nothing governed written) + +- **New test:** all checks pass. **Control on #1377's core: 4 FAILED**, the symlink escape + among them. +- **`mrh_scope_differential`:** 0 cells off their pin, 0 false negatives (unchanged). +- **Core suites:** `test_gate_core` all pass; `shell_grammar_test` OK; `seat_gate_boundary_test` + 17×4; sprintD 85/0; sprintE 9; sprintF 10. +- **Apply-check:** applies on `3b98c318` (temp index), and the staged core equals the patched tree's core. + +## Operator follow-up after it is installed + +Grant legion-being `path:/home/dp/miniforge3/**`, recursive, standing. Then rerun SAGE #441's replay. + +— legion-claude