feat(mosaic): WI-2 mutator-class guard for directive-freshness (#837)
WI-2: mutator-class guard for the compaction directive-freshness mechanism — command-position parser (prefix x var-indirection unified) + primitive-anchored invariant backstop + all-tools-hook fail-close. Parser-complete on principle: realistic evasion matrix RED-regression-covered, residual exotic evasions proven backstop-caught (B-tests), lens-convergence reached. closes #829
This commit was merged in pull request #837.
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
|
||||
- [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings.
|
||||
- [Broker operations](guides/lease-broker-operations.md) — protected paths, startup, fail-closed recovery posture, distinct-principal deployment, and residual risk.
|
||||
- [WI-1 security notes](architecture/lease-broker-security.md) — threat-boundary summary and coordinator review requirements.
|
||||
- [Lease-broker security notes](architecture/lease-broker-security.md) — identity, whole-class authorization, threat boundaries, and coordinator review requirements.
|
||||
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
|
||||
|
||||
## CLI and skill management
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@ Each connection carries exactly one UTF-8 JSON object followed by one newline, c
|
||||
- `authenticate`: `action`, broker-minted `session_id`, non-negative `runtime_generation`.
|
||||
- `mint_token`: authenticated identity plus `binding` containing exactly `compaction_epoch`, `request_epoch`, `h_source`, `h_payload`, and `schema_version`.
|
||||
- `consume_token`: authenticated identity plus `token`.
|
||||
- `begin_verification`: authenticated identity, runtime (`claude` or `pi`), cycle `binding`, and a TTL no greater than 300 seconds. The broker revokes existing authority first, enters `PENDING_VERIFICATION`, and returns a single-use promotion token.
|
||||
- `promote_lease`: authenticated identity plus the exact pending promotion token. The broker commits token consumption before making `VERIFIED` visible.
|
||||
- `revoke_lease`: authenticated observer signal; deletes pending tokens and makes the session `UNVERIFIED` immediately.
|
||||
- `authorize_tool`: authenticated identity, runtime, and exact runtime-reported tool name. The broker returns an explicit allow/deny decision from the whole-class policy and current lease.
|
||||
|
||||
A higher generation for the same anchor atomically replaces the stored incarnation and deletes all prior tokens for that session. A lower generation is stale. Tokens are 256-bit values from the operating-system cryptographic RNG and are single use. At most 256 pending tokens may be persisted; another mint fails with `TOKEN_CAPACITY` before mutation. Successful consumption deletes the token, while a replay still fails with `TOKEN_REPLAY`. Live v1 token records retain the existing `consumed: false` schema. Their persisted binding is the WI-1 substrate for later receipt work; WI-1 does not implement receipts, promotion, hooks, payload builders, mutator gates, or recovery.
|
||||
A higher generation for the same anchor atomically replaces the stored incarnation and deletes all prior tokens and lease authority for that session. A lower generation is stale. Tokens are 256-bit values from the operating-system cryptographic RNG and are single use. At most 256 pending tokens may be persisted; another mint fails with `TOKEN_CAPACITY` before mutation. Successful consumption deletes the token, while a replay still fails with `TOKEN_REPLAY`. Live v1 token records retain the existing `consumed: false` schema.
|
||||
|
||||
VERIFIED leases are volatile and monotonic-time bounded: broker restart, generation change, explicit observer revocation, or expiry returns the session to `UNVERIFIED`. `begin_verification` always revokes before minting a new prerequisite. `promote_lease` is valid only from the matching pending cycle; persistence failure rolls token and lease state back, while post-rename durability uncertainty terminates the broker. The WI-1 token is the atomic promotion prerequisite substrate. A later receipt implementation must satisfy that prerequisite but cannot replace the mechanical mutator gate as safety authority.
|
||||
|
||||
State replacement serializes and enforces the 4 MiB maximum before opening a temporary file, then uses a mode-`0600` temporary file, `fsync`, atomic rename, and parent-directory `fsync`. Every broker mutation snapshots the prior v1 state. A commit failure before rename restores that snapshot and leaves durable state unchanged. A failure after rename makes durability uncertain, so the store is poisoned without rolling memory back and the daemon terminates rather than serving with divergent state. Existing state is opened without following symlinks, must be a bounded regular file at mode `0600`, and is fully schema- and invariant-validated before use. Persisted tokens must be unconsumed, match their session's current generation, and remain within the 256-token cap. Session identity is uniquely keyed by `(anchor_pid,anchor_starttime)`; duplicate logical sessions for one anchor refuse startup. State integrity or mode failures refuse startup. The daemon does not log session IDs or tokens.
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
- Session IDs and cycle tokens use the OS cryptographic RNG. `Math.random` and model output are not token sources.
|
||||
- Framing and persistence failures fail closed. Sensitive tokens are not logged.
|
||||
- Built-in `0700`/`0600` filesystem modes provide same-principal hardening only, not socket authenticity against the same UID. WI-1 provides no distinct-principal isolation. That stronger deployment requires an external protected proxy, ACL, or service boundary, and the boundary must preserve authenticated client identity for the broker's `SO_PEERCRED` and ancestry authorization rather than substituting a shared proxy identity.
|
||||
- WI-2+ security surfaces—receipts, promotion transactions, mutator gates, hooks, payload construction, and recovery—are explicitly out of scope.
|
||||
- WI-2 whole-class authorization denies every consequential, unknown, and custom tool while UNVERIFIED; it does not inspect shell strings or trust wrapper selection. 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.
|
||||
|
||||
Coordinator security review must rerun the real socket/peercred acceptance suite on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration.
|
||||
Coordinator security review must rerun the real socket/peercred and mutator-gate acceptance suites on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration.
|
||||
|
||||
72
docs/architecture/mutator-class-gate.md
Normal file
72
docs/architecture/mutator-class-gate.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Whole mutator-class lease gate
|
||||
|
||||
WI-2 adds the framework-native authorization boundary for Claude (including the supported Claudex overlay) and Pi. Every runtime-reported tool name reaches the lease broker before execution. The gate classifies capabilities by the whole tool class; it never parses a Bash command to decide whether that particular string looks read-only.
|
||||
|
||||
## Default-deny policy
|
||||
|
||||
While a session is not VERIFIED, only these exact classes are allowed:
|
||||
|
||||
- Claude: `Read`, `Grep`, `Glob`, `Ls`, `Find`
|
||||
- Pi: `read`, `grep`, `find`, `ls`
|
||||
- Both runtimes: the fixed `mosaic_context_recover` primitive
|
||||
|
||||
Every other built-in, unknown tool, and custom/MCP tool is consequential by default and is denied. This includes Claude `Bash`, `Edit`, `Write`, and `NotebookEdit`, plus Pi `bash`, `edit`, and `write`. A compromised model therefore cannot bypass Mosaic wrappers by selecting raw `git`, `curl`, `kubectl`, provider, deployment, or filesystem commands inside a generic mutator—the generic mutator itself is blocked before its input executes.
|
||||
|
||||
## Broker-owned transition order
|
||||
|
||||
The authenticated broker is the sole lease writer:
|
||||
|
||||
1. `begin_verification` revokes existing authority and pending tokens first, then records `PENDING_VERIFICATION` and mints one WI-1 single-use promotion token bound to the exact cycle.
|
||||
2. `promote_lease` accepts only that session/generation/binding/token combination.
|
||||
3. Token consumption commits before the volatile lease becomes VERIFIED. Promotion is last and cannot be reached directly from UNVERIFIED.
|
||||
4. `revoke_lease`, a runtime-generation increase, broker restart, or monotonic expiry removes mutator authority.
|
||||
|
||||
The initial TTL is capped at the ratified 300-second maximum. A caller may request a shorter positive TTL but cannot lengthen the maximum. Dual compaction-hook miss within an unexpired lease remains the ratified bounded T-A residual; once either observer revokes or TTL expires, the next consequential tool is denied.
|
||||
|
||||
A receipt is only a future promotion prerequisite. It is not an obedience, residency, or safety proof and never replaces this mechanical gate.
|
||||
|
||||
## Runtime adapters
|
||||
|
||||
`launch-runtime.py` registers itself with the broker and then `exec`s Claude or Pi so PID/starttime remain the authenticated parent anchor. It exports only the broker-minted session ID and current generation to descendants.
|
||||
|
||||
- Claude installs `mutator-gate.py` as an all-tools (`.*`) `PreToolUse` hook.
|
||||
- `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.
|
||||
- Pi invokes the same executable from its `tool_call` handler.
|
||||
|
||||
The executable submits the runtime's actual tool name to `authorize_tool`. Missing identity, malformed input/reply, timeout, broker unavailability, or denial exits with status 2 and blocks fail-closed.
|
||||
|
||||
## Runtime-launch choke-point and permanent guard
|
||||
|
||||
Every repository-owned Claude/Pi launch entry converges on `launch-runtime.py`, either directly or through `mosaic` → `execLeaseGatedRuntime`. PRDY init/update and QA remediation invoke the wrapper directly so their existing prompts, dangerous-permission behavior, working directory, and environment survive without skipping broker registration. The raw Claude `--dangerously-skip-permissions` primitive is owned only by `launch-runtime.py`; callers request semantic `--dangerous` mode, and the wrapper validates Claude before injecting the primitive. `@mosaicstack/coord` rewrites direct Claude commands to `mosaic claude` and rejects unknown custom Claude launchers.
|
||||
|
||||
`check-runtime-launches.py` is the permanent completeness guard. It scans production shell, TypeScript/JavaScript, Python, and data launch definitions under `packages/`, `apps/`, `plugins/`, and `tools/`; direct literal, absolute-path, process-API, dynamic, command-substitution, `eval`, and variable-execution runtime launches fail. Shell comments are stripped with quote awareness, wrapper prefixes are tokenized with `shlex`, and only an invocation in command position with `--runtime` before the command separator is gated. Literal and tracked-variable command tokens use one terminal resolver after any nesting of `exec`, `command`, `nohup`, or `env` plus assignments. A direct command always wins over an inert marker on the same line. Independently, the raw dangerous primitive anywhere outside the choke-point is RED.
|
||||
|
||||
The command parser is a best-effort CI defense, not a complete shell interpreter. Alias/function redefinition, sourced commands, generated scripts, and encoded pipelines are intentionally residual rather than an invitation to chase an unbounded shell language. Two runtime controls backstop that residual surface: primitive ownership rejects a dangerous launch even when command identity is alias-indirected, and Claude's global `.*` `PreToolUse` hook invokes the broker gate for non-dangerous launches. Without `MOSAIC_LEASE_SESSION_ID`, representative read, mutator, and custom/MCP tools all fail closed with `GATE_UNAVAILABLE`. Hook absence or replacement remains in the documented T-C boundary.
|
||||
|
||||
### Parser stopping criterion
|
||||
|
||||
- **A — realistic parser matrix:** comments, inert strings/assignments, heredocs, continuations, chained commands, command substitution, `eval`, bare tracked variables, and quoted/unquoted tracked variables behind `exec`, `command`, `nohup`, or `env` are permanent RED regressions. Prefix-variable forms are covered in both multiline and same-line assignment shapes.
|
||||
- **B — residual backstops:** a dangerous alias-indirected launch is RED solely through primitive anchoring; a parser-missed non-dangerous alias launch is paired with an acceptance test proving the global all-tools hook denies every representative tool class as `GATE_UNAVAILABLE` without a lease.
|
||||
- **C — independent fresh review:** the parser class is considered complete only when reviewers find no new non-overlapping realistic evasion on the exact head. A and B are repository evidence; C is supplied by the fresh review round.
|
||||
|
||||
All three layers are load-bearing and complementary. The guard is mandatory in `@mosaicstack/mosaic`'s test script, so root CI fails on a future realistic bypass. Real-socket tests separately prove PRDY init/update and QA receive broker sessions and deny an unverified mutator.
|
||||
|
||||
The live inventory is emitted by:
|
||||
|
||||
```bash
|
||||
python3 packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py --root . --json
|
||||
```
|
||||
|
||||
| Production launch family | Gated entries |
|
||||
| ------------------------------------------------------ | ------------: |
|
||||
| `@mosaicstack/coord` default/configured Claude command | 2 |
|
||||
| Fleet runtime start | 1 |
|
||||
| QA remediation + generated QA command | 2 |
|
||||
| Orchestrator command construction/session launches | 3 |
|
||||
| PRDY init/update | 2 |
|
||||
| Mosaic Claude/Pi/Claudex adapter and wrapper boundary | 4 |
|
||||
| **Total** | **14 / 14** |
|
||||
|
||||
## Assurance boundary
|
||||
|
||||
This closes T-A after an observer fires or lease expiry and T-B for in-runtime tool calls. Hook/extension absence, a runtime executing outside the gated launcher, ptrace/same-UID broker replacement, and other fully rotted behavior remain T-C. Server-side branch protection and required PR review/CI remain the irreducible line for protected repository mutations.
|
||||
@@ -10,9 +10,26 @@ python3 "$MOSAIC_HOME/tools/lease-broker/daemon.py" \
|
||||
|
||||
The broker refuses an existing parent directory whose mode is not exactly `0700`, an existing state file not at `0600`, corrupt/incompatible state, or an already-existing socket path. After bind it sets the socket to `0600`. It never silently unlinks a pre-existing socket. On normal termination it unlinks only the socket inode it created, so it does not remove a replacement path.
|
||||
|
||||
Before launching Claude, Claudex, or Pi, export the socket path; `mosaic` then runs the runtime through the packaged register-and-exec wrapper:
|
||||
|
||||
```bash
|
||||
export MOSAIC_LEASE_BROKER_SOCKET=/run/user/1000/mosaic-lease/broker.sock
|
||||
mosaic claude # or: mosaic 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.
|
||||
|
||||
Run the permanent launch inventory locally with:
|
||||
|
||||
```bash
|
||||
python3 packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py --root .
|
||||
```
|
||||
|
||||
The same check runs in the Mosaic package test suite and therefore in root CI. Any direct Claude/Pi binary launch must be replaced with `launch-runtime.py`, `execLeaseGatedRuntime`, or the gated `mosaic` runtime command; do not add static allowlist exceptions.
|
||||
|
||||
Clients must complete the request boundary before waiting for a reply. After sending the single JSON object and its terminating newline, the client **MUST half-close the socket's write side** (`shutdown(SHUT_WR)` in POSIX clients; `socket.end()` in Node) and only then await the response. Merely calling `write()` and waiting is invalid: the broker waits for EOF to enforce the exact-one-frame contract and fails closed at its one-second deadline. Do not replace `end()` with `write()` in client helpers. A delayed second frame remains malformed and is rejected.
|
||||
|
||||
There is no automated recovery workflow in WI-1. After a crash, preserve the protected state file and restart only after verifying that no broker owns the socket. A leftover socket requires an operator to verify the owning service is stopped and remove that exact socket deliberately. Corrupt, oversized, symlinked, or non-regular state fails closed; do not overwrite it. Preserve it for incident review and establish new state only through an explicit operational decision, which invalidates prior sessions and tokens.
|
||||
There is no automated recovery workflow yet. `mosaic_context_recover` is reserved as the only unverified mutator class, but its fixed payload/receipt implementation lands in a later WI. After a crash, preserve the protected state file and restart only after verifying that no broker owns the socket. Restart intentionally clears all volatile VERIFIED leases. A leftover socket requires an operator to verify the owning service is stopped and remove that exact socket deliberately. Corrupt, oversized, symlinked, or non-regular state fails closed; do not overwrite it. Preserve it for incident review and establish new state only through an explicit operational decision, which invalidates prior sessions and tokens.
|
||||
|
||||
## Security posture
|
||||
|
||||
|
||||
130
docs/scratchpads/829-mutator-gate.md
Normal file
130
docs/scratchpads/829-mutator-gate.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# WI-2 Scratchpad — Whole mutator-class gate
|
||||
|
||||
- **Issue:** Gitea #829
|
||||
- **Branch:** `feat/829-mutator-gate`
|
||||
- **Base HEAD:** `8ec67a1126adb0dcd4c3a2bf5525f3e239c0b201`
|
||||
- **Role:** sol author/build lane only; terra code review and Opus security review are coordinator-owned.
|
||||
|
||||
## Mission prompt
|
||||
|
||||
Implement BUILD-BRIEF Deliverable 2 as a framework-native whole mutator-class gate under `packages/mosaic/`, building against the merged WI-1 lease broker. No consequential mutator may succeed while UNVERIFIED after a compaction observer fires or after TTL. Carry the T-B compromised-tool acceptance criteria. Enforce revoke-first and promote-last structurally. A receipt is only a promotion prerequisite; the mutator-class gate remains the safety mechanism. M1 is Claude + Pi only.
|
||||
|
||||
## Authority verification
|
||||
|
||||
Verified exact SHA-256 before design/code:
|
||||
|
||||
- BUILD-BRIEF: `89fdbc27ed0e5050dc7b52f3ef2ddaea691edf17fd89d51b15e26fb5ed47171b`
|
||||
- SPEC-v5: `a6d07ade835758e8488ca10d3b0631caf0beb93ea3a6733631f151b0c2f01433`
|
||||
- Ratification: `bac58319c9c4028b5b40e1129e0033cdb5a6b7b02033c25f06f4cb77d7779c67`
|
||||
- sol final red-team: `3da326a4ea91767b731e128a93b13194e8002358101e30de3fcb8ca2f8f54faa`
|
||||
|
||||
Carried authority chain also verified/read for the locked T-B gate contract: SPEC-v4 `a5e9c261…`, v4 sol `1e76ee59…`, SPEC-v3 `e0830ba0…`, v3 sol `9f321ade…`.
|
||||
|
||||
## Plan
|
||||
|
||||
1. RED real-socket acceptance tests for default-deny whole classes, T-B raw-tool bypass, observer/TTL revocation, and structural revoke-first/promote-last.
|
||||
2. Extend the merged WI-1 broker as the sole lease authority; authenticate every transition through existing peercred/ancestry/session logic and consume WI-1 single-use cycle tokens atomically before promotion.
|
||||
3. Add one broker-backed runtime gate executable and wire it across all Claude `PreToolUse` tools and Pi `tool_call`; unknown/custom tools deny by default.
|
||||
4. Add proportional protocol/security/operations documentation and requirements-to-evidence mapping.
|
||||
5. Run focused coverage, package/full suites, lint/typecheck/format, then queue-guard, push, open an unmerged PR with `closes #829`, and hand off the exact head.
|
||||
|
||||
## Risks and bounds
|
||||
|
||||
- Receipt parsing/builders and compaction observers are later WIs; WI-2 exposes the promotion prerequisite boundary but does not treat a receipt as safety authority.
|
||||
- Broker restart intentionally loses volatile VERIFIED leases and therefore restarts UNVERIFIED; persistent WI-1 identity/token state remains unchanged.
|
||||
- The gate is whole-class and does not parse shell command strings. T-C extension/hook absence and same-UID broker replacement remain outside the client guarantee and server branch protection remains the backstop.
|
||||
- Initial lease TTL is capped at ratified 300 seconds; callers may only shorten it.
|
||||
- Working budget assumption: 35K tokens; reduce documentation/refactor breadth before touching locked scope if pressure rises.
|
||||
|
||||
## Progress and verification
|
||||
|
||||
- RED #1: all 5 initial real-socket contract tests failed on WI-1 with `UNKNOWN_ACTION` or missing adapter behavior.
|
||||
- RED #2: register-before-exec runtime test failed because `launch-runtime.py` did not exist.
|
||||
- GREEN: broker-owned volatile lease state, 300-second maximum monotonic TTL, WI-1 token-backed promotion, all-tools runtime gate, Claude/Pi wiring, and register-before-exec launcher delivered without changing WI-1 peercred/ancestry authority.
|
||||
- Focused broker + gate acceptance: `43/43` GREEN.
|
||||
- Instrumented Python coverage: `88%` total — daemon `89%`, register/exec launcher `86%`, runtime gate `86%`.
|
||||
- Full repository suite: `43/43` Turbo tasks GREEN; `@mosaicstack/mosaic` `72/72` files and `1,377/1,377` tests GREEN.
|
||||
- Root typecheck: `42/42`; lint: `23/23`; format check and `git diff --check`: GREEN.
|
||||
- No author self-review was run. Exact-head terra CODE and Opus SECREV remain coordinator-owned gates.
|
||||
|
||||
## Acceptance mapping
|
||||
|
||||
| Acceptance criterion | Evidence |
|
||||
| --- | --- |
|
||||
| No consequential mutator succeeds while UNVERIFIED after observer revoke or TTL | `observer revocation and monotonic TTL expiry deny the next mutator` real-socket acceptance test |
|
||||
| T-B compromised-tool bypass is covered by the whole gate | `T-B raw and custom mutator tools are default-denied without shell parsing` across Claude/Pi built-ins, raw Bash class, MCP/custom/unknown tools |
|
||||
| Revoke-first / promote-last is structural | `revoke-first and promote-last structurally bracket mutator authority`; direct promotion rejected, pending remains denied, token consumption commits before VERIFIED |
|
||||
| Consume WI-1 auth/lease substrate | All transitions and decisions traverse merged peercred/ancestry authentication; promotion consumes the exact WI-1 CSPRNG cycle token |
|
||||
| M1 Claude + Pi | Claude `.*` PreToolUse and Pi `tool_call` invoke the same broker gate; register-before-exec test proves broker-minted parent identity reaches runtime descendants |
|
||||
|
||||
## Locked discipline
|
||||
|
||||
- Re-verify and read the four authority artifacts before design or code.
|
||||
- RED-first tests must cover unverified mutation refusal, T-B compromised-tool refusal, and revoke-first/promote-last ordering.
|
||||
- Consume WI-1 VERIFIED-lease state; do not re-derive kernel identity, ancestry, sessions, or token authentication.
|
||||
- Minimum 85% new-code coverage; full suite, lint, typecheck, and format checks green.
|
||||
- Build only: no self-review and no merge. Open a PR containing `closes #829`, report its exact 40-character head, then exit.
|
||||
|
||||
## Remediation — terra CODE comment 18091
|
||||
|
||||
- Coordinator correction: terra returned REQUEST CHANGES at head `77b137ccc04b5be035cac5ca21bbbf3df8b94f97`; Opus SECREV was GO and CI green, but no evidence transfers to the remediated head.
|
||||
- BLOCKER 1 verified: first-class Claude/Pi route through `execLeaseGatedRuntime`, while the supported Claudex path preserves isolation but directly invokes `claude`; it therefore registers no anchor, injects no lease session, and the isolated config has no guaranteed all-tools gate hook.
|
||||
- BLOCKER 2 accepted: prior 88% was aggregate evidence. Remediation must produce independently measured branch coverage of at least 85% for each new executable (`launch-runtime.py`, `mutator-gate.py`, and daemon delta evidence), including successful exec-boundary collection and validation/error branches.
|
||||
- Remediation discipline: RED tests first; preserve the reviewed-good broker lock/state-transition ordering; update PR #837 on the same branch; no self-review or merge.
|
||||
|
||||
### Remediation evidence
|
||||
|
||||
- RED commit `046896c6`: both `mosaic claudex` and `mosaic yolo claudex` behavioral probes exited 1 because the direct path supplied neither a broker session nor the isolated all-tools hook; branch-focused Python tests failed on the absent injectable boundaries. Fresh WI-2 daemon-delta instrumentation also failed the ≥85% branch gate at 75%.
|
||||
- GREEN: Claudex now exposes only `execLeaseGated`, passes the preserved isolated proxy environment through the shared register-before-exec wrapper, and merges the exact `.*` mutator hook into isolated `settings.json` with mode `0600`. Missing broker/identity, malformed or symlinked settings, and an unverified consequential tool all fail closed. The broker transition/lock implementation was not changed.
|
||||
- Behavioral regression: normal and YOLO Claudex both receive a 64-hex broker session, retain their mode-specific arguments, observe the exact all-tools hook, and receive status 2 for unverified `Bash`.
|
||||
- Independent branch coverage: `launch-runtime.py` 16/18 = **89%** (statements 98%); `mutator-gate.py` 21/22 = **95%** (statements 99%); `daemon.py` WI-2 delta 35/40 = **88%** (whole-file branch 80%, statements 90%).
|
||||
- Fresh focused real-socket coverage run: WI-1 + WI-2 acceptance `46/46`; persistence `10/10`; branch unit suite `10/10`.
|
||||
- Fresh full repository suite: `43/43` Turbo tasks; `@mosaicstack/mosaic` `72/72` files and `1,381/1,381` tests.
|
||||
- Fresh root gates: typecheck `42/42`; lint `23/23`; format and `git diff --check` GREEN.
|
||||
- PR #837 remains open and unmerged. Terra CODE and Opus SECREV must both rerun from zero on the exact remediated head before coordinator-owned merge authorization.
|
||||
|
||||
## Remediation round 3 — terra CODE comment 18099 + binding upgrade
|
||||
|
||||
- Locked-good surfaces: Claudex gating and B2 per-executable coverage are verified; do not regress them. Broker state-transition/lock ordering remains untouched.
|
||||
- Mechanical repository sweep found direct executing Claude entries in PRDY init, PRDY update, QA remediation, and `@mosaicstack/coord` task launch. It also found a direct Claude command rendered into the QA report template and documentation examples. Existing Mosaic CLI Claude/Pi/Claudex, orchestrator session-run, and fleet starts already reach the gated boundary.
|
||||
- Elevated hard requirements: ship a permanent suite/CI guard that scans production source and fails on any direct Claude/Pi launch; route every executing entry through one common gated wrapper; add real-broker RED/GREEN tests for PRDY init/update and QA; preserve each environment and denial behavior; independently measure all new executable coverage at ≥85%.
|
||||
- Round-3 plan: first commit RED behavioral and scanner-contract tests; then add one framework `launch-runtime.sh` choke-point over `launch-runtime.py`, make Mosaic CLI and shell launchers use it, make coord route through `mosaic`, and wire the permanent guard into package tests. Update all discovered operator-facing direct-launch examples so the scanner inventory remains complete.
|
||||
|
||||
### Round-3 outcome
|
||||
|
||||
- RED commit `7f3418fa`: PRDY init, PRDY update, and QA remediation all reached the fake Claude binary without a broker session even when the configured socket did not exist; the permanent-guard contract initially failed because its executable was absent, then failed against the five discovered direct entries (four executing plus the QA command template).
|
||||
- Choke-point decision: a new shell layer was unnecessary. Every executing repository entry now converges directly or through `mosaic`/`execLeaseGatedRuntime` on the existing single `launch-runtime.py` register-then-exec wrapper. PRDY and QA preserve their working directories, prompts, flags, logging pipe, and environment. Coord rewrites direct Claude commands to `mosaic claude` and rejects unknown custom Claude launchers fail-closed.
|
||||
- Permanent guard: `packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py`, invoked by `packages/mosaic/package.json` `test:framework-shell` and therefore root `pnpm test`/CI. It scans production code under `packages/`, `apps/`, `plugins/`, and `tools/` and rejects literal, absolute-path, process-API, command-array, and dynamic Claude/Pi launch forms. Synthetic bypass tests are permanent at `runtime_launch_guard_unittest.py`.
|
||||
- Mechanical inventory: **14 gated / 14 total** — coord 2, fleet 1, QA 2, orchestrator 3, PRDY 2, Mosaic Claude/Pi/Claudex adapter/boundary 4. No verification-layer fallback or follow-up issue is needed because the single code-level wrapper was achieved.
|
||||
- Real-socket behavioral evidence: PRDY init, PRDY update, and QA remediation each fail before runtime execution when the broker is absent; with the broker present they receive a broker-minted 64-hex session and the unverified `Bash` authorization exits 2. Claudex normal/YOLO and the broker state machine remain GREEN.
|
||||
- Fresh branch coverage: `launch-runtime.py` **18/18 = 100%**; `mutator-gate.py` **22/22 = 100%**; permanent guard **36/38 = 95%**; `daemon.py` WI-2 delta **35/40 = 87.5%**. All attributable executable statement coverage is at least 98%.
|
||||
- Fresh focused suites: broker + mutator real-socket acceptance `49/49`; persistence `10/10`; launcher/gate branch suite `13/13`; permanent guard suite `7/7`; coord `19/19`.
|
||||
- Fresh full repository suite: `43/43` Turbo tasks; `@mosaicstack/mosaic` `72/72` files and `1,384/1,384` tests. Root typecheck `42/42`, lint `23/23`, format, and diff checks GREEN.
|
||||
- PR #837 remains open and unmerged. Terra CODE and Opus SECREV must both rerun from zero on the exact round-3 head before coordinator-owned merge authorization.
|
||||
|
||||
## Remediation round 4 — terra CODE comment 18104
|
||||
|
||||
- Locked-good surfaces: the 14/14 launch inventory, single `launch-runtime.py` choke-point, real-socket launcher behavior, coverage, Claudex gating, and broker state machine must not change.
|
||||
- Reproduced RIDER E exactly at head `1792b7934dda7eff64a207b8b0edb9c460d4164b`: a temporary production file containing `exec claude --dangerously-skip-permissions "terra-r3" # launch-runtime.py` made the guard exit 0 and report `1 gated/1 total`.
|
||||
- Root cause: classification searched the unparsed physical line, and the broad gated regex treated any `launch-runtime.py` substring—including comments and inert arguments—as an invocation before the direct-launch finding was evaluated.
|
||||
- Round-4 plan: add permanent RED cases for the exact comment evasion plus string-argument, echo, and unrelated-variable marker evasions; tokenize/strip comments by launcher syntax; recognize only command-position wrapper invocations with `--runtime` and the gated command separator; retain 14/14 real inventory; rerun guard coverage and all gates fresh.
|
||||
- Mos Rider A/B decision: adopt **both** defenses. Command-position parsing remains necessary because a normal `claude -p` launch is consequential even without the dangerous flag. The primitive-location invariant is more mechanically robust for dangerous mode because it does not need to recognize a wrapper marker at all. Move the sole raw `--dangerously-skip-permissions` literal into `launch-runtime.py`; any occurrence in another production file is independently RED.
|
||||
- Rider-A RED matrix adds heredoc body, backslash continuation, non-first `;`/`&&`/pipe commands, command substitution, `eval`, and variable-execution indirection in addition to the six marker/comment evasions. Before the augmented implementation, primitive ownership, command substitution, `eval`, variable execution, and the preserved 14-site inventory all fail.
|
||||
- Round-4 GREEN uses both defenses. Quote-aware comment stripping removes shell/Python `#` and JS/TS line/block comments; shell command prefixes are segmented with `shlex`; validated wrappers require `launch-runtime.py` in command position, `--runtime`, and the `--` command separator; multiline TypeScript wrapper calls are validated as complete invocations. Direct command syntax wins over markers, while tracked runtime assignments plus `eval`/variable execution, command substitution, chained commands, heredocs, continuations, and `env`/`command`/`nohup` prefixes are rejected.
|
||||
- Primitive ownership is independently load-bearing: `launch-runtime.py` is the sole production owner of the raw Claude dangerous flag. Mosaic, Claudex, and PRDY request semantic `--dangerous`; the wrapper validates Claude and injects the primitive immediately before register/exec. This preserves actual YOLO argv behavior while making any raw primitive elsewhere fail without relying on wrapper-name recognition.
|
||||
- Permanent guard suite now has 10 tests and 31 direct-launch forms, including 18 new round-4 marker/comment/indirection/prefix evasions plus harmless-marker and multiline-wrapper controls. Terra's exact add-ungated source is exercised through the CLI effectiveness test. Repository inventory remains exactly **14 gated / 14 total**.
|
||||
- Fresh round-4 coverage: guard **97%** branch-aware aggregate (241 statements, 110 branches); `launch-runtime.py` **100%**; `mutator-gate.py` **100%**. The daemon is byte-unchanged from the round-3 head whose WI-2 delta is **87.5%**.
|
||||
- Fresh round-4 gates: real-socket acceptance **49/49**; persistence **10/10**; launcher/gate **14/14**; guard **10/10**; coord **19/19**; Mosaic **1384/1384**; root **43/43**; typecheck **42/42**; lint **23/23**; format and diff checks green.
|
||||
|
||||
## Remediation round 5 — terra 18116 + Opus 18114
|
||||
|
||||
- Both independent gates converged on one guard-only completeness gap at round-4 head `1eb77c17f3147d4fa9944f77f1826243135b9cc0`; all round-4 primitive anchoring, command-position parsing, 14/14 inventory, broker ordering, and coverage remain locked-good.
|
||||
- Reproduced exactly: a temporary production source containing `launcher=claude` followed by `exec "$launcher" -p x` exits 0 with `0 gated / 0 total`. The literal command resolver skips prefixes but cannot resolve a tracked variable; the variable resolver handles only bare/eval references and cannot skip prefixes.
|
||||
- Round-5 plan: add 10 permanent RED forms (quoted/unquoted `exec`, `command`, `nohup`, and `env` with assignment, each multiline and same-line), then unify shell command-position resolution so literal and tracked-variable terminal tokens traverse the same prefix parser. Retain an independent variable-reference backstop, the 14/14 inventory, and every round-4 regression.
|
||||
- Mos stopping-criterion augment: command parsing is explicitly best-effort rather than a complete shell interpreter. Add B1 proving a parser-exotic alias launch with the raw dangerous flag is still RED by primitive anchoring, and B2 proving a parser-missed non-dangerous alias launch reaches the global `.*` hook and fails closed with `GATE_UNAVAILABLE` when no lease session exists. Document A (realistic parser matrix) + B (robust residual backstops); fresh reviewers supply criterion C (no new non-overlapping finding).
|
||||
- RED commit `91a4a983`: all 10 prefix×variable cases failed as expected before the fix—quoted/unquoted `exec`, `command`, `nohup`, and `env A=1`, each in multiline and same-line assignment shapes.
|
||||
- GREEN structural resolution: `shell_command_tokens()` now owns command-position prefix skipping for both literal and variable callers, including nested `exec`/`command`/`nohup`/`env` ordering. `runtime_variables` is threaded into `is_shell_direct_invocation()` and the same terminal-token resolver backs `executes_runtime_variable()`; exact `$v` and `${v}` references are resolved after `shlex` removes quoting. Same-line runtime assignment delimiters include shell operators.
|
||||
- Residual backstops: B1 proves alias-indirected dangerous mode is classified `dangerous-primitive` even though the parser does not resolve the alias. B2 proves a non-dangerous alias residual remains parser-missed, then verifies the shipped global `.*` Claude hook and status-2 `GATE_UNAVAILABLE` denial for representative read, mutator, and custom/MCP tools without a lease session.
|
||||
- Stopping-criterion evidence A+B is committed in tests and architecture docs; C remains the fresh exact-head terra/Opus determination. Repository inventory remains exactly **14 gated / 14 total**.
|
||||
- Fresh round-5 coverage: permanent guard remains **97%** branch-aware aggregate (251 statements, 112 branches). Locked-good launcher and mutator-gate executables remain unchanged at their round-4 **100% / 100%** evidence.
|
||||
- Fresh round-5 gates: real-socket acceptance **50/50**; persistence **10/10**; launcher/gate **14/14**; guard **12/12**; coord **19/19**; Mosaic **1385/1385**; root **43/43**; typecheck **42/42**; lint **23/23**; format and diff checks green.
|
||||
41
packages/coord/src/__tests__/runtime-launch-gate.test.ts
Normal file
41
packages/coord/src/__tests__/runtime-launch-gate.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveLaunchCommand } from '../runner.js';
|
||||
|
||||
describe('coord consequential-runtime launch gate', () => {
|
||||
it('routes default and direct configured Claude commands through mosaic', () => {
|
||||
expect(resolveLaunchCommand('claude', 'continue', undefined)).toEqual([
|
||||
'mosaic',
|
||||
'claude',
|
||||
'-p',
|
||||
'continue',
|
||||
]);
|
||||
expect(resolveLaunchCommand('claude', 'continue', ['claude', '-p', '{prompt}'])).toEqual([
|
||||
'mosaic',
|
||||
'claude',
|
||||
'-p',
|
||||
'continue',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves an already-gated Claude command and rejects unknown launchers', () => {
|
||||
expect(
|
||||
resolveLaunchCommand('claude', 'continue', ['mosaic', 'yolo', 'claude', '{prompt}']),
|
||||
).toEqual(['mosaic', 'yolo', 'claude', 'continue']);
|
||||
expect(() => resolveLaunchCommand('claude', 'continue', ['custom-launcher'])).toThrow(
|
||||
/must use `mosaic claude`/,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not change the out-of-scope Codex command contract', () => {
|
||||
expect(resolveLaunchCommand('codex', 'continue', undefined)).toEqual([
|
||||
'codex',
|
||||
'-p',
|
||||
'continue',
|
||||
]);
|
||||
expect(resolveLaunchCommand('codex', 'continue', ['codex', '{prompt}'])).toEqual([
|
||||
'codex',
|
||||
'continue',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -179,32 +179,41 @@ function buildContinuationPrompt(params: {
|
||||
`3. Read \`${mission.scratchpadFile}\` for session history and decisions`,
|
||||
`4. Read \`${mission.tasksFile}\` for current task state`,
|
||||
'5. `git pull --rebase` to sync latest changes',
|
||||
`6. Launch runtime with \`${runtime} -p\``,
|
||||
`6. Launch runtime with \`mosaic ${runtime} -p\``,
|
||||
`7. Continue execution from task **${taskId}**`,
|
||||
'8. Follow Two-Phase Completion Protocol',
|
||||
`9. You are the SOLE writer of \`${mission.tasksFile}\``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function resolveLaunchCommand(
|
||||
export function resolveLaunchCommand(
|
||||
runtime: 'claude' | 'codex',
|
||||
prompt: string,
|
||||
configuredCommand: string[] | undefined,
|
||||
): string[] {
|
||||
if (configuredCommand === undefined || configuredCommand.length === 0) {
|
||||
return [runtime, '-p', prompt];
|
||||
return runtime === 'claude' ? ['mosaic', 'claude', '-p', prompt] : [runtime, '-p', prompt];
|
||||
}
|
||||
|
||||
const hasPromptPlaceholder = configuredCommand.some((value) => value === '{prompt}');
|
||||
const withInterpolation = configuredCommand.map((value) =>
|
||||
value === '{prompt}' ? prompt : value,
|
||||
);
|
||||
const command = hasPromptPlaceholder ? withInterpolation : [...withInterpolation, prompt];
|
||||
|
||||
if (hasPromptPlaceholder) {
|
||||
return withInterpolation;
|
||||
if (runtime !== 'claude') return command;
|
||||
if (
|
||||
command[0] === 'mosaic' &&
|
||||
(command[1] === 'claude' || (command[1] === 'yolo' && command[2] === 'claude'))
|
||||
) {
|
||||
return command;
|
||||
}
|
||||
|
||||
return [...withInterpolation, prompt];
|
||||
if (command[0] === 'claude') {
|
||||
return ['mosaic', 'claude', ...command.slice(1)];
|
||||
}
|
||||
throw new Error(
|
||||
'Custom Claude task commands must use `mosaic claude` so lease registration cannot be bypassed.',
|
||||
);
|
||||
}
|
||||
|
||||
async function writeAtomicJson(filePath: string, payload: unknown): Promise<void> {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
When spawning workers, include skill loading in the kickstart:
|
||||
|
||||
```bash
|
||||
claude -p "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."codex exec "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."
|
||||
mosaic claude -p "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."codex exec "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."
|
||||
```
|
||||
|
||||
#### **MANDATORY**
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
"model": "opus",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
|
||||
@@ -28,6 +28,7 @@ import { execSync, spawnSync } from 'node:child_process';
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
const MUTATOR_GATE = join(MOSAIC_HOME, 'tools', 'lease-broker', 'mutator-gate.py');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -106,6 +107,23 @@ function nowIso(): string {
|
||||
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
}
|
||||
|
||||
function checkPiMutatorGate(toolName: string): { block: true; reason: string } | undefined {
|
||||
const result = spawnSync('python3', [MUTATOR_GATE, '--runtime', 'pi'], {
|
||||
input: `${JSON.stringify({ tool_name: toolName })}\n`,
|
||||
encoding: 'utf8',
|
||||
timeout: 2_000,
|
||||
env: process.env,
|
||||
});
|
||||
if (result.status === 0) return undefined;
|
||||
const detail = String(result.stderr ?? '')
|
||||
.trim()
|
||||
.split('\n')[0];
|
||||
return {
|
||||
block: true,
|
||||
reason: detail || 'BLOCKED: Mosaic mutator gate is unavailable or the lease is UNVERIFIED.',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mission detection
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -250,6 +268,11 @@ export default function register(pi: ExtensionAPI) {
|
||||
let hbModel: string | null = null;
|
||||
let hbTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ── Whole mutator-class authorization gate ────────────────────────────
|
||||
// Every Pi tool, including unknown/custom tools, reaches the broker-backed
|
||||
// class gate before execution. Broker/script failure blocks fail-closed.
|
||||
pi.on('tool_call', async (event) => checkPiMutatorGate(event.toolName));
|
||||
|
||||
// ── Session Start ─────────────────────────────────────────────────────
|
||||
pi.on('session_start', async (_event, ctx) => {
|
||||
sessionCwd = process.cwd();
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail CI when production code launches Claude/Pi outside the lease gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Final, NamedTuple, Sequence
|
||||
|
||||
SCANNED_ROOTS: Final = ("packages", "apps", "plugins", "tools")
|
||||
SCANNED_SUFFIXES: Final = {
|
||||
".bash",
|
||||
".cjs",
|
||||
".js",
|
||||
".json",
|
||||
".mjs",
|
||||
".py",
|
||||
".sh",
|
||||
".ts",
|
||||
".tsx",
|
||||
".yaml",
|
||||
".yml",
|
||||
".zsh",
|
||||
}
|
||||
SKIPPED_DIRECTORIES: Final = {
|
||||
".git",
|
||||
".next",
|
||||
".turbo",
|
||||
"coverage",
|
||||
"dist",
|
||||
"node_modules",
|
||||
}
|
||||
DANGEROUS_PRIMITIVE: Final = "--dangerously-" + "skip-permissions"
|
||||
CHOKE_POINT_SUFFIX: Final = "framework/tools/lease-broker/launch-runtime.py"
|
||||
SHELL_SUFFIXES: Final = {".bash", ".sh", ".zsh"}
|
||||
|
||||
# A launch line is gated only when it CALLS the common wrapper in command
|
||||
# position, invokes the TypeScript adapter, or constructs/runs a `mosaic`
|
||||
# runtime command. A marker in a comment, string argument, echo, or unrelated
|
||||
# variable can never satisfy these invocation-shaped patterns.
|
||||
GATED_PATTERNS: Final = (
|
||||
re.compile(r"(?:^\s*|=>\s*)execLeaseGatedRuntime\s*\("),
|
||||
re.compile(
|
||||
r"\bexecRuntime\s*\(\s*[\"']python3[\"']\s*,\s*"
|
||||
r"\[\s*launcher\s*,.*[\"']--runtime[\"']\s*,\s*runtime\s*,\s*[\"']--[\"']"
|
||||
),
|
||||
re.compile(
|
||||
r"(?:^|[;&|]\s*|\bexec\s+|\b(?:LAUNCH_COMMAND|launch_cmd)\s*=\s*\(?|\becho\s+[\"'])"
|
||||
r"mosaic\s+(?:yolo\s+)?(?:claude|pi|claudex|[\"']?\$\{?runtime\}?[\"']?|[\"']?\$MOSAIC_AGENT_RUNTIME[\"']?)\b"
|
||||
),
|
||||
re.compile(r"\[\s*[\"']mosaic[\"']\s*,\s*(?:[\"']yolo[\"']\s*,\s*)?(?:runtime|[\"'](?:claude|pi|claudex)[\"'])"),
|
||||
)
|
||||
|
||||
DIRECT_PATTERNS: Final = (
|
||||
# Shell/process command forms, including here-doc command examples that an
|
||||
# operator could execute verbatim.
|
||||
re.compile(r"^\s*(?:claude|pi)(?:\s|$)"),
|
||||
re.compile(r"(?:^|[;&|]\s*|\bexec\s+|\bcommand\s+)(?:claude|pi)\s+(?:-p\b|--dangerously\b|--print\b)"),
|
||||
re.compile(r"\bexec\s+(?:claude|pi)(?:\s|$)"),
|
||||
re.compile(r"\bexec\s+(?:/[^\s/]+)+/(?:claude|pi)(?:\s|$)"),
|
||||
re.compile(r"\bexec\s+[\"']?\$(?:\{?runtime\}?|MOSAIC_AGENT_RUNTIME)\b"),
|
||||
# JS/TS and Python process APIs with a literal runtime binary.
|
||||
re.compile(
|
||||
r"\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync|execv|execvp|execvpe|Popen|run|call|system|check_call|check_output)\s*\(\s*(?:\[\s*)?[\"'](?:claude|pi)(?:[\"']|\s)"
|
||||
),
|
||||
re.compile(
|
||||
r"\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync|execv|execvp|execvpe|Popen|run|call|system|check_call|check_output)\s*\(\s*(?:\[\s*)?[\"'](?:/[^\"'/]+)+/(?:claude|pi)[\"']"
|
||||
),
|
||||
# Launch-command arrays and the prior @mosaicstack/coord dynamic default.
|
||||
re.compile(r"\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync)\s*\(\s*runtime\b"),
|
||||
re.compile(r"\b(?:command|launchCommand|LAUNCH_COMMAND)\s*=\s*(?:\(|\[)\s*[\"']?(?:claude|pi)\b"),
|
||||
re.compile(r"\b(?:command|launchCommand|LAUNCH_COMMAND)\s*=\s*(?:\(|\[)\s*[\"']?\$(?:\{?runtime\}?|MOSAIC_AGENT_RUNTIME)\b"),
|
||||
re.compile(r"\breturn\s*\[\s*runtime\s*,\s*[\"'](?:-p|--dangerously)"),
|
||||
re.compile(r"\$\(\s*(?:claude|pi)(?:\s|$)"),
|
||||
re.compile(r"\beval\s+[\"'](?:claude|pi)(?:\s|[\"'])"),
|
||||
)
|
||||
RUNTIME_ASSIGNMENT: Final = re.compile(
|
||||
r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*[\"']?(?:claude|pi)(?:\s|[\"']|[;&|]|$)"
|
||||
)
|
||||
TYPESCRIPT_WRAPPER_INVOCATION: Final = re.compile(
|
||||
r"(?m)^\s*execRuntime\s*\(\s*[\"']python3[\"']\s*,\s*"
|
||||
r"\[\s*launcher\s*,[^\]]*[\"']--runtime[\"']\s*,\s*runtime\s*,\s*"
|
||||
r"[\"']--[\"']\s*,\s*runtime(?:\s*,|\s*\])",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
class LaunchSite(NamedTuple):
|
||||
path: Path
|
||||
line_number: int
|
||||
line: str
|
||||
classification: str
|
||||
|
||||
|
||||
def is_test_path(path: Path) -> bool:
|
||||
name = path.name.lower()
|
||||
return (
|
||||
"__tests__" in path.parts
|
||||
or ".spec." in name
|
||||
or ".test." in name
|
||||
or name.endswith("_unittest.py")
|
||||
or name.startswith("test-")
|
||||
or name.startswith("test_")
|
||||
)
|
||||
|
||||
|
||||
def strip_comments(path: Path, line: str, in_block_comment: bool = False) -> tuple[str, bool]:
|
||||
suffix = path.suffix.lower()
|
||||
hash_comments = suffix in {".bash", ".py", ".sh", ".yaml", ".yml", ".zsh"}
|
||||
slash_comments = suffix in {".cjs", ".js", ".mjs", ".ts", ".tsx"}
|
||||
output: list[str] = []
|
||||
quote: str | None = None
|
||||
escaped = False
|
||||
index = 0
|
||||
|
||||
while index < len(line):
|
||||
if in_block_comment:
|
||||
end = line.find("*/", index)
|
||||
if end < 0:
|
||||
return "".join(output), True
|
||||
in_block_comment = False
|
||||
index = end + 2
|
||||
continue
|
||||
|
||||
character = line[index]
|
||||
following = line[index + 1] if index + 1 < len(line) else ""
|
||||
if quote is not None:
|
||||
output.append(character)
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif character == "\\":
|
||||
escaped = True
|
||||
elif character == quote:
|
||||
quote = None
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if character in {"'", '"', "`"}:
|
||||
quote = character
|
||||
output.append(character)
|
||||
index += 1
|
||||
continue
|
||||
if slash_comments and character == "/" and following == "/":
|
||||
break
|
||||
if slash_comments and character == "/" and following == "*":
|
||||
in_block_comment = True
|
||||
index += 2
|
||||
continue
|
||||
if hash_comments and character == "#":
|
||||
if suffix == ".py" or index == 0 or line[index - 1].isspace():
|
||||
break
|
||||
output.append(character)
|
||||
index += 1
|
||||
|
||||
return "".join(output), in_block_comment
|
||||
|
||||
|
||||
def is_choke_point(path: Path) -> bool:
|
||||
return path.as_posix().endswith(CHOKE_POINT_SUFFIX)
|
||||
|
||||
|
||||
def shell_commands(line: str) -> list[list[str]]:
|
||||
candidate = line.rstrip().removesuffix("\\").rstrip()
|
||||
try:
|
||||
lexer = shlex.shlex(candidate, posix=True, punctuation_chars=";&|")
|
||||
lexer.whitespace_split = True
|
||||
lexer.commenters = ""
|
||||
tokens = list(lexer)
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
commands: list[list[str]] = []
|
||||
current: list[str] = []
|
||||
for token in tokens:
|
||||
if token and all(character in ";&|" for character in token):
|
||||
if current:
|
||||
commands.append(current)
|
||||
current = []
|
||||
else:
|
||||
current.append(token)
|
||||
if current:
|
||||
commands.append(current)
|
||||
return commands
|
||||
|
||||
|
||||
def is_wrapper_invocation(path: Path, line: str) -> bool:
|
||||
if path.suffix.lower() not in SHELL_SUFFIXES:
|
||||
return False
|
||||
separator = re.search(r"\s--(?:\s|$)", line)
|
||||
if separator is None:
|
||||
return False
|
||||
# Everything after the wrapper separator is opaque runtime argv and may
|
||||
# contain an open quote continued on later physical lines. Parse only the
|
||||
# complete command-position prefix through the separator.
|
||||
wrapper_prefix = line[: separator.end()]
|
||||
for command in shell_commands(wrapper_prefix):
|
||||
if command and command[0] == "exec":
|
||||
command = command[1:]
|
||||
if not command:
|
||||
continue
|
||||
if command[0] == "python3":
|
||||
if len(command) < 2 or not command[1].endswith("launch-runtime.py"):
|
||||
continue
|
||||
arguments = command[2:]
|
||||
elif command[0].endswith(("launch-runtime.py", "launch-runtime.sh")):
|
||||
arguments = command[1:]
|
||||
else:
|
||||
continue
|
||||
try:
|
||||
runtime_index = arguments.index("--runtime")
|
||||
separator_index = arguments.index("--")
|
||||
except ValueError:
|
||||
continue
|
||||
if runtime_index + 1 < len(arguments) and runtime_index < separator_index:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_gated_line(path: Path, line: str) -> bool:
|
||||
return is_wrapper_invocation(path, line) or any(
|
||||
pattern.search(line) for pattern in GATED_PATTERNS
|
||||
)
|
||||
|
||||
|
||||
def shell_command_tokens(path: Path, line: str) -> list[str]:
|
||||
if path.suffix.lower() not in SHELL_SUFFIXES:
|
||||
return []
|
||||
assignment = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
||||
case_arm = re.match(r"^\s*[^;&()]+\)\s*", line)
|
||||
command_source = line[case_arm.end() :] if case_arm is not None else line
|
||||
resolved: list[str] = []
|
||||
for command in shell_commands(command_source):
|
||||
index = 0
|
||||
while index < len(command) and assignment.match(command[index]):
|
||||
index += 1
|
||||
# Resolve command position once for every literal and variable caller.
|
||||
# Prefixes may be nested in either order (for example `exec env A=1`).
|
||||
while index < len(command):
|
||||
if command[index] in {"command", "exec", "nohup"}:
|
||||
index += 1
|
||||
continue
|
||||
if command[index] == "env":
|
||||
index += 1
|
||||
while index < len(command) and (
|
||||
command[index].startswith("-") or assignment.match(command[index])
|
||||
):
|
||||
index += 1
|
||||
continue
|
||||
break
|
||||
if index < len(command):
|
||||
resolved.append(command[index])
|
||||
return resolved
|
||||
|
||||
|
||||
def runtime_variable_reference(token: str, runtime_variables: set[str]) -> bool:
|
||||
match = re.fullmatch(r"\$(?:([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)\})", token)
|
||||
if match is None:
|
||||
return False
|
||||
return (match.group(1) or match.group(2)) in runtime_variables
|
||||
|
||||
|
||||
def is_shell_direct_invocation(
|
||||
path: Path, line: str, runtime_variables: set[str]
|
||||
) -> bool:
|
||||
return any(
|
||||
Path(token).name in {"claude", "pi"}
|
||||
or runtime_variable_reference(token, runtime_variables)
|
||||
for token in shell_command_tokens(path, line)
|
||||
)
|
||||
|
||||
|
||||
def is_direct_line(path: Path, line: str, runtime_variables: set[str]) -> bool:
|
||||
return is_shell_direct_invocation(path, line, runtime_variables) or any(
|
||||
pattern.search(line) for pattern in DIRECT_PATTERNS
|
||||
)
|
||||
|
||||
|
||||
def executes_runtime_variable(
|
||||
path: Path, line: str, runtime_variables: set[str]
|
||||
) -> bool:
|
||||
if any(
|
||||
runtime_variable_reference(token, runtime_variables)
|
||||
for token in shell_command_tokens(path, line)
|
||||
):
|
||||
return True
|
||||
for variable in runtime_variables:
|
||||
reference = rf"\$(?:{re.escape(variable)}|\{{{re.escape(variable)}\}})"
|
||||
if re.search(rf"\beval\s+[\"']?{reference}", line):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def typescript_wrapper_lines(path: Path, source: str) -> set[int]:
|
||||
if path.suffix.lower() not in {".js", ".mjs", ".ts", ".tsx"}:
|
||||
return set()
|
||||
return {
|
||||
source.count("\n", 0, match.start()) + 1
|
||||
for match in TYPESCRIPT_WRAPPER_INVOCATION.finditer(source)
|
||||
}
|
||||
|
||||
|
||||
def classify_text(path: Path, source: str) -> list[LaunchSite]:
|
||||
sites: list[LaunchSite] = []
|
||||
validated_typescript_wrappers = typescript_wrapper_lines(path, source)
|
||||
runtime_variables: set[str] = set()
|
||||
gated_continuation = False
|
||||
in_block_comment = False
|
||||
for line_number, physical_line in enumerate(source.splitlines(), start=1):
|
||||
line, in_block_comment = strip_comments(path, physical_line, in_block_comment)
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
gated_continuation = False
|
||||
continue
|
||||
|
||||
assignment = RUNTIME_ASSIGNMENT.match(line)
|
||||
if assignment is not None:
|
||||
runtime_variables.add(assignment.group(1))
|
||||
primitive_violation = DANGEROUS_PRIMITIVE in line and not is_choke_point(path)
|
||||
line_is_direct = is_direct_line(path, line, runtime_variables) or executes_runtime_variable(
|
||||
path, line, runtime_variables
|
||||
)
|
||||
line_is_gated = line_number in validated_typescript_wrappers or is_gated_line(path, line)
|
||||
|
||||
if primitive_violation:
|
||||
sites.append(
|
||||
LaunchSite(path, line_number, physical_line.rstrip(), "dangerous-primitive")
|
||||
)
|
||||
elif line_is_direct and not gated_continuation:
|
||||
# Direct syntax always wins over a same-line marker. Only the command
|
||||
# continuation of a previously validated wrapper may contain the raw
|
||||
# runtime binary itself.
|
||||
sites.append(LaunchSite(path, line_number, physical_line.rstrip(), "direct"))
|
||||
elif line_is_gated:
|
||||
sites.append(LaunchSite(path, line_number, physical_line.rstrip(), "gated"))
|
||||
|
||||
gated = line_is_gated or gated_continuation
|
||||
gated_continuation = gated and line.rstrip().endswith("\\")
|
||||
return sites
|
||||
|
||||
|
||||
def scan_text(path: Path, source: str) -> list[LaunchSite]:
|
||||
return [site for site in classify_text(path, source) if site.classification != "gated"]
|
||||
|
||||
|
||||
def source_files(root: Path):
|
||||
for relative_root in SCANNED_ROOTS:
|
||||
search_root = root / relative_root
|
||||
if not search_root.is_dir():
|
||||
continue
|
||||
for path in search_root.rglob("*"):
|
||||
if not path.is_file() or path.suffix.lower() not in SCANNED_SUFFIXES:
|
||||
continue
|
||||
if any(part in SKIPPED_DIRECTORIES for part in path.parts):
|
||||
continue
|
||||
if is_test_path(path):
|
||||
continue
|
||||
yield path
|
||||
|
||||
|
||||
def scan_repository(root: Path) -> list[LaunchSite]:
|
||||
violations: list[LaunchSite] = []
|
||||
for path in source_files(root):
|
||||
try:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
violations.append(LaunchSite(path, 0, "non-UTF-8 source", "unscannable"))
|
||||
continue
|
||||
violations.extend(scan_text(path.relative_to(root), source))
|
||||
return violations
|
||||
|
||||
|
||||
def inventory_repository(root: Path) -> list[LaunchSite]:
|
||||
inventory: list[LaunchSite] = []
|
||||
for path in source_files(root):
|
||||
try:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
inventory.append(LaunchSite(path.relative_to(root), 0, "non-UTF-8 source", "unscannable"))
|
||||
continue
|
||||
relative_path = path.relative_to(root)
|
||||
inventory.extend(classify_text(relative_path, source))
|
||||
return inventory
|
||||
|
||||
|
||||
def format_violation(violation: LaunchSite) -> str:
|
||||
return f"{violation.path}:{violation.line_number}: {violation.classification}: {violation.line.strip()}"
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", type=Path, default=Path.cwd())
|
||||
parser.add_argument("--json", action="store_true")
|
||||
arguments = parser.parse_args(argv)
|
||||
root = arguments.root.resolve()
|
||||
inventory = inventory_repository(root)
|
||||
violations = [site for site in inventory if site.classification != "gated"]
|
||||
|
||||
if arguments.json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"gated": sum(site.classification == "gated" for site in inventory),
|
||||
"total": len(inventory),
|
||||
"sites": [
|
||||
{
|
||||
"path": str(site.path),
|
||||
"line": site.line_number,
|
||||
"classification": site.classification,
|
||||
"source": site.line.strip(),
|
||||
}
|
||||
for site in inventory
|
||||
],
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
for site in inventory:
|
||||
print(format_violation(site))
|
||||
print(
|
||||
f"runtime launch inventory: "
|
||||
f"{len(inventory) - len(violations)} gated/{len(inventory)} total"
|
||||
)
|
||||
|
||||
if violations:
|
||||
print("ungated consequential runtime launch detected", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -24,9 +24,19 @@ MAX_FRAME: Final = 64 * 1024
|
||||
MAX_STATE: Final = 4 * 1024 * 1024
|
||||
MAX_PENDING_TOKENS: Final = 256
|
||||
MAX_IN_FLIGHT_CONNECTIONS: Final = 16
|
||||
MAX_LEASE_TTL_SECONDS: Final = 300
|
||||
STATE_VERSION: Final = 1
|
||||
CONNECTION_DEADLINE_SECONDS: Final = 1.0
|
||||
HEX_256_LENGTH: Final = 64
|
||||
LEASE_UNVERIFIED: Final = "UNVERIFIED"
|
||||
LEASE_PENDING: Final = "PENDING_VERIFICATION"
|
||||
LEASE_PENDING_PROMOTION: Final = "PENDING_PROMOTION"
|
||||
LEASE_VERIFIED: Final = "VERIFIED"
|
||||
READ_ONLY_TOOLS: Final = {
|
||||
"claude": frozenset({"Read", "Grep", "Glob", "Ls", "Find"}),
|
||||
"pi": frozenset({"read", "grep", "find", "ls"}),
|
||||
}
|
||||
RECOVERY_TOOL: Final = "mosaic_context_recover"
|
||||
|
||||
|
||||
class BrokerFailure(Exception):
|
||||
@@ -265,6 +275,9 @@ class StateStore:
|
||||
class Broker:
|
||||
def __init__(self, store: StateStore) -> None:
|
||||
self.store = store
|
||||
# VERIFIED authority is deliberately volatile: broker restart revokes all
|
||||
# leases while preserving WI-1 identity and pending-token integrity.
|
||||
self.leases: dict[str, dict[str, object]] = {}
|
||||
|
||||
def authenticate(self, peer_pid: int, request: dict[str, object]) -> tuple[str, dict[str, object]]:
|
||||
session_id = request.get("session_id")
|
||||
@@ -289,7 +302,7 @@ class Broker:
|
||||
raise BrokerFailure("STALE_GENERATION")
|
||||
if generation > current_generation:
|
||||
session["runtime_generation"] = generation
|
||||
self.revoke_session_tokens(session_id)
|
||||
self.revoke_session_authority(session_id)
|
||||
return session_id, session
|
||||
|
||||
def session_for_anchor(
|
||||
@@ -310,19 +323,59 @@ class Broker:
|
||||
]:
|
||||
del tokens[token_value]
|
||||
|
||||
def revoke_session_authority(self, session_id: str) -> None:
|
||||
self.revoke_session_tokens(session_id)
|
||||
session = self.store.sessions().get(session_id)
|
||||
generation = session.get("runtime_generation") if isinstance(session, dict) else None
|
||||
self.leases[session_id] = {
|
||||
"state": LEASE_UNVERIFIED,
|
||||
"runtime_generation": generation,
|
||||
}
|
||||
|
||||
def mint_token(
|
||||
self,
|
||||
session_id: str,
|
||||
generation: int,
|
||||
binding: dict[str, object],
|
||||
) -> str:
|
||||
if len(self.store.tokens()) >= MAX_PENDING_TOKENS:
|
||||
raise BrokerFailure("TOKEN_CAPACITY")
|
||||
token = secrets.token_hex(32)
|
||||
self.store.tokens()[token] = {
|
||||
"session_id": session_id,
|
||||
"runtime_generation": generation,
|
||||
"binding": copy.deepcopy(binding),
|
||||
"consumed": False,
|
||||
}
|
||||
return token
|
||||
|
||||
def finish_promotion(self, session_id: str) -> None:
|
||||
lease = self.leases.get(session_id)
|
||||
if not isinstance(lease, dict) or lease.get("state") != LEASE_PENDING_PROMOTION:
|
||||
raise BrokerFailure("STATE_INTEGRITY")
|
||||
lease["state"] = LEASE_VERIFIED
|
||||
|
||||
def handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]:
|
||||
if self.store.poisoned:
|
||||
raise StateCommitUncertain()
|
||||
previous = copy.deepcopy(self.store.value)
|
||||
previous_leases = copy.deepcopy(self.leases)
|
||||
try:
|
||||
response = self._handle(peer, request)
|
||||
if self.store.value != previous:
|
||||
self.store.commit()
|
||||
if request.get("action") == "promote_lease":
|
||||
session_id = request.get("session_id")
|
||||
if not isinstance(session_id, str):
|
||||
raise BrokerFailure("INVALID_IDENTITY")
|
||||
self.finish_promotion(session_id)
|
||||
response["state"] = LEASE_VERIFIED
|
||||
return response
|
||||
except StateCommitUncertain:
|
||||
raise
|
||||
except Exception:
|
||||
self.store.value = previous
|
||||
self.leases = previous_leases
|
||||
raise
|
||||
|
||||
def _handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]:
|
||||
@@ -351,7 +404,7 @@ class Broker:
|
||||
raise BrokerFailure("STALE_GENERATION")
|
||||
if generation > current_generation:
|
||||
session["runtime_generation"] = generation
|
||||
self.revoke_session_tokens(session_id)
|
||||
self.revoke_session_authority(session_id)
|
||||
return {"ok": True, "session_id": session_id, "peer": {"pid": peer_pid, "uid": peer_uid, "gid": peer_gid, "starttime": anchor["starttime"]}}
|
||||
if action == "authenticate":
|
||||
self.authenticate(peer_pid, request)
|
||||
@@ -361,15 +414,7 @@ class Broker:
|
||||
binding = request.get("binding")
|
||||
if not valid_binding(binding):
|
||||
raise BrokerFailure("INVALID_BINDING")
|
||||
if len(self.store.tokens()) >= MAX_PENDING_TOKENS:
|
||||
raise BrokerFailure("TOKEN_CAPACITY")
|
||||
token = secrets.token_hex(32)
|
||||
self.store.tokens()[token] = {
|
||||
"session_id": session_id,
|
||||
"runtime_generation": request["runtime_generation"],
|
||||
"binding": binding,
|
||||
"consumed": False,
|
||||
}
|
||||
token = self.mint_token(session_id, request["runtime_generation"], binding)
|
||||
return {"ok": True, "token": token}
|
||||
if action == "consume_token":
|
||||
session_id, _ = self.authenticate(peer_pid, request)
|
||||
@@ -379,6 +424,112 @@ class Broker:
|
||||
raise BrokerFailure("TOKEN_REPLAY")
|
||||
del self.store.tokens()[token_value]
|
||||
return {"ok": True}
|
||||
if action == "begin_verification":
|
||||
session_id, _ = self.authenticate(peer_pid, request)
|
||||
runtime = request.get("runtime")
|
||||
binding = request.get("binding")
|
||||
ttl_seconds = request.get("ttl_seconds", MAX_LEASE_TTL_SECONDS)
|
||||
if runtime not in READ_ONLY_TOOLS:
|
||||
raise BrokerFailure("INVALID_RUNTIME")
|
||||
if not valid_binding(binding):
|
||||
raise BrokerFailure("INVALID_BINDING")
|
||||
if (
|
||||
type(ttl_seconds) is not int
|
||||
or ttl_seconds <= 0
|
||||
or ttl_seconds > MAX_LEASE_TTL_SECONDS
|
||||
):
|
||||
raise BrokerFailure("INVALID_LEASE_TTL")
|
||||
# Revoke-first is a broker operation, not advisory adapter order.
|
||||
self.revoke_session_authority(session_id)
|
||||
token = self.mint_token(session_id, request["runtime_generation"], binding)
|
||||
self.leases[session_id] = {
|
||||
"state": LEASE_PENDING,
|
||||
"runtime": runtime,
|
||||
"runtime_generation": request["runtime_generation"],
|
||||
"binding": copy.deepcopy(binding),
|
||||
"promotion_token": token,
|
||||
"ttl_seconds": ttl_seconds,
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"state": LEASE_PENDING,
|
||||
"promotion_token": token,
|
||||
}
|
||||
if action == "promote_lease":
|
||||
session_id, _ = self.authenticate(peer_pid, request)
|
||||
promotion_token = request.get("promotion_token")
|
||||
lease = self.leases.get(session_id)
|
||||
if not isinstance(lease, dict) or lease.get("state") != LEASE_PENDING:
|
||||
raise BrokerFailure("INVALID_LEASE_TRANSITION")
|
||||
expected_token = lease.get("promotion_token")
|
||||
if (
|
||||
not isinstance(promotion_token, str)
|
||||
or not isinstance(expected_token, str)
|
||||
or not secrets.compare_digest(promotion_token, expected_token)
|
||||
):
|
||||
raise BrokerFailure("PROMOTION_TOKEN_MISMATCH")
|
||||
token = self.store.tokens().get(promotion_token)
|
||||
if (
|
||||
not isinstance(token, dict)
|
||||
or token.get("session_id") != session_id
|
||||
or token.get("runtime_generation") != request.get("runtime_generation")
|
||||
or token.get("binding") != lease.get("binding")
|
||||
or token.get("consumed") is not False
|
||||
):
|
||||
raise BrokerFailure("PROMOTION_TOKEN_INVALID")
|
||||
del self.store.tokens()[promotion_token]
|
||||
lease["state"] = LEASE_PENDING_PROMOTION
|
||||
lease["expires_at"] = time.monotonic() + int(lease["ttl_seconds"])
|
||||
# handle() commits token consumption before finish_promotion() makes
|
||||
# VERIFIED externally visible: promote-last by construction.
|
||||
return {"ok": True, "state": LEASE_PENDING_PROMOTION}
|
||||
if action == "revoke_lease":
|
||||
session_id, _ = self.authenticate(peer_pid, request)
|
||||
self.revoke_session_authority(session_id)
|
||||
return {"ok": True, "state": LEASE_UNVERIFIED}
|
||||
if action == "authorize_tool":
|
||||
session_id, _ = self.authenticate(peer_pid, request)
|
||||
runtime = request.get("runtime")
|
||||
tool_name = request.get("tool_name")
|
||||
if runtime not in READ_ONLY_TOOLS:
|
||||
raise BrokerFailure("INVALID_RUNTIME")
|
||||
if not isinstance(tool_name, str) or not tool_name or len(tool_name) > 256:
|
||||
raise BrokerFailure("INVALID_TOOL")
|
||||
lease = self.leases.get(session_id)
|
||||
state = lease.get("state") if isinstance(lease, dict) else LEASE_UNVERIFIED
|
||||
if tool_name in READ_ONLY_TOOLS[runtime] or tool_name == RECOVERY_TOOL:
|
||||
return {"ok": True, "decision": "allow", "state": state}
|
||||
if not isinstance(lease, dict) or lease.get("state") != LEASE_VERIFIED:
|
||||
return {
|
||||
"ok": False,
|
||||
"code": "MUTATOR_UNVERIFIED",
|
||||
"decision": "deny",
|
||||
"state": LEASE_UNVERIFIED,
|
||||
}
|
||||
if (
|
||||
lease.get("runtime") != runtime
|
||||
or lease.get("runtime_generation") != request.get("runtime_generation")
|
||||
):
|
||||
return {
|
||||
"ok": False,
|
||||
"code": "MUTATOR_UNVERIFIED",
|
||||
"decision": "deny",
|
||||
"state": LEASE_UNVERIFIED,
|
||||
}
|
||||
expires_at = lease.get("expires_at")
|
||||
if not isinstance(expires_at, (int, float)) or time.monotonic() >= expires_at:
|
||||
self.revoke_session_authority(session_id)
|
||||
return {
|
||||
"ok": False,
|
||||
"code": "LEASE_EXPIRED",
|
||||
"decision": "deny",
|
||||
"state": LEASE_UNVERIFIED,
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"decision": "allow",
|
||||
"state": LEASE_VERIFIED,
|
||||
}
|
||||
raise BrokerFailure("UNKNOWN_ACTION")
|
||||
|
||||
|
||||
|
||||
103
packages/mosaic/framework/tools/lease-broker/launch-runtime.py
Normal file
103
packages/mosaic/framework/tools/lease-broker/launch-runtime.py
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Register a runtime parent with the lease broker, then exec without changing PID."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
MAX_FRAME: Final = 64 * 1024
|
||||
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
||||
CLAUDE_DANGEROUS_FLAG: Final = "--dangerously-skip-permissions"
|
||||
|
||||
|
||||
def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
|
||||
payload = (json.dumps(request, separators=(",", ":")) + "\n").encode()
|
||||
response = bytearray()
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(BROKER_TIMEOUT_SECONDS)
|
||||
connection.connect(str(socket_path))
|
||||
connection.sendall(payload)
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
while len(response) <= MAX_FRAME:
|
||||
chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(response)))
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
||||
raise ValueError("invalid broker reply")
|
||||
value = json.loads(response)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("invalid broker reply")
|
||||
return value
|
||||
|
||||
|
||||
def main(
|
||||
argv: Sequence[str] | None = None,
|
||||
*,
|
||||
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,
|
||||
) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
|
||||
parser.add_argument("--dangerous", action="store_true")
|
||||
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||
arguments = parser.parse_args(argv)
|
||||
command = arguments.command
|
||||
if command and command[0] == "--":
|
||||
command = command[1:]
|
||||
if not command:
|
||||
print("lease-gated runtime command is required", file=sys.stderr)
|
||||
return 64
|
||||
if arguments.dangerous:
|
||||
if arguments.runtime != "claude" or Path(command[0]).name != "claude":
|
||||
print("dangerous mode is supported only for the Claude runtime", file=sys.stderr)
|
||||
return 64
|
||||
command = [command[0], CLAUDE_DANGEROUS_FLAG, *command[1:]]
|
||||
|
||||
source_environment = os.environ if environ is None else environ
|
||||
try:
|
||||
socket_path = Path(source_environment["MOSAIC_LEASE_BROKER_SOCKET"])
|
||||
generation = int(source_environment.get("MOSAIC_RUNTIME_GENERATION", "1"))
|
||||
if generation < 0:
|
||||
raise ValueError("invalid generation")
|
||||
reply = request(
|
||||
socket_path,
|
||||
{
|
||||
"action": "register_anchor",
|
||||
"runtime_generation": generation,
|
||||
},
|
||||
)
|
||||
session_id = reply.get("session_id")
|
||||
if (
|
||||
reply.get("ok") is not True
|
||||
or not isinstance(session_id, str)
|
||||
or len(session_id) != 64
|
||||
or any(character not in "0123456789abcdef" for character in session_id)
|
||||
):
|
||||
raise ValueError("registration refused")
|
||||
except (KeyError, ValueError, OSError, json.JSONDecodeError):
|
||||
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
environment = dict(source_environment)
|
||||
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
|
||||
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
|
||||
environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime
|
||||
try:
|
||||
execute(command[0], command, environment)
|
||||
except OSError:
|
||||
print("Mosaic lease-gated runtime exec failed.", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
100
packages/mosaic/framework/tools/lease-broker/mutator-gate.py
Normal file
100
packages/mosaic/framework/tools/lease-broker/mutator-gate.py
Normal file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runtime-neutral whole mutator-class gate backed by the Mosaic lease broker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import BinaryIO, Final
|
||||
|
||||
MAX_FRAME: Final = 64 * 1024
|
||||
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
||||
|
||||
|
||||
def deny(code: str) -> int:
|
||||
print(f"BLOCKED: Mosaic mutator gate denied this tool ({code}).", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def read_tool_name(stream: BinaryIO | None = None) -> str:
|
||||
source = sys.stdin.buffer if stream is None else stream
|
||||
raw = source.read(MAX_FRAME + 1)
|
||||
if len(raw) > MAX_FRAME:
|
||||
raise ValueError("INVALID_GATE_INPUT")
|
||||
value = json.loads(raw)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("INVALID_GATE_INPUT")
|
||||
tool_name = value.get("tool_name")
|
||||
if not isinstance(tool_name, str) or not tool_name or len(tool_name) > 256:
|
||||
raise ValueError("INVALID_GATE_INPUT")
|
||||
return tool_name
|
||||
|
||||
|
||||
def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
|
||||
payload = (json.dumps(request, separators=(",", ":")) + "\n").encode()
|
||||
if len(payload) > MAX_FRAME:
|
||||
raise ValueError("INVALID_GATE_INPUT")
|
||||
response = bytearray()
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(BROKER_TIMEOUT_SECONDS)
|
||||
connection.connect(str(socket_path))
|
||||
connection.sendall(payload)
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
while len(response) <= MAX_FRAME:
|
||||
chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(response)))
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
||||
raise ValueError("INVALID_BROKER_REPLY")
|
||||
value = json.loads(response)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("INVALID_BROKER_REPLY")
|
||||
return value
|
||||
|
||||
|
||||
def main(
|
||||
argv: Sequence[str] | None = None,
|
||||
*,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
stream: BinaryIO | 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"))
|
||||
arguments = parser.parse_args(argv)
|
||||
source_environment = os.environ if environ is None else environ
|
||||
|
||||
try:
|
||||
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")
|
||||
reply = request(
|
||||
Path(socket_value),
|
||||
{
|
||||
"action": "authorize_tool",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": generation,
|
||||
"runtime": arguments.runtime,
|
||||
"tool_name": tool_name,
|
||||
},
|
||||
)
|
||||
except (KeyError, ValueError, OSError, json.JSONDecodeError):
|
||||
return deny("GATE_UNAVAILABLE")
|
||||
|
||||
if reply.get("ok") is True and reply.get("decision") == "allow":
|
||||
return 0
|
||||
code = reply.get("code")
|
||||
return deny(code if isinstance(code, str) else "MUTATOR_UNVERIFIED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -32,7 +32,7 @@ Claude:
|
||||
```json
|
||||
{
|
||||
"worker": {
|
||||
"command_template": "claude -p \"Execute task {task_id}: {task_title}\""
|
||||
"command_template": "mosaic claude -p \"Execute task {task_id}: {task_title}\""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -86,7 +86,8 @@ echo ""
|
||||
|
||||
cd "$PROJECT"
|
||||
if [[ "$RUNTIME_CMD" == "claude" ]]; then
|
||||
exec claude --dangerously-skip-permissions --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
|
||||
exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --dangerous --runtime claude -- \
|
||||
claude --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
|
||||
fi
|
||||
|
||||
if [[ "$RUNTIME_CMD" == "codex" ]]; then
|
||||
|
||||
@@ -74,7 +74,8 @@ echo ""
|
||||
|
||||
cd "$PROJECT"
|
||||
if [[ "$RUNTIME_CMD" == "claude" ]]; then
|
||||
exec claude --dangerously-skip-permissions --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
|
||||
exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --dangerous --runtime claude -- \
|
||||
claude --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
|
||||
fi
|
||||
|
||||
if [[ "$RUNTIME_CMD" == "codex" ]]; then
|
||||
|
||||
@@ -190,7 +190,7 @@ Pending QA validation
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
\`\`\`bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: $REPORT_PATH"
|
||||
python3 ~/.config/mosaic/tools/lease-broker/launch-runtime.py --runtime claude -- claude -p "Use Task tool to launch universal-qa-agent for report: $REPORT_PATH"
|
||||
\`\`\`
|
||||
EOF
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ ACTIONS_PATH="$IN_PROGRESS_DIR/$ACTIONS_FILE"
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting remediation: $ACTIONS_PATH" | tee -a "$LOG_FILE"
|
||||
|
||||
# Trigger remediation agent
|
||||
claude -p "Use Task tool to launch auto-remediation-agent for:
|
||||
# Trigger remediation agent through the authenticated lease-broker choke-point.
|
||||
python3 "$(dirname "$0")/../lease-broker/launch-runtime.py" --runtime claude -- claude -p "Use Task tool to launch auto-remediation-agent for:
|
||||
- Remediation Report: $IN_PROGRESS_DIR/$(basename "$REPORT_FILE")
|
||||
- Actions File: $ACTIONS_PATH
|
||||
- Max Iterations: 5
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/codex/test-pr-diff-context.sh"
|
||||
"test:framework-shell": "python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, symlinkSync, rmSync, lstatSync, writeFileSync } from 'node:fs';
|
||||
import {
|
||||
lstatSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
@@ -14,6 +22,7 @@ import {
|
||||
buildClaudexEnv,
|
||||
buildClaudexBanner,
|
||||
buildClaudexContractNote,
|
||||
ensureClaudexMutatorGateSettings,
|
||||
runClaudexProxyGate,
|
||||
launchClaudex,
|
||||
type ClaudexHarnessAdapter,
|
||||
@@ -36,12 +45,20 @@ function makeReport(overrides: Partial<PreflightReport> = {}): PreflightReport {
|
||||
};
|
||||
}
|
||||
|
||||
function okAdapter(overrides: Partial<ClaudexHarnessAdapter> = {}): ClaudexHarnessAdapter {
|
||||
type TestAdapterOverrides = Partial<ClaudexHarnessAdapter> & {
|
||||
exec?: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void;
|
||||
};
|
||||
|
||||
function okAdapter(overrides: TestAdapterOverrides = {}): ClaudexHarnessAdapter {
|
||||
const { exec, ...adapterOverrides } = overrides;
|
||||
return {
|
||||
harnessPreflight: () => {},
|
||||
composePrompt: () => '# Composed Claude contract',
|
||||
exec: () => {},
|
||||
...overrides,
|
||||
execLeaseGated:
|
||||
adapterOverrides.execLeaseGated ??
|
||||
((args, env, dangerous) =>
|
||||
exec?.('claude', dangerous ? ['--dangerously-skip-permissions', ...args] : args, env)),
|
||||
...adapterOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -561,11 +578,51 @@ describe('runClaudexProxyGate', () => {
|
||||
|
||||
// ─── launch orchestration (fail-closed ordering) ──────────────────────────────
|
||||
|
||||
describe('ensureClaudexMutatorGateSettings', () => {
|
||||
it('does not accept a lookalike hook and preserves isolated settings', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'claudex-gate-settings-'));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(root, 'settings.json'),
|
||||
JSON.stringify({
|
||||
preserved: true,
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: '.*',
|
||||
hooks: [{ type: 'command', command: 'echo lease-broker/mutator-gate.py' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
ensureClaudexMutatorGateSettings(root);
|
||||
|
||||
const settings = JSON.parse(readFileSync(join(root, 'settings.json'), 'utf8')) as {
|
||||
preserved: boolean;
|
||||
hooks: { PreToolUse: Array<{ hooks: Array<{ command: string }> }> };
|
||||
};
|
||||
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(lstatSync(join(root, 'settings.json')).mode & 0o777).toBe(0o600);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchClaudex', () => {
|
||||
const baseDeps = {
|
||||
baseEnv: {},
|
||||
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
|
||||
resolveConfigDir: () => '/home/agent/.config/mosaic/claudex/home',
|
||||
prepareConfig: () => {},
|
||||
log: () => {},
|
||||
errorLog: () => {},
|
||||
fail: (() => {
|
||||
|
||||
@@ -27,7 +27,14 @@
|
||||
* unit-testable without spawning Claude Code or touching a real config dir.
|
||||
*/
|
||||
|
||||
import { lstatSync, mkdirSync, realpathSync } from 'node:fs';
|
||||
import {
|
||||
chmodSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import {
|
||||
@@ -278,6 +285,77 @@ export function buildClaudexEnv(
|
||||
return env;
|
||||
}
|
||||
|
||||
const CLAUDEX_MUTATOR_GATE_COMMAND =
|
||||
'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude';
|
||||
|
||||
const CLAUDEX_MUTATOR_GATE_HOOK = {
|
||||
matcher: '.*',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: CLAUDEX_MUTATOR_GATE_COMMAND,
|
||||
timeout: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the mandatory all-tools gate in Claudex's isolated config without
|
||||
* discarding any existing isolated settings. Malformed settings and symlinked
|
||||
* settings files fail closed rather than launching with uncertain hook state.
|
||||
*/
|
||||
export function ensureClaudexMutatorGateSettings(configDir: string): void {
|
||||
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
||||
const settingsPath = join(configDir, 'settings.json');
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
if (lstatSync(settingsPath).isSymbolicLink()) {
|
||||
throw new Error('claudex: isolated settings.json must not be a symlink (fail closed).');
|
||||
}
|
||||
const parsed: unknown = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error('claudex: isolated settings.json must contain a JSON object (fail closed).');
|
||||
}
|
||||
settings = parsed;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
|
||||
const hooksValue = settings['hooks'];
|
||||
if (hooksValue !== undefined && !isRecord(hooksValue)) {
|
||||
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).');
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
|
||||
|
||||
/** Console banner shown at launch. Contains no token material by construction. */
|
||||
@@ -396,8 +474,8 @@ export interface ClaudexHarnessAdapter {
|
||||
harnessPreflight: () => void;
|
||||
/** Compose the full Claude runtime contract (== `composeContract('claude')`). */
|
||||
composePrompt: () => string;
|
||||
/** Replace the current process with `claude` using the composed env. */
|
||||
exec: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void;
|
||||
/** Register the Claude parent with the broker, then exec with the same PID. */
|
||||
execLeaseGated: (args: string[], env: NodeJS.ProcessEnv, dangerous: boolean) => void;
|
||||
}
|
||||
|
||||
export interface LaunchClaudexDeps {
|
||||
@@ -406,6 +484,7 @@ export interface LaunchClaudexDeps {
|
||||
resolveConfigDir?: () => string;
|
||||
models?: () => ClaudexModels;
|
||||
buildEnv?: (base: NodeJS.ProcessEnv, opts: BuildClaudexEnvOptions) => NodeJS.ProcessEnv;
|
||||
prepareConfig?: (configDir: string) => void;
|
||||
log?: (message: string) => void;
|
||||
errorLog?: (message: string) => void;
|
||||
fail?: (code: number) => never;
|
||||
@@ -431,6 +510,7 @@ export async function launchClaudex(
|
||||
const resolveConfigDir = deps.resolveConfigDir ?? (() => resolveClaudexConfigDir(baseEnv));
|
||||
const models = deps.models ?? (() => resolveClaudexModels(baseEnv));
|
||||
const buildEnv = deps.buildEnv ?? buildClaudexEnv;
|
||||
const prepareConfig = deps.prepareConfig ?? ensureClaudexMutatorGateSettings;
|
||||
|
||||
try {
|
||||
// Harness readiness first (claude on PATH, mosaic home, sequential-thinking).
|
||||
@@ -447,14 +527,14 @@ export async function launchClaudex(
|
||||
// Compose the isolated launch env (guard throws → caught below, fail closed).
|
||||
const resolvedModels = models();
|
||||
const configDir = resolveConfigDir();
|
||||
prepareConfig(configDir);
|
||||
const env = buildEnv(baseEnv, { configDir, models: resolvedModels });
|
||||
const prompt = `${adapter.composePrompt()}\n\n${buildClaudexContractNote(resolvedModels)}`;
|
||||
|
||||
log(buildClaudexBanner(resolvedModels));
|
||||
|
||||
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
||||
cliArgs.push('--append-system-prompt', prompt, ...args);
|
||||
adapter.exec('claude', cliArgs, env);
|
||||
const cliArgs = ['--append-system-prompt', prompt, ...args];
|
||||
adapter.execLeaseGated(cliArgs, env, yolo);
|
||||
} catch (err) {
|
||||
errorLog(
|
||||
`[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,
|
||||
|
||||
@@ -115,7 +115,7 @@ function auditClaudeSettings(): SettingsAudit {
|
||||
// Check required hooks
|
||||
const hooks = settings['hooks'] as Record<string, unknown[]> | undefined;
|
||||
|
||||
const requiredPreToolUse = ['prevent-memory-write.sh'];
|
||||
const requiredPreToolUse = ['mutator-gate.py', 'prevent-memory-write.sh'];
|
||||
const requiredPostToolUse = ['qa-hook-stdin.sh', 'typecheck-hook.sh'];
|
||||
|
||||
const preHooks = (hooks?.['PreToolUse'] ?? []) as Array<Record<string, unknown>>;
|
||||
@@ -755,7 +755,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
printSettingsWarnings(settingsAudit);
|
||||
|
||||
const prompt = buildRuntimePrompt('claude');
|
||||
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
||||
const cliArgs: string[] = [];
|
||||
cliArgs.push('--append-system-prompt', prompt);
|
||||
if (hasMissionNoArgs) {
|
||||
cliArgs.push(missionPrompt);
|
||||
@@ -763,7 +763,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||
execRuntime('claude', cliArgs);
|
||||
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -798,7 +798,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||
execRuntime('pi', cliArgs);
|
||||
execLeaseGatedRuntime('pi', cliArgs);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -806,6 +806,33 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
process.exit(0); // Unreachable but satisfies never
|
||||
}
|
||||
|
||||
function defaultLeaseBrokerSocket(env: NodeJS.ProcessEnv = process.env): string {
|
||||
if (env['MOSAIC_LEASE_BROKER_SOCKET']) return env['MOSAIC_LEASE_BROKER_SOCKET'];
|
||||
const runtimeDir = env['XDG_RUNTIME_DIR'];
|
||||
if (runtimeDir) return join(runtimeDir, 'mosaic-lease', 'broker.sock');
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
||||
return join('/run/user', String(uid), 'mosaic-lease', 'broker.sock');
|
||||
}
|
||||
|
||||
function execLeaseGatedRuntime(
|
||||
runtime: 'claude' | 'pi',
|
||||
args: string[],
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
dangerous = false,
|
||||
): void {
|
||||
const launcher = resolveTool('lease-broker', 'launch-runtime.py');
|
||||
const dangerousArgs = dangerous ? ['--dangerous'] : [];
|
||||
execRuntime(
|
||||
'python3',
|
||||
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
||||
{
|
||||
...baseEnv,
|
||||
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
||||
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** exec into the runtime, replacing the current process. */
|
||||
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
||||
try {
|
||||
@@ -839,7 +866,8 @@ function launchClaudexProduction(args: string[], yolo: boolean): void {
|
||||
checkSequentialThinking('claude');
|
||||
},
|
||||
composePrompt: () => buildRuntimePrompt('claude'),
|
||||
exec: (cmd, cmdArgs, env) => execRuntime(cmd, cmdArgs, env),
|
||||
execLeaseGated: (cmdArgs, env, dangerous) =>
|
||||
execLeaseGatedRuntime('claude', cmdArgs, env, dangerous),
|
||||
};
|
||||
void launchClaudex(args, yolo, adapter);
|
||||
}
|
||||
|
||||
667
packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts
Normal file
667
packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts
Normal file
@@ -0,0 +1,667 @@
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { createConnection } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
||||
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { launchClaudex, type ClaudexHarnessAdapter } from '../commands/claudex.js';
|
||||
|
||||
interface BrokerReply {
|
||||
ok: boolean;
|
||||
code?: string;
|
||||
decision?: 'allow' | 'deny';
|
||||
state?: 'UNVERIFIED' | 'PENDING_VERIFICATION' | 'VERIFIED';
|
||||
session_id?: string;
|
||||
promotion_token?: string;
|
||||
}
|
||||
|
||||
interface BrokerPaths {
|
||||
socket: string;
|
||||
}
|
||||
|
||||
const frameworkRoot = new URL('../../framework/', 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 claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json');
|
||||
const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.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');
|
||||
const children: ChildProcess[] = [];
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
const binding = (compaction_epoch = 1) => ({
|
||||
compaction_epoch,
|
||||
request_epoch: 0,
|
||||
h_source: 'a'.repeat(64),
|
||||
h_payload: 'b'.repeat(64),
|
||||
schema_version: 1,
|
||||
});
|
||||
|
||||
async function request(socketPath: string, requestValue: object): Promise<BrokerReply> {
|
||||
return await new Promise<BrokerReply>((resolve, reject) => {
|
||||
const socket = createConnection(socketPath);
|
||||
let response = '';
|
||||
socket.setEncoding('utf8');
|
||||
socket.once('error', reject);
|
||||
socket.on('data', (chunk: string) => {
|
||||
response += chunk;
|
||||
});
|
||||
socket.once('end', () => resolve(JSON.parse(response) as BrokerReply));
|
||||
socket.once('connect', () => socket.end(`${JSON.stringify(requestValue)}\n`));
|
||||
});
|
||||
}
|
||||
|
||||
async function startBroker(): Promise<BrokerPaths> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'mosaic-mutator-gate-'));
|
||||
await chmod(root, 0o700);
|
||||
const socket = join(root, 'broker.sock');
|
||||
const child = spawn(
|
||||
'python3',
|
||||
[daemonPath, '--socket', socket, '--state', join(root, 'state.json')],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
children.push(child);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let stderr = '';
|
||||
child.stderr?.setEncoding('utf8');
|
||||
child.stderr?.on('data', (chunk: string) => (stderr += chunk));
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code: number | null) =>
|
||||
reject(new Error(`broker exited ${code}: ${stderr}`)),
|
||||
);
|
||||
child.stdout?.once('data', () => resolve());
|
||||
});
|
||||
return { socket };
|
||||
}
|
||||
|
||||
interface RuntimeLaunchEntry {
|
||||
name: string;
|
||||
script: string;
|
||||
prepare(root: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
const runtimeLaunchEntries: RuntimeLaunchEntry[] = [
|
||||
{
|
||||
name: 'prdy-init',
|
||||
script: prdyInitPath,
|
||||
prepare: async (root) => ['--project', root, '--name', 'Gate Test'],
|
||||
},
|
||||
{
|
||||
name: 'prdy-update',
|
||||
script: prdyUpdatePath,
|
||||
prepare: async (root) => {
|
||||
await mkdir(join(root, 'docs'), { recursive: true });
|
||||
await writeFile(join(root, 'docs/PRD.md'), '# Existing PRD\n');
|
||||
return ['--project', root];
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'qa-remediation',
|
||||
script: remediationHandlerPath,
|
||||
prepare: async (root) => {
|
||||
const pending = join(root, 'reports/pending');
|
||||
await mkdir(pending, { recursive: true });
|
||||
const report = join(pending, 'gate_remediation_needed.md');
|
||||
await writeFile(report, '# remediation\n');
|
||||
return [report];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function runRuntimeLaunchEntry(entry: RuntimeLaunchEntry, socket: string) {
|
||||
const root = await mkdtemp(join(tmpdir(), `mosaic-${entry.name}-gate-`));
|
||||
temporaryRoots.push(root);
|
||||
const binDir = join(root, 'bin');
|
||||
await mkdir(binDir, { recursive: true });
|
||||
const fakeClaude = join(binDir, 'claude');
|
||||
await writeFile(
|
||||
fakeClaude,
|
||||
`#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
session_id = os.environ.get("MOSAIC_LEASE_SESSION_ID", "")
|
||||
denied = subprocess.run(
|
||||
["python3", ${JSON.stringify(gatePath)}, "--runtime", "claude"],
|
||||
input=json.dumps({"tool_name": "Bash"}) + "\\n",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=os.environ,
|
||||
).returncode == 2
|
||||
print("RUNTIME_PROBE=" + json.dumps({"session_id": session_id, "denied": denied}))
|
||||
raise SystemExit(0 if len(session_id) == 64 and denied else 1)
|
||||
`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
await chmod(fakeClaude, 0o700);
|
||||
const args = await entry.prepare(root);
|
||||
return spawnSync('bash', [entry.script, ...args], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
MOSAIC_HOME: frameworkRoot,
|
||||
MOSAIC_PRDY_RUNTIME: 'claude',
|
||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||
MOSAIC_RUNTIME_GENERATION: '1',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function register(socket: string, runtime_generation = 1): Promise<string> {
|
||||
const reply = await request(socket, { action: 'register_anchor', runtime_generation });
|
||||
expect(reply.ok).toBe(true);
|
||||
expect(reply.session_id).toMatch(/^[a-f0-9]{64}$/);
|
||||
return reply.session_id!;
|
||||
}
|
||||
|
||||
async function beginVerification(
|
||||
socket: string,
|
||||
session_id: string,
|
||||
runtime: 'claude' | 'pi',
|
||||
runtime_generation = 1,
|
||||
ttl_seconds = 300,
|
||||
compactionEpoch = 1,
|
||||
): Promise<BrokerReply> {
|
||||
return await request(socket, {
|
||||
action: 'begin_verification',
|
||||
session_id,
|
||||
runtime_generation,
|
||||
runtime,
|
||||
ttl_seconds,
|
||||
binding: binding(compactionEpoch),
|
||||
});
|
||||
}
|
||||
|
||||
async function promote(
|
||||
socket: string,
|
||||
session_id: string,
|
||||
promotion_token: string,
|
||||
runtime_generation = 1,
|
||||
): Promise<BrokerReply> {
|
||||
return await request(socket, {
|
||||
action: 'promote_lease',
|
||||
session_id,
|
||||
runtime_generation,
|
||||
promotion_token,
|
||||
});
|
||||
}
|
||||
|
||||
async function authorize(
|
||||
socket: string,
|
||||
session_id: string,
|
||||
runtime: 'claude' | 'pi',
|
||||
tool_name: string,
|
||||
runtime_generation = 1,
|
||||
): Promise<BrokerReply> {
|
||||
return await request(socket, {
|
||||
action: 'authorize_tool',
|
||||
session_id,
|
||||
runtime_generation,
|
||||
runtime,
|
||||
tool_name,
|
||||
});
|
||||
}
|
||||
|
||||
function runRuntimeGate(
|
||||
socket: string,
|
||||
sessionId: string,
|
||||
runtime: 'claude' | 'pi',
|
||||
toolName: string,
|
||||
generation = 1,
|
||||
) {
|
||||
return spawnSync('python3', [gatePath, '--runtime', runtime], {
|
||||
input: `${JSON.stringify({ tool_name: toolName })}\n`,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||
MOSAIC_LEASE_SESSION_ID: sessionId,
|
||||
MOSAIC_RUNTIME_GENERATION: String(generation),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const child of children.splice(0)) child.kill('SIGTERM');
|
||||
await Promise.all(
|
||||
temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe('whole mutator-class lease gate', () => {
|
||||
test('revoke-first and promote-last structurally bracket mutator authority', async () => {
|
||||
const { socket } = await startBroker();
|
||||
const sessionId = await register(socket);
|
||||
|
||||
expect(await promote(socket, sessionId, 'c'.repeat(64))).toMatchObject({
|
||||
ok: false,
|
||||
code: 'INVALID_LEASE_TRANSITION',
|
||||
});
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: false,
|
||||
code: 'MUTATOR_UNVERIFIED',
|
||||
decision: 'deny',
|
||||
});
|
||||
|
||||
const pending = await beginVerification(socket, sessionId, 'claude');
|
||||
expect(pending).toMatchObject({ ok: true, state: 'PENDING_VERIFICATION' });
|
||||
expect(pending.promotion_token).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Write')).toMatchObject({
|
||||
ok: false,
|
||||
decision: 'deny',
|
||||
});
|
||||
|
||||
expect(await promote(socket, sessionId, pending.promotion_token!)).toMatchObject({
|
||||
ok: true,
|
||||
state: 'VERIFIED',
|
||||
});
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: true,
|
||||
decision: 'allow',
|
||||
state: 'VERIFIED',
|
||||
});
|
||||
|
||||
const nextCycle = await beginVerification(socket, sessionId, 'claude', 1, 300, 2);
|
||||
expect(nextCycle).toMatchObject({ ok: true, state: 'PENDING_VERIFICATION' });
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Edit')).toMatchObject({
|
||||
ok: false,
|
||||
decision: 'deny',
|
||||
});
|
||||
expect(await promote(socket, sessionId, pending.promotion_token!)).toMatchObject({
|
||||
ok: false,
|
||||
code: 'PROMOTION_TOKEN_MISMATCH',
|
||||
});
|
||||
});
|
||||
|
||||
test('non-dangerous parser residual is denied by the global all-tools hook without a lease', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'mosaic-parser-residual-'));
|
||||
temporaryRoots.push(root);
|
||||
const source = join(root, 'packages/probe/launch.sh');
|
||||
await mkdir(join(root, 'packages/probe'), { recursive: true });
|
||||
await writeFile(source, 'alias hidden_runtime=claude\nhidden_runtime -p x\n');
|
||||
|
||||
const parserResult = spawnSync('python3', [launchGuardPath, '--root', root, '--json'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(parserResult.status).toBe(0);
|
||||
expect(JSON.parse(parserResult.stdout)).toMatchObject({ gated: 0, total: 0 });
|
||||
|
||||
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
|
||||
hooks: { PreToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> };
|
||||
};
|
||||
const allToolsHook = settings.hooks.PreToolUse.find((hook) => hook.matcher === '.*');
|
||||
expect(allToolsHook?.hooks[0]?.command).toContain('mutator-gate.py --runtime claude');
|
||||
|
||||
const environment = { ...process.env };
|
||||
delete environment['MOSAIC_LEASE_SESSION_ID'];
|
||||
delete environment['MOSAIC_LEASE_BROKER_SOCKET'];
|
||||
for (const toolName of ['Bash', 'Read', 'mcp__provider__custom']) {
|
||||
const gateResult = spawnSync('python3', [gatePath, '--runtime', 'claude'], {
|
||||
input: `${JSON.stringify({ tool_name: toolName })}\n`,
|
||||
encoding: 'utf8',
|
||||
env: environment,
|
||||
});
|
||||
expect(gateResult.status, toolName).toBe(2);
|
||||
expect(gateResult.stderr, toolName).toContain('GATE_UNAVAILABLE');
|
||||
}
|
||||
});
|
||||
|
||||
test('T-B raw and custom mutator tools are default-denied without shell parsing', async () => {
|
||||
const { socket } = await startBroker();
|
||||
const sessionId = await register(socket);
|
||||
const mutators: Array<['claude' | 'pi', string]> = [
|
||||
['claude', 'Bash'],
|
||||
['claude', 'Edit'],
|
||||
['claude', 'Write'],
|
||||
['claude', 'NotebookEdit'],
|
||||
['claude', 'mcp__provider__close_issue'],
|
||||
['pi', 'bash'],
|
||||
['pi', 'edit'],
|
||||
['pi', 'write'],
|
||||
['pi', 'deploy'],
|
||||
['pi', 'unknown_custom_tool'],
|
||||
];
|
||||
|
||||
for (const [runtime, toolName] of mutators) {
|
||||
expect(
|
||||
await authorize(socket, sessionId, runtime, toolName),
|
||||
`${runtime}:${toolName}`,
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
code: 'MUTATOR_UNVERIFIED',
|
||||
decision: 'deny',
|
||||
});
|
||||
}
|
||||
|
||||
for (const [runtime, toolName] of [
|
||||
['claude', 'Read'],
|
||||
['claude', 'Grep'],
|
||||
['pi', 'read'],
|
||||
['pi', 'grep'],
|
||||
['pi', 'mosaic_context_recover'],
|
||||
] as const) {
|
||||
expect(await authorize(socket, sessionId, runtime, toolName)).toMatchObject({
|
||||
ok: true,
|
||||
decision: 'allow',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('lease and tool validation failures remain fail-closed at the broker boundary', async () => {
|
||||
const { socket } = await startBroker();
|
||||
const sessionId = await register(socket);
|
||||
const baseRequest = {
|
||||
action: 'begin_verification',
|
||||
session_id: sessionId,
|
||||
runtime_generation: 1,
|
||||
runtime: 'claude',
|
||||
ttl_seconds: 300,
|
||||
binding: binding(),
|
||||
};
|
||||
|
||||
expect(await request(socket, { ...baseRequest, runtime: 'codex' })).toMatchObject({
|
||||
ok: false,
|
||||
code: 'INVALID_RUNTIME',
|
||||
});
|
||||
expect(
|
||||
await request(socket, { ...baseRequest, binding: { ...binding(), h_source: 'bad' } }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
code: 'INVALID_BINDING',
|
||||
});
|
||||
expect(await request(socket, { ...baseRequest, ttl_seconds: 0 })).toMatchObject({
|
||||
ok: false,
|
||||
code: 'INVALID_LEASE_TTL',
|
||||
});
|
||||
expect(
|
||||
await request(socket, {
|
||||
action: 'authorize_tool',
|
||||
session_id: sessionId,
|
||||
runtime_generation: 1,
|
||||
runtime: 'codex',
|
||||
tool_name: 'Read',
|
||||
}),
|
||||
).toMatchObject({ ok: false, code: 'INVALID_RUNTIME' });
|
||||
expect(
|
||||
await request(socket, {
|
||||
action: 'authorize_tool',
|
||||
session_id: sessionId,
|
||||
runtime_generation: 1,
|
||||
runtime: 'claude',
|
||||
tool_name: 'x'.repeat(257),
|
||||
}),
|
||||
).toMatchObject({ ok: false, code: 'INVALID_TOOL' });
|
||||
});
|
||||
|
||||
test('observer revocation and monotonic TTL expiry deny the next mutator', 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!);
|
||||
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: true,
|
||||
decision: 'allow',
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: false,
|
||||
code: 'LEASE_EXPIRED',
|
||||
decision: 'deny',
|
||||
});
|
||||
|
||||
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 2);
|
||||
await promote(socket, sessionId, refreshed.promotion_token!);
|
||||
expect(
|
||||
await request(socket, {
|
||||
action: 'revoke_lease',
|
||||
session_id: sessionId,
|
||||
runtime_generation: 1,
|
||||
reason: 'compaction_observer',
|
||||
}),
|
||||
).toMatchObject({ ok: true, state: 'UNVERIFIED' });
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Write')).toMatchObject({
|
||||
ok: false,
|
||||
code: 'MUTATOR_UNVERIFIED',
|
||||
decision: 'deny',
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime-generation replacement cannot inherit a verified lease', async () => {
|
||||
const { socket } = await startBroker();
|
||||
const sessionId = await register(socket);
|
||||
const pending = await beginVerification(socket, sessionId, 'pi');
|
||||
await promote(socket, sessionId, pending.promotion_token!);
|
||||
|
||||
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('runtime launcher anchors broker identity before exec and fails closed without broker', async () => {
|
||||
const { socket } = await startBroker();
|
||||
const probe = [
|
||||
'import json,os,socket',
|
||||
's=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)',
|
||||
"s.connect(os.environ['MOSAIC_LEASE_BROKER_SOCKET'])",
|
||||
"request={'action':'authorize_tool','session_id':os.environ['MOSAIC_LEASE_SESSION_ID'],'runtime_generation':int(os.environ['MOSAIC_RUNTIME_GENERATION']),'runtime':'claude','tool_name':'Read'}",
|
||||
"s.sendall((json.dumps(request)+'\\n').encode())",
|
||||
's.shutdown(socket.SHUT_WR)',
|
||||
"print(json.dumps({'session_id':os.environ['MOSAIC_LEASE_SESSION_ID'],'reply':json.loads(s.recv(65536))}))",
|
||||
].join(';');
|
||||
const launched = spawnSync(
|
||||
'python3',
|
||||
[launcherPath, '--runtime', 'claude', '--', 'python3', '-c', probe],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||
MOSAIC_RUNTIME_GENERATION: '1',
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(launched.status, launched.stderr).toBe(0);
|
||||
expect(JSON.parse(launched.stdout)).toMatchObject({
|
||||
session_id: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
reply: { ok: true, decision: 'allow' },
|
||||
});
|
||||
|
||||
const unavailable = spawnSync(
|
||||
'python3',
|
||||
[launcherPath, '--runtime', 'claude', '--', 'python3', '-c', "print('EXECUTED')"],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
MOSAIC_LEASE_BROKER_SOCKET: join(tmpdir(), 'missing-mosaic-broker.sock'),
|
||||
MOSAIC_RUNTIME_GENERATION: '1',
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(unavailable.status).not.toBe(0);
|
||||
expect(unavailable.stdout).not.toContain('EXECUTED');
|
||||
});
|
||||
|
||||
test.each(runtimeLaunchEntries)(
|
||||
'$name registers before launch, denies an unverified mutator, and fails closed without broker',
|
||||
async (entry) => {
|
||||
const missingSocket = join(tmpdir(), `missing-${entry.name}-${process.pid}.sock`);
|
||||
const unavailable = await runRuntimeLaunchEntry(entry, missingSocket);
|
||||
expect(unavailable.status).not.toBe(0);
|
||||
expect(`${unavailable.stdout}${unavailable.stderr}`).not.toContain('RUNTIME_PROBE=');
|
||||
|
||||
const { socket } = await startBroker();
|
||||
const launched = await runRuntimeLaunchEntry(entry, socket);
|
||||
expect(launched.status, launched.stderr).toBe(0);
|
||||
const match = /RUNTIME_PROBE=(\{[^\n]+\})/.exec(`${launched.stdout}${launched.stderr}`);
|
||||
expect(match).not.toBeNull();
|
||||
expect(JSON.parse(match![1]!)).toEqual({
|
||||
session_id: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
denied: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ command: 'mosaic claudex', yolo: false },
|
||||
{ command: 'mosaic yolo claudex', yolo: true },
|
||||
])(
|
||||
'$command registers a broker anchor, installs the all-tools hook, and denies an unverified mutator',
|
||||
async ({ yolo }) => {
|
||||
const { socket } = await startBroker();
|
||||
const root = await mkdtemp(join(tmpdir(), 'mosaic-claudex-gate-'));
|
||||
temporaryRoots.push(root);
|
||||
const configDir = join(root, 'isolated-claude');
|
||||
const binDir = join(root, 'bin');
|
||||
await mkdir(configDir, { recursive: true });
|
||||
await mkdir(binDir, { recursive: true });
|
||||
|
||||
const fakeClaude = join(binDir, 'claude');
|
||||
const probe = `#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
session_id = os.environ.get("MOSAIC_LEASE_SESSION_ID", "")
|
||||
settings_path = Path(os.environ["CLAUDE_CONFIG_DIR"]) / "settings.json"
|
||||
try:
|
||||
settings = json.loads(settings_path.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
settings = {}
|
||||
pre_tool = settings.get("hooks", {}).get("PreToolUse", [])
|
||||
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
|
||||
)
|
||||
denied = subprocess.run(
|
||||
["python3", ${JSON.stringify(gatePath)}, "--runtime", "claude"],
|
||||
input=json.dumps({"tool_name": "Bash"}) + "\\n",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=os.environ,
|
||||
).returncode == 2
|
||||
is_yolo = "--dangerously-skip-permissions" in sys.argv[1:]
|
||||
result = {
|
||||
"session_id": session_id,
|
||||
"hook_present": hook_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)
|
||||
`;
|
||||
await writeFile(fakeClaude, probe, { mode: 0o700 });
|
||||
await chmod(fakeClaude, 0o700);
|
||||
|
||||
let execution: ReturnType<typeof spawnSync> | undefined;
|
||||
const run = (cmd: string, args: string[], env: NodeJS.ProcessEnv) => {
|
||||
execution = spawnSync(cmd, args, { encoding: 'utf8', env });
|
||||
};
|
||||
const adapter = {
|
||||
harnessPreflight: () => {},
|
||||
composePrompt: () => '# composed Claude contract',
|
||||
// Claudex exposes only the shared register-before-exec boundary.
|
||||
execLeaseGated: (args: string[], env: NodeJS.ProcessEnv, dangerous: boolean) =>
|
||||
run(
|
||||
'python3',
|
||||
[
|
||||
launcherPath,
|
||||
...(dangerous ? ['--dangerous'] : []),
|
||||
'--runtime',
|
||||
'claude',
|
||||
'--',
|
||||
'claude',
|
||||
...args,
|
||||
],
|
||||
env,
|
||||
),
|
||||
} as unknown as ClaudexHarnessAdapter;
|
||||
|
||||
await launchClaudex([], yolo, adapter, {
|
||||
baseEnv: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||
MOSAIC_RUNTIME_GENERATION: '1',
|
||||
},
|
||||
proxyGate: () =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
report: {
|
||||
binaryPresent: true,
|
||||
binaryPath: '/test/claude-code-proxy',
|
||||
auth: { state: 'valid' },
|
||||
live: true,
|
||||
listenerVerdict: 'ok',
|
||||
needsReauth: false,
|
||||
ok: true,
|
||||
problems: [],
|
||||
},
|
||||
problems: [],
|
||||
}),
|
||||
resolveConfigDir: () => configDir,
|
||||
log: () => {},
|
||||
errorLog: () => {},
|
||||
fail: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
});
|
||||
|
||||
expect(execution).toBeDefined();
|
||||
expect(execution!.status, String(execution!.stderr)).toBe(0);
|
||||
expect(JSON.parse(String(execution!.stdout))).toEqual({
|
||||
session_id: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
hook_present: true,
|
||||
denied: true,
|
||||
is_yolo: yolo,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('Claude and Pi runtime adapters consult the broker for every tool class', async () => {
|
||||
const { socket } = await startBroker();
|
||||
const sessionId = await register(socket);
|
||||
|
||||
expect(runRuntimeGate(socket, sessionId, 'claude', 'Read').status).toBe(0);
|
||||
expect(runRuntimeGate(socket, sessionId, 'claude', 'Bash').status).toBe(2);
|
||||
expect(runRuntimeGate(socket, sessionId, 'pi', 'unknown_custom_tool').status).toBe(2);
|
||||
|
||||
const pending = await beginVerification(socket, sessionId, 'claude');
|
||||
await promote(socket, sessionId, pending.promotion_token!);
|
||||
expect(runRuntimeGate(socket, sessionId, 'claude', 'Bash').status).toBe(0);
|
||||
|
||||
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
|
||||
hooks: { PreToolUse: Array<{ matcher?: string; hooks: Array<{ command: string }> }> };
|
||||
};
|
||||
expect(
|
||||
settings.hooks.PreToolUse.some(
|
||||
(entry) =>
|
||||
entry.matcher === '.*' &&
|
||||
entry.hooks.some((hook) => hook.command.includes('mutator-gate.py')),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const piExtension = await readFile(piExtensionPath, 'utf8');
|
||||
expect(piExtension).toContain("pi.on('tool_call'");
|
||||
expect(piExtension).toContain('mutator-gate.py');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Contract tests for the permanent consequential-runtime launch guard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import runpy
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
MOSAIC_ROOT = Path(__file__).parents[2]
|
||||
REPO_ROOT = Path(__file__).parents[4]
|
||||
GUARD_PATH = MOSAIC_ROOT / "framework/tools/lease-broker/check-runtime-launches.py"
|
||||
SPEC = importlib.util.spec_from_file_location("runtime_launch_guard", GUARD_PATH)
|
||||
if SPEC is None or SPEC.loader is None:
|
||||
raise RuntimeError("unable to load runtime launch guard")
|
||||
GUARD = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(GUARD)
|
||||
|
||||
|
||||
class RuntimeLaunchGuardTest(unittest.TestCase):
|
||||
def test_detects_direct_shell_and_process_api_launches(self) -> None:
|
||||
cases = {
|
||||
"shell-exec.sh": 'exec claude --dangerously-skip-permissions "prompt"\n',
|
||||
"shell-print.sh": 'claude -p "prompt" | tee report.log\n',
|
||||
"typescript.ts": "spawn('pi', ['--print', prompt]);\n",
|
||||
"python.py": "subprocess.run(['claude', '-p', prompt])\n",
|
||||
"dynamic.ts": "return [runtime, '-p', prompt];\n",
|
||||
"plain-shell.sh": 'claude "$prompt"\n',
|
||||
"node-exec.ts": 'exec("claude --print hello");\n',
|
||||
"command-array.ts": "const launchCommand = ['pi', '--print', prompt];\n",
|
||||
"python-system.py": 'os.system("claude -p prompt")\n',
|
||||
"dynamic-shell.sh": 'exec "$runtime" "$prompt"\n',
|
||||
"dynamic-spawn.ts": 'spawn(runtime, args);\n',
|
||||
"dynamic-command.sh": 'LAUNCH_COMMAND=("$MOSAIC_AGENT_RUNTIME" --print)\n',
|
||||
"absolute-shell.sh": 'exec /usr/local/bin/claude -p prompt\n',
|
||||
"absolute-spawn.ts": "spawn('/opt/bin/pi', args);\n",
|
||||
"terra-comment.sh": 'exec claude --dangerously-skip-permissions "terra-r3" # launch-runtime.py\n',
|
||||
"python-comment.py": "subprocess.run(['claude', '-p', prompt]) # launch-runtime.py\n",
|
||||
"typescript-comment.ts": "spawn('pi', args); // launch-runtime.py\n",
|
||||
"marker-argument.sh": 'exec claude --dangerously-skip-permissions "launch-runtime.py"\n',
|
||||
"marker-echo.sh": 'exec claude --dangerously-skip-permissions "prompt"; echo launch-runtime.py\n',
|
||||
"marker-variable.sh": 'marker=launch-runtime.py; exec claude --dangerously-skip-permissions "prompt"\n',
|
||||
"heredoc.sh": "cat <<'EOF'\nexec claude --dangerously-skip-permissions prompt # launch-runtime.py\nEOF\n",
|
||||
"continued.sh": "exec \\\n claude --dangerously-skip-permissions prompt # launch-runtime.py\n",
|
||||
"chain-semicolon.sh": "true; claude -p prompt\n",
|
||||
"chain-and.sh": "true && claude -p prompt\n",
|
||||
"chain-pipe.sh": "printf input | claude -p prompt\n",
|
||||
"command-substitution.sh": "output=$(claude -p prompt)\n",
|
||||
"eval.sh": "launcher='claude -p prompt'\neval \"$launcher\"\n",
|
||||
"variable-exec.sh": "launcher=claude\n\"$launcher\" -p prompt\n",
|
||||
"env-prefix.sh": "env SAFE=1 claude --help\n",
|
||||
"command-prefix.sh": "command pi --help\n",
|
||||
"nohup-prefix.sh": "nohup claude --help &\n",
|
||||
}
|
||||
for filename, source in cases.items():
|
||||
with self.subTest(filename=filename):
|
||||
violations = GUARD.scan_text(Path(filename), source)
|
||||
self.assertNotEqual(violations, [], source)
|
||||
|
||||
def test_allows_only_explicit_gated_boundaries(self) -> None:
|
||||
cases = {
|
||||
"shell-helper.sh": 'exec "$GATED_RUNTIME" claude -- claude -p "prompt"\n',
|
||||
"mosaic.sh": 'exec mosaic yolo "$runtime" "prompt"\n',
|
||||
"launch.ts": "execLeaseGatedRuntime('claude', args);\n",
|
||||
"coord.ts": "return ['mosaic', runtime, '-p', prompt];\n",
|
||||
}
|
||||
for filename, source in cases.items():
|
||||
with self.subTest(filename=filename):
|
||||
self.assertEqual(GUARD.scan_text(Path(filename), source), [])
|
||||
|
||||
def test_detects_prefixed_tracked_runtime_variable_execution(self) -> None:
|
||||
multiline = {
|
||||
"exec-quoted": 'exec "$v" -p x',
|
||||
"exec-unquoted": "exec $v -p x",
|
||||
"command": 'command "$v" -p x',
|
||||
"nohup": 'nohup "$v" -p x',
|
||||
"env": 'env A=1 "$v" -p x',
|
||||
}
|
||||
cases = {
|
||||
**{f"multiline-{name}.sh": f"v=claude\n{command}\n" for name, command in multiline.items()},
|
||||
**{f"same-line-{name}.sh": f"v=claude; {command}\n" for name, command in multiline.items()},
|
||||
}
|
||||
for filename, source in cases.items():
|
||||
with self.subTest(filename=filename):
|
||||
self.assertNotEqual(GUARD.scan_text(Path(filename), source), [], source)
|
||||
|
||||
def test_accepts_only_validated_multiline_typescript_wrapper_invocation(self) -> None:
|
||||
source = """execRuntime(
|
||||
'python3',
|
||||
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
||||
environment,
|
||||
);
|
||||
"""
|
||||
sites = GUARD.classify_text(Path("launch.ts"), source)
|
||||
self.assertEqual(len(sites), 1)
|
||||
self.assertEqual(sites[0].classification, "gated")
|
||||
|
||||
def test_marker_comments_strings_and_assignments_are_not_gated_sites(self) -> None:
|
||||
harmless_sources = {
|
||||
"comment.sh": "# launch-runtime.py --runtime claude --\n",
|
||||
"echo.sh": "echo 'launch-runtime.py --runtime claude --'\n",
|
||||
"assignment.sh": "marker='launch-runtime.py --runtime claude --'\n",
|
||||
"argument.sh": "printf '%s' 'launch-runtime.py --runtime claude --'\n",
|
||||
}
|
||||
for filename, source in harmless_sources.items():
|
||||
with self.subTest(filename=filename):
|
||||
self.assertEqual(GUARD.classify_text(Path(filename), source), [])
|
||||
|
||||
def test_dangerous_primitive_backstops_parser_exotic_alias_indirection(self) -> None:
|
||||
source = (
|
||||
"alias hidden_runtime=claude\n"
|
||||
"hidden_runtime --dangerously-skip-permissions -p x\n"
|
||||
)
|
||||
sites = GUARD.scan_text(Path("alias-launch.sh"), source)
|
||||
self.assertEqual(len(sites), 1)
|
||||
self.assertEqual(sites[0].classification, "dangerous-primitive")
|
||||
|
||||
def test_dangerous_primitive_is_owned_only_by_the_choke_point(self) -> None:
|
||||
primitive = "--dangerously-skip-permissions"
|
||||
self.assertNotEqual(GUARD.scan_text(Path("caller.ts"), f"args = ['{primitive}'];\n"), [])
|
||||
self.assertEqual(
|
||||
GUARD.scan_text(Path("framework/tools/lease-broker/launch-runtime.py"), f'FLAG = "{primitive}"\n'),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_repository_has_no_ungated_consequential_runtime_launch(self) -> None:
|
||||
violations = GUARD.scan_repository(REPO_ROOT)
|
||||
self.assertEqual(
|
||||
violations,
|
||||
[],
|
||||
"\n".join(GUARD.format_violation(violation) for violation in violations),
|
||||
)
|
||||
inventory = GUARD.inventory_repository(REPO_ROOT)
|
||||
self.assertEqual(len(inventory), 14)
|
||||
self.assertTrue(all(site.classification == "gated" for site in inventory))
|
||||
|
||||
def test_repository_walk_skips_tests_build_outputs_and_reports_unscannable_source(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
production = root / "packages/example/src/launch.sh"
|
||||
production.parent.mkdir(parents=True)
|
||||
production.write_text("exec claude -p prompt\n")
|
||||
(production.parent / "launch.spec.ts").write_text("spawn('pi', [])\n")
|
||||
dist = root / "packages/example/dist/launch.js"
|
||||
dist.parent.mkdir(parents=True)
|
||||
dist.write_text("exec('claude -p prompt')\n")
|
||||
ignored_suffix = production.parent / "notes.txt"
|
||||
ignored_suffix.write_text("claude -p prompt\n")
|
||||
invalid = production.parent / "invalid.py"
|
||||
invalid.write_bytes(b"\xff\xfe")
|
||||
|
||||
violations = GUARD.scan_repository(root)
|
||||
formatted = [GUARD.format_violation(item) for item in violations]
|
||||
self.assertEqual(len(violations), 2)
|
||||
self.assertTrue(any("launch.sh:1: direct" in item for item in formatted))
|
||||
self.assertTrue(any("invalid.py:0: unscannable" in item for item in formatted))
|
||||
self.assertFalse(any("spec" in item or "dist" in item or "notes" in item for item in formatted))
|
||||
|
||||
inventory = GUARD.inventory_repository(root)
|
||||
self.assertEqual({item.classification for item in inventory}, {"direct", "unscannable"})
|
||||
|
||||
def test_main_emits_machine_inventory_and_fails_on_a_direct_site(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / "packages/example/launch.sh"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text(
|
||||
'exec claude --dangerously-skip-permissions "terra-r3" # launch-runtime.py\n'
|
||||
)
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
result = GUARD.main(["--root", str(root), "--json"])
|
||||
payload = json.loads(stdout.getvalue())
|
||||
self.assertEqual(result, 1)
|
||||
self.assertEqual(payload["gated"], 0)
|
||||
self.assertEqual(payload["total"], 1)
|
||||
self.assertIn("ungated consequential runtime", stderr.getvalue())
|
||||
|
||||
def test_main_text_mode_reports_a_green_gated_inventory(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / "packages/example/launch.sh"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text("exec mosaic yolo claude prompt\n")
|
||||
stdout = io.StringIO()
|
||||
with redirect_stdout(stdout):
|
||||
result = GUARD.main(["--root", str(root)])
|
||||
self.assertEqual(result, 0)
|
||||
self.assertIn("1 gated/1 total", stdout.getvalue())
|
||||
|
||||
def test_script_entrypoint_uses_current_directory_default(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "packages").mkdir()
|
||||
with patch.object(sys, "argv", [str(GUARD_PATH)]), patch("pathlib.Path.cwd", return_value=root):
|
||||
with redirect_stdout(io.StringIO()), self.assertRaises(SystemExit) as raised:
|
||||
runpy.run_path(str(GUARD_PATH), run_name="__main__")
|
||||
self.assertEqual(raised.exception.code, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
386
packages/mosaic/src/mutator-gate/runtime_tools_unittest.py
Normal file
386
packages/mosaic/src/mutator-gate/runtime_tools_unittest.py
Normal file
@@ -0,0 +1,386 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Branch-focused tests for the lease-gated runtime executables."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import runpy
|
||||
import socket
|
||||
import sys
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
TOOLS_DIR = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
||||
|
||||
|
||||
def load_tool(module_name: str, filename: str):
|
||||
spec = importlib.util.spec_from_file_location(module_name, TOOLS_DIR / filename)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"unable to load {filename}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
LAUNCHER = load_tool("lease_runtime_launcher", "launch-runtime.py")
|
||||
GATE = load_tool("lease_mutator_gate", "mutator-gate.py")
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
def __init__(self, *chunks: bytes):
|
||||
self.chunks = list(chunks)
|
||||
self.timeout = None
|
||||
self.connected = None
|
||||
self.sent = b""
|
||||
self.shutdown_how = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def settimeout(self, value: float) -> None:
|
||||
self.timeout = value
|
||||
|
||||
def connect(self, value: str) -> None:
|
||||
self.connected = value
|
||||
|
||||
def sendall(self, value: bytes) -> None:
|
||||
self.sent += value
|
||||
|
||||
def shutdown(self, how: int) -> None:
|
||||
self.shutdown_how = how
|
||||
|
||||
def recv(self, _size: int) -> bytes:
|
||||
return self.chunks.pop(0) if self.chunks else b""
|
||||
|
||||
|
||||
class LaunchRuntimeTest(unittest.TestCase):
|
||||
def test_success_registers_then_injects_session_before_exec(self) -> None:
|
||||
calls: dict[str, object] = {}
|
||||
session_id = "a" * 64
|
||||
|
||||
def request(path: Path, payload: dict[str, object]) -> dict[str, object]:
|
||||
calls["path"] = path
|
||||
calls["request"] = payload
|
||||
return {"ok": True, "session_id": session_id}
|
||||
|
||||
def execute(command: str, argv: list[str], environment: dict[str, str]) -> None:
|
||||
calls["execute"] = (command, argv, environment)
|
||||
|
||||
result = LAUNCHER.main(
|
||||
["--runtime", "claude", "--", "claude", "--print", "hello"],
|
||||
environ={
|
||||
"MOSAIC_LEASE_BROKER_SOCKET": "/run/test/broker.sock",
|
||||
"MOSAIC_RUNTIME_GENERATION": "7",
|
||||
"PRESERVED": "yes",
|
||||
},
|
||||
request=request,
|
||||
execute=execute,
|
||||
)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(calls["path"], Path("/run/test/broker.sock"))
|
||||
self.assertEqual(
|
||||
calls["request"],
|
||||
{"action": "register_anchor", "runtime_generation": 7},
|
||||
)
|
||||
command, argv, environment = calls["execute"]
|
||||
self.assertEqual(command, "claude")
|
||||
self.assertEqual(argv, ["claude", "--print", "hello"])
|
||||
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["PRESERVED"], "yes")
|
||||
|
||||
def test_dangerous_claude_mode_is_owned_and_injected_by_the_wrapper(self) -> None:
|
||||
executed: list[tuple[str, list[str], dict[str, str]]] = []
|
||||
result = LAUNCHER.main(
|
||||
["--runtime", "claude", "--dangerous", "--", "claude", "-p", "hello"],
|
||||
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/broker"},
|
||||
request=lambda *_args: {"ok": True, "session_id": "e" * 64},
|
||||
execute=lambda *args: executed.append(args),
|
||||
)
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(
|
||||
executed[0][1],
|
||||
["claude", "--dangerously-skip-permissions", "-p", "hello"],
|
||||
)
|
||||
|
||||
with redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(
|
||||
LAUNCHER.main(
|
||||
["--runtime", "pi", "--dangerous", "--", "pi"],
|
||||
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/broker"},
|
||||
request=lambda *_args: {"ok": True, "session_id": "e" * 64},
|
||||
execute=lambda *_args: self.fail("invalid dangerous runtime executed"),
|
||||
),
|
||||
64,
|
||||
)
|
||||
|
||||
def test_command_without_separator_is_forwarded_unchanged(self) -> None:
|
||||
executed: list[tuple[str, list[str], dict[str, str]]] = []
|
||||
result = LAUNCHER.main(
|
||||
["--runtime", "pi", "pi", "--print", "hello"],
|
||||
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/broker"},
|
||||
request=lambda *_args: {"ok": True, "session_id": "f" * 64},
|
||||
execute=lambda *args: executed.append(args),
|
||||
)
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(executed[0][0:2], ("pi", ["pi", "--print", "hello"]))
|
||||
|
||||
def test_missing_command_is_usage_error(self) -> None:
|
||||
with redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(
|
||||
LAUNCHER.main(
|
||||
["--runtime", "pi", "--"],
|
||||
environ={},
|
||||
request=lambda *_args: {},
|
||||
execute=lambda *_args: None,
|
||||
),
|
||||
64,
|
||||
)
|
||||
|
||||
def test_registration_validation_and_environment_fail_closed(self) -> None:
|
||||
good_session = "b" * 64
|
||||
cases = [
|
||||
({}, {"ok": True, "session_id": good_session}),
|
||||
({"MOSAIC_LEASE_BROKER_SOCKET": "/x", "MOSAIC_RUNTIME_GENERATION": "bad"}, {}),
|
||||
({"MOSAIC_LEASE_BROKER_SOCKET": "/x", "MOSAIC_RUNTIME_GENERATION": "-1"}, {}),
|
||||
({"MOSAIC_LEASE_BROKER_SOCKET": "/x"}, {"ok": False, "session_id": good_session}),
|
||||
({"MOSAIC_LEASE_BROKER_SOCKET": "/x"}, {"ok": True, "session_id": 4}),
|
||||
({"MOSAIC_LEASE_BROKER_SOCKET": "/x"}, {"ok": True, "session_id": "b" * 63}),
|
||||
({"MOSAIC_LEASE_BROKER_SOCKET": "/x"}, {"ok": True, "session_id": "z" * 64}),
|
||||
]
|
||||
for environment, reply in cases:
|
||||
with self.subTest(environment=environment, reply=reply), redirect_stderr(io.StringIO()):
|
||||
executed: list[object] = []
|
||||
result = LAUNCHER.main(
|
||||
["--runtime", "pi", "--", "pi"],
|
||||
environ=environment,
|
||||
request=lambda *_args, value=reply: value,
|
||||
execute=lambda *args: executed.append(args),
|
||||
)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertEqual(executed, [])
|
||||
|
||||
def test_registration_exceptions_fail_closed(self) -> None:
|
||||
failures = [ValueError("bad"), OSError("down"), json.JSONDecodeError("bad", "x", 0)]
|
||||
for failure in failures:
|
||||
with self.subTest(failure=type(failure).__name__), redirect_stderr(io.StringIO()):
|
||||
def request(*_args, error=failure):
|
||||
raise error
|
||||
|
||||
self.assertEqual(
|
||||
LAUNCHER.main(
|
||||
["--runtime", "claude", "--", "claude"],
|
||||
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/x"},
|
||||
request=request,
|
||||
execute=lambda *_args: self.fail("must not execute"),
|
||||
),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_exec_failure_is_fail_closed(self) -> None:
|
||||
with redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(
|
||||
LAUNCHER.main(
|
||||
["--runtime", "pi", "--", "pi"],
|
||||
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/x"},
|
||||
request=lambda *_args: {"ok": True, "session_id": "c" * 64},
|
||||
execute=lambda *_args: (_ for _ in ()).throw(OSError("missing")),
|
||||
),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_broker_reply_framing_and_shape_validation(self) -> None:
|
||||
replies = [
|
||||
(b'{"ok":true}\n', {"ok": True}),
|
||||
(b'{"ok":true}', ValueError),
|
||||
(b'[]\n', ValueError),
|
||||
(b"x" * (LAUNCHER.MAX_FRAME + 1), ValueError),
|
||||
]
|
||||
for wire_reply, expected in replies:
|
||||
with self.subTest(size=len(wire_reply)):
|
||||
fake = FakeSocket(wire_reply)
|
||||
with patch.object(LAUNCHER.socket, "socket", return_value=fake):
|
||||
if isinstance(expected, type) and issubclass(expected, Exception):
|
||||
with self.assertRaises(expected):
|
||||
LAUNCHER.broker_request(Path("/broker"), {"action": "register_anchor"})
|
||||
else:
|
||||
self.assertEqual(
|
||||
LAUNCHER.broker_request(Path("/broker"), {"action": "register_anchor"}),
|
||||
expected,
|
||||
)
|
||||
self.assertEqual(fake.timeout, LAUNCHER.BROKER_TIMEOUT_SECONDS)
|
||||
self.assertEqual(fake.connected, "/broker")
|
||||
self.assertEqual(fake.shutdown_how, socket.SHUT_WR)
|
||||
|
||||
|
||||
class ExecutableEntrypointTest(unittest.TestCase):
|
||||
def test_launcher_entrypoint_returns_usage_without_a_command(self) -> None:
|
||||
with patch.object(
|
||||
sys,
|
||||
"argv",
|
||||
[str(TOOLS_DIR / "launch-runtime.py"), "--runtime", "claude"],
|
||||
), redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as raised:
|
||||
runpy.run_path(str(TOOLS_DIR / "launch-runtime.py"), run_name="__main__")
|
||||
self.assertEqual(raised.exception.code, 64)
|
||||
|
||||
def test_gate_entrypoint_denies_when_identity_environment_is_absent(self) -> None:
|
||||
class Stdin:
|
||||
buffer = io.BytesIO(b'{"tool_name":"Bash"}')
|
||||
|
||||
with patch.object(
|
||||
sys,
|
||||
"argv",
|
||||
[str(TOOLS_DIR / "mutator-gate.py"), "--runtime", "claude"],
|
||||
), patch.object(sys, "stdin", Stdin()), patch.dict(
|
||||
os.environ, {}, clear=True
|
||||
), redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as raised:
|
||||
runpy.run_path(str(TOOLS_DIR / "mutator-gate.py"), run_name="__main__")
|
||||
self.assertEqual(raised.exception.code, 2)
|
||||
|
||||
|
||||
class MutatorGateTest(unittest.TestCase):
|
||||
@staticmethod
|
||||
def environment() -> dict[str, str]:
|
||||
return {
|
||||
"MOSAIC_LEASE_BROKER_SOCKET": "/run/test/broker.sock",
|
||||
"MOSAIC_LEASE_SESSION_ID": "d" * 64,
|
||||
"MOSAIC_RUNTIME_GENERATION": "2",
|
||||
}
|
||||
|
||||
def run_main(self, *, tool: object = "Bash", reply: dict[str, object] | None = None):
|
||||
calls: list[tuple[Path, dict[str, object]]] = []
|
||||
|
||||
def request(path: Path, payload: dict[str, object]) -> dict[str, object]:
|
||||
calls.append((path, payload))
|
||||
return reply if reply is not None else {"ok": True, "decision": "allow"}
|
||||
|
||||
stderr = io.StringIO()
|
||||
with redirect_stderr(stderr):
|
||||
result = GATE.main(
|
||||
["--runtime", "claude"],
|
||||
environ=self.environment(),
|
||||
stream=io.BytesIO(json.dumps({"tool_name": tool}).encode()),
|
||||
request=request,
|
||||
)
|
||||
return result, stderr.getvalue(), calls
|
||||
|
||||
def test_allow_and_denial_decisions(self) -> None:
|
||||
allowed, allowed_stderr, calls = self.run_main()
|
||||
self.assertEqual(allowed, 0)
|
||||
self.assertEqual(allowed_stderr, "")
|
||||
self.assertEqual(calls[0][0], Path("/run/test/broker.sock"))
|
||||
self.assertEqual(
|
||||
calls[0][1],
|
||||
{
|
||||
"action": "authorize_tool",
|
||||
"session_id": "d" * 64,
|
||||
"runtime_generation": 2,
|
||||
"runtime": "claude",
|
||||
"tool_name": "Bash",
|
||||
},
|
||||
)
|
||||
|
||||
denied, denied_stderr, _ = self.run_main(reply={"ok": False, "code": "LEASE_EXPIRED"})
|
||||
self.assertEqual(denied, 2)
|
||||
self.assertIn("LEASE_EXPIRED", denied_stderr)
|
||||
|
||||
defaulted, defaulted_stderr, _ = self.run_main(reply={"ok": False, "code": 4})
|
||||
self.assertEqual(defaulted, 2)
|
||||
self.assertIn("MUTATOR_UNVERIFIED", defaulted_stderr)
|
||||
|
||||
def test_input_validation_fails_closed(self) -> None:
|
||||
payloads = [
|
||||
b"x" * (GATE.MAX_FRAME + 1),
|
||||
b"[]",
|
||||
b"{}",
|
||||
json.dumps({"tool_name": ""}).encode(),
|
||||
json.dumps({"tool_name": 4}).encode(),
|
||||
json.dumps({"tool_name": "x" * 257}).encode(),
|
||||
b"not-json",
|
||||
]
|
||||
for payload in payloads:
|
||||
with self.subTest(size=len(payload)), redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(
|
||||
GATE.main(
|
||||
["--runtime", "pi"],
|
||||
environ=self.environment(),
|
||||
stream=io.BytesIO(payload),
|
||||
request=lambda *_args: self.fail("invalid input reached broker"),
|
||||
),
|
||||
2,
|
||||
)
|
||||
|
||||
def test_environment_generation_and_request_failures_deny(self) -> None:
|
||||
environments = [
|
||||
{},
|
||||
{**self.environment(), "MOSAIC_RUNTIME_GENERATION": "bad"},
|
||||
{**self.environment(), "MOSAIC_RUNTIME_GENERATION": "-1"},
|
||||
]
|
||||
for environment in environments:
|
||||
with self.subTest(environment=environment), redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(
|
||||
GATE.main(
|
||||
["--runtime", "claude"],
|
||||
environ=environment,
|
||||
stream=io.BytesIO(b'{"tool_name":"Read"}'),
|
||||
request=lambda *_args: {},
|
||||
),
|
||||
2,
|
||||
)
|
||||
|
||||
failures = [ValueError("bad"), OSError("down"), json.JSONDecodeError("bad", "x", 0)]
|
||||
for failure in failures:
|
||||
with self.subTest(failure=type(failure).__name__), redirect_stderr(io.StringIO()):
|
||||
def request(*_args, error=failure):
|
||||
raise error
|
||||
|
||||
self.assertEqual(
|
||||
GATE.main(
|
||||
["--runtime", "claude"],
|
||||
environ=self.environment(),
|
||||
stream=io.BytesIO(b'{"tool_name":"Read"}'),
|
||||
request=request,
|
||||
),
|
||||
2,
|
||||
)
|
||||
|
||||
def test_broker_request_framing_payload_and_shape_validation(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
GATE.broker_request(Path("/broker"), {"session_id": "x" * GATE.MAX_FRAME})
|
||||
|
||||
replies = [
|
||||
(b'{"ok":true,"decision":"allow"}\n', {"ok": True, "decision": "allow"}),
|
||||
(b'{"ok":true}', ValueError),
|
||||
(b'[]\n', ValueError),
|
||||
(b"x" * (GATE.MAX_FRAME + 1), ValueError),
|
||||
]
|
||||
for wire_reply, expected in replies:
|
||||
with self.subTest(size=len(wire_reply)):
|
||||
fake = FakeSocket(wire_reply)
|
||||
with patch.object(GATE.socket, "socket", return_value=fake):
|
||||
if isinstance(expected, type) and issubclass(expected, Exception):
|
||||
with self.assertRaises(expected):
|
||||
GATE.broker_request(Path("/broker"), {"action": "authorize_tool"})
|
||||
else:
|
||||
self.assertEqual(
|
||||
GATE.broker_request(Path("/broker"), {"action": "authorize_tool"}),
|
||||
expected,
|
||||
)
|
||||
self.assertEqual(fake.timeout, GATE.BROKER_TIMEOUT_SECONDS)
|
||||
self.assertEqual(fake.connected, "/broker")
|
||||
self.assertEqual(fake.shutdown_how, socket.SHUT_WR)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user