From 7729e6f2f75bf376cf9e4a02d8153c34c61dfd1d Mon Sep 17 00:00:00 2001 From: wjarvis mos-comms Date: Sun, 19 Jul 2026 19:04:25 -0500 Subject: [PATCH] feat(mosaic): add constrained context recovery command --- docs/SITEMAP.md | 3 +- docs/architecture/lease-broker-protocol.md | 4 +- .../probes/p6_constrained_recovery.py | 215 ++++++++++++++++++ docs/guides/lease-broker-operations.md | 6 +- .../833-constrained-recovery-command.md | 1 + .../skills/mosaic-context-refresh/SKILL.md | 56 +++++ .../framework/tools/lease-broker/daemon.py | 67 +++++- .../tools/lease-broker/recover-context.py | 146 ++++++++++++ packages/mosaic/package.json | 2 +- packages/mosaic/src/commands/skill.spec.ts | 23 ++ 10 files changed, 518 insertions(+), 5 deletions(-) create mode 100644 docs/compaction-refresh/probes/p6_constrained_recovery.py create mode 100644 packages/mosaic/framework/skills/mosaic-context-refresh/SKILL.md create mode 100644 packages/mosaic/framework/tools/lease-broker/recover-context.py diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index d7057a55..af8cc359 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -3,7 +3,8 @@ ## Compaction refresh lease broker - [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings. -- [Broker operations](guides/lease-broker-operations.md) — protected paths, startup, fail-closed recovery posture, distinct-principal deployment, and residual risk. +- [Broker operations](guides/lease-broker-operations.md) — protected paths, startup, constrained recovery, fail-closed posture, distinct-principal deployment, and residual risk. +- [Constrained recovery skill](../packages/mosaic/framework/skills/mosaic-context-refresh/SKILL.md) — source-resident thin wrapper, receipt scope, C4 replay boundary, and T-C middle-drop disclosure. - [Lease-broker security notes](architecture/lease-broker-security.md) — identity, whole-class authorization, threat boundaries, and coordinator review requirements. - [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary. - [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window. diff --git a/docs/architecture/lease-broker-protocol.md b/docs/architecture/lease-broker-protocol.md index 41ccda1d..707e3e91 100644 --- a/docs/architecture/lease-broker-protocol.md +++ b/docs/architecture/lease-broker-protocol.md @@ -13,12 +13,14 @@ Each connection carries exactly one UTF-8 JSON object followed by one newline, c - `mint_token`: authenticated identity plus `binding` containing exactly `compaction_epoch`, `request_epoch`, `h_source`, `h_payload`, and `schema_version`. - `consume_token`: authenticated identity plus `token`. - `begin_verification`: authenticated identity, runtime (`claude` or `pi`), cycle `binding`, and a TTL no greater than 300 seconds. The broker revokes existing authority first, enters `PENDING_VERIFICATION`, and returns a single-use promotion token. +- `begin_recovery`: the constrained recovery entrypoint. It rejects caller-provided receipt/challenge fields and delegates to the same `begin_verification` transition, but reports `PENDING_DELIVERY` and marks the volatile cycle as recovery-owned. +- `complete_recovery`: authenticated identity only. It rejects caller-provided receipt/challenge fields, obtains the current recovery challenge only from broker state, and delegates to the same trusted-observer → evidence → consume → promote sequence. An observation failure revokes recovery authority; retry starts a fresh challenge. - `promote_lease`: authenticated identity plus the exact pending promotion token. The broker commits token consumption before making `VERIFIED` visible. - `revoke_lease`: authenticated observer signal; deletes pending tokens and makes the session `UNVERIFIED` immediately. WI-3 Claude/Pi hooks send this existing action; `runtime` and bounded `reason` fields are diagnostic input only and never identity authority. - `authorize_tool`: authenticated identity, runtime, and exact runtime-reported tool name. The broker returns an explicit allow/deny decision from the whole-class policy and current lease. A higher generation for the same anchor atomically replaces the stored incarnation and deletes all prior tokens and lease authority for that session. A lower generation is stale. Runtime descendants resolve the current generation from an owner-only, locked generation file created by the register-before-exec launcher; reload/new/resume/fork observers advance and `fsync` it before broker revocation. This supports generation replacement even when PID/starttime do not change. Tokens are 256-bit values from the operating-system cryptographic RNG and are single use. At most 256 pending tokens may be persisted; another mint fails with `TOKEN_CAPACITY` before mutation. Successful consumption deletes the token, while a replay still fails with `TOKEN_REPLAY`. Live v1 token records retain the existing `consumed: false` schema. -VERIFIED leases are volatile and monotonic-time bounded: broker restart, generation change, explicit observer revocation, or expiry returns the session to `UNVERIFIED`. `begin_verification` always revokes before minting a new prerequisite. `promote_lease` is valid only from the matching pending cycle; persistence failure rolls token and lease state back, while post-rename durability uncertainty terminates the broker. The WI-1 token is the atomic promotion prerequisite substrate. A later receipt implementation must satisfy that prerequisite but cannot replace the mechanical mutator gate as safety authority. +VERIFIED leases are volatile and monotonic-time bounded: broker restart, generation change, explicit observer revocation, or expiry returns the session to `UNVERIFIED`. `begin_verification` always revokes before minting a new prerequisite. `begin_recovery` reuses that exact transition and mints a new challenge, so a normal-path receipt/challenge cannot be replayed through recovery. `promote_lease` is valid only from the matching pending cycle; persistence failure rolls token and lease state back, while post-rename durability uncertainty terminates the broker. The WI-1 token is the atomic promotion prerequisite substrate. Receipt evidence is a T-A delivery/liveness prerequisite only; it cannot replace the mechanical mutator gate as safety authority. A tail-preserving middle drop is not receipt-detectable and remains the disclosed T-C/server-side residual. State replacement serializes and enforces the 4 MiB maximum before opening a temporary file, then uses a mode-`0600` temporary file, `fsync`, atomic rename, and parent-directory `fsync`. Every broker mutation snapshots the prior v1 state. A commit failure before rename restores that snapshot and leaves durable state unchanged. A failure after rename makes durability uncertain, so the store is poisoned without rolling memory back and the daemon terminates rather than serving with divergent state. Existing state is opened without following symlinks, must be a bounded regular file at mode `0600`, and is fully schema- and invariant-validated before use. Persisted tokens must be unconsumed, match their session's current generation, and remain within the 256-token cap. Session identity is uniquely keyed by `(anchor_pid,anchor_starttime)`; duplicate logical sessions for one anchor refuse startup. State integrity or mode failures refuse startup. The daemon does not log session IDs or tokens. diff --git a/docs/compaction-refresh/probes/p6_constrained_recovery.py b/docs/compaction-refresh/probes/p6_constrained_recovery.py new file mode 100644 index 00000000..101f9c95 --- /dev/null +++ b/docs/compaction-refresh/probes/p6_constrained_recovery.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""P6 constrained-recovery probe; BUILT ONLY and Mos-gated. + +DO NOT self-fire. Under Mos authorization only: + python3 -I -S -B docs/compaction-refresh/probes/p6_constrained_recovery.py + +The default three isolated runs each start a fresh shipped daemon on a private +Unix socket and drive the shipped recovery *command* out of process. This +probe never resets broker state, replaces the observer seam, or reimplements +promotion: it exercises `recover-context.py` and the daemon's real socket. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import importlib.util +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path + + +HERE = Path(__file__).resolve().parent +REPOSITORY = HERE.parents[2] +TOOLS = REPOSITORY / "packages/mosaic/framework/tools/lease-broker" +DAEMON = TOOLS / "daemon.py" +RECOVERY_COMMAND = TOOLS / "recover-context.py" +FRAGMENTS = TOOLS / "normative_fragments.py" + + +def load_shipped_fragments(): + spec = importlib.util.spec_from_file_location("p6_shipped_fragments", FRAGMENTS) + if spec is None or spec.loader is None: + raise RuntimeError("shipped normative construction unavailable") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def request(socket_path: Path, value: dict[str, object]) -> dict[str, object]: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(3.0) + connection.connect(str(socket_path)) + connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode()) + connection.shutdown(socket.SHUT_WR) + response = bytearray() + while True: + chunk = connection.recv(4096) + if not chunk: + break + response.extend(chunk) + if not response.endswith(b"\n") or response.count(b"\n") != 1: + raise AssertionError(f"unframed broker reply: {bytes(response)!r}") + reply = json.loads(response[:-1]) + if not isinstance(reply, dict): + raise AssertionError("broker reply is not an object") + return reply + + +def wait_ready(process: subprocess.Popen[str], socket_path: Path) -> None: + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + if socket_path.exists(): + return + if process.poll() is not None: + output = process.stdout.read() if process.stdout is not None else "" + raise RuntimeError(f"shipped daemon exited before READY: {output}") + time.sleep(0.02) + raise TimeoutError("shipped daemon did not create private probe socket") + + +def recovery_command(arguments: list[str], environment: dict[str, str]) -> dict[str, object]: + completed = subprocess.run( + [sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), *arguments], + text=True, + capture_output=True, + env=environment, + check=False, + ) + if not completed.stdout.endswith("\n"): + raise AssertionError(f"recovery command omitted framed result: {completed.stderr!r}") + reply = json.loads(completed.stdout) + if not isinstance(reply, dict): + raise AssertionError("recovery command result is not an object") + return reply + + +def run_once(index: int) -> None: + # Parity guard: this is an out-of-process driver of the shipped recovery + # entrypoint, not a shadow receipt/promotion implementation. + source = RECOVERY_COMMAND.read_text(encoding="utf-8") + if '"action": "begin_recovery"' not in source or '"action": "complete_recovery"' not in source: + raise AssertionError("P6 parity guard: recovery command no longer drives shipped broker entrypoints") + + fragments = load_shipped_fragments() + root = Path(tempfile.mkdtemp(prefix=f"mosaic-p6-recovery-{index}-")) + os.chmod(root, 0o700) + socket_path = root / "broker.sock" + state_path = root / "state.json" + observer_path = root / "test-observer.json" + construction_path = root / "construction.json" + content = b"P6 constrained recovery fixture\n" + construction = { + "manifest_version": 1, + "generator_version": "p6-constrained-recovery", + "fragments": [{ + "source_id": "authority/p6", + "content_base64": base64.b64encode(content).decode("ascii"), + "expected_sha256": hashlib.sha256(content).hexdigest(), + }], + } + construction_path.write_text(json.dumps(construction), encoding="utf-8") + os.chmod(construction_path, 0o600) + process = subprocess.Popen( + [sys.executable, "-I", "-S", "-B", str(DAEMON), "--socket", str(socket_path), + "--state", str(state_path), "--test-observer-file", str(observer_path)], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + wait_ready(process, socket_path) + registered = request(socket_path, {"action": "register_anchor", "runtime_generation": 1}) + session_id = registered.get("session_id") + if registered.get("ok") is not True or not isinstance(session_id, str): + raise AssertionError(f"broker anchor registration failed: {registered!r}") + built = fragments.build_payload_from_wire(construction) + normal = request(socket_path, { + "action": "begin_verification", "session_id": session_id, "runtime_generation": 1, + "runtime": "pi", "construction": construction, + "binding": {"compaction_epoch": index, "request_epoch": index + 100, + "h_source": built.h_source, "h_payload": built.h_payload, "schema_version": 1}, + }) + normal_challenge = normal.get("receipt_challenge") + normal_receipt = normal.get("receipt") + if not isinstance(normal_challenge, str) or not isinstance(normal_receipt, str): + raise AssertionError("normal path did not mint a receipt challenge") + environment = { + **os.environ, + "MOSAIC_LEASE_BROKER_SOCKET": str(socket_path), + "MOSAIC_LEASE_SESSION_ID": session_id, + "MOSAIC_RUNTIME_GENERATION": "1", + "MOSAIC_LEASE_RUNTIME": "pi", + } + recovery = recovery_command([ + "begin", "--construction", str(construction_path), "--compaction-epoch", str(index + 10), + "--request-epoch", str(index + 110), + ], environment) + challenge = recovery.get("receipt_challenge") + receipt = recovery.get("receipt") + if recovery.get("state") != "PENDING_DELIVERY" or not isinstance(challenge, str) or not isinstance(receipt, str): + raise AssertionError(f"recovery command did not drive pending delivery: {recovery!r}") + if challenge == normal_challenge: + raise AssertionError("recovery reused a normal-path challenge") + + # C4: a normal-path receipt is not an admissible recovery receipt. + observer_path.write_text(json.dumps({ + "session_id": session_id, "runtime_generation": 1, "latest_assistant_message": normal_receipt, + }), encoding="utf-8") + os.chmod(observer_path, 0o600) + refused = recovery_command(["complete"], environment) + if refused.get("ok") is not False or refused.get("code") != "RECEIPT_MISMATCH": + raise AssertionError(f"normal-path receipt replay was not refused: {refused!r}") + + # Begin a new recovery cycle, then prove shipped evidence -> consume -> + # promote ordering and post-consumption non-repromotion. + recovery = recovery_command([ + "begin", "--construction", str(construction_path), "--compaction-epoch", str(index + 20), + "--request-epoch", str(index + 120), + ], environment) + receipt = recovery.get("receipt") + if recovery.get("state") != "PENDING_DELIVERY" or not isinstance(receipt, str): + raise AssertionError(f"fresh recovery retry did not pend: {recovery!r}") + observer_path.write_text(json.dumps({ + "session_id": session_id, "runtime_generation": 1, "latest_assistant_message": receipt, + }), encoding="utf-8") + os.chmod(observer_path, 0o600) + promoted = recovery_command(["complete"], environment) + if promoted.get("ok") is not True or promoted.get("state") != "VERIFIED": + raise AssertionError(f"recovery consume-before-promote failed: {promoted!r}") + replay = recovery_command(["complete"], environment) + if replay.get("ok") is not False or replay.get("code") != "INVALID_LEASE_TRANSITION": + raise AssertionError(f"consumed recovery challenge re-promoted: {replay!r}") + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=3.0) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + shutil.rmtree(root, ignore_errors=True) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--runs", type=int, default=3) + arguments = parser.parse_args() + if arguments.runs != 3: + raise SystemExit("P6 requires exactly three isolated runs") + for index in range(arguments.runs): + run_once(index) + print("P6 constrained recovery probe PASS: 3 isolated shipped recovery-command runs") + + +if __name__ == "__main__": + main() diff --git a/docs/guides/lease-broker-operations.md b/docs/guides/lease-broker-operations.md index af4c99e6..b0fac791 100644 --- a/docs/guides/lease-broker-operations.md +++ b/docs/guides/lease-broker-operations.md @@ -31,7 +31,11 @@ The same check runs in the Mosaic package test suite and therefore in root CI. A Clients must complete the request boundary before waiting for a reply. After sending the single JSON object and its terminating newline, the client **MUST half-close the socket's write side** (`shutdown(SHUT_WR)` in POSIX clients; `socket.end()` in Node) and only then await the response. Merely calling `write()` and waiting is invalid: the broker waits for EOF to enforce the exact-one-frame contract and fails closed at its one-second deadline. Do not replace `end()` with `write()` in client helpers. A delayed second frame remains malformed and is rejected. -There is no automated recovery workflow yet. `mosaic_context_recover` is reserved as the only unverified mutator class, but its fixed payload/receipt implementation lands in a later WI. After a runtime exits, its `generation-.state` file may be removed only after verifying that no process for that broker-minted session remains; stale files carry no lease authority but should be retained during incident analysis. After a broker crash, preserve the protected state file and restart only after verifying that no broker owns the socket. Restart intentionally clears all volatile VERIFIED leases. A leftover socket requires an operator to verify the owning service is stopped and remove that exact socket deliberately. Corrupt, oversized, symlinked, or non-regular state fails closed; do not overwrite it. Preserve it for incident review and establish new state only through an explicit operational decision, which invalidates prior sessions and tokens. +`mosaic_context_recover` is the only unverified mutator class. Its durable `mosaic-context-refresh` skill is a thin wrapper over `tools/lease-broker/recover-context.py`: `begin` has the broker rebuild the validated `B_payload`/`H_payload`, revoke first, and mint a new `PENDING_DELIVERY` receipt challenge; `complete` accepts neither receipt text nor a challenge argument. It uses only the trusted `ReceiptObserver` seam for the exact latest assistant entry, commits evidence, consumes the broker-held challenge, and promotes VERIFIED last. A normal-path receipt cannot be replayed through recovery because each retry begins a distinct recovery cycle and recovery completion cannot receive caller-presented evidence. + +Receipt honesty is load-bearing: absent, malformed, prefix-truncated, and observable adapter-mutated terminal receipts do not promote. A tail-only case is non-promoting only where the concrete terminal payload is malformed or observably incomplete. A tail-preserving middle drop is **not receipt-detectable**; it is the disclosed T-C injection-contract residual deferred to WI-7 server-side evidence. The receipt remains a T-A delivery/liveness prerequisite, never a safety, obedience, or residency proof. The framework skill is source-resident and bridge-projected on install/upgrade; do not hand-create a live runtime symlink. + +After a runtime exits, its `generation-.state` file may be removed only after verifying that no process for that broker-minted session remains; stale files carry no lease authority but should be retained during incident analysis. After a broker crash, preserve the protected state file and restart only after verifying that no broker owns the socket. Restart intentionally clears all volatile VERIFIED leases. A leftover socket requires an operator to verify the owning service is stopped and remove that exact socket deliberately. Corrupt, oversized, symlinked, or non-regular state fails closed; do not overwrite it. Preserve it for incident review and establish new state only through an explicit operational decision, which invalidates prior sessions and tokens. ## Security posture diff --git a/docs/scratchpads/833-constrained-recovery-command.md b/docs/scratchpads/833-constrained-recovery-command.md index a73010b3..b4ea57af 100644 --- a/docs/scratchpads/833-constrained-recovery-command.md +++ b/docs/scratchpads/833-constrained-recovery-command.md @@ -11,4 +11,5 @@ 4. Build an unfired, default-3-run P6 standalone real-socket driver; it is not added to package scripts and will not be run. 5. Run targeted broker/mutator/receipt suites, lint, and format check; report head to `mosaic-100` and stop. - **Risks:** The observer can only represent an exact latest message. Tail-preserving middle-drop is intentionally not claimed receipt-detectable (T-C residual deferred to WI-7 server evidence). +- **Evidence:** RED test committed at `3b5513bd6efad0c06b599fc759a66cff4286db04`; expected `UNKNOWN_ACTION` is logged in `/home/hermes/agent-work/reviews/833-wi6-red.log`. GREEN: `pnpm --filter @mosaicstack/mosaic run test:framework-shell` passed (deadline 2, normative 5, receipt 5, recovery 4, runtime tools 25, launch guard 12, inventory 14/14); `git diff --check`; Python `py_compile`; and a tmp-only execution of the real #824 `syncClaudeSkills` bridge projected the source skill (`WI-6 #824 tmp projection PASS`). P6 was built but not fired. The ordinary package Vitest/lint/typecheck/root-format commands cannot resolve their executables in this intentionally dependency-free fresh worktree (`node_modules` absent); no install/symlink workaround was used. - **Budget:** No explicit task token cap was supplied; scope is fixed to WI-6 and no unrelated behavior will be added. diff --git a/packages/mosaic/framework/skills/mosaic-context-refresh/SKILL.md b/packages/mosaic/framework/skills/mosaic-context-refresh/SKILL.md new file mode 100644 index 00000000..6b669970 --- /dev/null +++ b/packages/mosaic/framework/skills/mosaic-context-refresh/SKILL.md @@ -0,0 +1,56 @@ +--- +name: mosaic-context-refresh +description: Run the constrained Mosaic context-recovery flow after compaction or directive-loss. This is a thin wrapper over the broker-backed recovery command; it never treats a receipt as a safety or residency proof. +--- + +# mosaic-context-refresh + +Use this only after compaction, session resume, or confirmed directive drift. It invokes the +**single ungated mutator**, `tools/lease-broker/recover-context.py`; every other consequential +mutator remains behind the verified lease gate. + +## Wrapper procedure + +1. The runtime supplies the exact validated normative-fragment construction and the current + compaction/request epochs, then invokes: + + ```bash + python3 "$MOSAIC_HOME/tools/lease-broker/recover-context.py" begin \ + --construction "$MOSAIC_CONTEXT_REFRESH_CONSTRUCTION" \ + --compaction-epoch "$MOSAIC_COMPACTION_EPOCH" \ + --request-epoch "$MOSAIC_REQUEST_EPOCH" + ``` + + The command delegates to the shipped WI-5 broker transition: revoke first, build the canonical + `B_payload`/`H_payload`, enter `PENDING_DELIVERY`, and mint a fresh one-time challenge. It prints + the terminal receipt envelope to deliver exactly as returned. + +2. The current assistant message copies that one terminal receipt verbatim. It does not compute a + hash, add prose, quote a prior receipt, or present a caller-supplied receipt/challenge. +3. At `message_end`, the trusted runtime `ReceiptObserver` seam invokes: + + ```bash + python3 "$MOSAIC_HOME/tools/lease-broker/recover-context.py" complete + ``` + + Completion supplies no receipt or challenge argument. The broker observes the exact latest + assistant entry, commits evidence, consumes its own fresh challenge, and promotes VERIFIED last. + If observation is absent, malformed, stale, or duplicated, recovery remains UNVERIFIED and a + retry begins a new cycle. + +## Scope and honesty + +- A receipt from the normal verification path cannot be replayed through recovery: recovery mints a + distinct current challenge and does not accept caller-provided receipt text as evidence. +- Observable absent, malformed, prefix-truncated, and adapter-mutated terminal receipts do not + promote. “Tail-only” is non-promoting only when the delivered terminal bytes are concretely + malformed or incomplete. +- **Negative capability:** a tail-preserving middle drop is not represented as receipt-detectable. + It is a T-C injection-contract residual deferred to WI-7 server-side evidence; do not claim this + skill or receipt catches it. +- The receipt is a T-A delivery/liveness prerequisite only. It never proves obedience, comprehension, + durable residency, or safety; the whole mutator-class gate and server-side branch protection retain + those roles. + +This source-resident skill is projected by the Mosaic skill bridge after framework install/upgrade. +Do not create a live symlink manually. diff --git a/packages/mosaic/framework/tools/lease-broker/daemon.py b/packages/mosaic/framework/tools/lease-broker/daemon.py index 8df693ff..4dcd5c66 100644 --- a/packages/mosaic/framework/tools/lease-broker/daemon.py +++ b/packages/mosaic/framework/tools/lease-broker/daemon.py @@ -400,7 +400,7 @@ class Broker: response = self._handle(peer, request) if self.store.value != previous: self.store.commit() - if request.get("action") == "promote_lease": + if request.get("action") in {"promote_lease", "complete_recovery"}: session_id = request.get("session_id") if not isinstance(session_id, str): raise BrokerFailure("INVALID_IDENTITY") @@ -469,6 +469,71 @@ class Broker: raise BrokerFailure("TOKEN_REPLAY") del self.store.tokens()[token_value] return {"ok": True} + if action == "begin_recovery": + # Recovery is the one ungated mutator, but it is not a second + # receipt protocol. It delegates to this exact normal-path + # transition, then marks its volatile pending cycle so completion + # can obtain the broker-minted challenge internally. Caller-supplied + # receipt text is never an input to recovery. + if any(field in request for field in ("receipt", "latest_assistant_message", "receipt_challenge")): + raise BrokerFailure("INVALID_RECOVERY_REQUEST") + normal_request = dict(request) + normal_request["action"] = "begin_verification" + response = self._handle(peer, normal_request) + session_id = response.get("session_id") + if not isinstance(session_id, str): + # begin_verification deliberately does not return identity; + # recover it only after its authenticated shared transition. + candidate = request.get("session_id") + if not isinstance(candidate, str): + raise BrokerFailure("INVALID_IDENTITY") + session_id = candidate + lease = self.leases.get(session_id) + if not isinstance(lease, dict) or lease.get("state") != LEASE_PENDING: + raise BrokerFailure("STATE_INTEGRITY") + lease["cycle_kind"] = "recovery" + response["state"] = "PENDING_DELIVERY" + return response + if action == "complete_recovery": + if any(field in request for field in ("receipt", "latest_assistant_message", "receipt_challenge")): + raise BrokerFailure("INVALID_RECOVERY_REQUEST") + session_id, _ = self.authenticate(peer_pid, request) + lease = self.leases.get(session_id) + challenge = lease.get("receipt_challenge") if isinstance(lease, dict) else None + if ( + not isinstance(lease, dict) + or lease.get("cycle_kind") != "recovery" + or lease.get("state") != LEASE_PENDING + or not isinstance(challenge, str) + ): + raise BrokerFailure("INVALID_LEASE_TRANSITION") + try: + # Reuse the shipped observe -> evidence commit -> consume -> + # promote transition. The recovery caller supplies neither a + # normal-path receipt nor a challenge; the trusted observer + # and current broker cycle remain the sole evidence authority. + observed_request = { + "action": "observe_receipt", + "session_id": session_id, + "runtime_generation": request["runtime_generation"], + "receipt_challenge": challenge, + } + self._handle(peer, observed_request) + return self._handle(peer, { + "action": "promote_lease", + "session_id": session_id, + "runtime_generation": request["runtime_generation"], + "receipt_challenge": challenge, + }) + except Exception: + # A malformed, absent, or stale observed receipt must leave + # no pending recovery capability or live lease. A retry mints + # a new challenge through the shared begin transition. + self.revoke_session_authority(session_id) + self._rejected_cycle_fence = ( + copy.deepcopy(self.store.value), copy.deepcopy(self.leases) + ) + raise if action == "begin_verification": session_id, _ = self.authenticate(peer_pid, request) runtime = request.get("runtime") diff --git a/packages/mosaic/framework/tools/lease-broker/recover-context.py b/packages/mosaic/framework/tools/lease-broker/recover-context.py new file mode 100644 index 00000000..2bb58156 --- /dev/null +++ b/packages/mosaic/framework/tools/lease-broker/recover-context.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Constrained recovery command: the sole ungated Mosaic mutator. + +This is deliberately a thin client of the broker's recovery entrypoint. It +never accepts receipt text or a caller-provided challenge: the broker mints the +fresh challenge, delivers its exact receipt envelope, and later asks the +trusted ReceiptObserver seam to observe that same pending cycle. +""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Final + +from normative_fragments import build_payload_from_wire + +MAX_FRAME: Final = 64 * 1024 +BROKER_TIMEOUT_SECONDS: Final = 1.5 + + +def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]: + payload = (json.dumps(request, separators=(",", ":")) + "\n").encode() + if len(payload) > MAX_FRAME: + raise ValueError("recovery request too large") + response = bytearray() + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(BROKER_TIMEOUT_SECONDS) + connection.connect(str(socket_path)) + connection.sendall(payload) + connection.shutdown(socket.SHUT_WR) + while len(response) <= MAX_FRAME: + chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(response))) + if not chunk: + break + response.extend(chunk) + if len(response) > MAX_FRAME or not response.endswith(b"\n"): + raise ValueError("invalid broker reply") + value = json.loads(response) + if not isinstance(value, dict): + raise ValueError("invalid broker reply") + return value + + +def load_construction(path: Path) -> dict[str, object]: + raw = path.read_bytes() + if len(raw) > MAX_FRAME: + raise ValueError("construction exceeds broker frame limit") + value = json.loads(raw) + if not isinstance(value, dict): + raise ValueError("construction must be an object") + return value + + +def identity(environ: Mapping[str, str]) -> tuple[Path, str, int, str]: + socket_path = Path(environ["MOSAIC_LEASE_BROKER_SOCKET"]) + session_id = environ["MOSAIC_LEASE_SESSION_ID"] + generation = int(environ["MOSAIC_RUNTIME_GENERATION"]) + runtime = environ["MOSAIC_LEASE_RUNTIME"] + if generation < 0 or runtime not in {"claude", "pi"}: + raise ValueError("invalid runtime identity") + return socket_path, session_id, generation, runtime + + +def begin( + construction_path: Path, + compaction_epoch: int, + request_epoch: int, + environ: Mapping[str, str], +) -> dict[str, object]: + if compaction_epoch < 0 or request_epoch < 0: + raise ValueError("epochs must be non-negative") + construction = load_construction(construction_path) + # Invoke the shared WI-5 construction before asking the broker to repeat + # its authoritative admission/build. No digest or receipt enters via CLI. + built = build_payload_from_wire(construction) + if ( + built.injectionDecision != "ACCEPTED" + or not built.promotion + or not isinstance(built.h_source, str) + or not isinstance(built.h_payload, str) + ): + raise ValueError("payload construction refused") + socket_path, session_id, generation, runtime = identity(environ) + return broker_request(socket_path, { + "action": "begin_recovery", + "session_id": session_id, + "runtime_generation": generation, + "runtime": runtime, + "binding": { + "compaction_epoch": compaction_epoch, + "request_epoch": request_epoch, + "h_source": built.h_source, + "h_payload": built.h_payload, + "schema_version": 1, + }, + "construction": construction, + }) + + +def complete(environ: Mapping[str, str]) -> dict[str, object]: + socket_path, session_id, generation, _runtime = identity(environ) + # No receipt or challenge argument exists: recovery completion can only use + # the broker's current recovery cycle and its trusted observer seam. + return broker_request(socket_path, { + "action": "complete_recovery", + "session_id": session_id, + "runtime_generation": generation, + }) + + +def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + subcommands = parser.add_subparsers(dest="phase", required=True) + begin_parser = subcommands.add_parser("begin", help="mint and deliver a fresh recovery receipt") + begin_parser.add_argument("--construction", required=True, type=Path) + begin_parser.add_argument("--compaction-epoch", required=True, type=int) + begin_parser.add_argument("--request-epoch", required=True, type=int) + subcommands.add_parser("complete", help="observe and promote only the current recovery receipt") + arguments = parser.parse_args(argv) + source_environment = os.environ if environ is None else environ + try: + reply = ( + begin( + arguments.construction, + arguments.compaction_epoch, + arguments.request_epoch, + source_environment, + ) + if arguments.phase == "begin" + else complete(source_environment) + ) + except (KeyError, OSError, ValueError, json.JSONDecodeError) as error: + print(f"Mosaic constrained recovery refused: {error}", file=sys.stderr) + return 2 + print(json.dumps(reply, separators=(",", ":"))) + return 0 if reply.get("ok") is True else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index c7096afe..64220d39 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh" + "test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*", diff --git a/packages/mosaic/src/commands/skill.spec.ts b/packages/mosaic/src/commands/skill.spec.ts index cc768e62..8d31765b 100644 --- a/packages/mosaic/src/commands/skill.spec.ts +++ b/packages/mosaic/src/commands/skill.spec.ts @@ -8,6 +8,7 @@ import { mkdirSync, mkdtempSync, readlinkSync, + readFileSync, rmSync, symlinkSync, writeFileSync, @@ -26,6 +27,9 @@ import { const LEGACY_SYNC_SCRIPT = fileURLToPath( new URL('../../framework/tools/_scripts/mosaic-sync-skills', import.meta.url), ); +const CONTEXT_REFRESH_SOURCE_SKILL = fileURLToPath( + new URL('../../framework/skills/mosaic-context-refresh/SKILL.md', import.meta.url), +); describe('Claude skill bridge', () => { let root: string; @@ -360,6 +364,25 @@ describe('Claude skill bridge', () => { }); describe('syncClaudeSkills', () => { + it('projects the durable context-refresh skill through the #824 bridge in a tmp root', () => { + expect(existsSync(CONTEXT_REFRESH_SOURCE_SKILL)).toBe(true); + const source = readFileSync(CONTEXT_REFRESH_SOURCE_SKILL, 'utf8'); + expect(source).toContain('recover-context.py'); + expect(source).toContain('middle drop is not represented as receipt-detectable'); + const skillDir = createSkill('mosaic-context-refresh'); + writeFileSync(join(skillDir, 'SKILL.md'), source); + + const result = syncClaudeSkills(paths); + + expect(result).toEqual({ + registered: ['mosaic-context-refresh'], + repaired: [], + unchanged: [], + conflicts: [], + }); + expectCorrectLink('mosaic-context-refresh'); + }); + it('generically creates every missing canonical link and repairs managed broken links', () => { createSkill('added-after-setup'); createSkill('another-new-skill');