Skip to content

Commit 8fb0cc6

Browse files
committed
fix(evals): enforce trust checks and reliable comparison cleanup and reports
1 parent 266c01f commit 8fb0cc6

20 files changed

Lines changed: 1510 additions & 93 deletions

‎scripts/agent-evals/README.md‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,10 @@ them:
8080
message still appears in `completionMessageSeen`.
8181
- `SESSION_LOST` covers disconnects and interrupts. A manual stop after
8282
completion output stays `SESSION_LOST`.
83-
- The usage drain runs only after a `taskCompleted` event. It stays inside the
84-
bounded drain window.
83+
- The usage drain runs after a `taskCompleted` event and after an observer stop
84+
that followed the final `completion_result` message. It stays inside the
85+
bounded drain window. A plain stop, a run timeout, and a session loss get no
86+
drain.
8587

8688
## Checks
8789

@@ -147,9 +149,17 @@ A comparison runs the extension code of two commits with the provider credential
147149

148150
The comparison driver, fixtures, grader, and report builder all run from the trusted checkout that invokes `coding-evals:compare`. Only the extension host loads an evaluated worktree. The side process receives only the allowlisted environment plus the one named credential variable. Each side installs dependencies from its own worktree's frozen lockfile and builds its own exact commit, so each side runs its own compiled extension. Each side has its own wall clock (`--side-timeout-minutes`, default 120).
149151

152+
The side script runs the same gate on its own inputs. It requires `--trust-file`, resolves the full commit of its `--evaluated-root`, and rejects an unapproved or unreadable commit before it creates a host or reads the credential. The `--credential-env` name must come from the supported provider variable allowlist; an arbitrary name can never forward an unrelated parent secret. Worktree install and build children also receive only an allowlisted environment, so the driver's secrets never reach them.
153+
154+
Every driver child, preparation steps and the side alike, runs detached in its own process group and is tracked by one guard. A SIGINT or SIGTERM that reaches only the driver terminates the tracked group through a bounded SIGTERM-then-SIGKILL escalation, and the worktree cleanup waits for that escalation before it removes any tree.
155+
150156
### Output publication contract
151157

152-
Artifacts are published atomically: content lands in a same-directory temp file and is renamed into place. A reader never sees a partial file. `side-results.json` appears only after every repetition of that side finished, so its existence means a complete side. A missing `side-results.json` means an interrupted side; consumers must treat it as a failed side and never pair partial output. The comparison reports are written atomically and only after both sides published complete results; a failed comparison leaves no report files.
158+
Artifacts are published atomically: content lands in a same-directory temp file and is renamed into place. A reader never sees a partial file.
159+
160+
A side run invalidates its output directory first. Before the first repetition executes, the side removes any previous `side-results.json` and the whole `artifacts` tree, so stale results from an earlier rerun can never survive. `side-results.json` is the completion marker: it appears only after every repetition of the current invocation finished, and consumers must require it and treat its absence as a failed side. A side rerun that cannot remove a stale artifact fails before any repetition runs.
161+
162+
The comparison reports are published as one generation. `comparison-report.json` and `comparison-report.md` carry the report's `generatedAt` stamp, and `comparison-report.complete` holds that stamp; it is written last. Consumers must require the marker and match its stamp against the report files. `readPublishedComparisonReport` enforces this: it returns the report only when the marker exists and its stamp matches both files. The marker is removed before publication starts, so a crash between the two file publications leaves a detectable incomplete generation instead of a mixed one. A thrown failure rolls both files back; a failed comparison leaves no report files.
153163

154164
### Commands
155165

@@ -158,9 +168,9 @@ Artifacts are published atomically: content lands in a same-directory temp file
158168
pnpm test:agent-evals:unit
159169
pnpm agent-evals:check-types
160170

161-
# One comparison side against the real provider. Requires the credential
162-
# environment variable and the container runtime for grading.
163-
pnpm coding-evals:side --reps 3 --out /tmp/side-out
171+
# One comparison side against the real provider. Requires the trust policy,
172+
# the credential environment variable, and the container runtime for grading.
173+
pnpm coding-evals:side --reps 3 --out /tmp/side-out --trust-file trust-policy.json
164174

165175
# Full paired comparison of two approved commits.
166176
pnpm coding-evals:compare --baseline <sha> --candidate <sha> --trust-file trust-policy.json --reps 3 --out /tmp/compare-out

‎scripts/agent-evals/coding-compare.mts‎

