Skip to content

Commit b1bace7

Browse files
committed
fix(evals): contain completion-harness task paths and validate timeout
Confine custom task-set fixture/script/verify references to the harness directory, mark the version-controlled grading trust boundary, reject degenerate timeout values, and re-record the baseline against the current tree.
1 parent 77b8c30 commit b1bace7

5 files changed

Lines changed: 161 additions & 30 deletions

File tree

‎evals/completion/baseline-2026-09-14.json‎

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
22
"harness": "completion-baseline",
33
"version": 1,
4-
"startedAt": "2026-09-14T07:30:17.369Z",
5-
"finishedAt": "2026-09-14T07:30:19.083Z",
6-
"commitSha": "b509ed3edb0db5368eb190528d3beaee8895837b",
4+
"startedAt": "2026-09-14T19:57:32.797Z",
5+
"finishedAt": "2026-09-14T19:57:35.105Z",
6+
"commitSha": "77b8c30b445655cba06cffd4ab8bbde1e8adee25",
77
"provider": "stub-scripted",
88
"model": "completion-baseline-v1",
99
"repeats": 2,
@@ -25,8 +25,8 @@
2525
"doomLoopInterventions": 0,
2626
"thrashInterventions": 0,
2727
"gateSuspensions": 0,
28-
"agentDurationMs": 163,
29-
"verifyDurationMs": 149,
28+
"agentDurationMs": 252,
29+
"verifyDurationMs": 157,
3030
"verifyExitCode": 0,
3131
"overBudget": false
3232
},
@@ -45,8 +45,8 @@
4545
"doomLoopInterventions": 0,
4646
"thrashInterventions": 0,
4747
"gateSuspensions": 0,
48-
"agentDurationMs": 62,
49-
"verifyDurationMs": 141,
48+
"agentDurationMs": 95,
49+
"verifyDurationMs": 257,
5050
"verifyExitCode": 0,
5151
"overBudget": false
5252
},
@@ -65,8 +65,8 @@
6565
"doomLoopInterventions": 0,
6666
"thrashInterventions": 0,
6767
"gateSuspensions": 0,
68-
"agentDurationMs": 131,
69-
"verifyDurationMs": 135,
68+
"agentDurationMs": 92,
69+
"verifyDurationMs": 154,
7070
"verifyExitCode": 0,
7171
"overBudget": false
7272
},
@@ -85,8 +85,8 @@
8585
"doomLoopInterventions": 0,
8686
"thrashInterventions": 0,
8787
"gateSuspensions": 0,
88-
"agentDurationMs": 66,
89-
"verifyDurationMs": 127,
88+
"agentDurationMs": 69,
89+
"verifyDurationMs": 152,
9090
"verifyExitCode": 0,
9191
"overBudget": false
9292
},
@@ -105,8 +105,8 @@
105105
"doomLoopInterventions": 0,
106106
"thrashInterventions": 0,
107107
"gateSuspensions": 0,
108-
"agentDurationMs": 30,
109-
"verifyDurationMs": 72,
108+
"agentDurationMs": 86,
109+
"verifyDurationMs": 93,
110110
"verifyExitCode": 1,
111111
"overBudget": false
112112
},
@@ -125,8 +125,8 @@
125125
"doomLoopInterventions": 0,
126126
"thrashInterventions": 0,
127127
"gateSuspensions": 0,
128-
"agentDurationMs": 22,
129-
"verifyDurationMs": 65,
128+
"agentDurationMs": 21,
129+
"verifyDurationMs": 80,
130130
"verifyExitCode": 1,
131131
"overBudget": false
132132
},
@@ -145,8 +145,8 @@
145145
"doomLoopInterventions": 1,
146146
"thrashInterventions": 0,
147147
"gateSuspensions": 0,
148-
"agentDurationMs": 95,
149-
"verifyDurationMs": 16,
148+
"agentDurationMs": 66,
149+
"verifyDurationMs": 19,
150150
"verifyExitCode": 2,
151151
"overBudget": false,
152152
"error": "reactor error: Doom loop detected: an identical tool batch (read_file) executed 3 times consecutively"
@@ -166,7 +166,7 @@
166166
"doomLoopInterventions": 1,
167167
"thrashInterventions": 0,
168168
"gateSuspensions": 0,
169-
"agentDurationMs": 65,
169+
"agentDurationMs": 75,
170170
"verifyDurationMs": 17,
171171
"verifyExitCode": 2,
172172
"overBudget": false,
@@ -179,7 +179,7 @@
179179
"completedRuns": 4,
180180
"completionRate": 0.5,
181181
"meanTurnsToCompletion": 3,
182-
"meanAgentDurationMs": 79.25,
182+
"meanAgentDurationMs": 94.5,
183183
"totalRetries": 0,
184184
"totalCompactionEvents": 0,
185185
"totalDoomLoopInterventions": 2,

