@gethmy/harness 1.4.0 → 1.6.0

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.
@@ -36,6 +36,7 @@ import {
36
36
  type McpServerConfig,
37
37
  type Options,
38
38
  query,
39
+ type SandboxSettings,
39
40
  type SDKMessage,
40
41
  type SettingSource,
41
42
  type SpawnedProcess,
@@ -143,6 +144,18 @@ export interface SdkRunnerConfig {
143
144
  * Only the sizing preflight sets this. Every other spawn keeps CLI parity.
144
145
  */
145
146
  gateEveryToolCall?: boolean;
147
+ /**
148
+ * OS-level isolation for COMMAND EXECUTION — Seatbelt on macOS, bubblewrap on
149
+ * Linux (#988). Built by `implementRunContainment` (`run-containment.ts`),
150
+ * which is where the reasoning for each field lives.
151
+ *
152
+ * This is the only containment knob that leaves `Bash` usable. `canUseTool` +
153
+ * `confineToRepo` gate the agent's own TOOL calls, so they can confine a
154
+ * spawn that only reads and edits; they say nothing about what a command that
155
+ * spawn runs may then reach. An implement run has to run commands, so it
156
+ * needs the bound one layer down.
157
+ */
158
+ sandbox?: SandboxSettings;
146
159
  /**
147
160
  * Hands the spawned process-group leader to the worker so its existing
148
161
  * pause/resume/cancel paths can keep operating on `this.process`.
@@ -322,6 +335,12 @@ export class SdkAgentRunner implements AgentRunner {
322
335
  : {}),
323
336
  ...(this.cfg.mcpServers ? { mcpServers: this.cfg.mcpServers } : {}),
324
337
  ...(this.cfg.strictMcpConfig ? { strictMcpConfig: true } : {}),
338
+ // Execution sandbox (#988). Omitted entirely when unset, so every spawn
339
+ // that has not opted in keeps its current behaviour — the same shape as
340
+ // `canUseTool` above. When it IS set, `failIfUnavailable` inside it means
341
+ // an unsupported host fails the run rather than quietly running it
342
+ // uncontained, so there is no silent third state.
343
+ ...(this.cfg.sandbox ? { sandbox: this.cfg.sandbox } : {}),
325
344
  stderr: (data) => {
326
345
  this.capturedStderr += data;
327
346
  },
@@ -1,6 +1,10 @@
1
1
  import { type ChildProcess, execFileSync, spawn } from "node:child_process";
2
2
  import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
3
- import type { VerificationConfig } from "./exec-types.js";
3
+ import {
4
+ SANDBOX_STARTUP_GRACE_MS,
5
+ type VerificationConfig,
6
+ type VerificationSandbox,
7
+ } from "./exec-types.js";
4
8
  import { log } from "./log.js";
5
9
  import { spawnRunArgs } from "./pm.js";
6
10
  import {
@@ -10,7 +14,13 @@ import {
10
14
  supportsDevServer,
11
15
  testCommand,
12
16
  } from "./project-type.js";
17
+ import { runInSandbox, sandboxAvailable } from "./repair-sandbox.js";
13
18
  import { findDeletedTestFiles } from "./revert-guard.js";
19
+ import {
20
+ containedEnv,
21
+ GIT_NO_HOOKS,
22
+ implementRunContainmentCliArgs,
23
+ } from "./run-containment.js";
14
24
 
15
25
  const TAG = "verification";
16
26
 
@@ -58,6 +68,11 @@ export async function runVerification(
58
68
  revertWarnings: [],
59
69
  };
60
70
 
71
+ // Resolved once (#1036). Undefined unless the operator configured an image,
72
+ // in which case the four script-resolved steps run in a container instead of
73
+ // in this process. The two dev servers below are deliberately not passed it.
74
+ const sandbox = verificationSandbox(config);
75
+
61
76
  if (config.verification.revertGuard) {
62
77
  log.info(TAG, `[worker:${workerId}] Checking for reverted merged work...`);
63
78
  const deletedTests = findDeletedTestFiles(
@@ -84,7 +99,11 @@ export async function runVerification(
84
99
 
85
100
  if (config.verification.build) {
86
101
  log.info(TAG, `[worker:${workerId}] Running build...`);
87
- result.buildErrors = runBuild(worktreePath, config.verification.timeout);
102
+ result.buildErrors = await runBuild(
103
+ worktreePath,
104
+ config.verification.timeout,
105
+ sandbox,
106
+ );
88
107
  if (result.buildErrors.length > 0) {
89
108
  log.warn(
90
109
  TAG,
@@ -102,9 +121,10 @@ export async function runVerification(
102
121
  // still gates the branch.
103
122
  if (config.verification.test && result.buildErrors.length === 0) {
104
123
  log.info(TAG, `[worker:${workerId}] Running tests...`);
105
- result.testFailures = runTests(
124
+ result.testFailures = await runTests(
106
125
  worktreePath,
107
126
  config.verification.testTimeout,
127
+ sandbox,
108
128
  );
109
129
  if (result.testFailures.length > 0) {
110
130
  log.warn(
@@ -120,7 +140,11 @@ export async function runVerification(
120
140
 
121
141
  if (config.verification.lint) {
122
142
  log.info(TAG, `[worker:${workerId}] Running lint...`);
123
- result.lintWarnings = runLint(worktreePath, config.verification.timeout);
143
+ result.lintWarnings = await runLint(
144
+ worktreePath,
145
+ config.verification.timeout,
146
+ sandbox,
147
+ );
124
148
  if (result.lintWarnings.length > 0) {
125
149
  log.warn(
126
150
  TAG,
@@ -148,34 +172,198 @@ export async function runVerification(
148
172
  return result;
149
173
  }
150
174
 
151
- export function runBuild(worktreePath: string, timeout: number): string[] {
152
- const command = buildCommand(worktreePath);
153
- if (!command) {
154
- log.warn(
155
- TAG,
156
- `No known build toolchain for ${worktreePath} skipping build`,
157
- );
158
- return [];
175
+ /**
176
+ * ## The verification steps run the RUN'S OWN scripts — contained since #1036
177
+ *
178
+ * `runBuild`, `runTests`, `runFormatFix` and `runLint` resolve their command
179
+ * through `spawnRunArgs` — `bun run <script>` out of the worktree's own
180
+ * `package.json`. The worktree is in `sandbox.filesystem.allowWrite` by design,
181
+ * so a contained implement run can rewrite `"build"` to anything it likes, and
182
+ * before #1036 the daemon then ran it **in its own process**, as the operator,
183
+ * with network. No failure was needed to trigger it; verification runs on every
184
+ * successful run.
185
+ *
186
+ * `env: containedEnv()` (#988) removed the secret-shaped variables and was
187
+ * stated there as a reduction and NOT a fix — the command still read `~/.ssh`
188
+ * and `~/.harmony-mcp/config.json` off disk. **#1036 closes the rest, for these
189
+ * four, when `verification.sandboxImage` is set:** `execStep` routes them into
190
+ * `repair-sandbox.ts`'s container, whose only mount is the worktree and which
191
+ * has no network. There is no host fallback, because falling back would return
192
+ * exactly the exposure the image was configured to remove.
193
+ *
194
+ * It is opt-in, and unlike #988 that is a COST decision as well as a capability
195
+ * one. #988 measured no penalty for its own containment. This one, measured on
196
+ * macOS / Docker Desktop 29.7.2 with a warm `node:22-alpine` and a ~0.35 s
197
+ * workload:
198
+ *
199
+ * uncontained 0.38 / 0.35 / 0.34 s
200
+ * contained 5.53 / 5.42 / 5.41 s -> ~5.1 s added per command
201
+ *
202
+ * The workload is 0.35 s, so that is essentially all container start-up rather
203
+ * than a slower command — which means it is a FIXED cost paid per step, about
204
+ * 20 s on a run that builds, tests, lints and formats. On a Linux daemon host
205
+ * with no VM in the path it should be well below this; nobody has measured that,
206
+ * and this comment is the only record either way. That is the second reason
207
+ * there is no default: a fixed ~20 s per run is not a cost to start spending on
208
+ * an operator's behalf, and the first is that a guessed image reports every run
209
+ * as unverified — the reason `types.ts` already records for the repair path.
210
+ *
211
+ * **The image must match the repo's DETECTED package manager, not merely carry
212
+ * "a toolchain".** `spawnRunArgs` resolves through `detectPackageManager`, so a
213
+ * repo that resolves to `npm` fails in an `oven/bun:1` image with a raw OCI
214
+ * error (`exec: "npm": executable file not found in $PATH`). That surfaces as a
215
+ * `sandboxError` — the step reports it did not run — which is the right
216
+ * outcome, but the message is Docker's and not a friendly one. Found while
217
+ * taking the measurement above.
218
+ *
219
+ * The `build_green` gate collector re-runs the same build and lint scripts and
220
+ * takes the same sandbox, wired at the worker's registry call site. One config
221
+ * value, both paths.
222
+ *
223
+ * ## Still uncontained: the two `dev` servers, and the standalone CLI
224
+ *
225
+ * `runDeepReview`'s and the review worker's. Both are the same `spawnRunArgs`
226
+ * shape, and both are **probed over HTTP from the daemon**, so a `--network=none`
227
+ * container is unreachable by construction. They keep `containedEnv()` and stay
228
+ * named in `run-containment.ts`'s residual list rather than being quietly
229
+ * counted as covered. `harmony-harness`'s own CLI builds the same gate
230
+ * collector and passes no image, because the motor holds no operator config —
231
+ * a person running it against their own checkout, not board text.
232
+ *
233
+ * That distinction is stated rather than left implied because the first version
234
+ * of both lists named the four verification steps only, and the two dev servers
235
+ * then shipped with the daemon's full environment, unlisted and unnoticed
236
+ * through every gate. `spawn-run-containment.test.ts` asserts the environment
237
+ * property over call sites, and `verification-sandbox.test.ts` asserts that a
238
+ * configured image takes the four out of the daemon entirely.
239
+ */
240
+ /**
241
+ * Resolve the sandbox for the verification steps out of the daemon's config
242
+ * (#1036). Returns `undefined` when no image is configured, which is the
243
+ * default and means host execution exactly as before.
244
+ *
245
+ * An empty string is "not configured", never an image literally named `""` —
246
+ * that is the same reading `ci-patch.ts` gives `sandboxImage` on the repair
247
+ * path, and the two must not diverge.
248
+ */
249
+ export function verificationSandbox(
250
+ config: VerificationConfig,
251
+ ): VerificationSandbox | undefined {
252
+ const image = config.verification.sandboxImage?.trim();
253
+ if (!image) return undefined;
254
+ return { image };
255
+ }
256
+
257
+ /**
258
+ * One verification command, run in the container when one is configured and in
259
+ * the daemon process when it is not.
260
+ *
261
+ * ## Why the outcome has three shapes and not two
262
+ *
263
+ * `sandboxError` is not `ok: false`. "The command failed" and "the container
264
+ * never started" must not both read as a bad branch, because only the first is
265
+ * the branch's fault — the same distinction `repair-sandbox.ts` draws for the
266
+ * repair path, and for the same reason.
267
+ *
268
+ * ## Why there is no host fallback
269
+ *
270
+ * An operator who configured an image asked for these commands to run without
271
+ * their credentials in scope. Silently running them on the host when Docker is
272
+ * unreachable would hand back precisely the exposure the image was set to
273
+ * remove, and would do it invisibly. So an unavailable runtime is reported, not
274
+ * routed around. `repair-sandbox.ts`'s header states the general form: a
275
+ * half-sandbox on the host is the one option that trades the property for
276
+ * convenience, so it is not offered.
277
+ */
278
+ async function execStep(
279
+ command: { cmd: string; args: string[] },
280
+ args: {
281
+ worktreePath: string;
282
+ timeout: number;
283
+ sandbox: VerificationSandbox | undefined;
284
+ },
285
+ ): Promise<{ ok: boolean; err?: unknown; sandboxError?: string }> {
286
+ const { worktreePath, timeout } = args;
287
+ // An empty image is "not configured", never an image named `""` — and the
288
+ // check is here as well as in `verificationSandbox` because these four are
289
+ // exported and a caller can hand-build the object. Docker reads `""` as a
290
+ // missing argument and would fail in a way that reads like a broken runtime.
291
+ const sandbox = args.sandbox?.image.trim() ? args.sandbox : undefined;
292
+
293
+ if (sandbox) {
294
+ if (!(await sandboxAvailable())) {
295
+ return {
296
+ ok: false,
297
+ sandboxError:
298
+ `verification.sandboxImage is set to "${sandbox.image}" but no container runtime answered — ` +
299
+ "start Docker or unset the image to verify on the host",
300
+ };
301
+ }
302
+ const result = await runInSandbox({
303
+ image: sandbox.image,
304
+ worktree: worktreePath,
305
+ command,
306
+ // The STEP's own cap, plus what the container costs to start. Not a
307
+ // separate sandbox-wide timeout: build/lint and the test suite carry
308
+ // different caps on purpose, and one shared knob would silently replace
309
+ // both the moment an image is configured.
310
+ timeoutMs: timeout + SANDBOX_STARTUP_GRACE_MS,
311
+ });
312
+ if (result.sandboxError) {
313
+ return { ok: false, sandboxError: result.sandboxError };
314
+ }
315
+ if (result.passed) return { ok: true };
316
+ // Re-shape into what the existing parsers read, so the container path and
317
+ // the host path produce identical diagnostics from identical output.
318
+ return { ok: false, err: { stdout: result.output, stderr: "" } };
159
319
  }
320
+
160
321
  try {
161
322
  execFileSync(command.cmd, command.args, {
162
323
  cwd: worktreePath,
163
324
  timeout,
164
325
  stdio: "pipe",
165
326
  maxBuffer: MAX_OUTPUT_BUFFER,
327
+ env: containedEnv(),
166
328
  });
167
- return [];
329
+ return { ok: true };
168
330
  } catch (err: unknown) {
169
- return parseErrorOutput(err);
331
+ return { ok: false, err };
170
332
  }
171
333
  }
172
334
 
335
+ export async function runBuild(
336
+ worktreePath: string,
337
+ timeout: number,
338
+ sandbox?: VerificationSandbox,
339
+ ): Promise<string[]> {
340
+ const command = buildCommand(worktreePath);
341
+ if (!command) {
342
+ log.warn(
343
+ TAG,
344
+ `No known build toolchain for ${worktreePath} — skipping build`,
345
+ );
346
+ return [];
347
+ }
348
+ const outcome = await execStep(command, { worktreePath, timeout, sandbox });
349
+ if (outcome.ok) return [];
350
+ if (outcome.sandboxError) {
351
+ log.error(TAG, `Build not verified: ${outcome.sandboxError}`);
352
+ return [`Build did not run: ${outcome.sandboxError}`];
353
+ }
354
+ return parseErrorOutput(outcome.err);
355
+ }
356
+
173
357
  /**
174
358
  * Run the repo's own test suite. Returns the failures (empty = passed, or no
175
359
  * suite to run). A non-zero exit ALWAYS yields at least one entry — an
176
360
  * unparsable failure must never read as a pass (#688).
177
361
  */
178
- export function runTests(worktreePath: string, timeout: number): string[] {
362
+ export async function runTests(
363
+ worktreePath: string,
364
+ timeout: number,
365
+ sandbox?: VerificationSandbox,
366
+ ): Promise<string[]> {
179
367
  const command = testCommand(worktreePath);
180
368
  if (!command) {
181
369
  log.warn(
@@ -184,24 +372,23 @@ export function runTests(worktreePath: string, timeout: number): string[] {
184
372
  );
185
373
  return [];
186
374
  }
187
- try {
188
- execFileSync(command.cmd, command.args, {
189
- cwd: worktreePath,
190
- timeout,
191
- stdio: "pipe",
192
- maxBuffer: MAX_OUTPUT_BUFFER,
193
- });
194
- return [];
195
- } catch (err: unknown) {
196
- // The suite's own output is the diagnostic — put its tail in the run log,
197
- // not only the parsed lines that become subtasks.
198
- const output = combineOutput(err);
199
- log.warn(
200
- TAG,
201
- `Test run failed:\n${output.slice(-4000) || "(no output captured)"}`,
202
- );
203
- return parseTestFailures(err, timeout);
375
+ const outcome = await execStep(command, { worktreePath, timeout, sandbox });
376
+ if (outcome.ok) return [];
377
+ if (outcome.sandboxError) {
378
+ // #688's rule — a non-pass must never yield [] — with the reason being the
379
+ // sandbox rather than a test that never ran. Reporting a phantom assertion
380
+ // here would send someone to read a suite that produced no output.
381
+ log.error(TAG, `Tests not verified: ${outcome.sandboxError}`);
382
+ return [`Test run did not happen: ${outcome.sandboxError}`];
204
383
  }
384
+ // The suite's own output is the diagnostic — put its tail in the run log,
385
+ // not only the parsed lines that become subtasks.
386
+ const output = combineOutput(outcome.err);
387
+ log.warn(
388
+ TAG,
389
+ `Test run failed:\n${output.slice(-4000) || "(no output captured)"}`,
390
+ );
391
+ return parseTestFailures(outcome.err, timeout);
205
392
  }
206
393
 
207
394
  /**
@@ -216,35 +403,42 @@ export function runTests(worktreePath: string, timeout: number): string[] {
216
403
  * rule) is logged, never thrown. This does NOT change the lint-warn-only
217
404
  * policy — the later `runLint` still reports whatever the fixer left behind.
218
405
  */
219
- export function runFormatFix(
406
+ export async function runFormatFix(
220
407
  worktreePath: string,
221
408
  timeout: number,
222
409
  workerId: number,
223
- ): void {
410
+ sandbox?: VerificationSandbox,
411
+ ): Promise<void> {
224
412
  const command = formatFixCommand(worktreePath);
225
413
  if (!command) return;
226
- try {
227
- execFileSync(command.cmd, command.args, {
228
- cwd: worktreePath,
229
- timeout,
230
- stdio: "pipe",
231
- maxBuffer: MAX_OUTPUT_BUFFER,
232
- });
414
+ const outcome = await execStep(command, { worktreePath, timeout, sandbox });
415
+ if (outcome.ok) {
233
416
  log.info(
234
417
  TAG,
235
418
  `[worker:${workerId}] Auto-formatted worktree before commit/push`,
236
419
  );
237
- } catch (err: unknown) {
238
- log.warn(
239
- TAG,
240
- `[worker:${workerId}] Auto-format step exited non-zero (non-fatal): ${
241
- err instanceof Error ? err.message : String(err)
242
- }`,
243
- );
420
+ return;
244
421
  }
422
+ // Best-effort by contract: this step is tied to the lint step and never
423
+ // blocks. A broken container is one more reason it did not run, reported at
424
+ // the same level as a fixer that exited non-zero — the later `runLint` still
425
+ // reports whatever was left behind.
426
+ const why = outcome.sandboxError
427
+ ? outcome.sandboxError
428
+ : outcome.err instanceof Error
429
+ ? outcome.err.message
430
+ : String(outcome.err);
431
+ log.warn(
432
+ TAG,
433
+ `[worker:${workerId}] Auto-format step did not complete (non-fatal): ${why}`,
434
+ );
245
435
  }
246
436
 
247
- export function runLint(worktreePath: string, timeout: number): string[] {
437
+ export async function runLint(
438
+ worktreePath: string,
439
+ timeout: number,
440
+ sandbox?: VerificationSandbox,
441
+ ): Promise<string[]> {
248
442
  const command = lintCommand(worktreePath);
249
443
  if (!command) {
250
444
  log.info(
@@ -253,17 +447,16 @@ export function runLint(worktreePath: string, timeout: number): string[] {
253
447
  );
254
448
  return [];
255
449
  }
256
- try {
257
- execFileSync(command.cmd, command.args, {
258
- cwd: worktreePath,
259
- timeout,
260
- stdio: "pipe",
261
- maxBuffer: MAX_OUTPUT_BUFFER,
262
- });
263
- return [];
264
- } catch (err: unknown) {
265
- return parseErrorOutput(err);
450
+ const outcome = await execStep(command, { worktreePath, timeout, sandbox });
451
+ if (outcome.ok) return [];
452
+ if (outcome.sandboxError) {
453
+ // Lint is warn-only, so this is reported as a warning line rather than
454
+ // blocking — but it must still say the step did not run, because an empty
455
+ // lint result is indistinguishable from a clean one.
456
+ log.error(TAG, `Lint not verified: ${outcome.sandboxError}`);
457
+ return [`Lint did not run: ${outcome.sandboxError}`];
266
458
  }
459
+ return parseErrorOutput(outcome.err);
267
460
  }
268
461
 
269
462
  export async function runDeepReview(
@@ -290,6 +483,19 @@ export async function runDeepReview(
290
483
  devServer = spawn(cmd, args, {
291
484
  cwd: worktreePath,
292
485
  stdio: ["ignore", "pipe", "pipe"],
486
+ // Contained (#988), and it was the one spawn in this file that was not.
487
+ // `spawnRunArgs("dev", …)` resolves the worktree's OWN `dev` script,
488
+ // which the contained implement run may rewrite, so this is the same
489
+ // attacker-choosable command as `runBuild`/`runTests` — except it ran
490
+ // with the daemon's full environment, `HARMONY_API_KEY` and
491
+ // `GITHUB_PERSONAL_ACCESS_TOKEN` included, while `changelog.ts` told
492
+ // users the verification commands run "without your credentials".
493
+ //
494
+ // A reduction, not a fix: the command still executes outside the sandbox
495
+ // with network. That is why the dev server is now NAMED in the residual
496
+ // list in `run-containment.ts` and in `docs/agent-daemon.md`, instead of
497
+ // being left implied by a list of four other scripts.
498
+ env: containedEnv(),
293
499
  });
294
500
 
295
501
  // Wait for dev server to be ready, then confirm it answers HTTP.
@@ -311,7 +517,7 @@ export async function runDeepReview(
311
517
  // "(unable to retrieve diff)" — reviewing the change with no change (#701).
312
518
  diff = execFileSync(
313
519
  "git",
314
- ["diff", `origin/${config.worktree.baseBranch}..HEAD`],
520
+ [...GIT_NO_HOOKS, "diff", `origin/${config.worktree.baseBranch}..HEAD`],
315
521
  {
316
522
  cwd: worktreePath,
317
523
  encoding: "utf-8",
@@ -336,7 +542,6 @@ export async function runDeepReview(
336
542
  "```",
337
543
  ].join("\n");
338
544
 
339
- const leanSources = config.claude.leanSettingSources;
340
545
  const output = execFileSync(
341
546
  "claude",
342
547
  [
@@ -345,8 +550,16 @@ export async function runDeepReview(
345
550
  "sonnet",
346
551
  "--max-turns",
347
552
  "10",
348
- // Lean spawn skip project CLAUDE.md/@-imports (#348).
349
- ...(leanSources ? ["--setting-sources", leanSources] : []),
553
+ // Contained (#988). This spawn reads a diff the implement run wrote, in
554
+ // that run's own worktree, so it is downstream of untrusted text by
555
+ // construction — and it used to run with no sandbox and no credential
556
+ // deny at all. It has no `--allowedTools` of its own, which bounds it
557
+ // less than it looks: the default set still reads files.
558
+ ...implementRunContainmentCliArgs({
559
+ worktree: worktreePath,
560
+ // Reads a diff and reports findings; it writes nothing.
561
+ readOnly: true,
562
+ }),
350
563
  "--",
351
564
  reviewPrompt,
352
565
  ],
@@ -356,6 +569,7 @@ export async function runDeepReview(
356
569
  timeout: config.verification.timeout,
357
570
  stdio: "pipe",
358
571
  maxBuffer: MAX_OUTPUT_BUFFER,
572
+ env: containedEnv(),
359
573
  },
360
574
  );
361
575
 
@@ -396,7 +610,6 @@ export function attemptAutoFix(
396
610
  "```",
397
611
  ].join("\n");
398
612
 
399
- const leanSources = config.claude.leanSettingSources;
400
613
  const args = [
401
614
  "--print",
402
615
  "--model",
@@ -405,8 +618,23 @@ export function attemptAutoFix(
405
618
  "50",
406
619
  "--allowedTools",
407
620
  "Bash,Read,Write,Edit,Glob,Grep",
408
- // Lean spawn concrete build/lint errors, no project docs needed (#348).
409
- ...(leanSources ? ["--setting-sources", leanSources] : []),
621
+ // Contained exactly like the implement run (#988). This spawn used to pass
622
+ // `--setting-sources local,user` and nothing else: no sandbox, no
623
+ // credential denies, no env strip — measured reading
624
+ // `~/.harmony-mcp/config.json` (the Harmony API key), reaching the open
625
+ // internet and writing to `$HOME`.
626
+ //
627
+ // It is not an edge path. `maxFixAttempts` defaults to 1, so a failing
628
+ // build reaches here on an ordinary run — and whether the build fails is
629
+ // under the CONTAINED run's control, which made this the cheap way out of
630
+ // the containment: break the build, get an uncontained spawn in the same
631
+ // worktree, on the same branch.
632
+ //
633
+ // `leanSettingSources` is deliberately no longer read here. It exists to
634
+ // drop the project docs an auto-fix does not need (#348), and the
635
+ // containment's `settingSources` already excludes `user`; honouring both
636
+ // would let the operator's config widen a boundary.
637
+ ...implementRunContainmentCliArgs({ worktree: worktreePath }),
410
638
  "--",
411
639
  fixPrompt,
412
640
  ];
@@ -419,6 +647,7 @@ export function attemptAutoFix(
419
647
  timeout: config.verification.timeout,
420
648
  stdio: "pipe",
421
649
  maxBuffer: MAX_OUTPUT_BUFFER,
650
+ env: containedEnv(),
422
651
  });
423
652
  }
424
653