@gethmy/harness 1.3.0 → 1.5.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
  },
@@ -11,6 +11,11 @@ import {
11
11
  testCommand,
12
12
  } from "./project-type.js";
13
13
  import { findDeletedTestFiles } from "./revert-guard.js";
14
+ import {
15
+ containedEnv,
16
+ GIT_NO_HOOKS,
17
+ implementRunContainmentCliArgs,
18
+ } from "./run-containment.js";
14
19
 
15
20
  const TAG = "verification";
16
21
 
@@ -148,6 +153,38 @@ export async function runVerification(
148
153
  return result;
149
154
  }
150
155
 
156
+ /**
157
+ * ## The verification steps run the RUN'S OWN scripts, and that is a known hole
158
+ *
159
+ * `runBuild`, `runTests`, `runFormatFix`, `runLint` and `runDeepReview`'s dev
160
+ * server all resolve their command through `spawnRunArgs` — `bun run <script>`
161
+ * out of the worktree's own `package.json` — and execute it **in the daemon
162
+ * process**. (`review-worker.ts` spawns the sixth, its own `dev` server, for the
163
+ * same reason and with the same exposure.)
164
+ * The worktree is in `sandbox.filesystem.allowWrite` by design, so a contained
165
+ * implement run can rewrite `"build"` to anything it likes and the daemon will
166
+ * then run it, outside the sandbox, with full network. No failure is needed to
167
+ * trigger this; verification runs on every successful run.
168
+ *
169
+ * `env: containedEnv()` (#988) is a real reduction and NOT a fix: it removes
170
+ * the secret-shaped variables, so `HARMONY_API_KEY`, `GITHUB_PERSONAL_ACCESS_TOKEN`
171
+ * and their neighbours are gone from the child. What remains is a command of
172
+ * the run's choosing, executing as the operator, able to read `~/.ssh` and
173
+ * reach the network.
174
+ *
175
+ * Closing it properly means running the repo's own build and test somewhere the
176
+ * operator's credentials are not — which is the problem `repair-sandbox.ts`
177
+ * solved for the CI repair with a container, and it is a piece of work of its
178
+ * own rather than a line in this one. Until then this is listed in
179
+ * `run-containment.ts`'s "what this does not cover" and in `docs/agent-daemon.md`,
180
+ * because a residual nobody wrote down is indistinguishable from one nobody saw.
181
+ *
182
+ * That is not a figure of speech here. The first version of both lists named
183
+ * the four verification steps only, and the two `dev` servers — which are the
184
+ * same `spawnRunArgs` shape — then shipped with the daemon's full environment,
185
+ * unlisted and unnoticed through every gate. `spawn-run-containment.test.ts`
186
+ * now asserts the property over call sites rather than over a written list.
187
+ */
151
188
  export function runBuild(worktreePath: string, timeout: number): string[] {
152
189
  const command = buildCommand(worktreePath);
153
190
  if (!command) {
@@ -163,6 +200,7 @@ export function runBuild(worktreePath: string, timeout: number): string[] {
163
200
  timeout,
164
201
  stdio: "pipe",
165
202
  maxBuffer: MAX_OUTPUT_BUFFER,
203
+ env: containedEnv(),
166
204
  });
167
205
  return [];
168
206
  } catch (err: unknown) {
@@ -190,6 +228,7 @@ export function runTests(worktreePath: string, timeout: number): string[] {
190
228
  timeout,
191
229
  stdio: "pipe",
192
230
  maxBuffer: MAX_OUTPUT_BUFFER,
231
+ env: containedEnv(),
193
232
  });
194
233
  return [];
195
234
  } catch (err: unknown) {
@@ -229,6 +268,7 @@ export function runFormatFix(
229
268
  timeout,
230
269
  stdio: "pipe",
231
270
  maxBuffer: MAX_OUTPUT_BUFFER,
271
+ env: containedEnv(),
232
272
  });
233
273
  log.info(
234
274
  TAG,
@@ -259,6 +299,7 @@ export function runLint(worktreePath: string, timeout: number): string[] {
259
299
  timeout,
260
300
  stdio: "pipe",
261
301
  maxBuffer: MAX_OUTPUT_BUFFER,
302
+ env: containedEnv(),
262
303
  });
263
304
  return [];
