@diegopetrucci/pi-extensions 0.1.81 → 0.1.82
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/README.md +6 -1
- package/extensions/agent-workflow-audit/index.ts +47 -25
- package/extensions/agent-workflow-audit/package.json +1 -1
- package/extensions/brrr/index.ts +21 -15
- package/extensions/brrr/package.json +1 -1
- package/extensions/code-reviewer/index.ts +52 -19
- package/extensions/code-reviewer/package.json +1 -1
- package/extensions/contrarian/index.ts +17 -17
- package/extensions/contrarian/package.json +1 -1
- package/extensions/developer/.pi-fleet-tested-version +1 -0
- package/extensions/developer/README.md +70 -0
- package/extensions/developer/index.ts +906 -0
- package/extensions/developer/package.json +40 -0
- package/extensions/diff-summarizer/.pi-fleet-tested-version +1 -0
- package/extensions/diff-summarizer/README.md +75 -0
- package/extensions/diff-summarizer/index.ts +1585 -0
- package/extensions/diff-summarizer/package.json +40 -0
- package/extensions/librarian/index.ts +10 -0
- package/extensions/librarian/package.json +1 -1
- package/extensions/minimal-footer/index.ts +5 -1
- package/extensions/minimal-footer/package.json +1 -1
- package/extensions/notify/index.ts +7 -2
- package/extensions/notify/package.json +1 -1
- package/extensions/oracle/index.ts +17 -17
- package/extensions/oracle/package.json +1 -1
- package/extensions/permission-gate/README.md +6 -11
- package/extensions/permission-gate/index.ts +11 -54
- package/extensions/permission-gate/package.json +1 -3
- package/extensions/project-mcp-json/README.md +4 -3
- package/extensions/project-mcp-json/package.json +1 -1
- package/extensions/repo-scout/.pi-fleet-tested-version +1 -0
- package/extensions/repo-scout/README.md +74 -0
- package/extensions/repo-scout/index.ts +1575 -0
- package/extensions/repo-scout/package.json +40 -0
- package/extensions/test-runner/.pi-fleet-tested-version +1 -0
- package/extensions/test-runner/README.md +70 -0
- package/extensions/test-runner/index.ts +798 -0
- package/extensions/test-runner/package.json +40 -0
- package/extensions/triage-comments/index.ts +66 -16
- package/extensions/triage-comments/package.json +1 -1
- package/extensions/web-scout/.pi-fleet-tested-version +1 -0
- package/extensions/web-scout/README.md +67 -0
- package/extensions/web-scout/index.ts +1204 -0
- package/extensions/web-scout/package.json +41 -0
- package/package.json +7 -2
- package/extensions/permission-gate/powershell-safety.ts +0 -1053
|
@@ -0,0 +1,1575 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import type { AgentSession, ExtensionAPI, ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import {
|
|
9
|
+
DefaultResourceLoader,
|
|
10
|
+
SessionManager,
|
|
11
|
+
SettingsManager,
|
|
12
|
+
createAgentSession,
|
|
13
|
+
getAgentDir,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import * as piCodingAgent from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
|
|
18
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
19
|
+
|
|
20
|
+
const MAX_TOOL_CALLS_TO_KEEP = 80;
|
|
21
|
+
const MAX_TURNS = 8;
|
|
22
|
+
const MAX_RUN_MS = 8 * 60 * 1000;
|
|
23
|
+
const DEFAULT_BASH_TIMEOUT_SECONDS = 30;
|
|
24
|
+
const DEFAULT_THINKING_LEVEL = "medium";
|
|
25
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
26
|
+
|
|
27
|
+
type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
28
|
+
type ThinkingLevelMap = Partial<Record<ThinkingLevel, unknown | null>>;
|
|
29
|
+
|
|
30
|
+
const SUBAGENT_MCP_TOOLS = [
|
|
31
|
+
"mcp__*",
|
|
32
|
+
"tool_search",
|
|
33
|
+
"list_mcp_resources",
|
|
34
|
+
"list_mcp_resource_templates",
|
|
35
|
+
"read_mcp_resource",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// Derive project-trust for the isolated subagent session from the host tool context.
|
|
39
|
+
// Fails closed: returns false if the host has not trusted the project, or if the subagent
|
|
40
|
+
// runs in a different directory (e.g. a temp workspace), preventing an untrusted project's
|
|
41
|
+
// .pi/mcp.json from being loaded by the MCP extension.
|
|
42
|
+
function resolveSubagentProjectTrusted(
|
|
43
|
+
ctx: { cwd: string; isProjectTrusted(): boolean },
|
|
44
|
+
subagentCwd: string,
|
|
45
|
+
): boolean {
|
|
46
|
+
// Feature-detected for older Pi hosts that may lack isProjectTrusted.
|
|
47
|
+
const hostTrusted =
|
|
48
|
+
typeof (ctx as { isProjectTrusted?: unknown }).isProjectTrusted === "function"
|
|
49
|
+
? ctx.isProjectTrusted()
|
|
50
|
+
: false;
|
|
51
|
+
if (!hostTrusted) return false;
|
|
52
|
+
try {
|
|
53
|
+
return realpathSync(subagentCwd) === realpathSync(ctx.cwd);
|
|
54
|
+
} catch {
|
|
55
|
+
return false; // fail closed if realpath resolution fails
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isSubagentMcpTool(toolName: string): boolean {
|
|
60
|
+
return (
|
|
61
|
+
toolName.startsWith("mcp__") ||
|
|
62
|
+
toolName === "tool_search" ||
|
|
63
|
+
toolName === "list_mcp_resources" ||
|
|
64
|
+
toolName === "list_mcp_resource_templates" ||
|
|
65
|
+
toolName === "read_mcp_resource"
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Returns true when v is a semver string >=1.0.4 (ignores prerelease; missing/garbage -> false). */
|
|
70
|
+
function isMcpCompatibleVersion(v: string | undefined): boolean {
|
|
71
|
+
if (!v) return false;
|
|
72
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
|
|
73
|
+
if (!m) return false;
|
|
74
|
+
const major = Number(m[1]),
|
|
75
|
+
minor = Number(m[2]),
|
|
76
|
+
patch = Number(m[3]);
|
|
77
|
+
if (major !== 1) return major > 1;
|
|
78
|
+
if (minor !== 0) return minor > 0;
|
|
79
|
+
return patch >= 4;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Gated on VERSION >=1.0.4: on older hosts mcp__* allowlist entries are exact-name
|
|
83
|
+
// matches that resolve nothing, so spawning MCP servers provides no benefit.
|
|
84
|
+
function createMcpSubagentFactories(): ExtensionFactory[] {
|
|
85
|
+
const ns = piCodingAgent as Record<string, unknown>;
|
|
86
|
+
if (!isMcpCompatibleVersion(ns["VERSION"] as string | undefined)) return [];
|
|
87
|
+
const factories: ExtensionFactory[] = [];
|
|
88
|
+
const mcp = ns["createMcpExtension"];
|
|
89
|
+
const toolSearch = ns["createToolSearchExtension"];
|
|
90
|
+
if (typeof mcp === "function") factories.push((mcp as () => ExtensionFactory)());
|
|
91
|
+
if (typeof toolSearch === "function") factories.push((toolSearch as () => ExtensionFactory)());
|
|
92
|
+
return factories;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Emit session_shutdown before dispose so MCP closes server connections (no leaked child processes).
|
|
96
|
+
async function disposeSubagentSession(session: AgentSession | undefined): Promise<void> {
|
|
97
|
+
if (!session) return;
|
|
98
|
+
try {
|
|
99
|
+
const runner = (
|
|
100
|
+
session as unknown as {
|
|
101
|
+
extensionRunner?: {
|
|
102
|
+
hasHandlers(n: string): boolean;
|
|
103
|
+
emit(e: { type: string; reason: string }): Promise<unknown>;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
).extensionRunner;
|
|
107
|
+
if (runner?.hasHandlers("session_shutdown")) {
|
|
108
|
+
await runner.emit({ type: "session_shutdown", reason: "quit" });
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// Shutdown errors must not prevent dispose.
|
|
112
|
+
}
|
|
113
|
+
session.dispose();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const READ_ONLY_TOOL_NAMES = new Set(["read", "grep", "find", "ls", "bash"]);
|
|
117
|
+
const READ_ONLY_BASH_COMMANDS = new Set(["gh", "git", "pwd"]);
|
|
118
|
+
const READ_ONLY_GIT_SUBCOMMANDS = new Set([
|
|
119
|
+
"blame",
|
|
120
|
+
"cat-file",
|
|
121
|
+
"describe",
|
|
122
|
+
"diff",
|
|
123
|
+
"for-each-ref",
|
|
124
|
+
"log",
|
|
125
|
+
"ls-files",
|
|
126
|
+
"ls-tree",
|
|
127
|
+
"merge-base",
|
|
128
|
+
"name-rev",
|
|
129
|
+
"remote",
|
|
130
|
+
"rev-parse",
|
|
131
|
+
"shortlog",
|
|
132
|
+
"show",
|
|
133
|
+
"show-ref",
|
|
134
|
+
"status",
|
|
135
|
+
"whatchanged",
|
|
136
|
+
]);
|
|
137
|
+
const SAFE_GIT_BRANCH_FLAGS = new Set([
|
|
138
|
+
"-a",
|
|
139
|
+
"--all",
|
|
140
|
+
"-r",
|
|
141
|
+
"--remotes",
|
|
142
|
+
"-v",
|
|
143
|
+
"-vv",
|
|
144
|
+
"--verbose",
|
|
145
|
+
"-l",
|
|
146
|
+
"--list",
|
|
147
|
+
"--show-current",
|
|
148
|
+
"--column",
|
|
149
|
+
"--no-column",
|
|
150
|
+
"--color",
|
|
151
|
+
"--no-color",
|
|
152
|
+
"-i",
|
|
153
|
+
"--ignore-case",
|
|
154
|
+
]);
|
|
155
|
+
const SAFE_GIT_BRANCH_VALUE_FLAGS = new Set([
|
|
156
|
+
"--contains",
|
|
157
|
+
"--no-contains",
|
|
158
|
+
"--merged",
|
|
159
|
+
"--no-merged",
|
|
160
|
+
"--points-at",
|
|
161
|
+
"--sort",
|
|
162
|
+
"--format",
|
|
163
|
+
]);
|
|
164
|
+
// Flags that make `git branch` list instead of create a branch from positional args.
|
|
165
|
+
const GIT_BRANCH_LISTING_FLAGS = new Set([
|
|
166
|
+
"-a",
|
|
167
|
+
"--all",
|
|
168
|
+
"-r",
|
|
169
|
+
"--remotes",
|
|
170
|
+
"-l",
|
|
171
|
+
"--list",
|
|
172
|
+
"--contains",
|
|
173
|
+
"--no-contains",
|
|
174
|
+
"--merged",
|
|
175
|
+
"--no-merged",
|
|
176
|
+
"--points-at",
|
|
177
|
+
]);
|
|
178
|
+
const SAFE_GIT_GLOBAL_FLAGS = new Set(["--no-pager", "--no-optional-locks"]);
|
|
179
|
+
const SAFE_GIT_CONFIG_OVERRIDES = [
|
|
180
|
+
"core.pager=cat",
|
|
181
|
+
"core.fsmonitor=false",
|
|
182
|
+
"diff.external=",
|
|
183
|
+
"log.showSignature=false",
|
|
184
|
+
];
|
|
185
|
+
const GIT_SUBCOMMAND_SAFE_FLAGS: Partial<Record<string, string[]>> = {
|
|
186
|
+
blame: ["--no-textconv"],
|
|
187
|
+
diff: ["--no-ext-diff", "--no-textconv"],
|
|
188
|
+
log: ["--no-ext-diff", "--no-textconv"],
|
|
189
|
+
show: ["--no-ext-diff", "--no-textconv"],
|
|
190
|
+
whatchanged: ["--no-ext-diff", "--no-textconv"],
|
|
191
|
+
};
|
|
192
|
+
const GIT_OPTIONS_REQUIRING_LOCAL_FILE = [
|
|
193
|
+
{
|
|
194
|
+
flag: "--pathspec-from-file",
|
|
195
|
+
blockedPrefixes: ["--pathspec"],
|
|
196
|
+
reason:
|
|
197
|
+
"Repo scout bash blocks git --pathspec-from-file because it can read local files outside built-in path guards.",
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
flag: "--ignore-revs-file",
|
|
201
|
+
blockedPrefixes: ["--ignore-rev"],
|
|
202
|
+
reason:
|
|
203
|
+
"Repo scout bash blocks git --ignore-revs-file because it can read local files outside built-in path guards.",
|
|
204
|
+
},
|
|
205
|
+
] as const;
|
|
206
|
+
const GIT_SUBCOMMAND_OPTIONS_REQUIRING_LOCAL_FILE: Partial<
|
|
207
|
+
Record<string, Array<{ reason: string; matches: (token: string) => boolean }>>
|
|
208
|
+
> = {
|
|
209
|
+
blame: [
|
|
210
|
+
{
|
|
211
|
+
reason:
|
|
212
|
+
"Repo scout bash blocks git blame --contents because it can read local files outside built-in path guards.",
|
|
213
|
+
matches: (token) => {
|
|
214
|
+
if (!token.startsWith("--")) return false;
|
|
215
|
+
const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
|
|
216
|
+
return option === "--contents" || option.startsWith("--con");
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
reason:
|
|
221
|
+
"Repo scout bash blocks git blame -S because it can read local revs files outside built-in path guards.",
|
|
222
|
+
matches: (token) => token === "-S" || (token.startsWith("-S") && token.length > 2),
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
"ls-files": [
|
|
226
|
+
{
|
|
227
|
+
reason:
|
|
228
|
+
"Repo scout bash blocks git ls-files -X/--exclude-from because it can read local files outside built-in path guards.",
|
|
229
|
+
matches: (token) => token === "-X" || (token.startsWith("-X") && token.length > 2),
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
reason:
|
|
233
|
+
"Repo scout bash blocks git ls-files -X/--exclude-from because it can read local files outside built-in path guards.",
|
|
234
|
+
matches: (token) => {
|
|
235
|
+
if (!token.startsWith("--")) return false;
|
|
236
|
+
const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
|
|
237
|
+
return option === "--exclude-from" || option.startsWith("--exclude-f");
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
};
|
|
242
|
+
const GH_GLOBAL_OPTIONS_WITH_VALUE = new Set([
|
|
243
|
+
"--repo",
|
|
244
|
+
"-R",
|
|
245
|
+
"--hostname",
|
|
246
|
+
"--jq",
|
|
247
|
+
"-q",
|
|
248
|
+
"--template",
|
|
249
|
+
]);
|
|
250
|
+
const MUTATING_GH_API_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
251
|
+
|
|
252
|
+
const RepoScoutParams = Type.Object({
|
|
253
|
+
task: Type.Optional(
|
|
254
|
+
Type.String({
|
|
255
|
+
description:
|
|
256
|
+
"Optional focus for the scan, e.g. the feature or area the caller is about to work on. When omitted, a general repository orientation report is produced.",
|
|
257
|
+
}),
|
|
258
|
+
),
|
|
259
|
+
cwd: Type.Optional(
|
|
260
|
+
Type.String({
|
|
261
|
+
description:
|
|
262
|
+
"Optional working directory (repository) to scan; defaults to the current session directory.",
|
|
263
|
+
}),
|
|
264
|
+
),
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
type ToolCall = {
|
|
268
|
+
name: string;
|
|
269
|
+
args: unknown;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
type RunStatus = "running" | "done" | "error" | "aborted";
|
|
273
|
+
|
|
274
|
+
type RunDetails = {
|
|
275
|
+
status: RunStatus;
|
|
276
|
+
cwd: string;
|
|
277
|
+
turns: number;
|
|
278
|
+
toolCalls: ToolCall[];
|
|
279
|
+
startedAt: number;
|
|
280
|
+
endedAt?: number;
|
|
281
|
+
error?: string;
|
|
282
|
+
modelRef?: string;
|
|
283
|
+
modelName?: string;
|
|
284
|
+
requestedThinkingLevel?: ThinkingLevel;
|
|
285
|
+
effectiveThinkingLevel?: ThinkingLevel;
|
|
286
|
+
thinkingLevelClamped?: boolean;
|
|
287
|
+
thinkingLevelNote?: string;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
type PiModel = {
|
|
291
|
+
provider: string;
|
|
292
|
+
id: string;
|
|
293
|
+
name?: string;
|
|
294
|
+
reasoning?: boolean;
|
|
295
|
+
contextWindow?: number;
|
|
296
|
+
maxTokens?: number;
|
|
297
|
+
thinkingLevelMap?: ThinkingLevelMap;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
type CreateAgentSessionOptions = NonNullable<Parameters<typeof createAgentSession>[0]>;
|
|
301
|
+
type CreateAgentSessionModel = CreateAgentSessionOptions["model"];
|
|
302
|
+
|
|
303
|
+
function getModelRuntimeOption(ctx: {
|
|
304
|
+
modelRegistry?: unknown;
|
|
305
|
+
}): Pick<CreateAgentSessionOptions, "modelRuntime"> {
|
|
306
|
+
const modelRuntime = (
|
|
307
|
+
ctx.modelRegistry as { runtime?: CreateAgentSessionOptions["modelRuntime"] } | undefined
|
|
308
|
+
)?.runtime;
|
|
309
|
+
return modelRuntime ? { modelRuntime } : {};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function normalizeThinkingLevel(value: unknown): ThinkingLevel | undefined {
|
|
313
|
+
return typeof value === "string" && THINKING_LEVELS.includes(value as ThinkingLevel)
|
|
314
|
+
? (value as ThinkingLevel)
|
|
315
|
+
: undefined;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Keep these local so the extension stays compatible with older pi peer installs that do not export clamp helpers.
|
|
319
|
+
function isThinkingLevelSupported(model: PiModel, level: ThinkingLevel): boolean {
|
|
320
|
+
if (!model.reasoning) return level === "off";
|
|
321
|
+
|
|
322
|
+
const map = model.thinkingLevelMap;
|
|
323
|
+
if (level === "xhigh" || level === "max") {
|
|
324
|
+
return !!map && Object.prototype.hasOwnProperty.call(map, level) && map[level] != null;
|
|
325
|
+
}
|
|
326
|
+
return map?.[level] !== null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function clampThinkingLevel(model: PiModel, requested: ThinkingLevel): ThinkingLevel {
|
|
330
|
+
if (isThinkingLevelSupported(model, requested)) return requested;
|
|
331
|
+
|
|
332
|
+
const requestedIndex = THINKING_LEVELS.indexOf(requested);
|
|
333
|
+
for (let index = requestedIndex + 1; index < THINKING_LEVELS.length; index += 1) {
|
|
334
|
+
const level = THINKING_LEVELS[index];
|
|
335
|
+
if (isThinkingLevelSupported(model, level)) return level;
|
|
336
|
+
}
|
|
337
|
+
for (let index = requestedIndex - 1; index >= 0; index -= 1) {
|
|
338
|
+
const level = THINKING_LEVELS[index];
|
|
339
|
+
if (isThinkingLevelSupported(model, level)) return level;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return "off";
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function resolveThinkingLevel(
|
|
346
|
+
model: PiModel | undefined,
|
|
347
|
+
requestedThinkingLevel: ThinkingLevel | undefined,
|
|
348
|
+
): {
|
|
349
|
+
requested: ThinkingLevel;
|
|
350
|
+
effective: ThinkingLevel;
|
|
351
|
+
clamped: boolean;
|
|
352
|
+
note: string;
|
|
353
|
+
} {
|
|
354
|
+
const requested = requestedThinkingLevel ?? (model?.reasoning ? DEFAULT_THINKING_LEVEL : "off");
|
|
355
|
+
const effective = model ? clampThinkingLevel(model, requested) : requested;
|
|
356
|
+
const clamped = effective !== requested;
|
|
357
|
+
if (clamped) {
|
|
358
|
+
return {
|
|
359
|
+
requested,
|
|
360
|
+
effective,
|
|
361
|
+
clamped,
|
|
362
|
+
note: `requested ${requested}; clamped to ${effective}`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (requestedThinkingLevel) {
|
|
366
|
+
return { requested, effective, clamped, note: `requested ${requested}` };
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
requested,
|
|
370
|
+
effective,
|
|
371
|
+
clamped,
|
|
372
|
+
note: model?.reasoning
|
|
373
|
+
? `defaulted to ${effective}`
|
|
374
|
+
: `defaulted to off for non-reasoning model`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function applyModelAttemptDetails(
|
|
379
|
+
details: RunDetails,
|
|
380
|
+
model: PiModel,
|
|
381
|
+
thinking: ReturnType<typeof resolveThinkingLevel>,
|
|
382
|
+
): void {
|
|
383
|
+
details.modelRef = `${model.provider}/${model.id}`;
|
|
384
|
+
details.modelName = model.name;
|
|
385
|
+
details.requestedThinkingLevel = thinking.requested;
|
|
386
|
+
details.effectiveThinkingLevel = thinking.effective;
|
|
387
|
+
details.thinkingLevelClamped = thinking.clamped || undefined;
|
|
388
|
+
details.thinkingLevelNote = thinking.note;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function createEmptyUsage(): Usage {
|
|
392
|
+
return {
|
|
393
|
+
input: 0,
|
|
394
|
+
output: 0,
|
|
395
|
+
cacheRead: 0,
|
|
396
|
+
cacheWrite: 0,
|
|
397
|
+
totalTokens: 0,
|
|
398
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function addUsage(total: Usage, usage: Usage): void {
|
|
403
|
+
total.input += usage.input || 0;
|
|
404
|
+
total.output += usage.output || 0;
|
|
405
|
+
total.cacheRead += usage.cacheRead || 0;
|
|
406
|
+
total.cacheWrite += usage.cacheWrite || 0;
|
|
407
|
+
if (usage.cacheWrite1h !== undefined)
|
|
408
|
+
total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
|
|
409
|
+
if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
|
|
410
|
+
total.totalTokens += usage.totalTokens || 0;
|
|
411
|
+
total.cost.input += usage.cost?.input || 0;
|
|
412
|
+
total.cost.output += usage.cost?.output || 0;
|
|
413
|
+
total.cost.cacheRead += usage.cost?.cacheRead || 0;
|
|
414
|
+
total.cost.cacheWrite += usage.cost?.cacheWrite || 0;
|
|
415
|
+
total.cost.total += usage.cost?.total || 0;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
type AssistantLikeMessage = {
|
|
419
|
+
role?: string;
|
|
420
|
+
content?: unknown;
|
|
421
|
+
stopReason?: unknown;
|
|
422
|
+
errorMessage?: unknown;
|
|
423
|
+
usage?: unknown;
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
function addAssistantMessageUsage(total: Usage, message: unknown): void {
|
|
427
|
+
const assistant = message as AssistantLikeMessage;
|
|
428
|
+
if (assistant?.role !== "assistant" || !assistant.usage || typeof assistant.usage !== "object")
|
|
429
|
+
return;
|
|
430
|
+
addUsage(total, assistant.usage as Usage);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function addSessionEventUsage(total: Usage, event: unknown): void {
|
|
434
|
+
const sessionEvent = event as {
|
|
435
|
+
type?: string;
|
|
436
|
+
message?: unknown;
|
|
437
|
+
result?: { usage?: Usage };
|
|
438
|
+
};
|
|
439
|
+
if (sessionEvent?.type === "message_end")
|
|
440
|
+
return void addAssistantMessageUsage(total, sessionEvent.message);
|
|
441
|
+
if (sessionEvent?.type === "compaction_end" && sessionEvent.result?.usage)
|
|
442
|
+
addUsage(total, sessionEvent.result.usage);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function inspectFinalAssistant(
|
|
446
|
+
messages: unknown[],
|
|
447
|
+
):
|
|
448
|
+
| { ok: true; answer: string; stopReason?: string }
|
|
449
|
+
| { ok: false; reason: string; stopReason?: string; errorMessage?: string } {
|
|
450
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
451
|
+
const message = messages[i] as AssistantLikeMessage;
|
|
452
|
+
if (message?.role !== "assistant") continue;
|
|
453
|
+
const stopReason = typeof message.stopReason === "string" ? message.stopReason.trim() : "";
|
|
454
|
+
const errorMessage =
|
|
455
|
+
typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
|
|
456
|
+
const answer = Array.isArray(message.content)
|
|
457
|
+
? message.content
|
|
458
|
+
.map((part) =>
|
|
459
|
+
part &&
|
|
460
|
+
typeof part === "object" &&
|
|
461
|
+
(part as { type?: string }).type === "text" &&
|
|
462
|
+
typeof (part as { text?: unknown }).text === "string"
|
|
463
|
+
? (part as { text: string }).text
|
|
464
|
+
: "",
|
|
465
|
+
)
|
|
466
|
+
.join("")
|
|
467
|
+
.trim()
|
|
468
|
+
: "";
|
|
469
|
+
if (stopReason === "error")
|
|
470
|
+
return {
|
|
471
|
+
ok: false,
|
|
472
|
+
reason: `repo_scout subagent error: ${errorMessage || "provider/model error"}`,
|
|
473
|
+
stopReason,
|
|
474
|
+
errorMessage,
|
|
475
|
+
};
|
|
476
|
+
if (stopReason === "aborted")
|
|
477
|
+
return {
|
|
478
|
+
ok: false,
|
|
479
|
+
reason: "repo_scout subagent aborted before producing a usable answer",
|
|
480
|
+
stopReason,
|
|
481
|
+
};
|
|
482
|
+
if (answer) return { ok: true, answer, stopReason: stopReason || undefined };
|
|
483
|
+
return {
|
|
484
|
+
ok: false,
|
|
485
|
+
reason: stopReason
|
|
486
|
+
? `repo_scout subagent produced no final assistant text (stopReason: ${stopReason})`
|
|
487
|
+
: "repo_scout subagent produced no final assistant text",
|
|
488
|
+
stopReason: stopReason || undefined,
|
|
489
|
+
errorMessage: errorMessage || undefined,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
ok: false,
|
|
494
|
+
reason: "repo_scout subagent produced no assistant message",
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function classifyRunFailure(
|
|
499
|
+
error: unknown,
|
|
500
|
+
aborted: boolean,
|
|
501
|
+
): { status: "aborted" | "error"; message: string; error?: string } {
|
|
502
|
+
const message = aborted ? "Aborted" : error instanceof Error ? error.message : String(error);
|
|
503
|
+
return {
|
|
504
|
+
status: aborted ? "aborted" : "error",
|
|
505
|
+
message,
|
|
506
|
+
error: aborted ? undefined : message,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function isInside(parent: string, child: string): boolean {
|
|
511
|
+
const parentResolved = path.resolve(parent);
|
|
512
|
+
const childResolved = path.resolve(child);
|
|
513
|
+
return (
|
|
514
|
+
childResolved === parentResolved || childResolved.startsWith(`${parentResolved}${path.sep}`)
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const UNICODE_SPACES_PATTERN = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Mirrors normalizeWindowsShellPath from dist/utils/paths.js. No-op on
|
|
522
|
+
* non-win32 paths.
|
|
523
|
+
*/
|
|
524
|
+
function normalizeWindowsShellPathLocal(filePath: string): string {
|
|
525
|
+
if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\"))
|
|
526
|
+
return filePath;
|
|
527
|
+
const match = filePath.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
|
|
528
|
+
if (!match) return filePath;
|
|
529
|
+
const suffix = match[2]?.replaceAll("/", "\\");
|
|
530
|
+
return `${match[1].toUpperCase()}:\\${suffix ?? ""}`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Mirrors Pi's normalizePath (dist/utils/paths.js). On win32, Git Bash / WSL
|
|
535
|
+
* drive-path normalisation always runs; only tilde expansion is gated by
|
|
536
|
+
* expandTilde.
|
|
537
|
+
*/
|
|
538
|
+
function normalizePath(
|
|
539
|
+
input: string,
|
|
540
|
+
opts: {
|
|
541
|
+
normalizeUnicodeSpaces?: boolean;
|
|
542
|
+
stripAtPrefix?: boolean;
|
|
543
|
+
expandTilde?: boolean;
|
|
544
|
+
} = {},
|
|
545
|
+
): string {
|
|
546
|
+
const { normalizeUnicodeSpaces = false, stripAtPrefix = false, expandTilde = true } = opts;
|
|
547
|
+
let p = input;
|
|
548
|
+
if (normalizeUnicodeSpaces) p = p.replace(UNICODE_SPACES_PATTERN, " ");
|
|
549
|
+
if (stripAtPrefix && p.startsWith("@")) p = p.slice(1);
|
|
550
|
+
if (process.platform === "win32") p = normalizeWindowsShellPathLocal(p);
|
|
551
|
+
if (expandTilde) {
|
|
552
|
+
const home = os.homedir();
|
|
553
|
+
if (p === "~") p = home;
|
|
554
|
+
else if (p.startsWith("~/") || (process.platform === "win32" && p.startsWith("~\\")))
|
|
555
|
+
p = path.join(home, p.slice(2));
|
|
556
|
+
}
|
|
557
|
+
if (/^file:\/\//.test(p)) p = fileURLToPath(p);
|
|
558
|
+
return p;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Mirrors Pi's resolveToCwd (dist/core/tools/path-utils.js). Exposed via
|
|
563
|
+
* __test__ for parity tests; not imported by other extensions.
|
|
564
|
+
*/
|
|
565
|
+
function resolveToCwd(rawPath: string, cwd: string): string {
|
|
566
|
+
const normalized = normalizePath(rawPath, {
|
|
567
|
+
normalizeUnicodeSpaces: true,
|
|
568
|
+
stripAtPrefix: true,
|
|
569
|
+
});
|
|
570
|
+
const base = normalizePath(cwd);
|
|
571
|
+
return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(base, normalized);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Mirrors tryMacOSScreenshotPath from dist/core/tools/path-utils.js */
|
|
575
|
+
function tryMacOSScreenshotPathVariant(filePath: string): string {
|
|
576
|
+
return filePath.replace(/ (AM|PM)\./gi, "\u202F$1.");
|
|
577
|
+
}
|
|
578
|
+
/** Mirrors tryNFDVariant from dist/core/tools/path-utils.js */
|
|
579
|
+
function tryNFDPathVariant(filePath: string): string {
|
|
580
|
+
return filePath.normalize("NFD");
|
|
581
|
+
}
|
|
582
|
+
/** Mirrors tryCurlyQuoteVariant from dist/core/tools/path-utils.js */
|
|
583
|
+
function tryCurlyQuotePathVariant(filePath: string): string {
|
|
584
|
+
return filePath.replace(/'/g, "\u2019");
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Returns all read path candidates including Pi's resolveReadPathAsync fallback
|
|
588
|
+
* variants. Used only for the read tool guard; grep/find/ls use resolveToCwd only.
|
|
589
|
+
*/
|
|
590
|
+
function getReadPathCandidates(resolved: string): string[] {
|
|
591
|
+
const nfd = tryNFDPathVariant(resolved);
|
|
592
|
+
return [
|
|
593
|
+
resolved,
|
|
594
|
+
tryMacOSScreenshotPathVariant(resolved),
|
|
595
|
+
nfd,
|
|
596
|
+
tryCurlyQuotePathVariant(resolved),
|
|
597
|
+
tryCurlyQuotePathVariant(nfd),
|
|
598
|
+
];
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function assertToolPathInsideCwd(
|
|
602
|
+
cwd: string,
|
|
603
|
+
rawPath: unknown,
|
|
604
|
+
toolName: string,
|
|
605
|
+
): Promise<string | undefined> {
|
|
606
|
+
if (rawPath !== undefined && typeof rawPath !== "string")
|
|
607
|
+
return `${toolName} path must be a string.`;
|
|
608
|
+
// A path is allowed if it resolves inside EITHER the realpath'd root (root) OR
|
|
609
|
+
// the lexical cwd (lexicalCwd). This handles missing files in symlinked workspaces
|
|
610
|
+
// where realpath fails and the lexical fallback starts with the alias prefix.
|
|
611
|
+
const root = await fs.realpath(cwd).catch(() => path.resolve(cwd));
|
|
612
|
+
const lexicalCwd = path.resolve(cwd);
|
|
613
|
+
// For the read tool, also check Pi's resolveReadPathAsync fallback variants so
|
|
614
|
+
// a missing file whose variant resolves outside is not silently allowed.
|
|
615
|
+
try {
|
|
616
|
+
const piResolved = resolveToCwd(typeof rawPath === "string" ? rawPath : ".", cwd);
|
|
617
|
+
const candidates = toolName === "read" ? getReadPathCandidates(piResolved) : [piResolved];
|
|
618
|
+
// Deliberate policy: all candidates are checked regardless of existence (not
|
|
619
|
+
// Pi's existence-ordered selection) so the guard cannot be raced by creating
|
|
620
|
+
// a variant between the hook and the read. Trade-off: a read is blocked when
|
|
621
|
+
// an escaping variant symlink sits next to a safe primary file.
|
|
622
|
+
for (const candidate of candidates) {
|
|
623
|
+
const candidateRealPath = await fs.realpath(candidate).catch(() => candidate);
|
|
624
|
+
if (!isInside(root, candidateRealPath) && !isInside(lexicalCwd, candidateRealPath))
|
|
625
|
+
return `${toolName} is limited to the local checkout: ${candidateRealPath}`;
|
|
626
|
+
}
|
|
627
|
+
} catch {
|
|
628
|
+
return `${toolName} path could not be resolved safely.`;
|
|
629
|
+
}
|
|
630
|
+
return undefined;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function getUnsafePathPatternReason(value: unknown, label: string): string | undefined {
|
|
634
|
+
if (value === undefined) return undefined;
|
|
635
|
+
if (typeof value !== "string") return `${label} must be a string.`;
|
|
636
|
+
if (path.isAbsolute(value)) return `${label} must be relative to the local checkout.`;
|
|
637
|
+
if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(value))
|
|
638
|
+
return `${label} must not traverse outside the local checkout.`;
|
|
639
|
+
return undefined;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function stripTokenQuotes(token: string): string {
|
|
643
|
+
if (
|
|
644
|
+
(token.startsWith("'") && token.endsWith("'")) ||
|
|
645
|
+
(token.startsWith('"') && token.endsWith('"'))
|
|
646
|
+
) {
|
|
647
|
+
return token.slice(1, -1);
|
|
648
|
+
}
|
|
649
|
+
return token;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
type ShellTokenizeResult = {
|
|
653
|
+
tokens: string[];
|
|
654
|
+
reason?: string;
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
function tokenizeShellCommand(command: string): ShellTokenizeResult {
|
|
658
|
+
const tokens: string[] = [];
|
|
659
|
+
let token = "";
|
|
660
|
+
let tokenStarted = false;
|
|
661
|
+
let inSingleQuote = false;
|
|
662
|
+
let inDoubleQuote = false;
|
|
663
|
+
|
|
664
|
+
const pushToken = () => {
|
|
665
|
+
if (!tokenStarted) return;
|
|
666
|
+
tokens.push(token);
|
|
667
|
+
token = "";
|
|
668
|
+
tokenStarted = false;
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
672
|
+
const char = command[index];
|
|
673
|
+
const next = command[index + 1] ?? "";
|
|
674
|
+
|
|
675
|
+
if (inSingleQuote) {
|
|
676
|
+
if (char === "'") {
|
|
677
|
+
inSingleQuote = false;
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
token += char;
|
|
681
|
+
tokenStarted = true;
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (char === "\\") {
|
|
686
|
+
return {
|
|
687
|
+
tokens,
|
|
688
|
+
reason:
|
|
689
|
+
"Repo scout bash blocks shell escape sequences; pass plain git, gh, or pwd arguments without backslashes.",
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (inDoubleQuote) {
|
|
694
|
+
if (char === '"') {
|
|
695
|
+
inDoubleQuote = false;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (char === "`" || char === "$") {
|
|
699
|
+
return {
|
|
700
|
+
tokens,
|
|
701
|
+
reason:
|
|
702
|
+
char === "`" || next === "("
|
|
703
|
+
? "Repo scout bash blocks command substitution."
|
|
704
|
+
: "Repo scout bash blocks shell expansion in double quotes.",
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
token += char;
|
|
708
|
+
tokenStarted = true;
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (char === "\n" || char === "\r") {
|
|
713
|
+
return {
|
|
714
|
+
tokens,
|
|
715
|
+
reason:
|
|
716
|
+
"Repo scout bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
if (/\s/.test(char)) {
|
|
720
|
+
pushToken();
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
if (char === "'") {
|
|
724
|
+
inSingleQuote = true;
|
|
725
|
+
tokenStarted = true;
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
if (char === '"') {
|
|
729
|
+
inDoubleQuote = true;
|
|
730
|
+
tokenStarted = true;
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
if (char === "`")
|
|
734
|
+
return {
|
|
735
|
+
tokens,
|
|
736
|
+
reason: "Repo scout bash blocks command substitution.",
|
|
737
|
+
};
|
|
738
|
+
if (char === "$") {
|
|
739
|
+
return {
|
|
740
|
+
tokens,
|
|
741
|
+
reason:
|
|
742
|
+
next === "'" || next === '"'
|
|
743
|
+
? "Repo scout bash blocks ANSI-C and localized shell quotes."
|
|
744
|
+
: next === "("
|
|
745
|
+
? "Repo scout bash blocks command substitution."
|
|
746
|
+
: "Repo scout bash blocks shell expansion.",
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
if (/[;&|()]/.test(char)) {
|
|
750
|
+
return {
|
|
751
|
+
tokens,
|
|
752
|
+
reason:
|
|
753
|
+
"Repo scout bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
if (char === ">" || char === "<") {
|
|
757
|
+
return {
|
|
758
|
+
tokens,
|
|
759
|
+
reason: "Repo scout bash blocks shell redirection to keep inspection read-only.",
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
if (char === "{" || char === "}")
|
|
763
|
+
return {
|
|
764
|
+
tokens,
|
|
765
|
+
reason: "Repo scout bash blocks shell brace expansion.",
|
|
766
|
+
};
|
|
767
|
+
if (char === "*" || char === "?" || char === "[" || char === "]") {
|
|
768
|
+
return {
|
|
769
|
+
tokens,
|
|
770
|
+
reason: "Repo scout bash blocks shell glob expansion.",
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
if (char === "~" && !tokenStarted)
|
|
774
|
+
return {
|
|
775
|
+
tokens,
|
|
776
|
+
reason: "Repo scout bash blocks shell home-directory expansion.",
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
token += char;
|
|
780
|
+
tokenStarted = true;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (inSingleQuote || inDoubleQuote)
|
|
784
|
+
return {
|
|
785
|
+
tokens,
|
|
786
|
+
reason: "Repo scout bash blocks unterminated or malformed shell quoting.",
|
|
787
|
+
};
|
|
788
|
+
pushToken();
|
|
789
|
+
return { tokens };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function tokenizeSegment(segment: string): string[] {
|
|
793
|
+
return tokenizeShellCommand(segment).tokens;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function shellQuoteToken(token: string): string {
|
|
797
|
+
return `'${token.replace(/'/g, `'"'"'`)}'`;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function getExecutableName(token: string): string {
|
|
801
|
+
return path.basename(token).toLowerCase();
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function getShellSyntaxReason(command: string): string | undefined {
|
|
805
|
+
return tokenizeShellCommand(command).reason;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function getUnsafeGitTokenPathReason(token: string): string | undefined {
|
|
809
|
+
const valueParts = [token];
|
|
810
|
+
const equalsIndex = token.indexOf("=");
|
|
811
|
+
if (equalsIndex >= 0 && equalsIndex < token.length - 1)
|
|
812
|
+
valueParts.push(token.slice(equalsIndex + 1));
|
|
813
|
+
for (const value of valueParts) {
|
|
814
|
+
const normalized = value.startsWith("@") ? value.slice(1) : value;
|
|
815
|
+
if (normalized === "~" || normalized.startsWith("~/") || /^~[^/\\]*/.test(normalized)) {
|
|
816
|
+
return "Repo scout bash git arguments must not use home-directory paths; use built-in read/grep/find/ls for local files.";
|
|
817
|
+
}
|
|
818
|
+
if (path.isAbsolute(normalized)) {
|
|
819
|
+
return "Repo scout bash git arguments must not use absolute filesystem paths; use built-in read/grep/find/ls for local files.";
|
|
820
|
+
}
|
|
821
|
+
if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(normalized)) {
|
|
822
|
+
return "Repo scout bash git arguments must not traverse outside the local checkout.";
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return undefined;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function getBlockedPwdReason(tokens: string[]): string | undefined {
|
|
829
|
+
const args = tokens.slice(1);
|
|
830
|
+
if (args.length === 0) return undefined;
|
|
831
|
+
if (args.length === 1 && (args[0] === "-P" || args[0] === "-L")) return undefined;
|
|
832
|
+
return "Repo scout bash allows pwd only with no arguments or -P/-L.";
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function getGitSubcommand(tokens: string[]): {
|
|
836
|
+
subcommand?: string;
|
|
837
|
+
index: number;
|
|
838
|
+
reason?: string;
|
|
839
|
+
} {
|
|
840
|
+
let index = 1;
|
|
841
|
+
while (index < tokens.length) {
|
|
842
|
+
const token = tokens[index];
|
|
843
|
+
if (!token.startsWith("-")) break;
|
|
844
|
+
if (token === "--git-dir" || token.startsWith("--git-dir=")) {
|
|
845
|
+
return {
|
|
846
|
+
index,
|
|
847
|
+
reason:
|
|
848
|
+
"Repo scout bash blocks git --git-dir because it can inspect or mutate outside the checkout.",
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
if (token === "--work-tree" || token.startsWith("--work-tree=")) {
|
|
852
|
+
return {
|
|
853
|
+
index,
|
|
854
|
+
reason:
|
|
855
|
+
"Repo scout bash blocks git --work-tree because it can inspect or mutate outside the checkout.",
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
if (token === "-C") {
|
|
859
|
+
const gitCwd = tokens[index + 1];
|
|
860
|
+
if (gitCwd !== ".")
|
|
861
|
+
return {
|
|
862
|
+
index,
|
|
863
|
+
reason: "Repo scout bash only allows git -C .; run git from the local checkout.",
|
|
864
|
+
};
|
|
865
|
+
index += 2;
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
if (token.startsWith("-C")) {
|
|
869
|
+
if (token !== "-C.")
|
|
870
|
+
return {
|
|
871
|
+
index,
|
|
872
|
+
reason: "Repo scout bash only allows git -C .; run git from the local checkout.",
|
|
873
|
+
};
|
|
874
|
+
index += 1;
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
if (token === "--paginate" || token === "-p") {
|
|
878
|
+
return {
|
|
879
|
+
index,
|
|
880
|
+
reason: "Repo scout bash blocks git pagination because pagers can execute local utilities.",
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
if (SAFE_GIT_GLOBAL_FLAGS.has(token)) {
|
|
884
|
+
index += 1;
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
return {
|
|
888
|
+
index,
|
|
889
|
+
reason: `Repo scout bash blocks git global option ${token}; use direct read-only git inspection commands from the checkout.`,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
return { subcommand: tokens[index]?.toLowerCase(), index };
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function isSafeGitBranchCommand(tokens: string[], subcommandIndex: number): boolean {
|
|
896
|
+
const args = tokens.slice(subcommandIndex + 1);
|
|
897
|
+
if (args.length === 0) return true;
|
|
898
|
+
let hasListingMode = false;
|
|
899
|
+
let hasPositional = false;
|
|
900
|
+
for (const arg of args) {
|
|
901
|
+
if (!arg.startsWith("-")) {
|
|
902
|
+
hasPositional = true;
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
const name = arg.split("=", 1)[0] ?? "";
|
|
906
|
+
const hasValue = arg.includes("=");
|
|
907
|
+
if (!hasValue && SAFE_GIT_BRANCH_FLAGS.has(arg)) {
|
|
908
|
+
if (GIT_BRANCH_LISTING_FLAGS.has(arg)) hasListingMode = true;
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
if (SAFE_GIT_BRANCH_VALUE_FLAGS.has(name)) {
|
|
912
|
+
if (GIT_BRANCH_LISTING_FLAGS.has(name)) hasListingMode = true;
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
return false;
|
|
916
|
+
}
|
|
917
|
+
if (args.includes("--show-current")) return !hasPositional;
|
|
918
|
+
return !hasPositional || hasListingMode;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function isSafeGitRemoteCommand(tokens: string[], subcommandIndex: number): boolean {
|
|
922
|
+
const args = tokens.slice(subcommandIndex + 1);
|
|
923
|
+
if (args.length === 0) return true;
|
|
924
|
+
if (args.length === 1 && (args[0] === "-v" || args[0] === "--verbose")) return true;
|
|
925
|
+
const remoteAction = args.find((arg) => !arg.startsWith("-"));
|
|
926
|
+
if (remoteAction !== "get-url") return false;
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function isBlockedGitLocalFileOption(
|
|
931
|
+
token: string,
|
|
932
|
+
flag: string,
|
|
933
|
+
blockedPrefixes: readonly string[],
|
|
934
|
+
): boolean {
|
|
935
|
+
if (!token.startsWith("--")) return false;
|
|
936
|
+
const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
|
|
937
|
+
if (option === flag) return true;
|
|
938
|
+
return blockedPrefixes.some((prefix) => option.startsWith(prefix));
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function getUnsafeGitArgumentReason(
|
|
942
|
+
tokens: string[],
|
|
943
|
+
parsed: { subcommand?: string; index: number },
|
|
944
|
+
): string | undefined {
|
|
945
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
946
|
+
const token = tokens[index];
|
|
947
|
+
const lowerToken = token.toLowerCase();
|
|
948
|
+
if (token === "--no-index" || token.startsWith("--no-index=")) {
|
|
949
|
+
return "Repo scout bash blocks git --no-index because it can inspect arbitrary filesystem paths outside the checkout.";
|
|
950
|
+
}
|
|
951
|
+
if (token === "--paginate" || token.startsWith("--paginate=")) {
|
|
952
|
+
return "Repo scout bash blocks git --paginate because pagers can execute local utilities.";
|
|
953
|
+
}
|
|
954
|
+
if (token === "--open-files-in-pager" || token.startsWith("--open-files-in-pager=")) {
|
|
955
|
+
return "Repo scout bash blocks git --open-files-in-pager because it can execute local utilities.";
|
|
956
|
+
}
|
|
957
|
+
if (token === "-O" || token.startsWith("-O")) {
|
|
958
|
+
return "Repo scout bash blocks git -O because it can execute local utilities for some subcommands.";
|
|
959
|
+
}
|
|
960
|
+
if (token === "--ext-diff" || token.startsWith("--ext-diff=")) {
|
|
961
|
+
return "Repo scout bash blocks git --ext-diff because it can execute external diff helpers.";
|
|
962
|
+
}
|
|
963
|
+
if (token === "--textconv" || token.startsWith("--textconv=")) {
|
|
964
|
+
return "Repo scout bash blocks git --textconv because it can execute external text conversion filters.";
|
|
965
|
+
}
|
|
966
|
+
if (token === "--filters" || token.startsWith("--filters=")) {
|
|
967
|
+
return "Repo scout bash blocks git --filters because it can execute clean/smudge filters from local config.";
|
|
968
|
+
}
|
|
969
|
+
if (token === "--show-signature" || lowerToken.startsWith("--show-signat")) {
|
|
970
|
+
return "Repo scout bash blocks git signature verification flags because they can execute configured GPG helpers.";
|
|
971
|
+
}
|
|
972
|
+
if (token === "--help" || token.startsWith("--help=")) {
|
|
973
|
+
return "Repo scout bash blocks git help output because it can execute configured help, man, or browser helpers.";
|
|
974
|
+
}
|
|
975
|
+
if (token.includes("%G") || /%\((?:[^)]*signature[^)]*)\)/i.test(token)) {
|
|
976
|
+
return "Repo scout bash blocks git signature format atoms because they can execute configured GPG helpers.";
|
|
977
|
+
}
|
|
978
|
+
if (token === "--output" || token.startsWith("--output=")) {
|
|
979
|
+
return "Repo scout bash blocks git output-writing flags.";
|
|
980
|
+
}
|
|
981
|
+
for (const { flag, blockedPrefixes, reason } of GIT_OPTIONS_REQUIRING_LOCAL_FILE) {
|
|
982
|
+
if (isBlockedGitLocalFileOption(token, flag, blockedPrefixes)) return reason;
|
|
983
|
+
}
|
|
984
|
+
if (index > parsed.index) {
|
|
985
|
+
for (const option of GIT_SUBCOMMAND_OPTIONS_REQUIRING_LOCAL_FILE[parsed.subcommand ?? ""] ??
|
|
986
|
+
[]) {
|
|
987
|
+
if (option.matches(token)) return option.reason;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
const pathReason = getUnsafeGitTokenPathReason(token);
|
|
991
|
+
if (pathReason) return pathReason;
|
|
992
|
+
}
|
|
993
|
+
return undefined;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function buildSafeGitCommand(tokens: string[]): string {
|
|
997
|
+
const parsed = getGitSubcommand(tokens);
|
|
998
|
+
const prefix = [
|
|
999
|
+
"git",
|
|
1000
|
+
"--no-pager",
|
|
1001
|
+
"--no-optional-locks",
|
|
1002
|
+
...SAFE_GIT_CONFIG_OVERRIDES.flatMap((value) => ["-c", value]),
|
|
1003
|
+
];
|
|
1004
|
+
const subcommand = parsed.subcommand;
|
|
1005
|
+
if (!subcommand) return prefix.map(shellQuoteToken).join(" ");
|
|
1006
|
+
const args = tokens.slice(parsed.index + 1);
|
|
1007
|
+
const injectedFlags = (GIT_SUBCOMMAND_SAFE_FLAGS[subcommand] ?? []).filter(
|
|
1008
|
+
(flag) => !args.includes(flag),
|
|
1009
|
+
);
|
|
1010
|
+
return [...prefix, subcommand, ...injectedFlags, ...args].map(shellQuoteToken).join(" ");
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function getBlockedGitReason(tokens: string[]): string | undefined {
|
|
1014
|
+
if (getExecutableName(tokens[0] ?? "") !== "git") return undefined;
|
|
1015
|
+
const parsed = getGitSubcommand(tokens);
|
|
1016
|
+
if (parsed.reason) return parsed.reason;
|
|
1017
|
+
if (parsed.subcommand === "help") {
|
|
1018
|
+
return "Repo scout bash blocks git help because it can execute configured help, man, or browser helpers.";
|
|
1019
|
+
}
|
|
1020
|
+
const argumentReason = getUnsafeGitArgumentReason(tokens, parsed);
|
|
1021
|
+
if (argumentReason) return argumentReason;
|
|
1022
|
+
if (!parsed.subcommand) return undefined;
|
|
1023
|
+
if (parsed.subcommand === "branch") {
|
|
1024
|
+
if (!isSafeGitBranchCommand(tokens, parsed.index)) {
|
|
1025
|
+
return "Repo scout bash allows git branch only for read-only listing/show-current/contains/merged queries.";
|
|
1026
|
+
}
|
|
1027
|
+
return undefined;
|
|
1028
|
+
}
|
|
1029
|
+
if (parsed.subcommand === "remote") {
|
|
1030
|
+
if (!isSafeGitRemoteCommand(tokens, parsed.index)) {
|
|
1031
|
+
return "Repo scout bash allows git remote only for read-only list or get-url queries.";
|
|
1032
|
+
}
|
|
1033
|
+
return undefined;
|
|
1034
|
+
}
|
|
1035
|
+
if (!READ_ONLY_GIT_SUBCOMMANDS.has(parsed.subcommand)) {
|
|
1036
|
+
return `Repo scout bash blocks git ${parsed.subcommand}; only known read-only git subcommands are allowed.`;
|
|
1037
|
+
}
|
|
1038
|
+
return undefined;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function getGhCommand(tokens: string[]): {
|
|
1042
|
+
command?: string;
|
|
1043
|
+
subcommand?: string;
|
|
1044
|
+
} {
|
|
1045
|
+
let index = 1;
|
|
1046
|
+
while (index < tokens.length) {
|
|
1047
|
+
const token = tokens[index];
|
|
1048
|
+
if (!token.startsWith("-")) break;
|
|
1049
|
+
if (GH_GLOBAL_OPTIONS_WITH_VALUE.has(token)) {
|
|
1050
|
+
index += 2;
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
index += 1;
|
|
1054
|
+
}
|
|
1055
|
+
return {
|
|
1056
|
+
command: tokens[index]?.toLowerCase(),
|
|
1057
|
+
subcommand: tokens[index + 1]?.toLowerCase(),
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function normalizeFlagValue(value: string | undefined): string | undefined {
|
|
1062
|
+
return value ? stripTokenQuotes(value).trim() : undefined;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function getBlockedGhApiReason(tokens: string[]): string | undefined {
|
|
1066
|
+
const parsed = getGhCommand(tokens);
|
|
1067
|
+
if (parsed.command !== "api") return undefined;
|
|
1068
|
+
if (tokens.find((token) => token.toLowerCase() === "graphql")) {
|
|
1069
|
+
return "Repo scout bash blocks gh api graphql because it uses POST/body fields.";
|
|
1070
|
+
}
|
|
1071
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
1072
|
+
const token = tokens[index];
|
|
1073
|
+
const lowerToken = token.toLowerCase();
|
|
1074
|
+
if (
|
|
1075
|
+
token === "-f" ||
|
|
1076
|
+
token === "-F" ||
|
|
1077
|
+
token === "--field" ||
|
|
1078
|
+
token === "--raw-field" ||
|
|
1079
|
+
token === "--input" ||
|
|
1080
|
+
lowerToken.startsWith("-f") ||
|
|
1081
|
+
lowerToken.startsWith("--field=") ||
|
|
1082
|
+
lowerToken.startsWith("--raw-field=") ||
|
|
1083
|
+
lowerToken.startsWith("--input=")
|
|
1084
|
+
) {
|
|
1085
|
+
return "Repo scout bash allows read-only gh api calls only; request fields and input files are blocked.";
|
|
1086
|
+
}
|
|
1087
|
+
if (token === "--cache" || lowerToken.startsWith("--cache=")) {
|
|
1088
|
+
return "Repo scout bash blocks gh api --cache because it writes local cache files.";
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
let method: string | undefined;
|
|
1092
|
+
if (lowerToken === "-x" || lowerToken === "--method")
|
|
1093
|
+
method = normalizeFlagValue(tokens[index + 1]);
|
|
1094
|
+
else if (lowerToken.startsWith("-x") && token.length > 2)
|
|
1095
|
+
method = normalizeFlagValue(token.slice(2).replace(/^=/, ""));
|
|
1096
|
+
else if (lowerToken.startsWith("--method="))
|
|
1097
|
+
method = normalizeFlagValue(token.slice("--method=".length));
|
|
1098
|
+
if (method && MUTATING_GH_API_METHODS.has(method.toUpperCase())) {
|
|
1099
|
+
return "Repo scout bash allows read-only gh api calls only; mutating methods are blocked.";
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return undefined;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function isReadOnlyGhCommand(command: string | undefined, subcommand: string | undefined): boolean {
|
|
1106
|
+
if (!command) return true;
|
|
1107
|
+
if (command === "api") return true;
|
|
1108
|
+
if (command === "search") return true;
|
|
1109
|
+
if (command === "repo") return subcommand === "view" || subcommand === "list";
|
|
1110
|
+
if (command === "pr")
|
|
1111
|
+
return (
|
|
1112
|
+
subcommand === "view" ||
|
|
1113
|
+
subcommand === "list" ||
|
|
1114
|
+
subcommand === "diff" ||
|
|
1115
|
+
subcommand === "status" ||
|
|
1116
|
+
subcommand === "checks"
|
|
1117
|
+
);
|
|
1118
|
+
if (command === "issue")
|
|
1119
|
+
return subcommand === "view" || subcommand === "list" || subcommand === "status";
|
|
1120
|
+
if (command === "release") return subcommand === "view" || subcommand === "list";
|
|
1121
|
+
if (command === "run") return subcommand === "view" || subcommand === "list";
|
|
1122
|
+
if (command === "workflow") return subcommand === "view" || subcommand === "list";
|
|
1123
|
+
if (command === "label") return subcommand === "list";
|
|
1124
|
+
if (command === "milestone") return subcommand === "list";
|
|
1125
|
+
return false;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function getBlockedGhReason(command: string): string | undefined {
|
|
1129
|
+
const tokens = tokenizeSegment(command);
|
|
1130
|
+
if (getExecutableName(tokens[0] ?? "") !== "gh") return undefined;
|
|
1131
|
+
if (/\bgh\s+auth\s+(?:login|logout|refresh|setup-git|token)\b/i.test(command)) {
|
|
1132
|
+
return "Repo scout bash blocks gh auth commands and token inspection.";
|
|
1133
|
+
}
|
|
1134
|
+
if (
|
|
1135
|
+
tokens.some((token) => {
|
|
1136
|
+
const lowerToken = token.toLowerCase();
|
|
1137
|
+
return (
|
|
1138
|
+
lowerToken === "--web" ||
|
|
1139
|
+
lowerToken === "-w" ||
|
|
1140
|
+
lowerToken.startsWith("--web=") ||
|
|
1141
|
+
lowerToken.startsWith("-w=")
|
|
1142
|
+
);
|
|
1143
|
+
})
|
|
1144
|
+
) {
|
|
1145
|
+
return "Repo scout bash blocks gh --web because it can launch external browser helpers.";
|
|
1146
|
+
}
|
|
1147
|
+
const apiReason = getBlockedGhApiReason(tokens);
|
|
1148
|
+
if (apiReason) return apiReason;
|
|
1149
|
+
if (
|
|
1150
|
+
/\bgh\s+(?:repo\s+(?:archive|clone|create|delete|edit|fork|rename|sync)|pr\s+(?:checkout|close|comment|create|edit|lock|merge|ready|reopen|review|unlock)|issue\s+(?:close|comment|create|delete|edit|lock|reopen|transfer|unlock)|release\s+(?:create|delete|edit|upload)|workflow\s+run|run\s+(?:cancel|delete|rerun)|gist\s+(?:create|delete|edit))\b/i.test(
|
|
1151
|
+
command,
|
|
1152
|
+
)
|
|
1153
|
+
) {
|
|
1154
|
+
return "Repo scout bash blocks mutating gh commands.";
|
|
1155
|
+
}
|
|
1156
|
+
const parsed = getGhCommand(tokens);
|
|
1157
|
+
if (!isReadOnlyGhCommand(parsed.command, parsed.subcommand)) {
|
|
1158
|
+
return `Repo scout bash blocks gh ${[parsed.command, parsed.subcommand].filter(Boolean).join(" ")}; only known read-only gh commands are allowed.`;
|
|
1159
|
+
}
|
|
1160
|
+
return undefined;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function getBlockedBashReason(command: string): string | undefined {
|
|
1164
|
+
const trimmed = command.trim();
|
|
1165
|
+
if (!trimmed) return "Repo scout bash requires a non-empty command.";
|
|
1166
|
+
if (/`|\$\(/.test(trimmed)) return "Repo scout bash blocks command substitution.";
|
|
1167
|
+
const syntaxReason = getShellSyntaxReason(trimmed);
|
|
1168
|
+
if (syntaxReason) return syntaxReason;
|
|
1169
|
+
|
|
1170
|
+
const tokens = tokenizeSegment(trimmed);
|
|
1171
|
+
if (tokens.length === 0) return "Repo scout bash requires a non-empty command.";
|
|
1172
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]))
|
|
1173
|
+
return "Repo scout bash blocks inline environment assignment.";
|
|
1174
|
+
if (/[\\/]/.test(tokens[0])) {
|
|
1175
|
+
return "Repo scout bash requires direct git, gh, or pwd invocation without path-qualified executables.";
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const executable = getExecutableName(tokens[0]);
|
|
1179
|
+
if (!READ_ONLY_BASH_COMMANDS.has(executable)) {
|
|
1180
|
+
return `Repo scout bash blocks ${executable || "this command"}; use built-in read/grep/find/ls for local files or read-only git/gh inspection commands.`;
|
|
1181
|
+
}
|
|
1182
|
+
if (executable === "pwd") return getBlockedPwdReason(tokens);
|
|
1183
|
+
if (executable === "git") return getBlockedGitReason(tokens);
|
|
1184
|
+
if (executable === "gh") return getBlockedGhReason(trimmed);
|
|
1185
|
+
return undefined;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function createRepoScoutRuntimeGuardExtension(options: {
|
|
1189
|
+
cwd: string;
|
|
1190
|
+
maxTurns: number;
|
|
1191
|
+
}): ExtensionFactory {
|
|
1192
|
+
return (pi) => {
|
|
1193
|
+
let currentTurn = 0;
|
|
1194
|
+
|
|
1195
|
+
pi.on("turn_start", async (event) => {
|
|
1196
|
+
currentTurn = event.turnIndex;
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
pi.on("tool_call", async (event) => {
|
|
1200
|
+
if (!READ_ONLY_TOOL_NAMES.has(event.toolName) && !isSubagentMcpTool(event.toolName)) {
|
|
1201
|
+
return {
|
|
1202
|
+
block: true,
|
|
1203
|
+
reason: `repo_scout exposes read-only built-in tools and MCP tools; ${event.toolName} is not allowed.`,
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (currentTurn >= options.maxTurns - 1) {
|
|
1208
|
+
return {
|
|
1209
|
+
block: true,
|
|
1210
|
+
reason: `Tool use is disabled on final repo_scout turn ${options.maxTurns}/${options.maxTurns}. Answer now with the evidence already gathered.`,
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
if (event.toolName === "read") {
|
|
1215
|
+
const reason = await assertToolPathInsideCwd(
|
|
1216
|
+
options.cwd,
|
|
1217
|
+
(event.input as { path?: unknown }).path,
|
|
1218
|
+
"read",
|
|
1219
|
+
);
|
|
1220
|
+
if (reason) return { block: true, reason };
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
if (event.toolName === "grep" || event.toolName === "find" || event.toolName === "ls") {
|
|
1224
|
+
const reason = await assertToolPathInsideCwd(
|
|
1225
|
+
options.cwd,
|
|
1226
|
+
(event.input as { path?: unknown }).path,
|
|
1227
|
+
event.toolName,
|
|
1228
|
+
);
|
|
1229
|
+
if (reason) return { block: true, reason };
|
|
1230
|
+
if (event.toolName === "grep") {
|
|
1231
|
+
const globReason = getUnsafePathPatternReason(
|
|
1232
|
+
(event.input as { glob?: unknown }).glob,
|
|
1233
|
+
"grep glob",
|
|
1234
|
+
);
|
|
1235
|
+
if (globReason) return { block: true, reason: globReason };
|
|
1236
|
+
}
|
|
1237
|
+
if (event.toolName === "find") {
|
|
1238
|
+
const patternReason = getUnsafePathPatternReason(
|
|
1239
|
+
(event.input as { pattern?: unknown }).pattern,
|
|
1240
|
+
"find pattern",
|
|
1241
|
+
);
|
|
1242
|
+
if (patternReason) return { block: true, reason: patternReason };
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (event.toolName === "bash") {
|
|
1247
|
+
const input = event.input as { command?: unknown; timeout?: unknown };
|
|
1248
|
+
if (typeof input.timeout !== "number") input.timeout = DEFAULT_BASH_TIMEOUT_SECONDS;
|
|
1249
|
+
const command = typeof input.command === "string" ? input.command : "";
|
|
1250
|
+
const reason = getBlockedBashReason(command);
|
|
1251
|
+
if (reason) return { block: true, reason };
|
|
1252
|
+
const tokens = tokenizeSegment(command.trim());
|
|
1253
|
+
if (getExecutableName(tokens[0] ?? "") === "git")
|
|
1254
|
+
input.command = buildSafeGitCommand(tokens);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
return undefined;
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
pi.on("tool_result", async (event) => ({
|
|
1261
|
+
content: [
|
|
1262
|
+
...(event.content ?? []),
|
|
1263
|
+
{
|
|
1264
|
+
type: "text",
|
|
1265
|
+
text: `\n\n[repo_scout turn budget] turn ${Math.min(currentTurn + 1, options.maxTurns)}/${options.maxTurns}`,
|
|
1266
|
+
},
|
|
1267
|
+
],
|
|
1268
|
+
}));
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function buildSystemPrompt(options: {
|
|
1273
|
+
cwd: string;
|
|
1274
|
+
maxTurns: number;
|
|
1275
|
+
maxRunSeconds: number;
|
|
1276
|
+
}): string {
|
|
1277
|
+
return `You are the repo scout, an isolated read-only agent running inside the repo-scout package. Your job is to quickly inspect the current repository and return a concise, evidence-backed report so the caller avoids wrong-stack assumptions.\n\nWorking directory: ${options.cwd}\nTurn budget: ${options.maxTurns} turns total, including your final answer.\nWall-clock budget: ${options.maxRunSeconds} seconds.\n\nYou are read-only. Do not modify files, install dependencies, or use network access. Available tools are read, grep, find, ls, and bash. Use the built-in read, grep, find, and ls tools for local file inspection. Use bash only for a single read-only git, gh, or pwd invocation. A runtime guard blocks write/edit tools, shell pipelines/control operators, redirection, mutating git/gh commands, npm/publish commands, path traversal outside the checkout, and other filesystem mutation. Configured MCP tools are also available and run without confirmation; use them only for read-only research or inspection within your assignment and avoid mutations; if a tool's side effects are uncertain, do not call it. MCP access requires host Pi >=1.0.4; on older hosts MCP tools are absent with no error.\n\n## Scope and stop rules\n\n- Stay limited to repository orientation: stack, conventions, commands, hotspots, and implementation-relevant unknowns.\n- Inspect only the minimum representative files needed to answer those questions; do not drift into exhaustive code review, full diff analysis, task planning, or implementation design unless the caller explicitly asks.\n- Stop once you can give a confident orientation report or an explicit uncertainty/blocker.\n\n## Scan process\n\n1. Identify the repository root with \`git rev-parse --show-toplevel\` if available; otherwise use the working directory.\n2. Inspect top-level layout and signature files.\n3. Detect stack from evidence, not guesses:\n - JavaScript/TypeScript: \`package.json\`, lockfiles, \`tsconfig.json\`.\n - Python: \`pyproject.toml\`, requirements, lockfiles.\n - Rust: \`Cargo.toml\`.\n - Go: \`go.mod\`.\n - Java/Kotlin: Gradle/Maven files.\n - .NET: solution/project files.\n - Ruby/PHP/Terraform/container/CI files as applicable.\n4. Identify build, test, lint, typecheck, format, and release commands from config.\n5. Sample representative source files only when needed to infer conventions.\n6. If you are uncertain, say so and state what would disambiguate it.\n\nReport blockers in your final result instead of asking questions; you cannot ask follow-up questions. Repository inspection that is blocked, or a required assumption that cannot be resolved from files, is such a blocker.\n\n## Output\n\nReturn a single concise markdown report:\n\n# Repository scout report\n\n## Detected stack\n- Languages, frameworks, build/packaging, runtime/deployment, each with evidence paths.\n\n## Conventions\n- Formatting/linting, type checking, testing, docs/changelog, error handling/configuration patterns, each with evidence paths.\n\n## Commands\n- First-choice aggregate command if one exists.\n- Otherwise the smallest set of exact commands for validation, with evidence paths.\n\n## Project hotspots\n- Main entry points and high-change directories/files with one-line reasons.\n\n## Do and don't patterns\n- Concrete patterns the repo uses or avoids, with evidence paths.\n\n## Open questions\n- Only questions that materially affect implementation and cannot be answered from the repository.`;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function buildUserPrompt(input: { task?: string }, cwd: string): string {
|
|
1281
|
+
return `Local checkout: ${cwd}\n\nOptional focus:\n${input.task?.trim() ? input.task : "(not provided; produce a general repository orientation report)"}\n\nScan the repository using the required output format. Do not modify anything.`;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function formatToolCall(call: ToolCall): string {
|
|
1285
|
+
const args =
|
|
1286
|
+
call.args && typeof call.args === "object" ? (call.args as Record<string, unknown>) : {};
|
|
1287
|
+
if (call.name === "read") {
|
|
1288
|
+
const readPath = typeof args.path === "string" ? args.path : "";
|
|
1289
|
+
const offset = typeof args.offset === "number" ? args.offset : undefined;
|
|
1290
|
+
const limit = typeof args.limit === "number" ? args.limit : undefined;
|
|
1291
|
+
const range =
|
|
1292
|
+
offset || limit ? `:${offset ?? 1}${limit ? `-${(offset ?? 1) + limit - 1}` : ""}` : "";
|
|
1293
|
+
return `read ${readPath}${range}`.trim();
|
|
1294
|
+
}
|
|
1295
|
+
if (call.name === "grep") {
|
|
1296
|
+
const pattern = typeof args.pattern === "string" ? args.pattern : "";
|
|
1297
|
+
const grepPath = typeof args.path === "string" ? args.path : ".";
|
|
1298
|
+
return `grep ${pattern.slice(0, 50)} ${grepPath}`.trim();
|
|
1299
|
+
}
|
|
1300
|
+
if (call.name === "find") {
|
|
1301
|
+
const pattern = typeof args.pattern === "string" ? args.pattern : "";
|
|
1302
|
+
const findPath = typeof args.path === "string" ? args.path : ".";
|
|
1303
|
+
return `find ${pattern.slice(0, 50)} ${findPath}`.trim();
|
|
1304
|
+
}
|
|
1305
|
+
if (call.name === "ls") return `ls ${typeof args.path === "string" ? args.path : "."}`.trim();
|
|
1306
|
+
if (call.name === "bash") {
|
|
1307
|
+
const command = typeof args.command === "string" ? args.command : "";
|
|
1308
|
+
return `bash ${command.slice(0, 120)}`.trim();
|
|
1309
|
+
}
|
|
1310
|
+
return call.name;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function formatDuration(ms: number): string {
|
|
1314
|
+
if (ms < 1000) return `${ms}ms`;
|
|
1315
|
+
const seconds = Math.round(ms / 100) / 10;
|
|
1316
|
+
if (seconds < 60) return `${seconds}s`;
|
|
1317
|
+
const minutes = Math.floor(seconds / 60);
|
|
1318
|
+
const remainingSeconds = Math.round((seconds % 60) * 10) / 10;
|
|
1319
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function appendRunDetails(report: string, details: RunDetails): string {
|
|
1323
|
+
const trimmed = report.trim();
|
|
1324
|
+
const toolSummary =
|
|
1325
|
+
details.toolCalls.length > 0 ? details.toolCalls.map(formatToolCall).join("; ") : "none";
|
|
1326
|
+
const duration = formatDuration((details.endedAt ?? Date.now()) - details.startedAt);
|
|
1327
|
+
const modelSummary = details.modelRef ? `model ${details.modelRef}` : "model unknown";
|
|
1328
|
+
const thinkingSummary = details.effectiveThinkingLevel
|
|
1329
|
+
? `thinking ${details.effectiveThinkingLevel}${details.thinkingLevelNote ? ` (${details.thinkingLevelNote})` : ""}`
|
|
1330
|
+
: "thinking unknown";
|
|
1331
|
+
const suffix = `\n\n---\nRun details: ${modelSummary}; ${thinkingSummary}; ${details.turns} turn(s); ${details.toolCalls.length} tool call(s); duration ${duration}; cwd ${details.cwd}; tools ${toolSummary}.`;
|
|
1332
|
+
if (!trimmed)
|
|
1333
|
+
return `# Repository scout report\n\n## Open questions\n- No report was produced: the isolated repo_scout session returned no assistant text.${suffix}`;
|
|
1334
|
+
return `${trimmed}${suffix}`;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
export const __test__ = {
|
|
1338
|
+
SUBAGENT_MCP_TOOLS,
|
|
1339
|
+
isMcpCompatibleVersion,
|
|
1340
|
+
resolveSubagentProjectTrusted,
|
|
1341
|
+
createMcpSubagentFactories,
|
|
1342
|
+
disposeSubagentSession,
|
|
1343
|
+
createRepoScoutRuntimeGuardExtension,
|
|
1344
|
+
assertToolPathInsideCwd,
|
|
1345
|
+
resolveToCwd,
|
|
1346
|
+
buildSafeGitCommand,
|
|
1347
|
+
getBlockedBashReason,
|
|
1348
|
+
buildSystemPrompt,
|
|
1349
|
+
buildUserPrompt,
|
|
1350
|
+
normalizeThinkingLevel,
|
|
1351
|
+
resolveThinkingLevel,
|
|
1352
|
+
createEmptyUsage,
|
|
1353
|
+
addAssistantMessageUsage,
|
|
1354
|
+
addSessionEventUsage,
|
|
1355
|
+
inspectFinalAssistant,
|
|
1356
|
+
classifyRunFailure,
|
|
1357
|
+
appendRunDetails,
|
|
1358
|
+
};
|
|
1359
|
+
|
|
1360
|
+
async function resolveRunCwd(baseCwd: string, requested: unknown): Promise<string> {
|
|
1361
|
+
const base = path.resolve(baseCwd);
|
|
1362
|
+
if (requested === undefined) return base;
|
|
1363
|
+
if (typeof requested !== "string" || !requested.trim())
|
|
1364
|
+
throw new Error("Invalid parameters: expected cwd to be a non-empty string when provided.");
|
|
1365
|
+
const resolved = resolveToCwd(requested, base);
|
|
1366
|
+
const stat = await fs.stat(resolved).catch(() => undefined);
|
|
1367
|
+
if (!stat?.isDirectory())
|
|
1368
|
+
throw new Error(`Invalid parameters: cwd is not an existing directory: ${resolved}`);
|
|
1369
|
+
return resolved;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
export default function repoScoutExtension(pi: ExtensionAPI) {
|
|
1373
|
+
pi.on("tool_result", async (event) => {
|
|
1374
|
+
if (event.toolName !== "repo_scout") return undefined;
|
|
1375
|
+
const details = event.details as RunDetails | undefined;
|
|
1376
|
+
if (details?.status !== "error") return undefined;
|
|
1377
|
+
return { isError: true };
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
pi.registerTool({
|
|
1381
|
+
name: "repo_scout",
|
|
1382
|
+
label: "Repo scout",
|
|
1383
|
+
description:
|
|
1384
|
+
"Isolated read-only agent with read, grep, find, ls, guarded read-only bash, and configured MCP tools that scans a repository and reports stack, conventions, commands, and hotspots.",
|
|
1385
|
+
promptSnippet:
|
|
1386
|
+
"Scan the repository with an isolated read-only child agent; returns a concise report of detected stack, conventions, commands, hotspots, and open questions with run details.",
|
|
1387
|
+
promptGuidelines: [
|
|
1388
|
+
"Use repo_scout to orient in an unfamiliar repository before planning or implementing; optionally pass a task to focus the scan.",
|
|
1389
|
+
"repo_scout is read-only: it cannot edit files. It reports stack, conventions, validation commands, and hotspots with evidence paths.",
|
|
1390
|
+
],
|
|
1391
|
+
parameters: RepoScoutParams,
|
|
1392
|
+
|
|
1393
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1394
|
+
const cwd = await resolveRunCwd(ctx.cwd, params.cwd);
|
|
1395
|
+
|
|
1396
|
+
const input = {
|
|
1397
|
+
task: typeof params.task === "string" ? params.task : undefined,
|
|
1398
|
+
};
|
|
1399
|
+
const details: RunDetails = {
|
|
1400
|
+
status: "running",
|
|
1401
|
+
cwd,
|
|
1402
|
+
turns: 0,
|
|
1403
|
+
toolCalls: [],
|
|
1404
|
+
startedAt: Date.now(),
|
|
1405
|
+
};
|
|
1406
|
+
|
|
1407
|
+
let lastContent = "(scanning repository...)";
|
|
1408
|
+
let session: AgentSession | undefined;
|
|
1409
|
+
let unsubscribe: (() => void) | undefined;
|
|
1410
|
+
let runTimeout: NodeJS.Timeout | undefined;
|
|
1411
|
+
let abortListenerAdded = false;
|
|
1412
|
+
let callerAborted = Boolean(signal?.aborted);
|
|
1413
|
+
const usage = createEmptyUsage();
|
|
1414
|
+
|
|
1415
|
+
const emit = () => {
|
|
1416
|
+
onUpdate?.({ content: [{ type: "text", text: lastContent }], details });
|
|
1417
|
+
};
|
|
1418
|
+
|
|
1419
|
+
const abortForCleanup = () => {
|
|
1420
|
+
void session?.abort();
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
const abortFromCaller = () => {
|
|
1424
|
+
callerAborted = true;
|
|
1425
|
+
details.status = "aborted";
|
|
1426
|
+
details.endedAt = Date.now();
|
|
1427
|
+
lastContent = "Aborted";
|
|
1428
|
+
emit();
|
|
1429
|
+
abortForCleanup();
|
|
1430
|
+
};
|
|
1431
|
+
|
|
1432
|
+
if (signal?.aborted) abortFromCaller();
|
|
1433
|
+
if (signal && !signal.aborted) {
|
|
1434
|
+
signal.addEventListener("abort", abortFromCaller);
|
|
1435
|
+
abortListenerAdded = true;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
try {
|
|
1439
|
+
emit();
|
|
1440
|
+
|
|
1441
|
+
const model = ctx.model as PiModel | undefined;
|
|
1442
|
+
if (!model)
|
|
1443
|
+
throw new Error(
|
|
1444
|
+
"No active session model is available. Select a model before using repo_scout.",
|
|
1445
|
+
);
|
|
1446
|
+
|
|
1447
|
+
const projectTrusted = resolveSubagentProjectTrusted(ctx, cwd);
|
|
1448
|
+
const isolatedSettingsManager = SettingsManager.inMemory({}, { projectTrusted });
|
|
1449
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
1450
|
+
cwd,
|
|
1451
|
+
agentDir: getAgentDir(),
|
|
1452
|
+
settingsManager: isolatedSettingsManager,
|
|
1453
|
+
noExtensions: true,
|
|
1454
|
+
noSkills: true,
|
|
1455
|
+
noPromptTemplates: true,
|
|
1456
|
+
noThemes: true,
|
|
1457
|
+
noContextFiles: true,
|
|
1458
|
+
extensionFactories: [
|
|
1459
|
+
createRepoScoutRuntimeGuardExtension({
|
|
1460
|
+
cwd,
|
|
1461
|
+
maxTurns: MAX_TURNS,
|
|
1462
|
+
}),
|
|
1463
|
+
...createMcpSubagentFactories(),
|
|
1464
|
+
],
|
|
1465
|
+
systemPromptOverride: () =>
|
|
1466
|
+
buildSystemPrompt({
|
|
1467
|
+
cwd,
|
|
1468
|
+
maxTurns: MAX_TURNS,
|
|
1469
|
+
maxRunSeconds: Math.round(MAX_RUN_MS / 1000),
|
|
1470
|
+
}),
|
|
1471
|
+
appendSystemPromptOverride: () => [],
|
|
1472
|
+
skillsOverride: () => ({ skills: [], diagnostics: [] }),
|
|
1473
|
+
promptsOverride: () => ({ prompts: [], diagnostics: [] }),
|
|
1474
|
+
themesOverride: () => ({ themes: [], diagnostics: [] }),
|
|
1475
|
+
agentsFilesOverride: () => ({ agentsFiles: [] }),
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
await resourceLoader.reload();
|
|
1479
|
+
|
|
1480
|
+
const thinking = resolveThinkingLevel(model, DEFAULT_THINKING_LEVEL);
|
|
1481
|
+
applyModelAttemptDetails(details, model, thinking);
|
|
1482
|
+
|
|
1483
|
+
const created = await createAgentSession({
|
|
1484
|
+
cwd,
|
|
1485
|
+
...getModelRuntimeOption(ctx),
|
|
1486
|
+
resourceLoader,
|
|
1487
|
+
settingsManager: isolatedSettingsManager,
|
|
1488
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
1489
|
+
model: model as CreateAgentSessionModel,
|
|
1490
|
+
thinkingLevel: thinking.effective,
|
|
1491
|
+
tools: ["read", "grep", "find", "ls", "bash", ...SUBAGENT_MCP_TOOLS],
|
|
1492
|
+
});
|
|
1493
|
+
session = created.session;
|
|
1494
|
+
// Connect extensions (MCP servers) before prompting. Feature-detected: Pi >=0.99.0.
|
|
1495
|
+
await (created.session as any).bindExtensions?.({ mode: "json" });
|
|
1496
|
+
unsubscribe = session.subscribe((event) => {
|
|
1497
|
+
addSessionEventUsage(usage, event);
|
|
1498
|
+
switch (event.type) {
|
|
1499
|
+
case "turn_end":
|
|
1500
|
+
details.turns += 1;
|
|
1501
|
+
emit();
|
|
1502
|
+
break;
|
|
1503
|
+
case "tool_execution_start":
|
|
1504
|
+
details.toolCalls.push({
|
|
1505
|
+
name: event.toolName,
|
|
1506
|
+
args: event.args,
|
|
1507
|
+
});
|
|
1508
|
+
if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
|
|
1509
|
+
details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
|
|
1510
|
+
}
|
|
1511
|
+
emit();
|
|
1512
|
+
break;
|
|
1513
|
+
case "message_update":
|
|
1514
|
+
if (event.assistantMessageEvent?.type === "text_delta") {
|
|
1515
|
+
lastContent += event.assistantMessageEvent.delta ?? "";
|
|
1516
|
+
emit();
|
|
1517
|
+
}
|
|
1518
|
+
break;
|
|
1519
|
+
}
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
if (!callerAborted) {
|
|
1523
|
+
const promptPromise = session.prompt(buildUserPrompt(input, cwd), {
|
|
1524
|
+
expandPromptTemplates: false,
|
|
1525
|
+
});
|
|
1526
|
+
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
1527
|
+
runTimeout = setTimeout(() => {
|
|
1528
|
+
abortForCleanup();
|
|
1529
|
+
reject(
|
|
1530
|
+
new Error(`repo_scout timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`),
|
|
1531
|
+
);
|
|
1532
|
+
}, MAX_RUN_MS);
|
|
1533
|
+
});
|
|
1534
|
+
await Promise.race([promptPromise, timeoutPromise]);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
const outcome = inspectFinalAssistant(session.state.messages);
|
|
1538
|
+
if (outcome.ok) {
|
|
1539
|
+
lastContent = outcome.answer;
|
|
1540
|
+
details.status = "done";
|
|
1541
|
+
} else if (callerAborted) {
|
|
1542
|
+
lastContent = "Aborted";
|
|
1543
|
+
details.status = "aborted";
|
|
1544
|
+
} else {
|
|
1545
|
+
throw new Error(outcome.reason);
|
|
1546
|
+
}
|
|
1547
|
+
details.endedAt = Date.now();
|
|
1548
|
+
const report = appendRunDetails(lastContent, details);
|
|
1549
|
+
lastContent = report;
|
|
1550
|
+
emit();
|
|
1551
|
+
return { content: [{ type: "text", text: report }], details, usage };
|
|
1552
|
+
} catch (error) {
|
|
1553
|
+
const failure = classifyRunFailure(error, callerAborted);
|
|
1554
|
+
details.status = failure.status;
|
|
1555
|
+
details.error = failure.error;
|
|
1556
|
+
details.endedAt = Date.now();
|
|
1557
|
+
lastContent = appendRunDetails(
|
|
1558
|
+
`# Repository scout report\n\n## Open questions\n- repo_scout execution failed. Evidence: ${failure.message}.`,
|
|
1559
|
+
details,
|
|
1560
|
+
);
|
|
1561
|
+
emit();
|
|
1562
|
+
return {
|
|
1563
|
+
content: [{ type: "text", text: lastContent }],
|
|
1564
|
+
details,
|
|
1565
|
+
usage,
|
|
1566
|
+
};
|
|
1567
|
+
} finally {
|
|
1568
|
+
if (runTimeout) clearTimeout(runTimeout);
|
|
1569
|
+
if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
|
|
1570
|
+
unsubscribe?.();
|
|
1571
|
+
await disposeSubagentSession(session);
|
|
1572
|
+
}
|
|
1573
|
+
},
|
|
1574
|
+
});
|
|
1575
|
+
}
|