@crewhaus/tool-harness-synthesizer 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -12
- package/src/index.test.ts +318 -23
- package/src/index.ts +166 -87
- package/src/sandboxed-eval.ts +192 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/tool-harness-synthesizer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Track D / §55 — Thompson-sampled tree search over candidate verifier functions for skills and tools. Smaller LLM + synthesized verifier beats larger LLM (AutoHarness, arxiv 2603.03329).",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -12,14 +12,15 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.
|
|
16
|
-
"@crewhaus/prompt-optimizer": "0.
|
|
15
|
+
"@crewhaus/errors": "0.1.2",
|
|
16
|
+
"@crewhaus/prompt-optimizer": "0.1.2",
|
|
17
|
+
"@crewhaus/sandbox": "0.1.2"
|
|
17
18
|
},
|
|
18
19
|
"license": "Apache-2.0",
|
|
19
20
|
"author": {
|
|
20
21
|
"name": "Max Meier",
|
|
21
|
-
"email": "max@
|
|
22
|
-
"url": "https://
|
|
22
|
+
"email": "max@crewhaus.ai",
|
|
23
|
+
"url": "https://crewhaus.ai"
|
|
23
24
|
},
|
|
24
25
|
"repository": {
|
|
25
26
|
"type": "git",
|
|
@@ -31,12 +32,7 @@
|
|
|
31
32
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
32
33
|
},
|
|
33
34
|
"publishConfig": {
|
|
34
|
-
"access": "
|
|
35
|
+
"access": "public"
|
|
35
36
|
},
|
|
36
|
-
"files": [
|
|
37
|
-
"src",
|
|
38
|
-
"README.md",
|
|
39
|
-
"LICENSE",
|
|
40
|
-
"NOTICE"
|
|
41
|
-
]
|
|
37
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
42
38
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -1,11 +1,41 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { OptimizerState } from "@crewhaus/prompt-optimizer";
|
|
3
|
+
import type { Sandbox, SandboxExecOptions, SandboxExecResult } from "@crewhaus/sandbox";
|
|
2
4
|
import {
|
|
3
5
|
HarnessSynthesizerError,
|
|
6
|
+
VerifierMutationProvider,
|
|
4
7
|
type VerifierSample,
|
|
5
8
|
runVerifier,
|
|
6
9
|
synthesizeVerifier,
|
|
7
10
|
thompsonPick,
|
|
8
11
|
} from "./index";
|
|
12
|
+
import { VERIFIER_SENTINEL, evalVerifierPayload, runVerifierInSandbox } from "./sandboxed-eval";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Test double for `@crewhaus/sandbox`. Reports `backend: "docker"` so it
|
|
16
|
+
* passes `runVerifier`'s fail-closed gate (the real `noop` backend is
|
|
17
|
+
* rejected), and reproduces the in-container harness output by running the
|
|
18
|
+
* SAME `evalVerifierPayload` routine in-process. Deterministic, no docker
|
|
19
|
+
* daemon. The verifier strings under test are trusted, so evaluating them
|
|
20
|
+
* in-process here (rather than in a real jail) is safe.
|
|
21
|
+
*/
|
|
22
|
+
class FakeDockerSandbox implements Sandbox {
|
|
23
|
+
readonly backend = "docker" as const;
|
|
24
|
+
readonly calls: SandboxExecOptions[] = [];
|
|
25
|
+
async exec(opts: SandboxExecOptions): Promise<SandboxExecResult> {
|
|
26
|
+
this.calls.push(opts);
|
|
27
|
+
const payload = JSON.parse(opts.stdin ?? "{}");
|
|
28
|
+
const result = evalVerifierPayload(payload);
|
|
29
|
+
return {
|
|
30
|
+
stdout: VERIFIER_SENTINEL + JSON.stringify(result),
|
|
31
|
+
stderr: "",
|
|
32
|
+
exitCode: 0,
|
|
33
|
+
timedOut: false,
|
|
34
|
+
durationMs: 1,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async close(): Promise<void> {}
|
|
38
|
+
}
|
|
9
39
|
|
|
10
40
|
const evenSamples: VerifierSample[] = [
|
|
11
41
|
{ input: null, output: 0, expected: true },
|
|
@@ -16,45 +46,68 @@ const evenSamples: VerifierSample[] = [
|
|
|
16
46
|
];
|
|
17
47
|
|
|
18
48
|
describe("runVerifier", () => {
|
|
19
|
-
|
|
20
|
-
|
|
49
|
+
const sandbox = new FakeDockerSandbox();
|
|
50
|
+
|
|
51
|
+
test("scores a correct verifier at 1.0", async () => {
|
|
52
|
+
const r = await runVerifier(
|
|
53
|
+
"return typeof output === 'number' && output % 2 === 0",
|
|
54
|
+
evenSamples,
|
|
55
|
+
{ sandbox },
|
|
56
|
+
);
|
|
21
57
|
expect(r.heuristic).toBe(1);
|
|
22
58
|
expect(r.errors).toBe(0);
|
|
23
59
|
expect(r.verdicts).toEqual([true, false, true, false, true]);
|
|
24
60
|
});
|
|
25
61
|
|
|
26
|
-
test("scores a constant-true verifier at the majority class", () => {
|
|
27
|
-
const r = runVerifier("return true", evenSamples);
|
|
62
|
+
test("scores a constant-true verifier at the majority class", async () => {
|
|
63
|
+
const r = await runVerifier("return true", evenSamples, { sandbox });
|
|
28
64
|
// 3 of 5 expected: true → score 0.6
|
|
29
65
|
expect(r.heuristic).toBe(0.6);
|
|
30
66
|
});
|
|
31
67
|
|
|
32
|
-
test("captures runtime errors without throwing", () => {
|
|
33
|
-
const r = runVerifier("throw new Error('boom')", evenSamples);
|
|
68
|
+
test("captures runtime errors without throwing", async () => {
|
|
69
|
+
const r = await runVerifier("throw new Error('boom')", evenSamples, { sandbox });
|
|
34
70
|
expect(r.errors).toBe(5);
|
|
35
71
|
expect(r.heuristic).toBe(0.4); // false vs expected: 2 of 5 are expected false
|
|
36
72
|
});
|
|
37
73
|
|
|
38
|
-
test("throws on uncompilable code", () => {
|
|
39
|
-
expect(
|
|
74
|
+
test("throws on uncompilable code", async () => {
|
|
75
|
+
await expect(runVerifier("not valid javascript {{{", evenSamples, { sandbox })).rejects.toThrow(
|
|
76
|
+
HarnessSynthesizerError,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("fails closed on a noop (non-isolating) sandbox", async () => {
|
|
81
|
+
const noop: Sandbox = {
|
|
82
|
+
backend: "noop",
|
|
83
|
+
exec: async () => {
|
|
84
|
+
throw new Error("must not run untrusted code under noop");
|
|
85
|
+
},
|
|
86
|
+
close: async () => {},
|
|
87
|
+
};
|
|
88
|
+
await expect(runVerifier("return true", evenSamples, { sandbox: noop })).rejects.toThrow(
|
|
40
89
|
HarnessSynthesizerError,
|
|
41
90
|
);
|
|
42
91
|
});
|
|
92
|
+
|
|
93
|
+
test("ships code + label-free samples to node:22-alpine via stdin", async () => {
|
|
94
|
+
const sb = new FakeDockerSandbox();
|
|
95
|
+
await runVerifier("return true", evenSamples, { sandbox: sb });
|
|
96
|
+
const call = sb.calls[0];
|
|
97
|
+
expect(call?.image).toBe("node:22-alpine");
|
|
98
|
+
expect(call?.argv?.[0]).toBe("node");
|
|
99
|
+
const payload = JSON.parse(call?.stdin ?? "{}");
|
|
100
|
+
expect(payload.code).toBe("return true");
|
|
101
|
+
expect(payload.samples).toHaveLength(5);
|
|
102
|
+
// The jail must never receive the labels it is scored against.
|
|
103
|
+
expect(JSON.stringify(payload)).not.toContain("expected");
|
|
104
|
+
});
|
|
43
105
|
});
|
|
44
106
|
|
|
45
107
|
describe("thompsonPick", () => {
|
|
46
108
|
test("returns 0 for a single candidate", () => {
|
|
47
109
|
const idx = thompsonPick(
|
|
48
|
-
[
|
|
49
|
-
{
|
|
50
|
-
id: "x",
|
|
51
|
-
code: "return true",
|
|
52
|
-
score: 1,
|
|
53
|
-
heuristic: 1,
|
|
54
|
-
alpha: 10,
|
|
55
|
-
beta: 1,
|
|
56
|
-
},
|
|
57
|
-
],
|
|
110
|
+
[{ id: "x", code: "return true", score: 1, heuristic: 1, alpha: 10, beta: 1 }],
|
|
58
111
|
() => 0.5,
|
|
59
112
|
);
|
|
60
113
|
expect(idx).toBe(0);
|
|
@@ -65,20 +118,21 @@ describe("thompsonPick", () => {
|
|
|
65
118
|
{ id: "a", code: "1", score: 0.1, heuristic: 0.1, alpha: 1, beta: 9 },
|
|
66
119
|
{ id: "b", code: "1", score: 0.9, heuristic: 0.9, alpha: 9, beta: 1 },
|
|
67
120
|
];
|
|
68
|
-
// RNG always 0.5 — Marsaglia normal is degenerate; we just verify it
|
|
69
|
-
// doesn't crash and returns a valid index.
|
|
70
121
|
const idx = thompsonPick(nodes, () => 0.5);
|
|
71
122
|
expect([0, 1]).toContain(idx);
|
|
72
123
|
});
|
|
73
124
|
});
|
|
74
125
|
|
|
75
126
|
describe("synthesizeVerifier", () => {
|
|
127
|
+
const sandbox = new FakeDockerSandbox();
|
|
128
|
+
|
|
76
129
|
test("returns immediately when a seed already meets target", async () => {
|
|
77
130
|
const result = await synthesizeVerifier({
|
|
78
131
|
seedCandidates: ["return typeof output === 'number' && output % 2 === 0"],
|
|
79
132
|
samples: evenSamples,
|
|
80
133
|
refiner: async () => "throw new Error('should not be called')",
|
|
81
134
|
target: 1.0,
|
|
135
|
+
sandbox,
|
|
82
136
|
});
|
|
83
137
|
expect(result.converged).toBe(true);
|
|
84
138
|
expect(result.iterations).toBe(0);
|
|
@@ -86,9 +140,6 @@ describe("synthesizeVerifier", () => {
|
|
|
86
140
|
});
|
|
87
141
|
|
|
88
142
|
test("converges via refiner when seed is poor", async () => {
|
|
89
|
-
// Start with a constant-true seed; refiner produces the correct
|
|
90
|
-
// verifier on the first call. This proves the search loop wires
|
|
91
|
-
// refiner → score → pool update correctly.
|
|
92
143
|
const result = await synthesizeVerifier({
|
|
93
144
|
seedCandidates: ["return true"],
|
|
94
145
|
samples: evenSamples,
|
|
@@ -96,6 +147,7 @@ describe("synthesizeVerifier", () => {
|
|
|
96
147
|
target: 1.0,
|
|
97
148
|
maxIterations: 3,
|
|
98
149
|
rng: () => 0.5,
|
|
150
|
+
sandbox,
|
|
99
151
|
});
|
|
100
152
|
expect(result.converged).toBe(true);
|
|
101
153
|
expect(result.best.heuristic).toBe(1);
|
|
@@ -109,11 +161,34 @@ describe("synthesizeVerifier", () => {
|
|
|
109
161
|
target: 1.0,
|
|
110
162
|
maxIterations: 3,
|
|
111
163
|
rng: () => 0.5,
|
|
164
|
+
sandbox,
|
|
112
165
|
});
|
|
113
166
|
expect(result.converged).toBe(false);
|
|
114
167
|
expect(result.best.heuristic).toBeGreaterThanOrEqual(0.6);
|
|
115
168
|
});
|
|
116
169
|
|
|
170
|
+
test("picks the higher-heuristic seed when multiple seeds are supplied", async () => {
|
|
171
|
+
// Two seeds → the `pool.reduce` comparator runs (with one seed reduce
|
|
172
|
+
// returns the lone element without invoking the callback). The perfect
|
|
173
|
+
// seed must win and short-circuit at iteration 0.
|
|
174
|
+
const result = await synthesizeVerifier({
|
|
175
|
+
seedCandidates: [
|
|
176
|
+
"return false", // heuristic 0.4
|
|
177
|
+
"return typeof output === 'number' && output % 2 === 0", // heuristic 1.0
|
|
178
|
+
],
|
|
179
|
+
samples: evenSamples,
|
|
180
|
+
refiner: async () => "throw new Error('should not be called')",
|
|
181
|
+
target: 1.0,
|
|
182
|
+
rng: () => 0.5,
|
|
183
|
+
sandbox,
|
|
184
|
+
});
|
|
185
|
+
expect(result.converged).toBe(true);
|
|
186
|
+
expect(result.iterations).toBe(0);
|
|
187
|
+
expect(result.best.id).toBe("seed_1");
|
|
188
|
+
expect(result.best.heuristic).toBe(1);
|
|
189
|
+
expect(result.trajectory).toHaveLength(2);
|
|
190
|
+
});
|
|
191
|
+
|
|
117
192
|
test("throws on empty seed candidates", async () => {
|
|
118
193
|
await expect(
|
|
119
194
|
synthesizeVerifier({
|
|
@@ -134,3 +209,223 @@ describe("synthesizeVerifier", () => {
|
|
|
134
209
|
).rejects.toThrow(HarnessSynthesizerError);
|
|
135
210
|
});
|
|
136
211
|
});
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A sandbox whose container call returns a caller-supplied result verbatim, so
|
|
215
|
+
* tests can drive `runVerifierInSandbox`'s error branches (timeout, nonzero
|
|
216
|
+
* exit, empty/garbled output) deterministically without a real container.
|
|
217
|
+
*/
|
|
218
|
+
class ScriptedSandbox implements Sandbox {
|
|
219
|
+
readonly backend = "docker" as const;
|
|
220
|
+
readonly calls: SandboxExecOptions[] = [];
|
|
221
|
+
constructor(private readonly result: SandboxExecResult) {}
|
|
222
|
+
async exec(opts: SandboxExecOptions): Promise<SandboxExecResult> {
|
|
223
|
+
this.calls.push(opts);
|
|
224
|
+
return this.result;
|
|
225
|
+
}
|
|
226
|
+
async close(): Promise<void> {}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const ioSamples: VerifierSample[] = [{ input: null, output: 0, expected: true }];
|
|
230
|
+
|
|
231
|
+
describe("VerifierMutationProvider", () => {
|
|
232
|
+
const baseState = (prompt: string): OptimizerState =>
|
|
233
|
+
({
|
|
234
|
+
iteration: 0,
|
|
235
|
+
best: { id: "c0", prompt, mutations: [], score: 0.5 },
|
|
236
|
+
trajectory: [],
|
|
237
|
+
trainSet: [],
|
|
238
|
+
devSet: [],
|
|
239
|
+
}) as OptimizerState;
|
|
240
|
+
|
|
241
|
+
test("next() runs the inner search and appends a verifier annotation", async () => {
|
|
242
|
+
const sandbox = new FakeDockerSandbox();
|
|
243
|
+
const provider = new VerifierMutationProvider(
|
|
244
|
+
evenSamples,
|
|
245
|
+
// Seed is already perfect, so the inner search converges at iteration 0.
|
|
246
|
+
async () => "return false",
|
|
247
|
+
["return typeof output === 'number' && output % 2 === 0"],
|
|
248
|
+
4,
|
|
249
|
+
sandbox,
|
|
250
|
+
);
|
|
251
|
+
expect(provider.name).toBe("verifier-synthesis");
|
|
252
|
+
const result = await provider.next(baseState("BASE PROMPT"));
|
|
253
|
+
expect(result.prompt.startsWith("BASE PROMPT")).toBe(true);
|
|
254
|
+
expect(result.prompt).toContain("[verifier seed_0, h=1.000]");
|
|
255
|
+
expect(result.mutations).toEqual([{ kind: "rephrase-instruction" }]);
|
|
256
|
+
expect(result.rationale).toContain("verifier-synthesis pass 1");
|
|
257
|
+
expect(result.rationale).toContain("(converged)");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("next() increments the synthesis pass counter across calls", async () => {
|
|
261
|
+
const sandbox = new FakeDockerSandbox();
|
|
262
|
+
const provider = new VerifierMutationProvider(
|
|
263
|
+
evenSamples,
|
|
264
|
+
async () => "return typeof output === 'number' && output % 2 === 0",
|
|
265
|
+
["return typeof output === 'number' && output % 2 === 0"],
|
|
266
|
+
4,
|
|
267
|
+
sandbox,
|
|
268
|
+
);
|
|
269
|
+
const first = await provider.next(baseState("P"));
|
|
270
|
+
const second = await provider.next(baseState("P"));
|
|
271
|
+
expect(first.rationale).toContain("pass 1");
|
|
272
|
+
expect(second.rationale).toContain("pass 2");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("omits the sandbox option when none is injected (default backend path)", async () => {
|
|
276
|
+
// No sandbox passed → synthesizeVerifier creates the default backend. Pin it
|
|
277
|
+
// to `noop` so the fail-closed gate throws BEFORE any container is spawned —
|
|
278
|
+
// this exercises the `this.sandbox === undefined` branch with zero real I/O.
|
|
279
|
+
const prev = process.env["CREWHAUS_SANDBOX"];
|
|
280
|
+
process.env["CREWHAUS_SANDBOX"] = "noop";
|
|
281
|
+
try {
|
|
282
|
+
const provider = new VerifierMutationProvider(
|
|
283
|
+
evenSamples,
|
|
284
|
+
async () => "return true",
|
|
285
|
+
["return true"],
|
|
286
|
+
1,
|
|
287
|
+
);
|
|
288
|
+
await expect(provider.next(baseState("P"))).rejects.toThrow(HarnessSynthesizerError);
|
|
289
|
+
} finally {
|
|
290
|
+
// Restore exactly: an unset var must be *removed*, not set to the string
|
|
291
|
+
// "undefined" (which `readEnvBackend` would reject). `Reflect.deleteProperty`
|
|
292
|
+
// does this without tripping biome's noDelete rule.
|
|
293
|
+
if (prev === undefined) Reflect.deleteProperty(process.env, "CREWHAUS_SANDBOX");
|
|
294
|
+
else process.env["CREWHAUS_SANDBOX"] = prev;
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("non-converged inner search omits the (converged) marker", async () => {
|
|
299
|
+
const sandbox = new FakeDockerSandbox();
|
|
300
|
+
const provider = new VerifierMutationProvider(
|
|
301
|
+
evenSamples,
|
|
302
|
+
async () => "return false", // 0.4 — never reaches 1.0
|
|
303
|
+
["return false"],
|
|
304
|
+
2,
|
|
305
|
+
sandbox,
|
|
306
|
+
);
|
|
307
|
+
const result = await provider.next(baseState("P"));
|
|
308
|
+
expect(result.rationale).not.toContain("(converged)");
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("runVerifierInSandbox error branches", () => {
|
|
313
|
+
test("throws on non-JSON-serializable samples (BigInt)", async () => {
|
|
314
|
+
const sandbox = new FakeDockerSandbox();
|
|
315
|
+
const bad: VerifierSample[] = [{ input: 1n, output: 0, expected: true }];
|
|
316
|
+
await expect(runVerifierInSandbox(sandbox, "return true", bad)).rejects.toThrow(
|
|
317
|
+
/not JSON-serializable/,
|
|
318
|
+
);
|
|
319
|
+
// Nothing was shipped to the jail.
|
|
320
|
+
expect(sandbox.calls).toHaveLength(0);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("throws when the harness times out", async () => {
|
|
324
|
+
const sandbox = new ScriptedSandbox({
|
|
325
|
+
stdout: "",
|
|
326
|
+
stderr: "",
|
|
327
|
+
exitCode: 0,
|
|
328
|
+
timedOut: true,
|
|
329
|
+
durationMs: 1,
|
|
330
|
+
});
|
|
331
|
+
await expect(
|
|
332
|
+
runVerifierInSandbox(sandbox, "return true", ioSamples, { timeoutMs: 1234 }),
|
|
333
|
+
).rejects.toThrow(/timed out after 1234ms/);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("throws with stderr tail when the harness exits nonzero and emits no result", async () => {
|
|
337
|
+
const sandbox = new ScriptedSandbox({
|
|
338
|
+
stdout: "no sentinel here",
|
|
339
|
+
stderr: "fatal: allocation failure",
|
|
340
|
+
exitCode: 137,
|
|
341
|
+
timedOut: false,
|
|
342
|
+
durationMs: 1,
|
|
343
|
+
});
|
|
344
|
+
await expect(runVerifierInSandbox(sandbox, "return true", ioSamples)).rejects.toThrow(
|
|
345
|
+
/exited 137: fatal: allocation failure/,
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("throws 'no result' when exit is clean but no sentinel is present", async () => {
|
|
350
|
+
const sandbox = new ScriptedSandbox({
|
|
351
|
+
stdout: "garbage without the sentinel",
|
|
352
|
+
stderr: "",
|
|
353
|
+
exitCode: 0,
|
|
354
|
+
timedOut: false,
|
|
355
|
+
durationMs: 1,
|
|
356
|
+
});
|
|
357
|
+
await expect(runVerifierInSandbox(sandbox, "return true", ioSamples)).rejects.toThrow(
|
|
358
|
+
/produced no result/,
|
|
359
|
+
);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("throws on a verdict-count mismatch", async () => {
|
|
363
|
+
// Sentinel present, valid JSON, but two verdicts for one sample.
|
|
364
|
+
const sandbox = new ScriptedSandbox({
|
|
365
|
+
stdout: `${VERIFIER_SENTINEL}${JSON.stringify({ verdicts: [true, false], errors: 0 })}`,
|
|
366
|
+
stderr: "",
|
|
367
|
+
exitCode: 0,
|
|
368
|
+
timedOut: false,
|
|
369
|
+
durationMs: 1,
|
|
370
|
+
});
|
|
371
|
+
await expect(runVerifierInSandbox(sandbox, "return true", ioSamples)).rejects.toThrow(
|
|
372
|
+
/returned 2 verdicts for 1 samples/,
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("surfaces a compileError reported by the harness", async () => {
|
|
377
|
+
const sandbox = new ScriptedSandbox({
|
|
378
|
+
stdout: `${VERIFIER_SENTINEL}${JSON.stringify({ compileError: "Unexpected token" })}`,
|
|
379
|
+
stderr: "",
|
|
380
|
+
exitCode: 0,
|
|
381
|
+
timedOut: false,
|
|
382
|
+
durationMs: 1,
|
|
383
|
+
});
|
|
384
|
+
await expect(runVerifierInSandbox(sandbox, "bad {{", ioSamples)).rejects.toThrow(
|
|
385
|
+
/did not compile: Unexpected token/,
|
|
386
|
+
);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe("parseHarnessResult edge cases (via runVerifierInSandbox)", () => {
|
|
391
|
+
test("treats a non-JSON result line after the sentinel as no result", async () => {
|
|
392
|
+
// Exercises the JSON.parse catch inside parseHarnessResult (returns undefined).
|
|
393
|
+
const sandbox = new ScriptedSandbox({
|
|
394
|
+
stdout: `${VERIFIER_SENTINEL}this-is-not-json`,
|
|
395
|
+
stderr: "",
|
|
396
|
+
exitCode: 0,
|
|
397
|
+
timedOut: false,
|
|
398
|
+
durationMs: 1,
|
|
399
|
+
});
|
|
400
|
+
await expect(runVerifierInSandbox(sandbox, "return true", ioSamples)).rejects.toThrow(
|
|
401
|
+
/produced no result/,
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("treats a sentinel-framed object without a verdicts array as no result", async () => {
|
|
406
|
+
// Valid JSON object, but neither a compileError string nor a verdicts array
|
|
407
|
+
// → parseHarnessResult falls through to its final `return undefined`.
|
|
408
|
+
const sandbox = new ScriptedSandbox({
|
|
409
|
+
stdout: `${VERIFIER_SENTINEL}${JSON.stringify({ verdicts: "not-an-array" })}`,
|
|
410
|
+
stderr: "",
|
|
411
|
+
exitCode: 0,
|
|
412
|
+
timedOut: false,
|
|
413
|
+
durationMs: 1,
|
|
414
|
+
});
|
|
415
|
+
await expect(runVerifierInSandbox(sandbox, "return true", ioSamples)).rejects.toThrow(
|
|
416
|
+
/produced no result/,
|
|
417
|
+
);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe("runVerifier (live docker — gated by CREWHAUS_VERIFIER_LIVE_DOCKER=1)", () => {
|
|
422
|
+
test("scores a basic verifier in a real container", async () => {
|
|
423
|
+
if (process.env["CREWHAUS_VERIFIER_LIVE_DOCKER"] !== "1") return; // skipped by default
|
|
424
|
+
const r = await runVerifier(
|
|
425
|
+
"return typeof output === 'number' && output % 2 === 0",
|
|
426
|
+
evenSamples,
|
|
427
|
+
);
|
|
428
|
+
expect(r.heuristic).toBe(1);
|
|
429
|
+
expect(r.errors).toBe(0);
|
|
430
|
+
});
|
|
431
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -25,12 +25,15 @@
|
|
|
25
25
|
* variant that proposes verifier-aware prompt edits.
|
|
26
26
|
*
|
|
27
27
|
* v0 ships:
|
|
28
|
-
* - `synthesizeVerifier(spec)` —
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* - `synthesizeVerifier(spec)` — tree search over candidate verifier
|
|
29
|
+
* code strings (the LLM call is supplied by the caller so this
|
|
30
|
+
* package stays pure with respect to the model)
|
|
31
31
|
* - `thompsonPick(nodes)` — Thompson sampling over tree nodes
|
|
32
32
|
* - `VerifierMutationProvider` — adapter to plug verifier search
|
|
33
33
|
* into the existing optimizer
|
|
34
|
+
* - `runVerifier(code, samples)` — scores a candidate by running it
|
|
35
|
+
* inside a locked-down `@crewhaus/sandbox` container; the candidate
|
|
36
|
+
* code never executes on this host (see its SECURITY note).
|
|
34
37
|
*
|
|
35
38
|
* Cited paper: AutoHarness (arxiv 2603.03329, Lou et al., 2026-03).
|
|
36
39
|
*/
|
|
@@ -40,6 +43,8 @@ import type {
|
|
|
40
43
|
OptimizerState,
|
|
41
44
|
ProviderMutation,
|
|
42
45
|
} from "@crewhaus/prompt-optimizer";
|
|
46
|
+
import { type Sandbox, createSandbox } from "@crewhaus/sandbox";
|
|
47
|
+
import { runVerifierInSandbox } from "./sandboxed-eval";
|
|
43
48
|
|
|
44
49
|
export class HarnessSynthesizerError extends CrewhausError {
|
|
45
50
|
override readonly name = "HarnessSynthesizerError";
|
|
@@ -54,6 +59,11 @@ export class HarnessSynthesizerError extends CrewhausError {
|
|
|
54
59
|
* (false) this sample. Both classes are required for non-degenerate
|
|
55
60
|
* search — a verifier that returns `true` for everything passes the
|
|
56
61
|
* `expected: true` set perfectly.
|
|
62
|
+
*
|
|
63
|
+
* NOTE: `input`/`output` cross an isolation boundary (see `runVerifier`)
|
|
64
|
+
* and are marshaled as JSON, so they must be JSON-serializable. The
|
|
65
|
+
* verifier observes structured copies, not live host references — an
|
|
66
|
+
* intentional consequence of running it out-of-process.
|
|
57
67
|
*/
|
|
58
68
|
export type VerifierSample = {
|
|
59
69
|
readonly input: unknown;
|
|
@@ -66,8 +76,15 @@ export type VerifierSample = {
|
|
|
66
76
|
* achieved on the last evaluation. `code` is a function body string
|
|
67
77
|
* with the signature `(input: unknown, output: unknown) => boolean`.
|
|
68
78
|
* It's stored as a string so the search can mutate it and feed it
|
|
69
|
-
* back to the LLM.
|
|
70
|
-
*
|
|
79
|
+
* back to the LLM.
|
|
80
|
+
*
|
|
81
|
+
* SECURITY: `code` is attacker-influenceable (caller seed candidates +
|
|
82
|
+
* refiner output, which a future model-backed refiner derives from
|
|
83
|
+
* skill/tool I/O that can carry injected content). It is NEVER compiled
|
|
84
|
+
* or invoked on this host. Execution happens via `runVerifier`, which
|
|
85
|
+
* evaluates the candidate inside a locked-down `@crewhaus/sandbox`
|
|
86
|
+
* container with no ambient authority — see the SECURITY note on
|
|
87
|
+
* `runVerifier`.
|
|
71
88
|
*/
|
|
72
89
|
export type VerifierCandidate = {
|
|
73
90
|
readonly id: string;
|
|
@@ -85,50 +102,81 @@ export type VerifierCandidate = {
|
|
|
85
102
|
* returns a new code string. In production, this is a model call; for
|
|
86
103
|
* testing it's a deterministic rule-based mutation. Either way, the
|
|
87
104
|
* signature is the same.
|
|
105
|
+
*
|
|
106
|
+
* SECURITY: the string this returns is executed (in isolation — see
|
|
107
|
+
* `runVerifier`), so a model-backed refiner is effectively running
|
|
108
|
+
* model output as code. The container is the trust boundary, NOT the
|
|
109
|
+
* model — never relax `runVerifier`'s isolation on the assumption that
|
|
110
|
+
* refiner output is trustworthy.
|
|
88
111
|
*/
|
|
89
112
|
export type RefinerFn = (
|
|
90
113
|
current: VerifierCandidate,
|
|
91
114
|
failures: ReadonlyArray<VerifierSample>,
|
|
92
115
|
) => Promise<string>;
|
|
93
116
|
|
|
117
|
+
/** Isolation options shared by `runVerifier` and the inner search. */
|
|
118
|
+
export type RunVerifierOptions = {
|
|
119
|
+
/**
|
|
120
|
+
* Isolation backend. Defaults to `createSandbox()` (honors the
|
|
121
|
+
* `CREWHAUS_SANDBOX` env; `docker` by default). The non-isolating
|
|
122
|
+
* `noop` backend is REFUSED — see the SECURITY note on `runVerifier`.
|
|
123
|
+
*/
|
|
124
|
+
readonly sandbox?: Sandbox;
|
|
125
|
+
/** Per-evaluation wall-clock budget (ms). Default: 10_000. */
|
|
126
|
+
readonly timeoutMs?: number;
|
|
127
|
+
/** Container image. Default: `node:22-alpine` (on the sandbox allowlist). */
|
|
128
|
+
readonly image?: string;
|
|
129
|
+
};
|
|
130
|
+
|
|
94
131
|
/**
|
|
95
132
|
* The Critic: runs `code` against a sample set and returns the per-
|
|
96
|
-
* sample verdict and the heuristic value.
|
|
97
|
-
*
|
|
133
|
+
* sample verdict and the heuristic value. Deterministic given the same
|
|
134
|
+
* code + samples (modulo the verifier's own determinism).
|
|
135
|
+
*
|
|
136
|
+
* SECURITY (FR-007): `code` is untrusted and is NOT run in this process.
|
|
137
|
+
* `runVerifier` ships `{ code, samples }` to a locked-down
|
|
138
|
+
* `@crewhaus/sandbox` container (`--network none`, `--read-only`, no
|
|
139
|
+
* host env, cpu/mem caps, wall-clock kill, `no-new-privileges`) and
|
|
140
|
+
* scores the returned verdicts here on the host — the container never
|
|
141
|
+
* receives `expected`. Inside the jail the candidate is compiled with
|
|
142
|
+
* the runtime's dynamic-function constructor and invoked over the
|
|
143
|
+
* samples; an exfiltration/RCE attempt (e.g. a refiner emitting a
|
|
144
|
+
* network call) is contained by the jail. FAIL CLOSED: when no real
|
|
145
|
+
* isolation backend is available (the `noop` backend, or none),
|
|
146
|
+
* `runVerifier` throws rather than executing untrusted code unsandboxed,
|
|
147
|
+
* mirroring the `requiresSandbox` floor enforced elsewhere in the repo.
|
|
148
|
+
*
|
|
149
|
+
* Behavior contract: per-sample runtime errors are caught (verdict
|
|
150
|
+
* `false`, `errors++`), matching the prior in-process semantics. Code
|
|
151
|
+
* that fails to compile, times out, or otherwise crashes the harness
|
|
152
|
+
* throws a `HarnessSynthesizerError`.
|
|
98
153
|
*/
|
|
99
|
-
export function runVerifier(
|
|
154
|
+
export async function runVerifier(
|
|
100
155
|
code: string,
|
|
101
156
|
samples: ReadonlyArray<VerifierSample>,
|
|
102
|
-
|
|
157
|
+
opts: RunVerifierOptions = {},
|
|
158
|
+
): Promise<{
|
|
103
159
|
readonly verdicts: ReadonlyArray<boolean>;
|
|
104
160
|
readonly heuristic: number;
|
|
105
161
|
readonly errors: number;
|
|
106
|
-
} {
|
|
107
|
-
|
|
162
|
+
}> {
|
|
163
|
+
const ownsSandbox = opts.sandbox === undefined;
|
|
164
|
+
const sandbox = opts.sandbox ?? createSandbox();
|
|
108
165
|
try {
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
const verdicts: boolean[] = [];
|
|
117
|
-
let correct = 0;
|
|
118
|
-
let errors = 0;
|
|
119
|
-
for (const s of samples) {
|
|
120
|
-
let v: boolean;
|
|
121
|
-
try {
|
|
122
|
-
v = Boolean(fn(s.input, s.output));
|
|
123
|
-
} catch {
|
|
124
|
-
v = false;
|
|
125
|
-
errors++;
|
|
166
|
+
// FR-007 — fail closed: never run untrusted verifier code without
|
|
167
|
+
// a real isolation boundary.
|
|
168
|
+
if (sandbox.backend === "noop") {
|
|
169
|
+
throw new HarnessSynthesizerError(
|
|
170
|
+
"refusing to evaluate verifier code in a noop sandbox (no isolation); set CREWHAUS_SANDBOX=docker|podman",
|
|
171
|
+
);
|
|
126
172
|
}
|
|
127
|
-
|
|
128
|
-
|
|
173
|
+
return await runVerifierInSandbox(sandbox, code, samples, {
|
|
174
|
+
...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
175
|
+
...(opts.image !== undefined ? { image: opts.image } : {}),
|
|
176
|
+
});
|
|
177
|
+
} finally {
|
|
178
|
+
if (ownsSandbox) await sandbox.close();
|
|
129
179
|
}
|
|
130
|
-
const heuristic = samples.length === 0 ? 0 : correct / samples.length;
|
|
131
|
-
return { verdicts, heuristic, errors };
|
|
132
180
|
}
|
|
133
181
|
|
|
134
182
|
/**
|
|
@@ -210,6 +258,14 @@ export type SynthesizeOptions = {
|
|
|
210
258
|
readonly target?: number;
|
|
211
259
|
/** RNG for Thompson sampling. Default: Math.random. */
|
|
212
260
|
readonly rng?: () => number;
|
|
261
|
+
/**
|
|
262
|
+
* Isolation backend reused across every evaluation in the inner search.
|
|
263
|
+
* Defaults to one `createSandbox()` per call (closed when the search
|
|
264
|
+
* ends). See `runVerifier` for the isolation/fail-closed contract.
|
|
265
|
+
*/
|
|
266
|
+
readonly sandbox?: Sandbox;
|
|
267
|
+
/** Per-evaluation wall-clock budget (ms) forwarded to `runVerifier`. */
|
|
268
|
+
readonly timeoutMs?: number;
|
|
213
269
|
};
|
|
214
270
|
|
|
215
271
|
export type SynthesizeResult = {
|
|
@@ -223,6 +279,9 @@ export type SynthesizeResult = {
|
|
|
223
279
|
* Run the tree search. Returns the best candidate found, the
|
|
224
280
|
* iteration count, and whether the target heuristic was reached.
|
|
225
281
|
* Pure with respect to randomness: pass `rng` for determinism.
|
|
282
|
+
*
|
|
283
|
+
* One isolation backend is created (or reused, if `opts.sandbox` is
|
|
284
|
+
* supplied) for the whole search and closed on exit — see `runVerifier`.
|
|
226
285
|
*/
|
|
227
286
|
export async function synthesizeVerifier(opts: SynthesizeOptions): Promise<SynthesizeResult> {
|
|
228
287
|
if (opts.seedCandidates.length === 0) {
|
|
@@ -235,66 +294,79 @@ export async function synthesizeVerifier(opts: SynthesizeOptions): Promise<Synth
|
|
|
235
294
|
const rng = opts.rng ?? Math.random;
|
|
236
295
|
const maxIter = opts.maxIterations ?? 16;
|
|
237
296
|
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
score: heuristic,
|
|
247
|
-
heuristic,
|
|
248
|
-
// Beta starts uniform; update with observed correct/incorrect counts.
|
|
249
|
-
alpha: 1 + Math.round(heuristic * opts.samples.length),
|
|
250
|
-
beta: 1 + Math.round((1 - heuristic) * opts.samples.length),
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
const trajectory: VerifierCandidate[] = [...pool];
|
|
254
|
-
|
|
255
|
-
// Early exit if a seed already satisfies the target.
|
|
256
|
-
let best = pool.reduce((a, b) => (a.heuristic >= b.heuristic ? a : b));
|
|
257
|
-
if (best.heuristic >= target) {
|
|
258
|
-
return { best, iterations: 0, converged: true, trajectory };
|
|
259
|
-
}
|
|
297
|
+
// One isolation backend reused across the whole inner search; close it
|
|
298
|
+
// on exit only if we created it (the caller owns an injected one).
|
|
299
|
+
const ownsSandbox = opts.sandbox === undefined;
|
|
300
|
+
const sandbox = opts.sandbox ?? createSandbox();
|
|
301
|
+
const runOpts: RunVerifierOptions = {
|
|
302
|
+
sandbox,
|
|
303
|
+
...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
304
|
+
};
|
|
260
305
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
throw new HarnessSynthesizerError(
|
|
277
|
-
`refiner threw on iteration ${iter}: ${(err as Error).message}`,
|
|
278
|
-
err,
|
|
279
|
-
);
|
|
306
|
+
try {
|
|
307
|
+
// Initialize the candidate pool from seeds.
|
|
308
|
+
const pool: VerifierCandidate[] = [];
|
|
309
|
+
for (let i = 0; i < opts.seedCandidates.length; i++) {
|
|
310
|
+
const code = opts.seedCandidates[i] as string;
|
|
311
|
+
const { heuristic } = await runVerifier(code, opts.samples, runOpts);
|
|
312
|
+
pool.push({
|
|
313
|
+
id: `seed_${i}`,
|
|
314
|
+
code,
|
|
315
|
+
score: heuristic,
|
|
316
|
+
heuristic,
|
|
317
|
+
// Beta starts uniform; update with observed correct/incorrect counts.
|
|
318
|
+
alpha: 1 + Math.round(heuristic * opts.samples.length),
|
|
319
|
+
beta: 1 + Math.round((1 - heuristic) * opts.samples.length),
|
|
320
|
+
});
|
|
280
321
|
}
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
score: heuristic,
|
|
286
|
-
heuristic,
|
|
287
|
-
alpha: 1 + Math.round(heuristic * opts.samples.length),
|
|
288
|
-
beta: 1 + Math.round((1 - heuristic) * opts.samples.length),
|
|
289
|
-
};
|
|
290
|
-
pool.push(child);
|
|
291
|
-
trajectory.push(child);
|
|
292
|
-
if (heuristic > best.heuristic) best = child;
|
|
322
|
+
const trajectory: VerifierCandidate[] = [...pool];
|
|
323
|
+
|
|
324
|
+
// Early exit if a seed already satisfies the target.
|
|
325
|
+
let best = pool.reduce((a, b) => (a.heuristic >= b.heuristic ? a : b));
|
|
293
326
|
if (best.heuristic >= target) {
|
|
294
|
-
return { best, iterations:
|
|
327
|
+
return { best, iterations: 0, converged: true, trajectory };
|
|
295
328
|
}
|
|
329
|
+
|
|
330
|
+
for (let iter = 0; iter < maxIter; iter++) {
|
|
331
|
+
const pickIdx = thompsonPick(pool, rng);
|
|
332
|
+
const parent = pool[pickIdx] as VerifierCandidate;
|
|
333
|
+
// Compute concrete failures for the refiner.
|
|
334
|
+
const { verdicts } = await runVerifier(parent.code, opts.samples, runOpts);
|
|
335
|
+
const failures: VerifierSample[] = [];
|
|
336
|
+
for (let i = 0; i < opts.samples.length; i++) {
|
|
337
|
+
const s = opts.samples[i] as VerifierSample;
|
|
338
|
+
const v = verdicts[i] as boolean;
|
|
339
|
+
if (v !== s.expected) failures.push(s);
|
|
340
|
+
}
|
|
341
|
+
let newCode: string;
|
|
342
|
+
try {
|
|
343
|
+
newCode = await opts.refiner(parent, failures);
|
|
344
|
+
} catch (err) {
|
|
345
|
+
throw new HarnessSynthesizerError(
|
|
346
|
+
`refiner threw on iteration ${iter}: ${(err as Error).message}`,
|
|
347
|
+
err,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
const { heuristic } = await runVerifier(newCode, opts.samples, runOpts);
|
|
351
|
+
const child: VerifierCandidate = {
|
|
352
|
+
id: `cand_${iter}`,
|
|
353
|
+
code: newCode,
|
|
354
|
+
score: heuristic,
|
|
355
|
+
heuristic,
|
|
356
|
+
alpha: 1 + Math.round(heuristic * opts.samples.length),
|
|
357
|
+
beta: 1 + Math.round((1 - heuristic) * opts.samples.length),
|
|
358
|
+
};
|
|
359
|
+
pool.push(child);
|
|
360
|
+
trajectory.push(child);
|
|
361
|
+
if (heuristic > best.heuristic) best = child;
|
|
362
|
+
if (best.heuristic >= target) {
|
|
363
|
+
return { best, iterations: iter + 1, converged: true, trajectory };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return { best, iterations: maxIter, converged: false, trajectory };
|
|
367
|
+
} finally {
|
|
368
|
+
if (ownsSandbox) await sandbox.close();
|
|
296
369
|
}
|
|
297
|
-
return { best, iterations: maxIter, converged: false, trajectory };
|
|
298
370
|
}
|
|
299
371
|
|
|
300
372
|
/**
|
|
@@ -310,6 +382,11 @@ export async function synthesizeVerifier(opts: SynthesizeOptions): Promise<Synth
|
|
|
310
382
|
* a freshly-synthesized verifier persisted to .crewhaus/verifiers/.
|
|
311
383
|
* (CLI `--mutator verifier-synthesis` wiring is a follow-up; the CLI
|
|
312
384
|
* today exposes `rule-based` and `claude` only.)
|
|
385
|
+
*
|
|
386
|
+
* SECURITY: the inner search executes refiner-produced code. Pass a
|
|
387
|
+
* `sandbox` (or rely on the default) — `synthesizeVerifier`/`runVerifier`
|
|
388
|
+
* isolate every evaluation and fail closed without a real backend. A
|
|
389
|
+
* model-backed `refiner` is only safe to wire because of that jail.
|
|
313
390
|
*/
|
|
314
391
|
export class VerifierMutationProvider implements MutationProvider {
|
|
315
392
|
readonly name = "verifier-synthesis";
|
|
@@ -320,6 +397,7 @@ export class VerifierMutationProvider implements MutationProvider {
|
|
|
320
397
|
private readonly refiner: RefinerFn,
|
|
321
398
|
private readonly seedCandidates: ReadonlyArray<string>,
|
|
322
399
|
private readonly maxInnerIterations: number = 4,
|
|
400
|
+
private readonly sandbox?: Sandbox,
|
|
323
401
|
) {}
|
|
324
402
|
|
|
325
403
|
async next(state: OptimizerState): Promise<ProviderMutation> {
|
|
@@ -329,6 +407,7 @@ export class VerifierMutationProvider implements MutationProvider {
|
|
|
329
407
|
samples: this.samples,
|
|
330
408
|
refiner: this.refiner,
|
|
331
409
|
maxIterations: this.maxInnerIterations,
|
|
410
|
+
...(this.sandbox !== undefined ? { sandbox: this.sandbox } : {}),
|
|
332
411
|
});
|
|
333
412
|
const annotation = `\n\n[verifier ${result.best.id}, h=${result.best.heuristic.toFixed(3)}]`;
|
|
334
413
|
return {
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FR-007 — sandboxed evaluation of untrusted verifier code.
|
|
3
|
+
*
|
|
4
|
+
* This module is the ONLY place candidate verifier code is compiled and
|
|
5
|
+
* run. It is `tool-harness-synthesizer`'s trust boundary: `code` strings
|
|
6
|
+
* are attacker-influenceable (caller seeds + refiner output), so they are
|
|
7
|
+
* NEVER executed on this host. `runVerifierInSandbox` ships them to a
|
|
8
|
+
* locked-down `@crewhaus/sandbox` container (`--network none`,
|
|
9
|
+
* `--read-only`, no host env, cpu/mem caps, wall-clock kill,
|
|
10
|
+
* `no-new-privileges`) and scores the returned verdicts on the host —
|
|
11
|
+
* the container never receives the `expected` labels.
|
|
12
|
+
*
|
|
13
|
+
* The container runs `VERIFIER_HARNESS` (a fixed `node -e` script). The
|
|
14
|
+
* untrusted `code` is delivered as STDIN DATA, never interpolated into
|
|
15
|
+
* that script string — so it cannot break out of the harness.
|
|
16
|
+
*/
|
|
17
|
+
import type { Sandbox } from "@crewhaus/sandbox";
|
|
18
|
+
import { HarnessSynthesizerError, type VerifierSample } from "./index";
|
|
19
|
+
|
|
20
|
+
/** Sentinel that frames the harness's single JSON result line on stdout. */
|
|
21
|
+
export const VERIFIER_SENTINEL = "__CREWHAUS_VERIFIER__";
|
|
22
|
+
|
|
23
|
+
const VERIFIER_IMAGE = "node:22-alpine";
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
25
|
+
|
|
26
|
+
/** Input/output pair handed to the verifier (labels stripped). */
|
|
27
|
+
type VerifierIO = { readonly input: unknown; readonly output: unknown };
|
|
28
|
+
|
|
29
|
+
/** Payload marshaled across the isolation boundary. */
|
|
30
|
+
type VerifierPayload = { readonly code: string; readonly samples: ReadonlyArray<VerifierIO> };
|
|
31
|
+
|
|
32
|
+
/** Per-sample result from inside the jail (or the in-process test double). */
|
|
33
|
+
export type VerifierEvalResult =
|
|
34
|
+
| { readonly verdicts: ReadonlyArray<boolean>; readonly errors: number }
|
|
35
|
+
| { readonly compileError: string };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compile `payload.code` and run it over `payload.samples`, collecting a
|
|
39
|
+
* boolean verdict per sample. A compile failure returns `{ compileError }`;
|
|
40
|
+
* a per-sample runtime throw is caught (verdict `false`, `errors++`).
|
|
41
|
+
*
|
|
42
|
+
* Single source of truth for verifier scoring: it runs both (a) inside the
|
|
43
|
+
* container — `VERIFIER_HARNESS` embeds this function's own source via
|
|
44
|
+
* `.toString()` — and (b) in unit tests via a fake sandbox. It MUST stay
|
|
45
|
+
* self-contained: reference no imports or module scope, or the in-container
|
|
46
|
+
* copy throws a ReferenceError.
|
|
47
|
+
*/
|
|
48
|
+
export function evalVerifierPayload(payload: VerifierPayload): VerifierEvalResult {
|
|
49
|
+
let fn: (input: unknown, output: unknown) => unknown;
|
|
50
|
+
try {
|
|
51
|
+
fn = new Function("input", "output", String(payload.code)) as (
|
|
52
|
+
input: unknown,
|
|
53
|
+
output: unknown,
|
|
54
|
+
) => unknown;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { compileError: err instanceof Error ? err.message : String(err) };
|
|
57
|
+
}
|
|
58
|
+
const verdicts: boolean[] = [];
|
|
59
|
+
let errors = 0;
|
|
60
|
+
const samples = payload.samples || [];
|
|
61
|
+
for (let i = 0; i < samples.length; i++) {
|
|
62
|
+
try {
|
|
63
|
+
const s = samples[i] as VerifierIO;
|
|
64
|
+
verdicts.push(Boolean(fn(s.input, s.output)));
|
|
65
|
+
} catch {
|
|
66
|
+
verdicts.push(false);
|
|
67
|
+
errors++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { verdicts, errors };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The in-container harness (`node -e <this>`). Reads `{ code, samples }`
|
|
75
|
+
* from stdin, runs `evalVerifierPayload` (embedded verbatim), and writes
|
|
76
|
+
* `SENTINEL + JSON` once on stdout. Untrusted `code` is stdin DATA, never
|
|
77
|
+
* part of this script.
|
|
78
|
+
*/
|
|
79
|
+
const VERIFIER_HARNESS = [
|
|
80
|
+
'"use strict";',
|
|
81
|
+
`const SENTINEL = ${JSON.stringify(VERIFIER_SENTINEL)};`,
|
|
82
|
+
`const evalVerifierPayload = ${evalVerifierPayload.toString()};`,
|
|
83
|
+
'let raw = "";',
|
|
84
|
+
'try { raw = require("fs").readFileSync(0, "utf8"); } catch (e) { raw = ""; }',
|
|
85
|
+
"let payload;",
|
|
86
|
+
"try { payload = JSON.parse(raw); }",
|
|
87
|
+
'catch (e) { process.stdout.write(SENTINEL + JSON.stringify({ compileError: "invalid harness payload" })); process.exit(0); }',
|
|
88
|
+
"process.stdout.write(SENTINEL + JSON.stringify(evalVerifierPayload(payload)));",
|
|
89
|
+
].join("\n");
|
|
90
|
+
|
|
91
|
+
function parseHarnessResult(stdout: string): VerifierEvalResult | undefined {
|
|
92
|
+
const idx = stdout.lastIndexOf(VERIFIER_SENTINEL);
|
|
93
|
+
if (idx === -1) return undefined;
|
|
94
|
+
const tail = stdout.slice(idx + VERIFIER_SENTINEL.length);
|
|
95
|
+
const line = tail.split("\n", 1)[0] ?? "";
|
|
96
|
+
let obj: unknown;
|
|
97
|
+
try {
|
|
98
|
+
obj = JSON.parse(line);
|
|
99
|
+
} catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
if (typeof obj !== "object" || obj === null) return undefined;
|
|
103
|
+
if (
|
|
104
|
+
"compileError" in obj &&
|
|
105
|
+
typeof (obj as { compileError: unknown }).compileError === "string"
|
|
106
|
+
) {
|
|
107
|
+
return { compileError: (obj as { compileError: string }).compileError };
|
|
108
|
+
}
|
|
109
|
+
const rec = obj as { verdicts?: unknown; errors?: unknown };
|
|
110
|
+
if (Array.isArray(rec.verdicts)) {
|
|
111
|
+
return {
|
|
112
|
+
verdicts: rec.verdicts.map((v) => Boolean(v)),
|
|
113
|
+
errors: typeof rec.errors === "number" ? rec.errors : 0,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Marshal `{ code, samples }` (labels stripped) into the jail, run the
|
|
121
|
+
* harness, parse the framed result, and score verdicts against the
|
|
122
|
+
* host-held `expected` labels.
|
|
123
|
+
*
|
|
124
|
+
* Throws `HarnessSynthesizerError` on: non-serializable samples, compile
|
|
125
|
+
* failure, timeout, non-zero harness exit, or unparseable output. Caught
|
|
126
|
+
* per-sample runtime errors are reflected in `errors` (not thrown).
|
|
127
|
+
*/
|
|
128
|
+
export async function runVerifierInSandbox(
|
|
129
|
+
sandbox: Sandbox,
|
|
130
|
+
code: string,
|
|
131
|
+
samples: ReadonlyArray<VerifierSample>,
|
|
132
|
+
opts: { readonly timeoutMs?: number; readonly image?: string } = {},
|
|
133
|
+
): Promise<{
|
|
134
|
+
readonly verdicts: ReadonlyArray<boolean>;
|
|
135
|
+
readonly heuristic: number;
|
|
136
|
+
readonly errors: number;
|
|
137
|
+
}> {
|
|
138
|
+
// Strip `expected` — the jail produces verdicts; the host scores them.
|
|
139
|
+
const payload: VerifierPayload = {
|
|
140
|
+
code,
|
|
141
|
+
samples: samples.map((s) => ({ input: s.input, output: s.output })),
|
|
142
|
+
};
|
|
143
|
+
let stdin: string;
|
|
144
|
+
try {
|
|
145
|
+
// JSON.stringify throws on BigInt and circular references — surface
|
|
146
|
+
// that as a clear error rather than shipping a broken payload.
|
|
147
|
+
stdin = JSON.stringify(payload);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
throw new HarnessSynthesizerError(
|
|
150
|
+
`verifier samples are not JSON-serializable: ${err instanceof Error ? err.message : String(err)}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
155
|
+
const result = await sandbox.exec({
|
|
156
|
+
image: opts.image ?? VERIFIER_IMAGE,
|
|
157
|
+
argv: ["node", "-e", VERIFIER_HARNESS],
|
|
158
|
+
stdin,
|
|
159
|
+
timeoutMs,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (result.timedOut) {
|
|
163
|
+
throw new HarnessSynthesizerError(`verifier evaluation timed out after ${timeoutMs}ms`);
|
|
164
|
+
}
|
|
165
|
+
const parsed = parseHarnessResult(result.stdout);
|
|
166
|
+
if (parsed === undefined) {
|
|
167
|
+
if (result.exitCode !== 0) {
|
|
168
|
+
throw new HarnessSynthesizerError(
|
|
169
|
+
`verifier harness exited ${result.exitCode}: ${result.stderr.slice(-500)}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
throw new HarnessSynthesizerError("verifier harness produced no result");
|
|
173
|
+
}
|
|
174
|
+
if ("compileError" in parsed) {
|
|
175
|
+
throw new HarnessSynthesizerError(`verifier code did not compile: ${parsed.compileError}`);
|
|
176
|
+
}
|
|
177
|
+
if (parsed.verdicts.length !== samples.length) {
|
|
178
|
+
throw new HarnessSynthesizerError(
|
|
179
|
+
`verifier harness returned ${parsed.verdicts.length} verdicts for ${samples.length} samples`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let correct = 0;
|
|
184
|
+
const verdicts: boolean[] = [];
|
|
185
|
+
for (let i = 0; i < samples.length; i++) {
|
|
186
|
+
const v = parsed.verdicts[i] === true;
|
|
187
|
+
verdicts.push(v);
|
|
188
|
+
if (v === (samples[i] as VerifierSample).expected) correct++;
|
|
189
|
+
}
|
|
190
|
+
const heuristic = samples.length === 0 ? 0 : correct / samples.length;
|
|
191
|
+
return { verdicts, heuristic, errors: parsed.errors };
|
|
192
|
+
}
|