test(#829): define whole mutator-class gate contract
This commit is contained in:
45
docs/scratchpads/829-mutator-gate.md
Normal file
45
docs/scratchpads/829-mutator-gate.md
Normal file
@@ -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.
|
||||||
318
packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts
Normal file
318
packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts
Normal file
@@ -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<BrokerReply> {
|
||||||
|
return await new Promise<BrokerReply>((resolve, reject) => {
|
||||||
|
const socket = createConnection(socketPath);
|
||||||
|
let response = '';
|
||||||
|
socket.setEncoding('utf8');
|
||||||
|
socket.once('error', reject);
|
||||||
|
socket.on('data', (chunk: string) => {
|
||||||
|
response += chunk;
|
||||||
|
});
|
||||||
|
socket.once('end', () => resolve(JSON.parse(response) as BrokerReply));
|
||||||
|
socket.once('connect', () => socket.end(`${JSON.stringify(requestValue)}\n`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startBroker(): Promise<BrokerPaths> {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'mosaic-mutator-gate-'));
|
||||||
|
await chmod(root, 0o700);
|
||||||
|
const socket = join(root, 'broker.sock');
|
||||||
|
const child = spawn(
|
||||||
|
'python3',
|
||||||
|
[daemonPath, '--socket', socket, '--state', join(root, 'state.json')],
|
||||||
|
{
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
children.push(child);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr?.setEncoding('utf8');
|
||||||
|
child.stderr?.on('data', (chunk: string) => (stderr += chunk));
|
||||||
|
child.once('error', reject);
|
||||||
|
child.once('exit', (code: number | null) =>
|
||||||
|
reject(new Error(`broker exited ${code}: ${stderr}`)),
|
||||||
|
);
|
||||||
|
child.stdout?.once('data', () => resolve());
|
||||||
|
});
|
||||||
|
return { socket };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(socket: string, runtime_generation = 1): Promise<string> {
|
||||||
|
const reply = await request(socket, { action: 'register_anchor', runtime_generation });
|
||||||
|
expect(reply.ok).toBe(true);
|
||||||
|
expect(reply.session_id).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
return reply.session_id!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function beginVerification(
|
||||||
|
socket: string,
|
||||||
|
session_id: string,
|
||||||
|
runtime: 'claude' | 'pi',
|
||||||
|
runtime_generation = 1,
|
||||||
|
ttl_seconds = 300,
|
||||||
|
compactionEpoch = 1,
|
||||||
|
): Promise<BrokerReply> {
|
||||||
|
return await request(socket, {
|
||||||
|
action: 'begin_verification',
|
||||||
|
session_id,
|
||||||
|
runtime_generation,
|
||||||
|
runtime,
|
||||||
|
ttl_seconds,
|
||||||
|
binding: binding(compactionEpoch),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promote(
|
||||||
|
socket: string,
|
||||||
|
session_id: string,
|
||||||
|
promotion_token: string,
|
||||||
|
runtime_generation = 1,
|
||||||
|
): Promise<BrokerReply> {
|
||||||
|
return await request(socket, {
|
||||||
|
action: 'promote_lease',
|
||||||
|
session_id,
|
||||||
|
runtime_generation,
|
||||||
|
promotion_token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authorize(
|
||||||
|
socket: string,
|
||||||
|
session_id: string,
|
||||||
|
runtime: 'claude' | 'pi',
|
||||||
|
tool_name: string,
|
||||||
|
runtime_generation = 1,
|
||||||
|
): Promise<BrokerReply> {
|
||||||
|
return await request(socket, {
|
||||||
|
action: 'authorize_tool',
|
||||||
|
session_id,
|
||||||
|
runtime_generation,
|
||||||
|
runtime,
|
||||||
|
tool_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runRuntimeGate(
|
||||||
|
socket: string,
|
||||||
|
sessionId: string,
|
||||||
|
runtime: 'claude' | 'pi',
|
||||||
|
toolName: string,
|
||||||
|
generation = 1,
|
||||||
|
) {
|
||||||
|
return spawnSync('python3', [gatePath, '--runtime', runtime], {
|
||||||
|
input: `${JSON.stringify({ tool_name: toolName })}\n`,
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||||
|
MOSAIC_LEASE_SESSION_ID: sessionId,
|
||||||
|
MOSAIC_RUNTIME_GENERATION: String(generation),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user