This commit was merged in pull request #842.
This commit is contained in:
23
docs/PRD.md
23
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
43
docs/architecture/compaction-revocation.md
Normal file
43
docs/architecture/compaction-revocation.md
Normal file
@@ -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-<broker-session>.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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -2,13 +2,25 @@
|
||||
|
||||
- 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.
|
||||
|
||||
## Named residual: promote-lease-lost-ACK (WI-3 D2-v5)
|
||||
|
||||
A valid `promote_lease` can leave a session `VERIFIED` in the broker while the client never learns of it. This is a named, bounded D2-v5 T-A residual — an **authority-observability divergence, not an authority divergence, not an ALLOW-risk, and not a retry double-apply**. It is disclosed here, not laundered.
|
||||
|
||||
**Window — where it can occur.** The broker commits token consumption and durable `VERIFIED` state _before_ the success reply becomes visible (see the promotion order in `lease-broker-protocol.md`). The residual is confined to the interval after that commit+fsync when the broker→client reply or peer-ACK is lost — for example an extreme-contention send failure or peer disconnect after `handle()` has already mutated and persisted state (the #838 fail-closed transport path). The lease mutation is already durable broker-side; only the acknowledgement to the client is lost. No uncommitted or partially-applied state is involved: the commit either happened (and is authoritative) or it did not (and no lease exists).
|
||||
|
||||
**Fail-safe direction — the client can only under-claim.** Broker intent is the ceiling; client authority is always ≤ broker intent, never more. Client-side authority-belief is granted only by a _received_ acknowledgement; a lost acknowledgement conveys nothing, so the client cannot conclude "verified" and continues to treat itself as `UNVERIFIED` (it re-verifies or recovers). If the client retries `promote_lease` with the same token, the token is already consumed and the broker rejects the retry (`PROMOTION_TOKEN_MISMATCH` / `INVALID_LEASE_TRANSITION`); there is no double-apply. The committed `VERIFIED` state the broker holds is authority the lease _legitimately earned_ from a real promotion — the broker authorizing consequential tools under it is correct, not inflation. Divergence is therefore strictly toward _less_ client authority than the broker granted; it never produces authority the broker did not grant.
|
||||
|
||||
**Bound — TTL plus the observer/gen-bump revoke backstop, self-healing.** The orphaned `VERIFIED` lease is indistinguishable to the broker from any other legitimately verified lease, so the identical D2-v5 revocation backstops dispose of it: any compaction observer (`PreCompact` / `SessionStart(compact)` for Claude; `session_before_compact` / post-`session_compact` `context` for Pi), any same-PID runtime-generation bump (reload/new/resume/fork), broker restart, or monotonic-time expiry returns the session to `UNVERIFIED`. Monotonic TTL expiry (capped at 300 seconds) is **unconditional** — it requires no observer at all — so the maximum exposure of the orphaned lease is one TTL, ≤ 300 s, after which the next consequential tool is denied with `LEASE_EXPIRED`. Any observer that fires shortens the window further. The residual self-heals: "≥1 observer fires OR expiry ⇒ revoke" catches the lost-ACK lease on the same terms as every other stale lease. As with the dual-observer-miss stale window, WI-3 makes no claim that the mutator gate bounds actions inside the residual interval; the interval is bounded by TTL and the revoke backstop, and the server-side branch-protection / required-CI / independent-review line remains the irreducible backstop for protected-repository mutations.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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-<session>.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-<session>.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
|
||||
|
||||
|
||||
@@ -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.
|
||||
95
docs/scratchpads/830-compaction-revoke.md
Normal file
95
docs/scratchpads/830-compaction-revoke.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# WI-3 Scratchpad — Compaction revocation and runtime-generation rollover
|
||||
|
||||
- **Issue:** Gitea #830
|
||||
- **Branch:** `feat/830-compaction-revoke`
|
||||
- **Base HEAD:** `abd2791f59b3f06f46dd08e55298ced72f6aa7c2`
|
||||
- **Role:** sol author/build lane only; terra CODE and Opus SECREV are coordinator-owned.
|
||||
|
||||
## Mission prompt
|
||||
|
||||
Implement BUILD-BRIEF Deliverable 3.3 and D4 on merged WI-1/WI-2 under `packages/mosaic/`. Claude `PreCompact` and `SessionStart(matcher=compact)` plus Pi `session_before_compact`/`context` equivalents must revoke the active lease through the existing broker state machine. Any `runtime_generation` bump—including same-PID reload/resume/fork—must auto-revoke the prior incarnation so the new generation inherits no prior lease. M1 is Claude + Pi only.
|
||||
|
||||
Honor amended D2-v5 exactly: hard fail-closure when at least one observer fires or after lease expiry; both observers missing within TTL is an explicitly named bounded residual stale window (maximum 300 seconds, soak-tighten only), with no claim that the mutator gate bounds actions inside that window; total gate-hook miss is T-C. T12b/T30 must report both the within-TTL ALLOWED outcome and after-TTL DENIED outcome.
|
||||
|
||||
## Session start verification
|
||||
|
||||
- Worktree is clean on `feat/830-compaction-revoke` at exact required base `abd2791f59b3f06f46dd08e55298ced72f6aa7c2`; `origin/main` is the same SHA and includes merged WI-2 atop WI-1.
|
||||
- Authority SHA-256 verified:
|
||||
- BUILD-BRIEF: `89fdbc27ed0e5050dc7b52f3ef2ddaea691edf17fd89d51b15e26fb5ed47171b`
|
||||
- SPEC-v5: `a6d07ade835758e8488ca10d3b0631caf0beb93ea3a6733631f151b0c2f01433`
|
||||
- Ratification: `bac58319c9c4028b5b40e1129e0033cdb5a6b7b02033c25f06f4cb77d7779c67`
|
||||
- sol red-team: `3da326a4ea91767b731e128a93b13194e8002358101e30de3fcb8ca2f8f54faa`
|
||||
- WI-0 evidence pack SHA-256 `5d418306fcc597fd514e500bee40d1509f0bf467e46ee13fc5c280ed8274759d` read directly. Probe P3 is **PASS**: real Pi retained the same PID/starttime through reload/fork/new/resume while generations advanced and a prior VERIFIED generation was revoked.
|
||||
- P6 planner-return ruling SHA-256 `b7bbb6ea6e8d9a5c3366993642ab4e4f65b961af04936dcac20bfbcdcbaf1a09` read directly: feature WI admission is GO with the exact-delivery empirical compatibility fact and disclosed T-C middle-drop residual; no receipt redesign.
|
||||
|
||||
## Plan and budget
|
||||
|
||||
1. RED real-socket acceptance for T12b/T30, each Claude observer, same-PID generation rollover, Claude/Claudex hook wiring, and Pi lifecycle wiring.
|
||||
2. Add one broker client executable for observer revocation plus a private monotonic generation-file helper shared by launcher, gate, and revoker.
|
||||
3. Wire Claude `PreCompact`, `SessionStart(compact)`, and resume/clear generation rollover; merge equivalent mandatory hooks into isolated Claudex settings.
|
||||
4. Wire Pi pre/post compaction observers and reload/new/resume/fork generation rollover with local fail-closed tool blocking if lifecycle revocation fails.
|
||||
5. Document the D2-v5 bounded stale window without claiming the mutator gate bounds within-TTL actions; update protocol/security/operations/sitemap/checklist.
|
||||
6. Run focused real tests, independently measured executable coverage ≥85%, full repository gates, commit/push, open an unmerged `closes #830` PR, and hand off for terra CODE + mandatory Opus SECREV.
|
||||
|
||||
Working estimate: **35K tokens**. No explicit hard cap was supplied; reduce refactor breadth before touching locked broker authority/state-machine semantics.
|
||||
|
||||
## RED evidence
|
||||
|
||||
- New T12b/T30 test already reports the inherited primitive honestly: within-TTL **ALLOWED**, after-TTL **DENIED**. The complete AC remains RED because the mandatory threat-contract document is absent.
|
||||
- Focused real-socket suite is RED with 7 expected failures: missing revoker executable (both Claude observers + generation bump), missing Claude/Pi wiring, missing isolated Claudex observers, and missing D2-v5 disclosure.
|
||||
- Branch-focused Python suite is RED on the wished generation initializer/resolver interfaces and missing `lease_generation.py` / `revoke-lease.py`.
|
||||
- Pi lifecycle suite is RED because the wished standalone `lease-lifecycle.ts` observer/generation module does not exist.
|
||||
|
||||
## Locked discipline
|
||||
|
||||
- RED-first T12b/T30 and observer/generation tests; test commit precedes implementation.
|
||||
- Reuse broker `revoke_lease`; do not fork identity, lease, or transition authority.
|
||||
- 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-<broker-session>.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.
|
||||
|
||||
## Deterministic-main rebase evidence
|
||||
|
||||
- Fetched and confirmed `origin/main` at `8dfcf1903e385f977121069f798f476eb671fffc` (`#838` bounded broker deadlines, empty-read fail-closure, and de-flaked acceptance client).
|
||||
- Linear rebase completed. The only content conflict was `packages/mosaic/src/mutator-gate/runtime_tools_unittest.py`; resolution retained #838's `subprocess`/`threading` deadline regressions and WI-3's `stat` generation-state coverage. No authority/state-machine choice was ambiguous.
|
||||
- `packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts` auto-merged on top of #838's shared `requestBrokerReply` helper. No inline socket/`JSON.parse` client was resurrected.
|
||||
- Verified WI-3 has zero diff from `origin/main` for #838-owned `daemon.py`, `broker-test-client.ts`, `lease-broker.acceptance.spec.ts`, `vitest.config.ts`, and `packages/mosaic/package.json`; bounded deadlines and the de-flaked harness are preserved byte-for-byte.
|
||||
- Required verbose acceptance command: **2 files / 56 tests green**. T12b/T30 still prints within-TTL **ALLOWED** and after-TTL **DENIED**.
|
||||
- Full Mosaic package after explicit build: **74 files / 1408 tests green**; deadline unit `2/2`, runtime tools `25/25`, launch guard `12/12`, inventory `14/14`.
|
||||
- Full repository: **43/43 Turbo tasks green**. Root typecheck `42/42`, lint `23/23`, format and diff checks green.
|
||||
- Attributable coverage remains Python **99%** branch-aware and Pi lifecycle **100%** statements/branches/functions/lines.
|
||||
- Push and PR remain held pending combined GO and all WI-3 merge gates. The coordinator-owned promote-lease-lost-ACK SPEC amendment/backstop is acknowledged as a future merge prerequisite and was not retro-expanded into this core rebase/build.
|
||||
@@ -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": ".*",
|
||||
|
||||
93
packages/mosaic/framework/runtime/pi/lease-lifecycle.ts
Normal file
93
packages/mosaic/framework/runtime/pi/lease-lifecycle.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export type LeaseLifecycleRunner = (args: string[]) => boolean;
|
||||
|
||||
type LifecycleEvent = {
|
||||
reason?: unknown;
|
||||
toolName?: unknown;
|
||||
};
|
||||
|
||||
type LifecycleHandler = (
|
||||
event: LifecycleEvent,
|
||||
context: Record<string, unknown>,
|
||||
) => unknown | Promise<unknown>;
|
||||
|
||||
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.',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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<typeof setInterval> | 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
105
packages/mosaic/framework/tools/lease-broker/lease_generation.py
Normal file
105
packages/mosaic/framework/tools/lease-broker/lease_generation.py
Normal file
@@ -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)
|
||||
@@ -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),
|
||||
{
|
||||
|
||||
100
packages/mosaic/framework/tools/lease-broker/revoke-lease.py
Normal file
100
packages/mosaic/framework/tools/lease-broker/revoke-lease.py
Normal file
@@ -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())
|
||||
@@ -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<string, Array<{ matcher: string; hooks: Array<{ command: string }> }>>;
|
||||
};
|
||||
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 });
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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);
|
||||
|
||||
@@ -22,12 +22,16 @@ interface BrokerPaths {
|
||||
}
|
||||
|
||||
const frameworkRoot = new URL('../../framework/', import.meta.url).pathname;
|
||||
const repositoryRoot = new URL('../../../../', import.meta.url).pathname;
|
||||
const daemonPath = join(frameworkRoot, 'tools/lease-broker/daemon.py');
|
||||
const gatePath = join(frameworkRoot, 'tools/lease-broker/mutator-gate.py');
|
||||
const launchGuardPath = join(frameworkRoot, 'tools/lease-broker/check-runtime-launches.py');
|
||||
const launcherPath = join(frameworkRoot, 'tools/lease-broker/launch-runtime.py');
|
||||
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');
|
||||
@@ -393,6 +397,217 @@ describe('whole mutator-class lease gate', () => {
|
||||
).toMatchObject({ ok: false, code: 'INVALID_TOOL' });
|
||||
});
|
||||
|
||||
test('T12b/T30 reports the dual-observer-miss residual within and after TTL', async () => {
|
||||
const { socket } = await startBroker();
|
||||
const sessionId = await register(socket);
|
||||
const pending = await beginVerification(socket, sessionId, 'claude', 1, 1);
|
||||
await promote(socket, sessionId, pending.promotion_token!);
|
||||
|
||||
// Intentionally invoke neither compaction observer: this is the amended
|
||||
// D2-v5 bounded residual, not a fail-closed path.
|
||||
const withinTtl = await authorize(socket, sessionId, 'claude', 'Bash');
|
||||
expect(withinTtl).toMatchObject({ ok: true, decision: 'allow', state: 'VERIFIED' });
|
||||
console.info('T12b/T30 dual-hook-miss within-TTL: ALLOWED (bounded residual stale window)');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||
const afterTtl = await authorize(socket, sessionId, 'claude', 'Bash');
|
||||
expect(afterTtl).toMatchObject({
|
||||
ok: false,
|
||||
code: 'LEASE_EXPIRED',
|
||||
decision: 'deny',
|
||||
});
|
||||
console.info('T12b/T30 dual-hook-miss after-TTL: DENIED (lease expiry)');
|
||||
|
||||
const threatContract = await readFile(compactionThreatPath, 'utf8');
|
||||
expect(threatContract).toContain('BOUNDED RESIDUAL STALE WINDOW');
|
||||
expect(threatContract).toContain('within-TTL consequential actions are allowed');
|
||||
expect(threatContract).toContain('bounded by lease expiry, not by the mutator gate');
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ observer: 'Claude PreCompact', reason: 'pre-compact' },
|
||||
{ observer: 'Claude SessionStart(compact)', reason: 'session-start-compact' },
|
||||
])('$observer revokes a verified lease through the broker path', async ({ reason }) => {
|
||||
const { socket } = await startBroker();
|
||||
const sessionId = await register(socket);
|
||||
const pending = await beginVerification(socket, sessionId, 'claude');
|
||||
await promote(socket, sessionId, pending.promotion_token!);
|
||||
|
||||
const revoked = spawnSync('python3', [revokerPath, '--runtime', 'claude', '--reason', reason], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||
MOSAIC_LEASE_SESSION_ID: sessionId,
|
||||
MOSAIC_RUNTIME_GENERATION: '1',
|
||||
},
|
||||
});
|
||||
expect(revoked.status, revoked.stderr).toBe(0);
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Write')).toMatchObject({
|
||||
ok: false,
|
||||
code: 'MUTATOR_UNVERIFIED',
|
||||
decision: 'deny',
|
||||
});
|
||||
});
|
||||
|
||||
test('promote-lease-lost-ACK orphaned VERIFIED lease is caught by observer revoke and by monotonic TTL expiry (D2-v5 backstop)', async () => {
|
||||
const { socket } = await startBroker();
|
||||
const observerSessionId = await register(socket);
|
||||
const observerPending = await beginVerification(socket, observerSessionId, 'claude');
|
||||
await promote(socket, observerSessionId, observerPending.promotion_token!);
|
||||
|
||||
expect(await authorize(socket, observerSessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: true,
|
||||
decision: 'allow',
|
||||
state: 'VERIFIED',
|
||||
});
|
||||
const retriedPromotion = await promote(
|
||||
socket,
|
||||
observerSessionId,
|
||||
observerPending.promotion_token!,
|
||||
);
|
||||
expect(retriedPromotion.ok).toBe(false);
|
||||
expect(['PROMOTION_TOKEN_MISMATCH', 'INVALID_LEASE_TRANSITION']).toContain(
|
||||
retriedPromotion.code,
|
||||
);
|
||||
|
||||
const revoked = spawnSync(
|
||||
'python3',
|
||||
[revokerPath, '--runtime', 'claude', '--reason', 'session-start-compact'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||
MOSAIC_LEASE_SESSION_ID: observerSessionId,
|
||||
MOSAIC_RUNTIME_GENERATION: '1',
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(revoked.status, revoked.stderr).toBe(0);
|
||||
expect(await authorize(socket, observerSessionId, 'claude', 'Write')).toMatchObject({
|
||||
ok: false,
|
||||
code: 'MUTATOR_UNVERIFIED',
|
||||
decision: 'deny',
|
||||
});
|
||||
|
||||
const { socket: expirySocket } = await startBroker();
|
||||
const expirySessionId = await register(expirySocket);
|
||||
expect(expirySessionId).not.toBe(observerSessionId);
|
||||
const expiryPending = await beginVerification(expirySocket, expirySessionId, 'claude', 1, 1);
|
||||
await promote(expirySocket, expirySessionId, expiryPending.promotion_token!);
|
||||
expect(await authorize(expirySocket, expirySessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: true,
|
||||
decision: 'allow',
|
||||
state: 'VERIFIED',
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||
expect(await authorize(expirySocket, expirySessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: false,
|
||||
code: 'LEASE_EXPIRED',
|
||||
decision: 'deny',
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
const pending = await beginVerification(socket, sessionId, 'pi');
|
||||
await promote(socket, sessionId, pending.promotion_token!);
|
||||
const anchorPid = process.pid;
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), 'mosaic-generation-bump-'));
|
||||
temporaryRoots.push(root);
|
||||
const generationFile = join(root, 'runtime.generation');
|
||||
await writeFile(generationFile, '1\n', { mode: 0o600 });
|
||||
const bumped = spawnSync(
|
||||
'python3',
|
||||
[revokerPath, '--runtime', 'pi', '--reason', 'session-start-resume', '--bump-generation'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||
MOSAIC_LEASE_SESSION_ID: sessionId,
|
||||
MOSAIC_RUNTIME_GENERATION: '1',
|
||||
MOSAIC_LEASE_GENERATION_FILE: generationFile,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(bumped.status, bumped.stderr).toBe(0);
|
||||
expect(await readFile(generationFile, 'utf8')).toBe('2\n');
|
||||
expect(process.pid).toBe(anchorPid);
|
||||
expect(await authorize(socket, sessionId, 'pi', 'bash', 2)).toMatchObject({
|
||||
ok: false,
|
||||
code: 'MUTATOR_UNVERIFIED',
|
||||
decision: 'deny',
|
||||
});
|
||||
expect(await authorize(socket, sessionId, 'pi', 'bash', 1)).toMatchObject({
|
||||
ok: false,
|
||||
code: 'STALE_GENERATION',
|
||||
});
|
||||
});
|
||||
|
||||
test('Claude and Pi compaction observer wiring is complete and fail-closed', async () => {
|
||||
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
|
||||
hooks: Record<string, Array<{ matcher?: string; hooks: Array<{ command: string }> }>>;
|
||||
};
|
||||
expect(
|
||||
settings.hooks['PreCompact']?.some((entry) =>
|
||||
entry.hooks.some((hook) => hook.command.includes('revoke-lease.py')),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
settings.hooks['SessionStart']?.some(
|
||||
(entry) =>
|
||||
entry.matcher === 'compact' &&
|
||||
entry.hooks.some((hook) => hook.command.includes('revoke-lease.py')),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const piExtension = await readFile(piExtensionPath, 'utf8');
|
||||
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 () => {
|
||||
const { socket } = await startBroker();
|
||||
const sessionId = await register(socket);
|
||||
@@ -542,6 +757,12 @@ hook_present = any(
|
||||
item.get("matcher") == ".*" and any("mutator-gate.py" in hook.get("command", "") for hook in item.get("hooks", []))
|
||||
for item in pre_tool
|
||||
)
|
||||
pre_compact = settings.get("hooks", {}).get("PreCompact", [])
|
||||
session_start = settings.get("hooks", {}).get("SessionStart", [])
|
||||
observers_present = (
|
||||
any(any("revoke-lease.py" in hook.get("command", "") for hook in item.get("hooks", [])) for item in pre_compact)
|
||||
and any(item.get("matcher") == "compact" and any("revoke-lease.py" in hook.get("command", "") for hook in item.get("hooks", [])) for item in session_start)
|
||||
)
|
||||
denied = subprocess.run(
|
||||
["python3", ${JSON.stringify(gatePath)}, "--runtime", "claude"],
|
||||
input=json.dumps({"tool_name": "Bash"}) + "\\n",
|
||||
@@ -553,11 +774,12 @@ is_yolo = "--dangerously-skip-permissions" in sys.argv[1:]
|
||||
result = {
|
||||
"session_id": session_id,
|
||||
"hook_present": hook_present,
|
||||
"observers_present": observers_present,
|
||||
"denied": denied,
|
||||
"is_yolo": is_yolo,
|
||||
}
|
||||
print(json.dumps(result))
|
||||
raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1)
|
||||
raise SystemExit(0 if len(session_id) == 64 and hook_present and observers_present and denied else 1)
|
||||
`;
|
||||
await writeFile(fakeClaude, probe, { mode: 0o700 });
|
||||
await chmod(fakeClaude, 0o700);
|
||||
@@ -621,6 +843,7 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1)
|
||||
expect(JSON.parse(String(execution!.stdout))).toEqual({
|
||||
session_id: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
hook_present: true,
|
||||
observers_present: true,
|
||||
denied: true,
|
||||
is_yolo: yolo,
|
||||
});
|
||||
|
||||
127
packages/mosaic/src/mutator-gate/pi-compaction-lifecycle.spec.ts
Normal file
127
packages/mosaic/src/mutator-gate/pi-compaction-lifecycle.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
type LifecycleRunner = (args: string[]) => boolean;
|
||||
type LifecycleRegister = (api: unknown, runner: LifecycleRunner) => void;
|
||||
type Handler = (event: Record<string, unknown>, ctx: Record<string, unknown>) => 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<string, Handler[]>();
|
||||
return {
|
||||
handlers,
|
||||
api: {
|
||||
on(event: string, handler: Handler) {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
||||
},
|
||||
},
|
||||
async emit(event: string, value: Record<string, unknown> = {}) {
|
||||
const results = [];
|
||||
for (const handler of handlers.get(event) ?? []) {
|
||||
results.push(await handler(value, {}));
|
||||
}
|
||||
return results;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Pi compaction and runtime-generation lease lifecycle', () => {
|
||||
test('pre-compaction and first post-compaction context independently revoke', async () => {
|
||||
const pi = fakePi();
|
||||
const calls: string[][] = [];
|
||||
registerLeaseLifecycleHooks(pi.api as never, (args) => {
|
||||
calls.push(args);
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(await pi.emit('session_before_compact', { reason: 'threshold' })).toEqual([undefined]);
|
||||
expect(calls.at(-1)).toEqual([
|
||||
'--runtime',
|
||||
'pi',
|
||||
'--reason',
|
||||
'pi-session-before-compact:threshold',
|
||||
]);
|
||||
|
||||
await pi.emit('session_compact', { reason: 'threshold' });
|
||||
await pi.emit('context', { messages: [] });
|
||||
expect(calls.at(-1)).toEqual([
|
||||
'--runtime',
|
||||
'pi',
|
||||
'--reason',
|
||||
'pi-context-after-compact:threshold',
|
||||
]);
|
||||
const afterFirstContext = calls.length;
|
||||
await pi.emit('context', { messages: [] });
|
||||
expect(calls).toHaveLength(afterFirstContext);
|
||||
});
|
||||
|
||||
test.each(['reload', 'new', 'resume', 'fork'])(
|
||||
'%s bumps generation before reuse',
|
||||
async (reason) => {
|
||||
const pi = fakePi();
|
||||
const calls: string[][] = [];
|
||||
registerLeaseLifecycleHooks(pi.api as never, (args) => {
|
||||
calls.push(args);
|
||||
return true;
|
||||
});
|
||||
|
||||
await pi.emit('session_start', { reason });
|
||||
expect(calls).toEqual([
|
||||
['--runtime', 'pi', '--reason', `pi-session-start:${reason}`, '--bump-generation'],
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
const compact = await pi.emit('session_before_compact', { reason: 'manual' });
|
||||
expect(compact).toEqual([{ cancel: true }]);
|
||||
|
||||
await pi.emit('session_start', { reason: 'resume' });
|
||||
const tool = await pi.emit('tool_call', { toolName: 'bash' });
|
||||
expect(tool).toEqual([
|
||||
{
|
||||
block: true,
|
||||
reason: expect.stringContaining('lease lifecycle revoke failed'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import json
|
||||
import os
|
||||
import runpy
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -16,10 +17,13 @@ import threading
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
TOOLS_DIR = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
||||
if str(TOOLS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(TOOLS_DIR))
|
||||
|
||||
|
||||
def load_tool(module_name: str, filename: str):
|
||||
@@ -78,6 +82,9 @@ class LaunchRuntimeTest(unittest.TestCase):
|
||||
def execute(command: str, argv: list[str], environment: dict[str, str]) -> None:
|
||||
calls["execute"] = (command, argv, environment)
|
||||
|
||||
def initialize_generation(path: Path, generation: int) -> None:
|
||||
calls["generation"] = (path, generation)
|
||||
|
||||
result = LAUNCHER.main(
|
||||
["--runtime", "claude", "--", "claude", "--print", "hello"],
|
||||
environ={
|
||||
@@ -87,6 +94,7 @@ class LaunchRuntimeTest(unittest.TestCase):
|
||||
},
|
||||
request=request,
|
||||
execute=execute,
|
||||
initialize_generation=initialize_generation,
|
||||
)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
@@ -101,6 +109,14 @@ class LaunchRuntimeTest(unittest.TestCase):
|
||||
self.assertEqual(environment["MOSAIC_LEASE_SESSION_ID"], session_id)
|
||||
self.assertEqual(environment["MOSAIC_RUNTIME_GENERATION"], "7")
|
||||
self.assertEqual(environment["MOSAIC_LEASE_RUNTIME"], "claude")
|
||||
self.assertEqual(
|
||||
environment["MOSAIC_LEASE_GENERATION_FILE"],
|
||||
f"/run/test/generation-{session_id}.state",
|
||||
)
|
||||
self.assertEqual(
|
||||
calls["generation"],
|
||||
(Path(f"/run/test/generation-{session_id}.state"), 7),
|
||||
)
|
||||
self.assertEqual(environment["PRESERVED"], "yes")
|
||||
|
||||
def test_dangerous_claude_mode_is_owned_and_injected_by_the_wrapper(self) -> None:
|
||||
@@ -110,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(
|
||||
@@ -135,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"]))
|
||||
@@ -174,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:
|
||||
@@ -199,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,
|
||||
)
|
||||
@@ -284,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"}')
|
||||
@@ -325,6 +376,19 @@ class MutatorGateTest(unittest.TestCase):
|
||||
)
|
||||
return result, stderr.getvalue(), calls
|
||||
|
||||
def test_generation_file_is_the_effective_generation_authority(self) -> None:
|
||||
calls: list[dict[str, object]] = []
|
||||
result = GATE.main(
|
||||
["--runtime", "pi"],
|
||||
environ=self.environment(),
|
||||
stream=io.BytesIO(b'{"tool_name":"bash"}'),
|
||||
request=lambda _path, payload: calls.append(payload)
|
||||
or {"ok": True, "decision": "allow"},
|
||||
resolve_generation=lambda _environment: 9,
|
||||
)
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(calls[0]["runtime_generation"], 9)
|
||||
|
||||
def test_allow_and_denial_decisions(self) -> None:
|
||||
allowed, allowed_stderr, calls = self.run_main()
|
||||
self.assertEqual(allowed, 0)
|
||||
@@ -432,5 +496,252 @@ class MutatorGateTest(unittest.TestCase):
|
||||
self.assertEqual(fake.shutdown_how, socket.SHUT_WR)
|
||||
|
||||
|
||||
class LeaseRevocationTest(unittest.TestCase):
|
||||
def load_modules(self):
|
||||
generation_path = TOOLS_DIR / "lease_generation.py"
|
||||
revoker_path = TOOLS_DIR / "revoke-lease.py"
|
||||
self.assertTrue(generation_path.is_file(), "lease_generation.py must be shipped")
|
||||
self.assertTrue(revoker_path.is_file(), "revoke-lease.py must be shipped")
|
||||
return (
|
||||
load_tool("lease_generation_test", "lease_generation.py"),
|
||||
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:
|
||||
path = Path(directory) / "runtime.generation"
|
||||
generation.initialize_runtime_generation(path, 4)
|
||||
self.assertEqual(generation.read_runtime_generation({
|
||||
"MOSAIC_RUNTIME_GENERATION": "1",
|
||||
"MOSAIC_LEASE_GENERATION_FILE": str(path),
|
||||
}), 4)
|
||||
self.assertEqual(generation.bump_runtime_generation({
|
||||
"MOSAIC_RUNTIME_GENERATION": "1",
|
||||
"MOSAIC_LEASE_GENERATION_FILE": str(path),
|
||||
}), 5)
|
||||
self.assertEqual(path.read_text(), "5\n")
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
|
||||
|
||||
path.write_text("bad\n")
|
||||
with self.assertRaises(ValueError):
|
||||
generation.read_runtime_generation({
|
||||
"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()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
generation_file = Path(directory) / "runtime.generation"
|
||||
generation_file.write_text("2\n")
|
||||
generation_file.chmod(0o600)
|
||||
environment = {
|
||||
"MOSAIC_LEASE_BROKER_SOCKET": "/broker",
|
||||
"MOSAIC_LEASE_SESSION_ID": "a" * 64,
|
||||
"MOSAIC_RUNTIME_GENERATION": "2",
|
||||
"MOSAIC_LEASE_GENERATION_FILE": str(generation_file),
|
||||
}
|
||||
calls: list[tuple[Path, dict[str, object]]] = []
|
||||
result = revoker.main(
|
||||
["--runtime", "pi", "--reason", "session-start-resume", "--bump-generation"],
|
||||
environ=environment,
|
||||
request=lambda path, payload: calls.append((path, payload))
|
||||
or {"ok": True, "state": "UNVERIFIED"},
|
||||
)
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(generation_file.read_text(), "3\n")
|
||||
self.assertEqual(calls[0][0], Path("/broker"))
|
||||
self.assertEqual(calls[0][1], {
|
||||
"action": "revoke_lease",
|
||||
"session_id": "a" * 64,
|
||||
"runtime_generation": 3,
|
||||
"reason": "session-start-resume",
|
||||
"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 = {
|
||||
"MOSAIC_LEASE_BROKER_SOCKET": "/broker",
|
||||
"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()):
|
||||
self.assertEqual(
|
||||
revoker.main(
|
||||
["--runtime", "claude", "--reason", "pre-compact"],
|
||||
environ=environment,
|
||||
request=request,
|
||||
),
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user