Lines changed: 87 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,38 @@
99
// worktree. The credential never leaves the trusted processes except through
1010
// the one named provider variable. A commit runs only when its full SHA is
1111
// listed in the operator's trust policy; refs are resolved and checked
12-
// against the policy before any worktree or process exists.
12+
// against the policy before any worktree or process exists. The side process
13+
// re-checks the root it was handed against the same policy.
14+
//
15+
// Process containment. Install, build, worktree, and side children receive
16+
// only an allowlisted environment, so the driver's secrets never reach them.
17+
// Each runs detached in its own process group under one guard, so SIGINT and
18+
// SIGTERM aimed at the driver alone terminate the tracked group with a bounded
19+
// SIGTERM-then-SIGKILL escalation before the scratch cleanup runs, and an
20+
// interrupt leaves no orphan host behind.
1321
import { execFile, spawn } from "node:child_process"
1422
import fs from "node:fs/promises"
1523
import os from "node:os"
1624
import path from "node:path"
1725
import { fileURLToPath } from "node:url"
1826
import { promisify } from "node:util"
19-
import { sideResultFileName, toSideResult, validateSideResultFile, type SideResultFile } from "./coding-compare.ts"
27+
import {
28+
publishComparisonReport,
29+
runBoundedPreparation,
30+
sideResultFileName,
31+
toSideResult,
32+
validateSideResultFile,
33+
type SideResultFile,
34+
} from "./coding-compare.ts"
2035
import { buildComparisonReport, renderMarkdownReport } from "./coding-report.ts"
21-
import { assertCommitsApproved, buildSideEnvironment, loadTrustPolicy, resolveCommit } from "./coding-trust.ts"
22-
import { writeJsonAtomic, writeFileAtomic } from "./fs-atomic.ts"
36+
import {
37+
assertCommitsApproved,
38+
assertCredentialEnvAllowed,
39+
buildSideEnvironment,
40+
loadTrustPolicy,
41+
resolveCommit,
42+
} from "./coding-trust.ts"
43+
import { createSideProcessGuard, type SideKillOutcome } from "./side-process-guard.ts"
2344

2445
const execFileAsync = promisify(execFile)
2546
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
@@ -30,6 +51,22 @@ const WORKTREE_TIMEOUT_MS = 120_000
3051
const SIDE_KILL_GRACE_MS = 30_000
3152
const DEFAULT_SIDE_TIMEOUT_MINUTES = 120
3253

