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,2557 @@
1
+ import { classifyPath, isAgentPath } from "./paths.mjs";
2
+ import { COMMAND_FLAG, ENCODED_COMMAND_FLAG, HOST_NAMES as POWERSHELL_HOSTS } from "./powershell.mjs";
3
+
4
+ export const POLICY_VERSION = 2;
5
+
6
+ // Determinate, noncritical findings are normally filtered out in Guard so routine work stays quiet. These
7
+ // rules are the exception: Guard still asks, because the operation discards or rewrites work — local or
8
+ // remote — that no undo can restore. Critical findings are immutable denials in every mode and do not
9
+ // belong here.
10
+ export const GUARD_APPROVAL_RULES = new Set(["git.destructive", "git.force-push"]);
11
+
12
+ const DELETE_NAMES = new Set([
13
+ "rm",
14
+ "rmdir",
15
+ "find",
16
+ "shred",
17
+ "truncate",
18
+ "remove-item",
19
+ "ri",
20
+ "del",
21
+ "erase",
22
+ "rd",
23
+ "clear-item",
24
+ "clear-content",
25
+ "clear-recyclebin",
26
+ ]);
27
+ const REGISTRY_PROPERTY_NAMES = new Set([
28
+ "set-itemproperty",
29
+ "new-itemproperty",
30
+ "copy-itemproperty",
31
+ "move-itemproperty",
32
+ "rename-itemproperty",
33
+ "remove-itemproperty",
34
+ "clear-itemproperty",
35
+ ]);
36
+ const POWERSHELL_WRITE_NAMES = new Set([
37
+ "set-content",
38
+ "add-content",
39
+ "out-file",
40
+ "tee-object",
41
+ "export-alias",
42
+ "export-csv",
43
+ "export-clixml",
44
+ "export-counter",
45
+ "export-formatdata",
46
+ "export-pssession",
47
+ "new-item",
48
+ "copy-item",
49
+ "move-item",
50
+ "rename-item",
51
+ "set-item",
52
+ "compress-archive",
53
+ "expand-archive",
54
+ "start-transcript",
55
+ "add-type",
56
+ "new-modulemanifest",
57
+ "save-help",
58
+ "save-module",
59
+ "save-package",
60
+ "save-script",
61
+ ]);
62
+ const DISK_NAMES = new Set([
63
+ "dd",
64
+ "mkfs",
65
+ "mkfs.ext4",
66
+ "wipefs",
67
+ "fdisk",
68
+ "parted",
69
+ "diskpart",
70
+ "format",
71
+ "clear-disk",
72
+ "initialize-disk",
73
+ "format-volume",
74
+ "remove-partition",
75
+ "cipher",
76
+ ]);
77
+ const INTERPRETER_NAMES = new Set([
78
+ "python",
79
+ "python3",
80
+ "py",
81
+ "node",
82
+ "deno",
83
+ "bun",
84
+ "ruby",
85
+ "perl",
86
+ "php",
87
+ "lua",
88
+ "rscript",
89
+ "osascript",
90
+ "tclsh",
91
+ "expect",
92
+ ]);
93
+ // su and runuser are not interpreters, but their -c argument is an unrestricted shell command string.
94
+ const SHELL_NAMES = new Set([
95
+ "bash",
96
+ "sh",
97
+ "zsh",
98
+ "dash",
99
+ "ksh",
100
+ "fish",
101
+ "powershell",
102
+ "pwsh",
103
+ "cmd",
104
+ "cmd.exe",
105
+ "eval",
106
+ "iex",
107
+ "invoke-expression",
108
+ "su",
109
+ "runuser",
110
+ ...INTERPRETER_NAMES,
111
+ ]);
112
+ // awk is deliberately kept out of SHELL_NAMES so ordinary `… | awk '{print $1}'` pipelines stay allowed.
113
+ const AWK_NAMES = new Set(["awk", "gawk", "mawk", "nawk"]);
114
+ const AWK_ESCAPE = /\b(?:system|ENVIRON)\s*[([]|\|\s*&?\s*["']|>>?\s*["']/;
115
+ const AWK_PROGRAM_SOURCE = /^--?(?:f|file|e|source|l|load|include)$/i;
116
+ const ENVIRONMENT_DUMP_NAMES = new Set(["env", "printenv", "set", "export", "declare", "typeset", "compgen"]);
117
+ const SECRET_VARIABLE = /(?:token|secret|password|credential|api[_-]?key|aws_(?:secret|session)|github_token)/i;
118
+ // PowerShell resolves any unambiguous parameter prefix, so -enc and -com run the same code as their full
119
+ // spellings. Matching only the full names lets a shorter one walk past the inline-code gate entirely.
120
+ const INLINE_CODE_FLAG = /^(?:-c|-command|-encodedcommand|\/c|\/k|-e|--eval|-r)$/i;
121
+ function inlineCodeFlag(name, arg) {
122
+ const value = String(arg);
123
+ if (INLINE_CODE_FLAG.test(value)) {
124
+ return true;
125
+ }
126
+
127
+ return POWERSHELL_HOSTS.includes(name) && (COMMAND_FLAG.test(value) || ENCODED_COMMAND_FLAG.test(value));
128
+ }
129
+
130
+ const DOWNLOAD_NAMES = new Set([
131
+ "curl",
132
+ "wget",
133
+ "invoke-webrequest",
134
+ "invoke-restmethod",
135
+ "iwr",
136
+ "irm",
137
+ "start-bitstransfer",
138
+ "bitsadmin",
139
+ "certutil",
140
+ ]);
141
+ // Every one of these turns opaque bytes back into runnable text, so limiting the decode-to-interpreter rule to
142
+ // base64 left base32, hex and uuencode as equivalent uninspected channels into the same shell.
143
+ const DECODER_NAMES = new Set([
144
+ "base64",
145
+ "base32",
146
+ "basenc",
147
+ "openssl",
148
+ "certutil",
149
+ "uudecode",
150
+ "xxd",
151
+ "hexdump",
152
+ "od",
153
+ "tr",
154
+ "rev",
155
+ ]);
156
+ const NETWORK_NAMES = new Set([
157
+ ...DOWNLOAD_NAMES,
158
+ "scp",
159
+ "rsync",
160
+ "invoke-command",
161
+ "ssh",
162
+ "sftp",
163
+ "ftp",
164
+ "start-process",
165
+ ]);
166
+ const READ_NAMES = new Set([
167
+ "cat",
168
+ "head",
169
+ "tail",
170
+ "less",
171
+ "more",
172
+ "get-content",
173
+ "gc",
174
+ "type",
175
+ "copy",
176
+ "cp",
177
+ "tac",
178
+ "nl",
179
+ "od",
180
+ "xxd",
181
+ "hexdump",
182
+ "strings",
183
+ ]);
184
+ const SERVICE_NAMES = new Set([
185
+ "service",
186
+ "systemctl",
187
+ "launchctl",
188
+ "sc",
189
+ "sc.exe",
190
+ "stop-service",
191
+ "restart-service",
192
+ "new-service",
193
+ "remove-service",
194
+ ]);
195
+ // Endpoint-protection services matched as COMPLETE tokens. Substring matching on words such as "security",
196
+ // "firewall" and "sentinel" turned ordinary units — `redis-sentinel`, `security-scanner.service`, an in-house
197
+ // `firewall-ui` — into critical denials that locked the session, the opposite of a catastrophe backstop.
198
+ const SECURITY_SERVICE_TOKEN =
199
+ /(?:^|[\s"'=/\\])(?:windefend|windefendsvc|securityhealthservice|sense|mpssvc|auditd|firewalld|ufw|apparmor|selinux|clamav(?:-[a-z]+)?|falcon[-_]?sensor|crowdstrike[a-z-]*|sentinelone|sentinelagent|wazuh[a-z-]*|osqueryd|carbonblack|cbdaemon|defender|windowsdefender)(?:\.(?:service|exe))?(?:$|[\s"'/\\;,])/i;
200
+ const PERMISSION_NAMES = new Set(["chmod", "chown", "chgrp", "setfacl", "icacls", "takeown", "set-acl"]);
201
+ const ACCOUNT_NAMES = new Set([
202
+ "useradd",
203
+ "usermod",
204
+ "userdel",
205
+ "groupadd",
206
+ "groupmod",
207
+ "groupdel",
208
+ "passwd",
209
+ "chpasswd",
210
+ "dscl",
211
+ "net",
212
+ "new-localuser",
213
+ "set-localuser",
214
+ "remove-localuser",
215
+ "add-localgroupmember",
216
+ "remove-localgroupmember",
217
+ ]);
218
+
219
+ const normalized = (value) =>
220
+ String(value || "")
221
+ .toLowerCase()
222
+ .replace(/\.exe$/, "")
223
+ .split(/[\\/]/)
224
+ .pop();
225
+ const pathArguments = (args, options) =>
226
+ args.filter(
227
+ (arg) =>
228
+ typeof arg === "string" &&
229
+ arg !== "--" &&
230
+ !arg.startsWith("-") &&
231
+ !(
232
+ options.platform === "win32" &&
233
+ /^(?:cmd|cmd\.exe|powershell|pwsh)$/i.test(String(options.shell || "")) &&
234
+ /^\/[a-z?]+$/i.test(arg)
235
+ ) &&
236
+ arg !== "[dynamic]",
237
+ );
238
+ function match(id, severity, category, reason, leaf, saferAlternative) {
239
+ return {
240
+ action: severity === "critical" ? "deny" : "ask",
241
+ severity,
242
+ category,
243
+ ruleIds: [id],
244
+ leaves: [{ executable: leaf.executable, operation: leaf.operation, redactedTarget: leaf.redactedTarget }],
245
+ reason,
246
+ ...(saferAlternative ? { saferAlternative } : {}),
247
+ lockSession: severity === "critical",
248
+ };
249
+ }
250
+
251
+ function targetState(args, options) {
252
+ const targets = pathArguments(args, options);
253
+ const classified = targets.map((target) => ({ target, result: classifyPath(target, options) }));
254
+ const broadProtected = targets.some((target) => {
255
+ if (!/[?*[{]/.test(target)) {
256
+ return false;
257
+ }
258
+
259
+ const terminalBase = target.replace(/[\\/](?:\*|\.\*|\{[^}]*\})[\\/]?$/, "");
260
+ if (terminalBase !== target && classifyPath(terminalBase, options).protected) {
261
+ return true;
262
+ }
263
+
264
+ const wildcard = target.search(/[?*[{]/);
265
+ const literalPrefix = target.slice(0, wildcard);
266
+ const separator = Math.max(literalPrefix.lastIndexOf("/"), literalPrefix.lastIndexOf("\\"));
267
+ const ancestor = separator >= 0 ? literalPrefix.slice(0, separator + 1) : ".";
268
+
269
+ return classifyPath(ancestor, options).protected;
270
+ });
271
+
272
+ return {
273
+ targets,
274
+ protectedTarget: classified.some(({ result }) => result.protected),
275
+ rootTarget:
276
+ broadProtected ||
277
+ classified.some(
278
+ ({ target, result }) =>
279
+ result.kind === "device" ||
280
+ /^(?:\/|~|\.\.|[a-z]:[\\/]?)$/i.test(result.lexical || "") ||
281
+ /^(?:\/(?:\*|\.\*?)?|[a-z]:[\\/](?:\*|\.\*?)?)$/i.test(String(target)),
282
+ ),
283
+ };
284
+ }
285
+
286
+ function optionTargets(args, names) {
287
+ const targets = [],
288
+ lowered = names.map((name) => name.toLowerCase());
289
+ for (let index = 0; index < args.length; index += 1) {
290
+ const arg = String(args[index]),
291
+ lower = arg.toLowerCase();
292
+ const separator = Math.min(
293
+ ...[lower.indexOf("="), lower.indexOf(":")].filter((value) => value > 0),
294
+ Number.POSITIVE_INFINITY,
295
+ );
296
+ const key = Number.isFinite(separator) ? lower.slice(0, separator) : lower;
297
+ const exact = lowered.filter((name) => name === key);
298
+ const candidates = exact.length
299
+ ? exact
300
+ : lowered.filter(
301
+ (name) =>
302
+ (name.startsWith("--") && key.startsWith("--") && key.length >= 3 && name.startsWith(key)) ||
303
+ (/^-[a-z]{2,}$/i.test(name) && /^-[a-z]{2,}$/i.test(key) && name.startsWith(key)),
304
+ );
305
+ if (new Set(candidates).size === 1) {
306
+ if (Number.isFinite(separator)) {
307
+ targets.push(arg.slice(separator + 1));
308
+ } else if (typeof args[index + 1] === "string") {
309
+ targets.push(args[index + 1]);
310
+ }
311
+
312
+ continue;
313
+ }
314
+
315
+ for (const name of names) {
316
+ if (/^-[A-Za-z]$/.test(name) && lower.startsWith(name.toLowerCase()) && arg.length > name.length) {
317
+ targets.push(arg.slice(name.length));
318
+ }
319
+ }
320
+ }
321
+
322
+ return targets.filter(Boolean);
323
+ }
324
+
325
+ function hasRecursiveFlag(args) {
326
+ return args.some((arg) => /^(?:--recursive|--force|-{1}[a-z]*r[a-z]*|-recurse|-force|\/s|\/mir)$/i.test(arg));
327
+ }
328
+
329
+ // find only mutates when it carries an action predicate. A plain search is read-only, so it must not inherit
330
+ // the delete-family rules — and hasRecursiveFlag matches any predicate containing an "r" (-print, -perm,
331
+ // -newer), which would otherwise report `find src -type f -print` as a recursive deletion.
332
+ export const FIND_MUTATION_PREDICATES = Object.freeze([
333
+ "-delete",
334
+ "-exec",
335
+ "-execdir",
336
+ "-ok",
337
+ "-okdir",
338
+ "-fls",
339
+ "-fprint",
340
+ "-fprintf",
341
+ ]);
342
+ export function findMutates(args) {
343
+ return (args || []).some((arg) => FIND_MUTATION_PREDICATES.includes(String(arg)));
344
+ }
345
+
346
+ function deletesFiles(name, args) {
347
+ return DELETE_NAMES.has(name) && (name !== "find" || findMutates(args));
348
+ }
349
+
350
+ function bashCmdSyntaxMismatch(name, args, options) {
351
+ const windows = options.platform === "win32" || options.platform === "windows";
352
+ const bash = /^(?:bash|sh|zsh|dash|ksh|fish)$/i.test(String(options.shell || ""));
353
+ const cmdDelete = ["rd", "rmdir", "del", "erase"].includes(name);
354
+
355
+ return windows && bash && cmdDelete && args.some((arg) => /^(?:\/s|\/q|\/f|\/a(?::[a-z])?)$/i.test(String(arg)));
356
+ }
357
+
358
+ // True when the invocation prints or enumerates the whole environment rather than one named variable.
359
+ function dumpsEnvironment(name, args, helpOnly) {
360
+ // compgen -v lists variables; the shared helpOnly heuristic would misread it as --version.
361
+ if (name === "compgen") {
362
+ return (
363
+ args.some((arg) => /^-[a-z]*[ve][a-z]*$/i.test(String(arg))) ||
364
+ args.some(
365
+ (arg, index) =>
366
+ String(arg).toLowerCase() === "-a" &&
367
+ /^(?:variable|export|exported)$/i.test(String(args[index + 1] || "")),
368
+ )
369
+ );
370
+ }
371
+
372
+ if (helpOnly) {
373
+ return false;
374
+ }
375
+
376
+ if (name === "printenv" || name === "set") {
377
+ return args.length === 0;
378
+ }
379
+
380
+ if (name === "export" || name === "declare" || name === "typeset") {
381
+ const named = args.filter((arg) => !String(arg).startsWith("-"));
382
+ if (named.length > 0 || args.some((arg) => String(arg).includes("="))) {
383
+ return false;
384
+ }
385
+
386
+ return args.length === 0 || name === "export" || args.some((arg) => /^-[a-z]*[px][a-z]*$/i.test(String(arg)));
387
+ }
388
+
389
+ return false;
390
+ }
391
+
392
+ function criticalLeaf(leaf, options) {
393
+ const name = normalized(leaf.executable);
394
+ const args = leaf.args || [];
395
+ const joined = args.join(" ").toLowerCase();
396
+ let targetsCache;
397
+ const targets = () => (targetsCache ||= targetState(args, options));
398
+
399
+ if (bashCmdSyntaxMismatch(name, args, options)) {
400
+ return {
401
+ action: "deny",
402
+ severity: "high",
403
+ category: "filesystem",
404
+ ruleIds: ["shell.syntax-mismatch"],
405
+ leaves: [{ executable: leaf.executable, operation: leaf.operation, redactedTarget: leaf.redactedTarget }],
406
+ reason: "This is cmd deletion syntax sent to the Bash tool and will not clean the intended Windows path.",
407
+ saferAlternative: "Use Bash `rm -rf -- <forward-slash-path>` or the PowerShell tool.",
408
+ lockSession: false,
409
+ };
410
+ }
411
+
412
+ if (name === "fork-bomb") {
413
+ return match(
414
+ "process.fork-bomb",
415
+ "critical",
416
+ "process",
417
+ "Fork-bomb process creation is permanently denied.",
418
+ leaf,
419
+ );
420
+ }
421
+
422
+ if (
423
+ deletesFiles(name, args) &&
424
+ (hasRecursiveFlag(args) || name === "find") &&
425
+ (targets().protectedTarget || targets().rootTarget)
426
+ ) {
427
+ return match(
428
+ "fs.root-recursive-delete",
429
+ "critical",
430
+ "filesystem",
431
+ "Recursive deletion can encompass a protected system, profile, or filesystem root.",
432
+ leaf,
433
+ "Delete one specific reviewed workspace path instead.",
434
+ );
435
+ }
436
+
437
+ if (deletesFiles(name, args) && targets().protectedTarget) {
438
+ return match(
439
+ "path.protected-mutation",
440
+ "critical",
441
+ "protected-path",
442
+ "The command mutates a protected path.",
443
+ leaf,
444
+ );
445
+ }
446
+
447
+ if (["remove-item", "ri", "del", "erase", "rd"].includes(name) && /(?:registry::|cert:\\?)/i.test(joined)) {
448
+ return match(
449
+ "windows.provider-critical",
450
+ "critical",
451
+ "security",
452
+ "Removing Windows Registry or certificate-store data is permanently denied.",
453
+ leaf,
454
+ );
455
+ }
456
+
457
+ const helpOnly = args.length > 0 && args.every((arg) => /^(?:--?h(?:elp)?|--version|-v|\/\?|help)$/i.test(arg));
458
+ const ddOutput = args.find((arg) => /^of=/i.test(arg));
459
+ const ddTarget = ddOutput ? ddOutput.slice(ddOutput.indexOf("=") + 1) : undefined;
460
+ const destructiveDisk =
461
+ /\bof=\/dev\/(?:sd|nvme|disk|mapper|md)/i.test(joined) ||
462
+ (name === "dd" &&
463
+ ddTarget &&
464
+ (classifyPath(ddTarget, options).device || /^(?:\\\\[.?]\\|\\\\device\\)/i.test(ddTarget))) ||
465
+ (/^mkfs(?:\.|$)/i.test(name) && targets().targets.length > 0) ||
466
+ (name === "wipefs" && /(?:^|\s)(?:-a|--all|-o|--offset)(?:\s|$)/i.test(joined)) ||
467
+ (name === "fdisk" && !/(?:^|\s)(?:-l|--list)(?:\s|$)/i.test(joined) && targets().targets.length > 0) ||
468
+ (name === "parted" && /\b(?:mklabel|mkpart|rm|resizepart|set)\b/i.test(joined)) ||
469
+ (["diskpart", "format", "clear-disk", "initialize-disk", "format-volume", "remove-partition"].includes(name) &&
470
+ !helpOnly) ||
471
+ (name === "cipher" && /(?:^|\s)\/w(?::|\s|$)/i.test(` ${joined}`));
472
+ if (destructiveDisk) {
473
+ return match(
474
+ "disk.destructive",
475
+ "critical",
476
+ "disk",
477
+ "Raw disk, data-wipe, formatting, or partition operations are permanently denied.",
478
+ leaf,
479
+ );
480
+ }
481
+
482
+ if (
483
+ (["shutdown", "reboot", "halt", "poweroff", "stop-computer", "restart-computer"].includes(name) && !helpOnly) ||
484
+ (name === "bcdedit" && !/^\s*\/enum\b/i.test(joined) && !helpOnly) ||
485
+ (name === "vssadmin" && /\b(?:delete|resize)\b/i.test(joined))
486
+ ) {
487
+ return match(
488
+ "system.critical",
489
+ "critical",
490
+ "system",
491
+ "This shutdown, boot, or recovery-state operation is permanently denied.",
492
+ leaf,
493
+ );
494
+ }
495
+
496
+ if (name === "systemctl" && /\b(?:poweroff|reboot|halt|kexec|emergency|rescue)\b/i.test(joined)) {
497
+ return match(
498
+ "system.critical",
499
+ "critical",
500
+ "system",
501
+ "This system-manager operation can stop or destabilize the host.",
502
+ leaf,
503
+ );
504
+ }
505
+
506
+ if (
507
+ ["mdadm", "pvremove", "vgremove", "lvremove", "lvconvert", "zpool", "cryptsetup"].includes(name) &&
508
+ /\b(?:--zero-superblock|--create|remove|destroy|labelclear|luksformat|erase|reencrypt)\b/i.test(joined)
509
+ ) {
510
+ return match(
511
+ "disk.destructive",
512
+ "critical",
513
+ "disk",
514
+ "Destructive RAID, volume-manager, pool, or encrypted-volume operations are permanently denied.",
515
+ leaf,
516
+ );
517
+ }
518
+
519
+ if (["grub-install", "grub-mkconfig", "efibootmgr", "bootcfg"].includes(name) && !helpOnly) {
520
+ return match(
521
+ "boot.destructive",
522
+ "critical",
523
+ "system",
524
+ "Boot-loader configuration mutation is permanently denied.",
525
+ leaf,
526
+ );
527
+ }
528
+
529
+ if (
530
+ (name === "auditctl" && /(?:^|\s)-e\s*0(?:\s|$)/i.test(` ${joined}`)) ||
531
+ (name === "systemctl" && /\b(?:stop|disable|mask)\b/i.test(joined) && SECURITY_SERVICE_TOKEN.test(joined))
532
+ ) {
533
+ return match(
534
+ "security.disable",
535
+ "critical",
536
+ "security",
537
+ "Disabling audit, firewall, or security services is permanently denied.",
538
+ leaf,
539
+ );
540
+ }
541
+
542
+ if (
543
+ ["set-mppreference", "add-mppreference"].includes(name) &&
544
+ /(?:disable|exclusion|realtime|monitoring|mapsreporting|sampleconsent)/i.test(joined)
545
+ ) {
546
+ return match(
547
+ "security.disable",
548
+ "critical",
549
+ "security",
550
+ "Disabling or weakening host security controls is permanently denied.",
551
+ leaf,
552
+ );
553
+ }
554
+
555
+ if (
556
+ ["disable-netfirewallrule", "set-netfirewallprofile", "netsh", "ufw", "iptables", "nft"].includes(name) &&
557
+ /(?:disable|enabled\s+false|state\s+off|firewall.*off|\s-f\b|flush)/i.test(` ${joined}`)
558
+ ) {
559
+ return match(
560
+ "security.disable",
561
+ "critical",
562
+ "security",
563
+ "Disabling or flushing host firewall controls is permanently denied.",
564
+ leaf,
565
+ );
566
+ }
567
+
568
+ if (
569
+ ["setenforce", "csrutil", "spctl"].includes(name) &&
570
+ /(?:^|\s)(?:0|disable|--master-disable)(?:\s|$)/i.test(` ${joined}`)
571
+ ) {
572
+ return match(
573
+ "security.disable",
574
+ "critical",
575
+ "security",
576
+ "Disabling mandatory host security policy is permanently denied.",
577
+ leaf,
578
+ );
579
+ }
580
+
581
+ if (
582
+ [...SERVICE_NAMES, "net"].includes(name) &&
583
+ /\b(?:stop|disable|delete|remove|unload)\b/i.test(joined) &&
584
+ SECURITY_SERVICE_TOKEN.test(joined)
585
+ ) {
586
+ return match(
587
+ "security.disable",
588
+ "critical",
589
+ "security",
590
+ "Stopping or removing endpoint, audit, or firewall services is permanently denied.",
591
+ leaf,
592
+ );
593
+ }
594
+
595
+ if (
596
+ (name === "reg" || REGISTRY_PROPERTY_NAMES.has(name)) &&
597
+ /(?:defender|windows defender|securityhealth)/i.test(joined) &&
598
+ /(?:disable|exclusion|tamper|realtime)/i.test(joined)
599
+ ) {
600
+ return match(
601
+ "security.disable",
602
+ "critical",
603
+ "security",
604
+ "Weakening endpoint security through registry or policy state is permanently denied.",
605
+ leaf,
606
+ );
607
+ }
608
+
609
+ if (name === "set-executionpolicy" && /(?:bypass|unrestricted)/i.test(joined)) {
610
+ return match(
611
+ "security.disable",
612
+ "critical",
613
+ "security",
614
+ "Weakening script execution policy is permanently denied.",
615
+ leaf,
616
+ );
617
+ }
618
+
619
+ if (
620
+ ENVIRONMENT_DUMP_NAMES.has(name) &&
621
+ name !== "env" &&
622
+ (dumpsEnvironment(name, args, helpOnly) || args.some((arg) => SECRET_VARIABLE.test(String(arg))))
623
+ ) {
624
+ return match(
625
+ "credential.environment-read",
626
+ "high",
627
+ "security",
628
+ "Reading secret-bearing or complete environment state needs approval in Strict mode.",
629
+ leaf,
630
+ );
631
+ }
632
+
633
+ if (name === "env" && !leaf.nested && !helpOnly) {
634
+ return match(
635
+ "credential.environment-read",
636
+ "high",
637
+ "security",
638
+ "Dumping the complete process environment needs approval in Strict mode.",
639
+ leaf,
640
+ );
641
+ }
642
+
643
+ if (["get-childitem", "gci", "dir", "get-item", "gi"].includes(name) && args.some((arg) => /^env:/i.test(arg))) {
644
+ return match(
645
+ "credential.environment-read",
646
+ "high",
647
+ "security",
648
+ "Reading the PowerShell environment provider needs approval in Strict mode.",
649
+ leaf,
650
+ );
651
+ }
652
+
653
+ if (
654
+ (name === "security" && /\b(?:find-(?:generic|internet)-password.*\s-w\b|export)\b/i.test(joined)) ||
655
+ (name === "secret-tool" && /\b(?:lookup|get|show)\b/i.test(joined)) ||
656
+ name === "get-storedcredential" ||
657
+ (name === "vaultcmd" && /\/listcreds/i.test(joined))
658
+ ) {
659
+ return match(
660
+ "credential.store-read",
661
+ "high",
662
+ "security",
663
+ "Reading operating-system credential stores needs approval in Strict mode.",
664
+ leaf,
665
+ );
666
+ }
667
+
668
+ if (
669
+ (name === "git" && /^credential\s+(?:fill|get)\b/i.test(joined)) ||
670
+ (name === "gh" && /^auth\s+token\b/i.test(joined)) ||
671
+ (name === "aws" && /^configure\s+export-credentials\b/i.test(joined)) ||
672
+ (name === "az" && /^account\s+get-access-token\b/i.test(joined)) ||
673
+ (name === "gcloud" &&
674
+ /^auth\s+(?:print-access-token|application-default\s+print-access-token)\b/i.test(joined)) ||
675
+ (name === "kubectl" && /^config\s+view\b.*\s--raw\b/i.test(joined))
676
+ ) {
677
+ return match(
678
+ "credential.store-read",
679
+ "high",
680
+ "security",
681
+ "Printing stored access credentials needs approval in Strict mode.",
682
+ leaf,
683
+ );
684
+ }
685
+
686
+ if (name === "reg" && /^delete\b/i.test(joined) && /(?:hklm|security|sam|system|bcd|defender)/i.test(joined)) {
687
+ return match(
688
+ "registry.system-delete",
689
+ "critical",
690
+ "security",
691
+ "Deleting system or security registry state is permanently denied.",
692
+ leaf,
693
+ );
694
+ }
695
+
696
+ const criticalProcess =
697
+ args.some((arg) => ["0", "1", "*"].includes(String(arg))) ||
698
+ String(args.at(-1)) === "-1" ||
699
+ // Killing any of these takes the host down as surely as killing init does.
700
+ /\b(?:system|systemd|init|wininit|csrss|lsass|svchost|services|smss|winlogon|lsm|launchd|kernel_task)(?:\.exe)?\b/i.test(
701
+ joined,
702
+ ) ||
703
+ /(?:^|\s)-(?:u|U|G|P)\s+(?:root|0|1)(?:\s|$)|(?:^|\s)-f\s+[.*+](?:\s|$)/i.test(` ${joined}`) ||
704
+ (args.includes("--") && args.slice(args.indexOf("--") + 1).includes("-1"));
705
+ if (["kill", "killall", "pkill", "taskkill", "stop-process"].includes(name) && criticalProcess) {
706
+ return match(
707
+ "process.broad-kill",
708
+ "critical",
709
+ "process",
710
+ "Broad or critical host process termination is permanently denied.",
711
+ leaf,
712
+ );
713
+ }
714
+
715
+ if (PERMISSION_NAMES.has(name) && targets().protectedTarget && hasRecursiveFlag(args)) {
716
+ return match(
717
+ "security.protected-permissions",
718
+ "critical",
719
+ "security",
720
+ "Recursive permission or ownership changes to protected paths are permanently denied.",
721
+ leaf,
722
+ );
723
+ }
724
+
725
+ const mutationIntent =
726
+ deletesFiles(name, args) ||
727
+ PERMISSION_NAMES.has(name) ||
728
+ POWERSHELL_WRITE_NAMES.has(name) ||
729
+ [
730
+ "<redirect>",
731
+ "cp",
732
+ "copy",
733
+ "copy-item",
734
+ "mv",
735
+ "move",
736
+ "move-item",
737
+ "rename-item",
738
+ "set-item",
739
+ "set-content",
740
+ "add-content",
741
+ "out-file",
742
+ "tee",
743
+ "touch",
744
+ "mkdir",
745
+ "new-item",
746
+ "sed",
747
+ "dd",
748
+ "install",
749
+ "ln",
750
+ "rsync",
751
+ "scp",
752
+ "tar",
753
+ "unzip",
754
+ "7z",
755
+ "7za",
756
+ "expand-archive",
757
+ ...DOWNLOAD_NAMES,
758
+ ].includes(name) ||
759
+ (["npm", "pnpm", "yarn"].includes(name) && /\b(?:remove|uninstall|unlink)\b/i.test(joined));
760
+ const destinationOptions = ["cp", "install", "ln"].includes(name)
761
+ ? ["-t", "--target-directory"]
762
+ : name === "copy-item" || name === "move-item"
763
+ ? ["-destination"]
764
+ : ["compress-archive", "expand-archive"].includes(name)
765
+ ? ["-destinationpath"]
766
+ : name === "tee-object" || name === "out-file"
767
+ ? ["-filepath"]
768
+ : name === "export-pssession"
769
+ ? ["-outputmodule"]
770
+ : name === "add-type"
771
+ ? ["-outputassembly"]
772
+ : DOWNLOAD_NAMES.has(name)
773
+ ? ["-o", "--output", "--output-document", "-outfile", "--outfile", "-destination"]
774
+ : POWERSHELL_WRITE_NAMES.has(name)
775
+ ? ["-path", "-literalpath"]
776
+ : [];
777
+ const explicitDestination = optionTargets(args, destinationOptions);
778
+ const protectedMutationTargets = ["cp", "copy", "copy-item", "install", "ln"].includes(name)
779
+ ? explicitDestination.length
780
+ ? explicitDestination
781
+ : targets().targets.slice(-1)
782
+ : ["rsync", "scp"].includes(name)
783
+ ? targets().targets.slice(-1)
784
+ : ["mv", "move", "move-item", "rename-item"].includes(name)
785
+ ? [...targets().targets, ...explicitDestination]
786
+ : POWERSHELL_WRITE_NAMES.has(name)
787
+ ? explicitDestination.length
788
+ ? explicitDestination
789
+ : targets().targets
790
+ : ["set-content", "add-content", "out-file", "tee", "touch", "mkdir", "new-item"].includes(name)
791
+ ? targets().targets
792
+ : name === "expand-archive"
793
+ ? optionTargets(args, ["-destinationpath", "-destination"])
794
+ : DOWNLOAD_NAMES.has(name)
795
+ ? explicitDestination.length
796
+ ? explicitDestination
797
+ : ["start-bitstransfer", "bitsadmin", "certutil"].includes(name)
798
+ ? targets().targets.slice(-1)
799
+ : []
800
+ : name === "tar"
801
+ ? optionTargets(args, ["-c", "--directory"])
802
+ : name === "unzip"
803
+ ? optionTargets(args, ["-d"])
804
+ : name === "7z" || name === "7za"
805
+ ? optionTargets(args, ["-o"])
806
+ : name === "sed" && args.some((arg) => /^-[a-z]*i/i.test(arg))
807
+ ? targets().targets
808
+ : name === "dd" && ddTarget
809
+ ? [ddTarget]
810
+ : [];
811
+ if (mutationIntent && protectedMutationTargets.some((target) => classifyPath(target, options).protected)) {
812
+ return match(
813
+ "path.protected-mutation",
814
+ "critical",
815
+ "protected-path",
816
+ "The command writes, moves, or mutates a protected path.",
817
+ leaf,
818
+ );
819
+ }
820
+
821
+ // A destructive Git operation rewrites or discards whatever tree it runs in, so when that tree IS protected
822
+ // host or enforcement state it is the same tampering as deleting the files directly. `git -C <agent-dir>
823
+ // checkout -- extensions/command-guard/rules.mjs` silently reverted the guard's own sources.
824
+ const gitDestructive =
825
+ name === "git" &&
826
+ /(?:^|\s)(?:reset\s+--hard|clean\s+-[a-z]*f|checkout\s+--|restore\b|stash\s+(?:drop|clear)|filter-(?:branch|repo))/i.test(
827
+ joined,
828
+ );
829
+ if (
830
+ gitDestructive &&
831
+ (classifyPath(options.cwd || ".", options).protected ||
832
+ isAgentPath(options.cwd || ".", options) ||
833
+ targets().targets.some((target) => isAgentPath(target, options) || classifyPath(target, options).protected))
834
+ ) {
835
+ return match(
836
+ "guard.self-tamper",
837
+ "critical",
838
+ "protected-path",
839
+ "A destructive Git operation targets protected host or SpecPi enforcement state.",
840
+ leaf,
841
+ );
842
+ }
843
+
844
+ // Keyed on where the target resolves, not on "specpi" or "command-guard" appearing anywhere in the arguments:
845
+ // that substring test made `mkdir specpi-experiment` a critical, session-locking denial in any checkout.
846
+ if (
847
+ mutationIntent &&
848
+ [...targets().targets, ...protectedMutationTargets].some((target) => isAgentPath(target, options))
849
+ ) {
850
+ return match(
851
+ "guard.self-tamper",
852
+ "critical",
853
+ "protected-path",
854
+ "The command targets SpecPi guard, configuration, or private state.",
855
+ leaf,
856
+ );
857
+ }
858
+
859
+ return undefined;
860
+ }
861
+
862
+ function ordinaryLeaf(leaf, options) {
863
+ const name = normalized(leaf.executable);
864
+ const args = leaf.args || [];
865
+ const joined = args.join(" ").toLowerCase();
866
+ // These ordinary findings feed Strict's broader approval layer. Core filters determinate noncritical
867
+ // findings in Guard after all critical and indeterminate results have been aggregated, except the work-
868
+ // destroying rules in GUARD_APPROVAL_RULES, which Guard still surfaces.
869
+ const guard = options.mode === "guard";
870
+ const classifiedTargets = pathArguments(args, options).map((target) => classifyPath(target, options));
871
+ const escapesWorkspace = classifiedTargets.some((result) => result.protected || !result.withinWorkspace);
872
+ const workspaceFileOperation = guard && classifiedTargets.length > 0 && !escapesWorkspace;
873
+ const workspaceExecution = guard && !escapesWorkspace;
874
+
875
+ // Force pushes and the destructive Git family below are the determinate, noncritical operations Guard
876
+ // still surfaces: they discard or rewrite work no undo can restore. A force push gets its own rule id
877
+ // because it rewrites remote history collaborators may already hold; --force-with-lease narrows the race
878
+ // but still overwrites the remote ref, so it asks as well. Ordered ahead of the general git.destructive
879
+ // rule so a force push reports its own id.
880
+ if (name === "git" && /(?:^|\s)push(?:\s|$)/i.test(joined) && /(?:^|\s)(?:--force|-f)\b/i.test(joined)) {
881
+ return match(
882
+ "git.force-push",
883
+ "high",
884
+ "git",
885
+ "A force push rewrites remote history and needs approval.",
886
+ leaf,
887
+ "Push without force, or confirm the remote ref and use --force-with-lease.",
888
+ );
889
+ }
890
+
891
+ if (
892
+ name === "git" &&
893
+ /(?:reset\s+--hard|clean\s+-[a-z]*f|push\s+.*(?:--force|-f\b|--delete)|branch\s+-[dD]|tag\s+-d|rebase\b|filter-(?:branch|repo)|checkout\s+--\s|restore\s+.*(?:--worktree|--staged|--source)|stash\s+(?:drop|clear)|reflog\s+expire|gc\s+.*--prune)/i.test(
894
+ joined,
895
+ )
896
+ ) {
897
+ return match(
898
+ "git.destructive",
899
+ "high",
900
+ "git",
901
+ "This Git operation discards or rewrites working-tree or remote history.",
902
+ leaf,
903
+ "Inspect with git status and git diff first.",
904
+ );
905
+ }
906
+
907
+ if (
908
+ ["docker", "podman"].includes(name) &&
909
+ /(?:system\s+prune|(?:rm|rmi|volume\s+rm|network\s+rm)|--volumes|compose\s+down.*(?:-v\b|--volumes))/i.test(
910
+ joined,
911
+ )
912
+ ) {
913
+ return match(
914
+ "container.destructive",
915
+ "high",
916
+ "container",
917
+ "This container operation removes images, volumes, networks, or resources.",
918
+ leaf,
919
+ );
920
+ }
921
+
922
+ if (name === "kubectl" && /\b(?:delete|drain|cordon|replace|apply|patch|scale)\b/i.test(joined)) {
923
+ return match(
924
+ "container.cluster-mutation",
925
+ "high",
926
+ "container",
927
+ "This cluster operation mutates or removes resources.",
928
+ leaf,
929
+ );
930
+ }
931
+
932
+ if (["terraform", "tofu", "pulumi"].includes(name) && /\b(?:destroy|apply|state\s+rm|down)\b/i.test(joined)) {
933
+ return match(
934
+ "cloud.infrastructure-mutation",
935
+ "high",
936
+ "cloud",
937
+ "This infrastructure operation can replace or destroy remote resources.",
938
+ leaf,
939
+ );
940
+ }
941
+
942
+ if (
943
+ ["aws", "az", "gcloud", "doctl", "heroku", "flyctl"].includes(name) &&
944
+ /\b(?:delete|destroy|terminate|remove|purge|deprovision)\b/i.test(joined)
945
+ ) {
946
+ return match(
947
+ "cloud.resource-delete",
948
+ "high",
949
+ "cloud",
950
+ "This cloud command deletes or deprovisions remote resources.",
951
+ leaf,
952
+ );
953
+ }
954
+
955
+ if (
956
+ ["npm", "pnpm", "yarn", "twine", "cargo", "dotnet", "gem"].includes(name) &&
957
+ /\b(?:publish|unpublish|deprecate|upload|nuget\s+push|yank)\b/i.test(joined)
958
+ ) {
959
+ return match(
960
+ "package.registry-mutation",
961
+ "high",
962
+ "package",
963
+ "Package publication or registry mutation needs approval.",
964
+ leaf,
965
+ );
966
+ }
967
+
968
+ if (
969
+ ["npm", "pnpm", "yarn", "pip", "pip3", "apt", "apt-get", "dnf", "yum", "brew", "winget", "choco"].includes(
970
+ name,
971
+ ) &&
972
+ /\b(?:remove|uninstall|update|upgrade|install|add)\b/i.test(joined)
973
+ ) {
974
+ return match(
975
+ "package.mutation",
976
+ "medium",
977
+ "package",
978
+ "Package installation, update, or removal needs approval.",
979
+ leaf,
980
+ );
981
+ }
982
+
983
+ const pluginExecution =
984
+ ["npx", "pre-commit"].includes(name) ||
985
+ (["npm", "pnpm"].includes(name) && /^(?:run|test|exec|x)\b/i.test(joined.trim())) ||
986
+ (name === "yarn" && joined.trim().length > 0) ||
987
+ (["bun", "deno"].includes(name) && /^(?:run|test|task)\b/i.test(joined.trim())) ||
988
+ (name === "cargo" && /^(?:build|check|test|run)\b/i.test(joined.trim()));
989
+ if (pluginExecution) {
990
+ return workspaceExecution
991
+ ? undefined
992
+ : match(
993
+ "execution.plugins-or-hooks",
994
+ "medium",
995
+ "dynamic",
996
+ "This tool can execute project plugins, hooks, scripts, or configuration and needs approval.",
997
+ leaf,
998
+ );
999
+ }
1000
+
1001
+ if (["export", "set", "setx"].includes(name) && /(?:^|\s)(?:path|[A-Za-z_][A-Za-z0-9_]*)=/i.test(joined)) {
1002
+ return match("environment.mutation", "medium", "system", "Environment or PATH mutation needs approval.", leaf);
1003
+ }
1004
+
1005
+ if (["set-alias", "new-alias", "import-alias"].includes(name)) {
1006
+ return match(
1007
+ "dynamic.generated-code",
1008
+ "high",
1009
+ "dynamic",
1010
+ "Alias definitions can redirect later command execution and need approval.",
1011
+ leaf,
1012
+ );
1013
+ }
1014
+
1015
+ if (["source", "."].includes(name)) {
1016
+ return match(
1017
+ "dynamic.local-script",
1018
+ "high",
1019
+ "dynamic",
1020
+ "Sourcing an uninspected local script needs approval.",
1021
+ leaf,
1022
+ );
1023
+ }
1024
+
1025
+ if (NETWORK_NAMES.has(name)) {
1026
+ return match(
1027
+ "network.or.remote",
1028
+ "high",
1029
+ "network",
1030
+ "Network transfer, download, upload, or remote execution needs approval.",
1031
+ leaf,
1032
+ );
1033
+ }
1034
+
1035
+ if (["kill", "killall", "pkill", "taskkill", "stop-process"].includes(name)) {
1036
+ return match("process.termination", "medium", "process", "Process termination needs approval.", leaf);
1037
+ }
1038
+
1039
+ // Ordered ahead of the delete family so find keeps its own rule instead of being labelled a recursive delete.
1040
+ if (name === "find" && findMutates(args)) {
1041
+ return match(
1042
+ "filesystem.find-mutation",
1043
+ "high",
1044
+ "filesystem",
1045
+ "Find execution or deletion needs approval.",
1046
+ leaf,
1047
+ );
1048
+ }
1049
+
1050
+ if (deletesFiles(name, args)) {
1051
+ return !hasRecursiveFlag(args) && workspaceFileOperation
1052
+ ? undefined
1053
+ : match(
1054
+ hasRecursiveFlag(args) ? "filesystem.recursive-delete" : "filesystem.mutation",
1055
+ "high",
1056
+ "filesystem",
1057
+ hasRecursiveFlag(args)
1058
+ ? "Recursive deletion needs approval."
1059
+ : "File deletion or truncation needs approval.",
1060
+ leaf,
1061
+ "Use a bounded, explicitly reviewed workspace target.",
1062
+ );
1063
+ }
1064
+
1065
+ if (
1066
+ POWERSHELL_WRITE_NAMES.has(name) ||
1067
+ [
1068
+ "cp",
1069
+ "copy",
1070
+ "mv",
1071
+ "move",
1072
+ "tee",
1073
+ "touch",
1074
+ "mkdir",
1075
+ "install",
1076
+ "ln",
1077
+ "rsync",
1078
+ "scp",
1079
+ "tar",
1080
+ "unzip",
1081
+ "7z",
1082
+ "7za",
1083
+ ].includes(name) ||
1084
+ (name === "dd" && !args.every((arg) => /^(?:--?h(?:elp)?|--version|-v)$/i.test(arg))) ||
1085
+ (name === "sed" && args.some((arg) => /^-[a-z]*i/i.test(arg)))
1086
+ ) {
1087
+ return workspaceFileOperation
1088
+ ? undefined
1089
+ : match(
1090
+ "filesystem.write",
1091
+ "high",
1092
+ "filesystem",
1093
+ "Filesystem creation, overwrite, or movement needs approval.",
1094
+ leaf,
1095
+ );
1096
+ }
1097
+
1098
+ if (
1099
+ AWK_NAMES.has(name) &&
1100
+ args.some((arg) => AWK_ESCAPE.test(String(arg)) || AWK_PROGRAM_SOURCE.test(String(arg)))
1101
+ ) {
1102
+ return {
1103
+ ...match(
1104
+ "dynamic.inline-code",
1105
+ "high",
1106
+ "dynamic",
1107
+ "An awk program that can spawn shells, read the environment, load modules, or write files needs approval.",
1108
+ leaf,
1109
+ ),
1110
+ indeterminate: true,
1111
+ };
1112
+ }
1113
+
1114
+ if (["xargs", "eval", "invoke-expression", "iex"].includes(name)) {
1115
+ return match(
1116
+ "dynamic.generated-code",
1117
+ "high",
1118
+ "dynamic",
1119
+ "Generated or indirectly invoked code needs approval.",
1120
+ leaf,
1121
+ );
1122
+ }
1123
+
1124
+ if (SERVICE_NAMES.has(name) && /\b(?:stop|restart|disable|delete|remove|create|start)\b/i.test(joined)) {
1125
+ return match("service.mutation", "high", "system", "Service mutation needs approval.", leaf);
1126
+ }
1127
+
1128
+ if (PERMISSION_NAMES.has(name) || ACCOUNT_NAMES.has(name)) {
1129
+ return match(
1130
+ "security.identity-or-permission",
1131
+ "high",
1132
+ "security",
1133
+ "Account, group, ACL, permission, or ownership mutation needs approval.",
1134
+ leaf,
1135
+ );
1136
+ }
1137
+
1138
+ if (name === "reg" && /^delete\b/i.test(joined)) {
1139
+ return match("registry.delete", "high", "security", "Registry deletion needs approval.", leaf);
1140
+ }
1141
+
1142
+ if (name === "reg" && /^(?:add|copy|import|load|unload|restore)\b/i.test(joined)) {
1143
+ return match("registry.mutation", "high", "security", "Registry mutation needs approval.", leaf);
1144
+ }
1145
+
1146
+ if (REGISTRY_PROPERTY_NAMES.has(name)) {
1147
+ return match(
1148
+ "registry.mutation",
1149
+ "high",
1150
+ "security",
1151
+ "Registry property mutation needs approval regardless of provider-drive spelling.",
1152
+ leaf,
1153
+ );
1154
+ }
1155
+
1156
+ if (
1157
+ ["new-psdrive", "remove-psdrive"].includes(name) &&
1158
+ /(?:^|\s)(?:registry|-psprovider\s+registry)\b/i.test(joined)
1159
+ ) {
1160
+ return match("registry.mutation", "high", "security", "Registry provider-drive mutation needs approval.", leaf);
1161
+ }
1162
+
1163
+ if (
1164
+ (name === "schtasks" && /\/delete/i.test(joined)) ||
1165
+ name === "unregister-scheduledtask" ||
1166
+ (name === "crontab" && /(?:^|\s)-r(?:\s|$)/i.test(` ${joined}`)) ||
1167
+ (name === "wsl" && /--unregister/i.test(joined))
1168
+ ) {
1169
+ return match(
1170
+ "system.registration-delete",
1171
+ "high",
1172
+ "system",
1173
+ "Scheduled-task or subsystem deletion needs approval.",
1174
+ leaf,
1175
+ );
1176
+ }
1177
+
1178
+ if (
1179
+ (name === "robocopy" && /\/mir\b/i.test(joined)) ||
1180
+ name === "clear-winevent" ||
1181
+ (name === "wevtutil" && /^cl\b/i.test(joined))
1182
+ ) {
1183
+ return match(
1184
+ "filesystem.broad-mutation",
1185
+ "high",
1186
+ "filesystem",
1187
+ "Mirroring or clearing host data needs approval.",
1188
+ leaf,
1189
+ );
1190
+ }
1191
+
1192
+ if (
1193
+ ["psql", "mysql", "sqlcmd", "sqlite3", "mongosh", "mongo", "redis-cli"].includes(name) &&
1194
+ /\b(?:drop|truncate|delete\s+from|flushall|flushdb)\b/i.test(joined)
1195
+ ) {
1196
+ return match(
1197
+ "database.destructive",
1198
+ "high",
1199
+ "database",
1200
+ "Destructive database statements need approval.",
1201
+ leaf,
1202
+ );
1203
+ }
1204
+
1205
+ if (
1206
+ ["prisma", "knex", "sequelize", "alembic", "rails", "rake", "dotnet"].includes(name) &&
1207
+ /\b(?:migrate\s+reset|migrate:rollback|migrate:undo|downgrade|db:rollback|database\s+drop)\b/i.test(joined)
1208
+ ) {
1209
+ return match(
1210
+ "database.migration-rollback",
1211
+ "high",
1212
+ "database",
1213
+ "Database reset, drop, or migration rollback needs approval.",
1214
+ leaf,
1215
+ );
1216
+ }
1217
+
1218
+ if (SHELL_NAMES.has(name) && args.some((arg) => inlineCodeFlag(name, arg))) {
1219
+ return {
1220
+ ...match(
1221
+ "dynamic.inline-code",
1222
+ "high",
1223
+ "dynamic",
1224
+ "Inline interpreter code that could not be recursively analyzed needs approval.",
1225
+ leaf,
1226
+ ),
1227
+ indeterminate: !leaf.nested,
1228
+ };
1229
+ }
1230
+
1231
+ if (
1232
+ (SHELL_NAMES.has(name) &&
1233
+ args.some((arg) =>
1234
+ /\.(?:sh|bash|zsh|fish|ps1|bat|cmd|js|mjs|cjs|ts|py|rb|pl|php|lua|r|awk|tcl|scpt|applescript)(?:$|[?#])/i.test(
1235
+ arg,
1236
+ ),
1237
+ )) ||
1238
+ /\.(?:sh|bash|zsh|fish|ps1|bat|cmd|js|mjs|cjs|ts|py|rb|pl|php|lua|r|awk|tcl|scpt|applescript)$/i.test(
1239
+ String(leaf.executable || ""),
1240
+ )
1241
+ ) {
1242
+ return workspaceExecution
1243
+ ? undefined
1244
+ : match(
1245
+ "dynamic.local-script",
1246
+ "high",
1247
+ "dynamic",
1248
+ "Executing a local script that was not statically inspected needs approval.",
1249
+ leaf,
1250
+ );
1251
+ }
1252
+
1253
+ return undefined;
1254
+ }
1255
+
1256
+ function flatten(analysis) {
1257
+ return [
1258
+ ...(analysis.leaves || []),
1259
+ ...(analysis.leaves || []).flatMap((leaf) => (leaf.nested ? flatten(leaf.nested) : [])),
1260
+ ];
1261
+ }
1262
+
1263
+ function redirectsWithShell(analysis) {
1264
+ return [
1265
+ ...(analysis.redirects || []).map((redirect) => ({ redirect, shell: analysis.shell })),
1266
+ ...(analysis.leaves || []).flatMap((leaf) => (leaf.nested ? redirectsWithShell(leaf.nested) : [])),
1267
+ ];
1268
+ }
1269
+
1270
+ function leafOptions(leaf, options) {
1271
+ return {
1272
+ ...options,
1273
+ shell: leaf.shell || options.shell,
1274
+ cwd: typeof leaf.cwd === "string" && leaf.cwd ? leaf.cwd : options.cwd,
1275
+ };
1276
+ }
1277
+
1278
+ const CHDIR_NAMES = new Set(["cd", "chdir", "pushd", "popd", "set-location", "sl", "push-location", "pop-location"]);
1279
+ // Wrappers that run their payload from a different directory. `-C` is matched case-sensitively because
1280
+ // `git -c key=value` is configuration, not a directory change.
1281
+ function runnerWorkingDirectory(name, args) {
1282
+ const flags = ["git", "tar", "make", "gmake"].includes(name)
1283
+ ? ["-C", "--directory"]
1284
+ : name === "env"
1285
+ ? ["--chdir"]
1286
+ : [];
1287
+ if (!flags.length) {
1288
+ return undefined;
1289
+ }
1290
+
1291
+ const list = (args || []).map((arg) => String(arg));
1292
+ for (let index = 0; index < list.length; index += 1) {
1293
+ const arg = list[index];
1294
+ for (const flag of flags) {
1295
+ if (arg === flag) {
1296
+ return list[index + 1];
1297
+ }
1298
+
1299
+ if (arg.startsWith(`${flag}=`)) {
1300
+ return arg.slice(flag.length + 1);
1301
+ }
1302
+
1303
+ if (/^-[A-Za-z]$/.test(flag) && arg.startsWith(flag) && arg.length > flag.length) {
1304
+ return arg.slice(flag.length);
1305
+ }
1306
+ }
1307
+ }
1308
+
1309
+ return undefined;
1310
+ }
1311
+
1312
+ // Threads the working directory through a command sequence. Every relative target used to resolve against the
1313
+ // SESSION cwd no matter what ran before it, so `cd / && rm -rf usr` was reported as a determinate, clean delete
1314
+ // of <session>/usr while it actually removed /usr — and the same held for `Set-Location C:\` and `cd /d C:\`.
1315
+ // A directory change the analyzer cannot resolve poisons every later leaf rather than being ignored.
1316
+ function applyWorkingDirectories(analysis, options) {
1317
+ let anyUnresolved = false;
1318
+ const resolve = (target, base) => {
1319
+ if (base === undefined) {
1320
+ return undefined;
1321
+ }
1322
+
1323
+ const result = classifyPath(target, { ...options, cwd: base });
1324
+
1325
+ return typeof result.lexical === "string" && result.lexical ? result.lexical : undefined;
1326
+ };
1327
+
1328
+ // Each nested block keeps its own directory state: a `cd` inside `bash -c '…'` or behind an argv-prefix
1329
+ // runner must not leak back out into the enclosing sequence.
1330
+ const walk = (node, inherited, inheritedUnresolved) => {
1331
+ let current = inherited,
1332
+ unresolved = inheritedUnresolved;
1333
+ for (const leaf of node.leaves || []) {
1334
+ const name = normalized(leaf.executable);
1335
+ const runner = runnerWorkingDirectory(name, leaf.args);
1336
+ let leafCwd = unresolved ? undefined : current;
1337
+ let leafUnresolved = unresolved;
1338
+ if (runner !== undefined) {
1339
+ leafCwd = runner === "[dynamic]" ? undefined : resolve(runner, current);
1340
+ leafUnresolved = unresolved || leafCwd === undefined;
1341
+ }
1342
+
1343
+ leaf.cwd = leafCwd;
1344
+ leaf.unresolvedCwd = leafUnresolved;
1345
+ anyUnresolved = anyUnresolved || leafUnresolved;
1346
+ if (leaf.nested) {
1347
+ walk(leaf.nested, leafCwd, leafUnresolved);
1348
+ }
1349
+
1350
+ if (!CHDIR_NAMES.has(name)) {
1351
+ continue;
1352
+ }
1353
+
1354
+ const targets = pathArguments(leaf.args || [], { ...options, shell: leaf.shell || options.shell });
1355
+ if (name === "popd" || targets.length > 1 || (!targets.length && (leaf.args || []).length)) {
1356
+ unresolved = true;
1357
+ } else if (!targets.length) {
1358
+ // A bare `cd` returns to the home directory in every shell that treats it as a directory change.
1359
+ current = /^(?:cmd|cmd\.exe)$/i.test(String(leaf.shell || options.shell || ""))
1360
+ ? current
1361
+ : resolve("~", current);
1362
+ unresolved = unresolved || current === undefined;
1363
+ } else {
1364
+ current = resolve(targets[0], current);
1365
+ unresolved = unresolved || current === undefined;
1366
+ }
1367
+
1368
+ anyUnresolved = anyUnresolved || unresolved;
1369
+ }
1370
+ };
1371
+
1372
+ walk(analysis, typeof options.cwd === "string" ? options.cwd : undefined, false);
1373
+
1374
+ return anyUnresolved;
1375
+ }
1376
+
1377
+ function nestedNames(leaf) {
1378
+ return [
1379
+ normalized(leaf.executable),
1380
+ ...(leaf.nested ? flatten(leaf.nested).map((entry) => normalized(entry.executable)) : []),
1381
+ ];
1382
+ }
1383
+
1384
+ function pipelineGroups(analysis) {
1385
+ const groups = [],
1386
+ leaves = analysis.leaves || [];
1387
+ const explicit = new Map();
1388
+ for (const leaf of leaves) {
1389
+ if (Number.isInteger(leaf.pipelineGroup)) {
1390
+ explicit.set(leaf.pipelineGroup, [...(explicit.get(leaf.pipelineGroup) || []), leaf]);
1391
+ }
1392
+ }
1393
+
1394
+ for (const group of explicit.values()) {
1395
+ if (group.length > 1) {
1396
+ groups.push(group);
1397
+ }
1398
+ }
1399
+
1400
+ let current = [];
1401
+ for (const leaf of leaves) {
1402
+ if (leaf.executable === "<redirect>") {
1403
+ continue;
1404
+ }
1405
+
1406
+ if (leaf.separatorBefore !== "|" && current.length) {
1407
+ if (current.length > 1) {
1408
+ groups.push(current);
1409
+ }
1410
+
1411
+ current = [];
1412
+ }
1413
+
1414
+ current.push(leaf);
1415
+ if (leaf.nested) {
1416
+ groups.push(...pipelineGroups(leaf.nested));
1417
+ }
1418
+ }
1419
+
1420
+ if (current.length > 1) {
1421
+ groups.push(current);
1422
+ }
1423
+
1424
+ return groups;
1425
+ }
1426
+
1427
+ // Verbs in COMMAND POSITION — the start of the text or just past a command separator. Accepting them after any
1428
+ // whitespace made `echo rd /s /q C:\Windows` a match, and the scan runs on text nobody could parse, so a false
1429
+ // critical here is an immutable denial over a command that only printed a string.
1430
+ const TEXT_DESTRUCTIVE_VERB =
1431
+ /(?:^|[;&|({\n\r]|&&|\|\|)\s*(?:rm|rmdir|shred|srm|del|erase|rd|remove-item|ri|clear-item|mkfs(?:\.[a-z0-9]+)?|dd|wipefs|diskpart|format|takeown|move-item)(?:[\s;&|)}]|$)/i;
1432
+ const TEXT_DIRECTORY_VERB =
1433
+ /(?:^|[;&|({\n\r]|&&|\|\|)\s*(?:cd|chdir|set-location|sl|pushd|push-location)(?:[\s;&|)}]|$)/i;
1434
+ const TEXT_STANDALONE_CATASTROPHE =
1435
+ /(?:^|[;&|({\n\r]|&&|\|\|)\s*(?:shutdown|reboot|halt|poweroff|stop-computer|restart-computer)(?:[\s;&|)}]|$)|:\s*\(\s*\)\s*\{[^}]*\|[^}]*&[^}]*\}\s*;\s*:/i;
1436
+ const ESCAPED_WHITESPACE = "\u0000";
1437
+
1438
+ function substitutionEnd(text, start, shell, delimiter) {
1439
+ const powershell = /^(?:powershell|pwsh)$/.test(shell);
1440
+ const bash = /^(?:bash|sh|zsh|dash|ksh|fish)$/.test(shell);
1441
+ let depth = delimiter === ")" ? 1 : 0;
1442
+ let quote = "";
1443
+ for (let index = start; index < text.length; index += 1) {
1444
+ const character = text[index];
1445
+ const next = text[index + 1];
1446
+ if (quote) {
1447
+ if (powershell && quote === "'" && character === "'" && next === "'") {
1448
+ index += 1;
1449
+ continue;
1450
+ }
1451
+
1452
+ if (powershell && quote === '"' && character === "`" && next !== undefined) {
1453
+ index += 1;
1454
+ continue;
1455
+ }
1456
+
1457
+ if (bash && quote === '"' && character === "\\" && /[$`"\\\n]/.test(next || "")) {
1458
+ index += 1;
1459
+ continue;
1460
+ }
1461
+
1462
+ if (character === quote) {
1463
+ quote = "";
1464
+ }
1465
+
1466
+ continue;
1467
+ }
1468
+
1469
+ if ((powershell && character === "`") || (bash && character === "\\")) {
1470
+ if (next !== undefined) {
1471
+ index += 1;
1472
+ }
1473
+
1474
+ continue;
1475
+ }
1476
+
1477
+ if (character === "'" || character === '"') {
1478
+ quote = character;
1479
+ continue;
1480
+ }
1481
+
1482
+ if (delimiter === "`" && character === "`") {
1483
+ return index;
1484
+ }
1485
+
1486
+ if (delimiter === ")") {
1487
+ if (character === "(") {
1488
+ depth += 1;
1489
+ } else if (character === ")") {
1490
+ depth -= 1;
1491
+ if (depth === 0) {
1492
+ return index;
1493
+ }
1494
+ }
1495
+ }
1496
+ }
1497
+
1498
+ return text.length - 1;
1499
+ }
1500
+
1501
+ function executableSubstitutions(text, shell) {
1502
+ const powershell = /^(?:powershell|pwsh)$/.test(shell);
1503
+ const bash = /^(?:bash|sh|zsh|dash|ksh|fish)$/.test(shell);
1504
+ const found = [];
1505
+ let quote = "";
1506
+ for (let index = 0; index < text.length; index += 1) {
1507
+ const character = text[index];
1508
+ const next = text[index + 1];
1509
+ if (quote === "'") {
1510
+ if (powershell && character === "'" && next === "'") {
1511
+ index += 1;
1512
+ } else if (character === "'") {
1513
+ quote = "";
1514
+ }
1515
+
1516
+ continue;
1517
+ }
1518
+
1519
+ if (!quote && powershell && character === "<" && next === "#") {
1520
+ const end = text.indexOf("#>", index + 2);
1521
+ index = end < 0 ? text.length : end + 1;
1522
+ continue;
1523
+ }
1524
+
1525
+ const lineComment =
1526
+ !quote && character === "#" && (powershell || (bash && (index === 0 || /[\s;&|()]/.test(text[index - 1]))));
1527
+ if (lineComment) {
1528
+ const end = text.indexOf("\n", index + 1);
1529
+ index = end < 0 ? text.length : end;
1530
+ continue;
1531
+ }
1532
+
1533
+ if (powershell && character === "`" && next !== undefined) {
1534
+ index += 1;
1535
+ continue;
1536
+ }
1537
+
1538
+ if (bash && character === "\\" && next !== undefined && quote !== "'") {
1539
+ index += 1;
1540
+ continue;
1541
+ }
1542
+
1543
+ if (character === "'") {
1544
+ quote = "'";
1545
+ continue;
1546
+ }
1547
+
1548
+ if (character === '"') {
1549
+ quote = quote === '"' ? "" : '"';
1550
+ continue;
1551
+ }
1552
+
1553
+ if ((powershell || bash) && character === "$" && next === "(") {
1554
+ const end = substitutionEnd(text, index + 2, shell, ")");
1555
+ found.push({ start: index, end, payload: text.slice(index + 2, end) });
1556
+ index = end;
1557
+ continue;
1558
+ }
1559
+
1560
+ if (bash && character === "`") {
1561
+ const end = substitutionEnd(text, index + 1, shell, "`");
1562
+ found.push({ start: index, end, payload: text.slice(index + 1, end) });
1563
+ index = end;
1564
+ }
1565
+ }
1566
+
1567
+ return found;
1568
+ }
1569
+
1570
+ // Produces cooked statement views without executing or expanding input. `syntax` blanks quoted argument content
1571
+ // so inert data cannot invent command-position verbs or separators. `arguments` retains cooked quoted values so
1572
+ // a real destructive command still carries paths such as `Remove-Item "C:\Windows"` into target classification.
1573
+ function fallbackProjection(text, shell) {
1574
+ const shellName = String(shell || "").toLowerCase();
1575
+ const powershell = /^(?:powershell|pwsh)$/.test(shellName);
1576
+ const bash = /^(?:bash|sh|zsh|dash|ksh|fish)$/.test(shellName);
1577
+ const cmd = /^(?:cmd|cmd\.exe)$/.test(shellName);
1578
+ const statements = [];
1579
+ const substitutions = executableSubstitutions(text, shellName);
1580
+ const substitutionAt = new Map(substitutions.map((item) => [item.start, item]));
1581
+ let syntax = "",
1582
+ argumentsText = "",
1583
+ wholeSyntax = "",
1584
+ quote = "";
1585
+ const append = (syntaxText, argumentText = syntaxText) => {
1586
+ syntax += syntaxText;
1587
+ argumentsText += argumentText;
1588
+ wholeSyntax += syntaxText;
1589
+ };
1590
+
1591
+ const flush = () => {
1592
+ if (syntax.trim() || argumentsText.trim()) {
1593
+ statements.push({ syntax, arguments: argumentsText });
1594
+ }
1595
+
1596
+ syntax = "";
1597
+ argumentsText = "";
1598
+ };
1599
+
1600
+ for (let index = 0; index < text.length; index += 1) {
1601
+ const character = text[index];
1602
+ const next = text[index + 1];
1603
+ const substitution = substitutionAt.get(index);
1604
+ if (substitution) {
1605
+ const blanks = " ".repeat(substitution.end - substitution.start + 1);
1606
+ append(blanks, blanks);
1607
+ index = substitution.end;
1608
+ continue;
1609
+ }
1610
+
1611
+ if (quote) {
1612
+ if (powershell && quote === "'" && character === "'" && next === "'") {
1613
+ append(" ", "''");
1614
+ index += 1;
1615
+ continue;
1616
+ }
1617
+
1618
+ if (powershell && quote === '"' && character === "`" && next !== undefined) {
1619
+ append(" ", character + next);
1620
+ index += 1;
1621
+ continue;
1622
+ }
1623
+
1624
+ if (bash && quote === '"' && character === "\\" && /[$`"\\\n]/.test(next || "")) {
1625
+ append(" ", character + next);
1626
+ index += 1;
1627
+ continue;
1628
+ }
1629
+
1630
+ if (character === quote) {
1631
+ quote = "";
1632
+ append(" ", character);
1633
+ } else {
1634
+ append(" ", character);
1635
+ }
1636
+
1637
+ continue;
1638
+ }
1639
+
1640
+ if (powershell && character === "`" && next !== undefined) {
1641
+ if (next === "\n" || (next === "\r" && text[index + 2] === "\n")) {
1642
+ index += next === "\r" ? 2 : 1;
1643
+ } else {
1644
+ append(
1645
+ /[;&|\s]/.test(next) ? " " : next,
1646
+ /\s/.test(next) ? ESCAPED_WHITESPACE : /[;&|]/.test(next) ? character + next : next,
1647
+ );
1648
+ index += 1;
1649
+ }
1650
+
1651
+ continue;
1652
+ }
1653
+
1654
+ if (bash && character === "\\" && next !== undefined) {
1655
+ if (next === "\n") {
1656
+ index += 1;
1657
+ } else {
1658
+ append(
1659
+ /[;&|\s]/.test(next) ? " " : next,
1660
+ /\s/.test(next) ? ESCAPED_WHITESPACE : /[;&|]/.test(next) ? character + next : next,
1661
+ );
1662
+ index += 1;
1663
+ }
1664
+
1665
+ continue;
1666
+ }
1667
+
1668
+ if (cmd && character === "^" && next !== undefined) {
1669
+ if (next === "\n" || (next === "\r" && text[index + 2] === "\n")) {
1670
+ index += next === "\r" ? 2 : 1;
1671
+ } else {
1672
+ append(/[;&|\s]/.test(next) ? " " : next, /\s/.test(next) ? ESCAPED_WHITESPACE : character + next);
1673
+ index += 1;
1674
+ }
1675
+
1676
+ continue;
1677
+ }
1678
+
1679
+ if (powershell && character === "<" && next === "#") {
1680
+ const end = text.indexOf("#>", index + 2);
1681
+ index = end < 0 ? text.length : end + 1;
1682
+ continue;
1683
+ }
1684
+
1685
+ const lineComment =
1686
+ character === "#" && (powershell || (bash && (index === 0 || /[\s;&|()]/.test(text[index - 1]))));
1687
+ if (lineComment) {
1688
+ const end = text.indexOf("\n", index + 1);
1689
+ flush();
1690
+ wholeSyntax += "\n";
1691
+ index = end < 0 ? text.length : end;
1692
+ continue;
1693
+ }
1694
+
1695
+ if (character === "'" || character === '"') {
1696
+ quote = character;
1697
+ append(" ", character);
1698
+ continue;
1699
+ }
1700
+
1701
+ if ([";", "&", "|", "\n", "\r"].includes(character)) {
1702
+ flush();
1703
+ wholeSyntax += character;
1704
+ if ((character === "&" || character === "|") && next === character) {
1705
+ wholeSyntax += next;
1706
+ index += 1;
1707
+ }
1708
+
1709
+ continue;
1710
+ }
1711
+
1712
+ append(character);
1713
+ }
1714
+
1715
+ flush();
1716
+
1717
+ return {
1718
+ statements,
1719
+ wholeSyntax,
1720
+ embeddedPayloads: substitutions.map((item) => ({ payload: item.payload, shell: shellName })),
1721
+ shell: shellName,
1722
+ };
1723
+ }
1724
+
1725
+ const POWERSHELL_COMMAND_FLAGS = new Set(["-c", "-co", "-com", "-comm", "-comma", "-comman", "-command"]);
1726
+ const POWERSHELL_FILE_FLAGS = new Set(["-f", "-fi", "-fil", "-file"]);
1727
+ const POWERSHELL_ENCODED_FLAGS = new Set([
1728
+ "-e",
1729
+ "-en",
1730
+ "-enc",
1731
+ "-enco",
1732
+ "-encod",
1733
+ "-encode",
1734
+ "-encoded",
1735
+ "-encodedc",
1736
+ "-encodedco",
1737
+ "-encodedcom",
1738
+ "-encodedcomm",
1739
+ "-encodedcomma",
1740
+ "-encodedcomman",
1741
+ "-encodedcommand",
1742
+ ]);
1743
+ const INLINE_HOST_NAMES = new Set([
1744
+ "powershell",
1745
+ "pwsh",
1746
+ "cmd",
1747
+ "bash",
1748
+ "sh",
1749
+ "zsh",
1750
+ "dash",
1751
+ "ksh",
1752
+ "fish",
1753
+ "node",
1754
+ "python",
1755
+ "python3",
1756
+ "py",
1757
+ "deno",
1758
+ "bun",
1759
+ "ruby",
1760
+ "perl",
1761
+ "php",
1762
+ "lua",
1763
+ "rscript",
1764
+ "osascript",
1765
+ "tclsh",
1766
+ "expect",
1767
+ ]);
1768
+
1769
+ function fallbackWords(text, shell) {
1770
+ const powershell = /^(?:powershell|pwsh)$/.test(shell);
1771
+ const bash = /^(?:bash|sh|zsh|dash|ksh|fish)$/.test(shell);
1772
+ const words = [];
1773
+ let word = "",
1774
+ quote = "",
1775
+ quoted = false;
1776
+ const flush = () => {
1777
+ if (word || quoted) {
1778
+ words.push({ value: word.replaceAll(ESCAPED_WHITESPACE, " "), quoted });
1779
+ word = "";
1780
+ quoted = false;
1781
+ }
1782
+ };
1783
+
1784
+ for (let index = 0; index < text.length; index += 1) {
1785
+ const character = text[index];
1786
+ const next = text[index + 1];
1787
+ if (quote) {
1788
+ if (powershell && quote === "'" && character === "'" && next === "'") {
1789
+ word += "'";
1790
+ index += 1;
1791
+ continue;
1792
+ }
1793
+
1794
+ if (powershell && quote === '"' && character === "`" && next !== undefined) {
1795
+ if (next === "\n" || (next === "\r" && text[index + 2] === "\n")) {
1796
+ index += next === "\r" ? 2 : 1;
1797
+ } else {
1798
+ word += next;
1799
+ index += 1;
1800
+ }
1801
+
1802
+ continue;
1803
+ }
1804
+
1805
+ if (bash && quote === '"' && character === "\\" && /[$`"\\\n]/.test(next || "")) {
1806
+ if (next !== "\n") {
1807
+ word += next;
1808
+ }
1809
+
1810
+ index += 1;
1811
+ continue;
1812
+ }
1813
+
1814
+ if (character === quote) {
1815
+ quote = "";
1816
+ } else {
1817
+ word += character;
1818
+ }
1819
+
1820
+ continue;
1821
+ }
1822
+
1823
+ if (character === "'" || character === '"') {
1824
+ quote = character;
1825
+ quoted = true;
1826
+ continue;
1827
+ }
1828
+
1829
+ if (/\s/.test(character)) {
1830
+ flush();
1831
+ } else {
1832
+ word += character;
1833
+ }
1834
+ }
1835
+
1836
+ flush();
1837
+
1838
+ return words;
1839
+ }
1840
+
1841
+ function executableBase(value) {
1842
+ return value
1843
+ .split(/[\\/]/)
1844
+ .pop()
1845
+ .toLowerCase()
1846
+ .replace(/\.exe$/i, "");
1847
+ }
1848
+
1849
+ function inlineHost(words) {
1850
+ let index = 0;
1851
+ while (index < words.length) {
1852
+ const wrapper = executableBase(words[index].value);
1853
+ if (!["sudo", "doas", "env", "command", "exec", "nohup"].includes(wrapper)) {
1854
+ break;
1855
+ }
1856
+
1857
+ index += 1;
1858
+ while (index < words.length) {
1859
+ const option = words[index].value;
1860
+ if (option === "--") {
1861
+ index += 1;
1862
+ break;
1863
+ }
1864
+
1865
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(option)) {
1866
+ index += 1;
1867
+ continue;
1868
+ }
1869
+
1870
+ if (!option.startsWith("-")) {
1871
+ break;
1872
+ }
1873
+
1874
+ const separateValue =
1875
+ ((wrapper === "sudo" || wrapper === "doas") &&
1876
+ [
1877
+ "-u",
1878
+ "--user",
1879
+ "-g",
1880
+ "--group",
1881
+ "-h",
1882
+ "--host",
1883
+ "-p",
1884
+ "--prompt",
1885
+ "-C",
1886
+ "--close-from",
1887
+ "-D",
1888
+ "--chdir",
1889
+ "-R",
1890
+ "--chroot",
1891
+ "-T",
1892
+ "--command-timeout",
1893
+ ].includes(option)) ||
1894
+ (wrapper === "env" && ["-u", "--unset", "-C", "--chdir"].includes(option)) ||
1895
+ (wrapper === "exec" && option === "-a");
1896
+ index += separateValue ? 2 : 1;
1897
+ }
1898
+ }
1899
+
1900
+ if (index >= words.length) {
1901
+ return undefined;
1902
+ }
1903
+
1904
+ const host = executableBase(words[index].value);
1905
+
1906
+ return INLINE_HOST_NAMES.has(host) ? { host, index } : undefined;
1907
+ }
1908
+
1909
+ function inlineHostShell(host) {
1910
+ if (["powershell", "pwsh"].includes(host)) {
1911
+ return "powershell";
1912
+ }
1913
+
1914
+ if (host === "cmd") {
1915
+ return "cmd";
1916
+ }
1917
+
1918
+ if (["bash", "sh", "zsh", "dash", "ksh", "fish"].includes(host)) {
1919
+ return "bash";
1920
+ }
1921
+
1922
+ return host;
1923
+ }
1924
+
1925
+ function decodeInlinePayload(found, encoded, shell) {
1926
+ try {
1927
+ const decoded = Buffer.from(encoded, "base64");
1928
+ if (decoded.length && decoded.length <= 64 * 1024) {
1929
+ found.push({ payload: decoded.toString("utf16le"), shell }, { payload: decoded.toString("utf8"), shell });
1930
+ }
1931
+ } catch {
1932
+ /* an undecodable argument carries nothing to read */
1933
+ }
1934
+ }
1935
+
1936
+ // Returns only payloads selected by the invoked host's positional mode flag. This avoids treating quoted script
1937
+ // data after `powershell -File` as code and preserves the invoked shell family for recursive fallback scanning.
1938
+ function inlineCodePayloads(projection) {
1939
+ const found = [...projection.embeddedPayloads];
1940
+ for (const statement of projection.statements) {
1941
+ const words = fallbackWords(statement.arguments, projection.shell);
1942
+ const invocation = inlineHost(words);
1943
+ if (!invocation) {
1944
+ continue;
1945
+ }
1946
+
1947
+ const { host } = invocation;
1948
+ const payloadShell = inlineHostShell(host);
1949
+ const args = words.slice(invocation.index + 1);
1950
+ if (["powershell", "pwsh"].includes(host)) {
1951
+ for (let index = 0; index < args.length; index += 1) {
1952
+ const flag = args[index].value.toLowerCase();
1953
+ if (POWERSHELL_FILE_FLAGS.has(flag)) {
1954
+ break;
1955
+ }
1956
+
1957
+ if (POWERSHELL_COMMAND_FLAGS.has(flag)) {
1958
+ if (args[index + 1]) {
1959
+ found.push({
1960
+ payload: args
1961
+ .slice(index + 1)
1962
+ .map((word) => word.value)
1963
+ .join(" "),
1964
+ shell: payloadShell,
1965
+ });
1966
+ }
1967
+
1968
+ break;
1969
+ }
1970
+
1971
+ if (POWERSHELL_ENCODED_FLAGS.has(flag)) {
1972
+ if (args[index + 1]) {
1973
+ decodeInlinePayload(found, args[index + 1].value, payloadShell);
1974
+ }
1975
+
1976
+ break;
1977
+ }
1978
+ }
1979
+ } else {
1980
+ const shellHost = ["bash", "sh", "zsh", "dash", "ksh", "fish"].includes(host);
1981
+ const codeFlag = (value) => {
1982
+ const flag = value.toLowerCase();
1983
+ if (host === "cmd") {
1984
+ return flag === "/c" || flag === "/k";
1985
+ }
1986
+
1987
+ if (shellHost) {
1988
+ return flag === "--command" || /^-[a-z]*c[a-z]*$/i.test(flag);
1989
+ }
1990
+
1991
+ return flag === "-e" || flag === "--eval" || flag === "-c" || flag === "--command";
1992
+ };
1993
+
1994
+ let flagIndex = -1;
1995
+ if (shellHost || host === "cmd") {
1996
+ for (let index = 0; index < args.length; index += 1) {
1997
+ const value = args[index].value;
1998
+ if (value === "--") {
1999
+ break;
2000
+ }
2001
+
2002
+ if (codeFlag(value)) {
2003
+ flagIndex = index;
2004
+ break;
2005
+ }
2006
+
2007
+ const option = shellHost ? value.startsWith("-") : value.startsWith("/");
2008
+ if (!option) {
2009
+ break;
2010
+ }
2011
+ }
2012
+ } else {
2013
+ flagIndex = args.findIndex((arg) => codeFlag(arg.value));
2014
+ }
2015
+
2016
+ if (flagIndex >= 0 && args[flagIndex + 1]) {
2017
+ const codeWords = args.slice(flagIndex + 1);
2018
+ let payload = codeWords[0].value;
2019
+ if (host === "cmd" && codeWords.length > 1) {
2020
+ payload = codeWords
2021
+ .map((word) => (word.quoted ? `"${word.value.replaceAll('"', '\\"')}"` : word.value))
2022
+ .join(" ");
2023
+ }
2024
+
2025
+ found.push({ payload, shell: payloadShell });
2026
+ }
2027
+ }
2028
+ }
2029
+
2030
+ let size = 0;
2031
+
2032
+ return found.filter((item) => {
2033
+ size += Buffer.byteLength(item.payload, "utf8");
2034
+
2035
+ return size <= 64 * 1024;
2036
+ });
2037
+ }
2038
+
2039
+ // Pulls the path-shaped tokens out of one cooked fallback statement without needing a successful parse.
2040
+ function textPathTokens(text) {
2041
+ return text
2042
+ .split(/[\s;&|<>(){}`]+/)
2043
+ .map((token) => {
2044
+ const unquoted = token
2045
+ .replaceAll(ESCAPED_WHITESPACE, " ")
2046
+ .replace(/^["']+/, "")
2047
+ .replace(/["']+$/, "");
2048
+ const assigned = unquoted.includes("=") ? unquoted.slice(unquoted.lastIndexOf("=") + 1) : unquoted;
2049
+
2050
+ return assigned.replace(/[,]+$/, "");
2051
+ })
2052
+ .filter((token) => token && (/[\\/]/.test(token) || token === "~" || /^[a-z]:$/i.test(token)));
2053
+ }
2054
+
2055
+ function fallbackDirectoryTarget(statement, shell) {
2056
+ if (!TEXT_DIRECTORY_VERB.test(statement.syntax)) {
2057
+ return undefined;
2058
+ }
2059
+
2060
+ const words = fallbackWords(statement.arguments, shell);
2061
+ const commandIndex = words.findIndex((word) =>
2062
+ ["cd", "chdir", "set-location", "sl", "pushd", "push-location"].includes(executableBase(word.value)),
2063
+ );
2064
+ if (commandIndex < 0) {
2065
+ return undefined;
2066
+ }
2067
+
2068
+ const args = words.slice(commandIndex + 1).map((word) => word.value);
2069
+ const candidates = args.filter((arg) => {
2070
+ if (arg === "--" || /^\/d$/i.test(arg)) {
2071
+ return false;
2072
+ }
2073
+
2074
+ return !arg.startsWith("-");
2075
+ });
2076
+ const target = candidates.at(-1);
2077
+ if (!target || /[$%*?]/.test(target)) {
2078
+ return undefined;
2079
+ }
2080
+
2081
+ return target;
2082
+ }
2083
+
2084
+ function fallbackDestructiveTargets(statement, shell) {
2085
+ const words = fallbackWords(statement.arguments, shell);
2086
+ const commandIndex = words.findIndex((word) => {
2087
+ const name = executableBase(word.value);
2088
+
2089
+ return DELETE_NAMES.has(name) || /^(?:mkfs(?:\.[a-z0-9]+)?|srm|wipefs|diskpart|format|takeown)$/.test(name);
2090
+ });
2091
+ if (commandIndex < 0) {
2092
+ return [];
2093
+ }
2094
+
2095
+ return words
2096
+ .slice(commandIndex + 1)
2097
+ .map((word) => word.value)
2098
+ .filter((arg) => {
2099
+ if (!arg || arg === "--" || arg.startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) {
2100
+ return false;
2101
+ }
2102
+
2103
+ return !/^\/[a-z]+$/i.test(arg);
2104
+ });
2105
+ }
2106
+
2107
+ // A last-resort scan of the RAW command text, used only when the structural parser could not produce a usable
2108
+ // analysis. Without it an infrastructure failure — a helper timeout, a missing interpreter, a blown limit —
2109
+ // silently converts an immutable catastrophic denial into a prompt a person can approve, which is precisely the
2110
+ // case where the guard has the least information and should yield the least ground.
2111
+ export function catastrophicTextScan(command, options = {}) {
2112
+ const raw = String(command || "");
2113
+ if (!raw || Buffer.byteLength(raw, "utf8") > 128 * 1024) {
2114
+ return undefined;
2115
+ }
2116
+
2117
+ // Quoted values are inert for command-position detection but remain arguments of a real destructive command.
2118
+ // Inline-code payloads are scanned separately only when their flag belongs to a host invocation.
2119
+ const projections = [];
2120
+ const queue = [{ payload: raw, shell: options.shell, depth: 0 }];
2121
+ const seen = new Set();
2122
+ let scannedBytes = 0;
2123
+ let truncated = false;
2124
+ while (queue.length && projections.length < 64) {
2125
+ const item = queue.shift();
2126
+ const key = `${item.shell}\u0000${item.payload}`;
2127
+ if (seen.has(key)) {
2128
+ continue;
2129
+ }
2130
+
2131
+ seen.add(key);
2132
+ scannedBytes += Buffer.byteLength(item.payload, "utf8");
2133
+ if (scannedBytes > 128 * 1024) {
2134
+ truncated = true;
2135
+ break;
2136
+ }
2137
+
2138
+ const projection = fallbackProjection(item.payload, item.shell);
2139
+ projections.push(projection);
2140
+ const payloads = inlineCodePayloads(projection);
2141
+ if (item.depth < 8) {
2142
+ queue.push(...payloads.map((payload) => ({ ...payload, depth: item.depth + 1 })));
2143
+ } else if (payloads.some((payload) => !seen.has(`${payload.shell}\u0000${payload.payload}`))) {
2144
+ truncated = true;
2145
+ }
2146
+ }
2147
+
2148
+ truncated ||= queue.length > 0;
2149
+ if (truncated) {
2150
+ return {
2151
+ action: "deny",
2152
+ severity: "critical",
2153
+ category: "dynamic",
2154
+ ruleIds: ["parser.unanalyzed-catastrophe"],
2155
+ leaves: [],
2156
+ reason: "The unparsed command contains executable payload nesting beyond the fallback safety limit.",
2157
+ indeterminate: true,
2158
+ lockSession: false,
2159
+ };
2160
+ }
2161
+
2162
+ if (
2163
+ projections.some(
2164
+ (projection) =>
2165
+ TEXT_STANDALONE_CATASTROPHE.test(projection.wholeSyntax) ||
2166
+ projection.statements.some((statement) => TEXT_STANDALONE_CATASTROPHE.test(statement.syntax)),
2167
+ )
2168
+ ) {
2169
+ return {
2170
+ action: "deny",
2171
+ severity: "critical",
2172
+ category: "system",
2173
+ ruleIds: ["parser.unanalyzed-catastrophe"],
2174
+ leaves: [],
2175
+ reason: "The command could not be parsed and its text carries a host-destroying operation.",
2176
+ indeterminate: true,
2177
+ lockSession: false,
2178
+ };
2179
+ }
2180
+
2181
+ const scanOptions = { ...options, mode: "guard", read: false };
2182
+ let reaches = false;
2183
+ for (const projection of projections) {
2184
+ let projectedCwd = scanOptions.cwd;
2185
+ let changedDirectory = false;
2186
+ for (const statement of projection.statements) {
2187
+ const directoryTarget = fallbackDirectoryTarget(statement, projection.shell);
2188
+ if (directoryTarget) {
2189
+ try {
2190
+ projectedCwd = classifyPath(directoryTarget, { ...scanOptions, cwd: projectedCwd }).lexical;
2191
+ changedDirectory = true;
2192
+ } catch {
2193
+ changedDirectory = false;
2194
+ }
2195
+ }
2196
+
2197
+ if (!TEXT_DESTRUCTIVE_VERB.test(statement.syntax)) {
2198
+ continue;
2199
+ }
2200
+
2201
+ const targets = textPathTokens(statement.arguments);
2202
+ if (changedDirectory) {
2203
+ targets.push(...fallbackDestructiveTargets(statement, projection.shell));
2204
+ }
2205
+
2206
+ reaches = targets.some((token) => {
2207
+ const scoped = { ...scanOptions, cwd: projectedCwd };
2208
+ try {
2209
+ return classifyPath(token, scoped).protected || isAgentPath(token, scoped);
2210
+ } catch {
2211
+ return false;
2212
+ }
2213
+ });
2214
+ if (reaches) {
2215
+ break;
2216
+ }
2217
+ }
2218
+
2219
+ if (reaches) {
2220
+ break;
2221
+ }
2222
+ }
2223
+
2224
+ return reaches
2225
+ ? {
2226
+ action: "deny",
2227
+ severity: "critical",
2228
+ category: "filesystem",
2229
+ ruleIds: ["parser.unanalyzed-catastrophe"],
2230
+ leaves: [],
2231
+ reason: "The command could not be parsed and its text targets a protected host or enforcement path.",
2232
+ indeterminate: true,
2233
+ lockSession: false,
2234
+ }
2235
+ : undefined;
2236
+ }
2237
+
2238
+ export function evaluateRules(analysis, options = {}) {
2239
+ const findings = [];
2240
+ const unresolvedWorkingDirectory = applyWorkingDirectories(analysis, options);
2241
+ const allLeaves = flatten(analysis);
2242
+ const criticalOnly = options.criticalOnly === true;
2243
+ if (unresolvedWorkingDirectory) {
2244
+ findings.push({
2245
+ action: "ask",
2246
+ severity: "high",
2247
+ category: "dynamic",
2248
+ ruleIds: ["parser.indeterminate"],
2249
+ leaves: [],
2250
+ reason: "A directory change could not be resolved, so later targets are ambiguous; approval is required.",
2251
+ indeterminate: true,
2252
+ });
2253
+ }
2254
+
2255
+ if (!criticalOnly) {
2256
+ const readsProtectedInput = allLeaves.some(
2257
+ (leaf) =>
2258
+ READ_NAMES.has(normalized(leaf.executable)) &&
2259
+ (leaf.args || []).some(
2260
+ (arg) => classifyPath(arg, { ...leafOptions(leaf, options), read: true }).protected,
2261
+ ),
2262
+ );
2263
+ if (readsProtectedInput) {
2264
+ findings.push({
2265
+ action: "ask",
2266
+ severity: "high",
2267
+ category: "security",
2268
+ ruleIds: ["credential.protected-read"],
2269
+ leaves: [],
2270
+ reason: "Reading credential or Pi private-state paths needs approval in Strict mode.",
2271
+ });
2272
+ }
2273
+
2274
+ if (
2275
+ allLeaves.some((leaf) => {
2276
+ const name = normalized(leaf.executable);
2277
+ if (!SHELL_NAMES.has(name)) {
2278
+ return false;
2279
+ }
2280
+
2281
+ const semanticShell = ["cmd", "cmd.exe"].includes(name)
2282
+ ? "cmd"
2283
+ : ["powershell", "pwsh"].includes(name)
2284
+ ? "powershell"
2285
+ : leaf.shell;
2286
+ const scoped = { ...leafOptions(leaf, options), shell: semanticShell, read: true };
2287
+ const args = leaf.args || [];
2288
+ const inline = args.findIndex((arg) => inlineCodeFlag(name, arg));
2289
+ const candidateArgs = inline >= 0 ? args.slice(0, inline) : args;
2290
+
2291
+ return pathArguments(candidateArgs, scoped).some((arg) => classifyPath(arg, scoped).protected);
2292
+ })
2293
+ ) {
2294
+ findings.push({
2295
+ action: "ask",
2296
+ severity: "high",
2297
+ category: "security",
2298
+ ruleIds: ["credential.execution"],
2299
+ leaves: [],
2300
+ reason: "Credential or private-state content connected to an interpreter needs approval in Strict mode.",
2301
+ });
2302
+ }
2303
+
2304
+ for (const group of pipelineGroups(analysis)) {
2305
+ const names = group.map((leaf) => nestedNames(leaf)).flat();
2306
+ const protectedStage = group.some(
2307
+ (leaf) =>
2308
+ READ_NAMES.has(normalized(leaf.executable)) &&
2309
+ (leaf.args || []).some(
2310
+ (arg) => classifyPath(arg, { ...leafOptions(leaf, options), read: true }).protected,
2311
+ ),
2312
+ );
2313
+ if (names.some((name) => DOWNLOAD_NAMES.has(name)) && names.some((name) => SHELL_NAMES.has(name))) {
2314
+ findings.push({
2315
+ action: "ask",
2316
+ severity: "high",
2317
+ category: "dynamic",
2318
+ ruleIds: ["exec.download-pipe"],
2319
+ leaves: [],
2320
+ reason: "Downloaded content connected to an interpreter could not be inspected.",
2321
+ indeterminate: true,
2322
+ });
2323
+ }
2324
+
2325
+ if (
2326
+ names.some((name) => DECODER_NAMES.has(name)) &&
2327
+ names.some((name) => SHELL_NAMES.has(name)) &&
2328
+ !group.some((leaf) => leaf.decodedInput)
2329
+ ) {
2330
+ findings.push({
2331
+ action: "ask",
2332
+ severity: "high",
2333
+ category: "dynamic",
2334
+ ruleIds: ["exec.generated-pipe"],
2335
+ leaves: [],
2336
+ reason: "Decoded or generated content connected to an interpreter could not be inspected.",
2337
+ indeterminate: true,
2338
+ });
2339
+ }
2340
+
2341
+ if (protectedStage && names.some((name) => NETWORK_NAMES.has(name))) {
2342
+ findings.push({
2343
+ action: "ask",
2344
+ severity: "high",
2345
+ category: "security",
2346
+ ruleIds: ["credential.exfiltration"],
2347
+ leaves: [],
2348
+ reason: "Credential or private-key data connected to network transfer needs approval in Strict mode.",
2349
+ });
2350
+ }
2351
+ }
2352
+
2353
+ if (
2354
+ allLeaves.some(
2355
+ (leaf) =>
2356
+ NETWORK_NAMES.has(normalized(leaf.executable)) &&
2357
+ (leaf.args || []).some(
2358
+ (arg) =>
2359
+ classifyPath(String(arg).replace(/^@/, ""), { ...leafOptions(leaf, options), read: true })
2360
+ .protected,
2361
+ ),
2362
+ )
2363
+ ) {
2364
+ findings.push({
2365
+ action: "ask",
2366
+ severity: "high",
2367
+ category: "security",
2368
+ ruleIds: ["credential.exfiltration"],
2369
+ leaves: [],
2370
+ reason: "A network command targeting credential or private-key data needs approval in Strict mode.",
2371
+ });
2372
+ }
2373
+ }
2374
+
2375
+ for (const leaf of allLeaves) {
2376
+ const optionsForLeaf = leafOptions(leaf, options);
2377
+ const criticalFinding = criticalLeaf(leaf, optionsForLeaf);
2378
+ if (criticalFinding) {
2379
+ findings.push(criticalFinding);
2380
+ } else if (!criticalOnly) {
2381
+ const finding = ordinaryLeaf(leaf, optionsForLeaf);
2382
+ if (finding) {
2383
+ findings.push(finding);
2384
+ }
2385
+ }
2386
+ }
2387
+
2388
+ for (const entry of redirectsWithShell(analysis)) {
2389
+ const redirect = entry.redirect;
2390
+ const redirectOptions = { ...options, shell: entry.shell || options.shell };
2391
+ const target = typeof redirect === "string" ? undefined : redirect.target || redirect.targetLiteral;
2392
+ const output =
2393
+ typeof redirect === "string"
2394
+ ? redirect.includes(">")
2395
+ : redirect.operator
2396
+ ? String(redirect.operator).includes(">")
2397
+ : true;
2398
+ if (criticalOnly && !output) {
2399
+ continue;
2400
+ }
2401
+
2402
+ if (target && classifyPath(target, { ...redirectOptions, read: !output }).protected) {
2403
+ findings.push(
2404
+ output
2405
+ ? {
2406
+ action: "deny",
2407
+ severity: "critical",
2408
+ category: "protected-path",
2409
+ ruleIds: ["guard.redirect-tamper"],
2410
+ leaves: [],
2411
+ reason: "A shell redirect targets a protected path.",
2412
+ lockSession: true,
2413
+ }
2414
+ : {
2415
+ action: "ask",
2416
+ severity: "high",
2417
+ category: "security",
2418
+ ruleIds: ["credential.protected-read"],
2419
+ leaves: [],
2420
+ reason: "A shell redirect reads credential or private-state data and needs approval in Strict mode.",
2421
+ },
2422
+ );
2423
+ } else if (output && !criticalOnly) {
2424
+ // A redirect into the workspace is ordinary file writing; one that escapes it, or whose target could not
2425
+ // be resolved, still needs approval.
2426
+ const resolved = target ? classifyPath(target, redirectOptions) : undefined;
2427
+ const inWorkspace = options.mode === "guard" && resolved && !resolved.protected && resolved.withinWorkspace;
2428
+ if (!inWorkspace) {
2429
+ findings.push({
2430
+ action: "ask",
2431
+ severity: "high",
2432
+ category: "filesystem",
2433
+ ruleIds: ["filesystem.redirect"],
2434
+ leaves: [],
2435
+ reason: "A shell redirect may mutate a file and needs approval.",
2436
+ });
2437
+ }
2438
+ }
2439
+ }
2440
+
2441
+ if (
2442
+ !criticalOnly &&
2443
+ (analysis.dynamicConstructs || []).some((entry) => entry.kind === "resolved-command-execution")
2444
+ ) {
2445
+ findings.push({
2446
+ action: "ask",
2447
+ severity: "medium",
2448
+ category: "dynamic",
2449
+ ruleIds: ["dynamic.generated-code"],
2450
+ leaves: [],
2451
+ reason: "Strict mode requires approval for nested command execution.",
2452
+ indeterminate: false,
2453
+ });
2454
+ }
2455
+
2456
+ if (
2457
+ !criticalOnly &&
2458
+ (analysis.indeterminate ||
2459
+ (analysis.dynamicConstructs || []).some((entry) =>
2460
+ /substitution|environment-expansion|dynamic|depth|limit|batch|heredoc|stopparsing/i.test(entry.kind),
2461
+ ))
2462
+ ) {
2463
+ findings.push({
2464
+ action: "ask",
2465
+ severity: "high",
2466
+ category: "dynamic",
2467
+ ruleIds: ["parser.indeterminate"],
2468
+ leaves: [],
2469
+ reason: "The command could not be completely analyzed; approval is required.",
2470
+ indeterminate: true,
2471
+ });
2472
+ }
2473
+
2474
+ return findings;
2475
+ }
2476
+
2477
+ export const matchRules = evaluateRules;
2478
+ export const ruleCatalog = Object.freeze({
2479
+ version: POLICY_VERSION,
2480
+ ruleIds: Object.freeze([
2481
+ "boot.destructive",
2482
+ "cloud.infrastructure-mutation",
2483
+ "cloud.resource-delete",
2484
+ "container.cluster-mutation",
2485
+ "container.destructive",
2486
+ "credential.environment-read",
2487
+ "credential.execution",
2488
+ "credential.exfiltration",
2489
+ "credential.protected-read",
2490
+ "credential.store-read",
2491
+ "database.destructive",
2492
+ "database.migration-rollback",
2493
+ "disk.destructive",
2494
+ "filesystem.outside-workspace",
2495
+ "dynamic.generated-code",
2496
+ "dynamic.inline-code",
2497
+ "dynamic.local-script",
2498
+ "environment.mutation",
2499
+ "exec.download-pipe",
2500
+ "exec.generated-pipe",
2501
+ "execution.plugins-or-hooks",
2502
+ "filesystem.broad-mutation",
2503
+ "filesystem.find-mutation",
2504
+ "filesystem.mutation",
2505
+ "filesystem.recursive-delete",
2506
+ "filesystem.redirect",
2507
+ "filesystem.write",
2508
+ "fs.root-recursive-delete",
2509
+ "git.destructive",
2510
+ "git.force-push",
2511
+ "guard.redirect-tamper",
2512
+ "guard.self-tamper",
2513
+ "network.or.remote",
2514
+ "package.mutation",
2515
+ "package.registry-mutation",
2516
+ "parser.indeterminate",
2517
+ "parser.unanalyzed-catastrophe",
2518
+ "path.protected-mutation",
2519
+ "parser.integrity",
2520
+ "parser.syntax",
2521
+ "path.canonicalization",
2522
+ "path.protected",
2523
+ "policy.integrity",
2524
+ "process.broad-kill",
2525
+ "process.fork-bomb",
2526
+ "process.termination",
2527
+ "registry.delete",
2528
+ "registry.mutation",
2529
+ "registry.system-delete",
2530
+ "security.disable",
2531
+ "security.identity-or-permission",
2532
+ "security.protected-permissions",
2533
+ "service.mutation",
2534
+ "system.critical",
2535
+ "system.registration-delete",
2536
+ "session.locked",
2537
+ "shell.syntax-mismatch",
2538
+ "strict.execution",
2539
+ "strict.mutation",
2540
+ "tool.unknown-capability",
2541
+ "windows.provider-critical",
2542
+ ]),
2543
+ critical: ["filesystem-root", "disk", "boot", "host-security", "broad-process", "guard-tamper", "download-execute"],
2544
+ high: [
2545
+ "filesystem",
2546
+ "git",
2547
+ "database",
2548
+ "container",
2549
+ "cloud",
2550
+ "package-publish",
2551
+ "service",
2552
+ "registry",
2553
+ "identity",
2554
+ "network",
2555
+ ],
2556
+ medium: ["package-install", "process-termination"],
2557
+ });