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