@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.
@@ -0,0 +1,1312 @@
1
+ /**
2
+ * The containment bound for an implement run (#988).
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * An implement run spawns Claude headlessly on a prompt built from a card's
7
+ * title, description, subtasks and comment thread — text any workspace member
8
+ * can write. `sweep.trustedAuthors` (#978) gates who may *start* such a run by
9
+ * `cards.created_by`, and authorship is immutable. The text is not: the `cards`
10
+ * UPDATE policy is `is_workspace_member` with no author or assignee
11
+ * restriction, and the comment route is membership-gated too. So the gate binds
12
+ * the wrong thing, and #988 decided to stop making the card trustworthy and
13
+ * bound the RUN instead.
14
+ *
15
+ * ## Why the two existing recipes do not transplant
16
+ *
17
+ * The repo already contains untrusted code twice, and neither shape fits here:
18
+ *
19
+ * - `runRepairSpawn` (`ci-patch.ts`) confines a spawn with `canUseTool` +
20
+ * `confineToRepo`. That policy DENIES any tool absent from its path map, and
21
+ * `Bash` is deliberately absent — a CI repair edits files and never runs
22
+ * them. An implement run has to build, test and commit, so it cannot adopt a
23
+ * policy whose first act is to remove its shell.
24
+ * - `runInSandbox` (`repair-sandbox.ts`) is a real container, and its first
25
+ * flag is `--network=none`. An agent run has to reach the Anthropic API, so
26
+ * hosting one there means relaxing the flag that made it a boundary, then
27
+ * re-injecting the git and gh credentials it was built to exclude.
28
+ *
29
+ * ## What this uses instead
30
+ *
31
+ * `Options.sandbox` — OS-level isolation the Agent SDK has carried since
32
+ * v0.3.x and this repo had never switched on (Seatbelt on macOS, bubblewrap on
33
+ * Linux). It sandboxes COMMAND EXECUTION rather than removing the command tool,
34
+ * which is the one shape that leaves an implement run able to do its job.
35
+ *
36
+ * Three of its settings are the actual boundary, and each closes something no
37
+ * other mechanism here can:
38
+ *
39
+ * 1. **`filesystem.denyRead`** over the credential directories. This is the
40
+ * gap `stripEnvKeys` structurally cannot reach: the daemon's credentials
41
+ * are not only in its environment, they are on its disk. `runner.ts`'s own
42
+ * module comment concedes the point — a `Read`-tool deny "governs the
43
+ * agent's own Read tool, not a separate process's filesystem access", so
44
+ * `cat ~/.harmony-mcp/config.json` from inside a Bash call still worked,
45
+ * and `Grep`/`Glob`/`mcp__harmony__*` were never covered at all. A kernel
46
+ * deny does not care which process asks.
47
+ * 2. **`network.allowedDomains`** — an exfiltration bound. Reading a
48
+ * credential and sending it are two steps, and this closes the second even
49
+ * if the first ever reopens.
50
+ * 3. **`allowUnsandboxedCommands: false`** — without it the SDK honours a
51
+ * per-command `dangerouslyDisableSandbox` parameter, which is an escape
52
+ * hatch the model itself can reach. A boundary the contained party can
53
+ * switch off is not one.
54
+ *
55
+ * `failIfUnavailable: true` is the fourth, and it is about honesty rather than
56
+ * reach: on a host with no sandbox support the run FAILS instead of silently
57
+ * proceeding uncontained. A containment that degrades quietly would leave the
58
+ * daemon reporting the same thing whether or not it held.
59
+ *
60
+ * ## Why this is unconditional
61
+ *
62
+ * It applies to every implement run, not only a swept one. Two reasons, and the
63
+ * second is the practical one:
64
+ *
65
+ * - The hand-assigned path is exposed to the same text. Assigning a card says
66
+ * a person chose to run it; it does not say the description still reads the
67
+ * way it did when they chose. #988's finding is that a member can rewrite a
68
+ * card *you* authored, and that is true whoever pressed the button.
69
+ * - There is no provenance to condition on. `sweep.ts` is explicit that after
70
+ * the claim, "`classifyPickup`, `enqueue` and the worker run byte-unchanged
71
+ * — a swept card and a hand-assigned card are the same card to everything
72
+ * downstream". Conditioning would mean first building that channel, i.e.
73
+ * adding a distinction in order to protect only half of what it separates.
74
+ *
75
+ * ## What this does NOT cover
76
+ *
77
+ * Stated here rather than left to be rediscovered, because every one of these
78
+ * was found by a review round rather than by reading the code, and a residual
79
+ * nobody wrote down is indistinguishable from one nobody saw.
80
+ *
81
+ * - **The two `dev` servers, plus the standalone harness CLI.** #1036 closed
82
+ * the four verification steps AND the `build_green` gate collector, which
83
+ * re-runs the same two scripts: `runBuild`, `runTests`, `runFormatFix` and `runLint`
84
+ * now run inside `repair-sandbox.ts`'s container when
85
+ * `verification.sandboxImage` is set — worktree the only mount, no network,
86
+ * no host fallback. What is left of this bullet is `runDeepReview`'s dev
87
+ * server and the review worker's own. Both are the same `spawnRunArgs`
88
+ * shape, and both are **probed over HTTP from the daemon**, so
89
+ * `--network=none` makes them unreachable by construction: containing them
90
+ * needs a different answer, not the same one applied twice. They keep
91
+ * `containedEnv()`, so the secret-shaped environment is gone; the command is
92
+ * still the run's to choose and still executes in the daemon process.
93
+ *
94
+ * The containment for the four is **opt-in**, so on a host with no
95
+ * `sandboxImage` all six remain as described above. That is a deliberate
96
+ * asymmetry with this module's own containment, which defaults on: the SDK
97
+ * sandbox is inside the CLI, whereas a container needs a reachable Docker
98
+ * and an image carrying the repo's toolchain.
99
+ *
100
+ * `packages/harmony-harness/src/cli.ts` builds the same collector and passes
101
+ * no image, because the motor holds no operator config of its own — there is
102
+ * nothing for it to read. That path is driven by a person running the CLI
103
+ * against their own checkout rather than by board text, so it is a different
104
+ * threat model, but it is named here rather than left to be rediscovered.
105
+ *
106
+ * The count is in this bullet on purpose, and it has now been wrong twice.
107
+ * The first version named the four verification steps and stopped, and both
108
+ * dev servers then shipped with the daemon's whole environment while the
109
+ * changelog told users these commands run "without your credentials". The
110
+ * second version — #1036's own — said "the two dev servers" while the
111
+ * `build_green` gate ran the worktree's script on the host for any operator
112
+ * who had set an image precisely to stop that. Both were caught by a reader,
113
+ * not by a gate.
114
+ * `spawn-run-containment.test.ts` scans for the environment property instead
115
+ * of trusting this prose, and `verification-sandbox.test.ts` asserts that a
116
+ * configured image takes the four out of this process entirely — because the
117
+ * residual is a completeness claim over CALL SITES and a hand-list has
118
+ * already lost it once.
119
+ * - **The MCP surface.** The sandbox governs commands. MCP stdio servers are
120
+ * hosted by the CLI and are not sandboxed, so `mcp__harmony__*` is bounded by
121
+ * the tool allow-list and the daemon-owned denials (#525/#576), not by this.
122
+ * - **Prompt injection itself.** The untrusted-data markers raise the cost and
123
+ * make a successful attempt visible; the boundary is the sandbox.
124
+ * - **The shared package caches.** `~/.bun/install/cache` and
125
+ * `~/.npm/_cacache` are granted, and they are cross-project directories
126
+ * holding code the operator later executes — the class excluded above for
127
+ * `~/.bun/bin`, `~/.cache` and `$TMPDIR`. The exception is deliberate and it
128
+ * is not free either way: measured, a containment with no writable cache
129
+ * fails `bun add` and `npm install` outright, and a run that cannot install
130
+ * its dependencies is prevented rather than contained. Pointing the run at a
131
+ * private cache needs an env var this code cannot inject into the agent
132
+ * spawn — `SdkRunnerConfig` carries `stripEnvKeys`, not additions.
133
+ * - **Spend.** A member whose cards are claimable can still occupy the daemon.
134
+ * That is `maxCardsPerSweep` and `dailyBudgetCents`.
135
+ */
136
+ import { createHash } from "node:crypto";
137
+ import { readFileSync } from "node:fs";
138
+ import { createRequire } from "node:module";
139
+ import { homedir, tmpdir } from "node:os";
140
+ import { dirname, isAbsolute, join } from "node:path";
141
+ import type {
142
+ McpServerConfig,
143
+ SandboxSettings,
144
+ SettingSource,
145
+ } from "@anthropic-ai/claude-agent-sdk";
146
+ import { getConfigDir } from "@gethmy/mcp/src/config.js";
147
+ import { isInsideTree } from "./confine-to-repo.js";
148
+ import { HARMONY_CREDENTIAL_KEYS } from "./runner.js";
149
+
150
+ /**
151
+ * Directories holding credentials the daemon user can read, denied to the
152
+ * sandboxed run at the kernel rather than at the tool.
153
+ *
154
+ * Listed as DIRECTORIES, not files. A token file sits beside the config that
155
+ * names it (`~/.config/gh/hosts.yml` beside `config.yml`, an ssh key beside its
156
+ * `known_hosts`), and enumerating the filenames is the denylist game this repo
157
+ * has already lost twice — once on glob spellings of `..` and once on the case
158
+ * of `.git`. A directory has no spellings.
159
+ *
160
+ * `~/.claude` is here and it is the subtle one: it holds the operator's own
161
+ * `settings.json`, whose permission grants `settingSources: []` already stops
162
+ * the SDK from LOADING. Denying the read as well means a run cannot go and
163
+ * fetch what it was refused, which is a different failure from being handed it.
164
+ */
165
+ export function credentialDirectories(): string[] {
166
+ const home = homedir();
167
+ return [
168
+ // Harmony's own credential, wherever the MCP server resolves it to. Taken
169
+ // from `getConfigDir()` rather than re-spelled, for the same reason
170
+ // `credentialAccessDeny()` does: one source of truth for the location.
171
+ getConfigDir(),
172
+ join(home, ".claude"),
173
+ // `~/.claude.json` is a FILE beside the `~/.claude` directory, and denying
174
+ // the directory does not deny it — it was still readable (214 KB, holding
175
+ // MCP server definitions and their env) after the first version of this
176
+ // list. Neighbours-with-a-suffix are exactly what a directory-shaped list
177
+ // misses, which is why the two `.config` entries below are named
178
+ // individually rather than trusting `~/.config`.
179
+ join(home, ".claude.json"),
180
+ join(home, ".ssh"),
181
+ join(home, ".gnupg"),
182
+ join(home, ".aws"),
183
+ // Other agent runtimes keep tokens in the same shape. `~/.codex/auth.json`
184
+ // was readable and returned an `OPENAI_API_KEY`; a credential is a
185
+ // credential whichever vendor wrote it.
186
+ join(home, ".codex"),
187
+ join(home, ".gemini"),
188
+ join(home, ".config", "gh"),
189
+ join(home, ".config", "gcloud"),
190
+ join(home, ".config", "anthropic"),
191
+ join(home, ".config", "op"),
192
+ join(home, ".docker"),
193
+ join(home, ".kube"),
194
+ join(home, ".netrc"),
195
+ join(home, ".npmrc"),
196
+ join(home, ".git-credentials"),
197
+ ];
198
+ }
199
+
200
+ /**
201
+ * Denied for WRITING only, never for reading.
202
+ *
203
+ * `~/.gitconfig` is the whole list, and the read/write split is not a nicety —
204
+ * it is the difference between a contained run and a broken one. The risk in
205
+ * this file is entirely on the write side: `core.pager`, `core.sshCommand` and
206
+ * `alias.*` are the keys the #1015 review turned into host execution, and
207
+ * `core.fsmonitor` is the shape that ran a payload off the diff gate's own
208
+ * `git status`.
209
+ *
210
+ * Denying the READ breaks git outright. Git does not treat an unreadable global
211
+ * config as an absent one — it fatals, and it does so on every command.
212
+ * Measured in a real linked worktree with the emitted arguments, `.gitconfig`
213
+ * in `denyRead`:
214
+ *
215
+ * git status → 128 fatal: unable to access '/Users/av/.gitconfig': Operation not permitted
216
+ * git add -A → 128 (same)
217
+ * git commit → 128 (same)
218
+ *
219
+ * and with only that one path removed, all three return 0. It would have
220
+ * shipped looking green, because the daemon's own commits run uncontained in
221
+ * the daemon process — what breaks is git INSIDE a contained spawn, which is
222
+ * how the review run reads its diff (`review-prompt.ts` tells the reviewer to
223
+ * run `git diff`, and since #348 the prompt inlines only a `--stat` summary).
224
+ *
225
+ * The content is not much of a secret either: a name, an email and aliases.
226
+ * Losing the write is what mattered, and the write is still denied.
227
+ */
228
+ export function writeOnlyDenyPaths(): string[] {
229
+ const paths = [join(homedir(), ".gitconfig")];
230
+ // Git reads TWO user-level config files, not one. `$XDG_CONFIG_HOME/git/config`
231
+ // — `~/.config/git/config` by default — supplies `core.pager`,
232
+ // `core.sshCommand`, `core.fsmonitor` and `alias.*` with exactly the same
233
+ // effect whenever they are unset in `~/.gitconfig`. Denying only the first
234
+ // leaves the identical chain reachable through the path it does not name,
235
+ // which is the whole reason `credentialDirectories()` lists the `~/.config`
236
+ // subdirectories individually rather than trusting the parent.
237
+ const xdg = process.env.XDG_CONFIG_HOME;
238
+ paths.push(
239
+ xdg && isAbsolute(xdg)
240
+ ? join(xdg, "git")
241
+ : join(homedir(), ".config", "git"),
242
+ );
243
+ return paths;
244
+ }
245
+
246
+ /**
247
+ * Deny rules over the credential directories for the tools that read FILES.
248
+ *
249
+ * ## This is not belt-and-braces; it is the other half of the boundary
250
+ *
251
+ * The sandbox wraps COMMAND EXECUTION. The CLI process that hosts `Read`,
252
+ * `Grep` and `Glob` is not wrapped, so those tools reach straight past it.
253
+ * Measured on the shipped configuration during this card's own review:
254
+ * `Bash("head -c 5 ~/.claude/settings.json")` was denied by the sandbox, and
255
+ * the `Read` tool on the same path **succeeded**; `Glob` over `~/.ssh/*`
256
+ * returned 12 matches. Exfiltration completes off-sandbox too, since MCP stdio
257
+ * servers are hosted by the CLI and `mcp__harmony__*` can write the content
258
+ * back to the board.
259
+ *
260
+ * So the two layers are not redundant and neither is the fallback for the
261
+ * other: the sandbox covers what a COMMAND may touch, and these cover what a
262
+ * TOOL may touch. An earlier version of this module reused
263
+ * `credentialAccessDeny()`, which names `getConfigDir()` alone — that left
264
+ * eleven of the twelve directories readable, including `~/.ssh` and
265
+ * `~/.config/gh`.
266
+ *
267
+ * ## Why these are `Read(...)` rules and ONLY `Read(...)` rules
268
+ *
269
+ * The obvious spelling is to deny `Read`, `Grep` and `Glob` separately, since
270
+ * all three leak — `Grep` returns matching lines, so it exfiltrates content as
271
+ * effectively as `Read`, and `Glob` leaks names. That spelling is wrong, and
272
+ * the CLI says so out loud when given it:
273
+ *
274
+ * Permission deny rule (--disallowed-tools): Glob(//…/**) is not matched by
275
+ * file permission checks — only Read(path) rules are. Use Read(//…/**)
276
+ * instead (Read rules cover all file-reading tools).
277
+ *
278
+ * So a `Grep(...)`/`Glob(...)` deny is INERT: it denies nothing and emits a
279
+ * warning on every single run. A `Read(...)` rule is what the file-permission
280
+ * layer actually consults, and it governs every file-reading tool including
281
+ * those two — confirmed by probe, where `Glob` over `~/.ssh/*` was refused with
282
+ * only the `Read` rule in force.
283
+ *
284
+ * (`credentialAccessDeny()` in `runner.ts` still emits the three-tool form. It
285
+ * is pre-existing, used by the sizing preflight, and out of scope here — but it
286
+ * is denying less than it reads as, and is worth its own card.)
287
+ */
288
+ export function credentialToolDeny(): string[] {
289
+ // The leading `/` on an already-absolute path is deliberate and is the same
290
+ // spelling `credentialAccessDeny()` uses: the Agent SDK anchors a
291
+ // `//`-prefixed permission pattern to the FILESYSTEM ROOT, where a single
292
+ // leading slash would anchor it to the run's `repoPath` instead — which for
293
+ // these paths would match nothing and deny nothing. It reads like a typo and
294
+ // is load-bearing, so it is spelled out rather than "cleaned up" later.
295
+ //
296
+ // Two rules per entry, because the list mixes directories with FILES:
297
+ // `~/.ssh` is a directory and needs `/**` to cover what is inside it, while
298
+ // `~/.npmrc` and `~/.git-credentials` are files, for which `/**` matches
299
+ // nothing at all. Emitting both shapes means the list does not have to say
300
+ // which is which — and a path that changes from one to the other later
301
+ // cannot silently fall out of the deny.
302
+ return credentialDirectories().flatMap((dir) => [
303
+ `Read(/${dir})`,
304
+ `Read(/${dir}/**)`,
305
+ ]);
306
+ }
307
+
308
+ /**
309
+ * Directories a contained run must be able to WRITE despite holding no
310
+ * credentials — the package-manager caches.
311
+ *
312
+ * ## Why this list exists at all
313
+ *
314
+ * The sandbox profile is deny-by-default, and `allowWrite` adds to a closed
315
+ * allow-list. A fresh worktree has no `node_modules`, and the daemon's own
316
+ * verification step runs `bun install --frozen-lockfile` (`pm.ts`). Measured
317
+ * during this card's review: under the containment as first written,
318
+ * `bun add` failed with `bun is unable to write files to tempdir:
319
+ * PermissionDenied` and `npm install` exited 1. A containment that cannot
320
+ * install dependencies does not contain an implement run, it prevents one.
321
+ *
322
+ * The card's own cost measurement missed this because both of its arms ran with
323
+ * no tool call at all — which is exactly why a measurement needs to exercise
324
+ * the thing it is measuring.
325
+ *
326
+ * ## Why these paths and not `~/.bun`, and not `$TMPDIR`
327
+ *
328
+ * Each entry is a CACHE, not a `bin` directory. `~/.bun` as a whole would
329
+ * include `~/.bun/bin`, and a run that can write an executable there plants a
330
+ * binary the operator later runs OUTSIDE the sandbox — turning a bounded run
331
+ * into host persistence.
332
+ *
333
+ * The first version of this function then made exactly that mistake one line
334
+ * further down, by allow-listing `$TMPDIR` whole so `bun` could unpack. Review
335
+ * wrote a `chmod +x` file into `$TMPDIR/cmux-cli-shims/<uuid>/` from inside the
336
+ * containment — the directory holding this host's own `claude` launcher. A
337
+ * temp directory is not scratch space when it is a per-user directory other
338
+ * tools keep executables in.
339
+ *
340
+ * So the run gets its OWN subdirectory under the temp root instead, created per
341
+ * call and named after the worktree. That is enough for an unpack step and
342
+ * reaches nothing a neighbour owns. `process.env.TMPDIR` is also not trusted as
343
+ * a path — `TMPDIR=/` would otherwise allow-list the filesystem — so it is
344
+ * used only when it is absolute, and `tmpdir()` is the fallback.
345
+ */
346
+ export function toolchainCacheDirectories(worktree: string): string[] {
347
+ const home = homedir();
348
+ // A stable, per-worktree name: the same run resuming or retrying reuses its
349
+ // own scratch rather than accumulating one directory per attempt, and two
350
+ // concurrent workers never share.
351
+ const scratch = `harmony-run-${createHash("sha256").update(worktree).digest("hex").slice(0, 16)}`;
352
+ // `os.tmpdir()` is NOT a safe fallback on its own: it READS `TMPDIR`, so a
353
+ // relative or hostile value comes straight back out of it and the guard above
354
+ // would validate the same bad path twice. Fall through to a literal instead.
355
+ // (Caught by the test for this, which is the only reason it is not still a
356
+ // one-line `?? tmpdir()`.)
357
+ const candidate = process.env.TMPDIR ?? tmpdir();
358
+ const tmpRoot = isAbsolute(candidate) ? candidate : "/tmp";
359
+ return [
360
+ join(home, ".bun", "install", "cache"),
361
+ join(home, ".npm", "_cacache"),
362
+ // `~/.cache` as a whole was here and is GONE. It is a shared per-user root
363
+ // holding executables the operator runs: corepack's `yarn`/`pnpm` shims
364
+ // under `~/.cache/node/corepack/`, `~/.cache/pre-commit/**/bin/*` which
365
+ // `git commit` runs in any pre-commit repo, playwright browsers, `~/.cache/deno`.
366
+ // Granting it is the same defect as granting `~/.bun` or `$TMPDIR` whole —
367
+ // the two exclusions this function already documents — and `bun`/`npm` do
368
+ // not need it, since their caches are named individually above.
369
+ join(tmpRoot, scratch),
370
+ ];
371
+ }
372
+
373
+ /**
374
+ * Environment keys to strip beyond {@link HARMONY_CREDENTIAL_KEYS}.
375
+ *
376
+ * ## Why a pattern and not a list
377
+ *
378
+ * `HARMONY_CREDENTIAL_KEYS` names six `HARMONY_*`/`SUPABASE_*` variables — the
379
+ * ones Harmony itself sets. Everything else the operator happens to export
380
+ * reaches the run untouched, and review measured ten secret-shaped variables in
381
+ * the child's environment on this host, among them
382
+ * `GITHUB_PERSONAL_ACCESS_TOKEN`, `GEMINI_API_KEY`, `OPENROUTER_API_KEY` and
383
+ * `PINECONE_API_KEY`. A GitHub token matters most: `*.github.com` is on the
384
+ * egress allow-list, because a run has to push its branch — so a token in the
385
+ * environment plus an allowed destination is a complete read-and-send path, and
386
+ * "reading a credential and sending it are two steps" stops being true.
387
+ *
388
+ * Naming them individually cannot work: the set is whatever THIS operator
389
+ * exports, and the next one exports different names. So the rule is shaped, not
390
+ * enumerated — and unlike a denylist over attacker-chosen input, the thing being
391
+ * matched here is a variable name the operator chose, so a miss is a
392
+ * configuration this did not anticipate rather than an evasion.
393
+ *
394
+ * ## Why it is still not a boundary
395
+ *
396
+ * A variable called `DEPLOY_HOOK_URL` carrying a secret is not matched, and
397
+ * nothing here would catch it. This narrows the blast radius of the common
398
+ * shapes; the boundary remains the sandbox and the egress allow-list.
399
+ */
400
+ const SECRET_ENV_PATTERN =
401
+ /(TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|API_?KEY|_PAT$|^PAT_|PRIVATE_KEY|ACCESS_KEY)/i;
402
+
403
+ /**
404
+ * Secret-shaped names the run genuinely needs, and must therefore keep.
405
+ *
406
+ * `ANTHROPIC_*` authenticates the model call the run IS. Stripping it does not
407
+ * contain anything — it stops the run existing — and it is a credential for the
408
+ * one destination the sandbox already allows.
409
+ *
410
+ * The rest are here because an earlier version of {@link SECRET_ENV_PATTERN}
411
+ * carried a bare, unanchored `AUTH` alternative and swept up every one of them:
412
+ * `SSH_AUTH_SOCK` (which this file's own comment claimed was deliberately kept
413
+ * — the comment was simply wrong), `GIT_AUTHOR_NAME`/`EMAIL`, `XAUTHORITY`,
414
+ * `NODE_AUTH_TOKEN`'s neighbours, and plain `AUTHOR`. Dropping the alternative
415
+ * fixes the class; these are named for the two that a future re-add would break
416
+ * first, and because `GIT_COMMITTER_*` surviving while `GIT_AUTHOR_*` did not
417
+ * is the kind of half-broken git state that is miserable to debug.
418
+ *
419
+ * `SSH_AUTH_SOCK` in particular carries no secret — it is a socket path — and
420
+ * removing it breaks a legitimate `git push` over ssh. The agent socket itself
421
+ * is refused by the sandbox, which is measured and is the real bound.
422
+ */
423
+ const KEEP_ENV_KEYS = new Set([
424
+ "ANTHROPIC_API_KEY",
425
+ "ANTHROPIC_AUTH_TOKEN",
426
+ "ANTHROPIC_BASE_URL",
427
+ "CLAUDE_CODE_OAUTH_TOKEN",
428
+ "SSH_AUTH_SOCK",
429
+ "GIT_AUTHOR_NAME",
430
+ "GIT_AUTHOR_EMAIL",
431
+ "GIT_COMMITTER_NAME",
432
+ "GIT_COMMITTER_EMAIL",
433
+ "XAUTHORITY",
434
+ ]);
435
+
436
+ /**
437
+ * Every environment key this run should not carry, given the parent's env.
438
+ *
439
+ * Returns names, not values, and is exported so a test can assert the shape
440
+ * against a synthetic environment rather than the machine it happens to run on.
441
+ */
442
+ export function secretEnvKeysToStrip(
443
+ parentEnv: Record<string, string | undefined> = process.env,
444
+ ): string[] {
445
+ const stripped = new Set<string>(HARMONY_CREDENTIAL_KEYS);
446
+ for (const key of Object.keys(parentEnv)) {
447
+ if (KEEP_ENV_KEYS.has(key)) continue;
448
+ if (SECRET_ENV_PATTERN.test(key)) stripped.add(key);
449
+ }
450
+ return [...stripped];
451
+ }
452
+
453
+ /** Thrown when a run's own worktree carries settings that would widen the sandbox. */
454
+ export class ProjectSandboxOverrideError extends Error {
455
+ constructor(
456
+ readonly settingsPath: string,
457
+ readonly offendingKeys: readonly string[],
458
+ ) {
459
+ super(
460
+ `Refusing to spawn: ${settingsPath} sets ${offendingKeys.map((k) => `"${k}"`).join(", ")}. ` +
461
+ "Project settings are loaded so the run can read CLAUDE.md, and that same layer " +
462
+ "can widen the sandbox (`sandbox.filesystem.allowWrite`) or execute commands " +
463
+ "outside it (`hooks`, `env`). A run that can write this file could therefore " +
464
+ "widen its own bounds, so only keys known not to affect execution are accepted. " +
465
+ "Remove the key, or the run stays refused.",
466
+ );
467
+ this.name = "ProjectSandboxOverrideError";
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Refuse a spawn whose worktree carries a `sandbox` key in project settings.
473
+ *
474
+ * ## The hole this closes, measured
475
+ *
476
+ * The containment loads `settingSources: ["project"]`, because that source is
477
+ * what carries `CLAUDE.md` and dropping it silently removed every project
478
+ * instruction from every run. The cost of keeping it is that
479
+ * `.claude/settings.json` — a file inside the worktree the run may write —
480
+ * CONTRIBUTES to the sandbox.
481
+ *
482
+ * Not all of it, and the distinction is worth stating because it decides how
483
+ * narrow this guard has to be. Ablated one key at a time against the shipped
484
+ * arguments: `enabled`, `allowUnsandboxedCommands`, `denyRead` and `denyWrite`
485
+ * are refused from the project layer — a run genuinely cannot switch the
486
+ * sandbox off or re-open the credential denies. But
487
+ * `sandbox.filesystem.allowWrite` is merged in on BOTH runners, and
488
+ * `sandbox.network.allowedDomains` on the CLI runner. `allowWrite: ["/"]` was
489
+ * enough to write `~/.zshrc` and `~/Library/LaunchAgents/*.plist` — code
490
+ * execution outside the sandbox at the operator's next login.
491
+ *
492
+ * Delivery needs no cleverness: the worktree path is deterministic per card,
493
+ * `.claude/` is not gitignored, the daemon auto-commits with `git add -A`, and
494
+ * every retry, stage and steering resume re-spawns at the branch tip. One run
495
+ * writes the file; the next one is wider.
496
+ *
497
+ * ## Why refuse rather than repair
498
+ *
499
+ * Rewriting the file would work and is wrong twice: it mutates the run's own
500
+ * checkout, so the change lands in the diff a human reviews, and it teaches the
501
+ * daemon to silently edit repository files to protect itself. Refusing is
502
+ * fail-closed, leaves the evidence in place, and names the file.
503
+ *
504
+ * `managedSettings` — the SDK's policy tier, documented as filtered
505
+ * restrictive-only — was tried first and does NOT hold: the widening is
506
+ * accepted there too, on both modes. That is why this is a pre-spawn check and
507
+ * not a configuration.
508
+ *
509
+ * ## An ALLOW-list, because the first version of this was a denylist and lost
510
+ *
511
+ * That version refused exactly one key, `sandbox`, on the reasoning that the
512
+ * sandbox was the thing being widened. Review then walked past it with
513
+ * `hooks`, which the settings layer executes: a hook block in the worktree's
514
+ * own `.claude/settings.json` ran as the daemon user OUTSIDE the sandbox, read
515
+ * `~/.ssh/config` and `~/.harmony-mcp/config.json` — both in `denyRead` — and
516
+ * reached a host that is not on the egress allow-list. Project `env` is
517
+ * honoured too.
518
+ *
519
+ * That is the same shape this repo has now lost three times (glob spellings of
520
+ * `..`, the case of `.git`, and now settings keys): a denylist over input the
521
+ * attacker chooses is not a bound, and the answer is to invert it. So the rule
522
+ * is now "these keys are known not to affect execution, and anything else is
523
+ * refused" — a new escape has to be a key we deliberately blessed, rather than
524
+ * one nobody thought of.
525
+ *
526
+ * Note what the CLI's own trust gate does and does not cover, since it is easy
527
+ * to over-rely on: `permissions.allow` and `permissions.additionalDirectories`
528
+ * from an untrusted workspace ARE ignored ("this workspace has not been
529
+ * trusted"), and `hooks` and `env` are NOT. So the trust gate is not a
530
+ * substitute for this, and on the operator's own trusted checkout it covers
531
+ * even less.
532
+ *
533
+ * The check reads the file with plain `fs` and treats unparsable JSON as
534
+ * absent: a malformed settings file contributes nothing either, and failing the
535
+ * run on it would turn a typo into an outage.
536
+ */
537
+ const INERT_PROJECT_SETTING_KEYS: ReadonlySet<string> = new Set([
538
+ // Schema/editor metadata and presentation. None of these reach execution,
539
+ // the sandbox, the filesystem or the network. A key is added here only after
540
+ // someone checks that claim — the list being short is the point.
541
+ "$schema",
542
+ "cleanupPeriodDays",
543
+ "includeCoAuthoredBy",
544
+ "language",
545
+ "outputStyle",
546
+ "spinnerTipsEnabled",
547
+ "theme",
548
+ "verbose",
549
+ ]);
550
+
551
+ export function assertNoProjectSandboxOverride(worktree: string): void {
552
+ // Both spellings the `project` and `local` sources read. `local` is not in
553
+ // our `settingSources`, but it is checked anyway — the cost is one read and
554
+ // the alternative is this guard depending on a constant elsewhere staying
555
+ // narrow.
556
+ for (const name of ["settings.json", "settings.local.json"]) {
557
+ const path = join(worktree, ".claude", name);
558
+ let raw: string;
559
+ try {
560
+ raw = readFileSync(path, "utf-8");
561
+ } catch {
562
+ continue;
563
+ }
564
+ let parsed: unknown;
565
+ try {
566
+ parsed = JSON.parse(raw);
567
+ } catch {
568
+ continue;
569
+ }
570
+ if (parsed === null || typeof parsed !== "object") continue;
571
+ const offending = Object.keys(parsed).filter(
572
+ (key) => !INERT_PROJECT_SETTING_KEYS.has(key),
573
+ );
574
+ if (offending.length > 0) {
575
+ throw new ProjectSandboxOverrideError(path, offending);
576
+ }
577
+ }
578
+ }
579
+
580
+ /**
581
+ * The parent environment with {@link secretEnvKeysToStrip} removed.
582
+ *
583
+ * For the spawns that use `execFileSync` rather than `SdkAgentRunner` — the
584
+ * auto-fix and review paths — which have no `stripEnvKeys` of their own.
585
+ * `execFileSync`'s `env` REPLACES the child's environment rather than merging,
586
+ * so this returns the whole thing minus the stripped keys; handing it a partial
587
+ * object would drop `PATH` and `HOME` and break the spawn outright.
588
+ */
589
+ export function containedEnv(
590
+ parentEnv: Record<string, string | undefined> = process.env,
591
+ ): Record<string, string> {
592
+ const strip = new Set(secretEnvKeysToStrip(parentEnv));
593
+ const out: Record<string, string> = {};
594
+ for (const [key, value] of Object.entries(parentEnv)) {
595
+ if (value === undefined || strip.has(key)) continue;
596
+ out[key] = value;
597
+ }
598
+ return out;
599
+ }
600
+
601
+ /**
602
+ * Git metadata the contained run may not write — the escape the worktree grant
603
+ * would otherwise hand it.
604
+ *
605
+ * ## The chain, which this repo has already proven end to end
606
+ *
607
+ * The containment allows writes inside the worktree, because that is the run's
608
+ * workspace. In a LINKED worktree `<worktree>/.git` is a plain pointer FILE,
609
+ * and it therefore sits inside the granted scope. So:
610
+ *
611
+ * 1. rewrite `<worktree>/.git` to `gitdir: ./g`
612
+ * 2. write `<worktree>/g/config` with `[core] fsmonitor = ./payload.sh`
613
+ * 3. the daemon runs its ordinary post-run `git status` / `git add -A` /
614
+ * `git commit` in that worktree — `execFileSync`, in the DAEMON process,
615
+ * with no sandbox, the unstripped environment and full network
616
+ *
617
+ * and `payload.sh` executes as the operator, outside the profile, with every
618
+ * credential the containment spent three review rounds removing. It needs no
619
+ * credential read, no egress and no denied tool, so nothing else in this module
620
+ * fires. `core.sshCommand`, `core.pager` and `alias.*` are equivalent
621
+ * primitives; the #1015 security review demonstrated exactly this and closed it
622
+ * for the CI-repair spawn (`isGitMetadata`, `confine-to-repo.ts`).
623
+ *
624
+ * `writeOnlyDenyPaths` already denies the GLOBAL `~/.gitconfig` for this
625
+ * reason. The repository-local config is read at higher precedence and was
626
+ * fully inside the granted write scope.
627
+ *
628
+ * ## Why this is not simply "deny the git directory"
629
+ *
630
+ * A commit writes objects and refs into the COMMON dir, so denying it whole
631
+ * breaks `git commit` — the same shape as the `~/.gitconfig` regression one
632
+ * round earlier, where denying a read broke every git command. Only the paths
633
+ * that carry EXECUTION are denied: `config`, `config.worktree`, and `hooks`, in
634
+ * both the per-worktree git dir and the shared common dir.
635
+ *
636
+ * The bare `.git` path is denied only when it is a FILE. As a directory it is
637
+ * the whole store, and denying it would break the commit this exists to
638
+ * protect; there the `config`/`hooks` entries below do the work instead.
639
+ *
640
+ * Resolution is best-effort: a path that is not a git repository yet (a
641
+ * worktree about to be created, a unit test's fixture path) yields the
642
+ * worktree-relative guesses alone rather than throwing. A deny that names a
643
+ * file which does not exist costs nothing.
644
+ */
645
+ export function gitMetadataDenyPaths(worktree: string): string[] {
646
+ const paths = new Set<string>();
647
+ const dotGit = join(worktree, ".git");
648
+
649
+ // `createRequire` rather than a static import: a top-level `node:child_process`
650
+ // binding in a barrel-exported module breaks every test elsewhere that
651
+ // partially mocks it, at LOAD time — the trap `repair-sandbox.ts` documents.
652
+ const require = createRequire(import.meta.url);
653
+ const { execFileSync } =
654
+ require("node:child_process") as typeof import("node:child_process");
655
+ const { statSync } = require("node:fs") as typeof import("node:fs");
656
+
657
+ const gitDirs = new Set<string>();
658
+ try {
659
+ // Hardened like every other daemon-issued git command, including this one —
660
+ // the function that computes the git denies was itself the last unhardened
661
+ // call in the tree. `rev-parse` runs no hook today; the invariant is that
662
+ // no daemon git command CAN, not that this one happens not to.
663
+ const out = execFileSync(
664
+ "git",
665
+ [...GIT_NO_HOOKS, "rev-parse", "--git-dir", "--git-common-dir"],
666
+ { cwd: worktree, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] },
667
+ );
668
+ for (const line of out.split("\n")) {
669
+ const trimmed = line.trim();
670
+ if (!trimmed) continue;
671
+ gitDirs.add(isAbsolute(trimmed) ? trimmed : join(worktree, trimmed));
672
+ }
673
+ } catch {
674
+ // Not a repository (yet). Fall through to the in-worktree guess.
675
+ }
676
+ gitDirs.add(dotGit);
677
+
678
+ for (const dir of gitDirs) {
679
+ paths.add(join(dir, "config"));
680
+ paths.add(join(dir, "config.worktree"));
681
+ paths.add(join(dir, "hooks"));
682
+ }
683
+
684
+ // The pointer file itself — the first step of the chain. Only when it IS a
685
+ // file; as a directory this path is the object store.
686
+ try {
687
+ if (statSync(dotGit).isFile()) paths.add(dotGit);
688
+ } catch {
689
+ // Absent: nothing to point anywhere.
690
+ }
691
+
692
+ return [...paths];
693
+ }
694
+
695
+ /**
696
+ * Files and directories that make the operator's NEXT session run something.
697
+ *
698
+ * Not credentials — nothing here is worth reading — but every one of them is
699
+ * executed by a shell, a login or a session manager, so a write is host code
700
+ * execution on a delay. They belong on the write side only.
701
+ *
702
+ * ## Where the line is, and why it is drawn there
703
+ *
704
+ * The whole value of this function is that it is COMPLETE for the class it
705
+ * names, so the class has to be stated rather than left to the reader.
706
+ *
707
+ * **In: anything a shell, login or session manager executes unconditionally.**
708
+ * The startup file the operator never types is the point — they open a terminal
709
+ * and it runs. That is why `.zlogin` and `.bash_login` are here: zsh sources
710
+ * `.zprofile` then `.zlogin` for a login shell, and bash reads the first of
711
+ * `.bash_profile`, `.bash_login`, `.profile` that exists. Denying one member of
712
+ * a shell's startup chain and not the next is not a narrower rule, it is the
713
+ * same rule with a hole — the version of this list that shipped in #988 denied
714
+ * `.zprofile` but not `.zlogin`, and `.bash_profile` but not `.bash_login`.
715
+ * Fish is here for the same reason and was missing for the same reason.
716
+ *
717
+ * **Out: directories on `PATH`,** `~/.local/bin` and `~/bin` among them. A
718
+ * write there is real, but it is name SHADOWING — it fires only if the operator
719
+ * later runs a command by a name the run chose — and denying it breaks what a
720
+ * run legitimately does: `pip install --user` and its neighbours install there.
721
+ * That is the trade this module already made explicitly for `~/.bun/bin`,
722
+ * `~/.cache` and `$TMPDIR` in "what this does NOT cover", and it is made the
723
+ * same way here rather than differently. A run that cannot install its
724
+ * dependencies is prevented, not contained.
725
+ *
726
+ * **Out: the whole of `~/.config`.** Individual entries under it are in
727
+ * (`systemd/user`, `autostart`, `fish`) because a specific manager executes
728
+ * them. The directory as a whole is application configuration, and denying it
729
+ * wholesale would be a bound on the operator's tools rather than on the run.
730
+ *
731
+ * None of this is the boundary. The sandbox is; this list is the write-side
732
+ * mitigation that makes the sandbox's failure less useful.
733
+ */
734
+ export function hostPersistencePaths(): string[] {
735
+ const home = homedir();
736
+ return [
737
+ // zsh, in the order a login shell sources them.
738
+ join(home, ".zshenv"),
739
+ join(home, ".zprofile"),
740
+ join(home, ".zshrc"),
741
+ join(home, ".zlogin"),
742
+ // bash. `.bash_login` is the second candidate of the login triple and was
743
+ // the omitted one; `.profile` is the third and is also read by sh and dash.
744
+ join(home, ".bashrc"),
745
+ join(home, ".bash_profile"),
746
+ join(home, ".bash_login"),
747
+ join(home, ".profile"),
748
+ // fish. `conf.d/*.fish` is sourced before `config.fish`, so the directory
749
+ // matters at least as much as the file.
750
+ join(home, ".config", "fish", "config.fish"),
751
+ join(home, ".config", "fish", "conf.d"),
752
+ // Session managers: launchd (macOS), systemd user units and XDG autostart.
753
+ join(home, "Library", "LaunchAgents"),
754
+ join(home, ".config", "systemd", "user"),
755
+ join(home, ".config", "autostart"),
756
+ ];
757
+ }
758
+
759
+ /**
760
+ * The WRITE half of the tool-layer deny, and it was missing.
761
+ *
762
+ * ## Why this exists
763
+ *
764
+ * `credentialToolDeny` closes the read side: the sandbox wraps command
765
+ * execution, the CLI process hosting `Read`/`Grep`/`Glob` is not wrapped, so a
766
+ * kernel `denyRead` needs a `Read(...)` rule beside it. That reasoning is
767
+ * symmetric and was applied to one side only. `IMPLEMENT_ALLOWED_TOOLS` grants
768
+ * unqualified `Write` and `Edit`, which run in that same unwrapped process — so
769
+ * every `denyWrite` in the sandbox had no tool-layer counterpart, and the write
770
+ * side of the boundary stood open by exactly the mechanism this module
771
+ * documents.
772
+ *
773
+ * The gap was pinned by a test of this branch's own making. It asserted that
774
+ * `~/.gitconfig` must NOT appear in `disallowedTools`, on the reasoning that
775
+ * "the tool layer must not re-deny what the sandbox deliberately allows" — but
776
+ * the sandbox allows only the READ of that file and explicitly denies the
777
+ * WRITE. Dropping the write rule along with the read rule reopened the very
778
+ * path the `denyWrite` entry exists to close.
779
+ *
780
+ * ## Why this is still a denylist, and what the structural fix would be
781
+ *
782
+ * An allow-list would be better here, as everywhere else in this module. It is
783
+ * not expressible with these primitives: a deny wins over an allow, so
784
+ * `Edit(//**)` plus a re-allow of the worktree denies everything. The real
785
+ * inversion is `canUseTool` + `gateEveryToolCall` (`confine-to-repo.ts`), which
786
+ * IS an allow-list over paths — but it drops `allowedTools` and moves the spawn
787
+ * to `permissionMode: "default"`, and an implement run holds `mcp__harmony__*`,
788
+ * whose governance under that mode is not the same. A headless run that stops
789
+ * on a permission prompt is a hung card, so that swap needs measuring rather
790
+ * than assuming, and it is not folded into a security fix.
791
+ */
792
+ /*
793
+ * ## Why `Edit(...)` alone, and not `Write(...)`/`MultiEdit(...)` too
794
+ *
795
+ * MEASURED, because review raised it as blocking and the answer is not
796
+ * guessable from the types. Identical CLI runs, `Write` tool aimed at a denied
797
+ * directory, the only difference being whether these rules were passed:
798
+ *
799
+ * with the Edit(...) rules Write → DENIED, file absent
800
+ * the same rules stripped Write → WROTE, file on disk
801
+ *
802
+ * So `Edit(...)` IS the write-side rule and it governs every file-writing tool,
803
+ * exactly as one `Read(...)` rule governs `Read`, `Grep` and `Glob`. The SDK's
804
+ * own option doc says the same in one line — "Filesystem access: Use `Read` and
805
+ * `Edit` permission rules" — naming two rules for the whole filesystem surface,
806
+ * not six.
807
+ *
808
+ * Adding `Write(...)`/`MultiEdit(...)`/`NotebookEdit(...)` would not be
809
+ * harmless belt-and-braces: the read side already showed what happens to a rule
810
+ * the permission layer does not match — `Grep(...)` and `Glob(...)` denied
811
+ * NOTHING and printed a warning on every single run until they were removed.
812
+ * A rule that reads as protection and enforces none is worse than its absence.
813
+ */
814
+ export function credentialWriteToolDeny(worktree: string): string[] {
815
+ return [
816
+ ...credentialDirectories(),
817
+ ...writeOnlyDenyPaths(),
818
+ ...hostPersistencePaths(),
819
+ ...gitMetadataDenyPaths(worktree),
820
+ ].flatMap((p) => [`Edit(/${p})`, `Edit(/${p}/**)`]);
821
+ }
822
+
823
+ /** Tools that WRITE, and the argument each carries its target in. */
824
+ const WRITE_TOOL_PATHS: Record<string, readonly string[]> = {
825
+ Write: ["file_path"],
826
+ Edit: ["file_path"],
827
+ MultiEdit: ["file_path"],
828
+ NotebookEdit: ["notebook_path"],
829
+ };
830
+
831
+ /** Tools that READ a named path. `Grep`/`Glob` default to cwd when absent. */
832
+ const READ_TOOL_PATHS: Record<string, readonly string[]> = {
833
+ Read: ["file_path", "path", "notebook_path"],
834
+ Grep: ["path"],
835
+ Glob: ["path"],
836
+ };
837
+
838
+ /**
839
+ * The tool-layer policy, as an ALLOW-list over RESOLVED paths (#988).
840
+ *
841
+ * ## Why the denylist had to go
842
+ *
843
+ * `credentialToolDeny` and `credentialWriteToolDeny` are permission rules
844
+ * matched against the path STRING the model supplies, over a filesystem that is
845
+ * otherwise allow-by-default. Review established twice that this cannot be
846
+ * completed:
847
+ *
848
+ * - The rules match the REQUESTED path, not its resolved target, so a symlink
849
+ * created inside the writable worktree names a path no rule covers while
850
+ * landing in a denied tree.
851
+ * - The enumeration was short every time it was checked — and the last round
852
+ * named the entry that settles the argument: **the operator's primary
853
+ * checkout**, which is the PARENT of every worktree (`worktree.basePath`
854
+ * defaults to `.harmony-worktrees` under the repo root) and which carries
855
+ * the `.githooks/` directory this repo configures as `core.hooksPath` for
856
+ * the operator's own git.
857
+ *
858
+ * The sandbox's `filesystem.allowWrite` is already a closed allow-list for
859
+ * COMMANDS. This is the same shape for TOOLS, which run in the CLI process the
860
+ * sandbox does not wrap.
861
+ *
862
+ * ## The rules, and why reads stay a denylist
863
+ *
864
+ * - **Write tools**: the worktree and the toolchain caches, and nothing else —
865
+ * named or not. Git metadata inside them stays refused.
866
+ * - **Read tools**: refused inside the credential directories, allowed
867
+ * elsewhere. Deliberately still a denylist: a run legitimately reads the
868
+ * repo, its dependencies, the toolchain and the system headers, and an
869
+ * allow-list there would have to enumerate a working developer machine. The
870
+ * sandbox covers the command side of the same question.
871
+ * - **`Bash`**: allowed, and bounded by the sandbox instead. That is the whole
872
+ * reason this card chose an execution sandbox — a tool-layer policy cannot
873
+ * read a shell command's real effects, and pretending otherwise is how
874
+ * `confineToRepo` ends up removing the shell.
875
+ * - **`mcp__harmony__*`**: allowed. A scoped API, not a filesystem, bounded by
876
+ * the daemon-owned denials (#525/#576).
877
+ * - A tool that names a path this policy cannot resolve is REFUSED, not waved
878
+ * through — the fail-closed direction `decideConfinedTool` also takes.
879
+ *
880
+ * `isInsideTree` does the resolution, per component and through symlinks. It
881
+ * took three separate corrections in `confine-to-repo.ts` to get right, so it
882
+ * is reused rather than re-derived.
883
+ */
884
+ export function implementRunToolPolicy(args: {
885
+ worktree: string;
886
+ readOnly?: boolean;
887
+ }): (
888
+ toolName: string,
889
+ input: Record<string, unknown>,
890
+ ) => Promise<{ behavior: "allow" } | { behavior: "deny"; message: string }> {
891
+ const writable = [args.worktree, ...toolchainCacheDirectories(args.worktree)];
892
+ const gitMeta = gitMetadataDenyPaths(args.worktree);
893
+ const secrets = credentialDirectories();
894
+
895
+ return async (toolName, input) => {
896
+ // `updatedInput` is OPTIONAL in the SDK's TypeScript type and required by
897
+ // its runtime schema — measured: a bare `{ behavior: "allow" }` made every
898
+ // `Write` fail with a permission-handler `ZodError`, while `Bash` went
899
+ // through, so the run was contained and unable to do its job. Echoing the
900
+ // input back is the safe form and costs nothing. The unit tests could not
901
+ // catch this; only a real spawn could.
902
+ const allow = { behavior: "allow" as const, updatedInput: input };
903
+ const deny = (message: string) => ({ behavior: "deny" as const, message });
904
+
905
+ if (toolName.startsWith("mcp__")) return allow;
906
+ if (toolName === "Bash" || toolName === "BashOutput") {
907
+ // The sandbox is the bound here, and for a read-only spawn the caller's
908
+ // own `Bash(readonly)` grant still applies above this policy.
909
+ return allow;
910
+ }
911
+
912
+ const writePaths = WRITE_TOOL_PATHS[toolName];
913
+ if (writePaths) {
914
+ if (args.readOnly === true) {
915
+ return deny(`${toolName} is not available to a review run.`);
916
+ }
917
+ for (const key of writePaths) {
918
+ const value = input[key];
919
+ if (typeof value !== "string" || value.length === 0) continue;
920
+ const target = isAbsolute(value) ? value : join(args.worktree, value);
921
+ if (gitMeta.some((p) => isInsideTree(p, target) || p === target)) {
922
+ return deny(
923
+ `${toolName} may not touch git metadata. Refused: ${value}`,
924
+ );
925
+ }
926
+ if (!writable.some((root) => isInsideTree(root, target))) {
927
+ return deny(
928
+ `${toolName} may only write inside this run's worktree. Refused: ${value}`,
929
+ );
930
+ }
931
+ }
932
+ return allow;
933
+ }
934
+
935
+ const readPaths = READ_TOOL_PATHS[toolName];
936
+ if (readPaths) {
937
+ for (const key of readPaths) {
938
+ const value = input[key];
939
+ if (typeof value !== "string" || value.length === 0) continue;
940
+ const target = isAbsolute(value) ? value : join(args.worktree, value);
941
+ if (secrets.some((dir) => isInsideTree(dir, target))) {
942
+ return deny(
943
+ `${toolName} may not read the credential directories. Refused: ${value}`,
944
+ );
945
+ }
946
+ }
947
+ return allow;
948
+ }
949
+
950
+ // Unrecognised: allowed only because it names no path this policy knows how
951
+ // to check. `Task`/`Agent` inherit the same policy in their own spawn.
952
+ return allow;
953
+ };
954
+ }
955
+
956
+ /**
957
+ * Config flags every git command the daemon runs in a run's worktree must carry.
958
+ *
959
+ * ## The hole these close, which a path deny cannot
960
+ *
961
+ * `gitMetadataDenyPaths` stops the run writing git's own config and hook
962
+ * directories. It does not stop `core.hooksPath` pointing somewhere else — and
963
+ * in THIS repository it already does: the root `package.json`'s `prepare` runs
964
+ * `git config core.hooksPath .githooks`, and `createWorktree` runs the install
965
+ * command in every new worktree, so the shared repo config carries that value.
966
+ *
967
+ * A RELATIVE `core.hooksPath` resolves against the WORKING-TREE root. So in the
968
+ * run's own worktree it names `<worktree>/.githooks/` — an ordinary tracked
969
+ * directory, inside `sandbox.filesystem.allowWrite`, denied by neither the
970
+ * sandbox nor the `Edit(...)` rules, and holding a tracked executable
971
+ * `pre-push`. The daemon then runs `git push` there, in its own process, and
972
+ * the hook the run rewrote executes as the operator.
973
+ *
974
+ * Adding `.githooks` to a deny list would close this spelling and wait for the
975
+ * next hook convention. `ci-patch.ts` reached the same conclusion for the
976
+ * repair spawn and states it plainly: rather than enumerate conventions, no git
977
+ * command it issues can run a hook at all. This is that, for the daemon.
978
+ *
979
+ * `core.hooksPath=` is EMPTY rather than `/dev/null`, and the value must never
980
+ * be written: `git config core.hooksPath` from a linked worktree writes the
981
+ * SHARED repository config and would disable hooks in the operator's own
982
+ * checkout permanently. Passing it per command with `-c` touches nothing.
983
+ *
984
+ * `fsmonitor` and `pager` are the other two keys the #1015 review turned into
985
+ * execution off an ordinary read command, and `alias.*` cannot be disarmed by a
986
+ * flag — which is why the config files themselves stay denied. These flags and
987
+ * `gitMetadataDenyPaths` are two halves of one bound, not alternatives.
988
+ */
989
+ export const GIT_NO_HOOKS: readonly string[] = [
990
+ "-c",
991
+ "core.hooksPath=",
992
+ "-c",
993
+ "core.fsmonitor=",
994
+ "-c",
995
+ "core.pager=cat",
996
+ ];
997
+
998
+ /**
999
+ * Domains a contained implement run may reach.
1000
+ *
1001
+ * An allow-list, and short on purpose. The run needs the model API to think, a
1002
+ * package registry to install what a build needs, and the forge to push a
1003
+ * branch and open its PR. Nothing else on this list would be missed by a run
1004
+ * doing the job the card describes, and every addition is a new egress path for
1005
+ * text the run was given by someone untrusted.
1006
+ *
1007
+ * `deniedDomains` is deliberately empty: with an allow-list this short, a deny
1008
+ * entry would only ever be a note about something already refused.
1009
+ */
1010
+ export const IMPLEMENT_ALLOWED_DOMAINS: readonly string[] = [
1011
+ "api.anthropic.com",
1012
+ "*.anthropic.com",
1013
+ "registry.npmjs.org",
1014
+ "*.npmjs.org",
1015
+ "github.com",
1016
+ "*.github.com",
1017
+ "*.githubusercontent.com",
1018
+ ];
1019
+
1020
+ /**
1021
+ * The harmony MCP server, declared explicitly.
1022
+ *
1023
+ * ## Why this is not optional
1024
+ *
1025
+ * `settingSources: []` is what keeps the operator's own permission grants out
1026
+ * of a board-driven run — and the harmony MCP server is registered in the same
1027
+ * filesystem config it turns off. **Measured, not assumed:** with sources off
1028
+ * and nothing declared, `mcp__harmony__*` is simply gone, and a run that cannot
1029
+ * call `harmony_update_agent_progress` is a broken daemon rather than a
1030
+ * contained one. So the containment has to hand back what it just took away.
1031
+ *
1032
+ * ## Why it still works with the credential directory denied
1033
+ *
1034
+ * The MCP server reads its own credential from `~/.harmony-mcp/config.json`
1035
+ * with no environment fallback, which the sandbox denies. That looked like it
1036
+ * had to break the server, and it does not: an MCP stdio server is hosted by
1037
+ * the CLI, not run as a command inside the sandbox, so it authenticates
1038
+ * normally. Confirmed by running the contained configuration against a real
1039
+ * `harmony_list_workspaces` call with the deny in place.
1040
+ *
1041
+ * That is worth stating plainly, because it also bounds what the containment
1042
+ * claims: the sandbox governs the COMMANDS the model runs, not the MCP surface
1043
+ * the CLI hosts for it. The bound on the MCP surface is a different mechanism —
1044
+ * the tool allow-list, plus the daemon-owned denials (#525/#576).
1045
+ *
1046
+ * Resolved from the INSTALLED package rather than `npx @gethmy/mcp@latest`, the
1047
+ * spelling the host config uses. The daemon pins `@gethmy/mcp` exactly, in
1048
+ * lockstep with its own release, and a contained run reaching for `@latest`
1049
+ * would fetch a version this daemon was never tested against — over a network
1050
+ * the containment then has to allow the registry for.
1051
+ */
1052
+ export function harmonyMcpServer(): Record<string, McpServerConfig> {
1053
+ const require = createRequire(import.meta.url);
1054
+ // The package's `exports` map publishes "." → dist/index.js and no bin
1055
+ // subpath, so the CLI is named as its sibling rather than resolved directly.
1056
+ const cli = join(dirname(require.resolve("@gethmy/mcp")), "cli.js");
1057
+ return {
1058
+ harmony: {
1059
+ type: "stdio",
1060
+ // The daemon's own interpreter: no PATH lookup, no `npx` resolution step,
1061
+ // and no chance of a different Node than the one that was tested.
1062
+ command: process.execPath,
1063
+ args: [cli, "serve"],
1064
+ },
1065
+ };
1066
+ }
1067
+
1068
+ /** The fields {@link implementRunContainment} contributes to an `SdkRunnerConfig`. */
1069
+ export interface RunContainment {
1070
+ sandbox: SandboxSettings;
1071
+ /**
1072
+ * The tool-layer allow-list, and `gateEveryToolCall` is what makes it fire at
1073
+ * all — measured in #954: under `permissionMode: "dontAsk"` with
1074
+ * `allowedTools` set, the SDK pre-approves and NEVER calls the handler.
1075
+ */
1076
+ canUseTool: (
1077
+ toolName: string,
1078
+ input: Record<string, unknown>,
1079
+ ) => Promise<{ behavior: "allow" } | { behavior: "deny"; message: string }>;
1080
+ gateEveryToolCall: true;
1081
+ settingSources: SettingSource[];
1082
+ mcpServers: Record<string, McpServerConfig>;
1083
+ strictMcpConfig: true;
1084
+ stripEnvKeys: readonly string[];
1085
+ disallowedTools: string[];
1086
+ }
1087
+
1088
+ /**
1089
+ * Build the containment for an implement run against `worktree`.
1090
+ *
1091
+ * Returns a fragment to spread into an `SdkRunnerConfig`, rather than mutating
1092
+ * a runner: the caller still owns `allowedTools`, `model` and the turn budget,
1093
+ * and a function that returned a whole config would have to know about all of
1094
+ * them to hand one back.
1095
+ *
1096
+ * `extraDisallowedTools` carries the caller's existing denylist — the three
1097
+ * daemon-owned lifecycle MCP tools on a stage run (#525/#576). It is merged
1098
+ * rather than replaced, because those two denials answer different questions
1099
+ * and neither is allowed to drop the other.
1100
+ */
1101
+ export function implementRunContainment(args: {
1102
+ worktree: string;
1103
+ extraDisallowedTools?: readonly string[];
1104
+ /**
1105
+ * Spawn that must not run commands (the review pipeline). Turns off
1106
+ * `autoAllowBashIfSandboxed`, so a `Bash(readonly)` grant keeps meaning what
1107
+ * it says instead of being auto-approved because the run is sandboxed.
1108
+ */
1109
+ readOnly?: boolean;
1110
+ }): RunContainment {
1111
+ // Inside the builder rather than at the call sites, so a future spawn cannot
1112
+ // adopt the containment and skip the check that makes it hold. Throws — see
1113
+ // `assertNoProjectSandboxOverride`.
1114
+ assertNoProjectSandboxOverride(args.worktree);
1115
+ return {
1116
+ sandbox: {
1117
+ enabled: true,
1118
+ // Fail loudly on a host that cannot sandbox, rather than run uncontained.
1119
+ // This is also the SDK's own default once `enabled` is set through the
1120
+ // option; stated explicitly because the whole boundary rests on it.
1121
+ failIfUnavailable: true,
1122
+ // The model may not opt a single command back out. See the module note.
1123
+ allowUnsandboxedCommands: false,
1124
+ // Bash stays usable without a permission prompt, which is the point of
1125
+ // choosing an execution sandbox over a tool denylist: the run keeps its
1126
+ // shell and the shell keeps its bounds.
1127
+ //
1128
+ // OFF for a read-only spawn, and that is a correctness bug this had.
1129
+ // The review worker grants `Bash(readonly)` precisely so the reviewer
1130
+ // cannot modify the branch it is grading — and auto-approving Bash
1131
+ // BECAUSE it is sandboxed overrides that scoping. Measured A/B against
1132
+ // the review worker's own allow-list: with this on, a write and an `rm`
1133
+ // via Bash were both ALLOWED; with it off, both DENIED. The sandbox
1134
+ // bounds where a command may reach; it says nothing about whether this
1135
+ // particular spawn was supposed to run commands at all.
1136
+ autoAllowBashIfSandboxed: args.readOnly !== true,
1137
+ network: {
1138
+ allowedDomains: [...IMPLEMENT_ALLOWED_DOMAINS],
1139
+ // No inbound listener. A contained run has no reason to bind a port,
1140
+ // and one that does is either a test fixture that belongs in CI or a
1141
+ // channel out.
1142
+ allowLocalBinding: false,
1143
+ },
1144
+ filesystem: {
1145
+ // The worktree is the run's workspace; the rest are the package-manager
1146
+ // caches a build genuinely needs (see `toolchainCacheDirectories`).
1147
+ // This ADDS to a closed allow-list — the profile denies by default —
1148
+ // so it is not the whole of what the run may write: the profile also
1149
+ // allows the cwd, the git common dir (which is what lets `git commit`
1150
+ // work in a linked worktree), `/tmp/claude-<uid>` and `~/.claude/debug`.
1151
+ allowWrite: [
1152
+ args.worktree,
1153
+ ...toolchainCacheDirectories(args.worktree),
1154
+ ],
1155
+ denyRead: credentialDirectories(),
1156
+ // NOTE the asymmetry with `denyWrite` below: `~/.gitconfig` is
1157
+ // write-denied and read-ALLOWED, because denying its read makes every
1158
+ // git command in the spawn fatal. See `writeOnlyDenyPaths`.
1159
+ // `~/.claude` is denied for WRITE too, which subtracts the profile's
1160
+ // own `~/.claude/debug` allowance. That is accepted: the run's debug
1161
+ // log is worth less than a rule with an exception carved through it.
1162
+ denyWrite: [
1163
+ ...credentialDirectories(),
1164
+ ...writeOnlyDenyPaths(),
1165
+ // Not credentials, but a write to any of them runs on the operator's
1166
+ // next shell or login. See `hostPersistencePaths`.
1167
+ ...hostPersistencePaths(),
1168
+ // Inside the granted worktree, and the one thing in it the run must
1169
+ // not have: git metadata is executable by the daemon's own
1170
+ // uncontained git commands. See `gitMetadataDenyPaths`.
1171
+ ...gitMetadataDenyPaths(args.worktree),
1172
+ ],
1173
+ },
1174
+ },
1175
+ // `project` ONLY, and the choice between this and `[]` is the one real
1176
+ // trade in this module.
1177
+ //
1178
+ // `[]` is the tighter setting and it was the first version. It is wrong,
1179
+ // because the SDK loads CLAUDE.md through this same mechanism — the option
1180
+ // doc says so outright ("Must include 'project' to load CLAUDE.md files"),
1181
+ // and it was measured: with sources off, a code word planted in the repo's
1182
+ // own `CLAUDE.md` was invisible to the run. That silently drops CLAUDE.md,
1183
+ // AGENTS.md, PRODUCT.md, DESIGN.md, docs/architecture.md and every nested
1184
+ // `packages/*/CLAUDE.md` from every implement run — the instructions that
1185
+ // make the daemon's output match this repo rather than a generic one.
1186
+ //
1187
+ // What each source carries decides it:
1188
+ // - `user` — the OPERATOR's `~/.claude/settings.json`, holding their
1189
+ // personal permission grants (and, on the machine this was reviewed on,
1190
+ // a plaintext credential). This is the one that must not reach a
1191
+ // board-driven run, and it is excluded.
1192
+ // - `local` — `.claude/settings.local.json`, gitignored. The run can WRITE
1193
+ // its own worktree, so a loaded `local` is a run granting itself
1194
+ // permissions mid-flight. Excluded.
1195
+ // - `project` — `.claude/settings.json`, checked in and code-reviewed like
1196
+ // any other file in the repo, and the carrier for CLAUDE.md. Included.
1197
+ //
1198
+ // A run can still write `.claude/settings.json` in its own worktree, so
1199
+ // `project` is not proof against self-granting either. It does not need to
1200
+ // be: a re-granted TOOL permission is bounded by the sandbox, which is a
1201
+ // kernel profile the settings file cannot reach. That is the whole reason
1202
+ // the boundary was put there instead of here.
1203
+ // The tool-layer ALLOW-list. `disallowedTools` below still carries the
1204
+ // denies as a second, independent mechanism — this is the bound.
1205
+ canUseTool: implementRunToolPolicy(args),
1206
+ gateEveryToolCall: true,
1207
+ // ...but NOT for a read-only spawn, and that exception is a trust
1208
+ // boundary rather than a tuning choice.
1209
+ //
1210
+ // `readOnly` marks the spawns whose OUTPUT IS A VERDICT — the reviewer and
1211
+ // the deep review, whose findings gate `review.autoMerge`. Their cwd is a
1212
+ // worktree checked out from the agent-authored branch, so `CLAUDE.md`,
1213
+ // `AGENTS.md` and `DESIGN.md` in that tree are content the run under review
1214
+ // wrote. Loading the project source there hands the grader its instructions
1215
+ // from the thing being graded.
1216
+ //
1217
+ // This was a real regression introduced by keeping `project`: those spawns
1218
+ // previously ran `--setting-sources local,user`, which excluded the project
1219
+ // source outright, and the commit that changed it framed the difference as
1220
+ // a token cost and nothing more. `assertNoProjectSandboxOverride` guards
1221
+ // `.claude/settings.json` keys; it says nothing about instruction files,
1222
+ // and cannot — an instruction file has no schema to allow-list.
1223
+ //
1224
+ // The implement run keeps `project`, because there the instructions and the
1225
+ // work are the same person's, dropping them costs the repo's own
1226
+ // conventions, and its output is a diff a reviewer then reads. A verdict
1227
+ // has no such second reader.
1228
+ settingSources: args.readOnly === true ? [] : ["project"],
1229
+ // Handed back explicitly, because the line above took it away — see
1230
+ // `harmonyMcpServer`. Without this the run loses `mcp__harmony__*` and can
1231
+ // no longer report its own progress.
1232
+ mcpServers: harmonyMcpServer(),
1233
+ // Paired with the two above: the run sees only the server declared to it,
1234
+ // and no MCP config the operator happens to have registered for themselves.
1235
+ strictMcpConfig: true,
1236
+ // Belt to the sandbox's braces. The kernel deny already stops the file
1237
+ // being read; this stops the value being handed over in the environment,
1238
+ // which no filesystem rule addresses. Wider than
1239
+ // `HARMONY_CREDENTIAL_KEYS` — see `secretEnvKeysToStrip` for why the
1240
+ // operator's OWN tokens matter here, and why it is shaped rather than
1241
+ // enumerated.
1242
+ stripEnvKeys: secretEnvKeysToStrip(),
1243
+ // NOT redundant with the kernel deny — the other half of it. The sandbox
1244
+ // wraps commands; `Read`/`Grep`/`Glob` run in the CLI process and reach
1245
+ // past it. See `credentialToolDeny`.
1246
+ disallowedTools: [
1247
+ ...(args.extraDisallowedTools ?? []),
1248
+ ...credentialToolDeny(),
1249
+ // The WRITE half, covering everything the sandbox `denyWrite`s plus the
1250
+ // shell and login files a write turns into delayed host execution. The
1251
+ // read rules above without these left the tool layer one-sided — see
1252
+ // `credentialWriteToolDeny`.
1253
+ ...credentialWriteToolDeny(args.worktree),
1254
+ ],
1255
+ };
1256
+ }
1257
+
1258
+ /**
1259
+ * The same containment, as `claude` CLI arguments.
1260
+ *
1261
+ * The CLI runner is the configured fallback (`AgentConfig.runner === "cli"`),
1262
+ * and an operator who selects it must not thereby select their way out of the
1263
+ * boundary. So this exists rather than the CLI path being scoped out.
1264
+ *
1265
+ * It is DERIVED from {@link implementRunContainment} rather than re-specified,
1266
+ * so the two runners cannot drift into disagreeing about what a contained run
1267
+ * may reach — the failure mode that would matter here is the CLI path quietly
1268
+ * keeping an older, wider bound.
1269
+ *
1270
+ * The mapping is not one-to-one, and the one asymmetry is deliberate: the CLI
1271
+ * has no `--sandbox` flag, but `sandbox` is a **settings** key, so it travels
1272
+ * inside `--settings`. `--setting-sources ""` is the CLI spelling of
1273
+ * `settingSources: []`; passing both is not redundant, because `--settings` is
1274
+ * its own layer and is still honoured when the filesystem sources are off —
1275
+ * which is exactly what lets the sandbox survive the isolation that removes
1276
+ * everything else.
1277
+ *
1278
+ * `stripEnvKeys` has no argument form. It is applied by `spawnInGroup`, so a
1279
+ * caller must pass `containment.stripEnvKeys` to the spawn options as well;
1280
+ * these arguments alone do not strip the environment.
1281
+ */
1282
+ export function implementRunContainmentCliArgs(args: {
1283
+ worktree: string;
1284
+ extraDisallowedTools?: readonly string[];
1285
+ /**
1286
+ * Spawn that must not run commands (the review pipeline). Turns off
1287
+ * `autoAllowBashIfSandboxed`, so a `Bash(readonly)` grant keeps meaning what
1288
+ * it says instead of being auto-approved because the run is sandboxed.
1289
+ */
1290
+ readOnly?: boolean;
1291
+ }): string[] {
1292
+ const containment = implementRunContainment(args);
1293
+ return [
1294
+ "--settings",
1295
+ JSON.stringify({ sandbox: containment.sandbox }),
1296
+ // Same single source as the SDK path, for the same reason: it is what
1297
+ // carries CLAUDE.md, and it excludes the operator's `user` settings.
1298
+ "--setting-sources",
1299
+ containment.settingSources.join(","),
1300
+ // ...which also drops the harmony MCP registration, so it is re-declared
1301
+ // here exactly as the SDK path re-declares it.
1302
+ "--mcp-config",
1303
+ JSON.stringify({ mcpServers: containment.mcpServers }),
1304
+ "--strict-mcp-config",
1305
+ // `--disallowedTools` is variadic, so it goes LAST: a following positional
1306
+ // would be swallowed as another tool name. That is not hypothetical — the
1307
+ // same variadic parsing ate the prompt out of this card's own measurement
1308
+ // script twice before the cause was spotted.
1309
+ "--disallowedTools",
1310
+ containment.disallowedTools.join(","),
1311
+ ];
1312
+ }