@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/tool-harness-synthesizer",
3
- "version": "0.1.0",
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.0.0",
16
- "@crewhaus/prompt-optimizer": "0.0.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@studiomax.io",
22
- "url": "https://studiomax.io"
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": "restricted"
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
- test("scores a correct verifier at 1.0", () => {
20
- const r = runVerifier("return typeof output === 'number' && output % 2 === 0", evenSamples);
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(() => runVerifier("not valid javascript {{{", evenSamples)).toThrow(
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)` — pure tree search over candidate
29
- * verifier code strings (the LLM call is supplied by the caller
30
- * so this package stays pure)
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. Execution happens via `runVerifier` which
70
- * compiles + invokes safely in a sandboxed Function call.
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. Pure; deterministic given
97
- * the same code + samples.
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
- let fn: (input: unknown, output: unknown) => boolean;
162
+ }> {
163
+ const ownsSandbox = opts.sandbox === undefined;
164
+ const sandbox = opts.sandbox ?? createSandbox();
108
165
  try {
109
- // Code must be the body of a function with parameters (input, output).
110
- // We wrap defensively so callers can pass either a body or a complete
111
- // expression returning a function.
112
- fn = new Function("input", "output", `${code}`) as (input: unknown, output: unknown) => boolean;
113
- } catch (err) {
114
- throw new HarnessSynthesizerError(`verifier code did not compile: ${(err as Error).message}`);
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
- verdicts.push(v);
128
- if (v === s.expected) correct++;
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
- // Initialize the candidate pool from seeds.
239
- const pool: VerifierCandidate[] = [];
240
- for (let i = 0; i < opts.seedCandidates.length; i++) {
241
- const code = opts.seedCandidates[i] as string;
242
- const { heuristic } = runVerifier(code, opts.samples);
243
- pool.push({
244
- id: `seed_${i}`,
245
- code,
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
- for (let iter = 0; iter < maxIter; iter++) {
262
- const pickIdx = thompsonPick(pool, rng);
263
- const parent = pool[pickIdx] as VerifierCandidate;
264
- // Compute concrete failures for the refiner.
265
- const { verdicts } = runVerifier(parent.code, opts.samples);
266
- const failures: VerifierSample[] = [];
267
- for (let i = 0; i < opts.samples.length; i++) {
268
- const s = opts.samples[i] as VerifierSample;
269
- const v = verdicts[i] as boolean;
270
- if (v !== s.expected) failures.push(s);
271
- }
272
- let newCode: string;
273
- try {
274
- newCode = await opts.refiner(parent, failures);
275
- } catch (err) {
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 { heuristic } = runVerifier(newCode, opts.samples);
282
- const child: VerifierCandidate = {
283
- id: `cand_${iter}`,
284
- code: newCode,
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: iter + 1, converged: true, trajectory };
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
+ }