@diegopetrucci/pi-extensions 0.1.65 → 0.1.66

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 (69) hide show
  1. package/.pi-fleet-tested-version +1 -1
  2. package/README.md +1 -1
  3. package/extensions/agent-workflow-audit/.pi-fleet-tested-version +1 -1
  4. package/extensions/agent-workflow-audit/package.json +1 -1
  5. package/extensions/annotate-git-diff/.pi-fleet-tested-version +1 -1
  6. package/extensions/annotate-git-diff/README.md +3 -0
  7. package/extensions/annotate-git-diff/index.ts +49 -5
  8. package/extensions/annotate-git-diff/package.json +2 -1
  9. package/extensions/annotate-git-diff/quiet-glimpse.ts +24 -3
  10. package/extensions/annotate-git-diff/review-server.ts +379 -0
  11. package/extensions/annotate-git-diff/ui.ts +17 -41
  12. package/extensions/annotate-last-message/.pi-fleet-tested-version +1 -1
  13. package/extensions/annotate-last-message/index.ts +28 -2
  14. package/extensions/annotate-last-message/package.json +1 -1
  15. package/extensions/annotate-last-message/quiet-glimpse.ts +24 -3
  16. package/extensions/brrr/.pi-fleet-tested-version +1 -1
  17. package/extensions/brrr/package.json +1 -1
  18. package/extensions/claude-fast/.pi-fleet-tested-version +1 -1
  19. package/extensions/claude-fast/package.json +1 -1
  20. package/extensions/code-reviewer/.pi-fleet-tested-version +1 -1
  21. package/extensions/code-reviewer/package.json +1 -1
  22. package/extensions/confirm-destructive/.pi-fleet-tested-version +1 -1
  23. package/extensions/confirm-destructive/package.json +1 -1
  24. package/extensions/context-cap/.pi-fleet-tested-version +1 -1
  25. package/extensions/context-cap/package.json +1 -1
  26. package/extensions/context-inspector/.pi-fleet-tested-version +1 -1
  27. package/extensions/context-inspector/package.json +1 -1
  28. package/extensions/contrarian/.pi-fleet-tested-version +1 -1
  29. package/extensions/contrarian/index.ts +46 -17
  30. package/extensions/contrarian/package.json +1 -1
  31. package/extensions/dirty-repo-guard/.pi-fleet-tested-version +1 -1
  32. package/extensions/dirty-repo-guard/package.json +1 -1
  33. package/extensions/dynamic-context-pruning/.pi-fleet-tested-version +1 -1
  34. package/extensions/dynamic-context-pruning/package.json +1 -1
  35. package/extensions/fast/.pi-fleet-tested-version +1 -1
  36. package/extensions/fast/package.json +1 -1
  37. package/extensions/git-footer/.pi-fleet-tested-version +1 -1
  38. package/extensions/git-footer/package.json +1 -1
  39. package/extensions/gnosis/.pi-fleet-tested-version +1 -1
  40. package/extensions/gnosis/package.json +1 -1
  41. package/extensions/illustrations-to-explain-things/.pi-fleet-tested-version +1 -1
  42. package/extensions/illustrations-to-explain-things/package.json +1 -1
  43. package/extensions/inline-bash/.pi-fleet-tested-version +1 -1
  44. package/extensions/inline-bash/package.json +1 -1
  45. package/extensions/librarian/.pi-fleet-tested-version +1 -1
  46. package/extensions/librarian/package.json +1 -1
  47. package/extensions/minimal-footer/.pi-fleet-tested-version +1 -1
  48. package/extensions/minimal-footer/package.json +1 -1
  49. package/extensions/notify/.pi-fleet-tested-version +1 -1
  50. package/extensions/notify/package.json +1 -1
  51. package/extensions/openai-fast/.pi-fleet-tested-version +1 -1
  52. package/extensions/openai-fast/package.json +1 -1
  53. package/extensions/oracle/.pi-fleet-tested-version +1 -1
  54. package/extensions/oracle/index.ts +46 -17
  55. package/extensions/oracle/package.json +1 -1
  56. package/extensions/permission-gate/.pi-fleet-tested-version +1 -1
  57. package/extensions/permission-gate/README.md +11 -3
  58. package/extensions/permission-gate/index.ts +70 -7
  59. package/extensions/permission-gate/package.json +4 -2
  60. package/extensions/permission-gate/powershell-safety.ts +1006 -0
  61. package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
  62. package/extensions/quiet-tools/package.json +1 -1
  63. package/extensions/review/.pi-fleet-tested-version +1 -1
  64. package/extensions/review/package.json +1 -1
  65. package/extensions/todo/.pi-fleet-tested-version +1 -1
  66. package/extensions/todo/package.json +1 -1
  67. package/extensions/triage-comments/.pi-fleet-tested-version +1 -1
  68. package/extensions/triage-comments/package.json +1 -1
  69. package/package.json +4 -4
