pi-claude-supervisor 0.5.2 → 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/CHANGELOG.md +22 -2
- package/README.cn.md +58 -40
- package/README.md +84 -43
- package/docs/architecture.md +108 -65
- package/docs/automation-hardening-plan.md +24 -22
- package/docs/autonomy-target.md +130 -0
- package/docs/engineering-plan.md +136 -138
- package/docs/implementation-review.md +37 -14
- package/docs/independent-review.md +23 -18
- package/docs/releasing.md +7 -2
- package/docs/testing.md +53 -27
- package/package.json +1 -1
- package/src/acceptance.ts +16 -0
- package/src/config.ts +31 -0
- package/src/decision-session-store.ts +27 -2
- package/src/decision-worker.ts +46 -18
- package/src/index.ts +46 -54
- package/src/notifications.ts +29 -11
- package/src/policy.ts +294 -22
- package/src/reviewer.ts +90 -10
- package/src/state.ts +7 -6
- package/src/supervisor.ts +332 -120
- package/src/types.ts +26 -1
- package/src/verifier.ts +106 -7
- package/src/worker/environment.ts +217 -0
- package/src/worker/process-adapter.ts +379 -77
- package/src/worker/process-tree.ts +218 -0
- package/src/worker/tmux-adapter.ts +669 -25
package/src/types.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type SupervisorState =
|
|
|
6
6
|
| "paused"
|
|
7
7
|
| "verifying"
|
|
8
8
|
| "completed"
|
|
9
|
+
| "blocked"
|
|
9
10
|
| "failed"
|
|
10
11
|
| "stopped";
|
|
11
12
|
|
|
@@ -51,10 +52,17 @@ export interface WorkerStartInput {
|
|
|
51
52
|
};
|
|
52
53
|
/** Adopted sessions must not replay the task as a new user message. */
|
|
53
54
|
sendInitialInput?: boolean;
|
|
55
|
+
/** Automatic mode selects the structured tmux bridge instead of the manual TUI transport. */
|
|
56
|
+
automatic?: boolean;
|
|
54
57
|
/** Cancel startup before a worker is fully returned to the supervisor. */
|
|
55
58
|
abortSignal?: AbortSignal;
|
|
56
59
|
/** Internal token that scopes out-of-band startup cancellation. */
|
|
57
60
|
startupToken?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Automatic-start repository assertion. Built-in adapters invoke this after
|
|
63
|
+
* all asynchronous setup and immediately before spawning the Worker.
|
|
64
|
+
*/
|
|
65
|
+
preSpawnCheck?: () => Promise<void>;
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
export interface WorkerHandle {
|
|
@@ -105,7 +113,7 @@ export interface PermissionDecision {
|
|
|
105
113
|
export interface WorkerAdapter {
|
|
106
114
|
capabilities(): WorkerCapabilities;
|
|
107
115
|
/** Validate host/transport prerequisites before starting a Worker. */
|
|
108
|
-
preflight?(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval">): Promise<void>;
|
|
116
|
+
preflight?(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval" | "automatic">): Promise<void>;
|
|
109
117
|
start(input: WorkerStartInput): Promise<WorkerHandle>;
|
|
110
118
|
/** Cancel adapter-owned startup work before a WorkerHandle is returned. */
|
|
111
119
|
abortStart?(reason: string, startupToken?: string): Promise<void>;
|
|
@@ -166,6 +174,15 @@ export interface AcceptanceCheckResult {
|
|
|
166
174
|
finishedAt: string;
|
|
167
175
|
}
|
|
168
176
|
|
|
177
|
+
export interface TaskAutonomy {
|
|
178
|
+
/** Local development continues without synchronous human approval. */
|
|
179
|
+
unattended: boolean;
|
|
180
|
+
/** The Worker should commit the candidate on its local branch before completion. */
|
|
181
|
+
requireLocalCommit: boolean;
|
|
182
|
+
/** Number of autonomous Decision Worker retries before parking a candidate. */
|
|
183
|
+
maxDecisionRetries: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
169
186
|
export interface TaskSpec {
|
|
170
187
|
goal: string;
|
|
171
188
|
scope: string[];
|
|
@@ -173,8 +190,12 @@ export interface TaskSpec {
|
|
|
173
190
|
forbidden: string[];
|
|
174
191
|
acceptance: AcceptanceCheck[];
|
|
175
192
|
maxRepairRounds: number;
|
|
193
|
+
autonomy: TaskAutonomy;
|
|
176
194
|
}
|
|
177
195
|
|
|
196
|
+
/** Public input shape; nested autonomy fields may be omitted and are defaulted. */
|
|
197
|
+
export type TaskSpecInput = Partial<Omit<TaskSpec, "autonomy">> & { autonomy?: Partial<TaskAutonomy> };
|
|
198
|
+
|
|
178
199
|
export type ReviewVerdict = "pass" | "revise" | "human";
|
|
179
200
|
export type ReviewSeverity = "P0" | "P1" | "P2" | "P3";
|
|
180
201
|
|
|
@@ -203,6 +224,10 @@ export interface TaskContext {
|
|
|
203
224
|
cwd: string;
|
|
204
225
|
maxTurns: number;
|
|
205
226
|
startedAt: string;
|
|
227
|
+
/** Repository HEAD before this task; used to review local commits as well as worktree changes. */
|
|
228
|
+
baseCommit?: string;
|
|
229
|
+
/** Non-protected local branch recorded before automatic work begins. */
|
|
230
|
+
baseBranch?: string;
|
|
206
231
|
spec: TaskSpec;
|
|
207
232
|
repairRound: number;
|
|
208
233
|
lastFindingSignature?: string;
|
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";
|
|
@@ -38,11 +38,85 @@ export interface RepositoryEvidence {
|
|
|
38
38
|
untracked?: string;
|
|
39
39
|
/** False when a command/file could not be safely or completely collected. */
|
|
40
40
|
complete?: boolean;
|
|
41
|
+
/** Commit summaries made after the task baseline. */
|
|
42
|
+
commits?: string;
|
|
43
|
+
/** Baseline HEAD used for the diff and commit range, when available. */
|
|
44
|
+
baseRef?: string;
|
|
45
|
+
/** Current branch used for the local candidate, when available. */
|
|
46
|
+
branch?: string;
|
|
41
47
|
/** True when any evidence field was bounded or omitted. */
|
|
42
48
|
truncated?: boolean;
|
|
43
49
|
collectedAt: string;
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
/** Read the repository HEAD without invoking a shell. */
|
|
53
|
+
export async function repositoryHead(cwd: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
54
|
+
try {
|
|
55
|
+
const result = await execFileAsync("git", ["rev-parse", "--verify", "HEAD"], {
|
|
56
|
+
cwd,
|
|
57
|
+
timeout: 30_000,
|
|
58
|
+
maxBuffer: 1024,
|
|
59
|
+
signal,
|
|
60
|
+
env: workerEnvironment(process.env, { GIT_TERMINAL_PROMPT: "0" }),
|
|
61
|
+
});
|
|
62
|
+
const head = String(result.stdout).trim();
|
|
63
|
+
return /^[0-9a-f]{40,64}$/u.test(head) ? head : undefined;
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Verify that a full commit object is still present without invoking a shell. */
|
|
70
|
+
export async function repositoryCommitExists(cwd: string, commit: string, signal?: AbortSignal): Promise<boolean> {
|
|
71
|
+
if (!/^[0-9a-f]{40,64}$/iu.test(commit)) return false;
|
|
72
|
+
try {
|
|
73
|
+
const result = await execFileAsync("git", ["cat-file", "-e", `${commit}^{commit}`], {
|
|
74
|
+
cwd,
|
|
75
|
+
timeout: 30_000,
|
|
76
|
+
maxBuffer: 1024,
|
|
77
|
+
signal,
|
|
78
|
+
env: workerEnvironment(process.env, { GIT_TERMINAL_PROMPT: "0" }),
|
|
79
|
+
});
|
|
80
|
+
return result.stderr.length === 0;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Determine whether cwd is a non-bare Git worktree without invoking a shell. */
|
|
87
|
+
export async function repositoryWorkTree(cwd: string, signal?: AbortSignal): Promise<boolean | undefined> {
|
|
88
|
+
try {
|
|
89
|
+
const result = await execFileAsync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
90
|
+
cwd,
|
|
91
|
+
timeout: 30_000,
|
|
92
|
+
maxBuffer: 1024,
|
|
93
|
+
signal,
|
|
94
|
+
env: workerEnvironment(process.env, { GIT_TERMINAL_PROMPT: "0" }),
|
|
95
|
+
});
|
|
96
|
+
const value = String(result.stdout).trim();
|
|
97
|
+
return value === "true" ? true : value === "false" ? false : undefined;
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Read the current symbolic branch without invoking a shell. */
|
|
104
|
+
export async function repositoryBranch(cwd: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
105
|
+
try {
|
|
106
|
+
const result = await execFileAsync("git", ["symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
107
|
+
cwd,
|
|
108
|
+
timeout: 30_000,
|
|
109
|
+
maxBuffer: 1024,
|
|
110
|
+
signal,
|
|
111
|
+
env: workerEnvironment(process.env, { GIT_TERMINAL_PROMPT: "0" }),
|
|
112
|
+
});
|
|
113
|
+
const branch = String(result.stdout).trim();
|
|
114
|
+
return branch && /^[A-Za-z0-9._/-]+$/u.test(branch) ? branch : undefined;
|
|
115
|
+
} catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
46
120
|
export async function verify(
|
|
47
121
|
cwd: string,
|
|
48
122
|
command: VerificationCommand = { command: "git", args: ["diff", "--check"] },
|
|
@@ -91,21 +165,29 @@ export async function verifyAll(cwd: string, checks: readonly AcceptanceCheck[],
|
|
|
91
165
|
}
|
|
92
166
|
|
|
93
167
|
/** Collect bounded repository evidence for the independent read-only Reviewer. */
|
|
94
|
-
export async function collectRepositoryEvidence(cwd: string, options: Pick<VerificationOptions, "signal"> = {}): Promise<RepositoryEvidence> {
|
|
168
|
+
export async function collectRepositoryEvidence(cwd: string, options: Pick<VerificationOptions, "signal"> & { baseRef?: string } = {}): Promise<RepositoryEvidence> {
|
|
95
169
|
throwIfAborted(options.signal);
|
|
96
|
-
const
|
|
170
|
+
const baseRef = options.baseRef;
|
|
171
|
+
const diffRef = baseRef ?? "HEAD";
|
|
172
|
+
const commitArgs = baseRef ? ["log", "--format=%h %s", "--no-decorate", `${baseRef}..HEAD`, "--"] : ["log", "--format=%h %s", "--no-decorate", "-20", "--"];
|
|
173
|
+
const [statusResult, diffResult, commitsResult, branchResult, untrackedResult] = await Promise.all([
|
|
97
174
|
readGitEvidence(cwd, ["status", "--short", "--untracked-files=all"], options.signal),
|
|
98
|
-
//
|
|
99
|
-
readGitEvidence(cwd, ["diff", "--no-ext-diff", "--unified=3",
|
|
175
|
+
// A baseline-relative diff includes committed, staged, and unstaged changes.
|
|
176
|
+
readGitEvidence(cwd, ["diff", "--no-ext-diff", "--unified=3", diffRef, "--"], options.signal),
|
|
177
|
+
readGitEvidence(cwd, commitArgs, options.signal),
|
|
178
|
+
readGitEvidence(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"], options.signal),
|
|
100
179
|
collectUntrackedEvidence(cwd, options.signal),
|
|
101
180
|
]);
|
|
102
181
|
throwIfAborted(options.signal);
|
|
103
182
|
return {
|
|
104
183
|
status: statusResult.text,
|
|
105
184
|
diff: diffResult.text,
|
|
185
|
+
commits: commitsResult.text,
|
|
106
186
|
untracked: untrackedResult.text,
|
|
107
|
-
|
|
108
|
-
|
|
187
|
+
...(baseRef ? { baseRef } : {}),
|
|
188
|
+
...(branchResult.text.trim() !== "(none)" ? { branch: branchResult.text.trim() } : {}),
|
|
189
|
+
complete: statusResult.complete && diffResult.complete && commitsResult.complete && branchResult.complete && untrackedResult.complete,
|
|
190
|
+
truncated: statusResult.truncated || diffResult.truncated || commitsResult.truncated || branchResult.truncated || untrackedResult.truncated,
|
|
109
191
|
collectedAt: new Date().toISOString(),
|
|
110
192
|
};
|
|
111
193
|
}
|
|
@@ -228,6 +310,12 @@ async function collectUntrackedEvidence(cwd: string, signal?: AbortSignal): Prom
|
|
|
228
310
|
sections.push(`--- ${JSON.stringify(path)} [non-regular file omitted]`);
|
|
229
311
|
continue;
|
|
230
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);
|
|
231
319
|
const buffer = Buffer.alloc(MAX_UNTRACKED_FILE_BYTES + 1);
|
|
232
320
|
const read = await handle.read(buffer, 0, buffer.length, 0);
|
|
233
321
|
const bytes = buffer.subarray(0, read.bytesRead);
|
|
@@ -256,6 +344,17 @@ async function collectUntrackedEvidence(cwd: string, signal?: AbortSignal): Prom
|
|
|
256
344
|
return { ...bounded, complete: complete && bounded.complete, truncated: truncated || bounded.truncated };
|
|
257
345
|
}
|
|
258
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
|
+
|
|
259
358
|
async function assertNoSymlinkComponents(root: string, relativePath: string): Promise<void> {
|
|
260
359
|
const parts = relativePath.split(sep).filter(Boolean);
|
|
261
360
|
let current = root;
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import { access, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { delimiter, dirname, join } from "node:path";
|
|
4
|
+
|
|
1
5
|
const inheritedNames = [
|
|
2
6
|
"PATH",
|
|
3
7
|
"HOME",
|
|
@@ -29,3 +33,216 @@ export function workerEnvironment(
|
|
|
29
33
|
}
|
|
30
34
|
return result;
|
|
31
35
|
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Restrict explicit variables supplied to an unattended Worker. This is not a
|
|
39
|
+
* network sandbox, but it removes common remote-repository credentials and
|
|
40
|
+
* disables the Git/package-manager credential helpers before command policy
|
|
41
|
+
* gets a chance to inspect a structured Bash request.
|
|
42
|
+
*/
|
|
43
|
+
export function automaticWorkerEnvironment(explicit: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
|
|
44
|
+
const result: NodeJS.ProcessEnv = {};
|
|
45
|
+
for (const [name, value] of Object.entries(explicit)) {
|
|
46
|
+
if (value !== undefined && isAutomaticAllowedName(name) && (!isRemoteCredentialName(name) || isProviderCredentialName(name))) result[name] = value;
|
|
47
|
+
}
|
|
48
|
+
result.GIT_CONFIG_NOSYSTEM = "1";
|
|
49
|
+
result.GIT_CONFIG_SYSTEM = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
50
|
+
result.GIT_CONFIG_GLOBAL = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
51
|
+
result.GIT_CONFIG_COUNT = "3";
|
|
52
|
+
result.GIT_CONFIG_KEY_0 = "credential.helper";
|
|
53
|
+
result.GIT_CONFIG_VALUE_0 = "";
|
|
54
|
+
result.GIT_CONFIG_KEY_1 = "http.proxy";
|
|
55
|
+
result.GIT_CONFIG_VALUE_1 = "http://127.0.0.1:9";
|
|
56
|
+
result.GIT_CONFIG_KEY_2 = "https.proxy";
|
|
57
|
+
result.GIT_CONFIG_VALUE_2 = "http://127.0.0.1:9";
|
|
58
|
+
result.GIT_TERMINAL_PROMPT = "0";
|
|
59
|
+
result.GIT_SSH_COMMAND = "false";
|
|
60
|
+
result.GH_CONFIG_DIR = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
61
|
+
result.NPM_CONFIG_USERCONFIG = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
62
|
+
result.npm_config_userconfig = result.NPM_CONFIG_USERCONFIG;
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Ask Claude Code to sandbox Bash and its descendants. The API process keeps
|
|
68
|
+
* its provider connection, while Worker-launched commands get no outbound
|
|
69
|
+
* network and cannot silently fall back to an unsandboxed shell. Automatic mode
|
|
70
|
+
* admits only the direct Claude command name. Startup resolves and pins its
|
|
71
|
+
* operator-owned executable path so a custom path or writable replacement
|
|
72
|
+
* cannot silently omit this boundary.
|
|
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
|
+
*/
|
|
92
|
+
export function automaticClaudeArgs(command: string, args: readonly string[] = []): string[] {
|
|
93
|
+
if (!isDirectClaudeName(command)) {
|
|
94
|
+
throw new Error("automatic supervision requires the direct Claude executable command name; custom executable paths need their own host boundary");
|
|
95
|
+
}
|
|
96
|
+
if (args.some((arg) => arg === "--settings" || arg.startsWith("--settings="))) {
|
|
97
|
+
throw new Error("automatic Claude supervision controls --settings; remove the caller-provided settings override");
|
|
98
|
+
}
|
|
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",
|
|
115
|
+
"--settings",
|
|
116
|
+
JSON.stringify({
|
|
117
|
+
sandbox: {
|
|
118
|
+
enabled: true,
|
|
119
|
+
failIfUnavailable: true,
|
|
120
|
+
allowUnsandboxedCommands: false,
|
|
121
|
+
network: { allowedDomains: [] },
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
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, ""));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolve and pin the executable used by automatic mode before preflight and
|
|
145
|
+
* spawn. A bare command is resolved from the supervisor's own PATH; explicit
|
|
146
|
+
* paths and writable/untrusted executable locations are rejected. Operators
|
|
147
|
+
* who do not want PATH to be the trust root can set
|
|
148
|
+
* PI_CLAUDE_SUPERVISOR_TRUSTED_CLAUDE to the expected executable path.
|
|
149
|
+
*/
|
|
150
|
+
export async function assertTrustedAutomaticClaudeExecutable(command: string, expectedPath?: string): Promise<string> {
|
|
151
|
+
if (!isDirectClaudeName(command)) {
|
|
152
|
+
throw new Error("automatic supervision requires the direct Claude executable command name; custom executable paths need their own host boundary");
|
|
153
|
+
}
|
|
154
|
+
const candidate = await resolveExecutable(command, process.env.PATH);
|
|
155
|
+
if (!candidate) throw new Error("automatic supervision could not resolve the trusted Claude executable from PATH");
|
|
156
|
+
const resolved = await realpath(candidate);
|
|
157
|
+
await assertSecureExecutablePath(resolved);
|
|
158
|
+
const configured = expectedPath?.trim() || process.env.PI_CLAUDE_SUPERVISOR_TRUSTED_CLAUDE?.trim();
|
|
159
|
+
if (configured) {
|
|
160
|
+
let expected: string;
|
|
161
|
+
try {
|
|
162
|
+
expected = await realpath(configured);
|
|
163
|
+
await assertSecureExecutablePath(expected);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error instanceof Error && /writable|owned|regular file/u.test(error.message)) throw error;
|
|
166
|
+
throw new Error("the expected Claude executable path is not a readable executable");
|
|
167
|
+
}
|
|
168
|
+
if (expected !== resolved) {
|
|
169
|
+
throw new Error("resolved Claude executable does not match the expected pinned identity");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return resolved;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isDirectClaudeName(command: string): boolean {
|
|
176
|
+
if (command.includes("/") || command.includes("\\")) return false;
|
|
177
|
+
const executable = command.toLowerCase();
|
|
178
|
+
return executable === "claude" || executable === "claude.exe";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function resolveExecutable(command: string, pathValue: string | undefined): Promise<string | undefined> {
|
|
182
|
+
for (const directory of (pathValue ?? "").split(delimiter).filter(Boolean)) {
|
|
183
|
+
const candidate = join(directory, command);
|
|
184
|
+
try {
|
|
185
|
+
await access(candidate, fsConstants.X_OK);
|
|
186
|
+
return candidate;
|
|
187
|
+
} catch {
|
|
188
|
+
// Continue to the next PATH entry.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function assertSecureExecutablePath(path: string): Promise<void> {
|
|
195
|
+
await access(path, fsConstants.X_OK);
|
|
196
|
+
const executable = await stat(path);
|
|
197
|
+
if (!executable.isFile()) throw new Error("resolved Claude executable is not a regular file");
|
|
198
|
+
if (process.platform !== "win32") {
|
|
199
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
200
|
+
if ((executable.mode & 0o022) !== 0 || (uid !== undefined && executable.uid !== uid && executable.uid !== 0)) {
|
|
201
|
+
throw new Error("resolved Claude executable is writable by or owned by an untrusted user");
|
|
202
|
+
}
|
|
203
|
+
let directory = dirname(path);
|
|
204
|
+
while (true) {
|
|
205
|
+
const info = await stat(directory);
|
|
206
|
+
if ((info.mode & 0o022) !== 0 || (uid !== undefined && info.uid !== uid && info.uid !== 0)) {
|
|
207
|
+
throw new Error("a directory containing the resolved Claude executable is writable by or owned by an untrusted user");
|
|
208
|
+
}
|
|
209
|
+
const parent = dirname(directory);
|
|
210
|
+
if (parent === directory) break;
|
|
211
|
+
directory = parent;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const automaticEnvironmentAllowlist = new Set([
|
|
217
|
+
"ANTHROPIC_API_KEY",
|
|
218
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
219
|
+
"ANTHROPIC_BASE_URL",
|
|
220
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
221
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
222
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
223
|
+
"CLAUDE_CODE_USE_FOUNDRY",
|
|
224
|
+
"AWS_PROFILE",
|
|
225
|
+
"AWS_REGION",
|
|
226
|
+
"AWS_DEFAULT_REGION",
|
|
227
|
+
"AWS_SDK_LOAD_CONFIG",
|
|
228
|
+
"GOOGLE_CLOUD_PROJECT",
|
|
229
|
+
"GOOGLE_CLOUD_REGION",
|
|
230
|
+
"CLOUD_ML_REGION",
|
|
231
|
+
"NO_COLOR",
|
|
232
|
+
"CI",
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
function isAutomaticAllowedName(name: string): boolean {
|
|
236
|
+
return automaticEnvironmentAllowlist.has(name);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isProviderCredentialName(name: string): boolean {
|
|
240
|
+
return name === "ANTHROPIC_API_KEY" || name === "ANTHROPIC_AUTH_TOKEN" || name === "CLAUDE_CODE_OAUTH_TOKEN";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function isRemoteCredentialName(name: string): boolean {
|
|
244
|
+
if (isProviderCredentialName(name)) return false;
|
|
245
|
+
return /^(?:SSH_AUTH_SOCK|GIT_ASKPASS|GIT_SSH_COMMAND|GIT_CREDENTIAL_HELPER|GIT_CONFIG(?:_|$)|GH_CONFIG_DIR|NPM_CONFIG_USERCONFIG|AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|AZURE_CLIENT_ID|AZURE_CLIENT_SECRET|AZURE_TENANT_ID|GOOGLE_APPLICATION_CREDENTIALS|KUBECONFIG)$/iu.test(name)
|
|
246
|
+
|| /(?:^|_)(?:GITHUB|GH|GITLAB|BITBUCKET|NPM|NODE_AUTH|CODEARTIFACT|HUGGINGFACE|DOCKER|AWS|AZURE|GOOGLE|CI_JOB)(?:_|$)/iu.test(name)
|
|
247
|
+
|| /(?:^|_)(?:TOKEN|PASSWORD|PASSWD|SECRET|PRIVATE_KEY|ACCESS_KEY|AUTH_TOKEN|API_KEY|CREDENTIALS?)(?:_|$)/iu.test(name);
|
|
248
|
+
}
|