fix(#832): bind receipt promotion to trusted observation

This commit is contained in:
ms-lead-reviewer
2026-07-19 17:15:10 -05:00
parent 1713dfe5d0
commit e55262708e
7 changed files with 347 additions and 45 deletions

View File

@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto';
import { chmod, writeFile } from 'node:fs/promises';
import { createConnection, type Socket } from 'node:net';
const DEFAULT_TIMEOUT_MS = 3_000;
@@ -185,3 +186,51 @@ export async function requestBrokerReply<T extends object>(
options,
);
}
export interface ReceiptChallengeCycle {
sessionId: string;
runtimeGeneration: number;
receiptChallenge: string;
receipt: string;
}
export interface ReceiptChallengeReply {
ok: boolean;
code?: string;
state?: 'UNVERIFIED' | 'PENDING_VERIFICATION' | 'PENDING_PROMOTION' | 'VERIFIED';
}
/**
* Complete the shipped begin -> trusted-observer -> consume -> promote path.
* The private fixture is read by the daemon's injected test observer; the
* observation request itself never carries assistant-message content.
*/
export async function observeAndPromoteReceiptChallenge(
socketPath: string,
observerFixturePath: string,
cycle: ReceiptChallengeCycle,
): Promise<ReceiptChallengeReply> {
await writeFile(
observerFixturePath,
`${JSON.stringify({
session_id: cycle.sessionId,
runtime_generation: cycle.runtimeGeneration,
latest_assistant_message: cycle.receipt,
})}\n`,
{ encoding: 'utf8', mode: 0o600 },
);
await chmod(observerFixturePath, 0o600);
const observed = await requestBrokerReply<ReceiptChallengeReply>(socketPath, {
action: 'observe_receipt',
session_id: cycle.sessionId,
runtime_generation: cycle.runtimeGeneration,
receipt_challenge: cycle.receiptChallenge,
});
if (observed.ok !== true || observed.state !== 'PENDING_PROMOTION') return observed;
return await requestBrokerReply<ReceiptChallengeReply>(socketPath, {
action: 'promote_lease',
session_id: cycle.sessionId,
runtime_generation: cycle.runtimeGeneration,
receipt_challenge: cycle.receiptChallenge,
});
}

View File