@@ -0,0 +1,1006 @@
1
+ const powerShellRemoveCommands = new Set(["remove-item", "del", "erase", "rd", "ri", "rm", "rmdir"]);
2
+ 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",
21
+ ]);
22
+ const shellPowerShellCommands = new Set(["bash", "bash.exe", "sh", "sh.exe", "wsl", "wsl.exe"]);
23
+ 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",
40
+ ]);
41
+ const commandDefinitionPowerShellKeywords = new Set(["filter", "function", "workflow"]);
42
+ 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",
78
+ ]);
79
+
80
+ const MAX_LEXICAL_DEPTH = 32;
81
+ export const MAX_POWERSHELL_ANALYSIS_SOURCE_BYTES = 16_000;
82
+
83
+ export const POWERSHELL_AST_MARKER = "PI_PERMISSION_GATE_AST:";
84
+
85
+ /**
86
+ * Runs inside the same PowerShell installation used by Pi's built-in tool.
87
+ * It parses but never invokes the candidate command and returns only the
88
+ * bounded command metadata needed by the guard.
89
+ */
90
+ export const POWERSHELL_AST_SCRIPT = String.raw`
91
+ $ErrorActionPreference = 'Stop'
92
+ try {
93
+ try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}
94
+ if ([string]::IsNullOrWhiteSpace($encodedSource)) { throw 'missing encoded command' }
95
+ $source = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($encodedSource))
96
+ $parseTokens = $null
97
+ $parseErrors = $null
98
+ $ast = [System.Management.Automation.Language.Parser]::ParseInput($source, [ref]$parseTokens, [ref]$parseErrors)
99
+ $commands = @(
100
+ $ast.FindAll({
101
+ param($node)
102
+ $node -is [System.Management.Automation.Language.CommandAst]
103
+ }, $true) | ForEach-Object {
104
+ $commandAst = $_
105
+ $elements = @(
106
+ $commandAst.CommandElements | ForEach-Object {
107
+ $element = $_
108
+ $parameter = $null
109
+ $argument = $null
110
+ $splatted = $false
111
+ if ($element -is [System.Management.Automation.Language.CommandParameterAst]) {
112
+ $parameter = $element.ParameterName
113
+ if ($null -ne $element.Argument) { $argument = $element.Argument.Extent.Text }
114
+ }
115
+ if ($element -is [System.Management.Automation.Language.VariableExpressionAst]) {
116
+ $splatted = $element.Splatted
117
+ }
118
+ [pscustomobject]@{
119
+ type = $element.GetType().Name
120
+ text = $element.Extent.Text
121
+ parameter = $parameter
122
+ argument = $argument
123
+ splatted = [bool]$splatted
124
+ }
125
+ }
126
+ )
127
+ [pscustomobject]@{
128
+ name = $commandAst.GetCommandName()
129
+ invocationOperator = [string]$commandAst.InvocationOperator
130
+ elements = $elements
131
+ }
132
+ }
133
+ )
134
+ $dynamicInvocationCount = @(
135
+ $ast.FindAll({
136
+ param($node)
137
+ if ($node -is [System.Management.Automation.Language.UsingStatementAst]) {
138
+ return [string]$node.UsingStatementKind -eq 'Module'
139
+ }
140
+ if ($node -isnot [System.Management.Automation.Language.InvokeMemberExpressionAst]) {
141
+ return $false
142
+ }
143
+ if ($node.Member -isnot [System.Management.Automation.Language.StringConstantExpressionAst]) {
144
+ return $true
145
+ }
146
+ $memberName = [string]$node.Member.Value
147
+ if ($memberName -match '(?i)^(?:BeginInvoke|Create|CreateProcess\w*|EndInvoke|Exec|Execute|Invoke(?:ReturnAsIs|Script|WithContext)?|Run|ShellExecute\w*|Start|WinExec)$') {
148
+ return $true
149
+ }
150
+ $text = $node.Extent.Text
151
+ return (
152
+ $text -match '(?i)\.(?:BeginInvoke|Create|EndInvoke|Exec|Execute|Invoke(?:ReturnAsIs|Script|WithContext)?|Run|Start)\s*\(' -or
153
+ $text -match '(?i)\[\s*(?:System\.)?Diagnostics\.Process\s*\]\s*::\s*Start\s*\(' -or
154
+ $text -match '(?i)::\s*(?:CreateProcess\w*|ShellExecute\w*|WinExec)\s*\(' -or
155
+ $text -match '(?i)\[\s*(?:System\.)?(?:Management\.Automation\.)?ScriptBlock\s*\]\s*::\s*Create\s*\(' -or
156
+ $text -match '(?i)\[\s*(?:(?:System\.)?Management\.Automation\.)?PowerShell\s*\]\s*::\s*Create\s*\('
157
+ )
158
+ }, $true)
159
+ ).Count
160
+ $functionDefinitionCount = @(
161
+ $ast.FindAll({
162
+ param($node)
163
+ $node -is [System.Management.Automation.Language.FunctionDefinitionAst]
164
+ }, $true)
165
+ ).Count
166
+ $payload = [pscustomobject]@{
167
+ parseErrors = @($parseErrors | ForEach-Object { $_.Message })
168
+ commands = $commands
169
+ dynamicInvocationCount = $dynamicInvocationCount
170
+ functionDefinitionCount = $functionDefinitionCount
171
+ }
172
+ [Console]::Out.WriteLine('${POWERSHELL_AST_MARKER}' + ($payload | ConvertTo-Json -Depth 8 -Compress))
173
+ } catch {
174
+ $payload = [pscustomobject]@{ analyzerError = $_.Exception.Message }
175
+ [Console]::Out.WriteLine('${POWERSHELL_AST_MARKER}' + ($payload | ConvertTo-Json -Depth 4 -Compress))
176
+ exit 1
177
+ }
178
+ `;
179
+
180
+ export function buildPowerShellAstCommand(command: string): string {
181
+ const encodedCommand = Buffer.from(command, "utf8").toString("base64");
182
+ return `$encodedSource = '${encodedCommand}'\n${POWERSHELL_AST_SCRIPT}`;
183
+ }
184
+
185
+ export type PowerShellSafetyAnalysis = {
186
+ risky: boolean;
187
+ reason?: string;
188
+ };
189
+
190
+ type PowerShellLexToken = {
191
+ kind: "word" | "string" | "operator" | "separator" | "nested";
192
+ text: string;
193
+ dynamic?: boolean;
194
+ nestedSources?: string[];
195
+ };
196
+
197
+ type PowerShellLexResult = {
198
+ tokens: PowerShellLexToken[];
199
+ malformed: boolean;
200
+ };
201
+
202
+ type PowerShellAstElement = {
203
+ type: string;
204
+ text: string;
205
+ parameter: string | null;
206
+ argument: string | null;
207
+ splatted: boolean;
208
+ };
209
+
210
+ type PowerShellAstCommand = {
211
+ name: string | null;
212
+ invocationOperator: string;
213
+ elements: PowerShellAstElement[];
214
+ };
215
+
216
+ function risky(reason: string): PowerShellSafetyAnalysis {
217
+ return { risky: true, reason };
218
+ }
219
+
220
+ const safe: PowerShellSafetyAnalysis = { risky: false };
221
+
222
+ function isRecord(value: unknown): value is Record<string, unknown> {
223
+ return !!value && typeof value === "object" && !Array.isArray(value);
224
+ }
225
+
226
+ function powerShellCommandName(rawName: string): string {
227
+ const basename = rawName.trim().split(/[\\/]/).at(-1) ?? rawName;
228
+ return basename.toLowerCase();
229
+ }
230
+
231
+ function isPowerShellScriptCommand(commandName: string): boolean {
232
+ return /\.(?:ps1|psm1)$/i.test(commandName);
233
+ }
234
+
235
+ 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;
240
+ }
241
+
242
+ 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
+ );
249
+ }
250
+
251
+ function hasDynamicAstArguments(elements: PowerShellAstElement[]): boolean {
252
+ return elements.some(
253
+ (element) =>
254
+ element.splatted ||
255
+ !["CommandParameterAst", "ConstantExpressionAst", "StringConstantExpressionAst"].includes(element.type),
256
+ );
257
+ }
258
+
259
+ 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
+ );
274
+ }
275
+
276
+ 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;
299
+ }
300
+
301
+ function isFalseLiteral(value: string | null | undefined): boolean {
302
+ return /^(?:\$?false|0)$/i.test(value?.trim() ?? "");
303
+ }
304
+
305
+ function isTrueLiteral(value: string | null | undefined): boolean {
306
+ return /^(?:\$?true|1)$/i.test(value?.trim() ?? "");
307
+ }
308
+
309
+ 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);
318
+ }
319
+
320
+ 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) };
325
+ }
326
+
327
+ function isPotentiallyEnabledSwitch(
328
+ name: string,
329
+ value: string | null | undefined,
330
+ kind: "recurse" | "force",
331
+ ): boolean {
332
+ return parameterMatches(name, kind) && !isFalseLiteral(value);
333
+ }
334
+
335
+ function isDefinitelyEnabledWhatIf(name: string, value: string | null | undefined): boolean {
336
+ if (!parameterMatches(name, "whatIf")) return false;
337
+ return value === undefined || value === null || isTrueLiteral(value);
338
+ }
339
+
340
+ 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;
407
+ }
408
+
409
+ function readQuotedToken(
410
+ source: string,
411
+ startIndex: number,
412
+ quote: "'" | '"',
413
+ ): { 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
+ };
481
+ }
482
+
483
+ function readHereStringToken(
484
+ source: string,
485
+ startIndex: number,
486
+ quote: "'" | '"',
487
+ ): { 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
+ };
546
+ }
547
+
548
+ 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 };
735
+ }
736
+
737
+ 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;
775
+ }
776
+
777
+ 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;
837
+ }
838
+
839
+ 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);
865
+ }
866
+
867
+ 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
+ };
881
+ }
882
+
883
+ 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
+ };
893
+ }
894
+
895
+ 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;
912
+ }
913
+
914
+ 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;
956
+ }
957
+
958
+ 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;
993
+ }
994
+
995
+ 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
+ }
1006
+ }