diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index 52083a5..a3c32f4 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -4,7 +4,8 @@ - [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. -- [WI-1 security notes](architecture/lease-broker-security.md) — threat-boundary summary and coordinator review requirements. +- [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. ## CLI and skill management diff --git a/docs/architecture/lease-broker-protocol.md b/docs/architecture/lease-broker-protocol.md index 3aaf6da..e2b1cdb 100644 --- a/docs/architecture/lease-broker-protocol.md +++ b/docs/architecture/lease-broker-protocol.md @@ -12,7 +12,13 @@ Each connection carries exactly one UTF-8 JSON object followed by one newline, c - `authenticate`: `action`, broker-minted `session_id`, non-negative `runtime_generation`. - `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. +- `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. +- `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 for that session. A lower generation is stale. 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. Their persisted binding is the WI-1 substrate for later receipt work; WI-1 does not implement receipts, promotion, hooks, payload builders, mutator gates, or recovery. +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. 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. 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/architecture/lease-broker-security.md b/docs/architecture/lease-broker-security.md index ca73b0e..b90466f 100644 --- a/docs/architecture/lease-broker-security.md +++ b/docs/architecture/lease-broker-security.md @@ -6,6 +6,8 @@ - Session IDs and cycle tokens use the OS cryptographic RNG. `Math.random` and model output are not token sources. - Framing and persistence failures fail closed. Sensitive tokens are not logged. - Built-in `0700`/`0600` filesystem modes provide same-principal hardening only, not socket authenticity against the same UID. WI-1 provides no distinct-principal isolation. That stronger deployment requires an external protected proxy, ACL, or service boundary, and the boundary must preserve authenticated client identity for the broker's `SO_PEERCRED` and ancestry authorization rather than substituting a shared proxy identity. -- WI-2+ security surfaces—receipts, promotion transactions, mutator gates, hooks, payload construction, and recovery—are explicitly out of scope. +- WI-2 whole-class authorization denies every consequential, unknown, and custom tool while UNVERIFIED; it does not inspect shell strings or trust wrapper selection. +- WI-2 promotion consumes a WI-1 cycle token before VERIFIED becomes visible. Observer revocation, runtime-generation replacement, broker restart, and monotonic TTL expiry remove authority. +- Receipt observation, payload construction, compaction observers, and constrained recovery implementation remain later surfaces. A receipt can become a promotion prerequisite but is never the safety mechanism. -Coordinator security review must rerun the real socket/peercred acceptance suite on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration. +Coordinator security review must rerun the real socket/peercred and mutator-gate acceptance suites on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration. diff --git a/docs/architecture/mutator-class-gate.md b/docs/architecture/mutator-class-gate.md new file mode 100644 index 0000000..dbb1f6c --- /dev/null +++ b/docs/architecture/mutator-class-gate.md @@ -0,0 +1,39 @@ +# Whole mutator-class lease gate + +WI-2 adds the framework-native authorization boundary for Claude and Pi. Every runtime-reported tool name reaches the lease broker before execution. The gate classifies capabilities by the whole tool class; it never parses a Bash command to decide whether that particular string looks read-only. + +## Default-deny policy + +While a session is not VERIFIED, only these exact classes are allowed: + +- Claude: `Read`, `Grep`, `Glob`, `Ls`, `Find` +- Pi: `read`, `grep`, `find`, `ls` +- Both runtimes: the fixed `mosaic_context_recover` primitive + +Every other built-in, unknown tool, and custom/MCP tool is consequential by default and is denied. This includes Claude `Bash`, `Edit`, `Write`, and `NotebookEdit`, plus Pi `bash`, `edit`, and `write`. A compromised model therefore cannot bypass Mosaic wrappers by selecting raw `git`, `curl`, `kubectl`, provider, deployment, or filesystem commands inside a generic mutator—the generic mutator itself is blocked before its input executes. + +## Broker-owned transition order + +The authenticated broker is the sole lease writer: + +1. `begin_verification` revokes existing authority and pending tokens first, then records `PENDING_VERIFICATION` and mints one WI-1 single-use promotion token bound to the exact cycle. +2. `promote_lease` accepts only that session/generation/binding/token combination. +3. Token consumption commits before the volatile lease becomes VERIFIED. Promotion is last and cannot be reached directly from UNVERIFIED. +4. `revoke_lease`, a runtime-generation increase, broker restart, or monotonic expiry removes mutator authority. + +The initial TTL is capped at the ratified 300-second maximum. A caller may request a shorter positive TTL but cannot lengthen the maximum. Dual compaction-hook miss within an unexpired lease remains the ratified bounded T-A residual; once either observer revokes or TTL expires, the next consequential tool is denied. + +A receipt is only a future promotion prerequisite. It is not an obedience, residency, or safety proof and never replaces this mechanical gate. + +## Runtime adapters + +`launch-runtime.py` registers itself with the broker and then `exec`s Claude or Pi so PID/starttime remain the authenticated parent anchor. It exports only the broker-minted session ID and current generation to descendants. + +- Claude installs `mutator-gate.py` as an all-tools (`.*`) `PreToolUse` hook. +- Pi invokes the same executable from its `tool_call` handler. + +The executable submits the runtime's actual tool name to `authorize_tool`. Missing identity, malformed input/reply, timeout, broker unavailability, or denial exits with status 2 and blocks fail-closed. + +## Assurance boundary + +This closes T-A after an observer fires or lease expiry and T-B for in-runtime tool calls. Hook/extension absence, a runtime executing outside the gated launcher, ptrace/same-UID broker replacement, and other fully rotted behavior remain T-C. Server-side branch protection and required PR review/CI remain the irreducible line for protected repository mutations. diff --git a/docs/guides/lease-broker-operations.md b/docs/guides/lease-broker-operations.md index ed47fa6..9f16613 100644 --- a/docs/guides/lease-broker-operations.md +++ b/docs/guides/lease-broker-operations.md @@ -10,9 +10,18 @@ python3 "$MOSAIC_HOME/tools/lease-broker/daemon.py" \ The broker refuses an existing parent directory whose mode is not exactly `0700`, an existing state file not at `0600`, corrupt/incompatible state, or an already-existing socket path. After bind it sets the socket to `0600`. It never silently unlinks a pre-existing socket. On normal termination it unlinks only the socket inode it created, so it does not remove a replacement path. +Before launching Claude or Pi, export the socket path; `mosaic` then runs the runtime through the packaged register-and-exec wrapper: + +```bash +export MOSAIC_LEASE_BROKER_SOCKET=/run/user/1000/mosaic-lease/broker.sock +mosaic claude # or: mosaic pi +``` + +The wrapper obtains a broker-minted session ID and `exec`s the runtime without changing its PID/starttime anchor. The all-tools Claude `PreToolUse` hook and Pi `tool_call` handler inherit that identity. Broker registration failure denies runtime launch; missing identity, broker timeout/unavailability, and malformed replies block tools fail-closed. + 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 in WI-1. After a crash, preserve the protected state file and restart only after verifying that no broker owns the socket. 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. +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 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/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md index d933a95..c55d7f3 100644 --- a/docs/scratchpads/829-mutator-gate.md +++ b/docs/scratchpads/829-mutator-gate.md @@ -36,6 +36,27 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE - Initial lease TTL is capped at ratified 300 seconds; callers may only shorten it. - Working budget assumption: 35K tokens; reduce documentation/refactor breadth before touching locked scope if pressure rises. +## Progress and verification + +- RED #1: all 5 initial real-socket contract tests failed on WI-1 with `UNKNOWN_ACTION` or missing adapter behavior. +- RED #2: register-before-exec runtime test failed because `launch-runtime.py` did not exist. +- GREEN: broker-owned volatile lease state, 300-second maximum monotonic TTL, WI-1 token-backed promotion, all-tools runtime gate, Claude/Pi wiring, and register-before-exec launcher delivered without changing WI-1 peercred/ancestry authority. +- Focused broker + gate acceptance: `43/43` GREEN. +- Instrumented Python coverage: `88%` total — daemon `89%`, register/exec launcher `86%`, runtime gate `86%`. +- Full repository suite: `43/43` Turbo tasks GREEN; `@mosaicstack/mosaic` `72/72` files and `1,377/1,377` tests GREEN. +- Root typecheck: `42/42`; lint: `23/23`; format check and `git diff --check`: GREEN. +- No author self-review was run. Exact-head terra CODE and Opus SECREV remain coordinator-owned gates. + +## Acceptance mapping + +| Acceptance criterion | Evidence | +| --- | --- | +| No consequential mutator succeeds while UNVERIFIED after observer revoke or TTL | `observer revocation and monotonic TTL expiry deny the next mutator` real-socket acceptance test | +| T-B compromised-tool bypass is covered by the whole gate | `T-B raw and custom mutator tools are default-denied without shell parsing` across Claude/Pi built-ins, raw Bash class, MCP/custom/unknown tools | +| Revoke-first / promote-last is structural | `revoke-first and promote-last structurally bracket mutator authority`; direct promotion rejected, pending remains denied, token consumption commits before VERIFIED | +| Consume WI-1 auth/lease substrate | All transitions and decisions traverse merged peercred/ancestry authentication; promotion consumes the exact WI-1 CSPRNG cycle token | +| M1 Claude + Pi | Claude `.*` PreToolUse and Pi `tool_call` invoke the same broker gate; register-before-exec test proves broker-minted parent identity reaches runtime descendants | + ## Locked discipline - Re-verify and read the four authority artifacts before design or code. diff --git a/packages/mosaic/framework/runtime/claude/settings.json b/packages/mosaic/framework/runtime/claude/settings.json index 0318d9e..ee3cd0e 100644 --- a/packages/mosaic/framework/runtime/claude/settings.json +++ b/packages/mosaic/framework/runtime/claude/settings.json @@ -2,6 +2,16 @@ "model": "opus", "hooks": { "PreToolUse": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude", + "timeout": 3 + } + ] + }, { "matcher": "Write|Edit|MultiEdit", "hooks": [ diff --git a/packages/mosaic/framework/runtime/pi/mosaic-extension.ts b/packages/mosaic/framework/runtime/pi/mosaic-extension.ts index cbf783f..7ef7ee5 100644 --- a/packages/mosaic/framework/runtime/pi/mosaic-extension.ts +++ b/packages/mosaic/framework/runtime/pi/mosaic-extension.ts @@ -28,6 +28,7 @@ import { execSync, spawnSync } from 'node:child_process'; // --------------------------------------------------------------------------- const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); +const MUTATOR_GATE = join(MOSAIC_HOME, 'tools', 'lease-broker', 'mutator-gate.py'); // --------------------------------------------------------------------------- // Helpers @@ -106,6 +107,23 @@ function nowIso(): string { return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); } +function checkPiMutatorGate(toolName: string): { block: true; reason: string } | undefined { + const result = spawnSync('python3', [MUTATOR_GATE, '--runtime', 'pi'], { + input: `${JSON.stringify({ tool_name: toolName })}\n`, + encoding: 'utf8', + timeout: 2_000, + env: process.env, + }); + if (result.status === 0) return undefined; + const detail = String(result.stderr ?? '') + .trim() + .split('\n')[0]; + return { + block: true, + reason: detail || 'BLOCKED: Mosaic mutator gate is unavailable or the lease is UNVERIFIED.', + }; +} + // --------------------------------------------------------------------------- // Mission detection // --------------------------------------------------------------------------- @@ -250,6 +268,11 @@ export default function register(pi: ExtensionAPI) { let hbModel: string | null = null; let hbTimer: ReturnType | null = null; + // ── Whole mutator-class authorization gate ──────────────────────────── + // Every Pi tool, including unknown/custom tools, reaches the broker-backed + // class gate before execution. Broker/script failure blocks fail-closed. + pi.on('tool_call', async (event) => checkPiMutatorGate(event.toolName)); + // ── Session Start ───────────────────────────────────────────────────── pi.on('session_start', async (_event, ctx) => { sessionCwd = process.cwd(); diff --git a/packages/mosaic/framework/tools/lease-broker/daemon.py b/packages/mosaic/framework/tools/lease-broker/daemon.py index dfcddf1..d33e453 100644 --- a/packages/mosaic/framework/tools/lease-broker/daemon.py +++ b/packages/mosaic/framework/tools/lease-broker/daemon.py @@ -24,9 +24,19 @@ MAX_FRAME: Final = 64 * 1024 MAX_STATE: Final = 4 * 1024 * 1024 MAX_PENDING_TOKENS: Final = 256 MAX_IN_FLIGHT_CONNECTIONS: Final = 16 +MAX_LEASE_TTL_SECONDS: Final = 300 STATE_VERSION: Final = 1 CONNECTION_DEADLINE_SECONDS: Final = 1.0 HEX_256_LENGTH: Final = 64 +LEASE_UNVERIFIED: Final = "UNVERIFIED" +LEASE_PENDING: Final = "PENDING_VERIFICATION" +LEASE_PENDING_PROMOTION: Final = "PENDING_PROMOTION" +LEASE_VERIFIED: Final = "VERIFIED" +READ_ONLY_TOOLS: Final = { + "claude": frozenset({"Read", "Grep", "Glob", "Ls", "Find"}), + "pi": frozenset({"read", "grep", "find", "ls"}), +} +RECOVERY_TOOL: Final = "mosaic_context_recover" class BrokerFailure(Exception): @@ -265,6 +275,9 @@ class StateStore: class Broker: def __init__(self, store: StateStore) -> None: self.store = store + # VERIFIED authority is deliberately volatile: broker restart revokes all + # leases while preserving WI-1 identity and pending-token integrity. + self.leases: dict[str, dict[str, object]] = {} def authenticate(self, peer_pid: int, request: dict[str, object]) -> tuple[str, dict[str, object]]: session_id = request.get("session_id") @@ -289,7 +302,7 @@ class Broker: raise BrokerFailure("STALE_GENERATION") if generation > current_generation: session["runtime_generation"] = generation - self.revoke_session_tokens(session_id) + self.revoke_session_authority(session_id) return session_id, session def session_for_anchor( @@ -310,19 +323,59 @@ class Broker: ]: del tokens[token_value] + def revoke_session_authority(self, session_id: str) -> None: + self.revoke_session_tokens(session_id) + session = self.store.sessions().get(session_id) + generation = session.get("runtime_generation") if isinstance(session, dict) else None + self.leases[session_id] = { + "state": LEASE_UNVERIFIED, + "runtime_generation": generation, + } + + def mint_token( + self, + session_id: str, + generation: int, + binding: dict[str, object], + ) -> str: + if len(self.store.tokens()) >= MAX_PENDING_TOKENS: + raise BrokerFailure("TOKEN_CAPACITY") + token = secrets.token_hex(32) + self.store.tokens()[token] = { + "session_id": session_id, + "runtime_generation": generation, + "binding": copy.deepcopy(binding), + "consumed": False, + } + return token + + def finish_promotion(self, session_id: str) -> None: + lease = self.leases.get(session_id) + if not isinstance(lease, dict) or lease.get("state") != LEASE_PENDING_PROMOTION: + raise BrokerFailure("STATE_INTEGRITY") + lease["state"] = LEASE_VERIFIED + def handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]: if self.store.poisoned: raise StateCommitUncertain() previous = copy.deepcopy(self.store.value) + previous_leases = copy.deepcopy(self.leases) try: response = self._handle(peer, request) if self.store.value != previous: self.store.commit() + if request.get("action") == "promote_lease": + session_id = request.get("session_id") + if not isinstance(session_id, str): + raise BrokerFailure("INVALID_IDENTITY") + self.finish_promotion(session_id) + response["state"] = LEASE_VERIFIED return response except StateCommitUncertain: raise except Exception: self.store.value = previous + self.leases = previous_leases raise def _handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]: @@ -351,7 +404,7 @@ class Broker: raise BrokerFailure("STALE_GENERATION") if generation > current_generation: session["runtime_generation"] = generation - self.revoke_session_tokens(session_id) + self.revoke_session_authority(session_id) return {"ok": True, "session_id": session_id, "peer": {"pid": peer_pid, "uid": peer_uid, "gid": peer_gid, "starttime": anchor["starttime"]}} if action == "authenticate": self.authenticate(peer_pid, request) @@ -361,15 +414,7 @@ class Broker: binding = request.get("binding") if not valid_binding(binding): raise BrokerFailure("INVALID_BINDING") - if len(self.store.tokens()) >= MAX_PENDING_TOKENS: - raise BrokerFailure("TOKEN_CAPACITY") - token = secrets.token_hex(32) - self.store.tokens()[token] = { - "session_id": session_id, - "runtime_generation": request["runtime_generation"], - "binding": binding, - "consumed": False, - } + token = self.mint_token(session_id, request["runtime_generation"], binding) return {"ok": True, "token": token} if action == "consume_token": session_id, _ = self.authenticate(peer_pid, request) @@ -379,6 +424,112 @@ class Broker: raise BrokerFailure("TOKEN_REPLAY") del self.store.tokens()[token_value] return {"ok": True} + if action == "begin_verification": + session_id, _ = self.authenticate(peer_pid, request) + runtime = request.get("runtime") + binding = request.get("binding") + ttl_seconds = request.get("ttl_seconds", MAX_LEASE_TTL_SECONDS) + if runtime not in READ_ONLY_TOOLS: + raise BrokerFailure("INVALID_RUNTIME") + if not valid_binding(binding): + raise BrokerFailure("INVALID_BINDING") + if ( + type(ttl_seconds) is not int + or ttl_seconds <= 0 + or ttl_seconds > MAX_LEASE_TTL_SECONDS + ): + raise BrokerFailure("INVALID_LEASE_TTL") + # Revoke-first is a broker operation, not advisory adapter order. + self.revoke_session_authority(session_id) + token = self.mint_token(session_id, request["runtime_generation"], binding) + self.leases[session_id] = { + "state": LEASE_PENDING, + "runtime": runtime, + "runtime_generation": request["runtime_generation"], + "binding": copy.deepcopy(binding), + "promotion_token": token, + "ttl_seconds": ttl_seconds, + } + return { + "ok": True, + "state": LEASE_PENDING, + "promotion_token": token, + } + if action == "promote_lease": + session_id, _ = self.authenticate(peer_pid, request) + promotion_token = request.get("promotion_token") + lease = self.leases.get(session_id) + if not isinstance(lease, dict) or lease.get("state") != LEASE_PENDING: + raise BrokerFailure("INVALID_LEASE_TRANSITION") + expected_token = lease.get("promotion_token") + if ( + not isinstance(promotion_token, str) + or not isinstance(expected_token, str) + or not secrets.compare_digest(promotion_token, expected_token) + ): + raise BrokerFailure("PROMOTION_TOKEN_MISMATCH") + token = self.store.tokens().get(promotion_token) + if ( + not isinstance(token, dict) + or token.get("session_id") != session_id + or token.get("runtime_generation") != request.get("runtime_generation") + or token.get("binding") != lease.get("binding") + or token.get("consumed") is not False + ): + raise BrokerFailure("PROMOTION_TOKEN_INVALID") + del self.store.tokens()[promotion_token] + lease["state"] = LEASE_PENDING_PROMOTION + lease["expires_at"] = time.monotonic() + int(lease["ttl_seconds"]) + # handle() commits token consumption before finish_promotion() makes + # VERIFIED externally visible: promote-last by construction. + return {"ok": True, "state": LEASE_PENDING_PROMOTION} + if action == "revoke_lease": + session_id, _ = self.authenticate(peer_pid, request) + self.revoke_session_authority(session_id) + return {"ok": True, "state": LEASE_UNVERIFIED} + if action == "authorize_tool": + session_id, _ = self.authenticate(peer_pid, request) + runtime = request.get("runtime") + tool_name = request.get("tool_name") + if runtime not in READ_ONLY_TOOLS: + raise BrokerFailure("INVALID_RUNTIME") + if not isinstance(tool_name, str) or not tool_name or len(tool_name) > 256: + raise BrokerFailure("INVALID_TOOL") + lease = self.leases.get(session_id) + state = lease.get("state") if isinstance(lease, dict) else LEASE_UNVERIFIED + if tool_name in READ_ONLY_TOOLS[runtime] or tool_name == RECOVERY_TOOL: + return {"ok": True, "decision": "allow", "state": state} + if not isinstance(lease, dict) or lease.get("state") != LEASE_VERIFIED: + return { + "ok": False, + "code": "MUTATOR_UNVERIFIED", + "decision": "deny", + "state": LEASE_UNVERIFIED, + } + if ( + lease.get("runtime") != runtime + or lease.get("runtime_generation") != request.get("runtime_generation") + ): + return { + "ok": False, + "code": "MUTATOR_UNVERIFIED", + "decision": "deny", + "state": LEASE_UNVERIFIED, + } + expires_at = lease.get("expires_at") + if not isinstance(expires_at, (int, float)) or time.monotonic() >= expires_at: + self.revoke_session_authority(session_id) + return { + "ok": False, + "code": "LEASE_EXPIRED", + "decision": "deny", + "state": LEASE_UNVERIFIED, + } + return { + "ok": True, + "decision": "allow", + "state": LEASE_VERIFIED, + } raise BrokerFailure("UNKNOWN_ACTION") diff --git a/packages/mosaic/framework/tools/lease-broker/launch-runtime.py b/packages/mosaic/framework/tools/lease-broker/launch-runtime.py new file mode 100644 index 0000000..c6a3f4f --- /dev/null +++ b/packages/mosaic/framework/tools/lease-broker/launch-runtime.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Register a runtime parent with the lease broker, then exec without changing PID.""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import sys +from pathlib import Path +from typing import Final + +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() + 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 main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--runtime", required=True, choices=("claude", "pi")) + parser.add_argument("command", nargs=argparse.REMAINDER) + arguments = parser.parse_args() + command = arguments.command + if command and command[0] == "--": + command = command[1:] + if not command: + print("lease-gated runtime command is required", file=sys.stderr) + return 64 + + try: + socket_path = Path(os.environ["MOSAIC_LEASE_BROKER_SOCKET"]) + generation = int(os.environ.get("MOSAIC_RUNTIME_GENERATION", "1")) + if generation < 0: + raise ValueError("invalid generation") + reply = broker_request( + socket_path, + { + "action": "register_anchor", + "runtime_generation": generation, + }, + ) + session_id = reply.get("session_id") + if ( + reply.get("ok") is not True + or not isinstance(session_id, str) + or len(session_id) != 64 + or any(character not in "0123456789abcdef" for character in session_id) + ): + raise ValueError("registration refused") + except (KeyError, ValueError, OSError, json.JSONDecodeError): + print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr) + return 1 + + environment = dict(os.environ) + environment["MOSAIC_LEASE_SESSION_ID"] = session_id + environment["MOSAIC_RUNTIME_GENERATION"] = str(generation) + environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime + try: + os.execvpe(command[0], command, environment) + except OSError: + print("Mosaic lease-gated runtime exec failed.", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/mosaic/framework/tools/lease-broker/mutator-gate.py b/packages/mosaic/framework/tools/lease-broker/mutator-gate.py new file mode 100644 index 0000000..c66cbbb --- /dev/null +++ b/packages/mosaic/framework/tools/lease-broker/mutator-gate.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Runtime-neutral whole mutator-class gate backed by the Mosaic lease broker.""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import sys +from pathlib import Path +from typing import Final + +MAX_FRAME: Final = 64 * 1024 +BROKER_TIMEOUT_SECONDS: Final = 1.5 + + +def deny(code: str) -> int: + print(f"BLOCKED: Mosaic mutator gate denied this tool ({code}).", file=sys.stderr) + return 2 + + +def read_tool_name() -> str: + raw = sys.stdin.buffer.read(MAX_FRAME + 1) + if len(raw) > MAX_FRAME: + raise ValueError("INVALID_GATE_INPUT") + value = json.loads(raw) + if not isinstance(value, dict): + raise ValueError("INVALID_GATE_INPUT") + tool_name = value.get("tool_name") + if not isinstance(tool_name, str) or not tool_name or len(tool_name) > 256: + raise ValueError("INVALID_GATE_INPUT") + return tool_name + + +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("INVALID_GATE_INPUT") + 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 main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--runtime", required=True, choices=("claude", "pi")) + arguments = parser.parse_args() + + try: + tool_name = read_tool_name() + socket_value = os.environ["MOSAIC_LEASE_BROKER_SOCKET"] + session_id = os.environ["MOSAIC_LEASE_SESSION_ID"] + generation = int(os.environ["MOSAIC_RUNTIME_GENERATION"]) + if generation < 0: + raise ValueError("INVALID_GENERATION") + reply = broker_request( + Path(socket_value), + { + "action": "authorize_tool", + "session_id": session_id, + "runtime_generation": generation, + "runtime": arguments.runtime, + "tool_name": tool_name, + }, + ) + except (KeyError, ValueError, OSError, json.JSONDecodeError): + return deny("GATE_UNAVAILABLE") + + if reply.get("ok") is True and reply.get("decision") == "allow": + return 0 + code = reply.get("code") + return deny(code if isinstance(code, str) else "MUTATOR_UNVERIFIED") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index b51bcb1..ecfb620 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -115,7 +115,7 @@ function auditClaudeSettings(): SettingsAudit { // Check required hooks const hooks = settings['hooks'] as Record | undefined; - const requiredPreToolUse = ['prevent-memory-write.sh']; + const requiredPreToolUse = ['mutator-gate.py', 'prevent-memory-write.sh']; const requiredPostToolUse = ['qa-hook-stdin.sh', 'typecheck-hook.sh']; const preHooks = (hooks?.['PreToolUse'] ?? []) as Array>; @@ -763,7 +763,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev cliArgs.push(...args); } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); - execRuntime('claude', cliArgs); + execLeaseGatedRuntime('claude', cliArgs); break; } @@ -798,7 +798,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev cliArgs.push(...args); } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); - execRuntime('pi', cliArgs); + execLeaseGatedRuntime('pi', cliArgs); break; } } @@ -806,6 +806,23 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev process.exit(0); // Unreachable but satisfies never } +function defaultLeaseBrokerSocket(env: NodeJS.ProcessEnv = process.env): string { + if (env['MOSAIC_LEASE_BROKER_SOCKET']) return env['MOSAIC_LEASE_BROKER_SOCKET']; + const runtimeDir = env['XDG_RUNTIME_DIR']; + if (runtimeDir) return join(runtimeDir, 'mosaic-lease', 'broker.sock'); + const uid = typeof process.getuid === 'function' ? process.getuid() : 0; + return join('/run/user', String(uid), 'mosaic-lease', 'broker.sock'); +} + +function execLeaseGatedRuntime(runtime: 'claude' | 'pi', args: string[]): void { + const launcher = resolveTool('lease-broker', 'launch-runtime.py'); + execRuntime('python3', [launcher, '--runtime', runtime, '--', runtime, ...args], { + ...process.env, + MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(), + MOSAIC_RUNTIME_GENERATION: process.env['MOSAIC_RUNTIME_GENERATION'] ?? '1', + }); +} + /** exec into the runtime, replacing the current process. */ function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void { try {