diff --git a/docs/PRD.md b/docs/PRD.md index bd1df3c7..77ccd609 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -79,6 +79,29 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and --- +## Compaction Refresh Trust Lifecycle (M1, #827–#830) + +### Problem and objective + +Context compaction, session replacement, and same-PID runtime reloads can leave a previously VERIFIED runtime lease attached to stale directives. M1 must revoke that authority mechanically for Claude (including Claudex) and Pi without trusting caller-asserted identity or forking the external broker state machine. + +### Requirements + +1. `CR-REQ-01`: Claude `PreCompact` and `SessionStart` with matcher `compact`, plus Pi `session_before_compact` and the first post-`session_compact` `context`, SHALL independently revoke the active broker lease. +2. `CR-REQ-02`: Runtime generation increases—including same-PID Pi reload/new/resume/fork and Claude resume/clear—SHALL monotonically replace the prior broker incarnation and inherit no VERIFIED lease. +3. `CR-REQ-03`: A fired observer that cannot confirm broker revocation SHALL fail closed through lifecycle cancellation, a private local generation fence, and/or a runtime-local tool latch. The existing all-tools broker gate remains authoritative. +4. `CR-REQ-04`: The lease TTL SHALL remain monotonic and capped at 300 seconds. If both observers are missed, within-TTL consequential actions remain allowed and after-TTL actions are denied. This named bounded residual stale window SHALL be documented without claiming a mutator-action bound inside the window. +5. `CR-REQ-05`: Hook descendants SHALL use the broker-minted session and owner-only current-generation state inherited from register-before-exec. Caller-minted sessions and parallel lease state machines remain forbidden. + +### Acceptance criteria + +1. `AC-CR-01`: Real-socket tests prove each Claude observer revokes, Pi lifecycle tests prove both observer paths, and Claudex isolated settings preserve and install the mandatory hooks. +2. `AC-CR-02`: A same-PID generation test proves the old generation is stale and the replacement generation is UNVERIFIED across reload/resume/fork-equivalent lifecycle events. +3. `AC-CR-03`: RED-first T12b/T30 evidence explicitly reports dual-hook miss within TTL as **ALLOWED** and after TTL as **DENIED**. +4. `AC-CR-04`: Attributable executable coverage is at least 85%, the full repository suite is green on deterministic main, and independent code/security review completes before merge. + +--- + ## Fleet Declarative Configuration Management Workstream (FCM, #758) ### Problem and objective diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index a3c32f47..d7057a55 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -6,6 +6,7 @@ - [Broker operations](guides/lease-broker-operations.md) — protected paths, startup, fail-closed recovery posture, distinct-principal deployment, and residual risk. - [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. ## CLI and skill management diff --git a/docs/architecture/compaction-revocation.md b/docs/architecture/compaction-revocation.md new file mode 100644 index 00000000..00d88f2e --- /dev/null +++ b/docs/architecture/compaction-revocation.md @@ -0,0 +1,43 @@ +# Compaction observer revocation and runtime generations + +WI-3 connects Claude and Pi compaction/session lifecycle events to the existing authenticated lease-broker state machine. It does not add a second lease store or let runtime hooks assert identity. Each observer inherits the broker-minted session, resolves the current private runtime generation, and sends the existing `revoke_lease` action over the authenticated Unix socket. + +## Observer matrix + +| Runtime | Lifecycle signal | Action | +| ---------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Claude / Claudex | `PreCompact` | Revoke the current lease before compaction. A non-zero hook result blocks the lifecycle transition. | +| Claude / Claudex | `SessionStart` with matcher `compact` | Revoke again after compacted context starts. | +| Claude / Claudex | `SessionStart` with matcher `resume\|clear` | Atomically advance the private generation, then revoke the replacement incarnation. | +| Pi | `session_before_compact` | Revoke before compaction; return `{ cancel: true }` if revocation cannot be confirmed. | +| Pi | `session_compact` then the first `context` | Arm and run an independent post-compaction revoke. A failed post observer blocks later tools locally until a retry succeeds. | +| Pi | `session_start` with reason `reload`, `new`, `resume`, or `fork` | Atomically advance the private generation, then revoke the replacement incarnation before reuse. | + +The first observer that reaches the broker deletes pending promotion tokens and makes the lease `UNVERIFIED`. The second compaction observer is deliberate redundancy, not a prerequisite for the first. Claudex receives the same mandatory hooks in its isolated `CLAUDE_CONFIG_DIR`; hook merging preserves unrelated isolated settings and rejects malformed or symlinked settings fail-closed. + +## Private generation authority + +`launch-runtime.py` still registers before `exec`, preserving the kernel-authenticated PID/starttime anchor. It now also creates `generation-.state` beside the broker socket. The file is owner-only mode `0600` under the broker's mode-`0700` directory. Hook descendants read that file instead of relying only on an immutable inherited environment value. + +Generation changes use an exclusive file lock, validate owner/type/mode/size, increment monotonically, truncate and write the complete new value, and `fsync` before contacting the broker. Therefore reload, new-session, resume, and fork events may retain the same PID/starttime while still becoming a new broker incarnation. The higher generation causes the broker to atomically discard prior tokens and lease authority; the replacement generation inherits no VERIFIED lease. + +If an observer fires while broker transport is unavailable, `revoke-lease.py` advances the private generation as a local fence before returning non-zero. Every later all-tools gate reads that higher value. When the broker is reachable again, authentication of that value performs the same old-generation revocation before authorization. Pi also keeps a process-local post-compaction/rollover failure latch that blocks tool calls. An unsafe or unreadable generation file itself makes both lifecycle revocation and tool authorization fail closed. + +## Threat contract and stopping boundary + +### BOUNDED RESIDUAL STALE WINDOW + +If **both** pre- and post-compaction observers are missed entirely, no revocation signal exists. During the remaining unexpired lease, **within-TTL consequential actions are allowed**. Their count and timing are **bounded by lease expiry, not by the mutator gate**. WI-3 makes no claim that it bounds mutator actions inside this stale interval. The broker's monotonic lease TTL is capped at 300 seconds; after expiry, the next consequential tool is denied with `LEASE_EXPIRED`. + +This is the named D2-v5 T-A residual. It is distinct from an observer that fires but cannot contact the broker: the latter creates a local generation fence and fails closed. It is also distinct from T-C total rot, where the lifecycle observers and the all-tools gate are both absent or replaced. Server-side branch protection, required CI, and independent review remain the irreducible backstop for T-C. + +| Condition | Result | +| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Either compaction observer succeeds | Existing lease and pending promotion tokens are revoked immediately. | +| Observer runs but broker confirmation fails | Lifecycle transition is denied where supported; local generation fence and runtime latch prevent inherited authority. | +| Both observers are missed, lease unexpired | **ALLOWED** inside the bounded residual stale window. No within-window mutator bound is claimed. | +| Both observers are missed, lease expired | **DENIED** by monotonic TTL expiry. | +| Generation advances on reload/new/resume/fork | Prior incarnation revoked; replacement starts `UNVERIFIED`. | +| Lifecycle observers and all-tools gate both fail or are removed | T-C total-hook-miss residual; protected-branch controls remain required. | + +The permanent T12b/T30 acceptance case prints both required outcomes: dual-hook miss within TTL is **ALLOWED**, and the same lease after TTL is **DENIED**. Separate real-socket tests prove each Claude observer and same-PID generation rollover; Pi lifecycle tests exercise pre/post observers, all four replacement reasons, and local failure closure. diff --git a/docs/architecture/lease-broker-protocol.md b/docs/architecture/lease-broker-protocol.md index e2b1cdb2..41ccda1d 100644 --- a/docs/architecture/lease-broker-protocol.md +++ b/docs/architecture/lease-broker-protocol.md @@ -14,10 +14,10 @@ Each connection carries exactly one UTF-8 JSON object followed by one newline, c - `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. +- `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. 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. +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. diff --git a/docs/architecture/lease-broker-security.md b/docs/architecture/lease-broker-security.md index 1ce2e45b..d3dd19c3 100644 --- a/docs/architecture/lease-broker-security.md +++ b/docs/architecture/lease-broker-security.md @@ -2,13 +2,15 @@ - Trusted identity comes only from Linux `SO_PEERCRED` plus `/proc` starttime, never request identity fields. - Descendant authorization is anchored to `(pid,starttime)` and uses a complete second starttime pass to fail closed on disappearance or PID-reuse races. -- Runtime generations are monotonic per anchor; a bump revokes prior-incarnation tokens before persistence commits. +- Runtime generations are monotonic per anchor; a bump revokes prior-incarnation tokens before persistence commits. WI-3 stores the live generation in an owner-only locked file so same-PID Pi reload/new/resume/fork and Claude resume/clear transitions cannot inherit a VERIFIED lease. - 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 whole-class authorization denies every consequential, unknown, and custom tool while UNVERIFIED; it does not inspect shell strings or trust wrapper selection. First-class Claude/Pi, both Claudex dispatch modes, PRDY, QA remediation, coord, orchestrator, and fleet starts converge on broker register-before-exec; Claudex additionally installs the mandatory all-tools hook inside its preserved isolated config and fails closed on unsafe settings. - The permanent `check-runtime-launches.py` suite/CI guard scans production source for direct literal, absolute-path, process-API, command-array, and dynamic Claude/Pi launches. It has no bypass allowlist: an unrecognized launch form fails CI until routed through the common boundary. - 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. +- WI-3 wires redundant Claude `PreCompact`/`SessionStart(compact)` and Pi `session_before_compact`/post-`session_compact` `context` observers to that same revoke action. If broker confirmation fails after an observer fires, the revoker advances the private generation as a local fence; subsequent authorization revokes the stale broker incarnation before any consequential allow. +- Dual observer absence while a lease remains live is the named **bounded residual stale window**: consequential tools remain allowed until monotonic expiry, with no claimed within-window action bound. After expiry they are denied. Total observer-plus-gate absence remains T-C. +- Receipt observation, payload construction, 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 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 index 3624f19d..522b5833 100644 --- a/docs/architecture/mutator-class-gate.md +++ b/docs/architecture/mutator-class-gate.md @@ -21,13 +21,13 @@ The authenticated broker is the sole lease writer: 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. +The initial TTL is capped at the ratified 300-second maximum. A caller may request a shorter positive TTL but cannot lengthen the maximum. WI-3 installs the [compaction observer and generation lifecycle](compaction-revocation.md). Dual compaction-hook miss within an unexpired lease remains the ratified bounded T-A residual: consequential tools are allowed until expiry, with no claimed within-window action bound; 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. +`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 the broker-minted session ID and an owner-only generation-file reference to descendants; lifecycle hooks advance that file for same-PID replacement generations. - Claude installs `mutator-gate.py` as an all-tools (`.*`) `PreToolUse` hook. - `mosaic claudex` and `mosaic yolo claudex` preserve their isolated `CLAUDE_CONFIG_DIR`, merge the mandatory hook into that isolated `settings.json`, and use the same register-before-exec launcher. Malformed or symlinked isolated settings deny launch. diff --git a/docs/guides/lease-broker-operations.md b/docs/guides/lease-broker-operations.md index c64d9beb..af4c99e6 100644 --- a/docs/guides/lease-broker-operations.md +++ b/docs/guides/lease-broker-operations.md @@ -17,7 +17,9 @@ export MOSAIC_LEASE_BROKER_SOCKET=/run/user/1000/mosaic-lease/broker.sock mosaic claude # or: mosaic claudex, mosaic yolo claudex, 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. Claudex retains its isolated proxy environment and config directory; Mosaic merges the mandatory all-tools hook into that isolated `settings.json` before invoking the same wrapper. PRDY init/update, QA remediation, coord, orchestrator, and fleet launchers also converge on this boundary. Broker registration failure, unsafe isolated settings, or missing identity denies launch/tool execution fail-closed; broker timeout/unavailability and malformed replies also block tools. +The wrapper obtains a broker-minted session ID, creates a private `generation-.state` file beside the socket, 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 and read the current generation from the file. Claudex retains its isolated proxy environment and config directory; Mosaic merges the mandatory all-tools and compaction-lifecycle hooks into that isolated `settings.json` before invoking the same wrapper. PRDY init/update, QA remediation, coord, orchestrator, and fleet launchers also converge on this boundary. Broker registration failure, unsafe isolated settings, unsafe generation state, or missing identity denies launch/tool execution fail-closed; broker timeout/unavailability and malformed replies also block tools. + +Claude `PreCompact` and `SessionStart(compact)` hooks and Pi pre-/post-compaction handlers invoke `revoke-lease.py`. Pi `session_start` reload/new/resume/fork and Claude resume/clear advance the locked generation before revocation, so a replacement session inherits no lease even when PID/starttime stay unchanged. Do not invoke the revoker manually as a way to restore authority; it only removes authority. If a lifecycle hook reports failure, stop consequential work and repair broker/generation-state availability before re-verification. Run the permanent launch inventory locally with: @@ -29,7 +31,7 @@ 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 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. +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. ## Security posture diff --git a/docs/reports/compaction-refresh/830-documentation-checklist.md b/docs/reports/compaction-refresh/830-documentation-checklist.md new file mode 100644 index 00000000..83add361 --- /dev/null +++ b/docs/reports/compaction-refresh/830-documentation-checklist.md @@ -0,0 +1,34 @@ +# #830 Documentation Completion Checklist + +## Required artifacts + +- [x] `docs/PRD.md` contains the M1 compaction-refresh trust-lifecycle requirements and acceptance criteria. +- [x] Operator behavior and recovery are documented in `docs/guides/lease-broker-operations.md`. +- [x] Developer architecture and protocol behavior are documented in `docs/architecture/compaction-revocation.md`, `lease-broker-protocol.md`, and `mutator-class-gate.md`. +- [x] Security boundaries and residuals are documented in `docs/architecture/lease-broker-security.md` and `compaction-revocation.md`. +- [x] `docs/SITEMAP.md` links the new architecture page. +- [x] User-guide changes are not applicable: observers are mandatory internal runtime controls with no end-user workflow. +- [x] OpenAPI/endpoint changes are not applicable: the broker remains an internal Unix-socket protocol, not a public HTTP API. + +## Contract coverage + +- [x] Claude and Claudex lifecycle signals, matchers, commands, and fail-closed behavior are documented. +- [x] Pi pre-/post-compaction signals and session replacement reasons are documented. +- [x] Private generation-file ownership, monotonic update, same-PID replacement, and failure fencing are documented. +- [x] `revoke_lease` input purpose, broker response state, and denial behavior are documented. +- [x] T12b/T30 explicitly names the bounded residual stale window and reports within-TTL **ALLOWED** / after-TTL **DENIED**. +- [x] Documentation explicitly disclaims a within-window mutator-action bound. +- [x] T-A, T-C, same-principal, and protected-branch boundaries are retained. + +## Structure and review + +- [x] New architecture content is under `docs/architecture/`. +- [x] This report is under `docs/reports/compaction-refresh/`. +- [x] Session evidence is under `docs/scratchpads/`. +- [x] Documentation changes are in the same logical change set as code and tests. +- [ ] Independent exact-head code and Opus security reviews pending coordinator sequencing after the deterministic-main rebase gate. + +## Publishing + +- [x] Canonical documentation remains in-repository. +- [x] No external publishing target is required for this internal M1 control. diff --git a/docs/scratchpads/830-compaction-revoke.md b/docs/scratchpads/830-compaction-revoke.md index 646c807d..ac5d50c4 100644 --- a/docs/scratchpads/830-compaction-revoke.md +++ b/docs/scratchpads/830-compaction-revoke.md @@ -47,3 +47,37 @@ Working estimate: **35K tokens**. No explicit hard cap was supplied; reduce refa - Preserve revoke-first/promote-last and WI-1/WI-2 reviewed state machine. - ≥85% attributable executable coverage with real tests. - No author self-review, no merge, no `--no-verify`. + +## Local implementation complete (push held) + +Implemented on the WI-3 base `abd2791f59b3f06f46dd08e55298ced72f6aa7c2` without changing the reviewed broker state machine: + +- Added `revoke-lease.py`, which authenticates through the existing broker session/generation and invokes `revoke_lease`. A fired observer that cannot confirm broker revocation advances the private generation as a local fence before returning non-zero. +- Added `lease_generation.py`: owner/type/mode/size validation, no-follow opens, exclusive bump lock, monotonic `int64` generation, write-all + `fsync`, and fail-closed exhaustion/corruption handling. +- `launch-runtime.py` creates `generation-.state` mode `0600` beside the socket before `exec`; `mutator-gate.py` resolves that current file value on every tool check. +- Claude settings and isolated Claudex settings now preserve/install `PreCompact`, `SessionStart(compact)`, and resume/clear rollover hooks in addition to the global all-tools gate. +- Pi now registers tested `session_before_compact`, `session_compact`→first `context`, and `session_start(reload|new|resume|fork)` handlers. Failed pre-compact revocation cancels compaction; failed post-compact/rollover revocation latches local all-tool denial. +- Added PRD requirements, architecture/security/protocol/operations updates, sitemap entry, and the ignored-by-default documentation checklist (force-add required at commit). + +### Acceptance and coverage evidence + +- Focused acceptance: `19/19`; T12b/T30 prints within-TTL **ALLOWED** and after-TTL **DENIED**. +- Pi lifecycle: `8/8`, with **100% statements/branches/functions/lines** attributable coverage. +- New Python generation/revoker: `24/24`, **99% branch-aware aggregate coverage** (`lease_generation.py` 98%, `revoke-lease.py` 100%). +- Mosaic package: `1399/1399`; framework shell Python `24/24`, launch guard `12/12`, permanent launch inventory `14 gated/14 total`. +- Existing lease-broker real-socket acceptance: `37/37` within the package run. +- Full repository: `43/43` Turbo tasks green; gateway `628 passed / 12 skipped`; Mosaic `1399/1399`. +- Root typecheck: `42/42`; lint: `23/23`; format and `git diff --check` green. +- Initial direct package test without first building the package reproduced the known missing-`dist/cli.js` harness condition; the canonical root Turbo test (which schedules `@mosaicstack/mosaic#build`) and explicit package build+test are green. No test was weakened. + +### Review evidence + +- Codex uncommitted code review: **APPROVE**, confidence `0.88`, zero findings. Its read-only sandbox could not rerun Vitest, but the author-side focused and full suites above were green. +- Codex uncommitted security review: risk **none**, confidence `0.91`, zero findings. +- Coordinator-mandated fresh exact-head terra CODE and Opus SECREV remain pending after rebase/push clearance; these local reviews do not replace that final gate. + +### Hold and residuals + +- **DO NOT PUSH OR OPEN A PR YET.** Coordinator requires flake-fix #838 to land, then WI-3 must rebase onto deterministic-green `main` before push. +- Merge remains gated on #838, #827 Probe 3, and combined GO. +- Named residual retained verbatim: when both observers are entirely missed, within-TTL consequential actions remain allowed; only lease expiry denies after the bounded stale window. No within-window mutator-action bound is claimed. diff --git a/packages/mosaic/framework/runtime/claude/settings.json b/packages/mosaic/framework/runtime/claude/settings.json index ee3cd0e5..a37fca28 100644 --- a/packages/mosaic/framework/runtime/claude/settings.json +++ b/packages/mosaic/framework/runtime/claude/settings.json @@ -1,6 +1,37 @@ { "model": "opus", "hooks": { + "PreCompact": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact" + } + ] + } + ], + "SessionStart": [ + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact" + } + ] + }, + { + "matcher": "resume|clear", + "hooks": [ + { + "type": "command", + "command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation" + } + ] + } + ], "PreToolUse": [ { "matcher": ".*", diff --git a/packages/mosaic/framework/runtime/pi/lease-lifecycle.ts b/packages/mosaic/framework/runtime/pi/lease-lifecycle.ts new file mode 100644 index 00000000..f637558a --- /dev/null +++ b/packages/mosaic/framework/runtime/pi/lease-lifecycle.ts @@ -0,0 +1,93 @@ +export type LeaseLifecycleRunner = (args: string[]) => boolean; + +type LifecycleEvent = { + reason?: unknown; + toolName?: unknown; +}; + +type LifecycleHandler = ( + event: LifecycleEvent, + context: Record, +) => unknown | Promise; + +export interface LeaseLifecyclePiApi { + on(event: string, handler: LifecycleHandler): void; +} + +const ROLLOVER_REASONS = new Set(['reload', 'new', 'resume', 'fork']); + +function eventReason(event: LifecycleEvent): string { + return typeof event.reason === 'string' && event.reason.length > 0 ? event.reason : 'unknown'; +} + +/** + * Register redundant Pi compaction observers and same-PID generation rollover. + * + * A failed pre-compaction observer cancels compaction. A failed post-compaction + * observer or generation rollover locally blocks later tools in addition to the + * broker-backed all-tools gate. + */ +export function registerLeaseLifecycleHooks( + pi: LeaseLifecyclePiApi, + runRevoker: LeaseLifecycleRunner, +): void { + let postCompactReason: string | null = null; + let postCompactFailure = false; + let rolloverFailure = false; + + pi.on('session_before_compact', async (event) => { + const reason = eventReason(event); + const revoked = runRevoker([ + '--runtime', + 'pi', + '--reason', + `pi-session-before-compact:${reason}`, + ]); + if (!revoked) return { cancel: true }; + return undefined; + }); + + pi.on('session_compact', async (event) => { + postCompactReason = eventReason(event); + }); + + pi.on('context', async () => { + if (postCompactReason === null) return undefined; + const reason = postCompactReason; + const revoked = runRevoker([ + '--runtime', + 'pi', + '--reason', + `pi-context-after-compact:${reason}`, + ]); + if (revoked) { + postCompactReason = null; + postCompactFailure = false; + } else { + postCompactFailure = true; + } + return undefined; + }); + + pi.on('session_start', async (event) => { + const reason = eventReason(event); + if (!ROLLOVER_REASONS.has(reason)) return undefined; + const revoked = runRevoker([ + '--runtime', + 'pi', + '--reason', + `pi-session-start:${reason}`, + '--bump-generation', + ]); + rolloverFailure = !revoked; + return undefined; + }); + + pi.on('tool_call', async () => { + if (!postCompactFailure && !rolloverFailure) return undefined; + return { + block: true, + reason: 'BLOCKED: Mosaic lease lifecycle revoke failed; runtime remains UNVERIFIED.', + }; + }); +} diff --git a/packages/mosaic/framework/runtime/pi/mosaic-extension.ts b/packages/mosaic/framework/runtime/pi/mosaic-extension.ts index 7ef7ee59..cdf9b443 100644 --- a/packages/mosaic/framework/runtime/pi/mosaic-extension.ts +++ b/packages/mosaic/framework/runtime/pi/mosaic-extension.ts @@ -22,6 +22,7 @@ import { import { join, basename } from 'node:path'; import { homedir } from 'node:os'; import { execSync, spawnSync } from 'node:child_process'; +import { registerLeaseLifecycleHooks, type LeaseLifecyclePiApi } from './lease-lifecycle.js'; // --------------------------------------------------------------------------- // Config @@ -29,6 +30,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'); +const LEASE_REVOKER = join(MOSAIC_HOME, 'tools', 'lease-broker', 'revoke-lease.py'); // --------------------------------------------------------------------------- // Helpers @@ -107,6 +109,15 @@ function nowIso(): string { return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); } +function runPiLeaseRevoker(args: string[]): boolean { + const result = spawnSync('python3', [LEASE_REVOKER, ...args], { + encoding: 'utf8', + timeout: 2_000, + env: process.env, + }); + return result.status === 0; +} + function checkPiMutatorGate(toolName: string): { block: true; reason: string } | undefined { const result = spawnSync('python3', [MUTATOR_GATE, '--runtime', 'pi'], { input: `${JSON.stringify({ tool_name: toolName })}\n`, @@ -268,6 +279,9 @@ export default function register(pi: ExtensionAPI) { let hbModel: string | null = null; let hbTimer: ReturnType | null = null; + // ── Compaction observers and same-PID generation rollover ───────────── + registerLeaseLifecycleHooks(pi as unknown as LeaseLifecyclePiApi, runPiLeaseRevoker); + // ── 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. diff --git a/packages/mosaic/framework/tools/lease-broker/launch-runtime.py b/packages/mosaic/framework/tools/lease-broker/launch-runtime.py index 780f9588..8019f474 100644 --- a/packages/mosaic/framework/tools/lease-broker/launch-runtime.py +++ b/packages/mosaic/framework/tools/lease-broker/launch-runtime.py @@ -12,6 +12,8 @@ from collections.abc import Callable, Mapping, Sequence from pathlib import Path from typing import Final +from lease_generation import initialize_runtime_generation + MAX_FRAME: Final = 64 * 1024 BROKER_TIMEOUT_SECONDS: Final = 1.5 CLAUDE_DANGEROUS_FLAG: Final = "--dangerously-skip-permissions" @@ -44,6 +46,7 @@ def main( environ: Mapping[str, str] | None = None, request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request, execute: Callable[[str, list[str], dict[str, str]], object] = os.execvpe, + initialize_generation: Callable[[Path, int], None] = initialize_runtime_generation, ) -> int: parser = argparse.ArgumentParser() parser.add_argument("--runtime", required=True, choices=("claude", "pi")) @@ -83,6 +86,8 @@ def main( or any(character not in "0123456789abcdef" for character in session_id) ): raise ValueError("registration refused") + generation_file = socket_path.parent / f"generation-{session_id}.state" + initialize_generation(generation_file, generation) except (KeyError, ValueError, OSError, json.JSONDecodeError): print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr) return 1 @@ -90,6 +95,7 @@ def main( environment = dict(source_environment) environment["MOSAIC_LEASE_SESSION_ID"] = session_id environment["MOSAIC_RUNTIME_GENERATION"] = str(generation) + environment["MOSAIC_LEASE_GENERATION_FILE"] = str(generation_file) environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime try: execute(command[0], command, environment) diff --git a/packages/mosaic/framework/tools/lease-broker/lease_generation.py b/packages/mosaic/framework/tools/lease-broker/lease_generation.py new file mode 100644 index 00000000..17bfef61 --- /dev/null +++ b/packages/mosaic/framework/tools/lease-broker/lease_generation.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Private monotonic runtime-generation state shared by runtime hook processes.""" + +from __future__ import annotations + +import fcntl +import os +import stat +from collections.abc import Mapping +from pathlib import Path +from typing import Final + +MAX_GENERATION: Final = (1 << 63) - 1 +MAX_GENERATION_BYTES: Final = 32 + + +def parse_generation(value: object) -> int: + if not isinstance(value, str) or not value.isascii() or not value.isdigit(): + raise ValueError("invalid runtime generation") + generation = int(value) + if generation < 0 or generation > MAX_GENERATION: + raise ValueError("invalid runtime generation") + return generation + + +def _validate_descriptor(descriptor: int) -> None: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode): + raise ValueError("runtime generation state is not a regular file") + if metadata.st_uid != os.geteuid(): + raise ValueError("runtime generation state has the wrong owner") + if stat.S_IMODE(metadata.st_mode) & 0o077: + raise ValueError("runtime generation state permissions are not private") + if metadata.st_size > MAX_GENERATION_BYTES: + raise ValueError("runtime generation state is oversized") + + +def _read_descriptor(descriptor: int) -> int: + os.lseek(descriptor, 0, os.SEEK_SET) + raw = os.read(descriptor, MAX_GENERATION_BYTES + 1) + if len(raw) > MAX_GENERATION_BYTES: + raise ValueError("runtime generation state is oversized") + try: + return parse_generation(raw.decode("ascii").strip()) + except UnicodeDecodeError as exc: + raise ValueError("invalid runtime generation state") from exc + + +def _write_descriptor(descriptor: int, generation: int) -> None: + payload = f"{generation}\n".encode("ascii") + os.lseek(descriptor, 0, os.SEEK_SET) + os.ftruncate(descriptor, 0) + remaining = memoryview(payload) + while remaining: + written = os.write(descriptor, remaining) + if written <= 0: + raise OSError("runtime generation write made no progress") + remaining = remaining[written:] + os.fsync(descriptor) + + +def initialize_runtime_generation(path: Path, generation: int) -> None: + if generation < 0 or generation > MAX_GENERATION: + raise ValueError("invalid runtime generation") + descriptor = os.open( + path, + os.O_RDWR | os.O_CREAT | os.O_TRUNC | os.O_CLOEXEC | os.O_NOFOLLOW, + 0o600, + ) + try: + os.fchmod(descriptor, 0o600) + _validate_descriptor(descriptor) + _write_descriptor(descriptor, generation) + finally: + os.close(descriptor) + + +def read_runtime_generation(environ: Mapping[str, str]) -> int: + state_path = environ.get("MOSAIC_LEASE_GENERATION_FILE") + if not state_path: + return parse_generation(environ["MOSAIC_RUNTIME_GENERATION"]) + descriptor = os.open(state_path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW) + try: + _validate_descriptor(descriptor) + return _read_descriptor(descriptor) + finally: + os.close(descriptor) + + +def bump_runtime_generation(environ: Mapping[str, str]) -> int: + state_path = environ.get("MOSAIC_LEASE_GENERATION_FILE") + if not state_path: + raise ValueError("runtime generation file is required for same-PID rollover") + descriptor = os.open(state_path, os.O_RDWR | os.O_CLOEXEC | os.O_NOFOLLOW) + try: + fcntl.flock(descriptor, fcntl.LOCK_EX) + _validate_descriptor(descriptor) + current = _read_descriptor(descriptor) + if current >= MAX_GENERATION: + raise ValueError("runtime generation exhausted") + generation = current + 1 + _write_descriptor(descriptor, generation) + return generation + finally: + os.close(descriptor) diff --git a/packages/mosaic/framework/tools/lease-broker/mutator-gate.py b/packages/mosaic/framework/tools/lease-broker/mutator-gate.py index d622b33b..62a0a173 100644 --- a/packages/mosaic/framework/tools/lease-broker/mutator-gate.py +++ b/packages/mosaic/framework/tools/lease-broker/mutator-gate.py @@ -12,6 +12,8 @@ from collections.abc import Callable, Mapping, Sequence from pathlib import Path from typing import BinaryIO, Final +from lease_generation import read_runtime_generation + MAX_FRAME: Final = 64 * 1024 BROKER_TIMEOUT_SECONDS: Final = 1.5 @@ -64,6 +66,7 @@ def main( environ: Mapping[str, str] | None = None, stream: BinaryIO | None = None, request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request, + resolve_generation: Callable[[Mapping[str, str]], int] = read_runtime_generation, ) -> int: parser = argparse.ArgumentParser() parser.add_argument("--runtime", required=True, choices=("claude", "pi")) @@ -74,9 +77,7 @@ def main( tool_name = read_tool_name(stream) socket_value = source_environment["MOSAIC_LEASE_BROKER_SOCKET"] session_id = source_environment["MOSAIC_LEASE_SESSION_ID"] - generation = int(source_environment["MOSAIC_RUNTIME_GENERATION"]) - if generation < 0: - raise ValueError("INVALID_GENERATION") + generation = resolve_generation(source_environment) reply = request( Path(socket_value), { diff --git a/packages/mosaic/framework/tools/lease-broker/revoke-lease.py b/packages/mosaic/framework/tools/lease-broker/revoke-lease.py new file mode 100644 index 00000000..0e6a829f --- /dev/null +++ b/packages/mosaic/framework/tools/lease-broker/revoke-lease.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Revoke a runtime lease from a compaction or lifecycle observer.""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import sys +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path +from typing import Final + +from lease_generation import bump_runtime_generation, read_runtime_generation + +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("invalid revoke request") + 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( + argv: Sequence[str] | None = None, + *, + environ: Mapping[str, str] | None = None, + request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request, +) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--runtime", required=True, choices=("claude", "pi")) + parser.add_argument("--reason", required=True) + parser.add_argument("--bump-generation", action="store_true") + arguments = parser.parse_args(argv) + source_environment = os.environ if environ is None else environ + + try: + if not arguments.reason or len(arguments.reason) > 128: + raise ValueError("invalid revoke reason") + socket_path = Path(source_environment["MOSAIC_LEASE_BROKER_SOCKET"]) + session_id = source_environment["MOSAIC_LEASE_SESSION_ID"] + if ( + len(session_id) != 64 + or any(character not in "0123456789abcdef" for character in session_id) + ): + raise ValueError("invalid broker session") + generation = ( + bump_runtime_generation(source_environment) + if arguments.bump_generation + else read_runtime_generation(source_environment) + ) + reply = request( + socket_path, + { + "action": "revoke_lease", + "session_id": session_id, + "runtime_generation": generation, + "reason": arguments.reason, + "runtime": arguments.runtime, + }, + ) + if reply.get("ok") is not True or reply.get("state") != "UNVERIFIED": + raise ValueError("revocation refused") + except (KeyError, ValueError, OSError, json.JSONDecodeError): + # A fired observer must remain fail-closed even if the broker transport + # is unavailable. Advancing the private local generation fences every + # later tool check; the broker revokes the old lease when it next sees + # that higher generation. Explicit rollover already advanced it above. + if not arguments.bump_generation: + try: + bump_runtime_generation(source_environment) + except (KeyError, ValueError, OSError): + pass + print("Mosaic lease revocation failed; lifecycle transition denied.", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/mosaic/src/commands/claudex.spec.ts b/packages/mosaic/src/commands/claudex.spec.ts index 383c3b98..68019435 100644 --- a/packages/mosaic/src/commands/claudex.spec.ts +++ b/packages/mosaic/src/commands/claudex.spec.ts @@ -601,15 +601,23 @@ describe('ensureClaudexMutatorGateSettings', () => { const settings = JSON.parse(readFileSync(join(root, 'settings.json'), 'utf8')) as { preserved: boolean; - hooks: { PreToolUse: Array<{ hooks: Array<{ command: string }> }> }; + hooks: Record }>>; }; - const commands = settings.hooks.PreToolUse.flatMap((entry) => + const commands = settings.hooks['PreToolUse']!.flatMap((entry) => entry.hooks.map((hook) => hook.command), ); expect(settings.preserved).toBe(true); expect(commands).toContain( 'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude', ); + expect( + settings.hooks['PreCompact']?.some((entry) => + entry.hooks.some((hook) => hook.command.includes('revoke-lease.py')), + ), + ).toBe(true); + expect(settings.hooks['SessionStart']?.map((entry) => entry.matcher)).toEqual( + expect.arrayContaining(['compact', 'resume|clear']), + ); expect(lstatSync(join(root, 'settings.json')).mode & 0o777).toBe(0o600); } finally { rmSync(root, { recursive: true, force: true }); diff --git a/packages/mosaic/src/commands/claudex.ts b/packages/mosaic/src/commands/claudex.ts index 6f81a442..a10bd3d3 100644 --- a/packages/mosaic/src/commands/claudex.ts +++ b/packages/mosaic/src/commands/claudex.ts @@ -287,17 +287,19 @@ export function buildClaudexEnv( const CLAUDEX_MUTATOR_GATE_COMMAND = 'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude'; +const CLAUDEX_PRE_COMPACT_COMMAND = + 'python3 ~/.config/mosaic/tools/lease-broker/revoke-lease.py --runtime claude --reason pre-compact'; +const CLAUDEX_POST_COMPACT_COMMAND = + 'python3 ~/.config/mosaic/tools/lease-broker/revoke-lease.py --runtime claude --reason session-start-compact'; +const CLAUDEX_ROLLOVER_COMMAND = + 'python3 ~/.config/mosaic/tools/lease-broker/revoke-lease.py --runtime claude --reason session-start-rollover --bump-generation'; -const CLAUDEX_MUTATOR_GATE_HOOK = { - matcher: '.*', - hooks: [ - { - type: 'command', - command: CLAUDEX_MUTATOR_GATE_COMMAND, - timeout: 3, - }, - ], -}; +const CLAUDEX_MANDATORY_LEASE_HOOKS = [ + { event: 'PreToolUse', matcher: '.*', command: CLAUDEX_MUTATOR_GATE_COMMAND }, + { event: 'PreCompact', matcher: '.*', command: CLAUDEX_PRE_COMPACT_COMMAND }, + { event: 'SessionStart', matcher: 'compact', command: CLAUDEX_POST_COMPACT_COMMAND }, + { event: 'SessionStart', matcher: 'resume|clear', command: CLAUDEX_ROLLOVER_COMMAND }, +] as const; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -331,26 +333,31 @@ export function ensureClaudexMutatorGateSettings(configDir: string): void { throw new Error('claudex: isolated settings hooks must be an object (fail closed).'); } const hooks = hooksValue ?? {}; - const preToolUseValue = hooks['PreToolUse']; - if (preToolUseValue !== undefined && !Array.isArray(preToolUseValue)) { - throw new Error('claudex: isolated PreToolUse hooks must be an array (fail closed).'); + for (const mandatory of CLAUDEX_MANDATORY_LEASE_HOOKS) { + const eventValue = hooks[mandatory.event]; + if (eventValue !== undefined && !Array.isArray(eventValue)) { + throw new Error(`claudex: isolated ${mandatory.event} hooks must be an array (fail closed).`); + } + const eventHooks = eventValue ?? []; + const hookPresent = eventHooks.some( + (entry) => + isRecord(entry) && + entry['matcher'] === mandatory.matcher && + Array.isArray(entry['hooks']) && + entry['hooks'].some( + (hook) => + isRecord(hook) && hook['type'] === 'command' && hook['command'] === mandatory.command, + ), + ); + if (!hookPresent) { + eventHooks.unshift({ + matcher: mandatory.matcher, + hooks: [{ type: 'command', command: mandatory.command, timeout: 3 }], + }); + } + hooks[mandatory.event] = eventHooks; } - const preToolUse = preToolUseValue ?? []; - const gatePresent = preToolUse.some( - (entry) => - isRecord(entry) && - entry['matcher'] === '.*' && - Array.isArray(entry['hooks']) && - entry['hooks'].some( - (hook) => - isRecord(hook) && - hook['type'] === 'command' && - hook['command'] === CLAUDEX_MUTATOR_GATE_COMMAND, - ), - ); - if (!gatePresent) preToolUse.unshift(CLAUDEX_MUTATOR_GATE_HOOK); - hooks['PreToolUse'] = preToolUse; settings['hooks'] = hooks; writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 }); chmodSync(settingsPath, 0o600); diff --git a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts index aca66b6a..dc4a0acc 100644 --- a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts +++ b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts @@ -31,6 +31,7 @@ const revokerPath = join(frameworkRoot, 'tools/lease-broker/revoke-lease.py'); const compactionThreatPath = join(repositoryRoot, 'docs/architecture/compaction-revocation.md'); const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json'); const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts'); +const piLifecyclePath = join(frameworkRoot, 'runtime/pi/lease-lifecycle.ts'); const prdyInitPath = join(frameworkRoot, 'tools/prdy/prdy-init.sh'); const prdyUpdatePath = join(frameworkRoot, 'tools/prdy/prdy-update.sh'); const remediationHandlerPath = join(frameworkRoot, 'tools/qa/remediation-hook-handler.sh'); @@ -449,6 +450,39 @@ describe('whole mutator-class lease gate', () => { }); }); + test('a fired observer fences the old lease even while broker transport is unavailable', async () => { + const { socket } = await startBroker(); + const sessionId = await register(socket); + const pending = await beginVerification(socket, sessionId, 'claude'); + await promote(socket, sessionId, pending.promotion_token!); + + const root = await mkdtemp(join(tmpdir(), 'mosaic-observer-fence-')); + temporaryRoots.push(root); + const generationFile = join(root, 'runtime.generation'); + await writeFile(generationFile, '1\n', { mode: 0o600 }); + const failedObserver = spawnSync( + 'python3', + [revokerPath, '--runtime', 'claude', '--reason', 'session-start-compact'], + { + encoding: 'utf8', + env: { + ...process.env, + MOSAIC_LEASE_BROKER_SOCKET: join(root, 'unavailable.sock'), + MOSAIC_LEASE_SESSION_ID: sessionId, + MOSAIC_RUNTIME_GENERATION: '1', + MOSAIC_LEASE_GENERATION_FILE: generationFile, + }, + }, + ); + expect(failedObserver.status).toBe(2); + expect(await readFile(generationFile, 'utf8')).toBe('2\n'); + expect(await authorize(socket, sessionId, 'claude', 'Write', 2)).toMatchObject({ + ok: false, + code: 'MUTATOR_UNVERIFIED', + decision: 'deny', + }); + }); + test('same-PID runtime-generation bump revokes the prior incarnation automatically', async () => { const { socket } = await startBroker(); const sessionId = await register(socket); @@ -506,10 +540,12 @@ describe('whole mutator-class lease gate', () => { ).toBe(true); const piExtension = await readFile(piExtensionPath, 'utf8'); - expect(piExtension).toContain("pi.on('session_before_compact'"); - expect(piExtension).toContain("pi.on('session_compact'"); - expect(piExtension).toContain("pi.on('context'"); - expect(piExtension).toContain('--bump-generation'); + const piLifecycle = await readFile(piLifecyclePath, 'utf8'); + expect(piExtension).toContain('registerLeaseLifecycleHooks'); + expect(piLifecycle).toContain("pi.on('session_before_compact'"); + expect(piLifecycle).toContain("pi.on('session_compact'"); + expect(piLifecycle).toContain("pi.on('context'"); + expect(piLifecycle).toContain('--bump-generation'); }); test('observer revocation and monotonic TTL expiry deny the next mutator', async () => { diff --git a/packages/mosaic/src/mutator-gate/pi-compaction-lifecycle.spec.ts b/packages/mosaic/src/mutator-gate/pi-compaction-lifecycle.spec.ts index 2468f873..4b5652ae 100644 --- a/packages/mosaic/src/mutator-gate/pi-compaction-lifecycle.spec.ts +++ b/packages/mosaic/src/mutator-gate/pi-compaction-lifecycle.spec.ts @@ -1,9 +1,15 @@ import { describe, expect, test } from 'vitest'; -import { registerLeaseLifecycleHooks } from '../../framework/runtime/pi/lease-lifecycle.js'; - +type LifecycleRunner = (args: string[]) => boolean; +type LifecycleRegister = (api: unknown, runner: LifecycleRunner) => void; type Handler = (event: Record, ctx: Record) => unknown; +const lifecycleModuleUrl = new URL('../../framework/runtime/pi/lease-lifecycle.ts', import.meta.url) + .href; +const { registerLeaseLifecycleHooks } = (await import(lifecycleModuleUrl)) as { + registerLeaseLifecycleHooks: LifecycleRegister; +}; + function fakePi() { const handlers = new Map(); return { @@ -70,6 +76,38 @@ describe('Pi compaction and runtime-generation lease lifecycle', () => { }, ); + test('startup leaves the launcher generation intact and tools locally open', async () => { + const pi = fakePi(); + const calls: string[][] = []; + registerLeaseLifecycleHooks(pi.api as never, (args) => { + calls.push(args); + return true; + }); + + await pi.emit('session_start', { reason: 'startup' }); + await pi.emit('session_start'); + expect(calls).toEqual([]); + expect(await pi.emit('tool_call', { toolName: 'bash' })).toEqual([undefined]); + }); + + test('failed post-compaction revoke blocks tools until context retries successfully', async () => { + const pi = fakePi(); + const outcomes = [false, true]; + registerLeaseLifecycleHooks(pi.api as never, () => outcomes.shift() ?? true); + + await pi.emit('session_compact'); + await pi.emit('context'); + expect(await pi.emit('tool_call', { toolName: 'bash' })).toEqual([ + { + block: true, + reason: expect.stringContaining('lease lifecycle revoke failed'), + }, + ]); + + await pi.emit('context'); + expect(await pi.emit('tool_call', { toolName: 'bash' })).toEqual([undefined]); + }); + test('failed lifecycle revoke cancels compaction and closes later tool calls', async () => { const pi = fakePi(); registerLeaseLifecycleHooks(pi.api as never, () => false); diff --git a/packages/mosaic/src/mutator-gate/runtime_tools_unittest.py b/packages/mosaic/src/mutator-gate/runtime_tools_unittest.py index 8de60574..fa979ebc 100644 --- a/packages/mosaic/src/mutator-gate/runtime_tools_unittest.py +++ b/packages/mosaic/src/mutator-gate/runtime_tools_unittest.py @@ -17,6 +17,7 @@ import threading import unittest from contextlib import redirect_stderr from pathlib import Path +from types import SimpleNamespace from unittest.mock import patch @@ -125,6 +126,7 @@ class LaunchRuntimeTest(unittest.TestCase): environ={"MOSAIC_LEASE_BROKER_SOCKET": "/broker"}, request=lambda *_args: {"ok": True, "session_id": "e" * 64}, execute=lambda *args: executed.append(args), + initialize_generation=lambda *_args: None, ) self.assertEqual(result, 0) self.assertEqual( @@ -150,6 +152,7 @@ class LaunchRuntimeTest(unittest.TestCase): environ={"MOSAIC_LEASE_BROKER_SOCKET": "/broker"}, request=lambda *_args: {"ok": True, "session_id": "f" * 64}, execute=lambda *args: executed.append(args), + initialize_generation=lambda *_args: None, ) self.assertEqual(result, 0) self.assertEqual(executed[0][0:2], ("pi", ["pi", "--print", "hello"])) @@ -189,6 +192,21 @@ class LaunchRuntimeTest(unittest.TestCase): self.assertEqual(result, 1) self.assertEqual(executed, []) + def test_generation_initialization_failure_denies_before_exec(self) -> None: + with redirect_stderr(io.StringIO()): + self.assertEqual( + LAUNCHER.main( + ["--runtime", "pi", "--", "pi"], + environ={"MOSAIC_LEASE_BROKER_SOCKET": "/broker"}, + request=lambda *_args: {"ok": True, "session_id": "a" * 64}, + execute=lambda *_args: self.fail("must not execute"), + initialize_generation=lambda *_args: (_ for _ in ()).throw( + OSError("unsafe state") + ), + ), + 1, + ) + def test_registration_exceptions_fail_closed(self) -> None: failures = [ValueError("bad"), OSError("down"), json.JSONDecodeError("bad", "x", 0)] for failure in failures: @@ -214,6 +232,7 @@ class LaunchRuntimeTest(unittest.TestCase): environ={"MOSAIC_LEASE_BROKER_SOCKET": "/x"}, request=lambda *_args: {"ok": True, "session_id": "c" * 64}, execute=lambda *_args: (_ for _ in ()).throw(OSError("missing")), + initialize_generation=lambda *_args: None, ), 1, ) @@ -299,6 +318,23 @@ class ExecutableEntrypointTest(unittest.TestCase): runpy.run_path(str(TOOLS_DIR / "launch-runtime.py"), run_name="__main__") self.assertEqual(raised.exception.code, 64) + def test_revoker_entrypoint_denies_when_identity_environment_is_absent(self) -> None: + with patch.object( + sys, + "argv", + [ + str(TOOLS_DIR / "revoke-lease.py"), + "--runtime", + "claude", + "--reason", + "pre-compact", + ], + ), patch.dict(os.environ, {}, clear=True), redirect_stderr( + io.StringIO() + ), self.assertRaises(SystemExit) as raised: + runpy.run_path(str(TOOLS_DIR / "revoke-lease.py"), run_name="__main__") + self.assertEqual(raised.exception.code, 2) + def test_gate_entrypoint_denies_when_identity_environment_is_absent(self) -> None: class Stdin: buffer = io.BytesIO(b'{"tool_name":"Bash"}') @@ -471,6 +507,44 @@ class LeaseRevocationTest(unittest.TestCase): load_tool("lease_revoker_test", "revoke-lease.py"), ) + def test_generation_parser_and_descriptor_security_rejections(self) -> None: + generation, _ = self.load_modules() + for value in (None, "", "-1", "no", "é", str(generation.MAX_GENERATION + 1)): + with self.subTest(value=value), self.assertRaises(ValueError): + generation.parse_generation(value) + for value in (-1, generation.MAX_GENERATION + 1): + with self.subTest(initialize=value), tempfile.TemporaryDirectory() as directory, self.assertRaises(ValueError): + generation.initialize_runtime_generation(Path(directory) / "state", value) + + with patch.object( + generation.os, + "fstat", + return_value=SimpleNamespace(st_mode=stat.S_IFDIR | 0o700, st_uid=os.geteuid(), st_size=0), + ), self.assertRaises(ValueError): + generation._validate_descriptor(4) + with patch.object( + generation.os, + "fstat", + return_value=SimpleNamespace(st_mode=stat.S_IFREG | 0o600, st_uid=os.geteuid() + 1, st_size=0), + ), self.assertRaises(ValueError): + generation._validate_descriptor(4) + with patch.object( + generation.os, + "fstat", + return_value=SimpleNamespace(st_mode=stat.S_IFREG | 0o644, st_uid=os.geteuid(), st_size=0), + ), self.assertRaises(ValueError): + generation._validate_descriptor(4) + with patch.object( + generation.os, + "fstat", + return_value=SimpleNamespace( + st_mode=stat.S_IFREG | 0o600, + st_uid=os.geteuid(), + st_size=generation.MAX_GENERATION_BYTES + 1, + ), + ), self.assertRaises(ValueError): + generation._validate_descriptor(4) + def test_generation_file_is_private_monotonic_and_rejects_unsafe_state(self) -> None: generation, _ = self.load_modules() with tempfile.TemporaryDirectory() as directory: @@ -493,6 +567,25 @@ class LeaseRevocationTest(unittest.TestCase): "MOSAIC_RUNTIME_GENERATION": "1", "MOSAIC_LEASE_GENERATION_FILE": str(path), }) + path.write_bytes(b"\xff\n") + with self.assertRaises(ValueError): + generation.read_runtime_generation({ + "MOSAIC_LEASE_GENERATION_FILE": str(path), + }) + path.write_text(f"{generation.MAX_GENERATION}\n") + with self.assertRaises(ValueError): + generation.bump_runtime_generation({ + "MOSAIC_LEASE_GENERATION_FILE": str(path), + }) + with self.assertRaises(ValueError): + generation.bump_runtime_generation({"MOSAIC_RUNTIME_GENERATION": "1"}) + + def test_generation_write_must_make_progress(self) -> None: + generation, _ = self.load_modules() + with tempfile.TemporaryDirectory() as directory, patch.object( + generation.os, "write", return_value=0 + ), self.assertRaises(OSError): + generation.initialize_runtime_generation(Path(directory) / "state", 1) def test_revoker_reuses_broker_revoke_and_optional_generation_bump(self) -> None: _, revoker = self.load_modules() @@ -524,6 +617,65 @@ class LeaseRevocationTest(unittest.TestCase): "runtime": "pi", }) + calls.clear() + self.assertEqual( + revoker.main( + ["--runtime", "pi", "--reason", "pi-context-after-compact"], + environ=environment, + request=lambda path, payload: calls.append((path, payload)) + or {"ok": True, "state": "UNVERIFIED"}, + ), + 0, + ) + self.assertEqual(calls[0][1]["runtime_generation"], 3) + + def test_revoker_broker_framing_and_shape_validation(self) -> None: + _, revoker = self.load_modules() + with self.assertRaises(ValueError): + revoker.broker_request(Path("/broker"), {"reason": "x" * revoker.MAX_FRAME}) + replies = [ + (b'{"ok":true,"state":"UNVERIFIED"}\n', {"ok": True, "state": "UNVERIFIED"}), + (b'{"ok":true}', ValueError), + (b'[]\n', ValueError), + (b'x' * (revoker.MAX_FRAME + 1), ValueError), + ] + for wire_reply, expected in replies: + with self.subTest(size=len(wire_reply)): + fake = FakeSocket(wire_reply) + with patch.object(revoker.socket, "socket", return_value=fake): + if isinstance(expected, type) and issubclass(expected, Exception): + with self.assertRaises(expected): + revoker.broker_request(Path("/broker"), {"action": "revoke_lease"}) + else: + self.assertEqual( + revoker.broker_request(Path("/broker"), {"action": "revoke_lease"}), + expected, + ) + self.assertEqual(fake.timeout, revoker.BROKER_TIMEOUT_SECONDS) + self.assertEqual(fake.connected, "/broker") + self.assertEqual(fake.shutdown_how, socket.SHUT_WR) + + def test_failed_observer_revocation_advances_the_local_generation_fence(self) -> None: + _, revoker = self.load_modules() + with tempfile.TemporaryDirectory() as directory: + generation_file = Path(directory) / "runtime.generation" + generation_file.write_text("8\n") + generation_file.chmod(0o600) + environment = { + "MOSAIC_LEASE_BROKER_SOCKET": "/broker", + "MOSAIC_LEASE_SESSION_ID": "a" * 64, + "MOSAIC_RUNTIME_GENERATION": "8", + "MOSAIC_LEASE_GENERATION_FILE": str(generation_file), + } + with redirect_stderr(io.StringIO()): + result = revoker.main( + ["--runtime", "claude", "--reason", "session-start-compact"], + environ=environment, + request=lambda *_args: (_ for _ in ()).throw(OSError("down")), + ) + self.assertEqual(result, 2) + self.assertEqual(generation_file.read_text(), "9\n") + def test_revoker_fails_closed_on_identity_reply_and_transport_errors(self) -> None: _, revoker = self.load_modules() good = { @@ -531,11 +683,19 @@ class LeaseRevocationTest(unittest.TestCase): "MOSAIC_LEASE_SESSION_ID": "a" * 64, "MOSAIC_RUNTIME_GENERATION": "1", } + malformed_session = {**good, "MOSAIC_LEASE_SESSION_ID": "not-a-session"} cases = [ ({}, lambda *_args: {"ok": True, "state": "UNVERIFIED"}), + (malformed_session, lambda *_args: {"ok": True, "state": "UNVERIFIED"}), (good, lambda *_args: {"ok": False, "state": "UNVERIFIED"}), (good, lambda *_args: {"ok": True, "state": "VERIFIED"}), (good, lambda *_args: (_ for _ in ()).throw(OSError("down"))), + ( + good, + lambda *_args: (_ for _ in ()).throw( + json.JSONDecodeError("bad", "x", 0) + ), + ), ] for environment, request in cases: with self.subTest(environment=environment), redirect_stderr(io.StringIO()): @@ -548,6 +708,40 @@ class LeaseRevocationTest(unittest.TestCase): 2, ) + with redirect_stderr(io.StringIO()): + self.assertEqual( + revoker.main( + ["--runtime", "claude", "--reason", "x" * 129], + environ=good, + request=lambda *_args: self.fail("invalid reason reached broker"), + ), + 2, + ) + + with tempfile.TemporaryDirectory() as directory: + generation_file = Path(directory) / "runtime.generation" + generation_file.write_text("1\n") + generation_file.chmod(0o600) + with redirect_stderr(io.StringIO()): + self.assertEqual( + revoker.main( + [ + "--runtime", + "pi", + "--reason", + "session-start-resume", + "--bump-generation", + ], + environ={ + **good, + "MOSAIC_LEASE_GENERATION_FILE": str(generation_file), + }, + request=lambda *_args: (_ for _ in ()).throw(OSError("down")), + ), + 2, + ) + self.assertEqual(generation_file.read_text(), "2\n") + if __name__ == "__main__": unittest.main()