From d12f465746f4be92cf78432f3e6123dab07a9704 Mon Sep 17 00:00:00 2001 From: Dennis Palatov Date: Fri, 23 Oct 2026 22:14:26 -0700 Subject: [PATCH 1/8] gateway: a relative miss never offers a file outside reach as a read; peer_ask "seat" points at the seat conversation (#530) reference_f1a: when a relative read misses and the file exists under an ancestor of the home but outside the member's reach, #522 names a copy in reach if there is one. With no copy it fell through to "A file DOES exist at X. To read it: memory_read X", handing the member an out-of-reach path as a read. It now says the file exists, is outside what the member holds, and that no copy is in reach; no door is suggested. A verdict carrying no grants (hand-made, or a failed policy fetch) cannot tell reach and keeps the old line. hestia_dispatch._say_instead: "seat" / "the seat" / "my seat" resolve to the being's seat conversation (_seat_conversation), so peer_ask to="seat" is pointed at say there instead of the bare "not on the hub roster". cbp-being sent it seven times; every receipt calls the seat "the seat", but the name only resolved through a per-instance also_known_as no seat had declared. Tests: test_reference_f1a 91, test_hestia_dispatch 112; full gateway suite 1810 passed, 1 failed (test_activity_reports::test_the_real_beat_reports_each_phase_before_it_runs, which fails identically on clean main 9dc86518a). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH --- sage/gateway/hestia_dispatch.py | 15 ++++++++++ sage/gateway/reference_f1a.py | 16 ++++++++++ sage/gateway/tests/test_hestia_dispatch.py | 27 +++++++++++++++++ sage/gateway/tests/test_reference_f1a.py | 34 ++++++++++++++++++++++ 4 files changed, 92 insertions(+) diff --git a/sage/gateway/hestia_dispatch.py b/sage/gateway/hestia_dispatch.py index fb82d0659..b1be0a9bf 100644 --- a/sage/gateway/hestia_dispatch.py +++ b/sage/gateway/hestia_dispatch.py @@ -645,6 +645,21 @@ def _say_instead(self, to: str) -> Optional[str]: base = (to or "").split("/", 1)[0].strip().lower() if not base: return None + # "SEAT" IS WHAT EVERY RECEIPT CALLS THE SEAT. cbp-being sent peer_ask to="seat" seven + # times (to 2026-10-24) and each time got only "not on the hub roster" and a list of + # boxes, nothing leading back: the seat's own conversation title says "(the seat)" and + # receipts say "hand the seat", but the name resolved only through a per-instance + # `also_known_as`, which no seat had declared. The seat's conversation answers to it here. + if base in ("seat", "the seat", "my seat"): + seat_conv = self._seat_conversation() + if seat_conv: + return (f"'{to}' is your seat, and you are already in a conversation with it: " + f"\"{seat_conv}\". The hub is the wrong door and nothing was sent. Use say " + f"with the conversation id \"{seat_conv}\"; it reaches the seat directly " + f"and is not rate-limited the way asks are. This did not count against any " + f"limit. If what you want is for one of your files to be RUN, call " + f"request_run with the path: a message asking for a run is not a run " + f"request.") try: from sage.gateway import conversations as _conv for m in _conv.listing(self.memory_root): diff --git a/sage/gateway/reference_f1a.py b/sage/gateway/reference_f1a.py index 63c29d792..b42888b91 100644 --- a/sage/gateway/reference_f1a.py +++ b/sage/gateway/reference_f1a.py @@ -1187,6 +1187,22 @@ def _do_memory_read(self, intent: BeingIntent) -> ResultEnvelope: + " Nothing was read this time.]"), witness_id=self._witness( f"memory_read {p.name} (not in home; exists at {above}; copy in reach)")) + if self._reach_pairs(): + # AND WITH NO COPY, IT IS STILL NOT A READ TO OFFER (SAGE #530). Falling through + # to "To read it:" hands the member a path its grants do not cover, as one it + # can read. Say what is true and suggest no door; asking stays its decision. + # Only when the verdict said what the member holds: a verdict with no grants + # on it (hand-made, or a failed policy fetch) cannot tell, and keeps the old line. + return ResultEnvelope( + ok=True, + result=(f"[no such path in your home: a path without a leading / is read " + f"inside your home, and '{shown}' is not there. A file exists at " + f"'{above}', but it is outside what you hold, so a read of it " + f"would be refused, and nothing you hold has a copy of it. The " + f"paths you hold are in your beat under 'granted paths'. Nothing " + f"was read this time.]"), + witness_id=self._witness( + f"memory_read {p.name} (not in home; exists only outside reach at {above})")) if above is not None: # A RELATIVE PATH IS READ INSIDE THE HOME, AND THE BEING DID NOT KNOW THAT. # Measured 2026-10-20/21 on cbp-being: it read the gate source by its absolute diff --git a/sage/gateway/tests/test_hestia_dispatch.py b/sage/gateway/tests/test_hestia_dispatch.py index c85ceb37e..22948f3c4 100644 --- a/sage/gateway/tests/test_hestia_dispatch.py +++ b/sage/gateway/tests/test_hestia_dispatch.py @@ -2516,6 +2516,33 @@ def test_say_instead_also_names_request_run(): assert "request_run" in msg, msg +def test_peer_ask_to_seat_is_sent_to_the_seat_conversation(): + """SAGE #530 follow-up: every receipt calls the seat "the seat", and cbp-being sent + peer_ask to="seat" seven times, each answered only "not on the hub roster". The seat's own + conversation (named for the seat, the seat and the being, with a notify route) answers to + "seat"; with no identifiable seat conversation the name is not resolved.""" + import json as _json + from pathlib import Path + from sage.gateway import conversations as conv + published = [] + d, root = _disp(publish_fn=lambda to, body: published.append((to, body)) or "ptr") + assert d._say_instead("seat") is None, "no seat conversation: nothing to point at" + conv.create(Path(root), "sprout-claude", title="seat", participants=["sprout-claude", "sprout-being"], + writable_by=["sprout-claude", "sprout-being"]) + meta = conv.conv_dir(Path(root)) / "sprout-claude.meta.json" + m = _json.loads(meta.read_text()) + m["notify"] = {"sprout-claude": "claude-code"} + meta.write_text(_json.dumps(m)) + for name in ("seat", "Seat", "the seat"): + msg = d._say_instead(name) + assert msg and 'say with the conversation id "sprout-claude"' in msg, (name, msg) + assert "request_run" in msg and "did not count against any limit" in msg, msg + r = d(BeingIntent("peer_ask", {"to": "seat", "body": "are you there?"}), _ALLOW) + assert not r.ok and 'conversation id "sprout-claude"' in (r.error or ""), r + assert d.recent_asks() == [], "a redirected ask must not spend the per-peer cap" + assert published == [], "and must leave no forum file behind" + + def test_a_stale_transport_session_reconnects_once(): """The daemon's HTTP-level "Session not found" (a stale mcp-session-id) is a lost session like the JSON one: reconnect once and send again. 2026-09-23 20:04Z a request_run died on diff --git a/sage/gateway/tests/test_reference_f1a.py b/sage/gateway/tests/test_reference_f1a.py index 85d80779a..a684e63ee 100644 --- a/sage/gateway/tests/test_reference_f1a.py +++ b/sage/gateway/tests/test_reference_f1a.py @@ -89,6 +89,40 @@ def test_a_relative_miss_names_the_file_under_an_ancestor_of_the_home(): assert "DOES exist at" not in (r.result or ""), r.result +def test_a_relative_miss_never_offers_a_file_outside_reach_as_a_read(): + """SAGE #530, cbp-being 2026-10-24 (on a tree without #522): `hestia/hooks-gt/_shared/gate.py` + relative was answered with the seats' live tree, outside its grants, and "To read it:"; it + filed scope requests for that copy. #522 names a copy in reach; with no copy, the file is + named as existing and outside reach, never as a read. Exact grants do not count as reach.""" + ws = tempfile.mkdtemp(prefix="ref-f1a-ws-") + root = os.path.join(ws, "SAGE", "sage", "instances", "b") + os.makedirs(root) + for tree in (os.path.join(ws, "hestia"), os.path.join(ws, "governance", "hestia")): + os.makedirs(os.path.join(tree, "_shared")) + open(os.path.join(tree, "_shared", "gate.py"), "w").write("x = 1") + live = os.path.realpath(os.path.join(ws, "hestia", "_shared", "gate.py")) + snap = os.path.realpath(os.path.join(ws, "governance", "hestia", "_shared", "gate.py")) + gov = GatewayVerdict("allow", granted=(os.path.join(ws, "governance"),), + granted_reach=((os.path.join(ws, "governance"), True),)) + disp = ReferenceF1aDispatcher(memory_root=root) + r = disp(BeingIntent("memory_read", {"path": "hestia/_shared/gate.py"}), gov) + assert r.ok and snap in r.result and "outside your reach" in r.result, r.result + assert f"memory_read {{\"path\": \"{live}\"}}" not in r.result, r.result + + # only the live copy exists: it is named as existing and outside reach, never as a read + os.remove(snap) + r = disp(BeingIntent("memory_read", {"path": "hestia/_shared/gate.py"}), gov) + assert r.ok and f"exists at '{live}'" in r.result and "outside what you hold" in r.result, r.result + assert "To read it" not in r.result and "Nothing was read" in r.result, r.result + + # an exact grant on the governance dir does not reach a file under it + exact = GatewayVerdict("allow", granted=(os.path.join(ws, "governance"),), + granted_reach=((os.path.join(ws, "governance"), False),)) + open(snap, "w").write("x = 1") + r = disp(BeingIntent("memory_read", {"path": "hestia/_shared/gate.py"}), exact) + assert "outside what you hold" in r.result and "To read it" not in r.result, r.result + + def test_a_miss_with_several_same_named_files_lists_them_all_and_prefers_none(): """GPT review on #140: with notes/script.py AND scratch/script.py, the old answer listed both and then called the first in sort order the one the being "probably meant". Nothing From 730dbcfe10f22c163dc010b21e5f5e1a2ac81bdb Mon Sep 17 00:00:00 2001 From: Dennis Palatov Date: Fri, 23 Oct 2026 22:24:02 -0700 Subject: [PATCH 2/8] gateway: a member can ask what it holds (hestia://reach, hestia://reach/); the beat lists grants with modes (#530, #523) memory_read hestia://reach lists every grant the member holds now, with modes, channel, who granted it and the request it answered, and the society floor. memory_read hestia://reach/ says whether anything held covers that path, in which modes, and through which grant; where nothing does, it says so and names a copy in reach (same_path_in_reach). The floor answers only where no own grant reaches (#1381's effective_modes). Matching is the gate's (#1002: /** recursive, bare exact). A narrowed grant the gate did not admit on this verdict is listed with a note that it admits nothing until hestia #1345 part B. Both read hestia_scope_status and the verdict's resolved reach only: nothing at the path is read, no act is judged, no request is opened. A daemon that does not answer is reported as an outage. heartbeat: "granted paths" comes from grants_by_mode when present, with modes, so a narrowed grant no longer vanishes from the beat (#523); the block names the two reads. conformance step 5 counts a narrowed grant as a visible ruling. Tests: new test_reach_pointer (9); full gateway suite 1819 passed, 1 failed (the same test_activity_reports failure as on clean main). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH --- sage/gateway/conformance.py | 8 +- sage/gateway/heartbeat.py | 25 +++- sage/gateway/hestia_dispatch.py | 131 ++++++++++++++++++- sage/gateway/tests/test_reach_pointer.py | 155 +++++++++++++++++++++++ 4 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 sage/gateway/tests/test_reach_pointer.py diff --git a/sage/gateway/conformance.py b/sage/gateway/conformance.py index b65609240..72c6551b9 100644 --- a/sage/gateway/conformance.py +++ b/sage/gateway/conformance.py @@ -169,9 +169,13 @@ def run(member: str, instance: str, host_agent: str = "sage-conformance", # 5. ruling mid-session — an operator decision visible to the SAME session ---------- live = scope.get("live_grants") or []; standing = scope.get("standing_grants") or [] + # a ruling that narrowed a grant is still a ruling this session can see (#523): since hestia + # #1370 such a grant appears only in grants_by_mode. Step 4 keeps the two lists, since a + # narrowed grant admits nothing at a mode-blind gate and so cannot explain an allow. + by_mode = scope.get("grants_by_mode") or [] pend = [r for r in (scope.get("requests") or []) if r.get("status") == "pending"] - if live or standing: - out.append(_step("ruling_mid_session", "PASS", grants=live + standing)) + if live or standing or by_mode: + out.append(_step("ruling_mid_session", "PASS", grants=(live + standing) or by_mode)) else: out.append(_step("ruling_mid_session", "NOT_YET", pending_requests=[r.get("request_id") or r.get("id") for r in pend], needs="operator ruling (dashboard) — then this session sees the grant without reconnect")) diff --git a/sage/gateway/heartbeat.py b/sage/gateway/heartbeat.py index 4a3c2a351..4cacef260 100644 --- a/sage/gateway/heartbeat.py +++ b/sage/gateway/heartbeat.py @@ -1119,6 +1119,21 @@ def fleet_digest(hours: float, forum_dir: Path, repos: list[str]) -> str: return "\n\n".join(out) if out else "(nothing new in the window)" +def scope_grant_lines(st: dict) -> list: + """The beat's "granted paths", from hestia_scope_status. Spelled with the gate's own suffix: + `/**` reaches the subtree, bare is EXACT. + + WITH ITS MODES, AND NARROWED GRANTS KEPT (#523): since hestia #1370 a grant narrowed below + rwed appears only in `grants_by_mode`, so reading the two old lists made it vanish from the + beat while its request still read "granted". An older daemon has no such key, and its two + lists are read as before.""" + if st.get("grants_by_mode") is not None: + return [f"{g.get('path')}{'/**' if g.get('recursive') else ''} ({g.get('modes') or '-'})" + for g in st.get("grants_by_mode") or []] + return [f"{g.get('path')}{'/**' if g.get('recursive') else ''}" + for g in (st.get("live_grants") or []) + (st.get("standing_grants") or [])] + + def decided_requests(reqs): """The settled subset of hestia_scope_status `requests[]`, as (request_id, path, decision). hestia's `ScopeRequest::status` emits granted | refused | pending | expired, and its @@ -4112,9 +4127,7 @@ def main(argv=None) -> int: if disp is not None and hasattr(disp, "_call"): try: st = disp._call("hestia_scope_status", {"plugin_id": args.member}) - # spelled with the gate's own suffix: `/**` reaches the subtree, bare is EXACT - grants = [f"{g.get('path')}{'/**' if g.get('recursive') else ''}" - for g in (st.get("live_grants") or []) + (st.get("standing_grants") or [])] + grants = scope_grant_lines(st) reqs = [(r.get("request_id"), r.get("path"), r.get("decision") or r.get("status")) for r in (st.get("requests") or [])] who_ruled = {r.get("request_id"): r.get("decided_by") for r in (st.get("requests") or []) @@ -4149,7 +4162,11 @@ def main(argv=None) -> int: + (f". Their note: \"{str(why_ruled[i]).strip()[:400]}\"" if why_ruled.get(i) else "") for i, p_, d in new_decisions) + "\n" if new_decisions else "") - + "(live grants die when the daemon restarts; only standing grants persist)") + + "(live grants die when the daemon restarts; only standing grants persist)\n" + # SAGE #530: the being can ask, instead of acting to find out + + "(modes: r read, w write, e run, d delete. To see what you hold with who " + "granted it, or to ask whether one path is inside it: memory_read " + "{\"path\": \"hestia://reach\"} or {\"path\": \"hestia://reach/\"})") except Exception as e: scope = f"(scope status unavailable: {type(e).__name__})" # the being's own appeals and any ruling new since the last beat (see appeals_block) diff --git a/sage/gateway/hestia_dispatch.py b/sage/gateway/hestia_dispatch.py index b1be0a9bf..df4e85960 100644 --- a/sage/gateway/hestia_dispatch.py +++ b/sage/gateway/hestia_dispatch.py @@ -361,7 +361,9 @@ def __call__(self, intent: BeingIntent, verdict: GatewayVerdict) -> ResultEnvelo # before the local dispatcher tries to open it as a path (see _resolve_pointer). if intent.effector == "memory_read": try: - resolved = self._resolve_pointer(str(intent.args.get("path", ""))) + raw = str(intent.args.get("path", "")) + resolved = (self._reach_pointer(raw, verdict) if _is_reach_pointer(raw) + else self._resolve_pointer(raw)) except Exception as e: # a broken lookup must not eat the read resolved = (f"[could not resolve that pointer: {type(e).__name__}]" if "://" in str(intent.args.get("path", "")) else None) @@ -534,6 +536,127 @@ def _resolve_pointer(self, raw: str) -> Optional[str]: return (f"[a message of yours was not delivered: {frag or 'no detail recorded'} " f"(egress row {ident}). The row is retired; nothing of yours is queued behind it.]") + # -- what the member holds, on request (SAGE #530) ------------------------------- + # cbp-being, 2026-10-24, to dp: "What files or paths are in-scope for me to read? ... I want + # to narrow my request to something I can actually read." It held the file it wanted under a + # recursive grant the whole time, and had no way to ask except to act and read the refusal. + # The beat's "granted paths" line is a set of roots with no modes, beside every request ever + # filed; whether a path falls inside one is left to the being. These two reads answer it: + # hestia://reach every grant it holds now, with modes and provenance, and the floor + # hestia://reach/ does anything it holds cover this path, in which modes, via what + # Both read the law (hestia_scope_status, and the reach the gate itself resolved on this + # verdict); neither touches the path, judges an act, or opens a request. + def _reach_rows(self) -> tuple: + """(grants, floor) from hestia_scope_status. Each grant: {path, recursive, modes, + channel, granted_by, request_id, expires_at}; each floor entry {path, recursive, modes}. + `grants_by_mode` is preferred (hestia #1370: it alone carries narrowed grants); an older + daemon's two lists are read as all-modes.""" + st = self._call("hestia_scope_status", {"plugin_id": self.member}) or {} + if not isinstance(st, dict) or "_hestia_error" in st: + raise RuntimeError(str((st or {}).get("_hestia_error") if isinstance(st, dict) else st)) + grants = [] + if st.get("grants_by_mode") is not None: + for g in st.get("grants_by_mode") or []: + grants.append({**g, "modes": str(g.get("modes") or "")}) + else: + for chan in ("live", "standing"): + for g in st.get(f"{chan}_grants") or []: + grants.append({**g, "channel": chan, "modes": "rwed"}) + floor = [] + if st.get("floor_by_mode") is not None: + floor = [{**f, "modes": str(f.get("modes") or "")} for f in st.get("floor_by_mode") or []] + else: + floor = [{**f, "modes": "rwed"} for f in st.get("society_floor") or []] + return grants, floor + + @staticmethod + def _covers(row: dict, p: str) -> bool: + """The gate's own reading of reach (hestia #1002): a grant spelled with /** covers its + subtree, a bare one exactly itself. Both sides canonicalized.""" + root = os.path.realpath(os.path.expanduser(str(row.get("path") or ""))) + if not root or root == "/": + return False + return p == root or (bool(row.get("recursive")) and p.startswith(root.rstrip("/") + "/")) + + @staticmethod + def _modes_words(modes: str) -> str: + names = [w for c, w in (("r", "read"), ("w", "write"), ("e", "run"), ("d", "delete")) if c in modes] + return ", ".join(names) or "nothing" + + def _reach_pointer(self, raw: str, verdict) -> str: + """The answer to `hestia://reach` or `hestia://reach/` (see the block above).""" + try: + grants, floor = self._reach_rows() + except Exception as e: + return (f"[what you hold could not be read just now: hestia did not answer " + f"({type(e).__name__}). That is an outage, not a change in what you hold. " + f"Your beat's 'granted paths' line is the last list it gave.]") + # what the gate itself resolved as admitted on this verdict, when it says + admitted = tuple(_granted_reach_of(verdict)) if verdict is not None else () + + def gate_admits(row) -> Optional[bool]: + if not admitted: + return None + root = os.path.realpath(os.path.expanduser(str(row.get("path") or ""))) + return any(os.path.realpath(str(r)) == root and bool(rec) == bool(row.get("recursive")) + for r, rec, *_ in admitted) + + def spell(row) -> str: + return f"{row.get('path')}{'/**' if row.get('recursive') else ' (exactly this path)'}" + + def provenance(row) -> str: + bits = [str(row.get("channel") or "").strip() or None, + f"granted by {row['granted_by']}" if row.get("granted_by") else None, + f"request {row['request_id']}" if row.get("request_id") else None, + f"expires {row['expires_at']}" if row.get("expires_at") else None] + return "; ".join(b for b in bits if b) + + def narrowed_note(row) -> str: + # hestia #523/#1345: until the gate enforces modes, a narrowed grant admits nothing + return (" (recorded with these modes, but the gate does not admit it yet: a grant " + "narrowed to fewer than all four modes waits on hestia #1345)" + if gate_admits(row) is False and row.get("modes") != "rwed" else "") + + rest = raw.strip()[len("hestia://reach"):].lstrip("/") + if not rest: + lines = ["[what you hold right now, read from hestia at this moment]"] + lines += [f"- {spell(g)}: {self._modes_words(g['modes'])} ({provenance(g)}){narrowed_note(g)}" + for g in grants] or ["- no grants"] + if floor: + lines.append("The society floor, which every member holds where none of their own " + "grants reach the path:") + lines += [f"- {spell(f)}: {self._modes_words(f['modes'])}" for f in floor] + lines.append('To ask about one path: memory_read {"path": "hestia://reach/"}. ' + "This read looked at the law only: nothing was judged and no request was opened.]") + return "\n".join(lines) + + shown = rest if rest.startswith(("/", "~")) else "/" + rest + p = os.path.realpath(os.path.expanduser(shown)) + own = [g for g in grants if self._covers(g, p)] + lines = [f"[does what you hold reach {p}? read from hestia at this moment]"] + if own: + modes = "".join(sorted(set("".join(g["modes"] for g in own)), key="rwed".index)) + lines.append(f"yes: {self._modes_words(modes)}.") + lines += [f"- through {spell(g)}: {self._modes_words(g['modes'])} ({provenance(g)})" + f"{narrowed_note(g)}" for g in own] + if not os.path.lexists(p): + lines.append("Nothing exists at that path yet.") + else: + fl = [f for f in floor if self._covers(f, p)] + if fl: + modes = "".join(sorted(set("".join(f["modes"] for f in fl)), key="rwed".index)) + lines.append(f"yes, through the society floor: {self._modes_words(modes)}.") + lines += [f"- floor entry {spell(f)}" for f in fl] + else: + lines.append("no: nothing you hold reaches this path, so an act on it would be refused.") + copies = same_path_in_reach(p, admitted or tuple( + (g.get("path"), bool(g.get("recursive"))) for g in grants)) + if copies: + lines.append(copy_alternative(p, copies)) + lines.append("This read looked at the law only: nothing was judged, nothing was read at " + "that path, and no request was opened.]") + return "\n".join(lines) + def _escalation_pointer(self, ident: str) -> str: """What a `review_request` invitation is actually about (SAGE #109). @@ -4541,6 +4664,12 @@ def _release_stage_lock(lock_path: str, fd: int) -> None: os.close(fd) +def _is_reach_pointer(raw: str) -> bool: + """`hestia://reach` or `hestia://reach/` (SAGE #530).""" + s = (raw or "").strip() + return s == "hestia://reach" or s.startswith("hestia://reach/") + + def _granted_reach_of(verdict) -> tuple: """((root, recursive), ...) from a verdict. A verdict built without reach (older client, hand-made in tests) keeps the pre-#1002 prefix reading of its bare `granted` roots.""" diff --git a/sage/gateway/tests/test_reach_pointer.py b/sage/gateway/tests/test_reach_pointer.py new file mode 100644 index 000000000..50c957b2e --- /dev/null +++ b/sage/gateway/tests/test_reach_pointer.py @@ -0,0 +1,155 @@ +"""SAGE #530: a member can ask what it holds (hestia://reach) and whether one path falls inside it +(hestia://reach/), without acting on the path and reading the refusal.""" +import os +import sys +import tempfile + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))) +from sage.gateway.being_gate_client import BeingIntent, GatewayVerdict # noqa: E402 +from sage.gateway.hestia_dispatch import HestiaF1aDispatcher # noqa: E402 +from sage.gateway.tests.test_hestia_dispatch import FakeMcp # noqa: E402 + + +def _world(): + """A workspace with a live tree and a governance snapshot holding the same file.""" + ws = os.path.realpath(tempfile.mkdtemp(prefix="reach-ws-")) + home = os.path.join(ws, "SAGE", "instances", "b") + os.makedirs(home) + for tree in ("hestia", os.path.join("governance", "hestia")): + os.makedirs(os.path.join(ws, tree, "_shared")) + open(os.path.join(ws, tree, "_shared", "gate.py"), "w").write("x = 1") + os.makedirs(os.path.join(ws, "escalations")) + return ws, home + + +def _status(ws, home, by_mode=True, floor=None): + gov = {"path": os.path.join(ws, "governance"), "recursive": True, "granted_by": "dp", + "request_id": "scope-gov", "expires_at": None} + home_g = {"path": home, "recursive": True, "granted_by": "operator", "request_id": None, + "expires_at": None} + esc = {"path": os.path.join(ws, "escalations"), "recursive": True, "granted_by": "dp", + "request_id": "scope-esc", "expires_at": None} + if by_mode: + st = {"grants_by_mode": [{**home_g, "channel": "standing", "modes": "rwe"}, + {**gov, "channel": "standing", "modes": "rwed"}, + {**esc, "channel": "standing", "modes": "r"}], + "standing_grants": [gov], "live_grants": []} + else: + st = {"standing_grants": [home_g, gov], "live_grants": []} + if floor is not None: + st["floor_by_mode"] = floor + return st + + +def _disp(status): + class Fake(FakeMcp): + def call(self, name, args): + if name == "hestia_scope_status": + FakeMcp.calls.append((name, args)) + if status is None: + return {"result": {"structuredContent": {"_hestia_error": "down"}}} + return {"result": {"structuredContent": status}} + return super().call(name, args) + FakeMcp.calls, FakeMcp.fail = [], {} + root = tempfile.mkdtemp(prefix="hd-") + return HestiaF1aDispatcher("cbp-being", root, mcp_factory=lambda ep, pid: Fake(ep, pid)) + + +def _verdict(ws, home): + """What the gate resolved as admitted: the narrowed (r) escalations grant is absent, as on a + mode-blind gate (hestia #523).""" + reach = ((home, True), (os.path.join(ws, "governance"), True)) + return GatewayVerdict("allow", granted=tuple(r for r, _ in reach), granted_reach=reach) + + +def _read(d, path, verdict): + env = d(BeingIntent("memory_read", {"path": path}), verdict) + assert env.ok, env + return env.result + + +def test_the_list_names_every_grant_with_modes_provenance_and_the_floor(): + ws, home = _world() + floor = [{"path": os.path.join(ws, "governance", "hestia"), "recursive": True, "modes": "r"}] + d = _disp(_status(ws, home, floor=floor)) + out = _read(d, "hestia://reach", _verdict(ws, home)) + assert f"- {ws}/governance/**: read, write, run, delete (standing; granted by dp; request scope-gov)" in out, out + assert f"- {home}/**: read, write, run (" in out, out + # the narrowed grant is listed, and said plainly not to admit anything yet + assert f"{ws}/escalations/**: read (" in out and "does not admit it yet" in out, out + assert "society floor" in out and f"- {ws}/governance/hestia/**: read" in out, out + assert 'hestia://reach/' in out and "no request was opened" in out, out + + +def test_a_path_under_a_recursive_grant_is_reached_and_names_the_grant(): + ws, home = _world() + d = _disp(_status(ws, home)) + out = _read(d, f"hestia://reach{ws}/governance/hestia/_shared/gate.py", _verdict(ws, home)) + assert "yes: read, write, run, delete." in out, out + assert f"through {ws}/governance/**" in out and "request scope-gov" in out, out + assert "x = 1" not in out, "the answer reads the law, never the file" + + +def test_a_path_outside_every_grant_says_no_and_names_the_copy_in_reach(): + """The 2026-10-24 case: the live tree is not held, the snapshot copy is.""" + ws, home = _world() + d = _disp(_status(ws, home)) + out = _read(d, f"hestia://reach{ws}/hestia/_shared/gate.py", _verdict(ws, home)) + assert "no: nothing you hold reaches this path" in out, out + assert f"{ws}/governance/hestia/_shared/gate.py" in out, out + assert not [n for n, _ in FakeMcp.calls if n in ("hestia_request_scope", "hestia_member_notify")], \ + "asking about reach must open nothing" + + +def test_an_exact_grant_reaches_itself_not_its_subtree(): + ws, home = _world() + st = {"grants_by_mode": [{"path": os.path.join(ws, "hestia"), "recursive": False, "modes": "rwed", + "channel": "live", "granted_by": "peer:x"}]} + d = _disp(st) + v = GatewayVerdict("allow", granted=(os.path.join(ws, "hestia"),), + granted_reach=((os.path.join(ws, "hestia"), False),)) + assert "yes:" in _read(d, f"hestia://reach{ws}/hestia", v) + assert "no: nothing you hold" in _read(d, f"hestia://reach{ws}/hestia/_shared/gate.py", v) + + +def test_the_floor_answers_only_where_no_own_grant_reaches(): + ws, home = _world() + floor = [{"path": os.path.join(ws, "hestia"), "recursive": True, "modes": "r"}] + d = _disp(_status(ws, home, floor=floor)) + out = _read(d, f"hestia://reach{ws}/hestia/_shared/gate.py", _verdict(ws, home)) + assert "yes, through the society floor: read." in out, out + out = _read(d, f"hestia://reach{ws}/governance/hestia/_shared/gate.py", _verdict(ws, home)) + assert "yes: read, write, run, delete." in out and "floor" not in out, out + + +def test_an_older_daemon_without_modes_reads_every_grant_as_all_modes(): + ws, home = _world() + d = _disp(_status(ws, home, by_mode=False)) + out = _read(d, "hestia://reach", _verdict(ws, home)) + assert f"{ws}/governance/**: read, write, run, delete" in out, out + assert "does not admit" not in out, out + + +def test_a_daemon_that_does_not_answer_is_an_outage_not_a_change(): + ws, home = _world() + d = _disp(None) + out = _read(d, "hestia://reach", _verdict(ws, home)) + assert "outage, not a change in what you hold" in out, out + + +def test_a_missing_leading_slash_is_read_as_absolute(): + ws, home = _world() + d = _disp(_status(ws, home)) + out = _read(d, f"hestia://reach/{ws.lstrip('/')}/governance/hestia/_shared/gate.py", _verdict(ws, home)) + assert "yes: read, write, run, delete." in out, out + + +def test_the_beat_lists_grants_with_modes_and_keeps_narrowed_ones(): + """#523: a grant narrowed below rwed is only in grants_by_mode; the beat kept it out.""" + from sage.gateway.heartbeat import scope_grant_lines + ws, home = _world() + lines = scope_grant_lines(_status(ws, home)) + assert f"{ws}/escalations/** (r)" in lines and f"{ws}/governance/** (rwed)" in lines, lines + assert f"{home}/** (rwe)" in lines, lines + old = scope_grant_lines(_status(ws, home, by_mode=False)) + assert f"{ws}/governance/**" in old and not any("(" in x for x in old), old From efaa052d8a303671c9fd660d56541663e7ff9af9 Mon Sep 17 00:00:00 2001 From: Dennis Palatov Date: Fri, 23 Oct 2026 23:01:53 -0700 Subject: [PATCH 3/8] gateway: hestia://reach answers what the gate admits now, apart from what is recorded (Nova HOLD on #532) The per-path answer said "yes: " from recorded grants_by_mode, including a narrowed grant the mode-blind gate does not admit, and the floor branch said yes for an entry withheld from this member. A member asks this to know which acts will go through, so: - The first line is the EFFECTIVE answer: "now: yes, " is the union of the member's grants the gate itself admitted on this verdict and the floor entries this member's gate is served (society_floor = all modes, society_floor_read = read; #1381). Otherwise "now: no" (something recorded reaches it but is not admitted, or nothing does), or "now: unconfirmed" when the verdict carries no reach. - Every recorded grant and floor entry reaching the path is listed under it, labelled: "the gate admits it now", "recorded, but the gate does NOT admit it now" (narrowed: until hestia #1345 part B), "served to your gate now: ", or "NOT served to you now ()". The list form carries the same labels. - A daemon without the #1381 floor keys serves its whole society_floor as all modes, as its gate does. - The beat's "granted paths" marks a narrowed grant "narrowed: recorded, may not be admitted yet, see hestia://reach" instead of a bare mode letter. New tests: a path under a narrowed, unadmitted grant is "now: no"; a withheld floor entry is "now: no" with its reason; a verdict without reach is "unconfirmed". test_reach_pointer 12; full gateway suite 1822 passed, 1 failed (the test_activity_reports failure also on main). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH --- sage/gateway/heartbeat.py | 10 +- sage/gateway/hestia_dispatch.py | 117 ++++++++++++++++------- sage/gateway/tests/test_reach_pointer.py | 105 ++++++++++++++------ 3 files changed, 166 insertions(+), 66 deletions(-) diff --git a/sage/gateway/heartbeat.py b/sage/gateway/heartbeat.py index 4cacef260..3b93e5697 100644 --- a/sage/gateway/heartbeat.py +++ b/sage/gateway/heartbeat.py @@ -1126,9 +1126,15 @@ def scope_grant_lines(st: dict) -> list: WITH ITS MODES, AND NARROWED GRANTS KEPT (#523): since hestia #1370 a grant narrowed below rwed appears only in `grants_by_mode`, so reading the two old lists made it vanish from the beat while its request still read "granted". An older daemon has no such key, and its two - lists are read as before.""" + lists are read as before. + + A NARROWED GRANT IS RECORDED, NOT NECESSARILY ADMITTED (Nova on #532): a mode-blind gate + admits nothing for it (hestia #1345 part B). The line says so in those words rather than + guess which gate is installed; hestia://reach/ gives the effective answer.""" if st.get("grants_by_mode") is not None: - return [f"{g.get('path')}{'/**' if g.get('recursive') else ''} ({g.get('modes') or '-'})" + return [f"{g.get('path')}{'/**' if g.get('recursive') else ''} ({g.get('modes') or '-'}" + + ("" if (g.get("modes") or "") == "rwed" else + "; narrowed: recorded, may not be admitted yet, see hestia://reach") + ")" for g in st.get("grants_by_mode") or []] return [f"{g.get('path')}{'/**' if g.get('recursive') else ''}" for g in (st.get("live_grants") or []) + (st.get("standing_grants") or [])] diff --git a/sage/gateway/hestia_dispatch.py b/sage/gateway/hestia_dispatch.py index df4e85960..57bbd68df 100644 --- a/sage/gateway/hestia_dispatch.py +++ b/sage/gateway/hestia_dispatch.py @@ -547,10 +547,17 @@ def _resolve_pointer(self, raw: str) -> Optional[str]: # Both read the law (hestia_scope_status, and the reach the gate itself resolved on this # verdict); neither touches the path, judges an act, or opens a request. def _reach_rows(self) -> tuple: - """(grants, floor) from hestia_scope_status. Each grant: {path, recursive, modes, - channel, granted_by, request_id, expires_at}; each floor entry {path, recursive, modes}. + """(grants, floor) from hestia_scope_status. + + Each grant: {path, recursive, modes, channel, granted_by, request_id, expires_at}. `grants_by_mode` is preferred (hestia #1370: it alone carries narrowed grants); an older - daemon's two lists are read as all-modes.""" + daemon's two lists are read as all-modes. + + Each floor entry: {path, recursive, modes, served, why}. `modes` is what the floor + RECORDS; `served` is what this member's gate is actually handed: "rwed" for an entry in + `society_floor`, "r" for one in `society_floor_read` (#1381, read acts only), "" for one + in `society_floor_withheld` (with its `why`). A daemon without those keys serves every + `society_floor` entry as all-modes, as its gate does.""" st = self._call("hestia_scope_status", {"plugin_id": self.member}) or {} if not isinstance(st, dict) or "_hestia_error" in st: raise RuntimeError(str((st or {}).get("_hestia_error") if isinstance(st, dict) else st)) @@ -562,11 +569,20 @@ def _reach_rows(self) -> tuple: for chan in ("live", "standing"): for g in st.get(f"{chan}_grants") or []: grants.append({**g, "channel": chan, "modes": "rwed"}) + + def key(f): + return (os.path.realpath(os.path.expanduser(str(f.get("path") or ""))), bool(f.get("recursive"))) + served = {key(f): "rwed" for f in st.get("society_floor") or []} + served.update({key(f): "r" for f in st.get("society_floor_read") or []}) + withheld = {os.path.realpath(os.path.expanduser(str(w.get("path") or ""))): str(w.get("why") or "") + for w in st.get("society_floor_withheld") or []} + recorded = (st.get("floor_by_mode") if st.get("floor_by_mode") is not None + else [{**f, "modes": "rwed"} for f in st.get("society_floor") or []]) floor = [] - if st.get("floor_by_mode") is not None: - floor = [{**f, "modes": str(f.get("modes") or "")} for f in st.get("floor_by_mode") or []] - else: - floor = [{**f, "modes": "rwed"} for f in st.get("society_floor") or []] + for f in recorded or []: + k = key(f) + floor.append({**f, "modes": str(f.get("modes") or ""), "served": served.get(k, ""), + "why": withheld.get(k[0], "" if k in served else "not served to you")}) return grants, floor @staticmethod @@ -583,8 +599,20 @@ def _modes_words(modes: str) -> str: names = [w for c, w in (("r", "read"), ("w", "write"), ("e", "run"), ("d", "delete")) if c in modes] return ", ".join(names) or "nothing" + @staticmethod + def _union(modes) -> str: + return "".join(sorted(set("".join(modes)), key="rwed".index)) + def _reach_pointer(self, raw: str, verdict) -> str: - """The answer to `hestia://reach` or `hestia://reach/` (see the block above).""" + """The answer to `hestia://reach` or `hestia://reach/` (see the block above). + + WHAT THE GATE ADMITS NOW IS NOT WHAT IS RECORDED (Nova, HOLD on #532). A member asks this + to know which acts will go through, so the first line is always the EFFECTIVE answer: + the member's grants the gate itself resolved as admitted on this verdict, plus the floor + entries this member's gate is served. A recorded grant the gate does not admit (a + narrowed grant on a mode-blind gate, hestia #523/#1345) and a floor entry withheld from + this member are listed under it, labelled as such, and never counted as "yes". Where + the verdict says nothing about reach, admission is reported as unconfirmed.""" try: grants, floor = self._reach_rows() except Exception as e: @@ -611,21 +639,30 @@ def provenance(row) -> str: f"expires {row['expires_at']}" if row.get("expires_at") else None] return "; ".join(b for b in bits if b) - def narrowed_note(row) -> str: - # hestia #523/#1345: until the gate enforces modes, a narrowed grant admits nothing - return (" (recorded with these modes, but the gate does not admit it yet: a grant " - "narrowed to fewer than all four modes waits on hestia #1345)" - if gate_admits(row) is False and row.get("modes") != "rwed" else "") + def grant_status(row) -> str: + a = gate_admits(row) + if a is True: + return "the gate admits it now" + if a is None: + return "whether the gate admits it right now could not be confirmed" + if row.get("modes") != "rwed": + return ("recorded, but the gate does NOT admit it now: a grant narrowed to fewer " + "than all four modes admits nothing until hestia #1345 part B") + return "recorded, but the gate does NOT admit it now" + + def floor_status(f) -> str: + if f["served"]: + return f"served to your gate now: {self._modes_words(f['served'])}" + return f"NOT served to you now ({f['why'] or 'withheld'})" rest = raw.strip()[len("hestia://reach"):].lstrip("/") if not rest: - lines = ["[what you hold right now, read from hestia at this moment]"] - lines += [f"- {spell(g)}: {self._modes_words(g['modes'])} ({provenance(g)}){narrowed_note(g)}" + lines = ["[what you hold, read from hestia at this moment]"] + lines += [f"- {spell(g)}: {self._modes_words(g['modes'])} ({provenance(g)}). {grant_status(g)}." for g in grants] or ["- no grants"] if floor: - lines.append("The society floor, which every member holds where none of their own " - "grants reach the path:") - lines += [f"- {spell(f)}: {self._modes_words(f['modes'])}" for f in floor] + lines.append("The society floor (every member's, where none of their own grants reach):") + lines += [f"- {spell(f)}: {self._modes_words(f['modes'])}. {floor_status(f)}." for f in floor] lines.append('To ask about one path: memory_read {"path": "hestia://reach/"}. ' "This read looked at the law only: nothing was judged and no request was opened.]") return "\n".join(lines) @@ -633,26 +670,34 @@ def narrowed_note(row) -> str: shown = rest if rest.startswith(("/", "~")) else "/" + rest p = os.path.realpath(os.path.expanduser(shown)) own = [g for g in grants if self._covers(g, p)] + fl = [f for f in floor if self._covers(f, p)] + own_now = [g for g in own if gate_admits(g) is True] + unknown = [g for g in own if gate_admits(g) is None] + floor_now = [f for f in fl if f["served"]] + now = self._union([g["modes"] for g in own_now] + [f["served"] for f in floor_now]) lines = [f"[does what you hold reach {p}? read from hestia at this moment]"] - if own: - modes = "".join(sorted(set("".join(g["modes"] for g in own)), key="rwed".index)) - lines.append(f"yes: {self._modes_words(modes)}.") - lines += [f"- through {spell(g)}: {self._modes_words(g['modes'])} ({provenance(g)})" - f"{narrowed_note(g)}" for g in own] - if not os.path.lexists(p): - lines.append("Nothing exists at that path yet.") + if now: + lines.append(f"now: yes, {self._modes_words(now)}.") + elif unknown: + lines.append(f"now: unconfirmed. You hold a grant here recording " + f"{self._modes_words(self._union(g['modes'] for g in unknown))}, but whether " + f"the gate admits it right now could not be confirmed.") + elif own or fl: + lines.append("now: no. Something recorded reaches this path, but the gate does not admit " + "it at the moment (see below), so an act on it would be refused.") else: - fl = [f for f in floor if self._covers(f, p)] - if fl: - modes = "".join(sorted(set("".join(f["modes"] for f in fl)), key="rwed".index)) - lines.append(f"yes, through the society floor: {self._modes_words(modes)}.") - lines += [f"- floor entry {spell(f)}" for f in fl] - else: - lines.append("no: nothing you hold reaches this path, so an act on it would be refused.") - copies = same_path_in_reach(p, admitted or tuple( - (g.get("path"), bool(g.get("recursive"))) for g in grants)) - if copies: - lines.append(copy_alternative(p, copies)) + lines.append("now: no. Nothing you hold reaches this path, so an act on it would be refused.") + for g in own: + lines.append(f"- your grant {spell(g)}: {self._modes_words(g['modes'])} ({provenance(g)}). " + f"{grant_status(g)}.") + for f in fl: + lines.append(f"- floor entry {spell(f)}: {self._modes_words(f['modes'])}. {floor_status(f)}.") + if now and not os.path.lexists(p): + lines.append("Nothing exists at that path yet.") + if not now: + copies = same_path_in_reach(p, admitted) + if copies: + lines.append(copy_alternative(p, copies)) lines.append("This read looked at the law only: nothing was judged, nothing was read at " "that path, and no request was opened.]") return "\n".join(lines) diff --git a/sage/gateway/tests/test_reach_pointer.py b/sage/gateway/tests/test_reach_pointer.py index 50c957b2e..61d2b279c 100644 --- a/sage/gateway/tests/test_reach_pointer.py +++ b/sage/gateway/tests/test_reach_pointer.py @@ -22,7 +22,20 @@ def _world(): return ws, home -def _status(ws, home, by_mode=True, floor=None): +def _status(ws, home, by_mode=True, floor=None, served=None, served_read=None, withheld=None): + """`floor` is floor_by_mode (recorded); served/served_read/withheld are #1381's per-member + society_floor / society_floor_read / society_floor_withheld.""" + st = _status_grants(ws, home, by_mode) + if floor is not None: + st["floor_by_mode"] = floor + for k, v in (("society_floor", served), ("society_floor_read", served_read), + ("society_floor_withheld", withheld)): + if v is not None: + st[k] = v + return st + + +def _status_grants(ws, home, by_mode=True): gov = {"path": os.path.join(ws, "governance"), "recursive": True, "granted_by": "dp", "request_id": "scope-gov", "expires_at": None} home_g = {"path": home, "recursive": True, "granted_by": "operator", "request_id": None, @@ -36,8 +49,6 @@ def _status(ws, home, by_mode=True, floor=None): "standing_grants": [gov], "live_grants": []} else: st = {"standing_grants": [home_g, gov], "live_grants": []} - if floor is not None: - st["floor_by_mode"] = floor return st @@ -70,32 +81,58 @@ def _read(d, path, verdict): def test_the_list_names_every_grant_with_modes_provenance_and_the_floor(): ws, home = _world() - floor = [{"path": os.path.join(ws, "governance", "hestia"), "recursive": True, "modes": "r"}] - d = _disp(_status(ws, home, floor=floor)) + snap = {"path": os.path.join(ws, "governance", "hestia"), "recursive": True} + d = _disp(_status(ws, home, floor=[{**snap, "modes": "r"}], served=[], served_read=[snap], withheld=[])) out = _read(d, "hestia://reach", _verdict(ws, home)) - assert f"- {ws}/governance/**: read, write, run, delete (standing; granted by dp; request scope-gov)" in out, out - assert f"- {home}/**: read, write, run (" in out, out - # the narrowed grant is listed, and said plainly not to admit anything yet - assert f"{ws}/escalations/**: read (" in out and "does not admit it yet" in out, out - assert "society floor" in out and f"- {ws}/governance/hestia/**: read" in out, out + assert (f"- {ws}/governance/**: read, write, run, delete (standing; granted by dp; request " + f"scope-gov). the gate admits it now.") in out, out + # the narrowed grant is listed, and said plainly not to be admitted now + assert f"{ws}/escalations/**: read (" in out and "does NOT admit it now" in out, out + assert f"- {ws}/governance/hestia/**: read. served to your gate now: read." in out, out assert 'hestia://reach/' in out and "no request was opened" in out, out -def test_a_path_under_a_recursive_grant_is_reached_and_names_the_grant(): +def test_a_path_under_an_admitted_grant_is_reached_now_and_names_the_grant(): ws, home = _world() d = _disp(_status(ws, home)) out = _read(d, f"hestia://reach{ws}/governance/hestia/_shared/gate.py", _verdict(ws, home)) - assert "yes: read, write, run, delete." in out, out - assert f"through {ws}/governance/**" in out and "request scope-gov" in out, out + assert out.splitlines()[1] == "now: yes, read, write, run, delete.", out + assert f"your grant {ws}/governance/**" in out and "request scope-gov" in out, out assert "x = 1" not in out, "the answer reads the law, never the file" +def test_a_path_under_a_narrowed_grant_the_gate_does_not_admit_is_no_now(): + """Nova, HOLD on #532: a recorded r grant the mode-blind gate does not admit must not read + as yes. It is listed, labelled, and the effective answer is no.""" + ws, home = _world() + d = _disp(_status(ws, home)) + out = _read(d, f"hestia://reach{ws}/escalations/x.md", _verdict(ws, home)) + assert out.splitlines()[1].startswith("now: no. Something recorded reaches this path"), out + assert "yes" not in out.splitlines()[1], out + assert f"your grant {ws}/escalations/**: read" in out and "does NOT admit it now" in out, out + assert "hestia #1345 part B" in out, out + + +def test_a_floor_entry_withheld_from_this_member_is_no_now(): + """Nova, HOLD on #532: an entry in society_floor_withheld is recorded in floor_by_mode but not + served to this member's gate; it must not read as yes.""" + ws, home = _world() + live = {"path": os.path.join(ws, "hestia"), "recursive": True} + why = "overlaps your narrowed grant; the floor does not override it" + d = _disp(_status(ws, home, floor=[{**live, "modes": "r"}], served=[], served_read=[], + withheld=[{"path": live["path"], "why": why}])) + out = _read(d, f"hestia://reach{ws}/hestia/_shared/gate.py", _verdict(ws, home)) + assert out.splitlines()[1].startswith("now: no."), out + assert f"floor entry {ws}/hestia/**: read. NOT served to you now ({why})." in out, out + + def test_a_path_outside_every_grant_says_no_and_names_the_copy_in_reach(): """The 2026-10-24 case: the live tree is not held, the snapshot copy is.""" ws, home = _world() d = _disp(_status(ws, home)) out = _read(d, f"hestia://reach{ws}/hestia/_shared/gate.py", _verdict(ws, home)) - assert "no: nothing you hold reaches this path" in out, out + assert out.splitlines()[1] == ("now: no. Nothing you hold reaches this path, so an act on it " + "would be refused."), out assert f"{ws}/governance/hestia/_shared/gate.py" in out, out assert not [n for n, _ in FakeMcp.calls if n in ("hestia_request_scope", "hestia_member_notify")], \ "asking about reach must open nothing" @@ -108,26 +145,38 @@ def test_an_exact_grant_reaches_itself_not_its_subtree(): d = _disp(st) v = GatewayVerdict("allow", granted=(os.path.join(ws, "hestia"),), granted_reach=((os.path.join(ws, "hestia"), False),)) - assert "yes:" in _read(d, f"hestia://reach{ws}/hestia", v) - assert "no: nothing you hold" in _read(d, f"hestia://reach{ws}/hestia/_shared/gate.py", v) + assert "now: yes" in _read(d, f"hestia://reach{ws}/hestia", v) + assert "now: no. Nothing you hold" in _read(d, f"hestia://reach{ws}/hestia/_shared/gate.py", v) -def test_the_floor_answers_only_where_no_own_grant_reaches(): +def test_a_served_floor_entry_answers_where_no_own_grant_reaches(): ws, home = _world() - floor = [{"path": os.path.join(ws, "hestia"), "recursive": True, "modes": "r"}] - d = _disp(_status(ws, home, floor=floor)) + live = {"path": os.path.join(ws, "hestia"), "recursive": True} + d = _disp(_status(ws, home, floor=[{**live, "modes": "r"}], served=[], served_read=[live], withheld=[])) out = _read(d, f"hestia://reach{ws}/hestia/_shared/gate.py", _verdict(ws, home)) - assert "yes, through the society floor: read." in out, out + assert out.splitlines()[1] == "now: yes, read.", out + assert "served to your gate now: read" in out, out out = _read(d, f"hestia://reach{ws}/governance/hestia/_shared/gate.py", _verdict(ws, home)) - assert "yes: read, write, run, delete." in out and "floor" not in out, out + assert "now: yes, read, write, run, delete." in out and "floor" not in out, out -def test_an_older_daemon_without_modes_reads_every_grant_as_all_modes(): +def test_an_older_daemon_serves_its_whole_floor_and_reads_every_grant_as_all_modes(): ws, home = _world() - d = _disp(_status(ws, home, by_mode=False)) + st = _status(ws, home, by_mode=False) + st["society_floor"] = [{"path": os.path.join(ws, "hestia"), "recursive": True}] + d = _disp(st) out = _read(d, "hestia://reach", _verdict(ws, home)) assert f"{ws}/governance/**: read, write, run, delete" in out, out - assert "does not admit" not in out, out + assert "does NOT admit" not in out, out + assert "served to your gate now: read, write, run, delete" in out, out + + +def test_a_verdict_without_reach_reports_admission_as_unconfirmed(): + ws, home = _world() + d = _disp(_status(ws, home)) + out = _read(d, f"hestia://reach{ws}/governance/x", GatewayVerdict("allow")) + assert out.splitlines()[1].startswith("now: unconfirmed."), out + assert "could not be confirmed" in out and "now: yes" not in out, out def test_a_daemon_that_does_not_answer_is_an_outage_not_a_change(): @@ -141,15 +190,15 @@ def test_a_missing_leading_slash_is_read_as_absolute(): ws, home = _world() d = _disp(_status(ws, home)) out = _read(d, f"hestia://reach/{ws.lstrip('/')}/governance/hestia/_shared/gate.py", _verdict(ws, home)) - assert "yes: read, write, run, delete." in out, out - + assert "now: yes, read, write, run, delete." in out, out def test_the_beat_lists_grants_with_modes_and_keeps_narrowed_ones(): """#523: a grant narrowed below rwed is only in grants_by_mode; the beat kept it out.""" from sage.gateway.heartbeat import scope_grant_lines ws, home = _world() lines = scope_grant_lines(_status(ws, home)) - assert f"{ws}/escalations/** (r)" in lines and f"{ws}/governance/** (rwed)" in lines, lines - assert f"{home}/** (rwe)" in lines, lines + narrowed = "; narrowed: recorded, may not be admitted yet, see hestia://reach)" + assert f"{ws}/escalations/** (r{narrowed}" in lines and f"{ws}/governance/** (rwed)" in lines, lines + assert f"{home}/** (rwe{narrowed}" in lines, lines old = scope_grant_lines(_status(ws, home, by_mode=False)) assert f"{ws}/governance/**" in old and not any("(" in x for x in old), old From 7b39a140c730009adcd36158ce4af92154b06ed3 Mon Sep 17 00:00:00 2001 From: Dennis Palatov Date: Fri, 23 Oct 2026 23:32:54 -0700 Subject: [PATCH 4/8] gateway: the dispatcher confines by grant modes (hestia #1345 part B, SAGE side) _granted_reach takes (root, recursive, modes) from a core that exports scope_roots_with_modes (hestia #1383), narrowed grants included; an older core keeps the two-element reading. The dispatcher's own confinement (reference_f1a._safe_path) then requires r to read and w to write, naming the narrowed mode when that is why it refuses; the home is always all four. _reach_pairs (copies in reach, the out-of-reach hint) counts only grants with r; search and request_run's data files require r. A run already mounts each grant by its modes (#521). Inert until hestia #1383 is applied: no deployed core exports scope_roots_with_modes, so every reach item stays two-element and nothing changes. Tests: test_grant_modes_dispatch 6; full gateway suite 1828 passed, 1 failed (test_activity_reports, also on main). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH --- sage/gateway/being_gate_client.py | 8 ++ sage/gateway/hestia_dispatch.py | 12 ++- sage/gateway/reference_f1a.py | 29 +++++-- .../tests/test_grant_modes_dispatch.py | 80 +++++++++++++++++++ 4 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 sage/gateway/tests/test_grant_modes_dispatch.py diff --git a/sage/gateway/being_gate_client.py b/sage/gateway/being_gate_client.py index 2e3cae0a8..f0d95f18e 100644 --- a/sage/gateway/being_gate_client.py +++ b/sage/gateway/being_gate_client.py @@ -3240,6 +3240,14 @@ def _granted_reach(core, policy, workspace: str, granted_paths=()) -> tuple: if policy is None: return () try: + # A CORE THAT ENFORCES MODES (hestia #1345 part B) says which grants it admits and in + # which modes, narrowed ones included, as (root, recursive, modes). Taken whole, so the + # dispatcher confines by exactly the modes the gate judged by. + with_modes = getattr(core, "scope_roots_with_modes", None) + if with_modes is not None: + scopes = list(getattr(policy, "scope", ()) or ()) + return (tuple((str(r), bool(rec), str(md)) for r, rec, md in with_modes(policy, workspace)) + + tuple((r, True) for r in _repo_name_roots(core, scopes, workspace, granted_paths))) reach = getattr(core, "_scope_roots_with_reach", None) if reach is not None: scopes = list(getattr(policy, "scope", ()) or ()) diff --git a/sage/gateway/hestia_dispatch.py b/sage/gateway/hestia_dispatch.py index 57bbd68df..830b36e76 100644 --- a/sage/gateway/hestia_dispatch.py +++ b/sage/gateway/hestia_dispatch.py @@ -2996,7 +2996,7 @@ def _do_reach_search(self, intent: BeingIntent) -> ResultEnvelope: reach = _granted_reach_of(getattr(self, "_verdict", None)) inside = target is not None and any( target == os.path.realpath(r) or (rec and target.startswith(os.path.realpath(r) + os.sep)) - for r, rec, *_ in reach) + for r, rec, *md in reach if _mode_has(md, "r")) if not inside: return ResultEnvelope(ok=False, error=( f"search refused: {target or 'that path'} is outside the reach you hold. " @@ -3290,8 +3290,8 @@ def _do_run(self, intent: BeingIntent) -> ResultEnvelope: f"record is written, and a run must not outlive its beat. Ask again next beat; " f"the file is unchanged and nothing of yours was touched.")) root = os.path.realpath(self.memory_root) - granted = [(os.path.realpath(r), rec) for r, rec, *_ in - _granted_reach_of(getattr(self, "_verdict", None))] + granted = [(os.path.realpath(r), rec) for r, rec, *md in + _granted_reach_of(getattr(self, "_verdict", None)) if _mode_has(md, "r")] def _allowed(full: str, is_script: bool) -> bool: if full == root or full.startswith(root + os.sep): @@ -4709,6 +4709,12 @@ def _release_stage_lock(lock_path: str, fd: int) -> None: os.close(fd) +def _mode_has(rest, mode: str) -> bool: + """Whether a reach item's optional third element (its modes, hestia #1345 part B) includes + `mode`. An item without one is a full-mode grant, as every grant was before modes.""" + return mode in (str(rest[0]) if rest else "rwed") + + def _is_reach_pointer(raw: str) -> bool: """`hestia://reach` or `hestia://reach/` (SAGE #530).""" s = (raw or "").strip() diff --git a/sage/gateway/reference_f1a.py b/sage/gateway/reference_f1a.py index b42888b91..3e2c8c631 100644 --- a/sage/gateway/reference_f1a.py +++ b/sage/gateway/reference_f1a.py @@ -870,6 +870,9 @@ def __call__(self, intent: BeingIntent, verdict: GatewayVerdict) -> ResultEnvelo reach = tuple(getattr(verdict, "granted_reach", ()) or ()) self._extra_reach = (tuple((Path(r).resolve(), bool(rec)) for r, rec, *_ in reach) if reach else tuple((r, True) for r in self._extra_roots)) + # AND IN WHICH MODES (hestia #1345 part B): a grant narrowed below rwed carries them as a + # third element. A root without one is full-mode, as every grant was before modes. + self._extra_modes = {Path(r).resolve(): str(md[0]) for r, rec, *md in reach if md} handler = getattr(self, f"_do_{intent.effector}", None) if handler is None: # a consequential network act the reference won't run — real F1a's job @@ -939,10 +942,23 @@ def _safe_path(self, raw: str, writing: bool = False) -> Path: f"notes/{p.name} is what was said TO you, and it stays as it was said. Your " "reply belongs in your journal, in a conversation with `say`, or in an " "appeal, all of which are read") - reach =((self.memory_root, True),) + tuple( - getattr(self, "_extra_reach", None) - or ((r, True) for r in (getattr(self, "_extra_roots", ()) or ()))) + held = tuple(getattr(self, "_extra_reach", None) + or ((r, True) for r in (getattr(self, "_extra_roots", ()) or ()))) + # The dispatcher confines by the same modes the gate judged by: reading needs r, writing + # w. A grant without the mode is not reach for this act (the home is always all four). + need = "w" if writing else "r" + modes = getattr(self, "_extra_modes", None) or {} + reach = ((self.memory_root, True),) + tuple( + (r, rec) for r, rec in held if need in modes.get(r, "rwed")) if not any(p == r or (recursive and r in p.parents) for r, recursive in reach): + narrowed = [modes[r] for r, rec in held if r in modes and need not in modes[r] + and (p == r or (rec and r in p.parents))] + if narrowed: + raise ValueError( + f"'{p}' is under a grant of yours narrowed to {narrowed[0]}, and " + f"{'writing' if writing else 'reading'} needs {need}. Nothing was " + f"{'changed' if writing else 'read'}. memory_read hestia://reach/{p} shows " + f"what you hold there") # A NEAR MISS OF ITS OWN HOUSE IS NOT A TRESPASS. dp, 2026-09-25, fleet directive: # "each being has a home ... i want each to feel at home there ... addressing # unnecessary frictions." @@ -979,9 +995,12 @@ def _safe_path(self, raw: str, writing: bool = False) -> Path: def _reach_pairs(self) -> tuple: """((root, recursive), ...) beyond the home for this verdict. `_extra_reach` when the - verdict carried reach; otherwise the bare roots, read by prefix as _safe_path reads them.""" - return tuple(getattr(self, "_extra_reach", None) or ( + verdict carried reach; otherwise the bare roots, read by prefix as _safe_path reads them. + Only grants that include r (#1345 part B): every caller names a place to READ.""" + modes = getattr(self, "_extra_modes", None) or {} + return tuple((r, rec) for r, rec in (getattr(self, "_extra_reach", None) or ( (r, True) for r in (getattr(self, "_extra_roots", ()) or ()))) + if "r" in modes.get(r, "rwed")) def _within_reach(self, p: Path) -> bool: p = p.resolve() diff --git a/sage/gateway/tests/test_grant_modes_dispatch.py b/sage/gateway/tests/test_grant_modes_dispatch.py new file mode 100644 index 000000000..b24a915cc --- /dev/null +++ b/sage/gateway/tests/test_grant_modes_dispatch.py @@ -0,0 +1,80 @@ +"""hestia #1345 part B, SAGE's side: the dispatcher confines by the modes the gate judged by. + +A core that enforces modes exports scope_roots_with_modes(); _granted_reach passes the modes +through as a third element, the dispatcher's own confinement requires r to read and w to write, +and a run mounts full-mode grants only (the gate cannot judge a run line that mounts a narrowed +root yet).""" +import os +import sys +import tempfile +from types import SimpleNamespace + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))) +from sage.gateway.being_gate_client import BeingIntent, GatewayVerdict, _granted_reach # noqa: E402 +from sage.gateway.hestia_dispatch import _mode_has # noqa: E402 +from sage.gateway.reference_f1a import ReferenceF1aDispatcher # noqa: E402 + + +def _world(modes): + ws = os.path.realpath(tempfile.mkdtemp(prefix="modes-ws-")) + home = os.path.join(ws, "home") + forum = os.path.join(ws, "forum") + os.makedirs(home) + os.makedirs(forum) + open(os.path.join(forum, "thread.md"), "w").write("hello\n") + v = GatewayVerdict("allow", granted=(forum,), granted_reach=((forum, True, modes),)) + return ReferenceF1aDispatcher(memory_root=home), forum, v + + +def test_a_read_only_grant_reads_and_refuses_a_write_naming_the_mode(): + d, forum, v = _world("r") + r = d(BeingIntent("memory_read", {"path": os.path.join(forum, "thread.md")}), v) + assert r.ok and "hello" in (r.result or ""), r + r = d(BeingIntent("memory_write", {"path": os.path.join(forum, "new.md"), "content": "x"}), v) + assert not r.ok and "narrowed to r" in (r.error or "") and "writing needs w" in r.error, r + assert not os.path.exists(os.path.join(forum, "new.md")), "nothing may be written" + + +def test_a_write_only_grant_appends_and_refuses_a_read(): + d, forum, v = _world("w") + r = d(BeingIntent("memory_write", {"path": os.path.join(forum, "drop.md"), "content": "x"}), v) + assert r.ok, r + r = d(BeingIntent("memory_read", {"path": os.path.join(forum, "thread.md")}), v) + assert not r.ok or "hello" not in (r.result or ""), r + assert "narrowed to w" in (r.error or r.result or ""), r + + +def test_a_full_mode_or_modeless_grant_is_unchanged(): + for modes in ("rwed", None): + ws = os.path.realpath(tempfile.mkdtemp(prefix="modes-ws-")) + home, forum = os.path.join(ws, "home"), os.path.join(ws, "forum") + os.makedirs(home) + os.makedirs(forum) + item = (forum, True, modes) if modes else (forum, True) + v = GatewayVerdict("allow", granted=(forum,), granted_reach=(item,)) + d = ReferenceF1aDispatcher(memory_root=home) + r = d(BeingIntent("memory_write", {"path": os.path.join(forum, "n.md"), "content": "x"}), v) + assert r.ok, (modes, r) + r = d(BeingIntent("memory_read", {"path": os.path.join(forum, "n.md")}), v) + assert r.ok and "x" in r.result, (modes, r) + + +def test_the_home_is_always_all_four_modes(): + d, forum, v = _world("r") + r = d(BeingIntent("memory_write", {"path": "notes/x.md", "content": "y"}), v) + assert r.ok, r + + +def test_granted_reach_takes_the_cores_modes_when_it_has_them(): + pol = SimpleNamespace(scope=("path:/w/a/**",)) + core = SimpleNamespace(scope_roots_with_modes=lambda p, ws: (("/w/a", True, "rwed"), ("/w/f", True, "r")), + _scope_roots_with_reach=lambda s, ws: (("/w/a", True),)) + got = _granted_reach(core, pol, "/w") + assert ("/w/f", True, "r") in got and ("/w/a", True, "rwed") in got, got + # an older core without modes: today's two-element reading + core_old = SimpleNamespace(_scope_roots_with_reach=lambda s, ws: (("/w/a", True),)) + assert _granted_reach(core_old, pol, "/w") == (("/w/a", True),) + + +def test_mode_has_reads_a_missing_third_element_as_full(): + assert _mode_has([], "r") and _mode_has(["r"], "r") and not _mode_has(["w"], "r") From 6f5537f5b11c4725fe59f26ac2be6e5fb4af3d77 Mon Sep 17 00:00:00 2001 From: Dennis Palatov Date: Sat, 24 Oct 2026 23:32:21 -0700 Subject: [PATCH 5/8] dispatcher modes: key on (root, recursive) and union, as the gate does; docstring (nomad #533 review) - point 1: _extra_modes was keyed on the root alone, so a full /f/** plus an r grant on /f collapsed to whichever came last and a write the gate admitted was refused. Now keyed on (root, recursive) with modes unioned (run_grant_mounts' rule; also hestia #1386 rule 3). Test: test_two_grants_on_one_root_union_their_modes_as_the_gate_does. - point 3: the module docstring said a run mounts full-mode grants only; it mounts every grant with r, and the live single gate judges a run by its script path (hestia #1388 README). - point 2 is fixed on the hestia side: part B v2's scope_roots_with_modes lists a narrowed workspace-direct grant, which _granted_reach already consumes. grant-modes, reference_f1a, reach-pointer, hestia_dispatch: 223 passed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH --- sage/gateway/reference_f1a.py | 18 ++++++++--- .../tests/test_grant_modes_dispatch.py | 31 +++++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/sage/gateway/reference_f1a.py b/sage/gateway/reference_f1a.py index a7888beb9..32f7b0ddc 100644 --- a/sage/gateway/reference_f1a.py +++ b/sage/gateway/reference_f1a.py @@ -889,7 +889,16 @@ def __call__(self, intent: BeingIntent, verdict: GatewayVerdict) -> ResultEnvelo else tuple((r, True) for r in self._extra_roots)) # AND IN WHICH MODES (hestia #1345 part B): a grant narrowed below rwed carries them as a # third element. A root without one is full-mode, as every grant was before modes. - self._extra_modes = {Path(r).resolve(): str(md[0]) for r, rec, *md in reach if md} + # Keyed on (root, recursive), and two grants on the same key UNION their modes, as the + # gate does ("the law's reach is their union", run_grant_mounts): keyed on the root alone, + # a full `/f/**` and an `r` grant on `/f` collapsed to whichever came last, and a write the + # gate admitted was refused here (nomad review of #533, point 1). A grant without modes + # is full-mode. + self._extra_modes = {} + for r, rec, *md in reach: + k = (Path(r).resolve(), bool(rec)) + got = self._extra_modes.get(k, "") + (str(md[0]) if md else "rwed") + self._extra_modes[k] = "".join(c for c in "rwed" if c in got) handler = getattr(self, f"_do_{intent.effector}", None) if handler is None: # a consequential network act the reference won't run — real F1a's job @@ -979,9 +988,10 @@ def _safe_path(self, raw: str, writing: bool = False) -> Path: need = "w" if writing else "r" modes = getattr(self, "_extra_modes", None) or {} reach = ((self.memory_root, True),) + tuple( - (r, rec) for r, rec in held if need in modes.get(r, "rwed")) + (r, rec) for r, rec in held if need in modes.get((r, rec), "rwed")) if not any(p == r or (recursive and r in p.parents) for r, recursive in reach): - narrowed = [modes[r] for r, rec in held if r in modes and need not in modes[r] + narrowed = [modes[(r, rec)] for r, rec in held + if (r, rec) in modes and need not in modes[(r, rec)] and (p == r or (rec and r in p.parents))] if narrowed: raise ValueError( @@ -1030,7 +1040,7 @@ def _reach_pairs(self) -> tuple: modes = getattr(self, "_extra_modes", None) or {} return tuple((r, rec) for r, rec in (getattr(self, "_extra_reach", None) or ( (r, True) for r in (getattr(self, "_extra_roots", ()) or ()))) - if "r" in modes.get(r, "rwed")) + if "r" in modes.get((r, rec), "rwed")) def _within_reach(self, p: Path) -> bool: p = p.resolve() diff --git a/sage/gateway/tests/test_grant_modes_dispatch.py b/sage/gateway/tests/test_grant_modes_dispatch.py index b24a915cc..4266ead95 100644 --- a/sage/gateway/tests/test_grant_modes_dispatch.py +++ b/sage/gateway/tests/test_grant_modes_dispatch.py @@ -1,9 +1,10 @@ """hestia #1345 part B, SAGE's side: the dispatcher confines by the modes the gate judged by. A core that enforces modes exports scope_roots_with_modes(); _granted_reach passes the modes -through as a third element, the dispatcher's own confinement requires r to read and w to write, -and a run mounts full-mode grants only (the gate cannot judge a run line that mounts a narrowed -root yet).""" +through as a third element, and the dispatcher's own confinement requires r to read and w to +write. A run mounts every grant that holds r (`run_grant_mounts`: `--ro-bind` unless the grant +holds both w and d); the live single gate judges a run by its script path, never by that line +(hestia #1388 README, point 3).""" import os import sys import tempfile @@ -78,3 +79,27 @@ def test_granted_reach_takes_the_cores_modes_when_it_has_them(): def test_mode_has_reads_a_missing_third_element_as_full(): assert _mode_has([], "r") and _mode_has(["r"], "r") and not _mode_has(["w"], "r") + + +def test_two_grants_on_one_root_union_their_modes_as_the_gate_does(): + """nomad review of #533, point 1: keyed on the root alone, a full recursive grant and an `r` + grant on the same root collapsed to whichever came last, so a write the gate (union) admitted + was refused here. Keyed on (root, recursive) with modes unioned, the two agree.""" + ws = os.path.realpath(tempfile.mkdtemp(prefix="modes-ws-")) + home, forum = os.path.join(ws, "home"), os.path.join(ws, "forum") + os.makedirs(home) + os.makedirs(forum) + for reach in (((forum, True, "rwed"), (forum, True, "r")), + ((forum, True, "r"), (forum, True, "rwed")), + ((forum, True, "r"), (forum, True, "w"))): + v = GatewayVerdict("allow", granted=(forum,), granted_reach=reach) + d = ReferenceF1aDispatcher(memory_root=home) + r = d(BeingIntent("memory_write", {"path": os.path.join(forum, "u.md"), "content": "x"}), v) + assert r.ok, (reach, r) + r = d(BeingIntent("memory_read", {"path": os.path.join(forum, "u.md")}), v) + assert r.ok and "x" in r.result, (reach, r) + # An exact r grant on the root and a recursive w grant on it are different keys. + v = GatewayVerdict("allow", granted=(forum,), granted_reach=((forum, False, "r"), (forum, True, "w"))) + d = ReferenceF1aDispatcher(memory_root=home) + r = d(BeingIntent("memory_read", {"path": os.path.join(forum, "u.md")}), v) + assert not r.ok or "x" not in (r.result or ""), r From ec9948cf805606183f8f6091dd155d3ce5cd0b71 Mon Sep 17 00:00:00 2001 From: Dennis Palatov Date: Sun, 25 Oct 2026 00:28:52 -0700 Subject: [PATCH 6/8] dispatcher + run mounts follow the nearest explicit grant exactly when the gate does (hestia #1386) Answers Nova's hold on #533 without breaking gate/dispatcher parity: - GatewayVerdict.reach_rule: "nearest" when the core this verdict was judged with exports nearest_grant_modes (hestia part C, #1394), else "union" (part B). Set at both verdict sites from BeingGateClient._reach_rule(); the dispatcher never guesses it. - reference_f1a: under "nearest", _safe_path and _within_reach take the nearest held node's modes (grants on one node union) and refuse with the nearest grant named; under "union", unchanged. - run mounts: reach_rule rides both composition contexts (gate _compose_ctx, dispatcher run ctx) so the two lines stay identical. Under "nearest" a child stricter than its parent mount is kept (sorted, so its bind lands after the parent's) and a recursive child without r beneath a mounted parent is masked (--tmpfs + --remount-ro, like the home). Under "union", unchanged. Tests (test_grant_modes_dispatch.py, 12): union parity with part B; nearest: child capped, parent elsewhere open, w-only child unreadable through an r parent; the rule read from the core; run mounts keep/mask and order. Run, mount, gate-client, dispatch, reference and reach suites: 403 passed. (test_activity_reports fails on main too, environmental.) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH --- sage/gateway/being_gate_client.py | 48 ++++++++++--- sage/gateway/hestia_dispatch.py | 4 +- sage/gateway/reference_f1a.py | 36 ++++++++++ .../tests/test_grant_modes_dispatch.py | 67 +++++++++++++++++++ 4 files changed, 146 insertions(+), 9 deletions(-) diff --git a/sage/gateway/being_gate_client.py b/sage/gateway/being_gate_client.py index 79bd82557..ed2adacad 100644 --- a/sage/gateway/being_gate_client.py +++ b/sage/gateway/being_gate_client.py @@ -1713,6 +1713,10 @@ def run_grant_mounts(ctx: Optional[dict] = None) -> tuple: ctx = ctx or {} home = os.path.realpath(ctx.get("memory_root") or "/nonexistent-home") found, skipped = {}, [] # real path -> (kind, "rw"|"ro") + # hestia #1386 (part C): the nearest grant decides, so a child grant stricter than the mount + # above it must be mounted after it, not folded into it. Off unless the gate decides that way. + nearest = (ctx or {}).get("reach_rule") == "nearest" + unreadable = [] for item in ctx.get("granted_reach") or (): try: root, recursive = item[0], item[1] @@ -1724,6 +1728,8 @@ def run_grant_mounts(ctx: Optional[dict] = None) -> tuple: if "r" not in modes: skipped.append((os.path.abspath(os.path.expanduser(root.strip())), f"your grant there is '{modes}', without r (read)")) + if nearest and recursive: + unreadable.append(os.path.realpath(os.path.abspath(os.path.expanduser(root.strip())))) continue how = "rw" if ("w" in modes and "d" in modes) else "ro" lexical = os.path.abspath(os.path.expanduser(root.strip())) @@ -1753,9 +1759,19 @@ def run_grant_mounts(ctx: Optional[dict] = None) -> tuple: # De-nesting by the union rule: a mount inside a directory mount adds nothing unless it # is WIDER there (rw inside ro). Kept ones bind after their parent (sorted), so they win. dirs = [(p, h) for p, (k, h) in found.items() if k == "dir"] - mounts = sorted((p, k, h) for p, (k, h) in found.items() - if not any(p != d and p.startswith(d + os.sep) and (dh == "rw" or h == "ro") - for d, dh in dirs)) + if nearest: + # Keep a child unless it grants exactly what the mount above it already grants. + mounts = sorted((p, k, h) for p, (k, h) in found.items() + if not any(p != d and p.startswith(d + os.sep) and dh == h for d, dh in dirs)) + # A recursive grant without r beneath a mounted directory: the parent would show it, the + # gate would not. Masked with an empty read-only directory, like the home. + mounts += sorted((u, "mask", "ro") for u in unreadable + if os.path.isdir(u) and any(u.startswith(d + os.sep) for d, _ in dirs)) + mounts.sort() + else: + mounts = sorted((p, k, h) for p, (k, h) in found.items() + if not any(p != d and p.startswith(d + os.sep) and (dh == "rw" or h == "ro") + for d, dh in dirs)) mask_home = any(k == "dir" and home.startswith(p + os.sep) for p, k, _h in mounts) return mounts, sorted(set(skipped)), mask_home @@ -1824,8 +1840,10 @@ def run_command(args: dict, ctx: Optional[dict] = None) -> str: # symlink inside a granted tree that points into the home resolves inside the sandbox, to # the mask. mounts, _skipped, mask_home = run_grant_mounts(ctx) - grant_binds = "".join(f" {'--bind' if h == 'rw' else '--ro-bind'} {shlex.quote(p)} {shlex.quote(p)}" - for p, _k, h in mounts) + grant_binds = "".join( + f" --tmpfs {shlex.quote(p)} --remount-ro {shlex.quote(p)}" if k == "mask" else + f" {'--bind' if h == 'rw' else '--ro-bind'} {shlex.quote(p)} {shlex.quote(p)}" + for p, k, h in mounts) if mask_home: _qh = shlex.quote(_home) grant_binds += f" --tmpfs {_qh} --remount-ro {_qh}" @@ -3322,6 +3340,11 @@ class GatewayVerdict: # said the invariant was being checked; nothing was. Found while writing patch_apply, # which had copied the same pattern faithfully enough to inherit the same hole. command: Optional[str] = None + # WHICH RULE THE GATE DECIDED BY, so the dispatcher confines by the same one (hestia #1386): + # "nearest" when the core exports nearest_grant_modes (part C: the deepest grant covering a path + # decides it), "union" otherwise (part B: any covering grant admits). Read once, here, from the + # core this verdict was judged with; the dispatcher never guesses it. + reach_rule: str = "union" @property def blocks(self) -> bool: @@ -3542,7 +3565,14 @@ def _compose_ctx(self, effector: Optional[str] = None) -> dict: "member": getattr(self, "member_id", None), # where `camera` gets a frame (instance.json camera_source; "cortex" or absent) "camera_source": getattr(self, "camera_source", None), - **({"granted_reach": self._policy_grants()[1]} if effector == "run" else {})} + **({"granted_reach": self._policy_grants()[1], "reach_rule": self._reach_rule()} + if effector == "run" else {})} + + def _reach_rule(self) -> str: + """"nearest" when this seat's gate core decides by the nearest explicit grant (hestia #1386, + part C: it exports nearest_grant_modes), else "union" (part B). See GatewayVerdict.""" + return "nearest" if callable(getattr(getattr(self, "_core", None), "nearest_grant_modes", + None)) else "union" # -- gate one intent (intent -> verdict), fail-closed -------------------- def _policy_grants(self) -> tuple: @@ -3658,7 +3688,8 @@ def gate(self, intent: BeingIntent) -> GatewayVerdict: granted, granted_reach = self._policy_grants() if dec != "deny" else ((), ()) return GatewayVerdict(dec, rule, getattr(d, "reason", "") or ("ok" if dec != "deny" else ""), innate=False, stage="single-gate", command=judged_command, - granted=granted, granted_reach=granted_reach) + granted=granted, granted_reach=granted_reach, + reach_rule=self._reach_rule()) except Exception as e: # a gate that raises is a refused act, never an ungoverned one return GatewayVerdict("deny", "gate.raised", innate=True, stage="single-gate", reason=f"{type(e).__name__}: {e}") @@ -3738,7 +3769,8 @@ def gate(self, intent: BeingIntent) -> GatewayVerdict: # than recomposed by the caller, which is the whole point of the field. return GatewayVerdict(v.decision, v.rule, v.reason or "ok", v.innate, stage="local-law", command=judged_command, - granted=granted, granted_reach=granted_reach) + granted=granted, granted_reach=granted_reach, + reach_rule=self._reach_rule()) # -- the F1a seam: gate, then dispatch, then consume the result ---------- def _resolve_relative_to_grants(self, intent: BeingIntent): diff --git a/sage/gateway/hestia_dispatch.py b/sage/gateway/hestia_dispatch.py index 730668305..2ea460693 100644 --- a/sage/gateway/hestia_dispatch.py +++ b/sage/gateway/hestia_dispatch.py @@ -3284,7 +3284,9 @@ def _do_run(self, intent: BeingIntent) -> ResultEnvelope: "workspace": getattr(self, "workspace", None), # the grants this verdict carries: run_grant_mounts composes the same mounts the # law judged from the gate's copy of them, or the line differs and nothing runs - "granted_reach": _granted_reach_of(getattr(self, "_verdict", None))} + "granted_reach": _granted_reach_of(getattr(self, "_verdict", None)), + # the rule the gate decided by (hestia #1386), so both sides compose one line + "reach_rule": getattr(getattr(self, "_verdict", None), "reach_rule", "union")} try: cmd = run_command(intent.args, ctx) names = run_names(intent.args, ctx) diff --git a/sage/gateway/reference_f1a.py b/sage/gateway/reference_f1a.py index 32f7b0ddc..20a4a85b3 100644 --- a/sage/gateway/reference_f1a.py +++ b/sage/gateway/reference_f1a.py @@ -899,6 +899,9 @@ def __call__(self, intent: BeingIntent, verdict: GatewayVerdict) -> ResultEnvelo k = (Path(r).resolve(), bool(rec)) got = self._extra_modes.get(k, "") + (str(md[0]) if md else "rwed") self._extra_modes[k] = "".join(c for c in "rwed" if c in got) + # And BY WHICH RULE (hestia #1386): "nearest" when the gate decided by the nearest + # explicit grant (part C), so this confinement decides the same way. + self._reach_rule = getattr(verdict, "reach_rule", "union") handler = getattr(self, f"_do_{intent.effector}", None) if handler is None: # a consequential network act the reference won't run — real F1a's job @@ -987,6 +990,18 @@ def _safe_path(self, raw: str, writing: bool = False) -> Path: # w. A grant without the mode is not reach for this act (the home is always all four). need = "w" if writing else "r" modes = getattr(self, "_extra_modes", None) or {} + if (getattr(self, "_reach_rule", "union") == "nearest" + and not (p == self.memory_root or self.memory_root in p.parents)): + near = self._nearest_modes(p, held, modes) + if near is not None: + if need in near: + return p + raise ValueError( + f"'{p}' is under a grant of yours narrowed to {near}, the nearest grant covering " + f"it, and {'writing' if writing else 'reading'} needs {need}. A broader grant " + f"above it does not apply here: the nearest explicit grant decides (hestia " + f"#1386). Nothing was {'changed' if writing else 'read'}. memory_read " + f"hestia://reach/{p} shows what you hold there") reach = ((self.memory_root, True),) + tuple( (r, rec) for r, rec in held if need in modes.get((r, rec), "rwed")) if not any(p == r or (recursive and r in p.parents) for r, recursive in reach): @@ -1042,8 +1057,29 @@ def _reach_pairs(self) -> tuple: (r, True) for r in (getattr(self, "_extra_roots", ()) or ()))) if "r" in modes.get((r, rec), "rwed")) + @staticmethod + def _nearest_modes(p: Path, held, modes) -> Optional[str]: + """The modes of the nearest held grant covering `p` (grants on one node union), or None. + The gate's rule (hestia #1386), over this verdict's (root, recursive) nodes.""" + best, got = -1, set() + for r, rec in held: + if p == r or (rec and r in p.parents): + depth = len(r.parts) + m = modes.get((r, rec), "rwed") + if depth > best: + best, got = depth, set(m) + elif depth == best: + got |= set(m) + return None if best < 0 else "".join(c for c in "rwed" if c in got) + def _within_reach(self, p: Path) -> bool: p = p.resolve() + if getattr(self, "_reach_rule", "union") == "nearest" and not ( + p == self.memory_root or self.memory_root in p.parents): + held = tuple((Path(r).resolve(), rec) for r, rec in (getattr(self, "_extra_reach", None) or ( + (r, True) for r in (getattr(self, "_extra_roots", ()) or ())))) + near = self._nearest_modes(p, held, getattr(self, "_extra_modes", None) or {}) + return near is not None and "r" in near pairs = ((self.memory_root, True),) + tuple((Path(r).resolve(), rec) for r, rec in self._reach_pairs()) return any(p == r or (rec and r in p.parents) for r, rec in pairs) diff --git a/sage/gateway/tests/test_grant_modes_dispatch.py b/sage/gateway/tests/test_grant_modes_dispatch.py index 4266ead95..b8e3af6d0 100644 --- a/sage/gateway/tests/test_grant_modes_dispatch.py +++ b/sage/gateway/tests/test_grant_modes_dispatch.py @@ -103,3 +103,70 @@ def test_two_grants_on_one_root_union_their_modes_as_the_gate_does(): d = ReferenceF1aDispatcher(memory_root=home) r = d(BeingIntent("memory_read", {"path": os.path.join(forum, "u.md")}), v) assert not r.ok or "x" not in (r.result or ""), r + + +# -- hestia #1386 (part C): the nearest explicit grant decides, exactly when the gate does ------- + +def _nested(rule, child_modes="r", parent_modes="rwed"): + ws = os.path.realpath(tempfile.mkdtemp(prefix="near-ws-")) + home, proj = os.path.join(ws, "home"), os.path.join(ws, "proj") + os.makedirs(home) + os.makedirs(os.path.join(proj, "sub")) + os.makedirs(os.path.join(proj, "other")) + open(os.path.join(proj, "sub", "t.md"), "w").write("child\n") + reach = ((proj, True, parent_modes), (os.path.join(proj, "sub"), True, child_modes)) + v = GatewayVerdict("allow", granted=(proj, os.path.join(proj, "sub")), granted_reach=reach, + reach_rule=rule) + return ReferenceF1aDispatcher(memory_root=home), proj, v + + +def test_under_the_union_rule_a_nested_narrowed_grant_is_part_b_parity(): + """Part B's gate admits a write under an `r` child of a full parent; so must this.""" + d, proj, v = _nested("union") + r = d(BeingIntent("memory_write", {"path": os.path.join(proj, "sub", "n.md"), "content": "x"}), v) + assert r.ok, r + + +def test_under_the_nearest_rule_the_nearer_narrowed_grant_decides(): + d, proj, v = _nested("nearest") + r = d(BeingIntent("memory_write", {"path": os.path.join(proj, "sub", "n.md"), "content": "x"}), v) + assert not r.ok and "nearest grant covering" in (r.error or "") and "#1386" in r.error, r + assert not os.path.exists(os.path.join(proj, "sub", "n.md")) + r = d(BeingIntent("memory_read", {"path": os.path.join(proj, "sub", "t.md")}), v) + assert r.ok and "child" in (r.result or ""), r + r = d(BeingIntent("memory_write", {"path": os.path.join(proj, "other", "n.md"), "content": "x"}), v) + assert r.ok, r + + +def test_under_the_nearest_rule_a_write_only_child_cannot_be_read_through_its_parent(): + d, proj, v = _nested("nearest", child_modes="w", parent_modes="r") + r = d(BeingIntent("memory_read", {"path": os.path.join(proj, "sub", "t.md")}), v) + assert not r.ok or "child" not in (r.result or ""), r + assert "narrowed to w" in (r.error or r.result or ""), r + + +def test_the_rule_comes_from_the_core_the_verdict_was_judged_with(): + from sage.gateway.being_gate_client import BeingGateClient + c = BeingGateClient.__new__(BeingGateClient) + c._core = SimpleNamespace() + assert c._reach_rule() == "union" + c._core = SimpleNamespace(nearest_grant_modes=lambda *a, **k: None) + assert c._reach_rule() == "nearest" + + +def test_run_mounts_keep_a_stricter_child_and_mask_an_unreadable_one_under_the_nearest_rule(): + from sage.gateway.being_gate_client import run_grant_mounts + ws = os.path.realpath(tempfile.mkdtemp(prefix="near-mnt-")) + home, proj = os.path.join(ws, "home"), os.path.join(ws, "proj") + for dd in (home, os.path.join(proj, "ro"), os.path.join(proj, "hidden")): + os.makedirs(dd) + reach = ((proj, True, "rwed"), (os.path.join(proj, "ro"), True, "r"), + (os.path.join(proj, "hidden"), True, "w")) + union, _s, _m = run_grant_mounts({"memory_root": home, "granted_reach": reach}) + assert union == [(proj, "dir", "rw")], union + near, _s, _m = run_grant_mounts({"memory_root": home, "granted_reach": reach, + "reach_rule": "nearest"}) + assert (proj, "dir", "rw") in near and (os.path.join(proj, "ro"), "dir", "ro") in near, near + assert (os.path.join(proj, "hidden"), "mask", "ro") in near, near + assert near.index((proj, "dir", "rw")) < near.index((os.path.join(proj, "ro"), "dir", "ro")), \ + "a child's bind must come after its parent's, or the parent's bind hides it" From 91b3889b82324a367941fa40cb32ece0ef85b4f0 Mon Sep 17 00:00:00 2001 From: Dennis Palatov Date: Sun, 25 Oct 2026 11:06:57 -0700 Subject: [PATCH 7/8] dispatcher: the read-only society floor is reach for reading (dp's forum floor, 2026-10-25) dp put shared-context/forum on the floor with modes rw; the daemon serves it read-only (society_floor_read -> policy.read_scope). The gate admits a read there, but _granted_reach carried only the member's own grants, so the dispatcher would refuse the same read. _floor_read_reach adds those roots as (root, recursive, "r") when the core is mode-aware, and leaves out any floor root at or beneath one of the member's own grants (the floor applies only where no own grant reaches; hestia #1386). An older core gets nothing added: it could only express a floor root as all modes. Tests: read admitted and write refused through a read-only floor entry; the floor steps aside under an own grant; an older core adds none. Related suites: 326 passed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH --- sage/gateway/being_gate_client.py | 38 ++++++++++++- .../tests/test_grant_modes_dispatch.py | 53 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/sage/gateway/being_gate_client.py b/sage/gateway/being_gate_client.py index ed2adacad..8e3b39fb5 100644 --- a/sage/gateway/being_gate_client.py +++ b/sage/gateway/being_gate_client.py @@ -3279,6 +3279,39 @@ def _granted_roots(core, policy, workspace: str, granted_paths=()) -> tuple: return () + +def _floor_read_reach(core, policy, workspace: str, own) -> tuple: + """``((root, recursive, "r"), ...)`` for the society's READ-ONLY floor this member's gate is + served (`society_floor_read` -> policy.read_scope, hestia #1381), so the dispatcher's own + confinement admits the reads the gate admits there. + + Measured 2026-10-25 (cbp-being): dp put shared-context/forum on the floor with modes rw, which + the daemon serves read-only. The gate admitted a read there; this list carried only the + member's own grants, so the dispatcher would have refused the same read "outside your reach". + + THE FLOOR APPLIES ONLY WHERE NO OWN GRANT REACHES (dp, 2026-10-23/24; hestia #1386): a floor + root at or beneath one of the member's own grant roots is left out, so the member's grant + decides there. Only with a mode-aware core (the third element says "r"); an older consumer + reads a pair as all modes, so nothing is added for it.""" + import os + if getattr(core, "scope_roots_with_modes", None) is None: + return () + entries = list(getattr(policy, "read_scope", ()) or ()) + if not entries: + return () + ws = os.path.realpath(os.path.expanduser(workspace)).replace("\\", "/").rstrip("/") + roots = [(str(r), bool(rec)) for r, rec in core._scope_roots_with_reach(entries, workspace)] + roots += [(os.path.realpath(os.path.join(ws, e)).replace("\\", "/").rstrip("/"), True) + for e in entries if isinstance(e, str) and e and not e.startswith("path:") + and "/" not in e and "\\" not in e and e not in (".", "..", "*")] + covered = [(str(it[0]).rstrip("/"), bool(it[1])) for it in own] + out = [] + for root, rec in roots: + if any(root == r or (orec and root.startswith(r + "/")) for r, orec in covered): + continue + out.append((root, rec, "r")) + return tuple(out) + def _granted_reach(core, policy, workspace: str, granted_paths=()) -> tuple: """``((root, recursive), ...)`` for every path grant, via the core's own reach resolver (hestia_gate_core._scope_roots_with_reach, since #1002: exact unless spelled `/**`), @@ -3295,8 +3328,9 @@ def _granted_reach(core, policy, workspace: str, granted_paths=()) -> tuple: with_modes = getattr(core, "scope_roots_with_modes", None) if with_modes is not None: scopes = list(getattr(policy, "scope", ()) or ()) - return (tuple((str(r), bool(rec), str(md)) for r, rec, md in with_modes(policy, workspace)) - + tuple((r, True) for r in _repo_name_roots(core, scopes, workspace, granted_paths))) + own = (tuple((str(r), bool(rec), str(md)) for r, rec, md in with_modes(policy, workspace)) + + tuple((r, True) for r in _repo_name_roots(core, scopes, workspace, granted_paths))) + return own + _floor_read_reach(core, policy, workspace, own) reach = getattr(core, "_scope_roots_with_reach", None) if reach is not None: scopes = list(getattr(policy, "scope", ()) or ()) diff --git a/sage/gateway/tests/test_grant_modes_dispatch.py b/sage/gateway/tests/test_grant_modes_dispatch.py index b8e3af6d0..e73c1afb7 100644 --- a/sage/gateway/tests/test_grant_modes_dispatch.py +++ b/sage/gateway/tests/test_grant_modes_dispatch.py @@ -170,3 +170,56 @@ def test_run_mounts_keep_a_stricter_child_and_mask_an_unreadable_one_under_the_n assert (os.path.join(proj, "hidden"), "mask", "ro") in near, near assert near.index((proj, "dir", "rw")) < near.index((os.path.join(proj, "ro"), "dir", "ro")), \ "a child's bind must come after its parent's, or the parent's bind hides it" + + +# -- the read-only floor reaches the dispatcher (dp, 2026-10-25: forum on the floor, modes rw) ---- + +def _core_with_modes(): + def _roots(entries, ws): + out = [] + for e in entries: + if isinstance(e, str) and e.startswith("path:"): + raw = e[len("path:"):] + rec = raw.endswith("/**") + out.append((raw[:-3] if rec else raw, rec)) + return tuple(out) + + def _with_modes(policy, ws): + return tuple((r, rec, "rwed") for r, rec in _roots(policy.scope, ws)) + return SimpleNamespace(scope_roots_with_modes=_with_modes, _scope_roots_with_reach=_roots, + _scope_parts=lambda s, w: ((), ())) + + +def test_a_read_only_floor_entry_is_reach_for_reading_and_never_for_writing(): + ws = os.path.realpath(tempfile.mkdtemp(prefix="floor-ws-")) + home, forum = os.path.join(ws, "home"), os.path.join(ws, "shared-context", "forum") + os.makedirs(home) + os.makedirs(forum) + open(os.path.join(forum, "note.md"), "w").write("from sprout\n") + policy = SimpleNamespace(scope=(), read_scope=(f"path:{forum}/**",)) + reach = _granted_reach(_core_with_modes(), policy, ws) + assert (forum, True, "r") in reach, reach + d = ReferenceF1aDispatcher(memory_root=home) + v = GatewayVerdict("allow", granted=tuple(r for r, *_ in reach), granted_reach=reach) + r = d(BeingIntent("memory_read", {"path": os.path.join(forum, "note.md")}), v) + assert r.ok and "from sprout" in (r.result or ""), r + r = d(BeingIntent("memory_write", {"path": os.path.join(forum, "x.md"), "content": "x"}), v) + assert not r.ok and not os.path.exists(os.path.join(forum, "x.md")), r + + +def test_the_floor_steps_aside_where_an_own_grant_reaches(): + ws = os.path.realpath(tempfile.mkdtemp(prefix="floor-ws-")) + sc = os.path.join(ws, "shared-context") + forum = os.path.join(sc, "forum") + os.makedirs(forum) + policy = SimpleNamespace(scope=(f"path:{sc}/**",), read_scope=(f"path:{forum}/**",)) + reach = _granted_reach(_core_with_modes(), policy, ws) + assert (sc, True, "rwed") in reach and not [x for x in reach if x[0] == forum], reach + + +def test_an_older_core_adds_no_floor_it_could_only_express_as_all_modes(): + ws = os.path.realpath(tempfile.mkdtemp(prefix="floor-ws-")) + forum = os.path.join(ws, "forum") + core = SimpleNamespace(_scope_roots_with_reach=lambda e, w: (), _scope_parts=lambda s, w: ((), ())) + policy = SimpleNamespace(scope=(), read_scope=(f"path:{forum}/**",)) + assert _granted_reach(core, policy, ws) == () From a6e962e2343b2e003a4c4ade212a560d6069f967 Mon Sep 17 00:00:00 2001 From: Dennis Palatov Date: Sun, 25 Oct 2026 23:23:32 -0700 Subject: [PATCH 8/8] dispatcher floor: an own grant suppresses a floor root only where it covers the floor's whole reach Nova's HOLD on #533 (nomad's counterexample): an EXACT own grant on /f suppressed a RECURSIVE read floor on /f/**, though it does not reach /f/child, so the dispatcher refused a read the gate admits. Now an own recursive grant at or above the floor root covers its subtree; an own exact grant suppresses only an exact floor entry on the same path. Tests through _granted_reach AND a real dispatcher read (write still refused on the read floor): own-exact/floor-recursive (kept; this test fails on the old rule), own-recursive/floor-recursive (suppressed; the own grant decides), own-exact/floor-exact (suppressed). Related suites: 329 passed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH --- sage/gateway/being_gate_client.py | 9 +++- .../tests/test_grant_modes_dispatch.py | 46 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/sage/gateway/being_gate_client.py b/sage/gateway/being_gate_client.py index 8e3b39fb5..bf85b3b82 100644 --- a/sage/gateway/being_gate_client.py +++ b/sage/gateway/being_gate_client.py @@ -3307,7 +3307,14 @@ def _floor_read_reach(core, policy, workspace: str, own) -> tuple: covered = [(str(it[0]).rstrip("/"), bool(it[1])) for it in own] out = [] for root, rec in roots: - if any(root == r or (orec and root.startswith(r + "/")) for r, orec in covered): + # Suppressed only where an own grant reaches EVERYTHING the floor entry reaches (nomad's + # counterexample, Nova's HOLD on #533): an own RECURSIVE grant at or above the floor's root + # covers its whole subtree; an own EXACT grant covers only its one path, so it suppresses + # an exact floor entry on that same path and never a recursive one (whose children it does + # not reach). + if any((orec and (root == r or root.startswith(r + "/"))) + or (not orec and not rec and root == r) + for r, orec in covered): continue out.append((root, rec, "r")) return tuple(out) diff --git a/sage/gateway/tests/test_grant_modes_dispatch.py b/sage/gateway/tests/test_grant_modes_dispatch.py index e73c1afb7..d934911a5 100644 --- a/sage/gateway/tests/test_grant_modes_dispatch.py +++ b/sage/gateway/tests/test_grant_modes_dispatch.py @@ -223,3 +223,49 @@ def test_an_older_core_adds_no_floor_it_could_only_express_as_all_modes(): core = SimpleNamespace(_scope_roots_with_reach=lambda e, w: (), _scope_parts=lambda s, w: ((), ())) policy = SimpleNamespace(scope=(), read_scope=(f"path:{forum}/**",)) assert _granted_reach(core, policy, ws) == () + + +def _floor_world(): + ws = os.path.realpath(tempfile.mkdtemp(prefix="floor-ws-")) + home, forum = os.path.join(ws, "home"), os.path.join(ws, "shared-context", "forum") + os.makedirs(home) + os.makedirs(forum) + open(os.path.join(forum, "note.md"), "w").write("from sprout\n") + return ws, home, forum + + +def _dispatch_read_and_write(home, forum, reach): + d = ReferenceF1aDispatcher(memory_root=home) + v = GatewayVerdict("allow", granted=tuple(r for r, *_ in reach), granted_reach=reach) + read = d(BeingIntent("memory_read", {"path": os.path.join(forum, "note.md")}), v) + write = d(BeingIntent("memory_write", {"path": os.path.join(forum, "x.md"), "content": "x"}), v) + return read, write + + +def test_an_exact_own_grant_never_suppresses_a_recursive_floor_beneath_it(): + """nomad's counterexample (Nova HOLD on #533): an EXACT own grant on /f does not reach /f/child, + so it must not drop a RECURSIVE read floor on /f/** -- the gate admits that read.""" + ws, home, forum = _floor_world() + policy = SimpleNamespace(scope=(f"path:{forum}",), read_scope=(f"path:{forum}/**",)) + reach = _granted_reach(_core_with_modes(), policy, ws) + assert (forum, True, "r") in reach, reach + read, write = _dispatch_read_and_write(home, forum, reach) + assert read.ok and "from sprout" in (read.result or ""), read + assert not write.ok and not os.path.exists(os.path.join(forum, "x.md")), write + + +def test_a_recursive_own_grant_covers_the_floor_and_decides_there(): + ws, home, forum = _floor_world() + sc = os.path.dirname(forum) + policy = SimpleNamespace(scope=(f"path:{sc}/**",), read_scope=(f"path:{forum}/**",)) + reach = _granted_reach(_core_with_modes(), policy, ws) + assert not [x for x in reach if x[0] == forum], f"the own grant covers the floor's subtree: {reach}" + read, write = _dispatch_read_and_write(home, forum, reach) + assert read.ok and write.ok, (read, write) + + +def test_an_exact_own_grant_suppresses_only_an_exact_floor_on_the_same_path(): + ws, home, forum = _floor_world() + policy = SimpleNamespace(scope=(f"path:{forum}",), read_scope=(f"path:{forum}",)) + reach = _granted_reach(_core_with_modes(), policy, ws) + assert not [x for x in reach if x[0] == forum and len(x) > 2 and x[2] == "r"], reach