pi-claude-supervisor 0.5.2 → 0.5.3

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/index.ts CHANGED
@@ -7,11 +7,12 @@ import { readFile, realpath } from "node:fs/promises";
7
7
  import { EventLog } from "./events.ts";
8
8
  import { redactSensitive } from "./redaction.ts";
9
9
  import { ProcessWorkerAdapter } from "./worker/process-adapter.ts";
10
+ import { automaticWorkerEnvironment } from "./worker/environment.ts";
10
11
  import { TmuxWorkerAdapter, attachCommand } from "./worker/tmux-adapter.ts";
11
12
  import { Supervisor, type DecisionSessionClosedInfo, type SupervisorProgress } from "./supervisor.ts";
12
13
  import { evaluateCommand } from "./policy.ts";
13
14
  import { HumanWebhookNotifier } from "./notifications.ts";
14
- import { loadSupervisorEnvironment } from "./config.ts";
15
+ import { autonomyDefaults, loadSupervisorEnvironment } from "./config.ts";
15
16
  import { DecisionSessionStore, type DecisionSessionRecord } from "./decision-session-store.ts";
16
17
  import { CwdLeaseStore, type CwdLeaseHandle, pathsOverlap, workerIdentity } from "./cwd-lease.ts";
17
18
  import { normalizeTaskSpec } from "./acceptance.ts";
@@ -41,8 +42,8 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
41
42
  if (transport === "tmux" && cgroupMode === "required") {
42
43
  throw new Error("PI_CLAUDE_SUPERVISOR_CGROUP_MODE=required is unsupported with tmux; use process-pipe/jsonl or set cgroup mode to auto/off");
43
44
  }
44
- if (automation && transport === "process-pipe") {
45
- throw new Error("automatic supervision requires PI_CLAUDE_SUPERVISOR_TRANSPORT=jsonl or tmux; explicit process-pipe is manual-only");
45
+ if (automation && transport !== "jsonl") {
46
+ throw new Error("automatic supervision requires PI_CLAUDE_SUPERVISOR_TRANSPORT=jsonl; process-pipe and tmux are manual-only");
46
47
  }
47
48
  const adapter = transport === "tmux"
48
49
  ? new TmuxWorkerAdapter({ stateDir })
@@ -115,7 +116,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
115
116
  };
