@shanepadgett/tau-agent 0.45.1 → 0.46.1
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/extensions/patch/README.md +1 -1
- package/extensions/patch/index.ts +7 -6
- package/extensions/soul/README.md +1 -1
- package/extensions/soul/index.ts +6 -1
- package/extensions/soul/prompt.ts +7 -1
- package/extensions/tau-help/help.md +5 -3
- package/extensions/tool-approval/README.md +11 -3
- package/extensions/tool-approval/evidence.ts +679 -0
- package/extensions/tool-approval/index.ts +377 -120
- package/package.json +2 -2
- package/shared/events.ts +20 -44
- package/shared/model-fallback/index.ts +10 -4
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { open, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, extname, resolve } from "node:path";
|
|
5
|
+
import { parse, type Node, type Word } from "unbash";
|
|
6
|
+
import { errorText, truncAt } from "../../shared/text.ts";
|
|
7
|
+
|
|
8
|
+
const MAX_FILES = 4;
|
|
9
|
+
const MAX_SOURCE_BYTES = 48 * 1024;
|
|
10
|
+
const MAX_WORK_MS = 2000;
|
|
11
|
+
const MAX_REFERENCES = 32;
|
|
12
|
+
|
|
13
|
+
interface ExecutionTarget {
|
|
14
|
+
path: string;
|
|
15
|
+
cwd: string;
|
|
16
|
+
task: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ReviewedFile {
|
|
20
|
+
path: string;
|
|
21
|
+
canonicalPath: string;
|
|
22
|
+
fingerprint: string;
|
|
23
|
+
source: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type EvidenceGapReason =
|
|
27
|
+
| "inspection_budget_exceeded"
|
|
28
|
+
| "unsupported_execution"
|
|
29
|
+
| "code_loading_configuration"
|
|
30
|
+
| "unresolved_target"
|
|
31
|
+
| "source_unavailable"
|
|
32
|
+
| "invalid_reference"
|
|
33
|
+
| "task_unavailable";
|
|
34
|
+
|
|
35
|
+
// Internal reviewer evidence is inherently bounded, not a model-visible public tool result.
|
|
36
|
+
export class ApprovalEvidence {
|
|
37
|
+
readonly files: ReviewedFile[] = [];
|
|
38
|
+
readonly gaps: string[] = [];
|
|
39
|
+
readonly gapReasons = new Set<EvidenceGapReason>();
|
|
40
|
+
readonly targets = new Map<string, ExecutionTarget>();
|
|
41
|
+
private readonly localImports = new Map<
|
|
42
|
+
string,
|
|
43
|
+
{ paths: string[]; cwd: string; importRoot: string; required: boolean }
|
|
44
|
+
>();
|
|
45
|
+
private readonly importRoots = new Map<string, string>();
|
|
46
|
+
private readonly absentPaths = new Set<string>();
|
|
47
|
+
private readonly references = new Map<string, ExecutionTarget>();
|
|
48
|
+
private sourceBytes = 0;
|
|
49
|
+
private workStarted = 0;
|
|
50
|
+
private shellNodes = 0;
|
|
51
|
+
private importChecks = 0;
|
|
52
|
+
readonly cwd: string;
|
|
53
|
+
readonly signal: AbortSignal | undefined;
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
cwd: string,
|
|
57
|
+
signal: AbortSignal | undefined,
|
|
58
|
+
toolName: "bash" | "script_runner",
|
|
59
|
+
input: Record<string, unknown>,
|
|
60
|
+
) {
|
|
61
|
+
this.cwd = cwd;
|
|
62
|
+
this.signal = signal;
|
|
63
|
+
if (toolName === "bash" && typeof input.command === "string") this.shell(input.command, cwd, 0);
|
|
64
|
+
if (toolName === "script_runner" && typeof input.script === "string") {
|
|
65
|
+
// script_runner stages source in a new temporary directory, not in the project.
|
|
66
|
+
this.source(input.script, undefined, cwd, input.language === "python3" ? ".py" : ".js");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private gap(reason: EvidenceGapReason, message: string): void {
|
|
71
|
+
this.gapReasons.add(reason);
|
|
72
|
+
if (this.gaps.length < 8) this.gaps.push(truncAt(message, 400));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private reference(path: string, cwd: string, required: boolean, task: string | undefined): void {
|
|
76
|
+
const absolute = resolve(cwd, path);
|
|
77
|
+
if (this.references.size >= MAX_REFERENCES && !this.references.has(absolute)) {
|
|
78
|
+
this.gap("inspection_budget_exceeded", "Too many execution references to inspect within the review budget.");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const target = { path: absolute, cwd, task };
|
|
82
|
+
this.references.set(absolute, target);
|
|
83
|
+
if (required) this.targets.set(`${absolute}:${task ?? ""}`, target);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private shell(command: string, cwd: string, depth: number): void {
|
|
87
|
+
if (Buffer.byteLength(command) > MAX_SOURCE_BYTES) {
|
|
88
|
+
this.gap("inspection_budget_exceeded", "Shell source exceeds the inspection work budget.");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (depth > 8) {
|
|
92
|
+
this.gap("inspection_budget_exceeded", "Nested shell execution exceeds the inspection budget.");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
let script: ReturnType<typeof parse>;
|
|
96
|
+
try {
|
|
97
|
+
script = parse(command);
|
|
98
|
+
} catch {
|
|
99
|
+
this.gap("unsupported_execution", "The shell command could not be parsed to identify code it executes.");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (script.errors?.length) {
|
|
103
|
+
this.gap(
|
|
104
|
+
"unsupported_execution",
|
|
105
|
+
"The shell command has unsupported syntax, so its execution targets could not be verified.",
|
|
106
|
+
);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const visit = (node: Node, currentCwd: string): string => {
|
|
110
|
+
if (++this.shellNodes > 256) {
|
|
111
|
+
this.gap("inspection_budget_exceeded", "Shell execution exceeds the inspection work budget.");
|
|
112
|
+
return currentCwd;
|
|
113
|
+
}
|
|
114
|
+
switch (node.type) {
|
|
115
|
+
case "Statement":
|
|
116
|
+
return visit(node.command, currentCwd);
|
|
117
|
+
case "AndOr":
|
|
118
|
+
if (node.operators.includes("||") && /\bcd\b/.test(command))
|
|
119
|
+
this.gap("unresolved_target", "Conditional directory changes make execution targets uncertain.");
|
|
120
|
+
for (const child of node.commands) currentCwd = visit(child, currentCwd);
|
|
121
|
+
return currentCwd;
|
|
122
|
+
case "CompoundList":
|
|
123
|
+
for (const child of node.commands) currentCwd = visit(child, currentCwd);
|
|
124
|
+
return currentCwd;
|
|
125
|
+
case "Pipeline":
|
|
126
|
+
for (const child of node.commands) visit(child, currentCwd);
|
|
127
|
+
return currentCwd;
|
|
128
|
+
case "Subshell":
|
|
129
|
+
visit(node.body, currentCwd);
|
|
130
|
+
return currentCwd;
|
|
131
|
+
case "BraceGroup":
|
|
132
|
+
return visit(node.body, currentCwd);
|
|
133
|
+
case "Command": {
|
|
134
|
+
if (!node.name) return currentCwd;
|
|
135
|
+
const name = staticWord(node.name);
|
|
136
|
+
const args = node.suffix.map(staticWord);
|
|
137
|
+
if (!name) {
|
|
138
|
+
this.gap(
|
|
139
|
+
"unresolved_target",
|
|
140
|
+
"The executable name is computed at runtime and could not be inspected.",
|
|
141
|
+
);
|
|
142
|
+
return currentCwd;
|
|
143
|
+
}
|
|
144
|
+
const executable = basename(name);
|
|
145
|
+
for (const arg of args) {
|
|
146
|
+
if (arg && /\.(?:py|js|mjs|cjs|ts|sh|bash|json)$/.test(arg))
|
|
147
|
+
this.reference(arg, currentCwd, false, undefined);
|
|
148
|
+
}
|
|
149
|
+
for (const word of [node.name, ...node.suffix]) {
|
|
150
|
+
const parts = [...(word.parts ?? [])];
|
|
151
|
+
for (const part of parts) {
|
|
152
|
+
if (part.type === "DoubleQuoted" || part.type === "LocaleString") parts.push(...part.parts);
|
|
153
|
+
if (part.type === "CommandExpansion" || part.type === "ProcessSubstitution") {
|
|
154
|
+
if (part.inner) this.shell(part.inner, currentCwd, depth + 1);
|
|
155
|
+
else
|
|
156
|
+
this.gap("unresolved_target", "A nested executable shell expression could not be resolved.");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (node.prefix.some((prefix) => /^(?:PATH|PYTHONPATH|NODE_OPTIONS|BASH_ENV|ENV)=/.test(prefix.text))) {
|
|
161
|
+
this.gap(
|
|
162
|
+
"code_loading_configuration",
|
|
163
|
+
"The command changes code-loading configuration that could hide execution targets.",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (executable === "cd") {
|
|
167
|
+
const path = args[0];
|
|
168
|
+
if (!path || path.startsWith("-")) {
|
|
169
|
+
this.gap(
|
|
170
|
+
"unresolved_target",
|
|
171
|
+
"The working directory could not be resolved for execution inspection.",
|
|
172
|
+
);
|
|
173
|
+
return currentCwd;
|
|
174
|
+
}
|
|
175
|
+
return resolve(currentCwd, path);
|
|
176
|
+
}
|
|
177
|
+
if (["env", "command", "exec", "sudo", "timeout", "nohup"].includes(executable)) {
|
|
178
|
+
this.gap(
|
|
179
|
+
"unresolved_target",
|
|
180
|
+
`Execution through ${executable} needs confirmation because its target was not resolved.`,
|
|
181
|
+
);
|
|
182
|
+
return currentCwd;
|
|
183
|
+
}
|
|
184
|
+
if (["npm", "pnpm", "yarn"].includes(executable)) {
|
|
185
|
+
const task = args[0] === "run" || args[0] === "run-script" ? args[1] : args[0];
|
|
186
|
+
const packageTask =
|
|
187
|
+
executable !== "npm" ||
|
|
188
|
+
["run", "run-script", "start", "test", "stop", "restart"].includes(args[0] ?? "");
|
|
189
|
+
if (
|
|
190
|
+
packageTask &&
|
|
191
|
+
task &&
|
|
192
|
+
!["install", "ci", "add", "remove", "uninstall", "--version", "-v", "help"].includes(task)
|
|
193
|
+
) {
|
|
194
|
+
const separator = args.indexOf("--");
|
|
195
|
+
const taskArgs = separator < 0 ? args : args.slice(0, separator);
|
|
196
|
+
if (taskArgs.some((arg) => arg === undefined || arg.startsWith("-"))) {
|
|
197
|
+
this.gap(
|
|
198
|
+
"unresolved_target",
|
|
199
|
+
"Package-task options could change the manifest or execution target.",
|
|
200
|
+
);
|
|
201
|
+
} else {
|
|
202
|
+
this.reference("package.json", currentCwd, true, task);
|
|
203
|
+
for (const config of executable === "yarn"
|
|
204
|
+
? [".npmrc", ".yarnrc", ".yarnrc.yml"]
|
|
205
|
+
: [".npmrc"]) {
|
|
206
|
+
this.localImports.set(`${currentCwd}:${config}`, {
|
|
207
|
+
paths: [resolve(currentCwd, config)],
|
|
208
|
+
cwd: currentCwd,
|
|
209
|
+
importRoot: currentCwd,
|
|
210
|
+
required: false,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return currentCwd;
|
|
216
|
+
}
|
|
217
|
+
if (/^(?:python(?:\d+(?:\.\d+)?)?|node|deno|bash|sh|zsh|dash)$/.test(executable)) {
|
|
218
|
+
const shell = ["bash", "sh", "zsh", "dash"].includes(executable);
|
|
219
|
+
const consumed = new Set<number>();
|
|
220
|
+
for (let index = 0; index < args.length; index++) {
|
|
221
|
+
const option = args[index];
|
|
222
|
+
if (["-r", "--require", "--import", "--loader", "--rcfile"].includes(option ?? "")) {
|
|
223
|
+
const path = args[index + 1];
|
|
224
|
+
if (path) this.reference(path, currentCwd, true, undefined);
|
|
225
|
+
else this.gap("unresolved_target", "A runtime preload target is computed or missing.");
|
|
226
|
+
consumed.add(index + 1);
|
|
227
|
+
}
|
|
228
|
+
if (
|
|
229
|
+
option?.startsWith("--require=") ||
|
|
230
|
+
option?.startsWith("--import=") ||
|
|
231
|
+
option?.startsWith("--loader=")
|
|
232
|
+
)
|
|
233
|
+
this.reference(option.slice(option.indexOf("=") + 1), currentCwd, true, undefined);
|
|
234
|
+
}
|
|
235
|
+
const moduleIndex = args.indexOf("-m");
|
|
236
|
+
if (executable.startsWith("python") && moduleIndex >= 0) {
|
|
237
|
+
const module = args[moduleIndex + 1];
|
|
238
|
+
if (module && /^[\w.]+$/.test(module)) {
|
|
239
|
+
const path = module.replaceAll(".", "/");
|
|
240
|
+
this.localImports.set(`${currentCwd}:${module}`, {
|
|
241
|
+
paths: [resolve(currentCwd, `${path}.py`), resolve(currentCwd, path, "__main__.py")],
|
|
242
|
+
cwd: currentCwd,
|
|
243
|
+
importRoot: currentCwd,
|
|
244
|
+
required: false,
|
|
245
|
+
});
|
|
246
|
+
this.localImports.set(`${currentCwd}:${module}/__init__`, {
|
|
247
|
+
paths: [resolve(currentCwd, path, "__init__.py")],
|
|
248
|
+
cwd: currentCwd,
|
|
249
|
+
importRoot: currentCwd,
|
|
250
|
+
required: false,
|
|
251
|
+
});
|
|
252
|
+
} else this.gap("unresolved_target", "The Python module execution target is computed at runtime.");
|
|
253
|
+
return currentCwd;
|
|
254
|
+
}
|
|
255
|
+
const inlineIndex = args.findIndex(
|
|
256
|
+
(arg) =>
|
|
257
|
+
arg === "-c" ||
|
|
258
|
+
arg === "-e" ||
|
|
259
|
+
arg === "--eval" ||
|
|
260
|
+
arg === "--print" ||
|
|
261
|
+
arg === "-p" ||
|
|
262
|
+
(shell && /^-[a-z]*c[a-z]*$/.test(arg ?? "")),
|
|
263
|
+
);
|
|
264
|
+
if (inlineIndex >= 0) {
|
|
265
|
+
const source = args[inlineIndex + 1];
|
|
266
|
+
if (source === undefined)
|
|
267
|
+
this.gap("unresolved_target", "Inline executable source is computed at runtime.");
|
|
268
|
+
else if (shell) this.shell(source, currentCwd, depth + 1);
|
|
269
|
+
else this.source(source, currentCwd, currentCwd, executable.startsWith("python") ? ".py" : ".js");
|
|
270
|
+
return currentCwd;
|
|
271
|
+
}
|
|
272
|
+
const target = args.find(
|
|
273
|
+
(arg, index) =>
|
|
274
|
+
!consumed.has(index) && (arg === undefined || (arg !== "run" && !arg.startsWith("-"))),
|
|
275
|
+
);
|
|
276
|
+
if (!target || target === "-") {
|
|
277
|
+
const redirect = node.redirects.find(
|
|
278
|
+
(item) => item.content !== undefined || item.operator === "<",
|
|
279
|
+
);
|
|
280
|
+
if (redirect?.content !== undefined) {
|
|
281
|
+
if (shell) this.shell(redirect.content, currentCwd, depth + 1);
|
|
282
|
+
else
|
|
283
|
+
this.source(
|
|
284
|
+
redirect.content,
|
|
285
|
+
currentCwd,
|
|
286
|
+
currentCwd,
|
|
287
|
+
executable.startsWith("python") ? ".py" : ".js",
|
|
288
|
+
);
|
|
289
|
+
} else if (redirect?.target && staticWord(redirect.target)) {
|
|
290
|
+
this.reference(redirect.target.value, currentCwd, true, undefined);
|
|
291
|
+
} else
|
|
292
|
+
this.gap(
|
|
293
|
+
"unresolved_target",
|
|
294
|
+
"Executable input comes from a computed path or standard input and could not be inspected.",
|
|
295
|
+
);
|
|
296
|
+
} else this.reference(target, currentCwd, true, undefined);
|
|
297
|
+
return currentCwd;
|
|
298
|
+
}
|
|
299
|
+
if (
|
|
300
|
+
executable === "source" ||
|
|
301
|
+
name === "." ||
|
|
302
|
+
(name.includes("/") && !/^(?:\/usr\/bin|\/bin|\/usr\/local\/bin|\/opt\/homebrew\/bin)\//.test(name))
|
|
303
|
+
) {
|
|
304
|
+
const target = executable === "source" || name === "." ? args[0] : name;
|
|
305
|
+
if (target) this.reference(target, currentCwd, true, undefined);
|
|
306
|
+
else this.gap("unresolved_target", "The sourced script path is computed at runtime.");
|
|
307
|
+
}
|
|
308
|
+
return currentCwd;
|
|
309
|
+
}
|
|
310
|
+
default:
|
|
311
|
+
this.gap(
|
|
312
|
+
"unsupported_execution",
|
|
313
|
+
"Shell control flow could hide execution targets; this syntax is not inspected automatically.",
|
|
314
|
+
);
|
|
315
|
+
return currentCwd;
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
for (const statement of script.commands) cwd = visit(statement, cwd);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private source(source: string, importCwd: string | undefined, executionCwd: string, extension: string): void {
|
|
322
|
+
if (Buffer.byteLength(source) > MAX_SOURCE_BYTES) {
|
|
323
|
+
this.gap("inspection_budget_exceeded", "Inline source exceeds the inspection work budget.");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if ([".sh", ".bash", ".zsh"].includes(extension) || /^#![^\n]*\b(?:sh|bash|zsh)\b/.test(source)) {
|
|
327
|
+
this.shell(source, executionCwd, 0);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (/^#![^\n]*python/.test(source)) extension = ".py";
|
|
331
|
+
if (/\bsys\.path\s*(?:=|\.(?:insert|append|extend)\s*\()/.test(source))
|
|
332
|
+
this.gap(
|
|
333
|
+
"code_loading_configuration",
|
|
334
|
+
"The script changes Python's code search path, so its local imports could not be resolved reliably.",
|
|
335
|
+
);
|
|
336
|
+
// Exact quoted file references are available only when the reviewer asks for them.
|
|
337
|
+
for (const match of source.matchAll(/["']([^"'\n]+\.(?:py|js|mjs|cjs|ts|sh|bash|json))["']/g)) {
|
|
338
|
+
if (match[1]) this.reference(match[1], executionCwd, false, undefined);
|
|
339
|
+
}
|
|
340
|
+
for (const match of source.matchAll(
|
|
341
|
+
/(?:\brunpy\.run_path\s*\(\s*|\b(?:exec|eval)\s*\(\s*(?:open|(?:fs\.)?readFileSync)\s*\(\s*)["']([^"'\n]+)["']/g,
|
|
342
|
+
)) {
|
|
343
|
+
if (match[1]) this.reference(match[1], executionCwd, true, undefined);
|
|
344
|
+
}
|
|
345
|
+
if (extension === ".py") {
|
|
346
|
+
for (const match of source.matchAll(
|
|
347
|
+
/(?:^|[;\n])\s*(?:from\s+([\w.]+)\s+import\s+([^;\n]+)|import\s+([^;\n]+))/g,
|
|
348
|
+
)) {
|
|
349
|
+
if (this.localImports.size >= MAX_REFERENCES) {
|
|
350
|
+
this.gap(
|
|
351
|
+
"inspection_budget_exceeded",
|
|
352
|
+
"Too many local import references to inspect within the review budget.",
|
|
353
|
+
);
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
const modules = match[1]
|
|
357
|
+
? [match[1]]
|
|
358
|
+
: (match[3] ?? "")
|
|
359
|
+
.replace(/#.*/, "")
|
|
360
|
+
.split(",")
|
|
361
|
+
.map((module) => module.trim().split(/\s+as\s+/)[0] ?? "");
|
|
362
|
+
for (const module of modules) {
|
|
363
|
+
if (!module) continue;
|
|
364
|
+
if (this.localImports.size >= MAX_REFERENCES) {
|
|
365
|
+
this.gap(
|
|
366
|
+
"inspection_budget_exceeded",
|
|
367
|
+
"Too many local import references to inspect within the review budget.",
|
|
368
|
+
);
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
if (module.startsWith(".") || !/^[\w.]+$/.test(module)) {
|
|
372
|
+
this.gap(
|
|
373
|
+
"unresolved_target",
|
|
374
|
+
"A Python import could not be resolved without additional package context.",
|
|
375
|
+
);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (importCwd === undefined) continue;
|
|
379
|
+
const path = module.replaceAll(".", "/");
|
|
380
|
+
this.localImports.set(`${importCwd}:${module}`, {
|
|
381
|
+
paths: [resolve(importCwd, `${path}.py`), resolve(importCwd, path, "__init__.py")],
|
|
382
|
+
cwd: executionCwd,
|
|
383
|
+
importRoot: importCwd,
|
|
384
|
+
required: false,
|
|
385
|
+
});
|
|
386
|
+
const segments = module.split(".");
|
|
387
|
+
if (segments.length > 8) {
|
|
388
|
+
this.gap("inspection_budget_exceeded", "Python import depth exceeds the inspection work budget.");
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
for (let index = 1; index < segments.length && this.localImports.size < MAX_REFERENCES; index++) {
|
|
392
|
+
const parent = segments.slice(0, index).join("/");
|
|
393
|
+
this.localImports.set(`${importCwd}:${parent}/__init__`, {
|
|
394
|
+
paths: [resolve(importCwd, parent, "__init__.py")],
|
|
395
|
+
cwd: executionCwd,
|
|
396
|
+
importRoot: importCwd,
|
|
397
|
+
required: false,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
if (match[1] && match[2]) {
|
|
401
|
+
for (const member of match[2].replace(/#.*/, "").replace(/[()]/g, "").split(",")) {
|
|
402
|
+
const name = member.trim().split(/\s+as\s+/)[0];
|
|
403
|
+
if (name && /^\w+$/.test(name) && this.localImports.size < MAX_REFERENCES) {
|
|
404
|
+
this.localImports.set(`${importCwd}:${module}.${name}`, {
|
|
405
|
+
paths: [
|
|
406
|
+
resolve(importCwd, path, `${name}.py`),
|
|
407
|
+
resolve(importCwd, path, name, "__init__.py"),
|
|
408
|
+
],
|
|
409
|
+
cwd: executionCwd,
|
|
410
|
+
importRoot: importCwd,
|
|
411
|
+
required: false,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
} else {
|
|
419
|
+
for (const match of source.matchAll(
|
|
420
|
+
/(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)["']((?:\.|\/)[^"'\n]+)["']/g,
|
|
421
|
+
)) {
|
|
422
|
+
if (this.localImports.size >= MAX_REFERENCES) {
|
|
423
|
+
this.gap(
|
|
424
|
+
"inspection_budget_exceeded",
|
|
425
|
+
"Too many local import references to inspect within the review budget.",
|
|
426
|
+
);
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
const path = match[1];
|
|
430
|
+
if (!path) continue;
|
|
431
|
+
if (importCwd === undefined && !path.startsWith("/")) {
|
|
432
|
+
this.gap(
|
|
433
|
+
"unresolved_target",
|
|
434
|
+
"Relative script_runner imports resolve in a temporary source directory; their code could not be inspected.",
|
|
435
|
+
);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const base = importCwd ?? executionCwd;
|
|
439
|
+
if (extname(path)) this.reference(resolve(base, path), executionCwd, true, undefined);
|
|
440
|
+
else
|
|
441
|
+
this.localImports.set(`${base}:${path}`, {
|
|
442
|
+
paths: ["", ".js", ".json", ".mjs", ".cjs", ".ts"].map((suffix) => resolve(base, path + suffix)),
|
|
443
|
+
cwd: executionCwd,
|
|
444
|
+
importRoot: base,
|
|
445
|
+
required: true,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
for (const match of source.matchAll(
|
|
450
|
+
/(?:\bexecSync|\bexec|\bos\.system|\bsubprocess\.(?:run|call|Popen|check_call|check_output))\s*\(\s*["']([^"'\n]+)["']/g,
|
|
451
|
+
)) {
|
|
452
|
+
if (match[1]) this.shell(match[1], executionCwd, 0);
|
|
453
|
+
}
|
|
454
|
+
for (const match of source.matchAll(
|
|
455
|
+
/(?:\bsubprocess\.(?:run|call|Popen|check_call|check_output)\s*\(\s*|\b(?:spawn(?:Sync)?|execFile(?:Sync)?)\s*\(\s*["']([^"'\n]+)["']\s*,\s*)\[([^\]]{0,4096})\]/g,
|
|
456
|
+
)) {
|
|
457
|
+
const body = match[2] ?? "";
|
|
458
|
+
const words = [
|
|
459
|
+
...(match[1] ? [match[1]] : []),
|
|
460
|
+
...[...body.matchAll(/["']([^"'\n]*)["']/g)].map((word) => word[1] ?? ""),
|
|
461
|
+
];
|
|
462
|
+
if (body.replace(/["'][^"'\n]*["']/g, "").replace(/[\s,]/g, ""))
|
|
463
|
+
this.gap("unresolved_target", "A subprocess argument list is computed at runtime.");
|
|
464
|
+
else this.shell(words.map((word) => `'${word.replaceAll("'", "'\\''")}'`).join(" "), executionCwd, 0);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async prepare(): Promise<void> {
|
|
469
|
+
if (!this.workStarted) this.workStarted = Date.now();
|
|
470
|
+
for (const [key, { paths, cwd, importRoot, required }] of this.localImports) {
|
|
471
|
+
this.localImports.delete(key);
|
|
472
|
+
let found = false;
|
|
473
|
+
for (const path of paths) {
|
|
474
|
+
if (++this.importChecks > MAX_REFERENCES || Date.now() - this.workStarted > MAX_WORK_MS) {
|
|
475
|
+
this.gap("inspection_budget_exceeded", "Local import resolution exceeds the inspection budget.");
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
this.signal?.throwIfAborted();
|
|
479
|
+
try {
|
|
480
|
+
await boundedIO(stat(path), this.workStarted + MAX_WORK_MS);
|
|
481
|
+
this.reference(path, cwd, true, undefined);
|
|
482
|
+
this.importRoots.set(path, importRoot);
|
|
483
|
+
found = true;
|
|
484
|
+
break;
|
|
485
|
+
} catch (error) {
|
|
486
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
|
|
487
|
+
this.absentPaths.add(path);
|
|
488
|
+
else this.gap("source_unavailable", `Could not check local import ${path}.`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (!found && required)
|
|
492
|
+
this.gap("unresolved_target", `The local import ${key} could not be resolved to an exact source file.`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async inspect(requested: readonly string[]): Promise<void> {
|
|
497
|
+
this.workStarted = Date.now();
|
|
498
|
+
for (const path of requested) {
|
|
499
|
+
const target = this.references.get(resolve(this.cwd, path));
|
|
500
|
+
if (target) this.targets.set(`${target.path}:${target.task ?? ""}`, target);
|
|
501
|
+
else
|
|
502
|
+
this.gap(
|
|
503
|
+
"invalid_reference",
|
|
504
|
+
`The requested file ${path} is not a concrete execution reference in this request.`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
if (this.targets.size === 0)
|
|
508
|
+
this.gap(
|
|
509
|
+
"unresolved_target",
|
|
510
|
+
"The reviewer needs more execution evidence, but no exact local code target could be identified.",
|
|
511
|
+
);
|
|
512
|
+
const seen = new Set<string>();
|
|
513
|
+
for (const [key, target] of this.targets) {
|
|
514
|
+
if (seen.has(key)) continue;
|
|
515
|
+
seen.add(key);
|
|
516
|
+
if (this.files.length >= MAX_FILES || Date.now() - this.workStarted > MAX_WORK_MS) {
|
|
517
|
+
this.gap(
|
|
518
|
+
"inspection_budget_exceeded",
|
|
519
|
+
"The directly referenced code exceeds the four-file or two-second inspection budget.",
|
|
520
|
+
);
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
this.signal?.throwIfAborted();
|
|
524
|
+
try {
|
|
525
|
+
const file = await boundedIO(
|
|
526
|
+
readEvidenceFile(target.path, MAX_SOURCE_BYTES - this.sourceBytes),
|
|
527
|
+
this.workStarted + MAX_WORK_MS,
|
|
528
|
+
);
|
|
529
|
+
this.sourceBytes += Buffer.byteLength(file.source);
|
|
530
|
+
this.files.push(file);
|
|
531
|
+
if (basename(target.path) === ".npmrc") {
|
|
532
|
+
const executionSettings: string[] = [];
|
|
533
|
+
for (const line of file.source.split("\n")) {
|
|
534
|
+
const setting = /^\s*(script-shell|node-options|onload-script)\s*=\s*(.*?)\s*$/.exec(line);
|
|
535
|
+
if (!setting) continue;
|
|
536
|
+
executionSettings.push(line);
|
|
537
|
+
if (setting[1] === "node-options") this.shell(`node ${setting[2]} -e ''`, target.cwd, 0);
|
|
538
|
+
else if (setting[2]?.includes("/") && !/^(?:\/bin|\/usr\/bin)\//.test(setting[2]))
|
|
539
|
+
this.reference(setting[2], target.cwd, true, undefined);
|
|
540
|
+
else if (setting[1] === "onload-script")
|
|
541
|
+
this.gap(
|
|
542
|
+
"code_loading_configuration",
|
|
543
|
+
"Project npm configuration loads additional code that could not be resolved.",
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
// Registry credentials are irrelevant to execution review and must not be sent.
|
|
547
|
+
file.source = executionSettings.join("\n");
|
|
548
|
+
} else if ([".yarnrc", ".yarnrc.yml"].includes(basename(target.path))) {
|
|
549
|
+
file.source = "Project Yarn configuration is present; executable settings were not resolved.";
|
|
550
|
+
this.gap(
|
|
551
|
+
"code_loading_configuration",
|
|
552
|
+
"Project Yarn configuration can change the executable or load plugins; those settings could not be inspected automatically.",
|
|
553
|
+
);
|
|
554
|
+
} else if (target.task !== undefined) {
|
|
555
|
+
const manifest: unknown = JSON.parse(file.source);
|
|
556
|
+
if (
|
|
557
|
+
!manifest ||
|
|
558
|
+
typeof manifest !== "object" ||
|
|
559
|
+
!("scripts" in manifest) ||
|
|
560
|
+
!manifest.scripts ||
|
|
561
|
+
typeof manifest.scripts !== "object"
|
|
562
|
+
) {
|
|
563
|
+
this.gap("task_unavailable", `No task definitions were found for ${target.task}.`);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
const scripts = manifest.scripts as Record<string, unknown>;
|
|
567
|
+
const selected: Record<string, string> = {};
|
|
568
|
+
for (const task of [`pre${target.task}`, target.task, `post${target.task}`]) {
|
|
569
|
+
const command = scripts[task];
|
|
570
|
+
if (typeof command === "string") {
|
|
571
|
+
selected[task] = command;
|
|
572
|
+
this.shell(command, target.cwd, 0);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (!(target.task in selected))
|
|
576
|
+
this.gap("task_unavailable", `The requested package task ${target.task} was not found.`);
|
|
577
|
+
// Keep the full-file fingerprint, but send only relevant task definitions.
|
|
578
|
+
file.source = JSON.stringify({
|
|
579
|
+
scripts: selected,
|
|
580
|
+
type: "type" in manifest ? manifest.type : undefined,
|
|
581
|
+
});
|
|
582
|
+
} else {
|
|
583
|
+
const python = extname(target.path) === ".py" || /^#![^\n]*python/.test(file.source);
|
|
584
|
+
const importRoot = python
|
|
585
|
+
? (this.importRoots.get(target.path) ?? dirname(file.canonicalPath))
|
|
586
|
+
: dirname(file.canonicalPath);
|
|
587
|
+
this.source(file.source, importRoot, target.cwd, extname(target.path));
|
|
588
|
+
}
|
|
589
|
+
await this.prepare();
|
|
590
|
+
} catch (error) {
|
|
591
|
+
if (this.signal?.aborted) throw error;
|
|
592
|
+
this.gap("source_unavailable", `Could not inspect ${target.path}: ${errorText(error)}`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (Date.now() - this.workStarted > MAX_WORK_MS)
|
|
596
|
+
this.gap("inspection_budget_exceeded", "Evidence collection exceeded its two-second work budget.");
|
|
597
|
+
this.files.sort((a, b) => a.path.localeCompare(b.path));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// fallow-ignore-next-line unused-class-member -- called through ToolReviewResult.evidence in tool-approval/index.ts before execution and after confirmation
|
|
601
|
+
async isFresh(): Promise<boolean> {
|
|
602
|
+
const deadline = Date.now() + MAX_WORK_MS;
|
|
603
|
+
for (const file of this.files) {
|
|
604
|
+
this.signal?.throwIfAborted();
|
|
605
|
+
try {
|
|
606
|
+
const current = await boundedIO(readEvidenceFile(file.path, MAX_SOURCE_BYTES), deadline);
|
|
607
|
+
if (current.canonicalPath !== file.canonicalPath || current.fingerprint !== file.fingerprint) return false;
|
|
608
|
+
} catch {
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
for (const path of this.absentPaths) {
|
|
613
|
+
try {
|
|
614
|
+
await boundedIO(stat(path), deadline);
|
|
615
|
+
return false;
|
|
616
|
+
} catch (error) {
|
|
617
|
+
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") return false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function staticWord(word: Word): string | undefined {
|
|
625
|
+
if (
|
|
626
|
+
word.parts?.some((part) => {
|
|
627
|
+
if (part.type === "DoubleQuoted" || part.type === "LocaleString")
|
|
628
|
+
return part.parts.some((child) => child.type !== "Literal");
|
|
629
|
+
return !["Literal", "SingleQuoted", "AnsiCQuoted"].includes(part.type);
|
|
630
|
+
})
|
|
631
|
+
)
|
|
632
|
+
return undefined;
|
|
633
|
+
return word.value;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function readEvidenceFile(path: string, maxBytes: number): Promise<ReviewedFile> {
|
|
637
|
+
const canonicalPath = await realpath(path);
|
|
638
|
+
const handle = await open(canonicalPath, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
639
|
+
try {
|
|
640
|
+
const info = await handle.stat();
|
|
641
|
+
if (!info.isFile()) throw new Error("Execution target is not a regular source file.");
|
|
642
|
+
if (info.size > maxBytes) throw new Error("Source exceeds the 48 KiB inspection budget.");
|
|
643
|
+
const bytes = Buffer.alloc(maxBytes + 1);
|
|
644
|
+
let total = 0;
|
|
645
|
+
while (total < bytes.length) {
|
|
646
|
+
const { bytesRead } = await handle.read(bytes, total, bytes.length - total, null);
|
|
647
|
+
if (!bytesRead) break;
|
|
648
|
+
total += bytesRead;
|
|
649
|
+
}
|
|
650
|
+
if (total > maxBytes) throw new Error("Source exceeds the 48 KiB inspection budget.");
|
|
651
|
+
const content = bytes.subarray(0, total);
|
|
652
|
+
if (content.includes(0)) throw new Error("Execution target is not text source.");
|
|
653
|
+
return {
|
|
654
|
+
path,
|
|
655
|
+
canonicalPath,
|
|
656
|
+
fingerprint: createHash("sha256").update(content).digest("hex"),
|
|
657
|
+
source: content.toString("utf8"),
|
|
658
|
+
};
|
|
659
|
+
} finally {
|
|
660
|
+
await handle.close();
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function boundedIO<T>(operation: Promise<T>, deadline: number): Promise<T> {
|
|
665
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
666
|
+
try {
|
|
667
|
+
return await Promise.race([
|
|
668
|
+
operation,
|
|
669
|
+
new Promise<never>((_resolve, reject) => {
|
|
670
|
+
timer = setTimeout(
|
|
671
|
+
() => reject(new Error("File inspection exceeded its two-second work budget.")),
|
|
672
|
+
Math.max(0, deadline - Date.now()),
|
|
673
|
+
);
|
|
674
|
+
}),
|
|
675
|
+
]);
|
|
676
|
+
} finally {
|
|
677
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
678
|
+
}
|
|
679
|
+
}
|