Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/core/src/receipts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ const ReceiptCheckSchema = z.object({
exit_code: z.number().int().optional(),
});

// Non-gating evidence: named artifacts a run was expected to preserve. Distinct
// from `checks`, which the run's outcome is gated on. Optional so receipts
// sealed before this field existed still parse and digest unchanged.
const ReceiptEvidenceSchema = z.object({
name: z.string(),
status: z.enum(["passed", "failed", "skipped", "unknown"]),
reason: z.string().optional(),
});

export const FacilityReceiptSchema = z.object({
schema: z.literal("facility.run.v1"),
run_id: z.string().optional(),
Expand Down Expand Up @@ -71,6 +80,8 @@ export const FacilityReceiptSchema = z.object({
.optional(),
checks: z.array(ReceiptCheckSchema).optional(),
checks_truncated: z.boolean().optional(),
evidence: z.array(ReceiptEvidenceSchema).optional(),
evidence_truncated: z.boolean().optional(),
integrity: z
.object({
algorithm: z.literal("sha256"),
Expand Down
49 changes: 49 additions & 0 deletions packages/core/test/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,4 +468,53 @@ describe("receipts", () => {
}),
).toBe(false);
});

it("seals non-gating evidence without disturbing receipts that carry none", () => {
const base = parseLegacyAgentReceipt({
schema: "example.agent_sdlc.run.v1",
provider: "claude_code",
mode: "builder",
result: "succeeded",
usage: { input_tokens: 1, output_tokens: 1, cost_source: "provider" },
activity: {},
timing: { started_at: "2026-08-16T00:00:00.000Z" },
});
// A receipt sealed before `evidence` existed must still verify: both fields
// are optional and the digest skips undefined, so its payload hash is
// unchanged.
const withoutEvidence = sealFacilityReceipt(base, null);
expect(verifyFacilityReceipt(withoutEvidence)).toBe(true);
expect(
receiptContentDigest({ ...base, evidence: undefined, evidence_truncated: undefined }),
).toBe(receiptContentDigest(base));

const withEvidence = sealFacilityReceipt(
{
...base,
evidence: [{ name: "transcript", status: "failed", reason: "transcript_upload_failed" }],
},
null,
);
expect(verifyFacilityReceipt(withEvidence)).toBe(true);
expect(withEvidence.evidence).toEqual([
{ name: "transcript", status: "failed", reason: "transcript_upload_failed" },
]);
// Evidence is covered by the seal, so it cannot be edited after the fact.
expect(withEvidence.integrity?.payload_sha256).not.toBe(
withoutEvidence.integrity?.payload_sha256,
);
expect(
verifyFacilityReceipt({
...withEvidence,
evidence: [{ name: "transcript", status: "passed" }],
}),
).toBe(false);

// The truncation flag is sealed too, so a receipt cannot be made to look
// complete after the fact.
const truncated = sealFacilityReceipt({ ...withEvidence, evidence_truncated: true }, null);
expect(verifyFacilityReceipt(truncated)).toBe(true);
expect(truncated.evidence_truncated).toBe(true);
expect(verifyFacilityReceipt({ ...truncated, evidence_truncated: false })).toBe(false);
});
});
217 changes: 140 additions & 77 deletions runner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,7 @@ async function main() {
const phases = new RunPhaseRecorder(emit);
let bundle: RunBundle | null = null;
let steerStop: (() => void) | undefined;
let progressStop: (() => Promise<boolean>) | undefined;
let checkpointStop: (() => Promise<void>) | undefined;
const polls: RunPolls = {};
let preparedSecuritySweep: PreparedSecuritySweepEvidence | null = null;
let restoredSessionState = false;
secretsToRedact.add(runnerToken());
Expand Down Expand Up @@ -193,80 +192,24 @@ async function main() {
if (managedProgress) {
await emit([{ type: "agent_progress", data: { markdown: managedProgress } }]);
}
progressStop = startAgentProgressPoll(cwdFor(bundle));
const stopProgress = progressStop;
checkpointStop = startSessionCheckpointPoll(activeBundle);
const stopCheckpoint = checkpointStop;
polls.progress = startAgentProgressPoll(cwdFor(bundle));
polls.checkpoint = startSessionCheckpointPoll(activeBundle);
const engineCode = await phases.measure(
"agent",
() => runEngine(activeBundle, restoredSessionState),
(code) => ({
outcome: interruptRequested ? "canceled" : code === 0 ? "succeeded" : "failed",
}),
);
const captured = await phases.measure("result_capture", async () => {
const agentProgressPublished = await stopProgress();
progressStop = undefined;
if (managedProgress) {
await emit([
{
type: "agent_progress",
data: { markdown: readOnlyEngineProgress(engineCode === 0) },
},
]);
}
const progressPublished = managedProgress !== null || agentProgressPublished;
const securityReport = isSecurityMode(activeBundle.mode)
? await readSecurityReport(
join(cwdFor(activeBundle), ".agent-sdlc", "security-findings.json"),
)
: undefined;
const securityReportConfigured =
!isSecurityMode(activeBundle.mode) || securityReport !== null;
const securityEvidenceConfigured =
!isSecurityMode(activeBundle.mode) ||
(preparedSecuritySweep !== null &&
(await verifySecuritySweepEvidence(preparedSecuritySweep)));
if (isSecurityMode(activeBundle.mode)) {
await emit([
{
type: "check",
data: {
self_reported: false,
name: "deterministic security evidence",
status: securityEvidenceConfigured ? "passed" : "failed",
...(securityEvidenceConfigured ? {} : { reason: "security_evidence_invalid" }),
},
},
{
type: "check",
data: {
self_reported: false,
name: "structured security findings",
status: securityReportConfigured ? "passed" : "failed",
...(securityReportConfigured ? {} : { reason: "security_report_invalid" }),
},
},
]);
}
if (engineEventTransportDegraded) {
await emit([
{
type: "artifact_error",
data: { kind: "engine_events_degraded" },
},
]).catch(() => undefined);
}
await uploadTranscript();
await stopCheckpoint();
checkpointStop = undefined;
return {
progressPublished,
securityReport,
securityReportConfigured,
securityEvidenceConfigured,
};
});
const captured = await phases.measure("result_capture", () =>
captureRunResult({
bundle: activeBundle,
engineCode,
managedProgress,
preparedSecuritySweep,
polls,
}),
);
const {
progressPublished,
securityReport,
Expand Down Expand Up @@ -414,12 +357,113 @@ async function main() {
);
process.exitCode = 1;
} finally {
await progressStop?.().catch(() => false);
await checkpointStop?.().catch(() => undefined);
await polls.progress?.().catch(() => false);
await polls.checkpoint?.().catch(() => undefined);
steerStop?.();
}
}

/**
* The polls a run starts before the engine. `captureRunResult` clears each
* handle as it stops it, so `main()`'s `finally` cannot stop one a second time.
*/
export type RunPolls = {
progress?: () => Promise<boolean>;
checkpoint?: () => Promise<void>;
};

export type RunResultCapture = {
progressPublished: boolean;
securityReport: Record<string, unknown> | null | undefined;
securityReportConfigured: boolean;
securityEvidenceConfigured: boolean;
};

/**
* The run's `result_capture` phase: close the progress poll, record the
* security evidence the mode requires, and preserve the transcript.
*
* Exported because this is the only definition of that sequence. `main()` runs
* exactly this and the integration tests drive exactly this, so a step that
* stops happening for real stops happening under test too. Reassembling these
* calls in a test instead would keep the test green while the phase drifted —
* which is how the transcript evidence came to be emitted apart from the upload
* it describes.
*/
export async function captureRunResult({
bundle,
engineCode,
managedProgress,
preparedSecuritySweep,
polls,
transcriptPath,
}: {
bundle: RunBundle;
engineCode: number;
managedProgress: string | null;
preparedSecuritySweep: PreparedSecuritySweepEvidence | null;
polls: RunPolls;
transcriptPath?: string;
}): Promise<RunResultCapture> {
const agentProgressPublished = (await polls.progress?.()) ?? false;
polls.progress = undefined;
if (managedProgress) {
await emit([
{
type: "agent_progress",
data: { markdown: readOnlyEngineProgress(engineCode === 0) },
},
]);
}
const progressPublished = managedProgress !== null || agentProgressPublished;
const securityReport = isSecurityMode(bundle.mode)
? await readSecurityReport(join(cwdFor(bundle), ".agent-sdlc", "security-findings.json"))
: undefined;
const securityReportConfigured = !isSecurityMode(bundle.mode) || securityReport !== null;
const securityEvidenceConfigured =
!isSecurityMode(bundle.mode) ||
(preparedSecuritySweep !== null && (await verifySecuritySweepEvidence(preparedSecuritySweep)));
if (isSecurityMode(bundle.mode)) {
await emit([
{
type: "check",
data: {
self_reported: false,
name: "deterministic security evidence",
status: securityEvidenceConfigured ? "passed" : "failed",
...(securityEvidenceConfigured ? {} : { reason: "security_evidence_invalid" }),
},
},
{
type: "check",
data: {
self_reported: false,
name: "structured security findings",
status: securityReportConfigured ? "passed" : "failed",
...(securityReportConfigured ? {} : { reason: "security_report_invalid" }),
},
},
]);
}
if (engineEventTransportDegraded) {
await emit([
{
type: "artifact_error",
data: { kind: "engine_events_degraded" },
},
]).catch(() => undefined);
}
await uploadTranscript({ transcriptPath });
await polls.checkpoint?.();
polls.checkpoint = undefined;
return {
progressPublished,
securityReport,
securityReportConfigured,
securityEvidenceConfigured,
};
}

export async function prepareWorkspace(
bundle: RunBundle,
virtualKey: string,
Expand Down Expand Up @@ -873,8 +917,16 @@ function isSecurityReport(value: unknown): value is Record<string, unknown> {
});
}

async function uploadTranscript() {
const size = await stat(transcriptFile)
// `transcriptPath` stays injectable because the default is an absolute path
// inside the sandbox runtime root, which tests cannot write to. Nothing here is
// exported: `captureRunResult` is the only entry the tests drive, so the upload
// is exercised as part of the phase that runs it or not at all.
async function uploadTranscript({
transcriptPath = transcriptFile,
}: {
transcriptPath?: string;
} = {}) {
const size = await stat(transcriptPath)
.then((info) => info.size)
.catch(() => 0);
if (size === 0) return;
Expand All @@ -886,12 +938,23 @@ async function uploadTranscript() {
headers: { "content-type": "application/x-ndjson" },
duplex: "half",
} as RequestInit & { duplex: "half" },
() => createReadStream(transcriptFile) as unknown as RequestInit["body"],
() => createReadStream(transcriptPath) as unknown as RequestInit["body"],
);
} catch {
await emit([{ type: "artifact_error", data: { kind: "transcript_upload_failed" } }]).catch(
() => undefined,
);
// Best-effort, and deliberately one batch: `appendRunEvents` inserts a
// batch inside a single transaction, so the anonymous error and the named
// evidence either both land or neither does. Split across two requests, a
// transient failure on the second leaves the receipt holding the anonymous
// half alone — the loss this evidence exists to name. The whole emit stays
// guarded because this evidence is non-gating: a degraded events endpoint
// must not turn an otherwise successful run into a failed one.
await emit([
{ type: "artifact_error", data: { kind: "transcript_upload_failed" } },
{
type: "evidence",
data: { name: "transcript", status: "failed", reason: "transcript_upload_failed" },
},
]).catch(() => undefined);
}
}

Expand Down
Loading