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..3b93e5697 100644 --- a/sage/gateway/heartbeat.py +++ b/sage/gateway/heartbeat.py @@ -1119,6 +1119,27 @@ 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. + + 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 '-'}" + + ("" 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 [])] + + 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 +4133,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 +4168,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 fb82d0659..57bbd68df 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,172 @@ 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}. + `grants_by_mode` is preferred (hestia #1370: it alone carries narrowed grants); an older + 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)) + 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"}) + + 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 = [] + 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 + 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" + + @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). + + 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: + 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 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, 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 (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) + + 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 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: + 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) + def _escalation_pointer(self, ident: str) -> str: """What a `review_request` invitation is actually about (SAGE #109). @@ -645,6 +813,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): @@ -4526,6 +4709,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/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_reach_pointer.py b/sage/gateway/tests/test_reach_pointer.py new file mode 100644 index 000000000..61d2b279c --- /dev/null +++ b/sage/gateway/tests/test_reach_pointer.py @@ -0,0 +1,204 @@ +"""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, 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, + "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": []} + 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() + 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 " + 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_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 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 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" + + +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 "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_a_served_floor_entry_answers_where_no_own_grant_reaches(): + ws, home = _world() + 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 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 "now: yes, read, write, run, delete." in out and "floor" not in out, out + + +def test_an_older_daemon_serves_its_whole_floor_and_reads_every_grant_as_all_modes(): + ws, home = _world() + 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 "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(): + 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 "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)) + 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 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