@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.
- package/dist/cli.js +535 -69
- package/dist/index.js +697 -229
- package/package.json +2 -2
- package/src/ci-failure.ts +10 -5
- package/src/exec-types.ts +5 -7
- package/src/git-diff-stat.ts +2 -1
- package/src/git-pr.ts +59 -25
- package/src/index.ts +1 -0
- package/src/pm.ts +8 -3
- package/src/revert-guard.ts +9 -2
- package/src/run-containment.ts +1292 -0
- package/src/sdk-agent-runner.ts +19 -0
- package/src/verification.ts +84 -7
- package/src/worktree.ts +263 -59
|
@@ -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
|
+
}
|