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/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
 
@@ -55,6 +56,11 @@ export interface WorkerStartInput {
55
56
  abortSignal?: AbortSignal;
56
57
  /** Internal token that scopes out-of-band startup cancellation. */
57
58
  startupToken?: string;
59
+ /**
60
+ * Automatic-start repository assertion. Built-in adapters invoke this after
61
+ * all asynchronous setup and immediately before spawning the Worker.
62
+ */
63
+ preSpawnCheck?: () => Promise<void>;
58
64
  }
59
65
 
60
66
  export interface WorkerHandle {
@@ -166,6 +172,15 @@ export interface AcceptanceCheckResult {
166
172
  finishedAt: string;
167
173
  }
168
174
 
175
+ export interface TaskAutonomy {
176
+ /** Local development continues without synchronous human approval. */
177
+ unattended: boolean;
178
+ /** The Worker should commit the candidate on its local branch before completion. */
179
+ requireLocalCommit: boolean;
180
+ /** Number of autonomous Decision Worker retries before parking a candidate. */
181
+ maxDecisionRetries: number;
182
+ }
183
+
169
184
  export interface TaskSpec {
170
185
  goal: string;
171
186
  scope: string[];
@@ -173,8 +188,12 @@ export interface TaskSpec {
173
188
  forbidden: string[];
174
189
  acceptance: AcceptanceCheck[];
175
190
  maxRepairRounds: number;
191
+ autonomy: TaskAutonomy;
176
192
  }
177
193
 
194
+ /** Public input shape; nested autonomy fields may be omitted and are defaulted. */
195
+ export type TaskSpecInput = Partial<Omit<TaskSpec, "autonomy">> & { autonomy?: Partial<TaskAutonomy> };
196
+
178
197
  export type ReviewVerdict = "pass" | "revise" | "human";
179
198
  export type ReviewSeverity = "P0" | "P1" | "P2" | "P3";
180
199
 
@@ -203,6 +222,10 @@ export interface TaskContext {
203
222
  cwd: string;
204
223
  maxTurns: number;
205
224
  startedAt: string;
225
+ /** Repository HEAD before this task; used to review local commits as well as worktree changes. */
226
+ baseCommit?: string;
227
+ /** Non-protected local branch recorded before automatic work begins. */
228
+ baseBranch?: string;
206
229
  spec: TaskSpec;
207
230
  repairRound: number;
208
231
  lastFindingSignature?: string;
package/src/verifier.ts CHANGED
@@ -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 [statusResult, diffResult, untrackedResult] = await Promise.all([
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
- // HEAD-relative diff includes both staged and unstaged changes for tracked files.
99
- readGitEvidence(cwd, ["diff", "--no-ext-diff", "--unified=3", "HEAD", "--"], options.signal),
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
- complete: statusResult.complete && diffResult.complete && untrackedResult.complete,
108
- truncated: statusResult.truncated || diffResult.truncated || untrackedResult.truncated,
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
  }
@@ -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,168 @@ 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
+ export function automaticClaudeArgs(command: string, args: readonly string[] = []): string[] {
75
+ if (!isDirectClaudeName(command)) {
76
+ throw new Error("automatic supervision requires the direct Claude executable command name; custom executable paths need their own host boundary");
77
+ }
78
+ if (args.some((arg) => arg === "--settings" || arg.startsWith("--settings="))) {
79
+ throw new Error("automatic Claude supervision controls --settings; remove the caller-provided settings override");
80
+ }
81
+ return [
82
+ ...args,
83
+ "--settings",
84
+ JSON.stringify({
85
+ sandbox: {
86
+ enabled: true,
87
+ failIfUnavailable: true,
88
+ allowUnsandboxedCommands: false,
89
+ network: { allowedDomains: [] },
90
+ },
91
+ }),
92
+ ];
93
+ }
94
+
95
+ /**
96
+ * Resolve and pin the executable used by automatic mode before preflight and
97
+ * spawn. A bare command is resolved from the supervisor's own PATH; explicit
98
+ * paths and writable/untrusted executable locations are rejected. Operators
99
+ * who do not want PATH to be the trust root can set
100
+ * PI_CLAUDE_SUPERVISOR_TRUSTED_CLAUDE to the expected executable path.
101
+ */
102
+ export async function assertTrustedAutomaticClaudeExecutable(command: string, expectedPath?: string): Promise<string> {
103
+ if (!isDirectClaudeName(command)) {
104
+ throw new Error("automatic supervision requires the direct Claude executable command name; custom executable paths need their own host boundary");
105
+ }
106
+ const candidate = await resolveExecutable(command, process.env.PATH);
107
+ if (!candidate) throw new Error("automatic supervision could not resolve the trusted Claude executable from PATH");
108
+ const resolved = await realpath(candidate);
109
+ await assertSecureExecutablePath(resolved);
110
+ const configured = expectedPath?.trim() || process.env.PI_CLAUDE_SUPERVISOR_TRUSTED_CLAUDE?.trim();
111
+ if (configured) {
112
+ let expected: string;
113
+ try {
114
+ expected = await realpath(configured);
115
+ await assertSecureExecutablePath(expected);
116
+ } catch (error) {
117
+ if (error instanceof Error && /writable|owned|regular file/u.test(error.message)) throw error;
118
+ throw new Error("the expected Claude executable path is not a readable executable");
119
+ }
120
+ if (expected !== resolved) {
121
+ throw new Error("resolved Claude executable does not match the expected pinned identity");
122
+ }
123
+ }
124
+ return resolved;
125
+ }
126
+
127
+ function isDirectClaudeName(command: string): boolean {
128
+ if (command.includes("/") || command.includes("\\")) return false;
129
+ const executable = command.toLowerCase();
130
+ return executable === "claude" || executable === "claude.exe";
131
+ }
132
+
133
+ async function resolveExecutable(command: string, pathValue: string | undefined): Promise<string | undefined> {
134
+ for (const directory of (pathValue ?? "").split(delimiter).filter(Boolean)) {
135
+ const candidate = join(directory, command);
136
+ try {
137
+ await access(candidate, fsConstants.X_OK);
138
+ return candidate;
139
+ } catch {
140
+ // Continue to the next PATH entry.
141
+ }
142
+ }
143
+ return undefined;
144
+ }
145
+
146
+ async function assertSecureExecutablePath(path: string): Promise<void> {
147
+ await access(path, fsConstants.X_OK);
148
+ const executable = await stat(path);
149
+ if (!executable.isFile()) throw new Error("resolved Claude executable is not a regular file");
150
+ if (process.platform !== "win32") {
151
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
152
+ if ((executable.mode & 0o022) !== 0 || (uid !== undefined && executable.uid !== uid && executable.uid !== 0)) {
153
+ throw new Error("resolved Claude executable is writable by or owned by an untrusted user");
154
+ }
155
+ let directory = dirname(path);
156
+ while (true) {
157
+ const info = await stat(directory);
158
+ if ((info.mode & 0o022) !== 0 || (uid !== undefined && info.uid !== uid && info.uid !== 0)) {
159
+ throw new Error("a directory containing the resolved Claude executable is writable by or owned by an untrusted user");
160
+ }
161
+ const parent = dirname(directory);
162
+ if (parent === directory) break;
163
+ directory = parent;
164
+ }
165
+ }
166
+ }
167
+
168
+ const automaticEnvironmentAllowlist = new Set([
169
+ "ANTHROPIC_API_KEY",
170
+ "ANTHROPIC_AUTH_TOKEN",
171
+ "ANTHROPIC_BASE_URL",
172
+ "CLAUDE_CODE_OAUTH_TOKEN",
173
+ "CLAUDE_CODE_USE_BEDROCK",
174
+ "CLAUDE_CODE_USE_VERTEX",
175
+ "CLAUDE_CODE_USE_FOUNDRY",
176
+ "AWS_PROFILE",
177
+ "AWS_REGION",
178
+ "AWS_DEFAULT_REGION",
179
+ "AWS_SDK_LOAD_CONFIG",
180
+ "GOOGLE_CLOUD_PROJECT",
181
+ "GOOGLE_CLOUD_REGION",
182
+ "CLOUD_ML_REGION",
183
+ "NO_COLOR",
184
+ "CI",
185
+ ]);
186
+
187
+ function isAutomaticAllowedName(name: string): boolean {
188
+ return automaticEnvironmentAllowlist.has(name);
189
+ }
190
+
191
+ function isProviderCredentialName(name: string): boolean {
192
+ return name === "ANTHROPIC_API_KEY" || name === "ANTHROPIC_AUTH_TOKEN" || name === "CLAUDE_CODE_OAUTH_TOKEN";
193
+ }
194
+
195
+ export function isRemoteCredentialName(name: string): boolean {
196
+ if (isProviderCredentialName(name)) return false;
197
+ 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)
198
+ || /(?:^|_)(?:GITHUB|GH|GITLAB|BITBUCKET|NPM|NODE_AUTH|CODEARTIFACT|HUGGINGFACE|DOCKER|AWS|AZURE|GOOGLE|CI_JOB)(?:_|$)/iu.test(name)
199
+ || /(?:^|_)(?:TOKEN|PASSWORD|PASSWD|SECRET|PRIVATE_KEY|ACCESS_KEY|AUTH_TOKEN|API_KEY|CREDENTIALS?)(?:_|$)/iu.test(name);
200
+ }
@@ -172,6 +172,21 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
172
172
  const launch = cgroupPath
173
173
  ? cgroupBootstrapLaunch(input.command, args, input.cwd, workerEnv, cgroupPath)
174
174
  : { command: input.command, args, env: workerEnv };
175
+ try {
176
+ // This is the last asynchronous operation before spawn. Automatic mode
177
+ // uses it for an exact repository HEAD assertion; adapters that add
178
+ // setup work must invoke the hook only after that work is complete.
179
+ await input.preSpawnCheck?.();
180
+ } catch (error) {
181
+ if (cgroupPath) await rm(cgroupPath, { recursive: true, force: true }).catch(() => {});
182
+ throw error;
183
+ }
184
+ const finalAbortReason = input.startupToken ? this.#pendingStartupAborts.get(input.startupToken) : undefined;
185
+ if (input.startupToken) this.#pendingStartupAborts.delete(input.startupToken);
186
+ if (input.abortSignal?.aborted || finalAbortReason !== undefined) {
187
+ if (cgroupPath) await rm(cgroupPath, { recursive: true, force: true }).catch(() => {});
188
+ throw new Error(`worker startup aborted${finalAbortReason ? `: ${finalAbortReason}` : " before spawn"}`);
189
+ }
175
190
  let child: ChildProcess;
176
191
  try {
177
192
  child = spawn(launch.command, launch.args, {