chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* C-REGRESS (issue #869, Point-1) — proves the fail-closed gate is untouched
|
||||
* by the C1 activation probe added alongside this test.
|
||||
*
|
||||
* `mutator-gate.py`'s fail-closed-on-absent-identity behavior is INTENTIONAL
|
||||
* and TEST-LOCKED: #869 C1 gates the WIRING decision for enforcement (via
|
||||
* `leaseEnforcementActivatable()`), it does not — and must not — touch the
|
||||
* gate's own runtime denial behavior. This spec runs the two test-locked
|
||||
* cases from `runtime_tools_unittest.py` directly (rather than merely
|
||||
* re-asserting the same logic in TypeScript) so a regression in the actual
|
||||
* Python gate is caught here too, not just documented in prose.
|
||||
*/
|
||||
|
||||
const MUTATOR_GATE_DIR = new URL('.', import.meta.url).pathname;
|
||||
const UNITTEST_FILE = join(MUTATOR_GATE_DIR, 'runtime_tools_unittest.py');
|
||||
|
||||
const LOCKED_TEST_CASES = [
|
||||
'ExecutableEntrypointTest.test_gate_entrypoint_denies_when_identity_environment_is_absent',
|
||||
'MutatorGateTest.test_environment_generation_and_request_failures_deny',
|
||||
] as const;
|
||||
|
||||
describe('mutator-gate fail-closed behavior (C-REGRESS, unchanged by #869 C1)', () => {
|
||||
it.each(LOCKED_TEST_CASES)('%s still passes', (testCase) => {
|
||||
const result = spawnSync('python3', ['-m', 'unittest', `${moduleName()}.${testCase}`, '-v'], {
|
||||
cwd: MUTATOR_GATE_DIR,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
expect(result.status, `stderr:\n${result.stderr}`).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function moduleName(): string {
|
||||
// runtime_tools_unittest.py, addressed as a bare module name for `python3 -m unittest`.
|
||||
return UNITTEST_FILE.split('/').pop()!.replace(/\.py$/, '');
|
||||
}
|
||||
@@ -0,0 +1,977 @@
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
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';
|
||||
|
||||
import { launchClaudex, type ClaudexHarnessAdapter } from '../commands/claudex.js';
|
||||
import {
|
||||
observeAndPromoteReceiptChallenge,
|
||||
requestBrokerReply,
|
||||
} from '../lease-broker/broker-test-client.js';
|
||||
|
||||
interface BrokerReply {
|
||||
ok: boolean;
|
||||
code?: string;
|
||||
decision?: 'allow' | 'deny';
|
||||
state?: 'UNVERIFIED' | 'PENDING_VERIFICATION' | 'PENDING_PROMOTION' | 'VERIFIED';
|
||||
session_id?: string;
|
||||
receipt_challenge?: string;
|
||||
receipt?: string;
|
||||
}
|
||||
|
||||
interface PendingReceiptCycle {
|
||||
sessionId: string;
|
||||
runtimeGeneration: number;
|
||||
receiptChallenge: string;
|
||||
receipt: string;
|
||||
}
|
||||
|
||||
interface BrokerPaths {
|
||||
socket: string;
|
||||
observerFixture: string;
|
||||
}
|
||||
|
||||
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/DEVELOPER-GUIDE/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');
|
||||
|
||||
// C4 (#869 Point-1): launch-runtime.py now asserts, before anything else,
|
||||
// that the CLI's advertised lease-activation capability (normally read via
|
||||
// the hidden `mosaic __lease-capability` subcommand) matches what
|
||||
// enforcement expects — see framework/tools/lease-broker/
|
||||
// activation_version_gate.py. This suite drives launch-runtime.py directly
|
||||
// as a subprocess (never through the real `mosaic` CLI), so — exactly like
|
||||
// the fake broker (daemon.py) and fake `claude` binaries already used
|
||||
// below — it must supply a fake activation-capability probe rather than
|
||||
// depend on a real `mosaic` binary being on PATH. `MOSAIC_LEASE_VERSION_PROBE_COMMAND`
|
||||
// is launch-runtime.py's injection point for that fake; this literal
|
||||
// {name, version} pair must be kept in sync with
|
||||
// `EXPECTED_ACTIVATION_CAPABILITY` (activation_version_gate.py) and
|
||||
// `LEASE_ACTIVATION_CAPABILITY` (lease-activation-probe.ts) — all three
|
||||
// currently agree on v1.
|
||||
const leaseCapabilityProbeStub = `python3 -c "import json; print(json.dumps({'name': 'lease-runtime-activation', 'version': 1}))"`;
|
||||
const children: ChildProcess[] = [];
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
const construction = {
|
||||
manifest_version: 1,
|
||||
generator_version: 'mutator-gate-acceptance',
|
||||
fragments: [
|
||||
{
|
||||
source_id: 'authority/mutator-gate-acceptance',
|
||||
content_base64: 'bXV0YXRvci1nYXRlIGFjY2VwdGFuY2UK',
|
||||
expected_sha256: 'cc3de191821d48037f60b4d006fce74b5dd394d39fb5c8bf681c28889ff0e623',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const binding = (compaction_epoch = 1) => ({
|
||||
compaction_epoch,
|
||||
request_epoch: 0,
|
||||
h_source: '3c8fc6733d6a2bdc001ed9277d636d7cfac037ae48ed7d54203180a3839dc7a6',
|
||||
h_payload: '42da889037c29c3a41397df0f86465ffd121bdb8cd18ad0d7c3df259ab502d3e',
|
||||
schema_version: 1,
|
||||
});
|
||||
|
||||
const pendingReceiptCycles = new Map<string, PendingReceiptCycle>();
|
||||
const observerFixtures = new Map<string, string>();
|
||||
|
||||
async function request(socketPath: string, requestValue: object): Promise<BrokerReply> {
|
||||
return await requestBrokerReply<BrokerReply>(socketPath, requestValue);
|
||||
}
|
||||
|
||||
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 observerFixture = join(root, 'test-observer.json');
|
||||
const child = spawn(
|
||||
'python3',
|
||||
[
|
||||
daemonPath,
|
||||
'--socket',
|
||||
socket,
|
||||
'--state',
|
||||
join(root, 'state.json'),
|
||||
'--test-observer-file',
|
||||
observerFixture,
|
||||
],
|
||||
{
|
||||
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());
|
||||
});
|
||||
observerFixtures.set(socket, observerFixture);
|
||||
return { socket, observerFixture };
|
||||
}
|
||||
|
||||
interface RuntimeLaunchEntry {
|
||||
name: string;
|
||||
script: string;
|
||||
prepare(root: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
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 ?? ''}`,
|
||||
// Point MOSAIC_HOME at the per-entry temp root, NOT the shipped framework
|
||||
// tree: launch-runtime.py appends its launch ledger to
|
||||
// $MOSAIC_HOME/fleet/run/sessions/events.ndjson, and writing that into
|
||||
// framework/ pollutes the tree manifest.spec.ts walks. Nothing in the
|
||||
// launch chain resolves tools via MOSAIC_HOME (scripts use SCRIPT_DIR).
|
||||
MOSAIC_HOME: root,
|
||||
MOSAIC_PRDY_RUNTIME: 'claude',
|
||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||
MOSAIC_RUNTIME_GENERATION: '1',
|
||||
MOSAIC_LEASE_VERSION_PROBE_COMMAND: leaseCapabilityProbeStub,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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> {
|
||||
const reply = await request(socket, {
|
||||
action: 'begin_verification',
|
||||
session_id,
|
||||
runtime_generation,
|
||||
runtime,
|
||||
ttl_seconds,
|
||||
binding: binding(compactionEpoch),
|
||||
construction,
|
||||
});
|
||||
if (typeof reply.receipt_challenge === 'string' && typeof reply.receipt === 'string') {
|
||||
pendingReceiptCycles.set(reply.receipt_challenge, {
|
||||
sessionId: session_id,
|
||||
runtimeGeneration: runtime_generation,
|
||||
receiptChallenge: reply.receipt_challenge,
|
||||
receipt: reply.receipt,
|
||||
});
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
|
||||
async function promote(
|
||||
socket: string,
|
||||
session_id: string,
|
||||
receipt_challenge: string,
|
||||
runtime_generation = 1,
|
||||
): Promise<BrokerReply> {
|
||||
const cycle = pendingReceiptCycles.get(receipt_challenge);
|
||||
const observerFixture = observerFixtures.get(socket);
|
||||
if (cycle === undefined || observerFixture === undefined) {
|
||||
return await request(socket, {
|
||||
action: 'promote_lease',
|
||||
session_id,
|
||||
runtime_generation,
|
||||
receipt_challenge,
|
||||
});
|
||||
}
|
||||
return await observeAndPromoteReceiptChallenge(socket, observerFixture, cycle);
|
||||
}
|
||||
|
||||
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(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', () => {
|
||||
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.receipt_challenge).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Write')).toMatchObject({
|
||||
ok: false,
|
||||
decision: 'deny',
|
||||
});
|
||||
|
||||
expect(await promote(socket, sessionId, pending.receipt_challenge!)).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.receipt_challenge!)).toMatchObject({
|
||||
ok: false,
|
||||
code: 'RECEIPT_REPLAY',
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
const mutators: Array<['claude' | 'pi', string]> = [
|
||||
['claude', 'Bash'],
|
||||
['claude', 'Edit'],
|
||||
['claude', 'Write'],
|
||||
['claude', 'NotebookEdit'],
|
||||
['claude', 'mcp__provider__close_issue'],
|
||||
['claude', 'Ls'],
|
||||
['claude', 'Find'],
|
||||
['pi', 'bash'],
|
||||
['pi', 'edit'],
|
||||
['pi', 'grep'],
|
||||
['pi', 'find'],
|
||||
['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'],
|
||||
['claude', 'Glob'],
|
||||
['pi', 'read'],
|
||||
['pi', 'ls'],
|
||||
['pi', 'mosaic_context_recover'],
|
||||
] as const) {
|
||||
expect(await authorize(socket, sessionId, runtime, toolName)).toMatchObject({
|
||||
ok: true,
|
||||
decision: 'allow',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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('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.receipt_challenge!);
|
||||
|
||||
// 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.receipt_challenge!);
|
||||
|
||||
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.receipt_challenge!);
|
||||
|
||||
expect(await authorize(socket, observerSessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: true,
|
||||
decision: 'allow',
|
||||
state: 'VERIFIED',
|
||||
});
|
||||
const retriedPromotion = await promote(
|
||||
socket,
|
||||
observerSessionId,
|
||||
observerPending.receipt_challenge!,
|
||||
);
|
||||
expect(retriedPromotion.ok).toBe(false);
|
||||
expect(retriedPromotion.code).toBe('RECEIPT_REPLAY');
|
||||
|
||||
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.receipt_challenge!);
|
||||
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.receipt_challenge!);
|
||||
|
||||
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.receipt_challenge!);
|
||||
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);
|
||||
|
||||
// Establish the lease with a normal (non-racing) TTL first and prove it
|
||||
// authorizes. This "still valid" check is setup, not a TTL-expiry
|
||||
// assertion, so it must not share a lease with a 1-second TTL: on a
|
||||
// contended push-CI host, scheduling delay alone between promote() and
|
||||
// this authorize() call can consume that entire 1-second margin and
|
||||
// spuriously deny it (CI#1945). Using a generous TTL here removes that
|
||||
// real-time race without touching lease-gate security semantics.
|
||||
const pending = await beginVerification(socket, sessionId, 'claude');
|
||||
await promote(socket, sessionId, pending.receipt_challenge!);
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: true,
|
||||
decision: 'allow',
|
||||
});
|
||||
|
||||
// A dedicated, isolated short-TTL lease drives the deliberate monotonic
|
||||
// expiry demonstration below. It is never used for anything but the
|
||||
// wait-then-expire assertion, so there is no setup work racing its
|
||||
// 1-second window.
|
||||
const shortLived = await beginVerification(socket, sessionId, 'claude', 1, 1, 2);
|
||||
await promote(socket, sessionId, shortLived.receipt_challenge!);
|
||||
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, 3);
|
||||
await promote(socket, sessionId, refreshed.receipt_challenge!);
|
||||
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.receipt_challenge!);
|
||||
|
||||
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('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',
|
||||
MOSAIC_LEASE_VERSION_PROBE_COMMAND: leaseCapabilityProbeStub,
|
||||
},
|
||||
},
|
||||
);
|
||||
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',
|
||||
MOSAIC_LEASE_VERSION_PROBE_COMMAND: leaseCapabilityProbeStub,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(unavailable.status).not.toBe(0);
|
||||
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 },
|
||||
])(
|
||||
'$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
|
||||
)
|
||||
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",
|
||||
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,
|
||||
"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 observers_present and denied else 1)
|
||||
`;
|
||||
await writeFile(fakeClaude, probe, { mode: 0o700 });
|
||||
await chmod(fakeClaude, 0o700);
|
||||
|
||||
let execution: ReturnType<typeof spawnSync> | undefined;
|
||||
const run = (cmd: string, args: string[], env: NodeJS.ProcessEnv) => {
|
||||
execution = spawnSync(cmd, args, { encoding: 'utf8', env });
|
||||
};
|
||||
const adapter = {
|
||||
harnessPreflight: () => {},
|
||||
composePrompt: () => '# composed Claude contract',
|
||||
// Claudex exposes only the shared register-before-exec boundary.
|
||||
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, {
|
||||
baseEnv: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||
MOSAIC_RUNTIME_GENERATION: '1',
|
||||
MOSAIC_LEASE_VERSION_PROBE_COMMAND: leaseCapabilityProbeStub,
|
||||
},
|
||||
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, String(execution!.stderr)).toBe(0);
|
||||
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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
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.receipt_challenge!);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
type LifecycleRunner = (args: string[]) => boolean;
|
||||
type LifecycleRegister = (api: unknown, runner: LifecycleRunner) => void;
|
||||
type Handler = (event: Record<string, unknown>, ctx: Record<string, unknown>) => unknown;
|
||||
|
||||
const lifecycleModuleUrl = new URL('../../framework/runtime/pi/lease-lifecycle.ts', import.meta.url)
|
||||
.href;
|
||||
const { registerLeaseLifecycleHooks } = (await import(lifecycleModuleUrl)) as {
|
||||
registerLeaseLifecycleHooks: LifecycleRegister;
|
||||
};
|
||||
|
||||
function fakePi() {
|
||||
const handlers = new Map<string, Handler[]>();
|
||||
return {
|
||||
handlers,
|
||||
api: {
|
||||
on(event: string, handler: Handler) {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
||||
},
|
||||
},
|
||||
async emit(event: string, value: Record<string, unknown> = {}) {
|
||||
const results = [];
|
||||
for (const handler of handlers.get(event) ?? []) {
|
||||
results.push(await handler(value, {}));
|
||||
}
|
||||
return results;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Pi compaction and runtime-generation lease lifecycle', () => {
|
||||
test('pre-compaction and first post-compaction context independently revoke', async () => {
|
||||
const pi = fakePi();
|
||||
const calls: string[][] = [];
|
||||
registerLeaseLifecycleHooks(pi.api as never, (args) => {
|
||||
calls.push(args);
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(await pi.emit('session_before_compact', { reason: 'threshold' })).toEqual([undefined]);
|
||||
expect(calls.at(-1)).toEqual([
|
||||
'--runtime',
|
||||
'pi',
|
||||
'--reason',
|
||||
'pi-session-before-compact:threshold',
|
||||
]);
|
||||
|
||||
await pi.emit('session_compact', { reason: 'threshold' });
|
||||
await pi.emit('context', { messages: [] });
|
||||
expect(calls.at(-1)).toEqual([
|
||||
'--runtime',
|
||||
'pi',
|
||||
'--reason',
|
||||
'pi-context-after-compact:threshold',
|
||||
]);
|
||||
const afterFirstContext = calls.length;
|
||||
await pi.emit('context', { messages: [] });
|
||||
expect(calls).toHaveLength(afterFirstContext);
|
||||
});
|
||||
|
||||
test.each(['reload', 'new', 'resume', 'fork'])(
|
||||
'%s bumps generation before reuse',
|
||||
async (reason) => {
|
||||
const pi = fakePi();
|
||||
const calls: string[][] = [];
|
||||
registerLeaseLifecycleHooks(pi.api as never, (args) => {
|
||||
calls.push(args);
|
||||
return true;
|
||||
});
|
||||
|
||||
await pi.emit('session_start', { reason });
|
||||
expect(calls).toEqual([
|
||||
['--runtime', 'pi', '--reason', `pi-session-start:${reason}`, '--bump-generation'],
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test('startup leaves the launcher generation intact and tools locally open', async () => {
|
||||
const pi = fakePi();
|
||||
const calls: string[][] = [];
|
||||
registerLeaseLifecycleHooks(pi.api as never, (args) => {
|
||||
calls.push(args);
|
||||
return true;
|
||||
});
|
||||
|
||||
await pi.emit('session_start', { reason: 'startup' });
|
||||
await pi.emit('session_start');
|
||||
expect(calls).toEqual([]);
|
||||
expect(await pi.emit('tool_call', { toolName: 'bash' })).toEqual([undefined]);
|
||||
});
|
||||
|
||||
test('failed post-compaction revoke blocks tools until context retries successfully', async () => {
|
||||
const pi = fakePi();
|
||||
const outcomes = [false, true];
|
||||
registerLeaseLifecycleHooks(pi.api as never, () => outcomes.shift() ?? true);
|
||||
|
||||
await pi.emit('session_compact');
|
||||
await pi.emit('context');
|
||||
expect(await pi.emit('tool_call', { toolName: 'bash' })).toEqual([
|
||||
{
|
||||
block: true,
|
||||
reason: expect.stringContaining('lease lifecycle revoke failed'),
|
||||
},
|
||||
]);
|
||||
|
||||
await pi.emit('context');
|
||||
expect(await pi.emit('tool_call', { toolName: 'bash' })).toEqual([undefined]);
|
||||
});
|
||||
|
||||
test('failed lifecycle revoke cancels compaction and closes later tool calls', async () => {
|
||||
const pi = fakePi();
|
||||
registerLeaseLifecycleHooks(pi.api as never, () => false);
|
||||
|
||||
const compact = await pi.emit('session_before_compact', { reason: 'manual' });
|
||||
expect(compact).toEqual([{ cancel: true }]);
|
||||
|
||||
await pi.emit('session_start', { reason: 'resume' });
|
||||
const tool = await pi.emit('tool_call', { toolName: 'bash' });
|
||||
expect(tool).toEqual([
|
||||
{
|
||||
block: true,
|
||||
reason: expect.stringContaining('lease lifecycle revoke failed'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Contract tests for the permanent consequential-runtime launch guard."""
|
||||
|
||||
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]
|
||||
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",
|
||||
"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",
|
||||
"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',
|
||||
"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",
|
||||
"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):
|
||||
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_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',
|
||||
[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_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"), [])
|
||||
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(
|
||||
violations,
|
||||
[],
|
||||
"\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:
|
||||
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 --dangerously-skip-permissions "terra-r3" # launch-runtime.py\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()
|
||||
@@ -0,0 +1,808 @@
|
||||
#!/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 os
|
||||
import runpy
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
TOOLS_DIR = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
||||
if str(TOOLS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(TOOLS_DIR))
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def matching_activation_probe(*_args: object, **_kwargs: object) -> dict[str, object]:
|
||||
"""Fake activation-capability probe matching what enforcement expects
|
||||
(C4, #869 Point-1). Injected into `LAUNCHER.main()` calls below that are
|
||||
exercising OTHER branches (registration, exec, generation init, ...) so
|
||||
the new version-coupling gate — which runs before those — never blocks
|
||||
on host state (no real `mosaic` CLI on PATH in a test sandbox). The
|
||||
version-coupling gate's OWN behavior (match/mismatch/absent) is covered
|
||||
by its dedicated red-first tests in `version_coupling_unittest.py`."""
|
||||
|
||||
return dict(LAUNCHER.EXPECTED_ACTIVATION_CAPABILITY)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
def initialize_generation(path: Path, generation: int) -> None:
|
||||
calls["generation"] = (path, generation)
|
||||
|
||||
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,
|
||||
initialize_generation=initialize_generation,
|
||||
probe_activation_capability=matching_activation_probe,
|
||||
)
|
||||
|
||||
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["MOSAIC_LEASE_GENERATION_FILE"],
|
||||
f"/run/test/generation-{session_id}.state",
|
||||
)
|
||||
self.assertEqual(
|
||||
calls["generation"],
|
||||
(Path(f"/run/test/generation-{session_id}.state"), 7),
|
||||
)
|
||||
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),
|
||||
initialize_generation=lambda *_args: None,
|
||||
probe_activation_capability=matching_activation_probe,
|
||||
)
|
||||
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(
|
||||
["--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),
|
||||
initialize_generation=lambda *_args: None,
|
||||
probe_activation_capability=matching_activation_probe,
|
||||
)
|
||||
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(
|
||||
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),
|
||||
probe_activation_capability=matching_activation_probe,
|
||||
)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertEqual(executed, [])
|
||||
|
||||
def test_generation_initialization_failure_denies_before_exec(self) -> None:
|
||||
with redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(
|
||||
LAUNCHER.main(
|
||||
["--runtime", "pi", "--", "pi"],
|
||||
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/broker"},
|
||||
request=lambda *_args: {"ok": True, "session_id": "a" * 64},
|
||||
execute=lambda *_args: self.fail("must not execute"),
|
||||
initialize_generation=lambda *_args: (_ for _ in ()).throw(
|
||||
OSError("unsafe state")
|
||||
),
|
||||
probe_activation_capability=matching_activation_probe,
|
||||
),
|
||||
1,
|
||||
)
|
||||
|
||||
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"),
|
||||
probe_activation_capability=matching_activation_probe,
|
||||
),
|
||||
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")),
|
||||
initialize_generation=lambda *_args: None,
|
||||
probe_activation_capability=matching_activation_probe,
|
||||
),
|
||||
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 ExecutableEntrypointTest(unittest.TestCase):
|
||||
def test_real_claude_and_pi_gates_fail_closed_on_empty_or_truncated_reply(self) -> None:
|
||||
for runtime in ("claude", "pi"):
|
||||
for wire_reply in (b"", b'{"ok":true'):
|
||||
with self.subTest(runtime=runtime, wire_reply=wire_reply), tempfile.TemporaryDirectory() as root:
|
||||
socket_path = Path(root) / "broker.sock"
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(str(socket_path))
|
||||
server.listen(1)
|
||||
|
||||
def serve_reply() -> None:
|
||||
with server:
|
||||
connection, _ = server.accept()
|
||||
with connection:
|
||||
while connection.recv(4096):
|
||||
pass
|
||||
if wire_reply:
|
||||
connection.sendall(wire_reply)
|
||||
|
||||
thread = threading.Thread(target=serve_reply, daemon=True)
|
||||
thread.start()
|
||||
environment = {
|
||||
**os.environ,
|
||||
"MOSAIC_LEASE_BROKER_SOCKET": str(socket_path),
|
||||
"MOSAIC_LEASE_SESSION_ID": "d" * 64,
|
||||
"MOSAIC_RUNTIME_GENERATION": "1",
|
||||
}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(TOOLS_DIR / "mutator-gate.py"), "--runtime", runtime],
|
||||
input=b'{"tool_name":"Read"}\n',
|
||||
capture_output=True,
|
||||
env=environment,
|
||||
check=False,
|
||||
timeout=5,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
server.close()
|
||||
thread.join(timeout=2)
|
||||
self.fail(
|
||||
f"{runtime} gate hung on wire reply {wire_reply!r}: {exc}"
|
||||
)
|
||||
thread.join(timeout=2)
|
||||
|
||||
self.assertFalse(thread.is_alive())
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn(b"GATE_UNAVAILABLE", result.stderr)
|
||||
|
||||
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_revoker_entrypoint_noops_when_identity_environment_is_absent(self) -> None:
|
||||
# D29 supersession. This assertion previously pinned rc=2. Absent identity
|
||||
# means no lease was ever held, so there is nothing to revoke and the correct
|
||||
# result is no-op success. The old pin was written in e4d7d45 (WI-3), the same
|
||||
# commit that shipped launch-runtime.py's lease-var provisioning, on the
|
||||
# assumption that an envless revoker was unreachable. D29 falsified that in
|
||||
# production. Behavioural pins live in src/lease-broker/revoke_noop_unittest.py.
|
||||
with patch.object(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
str(TOOLS_DIR / "revoke-lease.py"),
|
||||
"--runtime",
|
||||
"claude",
|
||||
"--reason",
|
||||
"pre-compact",
|
||||
],
|
||||
), patch.dict(os.environ, {}, clear=True), redirect_stderr(
|
||||
io.StringIO()
|
||||
), self.assertRaises(SystemExit) as raised:
|
||||
runpy.run_path(str(TOOLS_DIR / "revoke-lease.py"), run_name="__main__")
|
||||
self.assertEqual(raised.exception.code, 0)
|
||||
|
||||
def test_revoker_entrypoint_denies_when_identity_environment_is_half_provisioned(
|
||||
self,
|
||||
) -> None:
|
||||
# The no-op above is reachable ONLY when identity is TOTALLY absent. A
|
||||
# half-provisioned environment is a machinery-present failure and must still
|
||||
# fail closed. main() already pins this; the entrypoint did not, and the
|
||||
# entrypoint is what the runtime extension actually spawns.
|
||||
half_provisioned = (
|
||||
{"MOSAIC_LEASE_BROKER_SOCKET": "/run/test/broker.sock"},
|
||||
{"MOSAIC_LEASE_SESSION_ID": "d" * 64},
|
||||
)
|
||||
for environment in half_provisioned:
|
||||
with self.subTest(environment=environment), patch.object(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
str(TOOLS_DIR / "revoke-lease.py"),
|
||||
"--runtime",
|
||||
"claude",
|
||||
"--reason",
|
||||
"pre-compact",
|
||||
],
|
||||
), patch.dict(os.environ, environment, clear=True), redirect_stderr(
|
||||
io.StringIO()
|
||||
), self.assertRaises(SystemExit) as raised:
|
||||
runpy.run_path(str(TOOLS_DIR / "revoke-lease.py"), run_name="__main__")
|
||||
self.assertEqual(raised.exception.code, 2)
|
||||
|
||||
def test_gate_entrypoint_denies_when_identity_environment_is_absent(self) -> None:
|
||||
# Deliberately NOT changed alongside its revoker twin above. The asymmetry is
|
||||
# intentional: the gate's deny-on-absent is the authorization path and is
|
||||
# load-bearing, so absent identity must fail closed here. The revoker's rc=2
|
||||
# was inert in the same case (no session id means no broker call is possible),
|
||||
# which is why only the revoker moved under D29. Do not "restore symmetry".
|
||||
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]:
|
||||
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_generation_file_is_the_effective_generation_authority(self) -> None:
|
||||
calls: list[dict[str, object]] = []
|
||||
result = GATE.main(
|
||||
["--runtime", "pi"],
|
||||
environ=self.environment(),
|
||||
stream=io.BytesIO(b'{"tool_name":"bash"}'),
|
||||
request=lambda _path, payload: calls.append(payload)
|
||||
or {"ok": True, "decision": "allow"},
|
||||
resolve_generation=lambda _environment: 9,
|
||||
)
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(calls[0]["runtime_generation"], 9)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class LeaseRevocationTest(unittest.TestCase):
|
||||
def load_modules(self):
|
||||
generation_path = TOOLS_DIR / "lease_generation.py"
|
||||
revoker_path = TOOLS_DIR / "revoke-lease.py"
|
||||
self.assertTrue(generation_path.is_file(), "lease_generation.py must be shipped")
|
||||
self.assertTrue(revoker_path.is_file(), "revoke-lease.py must be shipped")
|
||||
return (
|
||||
load_tool("lease_generation_test", "lease_generation.py"),
|
||||
load_tool("lease_revoker_test", "revoke-lease.py"),
|
||||
)
|
||||
|
||||
def test_generation_parser_and_descriptor_security_rejections(self) -> None:
|
||||
generation, _ = self.load_modules()
|
||||
for value in (None, "", "-1", "no", "é", str(generation.MAX_GENERATION + 1)):
|
||||
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||
generation.parse_generation(value)
|
||||
for value in (-1, generation.MAX_GENERATION + 1):
|
||||
with self.subTest(initialize=value), tempfile.TemporaryDirectory() as directory, self.assertRaises(ValueError):
|
||||
generation.initialize_runtime_generation(Path(directory) / "state", value)
|
||||
|
||||
with patch.object(
|
||||
generation.os,
|
||||
"fstat",
|
||||
return_value=SimpleNamespace(st_mode=stat.S_IFDIR | 0o700, st_uid=os.geteuid(), st_size=0),
|
||||
), self.assertRaises(ValueError):
|
||||
generation._validate_descriptor(4)
|
||||
with patch.object(
|
||||
generation.os,
|
||||
"fstat",
|
||||
return_value=SimpleNamespace(st_mode=stat.S_IFREG | 0o600, st_uid=os.geteuid() + 1, st_size=0),
|
||||
), self.assertRaises(ValueError):
|
||||
generation._validate_descriptor(4)
|
||||
with patch.object(
|
||||
generation.os,
|
||||
"fstat",
|
||||
return_value=SimpleNamespace(st_mode=stat.S_IFREG | 0o644, st_uid=os.geteuid(), st_size=0),
|
||||
), self.assertRaises(ValueError):
|
||||
generation._validate_descriptor(4)
|
||||
with patch.object(
|
||||
generation.os,
|
||||
"fstat",
|
||||
return_value=SimpleNamespace(
|
||||
st_mode=stat.S_IFREG | 0o600,
|
||||
st_uid=os.geteuid(),
|
||||
st_size=generation.MAX_GENERATION_BYTES + 1,
|
||||
),
|
||||
), self.assertRaises(ValueError):
|
||||
generation._validate_descriptor(4)
|
||||
|
||||
def test_generation_file_is_private_monotonic_and_rejects_unsafe_state(self) -> None:
|
||||
generation, _ = self.load_modules()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "runtime.generation"
|
||||
generation.initialize_runtime_generation(path, 4)
|
||||
self.assertEqual(generation.read_runtime_generation({
|
||||
"MOSAIC_RUNTIME_GENERATION": "1",
|
||||
"MOSAIC_LEASE_GENERATION_FILE": str(path),
|
||||
}), 4)
|
||||
self.assertEqual(generation.bump_runtime_generation({
|
||||
"MOSAIC_RUNTIME_GENERATION": "1",
|
||||
"MOSAIC_LEASE_GENERATION_FILE": str(path),
|
||||
}), 5)
|
||||
self.assertEqual(path.read_text(), "5\n")
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
|
||||
|
||||
path.write_text("bad\n")
|
||||
with self.assertRaises(ValueError):
|
||||
generation.read_runtime_generation({
|
||||
"MOSAIC_RUNTIME_GENERATION": "1",
|
||||
"MOSAIC_LEASE_GENERATION_FILE": str(path),
|
||||
})
|
||||
path.write_bytes(b"\xff\n")
|
||||
with self.assertRaises(ValueError):
|
||||
generation.read_runtime_generation({
|
||||
"MOSAIC_LEASE_GENERATION_FILE": str(path),
|
||||
})
|
||||
path.write_text(f"{generation.MAX_GENERATION}\n")
|
||||
with self.assertRaises(ValueError):
|
||||
generation.bump_runtime_generation({
|
||||
"MOSAIC_LEASE_GENERATION_FILE": str(path),
|
||||
})
|
||||
with self.assertRaises(ValueError):
|
||||
generation.bump_runtime_generation({"MOSAIC_RUNTIME_GENERATION": "1"})
|
||||
|
||||
def test_generation_write_must_make_progress(self) -> None:
|
||||
generation, _ = self.load_modules()
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(
|
||||
generation.os, "write", return_value=0
|
||||
), self.assertRaises(OSError):
|
||||
generation.initialize_runtime_generation(Path(directory) / "state", 1)
|
||||
|
||||
def test_revoker_reuses_broker_revoke_and_optional_generation_bump(self) -> None:
|
||||
_, revoker = self.load_modules()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
generation_file = Path(directory) / "runtime.generation"
|
||||
generation_file.write_text("2\n")
|
||||
generation_file.chmod(0o600)
|
||||
environment = {
|
||||
"MOSAIC_LEASE_BROKER_SOCKET": "/broker",
|
||||
"MOSAIC_LEASE_SESSION_ID": "a" * 64,
|
||||
"MOSAIC_RUNTIME_GENERATION": "2",
|
||||
"MOSAIC_LEASE_GENERATION_FILE": str(generation_file),
|
||||
}
|
||||
calls: list[tuple[Path, dict[str, object]]] = []
|
||||
result = revoker.main(
|
||||
["--runtime", "pi", "--reason", "session-start-resume", "--bump-generation"],
|
||||
environ=environment,
|
||||
request=lambda path, payload: calls.append((path, payload))
|
||||
or {"ok": True, "state": "UNVERIFIED"},
|
||||
)
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(generation_file.read_text(), "3\n")
|
||||
self.assertEqual(calls[0][0], Path("/broker"))
|
||||
self.assertEqual(calls[0][1], {
|
||||
"action": "revoke_lease",
|
||||
"session_id": "a" * 64,
|
||||
"runtime_generation": 3,
|
||||
"reason": "session-start-resume",
|
||||
"runtime": "pi",
|
||||
})
|
||||
|
||||
calls.clear()
|
||||
self.assertEqual(
|
||||
revoker.main(
|
||||
["--runtime", "pi", "--reason", "pi-context-after-compact"],
|
||||
environ=environment,
|
||||
request=lambda path, payload: calls.append((path, payload))
|
||||
or {"ok": True, "state": "UNVERIFIED"},
|
||||
),
|
||||
0,
|
||||
)
|
||||
self.assertEqual(calls[0][1]["runtime_generation"], 3)
|
||||
|
||||
def test_revoker_broker_framing_and_shape_validation(self) -> None:
|
||||
_, revoker = self.load_modules()
|
||||
with self.assertRaises(ValueError):
|
||||
revoker.broker_request(Path("/broker"), {"reason": "x" * revoker.MAX_FRAME})
|
||||
replies = [
|
||||
(b'{"ok":true,"state":"UNVERIFIED"}\n', {"ok": True, "state": "UNVERIFIED"}),
|
||||
(b'{"ok":true}', ValueError),
|
||||
(b'[]\n', ValueError),
|
||||
(b'x' * (revoker.MAX_FRAME + 1), ValueError),
|
||||
]
|
||||
for wire_reply, expected in replies:
|
||||
with self.subTest(size=len(wire_reply)):
|
||||
fake = FakeSocket(wire_reply)
|
||||
with patch.object(revoker.socket, "socket", return_value=fake):
|
||||
if isinstance(expected, type) and issubclass(expected, Exception):
|
||||
with self.assertRaises(expected):
|
||||
revoker.broker_request(Path("/broker"), {"action": "revoke_lease"})
|
||||
else:
|
||||
self.assertEqual(
|
||||
revoker.broker_request(Path("/broker"), {"action": "revoke_lease"}),
|
||||
expected,
|
||||
)
|
||||
self.assertEqual(fake.timeout, revoker.BROKER_TIMEOUT_SECONDS)
|
||||
self.assertEqual(fake.connected, "/broker")
|
||||
self.assertEqual(fake.shutdown_how, socket.SHUT_WR)
|
||||
|
||||
def test_failed_observer_revocation_advances_the_local_generation_fence(self) -> None:
|
||||
_, revoker = self.load_modules()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
generation_file = Path(directory) / "runtime.generation"
|
||||
generation_file.write_text("8\n")
|
||||
generation_file.chmod(0o600)
|
||||
environment = {
|
||||
"MOSAIC_LEASE_BROKER_SOCKET": "/broker",
|
||||
"MOSAIC_LEASE_SESSION_ID": "a" * 64,
|
||||
"MOSAIC_RUNTIME_GENERATION": "8",
|
||||
"MOSAIC_LEASE_GENERATION_FILE": str(generation_file),
|
||||
}
|
||||
with redirect_stderr(io.StringIO()):
|
||||
result = revoker.main(
|
||||
["--runtime", "claude", "--reason", "session-start-compact"],
|
||||
environ=environment,
|
||||
request=lambda *_args: (_ for _ in ()).throw(OSError("down")),
|
||||
)
|
||||
self.assertEqual(result, 2)
|
||||
self.assertEqual(generation_file.read_text(), "9\n")
|
||||
|
||||
def test_revoker_fails_closed_on_identity_reply_and_transport_errors(self) -> None:
|
||||
_, revoker = self.load_modules()
|
||||
good = {
|
||||
"MOSAIC_LEASE_BROKER_SOCKET": "/broker",
|
||||
"MOSAIC_LEASE_SESSION_ID": "a" * 64,
|
||||
"MOSAIC_RUNTIME_GENERATION": "1",
|
||||
}
|
||||
malformed_session = {**good, "MOSAIC_LEASE_SESSION_ID": "not-a-session"}
|
||||
# D29 exemption: the `({}, ...)` case was removed from this list. An empty
|
||||
# environment is absence-of-lease, not an identity/reply/transport failure, and
|
||||
# its correct result is no-op success (pinned in revoke_noop_unittest.py). The
|
||||
# five cases below are all machinery-present failures and stay fail-closed.
|
||||
cases = [
|
||||
(malformed_session, lambda *_args: {"ok": True, "state": "UNVERIFIED"}),
|
||||
(good, lambda *_args: {"ok": False, "state": "UNVERIFIED"}),
|
||||
(good, lambda *_args: {"ok": True, "state": "VERIFIED"}),
|
||||
(good, lambda *_args: (_ for _ in ()).throw(OSError("down"))),
|
||||
(
|
||||
good,
|
||||
lambda *_args: (_ for _ in ()).throw(
|
||||
json.JSONDecodeError("bad", "x", 0)
|
||||
),
|
||||
),
|
||||
]
|
||||
for environment, request in cases:
|
||||
with self.subTest(environment=environment), redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(
|
||||
revoker.main(
|
||||
["--runtime", "claude", "--reason", "pre-compact"],
|
||||
environ=environment,
|
||||
request=request,
|
||||
),
|
||||
2,
|
||||
)
|
||||
|
||||
with redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(
|
||||
revoker.main(
|
||||
["--runtime", "claude", "--reason", "x" * 129],
|
||||
environ=good,
|
||||
request=lambda *_args: self.fail("invalid reason reached broker"),
|
||||
),
|
||||
2,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
generation_file = Path(directory) / "runtime.generation"
|
||||
generation_file.write_text("1\n")
|
||||
generation_file.chmod(0o600)
|
||||
with redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(
|
||||
revoker.main(
|
||||
[
|
||||
"--runtime",
|
||||
"pi",
|
||||
"--reason",
|
||||
"session-start-resume",
|
||||
"--bump-generation",
|
||||
],
|
||||
environ={
|
||||
**good,
|
||||
"MOSAIC_LEASE_GENERATION_FILE": str(generation_file),
|
||||
},
|
||||
request=lambda *_args: (_ for _ in ()).throw(OSError("down")),
|
||||
),
|
||||
2,
|
||||
)
|
||||
self.assertEqual(generation_file.read_text(), "2\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,419 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Red-first tests for issue #869 Point-1 C4 — the enforcement/activation
|
||||
version-coupling assertion at the `launch-runtime.py` seam.
|
||||
|
||||
Root cause under test (#828 restated): the lease broker's ENFORCEMENT half
|
||||
(this toolkit) and its ACTIVATION half (`execLeaseGatedRuntime()` in
|
||||
`launch.ts`, chained through `launch-runtime.py`) shipped on different
|
||||
channels and drifted. C1 (`lease-activation-probe.ts`) gave the activation
|
||||
half a versioned, machine-checkable identity
|
||||
(`LEASE_ACTIVATION_CAPABILITY`, printed via the hidden CLI subcommand
|
||||
`mosaic __lease-capability`). C4 (this module + `activation_version_gate.py`)
|
||||
is the assertion that actually USES that identity: enforcement must refuse
|
||||
to proceed — loudly, with an actionable remediation message, never a
|
||||
silent pass — unless the activation capability it observes exactly matches
|
||||
what enforcement expects.
|
||||
|
||||
Every case here drives the seam with injected fakes/stubs (a fake
|
||||
`probe_activation_capability` callable at the `launch-runtime.py` level, or
|
||||
a fake `run` transport at the `activation_version_gate` level) — never a
|
||||
real broker, a real installed CLI, or a real `mosaic` binary on PATH.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
TOOLS_DIR = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
||||
if str(TOOLS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(TOOLS_DIR))
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# Loaded under distinct module names from runtime_tools_unittest.py's own
|
||||
# LAUNCHER/GATE loads — importlib.util.module_from_spec() gives each load a
|
||||
# fresh module object regardless of name collisions, but distinct names keep
|
||||
# tracebacks/debugging unambiguous when both files run in the same process.
|
||||
LAUNCHER = load_tool("lease_runtime_launcher_version_coupling", "launch-runtime.py")
|
||||
VERSION_GATE = load_tool("lease_activation_version_gate_test", "activation_version_gate.py")
|
||||
|
||||
|
||||
def matching_capability() -> dict[str, object]:
|
||||
return dict(VERSION_GATE.EXPECTED_ACTIVATION_CAPABILITY)
|
||||
|
||||
|
||||
def write_fake_mosaic(directory: Path, marker: Path) -> Path:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
executable = directory / "mosaic"
|
||||
executable.write_text(
|
||||
"#!/bin/sh\n"
|
||||
f"printf '%s\\n' executed >> {shlex.quote(str(marker))}\n"
|
||||
"printf '%s\\n' "
|
||||
"'{\"name\":\"lease-runtime-activation\",\"version\":1}'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
executable.chmod(0o755)
|
||||
return executable
|
||||
|
||||
|
||||
class AssertActivationCapabilityMatchesTest(unittest.TestCase):
|
||||
"""Unit-level coverage of `activation_version_gate.py`'s own assertion,
|
||||
isolated from the launch-runtime.py seam it is wired into below."""
|
||||
|
||||
def test_matching_capability_passes_silently(self) -> None:
|
||||
VERSION_GATE.assert_activation_capability_matches(matching_capability())
|
||||
# No exception is the assertion; nothing further to check.
|
||||
|
||||
def test_absent_capability_fails_closed_not_silent_pass(self) -> None:
|
||||
with self.assertRaises(VERSION_GATE.VersionCouplingError) as raised:
|
||||
VERSION_GATE.assert_activation_capability_matches(None)
|
||||
message = str(raised.exception)
|
||||
self.assertIn("#869", message)
|
||||
self.assertIn("upgrade", message.lower())
|
||||
|
||||
def test_version_mismatch_message_is_actionable(self) -> None:
|
||||
expected = {"name": "lease-runtime-activation", "version": 1}
|
||||
mismatched = {"name": "lease-runtime-activation", "version": 2}
|
||||
with self.assertRaises(VERSION_GATE.VersionCouplingError) as raised:
|
||||
VERSION_GATE.assert_activation_capability_matches(mismatched, expected)
|
||||
message = str(raised.exception)
|
||||
self.assertIn("v2", message)
|
||||
self.assertIn("v1", message)
|
||||
self.assertIn("#869", message)
|
||||
self.assertIn("upgrade", message.lower())
|
||||
self.assertIn("version skew", message.lower())
|
||||
|
||||
def test_name_mismatch_fails_loud(self) -> None:
|
||||
expected = {"name": "lease-runtime-activation", "version": 1}
|
||||
mismatched = {"name": "some-other-capability", "version": 1}
|
||||
with self.assertRaises(VERSION_GATE.VersionCouplingError) as raised:
|
||||
VERSION_GATE.assert_activation_capability_matches(mismatched, expected)
|
||||
message = str(raised.exception)
|
||||
self.assertIn("some-other-capability", message)
|
||||
self.assertIn("lease-runtime-activation", message)
|
||||
self.assertIn("#869", message)
|
||||
|
||||
def test_reversed_drift_newer_activation_than_enforcement_expects_also_fails(self) -> None:
|
||||
# A build/deploy where ACTIVATION shipped ahead of ENFORCEMENT is
|
||||
# exactly as much version skew as the reverse (#828's actual shape
|
||||
# was enforcement ahead of activation) — the assertion must not special
|
||||
# case direction.
|
||||
expected = {"name": "lease-runtime-activation", "version": 1}
|
||||
newer_activation = {"name": "lease-runtime-activation", "version": 2}
|
||||
with self.assertRaises(VERSION_GATE.VersionCouplingError):
|
||||
VERSION_GATE.assert_activation_capability_matches(newer_activation, expected)
|
||||
|
||||
|
||||
class ProbeActivationCapabilityTest(unittest.TestCase):
|
||||
"""Coverage of the probe's command resolution and fail-closed transport
|
||||
handling — never spawns a real `mosaic` process."""
|
||||
|
||||
def test_returns_none_when_mosaic_is_not_resolvable_on_path(self) -> None:
|
||||
# Keep even a deliberate ambient-lookup mutation away from any host
|
||||
# installation. The dedicated hermeticity tests below provide fake
|
||||
# ambient executables and markers.
|
||||
with mock.patch.dict(
|
||||
os.environ, {"PATH": "/nonexistent-ambient-bin-dir-for-869-c4-test"}
|
||||
):
|
||||
result = VERSION_GATE.default_probe_activation_capability(
|
||||
{"PATH": "/nonexistent-bin-dir-for-869-c4-test"}
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_supplied_path_wins_over_ambient_process_path(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
supplied_marker = root / "supplied.marker"
|
||||
ambient_marker = root / "ambient.marker"
|
||||
supplied_bin = root / "supplied-bin"
|
||||
ambient_bin = root / "ambient-bin"
|
||||
write_fake_mosaic(supplied_bin, supplied_marker)
|
||||
write_fake_mosaic(ambient_bin, ambient_marker)
|
||||
|
||||
with mock.patch.dict(os.environ, {"PATH": str(ambient_bin)}):
|
||||
result = VERSION_GATE.default_probe_activation_capability(
|
||||
{"PATH": str(supplied_bin)}
|
||||
)
|
||||
|
||||
self.assertEqual(result, matching_capability())
|
||||
self.assertTrue(supplied_marker.exists())
|
||||
self.assertFalse(ambient_marker.exists())
|
||||
|
||||
def test_absent_or_empty_supplied_path_never_falls_back_or_executes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
ambient_marker = root / "ambient.marker"
|
||||
current_directory_marker = root / "current-directory.marker"
|
||||
ambient_bin = root / "ambient-bin"
|
||||
current_directory = root / "current-directory"
|
||||
write_fake_mosaic(ambient_bin, ambient_marker)
|
||||
write_fake_mosaic(current_directory, current_directory_marker)
|
||||
original_directory = Path.cwd()
|
||||
|
||||
try:
|
||||
os.chdir(current_directory)
|
||||
with mock.patch.dict(os.environ, {"PATH": str(ambient_bin)}):
|
||||
for supplied_environment in ({}, {"PATH": ""}):
|
||||
with self.subTest(environ=supplied_environment):
|
||||
result = VERSION_GATE.default_probe_activation_capability(
|
||||
supplied_environment
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
self.assertFalse(ambient_marker.exists())
|
||||
self.assertFalse(current_directory_marker.exists())
|
||||
finally:
|
||||
os.chdir(original_directory)
|
||||
|
||||
def test_valid_override_wins_and_invalid_override_does_not_fall_back_to_path(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
supplied_marker = root / "supplied.marker"
|
||||
ambient_marker = root / "ambient.marker"
|
||||
override_marker = root / "override.marker"
|
||||
supplied_bin = root / "supplied-bin"
|
||||
ambient_bin = root / "ambient-bin"
|
||||
override_bin = root / "override-bin"
|
||||
write_fake_mosaic(supplied_bin, supplied_marker)
|
||||
write_fake_mosaic(ambient_bin, ambient_marker)
|
||||
override_executable = write_fake_mosaic(override_bin, override_marker)
|
||||
|
||||
with mock.patch.dict(os.environ, {"PATH": str(ambient_bin)}):
|
||||
result = VERSION_GATE.default_probe_activation_capability(
|
||||
{
|
||||
"PATH": str(supplied_bin),
|
||||
VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: str(override_executable),
|
||||
}
|
||||
)
|
||||
self.assertEqual(result, matching_capability())
|
||||
self.assertTrue(override_marker.exists())
|
||||
self.assertFalse(supplied_marker.exists())
|
||||
self.assertFalse(ambient_marker.exists())
|
||||
|
||||
override_marker.unlink()
|
||||
result = VERSION_GATE.default_probe_activation_capability(
|
||||
{
|
||||
"PATH": str(supplied_bin),
|
||||
VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: str(
|
||||
root / "invalid-override" / "mosaic"
|
||||
),
|
||||
}
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
self.assertFalse(override_marker.exists())
|
||||
self.assertFalse(supplied_marker.exists())
|
||||
self.assertFalse(ambient_marker.exists())
|
||||
|
||||
def test_override_command_is_parsed_and_the_probe_subcommand_is_not_double_appended(
|
||||
self,
|
||||
) -> None:
|
||||
captured: list[list[str]] = []
|
||||
|
||||
class FakeCompleted:
|
||||
returncode = 0
|
||||
stdout = '{"name": "lease-runtime-activation", "version": 1}'
|
||||
|
||||
def fake_run(argv: list[str], **_kwargs: object) -> FakeCompleted:
|
||||
captured.append(argv)
|
||||
return FakeCompleted()
|
||||
|
||||
result = VERSION_GATE.default_probe_activation_capability(
|
||||
{VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: "/fake/mosaic __lease-capability"},
|
||||
run=fake_run,
|
||||
)
|
||||
self.assertEqual(result, {"name": "lease-runtime-activation", "version": 1})
|
||||
self.assertEqual(captured, [["/fake/mosaic", "__lease-capability"]])
|
||||
|
||||
def test_probe_passes_ten_second_timeout_to_runner(self) -> None:
|
||||
captured_argv: list[str] = []
|
||||
captured_kwargs: dict[str, object] = {}
|
||||
|
||||
class FakeCompleted:
|
||||
returncode = 0
|
||||
stdout = '{"name": "lease-runtime-activation", "version": 1}'
|
||||
|
||||
def fake_run(argv: list[str], **kwargs: object) -> FakeCompleted:
|
||||
captured_argv.extend(argv)
|
||||
captured_kwargs.update(kwargs)
|
||||
return FakeCompleted()
|
||||
|
||||
result = VERSION_GATE.default_probe_activation_capability(
|
||||
{VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: "/fake/mosaic"},
|
||||
run=fake_run,
|
||||
)
|
||||
|
||||
self.assertEqual(result, matching_capability())
|
||||
self.assertEqual(captured_argv, ["/fake/mosaic"])
|
||||
self.assertEqual(captured_kwargs["timeout"], 10.0)
|
||||
self.assertEqual(captured_kwargs["check"], False)
|
||||
|
||||
def test_fails_closed_on_nonzero_exit_malformed_json_and_missing_fields(self) -> None:
|
||||
class NonZeroExit:
|
||||
returncode = 1
|
||||
stdout = '{"name": "lease-runtime-activation", "version": 1}'
|
||||
|
||||
class MalformedOutput:
|
||||
returncode = 0
|
||||
stdout = "not-json"
|
||||
|
||||
class MissingVersion:
|
||||
returncode = 0
|
||||
stdout = '{"name": "lease-runtime-activation"}'
|
||||
|
||||
class WrongShapeVersion:
|
||||
returncode = 0
|
||||
stdout = '{"name": "lease-runtime-activation", "version": "1"}'
|
||||
|
||||
class BooleanVersion:
|
||||
# bool is a subclass of int in Python; must not be accepted as
|
||||
# a version number.
|
||||
returncode = 0
|
||||
stdout = '{"name": "lease-runtime-activation", "version": true}'
|
||||
|
||||
for fake in (
|
||||
NonZeroExit(),
|
||||
MalformedOutput(),
|
||||
MissingVersion(),
|
||||
WrongShapeVersion(),
|
||||
BooleanVersion(),
|
||||
):
|
||||
with self.subTest(stdout=fake.stdout, returncode=fake.returncode):
|
||||
result = VERSION_GATE.default_probe_activation_capability(
|
||||
{VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: "/fake/mosaic"},
|
||||
run=lambda *_a, fake=fake, **_kw: fake,
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_fails_closed_on_timeout_and_transport_error(self) -> None:
|
||||
def timeout_run(*_args: object, **_kwargs: object) -> None:
|
||||
raise subprocess.TimeoutExpired(cmd="mosaic", timeout=10.0)
|
||||
|
||||
def oserror_run(*_args: object, **_kwargs: object) -> None:
|
||||
raise OSError("no such file or directory")
|
||||
|
||||
for run_fake in (timeout_run, oserror_run):
|
||||
with self.subTest(run=run_fake.__name__):
|
||||
result = VERSION_GATE.default_probe_activation_capability(
|
||||
{VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: "/fake/mosaic"},
|
||||
run=run_fake,
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class LaunchRuntimeVersionCouplingSeamTest(unittest.TestCase):
|
||||
"""End-to-end (still fully faked) coverage of the seam as wired into
|
||||
`launch-runtime.py`'s `main()` — the strongest natural enforcement point
|
||||
per the C4 card, run before any broker registration."""
|
||||
|
||||
def _run(self, *, probe):
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
def request(_path: Path, payload: dict[str, object]) -> dict[str, object]:
|
||||
calls["registered"] = True
|
||||
calls["request"] = payload
|
||||
return {"ok": True, "session_id": "a" * 64}
|
||||
|
||||
def execute(command: str, argv: list[str], environment: dict[str, str]) -> None:
|
||||
calls["executed"] = (command, argv, environment)
|
||||
|
||||
def initialize_generation(_path: Path, _generation: int) -> None:
|
||||
calls["generation_initialized"] = True
|
||||
|
||||
stderr = io.StringIO()
|
||||
with redirect_stderr(stderr):
|
||||
result = LAUNCHER.main(
|
||||
["--runtime", "claude", "--", "claude", "--print", "hello"],
|
||||
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/run/test/broker.sock"},
|
||||
request=request,
|
||||
execute=execute,
|
||||
initialize_generation=initialize_generation,
|
||||
probe_activation_capability=probe,
|
||||
)
|
||||
return result, stderr.getvalue(), calls
|
||||
|
||||
def test_matching_activation_version_passes_and_the_gate_proceeds(self) -> None:
|
||||
result, stderr_text, calls = self._run(probe=lambda *_a, **_kw: matching_capability())
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(stderr_text, "")
|
||||
self.assertTrue(calls.get("registered"))
|
||||
self.assertIn("executed", calls)
|
||||
|
||||
def test_version_mismatch_fails_loud_denies_and_never_registers_or_execs(self) -> None:
|
||||
expected = LAUNCHER.EXPECTED_ACTIVATION_CAPABILITY
|
||||
mismatched = {"name": expected["name"], "version": expected["version"] + 1}
|
||||
result, stderr_text, calls = self._run(probe=lambda *_a, **_kw: mismatched)
|
||||
|
||||
self.assertEqual(result, LAUNCHER.EXIT_VERSION_SKEW)
|
||||
self.assertNotEqual(result, 0)
|
||||
self.assertIn("#869", stderr_text)
|
||||
self.assertIn(f"v{mismatched['version']}", stderr_text)
|
||||
self.assertIn(f"v{expected['version']}", stderr_text)
|
||||
self.assertIn("upgrade", stderr_text.lower())
|
||||
# Never reaches broker registration or exec — the version gate is a
|
||||
# hard stop, not advisory.
|
||||
self.assertNotIn("registered", calls)
|
||||
self.assertNotIn("executed", calls)
|
||||
|
||||
def test_name_mismatch_fails_loud(self) -> None:
|
||||
expected = LAUNCHER.EXPECTED_ACTIVATION_CAPABILITY
|
||||
mismatched = {"name": "some-other-capability", "version": expected["version"]}
|
||||
result, stderr_text, calls = self._run(probe=lambda *_a, **_kw: mismatched)
|
||||
|
||||
self.assertEqual(result, LAUNCHER.EXIT_VERSION_SKEW)
|
||||
self.assertIn("#869", stderr_text)
|
||||
self.assertIn("some-other-capability", stderr_text)
|
||||
self.assertNotIn("registered", calls)
|
||||
self.assertNotIn("executed", calls)
|
||||
|
||||
def test_absent_activation_capability_fails_closed_not_a_silent_pass(self) -> None:
|
||||
result, stderr_text, calls = self._run(probe=lambda *_a, **_kw: None)
|
||||
|
||||
self.assertEqual(result, LAUNCHER.EXIT_VERSION_SKEW)
|
||||
self.assertNotEqual(result, 0)
|
||||
self.assertIn("#869", stderr_text)
|
||||
self.assertNotIn("registered", calls)
|
||||
self.assertNotIn("executed", calls)
|
||||
|
||||
def test_version_gate_runs_before_and_independently_of_broker_registration(self) -> None:
|
||||
def request_must_not_be_called(*_args: object, **_kwargs: object) -> dict[str, object]:
|
||||
self.fail("broker must not be contacted when activation version is mismatched")
|
||||
|
||||
stderr = io.StringIO()
|
||||
with redirect_stderr(stderr):
|
||||
result = LAUNCHER.main(
|
||||
["--runtime", "claude", "--", "claude"],
|
||||
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/run/test/broker.sock"},
|
||||
request=request_must_not_be_called,
|
||||
probe_activation_capability=lambda *_a, **_kw: None,
|
||||
)
|
||||
self.assertEqual(result, LAUNCHER.EXIT_VERSION_SKEW)
|
||||
|
||||
def test_dedicated_exit_code_never_collides_with_usage_or_registration_codes(self) -> None:
|
||||
# Distinctness guard: a version-skew denial must never be mistaken
|
||||
# for the pre-existing usage error (64) or registration/exec
|
||||
# fail-closed code (1) this script already owns.
|
||||
self.assertNotIn(LAUNCHER.EXIT_VERSION_SKEW, (0, 1, 64))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user