@hank-warren/pi-plan-mode 0.1.0
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/LICENSE +21 -0
- package/NOTICE.md +7 -0
- package/README.md +313 -0
- package/index.ts +1 -0
- package/package.json +47 -0
- package/src/active-implementation-menu.ts +68 -0
- package/src/auto-permissions-delegation.ts +122 -0
- package/src/command.ts +30 -0
- package/src/completion-tool.ts +91 -0
- package/src/extension-runtime.ts +24 -0
- package/src/fresh-implementation.ts +213 -0
- package/src/implementation-retention.ts +122 -0
- package/src/index.ts +1 -0
- package/src/interactive-ui.ts +5 -0
- package/src/message-transform.ts +232 -0
- package/src/plan-action-controller.ts +103 -0
- package/src/plan-action-menus.ts +197 -0
- package/src/plan-export-controller.ts +38 -0
- package/src/plan-export-screen.ts +19 -0
- package/src/plan-export.ts +145 -0
- package/src/plan-launch-menu.ts +122 -0
- package/src/plan-mode.ts +1037 -0
- package/src/presentation.ts +108 -0
- package/src/prompt.ts +67 -0
- package/src/question-tool.ts +273 -0
- package/src/required-tools.ts +22 -0
- package/src/saved-plan-menu.ts +93 -0
- package/src/saved-plan-preflight.ts +39 -0
- package/src/settings-menu.ts +384 -0
- package/src/settings.ts +420 -0
- package/src/state.ts +167 -0
- package/src/tool-policy.ts +563 -0
- package/src/tool-selection.ts +98 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import type { ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const BUILTIN_SAFE_GIT_SUBCOMMANDS = [
|
|
4
|
+
"status",
|
|
5
|
+
"log",
|
|
6
|
+
"diff",
|
|
7
|
+
"show",
|
|
8
|
+
"branch",
|
|
9
|
+
"remote",
|
|
10
|
+
"ls-files",
|
|
11
|
+
"grep",
|
|
12
|
+
] as const;
|
|
13
|
+
export const CONFIGURABLE_SAFE_GIT_SUBCOMMANDS = [
|
|
14
|
+
"rev-parse",
|
|
15
|
+
"blame",
|
|
16
|
+
"describe",
|
|
17
|
+
"merge-base",
|
|
18
|
+
"ls-tree",
|
|
19
|
+
"cat-file",
|
|
20
|
+
] as const;
|
|
21
|
+
export const SAFE_GIT_SUBCOMMANDS = [
|
|
22
|
+
...BUILTIN_SAFE_GIT_SUBCOMMANDS,
|
|
23
|
+
...CONFIGURABLE_SAFE_GIT_SUBCOMMANDS,
|
|
24
|
+
] as const;
|
|
25
|
+
export const SAFE_GH_SUBCOMMAND_PATHS = ["pr view", "pr list", "issue view", "issue list"] as const;
|
|
26
|
+
|
|
27
|
+
export type BuiltinSafeGitSubcommand = (typeof BUILTIN_SAFE_GIT_SUBCOMMANDS)[number];
|
|
28
|
+
export type ConfigurableSafeGitSubcommand = (typeof CONFIGURABLE_SAFE_GIT_SUBCOMMANDS)[number];
|
|
29
|
+
export type SafeGitSubcommand = (typeof SAFE_GIT_SUBCOMMANDS)[number];
|
|
30
|
+
export type SafeGhSubcommandPath = (typeof SAFE_GH_SUBCOMMAND_PATHS)[number];
|
|
31
|
+
export interface SafeSubcommands {
|
|
32
|
+
git?: SafeGitSubcommand[];
|
|
33
|
+
gh?: SafeGhSubcommandPath[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const SAFE_BUILTIN_PLAN_TOOLS = new Set(["read", "bash", "grep", "find", "ls"]);
|
|
37
|
+
export type PlanModeToolPolicy = "read-only" | "limited" | "user-opt-in" | "blocked";
|
|
38
|
+
|
|
39
|
+
const BLOCKED_BUILTIN_TOOLS = new Set(["edit", "write"]);
|
|
40
|
+
const MUTATING_COMMANDS = new Set([
|
|
41
|
+
"rm",
|
|
42
|
+
"rmdir",
|
|
43
|
+
"mv",
|
|
44
|
+
"cp",
|
|
45
|
+
"mkdir",
|
|
46
|
+
"touch",
|
|
47
|
+
"chmod",
|
|
48
|
+
"chown",
|
|
49
|
+
"chgrp",
|
|
50
|
+
"ln",
|
|
51
|
+
"tee",
|
|
52
|
+
"truncate",
|
|
53
|
+
"dd",
|
|
54
|
+
"sudo",
|
|
55
|
+
"su",
|
|
56
|
+
"kill",
|
|
57
|
+
"pkill",
|
|
58
|
+
"killall",
|
|
59
|
+
"reboot",
|
|
60
|
+
"shutdown",
|
|
61
|
+
"vim",
|
|
62
|
+
"vi",
|
|
63
|
+
"nano",
|
|
64
|
+
"emacs",
|
|
65
|
+
"code",
|
|
66
|
+
"subl",
|
|
67
|
+
]);
|
|
68
|
+
const READ_ONLY_COMMANDS = new Set([
|
|
69
|
+
"cat",
|
|
70
|
+
"head",
|
|
71
|
+
"tail",
|
|
72
|
+
"grep",
|
|
73
|
+
"find",
|
|
74
|
+
"ls",
|
|
75
|
+
"pwd",
|
|
76
|
+
"echo",
|
|
77
|
+
"printf",
|
|
78
|
+
"wc",
|
|
79
|
+
"sort",
|
|
80
|
+
"uniq",
|
|
81
|
+
"diff",
|
|
82
|
+
"file",
|
|
83
|
+
"stat",
|
|
84
|
+
"du",
|
|
85
|
+
"df",
|
|
86
|
+
"tree",
|
|
87
|
+
"which",
|
|
88
|
+
"whereis",
|
|
89
|
+
"type",
|
|
90
|
+
"printenv",
|
|
91
|
+
"uname",
|
|
92
|
+
"whoami",
|
|
93
|
+
"id",
|
|
94
|
+
"date",
|
|
95
|
+
"uptime",
|
|
96
|
+
"ps",
|
|
97
|
+
"jq",
|
|
98
|
+
"rg",
|
|
99
|
+
"fd",
|
|
100
|
+
"bat",
|
|
101
|
+
"eza",
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
export function isBuiltinTool(tool: ToolInfo) {
|
|
105
|
+
return tool.sourceInfo.source === "builtin";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function classifyPlanModeTool(tool: ToolInfo): PlanModeToolPolicy {
|
|
109
|
+
if (!isBuiltinTool(tool)) return "user-opt-in";
|
|
110
|
+
if (BLOCKED_BUILTIN_TOOLS.has(tool.name)) return "blocked";
|
|
111
|
+
if (tool.name === "bash") return "limited";
|
|
112
|
+
return SAFE_BUILTIN_PLAN_TOOLS.has(tool.name) ? "read-only" : "blocked";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function canSelectToolInPlanMode(tool: ToolInfo) {
|
|
116
|
+
return classifyPlanModeTool(tool) !== "blocked";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function readCommand(input: unknown) {
|
|
120
|
+
const command = input as { command?: unknown } | undefined;
|
|
121
|
+
return typeof command?.command === "string" ? command.command : "";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function findBlockedCommandSegment(
|
|
125
|
+
command: string,
|
|
126
|
+
safeSubcommands: SafeSubcommands = {},
|
|
127
|
+
): string | undefined {
|
|
128
|
+
const segments = splitShellSegments(command);
|
|
129
|
+
if (!segments || segments.length === 0) return command.trim() || "(empty command)";
|
|
130
|
+
return segments.find((segment) => !isSafeSegment(segment, safeSubcommands));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function isSafeCommand(command: string, safeSubcommands: SafeSubcommands = {}) {
|
|
134
|
+
return findBlockedCommandSegment(command, safeSubcommands) === undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function splitShellSegments(command: string): string[] | undefined {
|
|
138
|
+
const trimmed = command.trim();
|
|
139
|
+
if (!trimmed || /[\n\r`]/.test(trimmed)) return undefined;
|
|
140
|
+
|
|
141
|
+
const segments: string[] = [];
|
|
142
|
+
let quote: "'" | '"' | undefined;
|
|
143
|
+
let escaped = false;
|
|
144
|
+
let start = 0;
|
|
145
|
+
for (let index = 0; index < trimmed.length; index += 1) {
|
|
146
|
+
const character = trimmed[index];
|
|
147
|
+
if (escaped) {
|
|
148
|
+
escaped = false;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (character === "\\" && quote !== "'") {
|
|
152
|
+
escaped = true;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (quote) {
|
|
156
|
+
if (character === quote) quote = undefined;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (character === "'" || character === '"') {
|
|
160
|
+
quote = character;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (character === ">" || character === "<" || character === "(" || character === ")") {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
const next = trimmed[index + 1];
|
|
167
|
+
if (character === "&" && next !== "&") return undefined;
|
|
168
|
+
const separatorLength =
|
|
169
|
+
character === ";" || character === "|"
|
|
170
|
+
? next === character
|
|
171
|
+
? 2
|
|
172
|
+
: 1
|
|
173
|
+
: character === "&" && next === "&"
|
|
174
|
+
? 2
|
|
175
|
+
: 0;
|
|
176
|
+
if (separatorLength === 0) continue;
|
|
177
|
+
const segment = trimmed.slice(start, index).trim();
|
|
178
|
+
if (!segment) return undefined;
|
|
179
|
+
segments.push(segment);
|
|
180
|
+
index += separatorLength - 1;
|
|
181
|
+
start = index + 1;
|
|
182
|
+
}
|
|
183
|
+
if (quote || escaped) return undefined;
|
|
184
|
+
const finalSegment = trimmed.slice(start).trim();
|
|
185
|
+
if (!finalSegment) return undefined;
|
|
186
|
+
segments.push(finalSegment);
|
|
187
|
+
return segments;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isSafeSegment(segment: string, safeSubcommands: SafeSubcommands) {
|
|
191
|
+
if (hasShellExpansion(segment) || /(^|\s)[A-Za-z_][A-Za-z0-9_]*=/.test(segment)) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
const tokens = shellWords(segment);
|
|
195
|
+
if (!tokens || tokens.length === 0) return false;
|
|
196
|
+
const command = tokens[0]?.toLowerCase();
|
|
197
|
+
if (!command || MUTATING_COMMANDS.has(command)) return false;
|
|
198
|
+
const args = tokens.slice(1);
|
|
199
|
+
if (!hasSafeArguments(command, args)) return false;
|
|
200
|
+
if (READ_ONLY_COMMANDS.has(command)) return true;
|
|
201
|
+
return isSafeStructuredCommand(command, args, safeSubcommands);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function hasShellExpansion(segment: string) {
|
|
205
|
+
let quote: "'" | '"' | undefined;
|
|
206
|
+
let escaped = false;
|
|
207
|
+
for (const character of segment) {
|
|
208
|
+
if (escaped) {
|
|
209
|
+
escaped = false;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (character === "\\" && quote !== "'") {
|
|
213
|
+
escaped = true;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (quote) {
|
|
217
|
+
if (character === quote) quote = undefined;
|
|
218
|
+
else if (character === "$" && quote === '"') return true;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (character === "'" || character === '"') {
|
|
222
|
+
quote = character;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (["$", "*", "?", "[", "{"].includes(character)) return true;
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function shellWords(segment: string): string[] | undefined {
|
|
231
|
+
const words: string[] = [];
|
|
232
|
+
let word = "";
|
|
233
|
+
let quote: "'" | '"' | undefined;
|
|
234
|
+
let escaped = false;
|
|
235
|
+
for (const character of segment) {
|
|
236
|
+
if (escaped) {
|
|
237
|
+
word += character;
|
|
238
|
+
escaped = false;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (character === "\\" && quote !== "'") {
|
|
242
|
+
escaped = true;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (quote) {
|
|
246
|
+
if (character === quote) quote = undefined;
|
|
247
|
+
else word += character;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (character === "'" || character === '"') quote = character;
|
|
251
|
+
else if (/\s/.test(character)) {
|
|
252
|
+
if (word) words.push(word);
|
|
253
|
+
word = "";
|
|
254
|
+
} else word += character;
|
|
255
|
+
}
|
|
256
|
+
if (quote || escaped) return undefined;
|
|
257
|
+
if (word) words.push(word);
|
|
258
|
+
return words;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function hasSafeArguments(command: string, args: string[]) {
|
|
262
|
+
const forbidden = new Set(["-i", "--in-place", "--fix", "--write", "-delete", "--delete"]);
|
|
263
|
+
if (args.some((argument) => forbidden.has(argument))) return false;
|
|
264
|
+
if (
|
|
265
|
+
command === "sed" &&
|
|
266
|
+
args.some(
|
|
267
|
+
(argument) =>
|
|
268
|
+
argument.startsWith("--in-place=") ||
|
|
269
|
+
(/^-[^-]+/.test(argument) && argument.slice(1).includes("i")),
|
|
270
|
+
)
|
|
271
|
+
) {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
if (
|
|
275
|
+
command === "find" &&
|
|
276
|
+
args.some((argument) =>
|
|
277
|
+
["-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"].includes(
|
|
278
|
+
argument,
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
if (
|
|
285
|
+
command === "date" &&
|
|
286
|
+
args.some((argument) => argument === "-s" || argument.startsWith("--set"))
|
|
287
|
+
) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
if (
|
|
291
|
+
(command === "sort" || command === "tree") &&
|
|
292
|
+
args.some(
|
|
293
|
+
(argument) =>
|
|
294
|
+
argument === "-o" ||
|
|
295
|
+
(argument.startsWith("-o") && !argument.startsWith("--")) ||
|
|
296
|
+
argument.startsWith("--output"),
|
|
297
|
+
)
|
|
298
|
+
) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
if (
|
|
302
|
+
command === "sort" &&
|
|
303
|
+
args.some(
|
|
304
|
+
(argument) =>
|
|
305
|
+
argument === "-T" ||
|
|
306
|
+
(argument.startsWith("-T") && argument.length > 2) ||
|
|
307
|
+
argument.startsWith("--temporary-directory") ||
|
|
308
|
+
argument.startsWith("--compress-program"),
|
|
309
|
+
)
|
|
310
|
+
) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
if (
|
|
314
|
+
command === "diff" &&
|
|
315
|
+
args.some((argument) => argument === "--output" || argument.startsWith("--output="))
|
|
316
|
+
) {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
if (command === "uniq" && args.filter((argument) => !argument.startsWith("-")).length > 1) {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
if (
|
|
323
|
+
command === "fd" &&
|
|
324
|
+
args.some((argument) =>
|
|
325
|
+
["-x", "-X", "--exec", "--exec-batch"].some(
|
|
326
|
+
(flag) => argument === flag || argument.startsWith(`${flag}=`),
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
if (
|
|
333
|
+
command === "rg" &&
|
|
334
|
+
args.some((argument) => argument === "--pre" || argument.startsWith("--pre="))
|
|
335
|
+
) {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
if (
|
|
339
|
+
command === "bat" &&
|
|
340
|
+
args.some((argument) => argument === "--pager" || argument.startsWith("--pager="))
|
|
341
|
+
) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
type ArgumentValidator = (args: string[]) => boolean;
|
|
348
|
+
const allowReadOnlyArguments: ArgumentValidator = () => true;
|
|
349
|
+
const BUILTIN_GIT_VALIDATORS: Record<BuiltinSafeGitSubcommand, ArgumentValidator> = {
|
|
350
|
+
status: allowReadOnlyArguments,
|
|
351
|
+
log: allowReadOnlyArguments,
|
|
352
|
+
diff: allowReadOnlyArguments,
|
|
353
|
+
show: allowReadOnlyArguments,
|
|
354
|
+
branch: isSafeGitBranchArguments,
|
|
355
|
+
remote: isSafeGitRemoteArguments,
|
|
356
|
+
"ls-files": allowReadOnlyArguments,
|
|
357
|
+
grep: isSafeGitGrepArguments,
|
|
358
|
+
};
|
|
359
|
+
const CONFIGURABLE_GIT_VALIDATORS: Record<ConfigurableSafeGitSubcommand, ArgumentValidator> = {
|
|
360
|
+
"rev-parse": allowReadOnlyArguments,
|
|
361
|
+
blame: allowReadOnlyArguments,
|
|
362
|
+
describe: allowReadOnlyArguments,
|
|
363
|
+
"merge-base": allowReadOnlyArguments,
|
|
364
|
+
"ls-tree": allowReadOnlyArguments,
|
|
365
|
+
"cat-file": isSafeGitCatFileArguments,
|
|
366
|
+
};
|
|
367
|
+
const GH_VALIDATORS: Record<SafeGhSubcommandPath, ArgumentValidator> = {
|
|
368
|
+
"pr view": isSafeGhReadArguments,
|
|
369
|
+
"pr list": isSafeGhReadArguments,
|
|
370
|
+
"issue view": isSafeGhReadArguments,
|
|
371
|
+
"issue list": isSafeGhReadArguments,
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
function isSafeStructuredCommand(
|
|
375
|
+
command: string,
|
|
376
|
+
args: string[],
|
|
377
|
+
safeSubcommands: SafeSubcommands,
|
|
378
|
+
) {
|
|
379
|
+
if (command === "git") return isSafeGitCommand(args, safeSubcommands);
|
|
380
|
+
if (command === "gh") return isSafeGhCommand(args, safeSubcommands);
|
|
381
|
+
|
|
382
|
+
const subcommandIndex = args.findIndex((argument) => !argument.startsWith("-"));
|
|
383
|
+
const subcommand = args[subcommandIndex]?.toLowerCase();
|
|
384
|
+
const subcommandArgs = subcommandIndex >= 0 ? args.slice(subcommandIndex + 1) : [];
|
|
385
|
+
if (command === "sed") {
|
|
386
|
+
const script = args.find((argument) => !argument.startsWith("-"));
|
|
387
|
+
return (
|
|
388
|
+
Boolean(script) &&
|
|
389
|
+
(args.includes("-n") || args.some((argument) => /^-[^-]*n[^-]*$/.test(argument))) &&
|
|
390
|
+
/^\d+(,\d+)?p$/.test(script ?? "")
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
if (["node", "python", "python3", "tsc", "biome", "ruff", "ty"].includes(command)) {
|
|
394
|
+
if (args.includes("--version")) return true;
|
|
395
|
+
return (
|
|
396
|
+
command === "tsc" &&
|
|
397
|
+
args.includes("--noEmit") &&
|
|
398
|
+
!args.some(
|
|
399
|
+
(argument) =>
|
|
400
|
+
argument === "--incremental" ||
|
|
401
|
+
argument.startsWith("--incremental=") ||
|
|
402
|
+
argument === "--tsBuildInfoFile" ||
|
|
403
|
+
argument.startsWith("--tsBuildInfoFile=") ||
|
|
404
|
+
argument === "--generateTrace" ||
|
|
405
|
+
argument.startsWith("--generateTrace="),
|
|
406
|
+
)
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
if (command === "npm") {
|
|
410
|
+
if (subcommand === "audit" && subcommandArgs.includes("fix")) return false;
|
|
411
|
+
if (
|
|
412
|
+
["list", "ls", "view", "info", "search", "outdated", "audit", "test"].includes(
|
|
413
|
+
subcommand ?? "",
|
|
414
|
+
)
|
|
415
|
+
) {
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
return subcommand === "run" && ["test", "check", "typecheck", "lint"].includes(args[1] ?? "");
|
|
419
|
+
}
|
|
420
|
+
if (["cargo", "go", "pytest", "vitest", "jest"].includes(command)) {
|
|
421
|
+
return (
|
|
422
|
+
["test", "check"].includes(subcommand ?? "") || ["pytest", "vitest", "jest"].includes(command)
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function isSafeGitCommand(args: string[], safeSubcommands: SafeSubcommands) {
|
|
429
|
+
let subcommandIndex = 0;
|
|
430
|
+
while (args[subcommandIndex] === "--no-pager") subcommandIndex += 1;
|
|
431
|
+
const subcommand = args[subcommandIndex]?.toLowerCase();
|
|
432
|
+
if (!subcommand || subcommand.startsWith("-")) return false;
|
|
433
|
+
const subcommandArgs = args.slice(subcommandIndex + 1);
|
|
434
|
+
const builtinValidator = (BUILTIN_GIT_VALIDATORS as Record<string, ArgumentValidator>)[
|
|
435
|
+
subcommand
|
|
436
|
+
];
|
|
437
|
+
const configuredValidator = (CONFIGURABLE_GIT_VALIDATORS as Record<string, ArgumentValidator>)[
|
|
438
|
+
subcommand
|
|
439
|
+
];
|
|
440
|
+
const configured = safeSubcommands.git?.includes(subcommand as SafeGitSubcommand) === true;
|
|
441
|
+
const validator = builtinValidator ?? (configured ? configuredValidator : undefined);
|
|
442
|
+
return (
|
|
443
|
+
validator !== undefined &&
|
|
444
|
+
hasSafeGitArguments(subcommand, subcommandArgs) &&
|
|
445
|
+
validator(subcommandArgs)
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function hasSafeGitArguments(subcommand: string, args: string[]) {
|
|
450
|
+
return !args.some(
|
|
451
|
+
(argument) =>
|
|
452
|
+
argument === "--help" ||
|
|
453
|
+
argument === "--show-signature" ||
|
|
454
|
+
argument.startsWith("--show-signature=") ||
|
|
455
|
+
argument.includes("%G") ||
|
|
456
|
+
argument === "--output" ||
|
|
457
|
+
argument.startsWith("--output=") ||
|
|
458
|
+
argument === "--ext-diff" ||
|
|
459
|
+
argument.startsWith("--ext-diff=") ||
|
|
460
|
+
argument === "--textconv" ||
|
|
461
|
+
argument.startsWith("--textconv=") ||
|
|
462
|
+
argument === "--paginate" ||
|
|
463
|
+
argument === "--open-files-in-pager" ||
|
|
464
|
+
argument.startsWith("--open-files-in-pager=") ||
|
|
465
|
+
(subcommand === "grep" && (argument === "-O" || argument.startsWith("-O"))),
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function isSafeGitCatFileArguments(args: string[]) {
|
|
470
|
+
return !args.some(
|
|
471
|
+
(argument) =>
|
|
472
|
+
matchesLongOptionPrefix(argument, "--filters", "--fi") ||
|
|
473
|
+
matchesLongOptionPrefix(argument, "--textconv", "--t"),
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function isSafeGitGrepArguments(args: string[]) {
|
|
478
|
+
return !args.some(
|
|
479
|
+
(argument) =>
|
|
480
|
+
matchesLongOptionPrefix(argument, "--textconv", "--textc") ||
|
|
481
|
+
matchesLongOptionPrefix(argument, "--open-files-in-pager", "--op") ||
|
|
482
|
+
matchesLongOptionPrefix(argument, "--ext-grep", "--ext"),
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function matchesLongOptionPrefix(argument: string, option: string, shortest: string) {
|
|
487
|
+
const optionName = argument.split("=", 1)[0] ?? "";
|
|
488
|
+
return optionName.length >= shortest.length && option.startsWith(optionName);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function isSafeGitBranchArguments(args: string[]) {
|
|
492
|
+
if (args.some((argument) => !argument.startsWith("-"))) return false;
|
|
493
|
+
return !args.some(
|
|
494
|
+
(argument) =>
|
|
495
|
+
/^-[^-]*[dDmMcCu]/.test(argument) ||
|
|
496
|
+
matchesLongOptionPrefix(argument, "--delete", "--del") ||
|
|
497
|
+
matchesLongOptionPrefix(argument, "--move", "--mov") ||
|
|
498
|
+
matchesLongOptionPrefix(argument, "--copy", "--cop") ||
|
|
499
|
+
matchesLongOptionPrefix(argument, "--edit-description", "--e") ||
|
|
500
|
+
matchesLongOptionPrefix(argument, "--unset-upstream", "--u") ||
|
|
501
|
+
matchesLongOptionPrefix(argument, "--set-upstream-to", "--set-u") ||
|
|
502
|
+
matchesLongOptionPrefix(argument, "--create-reflog", "--creat"),
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function isSafeGitRemoteArguments(args: string[]) {
|
|
507
|
+
const actionIndex = args.findIndex((argument) => !argument.startsWith("-"));
|
|
508
|
+
if (actionIndex < 0) return true;
|
|
509
|
+
const action = args[actionIndex];
|
|
510
|
+
if (action === "get-url") return true;
|
|
511
|
+
if (action !== "show") return false;
|
|
512
|
+
|
|
513
|
+
const showArgs = args.slice(actionIndex + 1);
|
|
514
|
+
if (showArgs.includes("--")) return false;
|
|
515
|
+
const remotes = showArgs.filter((argument) => !argument.startsWith("-"));
|
|
516
|
+
return remotes.length === 0 || (remotes.length === 1 && showArgs.includes("-n"));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function isSafeGhCommand(args: string[], safeSubcommands: SafeSubcommands) {
|
|
520
|
+
const group = args[0]?.toLowerCase();
|
|
521
|
+
const action = args[1]?.toLowerCase();
|
|
522
|
+
if (!group || !action || group.startsWith("-") || action.startsWith("-")) return false;
|
|
523
|
+
const path = `${group} ${action}` as SafeGhSubcommandPath;
|
|
524
|
+
if (!safeSubcommands.gh?.includes(path)) return false;
|
|
525
|
+
const validator = (GH_VALIDATORS as Record<string, ArgumentValidator>)[path];
|
|
526
|
+
return validator?.(args.slice(2)) ?? false;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function isSafeGhReadArguments(args: string[]) {
|
|
530
|
+
return !args.some(isUnsafeGhReadArgument) && hasGhJsonOutput(args);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function isUnsafeGhReadArgument(argument: string) {
|
|
534
|
+
return (
|
|
535
|
+
argument.startsWith("-w") ||
|
|
536
|
+
argument === "--web" ||
|
|
537
|
+
argument.startsWith("--web=") ||
|
|
538
|
+
argument === "--browser" ||
|
|
539
|
+
argument.startsWith("--browser=") ||
|
|
540
|
+
argument === "--paginate" ||
|
|
541
|
+
argument === "--pager" ||
|
|
542
|
+
argument.startsWith("--pager=") ||
|
|
543
|
+
argument === "--output" ||
|
|
544
|
+
argument.startsWith("--output=")
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function hasGhJsonOutput(args: string[]) {
|
|
549
|
+
let hasJson = false;
|
|
550
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
551
|
+
const argument = args[index];
|
|
552
|
+
if (argument === "--json") {
|
|
553
|
+
const value = args[index + 1];
|
|
554
|
+
if (!value || value.startsWith("-")) return false;
|
|
555
|
+
hasJson = true;
|
|
556
|
+
index += 1;
|
|
557
|
+
} else if (argument.startsWith("--json=")) {
|
|
558
|
+
if (argument === "--json=") return false;
|
|
559
|
+
hasJson = true;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return hasJson;
|
|
563
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { PLAN_MODE_COMPLETE_TOOL_NAME } from "./completion-tool.js";
|
|
3
|
+
import { PLAN_MODE_QUESTION_TOOL_NAME } from "./question-tool.js";
|
|
4
|
+
import { withRequiredPlanModeTools } from "./required-tools.js";
|
|
5
|
+
import {
|
|
6
|
+
canSelectToolInPlanMode,
|
|
7
|
+
classifyPlanModeTool,
|
|
8
|
+
isBuiltinTool,
|
|
9
|
+
SAFE_BUILTIN_PLAN_TOOLS,
|
|
10
|
+
} from "./tool-policy.js";
|
|
11
|
+
|
|
12
|
+
export function toolNameFromLegacyKey(key: string, tools: ToolInfo[]) {
|
|
13
|
+
const directName = tools.find((tool) => tool.name === key)?.name;
|
|
14
|
+
if (directName) return directName;
|
|
15
|
+
const [name] = key.split("\u001f");
|
|
16
|
+
return tools.find((tool) => tool.name === name) ? name : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function compareTools(left: ToolInfo, right: ToolInfo) {
|
|
20
|
+
const leftBuiltin = isBuiltinTool(left);
|
|
21
|
+
const rightBuiltin = isBuiltinTool(right);
|
|
22
|
+
if (leftBuiltin !== rightBuiltin) return leftBuiltin ? -1 : 1;
|
|
23
|
+
return left.name.localeCompare(right.name);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function toolPolicyLabel(tool: ToolInfo) {
|
|
27
|
+
const policy = classifyPlanModeTool(tool);
|
|
28
|
+
if (policy === "read-only") return "built-in read-only";
|
|
29
|
+
if (policy === "limited") return "built-in limited";
|
|
30
|
+
if (policy === "blocked") return "built-in blocked";
|
|
31
|
+
return `user opt-in: ${toolSourceLabel(tool)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toolSourceLabel(tool: ToolInfo) {
|
|
35
|
+
const sourceInfo = tool.sourceInfo;
|
|
36
|
+
const source = `${sourceInfo.scope}/${sourceInfo.source}`;
|
|
37
|
+
return sourceInfo.path ? `${source} ${sourceInfo.path}` : source;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function unique(values: string[]) {
|
|
41
|
+
return Array.from(new Set(values));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function filterAvailableSelectedToolNames(names: string[], tools: ToolInfo[]) {
|
|
45
|
+
const availableNames = new Set(tools.filter(canSelectToolInPlanMode).map((tool) => tool.name));
|
|
46
|
+
return unique(names.filter((name) => availableNames.has(name)));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function defaultPlanModeToolNames(tools: ToolInfo[], configuredNames: string[] | undefined) {
|
|
50
|
+
if (configuredNames !== undefined) {
|
|
51
|
+
return filterAvailableSelectedToolNames(configuredNames, tools);
|
|
52
|
+
}
|
|
53
|
+
return tools
|
|
54
|
+
.filter((tool) => isBuiltinTool(tool) && SAFE_BUILTIN_PLAN_TOOLS.has(tool.name))
|
|
55
|
+
.map((tool) => tool.name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface PlanModeToolSelectionSnapshot {
|
|
59
|
+
selectedToolNames?: string[];
|
|
60
|
+
selectedToolKeys?: string[];
|
|
61
|
+
defaultPlanTools?: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function snapshotPlanModeSelectedNames(
|
|
65
|
+
tools: ToolInfo[],
|
|
66
|
+
selection: PlanModeToolSelectionSnapshot,
|
|
67
|
+
) {
|
|
68
|
+
const selectedToolNames =
|
|
69
|
+
selection.selectedToolNames ??
|
|
70
|
+
selection.selectedToolKeys
|
|
71
|
+
?.map((key) => toolNameFromLegacyKey(key, tools))
|
|
72
|
+
.filter((name): name is string => name !== undefined);
|
|
73
|
+
return new Set(
|
|
74
|
+
selectedToolNames === undefined
|
|
75
|
+
? defaultPlanModeToolNames(tools, selection.defaultPlanTools)
|
|
76
|
+
: filterAvailableSelectedToolNames(selectedToolNames, tools),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function snapshotPlanModeToolNames(
|
|
81
|
+
tools: ToolInfo[],
|
|
82
|
+
selectedNames: ReadonlySet<string>,
|
|
83
|
+
selection: PlanModeToolSelectionSnapshot,
|
|
84
|
+
) {
|
|
85
|
+
if (
|
|
86
|
+
tools.length === 0 &&
|
|
87
|
+
selection.selectedToolNames === undefined &&
|
|
88
|
+
selection.selectedToolKeys === undefined &&
|
|
89
|
+
selection.defaultPlanTools === undefined
|
|
90
|
+
) {
|
|
91
|
+
return ["read", "bash", PLAN_MODE_QUESTION_TOOL_NAME, PLAN_MODE_COMPLETE_TOOL_NAME];
|
|
92
|
+
}
|
|
93
|
+
return withRequiredPlanModeTools(
|
|
94
|
+
tools
|
|
95
|
+
.filter((tool) => selectedNames.has(tool.name) && canSelectToolInPlanMode(tool))
|
|
96
|
+
.map((tool) => tool.name),
|
|
97
|
+
);
|
|
98
|
+
}
|