264
305
  } catch (err: unknown) {
@@ -290,6 +331,19 @@ export async function runDeepReview(
290
331
  devServer = spawn(cmd, args, {
291
332
  cwd: worktreePath,
292
333
  stdio: ["ignore", "pipe", "pipe"],
334
+ // Contained (#988), and it was the one spawn in this file that was not.
335
+ // `spawnRunArgs("dev", …)` resolves the worktree's OWN `dev` script,
336
+ // which the contained implement run may rewrite, so this is the same
337
+ // attacker-choosable command as `runBuild`/`runTests` — except it ran
338
+ // with the daemon's full environment, `HARMONY_API_KEY` and
339
+ // `GITHUB_PERSONAL_ACCESS_TOKEN` included, while `changelog.ts` told
340
+ // users the verification commands run "without your credentials".
341
+ //
342
+ // A reduction, not a fix: the command still executes outside the sandbox
343
+ // with network. That is why the dev server is now NAMED in the residual
344
+ // list in `run-containment.ts` and in `docs/agent-daemon.md`, instead of
345
+ // being left implied by a list of four other scripts.
346
+ env: containedEnv(),
293
347
  });
294
348
 
295
349
  // Wait for dev server to be ready, then confirm it answers HTTP.
@@ -311,7 +365,7 @@ export async function runDeepReview(
311
365
  // "(unable to retrieve diff)" — reviewing the change with no change (#701).
312
366
  diff = execFileSync(
313
367
  "git",
314
- ["diff", `origin/${config.worktree.baseBranch}..HEAD`],
368
+ [...GIT_NO_HOOKS, "diff", `origin/${config.worktree.baseBranch}..HEAD`],
315
369
  {
316
370
  cwd: worktreePath,
317
371
  encoding: "utf-8",
@@ -336,7 +390,6 @@ export async function runDeepReview(
336
390
  "```",
337
391
  ].join("\n");
338
392
 
339
- const leanSources = config.claude.leanSettingSources;
340
393
  const output = execFileSync(
341
394
  "claude",
342
395
  [
@@ -345,8 +398,16 @@ export async function runDeepReview(
345
398
  "sonnet",
346
399
  "--max-turns",
347
400
  "10",
348
- // Lean spawn skip project CLAUDE.md/@-imports (#348).
349
- ...(leanSources ? ["--setting-sources", leanSources] : []),
401
+ // Contained (#988). This spawn reads a diff the implement run wrote, in
402
+ // that run's own worktree, so it is downstream of untrusted text by
403
+ // construction — and it used to run with no sandbox and no credential
404
+ // deny at all. It has no `--allowedTools` of its own, which bounds it
405
+ // less than it looks: the default set still reads files.
406
+ ...implementRunContainmentCliArgs({
407
+ worktree: worktreePath,
408
+ // Reads a diff and reports findings; it writes nothing.
409
+ readOnly: true,
410
+ }),
350
411
  "--",
351
412
  reviewPrompt,
352
413
  ],
@@ -356,6 +417,7 @@ export async function runDeepReview(
356
417
  timeout: config.verification.timeout,
357
418
  stdio: "pipe",
358
419
  maxBuffer: MAX_OUTPUT_BUFFER,
420
+ env: containedEnv(),
359
421
  },
360
422
  );
361
423
 
@@ -396,7 +458,6 @@ export function attemptAutoFix(
396
458
  "```",
397
459
  ].join("\n");
398
460
 
399
- const leanSources = config.claude.leanSettingSources;
400
461
  const args = [
401
462
  "--print",
402
463
  "--model",
@@ -405,8 +466,23 @@ export function attemptAutoFix(
405
466
  "50",
406
467
  "--allowedTools",
407
468
  "Bash,Read,Write,Edit,Glob,Grep",
408
- // Lean spawn concrete build/lint errors, no project docs needed (#348).
409
- ...(leanSources ? ["--setting-sources", leanSources] : []),
469
+ // Contained exactly like the implement run (#988). This spawn used to pass
470
+ // `--setting-sources local,user` and nothing else: no sandbox, no
471
+ // credential denies, no env strip — measured reading
472
+ // `~/.harmony-mcp/config.json` (the Harmony API key), reaching the open
473
+ // internet and writing to `$HOME`.
474
+ //
475
+ // It is not an edge path. `maxFixAttempts` defaults to 1, so a failing
476
+ // build reaches here on an ordinary run — and whether the build fails is
477
+ // under the CONTAINED run's control, which made this the cheap way out of
478
+ // the containment: break the build, get an uncontained spawn in the same
479
+ // worktree, on the same branch.
480
+ //
481
+ // `leanSettingSources` is deliberately no longer read here. It exists to
482
+ // drop the project docs an auto-fix does not need (#348), and the
483
+ // containment's `settingSources` already excludes `user`; honouring both
484
+ // would let the operator's config widen a boundary.
485
+ ...implementRunContainmentCliArgs({ worktree: worktreePath }),
410
486
  "--",
411
487
  fixPrompt,
412
488
  ];
@@ -419,6 +495,7 @@ export function attemptAutoFix(
419
495
  timeout: config.verification.timeout,
420
496
  stdio: "pipe",
421
497
  maxBuffer: MAX_OUTPUT_BUFFER,
498
+ env: containedEnv(),
422
499
  });
423
500
  }
424
501