WI-3 (#830): compaction observers → revoke + D4 same-PID generation auto-revoke (#842)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

This commit was merged in pull request #842.
This commit is contained in:
2026-07-19 19:46:28 +00:00
parent 8dfcf1903e
commit e4d7d4502d
21 changed files with 1278 additions and 42 deletions

View File

@@ -22,12 +22,16 @@ interface BrokerPaths {
}
const frameworkRoot = new URL('../../framework/', import.meta.url).pathname;
const repositoryRoot = new URL('../../../../', 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 revokerPath = join(frameworkRoot, 'tools/lease-broker/revoke-lease.py');
const compactionThreatPath = join(repositoryRoot, 'docs/architecture/compaction-revocation.md');
const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json');
const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts');
const piLifecyclePath = join(frameworkRoot, 'runtime/pi/lease-lifecycle.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');
@@ -393,6 +397,217 @@ describe('whole mutator-class lease gate', () => {
).toMatchObject({ ok: false, code: 'INVALID_TOOL' });
});
test('T12b/T30 reports the dual-observer-miss residual within and after TTL', 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!);
// Intentionally invoke neither compaction observer: this is the amended
// D2-v5 bounded residual, not a fail-closed path.
const withinTtl = await authorize(socket, sessionId, 'claude', 'Bash');
expect(withinTtl).toMatchObject({ ok: true, decision: 'allow', state: 'VERIFIED' });
console.info('T12b/T30 dual-hook-miss within-TTL: ALLOWED (bounded residual stale window)');
await new Promise((resolve) => setTimeout(resolve, 1_100));
const afterTtl = await authorize(socket, sessionId, 'claude', 'Bash');
expect(afterTtl).toMatchObject({
ok: false,
code: 'LEASE_EXPIRED',
decision: 'deny',
});
console.info('T12b/T30 dual-hook-miss after-TTL: DENIED (lease expiry)');
const threatContract = await readFile(compactionThreatPath, 'utf8');
expect(threatContract).toContain('BOUNDED RESIDUAL STALE WINDOW');
expect(threatContract).toContain('within-TTL consequential actions are allowed');
expect(threatContract).toContain('bounded by lease expiry, not by the mutator gate');
});
test.each([
{ observer: 'Claude PreCompact', reason: 'pre-compact' },
{ observer: 'Claude SessionStart(compact)', reason: 'session-start-compact' },
])('$observer revokes a verified lease through the broker path', async ({ reason }) => {
const { socket } = await startBroker();
const sessionId = await register(socket);
const pending = await beginVerification(socket, sessionId, 'claude');
await promote(socket, sessionId, pending.promotion_token!);
const revoked = spawnSync('python3', [revokerPath, '--runtime', 'claude', '--reason', reason], {
encoding: 'utf8',
env: {
...process.env,
MOSAIC_LEASE_BROKER_SOCKET: socket,
MOSAIC_LEASE_SESSION_ID: sessionId,
MOSAIC_RUNTIME_GENERATION: '1',
},
});
expect(revoked.status, revoked.stderr).toBe(0);
expect(await authorize(socket, sessionId, 'claude', 'Write')).toMatchObject({
ok: false,
code: 'MUTATOR_UNVERIFIED',
decision: 'deny',
});
});
test('promote-lease-lost-ACK orphaned VERIFIED lease is caught by observer revoke and by monotonic TTL expiry (D2-v5 backstop)', async () => {
const { socket } = await startBroker();
const observerSessionId = await register(socket);
const observerPending = await beginVerification(socket, observerSessionId, 'claude');
await promote(socket, observerSessionId, observerPending.promotion_token!);
expect(await authorize(socket, observerSessionId, 'claude', 'Bash')).toMatchObject({
ok: true,
decision: 'allow',
state: 'VERIFIED',
});
const retriedPromotion = await promote(
socket,
observerSessionId,
observerPending.promotion_token!,
);
expect(retriedPromotion.ok).toBe(false);
expect(['PROMOTION_TOKEN_MISMATCH', 'INVALID_LEASE_TRANSITION']).toContain(
retriedPromotion.code,
);
const revoked = spawnSync(
'python3',
[revokerPath, '--runtime', 'claude', '--reason', 'session-start-compact'],
{
encoding: 'utf8',
env: {
...process.env,
MOSAIC_LEASE_BROKER_SOCKET: socket,
MOSAIC_LEASE_SESSION_ID: observerSessionId,
MOSAIC_RUNTIME_GENERATION: '1',
},
},
);
expect(revoked.status, revoked.stderr).toBe(0);
expect(await authorize(socket, observerSessionId, 'claude', 'Write')).toMatchObject({
ok: false,
code: 'MUTATOR_UNVERIFIED',
decision: 'deny',
});
const { socket: expirySocket } = await startBroker();
const expirySessionId = await register(expirySocket);
expect(expirySessionId).not.toBe(observerSessionId);
const expiryPending = await beginVerification(expirySocket, expirySessionId, 'claude', 1, 1);
await promote(expirySocket, expirySessionId, expiryPending.promotion_token!);
expect(await authorize(expirySocket, expirySessionId, 'claude', 'Bash')).toMatchObject({
ok: true,
decision: 'allow',
state: 'VERIFIED',
});
await new Promise((resolve) => setTimeout(resolve, 1_100));
expect(await authorize(expirySocket, expirySessionId, 'claude', 'Bash')).toMatchObject({
ok: false,
code: 'LEASE_EXPIRED',
decision: 'deny',
});
});
test('a fired observer fences the old lease even while broker transport is unavailable', async () => {
const { socket } = await startBroker();
const sessionId = await register(socket);
const pending = await beginVerification(socket, sessionId, 'claude');
await promote(socket, sessionId, pending.promotion_token!);
const root = await mkdtemp(join(tmpdir(), 'mosaic-observer-fence-'));
temporaryRoots.push(root);
const generationFile = join(root, 'runtime.generation');
await writeFile(generationFile, '1\n', { mode: 0o600 });
const failedObserver = spawnSync(
'python3',
[revokerPath, '--runtime', 'claude', '--reason', 'session-start-compact'],
{
encoding: 'utf8',
env: {
...process.env,
MOSAIC_LEASE_BROKER_SOCKET: join(root, 'unavailable.sock'),
MOSAIC_LEASE_SESSION_ID: sessionId,
MOSAIC_RUNTIME_GENERATION: '1',
MOSAIC_LEASE_GENERATION_FILE: generationFile,
},
},
);
expect(failedObserver.status).toBe(2);
expect(await readFile(generationFile, 'utf8')).toBe('2\n');
expect(await authorize(socket, sessionId, 'claude', 'Write', 2)).toMatchObject({
ok: false,
code: 'MUTATOR_UNVERIFIED',
decision: 'deny',
});
});
test('same-PID runtime-generation bump revokes the prior incarnation automatically', async () => {
const { socket } = await startBroker();
const sessionId = await register(socket);
const pending = await beginVerification(socket, sessionId, 'pi');
await promote(socket, sessionId, pending.promotion_token!);
const anchorPid = process.pid;
const root = await mkdtemp(join(tmpdir(), 'mosaic-generation-bump-'));
temporaryRoots.push(root);
const generationFile = join(root, 'runtime.generation');
await writeFile(generationFile, '1\n', { mode: 0o600 });
const bumped = spawnSync(
'python3',
[revokerPath, '--runtime', 'pi', '--reason', 'session-start-resume', '--bump-generation'],
{
encoding: 'utf8',
env: {
...process.env,
MOSAIC_LEASE_BROKER_SOCKET: socket,
MOSAIC_LEASE_SESSION_ID: sessionId,
MOSAIC_RUNTIME_GENERATION: '1',
MOSAIC_LEASE_GENERATION_FILE: generationFile,
},
},
);
expect(bumped.status, bumped.stderr).toBe(0);
expect(await readFile(generationFile, 'utf8')).toBe('2\n');
expect(process.pid).toBe(anchorPid);
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 compaction observer wiring is complete and fail-closed', async () => {
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
hooks: Record<string, Array<{ matcher?: string; hooks: Array<{ command: string }> }>>;
};
expect(
settings.hooks['PreCompact']?.some((entry) =>
entry.hooks.some((hook) => hook.command.includes('revoke-lease.py')),
),
).toBe(true);
expect(
settings.hooks['SessionStart']?.some(
(entry) =>
entry.matcher === 'compact' &&
entry.hooks.some((hook) => hook.command.includes('revoke-lease.py')),
),
).toBe(true);
const piExtension = await readFile(piExtensionPath, 'utf8');
const piLifecycle = await readFile(piLifecyclePath, 'utf8');
expect(piExtension).toContain('registerLeaseLifecycleHooks');
expect(piLifecycle).toContain("pi.on('session_before_compact'");
expect(piLifecycle).toContain("pi.on('session_compact'");
expect(piLifecycle).toContain("pi.on('context'");
expect(piLifecycle).toContain('--bump-generation');
});
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
const { socket } = await startBroker();
const sessionId = await register(socket);
@@ -542,6 +757,12 @@ 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
)
pre_compact = settings.get("hooks", {}).get("PreCompact", [])
session_start = settings.get("hooks", {}).get("SessionStart", [])
observers_present = (
any(any("revoke-lease.py" in hook.get("command", "") for hook in item.get("hooks", [])) for item in pre_compact)
and any(item.get("matcher") == "compact" and any("revoke-lease.py" in hook.get("command", "") for hook in item.get("hooks", [])) for item in session_start)
)
denied = subprocess.run(
["python3", ${JSON.stringify(gatePath)}, "--runtime", "claude"],
input=json.dumps({"tool_name": "Bash"}) + "\\n",
@@ -553,11 +774,12 @@ is_yolo = "--dangerously-skip-permissions" in sys.argv[1:]
result = {
"session_id": session_id,
"hook_present": hook_present,
"observers_present": observers_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)
raise SystemExit(0 if len(session_id) == 64 and hook_present and observers_present and denied else 1)
`;
await writeFile(fakeClaude, probe, { mode: 0o700 });
await chmod(fakeClaude, 0o700);
@@ -621,6 +843,7 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1)
expect(JSON.parse(String(execution!.stdout))).toEqual({
session_id: expect.stringMatching(/^[a-f0-9]{64}$/),
hook_present: true,
observers_present: true,
denied: true,
is_yolo: yolo,
});