From 74fc62e4eef5676845a662ba9fd2177eacbb22a5 Mon Sep 17 00:00:00 2001 From: ms-lead-reviewer Date: Fri, 17 Jul 2026 22:23:38 -0500 Subject: [PATCH 01/13] test(#829): define whole mutator-class gate contract --- docs/scratchpads/829-mutator-gate.md | 45 +++ .../mutator-gate.acceptance.spec.ts | 318 ++++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 docs/scratchpads/829-mutator-gate.md create mode 100644 packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts diff --git a/docs/scratchpads/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md new file mode 100644 index 00000000..d933a952 --- /dev/null +++ b/docs/scratchpads/829-mutator-gate.md @@ -0,0 +1,45 @@ +# 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. + +## 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. diff --git a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts new file mode 100644 index 00000000..f9c5fc37 --- /dev/null +++ b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts @@ -0,0 +1,318 @@ +import { chmod, mkdtemp, readFile } 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'; + +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 claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json'); +const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts'); +const children: ChildProcess[] = []; + +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 { + return await new Promise((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 { + 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((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 }; +} + +async function register(socket: string, runtime_generation = 1): Promise { + 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 { + 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 { + 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 { + 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(() => { + for (const child of children.splice(0)) child.kill('SIGTERM'); +}); + +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('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('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('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'); + }); +}); -- 2.49.1 From 439f5f4bc9df5e7e59dd0475c75f6a50fb8754e3 Mon Sep 17 00:00:00 2001 From: ms-lead-reviewer Date: Fri, 17 Jul 2026 22:28:11 -0500 Subject: [PATCH 02/13] test(#829): bind runtime launch to broker identity --- .../mutator-gate.acceptance.spec.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts index f9c5fc37..2cb058a7 100644 --- a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts +++ b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts @@ -22,6 +22,7 @@ interface BrokerPaths { 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 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 children: ChildProcess[] = []; @@ -288,6 +289,51 @@ describe('whole mutator-class lease gate', () => { }); }); + 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('Claude and Pi runtime adapters consult the broker for every tool class', async () => { const { socket } = await startBroker(); const sessionId = await register(socket); -- 2.49.1 From 77b137ccc04b5be035cac5ca21bbbf3df8b94f97 Mon Sep 17 00:00:00 2001 From: ms-lead-reviewer Date: Fri, 17 Jul 2026 22:35:18 -0500 Subject: [PATCH 03/13] feat(#829): enforce whole mutator-class lease gate --- docs/SITEMAP.md | 3 +- docs/architecture/lease-broker-protocol.md | 8 +- docs/architecture/lease-broker-security.md | 6 +- docs/architecture/mutator-class-gate.md | 39 ++++ docs/guides/lease-broker-operations.md | 11 +- docs/scratchpads/829-mutator-gate.md | 21 +++ .../framework/runtime/claude/settings.json | 10 + .../framework/runtime/pi/mosaic-extension.ts | 23 +++ .../framework/tools/lease-broker/daemon.py | 173 ++++++++++++++++-- .../tools/lease-broker/launch-runtime.py | 87 +++++++++ .../tools/lease-broker/mutator-gate.py | 91 +++++++++ packages/mosaic/src/commands/launch.ts | 23 ++- 12 files changed, 476 insertions(+), 19 deletions(-) create mode 100644 docs/architecture/mutator-class-gate.md create mode 100644 packages/mosaic/framework/tools/lease-broker/launch-runtime.py create mode 100644 packages/mosaic/framework/tools/lease-broker/mutator-gate.py diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index 52083a5a..a3c32f47 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -4,7 +4,8 @@ - [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings. - [Broker operations](guides/lease-broker-operations.md) — protected paths, startup, fail-closed recovery posture, distinct-principal deployment, and residual risk. -- [WI-1 security notes](architecture/lease-broker-security.md) — threat-boundary summary and coordinator review requirements. +- [Lease-broker security notes](architecture/lease-broker-security.md) — identity, whole-class authorization, threat boundaries, and coordinator review requirements. +- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary. ## CLI and skill management diff --git a/docs/architecture/lease-broker-protocol.md b/docs/architecture/lease-broker-protocol.md index 3aaf6da8..e2b1cdb2 100644 --- a/docs/architecture/lease-broker-protocol.md +++ b/docs/architecture/lease-broker-protocol.md @@ -12,7 +12,13 @@ Each connection carries exactly one UTF-8 JSON object followed by one newline, c - `authenticate`: `action`, broker-minted `session_id`, non-negative `runtime_generation`. - `mint_token`: authenticated identity plus `binding` containing exactly `compaction_epoch`, `request_epoch`, `h_source`, `h_payload`, and `schema_version`. - `consume_token`: authenticated identity plus `token`. +- `begin_verification`: authenticated identity, runtime (`claude` or `pi`), cycle `binding`, and a TTL no greater than 300 seconds. The broker revokes existing authority first, enters `PENDING_VERIFICATION`, and returns a single-use promotion token. +- `promote_lease`: authenticated identity plus the exact pending promotion token. The broker commits token consumption before making `VERIFIED` visible. +- `revoke_lease`: authenticated observer signal; deletes pending tokens and makes the session `UNVERIFIED` immediately. +- `authorize_tool`: authenticated identity, runtime, and exact runtime-reported tool name. The broker returns an explicit allow/deny decision from the whole-class policy and current lease. -A higher generation for the same anchor atomically replaces the stored incarnation and deletes all prior tokens for that session. A lower generation is stale. Tokens are 256-bit values from the operating-system cryptographic RNG and are single use. At most 256 pending tokens may be persisted; another mint fails with `TOKEN_CAPACITY` before mutation. Successful consumption deletes the token, while a replay still fails with `TOKEN_REPLAY`. Live v1 token records retain the existing `consumed: false` schema. Their persisted binding is the WI-1 substrate for later receipt work; WI-1 does not implement receipts, promotion, hooks, payload builders, mutator gates, or recovery. +A higher generation for the same anchor atomically replaces the stored incarnation and deletes all prior tokens and lease authority for that session. A lower generation is stale. Tokens are 256-bit values from the operating-system cryptographic RNG and are single use. At most 256 pending tokens may be persisted; another mint fails with `TOKEN_CAPACITY` before mutation. Successful consumption deletes the token, while a replay still fails with `TOKEN_REPLAY`. Live v1 token records retain the existing `consumed: false` schema. + +VERIFIED leases are volatile and monotonic-time bounded: broker restart, generation change, explicit observer revocation, or expiry returns the session to `UNVERIFIED`. `begin_verification` always revokes before minting a new prerequisite. `promote_lease` is valid only from the matching pending cycle; persistence failure rolls token and lease state back, while post-rename durability uncertainty terminates the broker. The WI-1 token is the atomic promotion prerequisite substrate. A later receipt implementation must satisfy that prerequisite but cannot replace the mechanical mutator gate as safety authority. State replacement serializes and enforces the 4 MiB maximum before opening a temporary file, then uses a mode-`0600` temporary file, `fsync`, atomic rename, and parent-directory `fsync`. Every broker mutation snapshots the prior v1 state. A commit failure before rename restores that snapshot and leaves durable state unchanged. A failure after rename makes durability uncertain, so the store is poisoned without rolling memory back and the daemon terminates rather than serving with divergent state. Existing state is opened without following symlinks, must be a bounded regular file at mode `0600`, and is fully schema- and invariant-validated before use. Persisted tokens must be unconsumed, match their session's current generation, and remain within the 256-token cap. Session identity is uniquely keyed by `(anchor_pid,anchor_starttime)`; duplicate logical sessions for one anchor refuse startup. State integrity or mode failures refuse startup. The daemon does not log session IDs or tokens. diff --git a/docs/architecture/lease-broker-security.md b/docs/architecture/lease-broker-security.md index ca73b0e2..b90466ff 100644 --- a/docs/architecture/lease-broker-security.md +++ b/docs/architecture/lease-broker-security.md @@ -6,6 +6,8 @@ - Session IDs and cycle tokens use the OS cryptographic RNG. `Math.random` and model output are not token sources. - Framing and persistence failures fail closed. Sensitive tokens are not logged. - Built-in `0700`/`0600` filesystem modes provide same-principal hardening only, not socket authenticity against the same UID. WI-1 provides no distinct-principal isolation. That stronger deployment requires an external protected proxy, ACL, or service boundary, and the boundary must preserve authenticated client identity for the broker's `SO_PEERCRED` and ancestry authorization rather than substituting a shared proxy identity. -- WI-2+ security surfaces—receipts, promotion transactions, mutator gates, hooks, payload construction, and recovery—are explicitly out of scope. +- WI-2 whole-class authorization denies every consequential, unknown, and custom tool while UNVERIFIED; it does not inspect shell strings or trust wrapper selection. +- WI-2 promotion consumes a WI-1 cycle token before VERIFIED becomes visible. Observer revocation, runtime-generation replacement, broker restart, and monotonic TTL expiry remove authority. +- Receipt observation, payload construction, compaction observers, and constrained recovery implementation remain later surfaces. A receipt can become a promotion prerequisite but is never the safety mechanism. -Coordinator security review must rerun the real socket/peercred acceptance suite on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration. +Coordinator security review must rerun the real socket/peercred and mutator-gate acceptance suites on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration. diff --git a/docs/architecture/mutator-class-gate.md b/docs/architecture/mutator-class-gate.md new file mode 100644 index 00000000..dbb1f6cc --- /dev/null +++ b/docs/architecture/mutator-class-gate.md @@ -0,0 +1,39 @@ +# Whole mutator-class lease gate + +WI-2 adds the framework-native authorization boundary for Claude and Pi. Every runtime-reported tool name reaches the lease broker before execution. The gate classifies capabilities by the whole tool class; it never parses a Bash command to decide whether that particular string looks read-only. + +## Default-deny policy + +While a session is not VERIFIED, only these exact classes are allowed: + +- Claude: `Read`, `Grep`, `Glob`, `Ls`, `Find` +- Pi: `read`, `grep`, `find`, `ls` +- Both runtimes: the fixed `mosaic_context_recover` primitive + +Every other built-in, unknown tool, and custom/MCP tool is consequential by default and is denied. This includes Claude `Bash`, `Edit`, `Write`, and `NotebookEdit`, plus Pi `bash`, `edit`, and `write`. A compromised model therefore cannot bypass Mosaic wrappers by selecting raw `git`, `curl`, `kubectl`, provider, deployment, or filesystem commands inside a generic mutator—the generic mutator itself is blocked before its input executes. + +## Broker-owned transition order + +The authenticated broker is the sole lease writer: + +1. `begin_verification` revokes existing authority and pending tokens first, then records `PENDING_VERIFICATION` and mints one WI-1 single-use promotion token bound to the exact cycle. +2. `promote_lease` accepts only that session/generation/binding/token combination. +3. Token consumption commits before the volatile lease becomes VERIFIED. Promotion is last and cannot be reached directly from UNVERIFIED. +4. `revoke_lease`, a runtime-generation increase, broker restart, or monotonic expiry removes mutator authority. + +The initial TTL is capped at the ratified 300-second maximum. A caller may request a shorter positive TTL but cannot lengthen the maximum. Dual compaction-hook miss within an unexpired lease remains the ratified bounded T-A residual; once either observer revokes or TTL expires, the next consequential tool is denied. + +A receipt is only a future promotion prerequisite. It is not an obedience, residency, or safety proof and never replaces this mechanical gate. + +## Runtime adapters + +`launch-runtime.py` registers itself with the broker and then `exec`s Claude or Pi so PID/starttime remain the authenticated parent anchor. It exports only the broker-minted session ID and current generation to descendants. + +- Claude installs `mutator-gate.py` as an all-tools (`.*`) `PreToolUse` hook. +- Pi invokes the same executable from its `tool_call` handler. + +The executable submits the runtime's actual tool name to `authorize_tool`. Missing identity, malformed input/reply, timeout, broker unavailability, or denial exits with status 2 and blocks fail-closed. + +## Assurance boundary + +This closes T-A after an observer fires or lease expiry and T-B for in-runtime tool calls. Hook/extension absence, a runtime executing outside the gated launcher, ptrace/same-UID broker replacement, and other fully rotted behavior remain T-C. Server-side branch protection and required PR review/CI remain the irreducible line for protected repository mutations. diff --git a/docs/guides/lease-broker-operations.md b/docs/guides/lease-broker-operations.md index ed47fa6a..9f166132 100644 --- a/docs/guides/lease-broker-operations.md +++ b/docs/guides/lease-broker-operations.md @@ -10,9 +10,18 @@ python3 "$MOSAIC_HOME/tools/lease-broker/daemon.py" \ The broker refuses an existing parent directory whose mode is not exactly `0700`, an existing state file not at `0600`, corrupt/incompatible state, or an already-existing socket path. After bind it sets the socket to `0600`. It never silently unlinks a pre-existing socket. On normal termination it unlinks only the socket inode it created, so it does not remove a replacement path. +Before launching Claude or Pi, export the socket path; `mosaic` then runs the runtime through the packaged register-and-exec wrapper: + +```bash +export MOSAIC_LEASE_BROKER_SOCKET=/run/user/1000/mosaic-lease/broker.sock +mosaic claude # or: mosaic pi +``` + +The wrapper obtains a broker-minted session ID and `exec`s the runtime without changing its PID/starttime anchor. The all-tools Claude `PreToolUse` hook and Pi `tool_call` handler inherit that identity. Broker registration failure denies runtime launch; missing identity, broker timeout/unavailability, and malformed replies block tools fail-closed. + Clients must complete the request boundary before waiting for a reply. After sending the single JSON object and its terminating newline, the client **MUST half-close the socket's write side** (`shutdown(SHUT_WR)` in POSIX clients; `socket.end()` in Node) and only then await the response. Merely calling `write()` and waiting is invalid: the broker waits for EOF to enforce the exact-one-frame contract and fails closed at its one-second deadline. Do not replace `end()` with `write()` in client helpers. A delayed second frame remains malformed and is rejected. -There is no automated recovery workflow in WI-1. After a crash, preserve the protected state file and restart only after verifying that no broker owns the socket. A leftover socket requires an operator to verify the owning service is stopped and remove that exact socket deliberately. Corrupt, oversized, symlinked, or non-regular state fails closed; do not overwrite it. Preserve it for incident review and establish new state only through an explicit operational decision, which invalidates prior sessions and tokens. +There is no automated recovery workflow yet. `mosaic_context_recover` is reserved as the only unverified mutator class, but its fixed payload/receipt implementation lands in a later WI. After a crash, preserve the protected state file and restart only after verifying that no broker owns the socket. Restart intentionally clears all volatile VERIFIED leases. A leftover socket requires an operator to verify the owning service is stopped and remove that exact socket deliberately. Corrupt, oversized, symlinked, or non-regular state fails closed; do not overwrite it. Preserve it for incident review and establish new state only through an explicit operational decision, which invalidates prior sessions and tokens. ## Security posture diff --git a/docs/scratchpads/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md index d933a952..c55d7f39 100644 --- a/docs/scratchpads/829-mutator-gate.md +++ b/docs/scratchpads/829-mutator-gate.md @@ -36,6 +36,27 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE - Initial lease TTL is capped at ratified 300 seconds; callers may only shorten it. - Working budget assumption: 35K tokens; reduce documentation/refactor breadth before touching locked scope if pressure rises. +## Progress and verification + +- RED #1: all 5 initial real-socket contract tests failed on WI-1 with `UNKNOWN_ACTION` or missing adapter behavior. +- RED #2: register-before-exec runtime test failed because `launch-runtime.py` did not exist. +- GREEN: broker-owned volatile lease state, 300-second maximum monotonic TTL, WI-1 token-backed promotion, all-tools runtime gate, Claude/Pi wiring, and register-before-exec launcher delivered without changing WI-1 peercred/ancestry authority. +- Focused broker + gate acceptance: `43/43` GREEN. +- Instrumented Python coverage: `88%` total — daemon `89%`, register/exec launcher `86%`, runtime gate `86%`. +- Full repository suite: `43/43` Turbo tasks GREEN; `@mosaicstack/mosaic` `72/72` files and `1,377/1,377` tests GREEN. +- Root typecheck: `42/42`; lint: `23/23`; format check and `git diff --check`: GREEN. +- No author self-review was run. Exact-head terra CODE and Opus SECREV remain coordinator-owned gates. + +## Acceptance mapping + +| Acceptance criterion | Evidence | +| --- | --- | +| No consequential mutator succeeds while UNVERIFIED after observer revoke or TTL | `observer revocation and monotonic TTL expiry deny the next mutator` real-socket acceptance test | +| T-B compromised-tool bypass is covered by the whole gate | `T-B raw and custom mutator tools are default-denied without shell parsing` across Claude/Pi built-ins, raw Bash class, MCP/custom/unknown tools | +| Revoke-first / promote-last is structural | `revoke-first and promote-last structurally bracket mutator authority`; direct promotion rejected, pending remains denied, token consumption commits before VERIFIED | +| Consume WI-1 auth/lease substrate | All transitions and decisions traverse merged peercred/ancestry authentication; promotion consumes the exact WI-1 CSPRNG cycle token | +| M1 Claude + Pi | Claude `.*` PreToolUse and Pi `tool_call` invoke the same broker gate; register-before-exec test proves broker-minted parent identity reaches runtime descendants | + ## Locked discipline - Re-verify and read the four authority artifacts before design or code. diff --git a/packages/mosaic/framework/runtime/claude/settings.json b/packages/mosaic/framework/runtime/claude/settings.json index 0318d9e7..ee3cd0e5 100644 --- a/packages/mosaic/framework/runtime/claude/settings.json +++ b/packages/mosaic/framework/runtime/claude/settings.json @@ -2,6 +2,16 @@ "model": "opus", "hooks": { "PreToolUse": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude", + "timeout": 3 + } + ] + }, { "matcher": "Write|Edit|MultiEdit", "hooks": [ diff --git a/packages/mosaic/framework/runtime/pi/mosaic-extension.ts b/packages/mosaic/framework/runtime/pi/mosaic-extension.ts index cbf783fc..7ef7ee59 100644 --- a/packages/mosaic/framework/runtime/pi/mosaic-extension.ts +++ b/packages/mosaic/framework/runtime/pi/mosaic-extension.ts @@ -28,6 +28,7 @@ import { execSync, spawnSync } from 'node:child_process'; // --------------------------------------------------------------------------- const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); +const MUTATOR_GATE = join(MOSAIC_HOME, 'tools', 'lease-broker', 'mutator-gate.py'); // --------------------------------------------------------------------------- // Helpers @@ -106,6 +107,23 @@ function nowIso(): string { return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); } +function checkPiMutatorGate(toolName: string): { block: true; reason: string } | undefined { + const result = spawnSync('python3', [MUTATOR_GATE, '--runtime', 'pi'], { + input: `${JSON.stringify({ tool_name: toolName })}\n`, + encoding: 'utf8', + timeout: 2_000, + env: process.env, + }); + if (result.status === 0) return undefined; + const detail = String(result.stderr ?? '') + .trim() + .split('\n')[0]; + return { + block: true, + reason: detail || 'BLOCKED: Mosaic mutator gate is unavailable or the lease is UNVERIFIED.', + }; +} + // --------------------------------------------------------------------------- // Mission detection // --------------------------------------------------------------------------- @@ -250,6 +268,11 @@ export default function register(pi: ExtensionAPI) { let hbModel: string | null = null; let hbTimer: ReturnType | null = null; + // ── Whole mutator-class authorization gate ──────────────────────────── + // Every Pi tool, including unknown/custom tools, reaches the broker-backed + // class gate before execution. Broker/script failure blocks fail-closed. + pi.on('tool_call', async (event) => checkPiMutatorGate(event.toolName)); + // ── Session Start ───────────────────────────────────────────────────── pi.on('session_start', async (_event, ctx) => { sessionCwd = process.cwd(); diff --git a/packages/mosaic/framework/tools/lease-broker/daemon.py b/packages/mosaic/framework/tools/lease-broker/daemon.py index dfcddf13..d33e4535 100644 --- a/packages/mosaic/framework/tools/lease-broker/daemon.py +++ b/packages/mosaic/framework/tools/lease-broker/daemon.py @@ -24,9 +24,19 @@ MAX_FRAME: Final = 64 * 1024 MAX_STATE: Final = 4 * 1024 * 1024 MAX_PENDING_TOKENS: Final = 256 MAX_IN_FLIGHT_CONNECTIONS: Final = 16 +MAX_LEASE_TTL_SECONDS: Final = 300 STATE_VERSION: Final = 1 CONNECTION_DEADLINE_SECONDS: Final = 1.0 HEX_256_LENGTH: Final = 64 +LEASE_UNVERIFIED: Final = "UNVERIFIED" +LEASE_PENDING: Final = "PENDING_VERIFICATION" +LEASE_PENDING_PROMOTION: Final = "PENDING_PROMOTION" +LEASE_VERIFIED: Final = "VERIFIED" +READ_ONLY_TOOLS: Final = { + "claude": frozenset({"Read", "Grep", "Glob", "Ls", "Find"}), + "pi": frozenset({"read", "grep", "find", "ls"}), +} +RECOVERY_TOOL: Final = "mosaic_context_recover" class BrokerFailure(Exception): @@ -265,6 +275,9 @@ class StateStore: class Broker: def __init__(self, store: StateStore) -> None: self.store = store + # VERIFIED authority is deliberately volatile: broker restart revokes all + # leases while preserving WI-1 identity and pending-token integrity. + self.leases: dict[str, dict[str, object]] = {} def authenticate(self, peer_pid: int, request: dict[str, object]) -> tuple[str, dict[str, object]]: session_id = request.get("session_id") @@ -289,7 +302,7 @@ class Broker: raise BrokerFailure("STALE_GENERATION") if generation > current_generation: session["runtime_generation"] = generation - self.revoke_session_tokens(session_id) + self.revoke_session_authority(session_id) return session_id, session def session_for_anchor( @@ -310,19 +323,59 @@ class Broker: ]: del tokens[token_value] + def revoke_session_authority(self, session_id: str) -> None: + self.revoke_session_tokens(session_id) + session = self.store.sessions().get(session_id) + generation = session.get("runtime_generation") if isinstance(session, dict) else None + self.leases[session_id] = { + "state": LEASE_UNVERIFIED, + "runtime_generation": generation, + } + + def mint_token( + self, + session_id: str, + generation: int, + binding: dict[str, object], + ) -> str: + if len(self.store.tokens()) >= MAX_PENDING_TOKENS: + raise BrokerFailure("TOKEN_CAPACITY") + token = secrets.token_hex(32) + self.store.tokens()[token] = { + "session_id": session_id, + "runtime_generation": generation, + "binding": copy.deepcopy(binding), + "consumed": False, + } + return token + + def finish_promotion(self, session_id: str) -> None: + lease = self.leases.get(session_id) + if not isinstance(lease, dict) or lease.get("state") != LEASE_PENDING_PROMOTION: + raise BrokerFailure("STATE_INTEGRITY") + lease["state"] = LEASE_VERIFIED + def handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]: if self.store.poisoned: raise StateCommitUncertain() previous = copy.deepcopy(self.store.value) + previous_leases = copy.deepcopy(self.leases) try: response = self._handle(peer, request) if self.store.value != previous: self.store.commit() + if request.get("action") == "promote_lease": + session_id = request.get("session_id") + if not isinstance(session_id, str): + raise BrokerFailure("INVALID_IDENTITY") + self.finish_promotion(session_id) + response["state"] = LEASE_VERIFIED return response except StateCommitUncertain: raise except Exception: self.store.value = previous + self.leases = previous_leases raise def _handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]: @@ -351,7 +404,7 @@ class Broker: raise BrokerFailure("STALE_GENERATION") if generation > current_generation: session["runtime_generation"] = generation - self.revoke_session_tokens(session_id) + self.revoke_session_authority(session_id) return {"ok": True, "session_id": session_id, "peer": {"pid": peer_pid, "uid": peer_uid, "gid": peer_gid, "starttime": anchor["starttime"]}} if action == "authenticate": self.authenticate(peer_pid, request) @@ -361,15 +414,7 @@ class Broker: binding = request.get("binding") if not valid_binding(binding): raise BrokerFailure("INVALID_BINDING") - if len(self.store.tokens()) >= MAX_PENDING_TOKENS: - raise BrokerFailure("TOKEN_CAPACITY") - token = secrets.token_hex(32) - self.store.tokens()[token] = { - "session_id": session_id, - "runtime_generation": request["runtime_generation"], - "binding": binding, - "consumed": False, - } + token = self.mint_token(session_id, request["runtime_generation"], binding) return {"ok": True, "token": token} if action == "consume_token": session_id, _ = self.authenticate(peer_pid, request) @@ -379,6 +424,112 @@ class Broker: raise BrokerFailure("TOKEN_REPLAY") del self.store.tokens()[token_value] return {"ok": True} + if action == "begin_verification": + session_id, _ = self.authenticate(peer_pid, request) + runtime = request.get("runtime") + binding = request.get("binding") + ttl_seconds = request.get("ttl_seconds", MAX_LEASE_TTL_SECONDS) + if runtime not in READ_ONLY_TOOLS: + raise BrokerFailure("INVALID_RUNTIME") + if not valid_binding(binding): + raise BrokerFailure("INVALID_BINDING") + if ( + type(ttl_seconds) is not int + or ttl_seconds <= 0 + or ttl_seconds > MAX_LEASE_TTL_SECONDS + ): + raise BrokerFailure("INVALID_LEASE_TTL") + # Revoke-first is a broker operation, not advisory adapter order. + self.revoke_session_authority(session_id) + token = self.mint_token(session_id, request["runtime_generation"], binding) + self.leases[session_id] = { + "state": LEASE_PENDING, + "runtime": runtime, + "runtime_generation": request["runtime_generation"], + "binding": copy.deepcopy(binding), + "promotion_token": token, + "ttl_seconds": ttl_seconds, + } + return { + "ok": True, + "state": LEASE_PENDING, + "promotion_token": token, + } + if action == "promote_lease": + session_id, _ = self.authenticate(peer_pid, request) + promotion_token = request.get("promotion_token") + lease = self.leases.get(session_id) + if not isinstance(lease, dict) or lease.get("state") != LEASE_PENDING: + raise BrokerFailure("INVALID_LEASE_TRANSITION") + expected_token = lease.get("promotion_token") + if ( + not isinstance(promotion_token, str) + or not isinstance(expected_token, str) + or not secrets.compare_digest(promotion_token, expected_token) + ): + raise BrokerFailure("PROMOTION_TOKEN_MISMATCH") + token = self.store.tokens().get(promotion_token) + if ( + not isinstance(token, dict) + or token.get("session_id") != session_id + or token.get("runtime_generation") != request.get("runtime_generation") + or token.get("binding") != lease.get("binding") + or token.get("consumed") is not False + ): + raise BrokerFailure("PROMOTION_TOKEN_INVALID") + del self.store.tokens()[promotion_token] + lease["state"] = LEASE_PENDING_PROMOTION + lease["expires_at"] = time.monotonic() + int(lease["ttl_seconds"]) + # handle() commits token consumption before finish_promotion() makes + # VERIFIED externally visible: promote-last by construction. + return {"ok": True, "state": LEASE_PENDING_PROMOTION} + if action == "revoke_lease": + session_id, _ = self.authenticate(peer_pid, request) + self.revoke_session_authority(session_id) + return {"ok": True, "state": LEASE_UNVERIFIED} + if action == "authorize_tool": + session_id, _ = self.authenticate(peer_pid, request) + runtime = request.get("runtime") + tool_name = request.get("tool_name") + if runtime not in READ_ONLY_TOOLS: + raise BrokerFailure("INVALID_RUNTIME") + if not isinstance(tool_name, str) or not tool_name or len(tool_name) > 256: + raise BrokerFailure("INVALID_TOOL") + lease = self.leases.get(session_id) + state = lease.get("state") if isinstance(lease, dict) else LEASE_UNVERIFIED + if tool_name in READ_ONLY_TOOLS[runtime] or tool_name == RECOVERY_TOOL: + return {"ok": True, "decision": "allow", "state": state} + if not isinstance(lease, dict) or lease.get("state") != LEASE_VERIFIED: + return { + "ok": False, + "code": "MUTATOR_UNVERIFIED", + "decision": "deny", + "state": LEASE_UNVERIFIED, + } + if ( + lease.get("runtime") != runtime + or lease.get("runtime_generation") != request.get("runtime_generation") + ): + return { + "ok": False, + "code": "MUTATOR_UNVERIFIED", + "decision": "deny", + "state": LEASE_UNVERIFIED, + } + expires_at = lease.get("expires_at") + if not isinstance(expires_at, (int, float)) or time.monotonic() >= expires_at: + self.revoke_session_authority(session_id) + return { + "ok": False, + "code": "LEASE_EXPIRED", + "decision": "deny", + "state": LEASE_UNVERIFIED, + } + return { + "ok": True, + "decision": "allow", + "state": LEASE_VERIFIED, + } raise BrokerFailure("UNKNOWN_ACTION") diff --git a/packages/mosaic/framework/tools/lease-broker/launch-runtime.py b/packages/mosaic/framework/tools/lease-broker/launch-runtime.py new file mode 100644 index 00000000..c6a3f4f1 --- /dev/null +++ b/packages/mosaic/framework/tools/lease-broker/launch-runtime.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Register a runtime parent with the lease broker, then exec without changing PID.""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import sys +from pathlib import Path +from typing import Final + +MAX_FRAME: Final = 64 * 1024 +BROKER_TIMEOUT_SECONDS: Final = 1.5 + + +def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]: + payload = (json.dumps(request, separators=(",", ":")) + "\n").encode() + response = bytearray() + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(BROKER_TIMEOUT_SECONDS) + connection.connect(str(socket_path)) + connection.sendall(payload) + connection.shutdown(socket.SHUT_WR) + while len(response) <= MAX_FRAME: + chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(response))) + if not chunk: + break + response.extend(chunk) + if len(response) > MAX_FRAME or not response.endswith(b"\n"): + raise ValueError("invalid broker reply") + value = json.loads(response) + if not isinstance(value, dict): + raise ValueError("invalid broker reply") + return value + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--runtime", required=True, choices=("claude", "pi")) + parser.add_argument("command", nargs=argparse.REMAINDER) + arguments = parser.parse_args() + command = arguments.command + if command and command[0] == "--": + command = command[1:] + if not command: + print("lease-gated runtime command is required", file=sys.stderr) + return 64 + + try: + socket_path = Path(os.environ["MOSAIC_LEASE_BROKER_SOCKET"]) + generation = int(os.environ.get("MOSAIC_RUNTIME_GENERATION", "1")) + if generation < 0: + raise ValueError("invalid generation") + reply = broker_request( + socket_path, + { + "action": "register_anchor", + "runtime_generation": generation, + }, + ) + session_id = reply.get("session_id") + if ( + reply.get("ok") is not True + or not isinstance(session_id, str) + or len(session_id) != 64 + or any(character not in "0123456789abcdef" for character in session_id) + ): + raise ValueError("registration refused") + except (KeyError, ValueError, OSError, json.JSONDecodeError): + print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr) + return 1 + + environment = dict(os.environ) + environment["MOSAIC_LEASE_SESSION_ID"] = session_id + environment["MOSAIC_RUNTIME_GENERATION"] = str(generation) + environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime + try: + os.execvpe(command[0], command, environment) + except OSError: + print("Mosaic lease-gated runtime exec failed.", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/mosaic/framework/tools/lease-broker/mutator-gate.py b/packages/mosaic/framework/tools/lease-broker/mutator-gate.py new file mode 100644 index 00000000..c66cbbbd --- /dev/null +++ b/packages/mosaic/framework/tools/lease-broker/mutator-gate.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Runtime-neutral whole mutator-class gate backed by the Mosaic lease broker.""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import sys +from pathlib import Path +from typing import Final + +MAX_FRAME: Final = 64 * 1024 +BROKER_TIMEOUT_SECONDS: Final = 1.5 + + +def deny(code: str) -> int: + print(f"BLOCKED: Mosaic mutator gate denied this tool ({code}).", file=sys.stderr) + return 2 + + +def read_tool_name() -> str: + raw = sys.stdin.buffer.read(MAX_FRAME + 1) + if len(raw) > MAX_FRAME: + raise ValueError("INVALID_GATE_INPUT") + value = json.loads(raw) + if not isinstance(value, dict): + raise ValueError("INVALID_GATE_INPUT") + tool_name = value.get("tool_name") + if not isinstance(tool_name, str) or not tool_name or len(tool_name) > 256: + raise ValueError("INVALID_GATE_INPUT") + return tool_name + + +def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]: + payload = (json.dumps(request, separators=(",", ":")) + "\n").encode() + if len(payload) > MAX_FRAME: + raise ValueError("INVALID_GATE_INPUT") + response = bytearray() + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(BROKER_TIMEOUT_SECONDS) + connection.connect(str(socket_path)) + connection.sendall(payload) + connection.shutdown(socket.SHUT_WR) + while len(response) <= MAX_FRAME: + chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(response))) + if not chunk: + break + response.extend(chunk) + if len(response) > MAX_FRAME or not response.endswith(b"\n"): + raise ValueError("INVALID_BROKER_REPLY") + value = json.loads(response) + if not isinstance(value, dict): + raise ValueError("INVALID_BROKER_REPLY") + return value + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--runtime", required=True, choices=("claude", "pi")) + arguments = parser.parse_args() + + try: + tool_name = read_tool_name() + socket_value = os.environ["MOSAIC_LEASE_BROKER_SOCKET"] + session_id = os.environ["MOSAIC_LEASE_SESSION_ID"] + generation = int(os.environ["MOSAIC_RUNTIME_GENERATION"]) + if generation < 0: + raise ValueError("INVALID_GENERATION") + reply = broker_request( + Path(socket_value), + { + "action": "authorize_tool", + "session_id": session_id, + "runtime_generation": generation, + "runtime": arguments.runtime, + "tool_name": tool_name, + }, + ) + except (KeyError, ValueError, OSError, json.JSONDecodeError): + return deny("GATE_UNAVAILABLE") + + if reply.get("ok") is True and reply.get("decision") == "allow": + return 0 + code = reply.get("code") + return deny(code if isinstance(code, str) else "MUTATOR_UNVERIFIED") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index b51bcb1e..ecfb6209 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -115,7 +115,7 @@ function auditClaudeSettings(): SettingsAudit { // Check required hooks const hooks = settings['hooks'] as Record | undefined; - const requiredPreToolUse = ['prevent-memory-write.sh']; + const requiredPreToolUse = ['mutator-gate.py', 'prevent-memory-write.sh']; const requiredPostToolUse = ['qa-hook-stdin.sh', 'typecheck-hook.sh']; const preHooks = (hooks?.['PreToolUse'] ?? []) as Array>; @@ -763,7 +763,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev cliArgs.push(...args); } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); - execRuntime('claude', cliArgs); + execLeaseGatedRuntime('claude', cliArgs); break; } @@ -798,7 +798,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev cliArgs.push(...args); } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); - execRuntime('pi', cliArgs); + execLeaseGatedRuntime('pi', cliArgs); break; } } @@ -806,6 +806,23 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev process.exit(0); // Unreachable but satisfies never } +function defaultLeaseBrokerSocket(env: NodeJS.ProcessEnv = process.env): string { + if (env['MOSAIC_LEASE_BROKER_SOCKET']) return env['MOSAIC_LEASE_BROKER_SOCKET']; + const runtimeDir = env['XDG_RUNTIME_DIR']; + if (runtimeDir) return join(runtimeDir, 'mosaic-lease', 'broker.sock'); + const uid = typeof process.getuid === 'function' ? process.getuid() : 0; + return join('/run/user', String(uid), 'mosaic-lease', 'broker.sock'); +} + +function execLeaseGatedRuntime(runtime: 'claude' | 'pi', args: string[]): void { + const launcher = resolveTool('lease-broker', 'launch-runtime.py'); + execRuntime('python3', [launcher, '--runtime', runtime, '--', runtime, ...args], { + ...process.env, + MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(), + MOSAIC_RUNTIME_GENERATION: process.env['MOSAIC_RUNTIME_GENERATION'] ?? '1', + }); +} + /** exec into the runtime, replacing the current process. */ function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void { try { -- 2.49.1 From 046896c612a0c4de87c2d34a27d187720d075b6b Mon Sep 17 00:00:00 2001 From: ms-lead-reviewer Date: Fri, 17 Jul 2026 22:56:36 -0500 Subject: [PATCH 04/13] test(#829): regress claudex bypass and per-tool coverage gaps --- docs/scratchpads/829-mutator-gate.md | 7 + .../mutator-gate.acceptance.spec.ts | 118 ++++++- .../mutator-gate/runtime_tools_unittest.py | 322 ++++++++++++++++++ 3 files changed, 445 insertions(+), 2 deletions(-) create mode 100644 packages/mosaic/src/mutator-gate/runtime_tools_unittest.py diff --git a/docs/scratchpads/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md index c55d7f39..8c5cee92 100644 --- a/docs/scratchpads/829-mutator-gate.md +++ b/docs/scratchpads/829-mutator-gate.md @@ -64,3 +64,10 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE - 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. diff --git a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts index 2cb058a7..2b407386 100644 --- a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts +++ b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts @@ -1,4 +1,4 @@ -import { chmod, mkdtemp, readFile } from 'node:fs/promises'; +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'; @@ -6,6 +6,8 @@ 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; @@ -26,6 +28,7 @@ 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 children: ChildProcess[] = []; +const temporaryRoots: string[] = []; const binding = (compaction_epoch = 1) => ({ compaction_epoch, @@ -148,8 +151,11 @@ function runRuntimeGate( }); } -afterEach(() => { +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', () => { @@ -334,6 +340,114 @@ describe('whole mutator-class lease gate', () => { expect(unavailable.stdout).not.toContain('EXECUTED'); }); + 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 | undefined; + const run = (cmd: string, args: string[], env: NodeJS.ProcessEnv) => { + execution = spawnSync(cmd, args, { encoding: 'utf8', env }); + }; + const adapter = { + harnessPreflight: () => {}, + composePrompt: () => '# composed Claude contract', + // Pre-remediation Claudex uses this direct boundary and therefore bypasses registration. + exec: run, + // The remediated orchestration must select this shared register-before-exec boundary. + execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) => + run('python3', [launcherPath, '--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, execution!.stderr).toBe(0); + expect(JSON.parse(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); diff --git a/packages/mosaic/src/mutator-gate/runtime_tools_unittest.py b/packages/mosaic/src/mutator-gate/runtime_tools_unittest.py new file mode 100644 index 00000000..5641b958 --- /dev/null +++ b/packages/mosaic/src/mutator-gate/runtime_tools_unittest.py @@ -0,0 +1,322 @@ +#!/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 socket +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_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 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() -- 2.49.1 From 6a3505dfbc2cfeee6b651cea84f7050b90f4f786 Mon Sep 17 00:00:00 2001 From: ms-lead-reviewer Date: Fri, 17 Jul 2026 23:10:58 -0500 Subject: [PATCH 05/13] fix(#829): gate claudex and close branch coverage gaps --- docs/architecture/lease-broker-security.md | 2 +- docs/architecture/mutator-class-gate.md | 3 +- docs/guides/lease-broker-operations.md | 6 +- docs/scratchpads/829-mutator-gate.md | 11 +++ .../tools/lease-broker/launch-runtime.py | 23 +++-- .../tools/lease-broker/mutator-gate.py | 29 +++--- packages/mosaic/src/commands/claudex.spec.ts | 62 ++++++++++++- packages/mosaic/src/commands/claudex.ts | 89 ++++++++++++++++++- packages/mosaic/src/commands/launch.ts | 14 +-- .../mutator-gate.acceptance.spec.ts | 54 +++++++++-- 10 files changed, 253 insertions(+), 40 deletions(-) diff --git a/docs/architecture/lease-broker-security.md b/docs/architecture/lease-broker-security.md index b90466ff..6d7991eb 100644 --- a/docs/architecture/lease-broker-security.md +++ b/docs/architecture/lease-broker-security.md @@ -6,7 +6,7 @@ - Session IDs and cycle tokens use the OS cryptographic RNG. `Math.random` and model output are not token sources. - Framing and persistence failures fail closed. Sensitive tokens are not logged. - Built-in `0700`/`0600` filesystem modes provide same-principal hardening only, not socket authenticity against the same UID. WI-1 provides no distinct-principal isolation. That stronger deployment requires an external protected proxy, ACL, or service boundary, and the boundary must preserve authenticated client identity for the broker's `SO_PEERCRED` and ancestry authorization rather than substituting a shared proxy identity. -- WI-2 whole-class authorization denies every consequential, unknown, and custom tool while UNVERIFIED; it does not inspect shell strings or trust wrapper selection. +- 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 and both Claudex dispatch modes use broker register-before-exec; Claudex additionally installs the mandatory all-tools hook inside its preserved isolated config and fails closed on unsafe settings. - 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. diff --git a/docs/architecture/mutator-class-gate.md b/docs/architecture/mutator-class-gate.md index dbb1f6cc..cd03af42 100644 --- a/docs/architecture/mutator-class-gate.md +++ b/docs/architecture/mutator-class-gate.md @@ -1,6 +1,6 @@ # Whole mutator-class lease gate -WI-2 adds the framework-native authorization boundary for Claude and Pi. Every runtime-reported tool name reaches the lease broker before execution. The gate classifies capabilities by the whole tool class; it never parses a Bash command to decide whether that particular string looks read-only. +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 @@ -30,6 +30,7 @@ A receipt is only a future promotion prerequisite. It is not an obedience, resid `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. diff --git a/docs/guides/lease-broker-operations.md b/docs/guides/lease-broker-operations.md index 9f166132..53c69e03 100644 --- a/docs/guides/lease-broker-operations.md +++ b/docs/guides/lease-broker-operations.md @@ -10,14 +10,14 @@ python3 "$MOSAIC_HOME/tools/lease-broker/daemon.py" \ The broker refuses an existing parent directory whose mode is not exactly `0700`, an existing state file not at `0600`, corrupt/incompatible state, or an already-existing socket path. After bind it sets the socket to `0600`. It never silently unlinks a pre-existing socket. On normal termination it unlinks only the socket inode it created, so it does not remove a replacement path. -Before launching Claude or Pi, export the socket path; `mosaic` then runs the runtime through the packaged register-and-exec wrapper: +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 pi +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. Broker registration failure denies runtime launch; missing identity, broker timeout/unavailability, and malformed replies block tools fail-closed. +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. Broker registration failure, unsafe isolated settings, or missing identity denies launch/tool execution fail-closed; broker timeout/unavailability and malformed replies also block tools. 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. diff --git a/docs/scratchpads/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md index 8c5cee92..19b7279f 100644 --- a/docs/scratchpads/829-mutator-gate.md +++ b/docs/scratchpads/829-mutator-gate.md @@ -71,3 +71,14 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE - 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. diff --git a/packages/mosaic/framework/tools/lease-broker/launch-runtime.py b/packages/mosaic/framework/tools/lease-broker/launch-runtime.py index c6a3f4f1..b68de1b4 100644 --- a/packages/mosaic/framework/tools/lease-broker/launch-runtime.py +++ b/packages/mosaic/framework/tools/lease-broker/launch-runtime.py @@ -8,6 +8,7 @@ import json import os import socket import sys +from collections.abc import Callable, Mapping, Sequence from pathlib import Path from typing import Final @@ -36,11 +37,17 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o return value -def main() -> int: +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("command", nargs=argparse.REMAINDER) - arguments = parser.parse_args() + arguments = parser.parse_args(argv) command = arguments.command if command and command[0] == "--": command = command[1:] @@ -48,12 +55,13 @@ def main() -> int: print("lease-gated runtime command is required", file=sys.stderr) return 64 + source_environment = os.environ if environ is None else environ try: - socket_path = Path(os.environ["MOSAIC_LEASE_BROKER_SOCKET"]) - generation = int(os.environ.get("MOSAIC_RUNTIME_GENERATION", "1")) + 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 = broker_request( + reply = request( socket_path, { "action": "register_anchor", @@ -72,15 +80,16 @@ def main() -> int: print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr) return 1 - environment = dict(os.environ) + environment = dict(source_environment) environment["MOSAIC_LEASE_SESSION_ID"] = session_id environment["MOSAIC_RUNTIME_GENERATION"] = str(generation) environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime try: - os.execvpe(command[0], command, environment) + execute(command[0], command, environment) except OSError: print("Mosaic lease-gated runtime exec failed.", file=sys.stderr) return 1 + return 0 if __name__ == "__main__": diff --git a/packages/mosaic/framework/tools/lease-broker/mutator-gate.py b/packages/mosaic/framework/tools/lease-broker/mutator-gate.py index c66cbbbd..d622b33b 100644 --- a/packages/mosaic/framework/tools/lease-broker/mutator-gate.py +++ b/packages/mosaic/framework/tools/lease-broker/mutator-gate.py @@ -8,8 +8,9 @@ import json import os import socket import sys +from collections.abc import Callable, Mapping, Sequence from pathlib import Path -from typing import Final +from typing import BinaryIO, Final MAX_FRAME: Final = 64 * 1024 BROKER_TIMEOUT_SECONDS: Final = 1.5 @@ -20,8 +21,9 @@ def deny(code: str) -> int: return 2 -def read_tool_name() -> str: - raw = sys.stdin.buffer.read(MAX_FRAME + 1) +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) @@ -56,19 +58,26 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o return value -def main() -> int: +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() + arguments = parser.parse_args(argv) + source_environment = os.environ if environ is None else environ try: - tool_name = read_tool_name() - socket_value = os.environ["MOSAIC_LEASE_BROKER_SOCKET"] - session_id = os.environ["MOSAIC_LEASE_SESSION_ID"] - generation = int(os.environ["MOSAIC_RUNTIME_GENERATION"]) + 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 = broker_request( + reply = request( Path(socket_value), { "action": "authorize_tool", diff --git a/packages/mosaic/src/commands/claudex.spec.ts b/packages/mosaic/src/commands/claudex.spec.ts index fab7cdc6..65a13057 100644 --- a/packages/mosaic/src/commands/claudex.spec.ts +++ b/packages/mosaic/src/commands/claudex.spec.ts @@ -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,17 @@ function makeReport(overrides: Partial = {}): PreflightReport { }; } -function okAdapter(overrides: Partial = {}): ClaudexHarnessAdapter { +type TestAdapterOverrides = Partial & { + 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) => exec?.('claude', args, env)), + ...adapterOverrides, }; } @@ -561,11 +575,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: (() => { diff --git a/packages/mosaic/src/commands/claudex.ts b/packages/mosaic/src/commands/claudex.ts index f6f47d63..5fdc5c63 100644 --- a/packages/mosaic/src/commands/claudex.ts +++ b/packages/mosaic/src/commands/claudex.ts @@ -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 { + 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 = {}; + + 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) => 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,6 +527,7 @@ 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)}`; @@ -454,7 +535,7 @@ export async function launchClaudex( const cliArgs = yolo ? ['--dangerously-skip-permissions'] : []; cliArgs.push('--append-system-prompt', prompt, ...args); - adapter.exec('claude', cliArgs, env); + adapter.execLeaseGated(cliArgs, env); } catch (err) { errorLog( `[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`, diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index ecfb6209..2cc78d4a 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -814,12 +814,16 @@ function defaultLeaseBrokerSocket(env: NodeJS.ProcessEnv = process.env): string return join('/run/user', String(uid), 'mosaic-lease', 'broker.sock'); } -function execLeaseGatedRuntime(runtime: 'claude' | 'pi', args: string[]): void { +function execLeaseGatedRuntime( + runtime: 'claude' | 'pi', + args: string[], + baseEnv: NodeJS.ProcessEnv = process.env, +): void { const launcher = resolveTool('lease-broker', 'launch-runtime.py'); execRuntime('python3', [launcher, '--runtime', runtime, '--', runtime, ...args], { - ...process.env, - MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(), - MOSAIC_RUNTIME_GENERATION: process.env['MOSAIC_RUNTIME_GENERATION'] ?? '1', + ...baseEnv, + MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv), + MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1', }); } @@ -856,7 +860,7 @@ function launchClaudexProduction(args: string[], yolo: boolean): void { checkSequentialThinking('claude'); }, composePrompt: () => buildRuntimePrompt('claude'), - exec: (cmd, cmdArgs, env) => execRuntime(cmd, cmdArgs, env), + execLeaseGated: (cmdArgs, env) => execLeaseGatedRuntime('claude', cmdArgs, env), }; void launchClaudex(args, yolo, adapter); } diff --git a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts index 2b407386..78ca5207 100644 --- a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts +++ b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts @@ -244,6 +244,52 @@ describe('whole mutator-class lease gate', () => { } }); + 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); @@ -400,9 +446,7 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1) const adapter = { harnessPreflight: () => {}, composePrompt: () => '# composed Claude contract', - // Pre-remediation Claudex uses this direct boundary and therefore bypasses registration. - exec: run, - // The remediated orchestration must select this shared register-before-exec boundary. + // Claudex exposes only the shared register-before-exec boundary. execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) => run('python3', [launcherPath, '--runtime', 'claude', '--', 'claude', ...args], env), } as unknown as ClaudexHarnessAdapter; @@ -438,8 +482,8 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1) }); expect(execution).toBeDefined(); - expect(execution!.status, execution!.stderr).toBe(0); - expect(JSON.parse(execution!.stdout)).toEqual({ + 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, -- 2.49.1 From 7f3418fa8c757b6e9446f4d5008158207237a718 Mon Sep 17 00:00:00 2001 From: ms-lead-reviewer Date: Fri, 17 Jul 2026 23:29:05 -0500 Subject: [PATCH 06/13] test(#829): regress ungated runtime launch entries --- docs/scratchpads/829-mutator-gate.md | 7 ++ .../mutator-gate.acceptance.spec.ts | 99 +++++++++++++++++++ .../runtime_launch_guard_unittest.py | 56 +++++++++++ 3 files changed, 162 insertions(+) create mode 100644 packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py diff --git a/docs/scratchpads/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md index 19b7279f..0dfede8d 100644 --- a/docs/scratchpads/829-mutator-gate.md +++ b/docs/scratchpads/829-mutator-gate.md @@ -82,3 +82,10 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE - 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. diff --git a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts index 78ca5207..903900f6 100644 --- a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts +++ b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts @@ -27,6 +27,9 @@ const gatePath = join(frameworkRoot, 'tools/lease-broker/mutator-gate.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[] = []; @@ -77,6 +80,82 @@ async function startBroker(): Promise { return { socket }; } +interface RuntimeLaunchEntry { + name: string; + script: string; + prepare(root: string): Promise; +} + +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 { const reply = await request(socket, { action: 'register_anchor', runtime_generation }); expect(reply.ok).toBe(true); @@ -386,6 +465,26 @@ describe('whole mutator-class lease gate', () => { 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 }, diff --git a/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py new file mode 100644 index 00000000..7be7389d --- /dev/null +++ b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Contract tests for the permanent consequential-runtime launch guard.""" + +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path + + +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", + } + 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_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), + ) + + +if __name__ == "__main__": + unittest.main() -- 2.49.1 From 1792b7934dda7eff64a207b8b0edb9c460d4164b Mon Sep 17 00:00:00 2001 From: ms-lead-reviewer Date: Fri, 17 Jul 2026 23:39:46 -0500 Subject: [PATCH 07/13] fix(#829): enforce repository-wide runtime launch choke point --- docs/architecture/lease-broker-security.md | 3 +- docs/architecture/mutator-class-gate.md | 22 ++ docs/guides/lease-broker-operations.md | 10 +- docs/scratchpads/829-mutator-gate.md | 12 + .../src/__tests__/runtime-launch-gate.test.ts | 41 ++++ packages/coord/src/runner.ts | 23 +- .../mosaic/framework/guides/ORCHESTRATOR.md | 2 +- .../lease-broker/check-runtime-launches.py | 217 ++++++++++++++++++ .../orchestrator-matrix/adapters/README.md | 2 +- .../mosaic/framework/tools/prdy/prdy-init.sh | 3 +- .../framework/tools/prdy/prdy-update.sh | 3 +- .../framework/tools/qa/qa-hook-handler.sh | 2 +- .../tools/qa/remediation-hook-handler.sh | 4 +- packages/mosaic/package.json | 2 +- .../runtime_launch_guard_unittest.py | 78 +++++++ .../mutator-gate/runtime_tools_unittest.py | 39 ++++ 16 files changed, 446 insertions(+), 17 deletions(-) create mode 100644 packages/coord/src/__tests__/runtime-launch-gate.test.ts create mode 100644 packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py diff --git a/docs/architecture/lease-broker-security.md b/docs/architecture/lease-broker-security.md index 6d7991eb..1ce2e45b 100644 --- a/docs/architecture/lease-broker-security.md +++ b/docs/architecture/lease-broker-security.md @@ -6,7 +6,8 @@ - Session IDs and cycle tokens use the OS cryptographic RNG. `Math.random` and model output are not token sources. - Framing and persistence failures fail closed. Sensitive tokens are not logged. - Built-in `0700`/`0600` filesystem modes provide same-principal hardening only, not socket authenticity against the same UID. WI-1 provides no distinct-principal isolation. That stronger deployment requires an external protected proxy, ACL, or service boundary, and the boundary must preserve authenticated client identity for the broker's `SO_PEERCRED` and ancestry authorization rather than substituting a shared proxy identity. -- WI-2 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 and both Claudex dispatch modes use broker register-before-exec; Claudex additionally installs the mandatory all-tools hook inside its preserved isolated config and fails closed on unsafe settings. +- 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. diff --git a/docs/architecture/mutator-class-gate.md b/docs/architecture/mutator-class-gate.md index cd03af42..2ef63f2d 100644 --- a/docs/architecture/mutator-class-gate.md +++ b/docs/architecture/mutator-class-gate.md @@ -35,6 +35,28 @@ A receipt is only a future promotion prerequisite. It is not an obedience, resid 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 flags, working directory, and environment survive without skipping broker registration. `@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, and dynamic runtime launches fail. It is mandatory in `@mosaicstack/mosaic`'s test script, so root CI fails when a future entry does not name the wrapper, `execLeaseGatedRuntime`, or a gated `mosaic` launch. Synthetic scanner tests prove representative bypass forms are rejected, while real-socket tests 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. diff --git a/docs/guides/lease-broker-operations.md b/docs/guides/lease-broker-operations.md index 53c69e03..c64d9beb 100644 --- a/docs/guides/lease-broker-operations.md +++ b/docs/guides/lease-broker-operations.md @@ -17,7 +17,15 @@ 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. Broker registration failure, unsafe isolated settings, or missing identity denies launch/tool execution fail-closed; broker timeout/unavailability and malformed replies also block tools. +The wrapper obtains a broker-minted session ID 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. diff --git a/docs/scratchpads/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md index 0dfede8d..e3b416f5 100644 --- a/docs/scratchpads/829-mutator-gate.md +++ b/docs/scratchpads/829-mutator-gate.md @@ -89,3 +89,15 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE - 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. diff --git a/packages/coord/src/__tests__/runtime-launch-gate.test.ts b/packages/coord/src/__tests__/runtime-launch-gate.test.ts new file mode 100644 index 00000000..3e27f0de --- /dev/null +++ b/packages/coord/src/__tests__/runtime-launch-gate.test.ts @@ -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', + ]); + }); +}); diff --git a/packages/coord/src/runner.ts b/packages/coord/src/runner.ts index fd3d05de..ed663c8a 100644 --- a/packages/coord/src/runner.ts +++ b/packages/coord/src/runner.ts @@ -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 { diff --git a/packages/mosaic/framework/guides/ORCHESTRATOR.md b/packages/mosaic/framework/guides/ORCHESTRATOR.md index 81c8e5bd..1ed9a138 100644 --- a/packages/mosaic/framework/guides/ORCHESTRATOR.md +++ b/packages/mosaic/framework/guides/ORCHESTRATOR.md @@ -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** diff --git a/packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py b/packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py new file mode 100644 index 00000000..e7f42484 --- /dev/null +++ b/packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py @@ -0,0 +1,217 @@ +#!/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 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", +} + +# A launch line is gated only when it names the common wrapper, the TypeScript +# adapter that invokes that wrapper, or the `mosaic` runtime command that reaches +# the adapter. Merely mentioning a session ID or hook is not sufficient. +GATED_PATTERNS: Final = ( + re.compile(r"\bexecLeaseGatedRuntime\s*\("), + re.compile(r"\blaunch-runtime\.(?:sh|py)\b"), + re.compile( + r"(?:\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)"), +) + + +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 is_gated_line(line: str) -> bool: + return any(pattern.search(line) for pattern in GATED_PATTERNS) + + +def is_direct_line(line: str) -> bool: + return any(pattern.search(line) for pattern in DIRECT_PATTERNS) + + +def scan_text(path: Path, source: str) -> list[LaunchSite]: + violations: list[LaunchSite] = [] + gated_continuation = False + for line_number, line in enumerate(source.splitlines(), start=1): + stripped = line.strip() + if not stripped or stripped.startswith(("#", "//", "*")): + gated_continuation = False + continue + gated = is_gated_line(line) or gated_continuation + if is_direct_line(line) and not gated: + violations.append(LaunchSite(path, line_number, line.rstrip(), "direct")) + gated_continuation = gated and line.rstrip().endswith("\\") + return violations + + +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: + lines = path.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + inventory.append(LaunchSite(path.relative_to(root), 0, "non-UTF-8 source", "unscannable")) + continue + gated_continuation = False + for line_number, line in enumerate(lines, start=1): + stripped = line.strip() + if not stripped or stripped.startswith(("#", "//", "*")): + gated_continuation = False + continue + line_is_gated = is_gated_line(line) + gated = line_is_gated or gated_continuation + if line_is_gated and not stripped.startswith("function execLeaseGatedRuntime"): + inventory.append( + LaunchSite(path.relative_to(root), line_number, line.rstrip(), "gated") + ) + elif is_direct_line(line) and not gated: + inventory.append( + LaunchSite(path.relative_to(root), line_number, line.rstrip(), "direct") + ) + gated_continuation = gated and line.rstrip().endswith("\\") + 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()) diff --git a/packages/mosaic/framework/tools/orchestrator-matrix/adapters/README.md b/packages/mosaic/framework/tools/orchestrator-matrix/adapters/README.md index ad93ae8a..10dec663 100644 --- a/packages/mosaic/framework/tools/orchestrator-matrix/adapters/README.md +++ b/packages/mosaic/framework/tools/orchestrator-matrix/adapters/README.md @@ -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}\"" } } ``` diff --git a/packages/mosaic/framework/tools/prdy/prdy-init.sh b/packages/mosaic/framework/tools/prdy/prdy-init.sh index d701622b..c86855ef 100755 --- a/packages/mosaic/framework/tools/prdy/prdy-init.sh +++ b/packages/mosaic/framework/tools/prdy/prdy-init.sh @@ -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" --runtime claude -- \ + claude --dangerously-skip-permissions --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF" fi if [[ "$RUNTIME_CMD" == "codex" ]]; then diff --git a/packages/mosaic/framework/tools/prdy/prdy-update.sh b/packages/mosaic/framework/tools/prdy/prdy-update.sh index f130f043..19a3e3fc 100755 --- a/packages/mosaic/framework/tools/prdy/prdy-update.sh +++ b/packages/mosaic/framework/tools/prdy/prdy-update.sh @@ -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" --runtime claude -- \ + claude --dangerously-skip-permissions --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF" fi if [[ "$RUNTIME_CMD" == "codex" ]]; then diff --git a/packages/mosaic/framework/tools/qa/qa-hook-handler.sh b/packages/mosaic/framework/tools/qa/qa-hook-handler.sh index db0e4f63..a7fa1652 100755 --- a/packages/mosaic/framework/tools/qa/qa-hook-handler.sh +++ b/packages/mosaic/framework/tools/qa/qa-hook-handler.sh @@ -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 diff --git a/packages/mosaic/framework/tools/qa/remediation-hook-handler.sh b/packages/mosaic/framework/tools/qa/remediation-hook-handler.sh index fa4326a7..9139a3de 100755 --- a/packages/mosaic/framework/tools/qa/remediation-hook-handler.sh +++ b/packages/mosaic/framework/tools/qa/remediation-hook-handler.sh @@ -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 diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 5b045d1f..d1b8b074 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "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:*", diff --git a/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py index 7be7389d..4799ccec 100644 --- a/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py +++ b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py @@ -4,8 +4,15 @@ 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] @@ -26,6 +33,15 @@ class RuntimeLaunchGuardTest(unittest.TestCase): "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", } for filename, source in cases.items(): with self.subTest(filename=filename): @@ -51,6 +67,68 @@ class RuntimeLaunchGuardTest(unittest.TestCase): "\n".join(GUARD.format_violation(violation) for violation in violations), ) + 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 -p prompt\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() diff --git a/packages/mosaic/src/mutator-gate/runtime_tools_unittest.py b/packages/mosaic/src/mutator-gate/runtime_tools_unittest.py index 5641b958..8dcaa8b3 100644 --- a/packages/mosaic/src/mutator-gate/runtime_tools_unittest.py +++ b/packages/mosaic/src/mutator-gate/runtime_tools_unittest.py @@ -6,7 +6,10 @@ 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 @@ -97,6 +100,17 @@ class LaunchRuntimeTest(unittest.TestCase): self.assertEqual(environment["MOSAIC_LEASE_RUNTIME"], "claude") self.assertEqual(environment["PRESERVED"], "yes") + 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( @@ -185,6 +199,31 @@ class LaunchRuntimeTest(unittest.TestCase): 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]: -- 2.49.1 From 9582457228d67b1cd13a53f8ff0d24b1d3668dc5 Mon Sep 17 00:00:00 2001 From: ms-lead-reviewer Date: Fri, 17 Jul 2026 23:56:31 -0500 Subject: [PATCH 08/13] test(#829): regress runtime guard marker evasions --- docs/scratchpads/829-mutator-gate.md | 7 +++++++ .../src/mutator-gate/runtime_launch_guard_unittest.py | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/docs/scratchpads/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md index e3b416f5..98eedbd3 100644 --- a/docs/scratchpads/829-mutator-gate.md +++ b/docs/scratchpads/829-mutator-gate.md @@ -101,3 +101,10 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE - 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. diff --git a/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py index 4799ccec..880134d9 100644 --- a/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py +++ b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py @@ -42,6 +42,12 @@ class RuntimeLaunchGuardTest(unittest.TestCase): "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', } for filename, source in cases.items(): with self.subTest(filename=filename): -- 2.49.1 From 88a709a9f32daa1d50920a5feb417ddd8a20f586 Mon Sep 17 00:00:00 2001 From: ms-lead-reviewer Date: Fri, 17 Jul 2026 23:59:39 -0500 Subject: [PATCH 09/13] test(#829): extend guard evasion matrix and primitive invariant --- docs/scratchpads/829-mutator-gate.md | 2 ++ .../runtime_launch_guard_unittest.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/docs/scratchpads/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md index 98eedbd3..b5af096a 100644 --- a/docs/scratchpads/829-mutator-gate.md +++ b/docs/scratchpads/829-mutator-gate.md @@ -108,3 +108,5 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE - 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. diff --git a/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py index 880134d9..6c63d038 100644 --- a/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py +++ b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py @@ -48,6 +48,14 @@ class RuntimeLaunchGuardTest(unittest.TestCase): "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", } for filename, source in cases.items(): with self.subTest(filename=filename): @@ -65,6 +73,14 @@ class RuntimeLaunchGuardTest(unittest.TestCase): with self.subTest(filename=filename): self.assertEqual(GUARD.scan_text(Path(filename), source), []) + 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( @@ -72,6 +88,9 @@ class RuntimeLaunchGuardTest(unittest.TestCase): [], "\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: -- 2.49.1 From 64efdb44609faf0bd54564c7bedf34643fcdfd4b Mon Sep 17 00:00:00 2001 From: ms-lead-reviewer Date: Sat, 18 Jul 2026 00:01:23 -0500 Subject: [PATCH 10/13] test(#829): require choke-point-owned dangerous mode --- .../mutator-gate/runtime_tools_unittest.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/mosaic/src/mutator-gate/runtime_tools_unittest.py b/packages/mosaic/src/mutator-gate/runtime_tools_unittest.py index 8dcaa8b3..e809802c 100644 --- a/packages/mosaic/src/mutator-gate/runtime_tools_unittest.py +++ b/packages/mosaic/src/mutator-gate/runtime_tools_unittest.py @@ -100,6 +100,31 @@ class LaunchRuntimeTest(unittest.TestCase): 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( -- 2.49.1 From 1eb77c17f3147d4fa9944f77f1826243135b9cc0 Mon Sep 17 00:00:00 2001 From: ms-lead-reviewer Date: Sat, 18 Jul 2026 00:17:50 -0500 Subject: [PATCH 11/13] fix(#829): harden runtime launch guard against marker evasions --- docs/architecture/mutator-class-gate.md | 6 +- docs/scratchpads/829-mutator-gate.md | 5 + .../lease-broker/check-runtime-launches.py | 272 +++++++++++++++--- .../tools/lease-broker/launch-runtime.py | 7 + .../mosaic/framework/tools/prdy/prdy-init.sh | 4 +- .../framework/tools/prdy/prdy-update.sh | 4 +- packages/mosaic/src/commands/claudex.spec.ts | 5 +- packages/mosaic/src/commands/claudex.ts | 7 +- packages/mosaic/src/commands/launch.ts | 23 +- .../mutator-gate.acceptance.spec.ts | 16 +- .../runtime_launch_guard_unittest.py | 29 +- 11 files changed, 316 insertions(+), 62 deletions(-) diff --git a/docs/architecture/mutator-class-gate.md b/docs/architecture/mutator-class-gate.md index 2ef63f2d..2a64d6f6 100644 --- a/docs/architecture/mutator-class-gate.md +++ b/docs/architecture/mutator-class-gate.md @@ -37,9 +37,11 @@ The executable submits the runtime's actual tool name to `authorize_tool`. Missi ## 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 flags, working directory, and environment survive without skipping broker registration. `@mosaicstack/coord` rewrites direct Claude commands to `mosaic claude` and rejects unknown custom Claude launchers. +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, and dynamic runtime launches fail. It is mandatory in `@mosaicstack/mosaic`'s test script, so root CI fails when a future entry does not name the wrapper, `execLeaseGatedRuntime`, or a gated `mosaic` launch. Synthetic scanner tests prove representative bypass forms are rejected, while real-socket tests prove PRDY init/update and QA receive broker sessions and deny an unverified mutator. +`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. 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. + +Both checks are load-bearing: command-position analysis covers consequential launches that do not use dangerous mode, while primitive ownership cannot be fooled by a comment or marker string for dangerous mode. The guard is mandatory in `@mosaicstack/mosaic`'s test script, so root CI fails on a future bypass. Permanent regressions cover the original add-ungated effectiveness probe, comments, strings, assignments, heredocs, continuations, command chains, command substitution, `eval`, and variable execution; real-socket tests prove PRDY init/update and QA receive broker sessions and deny an unverified mutator. The live inventory is emitted by: diff --git a/docs/scratchpads/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md index b5af096a..858705dd 100644 --- a/docs/scratchpads/829-mutator-gate.md +++ b/docs/scratchpads/829-mutator-gate.md @@ -110,3 +110,8 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE - 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. diff --git a/packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py b/packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py index e7f42484..7888e21b 100644 --- a/packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py +++ b/packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py @@ -6,6 +6,7 @@ from __future__ import annotations import argparse import json import re +import shlex import sys from pathlib import Path from typing import Final, NamedTuple, Sequence @@ -33,15 +34,22 @@ SKIPPED_DIRECTORIES: Final = { "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 names the common wrapper, the TypeScript -# adapter that invokes that wrapper, or the `mosaic` runtime command that reaches -# the adapter. Merely mentioning a session ID or hook is not sufficient. +# 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"\bexecLeaseGatedRuntime\s*\("), - re.compile(r"\blaunch-runtime\.(?:sh|py)\b"), + re.compile(r"(?:^\s*|=>\s*)execLeaseGatedRuntime\s*\("), re.compile( - r"(?:\bexec\s+|\b(?:LAUNCH_COMMAND|launch_cmd)\s*=\s*\(?|\becho\s+[\"'])" + 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)[\"'])"), @@ -67,6 +75,17 @@ DIRECT_PATTERNS: Final = ( 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, ) @@ -89,27 +108,215 @@ def is_test_path(path: Path) -> bool: ) -def is_gated_line(line: str) -> bool: - return any(pattern.search(line) for pattern in GATED_PATTERNS) +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_direct_line(line: str) -> bool: - return any(pattern.search(line) for pattern in DIRECT_PATTERNS) +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 is_shell_direct_invocation(path: Path, line: str) -> bool: + if path.suffix.lower() not in SHELL_SUFFIXES: + return False + 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 + for command in shell_commands(command_source): + index = 0 + while index < len(command) and assignment.match(command[index]): + index += 1 + while index < len(command) and command[index] in {"command", "exec", "nohup"}: + index += 1 + if index < len(command) and command[index] == "env": + index += 1 + while index < len(command) and ( + command[index].startswith("-") or assignment.match(command[index]) + ): + index += 1 + while index < len(command) and command[index] in {"command", "exec", "nohup"}: + index += 1 + if index < len(command) and Path(command[index]).name in {"claude", "pi"}: + return True + return False + + +def is_direct_line(path: Path, line: str) -> bool: + return is_shell_direct_invocation(path, line) or any( + pattern.search(line) for pattern in DIRECT_PATTERNS + ) + + +def executes_runtime_variable(line: str, runtime_variables: set[str]) -> bool: + for variable in runtime_variables: + reference = rf"\$(?:{re.escape(variable)}|\{{{re.escape(variable)}\}})" + if re.search(rf"\beval\s+[\"']?{reference}", line): + return True + if re.match(rf"^\s*[\"']?{reference}[\"']?(?:\s|$)", 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) or executes_runtime_variable( + 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]: - violations: list[LaunchSite] = [] - gated_continuation = False - for line_number, line in enumerate(source.splitlines(), start=1): - stripped = line.strip() - if not stripped or stripped.startswith(("#", "//", "*")): - gated_continuation = False - continue - gated = is_gated_line(line) or gated_continuation - if is_direct_line(line) and not gated: - violations.append(LaunchSite(path, line_number, line.rstrip(), "direct")) - gated_continuation = gated and line.rstrip().endswith("\\") - return violations + return [site for site in classify_text(path, source) if site.classification != "gated"] def source_files(root: Path): @@ -143,27 +350,12 @@ def inventory_repository(root: Path) -> list[LaunchSite]: inventory: list[LaunchSite] = [] for path in source_files(root): try: - lines = path.read_text(encoding="utf-8").splitlines() + source = path.read_text(encoding="utf-8") except UnicodeDecodeError: inventory.append(LaunchSite(path.relative_to(root), 0, "non-UTF-8 source", "unscannable")) continue - gated_continuation = False - for line_number, line in enumerate(lines, start=1): - stripped = line.strip() - if not stripped or stripped.startswith(("#", "//", "*")): - gated_continuation = False - continue - line_is_gated = is_gated_line(line) - gated = line_is_gated or gated_continuation - if line_is_gated and not stripped.startswith("function execLeaseGatedRuntime"): - inventory.append( - LaunchSite(path.relative_to(root), line_number, line.rstrip(), "gated") - ) - elif is_direct_line(line) and not gated: - inventory.append( - LaunchSite(path.relative_to(root), line_number, line.rstrip(), "direct") - ) - gated_continuation = gated and line.rstrip().endswith("\\") + relative_path = path.relative_to(root) + inventory.extend(classify_text(relative_path, source)) return inventory diff --git a/packages/mosaic/framework/tools/lease-broker/launch-runtime.py b/packages/mosaic/framework/tools/lease-broker/launch-runtime.py index b68de1b4..780f9588 100644 --- a/packages/mosaic/framework/tools/lease-broker/launch-runtime.py +++ b/packages/mosaic/framework/tools/lease-broker/launch-runtime.py @@ -14,6 +14,7 @@ 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]: @@ -46,6 +47,7 @@ def main( ) -> 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 @@ -54,6 +56,11 @@ def main( 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: diff --git a/packages/mosaic/framework/tools/prdy/prdy-init.sh b/packages/mosaic/framework/tools/prdy/prdy-init.sh index c86855ef..74f4219f 100755 --- a/packages/mosaic/framework/tools/prdy/prdy-init.sh +++ b/packages/mosaic/framework/tools/prdy/prdy-init.sh @@ -86,8 +86,8 @@ echo "" cd "$PROJECT" if [[ "$RUNTIME_CMD" == "claude" ]]; then - exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --runtime claude -- \ - 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 diff --git a/packages/mosaic/framework/tools/prdy/prdy-update.sh b/packages/mosaic/framework/tools/prdy/prdy-update.sh index 19a3e3fc..146dd2f5 100755 --- a/packages/mosaic/framework/tools/prdy/prdy-update.sh +++ b/packages/mosaic/framework/tools/prdy/prdy-update.sh @@ -74,8 +74,8 @@ echo "" cd "$PROJECT" if [[ "$RUNTIME_CMD" == "claude" ]]; then - exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --runtime claude -- \ - 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 diff --git a/packages/mosaic/src/commands/claudex.spec.ts b/packages/mosaic/src/commands/claudex.spec.ts index 65a13057..383c3b98 100644 --- a/packages/mosaic/src/commands/claudex.spec.ts +++ b/packages/mosaic/src/commands/claudex.spec.ts @@ -54,7 +54,10 @@ function okAdapter(overrides: TestAdapterOverrides = {}): ClaudexHarnessAdapter return { harnessPreflight: () => {}, composePrompt: () => '# Composed Claude contract', - execLeaseGated: adapterOverrides.execLeaseGated ?? ((args, env) => exec?.('claude', args, env)), + execLeaseGated: + adapterOverrides.execLeaseGated ?? + ((args, env, dangerous) => + exec?.('claude', dangerous ? ['--dangerously-skip-permissions', ...args] : args, env)), ...adapterOverrides, }; } diff --git a/packages/mosaic/src/commands/claudex.ts b/packages/mosaic/src/commands/claudex.ts index 5fdc5c63..6f81a442 100644 --- a/packages/mosaic/src/commands/claudex.ts +++ b/packages/mosaic/src/commands/claudex.ts @@ -475,7 +475,7 @@ export interface ClaudexHarnessAdapter { /** Compose the full Claude runtime contract (== `composeContract('claude')`). */ composePrompt: () => string; /** Register the Claude parent with the broker, then exec with the same PID. */ - execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) => void; + execLeaseGated: (args: string[], env: NodeJS.ProcessEnv, dangerous: boolean) => void; } export interface LaunchClaudexDeps { @@ -533,9 +533,8 @@ export async function launchClaudex( log(buildClaudexBanner(resolvedModels)); - const cliArgs = yolo ? ['--dangerously-skip-permissions'] : []; - cliArgs.push('--append-system-prompt', prompt, ...args); - adapter.execLeaseGated(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)}`, diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index 2cc78d4a..2a696820 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -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}...`); - execLeaseGatedRuntime('claude', cliArgs); + execLeaseGatedRuntime('claude', cliArgs, process.env, yolo); break; } @@ -818,13 +818,19 @@ function execLeaseGatedRuntime( runtime: 'claude' | 'pi', args: string[], baseEnv: NodeJS.ProcessEnv = process.env, + dangerous = false, ): void { const launcher = resolveTool('lease-broker', 'launch-runtime.py'); - execRuntime('python3', [launcher, '--runtime', runtime, '--', runtime, ...args], { - ...baseEnv, - MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv), - MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1', - }); + 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. */ @@ -860,7 +866,8 @@ function launchClaudexProduction(args: string[], yolo: boolean): void { checkSequentialThinking('claude'); }, composePrompt: () => buildRuntimePrompt('claude'), - execLeaseGated: (cmdArgs, env) => execLeaseGatedRuntime('claude', cmdArgs, env), + execLeaseGated: (cmdArgs, env, dangerous) => + execLeaseGatedRuntime('claude', cmdArgs, env, dangerous), }; void launchClaudex(args, yolo, adapter); } diff --git a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts index 903900f6..3419cbde 100644 --- a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts +++ b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts @@ -546,8 +546,20 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1) harnessPreflight: () => {}, composePrompt: () => '# composed Claude contract', // Claudex exposes only the shared register-before-exec boundary. - execLeaseGated: (args: string[], env: NodeJS.ProcessEnv) => - run('python3', [launcherPath, '--runtime', 'claude', '--', 'claude', ...args], env), + 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, { diff --git a/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py index 6c63d038..e049b2c2 100644 --- a/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py +++ b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py @@ -56,6 +56,9 @@ class RuntimeLaunchGuardTest(unittest.TestCase): "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): @@ -73,6 +76,28 @@ class RuntimeLaunchGuardTest(unittest.TestCase): with self.subTest(filename=filename): self.assertEqual(GUARD.scan_text(Path(filename), 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_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"), []) @@ -122,7 +147,9 @@ class RuntimeLaunchGuardTest(unittest.TestCase): root = Path(directory) source = root / "packages/example/launch.sh" source.parent.mkdir(parents=True) - source.write_text("exec claude -p prompt\n") + 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): -- 2.49.1 From 91a4a98370d512dca4795719d4447137746cd8d5 Mon Sep 17 00:00:00 2001 From: ms-lead-reviewer Date: Sat, 18 Jul 2026 00:30:00 -0500 Subject: [PATCH 12/13] test(#829): regress prefixed runtime variable launches --- docs/scratchpads/829-mutator-gate.md | 6 ++++++ .../runtime_launch_guard_unittest.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/docs/scratchpads/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md index 858705dd..5bcbbdc5 100644 --- a/docs/scratchpads/829-mutator-gate.md +++ b/docs/scratchpads/829-mutator-gate.md @@ -115,3 +115,9 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE - 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. diff --git a/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py index e049b2c2..4e8a372f 100644 --- a/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py +++ b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py @@ -76,6 +76,22 @@ class RuntimeLaunchGuardTest(unittest.TestCase): 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', -- 2.49.1 From 04cc065c2babae5bcc04b932d89d0bc3ff8546df Mon Sep 17 00:00:00 2001 From: ms-lead-reviewer Date: Sat, 18 Jul 2026 00:39:13 -0500 Subject: [PATCH 13/13] fix(#829): guard prefixed runtime variable launches --- docs/architecture/mutator-class-gate.md | 12 +++- docs/scratchpads/829-mutator-gate.md | 7 ++ .../lease-broker/check-runtime-launches.py | 69 +++++++++++++------ .../mutator-gate.acceptance.spec.ts | 34 +++++++++ .../runtime_launch_guard_unittest.py | 9 +++ 5 files changed, 108 insertions(+), 23 deletions(-) diff --git a/docs/architecture/mutator-class-gate.md b/docs/architecture/mutator-class-gate.md index 2a64d6f6..3624f19d 100644 --- a/docs/architecture/mutator-class-gate.md +++ b/docs/architecture/mutator-class-gate.md @@ -39,9 +39,17 @@ The executable submits the runtime's actual tool name to `authorize_tool`. Missi 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. 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. +`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. -Both checks are load-bearing: command-position analysis covers consequential launches that do not use dangerous mode, while primitive ownership cannot be fooled by a comment or marker string for dangerous mode. The guard is mandatory in `@mosaicstack/mosaic`'s test script, so root CI fails on a future bypass. Permanent regressions cover the original add-ungated effectiveness probe, comments, strings, assignments, heredocs, continuations, command chains, command substitution, `eval`, and variable execution; real-socket tests prove PRDY init/update and QA receive broker sessions and deny an unverified mutator. +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: diff --git a/docs/scratchpads/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md index 5bcbbdc5..2c9c1f35 100644 --- a/docs/scratchpads/829-mutator-gate.md +++ b/docs/scratchpads/829-mutator-gate.md @@ -121,3 +121,10 @@ Carried authority chain also verified/read for the locked T-B gate contract: SPE - 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. diff --git a/packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py b/packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py index 7888e21b..68392c9d 100644 --- a/packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py +++ b/packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py @@ -79,7 +79,7 @@ DIRECT_PATTERNS: Final = ( 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|[\"']|$)" + 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*" @@ -226,44 +226,71 @@ def is_gated_line(path: Path, line: str) -> bool: ) -def is_shell_direct_invocation(path: Path, line: str) -> bool: +def shell_command_tokens(path: Path, line: str) -> list[str]: if path.suffix.lower() not in SHELL_SUFFIXES: - return False + 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 - while index < len(command) and command[index] in {"command", "exec", "nohup"}: - index += 1 - if index < len(command) and command[index] == "env": - index += 1 - while index < len(command) and ( - command[index].startswith("-") or assignment.match(command[index]) - ): + # 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 - while index < len(command) and command[index] in {"command", "exec", "nohup"}: + continue + if command[index] == "env": index += 1 - if index < len(command) and Path(command[index]).name in {"claude", "pi"}: - return True - return False + 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 is_direct_line(path: Path, line: str) -> bool: - return is_shell_direct_invocation(path, line) or any( +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(line: str, runtime_variables: set[str]) -> bool: +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 - if re.match(rf"^\s*[\"']?{reference}[\"']?(?:\s|$)", line): - return True return False @@ -293,8 +320,8 @@ def classify_text(path: Path, source: str) -> list[LaunchSite]: 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) or executes_runtime_variable( - line, runtime_variables + 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) diff --git a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts index 3419cbde..932eab19 100644 --- a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts +++ b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts @@ -24,6 +24,7 @@ interface BrokerPaths { 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'); @@ -282,6 +283,39 @@ describe('whole mutator-class lease gate', () => { }); }); + 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); diff --git a/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py index 4e8a372f..f84c81bd 100644 --- a/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py +++ b/packages/mosaic/src/mutator-gate/runtime_launch_guard_unittest.py @@ -114,6 +114,15 @@ class RuntimeLaunchGuardTest(unittest.TestCase): 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"), []) -- 2.49.1