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/CHANGELOG.md +12 -1
- package/README.cn.md +45 -36
- package/README.md +70 -41
- package/docs/architecture.md +65 -38
- package/docs/automation-hardening-plan.md +23 -22
- package/docs/autonomy-target.md +127 -0
- package/docs/engineering-plan.md +136 -138
- package/docs/implementation-review.md +29 -14
- package/docs/independent-review.md +23 -18
- package/docs/releasing.md +7 -2
- package/docs/testing.md +36 -22
- package/package.json +1 -1
- package/src/acceptance.ts +16 -0
- package/src/config.ts +31 -0
- package/src/decision-session-store.ts +19 -1
- package/src/decision-worker.ts +46 -18
- package/src/index.ts +37 -48
- package/src/notifications.ts +29 -11
- package/src/policy.ts +196 -21
- package/src/reviewer.ts +26 -1
- package/src/state.ts +7 -6
- package/src/supervisor.ts +329 -119
- package/src/types.ts +23 -0
- package/src/verifier.ts +88 -6
- package/src/worker/environment.ts +169 -0
- package/src/worker/process-adapter.ts +15 -0
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
|
|
45
|
-
throw new Error("automatic supervision requires PI_CLAUDE_SUPERVISOR_TRANSPORT=jsonl
|
|
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
|
|
232
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
if (ctx.hasUI) notify(ctx, `
|
|
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
|
-
|
|
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
|
-
|
|
459
|
-
if (
|
|
460
|
-
|
|
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
|
-
|
|
495
|
-
if (ctx.hasUI) notify(ctx, `
|
|
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.
|
|
498
|
-
catch (error) { console.error(`pi-claude-supervisor
|
|
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:
|
|
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> {
|
package/src/notifications.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
37
|
-
if (!response.ok) throw new Error(`
|
|
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:
|
|
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: `###
|
|
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: "
|
|
10
|
-
if (toolName !== "Bash") return { decision: "
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 =
|
|
49
|
+
const normalized = command.trim();
|
|
39
50
|
if (!normalized) return { decision: "deny", reason: "empty command" };
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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 {
|