pi-claude-supervisor 0.5.3 → 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 +11 -2
- package/README.cn.md +24 -15
- package/README.md +27 -15
- package/docs/architecture.md +43 -27
- package/docs/automation-hardening-plan.md +2 -1
- package/docs/autonomy-target.md +9 -6
- package/docs/engineering-plan.md +1 -1
- package/docs/implementation-review.md +8 -0
- package/docs/testing.md +22 -10
- package/package.json +1 -1
- package/src/decision-session-store.ts +8 -1
- package/src/index.ts +12 -9
- package/src/policy.ts +98 -1
- package/src/reviewer.ts +64 -9
- package/src/supervisor.ts +6 -4
- package/src/types.ts +3 -1
- package/src/verifier.ts +18 -1
- package/src/worker/environment.ts +51 -3
- package/src/worker/process-adapter.ts +366 -79
- package/src/worker/process-tree.ts +218 -0
- package/src/worker/tmux-adapter.ts +669 -25
package/src/policy.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { lstatSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
|
|
1
4
|
export type PolicyDecision = "allow" | "review" | "deny";
|
|
2
5
|
|
|
3
6
|
export interface PolicyResult {
|
|
@@ -11,16 +14,85 @@ export interface PolicyResult {
|
|
|
11
14
|
* turn. AskUserQuestion is denied so the Worker can restate the question as
|
|
12
15
|
* ordinary text and the Decision Worker can answer it from task evidence.
|
|
13
16
|
*/
|
|
14
|
-
|
|
17
|
+
const allowedWorkerTools = new Set(["Bash", "Edit", "Glob", "Grep", "Read", "Write", "NotebookEdit"]);
|
|
18
|
+
|
|
19
|
+
export function evaluatePermission(toolName: string, input: unknown, cwd = process.cwd()): PolicyResult {
|
|
15
20
|
if (toolName === "AskUserQuestion") return { decision: "deny", reason: "interactive questions are converted to ordinary Worker text" };
|
|
21
|
+
if (!allowedWorkerTools.has(toolName)) return { decision: "deny", reason: `Worker tool is outside the automatic allowlist: ${toolName}` };
|
|
22
|
+
if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") {
|
|
23
|
+
const paths = fileToolPaths(input);
|
|
24
|
+
if (paths.length === 0) return { decision: "deny", reason: `${toolName} request has no recognizable file path` };
|
|
25
|
+
if (paths.some((path) => isGitMetadataPath(path, cwd))) return { decision: "deny", reason: "Worker cannot write Git metadata or protected branch refs" };
|
|
26
|
+
return { decision: "allow", reason: `local Claude file tool is allowed by the task policy: ${toolName}` };
|
|
27
|
+
}
|
|
16
28
|
if (toolName !== "Bash") return { decision: "allow", reason: `local Claude tool is allowed by the task policy: ${toolName}` };
|
|
17
29
|
const command = input && typeof input === "object" && typeof (input as { command?: unknown }).command === "string"
|
|
18
30
|
? (input as { command: string }).command
|
|
19
31
|
: "";
|
|
20
32
|
if (!command) return { decision: "deny", reason: "Bash request has no recognizable command" };
|
|
33
|
+
if (containsNestedClaude(command)) {
|
|
34
|
+
return { decision: "deny", reason: "Worker cannot start a nested Claude or background Reviewer" };
|
|
35
|
+
}
|
|
21
36
|
return evaluateCommand("bash", ["-lc", command]);
|
|
22
37
|
}
|
|
23
38
|
|
|
39
|
+
function fileToolPaths(input: unknown): string[] {
|
|
40
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return [];
|
|
41
|
+
const value = input as Record<string, unknown>;
|
|
42
|
+
return ["file_path", "filePath", "path", "notebook_path", "notebookPath"]
|
|
43
|
+
.map((key) => value[key])
|
|
44
|
+
.filter((path): path is string => typeof path === "string" && path.trim().length > 0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isGitMetadataPath(value: string, cwd: string): boolean {
|
|
48
|
+
if (value.replaceAll("\\", "/").split("/").some((segment) => segment.toLowerCase() === ".git")) return true;
|
|
49
|
+
let root: string;
|
|
50
|
+
try { root = realpathSync(cwd); }
|
|
51
|
+
catch { return true; }
|
|
52
|
+
const raw = value.replaceAll("\\", "/");
|
|
53
|
+
const canonicalRoot = root.replaceAll("\\", "/").replace(/\/+$/u, "") || "/";
|
|
54
|
+
let components: string[];
|
|
55
|
+
if (isAbsolute(value)) {
|
|
56
|
+
const prefix = canonicalRoot === "/" ? "/" : `${canonicalRoot}/`;
|
|
57
|
+
if (raw !== canonicalRoot && !raw.startsWith(prefix)) return true;
|
|
58
|
+
components = raw === canonicalRoot ? [] : raw.slice(prefix.length).split("/");
|
|
59
|
+
} else {
|
|
60
|
+
components = raw.split("/");
|
|
61
|
+
}
|
|
62
|
+
const normalized: string[] = [];
|
|
63
|
+
for (const segment of components) {
|
|
64
|
+
if (!segment || segment === ".") continue;
|
|
65
|
+
if (segment === "..") {
|
|
66
|
+
if (normalized.length === 0) return true;
|
|
67
|
+
normalized.pop();
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (segment.toLowerCase() === ".git") return true;
|
|
71
|
+
const candidate = join(root, ...normalized, segment);
|
|
72
|
+
try {
|
|
73
|
+
const info = lstatSync(candidate);
|
|
74
|
+
if (info.isSymbolicLink()) return true;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
77
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") return true;
|
|
78
|
+
}
|
|
79
|
+
normalized.push(segment);
|
|
80
|
+
}
|
|
81
|
+
const absolute = join(root, ...normalized);
|
|
82
|
+
try {
|
|
83
|
+
const info = statSync(absolute);
|
|
84
|
+
// A regular file with multiple links may be an alias for a Git ref or
|
|
85
|
+
// other metadata file even when its pathname contains no `.git` segment.
|
|
86
|
+
if (info.isFile() && info.nlink > 1) return true;
|
|
87
|
+
const resolved = realpathSync(absolute);
|
|
88
|
+
if (resolved.replaceAll("\\", "/").split("/").some((segment) => segment.toLowerCase() === ".git")) return true;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
91
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
24
96
|
const deniedPatterns = [
|
|
25
97
|
/\b(?:npm|pnpm|yarn)\b[\s\S]*\bpublish\b/iu,
|
|
26
98
|
/\b(?:ssh|scp|sftp|rsync)\b/iu,
|
|
@@ -63,6 +135,7 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
|
|
|
63
135
|
const hasGhRemote = lower.some((value) => value === "gh" || value === "glab" || value === "hub")
|
|
64
136
|
&& lower.some((value) => value === "api" || value === "release" || value === "pull-request" || value === "pr" || value === "mr");
|
|
65
137
|
const hasPackagePublication = lower.some((value) => value === "npm" || value === "pnpm" || value === "yarn") && lower.includes("publish");
|
|
138
|
+
const hasGitAliasConfiguration = hasGit && lower.some((value) => /^alias\.[^=]*(?:=|$)/u.test(value));
|
|
66
139
|
if (depth < 4) {
|
|
67
140
|
for (const nested of nestedShellCommands(lower, values)) {
|
|
68
141
|
const nestedResult = evaluateCommandInternal(nested, depth + 1);
|
|
@@ -85,6 +158,9 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
|
|
|
85
158
|
if (hasGit && (hasRemoteOperation || hasGitTransport) || hasGhRemote) {
|
|
86
159
|
return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
|
|
87
160
|
}
|
|
161
|
+
if (hasGitAliasConfiguration) {
|
|
162
|
+
return { decision: "deny", reason: "Worker cannot redefine Git command aliases" };
|
|
163
|
+
}
|
|
88
164
|
if (hasGit && gitOperation && hasProtectedBranch) {
|
|
89
165
|
return { decision: "deny", reason: "Worker cannot switch to or mutate a protected integration branch" };
|
|
90
166
|
}
|
|
@@ -105,6 +181,27 @@ function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: st
|
|
|
105
181
|
return undefined;
|
|
106
182
|
}
|
|
107
183
|
|
|
184
|
+
function containsNestedClaude(command: string, depth = 0): boolean {
|
|
185
|
+
// Bash can hand an opaque quoted program to Python, Node, eval, env,
|
|
186
|
+
// command, or another interpreter. Once this permission boundary sees a
|
|
187
|
+
// Claude executable reference, fail closed rather than trying to prove which
|
|
188
|
+
// wrapper will eventually exec it. This intentionally rejects harmless prose
|
|
189
|
+
// mentions too; false positives cannot grant a nested Worker capability.
|
|
190
|
+
if (/\b(?:claude(?:\.exe)?|review(?:er)?|code[-_ ]?review|read[-_ ]?only[-_ ]?review|independent[-_ ]?review|codex|cursor(?:-agent)?|aider|opencode|gemini)\b/iu.test(command)) return true;
|
|
191
|
+
if (depth > 4) return false;
|
|
192
|
+
const lexical = lexShell(command);
|
|
193
|
+
if (lexical.error) return true;
|
|
194
|
+
for (const token of lexical.tokens) {
|
|
195
|
+
if (token.operator) continue;
|
|
196
|
+
const executable = token.value.split(/[\\/]/u).at(-1)?.toLowerCase();
|
|
197
|
+
if (executable === "claude" || executable === "claude.exe") return true;
|
|
198
|
+
// Quote concatenation such as c'l'a'u'd'e is normalized by the shell
|
|
199
|
+
// lexer into one token. Recurse into quoted interpreter payloads too.
|
|
200
|
+
if (/\s/u.test(token.value) && containsNestedClaude(token.value, depth + 1)) return true;
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
108
205
|
function nestedShellCommands(lower: readonly string[], values: readonly string[]): string[] {
|
|
109
206
|
const nested: string[] = [];
|
|
110
207
|
const shellNames = new Set(["sh", "bash", "dash", "zsh", "fish", "ksh"]);
|
package/src/reviewer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
3
|
import { redactSensitive } from "./redaction.ts";
|
|
3
4
|
import type { AcceptanceReport, ReviewFinding, ReviewReport, TaskSpec } from "./types.ts";
|
|
4
5
|
import type { RepositoryEvidence } from "./verifier.ts";
|
|
@@ -147,7 +148,7 @@ ${boundText(redactText(input.evidence.untracked ?? "(none)"), 16_000)}
|
|
|
147
148
|
|
|
148
149
|
EVIDENCE COMPLETE:
|
|
149
150
|
${String(input.evidence.complete !== false && input.evidence.truncated !== true)}
|
|
150
|
-
|
|
151
|
+
DISPLAY NOTE: fields below may end with [DISPLAY_TRUNCATED] because the prompt has a bounded presentation window. This is presentation-only and is not evidence loss. If EVIDENCE COMPLETE is true, do not return human merely because a displayed field is shortened; inspect the repository with the read-only tools instead. If EVIDENCE COMPLETE is false, do not return pass; return human and explain which evidence is unavailable.
|
|
151
152
|
|
|
152
153
|
WORKER OUTPUT (UNTRUSTED):
|
|
153
154
|
${boundTailText(redactSensitive(input.workerOutput ?? "(none)"), 8_000)}
|
|
@@ -187,17 +188,21 @@ export function normalizeReviewReport(value: unknown, round: number): ReviewRepo
|
|
|
187
188
|
export function parseReview(text: string, round: number): ReviewReport {
|
|
188
189
|
const checkedAt = new Date().toISOString();
|
|
189
190
|
if (Buffer.byteLength(text, "utf8") > MAX_REVIEW_RESPONSE_BYTES) return invalidReview(`Reviewer response exceeded ${MAX_REVIEW_RESPONSE_BYTES} bytes`, round, checkedAt);
|
|
190
|
-
|
|
191
|
-
if (!candidate) return invalidReview("Reviewer returned no JSON object", round, checkedAt);
|
|
191
|
+
if (!text.trim()) return invalidReview("Reviewer returned no JSON object", round, checkedAt);
|
|
192
192
|
try {
|
|
193
|
-
const
|
|
193
|
+
const objects = extractJsonObjects(text);
|
|
194
|
+
if (objects.length === 0) throw new Error("Reviewer output did not contain a JSON object");
|
|
195
|
+
if (objects.slice(1).some((value) => !isDeepStrictEqual(value, objects[0]))) {
|
|
196
|
+
throw new Error("Reviewer output contained multiple distinct JSON objects");
|
|
197
|
+
}
|
|
198
|
+
const value = objects[0] as Record<string, unknown>;
|
|
194
199
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Reviewer output must be a JSON object");
|
|
195
200
|
const verdict = value.verdict;
|
|
196
201
|
if (verdict !== "pass" && verdict !== "revise" && verdict !== "human") throw new Error("unsupported verdict");
|
|
197
202
|
const summary = typeof value.summary === "string" && value.summary.trim() ? value.summary.trim() : "no summary provided";
|
|
198
|
-
if (
|
|
199
|
-
if (
|
|
200
|
-
const findings =
|
|
203
|
+
if (!Array.isArray(value.findings)) throw new Error("findings must be an array");
|
|
204
|
+
if (value.findings.length > MAX_REVIEW_FINDINGS) throw new Error(`findings exceed the limit of ${MAX_REVIEW_FINDINGS}`);
|
|
205
|
+
const findings = value.findings.map((finding, index) => parseFinding(finding, index));
|
|
201
206
|
if (verdict === "revise" && findings.length === 0) throw new Error("revise verdict requires at least one finding");
|
|
202
207
|
return { verdict, summary: boundText(summary, 4_000), findings, round, checkedAt };
|
|
203
208
|
} catch (error) {
|
|
@@ -205,6 +210,56 @@ export function parseReview(text: string, round: number): ReviewReport {
|
|
|
205
210
|
}
|
|
206
211
|
}
|
|
207
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Claude occasionally wraps its answer in a fence, a sentence, or repeats
|
|
215
|
+
* the same JSON object. Extract one bounded object while rejecting conflicting
|
|
216
|
+
* objects: prose is harmless, but ambiguity must remain non-publishable.
|
|
217
|
+
*/
|
|
218
|
+
function extractJsonObjects(text: string): unknown[] {
|
|
219
|
+
const objects: unknown[] = [];
|
|
220
|
+
let offset = 0;
|
|
221
|
+
while (offset < text.length) {
|
|
222
|
+
const start = text.indexOf("{", offset);
|
|
223
|
+
if (start < 0) break;
|
|
224
|
+
const end = balancedObjectEnd(text, start);
|
|
225
|
+
if (end < 0) {
|
|
226
|
+
offset = start + 1;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const candidate = text.slice(start, end + 1);
|
|
230
|
+
try {
|
|
231
|
+
const value = JSON.parse(candidate) as unknown;
|
|
232
|
+
if (value && typeof value === "object" && !Array.isArray(value)) objects.push(value);
|
|
233
|
+
offset = end + 1;
|
|
234
|
+
} catch {
|
|
235
|
+
offset = start + 1;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return objects;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function balancedObjectEnd(text: string, start: number): number {
|
|
242
|
+
let depth = 0;
|
|
243
|
+
let inString = false;
|
|
244
|
+
let escaped = false;
|
|
245
|
+
for (let index = start; index < text.length; index += 1) {
|
|
246
|
+
const character = text[index];
|
|
247
|
+
if (inString) {
|
|
248
|
+
if (escaped) escaped = false;
|
|
249
|
+
else if (character === "\\") escaped = true;
|
|
250
|
+
else if (character === '"') inString = false;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (character === '"') {
|
|
254
|
+
inString = true;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (character === "{") depth += 1;
|
|
258
|
+
else if (character === "}" && --depth === 0) return index;
|
|
259
|
+
}
|
|
260
|
+
return -1;
|
|
261
|
+
}
|
|
262
|
+
|
|
208
263
|
function parseFinding(value: unknown, index: number): ReviewFinding {
|
|
209
264
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`finding ${index} must be an object`);
|
|
210
265
|
const source = value as Record<string, unknown>;
|
|
@@ -273,11 +328,11 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str
|
|
|
273
328
|
function boundTailText(value: unknown, maxBytes: number): string {
|
|
274
329
|
const text = String(value);
|
|
275
330
|
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
276
|
-
return `[
|
|
331
|
+
return `[DISPLAY_TRUNCATED]\n${Buffer.from(text, "utf8").subarray(-maxBytes).toString("utf8")}`;
|
|
277
332
|
}
|
|
278
333
|
|
|
279
334
|
function boundText(value: unknown, maxBytes: number): string {
|
|
280
335
|
const text = String(value);
|
|
281
336
|
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
282
|
-
return `${Buffer.from(text, "utf8").subarray(0, maxBytes).toString("utf8")}\n[
|
|
337
|
+
return `${Buffer.from(text, "utf8").subarray(0, maxBytes).toString("utf8")}\n[DISPLAY_TRUNCATED]`;
|
|
283
338
|
}
|
package/src/supervisor.ts
CHANGED
|
@@ -264,8 +264,8 @@ export class Supervisor {
|
|
|
264
264
|
},
|
|
265
265
|
});
|
|
266
266
|
this.#reportProgress("starting", "preflight and Worker startup");
|
|
267
|
-
if (this.#automation && this.#adapter.capabilities().transport
|
|
268
|
-
throw new Error("automatic supervision requires the claude-jsonl
|
|
267
|
+
if (this.#automation && !["jsonl", "tmux"].includes(this.#adapter.capabilities().transport)) {
|
|
268
|
+
throw new Error("automatic supervision requires the claude-jsonl or automated tmux transport");
|
|
269
269
|
}
|
|
270
270
|
const workerEnvironment = this.#automation ? automaticWorkerEnvironment(options.env) : options.env;
|
|
271
271
|
if (this.#automation) {
|
|
@@ -279,6 +279,7 @@ export class Supervisor {
|
|
|
279
279
|
args: workerArgs,
|
|
280
280
|
env: workerEnvironment,
|
|
281
281
|
approval: options.approval,
|
|
282
|
+
automatic: this.#automation,
|
|
282
283
|
});
|
|
283
284
|
this.#assertStartNotAborted(startAbortController.signal);
|
|
284
285
|
if (this.#automation) {
|
|
@@ -328,6 +329,7 @@ export class Supervisor {
|
|
|
328
329
|
tmuxSocket: options.tmuxSocket,
|
|
329
330
|
tmuxExpectedIdentity: options.tmuxExpectedIdentity,
|
|
330
331
|
sendInitialInput: options.sendInitialInput,
|
|
332
|
+
automatic: this.#automation,
|
|
331
333
|
eventListener: (event) => this.#receiveWorkerEvent(event),
|
|
332
334
|
env: workerEnvironment,
|
|
333
335
|
abortSignal: startAbortController.signal,
|
|
@@ -518,7 +520,7 @@ export class Supervisor {
|
|
|
518
520
|
await this.#parkCandidate(`Permission response is unavailable for ${event.type}`, event);
|
|
519
521
|
return;
|
|
520
522
|
}
|
|
521
|
-
const policy = evaluatePermission(event.request.toolName, event.request.input);
|
|
523
|
+
const policy = evaluatePermission(event.request.toolName, event.request.input, task.cwd);
|
|
522
524
|
const behavior = policy.decision === "deny" ? "deny" : action.action === "allow_permission" ? "allow" : "deny";
|
|
523
525
|
await this.#adapter.respondPermission(handle, event.request.requestId, event.request.toolUseId, {
|
|
524
526
|
behavior,
|
|
@@ -629,7 +631,7 @@ export class Supervisor {
|
|
|
629
631
|
const request = requestId ? this.#pendingPermissions.get(requestId) : [...this.#pendingPermissions.values()].at(-1);
|
|
630
632
|
if (!request) throw new Error("no pending permission request");
|
|
631
633
|
if (this.#humanRequired && this.#humanGate !== "permission") throw new Error("automatic decisions are held by a separate human gate; use resume-auto explicitly");
|
|
632
|
-
const policy = evaluatePermission(request.toolName, request.input);
|
|
634
|
+
const policy = evaluatePermission(request.toolName, request.input, task.cwd);
|
|
633
635
|
if (policy.decision === "deny" && behavior === "allow") throw new Error(`permission denied by policy: ${policy.reason}`);
|
|
634
636
|
await this.#adapter.respondPermission(handle, request.requestId, request.toolUseId, { behavior: policy.decision === "deny" ? "deny" : behavior }, behavior === "allow" ? request.input : undefined);
|
|
635
637
|
this.#pendingPermissions.delete(request.requestId);
|
package/src/types.ts
CHANGED
|
@@ -52,6 +52,8 @@ export interface WorkerStartInput {
|
|
|
52
52
|
};
|
|
53
53
|
/** Adopted sessions must not replay the task as a new user message. */
|
|
54
54
|
sendInitialInput?: boolean;
|
|
55
|
+
/** Automatic mode selects the structured tmux bridge instead of the manual TUI transport. */
|
|
56
|
+
automatic?: boolean;
|
|
55
57
|
/** Cancel startup before a worker is fully returned to the supervisor. */
|
|
56
58
|
abortSignal?: AbortSignal;
|
|
57
59
|
/** Internal token that scopes out-of-band startup cancellation. */
|
|
@@ -111,7 +113,7 @@ export interface PermissionDecision {
|
|
|
111
113
|
export interface WorkerAdapter {
|
|
112
114
|
capabilities(): WorkerCapabilities;
|
|
113
115
|
/** Validate host/transport prerequisites before starting a Worker. */
|
|
114
|
-
preflight?(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval">): Promise<void>;
|
|
116
|
+
preflight?(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval" | "automatic">): Promise<void>;
|
|
115
117
|
start(input: WorkerStartInput): Promise<WorkerHandle>;
|
|
116
118
|
/** Cancel adapter-owned startup work before a WorkerHandle is returned. */
|
|
117
119
|
abortStart?(reason: string, startupToken?: string): Promise<void>;
|
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";
|
|
@@ -310,6 +310,12 @@ async function collectUntrackedEvidence(cwd: string, signal?: AbortSignal): Prom
|
|
|
310
310
|
sections.push(`--- ${JSON.stringify(path)} [non-regular file omitted]`);
|
|
311
311
|
continue;
|
|
312
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);
|
|
313
319
|
const buffer = Buffer.alloc(MAX_UNTRACKED_FILE_BYTES + 1);
|
|
314
320
|
const read = await handle.read(buffer, 0, buffer.length, 0);
|
|
315
321
|
const bytes = buffer.subarray(0, read.bytesRead);
|
|
@@ -338,6 +344,17 @@ async function collectUntrackedEvidence(cwd: string, signal?: AbortSignal): Prom
|
|
|
338
344
|
return { ...bounded, complete: complete && bounded.complete, truncated: truncated || bounded.truncated };
|
|
339
345
|
}
|
|
340
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
|
+
|
|
341
358
|
async function assertNoSymlinkComponents(root: string, relativePath: string): Promise<void> {
|
|
342
359
|
const parts = relativePath.split(sep).filter(Boolean);
|
|
343
360
|
let current = root;
|
|
@@ -71,6 +71,24 @@ export function automaticWorkerEnvironment(explicit: NodeJS.ProcessEnv = {}): No
|
|
|
71
71
|
* operator-owned executable path so a custom path or writable replacement
|
|
72
72
|
* cannot silently omit this boundary.
|
|
73
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
|
+
*/
|
|
74
92
|
export function automaticClaudeArgs(command: string, args: readonly string[] = []): string[] {
|
|
75
93
|
if (!isDirectClaudeName(command)) {
|
|
76
94
|
throw new Error("automatic supervision requires the direct Claude executable command name; custom executable paths need their own host boundary");
|
|
@@ -78,8 +96,22 @@ export function automaticClaudeArgs(command: string, args: readonly string[] = [
|
|
|
78
96
|
if (args.some((arg) => arg === "--settings" || arg.startsWith("--settings="))) {
|
|
79
97
|
throw new Error("automatic Claude supervision controls --settings; remove the caller-provided settings override");
|
|
80
98
|
}
|
|
81
|
-
|
|
82
|
-
|
|
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",
|
|
83
115
|
"--settings",
|
|
84
116
|
JSON.stringify({
|
|
85
117
|
sandbox: {
|
|
@@ -89,7 +121,23 @@ export function automaticClaudeArgs(command: string, args: readonly string[] = [
|
|
|
89
121
|
network: { allowedDomains: [] },
|
|
90
122
|
},
|
|
91
123
|
}),
|
|
92
|
-
|
|
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, ""));
|
|
93
141
|
}
|
|
94
142
|
|
|
95
143
|
/**
|