pi-claude-supervisor 0.8.1 → 0.9.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/src/policy.ts CHANGED
@@ -1,11 +1,24 @@
1
1
  import { lstatSync, realpathSync, statSync } from "node:fs";
2
- import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
2
+ import { basename, dirname, isAbsolute, join, posix, relative, resolve } from "node:path";
3
3
 
4
4
  export type PolicyDecision = "allow" | "review" | "deny";
5
5
 
6
6
  export interface PolicyResult {
7
7
  decision: PolicyDecision;
8
8
  reason: string;
9
+ /**
10
+ * Set on a denial of the remote-repository boundary itself — a push, merge,
11
+ * pull request or remote mutation — so the Supervisor's publish hint answers
12
+ * that refusal and not an unrelated denial (an HTTP mutation, an outside-cwd
13
+ * write) that happens to share words with it.
14
+ */
15
+ boundary?: "remote";
16
+ /**
17
+ * Set only when a live publish grant admitted the command. The Supervisor
18
+ * issued that grant itself, so the request is answered locally rather than
19
+ * routed to a Decision Worker whose standing rule is to refuse a push.
20
+ */
21
+ granted?: true;
9
22
  }
10
23
 
11
24
  /**
@@ -18,8 +31,103 @@ export interface PolicyResult {
18
31
  export interface PermissionPolicyOptions {
19
32
  /** Extra directories the Worker may write to (Claude's per-session scratchpad); each must be an absolute path. */
20
33
  writeRoots?: readonly string[];
34
+ /** A publish-phase grant; absent (the default) keeps the Worker with no remote authority at all. */
35
+ remote?: RemoteGrant;
21
36
  }
22
37
 
