@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,1585 @@
|
|
|
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
|
+
"Diff summarizer 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
|
+
"Diff summarizer 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
|
+
"Diff summarizer 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
|
+
"Diff summarizer 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
|
+
"Diff summarizer 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
|
+
"Diff summarizer 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 DiffSummarizerParams = Type.Object({
|
|
253
|
+
diff: Type.Optional(
|
|
254
|
+
Type.String({
|
|
255
|
+
description:
|
|
256
|
+
"Optional diff or patch to summarize. When omitted, the current local git diff, staged diff, and untracked files are collected.",
|
|
257
|
+
}),
|
|
258
|
+
),
|
|
259
|
+
requirements: Type.Optional(
|
|
260
|
+
Type.String({
|
|
261
|
+
description:
|
|
262
|
+
"Optional requirements, ticket text, or intent to check the diff against. When omitted, intent is inferred with low confidence.",
|
|
263
|
+
}),
|
|
264
|
+
),
|
|
265
|
+
cwd: Type.Optional(
|
|
266
|
+
Type.String({
|
|
267
|
+
description:
|
|
268
|
+
"Optional working directory (checkout) to summarize; defaults to the current session directory.",
|
|
269
|
+
}),
|
|
270
|
+
),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
type ToolCall = {
|
|
274
|
+
name: string;
|
|
275
|
+
args: unknown;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
type RunStatus = "running" | "done" | "error" | "aborted";
|
|
279
|
+
|
|
280
|
+
type RunDetails = {
|
|
281
|
+
status: RunStatus;
|
|
282
|
+
cwd: string;
|
|
283
|
+
turns: number;
|
|
284
|
+
toolCalls: ToolCall[];
|
|
285
|
+
startedAt: number;
|
|
286
|
+
endedAt?: number;
|
|
287
|
+
error?: string;
|
|
288
|
+
modelRef?: string;
|
|
289
|
+
modelName?: string;
|
|
290
|
+
requestedThinkingLevel?: ThinkingLevel;
|
|
291
|
+
effectiveThinkingLevel?: ThinkingLevel;
|
|
292
|
+
thinkingLevelClamped?: boolean;
|
|
293
|
+
thinkingLevelNote?: string;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
type PiModel = {
|
|
297
|
+
provider: string;
|
|
298
|
+
id: string;
|
|
299
|
+
name?: string;
|
|
300
|
+
reasoning?: boolean;
|
|
301
|
+
contextWindow?: number;
|
|
302
|
+
maxTokens?: number;
|
|
303
|
+
thinkingLevelMap?: ThinkingLevelMap;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
type CreateAgentSessionOptions = NonNullable<Parameters<typeof createAgentSession>[0]>;
|
|
307
|
+
type CreateAgentSessionModel = CreateAgentSessionOptions["model"];
|
|
308
|
+
|
|
309
|
+
function getModelRuntimeOption(ctx: {
|
|
310
|
+
modelRegistry?: unknown;
|
|
311
|
+
}): Pick<CreateAgentSessionOptions, "modelRuntime"> {
|
|
312
|
+
const modelRuntime = (
|
|
313
|
+
ctx.modelRegistry as { runtime?: CreateAgentSessionOptions["modelRuntime"] } | undefined
|
|
314
|
+
)?.runtime;
|
|
315
|
+
return modelRuntime ? { modelRuntime } : {};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function normalizeThinkingLevel(value: unknown): ThinkingLevel | undefined {
|
|
319
|
+
return typeof value === "string" && THINKING_LEVELS.includes(value as ThinkingLevel)
|
|
320
|
+
? (value as ThinkingLevel)
|
|
321
|
+
: undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Keep these local so the extension stays compatible with older pi peer installs that do not export clamp helpers.
|
|
325
|
+
function isThinkingLevelSupported(model: PiModel, level: ThinkingLevel): boolean {
|
|
326
|
+
if (!model.reasoning) return level === "off";
|
|
327
|
+
|
|
328
|
+
const map = model.thinkingLevelMap;
|
|
329
|
+
if (level === "xhigh" || level === "max") {
|
|
330
|
+
return !!map && Object.prototype.hasOwnProperty.call(map, level) && map[level] != null;
|
|
331
|
+
}
|
|
332
|
+
return map?.[level] !== null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function clampThinkingLevel(model: PiModel, requested: ThinkingLevel): ThinkingLevel {
|
|
336
|
+
if (isThinkingLevelSupported(model, requested)) return requested;
|
|
337
|
+
|
|
338
|
+
const requestedIndex = THINKING_LEVELS.indexOf(requested);
|
|
339
|
+
for (let index = requestedIndex + 1; index < THINKING_LEVELS.length; index += 1) {
|
|
340
|
+
const level = THINKING_LEVELS[index];
|
|
341
|
+
if (isThinkingLevelSupported(model, level)) return level;
|
|
342
|
+
}
|
|
343
|
+
for (let index = requestedIndex - 1; index >= 0; index -= 1) {
|
|
344
|
+
const level = THINKING_LEVELS[index];
|
|
345
|
+
if (isThinkingLevelSupported(model, level)) return level;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return "off";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function resolveThinkingLevel(
|
|
352
|
+
model: PiModel | undefined,
|
|
353
|
+
requestedThinkingLevel: ThinkingLevel | undefined,
|
|
354
|
+
): {
|
|
355
|
+
requested: ThinkingLevel;
|
|
356
|
+
effective: ThinkingLevel;
|
|
357
|
+
clamped: boolean;
|
|
358
|
+
note: string;
|
|
359
|
+
} {
|
|
360
|
+
const requested = requestedThinkingLevel ?? (model?.reasoning ? DEFAULT_THINKING_LEVEL : "off");
|
|
361
|
+
const effective = model ? clampThinkingLevel(model, requested) : requested;
|
|
362
|
+
const clamped = effective !== requested;
|
|
363
|
+
if (clamped) {
|
|
364
|
+
return {
|
|
365
|
+
requested,
|
|
366
|
+
effective,
|
|
367
|
+
clamped,
|
|
368
|
+
note: `requested ${requested}; clamped to ${effective}`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
if (requestedThinkingLevel) {
|
|
372
|
+
return { requested, effective, clamped, note: `requested ${requested}` };
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
requested,
|
|
376
|
+
effective,
|
|
377
|
+
clamped,
|
|
378
|
+
note: model?.reasoning
|
|
379
|
+
? `defaulted to ${effective}`
|
|
380
|
+
: `defaulted to off for non-reasoning model`,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function applyModelAttemptDetails(
|
|
385
|
+
details: RunDetails,
|
|
386
|
+
model: PiModel,
|
|
387
|
+
thinking: ReturnType<typeof resolveThinkingLevel>,
|
|
388
|
+
): void {
|
|
389
|
+
details.modelRef = `${model.provider}/${model.id}`;
|
|
390
|
+
details.modelName = model.name;
|
|
391
|
+
details.requestedThinkingLevel = thinking.requested;
|
|
392
|
+
details.effectiveThinkingLevel = thinking.effective;
|
|
393
|
+
details.thinkingLevelClamped = thinking.clamped || undefined;
|
|
394
|
+
details.thinkingLevelNote = thinking.note;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function createEmptyUsage(): Usage {
|
|
398
|
+
return {
|
|
399
|
+
input: 0,
|
|
400
|
+
output: 0,
|
|
401
|
+
cacheRead: 0,
|
|
402
|
+
cacheWrite: 0,
|
|
403
|
+
totalTokens: 0,
|
|
404
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function addUsage(total: Usage, usage: Usage): void {
|
|
409
|
+
total.input += usage.input || 0;
|
|
410
|
+
total.output += usage.output || 0;
|
|
411
|
+
total.cacheRead += usage.cacheRead || 0;
|
|
412
|
+
total.cacheWrite += usage.cacheWrite || 0;
|
|
413
|
+
if (usage.cacheWrite1h !== undefined)
|
|
414
|
+
total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
|
|
415
|
+
if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
|
|
416
|
+
total.totalTokens += usage.totalTokens || 0;
|
|
417
|
+
total.cost.input += usage.cost?.input || 0;
|
|
418
|
+
total.cost.output += usage.cost?.output || 0;
|
|
419
|
+
total.cost.cacheRead += usage.cost?.cacheRead || 0;
|
|
420
|
+
total.cost.cacheWrite += usage.cost?.cacheWrite || 0;
|
|
421
|
+
total.cost.total += usage.cost?.total || 0;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
type AssistantLikeMessage = {
|
|
425
|
+
role?: string;
|
|
426
|
+
content?: unknown;
|
|
427
|
+
stopReason?: unknown;
|
|
428
|
+
errorMessage?: unknown;
|
|
429
|
+
usage?: unknown;
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
function addAssistantMessageUsage(total: Usage, message: unknown): void {
|
|
433
|
+
const assistant = message as AssistantLikeMessage;
|
|
434
|
+
if (assistant?.role !== "assistant" || !assistant.usage || typeof assistant.usage !== "object")
|
|
435
|
+
return;
|
|
436
|
+
addUsage(total, assistant.usage as Usage);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function addSessionEventUsage(total: Usage, event: unknown): void {
|
|
440
|
+
const sessionEvent = event as {
|
|
441
|
+
type?: string;
|
|
442
|
+
message?: unknown;
|
|
443
|
+
result?: { usage?: Usage };
|
|
444
|
+
};
|
|
445
|
+
if (sessionEvent?.type === "message_end")
|
|
446
|
+
return void addAssistantMessageUsage(total, sessionEvent.message);
|
|
447
|
+
if (sessionEvent?.type === "compaction_end" && sessionEvent.result?.usage)
|
|
448
|
+
addUsage(total, sessionEvent.result.usage);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function inspectFinalAssistant(
|
|
452
|
+
messages: unknown[],
|
|
453
|
+
):
|
|
454
|
+
| { ok: true; answer: string; stopReason?: string }
|
|
455
|
+
| { ok: false; reason: string; stopReason?: string; errorMessage?: string } {
|
|
456
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
457
|
+
const message = messages[i] as AssistantLikeMessage;
|
|
458
|
+
if (message?.role !== "assistant") continue;
|
|
459
|
+
const stopReason = typeof message.stopReason === "string" ? message.stopReason.trim() : "";
|
|
460
|
+
const errorMessage =
|
|
461
|
+
typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
|
|
462
|
+
const answer = Array.isArray(message.content)
|
|
463
|
+
? message.content
|
|
464
|
+
.map((part) =>
|
|
465
|
+
part &&
|
|
466
|
+
typeof part === "object" &&
|
|
467
|
+
(part as { type?: string }).type === "text" &&
|
|
468
|
+
typeof (part as { text?: unknown }).text === "string"
|
|
469
|
+
? (part as { text: string }).text
|
|
470
|
+
: "",
|
|
471
|
+
)
|
|
472
|
+
.join("")
|
|
473
|
+
.trim()
|
|
474
|
+
: "";
|
|
475
|
+
if (stopReason === "error")
|
|
476
|
+
return {
|
|
477
|
+
ok: false,
|
|
478
|
+
reason: `diff_summarizer subagent error: ${errorMessage || "provider/model error"}`,
|
|
479
|
+
stopReason,
|
|
480
|
+
errorMessage,
|
|
481
|
+
};
|
|
482
|
+
if (stopReason === "aborted")
|
|
483
|
+
return {
|
|
484
|
+
ok: false,
|
|
485
|
+
reason: "diff_summarizer subagent aborted before producing a usable answer",
|
|
486
|
+
stopReason,
|
|
487
|
+
};
|
|
488
|
+
if (answer) return { ok: true, answer, stopReason: stopReason || undefined };
|
|
489
|
+
return {
|
|
490
|
+
ok: false,
|
|
491
|
+
reason: stopReason
|
|
492
|
+
? `diff_summarizer subagent produced no final assistant text (stopReason: ${stopReason})`
|
|
493
|
+
: "diff_summarizer subagent produced no final assistant text",
|
|
494
|
+
stopReason: stopReason || undefined,
|
|
495
|
+
errorMessage: errorMessage || undefined,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
ok: false,
|
|
500
|
+
reason: "diff_summarizer subagent produced no assistant message",
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function classifyRunFailure(
|
|
505
|
+
error: unknown,
|
|
506
|
+
aborted: boolean,
|
|
507
|
+
): { status: "aborted" | "error"; message: string; error?: string } {
|
|
508
|
+
const message = aborted ? "Aborted" : error instanceof Error ? error.message : String(error);
|
|
509
|
+
return {
|
|
510
|
+
status: aborted ? "aborted" : "error",
|
|
511
|
+
message,
|
|
512
|
+
error: aborted ? undefined : message,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function isInside(parent: string, child: string): boolean {
|
|
517
|
+
const parentResolved = path.resolve(parent);
|
|
518
|
+
const childResolved = path.resolve(child);
|
|
519
|
+
return (
|
|
520
|
+
childResolved === parentResolved || childResolved.startsWith(`${parentResolved}${path.sep}`)
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const UNICODE_SPACES_PATTERN = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Mirrors normalizeWindowsShellPath from dist/utils/paths.js. No-op on
|
|
528
|
+
* non-win32 paths.
|
|
529
|
+
*/
|
|
530
|
+
function normalizeWindowsShellPathLocal(filePath: string): string {
|
|
531
|
+
if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\"))
|
|
532
|
+
return filePath;
|
|
533
|
+
const match = filePath.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
|
|
534
|
+
if (!match) return filePath;
|
|
535
|
+
const suffix = match[2]?.replaceAll("/", "\\");
|
|
536
|
+
return `${match[1].toUpperCase()}:\\${suffix ?? ""}`;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Mirrors Pi's normalizePath (dist/utils/paths.js). On win32, Git Bash / WSL
|
|
541
|
+
* drive-path normalisation always runs; only tilde expansion is gated by
|
|
542
|
+
* expandTilde.
|
|
543
|
+
*/
|
|
544
|
+
function normalizePath(
|
|
545
|
+
input: string,
|
|
546
|
+
opts: {
|
|
547
|
+
normalizeUnicodeSpaces?: boolean;
|
|
548
|
+
stripAtPrefix?: boolean;
|
|
549
|
+
expandTilde?: boolean;
|
|
550
|
+
} = {},
|
|
551
|
+
): string {
|
|
552
|
+
const { normalizeUnicodeSpaces = false, stripAtPrefix = false, expandTilde = true } = opts;
|
|
553
|
+
let p = input;
|
|
554
|
+
if (normalizeUnicodeSpaces) p = p.replace(UNICODE_SPACES_PATTERN, " ");
|
|
555
|
+
if (stripAtPrefix && p.startsWith("@")) p = p.slice(1);
|
|
556
|
+
if (process.platform === "win32") p = normalizeWindowsShellPathLocal(p);
|
|
557
|
+
if (expandTilde) {
|
|
558
|
+
const home = os.homedir();
|
|
559
|
+
if (p === "~") p = home;
|
|
560
|
+
else if (p.startsWith("~/") || (process.platform === "win32" && p.startsWith("~\\")))
|
|
561
|
+
p = path.join(home, p.slice(2));
|
|
562
|
+
}
|
|
563
|
+
if (/^file:\/\//.test(p)) p = fileURLToPath(p);
|
|
564
|
+
return p;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Mirrors Pi's resolveToCwd (dist/core/tools/path-utils.js). Exposed via
|
|
569
|
+
* __test__ for parity tests; not imported by other extensions.
|
|
570
|
+
*/
|
|
571
|
+
function resolveToCwd(rawPath: string, cwd: string): string {
|
|
572
|
+
const normalized = normalizePath(rawPath, {
|
|
573
|
+
normalizeUnicodeSpaces: true,
|
|
574
|
+
stripAtPrefix: true,
|
|
575
|
+
});
|
|
576
|
+
const base = normalizePath(cwd);
|
|
577
|
+
return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(base, normalized);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Mirrors tryMacOSScreenshotPath from dist/core/tools/path-utils.js */
|
|
581
|
+
function tryMacOSScreenshotPathVariant(filePath: string): string {
|
|
582
|
+
return filePath.replace(/ (AM|PM)\./gi, "\u202F$1.");
|
|
583
|
+
}
|
|
584
|
+
/** Mirrors tryNFDVariant from dist/core/tools/path-utils.js */
|
|
585
|
+
function tryNFDPathVariant(filePath: string): string {
|
|
586
|
+
return filePath.normalize("NFD");
|
|
587
|
+
}
|
|
588
|
+
/** Mirrors tryCurlyQuoteVariant from dist/core/tools/path-utils.js */
|
|
589
|
+
function tryCurlyQuotePathVariant(filePath: string): string {
|
|
590
|
+
return filePath.replace(/'/g, "\u2019");
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Returns all read path candidates including Pi's resolveReadPathAsync fallback
|
|
594
|
+
* variants. Used only for the read tool guard; grep/find/ls use resolveToCwd only.
|
|
595
|
+
*/
|
|
596
|
+
function getReadPathCandidates(resolved: string): string[] {
|
|
597
|
+
const nfd = tryNFDPathVariant(resolved);
|
|
598
|
+
return [
|
|
599
|
+
resolved,
|
|
600
|
+
tryMacOSScreenshotPathVariant(resolved),
|
|
601
|
+
nfd,
|
|
602
|
+
tryCurlyQuotePathVariant(resolved),
|
|
603
|
+
tryCurlyQuotePathVariant(nfd),
|
|
604
|
+
];
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function assertToolPathInsideCwd(
|
|
608
|
+
cwd: string,
|
|
609
|
+
rawPath: unknown,
|
|
610
|
+
toolName: string,
|
|
611
|
+
): Promise<string | undefined> {
|
|
612
|
+
if (rawPath !== undefined && typeof rawPath !== "string")
|
|
613
|
+
return `${toolName} path must be a string.`;
|
|
614
|
+
// A path is allowed if it resolves inside EITHER the realpath'd root (root) OR
|
|
615
|
+
// the lexical cwd (lexicalCwd). This handles missing files in symlinked workspaces
|
|
616
|
+
// where realpath fails and the lexical fallback starts with the alias prefix.
|
|
617
|
+
const root = await fs.realpath(cwd).catch(() => path.resolve(cwd));
|
|
618
|
+
const lexicalCwd = path.resolve(cwd);
|
|
619
|
+
// For the read tool, also check Pi's resolveReadPathAsync fallback variants so
|
|
620
|
+
// a missing file whose variant resolves outside is not silently allowed.
|
|
621
|
+
try {
|
|
622
|
+
const piResolved = resolveToCwd(typeof rawPath === "string" ? rawPath : ".", cwd);
|
|
623
|
+
const candidates = toolName === "read" ? getReadPathCandidates(piResolved) : [piResolved];
|
|
624
|
+
// Deliberate policy: all candidates are checked regardless of existence (not
|
|
625
|
+
// Pi's existence-ordered selection) so the guard cannot be raced by creating
|
|
626
|
+
// a variant between the hook and the read. Trade-off: a read is blocked when
|
|
627
|
+
// an escaping variant symlink sits next to a safe primary file.
|
|
628
|
+
for (const candidate of candidates) {
|
|
629
|
+
const candidateRealPath = await fs.realpath(candidate).catch(() => candidate);
|
|
630
|
+
if (!isInside(root, candidateRealPath) && !isInside(lexicalCwd, candidateRealPath))
|
|
631
|
+
return `${toolName} is limited to the local checkout: ${candidateRealPath}`;
|
|
632
|
+
}
|
|
633
|
+
} catch {
|
|
634
|
+
return `${toolName} path could not be resolved safely.`;
|
|
635
|
+
}
|
|
636
|
+
return undefined;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function getUnsafePathPatternReason(value: unknown, label: string): string | undefined {
|
|
640
|
+
if (value === undefined) return undefined;
|
|
641
|
+
if (typeof value !== "string") return `${label} must be a string.`;
|
|
642
|
+
if (path.isAbsolute(value)) return `${label} must be relative to the local checkout.`;
|
|
643
|
+
if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(value))
|
|
644
|
+
return `${label} must not traverse outside the local checkout.`;
|
|
645
|
+
return undefined;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function stripTokenQuotes(token: string): string {
|
|
649
|
+
if (
|
|
650
|
+
(token.startsWith("'") && token.endsWith("'")) ||
|
|
651
|
+
(token.startsWith('"') && token.endsWith('"'))
|
|
652
|
+
) {
|
|
653
|
+
return token.slice(1, -1);
|
|
654
|
+
}
|
|
655
|
+
return token;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
type ShellTokenizeResult = {
|
|
659
|
+
tokens: string[];
|
|
660
|
+
reason?: string;
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
function tokenizeShellCommand(command: string): ShellTokenizeResult {
|
|
664
|
+
const tokens: string[] = [];
|
|
665
|
+
let token = "";
|
|
666
|
+
let tokenStarted = false;
|
|
667
|
+
let inSingleQuote = false;
|
|
668
|
+
let inDoubleQuote = false;
|
|
669
|
+
|
|
670
|
+
const pushToken = () => {
|
|
671
|
+
if (!tokenStarted) return;
|
|
672
|
+
tokens.push(token);
|
|
673
|
+
token = "";
|
|
674
|
+
tokenStarted = false;
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
678
|
+
const char = command[index];
|
|
679
|
+
const next = command[index + 1] ?? "";
|
|
680
|
+
|
|
681
|
+
if (inSingleQuote) {
|
|
682
|
+
if (char === "'") {
|
|
683
|
+
inSingleQuote = false;
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
token += char;
|
|
687
|
+
tokenStarted = true;
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (char === "\\") {
|
|
692
|
+
return {
|
|
693
|
+
tokens,
|
|
694
|
+
reason:
|
|
695
|
+
"Diff summarizer bash blocks shell escape sequences; pass plain git, gh, or pwd arguments without backslashes.",
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (inDoubleQuote) {
|
|
700
|
+
if (char === '"') {
|
|
701
|
+
inDoubleQuote = false;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (char === "`" || char === "$") {
|
|
705
|
+
return {
|
|
706
|
+
tokens,
|
|
707
|
+
reason:
|
|
708
|
+
char === "`" || next === "("
|
|
709
|
+
? "Diff summarizer bash blocks command substitution."
|
|
710
|
+
: "Diff summarizer bash blocks shell expansion in double quotes.",
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
token += char;
|
|
714
|
+
tokenStarted = true;
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (char === "\n" || char === "\r") {
|
|
719
|
+
return {
|
|
720
|
+
tokens,
|
|
721
|
+
reason:
|
|
722
|
+
"Diff summarizer bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
if (/\s/.test(char)) {
|
|
726
|
+
pushToken();
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (char === "'") {
|
|
730
|
+
inSingleQuote = true;
|
|
731
|
+
tokenStarted = true;
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
if (char === '"') {
|
|
735
|
+
inDoubleQuote = true;
|
|
736
|
+
tokenStarted = true;
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
if (char === "`")
|
|
740
|
+
return {
|
|
741
|
+
tokens,
|
|
742
|
+
reason: "Diff summarizer bash blocks command substitution.",
|
|
743
|
+
};
|
|
744
|
+
if (char === "$") {
|
|
745
|
+
return {
|
|
746
|
+
tokens,
|
|
747
|
+
reason:
|
|
748
|
+
next === "'" || next === '"'
|
|
749
|
+
? "Diff summarizer bash blocks ANSI-C and localized shell quotes."
|
|
750
|
+
: next === "("
|
|
751
|
+
? "Diff summarizer bash blocks command substitution."
|
|
752
|
+
: "Diff summarizer bash blocks shell expansion.",
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
if (/[;&|()]/.test(char)) {
|
|
756
|
+
return {
|
|
757
|
+
tokens,
|
|
758
|
+
reason:
|
|
759
|
+
"Diff summarizer bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
if (char === ">" || char === "<") {
|
|
763
|
+
return {
|
|
764
|
+
tokens,
|
|
765
|
+
reason: "Diff summarizer bash blocks shell redirection to keep inspection read-only.",
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
if (char === "{" || char === "}")
|
|
769
|
+
return {
|
|
770
|
+
tokens,
|
|
771
|
+
reason: "Diff summarizer bash blocks shell brace expansion.",
|
|
772
|
+
};
|
|
773
|
+
if (char === "*" || char === "?" || char === "[" || char === "]") {
|
|
774
|
+
return {
|
|
775
|
+
tokens,
|
|
776
|
+
reason: "Diff summarizer bash blocks shell glob expansion.",
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
if (char === "~" && !tokenStarted)
|
|
780
|
+
return {
|
|
781
|
+
tokens,
|
|
782
|
+
reason: "Diff summarizer bash blocks shell home-directory expansion.",
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
token += char;
|
|
786
|
+
tokenStarted = true;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (inSingleQuote || inDoubleQuote)
|
|
790
|
+
return {
|
|
791
|
+
tokens,
|
|
792
|
+
reason: "Diff summarizer bash blocks unterminated or malformed shell quoting.",
|
|
793
|
+
};
|
|
794
|
+
pushToken();
|
|
795
|
+
return { tokens };
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function tokenizeSegment(segment: string): string[] {
|
|
799
|
+
return tokenizeShellCommand(segment).tokens;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function shellQuoteToken(token: string): string {
|
|
803
|
+
return `'${token.replace(/'/g, `'"'"'`)}'`;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function getExecutableName(token: string): string {
|
|
807
|
+
return path.basename(token).toLowerCase();
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function getShellSyntaxReason(command: string): string | undefined {
|
|
811
|
+
return tokenizeShellCommand(command).reason;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function getUnsafeGitTokenPathReason(token: string): string | undefined {
|
|
815
|
+
const valueParts = [token];
|
|
816
|
+
const equalsIndex = token.indexOf("=");
|
|
817
|
+
if (equalsIndex >= 0 && equalsIndex < token.length - 1)
|
|
818
|
+
valueParts.push(token.slice(equalsIndex + 1));
|
|
819
|
+
for (const value of valueParts) {
|
|
820
|
+
const normalized = value.startsWith("@") ? value.slice(1) : value;
|
|
821
|
+
if (normalized === "~" || normalized.startsWith("~/") || /^~[^/\\]*/.test(normalized)) {
|
|
822
|
+
return "Diff summarizer bash git arguments must not use home-directory paths; use built-in read/grep/find/ls for local files.";
|
|
823
|
+
}
|
|
824
|
+
if (path.isAbsolute(normalized)) {
|
|
825
|
+
return "Diff summarizer bash git arguments must not use absolute filesystem paths; use built-in read/grep/find/ls for local files.";
|
|
826
|
+
}
|
|
827
|
+
if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(normalized)) {
|
|
828
|
+
return "Diff summarizer bash git arguments must not traverse outside the local checkout.";
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return undefined;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function getBlockedPwdReason(tokens: string[]): string | undefined {
|
|
835
|
+
const args = tokens.slice(1);
|
|
836
|
+
if (args.length === 0) return undefined;
|
|
837
|
+
if (args.length === 1 && (args[0] === "-P" || args[0] === "-L")) return undefined;
|
|
838
|
+
return "Diff summarizer bash allows pwd only with no arguments or -P/-L.";
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function getGitSubcommand(tokens: string[]): {
|
|
842
|
+
subcommand?: string;
|
|
843
|
+
index: number;
|
|
844
|
+
reason?: string;
|
|
845
|
+
} {
|
|
846
|
+
let index = 1;
|
|
847
|
+
while (index < tokens.length) {
|
|
848
|
+
const token = tokens[index];
|
|
849
|
+
if (!token.startsWith("-")) break;
|
|
850
|
+
if (token === "--git-dir" || token.startsWith("--git-dir=")) {
|
|
851
|
+
return {
|
|
852
|
+
index,
|
|
853
|
+
reason:
|
|
854
|
+
"Diff summarizer bash blocks git --git-dir because it can inspect or mutate outside the checkout.",
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
if (token === "--work-tree" || token.startsWith("--work-tree=")) {
|
|
858
|
+
return {
|
|
859
|
+
index,
|
|
860
|
+
reason:
|
|
861
|
+
"Diff summarizer bash blocks git --work-tree because it can inspect or mutate outside the checkout.",
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
if (token === "-C") {
|
|
865
|
+
const gitCwd = tokens[index + 1];
|
|
866
|
+
if (gitCwd !== ".")
|
|
867
|
+
return {
|
|
868
|
+
index,
|
|
869
|
+
reason: "Diff summarizer bash only allows git -C .; run git from the local checkout.",
|
|
870
|
+
};
|
|
871
|
+
index += 2;
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
if (token.startsWith("-C")) {
|
|
875
|
+
if (token !== "-C.")
|
|
876
|
+
return {
|
|
877
|
+
index,
|
|
878
|
+
reason: "Diff summarizer bash only allows git -C .; run git from the local checkout.",
|
|
879
|
+
};
|
|
880
|
+
index += 1;
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (token === "--paginate" || token === "-p") {
|
|
884
|
+
return {
|
|
885
|
+
index,
|
|
886
|
+
reason:
|
|
887
|
+
"Diff summarizer bash blocks git pagination because pagers can execute local utilities.",
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
if (SAFE_GIT_GLOBAL_FLAGS.has(token)) {
|
|
891
|
+
index += 1;
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
return {
|
|
895
|
+
index,
|
|
896
|
+
reason: `Diff summarizer bash blocks git global option ${token}; use direct read-only git inspection commands from the checkout.`,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
return { subcommand: tokens[index]?.toLowerCase(), index };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function isSafeGitBranchCommand(tokens: string[], subcommandIndex: number): boolean {
|
|
903
|
+
const args = tokens.slice(subcommandIndex + 1);
|
|
904
|
+
if (args.length === 0) return true;
|
|
905
|
+
let hasListingMode = false;
|
|
906
|
+
let hasPositional = false;
|
|
907
|
+
for (const arg of args) {
|
|
908
|
+
if (!arg.startsWith("-")) {
|
|
909
|
+
hasPositional = true;
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
const name = arg.split("=", 1)[0] ?? "";
|
|
913
|
+
const hasValue = arg.includes("=");
|
|
914
|
+
if (!hasValue && SAFE_GIT_BRANCH_FLAGS.has(arg)) {
|
|
915
|
+
if (GIT_BRANCH_LISTING_FLAGS.has(arg)) hasListingMode = true;
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
if (SAFE_GIT_BRANCH_VALUE_FLAGS.has(name)) {
|
|
919
|
+
if (GIT_BRANCH_LISTING_FLAGS.has(name)) hasListingMode = true;
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
return false;
|
|
923
|
+
}
|
|
924
|
+
if (args.includes("--show-current")) return !hasPositional;
|
|
925
|
+
return !hasPositional || hasListingMode;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function isSafeGitRemoteCommand(tokens: string[], subcommandIndex: number): boolean {
|
|
929
|
+
const args = tokens.slice(subcommandIndex + 1);
|
|
930
|
+
if (args.length === 0) return true;
|
|
931
|
+
if (args.length === 1 && (args[0] === "-v" || args[0] === "--verbose")) return true;
|
|
932
|
+
const remoteAction = args.find((arg) => !arg.startsWith("-"));
|
|
933
|
+
if (remoteAction !== "get-url") return false;
|
|
934
|
+
return true;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function isBlockedGitLocalFileOption(
|
|
938
|
+
token: string,
|
|
939
|
+
flag: string,
|
|
940
|
+
blockedPrefixes: readonly string[],
|
|
941
|
+
): boolean {
|
|
942
|
+
if (!token.startsWith("--")) return false;
|
|
943
|
+
const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
|
|
944
|
+
if (option === flag) return true;
|
|
945
|
+
return blockedPrefixes.some((prefix) => option.startsWith(prefix));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function getUnsafeGitArgumentReason(
|
|
949
|
+
tokens: string[],
|
|
950
|
+
parsed: { subcommand?: string; index: number },
|
|
951
|
+
): string | undefined {
|
|
952
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
953
|
+
const token = tokens[index];
|
|
954
|
+
const lowerToken = token.toLowerCase();
|
|
955
|
+
if (token === "--no-index" || token.startsWith("--no-index=")) {
|
|
956
|
+
return "Diff summarizer bash blocks git --no-index because it can inspect arbitrary filesystem paths outside the checkout.";
|
|
957
|
+
}
|
|
958
|
+
if (token === "--paginate" || token.startsWith("--paginate=")) {
|
|
959
|
+
return "Diff summarizer bash blocks git --paginate because pagers can execute local utilities.";
|
|
960
|
+
}
|
|
961
|
+
if (token === "--open-files-in-pager" || token.startsWith("--open-files-in-pager=")) {
|
|
962
|
+
return "Diff summarizer bash blocks git --open-files-in-pager because it can execute local utilities.";
|
|
963
|
+
}
|
|
964
|
+
if (token === "-O" || token.startsWith("-O")) {
|
|
965
|
+
return "Diff summarizer bash blocks git -O because it can execute local utilities for some subcommands.";
|
|
966
|
+
}
|
|
967
|
+
if (token === "--ext-diff" || token.startsWith("--ext-diff=")) {
|
|
968
|
+
return "Diff summarizer bash blocks git --ext-diff because it can execute external diff helpers.";
|
|
969
|
+
}
|
|
970
|
+
if (token === "--textconv" || token.startsWith("--textconv=")) {
|
|
971
|
+
return "Diff summarizer bash blocks git --textconv because it can execute external text conversion filters.";
|
|
972
|
+
}
|
|
973
|
+
if (token === "--filters" || token.startsWith("--filters=")) {
|
|
974
|
+
return "Diff summarizer bash blocks git --filters because it can execute clean/smudge filters from local config.";
|
|
975
|
+
}
|
|
976
|
+
if (token === "--show-signature" || lowerToken.startsWith("--show-signat")) {
|
|
977
|
+
return "Diff summarizer bash blocks git signature verification flags because they can execute configured GPG helpers.";
|
|
978
|
+
}
|
|
979
|
+
if (token === "--help" || token.startsWith("--help=")) {
|
|
980
|
+
return "Diff summarizer bash blocks git help output because it can execute configured help, man, or browser helpers.";
|
|
981
|
+
}
|
|
982
|
+
if (token.includes("%G") || /%\((?:[^)]*signature[^)]*)\)/i.test(token)) {
|
|
983
|
+
return "Diff summarizer bash blocks git signature format atoms because they can execute configured GPG helpers.";
|
|
984
|
+
}
|
|
985
|
+
if (token === "--output" || token.startsWith("--output=")) {
|
|
986
|
+
return "Diff summarizer bash blocks git output-writing flags.";
|
|
987
|
+
}
|
|
988
|
+
for (const { flag, blockedPrefixes, reason } of GIT_OPTIONS_REQUIRING_LOCAL_FILE) {
|
|
989
|
+
if (isBlockedGitLocalFileOption(token, flag, blockedPrefixes)) return reason;
|
|
990
|
+
}
|
|
991
|
+
if (index > parsed.index) {
|
|
992
|
+
for (const option of GIT_SUBCOMMAND_OPTIONS_REQUIRING_LOCAL_FILE[parsed.subcommand ?? ""] ??
|
|
993
|
+
[]) {
|
|
994
|
+
if (option.matches(token)) return option.reason;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
const pathReason = getUnsafeGitTokenPathReason(token);
|
|
998
|
+
if (pathReason) return pathReason;
|
|
999
|
+
}
|
|
1000
|
+
return undefined;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function buildSafeGitCommand(tokens: string[]): string {
|
|
1004
|
+
const parsed = getGitSubcommand(tokens);
|
|
1005
|
+
const prefix = [
|
|
1006
|
+
"git",
|
|
1007
|
+
"--no-pager",
|
|
1008
|
+
"--no-optional-locks",
|
|
1009
|
+
...SAFE_GIT_CONFIG_OVERRIDES.flatMap((value) => ["-c", value]),
|
|
1010
|
+
];
|
|
1011
|
+
const subcommand = parsed.subcommand;
|
|
1012
|
+
if (!subcommand) return prefix.map(shellQuoteToken).join(" ");
|
|
1013
|
+
const args = tokens.slice(parsed.index + 1);
|
|
1014
|
+
const injectedFlags = (GIT_SUBCOMMAND_SAFE_FLAGS[subcommand] ?? []).filter(
|
|
1015
|
+
(flag) => !args.includes(flag),
|
|
1016
|
+
);
|
|
1017
|
+
return [...prefix, subcommand, ...injectedFlags, ...args].map(shellQuoteToken).join(" ");
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function getBlockedGitReason(tokens: string[]): string | undefined {
|
|
1021
|
+
if (getExecutableName(tokens[0] ?? "") !== "git") return undefined;
|
|
1022
|
+
const parsed = getGitSubcommand(tokens);
|
|
1023
|
+
if (parsed.reason) return parsed.reason;
|
|
1024
|
+
if (parsed.subcommand === "help") {
|
|
1025
|
+
return "Diff summarizer bash blocks git help because it can execute configured help, man, or browser helpers.";
|
|
1026
|
+
}
|
|
1027
|
+
const argumentReason = getUnsafeGitArgumentReason(tokens, parsed);
|
|
1028
|
+
if (argumentReason) return argumentReason;
|
|
1029
|
+
if (!parsed.subcommand) return undefined;
|
|
1030
|
+
if (parsed.subcommand === "branch") {
|
|
1031
|
+
if (!isSafeGitBranchCommand(tokens, parsed.index)) {
|
|
1032
|
+
return "Diff summarizer bash allows git branch only for read-only listing/show-current/contains/merged queries.";
|
|
1033
|
+
}
|
|
1034
|
+
return undefined;
|
|
1035
|
+
}
|
|
1036
|
+
if (parsed.subcommand === "remote") {
|
|
1037
|
+
if (!isSafeGitRemoteCommand(tokens, parsed.index)) {
|
|
1038
|
+
return "Diff summarizer bash allows git remote only for read-only list or get-url queries.";
|
|
1039
|
+
}
|
|
1040
|
+
return undefined;
|
|
1041
|
+
}
|
|
1042
|
+
if (!READ_ONLY_GIT_SUBCOMMANDS.has(parsed.subcommand)) {
|
|
1043
|
+
return `Diff summarizer bash blocks git ${parsed.subcommand}; only known read-only git subcommands are allowed.`;
|
|
1044
|
+
}
|
|
1045
|
+
return undefined;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function getGhCommand(tokens: string[]): {
|
|
1049
|
+
command?: string;
|
|
1050
|
+
subcommand?: string;
|
|
1051
|
+
} {
|
|
1052
|
+
let index = 1;
|
|
1053
|
+
while (index < tokens.length) {
|
|
1054
|
+
const token = tokens[index];
|
|
1055
|
+
if (!token.startsWith("-")) break;
|
|
1056
|
+
if (GH_GLOBAL_OPTIONS_WITH_VALUE.has(token)) {
|
|
1057
|
+
index += 2;
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
index += 1;
|
|
1061
|
+
}
|
|
1062
|
+
return {
|
|
1063
|
+
command: tokens[index]?.toLowerCase(),
|
|
1064
|
+
subcommand: tokens[index + 1]?.toLowerCase(),
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function normalizeFlagValue(value: string | undefined): string | undefined {
|
|
1069
|
+
return value ? stripTokenQuotes(value).trim() : undefined;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function getBlockedGhApiReason(tokens: string[]): string | undefined {
|
|
1073
|
+
const parsed = getGhCommand(tokens);
|
|
1074
|
+
if (parsed.command !== "api") return undefined;
|
|
1075
|
+
if (tokens.find((token) => token.toLowerCase() === "graphql")) {
|
|
1076
|
+
return "Diff summarizer bash blocks gh api graphql because it uses POST/body fields.";
|
|
1077
|
+
}
|
|
1078
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
1079
|
+
const token = tokens[index];
|
|
1080
|
+
const lowerToken = token.toLowerCase();
|
|
1081
|
+
if (
|
|
1082
|
+
token === "-f" ||
|
|
1083
|
+
token === "-F" ||
|
|
1084
|
+
token === "--field" ||
|
|
1085
|
+
token === "--raw-field" ||
|
|
1086
|
+
token === "--input" ||
|
|
1087
|
+
lowerToken.startsWith("-f") ||
|
|
1088
|
+
lowerToken.startsWith("--field=") ||
|
|
1089
|
+
lowerToken.startsWith("--raw-field=") ||
|
|
1090
|
+
lowerToken.startsWith("--input=")
|
|
1091
|
+
) {
|
|
1092
|
+
return "Diff summarizer bash allows read-only gh api calls only; request fields and input files are blocked.";
|
|
1093
|
+
}
|
|
1094
|
+
if (token === "--cache" || lowerToken.startsWith("--cache=")) {
|
|
1095
|
+
return "Diff summarizer bash blocks gh api --cache because it writes local cache files.";
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
let method: string | undefined;
|
|
1099
|
+
if (lowerToken === "-x" || lowerToken === "--method")
|
|
1100
|
+
method = normalizeFlagValue(tokens[index + 1]);
|
|
1101
|
+
else if (lowerToken.startsWith("-x") && token.length > 2)
|
|
1102
|
+
method = normalizeFlagValue(token.slice(2).replace(/^=/, ""));
|
|
1103
|
+
else if (lowerToken.startsWith("--method="))
|
|
1104
|
+
method = normalizeFlagValue(token.slice("--method=".length));
|
|
1105
|
+
if (method && MUTATING_GH_API_METHODS.has(method.toUpperCase())) {
|
|
1106
|
+
return "Diff summarizer bash allows read-only gh api calls only; mutating methods are blocked.";
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
return undefined;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function isReadOnlyGhCommand(command: string | undefined, subcommand: string | undefined): boolean {
|
|
1113
|
+
if (!command) return true;
|
|
1114
|
+
if (command === "api") return true;
|
|
1115
|
+
if (command === "search") return true;
|
|
1116
|
+
if (command === "repo") return subcommand === "view" || subcommand === "list";
|
|
1117
|
+
if (command === "pr")
|
|
1118
|
+
return (
|
|
1119
|
+
subcommand === "view" ||
|
|
1120
|
+
subcommand === "list" ||
|
|
1121
|
+
subcommand === "diff" ||
|
|
1122
|
+
subcommand === "status" ||
|
|
1123
|
+
subcommand === "checks"
|
|
1124
|
+
);
|
|
1125
|
+
if (command === "issue")
|
|
1126
|
+
return subcommand === "view" || subcommand === "list" || subcommand === "status";
|
|
1127
|
+
if (command === "release") return subcommand === "view" || subcommand === "list";
|
|
1128
|
+
if (command === "run") return subcommand === "view" || subcommand === "list";
|
|
1129
|
+
if (command === "workflow") return subcommand === "view" || subcommand === "list";
|
|
1130
|
+
if (command === "label") return subcommand === "list";
|
|
1131
|
+
if (command === "milestone") return subcommand === "list";
|
|
1132
|
+
return false;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function getBlockedGhReason(command: string): string | undefined {
|
|
1136
|
+
const tokens = tokenizeSegment(command);
|
|
1137
|
+
if (getExecutableName(tokens[0] ?? "") !== "gh") return undefined;
|
|
1138
|
+
if (/\bgh\s+auth\s+(?:login|logout|refresh|setup-git|token)\b/i.test(command)) {
|
|
1139
|
+
return "Diff summarizer bash blocks gh auth commands and token inspection.";
|
|
1140
|
+
}
|
|
1141
|
+
if (
|
|
1142
|
+
tokens.some((token) => {
|
|
1143
|
+
const lowerToken = token.toLowerCase();
|
|
1144
|
+
return (
|
|
1145
|
+
lowerToken === "--web" ||
|
|
1146
|
+
lowerToken === "-w" ||
|
|
1147
|
+
lowerToken.startsWith("--web=") ||
|
|
1148
|
+
lowerToken.startsWith("-w=")
|
|
1149
|
+
);
|
|
1150
|
+
})
|
|
1151
|
+
) {
|
|
1152
|
+
return "Diff summarizer bash blocks gh --web because it can launch external browser helpers.";
|
|
1153
|
+
}
|
|
1154
|
+
const apiReason = getBlockedGhApiReason(tokens);
|
|
1155
|
+
if (apiReason) return apiReason;
|
|
1156
|
+
if (
|
|
1157
|
+
/\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(
|
|
1158
|
+
command,
|
|
1159
|
+
)
|
|
1160
|
+
) {
|
|
1161
|
+
return "Diff summarizer bash blocks mutating gh commands.";
|
|
1162
|
+
}
|
|
1163
|
+
const parsed = getGhCommand(tokens);
|
|
1164
|
+
if (!isReadOnlyGhCommand(parsed.command, parsed.subcommand)) {
|
|
1165
|
+
return `Diff summarizer bash blocks gh ${[parsed.command, parsed.subcommand].filter(Boolean).join(" ")}; only known read-only gh commands are allowed.`;
|
|
1166
|
+
}
|
|
1167
|
+
return undefined;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function getBlockedBashReason(command: string): string | undefined {
|
|
1171
|
+
const trimmed = command.trim();
|
|
1172
|
+
if (!trimmed) return "Diff summarizer bash requires a non-empty command.";
|
|
1173
|
+
if (/`|\$\(/.test(trimmed)) return "Diff summarizer bash blocks command substitution.";
|
|
1174
|
+
const syntaxReason = getShellSyntaxReason(trimmed);
|
|
1175
|
+
if (syntaxReason) return syntaxReason;
|
|
1176
|
+
|
|
1177
|
+
const tokens = tokenizeSegment(trimmed);
|
|
1178
|
+
if (tokens.length === 0) return "Diff summarizer bash requires a non-empty command.";
|
|
1179
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]))
|
|
1180
|
+
return "Diff summarizer bash blocks inline environment assignment.";
|
|
1181
|
+
if (/[\\/]/.test(tokens[0])) {
|
|
1182
|
+
return "Diff summarizer bash requires direct git, gh, or pwd invocation without path-qualified executables.";
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const executable = getExecutableName(tokens[0]);
|
|
1186
|
+
if (!READ_ONLY_BASH_COMMANDS.has(executable)) {
|
|
1187
|
+
return `Diff summarizer bash blocks ${executable || "this command"}; use built-in read/grep/find/ls for local files or read-only git/gh inspection commands.`;
|
|
1188
|
+
}
|
|
1189
|
+
if (executable === "pwd") return getBlockedPwdReason(tokens);
|
|
1190
|
+
if (executable === "git") return getBlockedGitReason(tokens);
|
|
1191
|
+
if (executable === "gh") return getBlockedGhReason(trimmed);
|
|
1192
|
+
return undefined;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function createDiffSummarizerRuntimeGuardExtension(options: {
|
|
1196
|
+
cwd: string;
|
|
1197
|
+
maxTurns: number;
|
|
1198
|
+
}): ExtensionFactory {
|
|
1199
|
+
return (pi) => {
|
|
1200
|
+
let currentTurn = 0;
|
|
1201
|
+
|
|
1202
|
+
pi.on("turn_start", async (event) => {
|
|
1203
|
+
currentTurn = event.turnIndex;
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
pi.on("tool_call", async (event) => {
|
|
1207
|
+
if (!READ_ONLY_TOOL_NAMES.has(event.toolName) && !isSubagentMcpTool(event.toolName)) {
|
|
1208
|
+
return {
|
|
1209
|
+
block: true,
|
|
1210
|
+
reason: `diff_summarizer exposes read-only built-in tools and MCP tools; ${event.toolName} is not allowed.`,
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
if (currentTurn >= options.maxTurns - 1) {
|
|
1215
|
+
return {
|
|
1216
|
+
block: true,
|
|
1217
|
+
reason: `Tool use is disabled on final diff_summarizer turn ${options.maxTurns}/${options.maxTurns}. Answer now with the evidence already gathered.`,
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
if (event.toolName === "read") {
|
|
1222
|
+
const reason = await assertToolPathInsideCwd(
|
|
1223
|
+
options.cwd,
|
|
1224
|
+
(event.input as { path?: unknown }).path,
|
|
1225
|
+
"read",
|
|
1226
|
+
);
|
|
1227
|
+
if (reason) return { block: true, reason };
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (event.toolName === "grep" || event.toolName === "find" || event.toolName === "ls") {
|
|
1231
|
+
const reason = await assertToolPathInsideCwd(
|
|
1232
|
+
options.cwd,
|
|
1233
|
+
(event.input as { path?: unknown }).path,
|
|
1234
|
+
event.toolName,
|
|
1235
|
+
);
|
|
1236
|
+
if (reason) return { block: true, reason };
|
|
1237
|
+
if (event.toolName === "grep") {
|
|
1238
|
+
const globReason = getUnsafePathPatternReason(
|
|
1239
|
+
(event.input as { glob?: unknown }).glob,
|
|
1240
|
+
"grep glob",
|
|
1241
|
+
);
|
|
1242
|
+
if (globReason) return { block: true, reason: globReason };
|
|
1243
|
+
}
|
|
1244
|
+
if (event.toolName === "find") {
|
|
1245
|
+
const patternReason = getUnsafePathPatternReason(
|
|
1246
|
+
(event.input as { pattern?: unknown }).pattern,
|
|
1247
|
+
"find pattern",
|
|
1248
|
+
);
|
|
1249
|
+
if (patternReason) return { block: true, reason: patternReason };
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
if (event.toolName === "bash") {
|
|
1254
|
+
const input = event.input as { command?: unknown; timeout?: unknown };
|
|
1255
|
+
if (typeof input.timeout !== "number") input.timeout = DEFAULT_BASH_TIMEOUT_SECONDS;
|
|
1256
|
+
const command = typeof input.command === "string" ? input.command : "";
|
|
1257
|
+
const reason = getBlockedBashReason(command);
|
|
1258
|
+
if (reason) return { block: true, reason };
|
|
1259
|
+
const tokens = tokenizeSegment(command.trim());
|
|
1260
|
+
if (getExecutableName(tokens[0] ?? "") === "git")
|
|
1261
|
+
input.command = buildSafeGitCommand(tokens);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
return undefined;
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
pi.on("tool_result", async (event) => ({
|
|
1268
|
+
content: [
|
|
1269
|
+
...(event.content ?? []),
|
|
1270
|
+
{
|
|
1271
|
+
type: "text",
|
|
1272
|
+
text: `\n\n[diff_summarizer turn budget] turn ${Math.min(currentTurn + 1, options.maxTurns)}/${options.maxTurns}`,
|
|
1273
|
+
},
|
|
1274
|
+
],
|
|
1275
|
+
}));
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function buildSystemPrompt(options: {
|
|
1280
|
+
cwd: string;
|
|
1281
|
+
maxTurns: number;
|
|
1282
|
+
maxRunSeconds: number;
|
|
1283
|
+
}): string {
|
|
1284
|
+
return `You are the diff summarizer, an isolated read-only agent running inside the diff-summarizer package. Your job is to produce a terse, high-signal summary of an existing change set for the caller and reviewers.\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## Diff collection\n\nIf the caller provides an explicit diff, use it. Otherwise collect the local diff:\n\n1. Collect \`git diff --no-color\`, \`git diff --cached --no-color\`, and \`git status --short --untracked-files=all\`.\n2. If relevant untracked new files appear, inspect those files as needed so the summary covers them.\n3. If diff collection fails, say so in the final result and state what diff or instructions you need.\n\n## Scope and stop rules\n\n- Stay limited to the supplied diff or current local change set.\n- Focus on user-visible behavior, requirements fit, and review risk; do not drift into implementation planning, architecture redesign, or exhaustive file narration.\n- Stop once the main behavior changes, risky areas, and requirement status are covered.\n\n## Analysis\n\nFocus on behavior and risk, not file-by-file narration.\n\nIdentify:\n\n- Primary components touched.\n- User-visible or developer-visible behavior changes.\n- Configuration, data format, public API, dependency, or installer changes.\n- Security-sensitive or failure-prone areas.\n- Tests added/changed and whether they cover the risky logic.\n- Explicit requirements satisfied, violated, or unclear. If no requirements were supplied, label inferred intent as low confidence.\n\nReport blockers in your final result instead of asking questions; you cannot ask follow-up questions. A missing requirement or inaccessible diff that blocks the summary is such a blocker.\n\n## Output format\n\nKeep it short:\n\n- Diff source: \`git diff + git diff --cached\` or caller-provided.\n- Files touched: one line with directories and key files.\n- What changed: 2–6 bullets.\n- Risky areas touched: 2–8 bullets, each tied to evidence.\n- Requirements:\n - Appears satisfied: ...\n - Appears violated: ...\n - Unclear from diff: ...`;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
function buildUserPrompt(input: { diff?: string; requirements?: string }, cwd: string): string {
|
|
1288
|
+
return `Local checkout: ${cwd}\n\nCaller-provided diff:\n${input.diff?.trim() ? input.diff : "(not provided; collect the local diff as instructed)"}\n\nOptional requirements:\n${input.requirements?.trim() ? input.requirements : "(not provided; label inferred intent as low confidence)"}\n\nSummarize the change set using the required output format. Do not modify anything.`;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function formatToolCall(call: ToolCall): string {
|
|
1292
|
+
const args =
|
|
1293
|
+
call.args && typeof call.args === "object" ? (call.args as Record<string, unknown>) : {};
|
|
1294
|
+
if (call.name === "read") {
|
|
1295
|
+
const readPath = typeof args.path === "string" ? args.path : "";
|
|
1296
|
+
const offset = typeof args.offset === "number" ? args.offset : undefined;
|
|
1297
|
+
const limit = typeof args.limit === "number" ? args.limit : undefined;
|
|
1298
|
+
const range =
|
|
1299
|
+
offset || limit ? `:${offset ?? 1}${limit ? `-${(offset ?? 1) + limit - 1}` : ""}` : "";
|
|
1300
|
+
return `read ${readPath}${range}`.trim();
|
|
1301
|
+
}
|
|
1302
|
+
if (call.name === "grep") {
|
|
1303
|
+
const pattern = typeof args.pattern === "string" ? args.pattern : "";
|
|
1304
|
+
const grepPath = typeof args.path === "string" ? args.path : ".";
|
|
1305
|
+
return `grep ${pattern.slice(0, 50)} ${grepPath}`.trim();
|
|
1306
|
+
}
|
|
1307
|
+
if (call.name === "find") {
|
|
1308
|
+
const pattern = typeof args.pattern === "string" ? args.pattern : "";
|
|
1309
|
+
const findPath = typeof args.path === "string" ? args.path : ".";
|
|
1310
|
+
return `find ${pattern.slice(0, 50)} ${findPath}`.trim();
|
|
1311
|
+
}
|
|
1312
|
+
if (call.name === "ls") return `ls ${typeof args.path === "string" ? args.path : "."}`.trim();
|
|
1313
|
+
if (call.name === "bash") {
|
|
1314
|
+
const command = typeof args.command === "string" ? args.command : "";
|
|
1315
|
+
return `bash ${command.slice(0, 120)}`.trim();
|
|
1316
|
+
}
|
|
1317
|
+
return call.name;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function formatDuration(ms: number): string {
|
|
1321
|
+
if (ms < 1000) return `${ms}ms`;
|
|
1322
|
+
const seconds = Math.round(ms / 100) / 10;
|
|
1323
|
+
if (seconds < 60) return `${seconds}s`;
|
|
1324
|
+
const minutes = Math.floor(seconds / 60);
|
|
1325
|
+
const remainingSeconds = Math.round((seconds % 60) * 10) / 10;
|
|
1326
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function appendRunDetails(report: string, details: RunDetails): string {
|
|
1330
|
+
const trimmed = report.trim();
|
|
1331
|
+
const toolSummary =
|
|
1332
|
+
details.toolCalls.length > 0 ? details.toolCalls.map(formatToolCall).join("; ") : "none";
|
|
1333
|
+
const duration = formatDuration((details.endedAt ?? Date.now()) - details.startedAt);
|
|
1334
|
+
const modelSummary = details.modelRef ? `model ${details.modelRef}` : "model unknown";
|
|
1335
|
+
const thinkingSummary = details.effectiveThinkingLevel
|
|
1336
|
+
? `thinking ${details.effectiveThinkingLevel}${details.thinkingLevelNote ? ` (${details.thinkingLevelNote})` : ""}`
|
|
1337
|
+
: "thinking unknown";
|
|
1338
|
+
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}.`;
|
|
1339
|
+
if (!trimmed)
|
|
1340
|
+
return `- Diff source: unknown\n- What changed: no summary was produced.\n- Requirements:\n - Unclear from diff: the isolated diff_summarizer session returned no assistant text.${suffix}`;
|
|
1341
|
+
return `${trimmed}${suffix}`;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
export const __test__ = {
|
|
1345
|
+
SUBAGENT_MCP_TOOLS,
|
|
1346
|
+
isMcpCompatibleVersion,
|
|
1347
|
+
resolveSubagentProjectTrusted,
|
|
1348
|
+
createMcpSubagentFactories,
|
|
1349
|
+
disposeSubagentSession,
|
|
1350
|
+
createDiffSummarizerRuntimeGuardExtension,
|
|
1351
|
+
assertToolPathInsideCwd,
|
|
1352
|
+
resolveToCwd,
|
|
1353
|
+
buildSafeGitCommand,
|
|
1354
|
+
getBlockedBashReason,
|
|
1355
|
+
buildSystemPrompt,
|
|
1356
|
+
buildUserPrompt,
|
|
1357
|
+
normalizeThinkingLevel,
|
|
1358
|
+
resolveThinkingLevel,
|
|
1359
|
+
createEmptyUsage,
|
|
1360
|
+
addAssistantMessageUsage,
|
|
1361
|
+
addSessionEventUsage,
|
|
1362
|
+
inspectFinalAssistant,
|
|
1363
|
+
classifyRunFailure,
|
|
1364
|
+
appendRunDetails,
|
|
1365
|
+
};
|
|
1366
|
+
|
|
1367
|
+
async function resolveRunCwd(baseCwd: string, requested: unknown): Promise<string> {
|
|
1368
|
+
const base = path.resolve(baseCwd);
|
|
1369
|
+
if (requested === undefined) return base;
|
|
1370
|
+
if (typeof requested !== "string" || !requested.trim())
|
|
1371
|
+
throw new Error("Invalid parameters: expected cwd to be a non-empty string when provided.");
|
|
1372
|
+
const resolved = resolveToCwd(requested, base);
|
|
1373
|
+
const stat = await fs.stat(resolved).catch(() => undefined);
|
|
1374
|
+
if (!stat?.isDirectory())
|
|
1375
|
+
throw new Error(`Invalid parameters: cwd is not an existing directory: ${resolved}`);
|
|
1376
|
+
return resolved;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
export default function diffSummarizerExtension(pi: ExtensionAPI) {
|
|
1380
|
+
pi.on("tool_result", async (event) => {
|
|
1381
|
+
if (event.toolName !== "diff_summarizer") return undefined;
|
|
1382
|
+
const details = event.details as RunDetails | undefined;
|
|
1383
|
+
if (details?.status !== "error") return undefined;
|
|
1384
|
+
return { isError: true };
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
pi.registerTool({
|
|
1388
|
+
name: "diff_summarizer",
|
|
1389
|
+
label: "Diff summarizer",
|
|
1390
|
+
description:
|
|
1391
|
+
"Isolated read-only agent with read, grep, find, ls, guarded read-only bash, and configured MCP tools that summarizes a diff or the current local change set and highlights review risk hotspots.",
|
|
1392
|
+
promptSnippet:
|
|
1393
|
+
"Summarize a caller-provided diff or the current local git changes with an isolated read-only child agent; returns a terse summary of changes, risky areas, and requirement status with run details.",
|
|
1394
|
+
promptGuidelines: [
|
|
1395
|
+
"Use diff_summarizer to get a terse, high-signal summary of a change set before review; optionally pass a diff and the requirements to check against.",
|
|
1396
|
+
"diff_summarizer is read-only: it cannot edit files. Without a diff it collects git diff, git diff --cached, and git status in the checkout.",
|
|
1397
|
+
],
|
|
1398
|
+
parameters: DiffSummarizerParams,
|
|
1399
|
+
|
|
1400
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1401
|
+
const cwd = await resolveRunCwd(ctx.cwd, params.cwd);
|
|
1402
|
+
|
|
1403
|
+
const input = {
|
|
1404
|
+
diff: typeof params.diff === "string" ? params.diff : undefined,
|
|
1405
|
+
requirements: typeof params.requirements === "string" ? params.requirements : undefined,
|
|
1406
|
+
};
|
|
1407
|
+
const details: RunDetails = {
|
|
1408
|
+
status: "running",
|
|
1409
|
+
cwd,
|
|
1410
|
+
turns: 0,
|
|
1411
|
+
toolCalls: [],
|
|
1412
|
+
startedAt: Date.now(),
|
|
1413
|
+
};
|
|
1414
|
+
|
|
1415
|
+
let lastContent = "(summarizing diff...)";
|
|
1416
|
+
let session: AgentSession | undefined;
|
|
1417
|
+
let unsubscribe: (() => void) | undefined;
|
|
1418
|
+
let runTimeout: NodeJS.Timeout | undefined;
|
|
1419
|
+
let abortListenerAdded = false;
|
|
1420
|
+
let callerAborted = Boolean(signal?.aborted);
|
|
1421
|
+
const usage = createEmptyUsage();
|
|
1422
|
+
|
|
1423
|
+
const emit = () => {
|
|
1424
|
+
onUpdate?.({ content: [{ type: "text", text: lastContent }], details });
|
|
1425
|
+
};
|
|
1426
|
+
|
|
1427
|
+
const abortForCleanup = () => {
|
|
1428
|
+
void session?.abort();
|
|
1429
|
+
};
|
|
1430
|
+
|
|
1431
|
+
const abortFromCaller = () => {
|
|
1432
|
+
callerAborted = true;
|
|
1433
|
+
details.status = "aborted";
|
|
1434
|
+
details.endedAt = Date.now();
|
|
1435
|
+
lastContent = "Aborted";
|
|
1436
|
+
emit();
|
|
1437
|
+
abortForCleanup();
|
|
1438
|
+
};
|
|
1439
|
+
|
|
1440
|
+
if (signal?.aborted) abortFromCaller();
|
|
1441
|
+
if (signal && !signal.aborted) {
|
|
1442
|
+
signal.addEventListener("abort", abortFromCaller);
|
|
1443
|
+
abortListenerAdded = true;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
try {
|
|
1447
|
+
emit();
|
|
1448
|
+
|
|
1449
|
+
const model = ctx.model as PiModel | undefined;
|
|
1450
|
+
if (!model)
|
|
1451
|
+
throw new Error(
|
|
1452
|
+
"No active session model is available. Select a model before using diff_summarizer.",
|
|
1453
|
+
);
|
|
1454
|
+
|
|
1455
|
+
const projectTrusted = resolveSubagentProjectTrusted(ctx, cwd);
|
|
1456
|
+
const isolatedSettingsManager = SettingsManager.inMemory({}, { projectTrusted });
|
|
1457
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
1458
|
+
cwd,
|
|
1459
|
+
agentDir: getAgentDir(),
|
|
1460
|
+
settingsManager: isolatedSettingsManager,
|
|
1461
|
+
noExtensions: true,
|
|
1462
|
+
noSkills: true,
|
|
1463
|
+
noPromptTemplates: true,
|
|
1464
|
+
noThemes: true,
|
|
1465
|
+
noContextFiles: true,
|
|
1466
|
+
extensionFactories: [
|
|
1467
|
+
createDiffSummarizerRuntimeGuardExtension({
|
|
1468
|
+
cwd,
|
|
1469
|
+
maxTurns: MAX_TURNS,
|
|
1470
|
+
}),
|
|
1471
|
+
...createMcpSubagentFactories(),
|
|
1472
|
+
],
|
|
1473
|
+
systemPromptOverride: () =>
|
|
1474
|
+
buildSystemPrompt({
|
|
1475
|
+
cwd,
|
|
1476
|
+
maxTurns: MAX_TURNS,
|
|
1477
|
+
maxRunSeconds: Math.round(MAX_RUN_MS / 1000),
|
|
1478
|
+
}),
|
|
1479
|
+
appendSystemPromptOverride: () => [],
|
|
1480
|
+
skillsOverride: () => ({ skills: [], diagnostics: [] }),
|
|
1481
|
+
promptsOverride: () => ({ prompts: [], diagnostics: [] }),
|
|
1482
|
+
themesOverride: () => ({ themes: [], diagnostics: [] }),
|
|
1483
|
+
agentsFilesOverride: () => ({ agentsFiles: [] }),
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
await resourceLoader.reload();
|
|
1487
|
+
|
|
1488
|
+
const thinking = resolveThinkingLevel(model, DEFAULT_THINKING_LEVEL);
|
|
1489
|
+
applyModelAttemptDetails(details, model, thinking);
|
|
1490
|
+
|
|
1491
|
+
const created = await createAgentSession({
|
|
1492
|
+
cwd,
|
|
1493
|
+
...getModelRuntimeOption(ctx),
|
|
1494
|
+
resourceLoader,
|
|
1495
|
+
settingsManager: isolatedSettingsManager,
|
|
1496
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
1497
|
+
model: model as CreateAgentSessionModel,
|
|
1498
|
+
thinkingLevel: thinking.effective,
|
|
1499
|
+
tools: ["read", "grep", "find", "ls", "bash", ...SUBAGENT_MCP_TOOLS],
|
|
1500
|
+
});
|
|
1501
|
+
session = created.session;
|
|
1502
|
+
// Connect extensions (MCP servers) before prompting. Feature-detected: Pi >=0.99.0.
|
|
1503
|
+
await (created.session as any).bindExtensions?.({ mode: "json" });
|
|
1504
|
+
unsubscribe = session.subscribe((event) => {
|
|
1505
|
+
addSessionEventUsage(usage, event);
|
|
1506
|
+
switch (event.type) {
|
|
1507
|
+
case "turn_end":
|
|
1508
|
+
details.turns += 1;
|
|
1509
|
+
emit();
|
|
1510
|
+
break;
|
|
1511
|
+
case "tool_execution_start":
|
|
1512
|
+
details.toolCalls.push({
|
|
1513
|
+
name: event.toolName,
|
|
1514
|
+
args: event.args,
|
|
1515
|
+
});
|
|
1516
|
+
if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
|
|
1517
|
+
details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
|
|
1518
|
+
}
|
|
1519
|
+
emit();
|
|
1520
|
+
break;
|
|
1521
|
+
case "message_update":
|
|
1522
|
+
if (event.assistantMessageEvent?.type === "text_delta") {
|
|
1523
|
+
lastContent += event.assistantMessageEvent.delta ?? "";
|
|
1524
|
+
emit();
|
|
1525
|
+
}
|
|
1526
|
+
break;
|
|
1527
|
+
}
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
if (!callerAborted) {
|
|
1531
|
+
const promptPromise = session.prompt(buildUserPrompt(input, cwd), {
|
|
1532
|
+
expandPromptTemplates: false,
|
|
1533
|
+
});
|
|
1534
|
+
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
1535
|
+
runTimeout = setTimeout(() => {
|
|
1536
|
+
abortForCleanup();
|
|
1537
|
+
reject(
|
|
1538
|
+
new Error(
|
|
1539
|
+
`diff_summarizer timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`,
|
|
1540
|
+
),
|
|
1541
|
+
);
|
|
1542
|
+
}, MAX_RUN_MS);
|
|
1543
|
+
});
|
|
1544
|
+
await Promise.race([promptPromise, timeoutPromise]);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
const outcome = inspectFinalAssistant(session.state.messages);
|
|
1548
|
+
if (outcome.ok) {
|
|
1549
|
+
lastContent = outcome.answer;
|
|
1550
|
+
details.status = "done";
|
|
1551
|
+
} else if (callerAborted) {
|
|
1552
|
+
lastContent = "Aborted";
|
|
1553
|
+
details.status = "aborted";
|
|
1554
|
+
} else {
|
|
1555
|
+
throw new Error(outcome.reason);
|
|
1556
|
+
}
|
|
1557
|
+
details.endedAt = Date.now();
|
|
1558
|
+
const report = appendRunDetails(lastContent, details);
|
|
1559
|
+
lastContent = report;
|
|
1560
|
+
emit();
|
|
1561
|
+
return { content: [{ type: "text", text: report }], details, usage };
|
|
1562
|
+
} catch (error) {
|
|
1563
|
+
const failure = classifyRunFailure(error, callerAborted);
|
|
1564
|
+
details.status = failure.status;
|
|
1565
|
+
details.error = failure.error;
|
|
1566
|
+
details.endedAt = Date.now();
|
|
1567
|
+
lastContent = appendRunDetails(
|
|
1568
|
+
`- Diff source: unknown\n- What changed: diff summary failed.\n- Requirements:\n - Unclear from diff: diff_summarizer execution failed. Evidence: ${failure.message}.`,
|
|
1569
|
+
details,
|
|
1570
|
+
);
|
|
1571
|
+
emit();
|
|
1572
|
+
return {
|
|
1573
|
+
content: [{ type: "text", text: lastContent }],
|
|
1574
|
+
details,
|
|
1575
|
+
usage,
|
|
1576
|
+
};
|
|
1577
|
+
} finally {
|
|
1578
|
+
if (runTimeout) clearTimeout(runTimeout);
|
|
1579
|
+
if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
|
|
1580
|
+
unsubscribe?.();
|
|
1581
|
+
await disposeSubagentSession(session);
|
|
1582
|
+
}
|
|
1583
|
+
},
|
|
1584
|
+
});
|
|
1585
|
+
}
|