116
117
  const releaseSettledReservations = async (): Promise<void> => {
117
118
  for (const [taskId, session] of sessions) {
118
- const terminal = ["completed", "stopped", "failed"].includes(session.state);
119
+ const terminal = ["completed", "blocked", "stopped", "failed"].includes(session.state);
119
120
  const detached = session.released;
120
121
  if (!terminal && !detached) continue;
121
122
  if (!session.handle) {
@@ -165,7 +166,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
165
166
  await session.release(reason);
166
167
  return;
167
168
  }
168
- if (!handle && ["failed", "completed", "stopped"].includes(session.state)) return;
169
+ if (!handle && ["failed", "completed", "blocked", "stopped"].includes(session.state)) return;
169
170
  let lifecycleError: unknown;
170
171
  try {
171
172
  await session.stop(reason, { preserveDecisionSession });
@@ -228,24 +229,17 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
228
229
  }
229
230
  const tmuxSession = operation === "adopt-tmux" ? rest.shift() : undefined;
230
231
  const task = rest.join(" ").trim();
231
- const spec = specPath ? await readTaskSpecFile(specPath, ctx.cwd) : undefined;
232
- const goal = spec?.goal ?? task;
232
+ const fileSpec = specPath ? await readTaskSpecFile(specPath, ctx.cwd) : undefined;
233
+ const spec = fileSpec ?? { autonomy: autonomyDefaults() };
234
+ const goal = fileSpec?.goal ?? task;
235
+ const taskAutomation = automation && spec.autonomy.unattended;
233
236
  if (!goal) throw new Error(operation === "adopt-tmux" ? "Usage: /supervise adopt-tmux [--spec <file>] <tmux-session> <task>" : "Usage: /supervise start [--spec <file>] <task>");
234
237
  if (operation === "adopt-tmux" && adapter.capabilities().transport !== "tmux") throw new Error("/supervise adopt-tmux requires PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux");
235
238
  const [command, ...workerArgs] = parseCommand(process.env.PI_CLAUDE_SUPERVISOR_WORKER ?? "claude");
236
239
  if (!command) throw new Error("PI_CLAUDE_SUPERVISOR_WORKER must contain an executable");
237
240
  const policy = evaluateCommand(command, workerArgs);
238
- let approval: { actor: "human"; reason: string } | undefined;
241
+ const approval: { actor: "human"; reason: string } | undefined = undefined;
239
242
  if (policy.decision === "deny") throw new Error(`Worker command denied: ${policy.reason}`);
240
- if (policy.decision === "review") {
241
- if (!ctx.hasUI) throw new Error(`Worker command requires interactive approval: ${policy.reason}`);
242
- const approved = await ctx.ui.confirm(
243
- "Approve Claude worker command?",
244
- `${redactText([command, ...workerArgs].join(" "))}\n\nReason: ${redactText(policy.reason)}`,
245
- );
246
- if (!approved) throw new Error("Worker command not approved");
247
- approval = { actor: "human", reason: policy.reason };
248
- }
249
243
  if (shuttingDown) throw new Error("Pi session is shutting down");
250
244
  const cwdKey = await canonicalCwd(ctx.cwd);
251
245
  await releaseSettledReservations();
@@ -266,16 +260,11 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
266
260
  pendingCwds.add(cwdKey);
267
261
  const session = new Supervisor(adapter, events, {
268
262
  reviewer,
269
- onHumanRequired: async (notice) => {
270
- if (ctx.hasUI) notify(ctx, `Claude Worker needs human intervention: ${notice.reason}`, "warning");
263
+ onCandidate: async (notice) => {
264
+ if (ctx.hasUI) notify(ctx, `Candidate ${notice.status}: ${notice.reason}`, notice.status === "ready" ? "info" : "warning");
271
265
  if (humanWebhook.enabled) {
272
- try {
273
- await humanWebhook.notify(notice);
274
- } catch (error) {
275
- console.error(`pi-claude-supervisor human webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`);
276
- }
277
- } else {
278
- console.error(`pi-claude-supervisor human intervention required: ${redactText(notice.reason)}`);
266
+ try { await humanWebhook.notifyCandidate(notice); }
267
+ catch (error) { console.error(`pi-claude-supervisor candidate webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`); }
279
268
  }
280
269
  },
281
270
  });
@@ -291,9 +280,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
291
280
  cwd: cwdKey,
292
281
  command,
293
282
  args: workerArgs,
294
- env: selectedWorkerEnvironment(),
283
+ env: selectedWorkerEnvironment(taskAutomation),
295
284
  approval,
296
- automation,
285
+ automation: taskAutomation,
297
286
  tmuxSession,
298
287
  tmuxSocket: process.env.PI_CLAUDE_SUPERVISOR_TMUX_SOCKET,
299
288
  tmuxExpectedIdentity: tmuxSession && lease.record.worker ? {
@@ -321,6 +310,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
321
310
  deadlineMs: info.deadlineMs,
322
311
  noOutputTimeoutMs: info.noOutputTimeoutMs,
323
312
  startedAt: info.startedAt,
313
+ ...(info.baseCommit ? { baseCommit: info.baseCommit } : {}),
314
+ ...(info.baseBranch ? { baseBranch: info.baseBranch } : {}),
315
+ ...(info.resolvedExecutable ? { resolvedExecutable: info.resolvedExecutable } : {}),
324
316
  turn: info.turn,
325
317
  repairRound: info.repairRound,
326
318
  ...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}),
@@ -455,16 +447,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
455
447
  }
456
448
  const policy = evaluateCommand(record.command, record.args);
457
449
  if (policy.decision === "deny") throw new Error(`Worker command denied: ${policy.reason}`);
458
- let approval = record.approval;
459
- if (policy.decision === "review" && !approval) {
460
- if (!ctx.hasUI) throw new Error(`Worker command requires interactive approval: ${policy.reason}`);
461
- const approved = await ctx.ui.confirm(
462
- "Approve recovered Claude worker command?",
463
- `${redactText([record.command, ...record.args].join(" "))}\n\nReason: ${redactText(policy.reason)}`,
464
- );
465
- if (!approved) throw new Error("Worker command not approved");
466
- approval = { actor: "human", reason: policy.reason };
467
- }
450
+ const automaticRecovery = record.spec?.autonomy.unattended !== false;
451
+ if (automaticRecovery && !record.resolvedExecutable) throw new Error("automatic recovery requires a persisted resolved Claude executable identity");
452
+ const approval = record.approval;
468
453
  const lease = await cwdLeaseStore.acquire(cwdKey, record.taskId, adapter.capabilities().transport, takeover
469
454
  ? {
470
455
  takeover: {
@@ -491,13 +476,11 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
491
476
  pendingCwds.add(cwdKey);
492
477
  const session = new Supervisor(adapter, events, {
493
478
  reviewer,
494
- onHumanRequired: async (notice) => {
495
- if (ctx.hasUI) notify(ctx, `Claude Worker needs human intervention: ${notice.reason}`, "warning");
479
+ onCandidate: async (notice) => {
480
+ if (ctx.hasUI) notify(ctx, `Candidate ${notice.status}: ${notice.reason}`, notice.status === "ready" ? "info" : "warning");
496
481
  if (humanWebhook.enabled) {
497
- try { await humanWebhook.notify(notice); }
498
- catch (error) { console.error(`pi-claude-supervisor human webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`); }
499
- } else {
500
- console.error(`pi-claude-supervisor human intervention required: ${redactText(notice.reason)}`);
482
+ try { await humanWebhook.notifyCandidate(notice); }
483
+ catch (error) { console.error(`pi-claude-supervisor candidate webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`); }
501
484
  }
502
485
  },
503
486
  });
@@ -518,13 +501,16 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
518
501
  cwd: cwdKey,
519
502
  command: record.command,
520
503
  args: record.args,
521
- env: selectedWorkerEnvironment(),
504
+ env: selectedWorkerEnvironment(automaticRecovery),
522
505
  approval,
523
- automation: true,
506
+ automation: automaticRecovery,
507
+ ...(automaticRecovery && record.resolvedExecutable ? { expectedClaudeExecutable: record.resolvedExecutable } : {}),
524
508
  maxTurns: record.maxTurns,
525
509
  deadlineMs: record.deadlineMs,
526
510
  noOutputTimeoutMs: record.noOutputTimeoutMs,
527
511
  startedAt: record.startedAt,
512
+ baseCommit: record.baseCommit,
513
+ baseBranch: record.baseBranch,
528
514
  initialTurn: record.turn,
529
515
  initialRepairRound: record.repairRound,
530
516
  initialFindingSignature: record.lastFindingSignature,
@@ -546,6 +532,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
546
532
  deadlineMs: info.deadlineMs,
547
533
  noOutputTimeoutMs: info.noOutputTimeoutMs,
548
534
  startedAt: info.startedAt,
535
+ ...(info.baseCommit ? { baseCommit: info.baseCommit } : {}),
536
+ ...(info.baseBranch ? { baseBranch: info.baseBranch } : {}),
537
+ ...(info.resolvedExecutable ? { resolvedExecutable: info.resolvedExecutable } : {}),
549
538
  turn: info.turn,
550
539
  repairRound: info.repairRound,
551
540
  ...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}),
@@ -775,7 +764,7 @@ function formatSessions(sessions: Map<string, Supervisor>, recoverable: Decision
775
764
  return [...active, ...pending].join("\n") || "No task sessions.";
776
765
  }
777
766
 
778
- function selectedWorkerEnvironment(): NodeJS.ProcessEnv {
767
+ function selectedWorkerEnvironment(automatic = false): NodeJS.ProcessEnv {
779
768
  const result: NodeJS.ProcessEnv = {};
780
769
  const names = (process.env.PI_CLAUDE_SUPERVISOR_WORKER_ENV ?? "")
781
770
  .split(",")
@@ -784,7 +773,7 @@ function selectedWorkerEnvironment(): NodeJS.ProcessEnv {
784
773
  for (const name of names) {
785
774
  if (process.env[name] !== undefined) result[name] = process.env[name];
786
775
  }
787
- return result;
776
+ return automatic ? automaticWorkerEnvironment(result) : result;
788
777
  }
789
778
 
790
779
  async function readTaskSpecFile(path: string, cwd: string): Promise<TaskSpec> {
@@ -1,5 +1,5 @@
1
1
  import { createHmac, randomUUID } from "node:crypto";
2
- import type { HumanInterventionNotice } from "./supervisor.ts";
2
+ import type { CandidateNotice, HumanInterventionNotice } from "./supervisor.ts";
3
3
  import { redactSensitive } from "./redaction.ts";
4
4
 
5
5
  export interface HumanWebhookOptions {
@@ -9,7 +9,7 @@ export interface HumanWebhookOptions {
9
9
  timeoutMs?: number;
10
10
  }
11
11
 
12
- /** Outbound-only human escalation. Approval still happens through Pi/manual control. */
12
+ /** Optional outbound candidate delivery. It never grants permission or controls the Worker. */
13
13
  export class HumanWebhookNotifier {
14
14
  readonly #url?: string;
15
15
  readonly #format: "generic" | "wecom";
@@ -28,42 +28,60 @@ export class HumanWebhookNotifier {
28
28
  async notify(notice: HumanInterventionNotice): Promise<void> {
29
29
  if (!this.#url) return;
30
30
  const body = this.#format === "wecom" ? JSON.stringify(toWeCom(notice)) : JSON.stringify(toGeneric(notice));
31
+ await this.#send(body);
32
+ }
33
+
34
+ async notifyCandidate(notice: CandidateNotice): Promise<void> {
35
+ if (!this.#url) return;
36
+ const body = this.#format === "wecom" ? JSON.stringify(toWeCom(notice)) : JSON.stringify(toGeneric(notice));
37
+ await this.#send(body);
38
+ }
39
+
40
+ async #send(body: string): Promise<void> {
31
41
  const headers: Record<string, string> = { "content-type": "application/json", "user-agent": "pi-claude-supervisor/0.1" };
32
42
  if (this.#secret) headers["x-pi-supervisor-signature"] = `sha256=${createHmac("sha256", this.#secret).update(body).digest("hex")}`;
33
43
  const controller = new AbortController();
34
44
  const timer = setTimeout(() => controller.abort(), this.#timeoutMs);
35
45
  try {
36
- const response = await fetch(this.#url, { method: "POST", headers, body, signal: controller.signal });
37
- if (!response.ok) throw new Error(`human webhook returned HTTP ${response.status}`);
46
+ const response = await fetch(this.#url!, { method: "POST", headers, body, signal: controller.signal });
47
+ if (!response.ok) throw new Error(`candidate webhook returned HTTP ${response.status}`);
38
48
  } finally {
39
49
  clearTimeout(timer);
40
50
  }
41
51
  }
42
52
  }
43
53
 
44
- function toGeneric(notice: HumanInterventionNotice): Record<string, unknown> {
54
+ function toGeneric(notice: HumanInterventionNotice | CandidateNotice): Record<string, unknown> {
55
+ const candidate = "status" in notice;
45
56
  return {
46
- schema: "pi-claude-supervisor/human-intervention/v1",
57
+ schema: candidate ? "pi-claude-supervisor/candidate/v1" : "pi-claude-supervisor/human-intervention/v1",
47
58
  eventId: randomUUID(),
48
- event: "human_intervention_required",
59
+ event: candidate ? "candidate_status" : "human_intervention_required",
49
60
  occurredAt: new Date().toISOString(),
50
61
  task: { id: sanitize(notice.taskId), goal: sanitize(notice.task), cwd: sanitize(notice.cwd) },
51
62
  worker: { id: sanitize(notice.workerId) },
52
63
  reason: sanitize(notice.reason),
53
64
  question: sanitize(notice.question),
54
65
  permission: notice.permission ? sanitize(notice.permission) : undefined,
55
- actions: ["approve_or_deny_permission", "send_instruction", "stop_worker", "takeover"],
56
- note: "This is an outbound notification. Use the Pi session or a separately authenticated callback service to approve actions.",
66
+ ...(candidate ? { status: notice.status, deliverable: notice.deliverable } : { actions: ["approve_or_deny_permission", "send_instruction", "stop_worker", "takeover"] }),
67
+ note: candidate
68
+ ? "This is an optional candidate notification. It does not grant remote push or main/integration merge permission."
69
+ : "This is an outbound notification. Use the Pi session or a separately authenticated callback service to approve actions.",
57
70
  };
58
71
  }
59
72
 
60
- function toWeCom(notice: HumanInterventionNotice): Record<string, unknown> {
73
+ function toWeCom(notice: HumanInterventionNotice | CandidateNotice): Record<string, unknown> {
74
+ const candidate = "status" in notice;
61
75
  const permission = notice.permission ? `\n工具: ${safeText(notice.permission.toolName)}\n请求 ID: ${safeText(notice.permission.requestId)}` : "";
62
76
  const question = notice.question ? `\n问题: ${safeText(notice.question)}` : "";
77
+ const title = candidate ? "Claude Supervisor 候选状态" : "Claude Supervisor 需要人工介入";
78
+ const suffix = candidate
79
+ ? `\n> 状态: ${safeText(notice.status)}\n> 可交付: ${notice.deliverable ? "yes" : "no"}\n\n该通知不授予远程 push 或 main/integration merge 权限。`
80
+ : "\n\n请在 Pi 中执行对应的 approve/deny、send、stop 或 takeover 操作。";
63
81
  return {
64
82
  msgtype: "markdown",
65
83
  markdown: {
66
- content: `### Claude Supervisor 需要人工介入\n> 任务: ${safeText(notice.task)}\n> Task ID: ${safeText(notice.taskId)}\n> 原因: ${safeText(notice.reason)}${escapeMarkdown(question)}${escapeMarkdown(permission)}\n\n请在 Pi 中执行对应的 approve/deny、send、stop 或 takeover 操作。`,
84
+ content: `### ${title}\n> 任务: ${safeText(notice.task)}\n> Task ID: ${safeText(notice.taskId)}\n> 原因: ${safeText(notice.reason)}${escapeMarkdown(question)}${escapeMarkdown(permission)}${suffix}`,
67
85
  },
68
86
  };
69
87
  }
package/src/policy.ts CHANGED
@@ -5,18 +5,28 @@ export interface PolicyResult {
5
5
  reason: string;
6
6
  }
7
7
 
8
+ /**
9
+ * Permission decisions are autonomous for local development. A denial is a
10
+ * hard capability boundary, not a request for a human to approve the next
11
+ * turn. AskUserQuestion is denied so the Worker can restate the question as
12
+ * ordinary text and the Decision Worker can answer it from task evidence.
13
+ */
8
14
  export function evaluatePermission(toolName: string, input: unknown): PolicyResult {
9
- if (toolName === "AskUserQuestion") return { decision: "review", reason: "Claude requested an interactive product decision" };
10
- if (toolName !== "Bash") return { decision: "review", reason: `unknown Claude tool requires review: ${toolName}` };
15
+ if (toolName === "AskUserQuestion") return { decision: "deny", reason: "interactive questions are converted to ordinary Worker text" };
16
+ if (toolName !== "Bash") return { decision: "allow", reason: `local Claude tool is allowed by the task policy: ${toolName}` };
11
17
  const command = input && typeof input === "object" && typeof (input as { command?: unknown }).command === "string"
12
18
  ? (input as { command: string }).command
13
19
  : "";
14
- if (!command) return { decision: "review", reason: "Bash request has no recognizable command" };
20
+ if (!command) return { decision: "deny", reason: "Bash request has no recognizable command" };
15
21
  return evaluateCommand("bash", ["-lc", command]);
16
22
  }
17
23
 
18
24
  const deniedPatterns = [
19
- /--dangerously-skip-permissions\b/iu,
25
+ /\b(?:npm|pnpm|yarn)\b[\s\S]*\bpublish\b/iu,
26
+ /\b(?:ssh|scp|sftp|rsync)\b/iu,
27
+ /\b(?:curl|wget)\b[\s\S]*(?:-X\s*(?:POST|PUT|PATCH|DELETE)|--method(?:=|\s+)(?:POST|PUT|PATCH|DELETE)|https?:\/\/(?:api\.)?(?:github|gitlab|bitbucket|registry\.npmjs)\.)/iu,
28
+ /(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))[\s\S]*\b(?:push|merge|publish)\b|\b(?:push|merge|publish)\b[\s\S]*(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))/iu,
29
+ /--(?:allow-)?dangerously-skip-permissions\b/iu,
20
30
  /--permission-mode\s+(?:bypasspermissions|dontask)\b/iu,
21
31
  /\brm\s+-rf\s+\//iu,
22
32
  /\bmkfs(?:\.|\s)/iu,
@@ -25,37 +35,202 @@ const deniedPatterns = [
25
35
  /\b(shutdown|reboot|poweroff)\b/iu,
26
36
  ];
27
37
 
28
- const reviewPatterns = [
29
- /\bgit\s+push\b/iu,
30
- /\bgit\s+reset\s+--hard\b/iu,
31
- /\bgit\s+(merge|rebase)\b/iu,
32
- /\b(npm|pnpm|yarn)\s+publish\b/iu,
33
- /\b(curl|wget)\b.*\|\s*(?:\/[\w./-]+\/)?(?:sh|bash|zsh)\b/iu,
34
- /\b(chmod|chown)\b/iu,
35
- ];
38
+ interface ShellToken {
39
+ value: string;
40
+ operator: boolean;
41
+ dynamic: boolean;
42
+ }
43
+
44
+ const protectedBranches = new Set(["main", "master", "trunk", "integration", "develop"]);
45
+ const protectedBranchOperations = new Set(["checkout", "switch", "branch", "reset", "restore", "worktree", "update-ref", "symbolic-ref"]);
46
+ const remoteOperations = new Set(["push", "merge", "send-pack", "receive-pack", "update-ref"]);
36
47
 
37
48
  export function evaluateCommand(command: string, args: readonly string[] = []): PolicyResult {
38
- const normalized = [command, ...args].join(" ").trim();
49
+ const normalized = command.trim();
39
50
  if (!normalized) return { decision: "deny", reason: "empty command" };
40
- if (deniedPatterns.some((pattern) => pattern.test(normalized))) {
51
+ const lexical = lexShell(normalized);
52
+ if (lexical.error) return { decision: "deny", reason: `command could not be safely parsed: ${lexical.error}` };
53
+ const literalArgs = args.map((value) => ({ value, operator: false, dynamic: false }));
54
+ return evaluateTokens([...lexical.tokens, ...literalArgs], 0);
55
+ }
56
+
57
+ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: string, depth: number): PolicyResult | undefined {
58
+ const values = tokens.filter((token) => !token.operator).map((token) => token.value);
59
+ const lower = values.map((value) => value.toLowerCase());
60
+ const hasGit = values.some((value) => /(?:^|[\\/])git$/iu.test(value) || /^(?:git-(?:send|receive|upload)-pack)$/iu.test(value));
61
+ const hasGitTransport = lower.some((value) => /^(?:git-(?:send|receive|upload)-pack)$/u.test(value));
62
+ const hasRemoteOperation = lower.some((value) => remoteOperations.has(value));
63
+ const hasGhRemote = lower.some((value) => value === "gh" || value === "glab" || value === "hub")
64
+ && lower.some((value) => value === "api" || value === "release" || value === "pull-request" || value === "pr" || value === "mr");
65
+ const hasPackagePublication = lower.some((value) => value === "npm" || value === "pnpm" || value === "yarn") && lower.includes("publish");
66
+ if (depth < 4) {
67
+ for (const nested of nestedShellCommands(lower, values)) {
68
+ const nestedResult = evaluateCommandInternal(nested, depth + 1);
69
+ if (nestedResult.decision === "deny") return nestedResult;
70
+ }
71
+ }
72
+ const hasDynamicArgument = tokens.some((token) => !token.operator && token.dynamic);
73
+ const hasProtectedBranch = lower.some((value) => protectedBranches.has(value))
74
+ || /(?:^|\s)(?:[^\s]*\.git[\\/]refs[\\/]heads[\\/]|[^\s]*refs[\\/]heads[\\/])(?:main|master|trunk|integration|develop)(?:$|\s)/iu.test(canonical);
75
+ const gitOperation = lower.find((value) => protectedBranchOperations.has(value));
76
+
77
+ if (hasDynamicArgument) {
78
+ return { decision: "deny", reason: "dynamic shell arguments cannot be capability-checked safely" };
79
+ }
80
+ if (/\bgit\b[\s\S]*\b(?:push|merge|send-pack|receive-pack|update-ref)\b/iu.test(canonical)
81
+ || /\bgit-(?:send|receive|upload)-pack\b/iu.test(canonical)
82
+ || /\b(?:gh|glab|hub)\b[\s\S]*\b(?:api|pr|mr|pull-request|release)\b/iu.test(canonical)) {
83
+ return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
84
+ }
85
+ if (hasGit && (hasRemoteOperation || hasGitTransport) || hasGhRemote) {
86
+ return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
87
+ }
88
+ if (hasGit && gitOperation && hasProtectedBranch) {
89
+ return { decision: "deny", reason: "Worker cannot switch to or mutate a protected integration branch" };
90
+ }
91
+ const directRefWrite = tokens.some((token) => token.operator && (token.value === ">" || token.value === ">>"))
92
+ || ["cp", "echo", "install", "mv", "printf", "sed", "tee"].includes(lower[0] ?? "");
93
+ if (/\.git[\\/](?:HEAD|packed-refs|refs[\\/]heads[\\/])/iu.test(canonical)
94
+ || (directRefWrite && hasProtectedBranch && /refs[\\/]heads[\\/]/iu.test(canonical))) {
95
+ return { decision: "deny", reason: "Worker cannot write protected Git branch refs directly" };
96
+ }
97
+ if (hasPackagePublication) {
98
+ return { decision: "deny", reason: "package publication belongs to the protected release workflow" };
99
+ }
100
+ const unsafePermissionMode = (value: string): boolean => value.replace(/[-_]/gu, "") === "bypasspermissions" || value.replace(/[-_]/gu, "") === "dontask";
101
+ if (lower.some((value) => value.startsWith("--permission-mode=") && unsafePermissionMode(value.slice("--permission-mode=".length)))
102
+ || (lower.includes("--permission-mode") && lower.some((value) => unsafePermissionMode(value)))) {
103
+ return { decision: "deny", reason: "Worker cannot bypass Claude permission prompts" };
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ function nestedShellCommands(lower: readonly string[], values: readonly string[]): string[] {
109
+ const nested: string[] = [];
110
+ const shellNames = new Set(["sh", "bash", "dash", "zsh", "fish", "ksh"]);
111
+ for (let index = 0; index < lower.length; index += 1) {
112
+ const executable = lower[index]!.split(/[\\/]/u).at(-1);
113
+ if (executable && shellNames.has(executable)) {
114
+ for (let option = index + 1; option < lower.length; option += 1) {
115
+ if (["-c", "--command"].includes(lower[option]!)) {
116
+ const command = values.slice(option + 1).join(" ").trim();
117
+ if (command) nested.push(command);
118
+ break;
119
+ }
120
+ }
121
+ }
122
+ if (lower[index] === "eval") {
123
+ const command = values.slice(index + 1).join(" ").trim();
124
+ if (command) nested.push(command);
125
+ }
126
+ }
127
+ return nested;
128
+ }
129
+
130
+ function evaluateCommandInternal(command: string, depth: number): PolicyResult {
131
+ const normalized = command.trim();
132
+ if (!normalized) return { decision: "deny", reason: "empty command" };
133
+ const lexical = lexShell(normalized);
134
+ if (lexical.error) return { decision: "deny", reason: `command could not be safely parsed: ${lexical.error}` };
135
+ return evaluateTokens(lexical.tokens, depth);
136
+ }
137
+
138
+ function evaluateTokens(tokens: readonly ShellToken[], depth: number): PolicyResult {
139
+ const canonical = tokens.map((token) => token.value).join(" ").trim();
140
+ if (!canonical) return { decision: "deny", reason: "empty command" };
141
+ const boundary = evaluateRepositoryBoundary(tokens, canonical, depth);
142
+ if (boundary) return boundary;
143
+ if (deniedPatterns.some((pattern) => pattern.test(canonical))) {
144
+ if (/\b(?:curl|wget)\b[\s\S]*(?:github|gitlab|bitbucket|registry\.npmjs)\b/iu.test(canonical)) {
145
+ return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
146
+ }
147
+ if (/\bgit\b[\s\S]*\b(?:checkout|switch|branch|reset|restore|worktree|update-ref|symbolic-ref)\b[\s\S]*\b(?:main|master|trunk|integration|develop)\b/iu.test(canonical)) {
148
+ return { decision: "deny", reason: "Worker cannot switch to or mutate a protected integration branch" };
149
+ }
150
+ if (/\b(?:npm|pnpm|yarn)\b[\s\S]*\bpublish\b/iu.test(canonical)) {
151
+ return { decision: "deny", reason: "package publication belongs to the protected release workflow" };
152
+ }
41
153
  return { decision: "deny", reason: "command matches a prohibited destructive pattern" };
42
154
  }
43
- if (reviewPatterns.some((pattern) => pattern.test(normalized))) {
44
- return { decision: "review", reason: "command requires explicit human approval" };
155
+ return { decision: "allow", reason: "command is allowed for unattended local development" };
156
+ }
157
+
158
+ function lexShell(input: string): { tokens: ShellToken[]; error?: string } {
159
+ const tokens: ShellToken[] = [];
160
+ let value = "";
161
+ let dynamic = false;
162
+ let started = false;
163
+ let quote: "single" | "double" | undefined;
164
+ const flush = (): void => {
165
+ if (!started) return;
166
+ tokens.push({ value, operator: false, dynamic });
167
+ value = "";
168
+ dynamic = false;
169
+ started = false;
170
+ };
171
+ const pushOperator = (operator: string): void => {
172
+ flush();
173
+ tokens.push({ value: operator, operator: true, dynamic: false });
174
+ };
175
+ for (let index = 0; index < input.length; index += 1) {
176
+ const character = input[index]!;
177
+ const next = input[index + 1];
178
+ if (quote === "single") {
179
+ if (character === "'") quote = undefined;
180
+ else value += character;
181
+ started = true;
182
+ continue;
183
+ }
184
+ if (quote === "double") {
185
+ if (character === '"') quote = undefined;
186
+ else if (character === "\\" && next === "\n") index += 1;
187
+ else if (character === "\\" && next !== undefined && /[\\"$`]/u.test(next)) { value += next; index += 1; }
188
+ else { value += character; if (character === "$" || character === "`") dynamic = true; }
189
+ started = true;
190
+ continue;
191
+ }
192
+ if (character === "'") { quote = "single"; started = true; continue; }
193
+ if (character === '"') { quote = "double"; started = true; continue; }
194
+ if (character === "\\") {
195
+ if (next === "\n") index += 1;
196
+ else if (next !== undefined) { value += next; index += 1; }
197
+ started = true;
198
+ continue;
199
+ }
200
+ if (/\s/u.test(character)) { flush(); continue; }
201
+ if (character === "$" || character === "`") { dynamic = true; value += character; started = true; continue; }
202
+ // Brace, tilde and pathname expansion can change command names, targets or
203
+ // Git refs after this lexical pass. Treat all unquoted expansion markers as
204
+ // dynamic rather than attempting to model Bash's expansion order.
205
+ if ("*?[]{}~".includes(character)) { dynamic = true; value += character; started = true; continue; }
206
+ if (";&|<>".includes(character)) {
207
+ const operator = next && ((character === "&" && next === "&") || (character === "|" && next === "|") || (character === ">" && next === ">") || (character === "<" && next === "<"))
208
+ ? `${character}${next}`
209
+ : character;
210
+ pushOperator(operator);
211
+ if (operator.length === 2) index += 1;
212
+ continue;
213
+ }
214
+ value += character;
215
+ started = true;
45
216
  }
46
- return { decision: "allow", reason: "command is outside the default high-risk set" };
217
+ if (quote) return { tokens, error: "unterminated quote" };
218
+ flush();
219
+ return { tokens };
47
220
  }
48
221
 
222
+ /**
223
+ * Keep the approval argument for persisted v0.5.x records and embedding API
224
+ * compatibility. It is intentionally not required for local review patterns;
225
+ * only hard-denied capability boundaries fail this assertion.
226
+ */
49
227
  export function assertSafeWorkerCommand(
50
228
  command: string,
51
229
  args: readonly string[] = [],
52
- approval?: { actor: "human"; reason: string },
230
+ _approval?: { actor: "human"; reason: string },
53
231
  ): void {
54
232
  const result = evaluateCommand(command, args);
55
233
  if (result.decision === "deny") {
56
234
  throw new Error(`Worker command blocked by policy (deny): ${result.reason}`);
57
235
  }
58
- if (result.decision === "review" && (!approval || approval.actor !== "human" || !approval.reason.trim())) {
59
- throw new Error(`Worker command blocked by policy (review): ${result.reason}`);
60
- }
61
236
  }
package/src/reviewer.ts CHANGED
@@ -130,7 +130,16 @@ ${boundedJson(input.acceptance)}
130
130
  REPOSITORY STATUS:
131
131
  ${boundText(redactText(input.evidence.status), 8_000)}
132
132
 
133
- REPOSITORY DIFF (HEAD-RELATIVE, UNTRUSTED):
133
+ REPOSITORY BASE:
134
+ ${redactText(input.evidence.baseRef ?? "(unavailable; review current evidence)")}
135
+
136
+ LOCAL BRANCH:
137
+ ${redactText(input.evidence.branch ?? "(detached or unavailable)")}
138
+
139
+ COMMITS AFTER BASELINE (UNTRUSTED):
140
+ ${boundText(redactText(input.evidence.commits ?? "(none)"), 8_000)}
141
+
142
+ REPOSITORY DIFF (BASELINE-RELATIVE, UNTRUSTED):
134
143
  ${boundText(redactText(input.evidence.diff), 16_000)}
135
144
 
136
145
  UNTRACKED FILE EVIDENCE (UNTRUSTED):
@@ -159,6 +168,22 @@ function textFromMessage(content: unknown): string {
159
168
  .join("");
160
169
  }
161
170
 
171
+ export function normalizeReviewReport(value: unknown, round: number): ReviewReport {
172
+ try {
173
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
174
+ return invalidReview("invalid Reviewer output: result must be an object", round, new Date().toISOString());
175
+ }
176
+ const report = value as { verdict?: unknown; summary?: unknown; findings?: unknown };
177
+ if (typeof report.verdict !== "string" || typeof report.summary !== "string" || !report.summary.trim() || !Array.isArray(report.findings)) {
178
+ return invalidReview("invalid Reviewer output: result must include verdict, summary and findings", round, new Date().toISOString());
179
+ }
180
+ const encoded = JSON.stringify(value);
181
+ return parseReview(encoded ?? "", round);
182
+ } catch (error) {
183
+ return invalidReview(`invalid Reviewer output: ${error instanceof Error ? error.message : String(error)}`, round, new Date().toISOString());
184
+ }
185
+ }
186
+
162
187
  export function parseReview(text: string, round: number): ReviewReport {
163
188
  const checkedAt = new Date().toISOString();
164
189
  if (Buffer.byteLength(text, "utf8") > MAX_REVIEW_RESPONSE_BYTES) return invalidReview(`Reviewer response exceeded ${MAX_REVIEW_RESPONSE_BYTES} bytes`, round, checkedAt);
package/src/state.ts CHANGED
@@ -2,14 +2,15 @@ import type { SupervisorState } from "./types.ts";
2
2
 
3
3
  const transitions: Record<SupervisorState, readonly SupervisorState[]> = {
4
4
  idle: ["starting"],
5
- starting: ["running", "failed", "stopped"],
6
- running: ["waiting", "paused", "verifying", "failed", "stopped"],
7
- waiting: ["running", "paused", "verifying", "failed", "stopped"],
8
- paused: ["running", "stopped", "failed"],
9
- verifying: ["completed", "running", "failed", "stopped"],
5
+ starting: ["running", "blocked", "failed", "stopped"],
6
+ running: ["waiting", "paused", "verifying", "blocked", "failed", "stopped"],
7
+ waiting: ["running", "paused", "verifying", "blocked", "failed", "stopped"],
8
+ paused: ["running", "blocked", "stopped", "failed"],
9
+ verifying: ["completed", "running", "blocked", "failed", "stopped"],
10
10
  completed: ["idle"],
11
+ blocked: ["idle"],
11
12
  failed: ["idle"],
12
- stopped: ["idle"],
13
+ stopped: ["idle", "blocked"],
13
14
  };
14
15
 
15
16
  export class InvalidTransitionError extends Error {