38
+ /**
39
+ * Narrow, time-boxed remote authority for the publish phase. The Supervisor
40
+ * issues it only after its own acceptance and Reviewer passed, and only for the
41
+ * verified candidate's own branch, so an unverified Worker can never reach a
42
+ * remote. `pr` implies `push`.
43
+ */
44
+ export interface RemoteGrant {
45
+ authority: "push" | "pr";
46
+ /** The single remote the grant covers, e.g. `origin`. */
47
+ remoteName: string;
48
+ /** The candidate branch the verified commit is published to; a pull request must name it with `--head`. */
49
+ branch: string;
50
+ /**
51
+ * The verified commit. A granted push must name it as the refspec source
52
+ * (`<head>:refs/heads/<branch>`), so a commit made after verification can
53
+ * never ride the grant: git pushes exactly that object or nothing.
54
+ */
55
+ head: string;
56
+ /** The task working directory; a granted push must name it with an absolute `-C`, so the grant cannot be spent in another clone. */
57
+ cwd: string;
58
+ /**
59
+ * The granted remote's repository as `host/owner/repo`, resolved from its
60
+ * fetch URL when the grant was issued (an SSH-config host alias translated
61
+ * the way gh translates it). Under `pr` a pull request must name it with
62
+ * `--repo`: gh otherwise resolves a base repository from the remotes and
63
+ * prefers `upstream` on a fork, which is not the repository the grant named
64
+ * and not the one the confirmation reads. A pull request opens in the
65
+ * granted remote's repository, full stop.
66
+ */
67
+ repository?: string;
68
+ }
69
+
70
+ /** A full git object id (SHA-1 or SHA-256), lowercase or not. One test for the grant, the evidence and the baseline. */
71
+ export function isCommitId(value: unknown): value is string {
72
+ return typeof value === "string" && /^[0-9a-f]{40,64}$/iu.test(value);
73
+ }
74
+
75
+ /** A remote name git accepts verbatim; anything else could be an option or a path. One test for the env default and the spec. */
76
+ export function isPlainRemoteName(value: unknown): value is string {
77
+ return typeof value === "string" && /^[A-Za-z0-9._-]+$/u.test(value);
78
+ }
79
+
80
+ /** The hooks path the granted push must carry, so no hook a Worker could have installed runs inside the one granted command. */
81
+ export const GRANTED_HOOKS_PATH = "/dev/null";
82
+ /**
83
+ * The `-c` settings the granted push must carry, in this order: the hooks path
84
+ * (no installed hook runs inside the command) and `push.followTags=false` (no
85
+ * annotated tag rides along — `push.followTags=true` set through a file the
86
+ * policy never sees would otherwise make the one granted push also plant a
87
+ * tag the grant never named, and a tag is what release automation keys on).
88
+ * Both are ref/hook selection, not transport, so pinning them overrides no
89
+ * legitimate per-repository setting.
90
+ */
91
+ export const GRANTED_PUSH_SETTINGS = [`core.hooksPath=${GRANTED_HOOKS_PATH}`, "push.followTags=false"] as const;
92
+
93
+ /**
94
+ * A word quoted for a POSIX shell: plain words pass through, anything else is
95
+ * single-quoted with embedded quotes escaped. The one quoter for the hook
96
+ * relay, the tmux attach hint and the publish instruction, which must all
97
+ * produce what the policy's lexer reads back as a single literal token.
98
+ */
99
+ export function shellQuote(value: string): string {
100
+ if (/^[A-Za-z0-9_.\/-]+$/u.test(value)) return value;
101
+ return `'${value.replace(/'/gu, "'\\''")}'`;
102
+ }
103
+
104
+ /**
105
+ * The one push a grant admits, spelled the way `permittedRemoteCommand` reads
106
+ * it. Kept beside the parser so the instruction the Supervisor sends and the
107
+ * shape the policy accepts cannot drift apart. Every word the grant supplies
108
+ * is shell-quoted — the directory for a space, the refspec and branch because
109
+ * a legal branch name may carry `$`, `{}` or a quote that the lexer would
110
+ * otherwise read as dynamic — the hooks path is pinned so no `pre-push` runs,
111
+ * and the verified commit is the refspec source.
112
+ */
113
+ export function publishCommand(grant: RemoteGrant): string {
114
+ return `git -C ${shellQuote(grant.cwd)} ${GRANTED_PUSH_SETTINGS.map((setting) => `-c ${setting}`).join(" ")} push ${shellQuote(grant.remoteName)} ${shellQuote(`${grant.head}:refs/heads/${grant.branch}`)}`;
115
+ }
116
+
117
+ /** The one `gh pr create` prefix a `pr` grant admits; the Worker appends its title and body. */
118
+ export function pullRequestCommand(grant: RemoteGrant): string {
119
+ return `gh pr create --repo ${shellQuote(grant.repository ?? "")} --head ${shellQuote(grant.branch)}`;
120
+ }
121
+ /**
122
+ * The only options a granted `gh pr create` may carry. An allowlist, because an
123
+ * unlisted option is how a grant leaks: `--body-file`/`-F`/`--template` post the
124
+ * contents of an arbitrary local file, `-H` retargets the head branch, and `-w`
125
+ * is `--web`.
126
+ */
127
+ const ALLOWED_PR_CREATE_OPTIONS = new Set(["-t", "--title", "-b", "--body", "-B", "--base", "--draft", "-d", "--fill", "--fill-first", "-a", "--assignee", "-l", "--label", "-H", "--head", "-R", "--repo"]);
128
+ /** `gh pr create` options that take a value; the value is the next word. */
129
+ const PR_CREATE_VALUE_OPTIONS = new Set(["-t", "--title", "-b", "--body", "-B", "--base", "-a", "--assignee", "-l", "--label", "-H", "--head", "-R", "--repo"]);
130
+
23
131
  export function evaluatePermission(toolName: string, input: unknown, cwd = process.cwd(), options: PermissionPolicyOptions = {}): PolicyResult {
24
132
  if (toolName === "AskUserQuestion") return { decision: "deny", reason: "interactive questions are converted to ordinary Worker text" };
25
133
  if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") {
@@ -29,9 +137,10 @@ export function evaluatePermission(toolName: string, input: unknown, cwd = proce
29
137
  if (violation?.classification === "outside-cwd") {
30
138
  // Only name an alternative the Worker actually has: writeRoots is empty
31
139
  // for a bridge Worker and before an adopted session's first hook event.
32
- const alternatives = (options.writeRoots ?? []).length > 0 ? `; scratch work may go under ${(options.writeRoots ?? []).join(", ")}` : "";
140
+ const alternatives = (options.writeRoots ?? []).length > 0 ? `; writes are also allowed under ${(options.writeRoots ?? []).join(", ")}` : "";
33
141
  return { decision: "deny", reason: `Worker cannot write outside the task working directory: ${violation.path}${alternatives}` };
34
142
  }
143
+ if (violation?.classification === "cwd-missing") return { decision: "deny", reason: `the task working directory no longer exists, so nothing can be written under it: ${violation.path}` };
35
144
  if (violation?.classification === "git-metadata") return { decision: "deny", reason: "Worker cannot write Git metadata or protected branch refs" };
36
145
  return { decision: "allow", reason: `local Claude file tool is allowed by the task policy: ${toolName}` };
37
146
  }
@@ -42,7 +151,7 @@ export function evaluatePermission(toolName: string, input: unknown, cwd = proce
42
151
  if (!command) return { decision: "deny", reason: "Bash request has no recognizable command" };
43
152
  // Lex the command itself: wrapping it as a literal `bash -lc` argument would
44
153
  // hide its structure (heredoc bodies, dynamic words) from the token checks.
45
- return evaluateCommand(command);
154
+ return evaluateCommand(command, [], options.remote);
46
155
  }
47
156
 
48
157
  function fileToolPaths(input: unknown): string[] {
@@ -53,18 +162,20 @@ function fileToolPaths(input: unknown): string[] {
53
162
  .filter((path): path is string => typeof path === "string" && path.trim().length > 0);
54
163
  }
55
164
 
56
- type WritePathViolation = "outside-cwd" | "git-metadata";
165
+ type WritePathViolation = "outside-cwd" | "git-metadata" | "cwd-missing";
57
166
 
58
167
  /**
59
168
  * `realpath` of the deepest ancestor that exists, with the not-yet-created tail
60
169
  * re-appended. Plain `realpathSync` throws for a directory the Worker is about
61
- * to create, and the caller cannot tell that apart from a hostile path.
170
+ * to create, and the caller cannot tell that apart from a hostile path. Used
171
+ * for the write roots only: a task cwd that does not exist is refused, not
172
+ * tolerated (see `classifyWritePath`).
62
173
  */
63
174
  function resolveExistingPath(path: string): string {
64
175
  let current = resolve(path);
65
176
  const missing: string[] = [];
66
177
  for (;;) {
67
- try { return join(realpathSync(current), ...[...missing].reverse()); }
178
+ try { return join(realpathSync.native(current), ...[...missing].reverse()); }
68
179
  catch (error) {
69
180
  const code = (error as NodeJS.ErrnoException).code;
70
181
  if (code !== "ENOENT" && code !== "ENOTDIR") return resolve(path);
@@ -76,38 +187,65 @@ function resolveExistingPath(path: string): string {
76
187
  }
77
188
  }
78
189
 
190
+ /**
191
+ * The part of `value` below `root`, taken by raw string prefix rather than
192
+ * `path.relative`, which normalizes `link/../x` to `x` before the per-segment
193
+ * walk could see the symlink. Tried against both spellings of the root — the
194
+ * one the caller gave and its real path — so a root reached through a
195
+ * symlinked ancestor (a dotfile-managed ~/.claude, /var on macOS) matches
196
+ * however the Worker spelled it. Undefined when the value is not under the root.
197
+ */
198
+ function pathBelow(value: string, roots: readonly string[]): string | undefined {
199
+ const raw = value.replaceAll("\\", "/");
200
+ for (const root of roots) {
201
+ const canonical = root.replaceAll("\\", "/").replace(/\/+$/u, "") || "/";
202
+ const prefix = canonical === "/" ? "/" : `${canonical}/`;
203
+ if (raw === canonical) return "";
204
+ if (raw.startsWith(prefix)) return raw.slice(prefix.length);
205
+ }
206
+ return undefined;
207
+ }
208
+
79
209
  function classifyWritePath(value: string, cwd: string, writeRoots: readonly string[] = []): WritePathViolation | undefined {
80
210
  if (value.replaceAll("\\", "/").split("/").some((segment) => segment.toLowerCase() === ".git")) return "git-metadata";
81
- // A path inside an extra write root (Claude's own scratchpad) is judged
82
- // against that root instead of the cwd, with the same symlink/metadata rules.
83
- for (const root of writeRoots) {
84
- if (!isAbsolute(root) || !isAbsolute(value)) continue;
85
- // Resolve both sides before comparing: a write root reached through a
86
- // symlinked ancestor (a dotfile-managed ~/.claude, /var on macOS) would
87
- // otherwise be judged outside itself. The final component stays unresolved
88
- // so the per-segment symlink and `.git` checks below still see it.
89
- const resolvedRoot = resolveExistingPath(root);
90
- const resolvedValue = join(resolveExistingPath(dirname(value)), basename(value));
91
- const rel = relative(resolvedRoot, resolvedValue);
92
- if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) continue;
93
- return classifyWritePath(resolvedValue, resolvedRoot);
211
+ // A path inside an extra write root (Claude's own scratchpad or memory
212
+ // directory) is judged against that root instead of the cwd, with the same
213
+ // per-segment symlink and metadata rules. A root need not exist yet: Claude
214
+ // creates its memory directory on the first write, and failing closed there
215
+ // denied the very write the outside-cwd message points at. Each root is
216
+ // resolved once; the walk below never re-resolves it.
217
+ if (isAbsolute(value)) {
218
+ for (const root of writeRoots) {
219
+ if (!isAbsolute(root)) continue;
220
+ const resolvedRoot = resolveExistingPath(root);
221
+ const below = pathBelow(value, [root, resolvedRoot]);
222
+ if (below === undefined) continue;
223
+ return walkWritePath(below, resolvedRoot);
224
+ }
94
225
  }
95
- // A write root need not exist yet: Claude creates its memory directory on
96
- // the first write, and failing closed there denied the very write the
97
- // outside-cwd message points at.
98
- const root = resolveExistingPath(cwd);
99
- const raw = value.replaceAll("\\", "/");
100
- const canonicalRoot = root.replaceAll("\\", "/").replace(/\/+$/u, "") || "/";
101
- let components: string[];
226
+ // The task cwd itself is resolved fail-closed: a cwd that no longer exists
227
+ // (deleted, or its mount gone) must not have its writes allowed on whatever
228
+ // filesystem now sits there, which the Write tool's `mkdir -p` would create.
229
+ let root: string;
230
+ try { root = realpathSync.native(cwd); }
231
+ catch { return "cwd-missing"; }
102
232
  if (isAbsolute(value)) {
103
- const prefix = canonicalRoot === "/" ? "/" : `${canonicalRoot}/`;
104
- if (raw !== canonicalRoot && !raw.startsWith(prefix)) return "outside-cwd";
105
- components = raw === canonicalRoot ? [] : raw.slice(prefix.length).split("/");
106
- } else {
107
- components = raw.split("/");
233
+ const below = pathBelow(value, [cwd, root]);
234
+ if (below === undefined) return "outside-cwd";
235
+ return walkWritePath(below, root);
108
236
  }
237
+ return walkWritePath(value, root);
238
+ }
239
+
240
+ /**
241
+ * Walk a path relative to an already-resolved root one segment at a time,
242
+ * refusing `..` that would climb out, any `.git` segment, and any segment that
243
+ * is (or fails to be inspected as) a symlink — the kernel would follow it out
244
+ * of the root even though the pathname stays inside.
245
+ */
246
+ function walkWritePath(relativePath: string, root: string): WritePathViolation | undefined {
109
247
  const normalized: string[] = [];
110
- for (const segment of components) {
248
+ for (const segment of relativePath.replaceAll("\\", "/").split("/")) {
111
249
  if (!segment || segment === ".") continue;
112
250
  if (segment === "..") {
113
251
  if (normalized.length === 0) return "outside-cwd";
@@ -131,7 +269,7 @@ function classifyWritePath(value: string, cwd: string, writeRoots: readonly stri
131
269
  // A regular file with multiple links may be an alias for a Git ref or
132
270
  // other metadata file even when its pathname contains no `.git` segment.
133
271
  if (info.isFile() && info.nlink > 1) return "git-metadata";
134
- const resolved = realpathSync(absolute);
272
+ const resolved = realpathSync.native(absolute);
135
273
  if (resolved.replaceAll("\\", "/").split("/").some((segment) => segment.toLowerCase() === ".git")) return "git-metadata";
136
274
  } catch (error) {
137
275
  const code = (error as NodeJS.ErrnoException).code;
@@ -164,6 +302,127 @@ interface ShellToken {
164
302
  }
165
303
 
166
304
  const protectedBranches = new Set(["main", "master", "trunk", "integration", "develop"]);
305
+
306
+ /**
307
+ * Branch names that are never published: the integration names themselves and
308
+ * any `<prefix>/main|master|integration`. One predicate for both layers — the
309
+ * Supervisor consults it before issuing a grant, and the policy consults it
310
+ * again before honoring one — so neither can admit what the other refuses.
311
+ */
312
+ export function isProtectedBranch(branch: string): boolean {
313
+ return /^(?:main|master|trunk|integration|develop)$/iu.test(branch) || /(?:^|\/)(?:main|master|integration)$/iu.test(branch);
314
+ }
315
+
316
+ /**
317
+ * True when both paths name the same directory once the kernel has resolved
318
+ * them. `whenMissing` says what a path that does not exist means: `false` for a
319
+ * boundary check (a grant must never compare equal to nothing), `lexical` for
320
+ * a shape check on paths that may not exist yet.
321
+ */
322
+ export function sameDirectory(first: string, second: string, whenMissing: "false" | "lexical" = "false"): boolean {
323
+ // The native realpath, not Node's JavaScript one: the latter collapses a
324
+ // trailing `..` lexically even after a symlink, and disagrees with the
325
+ // kernel for exactly the paths a boundary check exists to catch.
326
+ try { return realpathSync.native(first) === realpathSync.native(second); }
327
+ catch { return whenMissing === "lexical" ? resolve(first) === resolve(second) : false; }
328
+ }
329
+
330
+ /**
331
+ * The one shape a granted publish may take. Everything is matched literally:
332
+ * the policy is static, so it cannot resolve `HEAD`, a variable or a second
333
+ * statement, and refuses rather than guess. Returns undefined when the command
334
+ * is not a permitted publish, leaving the ordinary denials to answer.
335
+ */
336
+ function permittedRemoteCommand(tokens: readonly ShellToken[], grant: RemoteGrant): PolicyResult | undefined {
337
+ // A single statement only: `git push origin x && rm -rf /` must never pass.
338
+ if (tokens.some((token) => token.operator)) return undefined;
339
+ if (tokens.some((token) => token.dynamic)) return undefined;
340
+ const words = tokens.map((token) => token.value);
341
+ if (words.length === 0) return undefined;
342
+ const name = (words[0] ?? "").split(/[\\/]/u).at(-1)?.toLowerCase();
343
+ if (isProtectedBranch(grant.branch)) return undefined;
344
+ if (!isCommitId(grant.head)) return undefined;
345
+ const optionName = (word: string): string => word.split("=")[0] ?? word;
346
+
347
+ if (name === "git") {
348
+ // `-C <task directory>` is *required*, not merely tolerated. Claude's Bash
349
+ // tool keeps its working directory between calls and `cd` is ordinary local
350
+ // work, so without an explicit directory the grant could be spent in any
351
+ // clone the Worker had wandered into. The directory must be the granted one
352
+ // *byte for byte* — no normalization, no realpath. Every looser comparison
353
+ // has had a spelling that this process resolves one way and git another:
354
+ // a relative `.` against this process's cwd, `/proc/self/cwd` against this
355
+ // process's, and `<cwd>/link/..`, which Node's own `realpathSync` collapses
356
+ // lexically to the task directory while the kernel — and git — follow the
357
+ // link and end up elsewhere. The instruction spells the exact directory,
358
+ // so no alternate spelling needs to be accepted at all.
359
+ if (words[1] !== "-C") return undefined;
360
+ const directory = words[2];
361
+ if (directory === undefined || !isAbsolute(directory) || directory !== grant.cwd) return undefined;
362
+ // The pinned `-c` settings are required too, in order: a `pre-push` hook
363
+ // runs inside the granted push with the Worker's credentials where no
364
+ // policy sees it, and `push.followTags=true` would push a tag along with
365
+ // the commit; both can arrive by more doors than a write denial can
366
+ // enumerate, so the one granted command is made immune instead.
367
+ let cursor = 3;
368
+ for (const setting of GRANTED_PUSH_SETTINGS) {
369
+ if (words[cursor] !== "-c" || words[cursor + 1] !== setting) return undefined;
370
+ cursor += 2;
371
+ }
372
+ if (words[cursor] !== "push") return undefined;
373
+ // No push option at all: `-u` did nothing with a commit as the source, and
374
+ // an allowlist of one no-op is only surface. The refspec names the
375
+ // verified commit, so git pushes exactly that object; a commit made during
376
+ // the publish turn stays local ("Everything up-to-date") instead of riding
377
+ // the grant. `refs/heads/` is spelled out because a bare destination is
378
+ // refused by git when the remote branch does not exist yet.
379
+ const rest = words.slice(cursor + 1);
380
+ if (rest.length !== 2 || rest.some((word) => word.startsWith("-"))) return undefined;
381
+ const [remote, refspec] = rest as [string, string];
382
+ if (remote !== grant.remoteName) return undefined;
383
+ if (refspec !== `${grant.head}:refs/heads/${grant.branch}`) return undefined;
384
+ return { decision: "allow", reason: `publish grant: push ${grant.head.slice(0, 12)} to ${grant.remoteName}/${grant.branch}`, granted: true };
385
+ }
386
+
387
+ if (name === "gh" && grant.authority === "pr") {
388
+ if (!grant.repository) return undefined;
389
+ if (words[1] !== "pr" || words[2] !== "create") return undefined;
390
+ const rest = words.slice(3);
391
+ // `--head <candidate branch>` is mandatory: without it gh uses whatever
392
+ // branch is checked out, and `git checkout` is ordinary local work, so a
393
+ // bare `gh pr create` could open a pull request for a branch nothing
394
+ // verified. `--repo <pinned URL>` is mandatory for the same reason one
395
+ // level up: without it gh picks a base repository from the remotes
396
+ // (`upstream` on a fork, or whatever clone the Worker's shell sits in).
397
+ // Each counts only as the option the loop itself parses, never as a word
398
+ // another option swallowed: `--title --head` sets the title.
399
+ let sawHead = false;
400
+ let sawRepo = false;
401
+ for (let cursor = 0; cursor < rest.length; cursor += 1) {
402
+ const word = rest[cursor]!;
403
+ if (!word.startsWith("-")) return undefined;
404
+ const option = optionName(word);
405
+ if (!ALLOWED_PR_CREATE_OPTIONS.has(option)) return undefined;
406
+ const inlineValue = word.includes("=");
407
+ const value = inlineValue ? word.slice(option.length + 1) : rest[cursor + 1];
408
+ if (PR_CREATE_VALUE_OPTIONS.has(option)) {
409
+ if (value === undefined) return undefined;
410
+ if (option === "-H" || option === "--head") {
411
+ if (value !== grant.branch) return undefined;
412
+ sawHead = true;
413
+ }
414
+ if (option === "-R" || option === "--repo") {
415
+ if (value !== grant.repository) return undefined;
416
+ sawRepo = true;
417
+ }
418
+ if (!inlineValue) cursor += 1;
419
+ }
420
+ }
421
+ if (!sawHead || !sawRepo) return undefined;
422
+ return { decision: "allow", reason: `publish grant: open a pull request for ${grant.branch}`, granted: true };
423
+ }
424
+ return undefined;
425
+ }
167
426
  /**
168
427
  * Commands whose dynamic arguments could carry a boundary-crossing action or
169
428
  * execute arbitrary expanded text: the repository, package, network and
@@ -205,16 +464,22 @@ function containsRemoteHttpMutation(command: string): boolean {
205
464
  return hasHttpClient && hasMutationFlag && hasProtectedEndpoint;
206
465
  }
207
466
 
208
- export function evaluateCommand(command: string, args: readonly string[] = []): PolicyResult {
467
+ export function evaluateCommand(command: string, args: readonly string[] = [], grant?: RemoteGrant): PolicyResult {
209
468
  const normalized = command.trim();
210
469
  if (!normalized) return { decision: "deny", reason: "empty command" };
211
470
  const lexical = lexShell(normalized);
212
471
  if (lexical.error) return { decision: "deny", reason: `command could not be safely parsed: ${lexical.error}` };
213
472
  const literalArgs = args.map((value) => ({ value, operator: false, dynamic: false }));
214
- return evaluateTokens([...lexical.tokens, ...literalArgs], 0);
473
+ return evaluateTokens([...lexical.tokens, ...literalArgs], 0, grant);
215
474
  }
216
475
 
217
- function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: string, depth: number): PolicyResult | undefined {
476
+ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: string, depth: number, grant?: RemoteGrant): PolicyResult | undefined {
477
+ // A publish grant admits exactly one shape; everything else still falls
478
+ // through to the ordinary boundary denials below.
479
+ if (grant) {
480
+ const permitted = permittedRemoteCommand(tokens, grant);
481
+ if (permitted) return permitted;
482
+ }
218
483
  const values = tokens.filter((token) => !token.operator).map((token) => token.value);
219
484
  const lower = values.map((value) => value.toLowerCase());
220
485
  const hasGit = values.some((value) => /(?:^|[\\/])git$/iu.test(value) || /^(?:git-(?:send|receive|upload)-pack)$/iu.test(value));
@@ -244,16 +509,33 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
244
509
  // interpreter that would execute the expanded text, in the same statement.
245
510
  // Dynamic text in an ordinary local command (`for f in …; echo "$f"`) or in
246
511
  // another statement (`npm test; echo "exit $?"`) is Claude's own business.
247
- if ((hasDynamicArgument && hasDynamicCommandName(tokens)) || segmentsOf(tokens).some((segment) => hasDynamicSensitiveArgument(segment))) {
512
+ const segments = segmentsOf(tokens);
513
+ if ((hasDynamicArgument && hasDynamicCommandName(tokens)) || segments.some((segment) => hasDynamicSensitiveArgument(segment))) {
248
514
  return { decision: "deny", reason: "a repository, package, network or shell command with a dynamic argument cannot be capability-checked; substitute the literal value for the shell variable so the command can be read, or use the Write/Edit tools when the intent is to change a file" };
249
515
  }
250
516
  if (/\bgit\b[\s\S]*\b(?:push|merge(?!-)|send-pack|receive-pack|update-ref)\b/iu.test(canonical)
251
517
  || /\bgit-(?:send|receive|upload)-pack\b/iu.test(canonical)
252
518
  || containsRemoteCliMutation(canonical)) {
253
- return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
519
+ return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority", boundary: "remote" };
254
520
  }
255
521
  if (hasGit && (hasRemoteOperation || hasGitTransport) || hasGhRemote) {
256
- return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
522
+ return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority", boundary: "remote" };
523
+ }
524
+ // Repointing a remote would make the grant's remote *name* meaningless and
525
+ // would fool the Supervisor's own confirmation, which resolves the same name.
526
+ // Scoped to one statement with `remote` in git's subcommand position and the
527
+ // action right after it: matching the words anywhere denied `git remote -v &&
528
+ // git add -A` and even `git add remote`, which are ordinary local work.
529
+ if (segments.some((segment) => mutatesRemotes(segment))) {
530
+ return { decision: "deny", reason: "Worker cannot change the repository's remotes" };
531
+ }
532
+ // The same boundary through `git config`: `remote.<name>.pushurl`,
533
+ // `url.<x>.insteadOf`, `push.*`, `core.sshCommand`, `core.hooksPath` and the
534
+ // credential/http keys change where a push goes, what travels with it or
535
+ // what runs during it, without touching the remote's name. Reads stay
536
+ // ordinary local work; only a write of one of these keys is refused.
537
+ if (segments.some((segment) => configuresRemoteTransport(segment))) {
538
+ return { decision: "deny", reason: "Worker cannot reconfigure the repository's remotes, URL rewrites, push behavior, credentials or hooks" };
257
539
  }
258
540
  if (hasGitAliasConfiguration) {
259
541
  return { decision: "deny", reason: "Worker cannot redefine Git command aliases" };
@@ -267,6 +549,22 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
267
549
  || (directRefWrite && hasProtectedBranch && /refs[\\/]heads[\\/]/iu.test(canonical))) {
268
550
  return { decision: "deny", reason: "Worker cannot write protected Git branch refs directly" };
269
551
  }
552
+ // `.git/config` (and `.git/config.worktree`) is the `git config` boundary
553
+ // above by another door, and a hook in `.git/hooks/` runs during the granted
554
+ // push where no policy sees it. A list of writing commands cannot be
555
+ // complete (`python3 -c`, `tar -C`, an archive), so any statement naming
556
+ // either path is refused unless it plainly only reads — the same stance the
557
+ // branch-ref rule above takes. The Write and Edit tools already refuse every
558
+ // `.git` path, and `git init --template=` would install hooks without naming
559
+ // the directory at all. What this cannot see (`~/.gitconfig`, a script) the
560
+ // Supervisor's remote-URL baseline catches at grant time instead.
561
+ const namesMetadataFile = (segment: readonly ShellToken[]): boolean => segment.some((token) => !token.operator && namesGitMetadata(token.value));
562
+ if (segments.some((segment) => namesMetadataFile(segment) && !onlyReads(segment))) {
563
+ return { decision: "deny", reason: "Worker cannot write the repository's Git configuration or hooks directly" };
564
+ }
565
+ if (segments.some((segment) => initializesWithTemplate(segment))) {
566
+ return { decision: "deny", reason: "Worker cannot install repository hooks from a template or relocate the repository's Git directory" };
567
+ }
270
568
  if (hasPackagePublication) {
271
569
  return { decision: "deny", reason: "package publication belongs to the protected release workflow" };
272
570
  }
@@ -278,6 +576,144 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
278
576
  return undefined;
279
577
  }
280
578
 
579
+ const REMOTE_MUTATIONS = new Set(["set-url", "add", "rename", "remove", "rm", "prune", "set-branches", "set-head"]);
580
+
581
+ /** git's own pre-subcommand options that take the next word as their value when not written `--opt=value`. */
582
+ const GIT_GLOBAL_VALUE_OPTIONS = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--config-env", "--attr-source"]);
583
+
584
+ /**
585
+ * The index of git's subcommand in one statement's words, or -1 when the
586
+ * statement is not a git invocation. Skips the pre-subcommand options,
587
+ * including the space-separated value forms: `git --git-dir .git remote …`
588
+ * has its subcommand at index 3, not 2, and a locator that only knew `-C`
589
+ * and `-c` let `remote set-url` and `config remote.*` hide behind `--git-dir`.
590
+ */
591
+ function gitSubcommandIndex(words: readonly string[]): number {
592
+ const first = words[0]?.split(/[\\/]/u).at(-1)?.toLowerCase();
593
+ if (first !== "git") return -1;
594
+ let index = 1;
595
+ while (index < words.length && words[index]!.startsWith("-")) {
596
+ index += GIT_GLOBAL_VALUE_OPTIONS.has(words[index]!) ? 2 : 1;
597
+ }
598
+ return index < words.length ? index : -1;
599
+ }
600
+
601
+ /** True when this one statement is `git [options] remote <mutating action>`. */
602
+ function mutatesRemotes(segment: readonly ShellToken[]): boolean {
603
+ const words = segment.filter((token) => !token.operator).map((token) => token.value);
604
+ const index = gitSubcommandIndex(words);
605
+ if (index < 0 || words[index]?.toLowerCase() !== "remote") return false;
606
+ // `remote`'s own options (`-v`, `--verbose`) sit before the action: git
607
+ // parses `git remote -v add evil …` as an `add`.
608
+ const action = words.slice(index + 1).find((word) => !word.startsWith("-"))?.toLowerCase();
609
+ return action !== undefined && REMOTE_MUTATIONS.has(action);
610
+ }
611
+
612
+ /**
613
+ * Configuration keys that decide where a push goes, what travels with it or
614
+ * what runs during it. `remote.*` covers `pushurl`; `url.*` the `insteadOf`
615
+ * rewrites; `push.*` follow-tags and push options; `include.*`/`includeIf.*`
616
+ * would pull any of the others in from a file the Worker wrote; the rest are
617
+ * credentials, transport and hook locations.
618
+ */
619
+ const REMOTE_TRANSPORT_CONFIG_KEY = /^(?:remote\.|url\.|push\.|credential\.|http\.|https\.|include\.|includeif\.|init\.|core\.(?:sshcommand|hookspath|gitproxy|askpass|alternaterefscommand)$)/iu;
620
+ /** `git config` flags and verbs that only read; a key next to one of them is a query, not a change. */
621
+ const GIT_CONFIG_READ_FLAGS = new Set(["--get", "--get-all", "--get-regexp", "--get-urlmatch", "-l", "--list", "--show-origin", "--show-scope", "--name-only"]);
622
+ const GIT_CONFIG_WRITE_FLAGS = new Set(["--add", "--replace-all", "--unset", "--unset-all", "--remove-section", "--rename-section", "--edit", "-e"]);
623
+
624
+ /**
625
+ * True when a word names `.git/config`, `.git/config.worktree` or something
626
+ * under `.git/hooks/` in any spelling the shell would resolve to it: the raw
627
+ * text, its normalized path (`.git/./config`, `.git//config`, `src/../.git/config`),
628
+ * and — for a word carrying glob characters — the shape the glob could expand
629
+ * to (`.gi[t]/config`; anything wildcarded whose path has a `config` or
630
+ * `hooks` segment). The Write and Edit tools normalize their paths; Bash
631
+ * does not, so this does.
632
+ */
633
+ function namesGitMetadata(word: string): boolean {
634
+ const metadata = /\.git[\\/](?:config|hooks)(?![A-Za-z0-9_-])/iu;
635
+ const value = word.replaceAll("\\", "/");
636
+ if (metadata.test(value) || metadata.test(posix.normalize(value))) return true;
637
+ if (!/[*?[]/u.test(value)) return false;
638
+ // A glob is judged segment by segment: it names the metadata only where
639
+ // some segment could expand to `.git` and the next to `config`,
640
+ // `config.worktree` or `hooks`. `src/hooks/*.ts` and `config/*.json` have no
641
+ // such segment and stay ordinary work; `.gi[t]/config`, `.git/conf?g`,
642
+ // `.g*/hooks/x` and `*/config` do not.
643
+ const segments = posix.normalize(value).split("/");
644
+ for (let index = 0; index + 1 < segments.length; index += 1) {
645
+ if (!globMatches(segments[index]!, ".git")) continue;
646
+ const next = segments[index + 1]!;
647
+ if (globMatches(next, "config") || globMatches(next, "config.worktree") || globMatches(next, "hooks")) return true;
648
+ }
649
+ return false;
650
+ }
651
+
652
+ /** True when one shell-glob path segment could expand to `literal` (`*` any run, `?` one character, `[…]` a class). */
653
+ function globMatches(segment: string, literal: string): boolean {
654
+ let pattern = "";
655
+ for (let index = 0; index < segment.length; index += 1) {
656
+ const char = segment[index]!;
657
+ if (char === "*") pattern += "[^/]*";
658
+ else if (char === "?") pattern += "[^/]";
659
+ else if (char === "[") {
660
+ const close = segment.indexOf("]", index + 1);
661
+ if (close > index) { pattern += `[${segment.slice(index + 1, close).replace(/\\/gu, "\\\\")}]`; index = close; }
662
+ else pattern += "\\[";
663
+ } else pattern += char.replace(/[.+^${}()|\\]/gu, "\\$&");
664
+ }
665
+ try { return new RegExp(`^${pattern}$`, "iu").test(literal); }
666
+ catch { return true; }
667
+ }
668
+
669
+ /** Commands that only read what they are given; a statement led by one of these may name `.git/config` or a hook to look at it. */
670
+ const READ_ONLY_COMMANDS = new Set(["cat", "head", "tail", "less", "more", "grep", "egrep", "fgrep", "rg", "ls", "stat", "file", "wc", "diff", "md5sum", "sha256sum", "bat", "view"]);
671
+
672
+ /** True when this one statement is led by a read-only command and carries no redirection. */
673
+ function onlyReads(segment: readonly ShellToken[]): boolean {
674
+ if (segment.some((token) => token.operator)) return false;
675
+ const first = segment[0]?.value.split(/[\\/]/u).at(-1)?.toLowerCase() ?? "";
676
+ return READ_ONLY_COMMANDS.has(first);
677
+ }
678
+
679
+ /**
680
+ * True when this one statement is `git init --template…`, which copies hooks
681
+ * into `.git/hooks/` of an existing repository too, or `git init|clone
682
+ * --separate-git-dir…`, which moves the repository's config and hooks to a
683
+ * path none of the `.git/` guards name.
684
+ */
685
+ function initializesWithTemplate(segment: readonly ShellToken[]): boolean {
686
+ const words = segment.filter((token) => !token.operator).map((token) => token.value);
687
+ const index = gitSubcommandIndex(words);
688
+ if (index < 0) return false;
689
+ const subcommand = words[index]?.toLowerCase();
690
+ const options = words.slice(index + 1).map((word) => word.toLowerCase());
691
+ if (subcommand === "init" && options.some((word) => word.startsWith("--template"))) return true;
692
+ return (subcommand === "init" || subcommand === "clone") && options.some((word) => word.startsWith("--separate-git-dir"));
693
+ }
694
+
695
+ /** True when this one statement is `git [options] config` writing a transport-affecting key. */
696
+ function configuresRemoteTransport(segment: readonly ShellToken[]): boolean {
697
+ const words = segment.filter((token) => !token.operator).map((token) => token.value);
698
+ const index = gitSubcommandIndex(words);
699
+ if (index < 0 || words[index]?.toLowerCase() !== "config") return false;
700
+ const rest = words.slice(index + 1);
701
+ const lower = rest.map((word) => word.toLowerCase());
702
+ const positional = rest.filter((word) => !word.startsWith("-"));
703
+ const verb = positional[0]?.toLowerCase();
704
+ // An editor session rewrites the whole file, every guarded key included,
705
+ // and names none of them on the command line.
706
+ if (lower.includes("-e") || lower.includes("--edit") || verb === "edit") return true;
707
+ if (!rest.some((word) => REMOTE_TRANSPORT_CONFIG_KEY.test(word))) return false;
708
+ // `git config <key>` alone reads it, as do the query flags and the newer
709
+ // `git config get <key>` form; anything that names a value or a write verb
710
+ // is a change.
711
+ if (lower.some((word) => GIT_CONFIG_READ_FLAGS.has(word)) || verb === "get" || verb === "list") return false;
712
+ const writes = lower.some((word) => GIT_CONFIG_WRITE_FLAGS.has(word))
713
+ || verb === "set" || verb === "unset" || verb === "remove-section" || verb === "rename-section" || verb === "edit";
714
+ return writes || positional.length >= 2;
715
+ }
716
+
281
717
  /** The statements of a command, split on `;`, `&&`, `||`, `|` and `&`. */
282
718
  function segmentsOf(tokens: readonly ShellToken[]): ShellToken[][] {
283
719
  const segments: ShellToken[][] = [[]];
@@ -357,26 +793,29 @@ function nestedShellCommands(lower: readonly string[], values: readonly string[]
357
793
  return nested;
358
794
  }
359
795
 
360
- function evaluateCommandInternal(command: string, depth: number): PolicyResult {
796
+ function evaluateCommandInternal(command: string, depth: number, grant?: RemoteGrant): PolicyResult {
361
797
  const normalized = command.trim();
362
798
  if (!normalized) return { decision: "deny", reason: "empty command" };
363
799
  const lexical = lexShell(normalized);
364
800
  if (lexical.error) return { decision: "deny", reason: `command could not be safely parsed: ${lexical.error}` };
365
- return evaluateTokens(lexical.tokens, depth);
801
+ return evaluateTokens(lexical.tokens, depth, grant);
366
802
  }
367
803
 
368
- function evaluateTokens(rawTokens: readonly ShellToken[], depth: number): PolicyResult {
804
+ function evaluateTokens(rawTokens: readonly ShellToken[], depth: number, grant?: RemoteGrant): PolicyResult {
369
805
  const { tokens: dataResolved, embedded } = resolveDataTokens(rawTokens);
370
806
  const tokens = resolveLiteralBindings(dataResolved);
371
807
  if (depth < 4) {
372
808
  for (const body of embedded) {
809
+ // Never with the grant: a nested result is consulted only when it denies,
810
+ // so a grant here could only suppress a denial -- a heredoc-wrapped push
811
+ // would pass while the direct form is the only shape that was reviewed.
373
812
  const nestedResult = evaluateCommandInternal(body, depth + 1);
374
813
  if (nestedResult.decision === "deny") return nestedResult;
375
814
  }
376
815
  }
377
816
  const canonical = tokens.map((token) => token.value).join(" ").trim();
378
817
  if (!canonical) return { decision: "deny", reason: "empty command" };
379
- const boundary = evaluateRepositoryBoundary(tokens, canonical, depth);
818
+ const boundary = evaluateRepositoryBoundary(tokens, canonical, depth, grant);
380
819
  if (boundary) return boundary;
381
820
  if (containsRemoteHttpMutation(canonical)) {
382
821
  return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
@@ -699,19 +1138,23 @@ const FIND_WRITE_ACTIONS = new Set(["-delete", "-exec", "-execdir", "-ok", "-okd
699
1138
  export function isRoutinePermission(toolName: string, input: unknown, cwd: string, options: PermissionPolicyOptions = {}): boolean {
700
1139
  const policyResult = evaluatePermission(toolName, input, cwd, options);
701
1140
  if (policyResult.decision === "deny") return false;
1141
+ // A granted publish is the Supervisor's own decision, already made: under
1142
+ // hybrid authority it is answered here, not escalated to a Decision Worker
1143
+ // that was never told the grant exists and whose standing rule is to refuse.
1144
+ if (policyResult.granted) return true;
702
1145
  if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") return true;
703
1146
  if (toolName === "Read" || toolName === "Glob" || toolName === "Grep" || toolName === "LS" || toolName === "TodoWrite") return true;
704
1147
  if (toolName === "Bash") {
705
1148
  const command = input && typeof input === "object" && typeof (input as { command?: unknown }).command === "string"
706
1149
  ? (input as { command: string }).command
707
1150
  : "";
708
- return command ? isRoutineShellCommand(command, cwd) : false;
1151
+ return command ? isRoutineShellCommand(command, cwd, options.writeRoots ?? []) : false;
709
1152
  }
710
1153
  // WebFetch, WebSearch, Task, mcp__* tools and any unrecognized tool name.
711
1154
  return false;
712
1155
  }
713
1156
 
714
- function isRoutineShellCommand(command: string, cwd: string): boolean {
1157
+ function isRoutineShellCommand(command: string, cwd: string, writeRoots: readonly string[] = []): boolean {
715
1158
  // A newline can carry a heredoc body, a second statement, or other content
716
1159
  // this single-line lexer never sees; treat any multi-line command as
717
1160
  // unrecognized rather than reasoning about what follows the first line.
@@ -733,7 +1176,7 @@ function isRoutineShellCommand(command: string, cwd: string): boolean {
733
1176
  segments.at(-1)!.push(token);
734
1177
  }
735
1178
  if (segments.some((segment) => segment.length === 0)) return false;
736
- return segments.every((segment) => isRoutineSegment(segment, cwd, 0));
1179
+ return segments.every((segment) => isRoutineSegment(segment, cwd, 0, writeRoots));
737
1180
  }
738
1181
 
739
1182
  /** True when `(` or `)` appears outside single/double quotes and not backslash-escaped. */
@@ -780,15 +1223,15 @@ function withoutFdDuplication(tokens: readonly ShellToken[]): ShellToken[] | und
780
1223
  return result;
781
1224
  }
782
1225
 
783
- function isRoutineSegment(segment: readonly ShellToken[], cwd: string, depth: number): boolean {
1226
+ function isRoutineSegment(segment: readonly ShellToken[], cwd: string, depth: number, writeRoots: readonly string[] = []): boolean {
784
1227
  if (depth > 5) return false;
785
- if (hasUnsafeRedirection(segment, cwd)) return false;
1228
+ if (hasUnsafeRedirection(segment, cwd, writeRoots)) return false;
786
1229
  const words = segment.filter((token) => !token.operator).map((token) => token.value);
787
1230
  if (words.length === 0) return false;
788
- return isRoutineWords(words, cwd, depth);
1231
+ return isRoutineWords(words, cwd, depth, writeRoots);
789
1232
  }
790
1233
 
791
- function isRoutineWords(words: readonly string[], cwd: string, depth: number): boolean {
1234
+ function isRoutineWords(words: readonly string[], cwd: string, depth: number, writeRoots: readonly string[] = []): boolean {
792
1235
  if (depth > 5) return false;
793
1236
  const first = words[0]!;
794
1237
  // A program named by path (./ls, /tmp/x/ls) is an arbitrary executable, not
@@ -798,7 +1241,7 @@ function isRoutineWords(words: readonly string[], cwd: string, depth: number): b
798
1241
  if (WRAPPER_COMMANDS.has(head)) {
799
1242
  const wrapped = unwrapWrapper(head, words.slice(1));
800
1243
  if (!wrapped || wrapped.length === 0) return false;
801
- return isRoutineWords(wrapped, cwd, depth + 1);
1244
+ return isRoutineWords(wrapped, cwd, depth + 1, writeRoots);
802
1245
  }
803
1246
  if (!ROUTINE_SHELL_COMMANDS.has(head)) return false;
804
1247
  const rest = words.slice(1);
@@ -807,7 +1250,7 @@ function isRoutineWords(words: readonly string[], cwd: string, depth: number): b
807
1250
  // /etc, ~/.ssh or ~/.aws and every option that names an outside file out of
808
1251
  // the routine set, at the cost of an occasional model call for a sed script
809
1252
  // that happens to start with `/`.
810
- if (rest.some((word) => namesPathOutsideCwd(word, cwd))) return false;
1253
+ if (rest.some((word) => namesPathOutsideCwd(word, cwd, writeRoots))) return false;
811
1254
  switch (head) {
812
1255
  case "git": return isRoutineGit(rest, cwd);
813
1256
  case "npm": return isRoutineNpm(rest);
@@ -948,46 +1391,50 @@ function sedScriptWritesFiles(script: string): boolean {
948
1391
  return /(?:^|[;{}\s])[wWrRe]\s*[^\s]|\/[gIp\d]*[we]/u.test(script);
949
1392
  }
950
1393
 
951
- function hasUnsafeRedirection(segment: readonly ShellToken[], cwd: string): boolean {
1394
+ function hasUnsafeRedirection(segment: readonly ShellToken[], cwd: string, writeRoots: readonly string[] = []): boolean {
952
1395
  for (let index = 0; index < segment.length; index += 1) {
953
1396
  const token = segment[index]!;
954
1397
  if (token.operator && (token.value === ">" || token.value === ">>")) {
955
1398
  const target = segment[index + 1];
956
1399
  if (!target || target.operator) return true;
957
- if (isUnsafeRedirectTarget(target.value, cwd)) return true;
1400
+ if (isUnsafeRedirectTarget(target.value, cwd, writeRoots)) return true;
958
1401
  }
959
1402
  }
960
1403
  return false;
961
1404
  }
962
1405
 
963
- function isUnsafeRedirectTarget(target: string, cwd: string): boolean {
1406
+ function isUnsafeRedirectTarget(target: string, cwd: string, writeRoots: readonly string[] = []): boolean {
964
1407
  if (target === "/dev/null") return false; // the one device sink that discards rather than writes
965
1408
  if (target.startsWith("/dev/") || target.startsWith("~") || target.startsWith("&")) return true;
966
- // The same check the Write/Edit tools get: inside the cwd after resolving
967
- // `..`, not Git metadata, and not through a symlink or hard link out of it.
968
- return classifyWritePath(target, cwd) !== undefined;
1409
+ // The same check the Write/Edit tools get, against the same roots: inside
1410
+ // the cwd or a granted write root after resolving `..`, not Git metadata,
1411
+ // and not through a symlink or hard link out of it. One root set governs
1412
+ // both tools, so `cmd > <scratchpad>/out` is as routine as writing it.
1413
+ return classifyWritePath(target, cwd, writeRoots) !== undefined;
969
1414
  }
970
1415
 
971
1416
  /** True for an argument that names a filesystem location outside the task cwd (or ~), including `--flag=path` values. */
972
- function namesPathOutsideCwd(word: string, cwd: string): boolean {
1417
+ function namesPathOutsideCwd(word: string, cwd: string, writeRoots: readonly string[] = []): boolean {
973
1418
  const value = word.startsWith("--") && word.includes("=") ? word.slice(word.indexOf("=") + 1) : word;
974
1419
  if (value === "/dev/null") return false;
975
1420
  if (value.startsWith("~")) return true;
976
1421
  const pathLike = value.startsWith("/") || /(?:^|[\\/])\.\.(?:[\\/]|$)/u.test(value);
977
- if (pathLike && !isInsideCwd(value, cwd)) return true;
1422
+ // A path inside a granted write root is inside for this purpose too, judged
1423
+ // by the same walk the Write tool gets (symlinks and metadata included).
1424
+ if (pathLike && !isInsideCwd(value, cwd)) return writeRoots.length === 0 || classifyWritePath(value, cwd, writeRoots) !== undefined;
978
1425
  // An argument that exists on disk may be (or pass through) a symlink that
979
1426
  // leaves the cwd; compare real paths. A non-existent argument is a pattern or
980
1427
  // a literal and needs no check.
981
1428
  if (value.startsWith("-") || value === "") return false;
982
1429
  let root: string;
983
- try { root = realpathSync(cwd); }
1430
+ try { root = realpathSync.native(cwd); }
984
1431
  catch { return true; }
985
1432
  // Resolve the nearest existing ancestor so a symlinked directory component
986
1433
  // (`link/new-file`) is caught even when the leaf does not exist yet.
987
1434
  let candidate = resolve(cwd, value);
988
1435
  while (true) {
989
1436
  try {
990
- const real = realpathSync(candidate);
1437
+ const real = realpathSync.native(candidate);
991
1438
  const rel = relative(root, real);
992
1439
  return rel !== "" && (rel.startsWith("..") || isAbsolute(rel));
993
1440
  } catch {