@@ -6,19 +6,31 @@ 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 { requestBrokerReply } from '../lease-broker/broker-test-client.js';
import {
observeAndPromoteReceiptChallenge,
requestBrokerReply,
} from '../lease-broker/broker-test-client.js';
interface BrokerReply {
ok: boolean;
code?: string;
decision?: 'allow' | 'deny';
state?: 'UNVERIFIED' | 'PENDING_VERIFICATION' | 'VERIFIED';
state?: 'UNVERIFIED' | 'PENDING_VERIFICATION' | 'PENDING_PROMOTION' | 'VERIFIED';
session_id?: string;
promotion_token?: 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;
@@ -38,14 +50,29 @@ const remediationHandlerPath = join(frameworkRoot, 'tools/qa/remediation-hook-ha
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: 'a'.repeat(64),
h_payload: 'b'.repeat(64),
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);
}
@@ -54,9 +81,18 @@ 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')],
[
daemonPath,
'--socket',
socket,
'--state',
join(root, 'state.json'),
'--test-observer-file',
observerFixture,
],
{
stdio: ['ignore', 'pipe', 'pipe'],
},
@@ -72,7 +108,8 @@ async function startBroker(): Promise<BrokerPaths> {
);
child.stdout?.once('data', () => resolve());
});
return { socket };
observerFixtures.set(socket, observerFixture);
return { socket, observerFixture };
}
interface RuntimeLaunchEntry {
@@ -166,28 +203,43 @@ async function beginVerification(
ttl_seconds = 300,
compactionEpoch = 1,
): Promise<BrokerReply> {
return await request(socket, {
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,
promotion_token: string,
receipt_challenge: string,
runtime_generation = 1,
): Promise<BrokerReply> {
return await request(socket, {
action: 'promote_lease',
session_id,
runtime_generation,
promotion_token,
});
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(
@@ -249,13 +301,13 @@ describe('whole mutator-class lease gate', () => {
const pending = await beginVerification(socket, sessionId, 'claude');
expect(pending).toMatchObject({ ok: true, state: 'PENDING_VERIFICATION' });
expect(pending.promotion_token).toMatch(/^[a-f0-9]{64}$/);
expect(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.promotion_token!)).toMatchObject({
expect(await promote(socket, sessionId, pending.receipt_challenge!)).toMatchObject({
ok: true,
state: 'VERIFIED',
});
@@ -271,9 +323,9 @@ describe('whole mutator-class lease gate', () => {
ok: false,
decision: 'deny',
});
expect(await promote(socket, sessionId, pending.promotion_token!)).toMatchObject({
expect(await promote(socket, sessionId, pending.receipt_challenge!)).toMatchObject({
ok: false,
code: 'PROMOTION_TOKEN_MISMATCH',
code: 'RECEIPT_REPLAY',
});
});
@@ -401,7 +453,7 @@ describe('whole mutator-class lease gate', () => {
const { socket } = await startBroker();
const sessionId = await register(socket);
const pending = await beginVerification(socket, sessionId, 'claude', 1, 1);
await promote(socket, sessionId, pending.promotion_token!);
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.
@@ -431,7 +483,7 @@ describe('whole mutator-class lease gate', () => {
const { socket } = await startBroker();
const sessionId = await register(socket);
const pending = await beginVerification(socket, sessionId, 'claude');
await promote(socket, sessionId, pending.promotion_token!);
await promote(socket, sessionId, pending.receipt_challenge!);
const revoked = spawnSync('python3', [revokerPath, '--runtime', 'claude', '--reason', reason], {
encoding: 'utf8',
@@ -454,7 +506,7 @@ describe('whole mutator-class lease gate', () => {
const { socket } = await startBroker();
const observerSessionId = await register(socket);
const observerPending = await beginVerification(socket, observerSessionId, 'claude');
await promote(socket, observerSessionId, observerPending.promotion_token!);
await promote(socket, observerSessionId, observerPending.receipt_challenge!);
expect(await authorize(socket, observerSessionId, 'claude', 'Bash')).toMatchObject({
ok: true,
@@ -464,12 +516,10 @@ describe('whole mutator-class lease gate', () => {
const retriedPromotion = await promote(
socket,
observerSessionId,
observerPending.promotion_token!,
observerPending.receipt_challenge!,
);
expect(retriedPromotion.ok).toBe(false);
expect(['PROMOTION_TOKEN_MISMATCH', 'INVALID_LEASE_TRANSITION']).toContain(
retriedPromotion.code,
);
expect(retriedPromotion.code).toBe('RECEIPT_REPLAY');
const revoked = spawnSync(
'python3',
@@ -495,7 +545,7 @@ describe('whole mutator-class lease gate', () => {
const expirySessionId = await register(expirySocket);
expect(expirySessionId).not.toBe(observerSessionId);
const expiryPending = await beginVerification(expirySocket, expirySessionId, 'claude', 1, 1);
await promote(expirySocket, expirySessionId, expiryPending.promotion_token!);
await promote(expirySocket, expirySessionId, expiryPending.receipt_challenge!);
expect(await authorize(expirySocket, expirySessionId, 'claude', 'Bash')).toMatchObject({
ok: true,
decision: 'allow',
@@ -514,7 +564,7 @@ describe('whole mutator-class lease gate', () => {
const { socket } = await startBroker();
const sessionId = await register(socket);
const pending = await beginVerification(socket, sessionId, 'claude');
await promote(socket, sessionId, pending.promotion_token!);
await promote(socket, sessionId, pending.receipt_challenge!);
const root = await mkdtemp(join(tmpdir(), 'mosaic-observer-fence-'));
temporaryRoots.push(root);
@@ -547,7 +597,7 @@ describe('whole mutator-class lease gate', () => {
const { socket } = await startBroker();
const sessionId = await register(socket);
const pending = await beginVerification(socket, sessionId, 'pi');
await promote(socket, sessionId, pending.promotion_token!);
await promote(socket, sessionId, pending.receipt_challenge!);
const anchorPid = process.pid;
const root = await mkdtemp(join(tmpdir(), 'mosaic-generation-bump-'));
@@ -612,7 +662,7 @@ describe('whole mutator-class lease gate', () => {
const { socket } = await startBroker();
const sessionId = await register(socket);
const pending = await beginVerification(socket, sessionId, 'claude', 1, 1);
await promote(socket, sessionId, pending.promotion_token!);
await promote(socket, sessionId, pending.receipt_challenge!);
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
ok: true,
@@ -626,7 +676,7 @@ describe('whole mutator-class lease gate', () => {
});
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 2);
await promote(socket, sessionId, refreshed.promotion_token!);
await promote(socket, sessionId, refreshed.receipt_challenge!);
expect(
await request(socket, {
action: 'revoke_lease',
@@ -646,7 +696,7 @@ describe('whole mutator-class lease gate', () => {
const { socket } = await startBroker();
const sessionId = await register(socket);
const pending = await beginVerification(socket, sessionId, 'pi');
await promote(socket, sessionId, pending.promotion_token!);
await promote(socket, sessionId, pending.receipt_challenge!);
expect(await authorize(socket, sessionId, 'pi', 'bash', 2)).toMatchObject({
ok: false,
@@ -859,7 +909,7 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and observers_prese
expect(runRuntimeGate(socket, sessionId, 'pi', 'unknown_custom_tool').status).toBe(2);
const pending = await beginVerification(socket, sessionId, 'claude');
await promote(socket, sessionId, pending.promotion_token!);
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 {