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 +8 -0
- package/README.cn.md +7 -1
- package/README.md +8 -1
- package/docs/implementation-review.md +6 -2
- package/package.json +1 -1
- package/src/hooks/types.ts +2 -0
- package/src/policy.ts +236 -21
- package/src/supervisor.ts +5 -5
- package/src/types.ts +6 -0
- package/src/worker/tmux-adapter.ts +4 -0
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
|
-
|
|
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
|
|
25
|
-
|
|
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
package/src/hooks/types.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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 &&
|
|
277
|
+
if (executable && SHELL_NAMES.has(executable)) {
|
|
207
278
|
for (let option = index + 1; option < lower.length; option += 1) {
|
|
208
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) => {
|