@diegopetrucci/pi-extensions 0.1.73 → 0.1.76

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.
Files changed (93) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +9 -4
  3. package/extensions/agent-workflow-audit/index.ts +1398 -1009
  4. package/extensions/agent-workflow-audit/package.json +1 -1
  5. package/extensions/annotate-git-diff/README.md +3 -1
  6. package/extensions/annotate-git-diff/clipboard.ts +109 -100
  7. package/extensions/annotate-git-diff/git.ts +853 -767
  8. package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
  9. package/extensions/annotate-git-diff/index.ts +712 -671
  10. package/extensions/annotate-git-diff/package.json +1 -1
  11. package/extensions/annotate-git-diff/prompt.ts +45 -45
  12. package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
  13. package/extensions/annotate-git-diff/review-server.ts +337 -311
  14. package/extensions/annotate-git-diff/types.ts +132 -132
  15. package/extensions/annotate-git-diff/ui.ts +27 -27
  16. package/extensions/annotate-git-diff/watch.ts +74 -74
  17. package/extensions/annotate-git-diff/web/app.js +1947 -1884
  18. package/extensions/annotate-git-diff/web/index.html +5 -6
  19. package/extensions/annotate-last-message/README.md +1 -1
  20. package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
  21. package/extensions/annotate-last-message/index.ts +210 -206
  22. package/extensions/annotate-last-message/package.json +1 -1
  23. package/extensions/annotate-last-message/prompt.ts +87 -66
  24. package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
  25. package/extensions/annotate-last-message/session.ts +94 -89
  26. package/extensions/annotate-last-message/theme.ts +199 -193
  27. package/extensions/annotate-last-message/types.ts +23 -23
  28. package/extensions/annotate-last-message/ui.ts +27 -25
  29. package/extensions/annotate-last-message/web/app.js +4 -7
  30. package/extensions/annotate-last-message/web/md-renderer.js +1 -1
  31. package/extensions/brrr/README.md +1 -1
  32. package/extensions/brrr/index.ts +213 -191
  33. package/extensions/brrr/package.json +1 -1
  34. package/extensions/claude-fast/README.md +5 -79
  35. package/extensions/claude-fast/index.ts +31 -289
  36. package/extensions/claude-fast/package.json +2 -3
  37. package/extensions/code-reviewer/README.md +1 -1
  38. package/extensions/code-reviewer/index.ts +1598 -1350
  39. package/extensions/code-reviewer/package.json +1 -1
  40. package/extensions/confirm-destructive/README.md +1 -1
  41. package/extensions/confirm-destructive/index.ts +55 -51
  42. package/extensions/confirm-destructive/package.json +1 -1
  43. package/extensions/context-cap/index.ts +312 -304
  44. package/extensions/context-cap/package.json +1 -1
  45. package/extensions/context-inspector/index.ts +1211 -1064
  46. package/extensions/context-inspector/package.json +1 -1
  47. package/extensions/contrarian/index.ts +1854 -1735
  48. package/extensions/contrarian/package.json +1 -1
  49. package/extensions/dirty-repo-guard/index.ts +41 -41
  50. package/extensions/dynamic-context-pruning/README.md +6 -38
  51. package/extensions/dynamic-context-pruning/index.ts +2124 -1980
  52. package/extensions/dynamic-context-pruning/package.json +1 -1
  53. package/extensions/fast/README.md +71 -21
  54. package/extensions/fast/index.ts +513 -313
  55. package/extensions/fast/package.json +1 -1
  56. package/extensions/git-footer/README.md +1 -1
  57. package/extensions/git-footer/index.ts +458 -411
  58. package/extensions/git-footer/package.json +2 -3
  59. package/extensions/gnosis/index.ts +180 -130
  60. package/extensions/gnosis/package.json +1 -1
  61. package/extensions/inline-bash/index.ts +88 -88
  62. package/extensions/inline-bash/package.json +1 -1
  63. package/extensions/librarian/index.ts +1669 -1419
  64. package/extensions/librarian/package.json +1 -1
  65. package/extensions/minimal-footer/index.ts +994 -1012
  66. package/extensions/minimal-footer/openai-usage.ts +195 -190
  67. package/extensions/notify/index.ts +222 -230
  68. package/extensions/notify/package.json +1 -1
  69. package/extensions/openai-fast/README.md +5 -73
  70. package/extensions/openai-fast/index.ts +32 -272
  71. package/extensions/openai-fast/package.json +2 -3
  72. package/extensions/oracle/index.ts +1731 -1625
  73. package/extensions/oracle/package.json +1 -1
  74. package/extensions/permission-gate/index.ts +658 -608
  75. package/extensions/permission-gate/package.json +1 -1
  76. package/extensions/permission-gate/powershell-safety.ts +840 -793
  77. package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
  78. package/extensions/project-mcp-json/README.md +199 -0
  79. package/extensions/project-mcp-json/config.ts +564 -0
  80. package/extensions/project-mcp-json/index.ts +119 -0
  81. package/extensions/project-mcp-json/package.json +38 -0
  82. package/extensions/quiet-tools/index.ts +282 -264
  83. package/extensions/review/README.md +2 -2
  84. package/extensions/review/index.ts +1745 -1872
  85. package/extensions/review/package.json +1 -1
  86. package/extensions/todo/index.ts +284 -265
  87. package/extensions/triage-comments/README.md +2 -2
  88. package/extensions/triage-comments/index.ts +2294 -1882
  89. package/extensions/triage-comments/package.json +1 -1
  90. package/package.json +6 -3
  91. package/assets/social-preview.png +0 -0
  92. package/extensions/claude-fast/claude-fast.example.json +0 -4
  93. package/extensions/openai-fast/openai-fast.example.json +0 -4
@@ -12,10 +12,10 @@ import * as path from "node:path";
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
 
14
14
  import {
15
- analyzePowerShellLexically,
16
- buildPowerShellAstCommand,
17
- MAX_POWERSHELL_ANALYSIS_SOURCE_BYTES,
18
- parsePowerShellAstOutput,
15
+ analyzePowerShellLexically,
16
+ buildPowerShellAstCommand,
17
+ MAX_POWERSHELL_ANALYSIS_SOURCE_BYTES,
18
+ parsePowerShellAstOutput,
19
19
  } from "./powershell-safety.ts";
20
20
 
