pi-claude-supervisor 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/policy.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { lstatSync, realpathSync, statSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+
1
4
  export type PolicyDecision = "allow" | "review" | "deny";
2
5
 
3
6
  export interface PolicyResult {
@@ -11,16 +14,85 @@ export interface PolicyResult {
11
14
  * turn. AskUserQuestion is denied so the Worker can restate the question as
12
15
  * ordinary text and the Decision Worker can answer it from task evidence.
13
16
  */
14
- export function evaluatePermission(toolName: string, input: unknown): PolicyResult {
17
+ const allowedWorkerTools = new Set(["Bash", "Edit", "Glob", "Grep", "Read", "Write", "NotebookEdit"]);
18
+
19
+ export function evaluatePermission(toolName: string, input: unknown, cwd = process.cwd()): PolicyResult {
15
20
  if (toolName === "AskUserQuestion") return { decision: "deny", reason: "interactive questions are converted to ordinary Worker text" };
21
+ if (!allowedWorkerTools.has(toolName)) return { decision: "deny", reason: `Worker tool is outside the automatic allowlist: ${toolName}` };
22
+ if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") {
23
+ const paths = fileToolPaths(input);
24
+ if (paths.length === 0) return { decision: "deny", reason: `${toolName} request has no recognizable file path` };
25
+ if (paths.some((path) => isGitMetadataPath(path, cwd))) return { decision: "deny", reason: "Worker cannot write Git metadata or protected branch refs" };
26
+ return { decision: "allow", reason: `local Claude file tool is allowed by the task policy: ${toolName}` };
27
+ }
16
28
  if (toolName !== "Bash") return { decision: "allow", reason: `local Claude tool is allowed by the task policy: ${toolName}` };
17
29
  const command = input && typeof input === "object" && typeof (input as { command?: unknown }).command === "string"
18
30
  ? (input as { command: string }).command
19
31
  : "";
20
32
  if (!command) return { decision: "deny", reason: "Bash request has no recognizable command" };
33
+ if (containsNestedClaude(command)) {
34
+ return { decision: "deny", reason: "Worker cannot start a nested Claude or background Reviewer" };
35
+ }
21
36
  return evaluateCommand("bash", ["-lc", command]);
22
37
  }
23
38
 
39
+ function fileToolPaths(input: unknown): string[] {
40
+ if (!input || typeof input !== "object" || Array.isArray(input)) return [];
41
+ const value = input as Record<string, unknown>;
42
+ return ["file_path", "filePath", "path", "notebook_path", "notebookPath"]
43
+ .map((key) => value[key])
44
+ .filter((path): path is string => typeof path === "string" && path.trim().length > 0);
45
+ }
46
+
47
+ function isGitMetadataPath(value: string, cwd: string): boolean {
48
+ if (value.replaceAll("\\", "/").split("/").some((segment) => segment.toLowerCase() === ".git")) return true;
49
+ let root: string;
50
+ try { root = realpathSync(cwd); }
51
+ catch { return true; }
52
+ const raw = value.replaceAll("\\", "/");
53
+ const canonicalRoot = root.replaceAll("\\", "/").replace(/\/+$/u, "") || "/";
54
+ let components: string[];
55
+ if (isAbsolute(value)) {
56
+ const prefix = canonicalRoot === "/" ? "/" : `${canonicalRoot}/`;
57
+ if (raw !== canonicalRoot && !raw.startsWith(prefix)) return true;
58
+ components = raw === canonicalRoot ? [] : raw.slice(prefix.length).split("/");
59
+ } else {
60
+ components = raw.split("/");
61
+ }
62
+ const normalized: string[] = [];
63
+ for (const segment of components) {
64
+ if (!segment || segment === ".") continue;
65
+ if (segment === "..") {
66
+ if (normalized.length === 0) return true;
67
+ normalized.pop();
68
+ continue;
69
+ }
70
+ if (segment.toLowerCase() === ".git") return true;
71
+ const candidate = join(root, ...normalized, segment);
72
+ try {
73
+ const info = lstatSync(candidate);
74
+ if (info.isSymbolicLink()) return true;
75
+ } catch (error) {
76
+ const code = (error as NodeJS.ErrnoException).code;
77
+ if (code !== "ENOENT" && code !== "ENOTDIR") return true;
78
+ }
79
+ normalized.push(segment);
80
+ }
81
+ const absolute = join(root, ...normalized);
82
+ try {
83
+ const info = statSync(absolute);
84
+ // A regular file with multiple links may be an alias for a Git ref or
85
+ // other metadata file even when its pathname contains no `.git` segment.
86
+ if (info.isFile() && info.nlink > 1) return true;
87
+ const resolved = realpathSync(absolute);
88
+ if (resolved.replaceAll("\\", "/").split("/").some((segment) => segment.toLowerCase() === ".git")) return true;
89
+ } catch (error) {
90
+ const code = (error as NodeJS.ErrnoException).code;
91
+ if (code !== "ENOENT" && code !== "ENOTDIR") return true;
92
+ }
93
+ return false;
94
+ }
95
+
24
96
  const deniedPatterns = [
25
97
  /\b(?:npm|pnpm|yarn)\b[\s\S]*\bpublish\b/iu,
26
98
  /\b(?:ssh|scp|sftp|rsync)\b/iu,
@@ -63,6 +135,7 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
63
135
  const hasGhRemote = lower.some((value) => value === "gh" || value === "glab" || value === "hub")
64
136
  && lower.some((value) => value === "api" || value === "release" || value === "pull-request" || value === "pr" || value === "mr");
65
137
  const hasPackagePublication = lower.some((value) => value === "npm" || value === "pnpm" || value === "yarn") && lower.includes("publish");
138
+ const hasGitAliasConfiguration = hasGit && lower.some((value) => /^alias\.[^=]*(?:=|$)/u.test(value));
66
139
  if (depth < 4) {
67
140
  for (const nested of nestedShellCommands(lower, values)) {
68
141
  const nestedResult = evaluateCommandInternal(nested, depth + 1);
@@ -85,6 +158,9 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
85
158
  if (hasGit && (hasRemoteOperation || hasGitTransport) || hasGhRemote) {
86
159
  return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
87
160
  }
161
+ if (hasGitAliasConfiguration) {
162
+ return { decision: "deny", reason: "Worker cannot redefine Git command aliases" };
163
+ }
88
164
  if (hasGit && gitOperation && hasProtectedBranch) {
89
165
  return { decision: "deny", reason: "Worker cannot switch to or mutate a protected integration branch" };
90
166
  }
@@ -105,6 +181,27 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
105
181
  return undefined;
106
182
  }
107
183
 
184
+ function containsNestedClaude(command: string, depth = 0): boolean {
185
+ // Bash can hand an opaque quoted program to Python, Node, eval, env,
186
+ // command, or another interpreter. Once this permission boundary sees a
187
+ // Claude executable reference, fail closed rather than trying to prove which
188
+ // wrapper will eventually exec it. This intentionally rejects harmless prose
189
+ // mentions too; false positives cannot grant a nested Worker capability.
190
+ if (/\b(?:claude(?:\.exe)?|review(?:er)?|code[-_ ]?review|read[-_ ]?only[-_ ]?review|independent[-_ ]?review|codex|cursor(?:-agent)?|aider|opencode|gemini)\b/iu.test(command)) return true;
191
+ if (depth > 4) return false;
192
+ const lexical = lexShell(command);
193
+ if (lexical.error) return true;
194
+ for (const token of lexical.tokens) {
195
+ if (token.operator) continue;
196
+ const executable = token.value.split(/[\\/]/u).at(-1)?.toLowerCase();
197
+ if (executable === "claude" || executable === "claude.exe") return true;
198
+ // Quote concatenation such as c'l'a'u'd'e is normalized by the shell
199
+ // lexer into one token. Recurse into quoted interpreter payloads too.
200
+ if (/\s/u.test(token.value) && containsNestedClaude(token.value, depth + 1)) return true;
201
+ }
202
+ return false;
203
+ }
204
+
108
205
  function nestedShellCommands(lower: readonly string[], values: readonly string[]): string[] {
109
206
  const nested: string[] = [];
110
207
  const shellNames = new Set(["sh", "bash", "dash", "zsh", "fish", "ksh"]);
package/src/reviewer.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager } from "@earendil-works/pi-coding-agent";
2
+ import { isDeepStrictEqual } from "node:util";
2
3
  import { redactSensitive } from "./redaction.ts";
3
4
  import type { AcceptanceReport, ReviewFinding, ReviewReport, TaskSpec } from "./types.ts";
4
5
  import type { RepositoryEvidence } from "./verifier.ts";
@@ -147,7 +148,7 @@ ${boundText(redactText(input.evidence.untracked ?? "(none)"), 16_000)}
147
148
 
148
149
  EVIDENCE COMPLETE:
149
150
  ${String(input.evidence.complete !== false && input.evidence.truncated !== true)}
150
- If repository evidence is incomplete or truncated, do not return pass; return human and explain which evidence is unavailable.
151
+ DISPLAY NOTE: fields below may end with [DISPLAY_TRUNCATED] because the prompt has a bounded presentation window. This is presentation-only and is not evidence loss. If EVIDENCE COMPLETE is true, do not return human merely because a displayed field is shortened; inspect the repository with the read-only tools instead. If EVIDENCE COMPLETE is false, do not return pass; return human and explain which evidence is unavailable.
151
152
 
152
153
  WORKER OUTPUT (UNTRUSTED):
153
154
  ${boundTailText(redactSensitive(input.workerOutput ?? "(none)"), 8_000)}
@@ -187,17 +188,21 @@ export function normalizeReviewReport(value: unknown, round: number): ReviewRepo
187
188
  export function parseReview(text: string, round: number): ReviewReport {
188
189
  const checkedAt = new Date().toISOString();
189
190
  if (Buffer.byteLength(text, "utf8") > MAX_REVIEW_RESPONSE_BYTES) return invalidReview(`Reviewer response exceeded ${MAX_REVIEW_RESPONSE_BYTES} bytes`, round, checkedAt);
190
- const candidate = text.trim();
191
- if (!candidate) return invalidReview("Reviewer returned no JSON object", round, checkedAt);
191
+ if (!text.trim()) return invalidReview("Reviewer returned no JSON object", round, checkedAt);
192
192
  try {
193
- const value = JSON.parse(candidate) as Record<string, unknown>;
193
+ const objects = extractJsonObjects(text);
194
+ if (objects.length === 0) throw new Error("Reviewer output did not contain a JSON object");
195
+ if (objects.slice(1).some((value) => !isDeepStrictEqual(value, objects[0]))) {
196
+ throw new Error("Reviewer output contained multiple distinct JSON objects");
197
+ }
198
+ const value = objects[0] as Record<string, unknown>;
194
199
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Reviewer output must be a JSON object");
195
200
  const verdict = value.verdict;
196
201
  if (verdict !== "pass" && verdict !== "revise" && verdict !== "human") throw new Error("unsupported verdict");
197
202
  const summary = typeof value.summary === "string" && value.summary.trim() ? value.summary.trim() : "no summary provided";
198
- if (value.findings !== undefined && !Array.isArray(value.findings)) throw new Error("findings must be an array");
199
- if (Array.isArray(value.findings) && value.findings.length > MAX_REVIEW_FINDINGS) throw new Error(`findings exceed the limit of ${MAX_REVIEW_FINDINGS}`);
200
- const findings = (value.findings ?? []).map((finding, index) => parseFinding(finding, index));
203
+ if (!Array.isArray(value.findings)) throw new Error("findings must be an array");
204
+ if (value.findings.length > MAX_REVIEW_FINDINGS) throw new Error(`findings exceed the limit of ${MAX_REVIEW_FINDINGS}`);
205
+ const findings = value.findings.map((finding, index) => parseFinding(finding, index));
201
206
  if (verdict === "revise" && findings.length === 0) throw new Error("revise verdict requires at least one finding");
202
207
  return { verdict, summary: boundText(summary, 4_000), findings, round, checkedAt };
203
208
  } catch (error) {
@@ -205,6 +210,56 @@ export function parseReview(text: string, round: number): ReviewReport {
205
210
  }
206
211
  }
207
212
 
213
+ /**
214
+ * Claude occasionally wraps its answer in a fence, a sentence, or repeats
215
+ * the same JSON object. Extract one bounded object while rejecting conflicting
216
+ * objects: prose is harmless, but ambiguity must remain non-publishable.
217
+ */
218
+ function extractJsonObjects(text: string): unknown[] {
219
+ const objects: unknown[] = [];
220
+ let offset = 0;
221
+ while (offset < text.length) {
222
+ const start = text.indexOf("{", offset);
223
+ if (start < 0) break;
224
+ const end = balancedObjectEnd(text, start);
225
+ if (end < 0) {
226
+ offset = start + 1;
227
+ continue;
228
+ }
229
+ const candidate = text.slice(start, end + 1);
230
+ try {
231
+ const value = JSON.parse(candidate) as unknown;
232
+ if (value && typeof value === "object" && !Array.isArray(value)) objects.push(value);
233
+ offset = end + 1;
234
+ } catch {
235
+ offset = start + 1;
236
+ }
237
+ }
238
+ return objects;
239
+ }
240
+
241
+ function balancedObjectEnd(text: string, start: number): number {
242
+ let depth = 0;
243
+ let inString = false;
244
+ let escaped = false;
245
+ for (let index = start; index < text.length; index += 1) {
246
+ const character = text[index];
247
+ if (inString) {
248
+ if (escaped) escaped = false;
249
+ else if (character === "\\") escaped = true;
250
+ else if (character === '"') inString = false;
251
+ continue;
252
+ }
253
+ if (character === '"') {
254
+ inString = true;
255
+ continue;
256
+ }
257
+ if (character === "{") depth += 1;
258
+ else if (character === "}" && --depth === 0) return index;
259
+ }
260
+ return -1;
261
+ }
262
+
208
263
  function parseFinding(value: unknown, index: number): ReviewFinding {
209
264
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`finding ${index} must be an object`);
210
265
  const source = value as Record<string, unknown>;
@@ -273,11 +328,11 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str
273
328
  function boundTailText(value: unknown, maxBytes: number): string {
274
329
  const text = String(value);
275
330
  if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
276
- return `[TRUNCATED]\n${Buffer.from(text, "utf8").subarray(-maxBytes).toString("utf8")}`;
331
+ return `[DISPLAY_TRUNCATED]\n${Buffer.from(text, "utf8").subarray(-maxBytes).toString("utf8")}`;
277
332
  }
278
333
 
279
334
  function boundText(value: unknown, maxBytes: number): string {
280
335
  const text = String(value);
281
336
  if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
282
- return `${Buffer.from(text, "utf8").subarray(0, maxBytes).toString("utf8")}\n[TRUNCATED]`;
337
+ return `${Buffer.from(text, "utf8").subarray(0, maxBytes).toString("utf8")}\n[DISPLAY_TRUNCATED]`;
283
338
  }
package/src/supervisor.ts CHANGED
@@ -264,8 +264,8 @@ export class Supervisor {
264
264
  },
265
265
  });
266
266
  this.#reportProgress("starting", "preflight and Worker startup");
267
- if (this.#automation && this.#adapter.capabilities().transport !== "jsonl") {
268
- throw new Error("automatic supervision requires the claude-jsonl transport; tmux is manual-only");
267
+ if (this.#automation && !["jsonl", "tmux"].includes(this.#adapter.capabilities().transport)) {
268
+ throw new Error("automatic supervision requires the claude-jsonl or automated tmux transport");
269
269
  }
270
270
  const workerEnvironment = this.#automation ? automaticWorkerEnvironment(options.env) : options.env;
271
271
  if (this.#automation) {
@@ -279,6 +279,7 @@ export class Supervisor {
279
279
  args: workerArgs,
280
280
  env: workerEnvironment,
281
281
  approval: options.approval,
282
+ automatic: this.#automation,
282
283
  });
283
284
  this.#assertStartNotAborted(startAbortController.signal);
284
285
  if (this.#automation) {
@@ -328,6 +329,7 @@ export class Supervisor {
328
329
  tmuxSocket: options.tmuxSocket,
329
330
  tmuxExpectedIdentity: options.tmuxExpectedIdentity,
330
331
  sendInitialInput: options.sendInitialInput,
332
+ automatic: this.#automation,
331
333
  eventListener: (event) => this.#receiveWorkerEvent(event),
332
334
  env: workerEnvironment,
333
335
  abortSignal: startAbortController.signal,
@@ -518,7 +520,7 @@ export class Supervisor {
518
520
  await this.#parkCandidate(`Permission response is unavailable for ${event.type}`, event);
519
521
  return;
520
522
  }
521
- const policy = evaluatePermission(event.request.toolName, event.request.input);
523
+ const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd);
522
524
  const behavior = policy.decision === "deny" ? "deny" : action.action === "allow_permission" ? "allow" : "deny";
523
525
  await this.#adapter.respondPermission(handle, event.request.requestId, event.request.toolUseId, {
524
526
  behavior,
@@ -629,7 +631,7 @@ export class Supervisor {
629
631
  const request = requestId ? this.#pendingPermissions.get(requestId) : [...this.#pendingPermissions.values()].at(-1);
630
632
  if (!request) throw new Error("no pending permission request");
631
633
  if (this.#humanRequired && this.#humanGate !== "permission") throw new Error("automatic decisions are held by a separate human gate; use resume-auto explicitly");
632
- const policy = evaluatePermission(request.toolName, request.input);
634
+ const policy = evaluatePermission(request.toolName, request.input, task.cwd);
633
635
  if (policy.decision === "deny" && behavior === "allow") throw new Error(`permission denied by policy: ${policy.reason}`);
634
636
  await this.#adapter.respondPermission(handle, request.requestId, request.toolUseId, { behavior: policy.decision === "deny" ? "deny" : behavior }, behavior === "allow" ? request.input : undefined);
635
637
  this.#pendingPermissions.delete(request.requestId);
package/src/types.ts CHANGED
@@ -52,6 +52,8 @@ export interface WorkerStartInput {
52
52
  };
53
53
  /** Adopted sessions must not replay the task as a new user message. */
54
54
  sendInitialInput?: boolean;
55
+ /** Automatic mode selects the structured tmux bridge instead of the manual TUI transport. */
56
+ automatic?: boolean;
55
57
  /** Cancel startup before a worker is fully returned to the supervisor. */
56
58
  abortSignal?: AbortSignal;
57
59
  /** Internal token that scopes out-of-band startup cancellation. */
@@ -111,7 +113,7 @@ export interface PermissionDecision {
111
113
  export interface WorkerAdapter {
112
114
  capabilities(): WorkerCapabilities;
113
115
  /** Validate host/transport prerequisites before starting a Worker. */
114
- preflight?(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval">): Promise<void>;
116
+ preflight?(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval" | "automatic">): Promise<void>;
115
117
  start(input: WorkerStartInput): Promise<WorkerHandle>;
116
118
  /** Cancel adapter-owned startup work before a WorkerHandle is returned. */
117
119
  abortStart?(reason: string, startupToken?: string): Promise<void>;
package/src/verifier.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { constants as fsConstants } from "node:fs";
3
- import { lstat, open, realpath } from "node:fs/promises";
3
+ import { lstat, open, readlink, realpath } from "node:fs/promises";
4
4
  import { isAbsolute, relative, resolve, sep } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import type { AcceptanceCheck, AcceptanceCheckResult, AcceptanceReport, VerificationResult } from "./types.ts";
@@ -310,6 +310,12 @@ async function collectUntrackedEvidence(cwd: string, signal?: AbortSignal): Prom
310
310
  sections.push(`--- ${JSON.stringify(path)} [non-regular file omitted]`);
311
311
  continue;
312
312
  }
313
+ if (opened.nlink > 1) {
314
+ complete = false;
315
+ sections.push(`--- ${JSON.stringify(path)} [hard-link file omitted]`);
316
+ continue;
317
+ }
318
+ await assertOpenedEvidencePath(root, handle.fd);
313
319
  const buffer = Buffer.alloc(MAX_UNTRACKED_FILE_BYTES + 1);
314
320
  const read = await handle.read(buffer, 0, buffer.length, 0);
315
321
  const bytes = buffer.subarray(0, read.bytesRead);
@@ -338,6 +344,17 @@ async function collectUntrackedEvidence(cwd: string, signal?: AbortSignal): Prom
338
344
  return { ...bounded, complete: complete && bounded.complete, truncated: truncated || bounded.truncated };
339
345
  }
340
346
 
347
+ async function assertOpenedEvidencePath(root: string, fd: number): Promise<void> {
348
+ if (process.platform !== "linux") throw new Error("opened evidence identity cannot be verified on this platform");
349
+ const target = (await readlink(`/proc/self/fd/${fd}`)).replace(/ \(deleted\)$/u, "");
350
+ const normalizedTarget = target.replaceAll("\\", "/");
351
+ if (normalizedTarget.split("/").some((segment) => segment.toLowerCase() === ".git")) throw new Error("opened evidence path resolves into Git metadata");
352
+ const relativeTarget = relative(root, target);
353
+ if (isAbsolute(relativeTarget) || relativeTarget === ".." || relativeTarget.startsWith(`..${sep}`) || relativeTarget.startsWith(`..${"\\"}`)) {
354
+ throw new Error("opened evidence path escaped the repository root");
355
+ }
356
+ }
357
+
341
358
  async function assertNoSymlinkComponents(root: string, relativePath: string): Promise<void> {
342
359
  const parts = relativePath.split(sep).filter(Boolean);
343
360
  let current = root;
@@ -71,6 +71,24 @@ export function automaticWorkerEnvironment(explicit: NodeJS.ProcessEnv = {}): No
71
71
  * operator-owned executable path so a custom path or writable replacement
72
72
  * cannot silently omit this boundary.
73
73
  */
74
+ const automaticToolAllowlist = new Set([
75
+ "Bash",
76
+ "Edit",
77
+ "Glob",
78
+ "Grep",
79
+ "Read",
80
+ "Write",
81
+ "NotebookEdit",
82
+ "AskUserQuestion",
83
+ ]);
84
+
85
+ /**
86
+ * The automatic Worker is the only Claude session allowed to drive the local
87
+ * lifecycle. Keep its tool surface explicit: nested agents/background tasks
88
+ * can start a second reviewer, outlive the Worker, or hide work from the
89
+ * Supervisor's event stream. The safe-mode and strict-MCP flags also prevent
90
+ * project/user customizations from reintroducing that capability.
91
+ */
74
92
  export function automaticClaudeArgs(command: string, args: readonly string[] = []): string[] {
75
93
  if (!isDirectClaudeName(command)) {
76
94
  throw new Error("automatic supervision requires the direct Claude executable command name; custom executable paths need their own host boundary");
@@ -78,8 +96,22 @@ export function automaticClaudeArgs(command: string, args: readonly string[] = [
78
96
  if (args.some((arg) => arg === "--settings" || arg.startsWith("--settings="))) {
79
97
  throw new Error("automatic Claude supervision controls --settings; remove the caller-provided settings override");
80
98
  }
81
- return [
82
- ...args,
99
+ for (const option of ["--allowedTools", "--allowed-tools", "--agent", "--agents", "--plugin-dir", "--plugin-url", "--resume", "-r", "--continue", "-c", "--bg", "--background", "--remote-control", "--tmux", "--worktree", "-w"]) {
100
+ if (args.some((arg) => arg === option || arg.startsWith(`${option}=`))) {
101
+ throw new Error(`automatic Claude supervision controls ${option}; remove the caller-provided session or tool-extension override`);
102
+ }
103
+ }
104
+ const result = [...args];
105
+ const tools = requestedAutomaticTools(result);
106
+ if (tools === undefined) result.push("--tools", [...automaticToolAllowlist].join(","));
107
+ else if (tools.some((tool) => !automaticToolAllowlist.has(tool))) {
108
+ throw new Error("automatic Claude supervision permits only the bounded local tool allowlist; nested agents and background reviewers are disabled");
109
+ }
110
+ result.push(
111
+ "--safe-mode",
112
+ "--strict-mcp-config",
113
+ "--disallowed-tools",
114
+ "Task,TaskOutput,Agent,Skill,SendUserMessage",
83
115
  "--settings",
84
116
  JSON.stringify({
85
117
  sandbox: {
@@ -89,7 +121,23 @@ export function automaticClaudeArgs(command: string, args: readonly string[] = [
89
121
  network: { allowedDomains: [] },
90
122
  },
91
123
  }),
92
- ];
124
+ );
125
+ return result;
126
+ }
127
+
128
+ function requestedAutomaticTools(args: string[]): string[] | undefined {
129
+ const index = args.findIndex((arg) => arg === "--tools" || arg.startsWith("--tools="));
130
+ if (index < 0) return undefined;
131
+ const values = args[index].startsWith("--tools=")
132
+ ? [args[index].slice("--tools=".length)]
133
+ : (() => {
134
+ const collected: string[] = [];
135
+ for (let cursor = index + 1; cursor < args.length && !args[cursor]!.startsWith("-"); cursor += 1) collected.push(args[cursor]!);
136
+ return collected;
137
+ })();
138
+ const tools = values.flatMap((value) => value.split(/[\s,]+/u)).map((value) => value.trim()).filter(Boolean);
139
+ if (tools.includes("default")) throw new Error("automatic Claude supervision requires an explicit tool allowlist; --tools default is not permitted");
140
+ return tools.map((tool) => tool.replace(/\(.*/u, ""));
93
141
  }
94
142
 
95
143
  /**