pi-claude-supervisor 0.2.0
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 +36 -0
- package/LICENSE +21 -0
- package/README.cn.md +98 -0
- package/README.md +122 -0
- package/docs/architecture.md +122 -0
- package/docs/engineering-plan.md +918 -0
- package/docs/implementation-review.md +62 -0
- package/docs/independent-review.md +332 -0
- package/docs/releasing.md +112 -0
- package/docs/testing.md +109 -0
- package/docs/transport-spike-2026-09-12.md +94 -0
- package/package.json +72 -0
- package/src/config.ts +35 -0
- package/src/decision-session-store.ts +170 -0
- package/src/decision-worker.ts +245 -0
- package/src/events.ts +179 -0
- package/src/index.ts +500 -0
- package/src/notifications.ts +85 -0
- package/src/policy.ts +61 -0
- package/src/state.ts +44 -0
- package/src/supervisor.ts +626 -0
- package/src/types.ts +122 -0
- package/src/verifier.ts +40 -0
- package/src/worker/environment.ts +31 -0
- package/src/worker/process-adapter.ts +604 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
2
|
+
import type { HumanInterventionNotice } from "./supervisor.ts";
|
|
3
|
+
|
|
4
|
+
export interface HumanWebhookOptions {
|
|
5
|
+
url?: string;
|
|
6
|
+
format?: "generic" | "wecom";
|
|
7
|
+
secret?: string;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Outbound-only human escalation. Approval still happens through Pi/manual control. */
|
|
12
|
+
export class HumanWebhookNotifier {
|
|
13
|
+
readonly #url?: string;
|
|
14
|
+
readonly #format: "generic" | "wecom";
|
|
15
|
+
readonly #secret?: string;
|
|
16
|
+
readonly #timeoutMs: number;
|
|
17
|
+
|
|
18
|
+
constructor(options: HumanWebhookOptions = {}) {
|
|
19
|
+
this.#url = options.url;
|
|
20
|
+
this.#format = options.format ?? "generic";
|
|
21
|
+
this.#secret = options.secret;
|
|
22
|
+
this.#timeoutMs = options.timeoutMs ?? 10_000;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get enabled(): boolean { return Boolean(this.#url); }
|
|
26
|
+
|
|
27
|
+
async notify(notice: HumanInterventionNotice): Promise<void> {
|
|
28
|
+
if (!this.#url) return;
|
|
29
|
+
const body = this.#format === "wecom" ? JSON.stringify(toWeCom(notice)) : JSON.stringify(toGeneric(notice));
|
|
30
|
+
const headers: Record<string, string> = { "content-type": "application/json", "user-agent": "pi-claude-supervisor/0.1" };
|
|
31
|
+
if (this.#secret) headers["x-pi-supervisor-signature"] = `sha256=${createHmac("sha256", this.#secret).update(body).digest("hex")}`;
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => controller.abort(), this.#timeoutMs);
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch(this.#url, { method: "POST", headers, body, signal: controller.signal });
|
|
36
|
+
if (!response.ok) throw new Error(`human webhook returned HTTP ${response.status}`);
|
|
37
|
+
} finally {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toGeneric(notice: HumanInterventionNotice): Record<string, unknown> {
|
|
44
|
+
return {
|
|
45
|
+
schema: "pi-claude-supervisor/human-intervention/v1",
|
|
46
|
+
eventId: randomUUID(),
|
|
47
|
+
event: "human_intervention_required",
|
|
48
|
+
occurredAt: new Date().toISOString(),
|
|
49
|
+
task: { id: notice.taskId, goal: notice.task, cwd: notice.cwd },
|
|
50
|
+
worker: { id: notice.workerId },
|
|
51
|
+
reason: notice.reason,
|
|
52
|
+
question: notice.question,
|
|
53
|
+
permission: notice.permission ? { ...notice.permission, input: sanitize(notice.permission.input) } : undefined,
|
|
54
|
+
actions: ["approve_or_deny_permission", "send_instruction", "stop_worker", "takeover"],
|
|
55
|
+
note: "This is an outbound notification. Use the Pi session or a separately authenticated callback service to approve actions.",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toWeCom(notice: HumanInterventionNotice): Record<string, unknown> {
|
|
60
|
+
const permission = notice.permission ? `\n工具: ${notice.permission.toolName}\n请求 ID: ${notice.permission.requestId}` : "";
|
|
61
|
+
const question = notice.question ? `\n问题: ${notice.question}` : "";
|
|
62
|
+
return {
|
|
63
|
+
msgtype: "markdown",
|
|
64
|
+
markdown: {
|
|
65
|
+
content: `### Claude Supervisor 需要人工介入\n> 任务: ${escapeMarkdown(notice.task)}\n> Task ID: ${notice.taskId}\n> 原因: ${escapeMarkdown(notice.reason)}${escapeMarkdown(question)}${escapeMarkdown(permission)}\n\n请在 Pi 中执行对应的 approve/deny、send、stop 或 takeover 操作。`,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sanitize(value: unknown, key?: string): unknown {
|
|
71
|
+
if (key && /(password|secret|token|api[-_]?key|authorization|credential)/iu.test(key)) return "[REDACTED]";
|
|
72
|
+
if (typeof value === "string") {
|
|
73
|
+
return value
|
|
74
|
+
.replace(/\\b(sk-ant-[A-Za-z0-9_-]+)\\b/gu, "[REDACTED]")
|
|
75
|
+
.replace(/\\b(Bearer\\s+)[^\\s]+/giu, "$1[REDACTED]")
|
|
76
|
+
.slice(0, 4_000);
|
|
77
|
+
}
|
|
78
|
+
if (Array.isArray(value)) return value.slice(0, 50).map((item) => sanitize(item, key));
|
|
79
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).slice(0, 50).map(([childKey, childValue]) => [childKey, sanitize(childValue, childKey)]));
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function escapeMarkdown(value: string): string {
|
|
84
|
+
return value.replace(/[\\`*_[\]<>]/gu, "\\$&").slice(0, 2_000);
|
|
85
|
+
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type PolicyDecision = "allow" | "review" | "deny";
|
|
2
|
+
|
|
3
|
+
export interface PolicyResult {
|
|
4
|
+
decision: PolicyDecision;
|
|
5
|
+
reason: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
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}` };
|
|
11
|
+
const command = input && typeof input === "object" && typeof (input as { command?: unknown }).command === "string"
|
|
12
|
+
? (input as { command: string }).command
|
|
13
|
+
: "";
|
|
14
|
+
if (!command) return { decision: "review", reason: "Bash request has no recognizable command" };
|
|
15
|
+
return evaluateCommand("bash", ["-lc", command]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const deniedPatterns = [
|
|
19
|
+
/--dangerously-skip-permissions\b/iu,
|
|
20
|
+
/--permission-mode\s+(?:bypasspermissions|dontask)\b/iu,
|
|
21
|
+
/\brm\s+-rf\s+\//iu,
|
|
22
|
+
/\bmkfs(?:\.|\s)/iu,
|
|
23
|
+
/\bdd\s+if=/iu,
|
|
24
|
+
/:\(\)\s*\{\s*:\|/u,
|
|
25
|
+
/\b(shutdown|reboot|poweroff)\b/iu,
|
|
26
|
+
];
|
|
27
|
+
|
|
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
|
+
];
|
|
36
|
+
|
|
37
|
+
export function evaluateCommand(command: string, args: readonly string[] = []): PolicyResult {
|
|
38
|
+
const normalized = [command, ...args].join(" ").trim();
|
|
39
|
+
if (!normalized) return { decision: "deny", reason: "empty command" };
|
|
40
|
+
if (deniedPatterns.some((pattern) => pattern.test(normalized))) {
|
|
41
|
+
return { decision: "deny", reason: "command matches a prohibited destructive pattern" };
|
|
42
|
+
}
|
|
43
|
+
if (reviewPatterns.some((pattern) => pattern.test(normalized))) {
|
|
44
|
+
return { decision: "review", reason: "command requires explicit human approval" };
|
|
45
|
+
}
|
|
46
|
+
return { decision: "allow", reason: "command is outside the default high-risk set" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function assertSafeWorkerCommand(
|
|
50
|
+
command: string,
|
|
51
|
+
args: readonly string[] = [],
|
|
52
|
+
approval?: { actor: "human"; reason: string },
|
|
53
|
+
): void {
|
|
54
|
+
const result = evaluateCommand(command, args);
|
|
55
|
+
if (result.decision === "deny") {
|
|
56
|
+
throw new Error(`Worker command blocked by policy (deny): ${result.reason}`);
|
|
57
|
+
}
|
|
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
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { SupervisorState } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const transitions: Record<SupervisorState, readonly SupervisorState[]> = {
|
|
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"],
|
|
10
|
+
completed: ["idle"],
|
|
11
|
+
failed: ["idle"],
|
|
12
|
+
stopped: ["idle"],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export class InvalidTransitionError extends Error {
|
|
16
|
+
constructor(from: SupervisorState, to: SupervisorState) {
|
|
17
|
+
super(`Invalid supervisor transition: ${from} -> ${to}`);
|
|
18
|
+
this.name = "InvalidTransitionError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class SupervisorStateMachine {
|
|
23
|
+
#state: SupervisorState = "idle";
|
|
24
|
+
|
|
25
|
+
get state(): SupervisorState {
|
|
26
|
+
return this.#state;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
transition(next: SupervisorState): SupervisorState {
|
|
30
|
+
if (!transitions[this.#state].includes(next)) {
|
|
31
|
+
throw new InvalidTransitionError(this.#state, next);
|
|
32
|
+
}
|
|
33
|
+
this.#state = next;
|
|
34
|
+
return this.#state;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
reset(): void {
|
|
38
|
+
this.#state = "idle";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function allowedTransitions(state: SupervisorState): readonly SupervisorState[] {
|
|
43
|
+
return transitions[state];
|
|
44
|
+
}
|