specpi 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/LICENSE +21 -0
  3. package/NPM_RELEASE.md +110 -0
  4. package/README.md +155 -0
  5. package/SECURITY.md +85 -0
  6. package/SECURITY_MODEL.md +107 -0
  7. package/THIRD_PARTY.md +61 -0
  8. package/browser-runtime/package-lock.json +86 -0
  9. package/browser-runtime/package.json +15 -0
  10. package/extensions/browser/core.mjs +306 -0
  11. package/extensions/browser/index.ts +723 -0
  12. package/extensions/browser/smoke.mjs +47 -0
  13. package/extensions/command-guard/bash.mjs +1426 -0
  14. package/extensions/command-guard/cmd.mjs +369 -0
  15. package/extensions/command-guard/core.mjs +506 -0
  16. package/extensions/command-guard/index.ts +634 -0
  17. package/extensions/command-guard/managed-files.mjs +22 -0
  18. package/extensions/command-guard/paths.mjs +398 -0
  19. package/extensions/command-guard/powershell-parser.ps1 +47 -0
  20. package/extensions/command-guard/powershell.mjs +655 -0
  21. package/extensions/command-guard/redact.mjs +65 -0
  22. package/extensions/command-guard/rules.mjs +2557 -0
  23. package/extensions/command-guard/smoke.mjs +422 -0
  24. package/extensions/files/core.mjs +422 -0
  25. package/extensions/files/index.ts +678 -0
  26. package/extensions/spec/core.mjs +47 -0
  27. package/extensions/spec.ts +457 -0
  28. package/extensions/tool-wishlist/capabilities.json +114 -0
  29. package/extensions/tool-wishlist/core.mjs +1525 -0
  30. package/extensions/tool-wishlist/index.ts +804 -0
  31. package/extensions/tool-wishlist/registry.mjs +99 -0
  32. package/extensions/tool-wishlist/validators.mjs +345 -0
  33. package/extensions/ui-refresh/index.ts +54 -0
  34. package/extensions/workflow-controls/challenge.mjs +196 -0
  35. package/extensions/workflow-controls/experiments.mjs +628 -0
  36. package/extensions/workflow-controls/index.ts +1144 -0
  37. package/extensions/workflow-controls/scope.mjs +272 -0
  38. package/extensions/workflow-controls/smoke.mjs +201 -0
  39. package/package.json +98 -0
  40. package/scripts/check-package.mjs +483 -0
  41. package/scripts/check-pi-package.mjs +223 -0
  42. package/scripts/check-release-order.mjs +97 -0
  43. package/scripts/lib.mjs +182 -0
  44. package/scripts/lock.mjs +122 -0
  45. package/scripts/specpi.mjs +2037 -0
  46. package/scripts/verify-artifact.mjs +21 -0
  47. package/shell/pi-profiles.sh +14 -0
  48. package/site/logo.svg +9 -0
  49. package/site/self-improvement-loop-v2.svg +108 -0
  50. package/skills/donsetch/SKILL.md +76 -0
  51. package/skills/specpi-improve/SKILL.md +54 -0
  52. package/specpi +4 -0
  53. package/specpi.cmd +4 -0
  54. package/templates/AGENTS.md +23 -0
  55. package/templates/settings.json +10 -0
  56. package/themes/specpi-spec.json +96 -0
  57. package/themes/tea-house.json +89 -0
