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/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 {
|
|
@@ -5,18 +8,97 @@ export interface PolicyResult {
|
|
|
5
8
|
reason: string;
|
|
6
9
|
}
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Permission decisions are autonomous for local development. A denial is a
|
|
13
|
+
* hard capability boundary, not a request for a human to approve the next
|
|
14
|
+
* turn. AskUserQuestion is denied so the Worker can restate the question as
|
|
15
|
+
* ordinary text and the Decision Worker can answer it from task evidence.
|
|
16
|
+
*/
|
|
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 {
|
|
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
|
+
}
|
|
28
|
+
if (toolName !== "Bash") return { decision: "allow", reason: `local Claude tool is allowed by the task policy: ${toolName}` };
|
|
11
29
|
const command = input && typeof input === "object" && typeof (input as { command?: unknown }).command === "string"
|
|
12
30
|
? (input as { command: string }).command
|
|
13
31
|
: "";
|
|
14
|
-
if (!command) return { decision: "
|
|
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
|
+
}
|
|
15
36
|
return evaluateCommand("bash", ["-lc", command]);
|
|
16
37
|
}
|
|
17
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
|
+
|
|
18
96
|
const deniedPatterns = [
|
|
19
|
-
|
|
97
|
+
/\b(?:npm|pnpm|yarn)\b[\s\S]*\bpublish\b/iu,
|
|
98
|
+
/\b(?:ssh|scp|sftp|rsync)\b/iu,
|
|
99
|
+
/\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,
|
|
100
|
+
/(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))[\s\S]*\b(?:push|merge|publish)\b|\b(?:push|merge|publish)\b[\s\S]*(?:\$\{?[^\s`}]+\}?|`[^`]*`|\$\([^)]*\))/iu,
|
|
101
|
+
/--(?:allow-)?dangerously-skip-permissions\b/iu,
|
|
20
102
|
/--permission-mode\s+(?:bypasspermissions|dontask)\b/iu,
|
|
21
103
|
/\brm\s+-rf\s+\//iu,
|
|
22
104
|
/\bmkfs(?:\.|\s)/iu,
|
|
@@ -25,37 +107,227 @@ const deniedPatterns = [
|
|
|
25
107
|
/\b(shutdown|reboot|poweroff)\b/iu,
|
|
26
108
|
];
|
|
27
109
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
];
|
|
110
|
+
interface ShellToken {
|
|
111
|
+
value: string;
|
|
112
|
+
operator: boolean;
|
|
113
|
+
dynamic: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const protectedBranches = new Set(["main", "master", "trunk", "integration", "develop"]);
|
|
117
|
+
const protectedBranchOperations = new Set(["checkout", "switch", "branch", "reset", "restore", "worktree", "update-ref", "symbolic-ref"]);
|
|
118
|
+
const remoteOperations = new Set(["push", "merge", "send-pack", "receive-pack", "update-ref"]);
|
|
36
119
|
|
|
37
120
|
export function evaluateCommand(command: string, args: readonly string[] = []): PolicyResult {
|
|
38
|
-
const normalized =
|
|
121
|
+
const normalized = command.trim();
|
|
122
|
+
if (!normalized) return { decision: "deny", reason: "empty command" };
|
|
123
|
+
const lexical = lexShell(normalized);
|
|
124
|
+
if (lexical.error) return { decision: "deny", reason: `command could not be safely parsed: ${lexical.error}` };
|
|
125
|
+
const literalArgs = args.map((value) => ({ value, operator: false, dynamic: false }));
|
|
126
|
+
return evaluateTokens([...lexical.tokens, ...literalArgs], 0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function evaluateRepositoryBoundary(tokens: readonly ShellToken[], canonical: string, depth: number): PolicyResult | undefined {
|
|
130
|
+
const values = tokens.filter((token) => !token.operator).map((token) => token.value);
|
|
131
|
+
const lower = values.map((value) => value.toLowerCase());
|
|
132
|
+
const hasGit = values.some((value) => /(?:^|[\\/])git$/iu.test(value) || /^(?:git-(?:send|receive|upload)-pack)$/iu.test(value));
|
|
133
|
+
const hasGitTransport = lower.some((value) => /^(?:git-(?:send|receive|upload)-pack)$/u.test(value));
|
|
134
|
+
const hasRemoteOperation = lower.some((value) => remoteOperations.has(value));
|
|
135
|
+
const hasGhRemote = lower.some((value) => value === "gh" || value === "glab" || value === "hub")
|
|
136
|
+
&& lower.some((value) => value === "api" || value === "release" || value === "pull-request" || value === "pr" || value === "mr");
|
|
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));
|
|
139
|
+
if (depth < 4) {
|
|
140
|
+
for (const nested of nestedShellCommands(lower, values)) {
|
|
141
|
+
const nestedResult = evaluateCommandInternal(nested, depth + 1);
|
|
142
|
+
if (nestedResult.decision === "deny") return nestedResult;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const hasDynamicArgument = tokens.some((token) => !token.operator && token.dynamic);
|
|
146
|
+
const hasProtectedBranch = lower.some((value) => protectedBranches.has(value))
|
|
147
|
+
|| /(?:^|\s)(?:[^\s]*\.git[\\/]refs[\\/]heads[\\/]|[^\s]*refs[\\/]heads[\\/])(?:main|master|trunk|integration|develop)(?:$|\s)/iu.test(canonical);
|
|
148
|
+
const gitOperation = lower.find((value) => protectedBranchOperations.has(value));
|
|
149
|
+
|
|
150
|
+
if (hasDynamicArgument) {
|
|
151
|
+
return { decision: "deny", reason: "dynamic shell arguments cannot be capability-checked safely" };
|
|
152
|
+
}
|
|
153
|
+
if (/\bgit\b[\s\S]*\b(?:push|merge|send-pack|receive-pack|update-ref)\b/iu.test(canonical)
|
|
154
|
+
|| /\bgit-(?:send|receive|upload)-pack\b/iu.test(canonical)
|
|
155
|
+
|| /\b(?:gh|glab|hub)\b[\s\S]*\b(?:api|pr|mr|pull-request|release)\b/iu.test(canonical)) {
|
|
156
|
+
return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
|
|
157
|
+
}
|
|
158
|
+
if (hasGit && (hasRemoteOperation || hasGitTransport) || hasGhRemote) {
|
|
159
|
+
return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
|
|
160
|
+
}
|
|
161
|
+
if (hasGitAliasConfiguration) {
|
|
162
|
+
return { decision: "deny", reason: "Worker cannot redefine Git command aliases" };
|
|
163
|
+
}
|
|
164
|
+
if (hasGit && gitOperation && hasProtectedBranch) {
|
|
165
|
+
return { decision: "deny", reason: "Worker cannot switch to or mutate a protected integration branch" };
|
|
166
|
+
}
|
|
167
|
+
const directRefWrite = tokens.some((token) => token.operator && (token.value === ">" || token.value === ">>"))
|
|
168
|
+
|| ["cp", "echo", "install", "mv", "printf", "sed", "tee"].includes(lower[0] ?? "");
|
|
169
|
+
if (/\.git[\\/](?:HEAD|packed-refs|refs[\\/]heads[\\/])/iu.test(canonical)
|
|
170
|
+
|| (directRefWrite && hasProtectedBranch && /refs[\\/]heads[\\/]/iu.test(canonical))) {
|
|
171
|
+
return { decision: "deny", reason: "Worker cannot write protected Git branch refs directly" };
|
|
172
|
+
}
|
|
173
|
+
if (hasPackagePublication) {
|
|
174
|
+
return { decision: "deny", reason: "package publication belongs to the protected release workflow" };
|
|
175
|
+
}
|
|
176
|
+
const unsafePermissionMode = (value: string): boolean => value.replace(/[-_]/gu, "") === "bypasspermissions" || value.replace(/[-_]/gu, "") === "dontask";
|
|
177
|
+
if (lower.some((value) => value.startsWith("--permission-mode=") && unsafePermissionMode(value.slice("--permission-mode=".length)))
|
|
178
|
+
|| (lower.includes("--permission-mode") && lower.some((value) => unsafePermissionMode(value)))) {
|
|
179
|
+
return { decision: "deny", reason: "Worker cannot bypass Claude permission prompts" };
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
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
|
+
|
|
205
|
+
function nestedShellCommands(lower: readonly string[], values: readonly string[]): string[] {
|
|
206
|
+
const nested: string[] = [];
|
|
207
|
+
const shellNames = new Set(["sh", "bash", "dash", "zsh", "fish", "ksh"]);
|
|
208
|
+
for (let index = 0; index < lower.length; index += 1) {
|
|
209
|
+
const executable = lower[index]!.split(/[\\/]/u).at(-1);
|
|
210
|
+
if (executable && shellNames.has(executable)) {
|
|
211
|
+
for (let option = index + 1; option < lower.length; option += 1) {
|
|
212
|
+
if (["-c", "--command"].includes(lower[option]!)) {
|
|
213
|
+
const command = values.slice(option + 1).join(" ").trim();
|
|
214
|
+
if (command) nested.push(command);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (lower[index] === "eval") {
|
|
220
|
+
const command = values.slice(index + 1).join(" ").trim();
|
|
221
|
+
if (command) nested.push(command);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return nested;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function evaluateCommandInternal(command: string, depth: number): PolicyResult {
|
|
228
|
+
const normalized = command.trim();
|
|
39
229
|
if (!normalized) return { decision: "deny", reason: "empty command" };
|
|
40
|
-
|
|
230
|
+
const lexical = lexShell(normalized);
|
|
231
|
+
if (lexical.error) return { decision: "deny", reason: `command could not be safely parsed: ${lexical.error}` };
|
|
232
|
+
return evaluateTokens(lexical.tokens, depth);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function evaluateTokens(tokens: readonly ShellToken[], depth: number): PolicyResult {
|
|
236
|
+
const canonical = tokens.map((token) => token.value).join(" ").trim();
|
|
237
|
+
if (!canonical) return { decision: "deny", reason: "empty command" };
|
|
238
|
+
const boundary = evaluateRepositoryBoundary(tokens, canonical, depth);
|
|
239
|
+
if (boundary) return boundary;
|
|
240
|
+
if (deniedPatterns.some((pattern) => pattern.test(canonical))) {
|
|
241
|
+
if (/\b(?:curl|wget)\b[\s\S]*(?:github|gitlab|bitbucket|registry\.npmjs)\b/iu.test(canonical)) {
|
|
242
|
+
return { decision: "deny", reason: "Worker has no remote repository or main/integration merge authority" };
|
|
243
|
+
}
|
|
244
|
+
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)) {
|
|
245
|
+
return { decision: "deny", reason: "Worker cannot switch to or mutate a protected integration branch" };
|
|
246
|
+
}
|
|
247
|
+
if (/\b(?:npm|pnpm|yarn)\b[\s\S]*\bpublish\b/iu.test(canonical)) {
|
|
248
|
+
return { decision: "deny", reason: "package publication belongs to the protected release workflow" };
|
|
249
|
+
}
|
|
41
250
|
return { decision: "deny", reason: "command matches a prohibited destructive pattern" };
|
|
42
251
|
}
|
|
43
|
-
|
|
44
|
-
|
|
252
|
+
return { decision: "allow", reason: "command is allowed for unattended local development" };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function lexShell(input: string): { tokens: ShellToken[]; error?: string } {
|
|
256
|
+
const tokens: ShellToken[] = [];
|
|
257
|
+
let value = "";
|
|
258
|
+
let dynamic = false;
|
|
259
|
+
let started = false;
|
|
260
|
+
let quote: "single" | "double" | undefined;
|
|
261
|
+
const flush = (): void => {
|
|
262
|
+
if (!started) return;
|
|
263
|
+
tokens.push({ value, operator: false, dynamic });
|
|
264
|
+
value = "";
|
|
265
|
+
dynamic = false;
|
|
266
|
+
started = false;
|
|
267
|
+
};
|
|
268
|
+
const pushOperator = (operator: string): void => {
|
|
269
|
+
flush();
|
|
270
|
+
tokens.push({ value: operator, operator: true, dynamic: false });
|
|
271
|
+
};
|
|
272
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
273
|
+
const character = input[index]!;
|
|
274
|
+
const next = input[index + 1];
|
|
275
|
+
if (quote === "single") {
|
|
276
|
+
if (character === "'") quote = undefined;
|
|
277
|
+
else value += character;
|
|
278
|
+
started = true;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (quote === "double") {
|
|
282
|
+
if (character === '"') quote = undefined;
|
|
283
|
+
else if (character === "\\" && next === "\n") index += 1;
|
|
284
|
+
else if (character === "\\" && next !== undefined && /[\\"$`]/u.test(next)) { value += next; index += 1; }
|
|
285
|
+
else { value += character; if (character === "$" || character === "`") dynamic = true; }
|
|
286
|
+
started = true;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (character === "'") { quote = "single"; started = true; continue; }
|
|
290
|
+
if (character === '"') { quote = "double"; started = true; continue; }
|
|
291
|
+
if (character === "\\") {
|
|
292
|
+
if (next === "\n") index += 1;
|
|
293
|
+
else if (next !== undefined) { value += next; index += 1; }
|
|
294
|
+
started = true;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (/\s/u.test(character)) { flush(); continue; }
|
|
298
|
+
if (character === "$" || character === "`") { dynamic = true; value += character; started = true; continue; }
|
|
299
|
+
// Brace, tilde and pathname expansion can change command names, targets or
|
|
300
|
+
// Git refs after this lexical pass. Treat all unquoted expansion markers as
|
|
301
|
+
// dynamic rather than attempting to model Bash's expansion order.
|
|
302
|
+
if ("*?[]{}~".includes(character)) { dynamic = true; value += character; started = true; continue; }
|
|
303
|
+
if (";&|<>".includes(character)) {
|
|
304
|
+
const operator = next && ((character === "&" && next === "&") || (character === "|" && next === "|") || (character === ">" && next === ">") || (character === "<" && next === "<"))
|
|
305
|
+
? `${character}${next}`
|
|
306
|
+
: character;
|
|
307
|
+
pushOperator(operator);
|
|
308
|
+
if (operator.length === 2) index += 1;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
value += character;
|
|
312
|
+
started = true;
|
|
45
313
|
}
|
|
46
|
-
return {
|
|
314
|
+
if (quote) return { tokens, error: "unterminated quote" };
|
|
315
|
+
flush();
|
|
316
|
+
return { tokens };
|
|
47
317
|
}
|
|
48
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Keep the approval argument for persisted v0.5.x records and embedding API
|
|
321
|
+
* compatibility. It is intentionally not required for local review patterns;
|
|
322
|
+
* only hard-denied capability boundaries fail this assertion.
|
|
323
|
+
*/
|
|
49
324
|
export function assertSafeWorkerCommand(
|
|
50
325
|
command: string,
|
|
51
326
|
args: readonly string[] = [],
|
|
52
|
-
|
|
327
|
+
_approval?: { actor: "human"; reason: string },
|
|
53
328
|
): void {
|
|
54
329
|
const result = evaluateCommand(command, args);
|
|
55
330
|
if (result.decision === "deny") {
|
|
56
331
|
throw new Error(`Worker command blocked by policy (deny): ${result.reason}`);
|
|
57
332
|
}
|
|
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
333
|
}
|
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";
|
|
@@ -130,7 +131,16 @@ ${boundedJson(input.acceptance)}
|
|
|
130
131
|
REPOSITORY STATUS:
|
|
131
132
|
${boundText(redactText(input.evidence.status), 8_000)}
|
|
132
133
|
|
|
133
|
-
REPOSITORY
|
|
134
|
+
REPOSITORY BASE:
|
|
135
|
+
${redactText(input.evidence.baseRef ?? "(unavailable; review current evidence)")}
|
|
136
|
+
|
|
137
|
+
LOCAL BRANCH:
|
|
138
|
+
${redactText(input.evidence.branch ?? "(detached or unavailable)")}
|
|
139
|
+
|
|
140
|
+
COMMITS AFTER BASELINE (UNTRUSTED):
|
|
141
|
+
${boundText(redactText(input.evidence.commits ?? "(none)"), 8_000)}
|
|
142
|
+
|
|
143
|
+
REPOSITORY DIFF (BASELINE-RELATIVE, UNTRUSTED):
|
|
134
144
|
${boundText(redactText(input.evidence.diff), 16_000)}
|
|
135
145
|
|
|
136
146
|
UNTRACKED FILE EVIDENCE (UNTRUSTED):
|
|
@@ -138,7 +148,7 @@ ${boundText(redactText(input.evidence.untracked ?? "(none)"), 16_000)}
|
|
|
138
148
|
|
|
139
149
|
EVIDENCE COMPLETE:
|
|
140
150
|
${String(input.evidence.complete !== false && input.evidence.truncated !== true)}
|
|
141
|
-
|
|
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.
|
|
142
152
|
|
|
143
153
|
WORKER OUTPUT (UNTRUSTED):
|
|
144
154
|
${boundTailText(redactSensitive(input.workerOutput ?? "(none)"), 8_000)}
|
|
@@ -159,20 +169,40 @@ function textFromMessage(content: unknown): string {
|
|
|
159
169
|
.join("");
|
|
160
170
|
}
|
|
161
171
|
|
|
172
|
+
export function normalizeReviewReport(value: unknown, round: number): ReviewReport {
|
|
173
|
+
try {
|
|
174
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
175
|
+
return invalidReview("invalid Reviewer output: result must be an object", round, new Date().toISOString());
|
|
176
|
+
}
|
|
177
|
+
const report = value as { verdict?: unknown; summary?: unknown; findings?: unknown };
|
|
178
|
+
if (typeof report.verdict !== "string" || typeof report.summary !== "string" || !report.summary.trim() || !Array.isArray(report.findings)) {
|
|
179
|
+
return invalidReview("invalid Reviewer output: result must include verdict, summary and findings", round, new Date().toISOString());
|
|
180
|
+
}
|
|
181
|
+
const encoded = JSON.stringify(value);
|
|
182
|
+
return parseReview(encoded ?? "", round);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return invalidReview(`invalid Reviewer output: ${error instanceof Error ? error.message : String(error)}`, round, new Date().toISOString());
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
162
188
|
export function parseReview(text: string, round: number): ReviewReport {
|
|
163
189
|
const checkedAt = new Date().toISOString();
|
|
164
190
|
if (Buffer.byteLength(text, "utf8") > MAX_REVIEW_RESPONSE_BYTES) return invalidReview(`Reviewer response exceeded ${MAX_REVIEW_RESPONSE_BYTES} bytes`, round, checkedAt);
|
|
165
|
-
|
|
166
|
-
if (!candidate) return invalidReview("Reviewer returned no JSON object", round, checkedAt);
|
|
191
|
+
if (!text.trim()) return invalidReview("Reviewer returned no JSON object", round, checkedAt);
|
|
167
192
|
try {
|
|
168
|
-
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>;
|
|
169
199
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Reviewer output must be a JSON object");
|
|
170
200
|
const verdict = value.verdict;
|
|
171
201
|
if (verdict !== "pass" && verdict !== "revise" && verdict !== "human") throw new Error("unsupported verdict");
|
|
172
202
|
const summary = typeof value.summary === "string" && value.summary.trim() ? value.summary.trim() : "no summary provided";
|
|
173
|
-
if (
|
|
174
|
-
if (
|
|
175
|
-
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));
|
|
176
206
|
if (verdict === "revise" && findings.length === 0) throw new Error("revise verdict requires at least one finding");
|
|
177
207
|
return { verdict, summary: boundText(summary, 4_000), findings, round, checkedAt };
|
|
178
208
|
} catch (error) {
|
|
@@ -180,6 +210,56 @@ export function parseReview(text: string, round: number): ReviewReport {
|
|
|
180
210
|
}
|
|
181
211
|
}
|
|
182
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
|
+
|
|
183
263
|
function parseFinding(value: unknown, index: number): ReviewFinding {
|
|
184
264
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`finding ${index} must be an object`);
|
|
185
265
|
const source = value as Record<string, unknown>;
|
|
@@ -248,11 +328,11 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str
|
|
|
248
328
|
function boundTailText(value: unknown, maxBytes: number): string {
|
|
249
329
|
const text = String(value);
|
|
250
330
|
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
251
|
-
return `[
|
|
331
|
+
return `[DISPLAY_TRUNCATED]\n${Buffer.from(text, "utf8").subarray(-maxBytes).toString("utf8")}`;
|
|
252
332
|
}
|
|
253
333
|
|
|
254
334
|
function boundText(value: unknown, maxBytes: number): string {
|
|
255
335
|
const text = String(value);
|
|
256
336
|
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
257
|
-
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]`;
|
|
258
338
|
}
|
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 {
|