test(#829): define whole mutator-class gate contract

This commit is contained in:
ms-lead-reviewer
2026-07-17 22:23:38 -05:00
parent 8ec67a1126
commit 74fc62e4ee
2 changed files with 363 additions and 0 deletions

View 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');
});
});