pi-claude-supervisor 0.7.0 → 0.7.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented here.
4
4
 
5
+ ## [0.7.1](https://github.com/btnalit/pi-claude-supervisor/compare/v0.7.0...v0.7.1) (2026-09-17)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **policy:** judge heredoc bodies by their consumer and separate statements on newlines ([ac69208](https://github.com/btnalit/pi-claude-supervisor/commit/ac692083d824b05b7665c2864aad9478f7b367a4))
11
+ * **policy:** stop vetoing ordinary shell and scratchpad writes on the Worker's behalf ([eb72c8f](https://github.com/btnalit/pi-claude-supervisor/commit/eb72c8f267f77f4097f20c325c4087c925be5803))
12
+
5
13
  ## [0.7.0](https://github.com/btnalit/pi-claude-supervisor/compare/v0.6.0...v0.7.0) (2026-09-17)
6
14
 
7
15
 
package/README.cn.md CHANGED
@@ -171,7 +171,13 @@ stream-json` 的方式运行 Claude,完全没有终端界面;一旦设置
171
171
  - 无论策略或权限模式如何,始终拒绝:远程 push、合并/PR 进 `main` 或 integration
172
172
  分支、其他远程 CLI 变更、`.git` 元数据写入,以及对受保护分支的破坏性改写
173
173
  (`reset`、`update-ref`、`symbolic-ref`,或带删除/移动/强制标志的 `branch`)。
174
- 除此之外的一切都按配置的策略处理。
174
+ 策略看不透的 shell 参数(`$VAR`、`$(…)`、通配符)只在可能触及这条边界的命令上
175
+ 被尽力拦截——git、gh、npm/pnpm/yarn、curl/wget/ssh、嵌套的 `claude`,以及
176
+ `eval`、`sh -c`、`xargs`、`find -exec` 之类的解释器/执行器。带引号分隔符的
177
+ heredoc 正文按其消费者判断:交给 shell 就是命令,交给 `cat > file` 或
178
+ `git commit -m` 就是数据。除此之外的一切(`for f in …; do echo "$f"`、
179
+ `rm -rf ./dist`、写入 Claude 自己的 scratchpad)都按配置的策略处理——由
180
+ Claude 自己的权限模式决定,和你亲自运行 Claude 时一样。
175
181
  - `autonomy.permissionAuthority`(`policy` | `hybrid` 默认 |
176
182
  `decision-worker`)决定谁来回答权限请求——headless 模式下是每一个请求,交互式
177
183
  tmux 模式下只是那些 Claude 本来会弹窗问你的请求:`hybrid` 会让策略独自回答
package/README.md CHANGED
@@ -203,7 +203,14 @@ Supervisor being able to see it, or when you don't need to attach.
203
203
  merge/PR into `main` or an integration branch, other remote CLI mutations,
204
204
  `.git` metadata writes, and destructive rewrites of protected branches
205
205
  (`reset`, `update-ref`, `symbolic-ref`, or a delete/move/force `branch`).
206
- Everything else follows the configured policy.
206
+ A shell argument the policy cannot see through (`$VAR`, `$(…)`, a glob)
207
+ is vetoed, best-effort, only on the commands where it could reach that
208
+ boundary — git, gh, npm/pnpm/yarn, curl/wget/ssh, a nested `claude`, or an
209
+ interpreter/runner such as `eval`, `sh -c`, `xargs`, `find -exec`. A quoted
210
+ heredoc body is judged by its consumer: a shell runs it, `cat > file` or
211
+ `git commit -m` stores it. Everything else (`for f in …; do echo "$f"`,
212
+ `rm -rf ./dist`, a Write to Claude's own scratchpad) follows the configured
213
+ policy — Claude's own permission mode governs it, as when you run Claude.
207
214
  - `autonomy.permissionAuthority` (`policy` | `hybrid` default |
208
215
  `decision-worker`) controls who answers a permission request — every
209
216
  request in headless mode, and in interactive tmux mode only those Claude
@@ -21,8 +21,12 @@ custom/nested descendants are trusted rather than denied by a nested-process gua
21
21
  ## Findings addressed in this pass
22
22
 
23
23
  - Policy now evaluates every shell argument as well as the executable and argv
24
- together; dynamic arguments are denied because their capability cannot be checked,
25
- including Claude permission-bypass flags.
24
+ together. A dynamic argument (`$VAR`, `$(…)`, a glob) is denied on the commands
25
+ where it could reach the boundary — repository, package, network, nested
26
+ `claude`, interpreters and runners, or a dynamic command name — as a best-effort
27
+ veto; elsewhere it is ordinary shell that Claude's own permission mode governs.
28
+ Quoted heredoc bodies are evaluated according to their consumer (a shell runs
29
+ them, a data sink stores them, anything else keeps them visible to the checks).
26
30
  - Automatic startup pins a secure resolved Claude executable identity and rejects
27
31
  explicit paths, persists that identity for recovery, and rechecks the exact startup
28
32
  HEAD through the built-in adapter's final `preSpawnCheck` immediately before spawn.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-supervisor",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "A policy-gated Pi supervisor for observing and verifying Claude Code workers.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -24,6 +24,8 @@ export interface ClaudeHookEvent {
24
24
  permission_mode?: string;
25
25
  /** SessionStart */
26
26
  source?: string;
27
+ /** SessionStart: Claude Code's per-session scratchpad directory (outside the cwd). */
28
+ scratchpad_dir?: string;
27
29
  /** SessionEnd */
28
30
  reason?: string;
29
31
  /** UserPromptSubmit */
package/src/policy.ts CHANGED
@@ -15,12 +15,17 @@ export interface PolicyResult {
15
15
  * allowed; only the explicit unattended interaction and repository/remote
16
16
  * authority boundaries below remain special-cased.
17
17
  */
18
- export function evaluatePermission(toolName: string, input: unknown, cwd = process.cwd()): PolicyResult {
18
+ export interface PermissionPolicyOptions {
19
+ /** Extra directories the Worker may write to (Claude's per-session scratchpad); each must be an absolute path. */
20
+ writeRoots?: readonly string[];
21
+ }
22
+
23
+ export function evaluatePermission(toolName: string, input: unknown, cwd = process.cwd(), options: PermissionPolicyOptions = {}): PolicyResult {
19
24
  if (toolName === "AskUserQuestion") return { decision: "deny", reason: "interactive questions are converted to ordinary Worker text" };
20
25
  if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") {
21
26
  const paths = fileToolPaths(input);
22
27
  if (paths.length === 0) return { decision: "deny", reason: `${toolName} request has no recognizable file path` };
23
- const violation = paths.map((path) => ({ path, classification: classifyWritePath(path, cwd) })).find((entry) => entry.classification !== undefined);
28
+ const violation = paths.map((path) => ({ path, classification: classifyWritePath(path, cwd, options.writeRoots) })).find((entry) => entry.classification !== undefined);
24
29
  if (violation?.classification === "outside-cwd") return { decision: "deny", reason: `Worker cannot write outside the task working directory: ${violation.path}` };
25
30
  if (violation?.classification === "git-metadata") return { decision: "deny", reason: "Worker cannot write Git metadata or protected branch refs" };
26
31
  return { decision: "allow", reason: `local Claude file tool is allowed by the task policy: ${toolName}` };
@@ -30,7 +35,9 @@ export function evaluatePermission(toolName: string, input: unknown, cwd = proce
30
35
  ? (input as { command: string }).command
31
36
  : "";
32
37
  if (!command) return { decision: "deny", reason: "Bash request has no recognizable command" };
33
- return evaluateCommand("bash", ["-lc", command]);
38
+ // Lex the command itself: wrapping it as a literal `bash -lc` argument would
39
+ // hide its structure (heredoc bodies, dynamic words) from the token checks.
40
+ return evaluateCommand(command);
34
41
  }
35
42
 
36
43
  function fileToolPaths(input: unknown): string[] {
@@ -43,8 +50,16 @@ function fileToolPaths(input: unknown): string[] {
43
50
 
44
51
  type WritePathViolation = "outside-cwd" | "git-metadata";
45
52
 
46
- function classifyWritePath(value: string, cwd: string): WritePathViolation | undefined {
53
+ function classifyWritePath(value: string, cwd: string, writeRoots: readonly string[] = []): WritePathViolation | undefined {
47
54
  if (value.replaceAll("\\", "/").split("/").some((segment) => segment.toLowerCase() === ".git")) return "git-metadata";
55
+ // A path inside an extra write root (Claude's own scratchpad) is judged
56
+ // against that root instead of the cwd, with the same symlink/metadata rules.
57
+ for (const root of writeRoots) {
58
+ if (!isAbsolute(root) || !isAbsolute(value)) continue;
59
+ const rel = relative(root, value);
60
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) continue;
61
+ return classifyWritePath(value, root);
62
+ }
48
63
  let root: string;
49
64
  try { root = realpathSync(cwd); }
50
65
  catch { return "git-metadata"; }
@@ -93,12 +108,18 @@ function classifyWritePath(value: string, cwd: string): WritePathViolation | und
93
108
  }
94
109
 
95
110
  const deniedPatterns = [
96
- /\b(?:npm|pnpm|yarn)\b[\s\S]*\bpublish\b/iu,
111
+ // `publish` must be the subcommand; a later argument that merely contains the
112
+ // word (scripts/publish-package.mjs) is not a publication.
113
+ /\b(?:npm|pnpm|yarn)\b(?:\s+-\S+)*\s+publish\b/iu,
97
114
  /\b(?:curl|wget)\b[\s\S]*(?:-X\s*(?:POST|PUT|PATCH|DELETE)|--request(?:=|\s+)(?:POST|PUT|PATCH|DELETE)|--method(?:=|\s+)(?:POST|PUT|PATCH|DELETE)|(?:^|\s)(?:-d|--data(?:[-a-z]*)(?:=|\s+)|--post-data(?:=|\s+)|--body-data(?:=|\s+)))[\s\S]*https?:\/\/(?:api\.)?(?:github|gitlab|bitbucket|registry\.npmjs)\b/iu,
98
- /(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))[\s\S]*\b(?:push|merge|publish)\b|\b(?:push|merge|publish)\b[\s\S]*(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))/iu,
115
+ // A repository/package command with a dynamic argument cannot be
116
+ // capability-checked (`git $ACTION origin main`); dynamic text elsewhere in a
117
+ // command is ordinary shell and is not a boundary concern.
118
+ /\b(?:git|gh|glab|hub|npm|pnpm|yarn)\b[^|;&\n]*(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))/iu,
99
119
  /--(?:allow-)?dangerously-skip-permissions\b/iu,
100
120
  /--permission-mode\s+(?:bypasspermissions|dontask)\b/iu,
101
- /\brm\s+-rf\s+\//iu,
121
+ // Only the filesystem root itself; `rm -rf /abs/path/dist` is ordinary local work.
122
+ /\brm\s+(?:-\S+\s+)*\/+(?:\*|\s|$)/iu,
102
123
  /\bmkfs(?:\.|\s)/iu,
103
124
  /\bdd\s+if=/iu,
104
125
  /:\(\)\s*\{\s*:\|/u,
@@ -109,9 +130,34 @@ interface ShellToken {
109
130
  value: string;
110
131
  operator: boolean;
111
132
  dynamic: boolean;
133
+ /** A quoted heredoc body: literal text whose meaning depends on the command that consumes it. */
134
+ data?: boolean;
112
135
  }
113
136
 
114
137
  const protectedBranches = new Set(["main", "master", "trunk", "integration", "develop"]);
138
+ /**
139
+ * Commands whose dynamic arguments could carry a boundary-crossing action or
140
+ * execute arbitrary expanded text: the repository, package, network and
141
+ * nested-worker surfaces, plus interpreters, runners and the catastrophe guards.
142
+ */
143
+ const DYNAMIC_SENSITIVE_COMMANDS = new Set([
144
+ "git", "gh", "glab", "hub", "npm", "pnpm", "yarn", "npx", "curl", "wget", "ssh", "scp", "rsync", "sftp", "claude",
145
+ "eval", "exec", "source", "sh", "bash", "zsh", "dash", "ksh", "fish", "xargs", "env", "sudo", "su", "doas",
146
+ "timeout", "time", "nice", "nohup", "command", "builtin", "watch", "setsid", "strace", "ltrace", "stdbuf", "flock",
147
+ "unshare", "nsenter", "chroot", "script", "parallel", "expect", "ionice", "chrt", "taskset", "crontab", "at", "batch",
148
+ "systemd-run", "dd", "mkfs", "shred",
149
+ ]);
150
+ /** `find` runs its `-exec`/`-ok` argv and so joins the sensitive set when one is present. */
151
+ const FIND_EXEC_ACTIONS = new Set(["-exec", "-execdir", "-ok", "-okdir"]);
152
+ /** Commands that only store or display their input; a quoted heredoc fed to one never executes. */
153
+ const DATA_SINK_COMMANDS = new Set([
154
+ "cat", "tee", "head", "tail", "grep", "rg", "wc", "sort", "uniq", "cut", "tr", "diff", "less", "more", "base64",
155
+ "md5sum", "sha1sum", "sha256sum", "jq", "column", "fold", "paste", "comm", "cmp", "od", "hexdump", "xxd", "nl", "tac", "rev",
156
+ "echo", "printf",
157
+ ]);
158
+ const SHELL_NAMES = new Set(["sh", "bash", "dash", "zsh", "fish", "ksh"]);
159
+ /** Shell words after which the next word is again in command position. */
160
+ const COMMAND_POSITION_KEYWORDS = new Set(["if", "then", "elif", "else", "while", "until", "do", "!", "(", "{", "time", "exec", "command", "builtin", "nohup", "sudo", "doas"]);
115
161
  /** Rewriting a protected ref's identity directly; `checkout`/`switch`/`restore`/`worktree` are read-only uses of a branch name and are not included. */
116
162
  const protectedBranchRewriteOperations = new Set(["reset", "update-ref", "symbolic-ref"]);
117
163
  /** `branch` only rewrites or deletes a protected branch when combined with one of these flags. */
@@ -164,8 +210,14 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
164
210
  const hasForcedBranchCreate = (lower.includes("checkout") && rawValues.includes("-B"))
165
211
  || (lower.includes("switch") && (rawValues.includes("-C") || rawValues.includes("--force-create")));
166
212
 
167
- if (hasDynamicArgument) {
168
- return { decision: "deny", reason: "dynamic shell arguments cannot be capability-checked safely" };
213
+ // An argument the lexer cannot see through matters only where it could reach
214
+ // the boundary: a repository, package, network or remote-shell command, or an
215
+ // interpreter that would execute the expanded text. Dynamic text in an
216
+ // ordinary local command (`for f in …; echo "$f"`) is Claude's own business.
217
+ const dynamicSensitive = lower.some((value) => DYNAMIC_SENSITIVE_COMMANDS.has(value.split(/[\\/]/u).at(-1) ?? value))
218
+ || (lower.some((value) => (value.split(/[\\/]/u).at(-1) ?? value) === "find") && lower.some((value) => FIND_EXEC_ACTIONS.has(value)));
219
+ if (hasDynamicArgument && (hasGit || hasGhRemote || hasPackagePublication || dynamicSensitive || hasDynamicCommandName(tokens))) {
220
+ return { decision: "deny", reason: "a repository, package, network or shell command with a dynamic argument cannot be capability-checked" };
169
221
  }
170
222
  if (/\bgit\b[\s\S]*\b(?:push|merge(?!-)|send-pack|receive-pack|update-ref)\b/iu.test(canonical)
171
223
  || /\bgit-(?:send|receive|upload)-pack\b/iu.test(canonical)
@@ -198,14 +250,34 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
198
250
  return undefined;
199
251
  }
200
252
 
253
+ /** A dynamic word in command position (`$CMD …`, `; $CMD`, `do . $file`) could name anything. */
254
+ function hasDynamicCommandName(tokens: readonly ShellToken[]): boolean {
255
+ let commandPosition = true;
256
+ for (const token of tokens) {
257
+ if (token.operator) {
258
+ commandPosition = SEGMENT_SPLIT_OPERATORS.has(token.value);
259
+ continue;
260
+ }
261
+ if (!commandPosition) continue;
262
+ // `VAR=value cmd` keeps the following word in command position; the value itself is data.
263
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(token.value)) continue;
264
+ if (token.dynamic) return true;
265
+ const word = token.value.toLowerCase().replace(/^\(+/u, "") || "(";
266
+ if (word === "." || word === "source") return true;
267
+ if (COMMAND_POSITION_KEYWORDS.has(word)) continue;
268
+ commandPosition = false;
269
+ }
270
+ return false;
271
+ }
272
+
201
273
  function nestedShellCommands(lower: readonly string[], values: readonly string[]): string[] {
202
274
  const nested: string[] = [];
203
- const shellNames = new Set(["sh", "bash", "dash", "zsh", "fish", "ksh"]);
204
275
  for (let index = 0; index < lower.length; index += 1) {
205
276
  const executable = lower[index]!.split(/[\\/]/u).at(-1);
206
- if (executable && shellNames.has(executable)) {
277
+ if (executable && SHELL_NAMES.has(executable)) {
207
278
  for (let option = index + 1; option < lower.length; option += 1) {
208
- if (["-c", "--command"].includes(lower[option]!)) {
279
+ // `-c`, `--command`, or a combined short option such as `-lc` / `-ec`.
280
+ if (lower[option] === "--command" || /^-[a-z]*c[a-z]*$/u.test(lower[option]!)) {
209
281
  const command = values.slice(option + 1).join(" ").trim();
210
282
  if (command) nested.push(command);
211
283
  break;
@@ -228,7 +300,14 @@ function evaluateCommandInternal(command: string, depth: number): PolicyResult {
228
300
  return evaluateTokens(lexical.tokens, depth);
229
301
  }
230
302
 
231
- function evaluateTokens(tokens: readonly ShellToken[], depth: number): PolicyResult {
303
+ function evaluateTokens(rawTokens: readonly ShellToken[], depth: number): PolicyResult {
304
+ const { tokens, embedded } = resolveDataTokens(rawTokens);
305
+ if (depth < 4) {
306
+ for (const body of embedded) {
307
+ const nestedResult = evaluateCommandInternal(body, depth + 1);
308
+ if (nestedResult.decision === "deny") return nestedResult;
309
+ }
310
+ }
232
311
  const canonical = tokens.map((token) => token.value).join(" ").trim();
233
312
  if (!canonical) return { decision: "deny", reason: "empty command" };
234
313
  const boundary = evaluateRepositoryBoundary(tokens, canonical, depth);
@@ -244,31 +323,137 @@ function evaluateTokens(tokens: readonly ShellToken[], depth: number): PolicyRes
244
323
  || /\bgit\b[\s\S]*\bbranch\b[\s\S]*(?:^|\s)(?:-d|-m|-f|--force|--delete|--move)\b[\s\S]*\b(?:main|master|trunk|integration|develop)\b/iu.test(canonical)) {
245
324
  return { decision: "deny", reason: "Worker cannot rewrite or delete a protected integration branch" };
246
325
  }
247
- if (/\b(?:npm|pnpm|yarn)\b[\s\S]*\bpublish\b/iu.test(canonical)) {
326
+ if (/\b(?:npm|pnpm|yarn)\b(?:\s+-\S+)*\s+publish\b/iu.test(canonical)) {
248
327
  return { decision: "deny", reason: "package publication belongs to the protected release workflow" };
249
328
  }
329
+ if (/\b(?:git|gh|glab|hub|npm|pnpm|yarn)\b[^|;&\n]*(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))/iu.test(canonical)) {
330
+ return { decision: "deny", reason: "a repository or package command with a dynamic argument cannot be capability-checked" };
331
+ }
250
332
  return { decision: "deny", reason: "command matches a prohibited destructive pattern" };
251
333
  }
252
334
  return { decision: "allow", reason: "command is allowed for unattended local development" };
253
335
  }
254
336
 
337
+ /**
338
+ * A quoted heredoc body means whatever its consumer makes of it. Fed to a shell
339
+ * (`bash <<'EOF'`, `cat <<'EOF' | sh`, `eval "$(cat <<'EOF' …)"`) it is a
340
+ * command and is evaluated as one; fed to a pure data sink (`cat > file`,
341
+ * `git commit -m`) it is text the boundary never needs to see; fed to anything
342
+ * else it stays in the command as literal words for the pattern checks.
343
+ */
344
+ function resolveDataTokens(tokens: readonly ShellToken[]): { tokens: ShellToken[]; embedded: string[] } {
345
+ if (!tokens.some((token) => token.data)) return { tokens: [...tokens], embedded: [] };
346
+ const segments: { tokens: ShellToken[]; joiner?: string }[] = [{ tokens: [] }];
347
+ for (const token of tokens) {
348
+ if (token.operator && SEGMENT_SPLIT_OPERATORS.has(token.value)) {
349
+ segments.at(-1)!.joiner = token.value;
350
+ segments.push({ tokens: [] });
351
+ continue;
352
+ }
353
+ segments.at(-1)!.tokens.push(token);
354
+ }
355
+ const commandOf = (segment: readonly ShellToken[]): { name: string; words: string[] } | undefined => {
356
+ let afterRedirect = false;
357
+ let name: string | undefined;
358
+ const words: string[] = [];
359
+ for (const token of segment) {
360
+ if (token.operator) { afterRedirect = [">", ">>", "<", "<<", "<<<"].includes(token.value); continue; }
361
+ const skip = afterRedirect;
362
+ afterRedirect = false;
363
+ if (skip || token.data) continue;
364
+ const word = token.value.toLowerCase();
365
+ if (name === undefined) {
366
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(token.value) || COMMAND_POSITION_KEYWORDS.has(word)) continue;
367
+ name = word.replace(/^\(+/u, "").split(/[\\/]/u).at(-1) ?? word;
368
+ }
369
+ words.push(word);
370
+ }
371
+ return name === undefined ? undefined : { name, words };
372
+ };
373
+ const resolved: ShellToken[] = [];
374
+ const embedded: string[] = [];
375
+ segments.forEach((segment, index) => {
376
+ const bodies = segment.tokens.filter((token) => token.data);
377
+ if (bodies.length === 0) {
378
+ resolved.push(...segment.tokens, ...(segment.joiner ? [{ value: segment.joiner, operator: true, dynamic: false }] : []));
379
+ return;
380
+ }
381
+ const consumers: { name: string; words: string[] }[] = [];
382
+ for (let cursor = index; cursor < segments.length; cursor += 1) {
383
+ const command = commandOf(segments[cursor]!.tokens);
384
+ if (command) consumers.push(command);
385
+ if (segments[cursor]!.joiner !== "|") break;
386
+ }
387
+ const shellConsumer = consumers.some((consumer) => SHELL_NAMES.has(consumer.name) || consumer.name === "eval" || consumer.name === "source" || consumer.name === ".");
388
+ const sinkOnly = consumers.length > 0 && consumers.every((consumer) => DATA_SINK_COMMANDS.has(consumer.name) || (consumer.name === "git" && consumer.words.includes("commit")));
389
+ if (shellConsumer) embedded.push(...bodies.map((token) => token.value));
390
+ const kept = shellConsumer || sinkOnly ? segment.tokens.filter((token) => !token.data) : segment.tokens.map((token) => ({ ...token, data: false }));
391
+ resolved.push(...kept, ...(segment.joiner ? [{ value: segment.joiner, operator: true, dynamic: false }] : []));
392
+ });
393
+ return { tokens: resolved, embedded };
394
+ }
395
+
396
+ /**
397
+ * `$(cat <<'EOF' … EOF\n)` inside double quotes: Claude Code's commit-message
398
+ * idiom. With a quoted delimiter nothing in the body expands or runs, so the
399
+ * whole substitution is literal data.
400
+ */
401
+ const LITERAL_CAT_HEREDOC = /^\$\(\s*cat\s+<<-?\s*(['"])([^'"\s]+)\1[ \t]*\n(?:([\s\S]*?)\n)?[ \t]*\2[ \t]*\n?\s*\)/u;
402
+
255
403
  function lexShell(input: string): { tokens: ShellToken[]; error?: string } {
256
404
  const tokens: ShellToken[] = [];
257
405
  let value = "";
258
406
  let dynamic = false;
259
407
  let started = false;
408
+ let tokenQuoted = false;
409
+ let tokenData = false;
260
410
  let quote: "single" | "double" | undefined;
411
+ // Heredocs: the word after `<<` names the delimiter; the body starts on the
412
+ // next line and ends at a line equal to it. A quoted delimiter makes the body
413
+ // pure data, which the boundary never needs to see.
414
+ let expectDelimiter: { stripTabs: boolean } | undefined;
415
+ const pendingHeredocs: { delimiter: string; quoted: boolean; stripTabs: boolean }[] = [];
261
416
  const flush = (): void => {
262
417
  if (!started) return;
263
- tokens.push({ value, operator: false, dynamic });
418
+ // A bare `[`, `[[`, `]`, `]]`, `{` or `}` is shell syntax, not an expansion.
419
+ if (/^[[\]{}]+$/u.test(value)) dynamic = false;
420
+ if (expectDelimiter) {
421
+ pendingHeredocs.push({ delimiter: value, quoted: tokenQuoted, stripTabs: expectDelimiter.stripTabs });
422
+ expectDelimiter = undefined;
423
+ dynamic = false;
424
+ }
425
+ tokens.push({ value, operator: false, dynamic, ...(tokenData ? { data: true } : {}) });
264
426
  value = "";
265
427
  dynamic = false;
266
428
  started = false;
429
+ tokenQuoted = false;
430
+ tokenData = false;
267
431
  };
268
432
  const pushOperator = (operator: string): void => {
269
433
  flush();
270
434
  tokens.push({ value: operator, operator: true, dynamic: false });
271
435
  };
436
+ /** Consume the heredoc bodies that start after the newline at `newlineIndex`; returns the index to resume lexing at. */
437
+ const consumeHeredocs = (newlineIndex: number): number => {
438
+ let position = newlineIndex + 1;
439
+ for (const heredoc of pendingHeredocs.splice(0)) {
440
+ const bodyLines: string[] = [];
441
+ let terminated = false;
442
+ while (position <= input.length) {
443
+ const lineEnd = input.indexOf("\n", position);
444
+ const line = input.slice(position, lineEnd === -1 ? input.length : lineEnd);
445
+ position = lineEnd === -1 ? input.length + 1 : lineEnd + 1;
446
+ if ((heredoc.stripTabs ? line.replace(/^\t+/u, "") : line) === heredoc.delimiter) { terminated = true; break; }
447
+ bodyLines.push(line);
448
+ }
449
+ const body = bodyLines.join("\n");
450
+ // A quoted delimiter suppresses expansion: the body is data for its
451
+ // consumer. An unquoted one expands, so the body is an ordinary argument.
452
+ tokens.push(heredoc.quoted ? { value: body, operator: false, dynamic: false, data: true } : { value: body, operator: false, dynamic: /[$`]/u.test(body) });
453
+ if (!terminated) break;
454
+ }
455
+ return Math.min(position, input.length);
456
+ };
272
457
  for (let index = 0; index < input.length; index += 1) {
273
458
  const character = input[index]!;
274
459
  const next = input[index + 1];
@@ -282,18 +467,39 @@ function lexShell(input: string): { tokens: ShellToken[]; error?: string } {
282
467
  if (character === '"') quote = undefined;
283
468
  else if (character === "\\" && next === "\n") index += 1;
284
469
  else if (character === "\\" && next !== undefined && /[\\"$`]/u.test(next)) { value += next; index += 1; }
285
- else { value += character; if (character === "$" || character === "`") dynamic = true; }
470
+ else if (character === "$") {
471
+ const literal = LITERAL_CAT_HEREDOC.exec(input.slice(index));
472
+ if (literal) { value += literal[3] ?? ""; tokenData = true; index += literal[0].length - 1; }
473
+ else { value += character; dynamic = true; }
474
+ }
475
+ else { value += character; if (character === "`") dynamic = true; }
286
476
  started = true;
287
477
  continue;
288
478
  }
289
- if (character === "'") { quote = "single"; started = true; continue; }
290
- if (character === '"') { quote = "double"; started = true; continue; }
479
+ if (character === "'") { quote = "single"; started = true; tokenQuoted = true; continue; }
480
+ if (character === '"') { quote = "double"; started = true; tokenQuoted = true; continue; }
291
481
  if (character === "\\") {
292
482
  if (next === "\n") index += 1;
293
483
  else if (next !== undefined) { value += next; index += 1; }
294
484
  started = true;
295
485
  continue;
296
486
  }
487
+ // A comment runs to the end of the line.
488
+ if (character === "#" && !started) {
489
+ const lineEnd = input.indexOf("\n", index);
490
+ index = (lineEnd === -1 ? input.length : lineEnd) - 1;
491
+ continue;
492
+ }
493
+ // A newline ends the statement. Any pending heredoc bodies belong to the
494
+ // statement just lexed, so they are emitted before the separator.
495
+ if (character === "\n") {
496
+ flush();
497
+ expectDelimiter = undefined;
498
+ if (pendingHeredocs.length > 0) index = consumeHeredocs(index) - 1;
499
+ const last = tokens.at(-1);
500
+ if (last && !(last.operator && SEGMENT_SPLIT_OPERATORS.has(last.value))) pushOperator(";");
501
+ continue;
502
+ }
297
503
  if (/\s/u.test(character)) { flush(); continue; }
298
504
  if (character === "$" || character === "`") { dynamic = true; value += character; started = true; continue; }
299
505
  // Brace, tilde and pathname expansion can change command names, targets or
@@ -301,7 +507,16 @@ function lexShell(input: string): { tokens: ShellToken[]; error?: string } {
301
507
  // dynamic rather than attempting to model Bash's expansion order.
302
508
  if ("*?[]{}~".includes(character)) { dynamic = true; value += character; started = true; continue; }
303
509
  if (";&|<>".includes(character)) {
304
- const operator = next && ((character === "&" && next === "&") || (character === "|" && next === "|") || (character === ">" && next === ">") || (character === "<" && next === "<"))
510
+ if (character === "<" && next === "<") {
511
+ const third = input[index + 2];
512
+ if (third === "<") { pushOperator("<<<"); index += 2; continue; }
513
+ const stripTabs = third === "-";
514
+ pushOperator("<<");
515
+ expectDelimiter = { stripTabs };
516
+ index += stripTabs ? 2 : 1;
517
+ continue;
518
+ }
519
+ const operator = next && ((character === "&" && next === "&") || (character === "|" && next === "|") || (character === ">" && next === ">"))
305
520
  ? `${character}${next}`
306
521
  : character;
307
522
  pushOperator(operator);
@@ -360,8 +575,8 @@ const FIND_WRITE_ACTIONS = new Set(["-delete", "-exec", "-execdir", "-ok", "-okd
360
575
  * escalation, destructive git operations and any write outside the task cwd
361
576
  * are never routine; the Decision Worker judges those.
362
577
  */
363
- export function isRoutinePermission(toolName: string, input: unknown, cwd: string): boolean {
364
- const policyResult = evaluatePermission(toolName, input, cwd);
578
+ export function isRoutinePermission(toolName: string, input: unknown, cwd: string, options: PermissionPolicyOptions = {}): boolean {
579
+ const policyResult = evaluatePermission(toolName, input, cwd, options);
365
580
  if (policyResult.decision === "deny") return false;
366
581
  if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") return true;
367
582
  if (toolName === "Read" || toolName === "Glob" || toolName === "Grep" || toolName === "LS" || toolName === "TodoWrite") return true;
package/src/supervisor.ts CHANGED
@@ -651,7 +651,7 @@ export class Supervisor {
651
651
  this.#pendingPermissions.delete(event.request.requestId);
652
652
  skipDecisionNotify = true;
653
653
  } else {
654
- const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd);
654
+ const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd, { writeRoots: event.request.writeRoots });
655
655
  if (policy.decision === "deny") {
656
656
  if (this.#adapter.respondPermission) {
657
657
  await this.#adapter.respondPermission(handle, event.request.requestId, event.request.toolUseId, {
@@ -682,9 +682,9 @@ export class Supervisor {
682
682
  skipDecisionNotify = true;
683
683
  } else if (this.#automation && !this.#humanRequired) {
684
684
  const authority = task.spec.autonomy.permissionAuthority;
685
- const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd);
685
+ const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd, { writeRoots: event.request.writeRoots });
686
686
  const answerLocally = authority === "policy"
687
- || (authority === "hybrid" && (policy.decision === "deny" || isRoutinePermission(event.request.toolName, event.request.input, task.cwd)));
687
+ || (authority === "hybrid" && (policy.decision === "deny" || isRoutinePermission(event.request.toolName, event.request.input, task.cwd, { writeRoots: event.request.writeRoots })));
688
688
  if (answerLocally && this.#adapter.respondPermission) {
689
689
  const behavior: "allow" | "deny" = policy.decision === "deny" ? "deny" : "allow";
690
690
  await this.#adapter.respondPermission(handle, event.request.requestId, event.request.toolUseId, {
@@ -865,7 +865,7 @@ export class Supervisor {
865
865
  // checked before the generic policy-deny reason, or the Decision
866
866
  // Worker's chosen answer would never reach Claude.
867
867
  const isAskUserQuestionAnswer = event.request.toolName === "AskUserQuestion" && action.action === "deny_permission";
868
- const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd);
868
+ const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd, { writeRoots: event.request.writeRoots });
869
869
  const behavior = policy.decision === "deny" ? "deny" : action.action === "allow_permission" ? "allow" : "deny";
870
870
  const message = behavior !== "deny"
871
871
  ? undefined
@@ -1042,7 +1042,7 @@ export class Supervisor {
1042
1042
  const request = requestId ? this.#pendingPermissions.get(requestId) : [...this.#pendingPermissions.values()].at(-1);
1043
1043
  if (!request) throw new Error("no pending permission request");
1044
1044
  if (this.#humanRequired && this.#humanGate !== "permission") throw new Error("automatic decisions are held by a separate human gate; use resume-auto explicitly");
1045
- const policy = evaluatePermission(request.toolName, request.input, task.cwd);
1045
+ const policy = evaluatePermission(request.toolName, request.input, task.cwd, { writeRoots: request.writeRoots });
1046
1046
  if (policy.decision === "deny" && behavior === "allow") throw new Error(`permission denied by policy: ${policy.reason}`);
1047
1047
  await this.#adapter.respondPermission(handle, request.requestId, request.toolUseId, { behavior: policy.decision === "deny" ? "deny" : behavior }, behavior === "allow" ? request.input : undefined);
1048
1048
  this.#pendingPermissions.delete(request.requestId);
package/src/types.ts CHANGED
@@ -25,6 +25,12 @@ export interface WorkerPermissionRequest {
25
25
  * prompt to a human. Bridge/JSONL requests have no phase.
26
26
  */
27
27
  phase?: "pre" | "prompt";
28
+ /**
29
+ * Directories outside the task cwd that the Worker may write to for this
30
+ * session — today Claude Code's own per-session scratchpad, reported by its
31
+ * SessionStart hook. Never a repository or a home directory.
32
+ */
33
+ writeRoots?: string[];
28
34
  }
29
35
 
30
36
  export type WorkerEvent =
@@ -120,6 +120,8 @@ interface TmuxRecord {
120
120
  hookUnsubscribe?: () => Promise<void>;
121
121
  claudeSessionId?: string;
122
122
  transcriptPath?: string;
123
+ /** Claude Code's per-session scratchpad directory (from SessionStart); an extra write root for the policy. */
124
+ scratchpadDir?: string;
123
125
  /** Primary readiness signal for interactive startup: SessionStart observed. */
124
126
  sessionStartReceived: boolean;
125
127
  /** Messages the adapter itself pasted, awaiting UserPromptSubmit acknowledgement. */
@@ -1990,6 +1992,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
1990
1992
  record.claudeSessionId ??= event.session_id;
1991
1993
  record.handle.sessionId = event.session_id;
1992
1994
  if (event.transcript_path) record.transcriptPath = event.transcript_path;
1995
+ if (typeof event.scratchpad_dir === "string" && isAbsolute(event.scratchpad_dir) && !event.scratchpad_dir.includes("\0")) record.scratchpadDir = event.scratchpad_dir;
1993
1996
  record.sessionStartReceived = true;
1994
1997
  return {};
1995
1998
  }
@@ -2069,6 +2072,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
2069
2072
  input: event.tool_input,
2070
2073
  raw: event as unknown as Record<string, unknown>,
2071
2074
  phase,
2075
+ ...(record.scratchpadDir ? { writeRoots: [record.scratchpadDir] } : {}),
2072
2076
  },
2073
2077
  });
2074
2078
  return new Promise<HookRelayReply>((resolve) => {