@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
@@ -1,80 +1,88 @@
1
- const powerShellRemoveCommands = new Set(["remove-item", "del", "erase", "rd", "ri", "rm", "rmdir"]);
1
+ const powerShellRemoveCommands = new Set([
2
+ "remove-item",
3
+ "del",
4
+ "erase",
5
+ "rd",
6
+ "ri",
7
+ "rm",
8
+ "rmdir",
9
+ ]);
2
10
  const dynamicPowerShellCommands = new Set([
3
- "cmd",
4
- "cmd.exe",
5
- "icm",
6
- "ii",
7
- "iex",
8
- "invoke-command",
9
- "invoke-expression",
10
- "invoke-item",
11
- "powershell",
12
- "powershell.exe",
13
- "pwsh",
14
- "pwsh.exe",
15
- "saps",
16
- "sajb",
17
- "start",
18
- "start-job",
19
- "start-process",
20
- "start-threadjob",
11
+ "cmd",
12
+ "cmd.exe",
13
+ "icm",
14
+ "ii",
15
+ "iex",
16
+ "invoke-command",
17
+ "invoke-expression",
18
+ "invoke-item",
19
+ "powershell",
20
+ "powershell.exe",
21
+ "pwsh",
22
+ "pwsh.exe",
23
+ "saps",
24
+ "sajb",
25
+ "start",
26
+ "start-job",
27
+ "start-process",
28
+ "start-threadjob",
21
29
  ]);
22
30
  const shellPowerShellCommands = new Set(["bash", "bash.exe", "sh", "sh.exe", "wsl", "wsl.exe"]);
23
31
  const commandResolutionPowerShellCommands = new Set([
24
- "import-alias",
25
- "import-module",
26
- "import-pssession",
27
- "ipal",
28
- "ipmo",
29
- "ipsn",
30
- "nal",
31
- "new-alias",
32
- "new-module",
33
- "nmo",
34
- "ral",
35
- "remove-alias",
36
- "remove-module",
37
- "rmo",
38
- "sal",
39
- "set-alias",
32
+ "import-alias",
33
+ "import-module",
34
+ "import-pssession",
35
+ "ipal",
36
+ "ipmo",
37
+ "ipsn",
38
+ "nal",
39
+ "new-alias",
40
+ "new-module",
41
+ "nmo",
42
+ "ral",
43
+ "remove-alias",
44
+ "remove-module",
45
+ "rmo",
46
+ "sal",
47
+ "set-alias",
40
48
  ]);
41
49
  const commandDefinitionPowerShellKeywords = new Set(["filter", "function", "workflow"]);
42
50
  const providerMutationPowerShellCommands = new Set([
43
- "ac",
44
- "add-content",
45
- "clear-content",
46
- "clear-item",
47
- "cd",
48
- "chdir",
49
- "clc",
50
- "cli",
51
- "copy-item",
52
- "cp",
53
- "cpi",
54
- "del",
55
- "erase",
56
- "move-item",
57
- "mi",
58
- "move",
59
- "mv",
60
- "new-item",
61
- "ni",
62
- "push-location",
63
- "pushd",
64
- "rd",
65
- "remove-item",
66
- "rename-item",
67
- "ren",
68
- "ri",
69
- "rm",
70
- "rmdir",
71
- "rni",
72
- "sc",
73
- "set-content",
74
- "set-item",
75
- "set-location",
76
- "si",
77
- "sl",
51
+ "ac",
52
+ "add-content",
53
+ "clear-content",
54
+ "clear-item",
55
+ "cd",
56
+ "chdir",
57
+ "clc",
58
+ "cli",
59
+ "copy-item",
60
+ "cp",
61
+ "cpi",
62
+ "del",
63
+ "erase",
64
+ "move-item",
65
+ "mi",
66
+ "move",
67
+ "mv",
68
+ "new-item",
69
+ "ni",
70
+ "push-location",
71
+ "pushd",
72
+ "rd",
73
+ "remove-item",
74
+ "rename-item",
75
+ "ren",
76
+ "ri",
77
+ "rm",
78
+ "rmdir",
79
+ "rni",
80
+ "sc",
81
+ "set-content",
82
+ "set-item",
83
+ "set-location",
84
+ "si",
85
+ "sl",
78
86
  ]);
79
87
 
80
88
  const MAX_LEXICAL_DEPTH = 32;
@@ -87,7 +95,7 @@ export const POWERSHELL_AST_MARKER = "PI_PERMISSION_GATE_AST:";
87
95
  * It parses but never invokes the candidate command and returns only the
88
96
  * bounded command metadata needed by the guard.
89
97
  */
90
- export const POWERSHELL_AST_SCRIPT = String.raw`
98
+ const POWERSHELL_AST_SCRIPT = String.raw`
91
99
  $ErrorActionPreference = 'Stop'
92
100
  try {
93
101
  try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}