21
21
  const dangerousPatterns = [/\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
@@ -27,25 +27,25 @@ const POWERSHELL_ANALYSIS_TIMEOUT_MS = 5_000;
27
27
 
28
28
  type GuardDecision = { block: true; reason: string } | undefined;
29
29
  type ConfirmationContext = {
30
- hasUI?: boolean;
31
- signal?: AbortSignal;
32
- ui?: {
33
- select(
34
- prompt: string,
35
- choices: string[],
36
- dialogOptions?: { signal?: AbortSignal },
37
- ): Promise<string | undefined>;
38
- };
30
+ hasUI?: boolean;
31
+ signal?: AbortSignal;
32
+ ui?: {
33
+ select(
34
+ prompt: string,
35
+ choices: string[],
36
+ dialogOptions?: { signal?: AbortSignal },
37
+ ): Promise<string | undefined>;
38
+ };
39
39
  };
40
40
  type NormalizedPath = {
41
- original: string;
42
- displayPath: string;
43
- segments: string[];
44
- basename: string;
41
+ original: string;
42
+ displayPath: string;
43
+ segments: string[];
44
+ basename: string;
45
45
  };
46
46
  type ShellToken = {
47
- text: string;
48
- quote?: '"' | "'";
47
+ text: string;
48
+ quote?: '"' | "'";
49
49
  };
50
50
 
51
51
  type WriteInput = { path: string; content: string };
@@ -53,660 +53,710 @@ type EditInput = { path: string; edits: Array<{ oldText: string; newText: string
53
53
  type ShellInput = { command: string; timeout?: number };
54
54
 
55
55
  function isRecord(value: unknown): value is Record<string, unknown> {
56
- return !!value && typeof value === "object" && !Array.isArray(value);
56
+ return !!value && typeof value === "object" && !Array.isArray(value);
57
57
  }
58
58
 
59
59
  function normalizeToolPath(rawPath: string): NormalizedPath | undefined {
60
- const trimmed = rawPath.trim();
61
- if (!trimmed) return undefined;
62
-
63
- // Pi's built-in path tools treat a leading @ as path syntax and strip it
64
- // before execution, so inspect the same effective target.
65
- const withoutPathSigil = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
66
- if (!withoutPathSigil) return undefined;
67
-
68
- const withPosixSeparators = withoutPathSigil.replace(/\\+/g, "/");
69
- const normalizedPath = path.posix.normalize(
70
- withPosixSeparators.startsWith("/") ? withPosixSeparators : path.posix.join("/", withPosixSeparators),
71
- );
72
- const segments = normalizedPath.split("/").filter(Boolean);
73
- const basename = segments.at(-1) ?? "";
74
-
75
- return {
76
- original: rawPath,
77
- displayPath: withPosixSeparators,
78
- segments,
79
- basename,
80
- };
60
+ const trimmed = rawPath.trim();
61
+ if (!trimmed) return undefined;
62
+
63
+ // Pi's built-in path tools treat a leading @ as path syntax and strip it
64
+ // before execution, so inspect the same effective target.
65
+ const withoutPathSigil = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
66
+ if (!withoutPathSigil) return undefined;
67
+
68
+ const withPosixSeparators = withoutPathSigil.replace(/\\+/g, "/");
69
+ const normalizedPath = path.posix.normalize(
70
+ withPosixSeparators.startsWith("/")
71
+ ? withPosixSeparators
72
+ : path.posix.join("/", withPosixSeparators),
73
+ );
74
+ const segments = normalizedPath.split("/").filter(Boolean);
75
+ const basename = segments.at(-1) ?? "";
76
+
77
+ return {
78
+ original: rawPath,
79
+ displayPath: withPosixSeparators,
80
+ segments,
81
+ basename,
82
+ };
81
83
  }
82
84
 
83
85
  function isSafeEnvTemplate(basename: string): boolean {
84
- const normalizedBasename = basename.toLowerCase();
85
- if (!normalizedBasename.startsWith(".env.")) return false;
86
- const terminalSuffix = normalizedBasename.split(".").at(-1) ?? "";
87
- return safeEnvSuffixes.has(terminalSuffix);
86
+ const normalizedBasename = basename.toLowerCase();
87
+ if (!normalizedBasename.startsWith(".env.")) return false;
88
+ const terminalSuffix = normalizedBasename.split(".").at(-1) ?? "";
89
+ return safeEnvSuffixes.has(terminalSuffix);
88
90
  }
89
91
 
90
92
  function isProtectedEnvFile(basename: string): boolean {
91
- const normalizedBasename = basename.toLowerCase();
92
- if (normalizedBasename === ".env") return true;
93
- if (!normalizedBasename.startsWith(".env.")) return false;
94
- return !isSafeEnvTemplate(normalizedBasename);
93
+ const normalizedBasename = basename.toLowerCase();
94
+ if (normalizedBasename === ".env") return true;
95
+ if (!normalizedBasename.startsWith(".env.")) return false;
96
+ return !isSafeEnvTemplate(normalizedBasename);
95
97
  }
96
98
 
97
99
  function isProtectedPath(normalizedPath: NormalizedPath): boolean {
98
- if (normalizedPath.segments.some((segment) => protectedPathSegments.has(segment.toLowerCase()))) return true;
99
- return isProtectedEnvFile(normalizedPath.basename);
100
+ if (normalizedPath.segments.some((segment) => protectedPathSegments.has(segment.toLowerCase())))
101
+ return true;
102
+ return isProtectedEnvFile(normalizedPath.basename);
100
103
  }
101
104
 
102
- function findClosingDelimiter(command: string, start: number, delimiter: ')' | '`'): number {
103
- let quote: '"' | "'" | undefined;
104
- let escaping = false;
105
- let depth = delimiter === ')' ? 1 : 0;
106
-
107
- for (let index = start; index < command.length; index += 1) {
108
- const char = command[index];
109
- const next = command[index + 1];
110
-
111
- if (escaping) {
112
- escaping = false;
113
- continue;
114
- }
115
-
116
- if (quote) {
117
- if (char === "\\" && quote === '"') {
118
- escaping = true;
119
- continue;
120
- }
121
- if (char === quote) {
122
- quote = undefined;
123
- }
124
- continue;
125
- }
126
-
127
- if (char === "\\") {
128
- escaping = true;
129
- continue;
130
- }
131
-
132
- if (char === '"' || char === "'") {
133
- quote = char;
134
- continue;
135
- }
136
-
137
- if (delimiter === ')' && char === '$' && next === '(') {
138
- depth += 1;
139
- index += 1;
140
- continue;
141
- }
142
-
143
- if (char === delimiter) {
144
- if (delimiter === ')') {
145
- depth -= 1;
146
- if (depth === 0) return index;
147
- continue;
148
- }
149
- return index;
150
- }
151
- }
152
-
153
- return -1;
105
+ function findClosingDelimiter(command: string, start: number, delimiter: ")" | "`"): number {
106
+ let quote: '"' | "'" | undefined;
107
+ let escaping = false;
108
+ let depth = delimiter === ")" ? 1 : 0;
109
+
110
+ for (let index = start; index < command.length; index += 1) {
111
+ const char = command[index];
112
+ const next = command[index + 1];
113
+
114
+ if (escaping) {
115
+ escaping = false;
116
+ continue;
117
+ }
118
+
119
+ if (quote) {
120
+ if (char === "\\" && quote === '"') {
121
+ escaping = true;
122
+ continue;
123
+ }
124
+ if (char === quote) {
125
+ quote = undefined;
126
+ }
127
+ continue;
128
+ }
129
+
130
+ if (char === "\\") {
131
+ escaping = true;
132
+ continue;
133
+ }
134
+
135
+ if (char === '"' || char === "'") {
136
+ quote = char;
137
+ continue;
138
+ }
139
+
140
+ if (delimiter === ")" && char === "$" && next === "(") {
141
+ depth += 1;
142
+ index += 1;
143
+ continue;
144
+ }
145
+
146
+ if (char === delimiter) {
147
+ if (delimiter === ")") {
148
+ depth -= 1;
149
+ if (depth === 0) return index;
150
+ continue;
151
+ }
152
+ return index;
153
+ }
154
+ }
155
+
156
+ return -1;
154
157
  }
155
158
 
156
159
  function tokenizeShellWords(command: string): ShellToken[] {
157
- // Intentionally shallow tokenization for reviewable safety checks. This handles
158
- // simple quoting, command separators, and nested command substitutions, but it does
159
- // not attempt full shell grammar such as heredocs, arrays, or parameter expansion.
160
- const tokens: ShellToken[] = [];
161
- let current = "";
162
- let currentQuote: '"' | "'" | undefined;
163
- let escaping = false;
164
-
165
- const flushCurrent = () => {
166
- if (!current) return;
167
- tokens.push({ text: current, quote: currentQuote });
168
- current = "";
169
- currentQuote = undefined;
170
- };
171
-
172
- for (let index = 0; index < command.length; index += 1) {
173
- const char = command[index];
174
- const next = command[index + 1];
175
-
176
- if (escaping) {
177
- current += char;
178
- escaping = false;
179
- continue;
180
- }
181
-
182
- if (currentQuote) {
183
- if (char === "\\" && currentQuote === '"') {
184
- escaping = true;
185
- continue;
186
- }
187
- if (char === currentQuote) {
188
- flushCurrent();
189
- continue;
190
- }
191
- current += char;
192
- continue;
193
- }
194
-
195
- if (char === "\\") {
196
- escaping = true;
197
- continue;
198
- }
199
-
200
- if (char === '"' || char === "'") {
201
- flushCurrent();
202
- currentQuote = char;
203
- continue;
204
- }
205
-
206
- if (char === "$" && next === "(") {
207
- const closingIndex = findClosingDelimiter(command, index + 2, ')');
208
- if (closingIndex !== -1) {
209
- flushCurrent();
210
- tokens.push({ text: command.slice(index, closingIndex + 1) });
211
- index = closingIndex;
212
- continue;
213
- }
214
- }
215
-
216
- if (char === "`") {
217
- const closingIndex = findClosingDelimiter(command, index + 1, '`');
218
- if (closingIndex !== -1) {
219
- flushCurrent();
220
- tokens.push({ text: command.slice(index, closingIndex + 1) });
221
- index = closingIndex;
222
- continue;
223
- }
224
- }
225
-
226
- if (char === "&" && next === "&") {
227
- flushCurrent();
228
- tokens.push({ text: "&&" });
229
- index += 1;
230
- continue;
231
- }
232
-
233
- if (char === "|" && next === "|") {
234
- flushCurrent();
235
- tokens.push({ text: "||" });
236
- index += 1;
237
- continue;
238
- }
239
-
240
- if (char === ";" || char === "&" || char === "|" || char === "\n" || char === "(" || char === ")" || char === "{" || char === "}") {
241
- flushCurrent();
242
- tokens.push({ text: char });
243
- continue;
244
- }
245
-
246
- if (/\s/.test(char)) {
247
- flushCurrent();
248
- continue;
249
- }
250
-
251
- current += char;
252
- }
253
-
254
- flushCurrent();
255
- return tokens;
160
+ // Intentionally shallow tokenization for reviewable safety checks. This handles
161
+ // simple quoting, command separators, and nested command substitutions, but it does
162
+ // not attempt full shell grammar such as heredocs, arrays, or parameter expansion.
163
+ const tokens: ShellToken[] = [];
164
+ let current = "";
165
+ let currentQuote: '"' | "'" | undefined;
166
+ let escaping = false;
167
+
168
+ const flushCurrent = () => {
169
+ if (!current) return;
170
+ tokens.push({ text: current, quote: currentQuote });
171
+ current = "";
172
+ currentQuote = undefined;
173
+ };
174
+
175
+ for (let index = 0; index < command.length; index += 1) {
176
+ const char = command[index];
177
+ const next = command[index + 1];
178
+
179
+ if (escaping) {
180
+ current += char;
181
+ escaping = false;
182
+ continue;
183
+ }
184
+
185
+ if (currentQuote) {
186
+ if (char === "\\" && currentQuote === '"') {
187
+ escaping = true;
188
+ continue;
189
+ }
190
+ if (char === currentQuote) {
191
+ flushCurrent();
192
+ continue;
193
+ }
194
+ current += char;
195
+ continue;
196
+ }
197
+
198
+ if (char === "\\") {
199
+ escaping = true;
200
+ continue;
201
+ }
202
+
203
+ if (char === '"' || char === "'") {
204
+ flushCurrent();
205
+ currentQuote = char;
206
+ continue;
207
+ }
208
+
209
+ if (char === "$" && next === "(") {
210
+ const closingIndex = findClosingDelimiter(command, index + 2, ")");
211
+ if (closingIndex !== -1) {
212
+ flushCurrent();
213
+ tokens.push({ text: command.slice(index, closingIndex + 1) });
214
+ index = closingIndex;
215
+ continue;
216
+ }
217
+ }
218
+
219
+ if (char === "`") {
220
+ const closingIndex = findClosingDelimiter(command, index + 1, "`");
221
+ if (closingIndex !== -1) {
222
+ flushCurrent();
223
+ tokens.push({ text: command.slice(index, closingIndex + 1) });
224
+ index = closingIndex;
225
+ continue;
226
+ }
227
+ }
228
+
229
+ if (char === "&" && next === "&") {
230
+ flushCurrent();
231
+ tokens.push({ text: "&&" });
232
+ index += 1;
233
+ continue;
234
+ }
235
+
236
+ if (char === "|" && next === "|") {
237
+ flushCurrent();
238
+ tokens.push({ text: "||" });
239
+ index += 1;
240
+ continue;
241
+ }
242
+
243
+ if (
244
+ char === ";" ||
245
+ char === "&" ||
246
+ char === "|" ||
247
+ char === "\n" ||
248
+ char === "(" ||
249
+ char === ")" ||
250
+ char === "{" ||
251
+ char === "}"
252
+ ) {
253
+ flushCurrent();
254
+ tokens.push({ text: char });
255
+ continue;
256
+ }
257
+
258
+ if (/\s/.test(char)) {
259
+ flushCurrent();
260
+ continue;
261
+ }
262
+
263
+ current += char;
264
+ }
265
+
266
+ flushCurrent();
267
+ return tokens;
256
268
  }
257
269
 
258
270
  function isShellAssignmentToken(token: string): boolean {
259
- return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
271
+ return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
260
272
  }
261
273
 
262
274
  function hasDangerousSubstitution(command: string): boolean {
263
- let quote: '"' | "'" | undefined;
264
- let escaping = false;
265
-
266
- for (let index = 0; index < command.length; index += 1) {
267
- const char = command[index];
268
- const next = command[index + 1];
269
-
270
- if (escaping) {
271
- escaping = false;
272
- continue;
273
- }
274
-
275
- if (quote === "'") {
276
- if (char === "'") quote = undefined;
277
- continue;
278
- }
279
-
280
- if (char === "\\") {
281
- escaping = true;
282
- continue;
283
- }
284
-
285
- if (char === "'") {
286
- quote = "'";
287
- continue;
288
- }
289
-
290
- if (char === '"') {
291
- quote = quote === '"' ? undefined : '"';
292
- continue;
293
- }
294
-
295
- if (char === '$' && next === '(') {
296
- const closingIndex = findClosingDelimiter(command, index + 2, ')');
297
- if (closingIndex !== -1 && hasDangerousRecursiveRm(command.slice(index + 2, closingIndex))) return true;
298
- if (closingIndex !== -1) index = closingIndex;
299
- continue;
300
- }
301
-
302
- if (char === '`') {
303
- const closingIndex = findClosingDelimiter(command, index + 1, '`');
304
- if (closingIndex !== -1 && hasDangerousRecursiveRm(command.slice(index + 1, closingIndex))) return true;
305
- if (closingIndex !== -1) index = closingIndex;
306
- }
307
- }
308
-
309
- return false;
275
+ let quote: '"' | "'" | undefined;
276
+ let escaping = false;
277
+
278
+ for (let index = 0; index < command.length; index += 1) {
279
+ const char = command[index];
280
+ const next = command[index + 1];
281
+
282
+ if (escaping) {
283
+ escaping = false;
284
+ continue;
285
+ }
286
+
287
+ if (quote === "'") {
288
+ if (char === "'") quote = undefined;
289
+ continue;
290
+ }
291
+
292
+ if (char === "\\") {
293
+ escaping = true;
294
+ continue;
295
+ }
296
+
297
+ if (char === "'") {
298
+ quote = "'";
299
+ continue;
300
+ }
301
+
302
+ if (char === '"') {
303
+ quote = quote === '"' ? undefined : '"';
304
+ continue;
305
+ }
306
+
307
+ if (char === "$" && next === "(") {
308
+ const closingIndex = findClosingDelimiter(command, index + 2, ")");
309
+ if (closingIndex !== -1 && hasDangerousRecursiveRm(command.slice(index + 2, closingIndex)))
310
+ return true;
311
+ if (closingIndex !== -1) index = closingIndex;
312
+ continue;
313
+ }
314
+
315
+ if (char === "`") {
316
+ const closingIndex = findClosingDelimiter(command, index + 1, "`");
317
+ if (closingIndex !== -1 && hasDangerousRecursiveRm(command.slice(index + 1, closingIndex)))
318
+ return true;
319
+ if (closingIndex !== -1) index = closingIndex;
320
+ }
321
+ }
322
+
323
+ return false;
310
324
  }
311
325
 
312
326
  function basenameToken(token: string): string {
313
- return path.posix.basename(token);
327
+ return path.posix.basename(token);
314
328
  }
315
329
 
316
330
  function unwrapLeadingWrappers(
317
- tokens: ShellToken[],
318
- startIndex: number,
331
+ tokens: ShellToken[],
332
+ startIndex: number,
319
333
  ): { headIndex: number; nonExecuting: boolean; consumed: number } {
320
- let index = startIndex;
321
-
322
- const consumeOptionValue = (inlinePrefix: string, separateOptions: string[], longOption: string): boolean => {
323
- const token = tokens[index]?.text;
324
- if (!token) return false;
325
- if (token === longOption || separateOptions.includes(token)) {
326
- index += 1;
327
- if (index < tokens.length) index += 1;
328
- return true;
329
- }
330
- if (token.startsWith(`${longOption}=`) || (inlinePrefix && token.startsWith(inlinePrefix) && token.length > inlinePrefix.length)) {
331
- index += 1;
332
- return true;
333
- }
334
- return false;
335
- };
336
-
337
- const consumeOptionalLongOption = (inlinePrefix: string, shortOption: string, longOption: string): boolean => {
338
- const token = tokens[index]?.text;
339
- if (!token) return false;
340
- if (token === longOption) {
341
- index += 1;
342
- return true;
343
- }
344
- if (token === shortOption) {
345
- index += 1;
346
- if (index < tokens.length) index += 1;
347
- return true;
348
- }
349
- if (token.startsWith(`${longOption}=`) || token.startsWith(inlinePrefix) && token.length > inlinePrefix.length) {
350
- index += 1;
351
- return true;
352
- }
353
- return false;
354
- };
355
-
356
- while (index < tokens.length && isShellAssignmentToken(tokens[index].text)) index += 1;
357
- while (tokens[index]?.text === "sudo") index += 1;
358
-
359
- while (index < tokens.length) {
360
- const current = tokens[index]?.text;
361
- const currentBase = current ? basenameToken(current) : undefined;
362
- if (!current) break;
363
-
364
- if (current === "command") {
365
- index += 1;
366
- while (index < tokens.length) {
367
- const option = tokens[index]?.text;
368
- if (!option?.startsWith("-")) break;
369
- if (option === "--") {
370
- index += 1;
371
- break;
372
- }
373
- if (option === "-v" || option === "-V") {
374
- return { headIndex: tokens.length, nonExecuting: true, consumed: index + 1 - startIndex };
375
- }
376
- index += 1;
377
- }
378
- continue;
379
- }
380
-
381
- if (currentBase === "env") {
382
- index += 1;
383
- while (index < tokens.length) {
384
- const option = tokens[index]?.text;
385
- if (!option) break;
386
- if (option === "--") {
387
- index += 1;
388
- break;
389
- }
390
- if (option === "--help" || option === "--version") {
391
- return { headIndex: tokens.length, nonExecuting: true, consumed: index + 1 - startIndex };
392
- }
393
- if (isShellAssignmentToken(option)) {
394
- index += 1;
395
- continue;
396
- }
397
- if (consumeOptionValue("-u", ["-u"], "--unset")) continue;
398
- if (consumeOptionValue("-C", ["-C"], "--chdir")) continue;
399
- if (!option.startsWith("-")) break;
400
- index += 1;
401
- }
402
- continue;
403
- }
404
-
405
- if (currentBase === "xargs") {
406
- index += 1;
407
- while (index < tokens.length) {
408
- const option = tokens[index]?.text;
409
- if (!option) break;
410
- if (option === "--") {
411
- index += 1;
412
- break;
413
- }
414
- if (option === "--help" || option === "--version") {
415
- return { headIndex: tokens.length, nonExecuting: true, consumed: index + 1 - startIndex };
416
- }
417
- if (consumeOptionValue("-n", ["-n"], "--max-args")) continue;
418
- if (consumeOptionValue("-P", ["-P"], "--max-procs")) continue;
419
- if (consumeOptionalLongOption("-I", "-I", "--replace")) continue;
420
- if (consumeOptionValue("-a", ["-a"], "--arg-file")) continue;
421
- if (consumeOptionalLongOption("-E", "-E", "--eof")) continue;
422
- if (consumeOptionValue("-s", ["-s"], "--max-chars")) continue;
423
- if (consumeOptionValue("-d", ["-d"], "--delimiter")) continue;
424
- if (consumeOptionalLongOption("-L", "-L", "--max-lines")) continue;
425
- if (!option.startsWith("-")) break;
426
- index += 1;
427
- }
428
- continue;
429
- }
430
-
431
- break;
432
- }
433
-
434
- return { headIndex: index, nonExecuting: false, consumed: index - startIndex };
334
+ let index = startIndex;
335
+
336
+ const consumeOptionValue = (
337
+ inlinePrefix: string,
338
+ separateOptions: string[],
339
+ longOption: string,
340
+ ): boolean => {
341
+ const token = tokens[index]?.text;
342
+ if (!token) return false;
343
+ if (token === longOption || separateOptions.includes(token)) {
344
+ index += 1;
345
+ if (index < tokens.length) index += 1;
346
+ return true;
347
+ }
348
+ if (
349
+ token.startsWith(`${longOption}=`) ||
350
+ (inlinePrefix && token.startsWith(inlinePrefix) && token.length > inlinePrefix.length)
351
+ ) {
352
+ index += 1;
353
+ return true;
354
+ }
355
+ return false;
356
+ };
357
+
358
+ const consumeOptionalLongOption = (
359
+ inlinePrefix: string,
360
+ shortOption: string,
361
+ longOption: string,
362
+ ): boolean => {
363
+ const token = tokens[index]?.text;
364
+ if (!token) return false;
365
+ if (token === longOption) {
366
+ index += 1;
367
+ return true;
368
+ }
369
+ if (token === shortOption) {
370
+ index += 1;
371
+ if (index < tokens.length) index += 1;
372
+ return true;
373
+ }
374
+ if (
375
+ token.startsWith(`${longOption}=`) ||
376
+ (token.startsWith(inlinePrefix) && token.length > inlinePrefix.length)
377
+ ) {
378
+ index += 1;
379
+ return true;
380
+ }
381
+ return false;
382
+ };
383
+
384
+ while (index < tokens.length && isShellAssignmentToken(tokens[index].text)) index += 1;
385
+ while (tokens[index]?.text === "sudo") index += 1;
386
+
387
+ while (index < tokens.length) {
388
+ const current = tokens[index]?.text;
389
+ const currentBase = current ? basenameToken(current) : undefined;
390
+ if (!current) break;
391
+
392
+ if (current === "command") {
393
+ index += 1;
394
+ while (index < tokens.length) {
395
+ const option = tokens[index]?.text;
396
+ if (!option?.startsWith("-")) break;
397
+ if (option === "--") {
398
+ index += 1;
399
+ break;
400
+ }
401
+ if (option === "-v" || option === "-V") {
402
+ return { headIndex: tokens.length, nonExecuting: true, consumed: index + 1 - startIndex };
403
+ }
404
+ index += 1;
405
+ }
406
+ continue;
407
+ }
408
+
409
+ if (currentBase === "env") {
410
+ index += 1;
411
+ while (index < tokens.length) {
412
+ const option = tokens[index]?.text;
413
+ if (!option) break;
414
+ if (option === "--") {
415
+ index += 1;
416
+ break;
417
+ }
418
+ if (option === "--help" || option === "--version") {
419
+ return { headIndex: tokens.length, nonExecuting: true, consumed: index + 1 - startIndex };
420
+ }
421
+ if (isShellAssignmentToken(option)) {
422
+ index += 1;
423
+ continue;
424
+ }
425
+ if (consumeOptionValue("-u", ["-u"], "--unset")) continue;
426
+ if (consumeOptionValue("-C", ["-C"], "--chdir")) continue;
427
+ if (!option.startsWith("-")) break;
428
+ index += 1;
429
+ }
430
+ continue;
431
+ }
432
+
433
+ if (currentBase === "xargs") {
434
+ index += 1;
435
+ while (index < tokens.length) {
436
+ const option = tokens[index]?.text;
437
+ if (!option) break;
438
+ if (option === "--") {
439
+ index += 1;
440
+ break;
441
+ }
442
+ if (option === "--help" || option === "--version") {
443
+ return { headIndex: tokens.length, nonExecuting: true, consumed: index + 1 - startIndex };
444
+ }
445
+ if (consumeOptionValue("-n", ["-n"], "--max-args")) continue;
446
+ if (consumeOptionValue("-P", ["-P"], "--max-procs")) continue;
447
+ if (consumeOptionalLongOption("-I", "-I", "--replace")) continue;
448
+ if (consumeOptionValue("-a", ["-a"], "--arg-file")) continue;
449
+ if (consumeOptionalLongOption("-E", "-E", "--eof")) continue;
450
+ if (consumeOptionValue("-s", ["-s"], "--max-chars")) continue;
451
+ if (consumeOptionValue("-d", ["-d"], "--delimiter")) continue;
452
+ if (consumeOptionalLongOption("-L", "-L", "--max-lines")) continue;
453
+ if (!option.startsWith("-")) break;
454
+ index += 1;
455
+ }
456
+ continue;
457
+ }
458
+
459
+ break;
460
+ }
461
+
462
+ return { headIndex: index, nonExecuting: false, consumed: index - startIndex };
435
463
  }
436
464
 
437
465
  function detectRecursiveForceRm(tokens: ShellToken[], headIndex: number): boolean {
438
- const commandName = tokens[headIndex];
439
- if (!commandName || basenameToken(commandName.text) !== "rm") return false;
440
-
441
- let sawRecursive = false;
442
- let sawForce = false;
443
-
444
- for (let index = headIndex + 1; index < tokens.length; index += 1) {
445
- const token = tokens[index]?.text;
446
- if (!token) continue;
447
- if (token === "--") break;
448
- if (!token.startsWith("-") || token === "-") continue;
449
- if (token === "--recursive") sawRecursive = true;
450
- if (token === "--force") sawForce = true;
451
- if (/^-[^-]+$/.test(token)) {
452
- const flags = token.slice(1);
453
- if (flags.includes("r") || flags.includes("R")) sawRecursive = true;
454
- if (flags.includes("f")) sawForce = true;
455
- }
456
- }
457
-
458
- return sawRecursive && sawForce;
466
+ const commandName = tokens[headIndex];
467
+ if (!commandName || basenameToken(commandName.text) !== "rm") return false;
468
+
469
+ let sawRecursive = false;
470
+ let sawForce = false;
471
+
472
+ for (let index = headIndex + 1; index < tokens.length; index += 1) {
473
+ const token = tokens[index]?.text;
474
+ if (!token) continue;
475
+ if (token === "--") break;
476
+ if (!token.startsWith("-") || token === "-") continue;
477
+ if (token === "--recursive") sawRecursive = true;
478
+ if (token === "--force") sawForce = true;
479
+ if (/^-[^-]+$/.test(token)) {
480
+ const flags = token.slice(1);
481
+ if (flags.includes("r") || flags.includes("R")) sawRecursive = true;
482
+ if (flags.includes("f")) sawForce = true;
483
+ }
484
+ }
485
+
486
+ return sawRecursive && sawForce;
459
487
  }
460
488
 
461
489
  function hasDangerousCommandHead(tokens: ShellToken[], headIndex: number): boolean {
462
- if (detectRecursiveForceRm(tokens, headIndex)) return true;
463
-
464
- const firstToken = tokens[headIndex];
465
- if (!firstToken) return false;
466
-
467
- if (["sh", "bash", "zsh", "dash", "ksh"].includes(basenameToken(firstToken.text))) {
468
- for (let index = headIndex + 1; index < tokens.length; index += 1) {
469
- const token = tokens[index]?.text;
470
- if (!token) break;
471
- if (token === "--") break;
472
- if (token === "-c" || (/^-[A-Za-z]+$/.test(token) && token.includes("c"))) {
473
- const script = tokens[index + 1]?.text;
474
- return typeof script === "string" && hasDangerousRecursiveRm(script);
475
- }
476
- if (!token.startsWith("-")) break;
477
- }
478
- }
479
-
480
- if (firstToken.text === "eval") {
481
- return headIndex + 1 < tokens.length && hasDangerousRecursiveRm(tokens.slice(headIndex + 1).map(({ text }) => text).join(" "));
482
- }
483
-
484
- return false;
490
+ if (detectRecursiveForceRm(tokens, headIndex)) return true;
491
+
492
+ const firstToken = tokens[headIndex];
493
+ if (!firstToken) return false;
494
+
495
+ if (["sh", "bash", "zsh", "dash", "ksh"].includes(basenameToken(firstToken.text))) {
496
+ for (let index = headIndex + 1; index < tokens.length; index += 1) {
497
+ const token = tokens[index]?.text;
498
+ if (!token) break;
499
+ if (token === "--") break;
500
+ if (token === "-c" || (/^-[A-Za-z]+$/.test(token) && token.includes("c"))) {
501
+ const script = tokens[index + 1]?.text;
502
+ return typeof script === "string" && hasDangerousRecursiveRm(script);
503
+ }
504
+ if (!token.startsWith("-")) break;
505
+ }
506
+ }
507
+
508
+ if (firstToken.text === "eval") {
509
+ return (
510
+ headIndex + 1 < tokens.length &&
511
+ hasDangerousRecursiveRm(
512
+ tokens
513
+ .slice(headIndex + 1)
514
+ .map(({ text }) => text)
515
+ .join(" "),
516
+ )
517
+ );
518
+ }
519
+
520
+ return false;
485
521
  }
486
522
 
487
523
  function hasDangerousCommandSuffix(tokens: ShellToken[]): boolean {
488
- let index = 0;
524
+ let index = 0;
489
525
 
490
- while (index < tokens.length) {
491
- const unwrapped = unwrapLeadingWrappers(tokens, index);
492
- if (unwrapped.nonExecuting) return false;
493
- if (hasDangerousCommandHead(tokens, unwrapped.headIndex)) return true;
526
+ while (index < tokens.length) {
527
+ const unwrapped = unwrapLeadingWrappers(tokens, index);
528
+ if (unwrapped.nonExecuting) return false;
529
+ if (hasDangerousCommandHead(tokens, unwrapped.headIndex)) return true;
494
530
 
495
- // Check every possible executable head once, while skipping known-wrapper
496
- // option values so data such as an xargs EOF marker is never treated as a command.
497
- index += unwrapped.consumed + 1;
498
- }
531
+ // Check every possible executable head once, while skipping known-wrapper
532
+ // option values so data such as an xargs EOF marker is never treated as a command.
533
+ index += unwrapped.consumed + 1;
534
+ }
499
535
 
500
- return false;
536
+ return false;
501
537
  }
502
538
 
503
539
  function hasDangerousRecursiveRm(command: string): boolean {
504
- // Conservative detector: recurse through obvious command-substitution and shell-wrapper
505
- // surfaces, then inspect argv-like tokens for rm plus recursive+force semantics.
506
- if (hasDangerousSubstitution(command)) return true;
507
-
508
- const tokens = tokenizeShellWords(command);
509
- let currentCommand: ShellToken[] = [];
510
- let findExecCommand: ShellToken[] | undefined;
511
-
512
- const evaluateTokens = (commandTokens: ShellToken[]): boolean => hasDangerousCommandSuffix(commandTokens);
513
-
514
- const resetCurrentCommand = () => {
515
- currentCommand = [];
516
- };
517
-
518
- for (const token of tokens) {
519
- if (findExecCommand) {
520
- if (token.text === ";" || token.text === "+") {
521
- if (evaluateTokens(findExecCommand)) return true;
522
- findExecCommand = undefined;
523
- resetCurrentCommand();
524
- continue;
525
- }
526
- findExecCommand.push(token);
527
- continue;
528
- }
529
-
530
- if (token.text === "-exec" || token.text === "-execdir") {
531
- findExecCommand = [];
532
- continue;
533
- }
534
-
535
- if (shellCommandSeparators.has(token.text)) {
536
- if (evaluateTokens(currentCommand)) return true;
537
- resetCurrentCommand();
538
- continue;
539
- }
540
-
541
- currentCommand.push(token);
542
- }
543
-
544
- if (findExecCommand && evaluateTokens(findExecCommand)) return true;
545
- return evaluateTokens(currentCommand);
540
+ // Conservative detector: recurse through obvious command-substitution and shell-wrapper
541
+ // surfaces, then inspect argv-like tokens for rm plus recursive+force semantics.
542
+ if (hasDangerousSubstitution(command)) return true;
543
+
544
+ const tokens = tokenizeShellWords(command);
545
+ let currentCommand: ShellToken[] = [];
546
+ let findExecCommand: ShellToken[] | undefined;
547
+
548
+ const evaluateTokens = (commandTokens: ShellToken[]): boolean =>
549
+ hasDangerousCommandSuffix(commandTokens);
550
+
551
+ const resetCurrentCommand = () => {
552
+ currentCommand = [];
553
+ };
554
+
555
+ for (const token of tokens) {
556
+ if (findExecCommand) {
557
+ if (token.text === ";" || token.text === "+") {
558
+ if (evaluateTokens(findExecCommand)) return true;
559
+ findExecCommand = undefined;
560
+ resetCurrentCommand();
561
+ continue;
562
+ }
563
+ findExecCommand.push(token);
564
+ continue;
565
+ }
566
+
567
+ if (token.text === "-exec" || token.text === "-execdir") {
568
+ findExecCommand = [];
569
+ continue;
570
+ }
571
+
572
+ if (shellCommandSeparators.has(token.text)) {
573
+ if (evaluateTokens(currentCommand)) return true;
574
+ resetCurrentCommand();
575
+ continue;
576
+ }
577
+
578
+ currentCommand.push(token);
579
+ }
580
+
581
+ if (findExecCommand && evaluateTokens(findExecCommand)) return true;
582
+ return evaluateTokens(currentCommand);
546
583
  }
547
584
 
548
585
  function validateShellInput(input: unknown): ShellInput | undefined {
549
- if (!isRecord(input) || typeof input.command !== "string" || input.command.trim() === "") return undefined;
550
- if (
551
- input.timeout !== undefined &&
552
- (typeof input.timeout !== "number" ||
553
- !Number.isFinite(input.timeout) ||
554
- input.timeout <= 0 ||
555
- input.timeout > MAX_SHELL_TIMEOUT_SECONDS)
556
- ) {
557
- return undefined;
558
- }
559
- return input.timeout === undefined
560
- ? { command: input.command }
561
- : { command: input.command, timeout: input.timeout };
586
+ if (!isRecord(input) || typeof input.command !== "string" || input.command.trim() === "")
587
+ return undefined;
588
+ if (
589
+ input.timeout !== undefined &&
590
+ (typeof input.timeout !== "number" ||
591
+ !Number.isFinite(input.timeout) ||
592
+ input.timeout <= 0 ||
593
+ input.timeout > MAX_SHELL_TIMEOUT_SECONDS)
594
+ ) {
595
+ return undefined;
596
+ }
597
+ return input.timeout === undefined
598
+ ? { command: input.command }
599
+ : { command: input.command, timeout: input.timeout };
562
600
  }
563
601
 
564
602
  async function hasDangerousPowerShellCommand(
565
- pi: ExtensionAPI,
566
- command: string,
567
- signal?: AbortSignal,
603
+ pi: ExtensionAPI,
604
+ command: string,
605
+ signal?: AbortSignal,
568
606
  ): Promise<boolean> {
569
- // Keep both lexical work and Windows' base64-expanded -Command transport
570
- // bounded below the platform command-line limit. Oversized input is
571
- // deliberately treated as requiring confirmation rather than failing open.
572
- if (Buffer.byteLength(command, "utf8") > MAX_POWERSHELL_ANALYSIS_SOURCE_BYTES) return true;
573
-
574
- const lexicalAnalysis = analyzePowerShellLexically(command);
575
- if (lexicalAnalysis.risky) return true;
576
-
577
- // Pi only exposes its built-in PowerShell tool on Windows. On that platform,
578
- // ask PowerShell's own parser for the final word so ordinary syntax features
579
- // (module qualification, interpolation, splatting, and backtick escapes) cannot
580
- // bypass the fallback lexer. Parser failures and malformed output fail closed.
581
- if (process.platform !== "win32") return false;
582
-
583
- try {
584
- const { getPowerShellConfig } = await import("@earendil-works/pi-coding-agent");
585
- const config = getPowerShellConfig();
586
- const result = await pi.exec(config.shell, [...config.args, buildPowerShellAstCommand(command)], {
587
- timeout: POWERSHELL_ANALYSIS_TIMEOUT_MS,
588
- signal,
589
- });
590
- if (result.code !== 0 || result.killed) return true;
591
- return parsePowerShellAstOutput(result.stdout).risky;
592
- } catch {
593
- return true;
594
- }
607
+ // Keep both lexical work and Windows' base64-expanded -Command transport
608
+ // bounded below the platform command-line limit. Oversized input is
609
+ // deliberately treated as requiring confirmation rather than failing open.
610
+ if (Buffer.byteLength(command, "utf8") > MAX_POWERSHELL_ANALYSIS_SOURCE_BYTES) return true;
611
+
612
+ const lexicalAnalysis = analyzePowerShellLexically(command);
613
+ if (lexicalAnalysis.risky) return true;
614
+
615
+ // Pi only exposes its built-in PowerShell tool on Windows. On that platform,
616
+ // ask PowerShell's own parser for the final word so ordinary syntax features
617
+ // (module qualification, interpolation, splatting, and backtick escapes) cannot
618
+ // bypass the fallback lexer. Parser failures and malformed output fail closed.
619
+ if (process.platform !== "win32") return false;
620
+
621
+ try {
622
+ const { getPowerShellConfig } = await import("@earendil-works/pi-coding-agent");
623
+ const config = getPowerShellConfig();
624
+ const result = await pi.exec(
625
+ config.shell,
626
+ [...config.args, buildPowerShellAstCommand(command)],
627
+ {
628
+ timeout: POWERSHELL_ANALYSIS_TIMEOUT_MS,
629
+ signal,
630
+ },
631
+ );
632
+ if (result.code !== 0 || result.killed) return true;
633
+ return parsePowerShellAstOutput(result.stdout).risky;
634
+ } catch {
635
+ return true;
636
+ }
595
637
  }
596
638
 
597
639
  function validateWriteInput(input: unknown): WriteInput | undefined {
598
- if (!isRecord(input)) return undefined;
599
- if (typeof input.path !== "string" || typeof input.content !== "string") return undefined;
600
- if (!normalizeToolPath(input.path)) return undefined;
601
- return { path: input.path, content: input.content };
640
+ if (!isRecord(input)) return undefined;
641
+ if (typeof input.path !== "string" || typeof input.content !== "string") return undefined;
642
+ if (!normalizeToolPath(input.path)) return undefined;
643
+ return { path: input.path, content: input.content };
602
644
  }
603
645
 
604
646
  function validateEditInput(input: unknown): EditInput | undefined {
605
- if (!isRecord(input)) return undefined;
606
- if (typeof input.path !== "string" || !Array.isArray(input.edits)) return undefined;
607
- if (!normalizeToolPath(input.path)) return undefined;
608
-
609
- const edits = input.edits;
610
- if (
611
- edits.some(
612
- (edit) =>
613
- !isRecord(edit) || typeof edit.oldText !== "string" || typeof edit.newText !== "string",
614
- )
615
- ) {
616
- return undefined;
617
- }
618
-
619
- return {
620
- path: input.path,
621
- edits: edits as Array<{ oldText: string; newText: string }>,
622
- };
647
+ if (!isRecord(input)) return undefined;
648
+ if (typeof input.path !== "string" || !Array.isArray(input.edits)) return undefined;
649
+ if (!normalizeToolPath(input.path)) return undefined;
650
+
651
+ const edits = input.edits;
652
+ if (
653
+ edits.some(
654
+ (edit) =>
655
+ !isRecord(edit) || typeof edit.oldText !== "string" || typeof edit.newText !== "string",
656
+ )
657
+ ) {
658
+ return undefined;
659
+ }
660
+
661
+ return {
662
+ path: input.path,
663
+ edits: edits as Array<{ oldText: string; newText: string }>,
664
+ };
623
665
  }
624
666
 
625
- async function requestConfirmation(prompt: string, ctx: ConfirmationContext): Promise<GuardDecision> {
626
- if (!ctx.hasUI || !ctx.ui) {
627
- return { block: true, reason: "Confirmation unavailable (no UI)" };
628
- }
629
- if (ctx.signal?.aborted) {
630
- return { block: true, reason: "Confirmation cancelled" };
631
- }
632
-
633
- try {
634
- const choice = await ctx.ui.select(prompt, ["Yes", "No"], { signal: ctx.signal });
635
- if (ctx.signal?.aborted) {
636
- return { block: true, reason: "Confirmation cancelled" };
637
- }
638
- return choice === "Yes" ? undefined : { block: true, reason: "Blocked by user" };
639
- } catch {
640
- // A dismissed or failed UI must never turn a confirmation failure into an allow.
641
- return { block: true, reason: "Confirmation unavailable" };
642
- }
667
+ async function requestConfirmation(
668
+ prompt: string,
669
+ ctx: ConfirmationContext,
670
+ ): Promise<GuardDecision> {
671
+ if (!ctx.hasUI || !ctx.ui) {
672
+ return { block: true, reason: "Confirmation unavailable (no UI)" };
673
+ }
674
+ if (ctx.signal?.aborted) {
675
+ return { block: true, reason: "Confirmation cancelled" };
676
+ }
677
+
678
+ try {
679
+ const choice = await ctx.ui.select(prompt, ["Yes", "No"], { signal: ctx.signal });
680
+ if (ctx.signal?.aborted) {
681
+ return { block: true, reason: "Confirmation cancelled" };
682
+ }
683
+ return choice === "Yes" ? undefined : { block: true, reason: "Blocked by user" };
684
+ } catch {
685
+ // A dismissed or failed UI must never turn a confirmation failure into an allow.
686
+ return { block: true, reason: "Confirmation unavailable" };
687
+ }
643
688
  }
644
689
 
645
690
  async function confirmProtectedPathAction(
646
- toolName: "write" | "edit",
647
- normalizedPath: NormalizedPath,
648
- ctx: ConfirmationContext,
691
+ toolName: "write" | "edit",
692
+ normalizedPath: NormalizedPath,
693
+ ctx: ConfirmationContext,
649
694
  ): Promise<GuardDecision> {
650
- if (!isProtectedPath(normalizedPath)) return undefined;
651
- if (!ctx.hasUI || !ctx.ui) {
652
- return { block: true, reason: `Protected path blocked (${toolName} without UI confirmation): ${normalizedPath.displayPath}` };
653
- }
654
-
655
- return requestConfirmation(
656
- `⚠️ Protected path ${toolName} request:\n\n ${normalizedPath.displayPath}\n\nAllow?`,
657
- ctx,
658
- );
695
+ if (!isProtectedPath(normalizedPath)) return undefined;
696
+ if (!ctx.hasUI || !ctx.ui) {
697
+ return {
698
+ block: true,
699
+ reason: `Protected path blocked (${toolName} without UI confirmation): ${normalizedPath.displayPath}`,
700
+ };
701
+ }
702
+
703
+ return requestConfirmation(
704
+ `⚠️ Protected path ${toolName} request:\n\n ${normalizedPath.displayPath}\n\nAllow?`,
705
+ ctx,
706
+ );
659
707
  }
660
708
 
661
709
  export default function (pi: ExtensionAPI) {
662
- pi.on("tool_call", async (event, ctx) => {
663
- if (event.toolName === "bash" || event.toolName === "powershell") {
664
- const shellToolName = event.toolName;
665
- const input = validateShellInput(event.input);
666
- if (!input) {
667
- return { block: true, reason: `Malformed ${shellToolName} command blocked` };
668
- }
669
- const { command } = input;
670
-
671
- const isDangerous = shellToolName === "powershell"
672
- ? await hasDangerousPowerShellCommand(pi, command, ctx.signal)
673
- : hasDangerousRecursiveRm(command) || dangerousPatterns.some((pattern) => pattern.test(command));
674
-
675
- if (isDangerous) {
676
- if (!ctx.hasUI) {
677
- return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
678
- }
679
-
680
- return requestConfirmation(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ctx);
681
- }
682
-
683
- return undefined;
684
- }
685
-
686
- if (event.toolName === "write") {
687
- const input = validateWriteInput(event.input);
688
- if (!input) {
689
- return { block: true, reason: "Malformed write input blocked" };
690
- }
691
- const normalizedPath = normalizeToolPath(input.path);
692
- if (!normalizedPath) {
693
- return { block: true, reason: "Malformed write input blocked" };
694
- }
695
- return confirmProtectedPathAction("write", normalizedPath, ctx);
696
- }
697
-
698
- if (event.toolName === "edit") {
699
- const input = validateEditInput(event.input);
700
- if (!input) {
701
- return { block: true, reason: "Malformed edit input blocked" };
702
- }
703
- const normalizedPath = normalizeToolPath(input.path);
704
- if (!normalizedPath) {
705
- return { block: true, reason: "Malformed edit input blocked" };
706
- }
707
- return confirmProtectedPathAction("edit", normalizedPath, ctx);
708
- }
709
-
710
- return undefined;
711
- });
710
+ pi.on("tool_call", async (event, ctx) => {
711
+ if (event.toolName === "bash" || event.toolName === "powershell") {
712
+ const shellToolName = event.toolName;
713
+ const input = validateShellInput(event.input);
714
+ if (!input) {
715
+ return { block: true, reason: `Malformed ${shellToolName} command blocked` };
716
+ }
717
+ const { command } = input;
718
+
719
+ const isDangerous =
720
+ shellToolName === "powershell"
721
+ ? await hasDangerousPowerShellCommand(pi, command, ctx.signal)
722
+ : hasDangerousRecursiveRm(command) ||
723
+ dangerousPatterns.some((pattern) => pattern.test(command));
724
+
725
+ if (isDangerous) {
726
+ if (!ctx.hasUI) {
727
+ return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
728
+ }
729
+
730
+ return requestConfirmation(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ctx);
731
+ }
732
+
733
+ return undefined;
734
+ }
735
+
736
+ if (event.toolName === "write") {
737
+ const input = validateWriteInput(event.input);
738
+ if (!input) {
739
+ return { block: true, reason: "Malformed write input blocked" };
740
+ }
741
+ const normalizedPath = normalizeToolPath(input.path);
742
+ if (!normalizedPath) {
743
+ return { block: true, reason: "Malformed write input blocked" };
744
+ }
745
+ return confirmProtectedPathAction("write", normalizedPath, ctx);
746
+ }
747
+
748
+ if (event.toolName === "edit") {
749
+ const input = validateEditInput(event.input);
750
+ if (!input) {
751
+ return { block: true, reason: "Malformed edit input blocked" };
752
+ }
753
+ const normalizedPath = normalizeToolPath(input.path);
754
+ if (!normalizedPath) {
755
+ return { block: true, reason: "Malformed edit input blocked" };
756
+ }
757
+ return confirmProtectedPathAction("edit", normalizedPath, ctx);
758
+ }
759
+
760
+ return undefined;
761
+ });
712
762
  }