@@ -0,0 +1,655 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { spawnSync } from "node:child_process";
5
+ import { redactCommand } from "./redact.mjs";
6
+ import { analyze as analyzeCmd, literalCmdPayload } from "./cmd.mjs";
7
+
8
+ export const POWERSHELL_LIMITS = Object.freeze({
9
+ maxInput: 128 * 1024,
10
+ maxCommands: 128,
11
+ // A healthy parse is ~400ms warm and ~1.4s cold on a CI runner, so this is generous headroom. It is kept
12
+ // small deliberately: every timed-out parse pays the bound in full, and a session makes many of them.
13
+ timeoutMs: 5000,
14
+ // Interpreter startup is paid once per process, not per analysis, so only the first spawn gets this budget.
15
+ coldStartTimeoutMs: 10000,
16
+ maxOutput: 256 * 1024,
17
+ maxDepth: 8,
18
+ });
19
+ let parserWarmed = false;
20
+ const aliasMap = new Map(
21
+ Object.entries({
22
+ ac: "add-content",
23
+ cat: "get-content",
24
+ clc: "clear-content",
25
+ cli: "clear-item",
26
+ clp: "clear-itemproperty",
27
+ copy: "copy-item",
28
+ cp: "copy-item",
29
+ cpi: "copy-item",
30
+ cpp: "copy-itemproperty",
31
+ del: "remove-item",
32
+ epal: "export-alias",
33
+ epcsv: "export-csv",
34
+ epsn: "export-pssession",
35
+ erase: "remove-item",
36
+ gc: "get-content",
37
+ gci: "get-childitem",
38
+ gi: "get-item",
39
+ gps: "get-process",
40
+ iex: "invoke-expression",
41
+ irm: "invoke-restmethod",
42
+ iwr: "invoke-webrequest",
43
+ kill: "stop-process",
44
+ mi: "move-item",
45
+ move: "move-item",
46
+ mp: "move-itemproperty",
47
+ mv: "move-item",
48
+ nal: "new-alias",
49
+ ni: "new-item",
50
+ np: "new-itemproperty",
51
+ rd: "remove-item",
52
+ ren: "rename-item",
53
+ ri: "remove-item",
54
+ rm: "remove-item",
55
+ rmdir: "remove-item",
56
+ rni: "rename-item",
57
+ rnp: "rename-itemproperty",
58
+ rp: "remove-itemproperty",
59
+ sal: "set-alias",
60
+ saps: "start-process",
61
+ sc: "set-content",
62
+ si: "set-item",
63
+ sp: "set-itemproperty",
64
+ spps: "stop-process",
65
+ spsv: "stop-service",
66
+ start: "start-process",
67
+ tee: "tee-object",
68
+ type: "get-content",
69
+ wget: "invoke-webrequest",
70
+ curl: "invoke-webrequest",
71
+ }),
72
+ );
73
+ const canonicalName = (value) => {
74
+ const raw = String(value || "");
75
+ const lower = raw.toLowerCase();
76
+
77
+ return lower.endsWith(".exe") ? raw : aliasMap.get(lower) || raw;
78
+ };
79
+
80
+ function unavailable(reason) {
81
+ return {
82
+ shell: "powershell",
83
+ leaves: [],
84
+ redirects: [],
85
+ dynamicConstructs: [{ kind: reason }],
86
+ parseErrors: [{ message: reason }],
87
+ indeterminate: true,
88
+ };
89
+ }
90
+
91
+ // PowerShell accepts any unambiguous prefix of a parameter name, so -enc runs the same code as
92
+ // -EncodedCommand. Every consumer must match the whole prefix range or a shorter spelling walks past it.
93
+ export const ENCODED_COMMAND_FLAG =
94
+ /^-(?:e|ec|en|enc|enco|encod|encode|encoded|encodedc|encodedco|encodedcom|encodedcomm|encodedcomma|encodedcomman|encodedcommand)$/i;
95
+ export const COMMAND_FLAG = /^-(?:c|co|com|comm|comma|comman|command)$/i;
96
+ export const HOST_NAMES = Object.freeze(["powershell", "pwsh"]);
97
+ export function decodeEncodedCommand(value, maxInput = POWERSHELL_LIMITS.maxInput) {
98
+ const encoded = typeof value === "string" ? value : "";
99
+ if (!encoded || encoded.includes("[dynamic]") || encoded.length > 65536 || !/^[A-Za-z0-9+/=]+$/.test(encoded)) {
100
+ return undefined;
101
+ }
102
+
103
+ try {
104
+ const decoded = Buffer.from(encoded, "base64");
105
+ if (!decoded.length || decoded.length > maxInput || decoded.length % 2 !== 0) {
106
+ return undefined;
107
+ }
108
+
109
+ return decoded.toString("utf16le");
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ function validate(data, limits) {
116
+ if (
117
+ !data ||
118
+ data.schema !== 1 ||
119
+ typeof data.ok !== "boolean" ||
120
+ !data.parser ||
121
+ typeof data.parser.version !== "string" ||
122
+ !Array.isArray(data.commands) ||
123
+ !Array.isArray(data.errors)
124
+ ) {
125
+ return undefined;
126
+ }
127
+
128
+ if (
129
+ !Array.isArray(data.dynamicConstructs) ||
130
+ !Array.isArray(data.stopParsingTokens) ||
131
+ data.commands.length > limits.maxCommands ||
132
+ data.errors.length > 32 ||
133
+ data.dynamicConstructs.length > 256 ||
134
+ data.stopParsingTokens.length > 128
135
+ ) {
136
+ return undefined;
137
+ }
138
+
139
+ if (
140
+ data.limitExceeded !== undefined &&
141
+ !["tokens", "commands", "elements", "redirections"].includes(data.limitExceeded)
142
+ ) {
143
+ return undefined;
144
+ }
145
+
146
+ if (
147
+ !data.commands.every(
148
+ (command) =>
149
+ command &&
150
+ typeof command === "object" &&
151
+ (typeof command.commandName === "string" || command.commandName === null) &&
152
+ Array.isArray(command.elements) &&
153
+ command.elements.length <= 256 &&
154
+ command.elements.every(
155
+ (element) =>
156
+ element &&
157
+ (element.literal === null ||
158
+ element.literal === undefined ||
159
+ (typeof element.literal === "string" &&
160
+ Buffer.byteLength(element.literal, "utf8") <= 65536)) &&
161
+ (element.raw === undefined ||
162
+ (typeof element.raw === "string" && Buffer.byteLength(element.raw, "utf8") <= 65536)),
163
+ ) &&
164
+ Array.isArray(command.redirections) &&
165
+ command.redirections.length <= 128,
166
+ )
167
+ ) {
168
+ return undefined;
169
+ }
170
+
171
+ return data;
172
+ }
173
+
174
+ function parameterValue(args, name) {
175
+ for (let index = 0; index < args.length; index += 1) {
176
+ const value = String(args[index]),
177
+ lower = value.toLowerCase(),
178
+ wanted = `-${name}`;
179
+ if (lower === wanted || (wanted.startsWith(lower) && /^-[a-z]+$/i.test(lower))) {
180
+ return args[index + 1];
181
+ }
182
+
183
+ if (lower.startsWith(`${wanted}:`)) {
184
+ return value.slice(value.indexOf(":") + 1);
185
+ }
186
+ }
187
+
188
+ return undefined;
189
+ }
190
+
191
+ function aliasPositionalArguments(args, dynamicConstructs) {
192
+ const valueParameters = [
193
+ "name",
194
+ "value",
195
+ "description",
196
+ "option",
197
+ "scope",
198
+ "erroraction",
199
+ "errorvariable",
200
+ "warningaction",
201
+ "warningvariable",
202
+ "informationaction",
203
+ "informationvariable",
204
+ "outvariable",
205
+ "outbuffer",
206
+ "pipelinevariable",
207
+ ];
208
+ const switchParameters = ["force", "passthru", "verbose", "debug", "whatif", "confirm"];
209
+ const positional = [];
210
+ for (let index = 0; index < args.length; index += 1) {
211
+ const value = String(args[index]);
212
+ if (!value.startsWith("-")) {
213
+ positional.push(value);
214
+ continue;
215
+ }
216
+
217
+ const key = value.slice(1).split(":", 1)[0].toLowerCase();
218
+ const valueMatches = valueParameters.filter((name) => name.startsWith(key));
219
+ const switchMatches = switchParameters.filter((name) => name.startsWith(key));
220
+ if (valueMatches.length === 1 && switchMatches.length === 0) {
221
+ if (!value.includes(":")) {
222
+ index += 1;
223
+ }
224
+
225
+ continue;
226
+ }
227
+
228
+ if (switchMatches.length === 1 && valueMatches.length === 0) {
229
+ continue;
230
+ }
231
+
232
+ dynamicConstructs.push({ kind: "dynamic-alias-definition" });
233
+ }
234
+
235
+ return positional;
236
+ }
237
+
238
+ function resolveLiteralAliases(leaves, dynamicConstructs) {
239
+ const local = new Map(),
240
+ alternatives = [];
241
+ for (const leaf of leaves) {
242
+ const encountered = String(leaf.executable || "").toLowerCase();
243
+ const meanings = local.get(encountered);
244
+ if (meanings?.size) {
245
+ const values = [...meanings];
246
+ leaf.executable = values[0];
247
+ for (const executable of values.slice(1)) {
248
+ alternatives.push({ ...leaf, executable, args: [...leaf.args], redirections: [...leaf.redirections] });
249
+ }
250
+ }
251
+
252
+ const name = canonicalName(leaf.executable).toLowerCase();
253
+ if (name === "import-alias") {
254
+ dynamicConstructs.push({ kind: "dynamic-alias-definition" });
255
+ continue;
256
+ }
257
+
258
+ let aliasName, targetName;
259
+ if (name === "set-alias" || name === "new-alias") {
260
+ const positional = aliasPositionalArguments(leaf.args, dynamicConstructs);
261
+ aliasName = parameterValue(leaf.args, "name") || positional[0];
262
+ targetName = parameterValue(leaf.args, "value") || positional[1];
263
+ } else if (
264
+ (name === "set-item" || name === "new-item") &&
265
+ leaf.args.some((arg) => /^alias:/i.test(String(arg)))
266
+ ) {
267
+ const aliasPath =
268
+ parameterValue(leaf.args, "path") ||
269
+ parameterValue(leaf.args, "literalpath") ||
270
+ leaf.args.find((arg) => /^alias:/i.test(String(arg)));
271
+ aliasName = String(aliasPath || "").replace(/^alias:/i, "");
272
+ targetName =
273
+ parameterValue(leaf.args, "value") ||
274
+ leaf.args.filter((arg) => !String(arg).startsWith("-") && !/^alias:/i.test(String(arg))).at(-1);
275
+ }
276
+
277
+ if (aliasName || targetName) {
278
+ if (
279
+ typeof aliasName !== "string" ||
280
+ typeof targetName !== "string" ||
281
+ /\[dynamic\]|[$`]/.test(`${aliasName}${targetName}`) ||
282
+ !/^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/.test(aliasName) ||
283
+ !/^[A-Za-z_][A-Za-z0-9_.-]{0,127}$/.test(targetName)
284
+ ) {
285
+ dynamicConstructs.push({ kind: "dynamic-alias-definition" });
286
+ } else {
287
+ const key = aliasName.toLowerCase(),
288
+ targets = local.get(targetName.toLowerCase()) || new Set([canonicalName(targetName)]);
289
+ const accumulated = local.get(key) || new Set();
290
+ for (const target of targets) {
291
+ accumulated.add(target);
292
+ }
293
+
294
+ local.set(key, accumulated);
295
+ }
296
+ }
297
+ }
298
+
299
+ leaves.push(...alternatives);
300
+ }
301
+
302
+ function cmdEquivalentArg(element, cooked) {
303
+ const raw = typeof element?.raw === "string" ? element.raw : cooked;
304
+ if (typeof cooked !== "string" || cooked === "[dynamic]") {
305
+ return cooked;
306
+ }
307
+
308
+ if (cooked.includes('"')) {
309
+ return "[dynamic]";
310
+ }
311
+
312
+ if (/^["'].*["']$/s.test(raw)) {
313
+ return `"${cooked}"`;
314
+ }
315
+
316
+ const attached = /^\/(c|k)(["'])(.*)\2$/is.exec(raw);
317
+ if (attached && /^\/(?:c|k)/i.test(cooked)) {
318
+ return `${cooked.slice(0, 2)}"${cooked.slice(2)}"`;
319
+ }
320
+
321
+ return raw;
322
+ }
323
+
324
+ function map(data) {
325
+ const leaves = data.commands.map((command) => {
326
+ const name = typeof command.commandName === "string" ? canonicalName(command.commandName) : "";
327
+ const args = Array.isArray(command.elements)
328
+ ? command.elements
329
+ .slice(1)
330
+ .map((element) => (typeof element.literal === "string" ? element.literal : "[dynamic]"))
331
+ : [];
332
+ const rawArgs = Array.isArray(command.elements)
333
+ ? command.elements.slice(1).map((element, index) => cmdEquivalentArg(element, args[index]))
334
+ : [];
335
+
336
+ return {
337
+ shell: "powershell",
338
+ executable: name || "<dynamic>",
339
+ operation: args.join(" "),
340
+ args,
341
+ rawArgs,
342
+ redactedTarget: redactCommand(args.join(" ")),
343
+ dynamic: !name || args.includes("[dynamic]"),
344
+ pipelineGroup: Number.isInteger(command.pipelineStart) ? command.pipelineStart : undefined,
345
+ redirections: command.redirections || [],
346
+ };
347
+ });
348
+ const localDynamicConstructs = [...data.dynamicConstructs];
349
+ resolveLiteralAliases(leaves, localDynamicConstructs);
350
+ const literalTruncated = data.commands.some(
351
+ (command) =>
352
+ command.elements.some((element) => element?.literalTruncated === true || element?.rawTruncated === true) ||
353
+ command.redirections.some((redirection) => redirection?.targetTruncated === true),
354
+ );
355
+ const limitConstructs = [
356
+ ...(typeof data.limitExceeded === "string" ? [{ kind: `${data.limitExceeded.replace(/s$/, "")}-limit` }] : []),
357
+ ...(literalTruncated ? [{ kind: "literal-limit" }] : []),
358
+ ];
359
+ const stopParsing =
360
+ data.stopParsingTokens.length > 0 || leaves.some((leaf) => leaf.args.some((arg) => arg === "--%"));
361
+ if (stopParsing && !localDynamicConstructs.some((entry) => entry.kind === "stop-parsing")) {
362
+ localDynamicConstructs.push({ kind: "stop-parsing" });
363
+ }
364
+
365
+ return {
366
+ shell: "powershell",
367
+ leaves,
368
+ redirects: leaves.flatMap((leaf) => leaf.redirections),
369
+ dynamicConstructs: [...localDynamicConstructs, ...limitConstructs],
370
+ parseErrors: data.errors,
371
+ indeterminate:
372
+ !data.ok ||
373
+ data.errors.length > 0 ||
374
+ localDynamicConstructs.length > 0 ||
375
+ limitConstructs.length > 0 ||
376
+ stopParsing ||
377
+ leaves.some((leaf) => leaf.dynamic),
378
+ };
379
+ }
380
+
381
+ function attachNested(parsed, options, limits) {
382
+ const depth = options.depth || 0;
383
+ for (const leaf of parsed.leaves) {
384
+ const name = canonicalName(leaf.executable)
385
+ .toLowerCase()
386
+ .replace(/\.exe$/, "")
387
+ .split(/[\\/]/)
388
+ .pop();
389
+ if (name === "invoke-expression") {
390
+ const payload = leaf.args.join(" ");
391
+ if (!payload || payload.includes("[dynamic]") || depth >= limits.maxDepth) {
392
+ parsed.dynamicConstructs.push({ kind: "dynamic-invoke-expression" });
393
+ parsed.indeterminate = true;
394
+ } else {
395
+ leaf.nested = analyze(payload, { ...options, depth: depth + 1 });
396
+ parsed.dynamicConstructs.push({ kind: "nested-invoke-expression" }, ...leaf.nested.dynamicConstructs);
397
+ parsed.indeterminate ||= leaf.nested.indeterminate;
398
+ }
399
+ }
400
+
401
+ if (name === "start-process") {
402
+ const argumentList = leaf.args.findIndex((arg) => /^-(?:argumentlist|args)$/i.test(arg));
403
+ const executable = leaf.args.find(
404
+ (arg, index) => index !== argumentList && index !== argumentList + 1 && !arg.startsWith("-"),
405
+ );
406
+ const payload = argumentList >= 0 ? leaf.args.slice(argumentList + 1).join(" ") : "";
407
+ if (!executable || !payload || payload.includes("[dynamic]") || depth >= limits.maxDepth) {
408
+ parsed.dynamicConstructs.push({ kind: "dynamic-start-process" });
409
+ parsed.indeterminate = true;
410
+ } else {
411
+ leaf.nested = analyze(`${executable} ${payload}`, { ...options, depth: depth + 1 });
412
+ parsed.dynamicConstructs.push({ kind: "nested-start-process" }, ...leaf.nested.dynamicConstructs);
413
+ parsed.indeterminate ||= leaf.nested.indeterminate;
414
+ }
415
+ }
416
+
417
+ if (name === "cmd") {
418
+ const payload = literalCmdPayload(leaf.args, leaf.rawArgs);
419
+ if (!payload || payload.includes("[dynamic]") || depth >= limits.maxDepth) {
420
+ parsed.dynamicConstructs.push({ kind: "dynamic-nested-cmd" });
421
+ parsed.indeterminate = true;
422
+ } else {
423
+ leaf.nested = analyzeCmd(payload, { ...options, depth: depth + 1 });
424
+ parsed.dynamicConstructs.push({ kind: "nested-cmd" }, ...leaf.nested.dynamicConstructs);
425
+ parsed.indeterminate ||= leaf.nested.indeterminate;
426
+ }
427
+ }
428
+
429
+ if (HOST_NAMES.includes(name)) {
430
+ const commandFlag = leaf.args.findIndex((arg) => COMMAND_FLAG.test(String(arg)));
431
+ const encodedFlag = leaf.args.findIndex((arg) => ENCODED_COMMAND_FLAG.test(String(arg)));
432
+ if (encodedFlag >= 0) {
433
+ const decoded =
434
+ depth >= limits.maxDepth
435
+ ? undefined
436
+ : decodeEncodedCommand(leaf.args[encodedFlag + 1], limits.maxInput);
437
+ if (!decoded) {
438
+ parsed.dynamicConstructs.push({ kind: "dynamic-encoded-command" });
439
+ parsed.parseErrors.push({ message: "encoded-command-unavailable" });
440
+ parsed.indeterminate = true;
441
+ } else {
442
+ leaf.nested = analyze(decoded, { ...options, depth: depth + 1 });
443
+ parsed.dynamicConstructs.push({ kind: "encoded-command" }, ...leaf.nested.dynamicConstructs);
444
+ parsed.indeterminate ||= leaf.nested.indeterminate;
445
+ }
446
+ } else if (commandFlag >= 0) {
447
+ const payload = leaf.args.slice(commandFlag + 1).join(" ");
448
+ if (!payload || payload.includes("[dynamic]") || depth >= limits.maxDepth) {
449
+ parsed.dynamicConstructs.push({ kind: "dynamic-nested-powershell" });
450
+ parsed.indeterminate = true;
451
+ } else {
452
+ leaf.nested = analyze(payload, { ...options, depth: depth + 1 });
453
+ parsed.dynamicConstructs.push({ kind: "nested-powershell" }, ...leaf.nested.dynamicConstructs);
454
+ parsed.indeterminate ||= leaf.nested.indeterminate;
455
+ }
456
+ }
457
+ }
458
+ }
459
+
460
+ return parsed;
461
+ }
462
+
463
+ export function parsePowerShellResult(stdout, limits = POWERSHELL_LIMITS) {
464
+ if (typeof stdout !== "string" || stdout.length > limits.maxOutput) {
465
+ return unavailable("output-limit");
466
+ }
467
+
468
+ let data;
469
+ try {
470
+ data = JSON.parse(stdout);
471
+ } catch {
472
+ return unavailable("invalid-helper-json");
473
+ }
474
+
475
+ const valid = validate(data, limits);
476
+ if (!valid) {
477
+ return unavailable("invalid-helper-result");
478
+ }
479
+
480
+ return map(valid);
481
+ }
482
+
483
+ const INFRASTRUCTURE_FAILURES = new Set([
484
+ "helper-unavailable",
485
+ "helper-failure",
486
+ "helper-timeout",
487
+ "invalid-helper-json",
488
+ "invalid-helper-result",
489
+ "input-limit",
490
+ "output-limit",
491
+ ]);
492
+ function infrastructureFailure(result) {
493
+ return (result.dynamicConstructs || []).some((entry) => INFRASTRUCTURE_FAILURES.has(entry.kind));
494
+ }
495
+
496
+ export function parserHosts(options = {}) {
497
+ if (options.executable) {
498
+ return [String(options.executable)];
499
+ }
500
+
501
+ if (process.platform !== "win32") {
502
+ return [];
503
+ }
504
+
505
+ const windowsPowerShell = path.join(
506
+ process.env.SystemRoot || "C:\\Windows",
507
+ "System32",
508
+ "WindowsPowerShell",
509
+ "v1.0",
510
+ "powershell.exe",
511
+ );
512
+ // Fixed installer-owned locations only: resolving pwsh.exe through PATH would let a planted binary answer for the guard.
513
+ const powerShell7 = [process.env.ProgramFiles, process.env["ProgramFiles(x86)"]]
514
+ .filter((root) => typeof root === "string" && root)
515
+ .map((root) => path.join(root, "PowerShell", "7", "pwsh.exe"));
516
+ const ordered =
517
+ String(options.shell || "").toLowerCase() === "pwsh"
518
+ ? [...powerShell7, windowsPowerShell]
519
+ : [windowsPowerShell, ...powerShell7];
520
+
521
+ return [...new Set(ordered)];
522
+ }
523
+
524
+ // A clean parse always wins; otherwise keep a real grammar rejection over a spawn failure so
525
+ // infrastructure noise cannot escalate an ordinary syntax error into a critical parser-integrity denial.
526
+ export function preferParserResult(previous, candidate) {
527
+ if (!(candidate.parseErrors || []).length || !previous) {
528
+ return candidate;
529
+ }
530
+
531
+ return infrastructureFailure(previous) && !infrastructureFailure(candidate) ? candidate : previous;
532
+ }
533
+
534
+ // The helper runs with a reduced environment so no credential-bearing variable reaches it. The allowlist below
535
+ // is the set Windows PowerShell 5.1 needs in order to START: measured on a Windows Server 2025 runner, omitting
536
+ // PSModulePath/APPDATA made every 5.1 spawn hang until it was killed — zero output, no error — while the same
537
+ // spawn with these present completed in ~380ms. PowerShell 7 was unaffected either way. None of these carry
538
+ // secrets; tokens, keys and profile-specific credentials are still withheld.
539
+ const PARSER_ENVIRONMENT = [
540
+ "SystemRoot",
541
+ "SystemDrive",
542
+ "windir",
543
+ "PATH",
544
+ "PATHEXT",
545
+ "TEMP",
546
+ "TMP",
547
+ "ComSpec",
548
+ "PSModulePath",
549
+ "APPDATA",
550
+ "LOCALAPPDATA",
551
+ "USERPROFILE",
552
+ "HOMEDRIVE",
553
+ "HOMEPATH",
554
+ "ProgramFiles",
555
+ "ProgramFiles(x86)",
556
+ "ProgramData",
557
+ "NUMBER_OF_PROCESSORS",
558
+ "PROCESSOR_ARCHITECTURE",
559
+ ];
560
+ function runParser(executable, helper, input, limits) {
561
+ const env = { SystemRoot: process.env.SystemRoot || "C:\\Windows", PATH: process.env.PATH || "", TEMP: "" };
562
+ for (const name of PARSER_ENVIRONMENT) {
563
+ if (typeof process.env[name] === "string") {
564
+ env[name] = process.env[name];
565
+ }
566
+ }
567
+
568
+ const timeout = parserWarmed
569
+ ? limits.timeoutMs
570
+ : Math.max(limits.timeoutMs, limits.coldStartTimeoutMs || limits.timeoutMs);
571
+ parserWarmed = true;
572
+ const result = spawnSync(executable, ["-NoLogo", "-NoProfile", "-NonInteractive", "-File", helper], {
573
+ input,
574
+ encoding: "utf8",
575
+ timeout,
576
+ maxBuffer: limits.maxOutput,
577
+ shell: false,
578
+ windowsHide: true,
579
+ env,
580
+ });
581
+ if (result.error) {
582
+ return unavailable(result.error.code === "ETIMEDOUT" ? "helper-timeout" : "helper-failure");
583
+ }
584
+
585
+ if (result.status !== 0 || typeof result.stdout !== "string" || result.stderr) {
586
+ return unavailable("helper-failure");
587
+ }
588
+
589
+ return parsePowerShellResult(result.stdout, limits);
590
+ }
591
+
592
+ export function analyze(input, options = {}) {
593
+ const limits = { ...POWERSHELL_LIMITS, ...options };
594
+ if (typeof input !== "string" || Buffer.byteLength(input, "utf8") > limits.maxInput) {
595
+ return unavailable("input-limit");
596
+ }
597
+
598
+ const helper =
599
+ options.helperPath || path.join(path.dirname(fileURLToPath(import.meta.url)), "powershell-parser.ps1");
600
+ if (!fs.existsSync(helper)) {
601
+ return unavailable("helper-unavailable");
602
+ }
603
+
604
+ const hosts = parserHosts(options).filter((executable) => path.isAbsolute(executable) && fs.existsSync(executable));
605
+ if (!hosts.length) {
606
+ return unavailable("helper-unavailable");
607
+ }
608
+
609
+ // Windows PowerShell 5.1 rejects PowerShell 7 grammar (&&, ??, ?:) that a co-installed pwsh executes happily.
610
+ // Text any installed host accepts can really run, so a rejection is authoritative only when every host rejects it.
611
+ let chosen;
612
+ for (const executable of hosts) {
613
+ chosen = preferParserResult(chosen, runParser(executable, helper, input, limits));
614
+ if (!(chosen.parseErrors || []).length) {
615
+ break;
616
+ }
617
+ }
618
+
619
+ return attachNested(chosen, options, limits);
620
+ }
621
+
622
+ // Analyzes the inline payload of a powershell/pwsh invocation seen from another shell's parser, so an
623
+ // encoded or -Command payload is classified rather than passed through as an opaque argument.
624
+ // Returns undefined when the invocation carries no inline payload.
625
+ export function analyzeHostArguments(args, options = {}) {
626
+ const limits = { ...POWERSHELL_LIMITS, ...options };
627
+ const list = (args || []).map((arg) => String(arg));
628
+ const encodedIndex = list.findIndex((arg) => ENCODED_COMMAND_FLAG.test(arg));
629
+ const commandIndex = list.findIndex((arg) => COMMAND_FLAG.test(arg));
630
+ if (encodedIndex < 0 && commandIndex < 0) {
631
+ return undefined;
632
+ }
633
+
634
+ const depth = options.depth || 0;
635
+ const payload =
636
+ encodedIndex >= 0
637
+ ? decodeEncodedCommand(list[encodedIndex + 1], limits.maxInput)
638
+ : list.slice(commandIndex + 1).join(" ");
639
+ if (!payload || payload.includes("[dynamic]") || /[$`]/.test(payload) || depth >= limits.maxDepth) {
640
+ return { unresolved: true };
641
+ }
642
+
643
+ const nested = analyze(payload, { ...options, depth: depth + 1 });
644
+ // A missing or failing PowerShell parser means this command cannot run on this host either. Report it as
645
+ // unresolved so the caller asks, rather than propagating a fatal parser-integrity kind into an unrelated
646
+ // shell's analysis and locking the session over an absent interpreter.
647
+ if ((nested.parseErrors || []).length || infrastructureFailure(nested)) {
648
+ return { unresolved: true };
649
+ }
650
+
651
+ return { nested };
652
+ }
653
+
654
+ export const analyzePowerShell = analyze;
655
+ export const aliases = Object.freeze(Object.fromEntries(aliasMap));