@@ -178,829 +186,868 @@ try {
178
186
  `;
179
187
 
180
188
  export function buildPowerShellAstCommand(command: string): string {
181
- const encodedCommand = Buffer.from(command, "utf8").toString("base64");
182
- return `$encodedSource = '${encodedCommand}'\n${POWERSHELL_AST_SCRIPT}`;
189
+ const encodedCommand = Buffer.from(command, "utf8").toString("base64");
190
+ return `$encodedSource = '${encodedCommand}'\n${POWERSHELL_AST_SCRIPT}`;
183
191
  }
184
192
 
185
- export type PowerShellSafetyAnalysis = {
186
- risky: boolean;
187
- reason?: string;
193
+ type PowerShellSafetyAnalysis = {
194
+ risky: boolean;
195
+ reason?: string;
188
196
  };
189
197
 
190
198
  type PowerShellLexToken = {
191
- kind: "word" | "string" | "operator" | "separator" | "nested";
192
- text: string;
193
- dynamic?: boolean;
194
- nestedSources?: string[];
199
+ kind: "word" | "string" | "operator" | "separator" | "nested";
200
+ text: string;
201
+ dynamic?: boolean;
202
+ nestedSources?: string[];
195
203
  };
196
204
 
197
205
  type PowerShellLexResult = {
198
- tokens: PowerShellLexToken[];
199
- malformed: boolean;
206
+ tokens: PowerShellLexToken[];
207
+ malformed: boolean;
200
208
  };
201
209
 
202
210
  type PowerShellAstElement = {
203
- type: string;
204
- text: string;
205
- parameter: string | null;
206
- argument: string | null;
207
- splatted: boolean;
211
+ type: string;
212
+ text: string;
213
+ parameter: string | null;
214
+ argument: string | null;
215
+ splatted: boolean;
208
216
  };
209
217
 
210
218
  type PowerShellAstCommand = {
211
- name: string | null;
212
- invocationOperator: string;
213
- elements: PowerShellAstElement[];
219
+ name: string | null;
220
+ invocationOperator: string;
221
+ elements: PowerShellAstElement[];
214
222
  };
215
223
 
216
224
  function risky(reason: string): PowerShellSafetyAnalysis {
217
- return { risky: true, reason };
225
+ return { risky: true, reason };
218
226
  }
219
227
 
220
228
  const safe: PowerShellSafetyAnalysis = { risky: false };
221
229
 
222
230
  function isRecord(value: unknown): value is Record<string, unknown> {
223
- return !!value && typeof value === "object" && !Array.isArray(value);
231
+ return !!value && typeof value === "object" && !Array.isArray(value);
224
232
  }
225
233
 
226
234
  function powerShellCommandName(rawName: string): string {
227
- const basename = rawName.trim().split(/[\\/]/).at(-1) ?? rawName;
228
- return basename.toLowerCase();
235
+ const basename = rawName.trim().split(/[\\/]/).at(-1) ?? rawName;
236
+ return basename.toLowerCase();
229
237
  }
230
238
 
231
239
  function isPowerShellScriptCommand(commandName: string): boolean {
232
- return /\.(?:ps1|psm1)$/i.test(commandName);
240
+ return /\.(?:ps1|psm1)$/i.test(commandName);
233
241
  }
234
242
 
235
243
  function containsCommandProviderPath(values: Iterable<string>): boolean {
236
- for (const value of values) {
237
- if (/(?:alias|function)\s*:/i.test(value)) return true;
238
- }
239
- return false;
244
+ for (const value of values) {
245
+ if (/(?:alias|function)\s*:/i.test(value)) return true;
246
+ }
247
+ return false;
240
248
  }
241
249
 
242
250
  function hasDynamicLexicalArguments(tokens: PowerShellLexToken[]): boolean {
243
- return tokens.some(
244
- (token) =>
245
- token.kind === "nested" ||
246
- token.dynamic === true ||
247
- (token.kind === "word" && /^(?:\$|@(?!\{))/.test(token.text)),
248
- );
251
+ return tokens.some(
252
+ (token) =>
253
+ token.kind === "nested" ||
254
+ token.dynamic === true ||
255
+ (token.kind === "word" && /^(?:\$|@(?!\{))/.test(token.text)),
256
+ );
249
257
  }
250
258
 
251
259
  function hasDynamicAstArguments(elements: PowerShellAstElement[]): boolean {
252
- return elements.some(
253
- (element) =>
254
- element.splatted ||
255
- !["CommandParameterAst", "ConstantExpressionAst", "StringConstantExpressionAst"].includes(element.type),
256
- );
260
+ return elements.some(
261
+ (element) =>
262
+ element.splatted ||
263
+ !["CommandParameterAst", "ConstantExpressionAst", "StringConstantExpressionAst"].includes(
264
+ element.type,
265
+ ),
266
+ );
257
267
  }
258
268
 
259
269
  function isDynamicMemberInvocationToken(token: PowerShellLexToken): boolean {
260
- if (token.kind !== "word") return false;
261
- const normalized = token.text
262
- .replace(/`\r?\n/g, "")
263
- .replace(/`(.)/gs, "$1");
264
- return (
265
- /\.\$/.test(normalized) ||
266
- /^(?:\$|@).+\.$/.test(normalized) ||
267
- /^\[.+\]::$/.test(normalized) ||
268
- /\.(?:begininvoke|create|createprocess\w*|endinvoke|exec|execute|invoke(?:returnasis|script|withcontext)?|run|shellexecute\w*|start|winexec)$/i.test(normalized) ||
269
- /^\[(?:system\.)?diagnostics\.process\]::start$/i.test(normalized) ||
270
- /::(?:createprocess\w*|shellexecute\w*|winexec)$/i.test(normalized) ||
271
- /^\[(?:system\.)?(?:management\.automation\.)?scriptblock\]::create$/i.test(normalized) ||
272
- /^\[(?:(?:system\.)?management\.automation\.)?powershell\]::create$/i.test(normalized)
273
- );
270
+ if (token.kind !== "word") return false;
271
+ const normalized = token.text.replace(/`\r?\n/g, "").replace(/`(.)/gs, "$1");
272
+ return (
273
+ /\.\$/.test(normalized) ||
274
+ /^(?:\$|@).+\.$/.test(normalized) ||
275
+ /^\[.+\]::$/.test(normalized) ||
276
+ /\.(?:begininvoke|create|createprocess\w*|endinvoke|exec|execute|invoke(?:returnasis|script|withcontext)?|run|shellexecute\w*|start|winexec)$/i.test(
277
+ normalized,
278
+ ) ||
279
+ /^\[(?:system\.)?diagnostics\.process\]::start$/i.test(normalized) ||
280
+ /::(?:createprocess\w*|shellexecute\w*|winexec)$/i.test(normalized) ||
281
+ /^\[(?:system\.)?(?:management\.automation\.)?scriptblock\]::create$/i.test(normalized) ||
282
+ /^\[(?:(?:system\.)?management\.automation\.)?powershell\]::create$/i.test(normalized)
283
+ );
274
284
  }
275
285
 
276
286
  function hasDangerousQuotedMemberInvocation(tokens: PowerShellLexToken[]): boolean {
277
- for (let index = 1; index < tokens.length - 1; index += 1) {
278
- const previous = tokens[index - 1];
279
- const member = tokens[index];
280
- const callArguments = tokens[index + 1];
281
- if (
282
- member.kind !== "string" ||
283
- previous.kind !== "word" ||
284
- !/(?:\.|::)$/.test(previous.text) ||
285
- callArguments.kind !== "nested"
286
- ) {
287
- continue;
288
- }
289
- if (
290
- member.dynamic ||
291
- /^(?:begininvoke|create|createprocess\w*|endinvoke|exec|execute|invoke(?:returnasis|script|withcontext)?|run|shellexecute\w*|start|winexec)$/i.test(
292
- member.text,
293
- )
294
- ) {
295
- return true;
296
- }
297
- }
298
- return false;
287
+ for (let index = 1; index < tokens.length - 1; index += 1) {
288
+ const previous = tokens[index - 1];
289
+ const member = tokens[index];
290
+ const callArguments = tokens[index + 1];
291
+ if (
292
+ member.kind !== "string" ||
293
+ previous.kind !== "word" ||
294
+ !/(?:\.|::)$/.test(previous.text) ||
295
+ callArguments.kind !== "nested"
296
+ ) {
297
+ continue;
298
+ }
299
+ if (
300
+ member.dynamic ||
301
+ /^(?:begininvoke|create|createprocess\w*|endinvoke|exec|execute|invoke(?:returnasis|script|withcontext)?|run|shellexecute\w*|start|winexec)$/i.test(
302
+ member.text,
303
+ )
304
+ ) {
305
+ return true;
306
+ }
307
+ }
308
+ return false;
299
309
  }
300
310
 
301
311
  function isFalseLiteral(value: string | null | undefined): boolean {
302
- return /^(?:\$?false|0)$/i.test(value?.trim() ?? "");
312
+ return /^(?:\$?false|0)$/i.test(value?.trim() ?? "");
303
313
  }
304
314
 
305
315
  function isTrueLiteral(value: string | null | undefined): boolean {
306
- return /^(?:\$?true|1)$/i.test(value?.trim() ?? "");
316
+ return /^(?:\$?true|1)$/i.test(value?.trim() ?? "");
307
317
  }
308
318
 
309
319
  function parameterMatches(parameter: string, kind: "recurse" | "force" | "whatIf"): boolean {
310
- const normalized = parameter
311
- .replace(/`\r?\n/g, "")
312
- .replace(/`(.)/gs, "$1")
313
- .replace(/^-+/, "")
314
- .toLowerCase();
315
- if (kind === "recurse") return /^r(?:e(?:c(?:u(?:r(?:s(?:e)?)?)?)?)?)?$/.test(normalized);
316
- if (kind === "force") return /^fo(?:r(?:c(?:e)?)?)?$/.test(normalized);
317
- return /^wh(?:a(?:t(?:i(?:f)?)?)?)?$/.test(normalized);
320
+ const normalized = parameter
321
+ .replace(/`\r?\n/g, "")
322
+ .replace(/`(.)/gs, "$1")
323
+ .replace(/^-+/, "")
324
+ .toLowerCase();
325
+ if (kind === "recurse") return /^r(?:e(?:c(?:u(?:r(?:s(?:e)?)?)?)?)?)?$/.test(normalized);
326
+ if (kind === "force") return /^fo(?:r(?:c(?:e)?)?)?$/.test(normalized);
327
+ return /^wh(?:a(?:t(?:i(?:f)?)?)?)?$/.test(normalized);
318
328
  }
319
329
 
320
330
  function parseLexicalParameter(token: string): { name: string; value?: string } | undefined {
321
- if (!token.startsWith("-") || token === "-") return undefined;
322
- const colon = token.indexOf(":");
323
- if (colon === -1) return { name: token };
324
- return { name: token.slice(0, colon), value: token.slice(colon + 1) };
331
+ if (!token.startsWith("-") || token === "-") return undefined;
332
+ const colon = token.indexOf(":");
333
+ if (colon === -1) return { name: token };
334
+ return { name: token.slice(0, colon), value: token.slice(colon + 1) };
325
335
  }
326
336
 
327
337
  function isPotentiallyEnabledSwitch(
328
- name: string,
329
- value: string | null | undefined,
330
- kind: "recurse" | "force",
338
+ name: string,
339
+ value: string | null | undefined,
340
+ kind: "recurse" | "force",
331
341
  ): boolean {
332
- return parameterMatches(name, kind) && !isFalseLiteral(value);
342
+ return parameterMatches(name, kind) && !isFalseLiteral(value);
333
343
  }
334
344
 
335
345
  function isDefinitelyEnabledWhatIf(name: string, value: string | null | undefined): boolean {
336
- if (!parameterMatches(name, "whatIf")) return false;
337
- return value === undefined || value === null || isTrueLiteral(value);
346
+ if (!parameterMatches(name, "whatIf")) return false;
347
+ return value === undefined || value === null || isTrueLiteral(value);
338
348
  }
339
349
 
340
350
  function findClosingParenthesis(source: string, openIndex: number): number {
341
- let depth = 1;
342
- let quote: "'" | '"' | undefined;
343
- let blockCommentDepth = 0;
344
-
345
- for (let index = openIndex + 1; index < source.length; index += 1) {
346
- const char = source[index];
347
- const next = source[index + 1];
348
-
349
- if (blockCommentDepth > 0) {
350
- if (char === "<" && next === "#") {
351
- blockCommentDepth += 1;
352
- index += 1;
353
- continue;
354
- }
355
- if (char === "#" && next === ">") {
356
- blockCommentDepth -= 1;
357
- index += 1;
358
- }
359
- continue;
360
- }
361
-
362
- if (quote === "'") {
363
- if (char === "'" && next === "'") {
364
- index += 1;
365
- continue;
366
- }
367
- if (char === "'") quote = undefined;
368
- continue;
369
- }
370
-
371
- if (quote === '"') {
372
- if (char === "`") {
373
- if (next === "\r" && source[index + 2] === "\n") index += 2;
374
- else if (next !== undefined) index += 1;
375
- continue;
376
- }
377
- if (char === '"') quote = undefined;
378
- continue;
379
- }
380
-
381
- if (char === "`") {
382
- if (next === "\r" && source[index + 2] === "\n") index += 2;
383
- else if (next !== undefined) index += 1;
384
- continue;
385
- }
386
- if (char === "#") {
387
- while (index + 1 < source.length && source[index + 1] !== "\n") index += 1;
388
- continue;
389
- }
390
- if (char === "<" && next === "#") {
391
- blockCommentDepth = 1;
392
- index += 1;
393
- continue;
394
- }
395
- if (char === "'" || char === '"') {
396
- quote = char;
397
- continue;
398
- }
399
- if (char === "(") depth += 1;
400
- if (char === ")") {
401
- depth -= 1;
402
- if (depth === 0) return index;
403
- }
404
- }
405
-
406
- return -1;
351
+ let depth = 1;
352
+ let quote: "'" | '"' | undefined;
353
+ let blockCommentDepth = 0;
354
+
355
+ for (let index = openIndex + 1; index < source.length; index += 1) {
356
+ const char = source[index];
357
+ const next = source[index + 1];
358
+
359
+ if (blockCommentDepth > 0) {
360
+ if (char === "<" && next === "#") {
361
+ blockCommentDepth += 1;
362
+ index += 1;
363
+ continue;
364
+ }
365
+ if (char === "#" && next === ">") {
366
+ blockCommentDepth -= 1;
367
+ index += 1;
368
+ }
369
+ continue;
370
+ }
371
+
372
+ if (quote === "'") {
373
+ if (char === "'" && next === "'") {
374
+ index += 1;
375
+ continue;
376
+ }
377
+ if (char === "'") quote = undefined;
378
+ continue;
379
+ }
380
+
381
+ if (quote === '"') {
382
+ if (char === "`") {
383
+ if (next === "\r" && source[index + 2] === "\n") index += 2;
384
+ else if (next !== undefined) index += 1;
385
+ continue;
386
+ }
387
+ if (char === '"') quote = undefined;
388
+ continue;
389
+ }
390
+
391
+ if (char === "`") {
392
+ if (next === "\r" && source[index + 2] === "\n") index += 2;
393
+ else if (next !== undefined) index += 1;
394
+ continue;
395
+ }
396
+ if (char === "#") {
397
+ while (index + 1 < source.length && source[index + 1] !== "\n") index += 1;
398
+ continue;
399
+ }
400
+ if (char === "<" && next === "#") {
401
+ blockCommentDepth = 1;
402
+ index += 1;
403
+ continue;
404
+ }
405
+ if (char === "'" || char === '"') {
406
+ quote = char;
407
+ continue;
408
+ }
409
+ if (char === "(") depth += 1;
410
+ if (char === ")") {
411
+ depth -= 1;
412
+ if (depth === 0) return index;
413
+ }
414
+ }
415
+
416
+ return -1;
407
417
  }
