@diegopetrucci/pi-extensions 0.1.74 → 0.1.78

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