diff --git a/docs/scratchpads/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md new file mode 100644 index 0000000..d933a95 --- /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 0000000..f9c5fc3 --- /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'); + }); +});