‎evals/completion/lib.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { describe, expect, test } from "bun:test";
22
import {
33
CompletionReport,
4+
assertTaskSetContained,
45
computeTotals,
56
formatSummary,
67
isCompletedRun,
78
parseResponderScript,
89
parseTaskSetFile,
10+
resolveTaskRelativePath,
911
TaskResult,
1012
type CompletionReport as CompletionReportType,
1113
type TaskResult as TaskResultType,
@@ -109,6 +111,59 @@ describe("boundary parsing", () => {
109111
});
110112
});
111113

114+
describe("task path containment", () => {
115+
const root = "/repo/evals/completion";
116+
117+
test("keeps relative fixture paths inside the harness directory", () => {
118+
expect(resolveTaskRelativePath(root, "tasks/sum-fix/fixture")).toBe(
119+
"/repo/evals/completion/tasks/sum-fix/fixture",
120+
);
121+
});
122+
123+
test("rejects absolute escapes", () => {
124+
expect(() => resolveTaskRelativePath(root, "/etc/passwd")).toThrow(
125+
/escapes the harness directory/,
126+
);
127+
});
128+
129+
test("rejects dot-dot escapes outside the harness directory", () => {
130+
expect(() => resolveTaskRelativePath(root, "../capability/lib.ts")).toThrow(
131+
/escapes the harness directory/,
132+
);
133+
expect(() =>
134+
resolveTaskRelativePath(root, "tasks/../../package.json"),
135+
).toThrow(/escapes the harness directory/);
136+
});
137+
138+
test("rejects an empty reference", () => {
139+
expect(() => resolveTaskRelativePath(root, " ")).toThrow(
140+
/escapes the harness directory/,
141+
);
142+
});
143+
144+
test("assertTaskSetContained rejects a task set with an escaped grader", () => {
145+
const taskSet = parseTaskSetFile({
146+
version: 1,
147+
note: "x",
148+
tasks: [
149+
{
150+
id: "evil",
151+
title: "evil",
152+
profile: "solve",
153+
prompt: "p",
154+
fixture: "tasks/sum-fix/fixture",
155+
script: "tasks/sum-fix/script.json",
156+
verify: "/tmp/evil.sh",
157+
maxTurns: 1,
158+
},
159+
],
160+
});
161+
expect(() => assertTaskSetContained(taskSet, root)).toThrow(
162+
/escapes the harness directory/,
163+
);
164+
});
165+
});
166+
112167
describe("human summary", () => {
113168
test("names the harness, provenance, rate, and every run", () => {
114169
const results = [

‎evals/completion/lib.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,18 @@
33
* No process I/O here: the runner (scripts/eval-completion.ts) owns the
44
* filesystem, child processes, and the agent loop; this module owns the
55
* shapes at the boundary (arktype) plus completion math and the summary.
6+
*
7+
* TRUST BOUNDARY: the version-controlled task and grading files under
8+
* evals/completion/tasks/ (tasks.json, per-task script.json, verify.sh, and
9+
* fixture/ copies) plus evals/completion/lib.ts and
10+
* scripts/eval-completion.ts are the trusted grading boundary. Treat edits to
11+
* those paths as grading changes requiring owner review; custom --tasks JSON
12+
* passed on the CLI is untrusted input and its fixture/script/verify
13+
* references stay confined to evals/completion/ via
14+
* resolveTaskRelativePath (absolute paths and `..` escapes are rejected).
615
*/
716

17+
import { isAbsolute, relative, resolve } from "node:path";
818
import { type } from "arktype";
919

1020
export const ResponderProfile = type("'solve' | 'decline' | 'stall'");
@@ -114,6 +124,36 @@ export function parseResponderScript(payload: unknown): ResponderScript {
114124
return ResponderScript.assert(payload);
115125
}
116126

127+
/**
128+
* Resolve a task-set fixture/script/verify reference against the harness
129+
* task directory. Custom task sets are untrusted: absolute paths and `..`
130+
* escapes outside the directory are rejected so fixture copies and grader
131+
* spawns cannot reach outside evals/completion/.
132+
*/
133+
export function resolveTaskRelativePath(taskDir: string, ref: string): string {
134+
if (ref.trim() === "" || isAbsolute(ref)) {
135+
throw new Error(`Task path escapes the harness directory: ${ref}`);
136+
}
137+
const resolved = resolve(taskDir, ref);
138+
const rel = relative(taskDir, resolved);
139+
if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) {
140+
throw new Error(`Task path escapes the harness directory: ${ref}`);
141+
}
142+
return resolved;
143+
}
144+
145+
/** Assert every task-set path reference stays inside the harness directory. */
146+
export function assertTaskSetContained(
147+
taskSet: CompletionTaskSet,
148+
taskDir: string,
149+
): void {
150+
for (const task of taskSet.tasks) {
151+
resolveTaskRelativePath(taskDir, task.fixture);
152+
resolveTaskRelativePath(taskDir, task.script);
153+
resolveTaskRelativePath(taskDir, task.verify);
154+
}
155+
}
156+
117157
/**
118158
* Completion predicate shared by the runner and tests: a run counts as
119159
* complete only when the agent loop finished cleanly, the grader passed,

‎scripts/eval-completion.test.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from "bun:test";
2-
import { withTimeout } from "./eval-completion.js";
2+
import { parseArgs, withTimeout } from "./eval-completion.js";
33

44
describe("withTimeout", () => {
55
test("rejects a hung run after the timeout", async () => {
@@ -29,3 +29,15 @@ describe("withTimeout", () => {
2929
).rejects.toThrow("boom");
3030
});
3131
});
32+
33+
describe("parseArgs timeout validation", () => {
34+
test("rejects NaN, zero, and negative timeouts", () => {
35+
expect(() => parseArgs(["--timeout-ms", "NaN"])).toThrow(/--timeout-ms/);
36+
expect(() => parseArgs(["--timeout-ms", "0"])).toThrow(/--timeout-ms/);
37+
expect(() => parseArgs(["--timeout-ms", "-5"])).toThrow(/--timeout-ms/);
38+
});
39+
40+
test("accepts a positive integer timeout", () => {
41+
expect(parseArgs(["--timeout-ms", "1000"]).timeoutMs).toBe(1000);
42+
});
43+
});

‎scripts/eval-completion.ts‎

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@
1414
* purpose: solve/decline/stall profiles exercise finish, decline, and
1515
* guard-trip paths deterministically so the 0.4.x re-measure sees the
1616
* control layer move, not provider noise.
17+
*
18+
* TRUST BOUNDARY: the version-controlled files under evals/completion/tasks/
19+
* (tasks.json, per-task script.json, verify.sh, fixture/) are the trusted
20+
* grading boundary — edits there are grading changes requiring owner review.
21+
* A custom --tasks JSON file is untrusted: its fixture/script/verify
22+
* references are confined to evals/completion/ (see resolveTaskRelativePath;
23+
* absolute paths and `..` escapes are rejected) before any fixture copy or
24+
* grader spawn.
1725
*/
1826

1927
import { cpSync } from "node:fs";
@@ -29,11 +37,13 @@ import {
2937
} from "../tests/integration/harness.js";
3038
import {
3139
CompletionReport,
40+
assertTaskSetContained,
3241
computeTotals,
3342
formatSummary,
3443
isCompletedRun,
3544
parseResponderScript,
3645
parseTaskSetFile,
46+
resolveTaskRelativePath,
3747
TaskResult,
3848
type CompletionTask,
3949
type RunStatus,
@@ -64,7 +74,7 @@ function printUsage(): void {
6474
--help Print this message`);
6575
}
6676

67-
function parseArgs(argv: string[]): CliOptions {
77+
export function parseArgs(argv: string[]): CliOptions {
6878
const opts: CliOptions = {
6979
tasksPath: join(COMPLETION_ROOT, "tasks.json"),
7080
caseId: "all",
@@ -101,6 +111,11 @@ function parseArgs(argv: string[]): CliOptions {
101111
`--repeats must be a positive integer, got ${opts.repeats}`,
102112
);
103113
}
114+
if (!Number.isInteger(opts.timeoutMs) || opts.timeoutMs <= 0) {
115+
throw new Error(
116+
`--timeout-ms must be a positive integer, got ${opts.timeoutMs}`,
117+
);
118+
}
104119
return opts;
105120
}
106121

@@ -213,11 +228,15 @@ async function runTask(
213228
});
214229
const agentStart = Date.now();
215230
try {
216-
cpSync(resolve(COMPLETION_ROOT, task.fixture), session.cwd, {
217-
recursive: true,
218-
});
231+
cpSync(
232+
resolveTaskRelativePath(COMPLETION_ROOT, task.fixture),
233+
session.cwd,
234+
{
235+
recursive: true,
236+
},
237+
);
219238
const scriptRaw = await readFile(
220-
resolve(COMPLETION_ROOT, task.script),
239+
resolveTaskRelativePath(COMPLETION_ROOT, task.script),
221240
"utf8",
222241
);
223242
const script = parseResponderScript(JSON.parse(scriptRaw));
@@ -280,11 +299,15 @@ async function runTask(
280299
if (signals.runFailed) runStatus = "failed";
281300

282301
const verifyStart = Date.now();
283-
const verify = spawnSync("bash", [resolve(COMPLETION_ROOT, task.verify)], {
284-
cwd: session.cwd,
285-
encoding: "utf8",
286-
timeout: 60_000,
287-
});
302+
const verify = spawnSync(
303+
"bash",
304+
[resolveTaskRelativePath(COMPLETION_ROOT, task.verify)],
305+
{
306+
cwd: session.cwd,
307+
encoding: "utf8",
308+
timeout: 60_000,
309+
},
310+
);
288311
const verifyDurationMs = Date.now() - verifyStart;
289312
const verifyExitCode = verify.status ?? 1;
290313

@@ -330,6 +353,7 @@ async function main(): Promise<void> {
330353
const taskSet = parseTaskSetFile(
331354
JSON.parse(await readFile(opts.tasksPath, "utf8")),
332355
);
356+
assertTaskSetContained(taskSet, COMPLETION_ROOT);
333357
const tasks =
334358
opts.caseId === "all"
335359
? taskSet.tasks

0 commit comments

Comments
 (0)