408
418
 
409
419
  function readQuotedToken(
410
- source: string,
411
- startIndex: number,
412
- quote: "'" | '"',
420
+ source: string,
421
+ startIndex: number,
422
+ quote: "'" | '"',
413
423
  ): { token: PowerShellLexToken; endIndex: number; malformed: boolean } {
414
- let text = "";
415
- let dynamic = false;
416
- const nestedSources: string[] = [];
417
-
418
- for (let index = startIndex + 1; index < source.length; index += 1) {
419
- const char = source[index];
420
- const next = source[index + 1];
421
-
422
- if (quote === "'" && char === "'" && next === "'") {
423
- text += "'";
424
- index += 1;
425
- continue;
426
- }
427
-
428
- if (quote === '"' && char === "`") {
429
- if (next === "\r" && source[index + 2] === "\n") {
430
- index += 2;
431
- continue;
432
- }
433
- if (next === "\n") {
434
- index += 1;
435
- continue;
436
- }
437
- if (next !== undefined) {
438
- text += next;
439
- index += 1;
440
- continue;
441
- }
442
- }
443
-
444
- if (char === quote) {
445
- return {
446
- token: {
447
- kind: "string",
448
- text,
449
- dynamic,
450
- ...(nestedSources.length ? { nestedSources } : {}),
451
- },
452
- endIndex: index,
453
- malformed: false,
454
- };
455
- }
456
-
457
- if (quote === '"' && char === "$" && next === "(") {
458
- const closingIndex = findClosingParenthesis(source, index + 1);
459
- if (closingIndex === -1) {
460
- return {
461
- token: { kind: "string", text, dynamic: true, nestedSources },
462
- endIndex: source.length - 1,
463
- malformed: true,
464
- };
465
- }
466
- nestedSources.push(source.slice(index + 2, closingIndex));
467
- dynamic = true;
468
- index = closingIndex;
469
- continue;
470
- }
471
-
472
- if (quote === '"' && char === "$" && /[A-Za-z_{]/.test(next ?? "")) dynamic = true;
473
- text += char;
474
- }
475
-
476
- return {
477
- token: { kind: "string", text, dynamic: true, ...(nestedSources.length ? { nestedSources } : {}) },
478
- endIndex: source.length - 1,
479
- malformed: true,
480
- };
424
+ let text = "";
425
+ let dynamic = false;
426
+ const nestedSources: string[] = [];
427
+
428
+ for (let index = startIndex + 1; index < source.length; index += 1) {
429
+ const char = source[index];
430
+ const next = source[index + 1];
431
+
432
+ if (quote === "'" && char === "'" && next === "'") {
433
+ text += "'";
434
+ index += 1;
435
+ continue;
436
+ }
437
+
438
+ if (quote === '"' && char === "`") {
439
+ if (next === "\r" && source[index + 2] === "\n") {
440
+ index += 2;
441
+ continue;
442
+ }
443
+ if (next === "\n") {
444
+ index += 1;
445
+ continue;
446
+ }
447
+ if (next !== undefined) {
448
+ text += next;
449
+ index += 1;
450
+ continue;
451
+ }
452
+ }
453
+
454
+ if (char === quote) {
455
+ return {
456
+ token: {
457
+ kind: "string",
458
+ text,
459
+ dynamic,
460
+ ...(nestedSources.length ? { nestedSources } : {}),
461
+ },
462
+ endIndex: index,
463
+ malformed: false,
464
+ };
465
+ }
466
+
467
+ if (quote === '"' && char === "$" && next === "(") {
468
+ const closingIndex = findClosingParenthesis(source, index + 1);
469
+ if (closingIndex === -1) {
470
+ return {
471
+ token: { kind: "string", text, dynamic: true, nestedSources },
472
+ endIndex: source.length - 1,
473
+ malformed: true,
474
+ };
475
+ }
476
+ nestedSources.push(source.slice(index + 2, closingIndex));
477
+ dynamic = true;
478
+ index = closingIndex;
479
+ continue;
480
+ }
481
+
482
+ if (quote === '"' && char === "$" && /[A-Za-z_{]/.test(next ?? "")) dynamic = true;
483
+ text += char;
484
+ }
485
+
486
+ return {
487
+ token: {
488
+ kind: "string",
489
+ text,
490
+ dynamic: true,
491
+ ...(nestedSources.length ? { nestedSources } : {}),
492
+ },
493
+ endIndex: source.length - 1,
494
+ malformed: true,
495
+ };
481
496
  }
482
497
 
483
498
  function readHereStringToken(
484
- source: string,
485
- startIndex: number,
486
- quote: "'" | '"',
499
+ source: string,
500
+ startIndex: number,
501
+ quote: "'" | '"',
487
502
  ): { token: PowerShellLexToken; endIndex: number; malformed: boolean } | undefined {
488
- const openerEnd = startIndex + 2;
489
- let contentStart: number;
490
- if (source[openerEnd] === "\r" && source[openerEnd + 1] === "\n") contentStart = openerEnd + 2;
491
- else if (source[openerEnd] === "\n") contentStart = openerEnd + 1;
492
- else return undefined;
493
-
494
- let closingMarker = -1;
495
- let contentEnd = source.length;
496
- for (let lineStart = contentStart; lineStart <= source.length; ) {
497
- let marker = lineStart;
498
- while (source[marker] === " " || source[marker] === "\t") marker += 1;
499
- if (source[marker] === quote && source[marker + 1] === "@") {
500
- closingMarker = marker;
501
- contentEnd = lineStart;
502
- break;
503
- }
504
- const newline = source.indexOf("\n", lineStart);
505
- if (newline === -1) break;
506
- lineStart = newline + 1;
507
- }
508
-
509
- const text = source.slice(contentStart, contentEnd);
510
- let dynamic = false;
511
- let malformed = closingMarker === -1;
512
- const nestedSources: string[] = [];
513
- if (quote === '"') {
514
- for (let index = 0; index < text.length; index += 1) {
515
- const char = text[index];
516
- const next = text[index + 1];
517
- if (char === "`") {
518
- if (next !== undefined) index += 1;
519
- continue;
520
- }
521
- if (char === "$" && next === "(") {
522
- const closingIndex = findClosingParenthesis(text, index + 1);
523
- if (closingIndex === -1) {
524
- malformed = true;
525
- break;
526
- }
527
- nestedSources.push(text.slice(index + 2, closingIndex));
528
- dynamic = true;
529
- index = closingIndex;
530
- continue;
531
- }
532
- if (char === "$" && /[A-Za-z_{]/.test(next ?? "")) dynamic = true;
533
- }
534
- }
535
-
536
- return {
537
- token: {
538
- kind: "string",
539
- text,
540
- dynamic,
541
- ...(nestedSources.length ? { nestedSources } : {}),
542
- },
543
- endIndex: closingMarker === -1 ? source.length - 1 : closingMarker + 1,
544
- malformed,
545
- };
503
+ const openerEnd = startIndex + 2;
504
+ let contentStart: number;
505
+ if (source[openerEnd] === "\r" && source[openerEnd + 1] === "\n") contentStart = openerEnd + 2;
506
+ else if (source[openerEnd] === "\n") contentStart = openerEnd + 1;
507
+ else return undefined;
508
+
509
+ let closingMarker = -1;
510
+ let contentEnd = source.length;
511
+ for (let lineStart = contentStart; lineStart <= source.length;) {
512
+ let marker = lineStart;
513
+ while (source[marker] === " " || source[marker] === "\t") marker += 1;
514
+ if (source[marker] === quote && source[marker + 1] === "@") {
515
+ closingMarker = marker;
516
+ contentEnd = lineStart;
517
+ break;
518
+ }
519
+ const newline = source.indexOf("\n", lineStart);
520
+ if (newline === -1) break;
521
+ lineStart = newline + 1;
522
+ }
523
+
524
+ const text = source.slice(contentStart, contentEnd);
525
+ let dynamic = false;
526
+ let malformed = closingMarker === -1;
527
+ const nestedSources: string[] = [];
528
+ if (quote === '"') {
529
+ for (let index = 0; index < text.length; index += 1) {
530
+ const char = text[index];
531
+ const next = text[index + 1];
532
+ if (char === "`") {
533
+ if (next !== undefined) index += 1;
534
+ continue;
535
+ }
536
+ if (char === "$" && next === "(") {
537
+ const closingIndex = findClosingParenthesis(text, index + 1);
538
+ if (closingIndex === -1) {
539
+ malformed = true;
540
+ break;
541
+ }
542
+ nestedSources.push(text.slice(index + 2, closingIndex));
543
+ dynamic = true;
544
+ index = closingIndex;
545
+ continue;
546
+ }
547
+ if (char === "$" && /[A-Za-z_{]/.test(next ?? "")) dynamic = true;
548
+ }
549
+ }
550
+
551
+ return {
552
+ token: {
553
+ kind: "string",
554
+ text,
555
+ dynamic,
556
+ ...(nestedSources.length ? { nestedSources } : {}),
557
+ },
558
+ endIndex: closingMarker === -1 ? source.length - 1 : closingMarker + 1,
559
+ malformed,
560
+ };
546
561
  }
547
562
 
548
563
  function tokenizePowerShell(source: string): PowerShellLexResult {
549
- const tokens: PowerShellLexToken[] = [];
550
- let current = "";
551
- let malformed = false;
552
- let braceDepth = 0;
553
-
554
- const flush = () => {
555
- if (!current) return;
556
- tokens.push({ kind: "word", text: current });
557
- current = "";
558
- };
559
-
560
- for (let index = 0; index < source.length; index += 1) {
561
- const char = source[index];
562
- const next = source[index + 1];
563
-
564
- if (char === "`") {
565
- if (next === "\r" && source[index + 2] === "\n") {
566
- index += 2;
567
- continue;
568
- }
569
- if (next === "\n") {
570
- index += 1;
571
- continue;
572
- }
573
- if (next === undefined) {
574
- malformed = true;
575
- continue;
576
- }
577
- current += next;
578
- index += 1;
579
- continue;
580
- }
581
-
582
- if (char === "<" && next === "#") {
583
- flush();
584
- let depth = 1;
585
- index += 2;
586
- for (; index < source.length && depth > 0; index += 1) {
587
- if (source[index] === "<" && source[index + 1] === "#") {
588
- depth += 1;
589
- index += 1;
590
- } else if (source[index] === "#" && source[index + 1] === ">") {
591
- depth -= 1;
592
- index += 1;
593
- }
594
- }
595
- if (depth > 0) malformed = true;
596
- index -= 1;
597
- continue;
598
- }
599
-
600
- if (char === "#") {
601
- flush();
602
- while (index + 1 < source.length && source[index + 1] !== "\n") index += 1;
603
- continue;
604
- }
605
-
606
- if (char === "@" && (next === "'" || next === '"')) {
607
- const hereString = readHereStringToken(source, index, next);
608
- if (hereString) {
609
- flush();
610
- tokens.push(hereString.token);
611
- malformed ||= hereString.malformed;
612
- index = hereString.endIndex;
613
- continue;
614
- }
615
- }
616
-
617
- if (char === "'" || char === '"') {
618
- flush();
619
- const quoted = readQuotedToken(source, index, char);
620
- tokens.push(quoted.token);
621
- malformed ||= quoted.malformed;
622
- index = quoted.endIndex;
623
- continue;
624
- }
625
-
626
- if ((char === "$" || char === "@") && next === "(") {
627
- flush();
628
- const closingIndex = findClosingParenthesis(source, index + 1);
629
- if (closingIndex === -1) {
630
- malformed = true;
631
- break;
632
- }
633
- tokens.push({ kind: "nested", text: source.slice(index + 2, closingIndex) });
634
- index = closingIndex;
635
- continue;
636
- }
637
-
638
- if (char === "(") {
639
- flush();
640
- const closingIndex = findClosingParenthesis(source, index);
641
- if (closingIndex === -1) {
642
- malformed = true;
643
- break;
644
- }
645
- tokens.push({ kind: "nested", text: source.slice(index + 1, closingIndex) });
646
- index = closingIndex;
647
- continue;
648
- }
649
-
650
- if (char === ")") {
651
- flush();
652
- malformed = true;
653
- continue;
654
- }
655
-
656
- if (char === "&" && next === "&" || char === "|" && next === "|") {
657
- flush();
658
- tokens.push({ kind: "separator", text: `${char}${next}` });
659
- index += 1;
660
- continue;
661
- }
662
-
663
- if (char === "{") {
664
- flush();
665
- braceDepth += 1;
666
- tokens.push({ kind: "separator", text: char });
667
- continue;
668
- }
669
-
670
- if (char === "}") {
671
- flush();
672
- if (braceDepth === 0) malformed = true;
673
- else braceDepth -= 1;
674
- tokens.push({ kind: "separator", text: char });
675
- continue;
676
- }
677
-
678
- if (char === ";" || char === "|" || char === "\n") {
679
- flush();
680
- tokens.push({ kind: "separator", text: char });
681
- continue;
682
- }
683
-
684
- if (char === "&" || char === "=") {
685
- flush();
686
- tokens.push({ kind: "operator", text: char });
687
- continue;
688
- }
689
-
690
- if (/\s/.test(char)) {
691
- flush();
692
- continue;
693
- }
694
-
695
- current += char;
696
- }
697
-
698
- flush();
699
- if (braceDepth !== 0) malformed = true;
700
- return { tokens, malformed };
701
- }
702
-
703
- function segmentCommandStart(tokens: PowerShellLexToken[]): { index: number; dynamic: boolean } | undefined {
704
- let index = 0;
705
- while (tokens[index]?.kind === "word" && ["try", "finally", "catch", "begin", "process", "end", "else"].includes(tokens[index].text.toLowerCase())) {
706
- index += 1;
707
- }
708
-
709
- let assignment = false;
710
- for (let candidate = index; candidate < tokens.length; candidate += 1) {
711
- if (tokens[candidate]?.kind === "operator" && tokens[candidate].text === "=") {
712
- index = candidate + 1;
713
- assignment = true;
714
- }
715
- }
716
-
717
- let usedCallOperator = false;
718
- while (
719
- tokens[index] &&
720
- ((tokens[index].kind === "operator" && tokens[index].text === "&") ||
721
- (tokens[index].kind === "word" && tokens[index].text === "."))
722
- ) {
723
- usedCallOperator = true;
724
- index += 1;
725
- }
726
-
727
- const head = tokens[index];
728
- if (!head) return undefined;
729
- if (head.kind === "nested") return usedCallOperator ? { index, dynamic: true } : undefined;
730
- if (head.kind !== "word" && head.kind !== "string") return undefined;
731
- if (head.kind === "string") return usedCallOperator ? { index, dynamic: head.dynamic === true } : undefined;
732
- if (/^(?:\$|@|\[|\d)/.test(head.text)) return usedCallOperator ? { index, dynamic: true } : undefined;
733
- if (assignment && head.text === "{") return undefined;
734
- return { index, dynamic: false };
564
+ const tokens: PowerShellLexToken[] = [];
565
+ let current = "";
566
+ let malformed = false;
567
+ let braceDepth = 0;
568
+
569
+ const flush = () => {
570
+ if (!current) return;
571
+ tokens.push({ kind: "word", text: current });
572
+ current = "";
573
+ };
574
+
575
+ for (let index = 0; index < source.length; index += 1) {
576
+ const char = source[index];
577
+ const next = source[index + 1];
578
+
579
+ if (char === "`") {
580
+ if (next === "\r" && source[index + 2] === "\n") {
581
+ index += 2;
582
+ continue;
583
+ }
584
+ if (next === "\n") {
585
+ index += 1;
586
+ continue;
587
+ }
588
+ if (next === undefined) {
589
+ malformed = true;
590
+ continue;
591
+ }
592
+ current += next;
593
+ index += 1;
594
+ continue;
595
+ }
596
+
597
+ if (char === "<" && next === "#") {
598
+ flush();
599
+ let depth = 1;
600
+ index += 2;
601
+ for (; index < source.length && depth > 0; index += 1) {
602
+ if (source[index] === "<" && source[index + 1] === "#") {
603
+ depth += 1;
604
+ index += 1;
605
+ } else if (source[index] === "#" && source[index + 1] === ">") {
606
+ depth -= 1;
607
+ index += 1;
608
+ }
609
+ }
610
+ if (depth > 0) malformed = true;
611
+ index -= 1;
612
+ continue;
613
+ }
614
+
615
+ if (char === "#") {
616
+ flush();
617
+ while (index + 1 < source.length && source[index + 1] !== "\n") index += 1;
618
+ continue;
619
+ }
620
+
621
+ if (char === "@" && (next === "'" || next === '"')) {
622
+ const hereString = readHereStringToken(source, index, next);
623
+ if (hereString) {
624
+ flush();
625
+ tokens.push(hereString.token);
626
+ malformed ||= hereString.malformed;
627
+ index = hereString.endIndex;
628
+ continue;
629
+ }
630
+ }
631
+
632
+ if (char === "'" || char === '"') {
633
+ flush();
634
+ const quoted = readQuotedToken(source, index, char);
635
+ tokens.push(quoted.token);
636
+ malformed ||= quoted.malformed;
637
+ index = quoted.endIndex;
638
+ continue;
639
+ }
640
+
641
+ if ((char === "$" || char === "@") && next === "(") {
642
+ flush();
643
+ const closingIndex = findClosingParenthesis(source, index + 1);
644
+ if (closingIndex === -1) {
645
+ malformed = true;
646
+ break;
647
+ }
648
+ tokens.push({ kind: "nested", text: source.slice(index + 2, closingIndex) });
649
+ index = closingIndex;
650
+ continue;
651
+ }
652
+
653
+ if (char === "(") {
654
+ flush();
655
+ const closingIndex = findClosingParenthesis(source, index);
656
+ if (closingIndex === -1) {
657
+ malformed = true;
658
+ break;
659
+ }
660
+ tokens.push({ kind: "nested", text: source.slice(index + 1, closingIndex) });
661
+ index = closingIndex;
662
+ continue;
663
+ }
664
+
665
+ if (char === ")") {
666
+ flush();
667
+ malformed = true;
668
+ continue;
669
+ }
670
+
671
+ if ((char === "&" && next === "&") || (char === "|" && next === "|")) {
672
+ flush();
673
+ tokens.push({ kind: "separator", text: `${char}${next}` });
674
+ index += 1;
675
+ continue;
676
+ }
677
+
678
+ if (char === "{") {
679
+ flush();
680
+ braceDepth += 1;
681
+ tokens.push({ kind: "separator", text: char });
682
+ continue;
683
+ }
684
+
685
+ if (char === "}") {
686
+ flush();
687
+ if (braceDepth === 0) malformed = true;
688
+ else braceDepth -= 1;
689
+ tokens.push({ kind: "separator", text: char });
690
+ continue;
691
+ }
692
+
693
+ if (char === ";" || char === "|" || char === "\n") {
694
+ flush();
695
+ tokens.push({ kind: "separator", text: char });
696
+ continue;
697
+ }
698
+
699
+ if (char === "&" || char === "=") {
700
+ flush();
701
+ tokens.push({ kind: "operator", text: char });
702
+ continue;
703
+ }
704
+
705
+ if (/\s/.test(char)) {
706
+ flush();
707
+ continue;
708
+ }
709
+
710
+ current += char;
711
+ }
712
+
713
+ flush();
714
+ if (braceDepth !== 0) malformed = true;
715
+ return { tokens, malformed };
716
+ }
717
+
718
+ function segmentCommandStart(
719
+ tokens: PowerShellLexToken[],
720
+ ): { index: number; dynamic: boolean } | undefined {
721
+ let index = 0;
722
+ while (
723
+ tokens[index]?.kind === "word" &&
724
+ ["try", "finally", "catch", "begin", "process", "end", "else"].includes(
725
+ tokens[index].text.toLowerCase(),
726
+ )
727
+ ) {
728
+ index += 1;
729
+ }
730
+
731
+ let assignment = false;
732
+ for (let candidate = index; candidate < tokens.length; candidate += 1) {
733
+ if (tokens[candidate]?.kind === "operator" && tokens[candidate].text === "=") {
734
+ index = candidate + 1;
735
+ assignment = true;
736
+ }
737
+ }
738
+
739
+ let usedCallOperator = false;
740
+ while (
741
+ tokens[index] &&
742
+ ((tokens[index].kind === "operator" && tokens[index].text === "&") ||
743
+ (tokens[index].kind === "word" && tokens[index].text === "."))
744
+ ) {
745
+ usedCallOperator = true;
746
+ index += 1;
747
+ }
748
+
749
+ const head = tokens[index];
750
+ if (!head) return undefined;
751
+ if (head.kind === "nested") return usedCallOperator ? { index, dynamic: true } : undefined;
752
+ if (head.kind !== "word" && head.kind !== "string") return undefined;
753
+ if (head.kind === "string")
754
+ return usedCallOperator ? { index, dynamic: head.dynamic === true } : undefined;
755
+ if (/^(?:\$|@|\[|\d)/.test(head.text))
756
+ return usedCallOperator ? { index, dynamic: true } : undefined;
757
+ if (assignment && head.text === "{") return undefined;
758
+ return { index, dynamic: false };
735
759
  }
736
760
 
737
761
  function hasNativeRecursiveForceFlags(tokens: PowerShellLexToken[], startIndex: number): boolean {
738
- let sawRecursive = false;
739
- let sawForce = false;
740
- for (const token of tokens.slice(startIndex + 1)) {
741
- if (token.kind !== "word") continue;
742
- if (token.text === "--") break;
743
- if (/^--recursive$/i.test(token.text)) sawRecursive = true;
744
- if (/^--force$/i.test(token.text)) sawForce = true;
745
- if (/^-[^-]+$/.test(token.text)) {
746
- const flags = token.text.slice(1);
747
- if (/[rR]/.test(flags)) sawRecursive = true;
748
- if (/f/.test(flags)) sawForce = true;
749
- }
750
- }
751
- return sawRecursive && sawForce;
752
- }
753
-
754
- function analyzeRemoveInvocation(tokens: PowerShellLexToken[], startIndex: number): PowerShellSafetyAnalysis {
755
- let sawRecursive = false;
756
- let sawForce = false;
757
- let sawWhatIf = false;
758
-
759
- for (const token of tokens.slice(startIndex + 1)) {
760
- if (token.kind !== "word") continue;
761
- if (token.text.startsWith("@") && token.text.length > 1) {
762
- return risky("Remove-Item parameter splatting requires confirmation");
763
- }
764
- const parameter = parseLexicalParameter(token.text);
765
- if (!parameter) continue;
766
- if (isPotentiallyEnabledSwitch(parameter.name, parameter.value, "recurse")) sawRecursive = true;
767
- if (isPotentiallyEnabledSwitch(parameter.name, parameter.value, "force")) sawForce = true;
768
- if (isDefinitelyEnabledWhatIf(parameter.name, parameter.value)) sawWhatIf = true;
769
- }
770
-
771
- if (sawRecursive && sawForce && !sawWhatIf) {
772
- return risky("recursive forced Remove-Item invocation");
773
- }
774
- return safe;
762
+ let sawRecursive = false;
763
+ let sawForce = false;
764
+ for (const token of tokens.slice(startIndex + 1)) {
765
+ if (token.kind !== "word") continue;
766
+ if (token.text === "--") break;
767
+ if (/^--recursive$/i.test(token.text)) sawRecursive = true;
768
+ if (/^--force$/i.test(token.text)) sawForce = true;
769
+ if (/^-[^-]+$/.test(token.text)) {
770
+ const flags = token.text.slice(1);
771
+ if (/[rR]/.test(flags)) sawRecursive = true;
772
+ if (/f/.test(flags)) sawForce = true;
773
+ }
774
+ }
775
+ return sawRecursive && sawForce;
776
+ }
777
+
778
+ function analyzeRemoveInvocation(
779
+ tokens: PowerShellLexToken[],
780
+ startIndex: number,
781
+ ): PowerShellSafetyAnalysis {
782
+ let sawRecursive = false;
783
+ let sawForce = false;
784
+ let sawWhatIf = false;
785
+
786
+ for (const token of tokens.slice(startIndex + 1)) {
787
+ if (token.kind !== "word") continue;
788
+ if (token.text.startsWith("@") && token.text.length > 1) {
789
+ return risky("Remove-Item parameter splatting requires confirmation");
790
+ }
791
+ const parameter = parseLexicalParameter(token.text);
792
+ if (!parameter) continue;
793
+ if (isPotentiallyEnabledSwitch(parameter.name, parameter.value, "recurse")) sawRecursive = true;
794
+ if (isPotentiallyEnabledSwitch(parameter.name, parameter.value, "force")) sawForce = true;
795
+ if (isDefinitelyEnabledWhatIf(parameter.name, parameter.value)) sawWhatIf = true;
796
+ }
797
+
798
+ if (sawRecursive && sawForce && !sawWhatIf) {
799
+ return risky("recursive forced Remove-Item invocation");
800
+ }
801
+ return safe;
775
802
  }
776
803
 
777
804
  function analyzeLexicalSegment(tokens: PowerShellLexToken[]): PowerShellSafetyAnalysis {
778
- if (tokens.length === 0) return safe;
779
- if (tokens.some(isDynamicMemberInvocationToken) || hasDangerousQuotedMemberInvocation(tokens)) {
780
- return risky("dynamic PowerShell script-block invocation requires confirmation");
781
- }
782
-
783
- const start = segmentCommandStart(tokens);
784
- if (!start) return safe;
785
- if (start.dynamic) return risky("dynamic PowerShell command invocation requires confirmation");
786
-
787
- const head = tokens[start.index];
788
- const commandName = powerShellCommandName(head.text);
789
- if (!commandName) {
790
- return risky("dynamic PowerShell command invocation requires confirmation");
791
- }
792
- if (dynamicPowerShellCommands.has(commandName) || shellPowerShellCommands.has(commandName)) {
793
- return risky(`nested or dynamic PowerShell execution through ${commandName}`);
794
- }
795
- if (commandResolutionPowerShellCommands.has(commandName)) {
796
- return risky(`PowerShell command-resolution mutation through ${commandName}`);
797
- }
798
- if (commandDefinitionPowerShellKeywords.has(commandName)) {
799
- return risky(`PowerShell command-resolution mutation through ${commandName} definition`);
800
- }
801
- if (
802
- providerMutationPowerShellCommands.has(commandName) &&
803
- (containsCommandProviderPath(tokens.slice(start.index + 1).map((token) => token.text)) ||
804
- hasDynamicLexicalArguments(tokens.slice(start.index + 1)))
805
- ) {
806
- return risky(`PowerShell command-provider mutation through ${commandName}`);
807
- }
808
- if (
809
- commandName === "using" &&
810
- tokens.slice(start.index + 1).some((token) => token.text.toLowerCase() === "module")
811
- ) {
812
- return risky("PowerShell module loading requires confirmation");
813
- }
814
- if (isPowerShellScriptCommand(commandName)) {
815
- return risky("nested PowerShell script invocation requires confirmation");
816
- }
817
- if (commandName === "sudo" || commandName === "sudo.exe") return risky("sudo invocation");
818
-
819
- if (powerShellRemoveCommands.has(commandName)) {
820
- const analysis = analyzeRemoveInvocation(tokens, start.index);
821
- if (analysis.risky) return analysis;
822
- if (commandName === "rm" && hasNativeRecursiveForceFlags(tokens, start.index)) {
823
- return risky("recursive forced rm invocation");
824
- }
825
- }
826
-
827
- if (commandName === "rm.exe" && hasNativeRecursiveForceFlags(tokens, start.index)) {
828
- return risky("recursive forced native rm invocation");
829
- }
830
- if (["chmod", "chmod.exe", "chown", "chown.exe"].includes(commandName)) {
831
- if (tokens.slice(start.index + 1).some((token) => token.kind === "word" && /777/.test(token.text))) {
832
- return risky(`${commandName} 777 invocation`);
833
- }
834
- }
835
-
836
- return safe;
805
+ if (tokens.length === 0) return safe;
806
+ if (tokens.some(isDynamicMemberInvocationToken) || hasDangerousQuotedMemberInvocation(tokens)) {
807
+ return risky("dynamic PowerShell script-block invocation requires confirmation");
808
+ }
809
+
810
+ const start = segmentCommandStart(tokens);
811
+ if (!start) return safe;
812
+ if (start.dynamic) return risky("dynamic PowerShell command invocation requires confirmation");
813
+
814
+ const head = tokens[start.index];
815
+ const commandName = powerShellCommandName(head.text);
816
+ if (!commandName) {
817
+ return risky("dynamic PowerShell command invocation requires confirmation");
818
+ }
819
+ if (dynamicPowerShellCommands.has(commandName) || shellPowerShellCommands.has(commandName)) {
820
+ return risky(`nested or dynamic PowerShell execution through ${commandName}`);
821
+ }
822
+ if (commandResolutionPowerShellCommands.has(commandName)) {
823
+ return risky(`PowerShell command-resolution mutation through ${commandName}`);
824
+ }
825
+ if (commandDefinitionPowerShellKeywords.has(commandName)) {
826
+ return risky(`PowerShell command-resolution mutation through ${commandName} definition`);
827
+ }
828
+ if (
829
+ providerMutationPowerShellCommands.has(commandName) &&
830
+ (containsCommandProviderPath(tokens.slice(start.index + 1).map((token) => token.text)) ||
831
+ hasDynamicLexicalArguments(tokens.slice(start.index + 1)))
832
+ ) {
833
+ return risky(`PowerShell command-provider mutation through ${commandName}`);
834
+ }
835
+ if (
836
+ commandName === "using" &&
837
+ tokens.slice(start.index + 1).some((token) => token.text.toLowerCase() === "module")
838
+ ) {
839
+ return risky("PowerShell module loading requires confirmation");
840
+ }
841
+ if (isPowerShellScriptCommand(commandName)) {
842
+ return risky("nested PowerShell script invocation requires confirmation");
843
+ }
844
+ if (commandName === "sudo" || commandName === "sudo.exe") return risky("sudo invocation");
845
+
846
+ if (powerShellRemoveCommands.has(commandName)) {
847
+ const analysis = analyzeRemoveInvocation(tokens, start.index);
848
+ if (analysis.risky) return analysis;
849
+ if (commandName === "rm" && hasNativeRecursiveForceFlags(tokens, start.index)) {
850
+ return risky("recursive forced rm invocation");
851
+ }
852
+ }
853
+
854
+ if (commandName === "rm.exe" && hasNativeRecursiveForceFlags(tokens, start.index)) {
855
+ return risky("recursive forced native rm invocation");
856
+ }
857
+ if (["chmod", "chmod.exe", "chown", "chown.exe"].includes(commandName)) {
858
+ if (
859
+ tokens.slice(start.index + 1).some((token) => token.kind === "word" && /777/.test(token.text))
860
+ ) {
861
+ return risky(`${commandName} 777 invocation`);
862
+ }
863
+ }
864
+
865
+ return safe;
837
866
  }
838
867
 
839
868
  export function analyzePowerShellLexically(command: string, depth = 0): PowerShellSafetyAnalysis {
840
- if (depth > MAX_LEXICAL_DEPTH) return risky("PowerShell safety analysis nesting limit exceeded");
841
-
842
- const lexed = tokenizePowerShell(command);
843
- if (lexed.malformed) return risky("malformed PowerShell syntax requires confirmation");
844
-
845
- for (const token of lexed.tokens) {
846
- const nestedSources = token.kind === "nested" ? [token.text] : token.nestedSources ?? [];
847
- for (const nestedSource of nestedSources) {
848
- const nested = analyzePowerShellLexically(nestedSource, depth + 1);
849
- if (nested.risky) return nested;
850
- }
851
- }
852
-
853
- let segment: PowerShellLexToken[] = [];
854
- for (const token of lexed.tokens) {
855
- if (token.kind === "separator") {
856
- const analysis = analyzeLexicalSegment(segment);
857
- if (analysis.risky) return analysis;
858
- segment = [];
859
- continue;
860
- }
861
- segment.push(token);
862
- }
863
-
864
- return analyzeLexicalSegment(segment);
869
+ if (depth > MAX_LEXICAL_DEPTH) return risky("PowerShell safety analysis nesting limit exceeded");
870
+
871
+ const lexed = tokenizePowerShell(command);
872
+ if (lexed.malformed) return risky("malformed PowerShell syntax requires confirmation");
873
+
874
+ for (const token of lexed.tokens) {
875
+ const nestedSources = token.kind === "nested" ? [token.text] : (token.nestedSources ?? []);
876
+ for (const nestedSource of nestedSources) {
877
+ const nested = analyzePowerShellLexically(nestedSource, depth + 1);
878
+ if (nested.risky) return nested;
879
+ }
880
+ }
881
+
882
+ let segment: PowerShellLexToken[] = [];
883
+ for (const token of lexed.tokens) {
884
+ if (token.kind === "separator") {
885
+ const analysis = analyzeLexicalSegment(segment);
886
+ if (analysis.risky) return analysis;
887
+ segment = [];
888
+ continue;
889
+ }
890
+ segment.push(token);
891
+ }
892
+
893
+ return analyzeLexicalSegment(segment);
865
894
  }
866
895
 
867
896
  function parseAstElement(value: unknown): PowerShellAstElement | undefined {
868
- if (!isRecord(value)) return undefined;
869
- if (typeof value.type !== "string" || typeof value.text !== "string" || typeof value.splatted !== "boolean") {
870
- return undefined;
871
- }
872
- if (value.parameter !== null && typeof value.parameter !== "string") return undefined;
873
- if (value.argument !== null && typeof value.argument !== "string") return undefined;
874
- return {
875
- type: value.type,
876
- text: value.text,
877
- parameter: value.parameter,
878
- argument: value.argument,
879
- splatted: value.splatted,
880
- };
897
+ if (!isRecord(value)) return undefined;
898
+ if (
899
+ typeof value.type !== "string" ||
900
+ typeof value.text !== "string" ||
901
+ typeof value.splatted !== "boolean"
902
+ ) {
903
+ return undefined;
904
+ }
905
+ if (value.parameter !== null && typeof value.parameter !== "string") return undefined;
906
+ if (value.argument !== null && typeof value.argument !== "string") return undefined;
907
+ return {
908
+ type: value.type,
909
+ text: value.text,
910
+ parameter: value.parameter,
911
+ argument: value.argument,
912
+ splatted: value.splatted,
913
+ };
881
914
  }
882
915
 
883
916
  function parseAstCommand(value: unknown): PowerShellAstCommand | undefined {
884
- if (!isRecord(value) || !Array.isArray(value.elements) || typeof value.invocationOperator !== "string") return undefined;
885
- if (value.name !== null && typeof value.name !== "string") return undefined;
886
- const elements = value.elements.map(parseAstElement);
887
- if (elements.length === 0 || elements.some((element) => !element)) return undefined;
888
- return {
889
- name: value.name,
890
- invocationOperator: value.invocationOperator,
891
- elements: elements as PowerShellAstElement[],
892
- };
917
+ if (
918
+ !isRecord(value) ||
919
+ !Array.isArray(value.elements) ||
920
+ typeof value.invocationOperator !== "string"
921
+ )
922
+ return undefined;
923
+ if (value.name !== null && typeof value.name !== "string") return undefined;
924
+ const elements = value.elements.map(parseAstElement);
925
+ if (elements.length === 0 || elements.some((element) => !element)) return undefined;
926
+ return {
927
+ name: value.name,
928
+ invocationOperator: value.invocationOperator,
929
+ elements: elements as PowerShellAstElement[],
930
+ };
893
931
  }
894
932
 
895
933
  function analyzeAstRemoveInvocation(command: PowerShellAstCommand): PowerShellSafetyAnalysis {
896
- let sawRecursive = false;
897
- let sawForce = false;
898
- let sawWhatIf = false;
899
-
900
- for (const element of command.elements.slice(1)) {
901
- if (element.splatted) return risky("Remove-Item parameter splatting requires confirmation");
902
- if (element.type !== "CommandParameterAst" || element.parameter === null) continue;
903
- if (isPotentiallyEnabledSwitch(element.parameter, element.argument, "recurse")) sawRecursive = true;
904
- if (isPotentiallyEnabledSwitch(element.parameter, element.argument, "force")) sawForce = true;
905
- if (isDefinitelyEnabledWhatIf(element.parameter, element.argument)) sawWhatIf = true;
906
- }
907
-
908
- if (sawRecursive && sawForce && !sawWhatIf) {
909
- return risky("recursive forced Remove-Item invocation");
910
- }
911
- return safe;
934
+ let sawRecursive = false;
935
+ let sawForce = false;
936
+ let sawWhatIf = false;
937
+
938
+ for (const element of command.elements.slice(1)) {
939
+ if (element.splatted) return risky("Remove-Item parameter splatting requires confirmation");
940
+ if (element.type !== "CommandParameterAst" || element.parameter === null) continue;
941
+ if (isPotentiallyEnabledSwitch(element.parameter, element.argument, "recurse"))
942
+ sawRecursive = true;
943
+ if (isPotentiallyEnabledSwitch(element.parameter, element.argument, "force")) sawForce = true;
944
+ if (isDefinitelyEnabledWhatIf(element.parameter, element.argument)) sawWhatIf = true;
945
+ }
946
+
947
+ if (sawRecursive && sawForce && !sawWhatIf) {
948
+ return risky("recursive forced Remove-Item invocation");
949
+ }
950
+ return safe;
912
951
  }
913
952
 
914
953
  function analyzeAstCommand(command: PowerShellAstCommand): PowerShellSafetyAnalysis {
915
- if (!command.name) return risky("dynamic PowerShell command invocation requires confirmation");
916
- const commandName = powerShellCommandName(command.name);
917
- if (dynamicPowerShellCommands.has(commandName) || shellPowerShellCommands.has(commandName)) {
918
- return risky(`nested or dynamic PowerShell execution through ${commandName}`);
919
- }
920
- if (commandResolutionPowerShellCommands.has(commandName)) {
921
- return risky(`PowerShell command-resolution mutation through ${commandName}`);
922
- }
923
- if (
924
- providerMutationPowerShellCommands.has(commandName) &&
925
- (containsCommandProviderPath(command.elements.slice(1).map((element) => element.text)) ||
926
- hasDynamicAstArguments(command.elements.slice(1)))
927
- ) {
928
- return risky(`PowerShell command-provider mutation through ${commandName}`);
929
- }
930
- if (isPowerShellScriptCommand(commandName)) {
931
- return risky("nested PowerShell script invocation requires confirmation");
932
- }
933
- if (commandName === "sudo" || commandName === "sudo.exe") return risky("sudo invocation");
934
-
935
- if (powerShellRemoveCommands.has(commandName)) {
936
- const removeAnalysis = analyzeAstRemoveInvocation(command);
937
- if (removeAnalysis.risky) return removeAnalysis;
938
- const tokens = command.elements.map((element) => ({ kind: "word" as const, text: element.text }));
939
- if (commandName === "rm" && hasNativeRecursiveForceFlags(tokens, 0)) {
940
- return risky("recursive forced rm invocation");
941
- }
942
- }
943
-
944
- if (commandName === "rm.exe") {
945
- const tokens = command.elements.map((element) => ({ kind: "word" as const, text: element.text }));
946
- if (hasNativeRecursiveForceFlags(tokens, 0)) return risky("recursive forced native rm invocation");
947
- }
948
-
949
- if (["chmod", "chmod.exe", "chown", "chown.exe"].includes(commandName)) {
950
- if (command.elements.slice(1).some((element) => /777/.test(element.text))) {
951
- return risky(`${commandName} 777 invocation`);
952
- }
953
- }
954
-
955
- return safe;
954
+ if (!command.name) return risky("dynamic PowerShell command invocation requires confirmation");
955
+ const commandName = powerShellCommandName(command.name);
956
+ if (dynamicPowerShellCommands.has(commandName) || shellPowerShellCommands.has(commandName)) {
957
+ return risky(`nested or dynamic PowerShell execution through ${commandName}`);
958
+ }
959
+ if (commandResolutionPowerShellCommands.has(commandName)) {
960
+ return risky(`PowerShell command-resolution mutation through ${commandName}`);
961
+ }
962
+ if (
963
+ providerMutationPowerShellCommands.has(commandName) &&
964
+ (containsCommandProviderPath(command.elements.slice(1).map((element) => element.text)) ||
965
+ hasDynamicAstArguments(command.elements.slice(1)))
966
+ ) {
967
+ return risky(`PowerShell command-provider mutation through ${commandName}`);
968
+ }
969
+ if (isPowerShellScriptCommand(commandName)) {
970
+ return risky("nested PowerShell script invocation requires confirmation");
971
+ }
972
+ if (commandName === "sudo" || commandName === "sudo.exe") return risky("sudo invocation");
973
+
974
+ if (powerShellRemoveCommands.has(commandName)) {
975
+ const removeAnalysis = analyzeAstRemoveInvocation(command);
976
+ if (removeAnalysis.risky) return removeAnalysis;
977
+ const tokens = command.elements.map((element) => ({
978
+ kind: "word" as const,
979
+ text: element.text,
980
+ }));
981
+ if (commandName === "rm" && hasNativeRecursiveForceFlags(tokens, 0)) {
982
+ return risky("recursive forced rm invocation");
983
+ }
984
+ }
985
+
986
+ if (commandName === "rm.exe") {
987
+ const tokens = command.elements.map((element) => ({
988
+ kind: "word" as const,
989
+ text: element.text,
990
+ }));
991
+ if (hasNativeRecursiveForceFlags(tokens, 0))
992
+ return risky("recursive forced native rm invocation");
993
+ }
994
+
995
+ if (["chmod", "chmod.exe", "chown", "chown.exe"].includes(commandName)) {
996
+ if (command.elements.slice(1).some((element) => /777/.test(element.text))) {
997
+ return risky(`${commandName} 777 invocation`);
998
+ }
999
+ }
1000
+
1001
+ return safe;
956
1002
  }
957
1003
 
958
1004
  export function analyzePowerShellAstPayload(payload: unknown): PowerShellSafetyAnalysis {
959
- if (!isRecord(payload)) return risky("malformed PowerShell AST analyzer response");
960
- if (Object.hasOwn(payload, "analyzerError")) {
961
- return typeof payload.analyzerError === "string"
962
- ? risky(`PowerShell AST analyzer failed: ${payload.analyzerError}`)
963
- : risky("malformed PowerShell AST analyzer response");
964
- }
965
- if (
966
- !Array.isArray(payload.parseErrors) ||
967
- !Array.isArray(payload.commands) ||
968
- !Number.isSafeInteger(payload.dynamicInvocationCount) ||
969
- (payload.dynamicInvocationCount as number) < 0 ||
970
- !Number.isSafeInteger(payload.functionDefinitionCount) ||
971
- (payload.functionDefinitionCount as number) < 0
972
- ) {
973
- return risky("malformed PowerShell AST analyzer response");
974
- }
975
- if (payload.parseErrors.some((error) => typeof error !== "string")) {
976
- return risky("malformed PowerShell AST parser errors");
977
- }
978
- if (payload.parseErrors.length > 0) return risky("PowerShell parser reported invalid syntax");
979
- if ((payload.dynamicInvocationCount as number) > 0) {
980
- return risky("dynamic PowerShell invocation requires confirmation");
981
- }
982
- if ((payload.functionDefinitionCount as number) > 0) {
983
- return risky("PowerShell function or filter definition changes command resolution");
984
- }
985
-
986
- const commands = payload.commands.map(parseAstCommand);
987
- if (commands.some((command) => !command)) return risky("malformed PowerShell AST command metadata");
988
- for (const command of commands as PowerShellAstCommand[]) {
989
- const analysis = analyzeAstCommand(command);
990
- if (analysis.risky) return analysis;
991
- }
992
- return safe;
1005
+ if (!isRecord(payload)) return risky("malformed PowerShell AST analyzer response");
1006
+ if (Object.hasOwn(payload, "analyzerError")) {
1007
+ return typeof payload.analyzerError === "string"
1008
+ ? risky(`PowerShell AST analyzer failed: ${payload.analyzerError}`)
1009
+ : risky("malformed PowerShell AST analyzer response");
1010
+ }
1011
+ if (
1012
+ !Array.isArray(payload.parseErrors) ||
1013
+ !Array.isArray(payload.commands) ||
1014
+ !Number.isSafeInteger(payload.dynamicInvocationCount) ||
1015
+ (payload.dynamicInvocationCount as number) < 0 ||
1016
+ !Number.isSafeInteger(payload.functionDefinitionCount) ||
1017
+ (payload.functionDefinitionCount as number) < 0
1018
+ ) {
1019
+ return risky("malformed PowerShell AST analyzer response");
1020
+ }
1021
+ if (payload.parseErrors.some((error) => typeof error !== "string")) {
1022
+ return risky("malformed PowerShell AST parser errors");
1023
+ }
1024
+ if (payload.parseErrors.length > 0) return risky("PowerShell parser reported invalid syntax");
1025
+ if ((payload.dynamicInvocationCount as number) > 0) {
1026
+ return risky("dynamic PowerShell invocation requires confirmation");
1027
+ }
1028
+ if ((payload.functionDefinitionCount as number) > 0) {
1029
+ return risky("PowerShell function or filter definition changes command resolution");
1030
+ }
1031
+
1032
+ const commands = payload.commands.map(parseAstCommand);
1033
+ if (commands.some((command) => !command))
1034
+ return risky("malformed PowerShell AST command metadata");
1035
+ for (const command of commands as PowerShellAstCommand[]) {
1036
+ const analysis = analyzeAstCommand(command);
1037
+ if (analysis.risky) return analysis;
1038
+ }
1039
+ return safe;
993
1040
  }
994
1041
 
995
1042
  export function parsePowerShellAstOutput(stdout: string): PowerShellSafetyAnalysis {
996
- const markerLine = stdout
997
- .split(/\r?\n/)
998
- .reverse()
999
- .find((line) => line.startsWith(POWERSHELL_AST_MARKER));
1000
- if (!markerLine) return risky("PowerShell AST analyzer produced no result");
1001
- try {
1002
- return analyzePowerShellAstPayload(JSON.parse(markerLine.slice(POWERSHELL_AST_MARKER.length)));
1003
- } catch {
1004
- return risky("PowerShell AST analyzer produced invalid JSON");
1005
- }
1043
+ const markerLine = stdout
1044
+ .split(/\r?\n/)
1045
+ .reverse()
1046
+ .find((line) => line.startsWith(POWERSHELL_AST_MARKER));
1047
+ if (!markerLine) return risky("PowerShell AST analyzer produced no result");
1048
+ try {
1049
+ return analyzePowerShellAstPayload(JSON.parse(markerLine.slice(POWERSHELL_AST_MARKER.length)));
1050
+ } catch {
1051
+ return risky("PowerShell AST analyzer produced invalid JSON");
1052
+ }
1006
1053
  }