54+
// Interrupt state. The handlers are registered before any child is spawned.
55+
// A signal terminates the tracked child group at once; the main flow observes
56+
// the child exit, the cleanup awaits the escalation before it removes the
57+
// worktrees, and the driver exits with the conventional signal exit code.
58+
const sideGuard = createSideProcessGuard(SIDE_KILL_GRACE_MS)
59+
let interruptSignal: NodeJS.Signals | undefined
60+
let interruptKill: Promise<SideKillOutcome> | undefined
61+
const isInterrupted = (): boolean => interruptSignal !== undefined
62+
const onInterruptSignal = (signal: NodeJS.Signals): void => {
63+
if (interruptSignal !== undefined) return
64+
interruptSignal = signal
65+
interruptKill = sideGuard.interrupt()
66+
}
67+
process.on("SIGINT", () => onInterruptSignal("SIGINT"))
68+
process.on("SIGTERM", () => onInterruptSignal("SIGTERM"))
69+
3370
const readArgs = (): {
3471
baseline: string
3572
candidate: string
@@ -58,42 +95,59 @@ const readArgs = (): {
5895
if (!Number.isInteger(sideTimeoutMinutes) || sideTimeoutMinutes < 1) {
5996
throw new Error("--side-timeout-minutes must be a positive integer")
6097
}
98+
const credentialEnv = read("credential-env") ?? "SOLHEIM_API_KEY"
99+
// The variable name must be one the framework supports. An arbitrary name
100+
// could forward an unrelated parent secret to the side.
101+
assertCredentialEnvAllowed(credentialEnv)
61102
return {
62103
baseline,
63104
candidate,
64105
reps,
65106
out: path.resolve(out),
66-
credentialEnv: read("credential-env") ?? "SOLHEIM_API_KEY",
107+
credentialEnv,
67108
allowUnisolatedGrading: args.includes("--allow-unisolated-grading"),
68109
trustFile: path.resolve(trustFile),
69110
sideTimeoutMs: sideTimeoutMinutes * 60_000,
70111
}
71112
}
72113

73-
const runBounded = async (command: string, args: readonly string[], cwd: string, timeoutMs: number): Promise<void> => {
74-
await execFileAsync(command, [...args], { cwd, timeout: timeoutMs, killSignal: "SIGKILL" })
75-
}
76-
77114
// Prepares the worktree of one evaluated commit. Dependencies install from
78115
// the worktree's own frozen lockfile, and that exact commit's extension and
79116
// webview build from its own sources. No artifact crosses sides.
80117
const prepareWorktree = async (worktree: string): Promise<void> => {
81-
await runBounded("pnpm", ["install", "--frozen-lockfile"], worktree, INSTALL_TIMEOUT_MS)
82-
await runBounded("pnpm", ["build"], worktree, BUILD_TIMEOUT_MS)
118+
await runBoundedPreparation(
119+
sideGuard,
120+
isInterrupted,
121+
"pnpm",
122+
["install", "--frozen-lockfile"],
123+
worktree,
124+
INSTALL_TIMEOUT_MS,
125+
)
126+
await runBoundedPreparation(sideGuard, isInterrupted, "pnpm", ["build"], worktree, BUILD_TIMEOUT_MS)
83127
}
84128

85129
// Runs one trusted side against the evaluated worktree. The side script comes
86130
// from this checkout, receives only the allowlisted environment plus the
87-
// named credential variable, and must finish inside its own wall clock.
131+
// named credential variable, re-checks the worktree commit against the trust
132+
// policy, and must finish inside its own wall clock.
88133
const runSide = async (
89134
side: "baseline" | "candidate",
90135
commit: string,
91136
inputs: ReturnType<typeof readArgs>,
92137
scratchDir: string,
93138
): Promise<SideResultFile> => {
139+
if (isInterrupted()) throw new Error(`interrupted before the ${side} side started`)
94140
const worktree = path.join(scratchDir, side)
95-
await runBounded("git", ["worktree", "add", "--detach", worktree, commit], root, WORKTREE_TIMEOUT_MS)
141+
await runBoundedPreparation(
142+
sideGuard,
143+
isInterrupted,
144+
"git",
145+
["worktree", "add", "--detach", worktree, commit],
146+
root,
147+
WORKTREE_TIMEOUT_MS,
148+
)
96149
await prepareWorktree(worktree)
150+
if (isInterrupted()) throw new Error(`interrupted before the ${side} side started`)
97151
const sideOut = path.join(scratchDir, `${side}-out`)
98152
await fs.mkdir(sideOut, { recursive: true })
99153
const exit = await new Promise<number>((resolve) => {
@@ -107,6 +161,8 @@ const runSide = async (
107161
String(inputs.reps),
108162
"--out",
109163
sideOut,
164+
"--trust-file",
165+
inputs.trustFile,
110166
"--credential-env",
111167
inputs.credentialEnv,
112168
"--evaluated-root",
@@ -120,6 +176,7 @@ const runSide = async (
120176
detached: true,
121177
},
122178
)
179+
sideGuard.track(child.pid)
123180
let timedOut = false
124181
const timer = setTimeout(() => {
125182
timedOut = true
@@ -138,18 +195,20 @@ const runSide = async (
138195
}, inputs.sideTimeoutMs)
139196
child.once("error", () => {
140197
clearTimeout(timer)
198+
sideGuard.track(undefined)
141199
resolve(1)
142200
})
143201
child.once("exit", (code) => {
144202
clearTimeout(timer)
203+
sideGuard.track(undefined)
145204
resolve(timedOut ? 1 : (code ?? 1))
146205
})
147206
})
148207
if (exit !== 0) throw new Error(`${side} side failed with exit code ${exit}`)
149-
const parsed: unknown = JSON.parse(await fs.readFile(path.join(sideOut, sideResultFileName(side)), "utf8"))
150-
// The side output must describe exactly the approved commit, or the pair
151-
// is invalid.
152-
return validateSideResultFile(parsed, commit)
208+
const parsed: unknown = JSON.parse(await fs.readFile(path.join(sideOut, sideResultFileName()), "utf8"))
209+
// The side output must describe exactly this side and its approved commit,
210+
// or the pair is invalid.
211+
return validateSideResultFile(parsed, side, commit)
153212
}
154213

155214
const inputs = readArgs()
@@ -171,17 +230,19 @@ try {
171230
candidate: toSideResult(await runSide("candidate", candidateCommit, inputs, scratchDir)),
172231
repetitions: inputs.reps,
173232
})
174-
// The reports are the comparison's publication point. They appear
175-
// atomically and only after both sides published complete results; any
176-
// failure above leaves no report files behind.
177-
await writeJsonAtomic(path.join(inputs.out, "comparison-report.json"), report)
178-
await writeFileAtomic(path.join(inputs.out, "comparison-report.md"), renderMarkdownReport(report))
233+
// The reports are the comparison's publication point. The pair is
234+
// published as one generation with a completion marker; any failure above
235+
// or during publication leaves no complete report generation behind.
236+
await publishComparisonReport(inputs.out, report)
179237
console.log(
180238
report.compatibility.compatible
181239
? `comparison complete: compatible pair, report in ${inputs.out}`
182240
: `comparison complete: INCOMPATIBLE pair (${report.compatibility.differences.join(", ")}), report in ${inputs.out}`,
183241
)
184242
} finally {
243+
// An interrupt escalation must finish before the worktrees go away, so
244+
// no child is still writing while its tree is deleted.
245+
await interruptKill
185246
await execFileAsync("git", ["worktree", "remove", "--force", path.join(scratchDir, "baseline")], {
186247
cwd: root,
187248
timeout: 30_000,
@@ -192,3 +253,7 @@ try {
192253
}).catch(() => {})
193254
await fs.rm(scratchDir, { recursive: true, force: true }).catch(() => {})
194255
}
256+
// An interrupt terminated the side group above, and the cleanup in the
257+
// finally block has completed. Exit with the conventional signal code.
258+
if (interruptSignal === "SIGINT") process.exit(130)
259+
if (interruptSignal === "SIGTERM") process.exit(143)

0 commit comments

Comments
 (0)