pi-claude-supervisor 0.7.0 → 0.7.2
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 +19 -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/decision-worker.ts +8 -5
- package/src/hooks/types.ts +2 -0
- package/src/policy.ts +265 -21
- package/src/redaction.ts +3 -1
- package/src/supervisor.ts +73 -8
- package/src/types.ts +6 -0
- package/src/worker/tmux-adapter.ts +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented here.
|
|
4
4
|
|
|
5
|
+
## [0.7.2](https://github.com/btnalit/pi-claude-supervisor/compare/v0.7.1...v0.7.2) (2026-09-17)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **policy:** a redirection target is not a dynamic argument, and drop the regex twin of the token check ([94742c7](https://github.com/btnalit/pi-claude-supervisor/commit/94742c7c8c8c810843ddb62703d7b3c1fea1254f))
|
|
11
|
+
* **policy:** an environment prefix's dynamic value is not a dynamic argument ([b53e77c](https://github.com/btnalit/pi-claude-supervisor/commit/b53e77c6932851a1c88452bca38c8d5bdf1030b5))
|
|
12
|
+
* **policy:** scope the dynamic-argument veto to the statement holding the sensitive command ([5515a2b](https://github.com/btnalit/pi-claude-supervisor/commit/5515a2b0d746d3d3cd05b0ef4ba86602f92eb072))
|
|
13
|
+
* **redaction:** keep numeric token counts in usage events ([6d4e183](https://github.com/btnalit/pi-claude-supervisor/commit/6d4e1831b588b71175d581764b611e5ed5cbe664))
|
|
14
|
+
* **supervisor:** survive Claude's own background work in an interactive session ([d883b9a](https://github.com/btnalit/pi-claude-supervisor/commit/d883b9a2b187c55693ff2669a1ba4f7f949272d3))
|
|
15
|
+
|
|
16
|
+
## [0.7.1](https://github.com/btnalit/pi-claude-supervisor/compare/v0.7.0...v0.7.1) (2026-09-17)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Bug Fixes
|
|
20
|
+
|
|
21
|
+
* **policy:** judge heredoc bodies by their consumer and separate statements on newlines ([ac69208](https://github.com/btnalit/pi-claude-supervisor/commit/ac692083d824b05b7665c2864aad9478f7b367a4))
|
|
22
|
+
* **policy:** stop vetoing ordinary shell and scratchpad writes on the Worker's behalf ([eb72c8f](https://github.com/btnalit/pi-claude-supervisor/commit/eb72c8f267f77f4097f20c325c4087c925be5803))
|
|
23
|
+
|
|
5
24
|
## [0.7.0](https://github.com/btnalit/pi-claude-supervisor/compare/v0.6.0...v0.7.0) (2026-09-17)
|
|
6
25
|
|
|
7
26
|
|
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/decision-worker.ts
CHANGED
|
@@ -14,7 +14,7 @@ const MAX_DECISION_FIELD_BYTES = 8 * 1024;
|
|
|
14
14
|
export type DecisionAction =
|
|
15
15
|
| { action: "continue" | "redirect" | "answer"; message: string; reason: string; confidence?: number }
|
|
16
16
|
| { action: "allow_permission" | "deny_permission"; requestId: string; toolUseId: string; reason: string; confidence?: number }
|
|
17
|
-
| { action: "verify" | "stop" | "park" | "ask_human" | "noop"; reason: string; question?: string; confidence?: number }
|
|
17
|
+
| { action: "verify" | "stop" | "park" | "ask_human" | "noop" | "wait"; reason: string; question?: string; confidence?: number }
|
|
18
18
|
| { action: "retry"; reason: string; message?: string; confidence?: number };
|
|
19
19
|
|
|
20
20
|
export interface DecisionContext {
|
|
@@ -280,7 +280,7 @@ Current repair round: ${context.repairRound ?? 0}
|
|
|
280
280
|
Task specification: ${boundedJson(context.spec ?? { goal: context.task })}
|
|
281
281
|
|
|
282
282
|
Return exactly one JSON object and no markdown:
|
|
283
|
-
{"action":"continue|redirect|answer|allow_permission|deny_permission|verify|retry|stop|park|noop",...}
|
|
283
|
+
{"action":"continue|redirect|answer|allow_permission|deny_permission|verify|retry|stop|park|wait|noop",...}
|
|
284
284
|
For continue/redirect/answer include message and reason. For permission actions include
|
|
285
285
|
requestId and toolUseId. Retry may include a corrective message. Never choose allow_permission
|
|
286
286
|
for a command that crosses the remote push or main/integration merge boundary; the deterministic
|
|
@@ -300,7 +300,10 @@ Claude says it will stop; choose stop only for an explicit stop or technical con
|
|
|
300
300
|
Use park only when the task cannot safely produce a candidate because required evidence,
|
|
301
301
|
authority, or runtime capability is unavailable. A parked candidate is asynchronous and must not
|
|
302
302
|
wait for a human to be online. For an exited event choose verify, park or stop; a noop on an exited event is treated as
|
|
303
|
-
verify.
|
|
303
|
+
verify. Choose wait when the Worker's result says it is waiting for its own background agents,
|
|
304
|
+
tasks or monitors: their completion re-invokes the Worker automatically, a message would only
|
|
305
|
+
interrupt it, and the Supervisor asks you again if the Worker has not resumed within the wait
|
|
306
|
+
timeout. A completed turn or a permission request always requires a concrete action.`;
|
|
304
307
|
}
|
|
305
308
|
|
|
306
309
|
/**
|
|
@@ -544,7 +547,7 @@ function parseDecision(text: string, event: WorkerEvent): DecisionAction {
|
|
|
544
547
|
const value = distinct[0] as Record<string, unknown>;
|
|
545
548
|
const action = value.action;
|
|
546
549
|
if (typeof action !== "string") throw new Error("missing action");
|
|
547
|
-
const allowed = new Set(["continue", "redirect", "answer", "allow_permission", "deny_permission", "verify", "retry", "stop", "park", "ask_human", "noop"]);
|
|
550
|
+
const allowed = new Set(["continue", "redirect", "answer", "allow_permission", "deny_permission", "verify", "retry", "stop", "park", "ask_human", "noop", "wait"]);
|
|
548
551
|
if (!allowed.has(action)) throw new Error(`unsupported action: ${action}`);
|
|
549
552
|
const reason = typeof value.reason === "string" && value.reason.trim() ? boundedDecisionText(value.reason, "reason") : "no reason provided";
|
|
550
553
|
const confidence = value.confidence === undefined ? undefined : typeof value.confidence === "number" && Number.isFinite(value.confidence) && value.confidence >= 0 && value.confidence <= 1
|
|
@@ -564,7 +567,7 @@ function parseDecision(text: string, event: WorkerEvent): DecisionAction {
|
|
|
564
567
|
if (action === "retry") {
|
|
565
568
|
return { action, reason, message: typeof value.message === "string" ? boundedDecisionText(value.message, "message") : undefined, confidence };
|
|
566
569
|
}
|
|
567
|
-
return { action: action as "verify" | "stop" | "park" | "ask_human" | "noop", reason, question: typeof value.question === "string" ? boundedDecisionText(value.question, "question") : undefined, confidence };
|
|
570
|
+
return { action: action as "verify" | "stop" | "park" | "ask_human" | "noop" | "wait", reason, question: typeof value.question === "string" ? boundedDecisionText(value.question, "question") : undefined, confidence };
|
|
568
571
|
} catch (error) {
|
|
569
572
|
return { action: "park", reason: `invalid Decision Worker action: ${error instanceof Error ? error.message : String(error)}` };
|
|
570
573
|
}
|
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,14 @@ 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
|
-
/(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))[\s\S]*\b(?:push|merge|publish)\b|\b(?:push|merge|publish)\b[\s\S]*(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))/iu,
|
|
99
115
|
/--(?:allow-)?dangerously-skip-permissions\b/iu,
|
|
100
116
|
/--permission-mode\s+(?:bypasspermissions|dontask)\b/iu,
|
|
101
|
-
|
|
117
|
+
// Only the filesystem root itself; `rm -rf /abs/path/dist` is ordinary local work.
|
|
118
|
+
/\brm\s+(?:-\S+\s+)*\/+(?:\*|\s|$)/iu,
|
|
102
119
|
/\bmkfs(?:\.|\s)/iu,
|
|
103
120
|
/\bdd\s+if=/iu,
|
|
104
121
|
/:\(\)\s*\{\s*:\|/u,
|
|
@@ -109,9 +126,34 @@ interface ShellToken {
|
|
|
109
126
|
value: string;
|
|
110
127
|
operator: boolean;
|
|
111
128
|
dynamic: boolean;
|
|
129
|
+
/** A quoted heredoc body: literal text whose meaning depends on the command that consumes it. */
|
|
130
|
+
data?: boolean;
|
|
112
131
|
}
|
|
113
132
|
|
|
114
133
|
const protectedBranches = new Set(["main", "master", "trunk", "integration", "develop"]);
|
|
134
|
+
/**
|
|
135
|
+
* Commands whose dynamic arguments could carry a boundary-crossing action or
|
|
136
|
+
* execute arbitrary expanded text: the repository, package, network and
|
|
137
|
+
* nested-worker surfaces, plus interpreters, runners and the catastrophe guards.
|
|
138
|
+
*/
|
|
139
|
+
const DYNAMIC_SENSITIVE_COMMANDS = new Set([
|
|
140
|
+
"git", "gh", "glab", "hub", "npm", "pnpm", "yarn", "npx", "curl", "wget", "ssh", "scp", "rsync", "sftp", "claude",
|
|
141
|
+
"eval", "exec", "source", "sh", "bash", "zsh", "dash", "ksh", "fish", "xargs", "env", "sudo", "su", "doas",
|
|
142
|
+
"timeout", "time", "nice", "nohup", "command", "builtin", "watch", "setsid", "strace", "ltrace", "stdbuf", "flock",
|
|
143
|
+
"unshare", "nsenter", "chroot", "script", "parallel", "expect", "ionice", "chrt", "taskset", "crontab", "at", "batch",
|
|
144
|
+
"systemd-run", "dd", "mkfs", "shred",
|
|
145
|
+
]);
|
|
146
|
+
/** `find` runs its `-exec`/`-ok` argv and so joins the sensitive set when one is present. */
|
|
147
|
+
const FIND_EXEC_ACTIONS = new Set(["-exec", "-execdir", "-ok", "-okdir"]);
|
|
148
|
+
/** Commands that only store or display their input; a quoted heredoc fed to one never executes. */
|
|
149
|
+
const DATA_SINK_COMMANDS = new Set([
|
|
150
|
+
"cat", "tee", "head", "tail", "grep", "rg", "wc", "sort", "uniq", "cut", "tr", "diff", "less", "more", "base64",
|
|
151
|
+
"md5sum", "sha1sum", "sha256sum", "jq", "column", "fold", "paste", "comm", "cmp", "od", "hexdump", "xxd", "nl", "tac", "rev",
|
|
152
|
+
"echo", "printf",
|
|
153
|
+
]);
|
|
154
|
+
const SHELL_NAMES = new Set(["sh", "bash", "dash", "zsh", "fish", "ksh"]);
|
|
155
|
+
/** Shell words after which the next word is again in command position. */
|
|
156
|
+
const COMMAND_POSITION_KEYWORDS = new Set(["if", "then", "elif", "else", "while", "until", "do", "!", "(", "{", "time", "exec", "command", "builtin", "nohup", "sudo", "doas"]);
|
|
115
157
|
/** Rewriting a protected ref's identity directly; `checkout`/`switch`/`restore`/`worktree` are read-only uses of a branch name and are not included. */
|
|
116
158
|
const protectedBranchRewriteOperations = new Set(["reset", "update-ref", "symbolic-ref"]);
|
|
117
159
|
/** `branch` only rewrites or deletes a protected branch when combined with one of these flags. */
|
|
@@ -164,8 +206,13 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
|
|
|
164
206
|
const hasForcedBranchCreate = (lower.includes("checkout") && rawValues.includes("-B"))
|
|
165
207
|
|| (lower.includes("switch") && (rawValues.includes("-C") || rawValues.includes("--force-create")));
|
|
166
208
|
|
|
167
|
-
|
|
168
|
-
|
|
209
|
+
// An argument the lexer cannot see through matters only where it could reach
|
|
210
|
+
// the boundary: a repository, package, network or remote-shell command, or an
|
|
211
|
+
// interpreter that would execute the expanded text, in the same statement.
|
|
212
|
+
// Dynamic text in an ordinary local command (`for f in …; echo "$f"`) or in
|
|
213
|
+
// another statement (`npm test; echo "exit $?"`) is Claude's own business.
|
|
214
|
+
if ((hasDynamicArgument && hasDynamicCommandName(tokens)) || segmentsOf(tokens).some((segment) => hasDynamicSensitiveArgument(segment))) {
|
|
215
|
+
return { decision: "deny", reason: "a repository, package, network or shell command with a dynamic argument cannot be capability-checked" };
|
|
169
216
|
}
|
|
170
217
|
if (/\bgit\b[\s\S]*\b(?:push|merge(?!-)|send-pack|receive-pack|update-ref)\b/iu.test(canonical)
|
|
171
218
|
|| /\bgit-(?:send|receive|upload)-pack\b/iu.test(canonical)
|
|
@@ -198,14 +245,71 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
|
|
|
198
245
|
return undefined;
|
|
199
246
|
}
|
|
200
247
|
|
|
248
|
+
/** The statements of a command, split on `;`, `&&`, `||`, `|` and `&`. */
|
|
249
|
+
function segmentsOf(tokens: readonly ShellToken[]): ShellToken[][] {
|
|
250
|
+
const segments: ShellToken[][] = [[]];
|
|
251
|
+
for (const token of tokens) {
|
|
252
|
+
if (token.operator && SEGMENT_SPLIT_OPERATORS.has(token.value)) segments.push([]);
|
|
253
|
+
else segments.at(-1)!.push(token);
|
|
254
|
+
}
|
|
255
|
+
return segments.filter((segment) => segment.length > 0);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** True when one statement combines a dynamic word with a command whose dynamic argument could reach the boundary. */
|
|
259
|
+
function hasDynamicSensitiveArgument(segment: readonly ShellToken[]): boolean {
|
|
260
|
+
// A leading `NAME=value` prefix sets the environment and a redirection
|
|
261
|
+
// target names a file; neither reaches the command's argv, so
|
|
262
|
+
// `npm_config_cache=$TMPDIR/x npm run check` and `git show HEAD:f > $OLD/f`
|
|
263
|
+
// are literal commands.
|
|
264
|
+
const words: ShellToken[] = [];
|
|
265
|
+
let afterRedirect = false;
|
|
266
|
+
for (const token of segment) {
|
|
267
|
+
if (token.operator) { afterRedirect = [">", ">>", "<", "<<<"].includes(token.value); continue; }
|
|
268
|
+
if (!afterRedirect) words.push(token);
|
|
269
|
+
afterRedirect = false;
|
|
270
|
+
}
|
|
271
|
+
let argvStart = 0;
|
|
272
|
+
while (argvStart < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[argvStart]!.value)) argvStart += 1;
|
|
273
|
+
if (!words.slice(argvStart).some((token) => token.dynamic)) return false;
|
|
274
|
+
const values = words.map((token) => token.value);
|
|
275
|
+
const lower = values.map((value) => value.toLowerCase());
|
|
276
|
+
const executables = lower.map((value) => value.split(/[\\/]/u).at(-1) ?? value);
|
|
277
|
+
const canonical = values.join(" ");
|
|
278
|
+
return values.some((value) => /(?:^|[\\/])git$/iu.test(value) || /^git-(?:send|receive|upload)-pack$/iu.test(value))
|
|
279
|
+
|| containsRemoteCliMutation(canonical)
|
|
280
|
+
|| (lower.some((value) => value === "npm" || value === "pnpm" || value === "yarn") && lower.includes("publish"))
|
|
281
|
+
|| executables.some((executable) => DYNAMIC_SENSITIVE_COMMANDS.has(executable))
|
|
282
|
+
|| (executables.includes("find") && lower.some((value) => FIND_EXEC_ACTIONS.has(value)));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** A dynamic word in command position (`$CMD …`, `; $CMD`, `do . $file`) could name anything. */
|
|
286
|
+
function hasDynamicCommandName(tokens: readonly ShellToken[]): boolean {
|
|
287
|
+
let commandPosition = true;
|
|
288
|
+
for (const token of tokens) {
|
|
289
|
+
if (token.operator) {
|
|
290
|
+
commandPosition = SEGMENT_SPLIT_OPERATORS.has(token.value);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (!commandPosition) continue;
|
|
294
|
+
// `VAR=value cmd` keeps the following word in command position; the value itself is data.
|
|
295
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(token.value)) continue;
|
|
296
|
+
if (token.dynamic) return true;
|
|
297
|
+
const word = token.value.toLowerCase().replace(/^\(+/u, "") || "(";
|
|
298
|
+
if (word === "." || word === "source") return true;
|
|
299
|
+
if (COMMAND_POSITION_KEYWORDS.has(word)) continue;
|
|
300
|
+
commandPosition = false;
|
|
301
|
+
}
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
|
|
201
305
|
function nestedShellCommands(lower: readonly string[], values: readonly string[]): string[] {
|
|
202
306
|
const nested: string[] = [];
|
|
203
|
-
const shellNames = new Set(["sh", "bash", "dash", "zsh", "fish", "ksh"]);
|
|
204
307
|
for (let index = 0; index < lower.length; index += 1) {
|
|
205
308
|
const executable = lower[index]!.split(/[\\/]/u).at(-1);
|
|
206
|
-
if (executable &&
|
|
309
|
+
if (executable && SHELL_NAMES.has(executable)) {
|
|
207
310
|
for (let option = index + 1; option < lower.length; option += 1) {
|
|
208
|
-
|
|
311
|
+
// `-c`, `--command`, or a combined short option such as `-lc` / `-ec`.
|
|
312
|
+
if (lower[option] === "--command" || /^-[a-z]*c[a-z]*$/u.test(lower[option]!)) {
|
|
209
313
|
const command = values.slice(option + 1).join(" ").trim();
|
|
210
314
|
if (command) nested.push(command);
|
|
211
315
|
break;
|
|
@@ -228,7 +332,14 @@ function evaluateCommandInternal(command: string, depth: number): PolicyResult {
|
|
|
228
332
|
return evaluateTokens(lexical.tokens, depth);
|
|
229
333
|
}
|
|
230
334
|
|
|
231
|
-
function evaluateTokens(
|
|
335
|
+
function evaluateTokens(rawTokens: readonly ShellToken[], depth: number): PolicyResult {
|
|
336
|
+
const { tokens, embedded } = resolveDataTokens(rawTokens);
|
|
337
|
+
if (depth < 4) {
|
|
338
|
+
for (const body of embedded) {
|
|
339
|
+
const nestedResult = evaluateCommandInternal(body, depth + 1);
|
|
340
|
+
if (nestedResult.decision === "deny") return nestedResult;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
232
343
|
const canonical = tokens.map((token) => token.value).join(" ").trim();
|
|
233
344
|
if (!canonical) return { decision: "deny", reason: "empty command" };
|
|
234
345
|
const boundary = evaluateRepositoryBoundary(tokens, canonical, depth);
|
|
@@ -244,7 +355,7 @@ function evaluateTokens(tokens: readonly ShellToken[], depth: number): PolicyRes
|
|
|
244
355
|
|| /\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
356
|
return { decision: "deny", reason: "Worker cannot rewrite or delete a protected integration branch" };
|
|
246
357
|
}
|
|
247
|
-
if (/\b(?:npm|pnpm|yarn)\b
|
|
358
|
+
if (/\b(?:npm|pnpm|yarn)\b(?:\s+-\S+)*\s+publish\b/iu.test(canonical)) {
|
|
248
359
|
return { decision: "deny", reason: "package publication belongs to the protected release workflow" };
|
|
249
360
|
}
|
|
250
361
|
return { decision: "deny", reason: "command matches a prohibited destructive pattern" };
|
|
@@ -252,23 +363,126 @@ function evaluateTokens(tokens: readonly ShellToken[], depth: number): PolicyRes
|
|
|
252
363
|
return { decision: "allow", reason: "command is allowed for unattended local development" };
|
|
253
364
|
}
|
|
254
365
|
|
|
366
|
+
/**
|
|
367
|
+
* A quoted heredoc body means whatever its consumer makes of it. Fed to a shell
|
|
368
|
+
* (`bash <<'EOF'`, `cat <<'EOF' | sh`, `eval "$(cat <<'EOF' …)"`) it is a
|
|
369
|
+
* command and is evaluated as one; fed to a pure data sink (`cat > file`,
|
|
370
|
+
* `git commit -m`) it is text the boundary never needs to see; fed to anything
|
|
371
|
+
* else it stays in the command as literal words for the pattern checks.
|
|
372
|
+
*/
|
|
373
|
+
function resolveDataTokens(tokens: readonly ShellToken[]): { tokens: ShellToken[]; embedded: string[] } {
|
|
374
|
+
if (!tokens.some((token) => token.data)) return { tokens: [...tokens], embedded: [] };
|
|
375
|
+
const segments: { tokens: ShellToken[]; joiner?: string }[] = [{ tokens: [] }];
|
|
376
|
+
for (const token of tokens) {
|
|
377
|
+
if (token.operator && SEGMENT_SPLIT_OPERATORS.has(token.value)) {
|
|
378
|
+
segments.at(-1)!.joiner = token.value;
|
|
379
|
+
segments.push({ tokens: [] });
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
segments.at(-1)!.tokens.push(token);
|
|
383
|
+
}
|
|
384
|
+
const commandOf = (segment: readonly ShellToken[]): { name: string; words: string[] } | undefined => {
|
|
385
|
+
let afterRedirect = false;
|
|
386
|
+
let name: string | undefined;
|
|
387
|
+
const words: string[] = [];
|
|
388
|
+
for (const token of segment) {
|
|
389
|
+
if (token.operator) { afterRedirect = [">", ">>", "<", "<<", "<<<"].includes(token.value); continue; }
|
|
390
|
+
const skip = afterRedirect;
|
|
391
|
+
afterRedirect = false;
|
|
392
|
+
if (skip || token.data) continue;
|
|
393
|
+
const word = token.value.toLowerCase();
|
|
394
|
+
if (name === undefined) {
|
|
395
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(token.value) || COMMAND_POSITION_KEYWORDS.has(word)) continue;
|
|
396
|
+
name = word.replace(/^\(+/u, "").split(/[\\/]/u).at(-1) ?? word;
|
|
397
|
+
}
|
|
398
|
+
words.push(word);
|
|
399
|
+
}
|
|
400
|
+
return name === undefined ? undefined : { name, words };
|
|
401
|
+
};
|
|
402
|
+
const resolved: ShellToken[] = [];
|
|
403
|
+
const embedded: string[] = [];
|
|
404
|
+
segments.forEach((segment, index) => {
|
|
405
|
+
const bodies = segment.tokens.filter((token) => token.data);
|
|
406
|
+
if (bodies.length === 0) {
|
|
407
|
+
resolved.push(...segment.tokens, ...(segment.joiner ? [{ value: segment.joiner, operator: true, dynamic: false }] : []));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const consumers: { name: string; words: string[] }[] = [];
|
|
411
|
+
for (let cursor = index; cursor < segments.length; cursor += 1) {
|
|
412
|
+
const command = commandOf(segments[cursor]!.tokens);
|
|
413
|
+
if (command) consumers.push(command);
|
|
414
|
+
if (segments[cursor]!.joiner !== "|") break;
|
|
415
|
+
}
|
|
416
|
+
const shellConsumer = consumers.some((consumer) => SHELL_NAMES.has(consumer.name) || consumer.name === "eval" || consumer.name === "source" || consumer.name === ".");
|
|
417
|
+
const sinkOnly = consumers.length > 0 && consumers.every((consumer) => DATA_SINK_COMMANDS.has(consumer.name) || (consumer.name === "git" && consumer.words.includes("commit")));
|
|
418
|
+
if (shellConsumer) embedded.push(...bodies.map((token) => token.value));
|
|
419
|
+
const kept = shellConsumer || sinkOnly ? segment.tokens.filter((token) => !token.data) : segment.tokens.map((token) => ({ ...token, data: false }));
|
|
420
|
+
resolved.push(...kept, ...(segment.joiner ? [{ value: segment.joiner, operator: true, dynamic: false }] : []));
|
|
421
|
+
});
|
|
422
|
+
return { tokens: resolved, embedded };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* `$(cat <<'EOF' … EOF\n)` inside double quotes: Claude Code's commit-message
|
|
427
|
+
* idiom. With a quoted delimiter nothing in the body expands or runs, so the
|
|
428
|
+
* whole substitution is literal data.
|
|
429
|
+
*/
|
|
430
|
+
const LITERAL_CAT_HEREDOC = /^\$\(\s*cat\s+<<-?\s*(['"])([^'"\s]+)\1[ \t]*\n(?:([\s\S]*?)\n)?[ \t]*\2[ \t]*\n?\s*\)/u;
|
|
431
|
+
|
|
255
432
|
function lexShell(input: string): { tokens: ShellToken[]; error?: string } {
|
|
256
433
|
const tokens: ShellToken[] = [];
|
|
257
434
|
let value = "";
|
|
258
435
|
let dynamic = false;
|
|
259
436
|
let started = false;
|
|
437
|
+
let tokenQuoted = false;
|
|
438
|
+
let tokenData = false;
|
|
260
439
|
let quote: "single" | "double" | undefined;
|
|
440
|
+
// Heredocs: the word after `<<` names the delimiter; the body starts on the
|
|
441
|
+
// next line and ends at a line equal to it. A quoted delimiter makes the body
|
|
442
|
+
// pure data, which the boundary never needs to see.
|
|
443
|
+
let expectDelimiter: { stripTabs: boolean } | undefined;
|
|
444
|
+
const pendingHeredocs: { delimiter: string; quoted: boolean; stripTabs: boolean }[] = [];
|
|
261
445
|
const flush = (): void => {
|
|
262
446
|
if (!started) return;
|
|
263
|
-
|
|
447
|
+
// A bare `[`, `[[`, `]`, `]]`, `{` or `}` is shell syntax, not an expansion.
|
|
448
|
+
if (/^[[\]{}]+$/u.test(value)) dynamic = false;
|
|
449
|
+
if (expectDelimiter) {
|
|
450
|
+
pendingHeredocs.push({ delimiter: value, quoted: tokenQuoted, stripTabs: expectDelimiter.stripTabs });
|
|
451
|
+
expectDelimiter = undefined;
|
|
452
|
+
dynamic = false;
|
|
453
|
+
}
|
|
454
|
+
tokens.push({ value, operator: false, dynamic, ...(tokenData ? { data: true } : {}) });
|
|
264
455
|
value = "";
|
|
265
456
|
dynamic = false;
|
|
266
457
|
started = false;
|
|
458
|
+
tokenQuoted = false;
|
|
459
|
+
tokenData = false;
|
|
267
460
|
};
|
|
268
461
|
const pushOperator = (operator: string): void => {
|
|
269
462
|
flush();
|
|
270
463
|
tokens.push({ value: operator, operator: true, dynamic: false });
|
|
271
464
|
};
|
|
465
|
+
/** Consume the heredoc bodies that start after the newline at `newlineIndex`; returns the index to resume lexing at. */
|
|
466
|
+
const consumeHeredocs = (newlineIndex: number): number => {
|
|
467
|
+
let position = newlineIndex + 1;
|
|
468
|
+
for (const heredoc of pendingHeredocs.splice(0)) {
|
|
469
|
+
const bodyLines: string[] = [];
|
|
470
|
+
let terminated = false;
|
|
471
|
+
while (position <= input.length) {
|
|
472
|
+
const lineEnd = input.indexOf("\n", position);
|
|
473
|
+
const line = input.slice(position, lineEnd === -1 ? input.length : lineEnd);
|
|
474
|
+
position = lineEnd === -1 ? input.length + 1 : lineEnd + 1;
|
|
475
|
+
if ((heredoc.stripTabs ? line.replace(/^\t+/u, "") : line) === heredoc.delimiter) { terminated = true; break; }
|
|
476
|
+
bodyLines.push(line);
|
|
477
|
+
}
|
|
478
|
+
const body = bodyLines.join("\n");
|
|
479
|
+
// A quoted delimiter suppresses expansion: the body is data for its
|
|
480
|
+
// consumer. An unquoted one expands, so the body is an ordinary argument.
|
|
481
|
+
tokens.push(heredoc.quoted ? { value: body, operator: false, dynamic: false, data: true } : { value: body, operator: false, dynamic: /[$`]/u.test(body) });
|
|
482
|
+
if (!terminated) break;
|
|
483
|
+
}
|
|
484
|
+
return Math.min(position, input.length);
|
|
485
|
+
};
|
|
272
486
|
for (let index = 0; index < input.length; index += 1) {
|
|
273
487
|
const character = input[index]!;
|
|
274
488
|
const next = input[index + 1];
|
|
@@ -282,18 +496,39 @@ function lexShell(input: string): { tokens: ShellToken[]; error?: string } {
|
|
|
282
496
|
if (character === '"') quote = undefined;
|
|
283
497
|
else if (character === "\\" && next === "\n") index += 1;
|
|
284
498
|
else if (character === "\\" && next !== undefined && /[\\"$`]/u.test(next)) { value += next; index += 1; }
|
|
285
|
-
else
|
|
499
|
+
else if (character === "$") {
|
|
500
|
+
const literal = LITERAL_CAT_HEREDOC.exec(input.slice(index));
|
|
501
|
+
if (literal) { value += literal[3] ?? ""; tokenData = true; index += literal[0].length - 1; }
|
|
502
|
+
else { value += character; dynamic = true; }
|
|
503
|
+
}
|
|
504
|
+
else { value += character; if (character === "`") dynamic = true; }
|
|
286
505
|
started = true;
|
|
287
506
|
continue;
|
|
288
507
|
}
|
|
289
|
-
if (character === "'") { quote = "single"; started = true; continue; }
|
|
290
|
-
if (character === '"') { quote = "double"; started = true; continue; }
|
|
508
|
+
if (character === "'") { quote = "single"; started = true; tokenQuoted = true; continue; }
|
|
509
|
+
if (character === '"') { quote = "double"; started = true; tokenQuoted = true; continue; }
|
|
291
510
|
if (character === "\\") {
|
|
292
511
|
if (next === "\n") index += 1;
|
|
293
512
|
else if (next !== undefined) { value += next; index += 1; }
|
|
294
513
|
started = true;
|
|
295
514
|
continue;
|
|
296
515
|
}
|
|
516
|
+
// A comment runs to the end of the line.
|
|
517
|
+
if (character === "#" && !started) {
|
|
518
|
+
const lineEnd = input.indexOf("\n", index);
|
|
519
|
+
index = (lineEnd === -1 ? input.length : lineEnd) - 1;
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
// A newline ends the statement. Any pending heredoc bodies belong to the
|
|
523
|
+
// statement just lexed, so they are emitted before the separator.
|
|
524
|
+
if (character === "\n") {
|
|
525
|
+
flush();
|
|
526
|
+
expectDelimiter = undefined;
|
|
527
|
+
if (pendingHeredocs.length > 0) index = consumeHeredocs(index) - 1;
|
|
528
|
+
const last = tokens.at(-1);
|
|
529
|
+
if (last && !(last.operator && SEGMENT_SPLIT_OPERATORS.has(last.value))) pushOperator(";");
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
297
532
|
if (/\s/u.test(character)) { flush(); continue; }
|
|
298
533
|
if (character === "$" || character === "`") { dynamic = true; value += character; started = true; continue; }
|
|
299
534
|
// Brace, tilde and pathname expansion can change command names, targets or
|
|
@@ -301,7 +536,16 @@ function lexShell(input: string): { tokens: ShellToken[]; error?: string } {
|
|
|
301
536
|
// dynamic rather than attempting to model Bash's expansion order.
|
|
302
537
|
if ("*?[]{}~".includes(character)) { dynamic = true; value += character; started = true; continue; }
|
|
303
538
|
if (";&|<>".includes(character)) {
|
|
304
|
-
|
|
539
|
+
if (character === "<" && next === "<") {
|
|
540
|
+
const third = input[index + 2];
|
|
541
|
+
if (third === "<") { pushOperator("<<<"); index += 2; continue; }
|
|
542
|
+
const stripTabs = third === "-";
|
|
543
|
+
pushOperator("<<");
|
|
544
|
+
expectDelimiter = { stripTabs };
|
|
545
|
+
index += stripTabs ? 2 : 1;
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
const operator = next && ((character === "&" && next === "&") || (character === "|" && next === "|") || (character === ">" && next === ">"))
|
|
305
549
|
? `${character}${next}`
|
|
306
550
|
: character;
|
|
307
551
|
pushOperator(operator);
|
|
@@ -360,8 +604,8 @@ const FIND_WRITE_ACTIONS = new Set(["-delete", "-exec", "-execdir", "-ok", "-okd
|
|
|
360
604
|
* escalation, destructive git operations and any write outside the task cwd
|
|
361
605
|
* are never routine; the Decision Worker judges those.
|
|
362
606
|
*/
|
|
363
|
-
export function isRoutinePermission(toolName: string, input: unknown, cwd: string): boolean {
|
|
364
|
-
const policyResult = evaluatePermission(toolName, input, cwd);
|
|
607
|
+
export function isRoutinePermission(toolName: string, input: unknown, cwd: string, options: PermissionPolicyOptions = {}): boolean {
|
|
608
|
+
const policyResult = evaluatePermission(toolName, input, cwd, options);
|
|
365
609
|
if (policyResult.decision === "deny") return false;
|
|
366
610
|
if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") return true;
|
|
367
611
|
if (toolName === "Read" || toolName === "Glob" || toolName === "Grep" || toolName === "LS" || toolName === "TodoWrite") return true;
|
package/src/redaction.ts
CHANGED
|
@@ -2,7 +2,9 @@ const sensitiveKeyPattern = /(password|secret|token|api[-_]?key|authorization|cr
|
|
|
2
2
|
|
|
3
3
|
/** Recursively redact credential-shaped values before persistence or model prompts. */
|
|
4
4
|
export function redactSensitive(value: unknown, key?: string): unknown {
|
|
5
|
-
|
|
5
|
+
// A credential is a string; a number or boolean under a sensitive-looking
|
|
6
|
+
// key (`totalTokens`, `contextTokens`, `maxTokens`) is a count, not a secret.
|
|
7
|
+
if (key && sensitiveKeyPattern.test(key) && typeof value === "string") return "[REDACTED]";
|
|
6
8
|
if (typeof value === "string") {
|
|
7
9
|
return value
|
|
8
10
|
.replace(/\b(sk-ant-[A-Za-z0-9_-]+)\b/gu, "[REDACTED]")
|
package/src/supervisor.ts
CHANGED
|
@@ -98,6 +98,8 @@ export interface SupervisorStartOptions {
|
|
|
98
98
|
deadlineMs?: number;
|
|
99
99
|
/** Maximum time without worker output; defaults to 20 minutes. Set to 0 to disable. */
|
|
100
100
|
noOutputTimeoutMs?: number;
|
|
101
|
+
/** After a `wait` decision, how long the Worker may stay silent before the Decision Worker is asked again; defaults to 10 minutes. Set to 0 to disable. */
|
|
102
|
+
waitTimeoutMs?: number;
|
|
101
103
|
/** Human approval for a review-level worker command. */
|
|
102
104
|
approval?: { actor: "human"; reason: string };
|
|
103
105
|
/** Adopt an existing tmux session instead of starting a new worker. */
|
|
@@ -196,6 +198,9 @@ export class Supervisor {
|
|
|
196
198
|
#workerOutput = "";
|
|
197
199
|
#lastWorkerResult?: Record<string, unknown>;
|
|
198
200
|
#lastTurnCompleted?: WorkerEvent;
|
|
201
|
+
/** Armed by a `wait` decision: re-asks the Decision Worker if the Worker never resumes on its own. */
|
|
202
|
+
#waitTimer?: NodeJS.Timeout;
|
|
203
|
+
#waitTimeoutMs = 10 * 60_000;
|
|
199
204
|
#turn = 0;
|
|
200
205
|
#repairRound = 0;
|
|
201
206
|
#lastFindingSignature?: string;
|
|
@@ -324,6 +329,7 @@ export class Supervisor {
|
|
|
324
329
|
this.#turn = options.initialTurn ?? 0;
|
|
325
330
|
this.#deadlineMs = options.deadlineMs ?? 4 * 60 * 60_000;
|
|
326
331
|
this.#noOutputTimeoutMs = options.noOutputTimeoutMs ?? 20 * 60_000;
|
|
332
|
+
this.#waitTimeoutMs = options.waitTimeoutMs ?? 10 * 60_000;
|
|
327
333
|
this.#noOutputBaselineAt = undefined;
|
|
328
334
|
this.#verificationAbortController = undefined;
|
|
329
335
|
this.#progressPhase = undefined;
|
|
@@ -608,6 +614,8 @@ export class Supervisor {
|
|
|
608
614
|
if (this.#handledEvents.has(key)) return;
|
|
609
615
|
try {
|
|
610
616
|
let skipDecisionNotify = false;
|
|
617
|
+
// Any fresh Worker activity supersedes a pending wait.
|
|
618
|
+
this.#clearWaitTimer();
|
|
611
619
|
if (event.type === "turn_completed") { this.#lastWorkerResult = event.result; this.#lastTurnCompleted = event; }
|
|
612
620
|
// Do not call #pollInternal from within a deferred retry: it would
|
|
613
621
|
// recurse back into #retryDeferredWorkerEvents through #pollInternal's
|
|
@@ -651,7 +659,7 @@ export class Supervisor {
|
|
|
651
659
|
this.#pendingPermissions.delete(event.request.requestId);
|
|
652
660
|
skipDecisionNotify = true;
|
|
653
661
|
} else {
|
|
654
|
-
const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd);
|
|
662
|
+
const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd, { writeRoots: event.request.writeRoots });
|
|
655
663
|
if (policy.decision === "deny") {
|
|
656
664
|
if (this.#adapter.respondPermission) {
|
|
657
665
|
await this.#adapter.respondPermission(handle, event.request.requestId, event.request.toolUseId, {
|
|
@@ -682,9 +690,9 @@ export class Supervisor {
|
|
|
682
690
|
skipDecisionNotify = true;
|
|
683
691
|
} else if (this.#automation && !this.#humanRequired) {
|
|
684
692
|
const authority = task.spec.autonomy.permissionAuthority;
|
|
685
|
-
const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd);
|
|
693
|
+
const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd, { writeRoots: event.request.writeRoots });
|
|
686
694
|
const answerLocally = authority === "policy"
|
|
687
|
-
|| (authority === "hybrid" && (policy.decision === "deny" || isRoutinePermission(event.request.toolName, event.request.input, task.cwd)));
|
|
695
|
+
|| (authority === "hybrid" && (policy.decision === "deny" || isRoutinePermission(event.request.toolName, event.request.input, task.cwd, { writeRoots: event.request.writeRoots })));
|
|
688
696
|
if (answerLocally && this.#adapter.respondPermission) {
|
|
689
697
|
const behavior: "allow" | "deny" = policy.decision === "deny" ? "deny" : "allow";
|
|
690
698
|
await this.#adapter.respondPermission(handle, event.request.requestId, event.request.toolUseId, {
|
|
@@ -865,7 +873,7 @@ export class Supervisor {
|
|
|
865
873
|
// checked before the generic policy-deny reason, or the Decision
|
|
866
874
|
// Worker's chosen answer would never reach Claude.
|
|
867
875
|
const isAskUserQuestionAnswer = event.request.toolName === "AskUserQuestion" && action.action === "deny_permission";
|
|
868
|
-
const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd);
|
|
876
|
+
const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd, { writeRoots: event.request.writeRoots });
|
|
869
877
|
const behavior = policy.decision === "deny" ? "deny" : action.action === "allow_permission" ? "allow" : "deny";
|
|
870
878
|
const message = behavior !== "deny"
|
|
871
879
|
? undefined
|
|
@@ -901,9 +909,16 @@ export class Supervisor {
|
|
|
901
909
|
return;
|
|
902
910
|
}
|
|
903
911
|
if (action.action === "continue" || action.action === "redirect" || action.action === "answer") {
|
|
912
|
+
if (await this.#decisionIsStale(event)) return;
|
|
904
913
|
await this.#sendInternal(action.message);
|
|
905
914
|
return;
|
|
906
915
|
}
|
|
916
|
+
if (action.action === "wait") {
|
|
917
|
+
// The Worker will be re-invoked by its own background work; send
|
|
918
|
+
// nothing, but re-ask if it stays silent for the wait timeout.
|
|
919
|
+
this.#armWaitTimer(event);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
907
922
|
if (action.action === "verify") {
|
|
908
923
|
if (this.#machine.state === "waiting" && canRepairInPlace(this.#adapter)) {
|
|
909
924
|
this.#machine.transition("verifying");
|
|
@@ -938,12 +953,57 @@ export class Supervisor {
|
|
|
938
953
|
return;
|
|
939
954
|
}
|
|
940
955
|
if (action.action === "retry") {
|
|
941
|
-
if (action.message?.trim()) await this.#
|
|
942
|
-
|
|
956
|
+
if (!action.message?.trim()) { await this.#parkCandidate(`Retry requires a concrete corrective instruction: ${action.reason}`, event); return; }
|
|
957
|
+
if (await this.#decisionIsStale(event)) return;
|
|
958
|
+
await this.#sendInternal(action.message);
|
|
943
959
|
}
|
|
944
960
|
});
|
|
945
961
|
}
|
|
946
962
|
|
|
963
|
+
/**
|
|
964
|
+
* A decision about a completed turn is stale once the Worker has started a
|
|
965
|
+
* new turn on its own (a background agent, task or monitor of its own
|
|
966
|
+
* re-invoked it). Sending then would be refused by the adapter; it is not a
|
|
967
|
+
* failure of anything, so record it and let the next turn drive a new decision.
|
|
968
|
+
*/
|
|
969
|
+
async #decisionIsStale(event: WorkerEvent): Promise<boolean> {
|
|
970
|
+
const handle = this.#handle;
|
|
971
|
+
if (!handle || event.type !== "turn_completed") return false;
|
|
972
|
+
const status = await this.#adapter.getStatus(handle).catch(() => undefined);
|
|
973
|
+
if (!status || status.activeRequests === undefined || status.activeRequests === 0) return false;
|
|
974
|
+
await this.#appendEvent({
|
|
975
|
+
type: "decision_ignored",
|
|
976
|
+
taskId: this.#task?.taskId,
|
|
977
|
+
workerId: handle.id,
|
|
978
|
+
data: { reason: "worker resumed on its own before the decision arrived", eventType: event.type },
|
|
979
|
+
}).catch(() => {});
|
|
980
|
+
return true;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
#armWaitTimer(event: WorkerEvent): void {
|
|
984
|
+
this.#clearWaitTimer();
|
|
985
|
+
if (this.#waitTimeoutMs <= 0) return;
|
|
986
|
+
this.#waitTimer = setTimeout(() => {
|
|
987
|
+
this.#waitTimer = undefined;
|
|
988
|
+
void this.#exclusive(async () => {
|
|
989
|
+
if (!this.#decision || !this.#automation || this.#humanRequired || this.#machine.state !== "waiting" || this.#lastTurnCompleted !== event) return;
|
|
990
|
+
const handle = this.#handle;
|
|
991
|
+
if (!handle) return;
|
|
992
|
+
const status = await this.#adapter.getStatus(handle).catch(() => undefined);
|
|
993
|
+
if (status?.activeRequests) return;
|
|
994
|
+
await this.#appendEvent({ type: "wait_expired", taskId: this.#task?.taskId, workerId: handle.id, data: { waitTimeoutMs: this.#waitTimeoutMs } }).catch(() => {});
|
|
995
|
+
this.#decision.updateContext({ state: this.#machine.state, turn: this.#turn, repairRound: this.#repairRound });
|
|
996
|
+
this.#decision.replay?.(event);
|
|
997
|
+
}).catch(() => { /* the watchdog still covers a silent Worker */ });
|
|
998
|
+
}, this.#waitTimeoutMs);
|
|
999
|
+
this.#waitTimer.unref?.();
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
#clearWaitTimer(): void {
|
|
1003
|
+
if (this.#waitTimer) clearTimeout(this.#waitTimer);
|
|
1004
|
+
this.#waitTimer = undefined;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
947
1007
|
/**
|
|
948
1008
|
* Record, best effort, that the task's current branch diverged from the
|
|
949
1009
|
* last one observed. The task is anchored to its baseline commit, not to a
|
|
@@ -1042,7 +1102,7 @@ export class Supervisor {
|
|
|
1042
1102
|
const request = requestId ? this.#pendingPermissions.get(requestId) : [...this.#pendingPermissions.values()].at(-1);
|
|
1043
1103
|
if (!request) throw new Error("no pending permission request");
|
|
1044
1104
|
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);
|
|
1105
|
+
const policy = evaluatePermission(request.toolName, request.input, task.cwd, { writeRoots: request.writeRoots });
|
|
1046
1106
|
if (policy.decision === "deny" && behavior === "allow") throw new Error(`permission denied by policy: ${policy.reason}`);
|
|
1047
1107
|
await this.#adapter.respondPermission(handle, request.requestId, request.toolUseId, { behavior: policy.decision === "deny" ? "deny" : behavior }, behavior === "allow" ? request.input : undefined);
|
|
1048
1108
|
this.#pendingPermissions.delete(request.requestId);
|
|
@@ -1069,7 +1129,11 @@ export class Supervisor {
|
|
|
1069
1129
|
this.#humanRequired = false;
|
|
1070
1130
|
this.#humanGate = undefined;
|
|
1071
1131
|
await this.#appendEvent({ type: "automation_resumed", taskId: this.#task?.taskId, workerId: this.#handle?.id });
|
|
1072
|
-
if (this.#decision && this.#machine.state === "waiting" && this.#lastTurnCompleted) {
|
|
1132
|
+
if (this.#decision && this.#machine.state === "waiting" && this.#lastTurnCompleted && this.#handle) {
|
|
1133
|
+
// Replay the last completed turn only if the Worker is really idle; if
|
|
1134
|
+
// it has resumed on its own, its next Stop brings a fresh turn.
|
|
1135
|
+
const status = await this.#adapter.getStatus(this.#handle).catch(() => undefined);
|
|
1136
|
+
if (status?.activeRequests) return;
|
|
1073
1137
|
this.#decision.updateContext({ state: this.#machine.state, turn: this.#turn, repairRound: this.#repairRound });
|
|
1074
1138
|
this.#decision.replay?.(this.#lastTurnCompleted);
|
|
1075
1139
|
}
|
|
@@ -1727,6 +1791,7 @@ export class Supervisor {
|
|
|
1727
1791
|
#clearWatchdog(): void {
|
|
1728
1792
|
if (this.#watchdog) clearInterval(this.#watchdog);
|
|
1729
1793
|
this.#watchdog = undefined;
|
|
1794
|
+
this.#clearWaitTimer();
|
|
1730
1795
|
}
|
|
1731
1796
|
|
|
1732
1797
|
async #checkWatchdog(): Promise<void> {
|
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
|
}
|
|
@@ -2000,7 +2003,11 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
2000
2003
|
record.pendingSentMessages.splice(matchedIndex, 1);
|
|
2001
2004
|
return {};
|
|
2002
2005
|
}
|
|
2003
|
-
|
|
2006
|
+
// Claude Code delivers its own background-task, monitor and agent
|
|
2007
|
+
// completions through this hook as a user-role message; nobody typed it.
|
|
2008
|
+
if (!isClaudeRuntimePrompt(prompt)) {
|
|
2009
|
+
this.#emit(record, { type: "human_input", handle: record.handle, text: boundTextHead(prompt, 4_096) });
|
|
2010
|
+
}
|
|
2004
2011
|
record.activeRequests = 1;
|
|
2005
2012
|
record.inputAt = Date.now();
|
|
2006
2013
|
record.lastInputAt = new Date().toISOString();
|
|
@@ -2069,6 +2076,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
2069
2076
|
input: event.tool_input,
|
|
2070
2077
|
raw: event as unknown as Record<string, unknown>,
|
|
2071
2078
|
phase,
|
|
2079
|
+
...(record.scratchpadDir ? { writeRoots: [record.scratchpadDir] } : {}),
|
|
2072
2080
|
},
|
|
2073
2081
|
});
|
|
2074
2082
|
return new Promise<HookRelayReply>((resolve) => {
|
|
@@ -2273,6 +2281,11 @@ function normalizeForMatch(value: string): string {
|
|
|
2273
2281
|
return value.trim().replace(/\s+/gu, " ");
|
|
2274
2282
|
}
|
|
2275
2283
|
|
|
2284
|
+
/** True for a prompt Claude Code injected itself (`<task-notification>`, `<system-reminder>`), which a human never typed. */
|
|
2285
|
+
function isClaudeRuntimePrompt(prompt: string): boolean {
|
|
2286
|
+
return /^\s*<(?:task-notification|system-reminder)\b/u.test(prompt);
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2276
2289
|
/**
|
|
2277
2290
|
* A UserPromptSubmit hook reports the prompt as Claude's TUI captured it,
|
|
2278
2291
|
* which can reflow long pasted text. Treat it as the adapter's own send when
|