@blogic-cz/agent-tools 1.11.1 → 1.13.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 (100) hide show
  1. package/README.md +18 -43
  2. package/dist/credential-guard/bash.d.ts +3 -0
  3. package/dist/credential-guard/bash.d.ts.map +1 -0
  4. package/dist/credential-guard/claude-hook.d.ts.map +1 -1
  5. package/dist/credential-guard/engine.test.d.ts +5 -0
  6. package/dist/credential-guard/engine.test.d.ts.map +1 -0
  7. package/dist/credential-guard/index.d.ts +8 -35
  8. package/dist/credential-guard/index.d.ts.map +1 -1
  9. package/dist/credential-guard/ir.d.ts +150 -0
  10. package/dist/credential-guard/ir.d.ts.map +1 -0
  11. package/dist/credential-guard/literal-output.d.ts +2 -0
  12. package/dist/credential-guard/literal-output.d.ts.map +1 -0
  13. package/dist/credential-guard/passive-awk.d.ts +2 -0
  14. package/dist/credential-guard/passive-awk.d.ts.map +1 -0
  15. package/dist/credential-guard/passive-jq.d.ts.map +1 -1
  16. package/dist/credential-guard/passive-sed.d.ts +2 -0
  17. package/dist/credential-guard/passive-sed.d.ts.map +1 -0
  18. package/dist/credential-guard/passive.test.d.ts +2 -0
  19. package/dist/credential-guard/passive.test.d.ts.map +1 -0
  20. package/dist/credential-guard/paths.d.ts +14 -0
  21. package/dist/credential-guard/paths.d.ts.map +1 -0
  22. package/dist/credential-guard/policy.d.ts +47 -0
  23. package/dist/credential-guard/policy.d.ts.map +1 -0
  24. package/dist/credential-guard/powershell.d.ts +3 -0
  25. package/dist/credential-guard/powershell.d.ts.map +1 -0
  26. package/dist/credential-guard/registry.d.ts +89 -0
  27. package/dist/credential-guard/registry.d.ts.map +1 -0
  28. package/dist/credential-guard/review4.test.d.ts +2 -0
  29. package/dist/credential-guard/review4.test.d.ts.map +1 -0
  30. package/dist/credential-guard/review7.test.d.ts +2 -0
  31. package/dist/credential-guard/review7.test.d.ts.map +1 -0
  32. package/dist/credential-guard/shell-oracle.d.ts +2 -0
  33. package/dist/credential-guard/shell-oracle.d.ts.map +1 -0
  34. package/dist/credential-guard/xargs.d.ts +7 -0
  35. package/dist/credential-guard/xargs.d.ts.map +1 -0
  36. package/dist/gh-tool/pr/checkbox-markdown.d.ts +22 -0
  37. package/dist/gh-tool/pr/checkbox-markdown.d.ts.map +1 -0
  38. package/dist/gh-tool/pr/checkbox.d.ts +21 -0
  39. package/dist/gh-tool/pr/checkbox.d.ts.map +1 -0
  40. package/dist/gh-tool/pr/commands.d.ts +9 -0
  41. package/dist/gh-tool/pr/commands.d.ts.map +1 -1
  42. package/dist/gh-tool/pr/index.d.ts +1 -1
  43. package/dist/gh-tool/pr/index.d.ts.map +1 -1
  44. package/dist/gh-tool/read/api.d.ts +8 -0
  45. package/dist/gh-tool/read/api.d.ts.map +1 -1
  46. package/dist/gh-tool/read/cache.d.ts.map +1 -1
  47. package/dist/gh-tool/read/commands.d.ts +5 -0
  48. package/dist/gh-tool/read/commands.d.ts.map +1 -1
  49. package/dist/gh-tool/read/pulls.d.ts +1 -0
  50. package/dist/gh-tool/read/pulls.d.ts.map +1 -1
  51. package/dist/gh-tool/read/reads.d.ts +39 -0
  52. package/dist/gh-tool/read/reads.d.ts.map +1 -1
  53. package/dist/gh-tool/read/telemetry-command.d.ts +7 -0
  54. package/dist/gh-tool/read/telemetry-command.d.ts.map +1 -0
  55. package/dist/gh-tool/read/telemetry-store.d.ts +3 -0
  56. package/dist/gh-tool/read/telemetry-store.d.ts.map +1 -0
  57. package/dist/gh-tool/read/telemetry.d.ts +59 -0
  58. package/dist/gh-tool/read/telemetry.d.ts.map +1 -0
  59. package/dist/gh-tool/read/transport.d.ts +9 -0
  60. package/dist/gh-tool/read/transport.d.ts.map +1 -1
  61. package/dist/gh-tool/read/writes.d.ts +2 -1
  62. package/dist/gh-tool/read/writes.d.ts.map +1 -1
  63. package/package.json +2 -1
  64. package/schemas/agent-tools.schema.json +1 -1
  65. package/src/credential-guard/bash.ts +769 -0
  66. package/src/credential-guard/claude-hook.ts +14 -14
  67. package/src/credential-guard/engine.test.ts +2050 -0
  68. package/src/credential-guard/index.ts +285 -4268
  69. package/src/credential-guard/ir.ts +139 -0
  70. package/src/credential-guard/literal-output.ts +8 -0
  71. package/src/credential-guard/passive-awk.ts +239 -0
  72. package/src/credential-guard/passive-jq.ts +183 -24
  73. package/src/credential-guard/passive-sed.ts +78 -0
  74. package/src/credential-guard/passive.test.ts +141 -0
  75. package/src/credential-guard/paths.ts +108 -0
  76. package/src/credential-guard/policy.ts +1576 -0
  77. package/src/credential-guard/powershell.ts +220 -0
  78. package/src/credential-guard/registry.ts +4720 -0
  79. package/src/credential-guard/review4.test.ts +83 -0
  80. package/src/credential-guard/review7.test.ts +51 -0
  81. package/src/credential-guard/rg-types.json +389 -0
  82. package/src/credential-guard/shell-oracle.ts +187 -0
  83. package/src/credential-guard/xargs.ts +65 -0
  84. package/src/gh-tool/main.ts +32 -6
  85. package/src/gh-tool/pr/checkbox-markdown.ts +317 -0
  86. package/src/gh-tool/pr/checkbox.ts +113 -0
  87. package/src/gh-tool/pr/commands.ts +55 -2
  88. package/src/gh-tool/pr/core.ts +2 -2
  89. package/src/gh-tool/pr/index.ts +1 -0
  90. package/src/gh-tool/read/api.ts +73 -6
  91. package/src/gh-tool/read/cache.ts +13 -4
  92. package/src/gh-tool/read/commands.ts +17 -1
  93. package/src/gh-tool/read/pulls.ts +19 -0
  94. package/src/gh-tool/read/reads.ts +86 -2
  95. package/src/gh-tool/read/telemetry-command.ts +45 -0
  96. package/src/gh-tool/read/telemetry-store.ts +85 -0
  97. package/src/gh-tool/read/telemetry.ts +191 -0
  98. package/src/gh-tool/read/transport.ts +95 -6
  99. package/src/gh-tool/read/writes.ts +19 -2
  100. package/src/gh-tool/workflow.ts +2 -2
@@ -0,0 +1,1576 @@
1
+ import { formatLiteralPrintf } from "./literal-output";
2
+ import { splitXargsInput } from "./xargs";
3
+ import { parseBash } from "./bash";
4
+ import { parsePowerShell } from "./powershell";
5
+ import { MAX_PARSE_DEPTH, MAX_SCRIPT_BYTES, wordText } from "./ir";
6
+ import type { Command, List, ParseError, Pipeline, Redirect, Script, Span, Word } from "./ir";
7
+ import {
8
+ CODE_OR_PROSE_EXTENSION,
9
+ expandGlobAlternatives,
10
+ isSensitivePath,
11
+ normalizePath,
12
+ } from "./paths";
13
+ import type { PathContext } from "./paths";
14
+ import { analyzeCode, buildRegistry, classify, executableName, isStdinScript } from "./registry";
15
+ import type { Issue, Language, Registry } from "./registry";
16
+
17
+ const builtInNonSecretNames = ["HOME", "PWD", "OLDPWD", "PATH"] as const;
18
+
19
+ const ruleNames = [
20
+ "unparseable",
21
+ "ambient-expansion",
22
+ "secret-source",
23
+ "sensitive-path",
24
+ "opaque-path",
25
+ "opaque-executable",
26
+ "opaque-code",
27
+ "opaque-env-name",
28
+ "no-analyzer",
29
+ "wrapper-required",
30
+ "configured-pattern",
31
+ "startup-mutation",
32
+ "unsupported-option",
33
+ "analysis-limit",
34
+ "copy-destination",
35
+ ] satisfies readonly string[];
36
+ export type RuleId = string & { readonly __brand: "RuleId" };
37
+ function isRuleId(value: string): value is RuleId {
38
+ return ruleNames.includes(value);
39
+ }
40
+ function ruleId(value: string): RuleId {
41
+ if (isRuleId(value)) return value;
42
+ throw new Error(`unknown rule ${value}`);
43
+ }
44
+ export type Finding = { readonly rule: RuleId; readonly detail: string; readonly span: Span };
45
+ export type Decision =
46
+ | { readonly kind: "pass" }
47
+ | { readonly kind: "violation"; readonly findings: readonly [Finding, ...Finding[]] }
48
+ | { readonly kind: "missing-knowledge"; readonly findings: readonly [Finding, ...Finding[]] };
49
+ export type PolicyContext = PathContext & {
50
+ readonly approvedNames?: ReadonlySet<string>;
51
+ readonly onInvocation?: (executable: string, argv: readonly (string | undefined)[]) => void;
52
+ readonly registry?: Registry;
53
+ readonly dangerousPatterns?: readonly RegExp[];
54
+ readonly isPathBlocked?: (path: string) => boolean;
55
+ readonly chainCopyDestinations?: readonly string[];
56
+ readonly proveCopyDestination?: (path: string, source: string) => boolean;
57
+ };
58
+ export type Value =
59
+ | { readonly kind: "literal"; readonly texts: readonly string[] }
60
+ | {
61
+ readonly kind: "opaque";
62
+ readonly mentions: readonly string[];
63
+ readonly prefix: string;
64
+ readonly globPatterns?: readonly string[];
65
+ readonly supplied?: boolean;
66
+ }
67
+ | { readonly kind: "stream"; readonly stdout: Value };
68
+ type Scope = {
69
+ readonly bindings: ReadonlyMap<string, Value>;
70
+ readonly cwd: Value;
71
+ readonly positional: readonly Value[] | null;
72
+ readonly written: ReadonlyMap<string, Value>;
73
+ readonly exported: ReadonlySet<string>;
74
+ readonly dirtyStartup: readonly string[];
75
+ readonly dialect: Script["dialect"];
76
+ readonly braceMode: "bash" | "zsh" | "literal" | "host" | "sh";
77
+ };
78
+ type LoopFrame = { allowed: boolean; breaks: Scope | null; continues: Scope | null };
79
+ type Flow = {
80
+ readonly success: Scope | null;
81
+ readonly failure: Scope | null;
82
+ readonly stdout: Value;
83
+ };
84
+ export const MAX_VALUE_ALTERNATIVES = 64;
85
+ export const MAX_LOOP_PASSES = 8;
86
+ export const MAX_FOLD_STEPS = 20000;
87
+ const literal = (...texts: string[]): Value => ({ kind: "literal", texts });
88
+ const opaque = (...fragments: string[]): Extract<Value, { kind: "opaque" }> => ({
89
+ kind: "opaque",
90
+ prefix: "",
91
+ mentions: [...new Set(fragments)].slice(0, MAX_VALUE_ALTERNATIVES),
92
+ });
93
+ const glob = (text: string): Value => ({ ...opaque(text), globPatterns: [text] });
94
+ function mentions(value: Value): readonly string[] {
95
+ return value.kind === "literal" ? value.texts : value.kind === "opaque" ? value.mentions : [];
96
+ }
97
+ function join(a: Value, b: Value): Value {
98
+ const texts = [...new Set([...mentions(a), ...mentions(b)])];
99
+ return a.kind === "literal" && b.kind === "literal" && texts.length <= MAX_VALUE_ALTERNATIVES
100
+ ? { kind: "literal", texts }
101
+ : opaque(...texts);
102
+ }
103
+ function concat(a: Value, b: Value): Value {
104
+ if (a.kind === "literal" && a.texts.length === 1 && a.texts[0] === "") return b;
105
+ if (b.kind === "literal" && b.texts.length === 1 && b.texts[0] === "") return a;
106
+ const aPatterns =
107
+ a.kind === "literal" ? a.texts : a.kind === "opaque" ? a.globPatterns : undefined;
108
+ const bPatterns =
109
+ b.kind === "literal" ? b.texts : b.kind === "opaque" ? b.globPatterns : undefined;
110
+ if (
111
+ aPatterns &&
112
+ bPatterns &&
113
+ (a.kind !== "literal" || b.kind !== "literal") &&
114
+ aPatterns.length * bPatterns.length <= MAX_VALUE_ALTERNATIVES
115
+ ) {
116
+ const patterns = aPatterns.flatMap((left) => bPatterns.map((right) => left + right));
117
+ if (patterns.every((pattern) => pattern.length <= MAX_SCRIPT_BYTES))
118
+ return { ...opaque(...patterns), globPatterns: patterns };
119
+ }
120
+ if (
121
+ a.kind !== "literal" ||
122
+ b.kind !== "literal" ||
123
+ a.texts.length * b.texts.length > MAX_VALUE_ALTERNATIVES ||
124
+ a.texts.some((x) => b.texts.some((y) => x.length + y.length > MAX_SCRIPT_BYTES))
125
+ )
126
+ return {
127
+ ...opaque(...mentions(a), ...mentions(b)),
128
+ prefix:
129
+ a.kind === "literal" && a.texts.length === 1
130
+ ? (a.texts[0] ?? "") + (b.kind === "opaque" ? b.prefix : "")
131
+ : a.kind === "opaque"
132
+ ? a.prefix
133
+ : "",
134
+ };
135
+ return { kind: "literal", texts: a.texts.flatMap((x) => b.texts.map((y) => x + y)) };
136
+ }
137
+ function merge(a: Scope | null, b: Scope | null): Scope | null {
138
+ if (!a) return b;
139
+ if (!b) return a;
140
+ const bindings = new Map<string, Value>();
141
+ for (const [key, value] of a.bindings) {
142
+ const other = b.bindings.get(key);
143
+ if (other) bindings.set(key, join(value, other));
144
+ }
145
+ const written = new Map(a.written);
146
+ for (const [key, value] of b.written)
147
+ written.set(key, written.has(key) ? join(written.get(key) ?? opaque(), value) : value);
148
+ return {
149
+ ...a,
150
+ braceMode: a.braceMode === b.braceMode ? a.braceMode : "host",
151
+ bindings,
152
+ written,
153
+ exported: new Set([...a.exported, ...b.exported]),
154
+ cwd: join(a.cwd, b.cwd),
155
+ dirtyStartup: [...new Set([...a.dirtyStartup, ...b.dirtyStartup])],
156
+ };
157
+ }
158
+ function withStartup(scope: Scope, paths: readonly string[]): Scope {
159
+ return {
160
+ ...scope,
161
+ dirtyStartup: [...new Set([...scope.dirtyStartup, ...paths])],
162
+ };
163
+ }
164
+ function after(flow: Flow): Scope | null {
165
+ return merge(flow.success, flow.failure);
166
+ }
167
+ function stateKey(scope: Scope): string {
168
+ return JSON.stringify({
169
+ bindings: [...scope.bindings].toSorted(([a], [b]) => a.localeCompare(b)),
170
+ cwd: scope.cwd,
171
+ dirty: scope.dirtyStartup,
172
+ braceMode: scope.braceMode,
173
+ exported: [...scope.exported].toSorted(),
174
+ written: [...scope.written].toSorted(([a], [b]) => a.localeCompare(b)),
175
+ });
176
+ }
177
+
178
+ function redirectsCannotFail(items: readonly Redirect[]): boolean {
179
+ const open = new Set([0, 1, 2]);
180
+ for (const item of items) {
181
+ if (item.kind === "file" && wordText(item.target) === "/dev/null") {
182
+ open.add(item.fd ?? (item.op === "<" ? 0 : 1));
183
+ } else if (item.kind === "duplicate") {
184
+ const fd = item.fd ?? (item.input ? 0 : 1);
185
+ if (item.target === "-") open.delete(fd);
186
+ else if (/^[0-9]+$/.test(item.target) && open.has(Number(item.target))) open.add(fd);
187
+ else return false;
188
+ } else return false;
189
+ }
190
+ return true;
191
+ }
192
+
193
+ function readTargets(command: Command): readonly string[] | null {
194
+ if (command.kind !== "simple" || !command.argv[0] || wordText(command.argv[0]) !== "read")
195
+ return null;
196
+ const names = command.argv.slice(1).map(wordText);
197
+ if (names[0] === "-r") names.shift();
198
+ if (names[0] === "--") names.shift();
199
+ return names.length &&
200
+ names.every(
201
+ (name): name is string => name !== undefined && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name),
202
+ )
203
+ ? names
204
+ : null;
205
+ }
206
+
207
+ export function judge(script: Script | ParseError, context: PolicyContext): Decision {
208
+ const findings: Finding[] = [];
209
+ let detected = false;
210
+ let steps = 0;
211
+ let herdrSeen = false;
212
+ let loopFrames: LoopFrame[] = [];
213
+ const childShell = (run: () => Flow): Flow => {
214
+ const parent = loopFrames;
215
+ loopFrames = [];
216
+ try {
217
+ return run();
218
+ } finally {
219
+ loopFrames = parent;
220
+ }
221
+ };
222
+ const observedWrites = new Map<string, Value>();
223
+ const observeWrite = (path: string, value: Value): void => {
224
+ observedWrites.set(path, join(observedWrites.get(path) ?? literal(), value));
225
+ };
226
+ const writtenValue = (scope: Scope, path: string): Value | undefined => {
227
+ const scoped = scope.written.get(path);
228
+ const observed = observedWrites.get(path);
229
+ return scoped
230
+ ? observed
231
+ ? join(scoped, observed)
232
+ : scoped
233
+ : observed
234
+ ? opaque(...mentions(observed))
235
+ : undefined;
236
+ };
237
+ const copyProofs: Array<() => void> = [];
238
+ const registry = context.registry ?? buildRegistry();
239
+ const emit = (rule: string, detail: string, span: Span, uncertainty = false): void => {
240
+ detected ||= !uncertainty;
241
+ if (findings.length < 256) findings.push({ rule: ruleId(rule), detail, span });
242
+ };
243
+ const checkConfigured = (text: string, span: Span): void => {
244
+ for (const pattern of context.dangerousPatterns ?? [])
245
+ if (new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, "")).test(text))
246
+ emit("configured-pattern", pattern.source, span);
247
+ };
248
+ const issue = (item: Issue, span: Span): void =>
249
+ emit(item.rule, item.detail, span, item.kind === "missing-knowledge");
250
+ const allowed = (name: string, dialect: Script["dialect"]): boolean =>
251
+ [...builtInNonSecretNames, ...(context.approvedNames ?? [])].some((n) =>
252
+ dialect === "powershell" ? n.toLowerCase() === name.toLowerCase() : n === name,
253
+ );
254
+ const bindingName = (name: string, scope: Scope): string =>
255
+ scope.dialect === "powershell" ? name.toLowerCase() : name;
256
+ const startup = (name: string): boolean =>
257
+ registry.startupVariables.has(name.toUpperCase()) ||
258
+ /^(?:LD_|DYLD_|_RLD_|LDR_|PYTHON[A-Z_]|GIT_CONFIG_(?:KEY|VALUE)_)/i.test(name);
259
+ const safeStartup = (
260
+ name: string,
261
+ value: Value,
262
+ argv: readonly (string | undefined)[],
263
+ ): boolean => {
264
+ if (value.kind !== "literal" || value.texts.length !== 1) return false;
265
+ const target = executableName(argv[0] ?? "");
266
+ if (name === "HOME")
267
+ return [
268
+ "jq",
269
+ "cat",
270
+ "sed",
271
+ "awk",
272
+ "ls",
273
+ "rg",
274
+ "grep",
275
+ "head",
276
+ "tail",
277
+ "wc",
278
+ "sort",
279
+ "true",
280
+ ":",
281
+ ].includes(target);
282
+ return (
283
+ ["GIT_EDITOR", "GIT_SEQUENCE_EDITOR"].includes(name) &&
284
+ target === "git" &&
285
+ ["true", ":", "false"].includes(value.texts[0] ?? "")
286
+ );
287
+ };
288
+ const charge = (span: Span, units = 1): boolean => {
289
+ steps += units;
290
+ if (steps <= MAX_FOLD_STEPS) return true;
291
+ emit("analysis-limit", "fold step limit", span, true);
292
+ return false;
293
+ };
294
+ const checkPath = (
295
+ value: Value,
296
+ scope: Scope,
297
+ span: Span,
298
+ contentRead = false,
299
+ gitObject = false,
300
+ ): readonly string[] => {
301
+ const resolved: string[] = [];
302
+ if (value.kind === "stream") return [];
303
+ if (
304
+ (value.kind === "opaque" &&
305
+ !value.globPatterns?.every((pattern) => CODE_OR_PROSE_EXTENSION.test(pattern))) ||
306
+ scope.cwd.kind !== "literal"
307
+ )
308
+ emit("opaque-path", "path is not a finite literal", span, true);
309
+ for (const text of mentions(value)) {
310
+ for (const cwd of mentions(scope.cwd)) {
311
+ const named = text.replace(/^@/, "");
312
+ const clean =
313
+ gitObject && !/^[A-Za-z]:[\\/]/.test(named) ? named.replace(/^[^:]+:/, "") : named;
314
+ const path = normalizePath(clean, context.home, cwd);
315
+ resolved.push(path);
316
+ if (
317
+ isSensitivePath(clean, { ...context, cwd, contentRead }) ||
318
+ context.isPathBlocked?.(path)
319
+ )
320
+ emit("sensitive-path", text, span);
321
+ }
322
+ }
323
+ return resolved;
324
+ };
325
+ const evaluate = (word: Word, scope: Scope, depth: number): Value => {
326
+ if (!charge(word.span, word.parts.length + 1)) return opaque();
327
+ let value = literal("");
328
+ for (const part of word.parts) {
329
+ let next: Value;
330
+ switch (part.kind) {
331
+ case "literal":
332
+ next = literal(part.text);
333
+ break;
334
+ case "brace":
335
+ next = opaque("{" + part.alternatives.join(",") + "}");
336
+ break;
337
+ case "glob":
338
+ next = glob(part.text);
339
+ break;
340
+ case "special": {
341
+ if (/^[0-9]+$/.test(part.name) || part.name === "@" || part.name === "*") {
342
+ if (scope.positional === null) {
343
+ emit("ambient-expansion", `unbound positional ${part.name}`, word.span);
344
+ next = opaque();
345
+ } else if (/^[0-9]+$/.test(part.name))
346
+ next = scope.positional[Number(part.name)] ?? literal("");
347
+ else next = scope.positional.slice(1).reduce(join, literal());
348
+ } else next = opaque();
349
+ break;
350
+ }
351
+ case "parameter": {
352
+ const name = scope.dialect === "powershell" ? part.name.toUpperCase() : part.name;
353
+ const builtIn =
354
+ name === "HOME" && (scope.dialect === "bash" || !part.ambient)
355
+ ? literal(context.home)
356
+ : name === "PWD" && (scope.dialect === "bash" || !part.ambient)
357
+ ? scope.cwd
358
+ : undefined;
359
+ const bound =
360
+ (part.ambient ? undefined : scope.bindings.get(bindingName(part.name, scope))) ??
361
+ builtIn;
362
+ const fallback = part.operand ? evaluate(part.operand, scope, depth) : null;
363
+ if (bound) next = fallback && part.operator !== "plain" ? join(bound, fallback) : bound;
364
+ else if (
365
+ allowed(part.name, scope.dialect) ||
366
+ [
367
+ "PIPESTATUS",
368
+ "pipestatus",
369
+ "BASH_REMATCH",
370
+ "RANDOM",
371
+ "LINENO",
372
+ "SECONDS",
373
+ "PPID",
374
+ "UID",
375
+ "EUID",
376
+ "OPTIND",
377
+ "BASH_VERSION",
378
+ ].includes(part.name)
379
+ )
380
+ next = opaque();
381
+ else if (
382
+ part.operator === "alternate" &&
383
+ fallback?.kind === "literal" &&
384
+ fallback.texts.every((t) => t === "")
385
+ )
386
+ next = literal("");
387
+ else {
388
+ emit("ambient-expansion", part.name, part.span);
389
+ next = opaque();
390
+ }
391
+ if (part.operator === "length") next = opaque();
392
+ break;
393
+ }
394
+ case "command-substitution": {
395
+ const flow = childShell(() =>
396
+ foldList(part.body.body, { ...scope, dialect: part.body.dialect }, opaque(), depth + 1),
397
+ );
398
+ next =
399
+ flow.stdout.kind === "literal"
400
+ ? { kind: "literal", texts: flow.stdout.texts.map((t) => t.replace(/\n+$/, "")) }
401
+ : flow.stdout;
402
+ break;
403
+ }
404
+ case "process-substitution": {
405
+ const flow = childShell(() => foldList(part.body.body, scope, opaque(), depth + 1));
406
+ next = { kind: "stream", stdout: flow.stdout };
407
+ break;
408
+ }
409
+ }
410
+ value = concat(value, next);
411
+ }
412
+ return value;
413
+ };
414
+ const analyze = (
415
+ language: Language,
416
+ value: Value,
417
+ scope: Scope,
418
+ input: Value,
419
+ span: Span,
420
+ depth: number,
421
+ positional: readonly Value[] = [],
422
+ ): Value => {
423
+ if (depth > MAX_PARSE_DEPTH) {
424
+ emit("analysis-limit", "nested execution depth", span, true);
425
+ return opaque();
426
+ }
427
+ if (value.kind !== "literal") {
428
+ emit("opaque-code", "code is not literal", span, true);
429
+ return opaque();
430
+ }
431
+ let stdout: Value = literal();
432
+ for (const text of value.texts) {
433
+ if (language === "bash" || language === "powershell") {
434
+ checkConfigured(text, span);
435
+ const nested = language === "bash" ? parseBash(text) : parsePowerShell(text);
436
+ if (nested.kind === "parse-error") {
437
+ emit(
438
+ nested.code === "work-limit" ? "analysis-limit" : "unparseable",
439
+ nested.code,
440
+ span,
441
+ true,
442
+ );
443
+ continue;
444
+ }
445
+ const findingStart = findings.length;
446
+ const flow = childShell(() =>
447
+ foldList(
448
+ nested.body,
449
+ {
450
+ ...scope,
451
+ positional,
452
+ dialect: nested.dialect,
453
+ },
454
+ input,
455
+ depth + 1,
456
+ ),
457
+ );
458
+ for (let index = findingStart; index < findings.length; index++) {
459
+ const finding = findings[index];
460
+ if (finding) findings[index] = { ...finding, span };
461
+ }
462
+ stdout = join(stdout, flow.stdout);
463
+ } else {
464
+ for (const finding of analyzeCode(language, text)) issue(finding, span);
465
+ stdout = opaque();
466
+ }
467
+ }
468
+ return stdout;
469
+ };
470
+ const redirects = (
471
+ items: readonly Redirect[],
472
+ scope: Scope,
473
+ input: Value,
474
+ depth: number,
475
+ span: Span,
476
+ ): {
477
+ scope: Scope;
478
+ input: Value;
479
+ outputs: readonly { path: string; append: boolean; fd: number }[];
480
+ } => {
481
+ let current = scope;
482
+ let stdin = input;
483
+ const outputs: { path: string; append: boolean; fd: number }[] = [];
484
+ for (const redirect of items) {
485
+ if (redirect.kind === "duplicate") {
486
+ if (redirect.input && (redirect.fd === null || redirect.fd === 0))
487
+ stdin = { ...opaque(), supplied: true };
488
+ continue;
489
+ }
490
+ if (redirect.kind === "heredoc" || redirect.kind === "herestring") {
491
+ const body = evaluate(redirect.body, current, depth);
492
+ const exact = redirect.body.parts.every((part) => part.kind === "literal");
493
+ if (redirect.fd === null || redirect.fd === 0)
494
+ stdin = !exact
495
+ ? opaque(...mentions(body))
496
+ : redirect.kind === "herestring"
497
+ ? concat(body, literal("\n"))
498
+ : body;
499
+ continue;
500
+ }
501
+ const target = evaluate(redirect.target, current, depth);
502
+ const paths = checkPath(target, current, span, redirect.op === "<" || redirect.op === "<>");
503
+ if (
504
+ (redirect.op === "<" || redirect.op === "<>") &&
505
+ (redirect.fd === null || redirect.fd === 0)
506
+ ) {
507
+ stdin = target.kind === "stream" ? target.stdout : { ...opaque(...paths), supplied: true };
508
+ }
509
+ if (redirect.op !== "<") {
510
+ outputs.push(
511
+ ...paths.map((path) => ({
512
+ path,
513
+ append: redirect.op === ">>" || redirect.op === "<>",
514
+ fd: redirect.fd ?? 1,
515
+ })),
516
+ );
517
+ current = withStartup(current, paths);
518
+ }
519
+ }
520
+ return { scope: current, input: stdin, outputs };
521
+ };
522
+ const invoke = (
523
+ values: readonly Value[],
524
+ scope: Scope,
525
+ input: Value,
526
+ span: Span,
527
+ depth: number,
528
+ runtimeWrapper = false,
529
+ ): Flow => {
530
+ if (!charge(span, values.length + 1) || depth > MAX_PARSE_DEPTH) {
531
+ if (depth > MAX_PARSE_DEPTH) emit("analysis-limit", "wrapper depth", span, true);
532
+ return { success: scope, failure: scope, stdout: opaque() };
533
+ }
534
+ const first = values[0];
535
+ if (!first || first.kind !== "literal" || first.texts.length !== 1) {
536
+ emit("opaque-executable", "executable is not one literal", span, true);
537
+ return { success: scope, failure: scope, stdout: opaque() };
538
+ }
539
+ const argv = values.map((v) =>
540
+ v.kind === "literal" && v.texts.length === 1
541
+ ? v.texts[0]
542
+ : v.kind === "opaque" &&
543
+ v.globPatterns?.length === 1 &&
544
+ /^(?:--glob|--iglob|--include)=/.test(v.globPatterns[0] ?? "")
545
+ ? v.globPatterns[0]
546
+ : undefined,
547
+ );
548
+ for (const cwd of mentions(scope.cwd)) {
549
+ const executable = first.texts[0] ?? "";
550
+ const written = writtenValue(scope, normalizePath(executable, context.home, cwd));
551
+ if (written)
552
+ analyze(
553
+ /\.(?:py|[cm]?[jt]sx?)$/i.test(executable) ? "runtime" : "bash",
554
+ written,
555
+ scope,
556
+ input,
557
+ span,
558
+ depth + 1,
559
+ values,
560
+ );
561
+ }
562
+ if (argv.every((word) => word !== undefined)) {
563
+ checkConfigured(argv.join(" "), span);
564
+ checkConfigured(
565
+ [argv[0], ...argv.slice(1).map((word) => JSON.stringify(word))].join(" "),
566
+ span,
567
+ );
568
+ }
569
+ herdrSeen ||= executableName(first.texts[0] ?? "") === "herdr";
570
+ context.onInvocation?.(first.texts[0] ?? "", argv);
571
+ for (const value of values.slice(1)) {
572
+ for (const text of mentions(value))
573
+ if (/^(?:env:|(?:microsoft\.powershell\.core[\\/])?environment::)/i.test(text))
574
+ checkPath(value, scope, span);
575
+ }
576
+ const hasInput = input.kind !== "opaque" || input.supplied === true;
577
+ const plan = classify(argv, registry, scope.dialect, runtimeWrapper);
578
+ if (plan.refuseSuppliedStdin && hasInput)
579
+ emit("opaque-code", "wrapped module receives code input", span, true);
580
+ if (plan.pipelinePaths && hasInput) {
581
+ if (input.kind !== "literal")
582
+ emit("opaque-path", "PowerShell pipeline path is not a literal string", span, true);
583
+ else checkPath(input, scope, span, true);
584
+ }
585
+ if (
586
+ [...scope.dirtyStartup, ...observedWrites.keys()].some((path) =>
587
+ plan.startupFiles.some((pattern) => pattern.test(path)),
588
+ )
589
+ )
590
+ emit(
591
+ "startup-mutation",
592
+ "execution follows a write to its trusted startup configuration",
593
+ span,
594
+ true,
595
+ );
596
+ for (const finding of plan.issues) issue(finding, span);
597
+ const bindings = new Map(plan.remote ? [] : scope.bindings);
598
+ const exported = new Set(plan.remote ? [] : scope.exported);
599
+ if (executableName(first.texts[0] ?? "") === "export")
600
+ for (const value of values.slice(1)) {
601
+ const text =
602
+ value.kind === "literal" && value.texts.length === 1
603
+ ? value.texts[0]
604
+ : value.kind === "opaque"
605
+ ? value.prefix
606
+ : undefined;
607
+ const name = /^([A-Za-z_][A-Za-z0-9_]*)(?:=|$)/.exec(text ?? "")?.[1];
608
+ if (name) exported.add(bindingName(name, scope));
609
+ }
610
+ if (plan.delta === "assign") {
611
+ for (const value of values.slice(1)) {
612
+ if (value.kind === "literal") continue;
613
+ const name =
614
+ executableName(first.texts[0] ?? "") === "export" && value.kind === "opaque"
615
+ ? /^([A-Za-z_][A-Za-z0-9_]*)=/.exec(value.prefix)?.[1]
616
+ : undefined;
617
+ if (name) {
618
+ if (startup(name)) emit("startup-mutation", name, span, true);
619
+ bindings.set(bindingName(name, scope), opaque(...mentions(value)));
620
+ continue;
621
+ }
622
+ emit("opaque-env-name", "declaration operand is not literal", span, true);
623
+ for (const fragment of mentions(value)) {
624
+ const mentionedName = /^([A-Za-z_][A-Za-z0-9_]*)=/.exec(fragment)?.[1];
625
+ if (mentionedName && startup(mentionedName))
626
+ emit("startup-mutation", mentionedName, span, true);
627
+ }
628
+ }
629
+ }
630
+ for (const assignment of plan.assignments) {
631
+ if (
632
+ startup(assignment.name) &&
633
+ !safeStartup(
634
+ assignment.name,
635
+ literal(assignment.value),
636
+ argv.slice(plan.children[0]?.start ?? argv.length),
637
+ )
638
+ )
639
+ emit("startup-mutation", assignment.name, span, true);
640
+ bindings.set(bindingName(assignment.name, scope), literal(assignment.value));
641
+ }
642
+ let invocationCwd = plan.remote ? literal("/") : scope.cwd;
643
+ for (const operand of plan.cwdOperands) {
644
+ const destination =
645
+ operand.literal !== undefined
646
+ ? literal(operand.literal)
647
+ : (values[operand.index] ?? opaque());
648
+ for (const path of mentions(destination))
649
+ if (/^(?:env:|(?:microsoft\.powershell\.core[\\/])?environment::)/i.test(path))
650
+ checkPath(destination, scope, span);
651
+ const cwd = invocationCwd;
652
+ invocationCwd =
653
+ destination.kind === "literal" && cwd.kind === "literal"
654
+ ? literal(
655
+ ...destination.texts.flatMap((path) =>
656
+ cwd.texts.map((base) => normalizePath(path, context.home, base)),
657
+ ),
658
+ )
659
+ : opaque(...mentions(destination));
660
+ }
661
+ const invocationScope = { ...scope, cwd: invocationCwd };
662
+ if (executableName(first.texts[0] ?? "") === "git") {
663
+ checkPath(invocationScope.cwd, invocationScope, span);
664
+ checkPath(concat(invocationScope.cwd, literal("/")), invocationScope, span);
665
+ }
666
+ if (executableName(first.texts[0] ?? "") === "bun" && plan.cwdOperands.length) {
667
+ checkPath(invocationScope.cwd, invocationScope, span);
668
+ checkPath(concat(invocationScope.cwd, literal("/")), invocationScope, span);
669
+ for (const cwd of mentions(invocationScope.cwd)) {
670
+ if (writtenValue(scope, cwd))
671
+ emit("opaque-path", "Bun working directory was written in this command", span, true);
672
+ const segments = cwd.split("/");
673
+ while (segments.length) {
674
+ const directory = segments.join("/") || "/";
675
+ for (const filename of ["package.json", "bunfig.toml", ".bunfig.toml"]) {
676
+ if (writtenValue(scope, normalizePath(filename, context.home, directory)))
677
+ emit(
678
+ "startup-mutation",
679
+ "Bun project configuration was written in this command",
680
+ span,
681
+ true,
682
+ );
683
+ }
684
+ segments.pop();
685
+ }
686
+ }
687
+ }
688
+ const childScope = {
689
+ ...invocationScope,
690
+ written: plan.remote ? new Map<string, Value>() : invocationScope.written,
691
+ dirtyStartup: plan.remote ? [] : invocationScope.dirtyStartup,
692
+ braceMode:
693
+ executableName(first.texts[0] ?? "") === "bash"
694
+ ? "bash"
695
+ : executableName(first.texts[0] ?? "") === "sh"
696
+ ? "sh"
697
+ : executableName(first.texts[0] ?? "") === "dash"
698
+ ? "literal"
699
+ : invocationScope.braceMode,
700
+ bindings,
701
+ exported: new Set([
702
+ ...exported,
703
+ ...plan.assignments.map((assignment) => bindingName(assignment.name, scope)),
704
+ ]),
705
+ };
706
+ let stdout: Value =
707
+ plan.stdout === "stdin" && scope.dialect === "bash"
708
+ ? input
709
+ : opaque(...values.slice(1).flatMap(mentions));
710
+ if (
711
+ plan.stdout === "arguments" &&
712
+ executableName(first.texts[0] ?? "") === "echo" &&
713
+ scope.dialect === "bash" &&
714
+ argv.every((word) => word !== undefined)
715
+ )
716
+ stdout = literal(argv.slice(1).join(" ") + "\n");
717
+ if (
718
+ executableName(first.texts[0] ?? "") === "printf" &&
719
+ scope.dialect === "bash" &&
720
+ argv.every((word) => word !== undefined)
721
+ ) {
722
+ const output = formatLiteralPrintf(argv.slice(1));
723
+ if (output !== null) stdout = literal(output);
724
+ }
725
+
726
+ if (
727
+ scope.dialect === "powershell" &&
728
+ ["write-output", "echo"].includes(executableName(first.texts[0] ?? "")) &&
729
+ argv.length === 2 &&
730
+ argv[1] !== undefined
731
+ )
732
+ stdout = literal(argv[1]);
733
+ let resultScope: Scope = plan.delta === "assign" ? { ...scope, bindings, exported } : scope;
734
+ for (const operand of plan.roles) {
735
+ const value =
736
+ operand.literal !== undefined
737
+ ? literal(operand.literal)
738
+ : (values[operand.index] ?? opaque());
739
+ const role = operand.role;
740
+ if (typeof role !== "string") {
741
+ const analyzedOutput = analyze(
742
+ role.code,
743
+ value,
744
+ childScope,
745
+ input,
746
+ span,
747
+ depth + 1,
748
+ plan.codePositionals.length
749
+ ? plan.codePositionals.map((i) => values[i] ?? opaque())
750
+ : [values[0] ?? opaque()],
751
+ );
752
+ stdout = ["sh", "bash"].includes(executableName(first.texts[0] ?? ""))
753
+ ? analyzedOutput
754
+ : opaque();
755
+ continue;
756
+ }
757
+ if (
758
+ role === "names" &&
759
+ mentions(value).some((text) =>
760
+ /^(?:env:|(?:microsoft\.powershell\.core[\\/])?environment::)/i.test(text),
761
+ )
762
+ )
763
+ checkPath(value, invocationScope, span);
764
+ if (role === "member") {
765
+ if (
766
+ value.kind !== "literal" ||
767
+ !value.texts.every((text) =>
768
+ [
769
+ "name",
770
+ "fullname",
771
+ "length",
772
+ "basename",
773
+ "extension",
774
+ "lastwritetime",
775
+ "count",
776
+ ].includes(text.toLowerCase()),
777
+ )
778
+ )
779
+ emit("no-analyzer", "PowerShell member is not an allowed property", span, true);
780
+ continue;
781
+ }
782
+ if (role === "search-filter") {
783
+ if (value.kind === "opaque" && !value.globPatterns)
784
+ emit("opaque-path", "search filter is not literal", span, true);
785
+ checkPath(literal(...mentions(value)), invocationScope, span, true);
786
+ continue;
787
+ }
788
+ if (role === "path-pattern") {
789
+ const patterns =
790
+ value.kind === "literal"
791
+ ? value.texts.filter(
792
+ (text) => executableName(first.texts[0] ?? "") !== "rg" || !text.startsWith("!"),
793
+ )
794
+ : null;
795
+ if (patterns?.length === 0) continue;
796
+ const alternatives =
797
+ patterns?.flatMap((pattern) => {
798
+ const expanded = expandGlobAlternatives(pattern);
799
+ if (expanded === null) emit("opaque-path", "unsupported glob alternation", span, true);
800
+ return expanded ?? [];
801
+ }) ?? null;
802
+ const patternValue =
803
+ alternatives === null
804
+ ? value
805
+ : alternatives.some((text) => /[*?[]/.test(text))
806
+ ? { ...opaque(...alternatives), globPatterns: alternatives }
807
+ : literal(...alternatives);
808
+ checkPath(patternValue, invocationScope, span, true);
809
+ continue;
810
+ }
811
+ if (
812
+ role === "path" ||
813
+ role === "content" ||
814
+ role === "write" ||
815
+ role === "script" ||
816
+ role === "test-filter"
817
+ ) {
818
+ const testGlob =
819
+ role === "test-filter" && value.kind === "opaque" && value.globPatterns !== undefined;
820
+ if (
821
+ testGlob &&
822
+ [...invocationScope.written.keys(), ...observedWrites.keys()].some((path) =>
823
+ /\.[cm]?[jt]sx?$/i.test(path),
824
+ )
825
+ )
826
+ emit(
827
+ "opaque-code",
828
+ "test filter may select a script written in this command",
829
+ span,
830
+ true,
831
+ );
832
+ const paths = checkPath(
833
+ testGlob ? literal(...mentions(value)) : value,
834
+ invocationScope,
835
+ span,
836
+ role === "content" || (role === "write" && plan.inPlaceBackupSuffix !== null),
837
+ executableName(first.texts[0] ?? "") === "git",
838
+ );
839
+ if (role === "write") {
840
+ const written = new Map(resultScope.written);
841
+ const targets = [...paths];
842
+ if (plan.inPlaceBackupSuffix !== null) {
843
+ const suffix = plan.inPlaceBackupSuffix;
844
+ if (value.kind !== "literal")
845
+ emit("opaque-path", "in-place target is not literal", span, true);
846
+ if (suffix) {
847
+ const backups = mentions(value).flatMap((text) =>
848
+ suffix.includes("*")
849
+ ? [text + suffix, suffix.replaceAll("*", text)]
850
+ : [text + suffix],
851
+ );
852
+ targets.push(...checkPath(literal(...backups), invocationScope, span, true));
853
+ }
854
+ const startupPatterns = [...registry.entries.values()].flatMap(
855
+ (entry) => entry.startupFiles ?? [],
856
+ );
857
+ for (const target of targets)
858
+ if (
859
+ startupPatterns.some((pattern) => pattern.test(target)) ||
860
+ /(?:^|\/)(?:\.?(?:bashrc|bash_profile|bash_login|bash_logout|profile|zshrc|zshenv|zprofile|zlogin|zlogout)|config\.fish)$/.test(
861
+ target,
862
+ )
863
+ )
864
+ emit("startup-mutation", "in-place write to startup configuration", span, true);
865
+ }
866
+ if (plan.copyDestination)
867
+ for (const source of values.slice(1, -1))
868
+ for (const name of mentions(source))
869
+ for (const path of paths)
870
+ targets.push(
871
+ normalizePath(
872
+ `${path}/${name.replace(/\\/g, "/").split("/").at(-1) ?? ""}`,
873
+ context.home,
874
+ context.cwd,
875
+ ),
876
+ );
877
+ if (
878
+ plan.copyDestination &&
879
+ targets.some((target) =>
880
+ /(?:^|\/)(?:\.?(?:bashrc|bash_profile|profile|zshrc|zshenv|zprofile)|config\.fish)$/.test(
881
+ target,
882
+ ),
883
+ )
884
+ )
885
+ emit("startup-mutation", "copy to startup configuration", span, true);
886
+ for (const path of targets) {
887
+ written.set(path, opaque());
888
+ observeWrite(path, opaque());
889
+ }
890
+ resultScope = withStartup({ ...resultScope, written }, targets);
891
+ }
892
+ if (role === "script" || role === "test-filter") {
893
+ if (
894
+ value.kind === "literal" &&
895
+ value.texts.some((text) => isStdinScript(text) || /^\/dev\/fd\/[0-9]+$/.test(text))
896
+ )
897
+ stdout = analyze(
898
+ plan.scriptLanguage,
899
+ value.texts.every(isStdinScript) ? input : opaque(),
900
+ childScope,
901
+ input,
902
+ span,
903
+ depth + 1,
904
+ values.slice(operand.index),
905
+ );
906
+ if (value.kind === "opaque" && !testGlob)
907
+ emit("opaque-code", "script path is not literal", span, true);
908
+ if (value.kind === "stream")
909
+ stdout = analyze(
910
+ plan.scriptLanguage,
911
+ value.stdout,
912
+ childScope,
913
+ input,
914
+ span,
915
+ depth + 1,
916
+ values.slice(operand.index),
917
+ );
918
+ for (const path of paths) {
919
+ const written = writtenValue(scope, path);
920
+ if (written)
921
+ stdout = analyze(
922
+ plan.scriptLanguage,
923
+ written,
924
+ childScope,
925
+ input,
926
+ span,
927
+ depth + 1,
928
+ values.slice(operand.index),
929
+ );
930
+ }
931
+ }
932
+ } else if (role === "env-name") {
933
+ if (value.kind !== "literal")
934
+ emit("opaque-env-name", "environment name is not literal", span, true);
935
+ else
936
+ for (const name of value.texts)
937
+ if (!allowed(name, scope.dialect) && !bindings.has(bindingName(name, scope)))
938
+ emit("secret-source", `environment ${name}`, span);
939
+ }
940
+ }
941
+ for (const nested of plan.children) {
942
+ const extra = nested.findRoots !== null ? opaque(...nested.findRoots) : input;
943
+ if (plan.xargsInput) {
944
+ const groups =
945
+ input.kind === "literal" && input.texts.length === 1
946
+ ? splitXargsInput(input.texts[0] ?? "", plan.xargsInput)
947
+ : null;
948
+ if (groups === null && input.kind === "literal" && plan.xargsInput.replacement !== null)
949
+ emit("unsupported-option", "xargs replacement input is not portable", span, true);
950
+ for (const words of groups ?? [[""]]) {
951
+ const added = groups === null ? [opaque()] : words.map((word) => literal(word));
952
+ const replaced = values.slice(nested.start, nested.end).map((value) => {
953
+ if (
954
+ !nested.placeholder ||
955
+ value.kind !== "literal" ||
956
+ !value.texts.some((text) => text.includes(nested.placeholder ?? ""))
957
+ )
958
+ return value;
959
+ return groups === null
960
+ ? opaque()
961
+ : literal(
962
+ ...value.texts.map((text) =>
963
+ text.replaceAll(nested.placeholder ?? "", words[0] ?? ""),
964
+ ),
965
+ );
966
+ });
967
+ invoke(
968
+ nested.placeholder ? replaced : [...replaced, ...added],
969
+ childScope,
970
+ opaque(),
971
+ span,
972
+ depth + 1,
973
+ );
974
+ }
975
+ stdout = opaque();
976
+ continue;
977
+ }
978
+ const childValues = values
979
+ .slice(nested.start, nested.end)
980
+ .map((v) =>
981
+ nested.placeholder &&
982
+ v.kind === "literal" &&
983
+ v.texts.some((t) => t.includes(nested.placeholder ?? ""))
984
+ ? extra
985
+ : v,
986
+ );
987
+ if (nested.appendInput) childValues.push(extra);
988
+ const flow = invoke(childValues, childScope, input, span, depth + 1, nested.runtimeWrapper);
989
+ const forwardedBy = executableName(first.texts[0] ?? "");
990
+ const exactForward =
991
+ (forwardedBy === "env" &&
992
+ argv
993
+ .slice(1, nested.start)
994
+ .every((word) => word !== undefined && /^[A-Za-z_][A-Za-z0-9_]*=/.test(word))) ||
995
+ (["command", "builtin"].includes(forwardedBy) && nested.start === 1) ||
996
+ (forwardedBy === "rtk" && argv[1] === "proxy" && nested.start === 2);
997
+ stdout = exactForward ? flow.stdout : opaque();
998
+ if (["command", "builtin"].includes((first.texts[0] ?? "").toLowerCase()))
999
+ resultScope = after(flow) ?? resultScope;
1000
+ else if (!plan.remote) {
1001
+ const persisted = merge(resultScope, after(flow)) ?? resultScope;
1002
+ resultScope = {
1003
+ ...resultScope,
1004
+ written: persisted.written,
1005
+ dirtyStartup: persisted.dirtyStartup,
1006
+ };
1007
+ }
1008
+ }
1009
+ if (plan.stdinCode) stdout = analyze(plan.stdinCode, input, childScope, input, span, depth + 1);
1010
+ if (plan.delta === "cd") {
1011
+ const directoryBindings = new Map(resultScope.bindings);
1012
+ if (scope.dialect === "bash") {
1013
+ directoryBindings.set("PWD", invocationScope.cwd);
1014
+ directoryBindings.set("OLDPWD", scope.cwd);
1015
+ }
1016
+ return {
1017
+ success: { ...resultScope, bindings: directoryBindings, cwd: invocationScope.cwd },
1018
+ failure: resultScope,
1019
+ stdout,
1020
+ };
1021
+ }
1022
+ if (plan.copyDestination)
1023
+ copyProofs.push(() => {
1024
+ const destination = values.at(-1) ?? opaque();
1025
+ const paths = checkPath(destination, scope, span);
1026
+ if (
1027
+ destination.kind !== "literal" ||
1028
+ values.length !== 3 ||
1029
+ values[1]?.kind !== "literal" ||
1030
+ !paths.length ||
1031
+ !context.chainCopyDestinations?.length ||
1032
+ paths.some(
1033
+ (path) =>
1034
+ !context.chainCopyDestinations?.some((root) => {
1035
+ const normalized = normalizePath(root, context.home, context.cwd);
1036
+ return path === normalized || path.startsWith(`${normalized}/`);
1037
+ }),
1038
+ ) ||
1039
+ (destination.kind === "literal" &&
1040
+ destination.texts.some(
1041
+ (path) => context.proveCopyDestination?.(path, argv[1] ?? "") !== true,
1042
+ ))
1043
+ )
1044
+ emit(
1045
+ "copy-destination",
1046
+ "copy destination requires the existing filesystem proof",
1047
+ span,
1048
+ true,
1049
+ );
1050
+ });
1051
+ return { success: resultScope, failure: resultScope, stdout };
1052
+ };
1053
+ const expandWords = (words: readonly Word[], scope: Scope): readonly Word[] => {
1054
+ const expanded: Word[] = [];
1055
+ const anyBraces = words.some((word) => word.parts.some((part) => part.kind === "brace"));
1056
+ if (!anyBraces) return words;
1057
+ for (const word of words) {
1058
+ let variants: Array<{ split: boolean; span: Span; parts: Word["parts"][number][] }> = [
1059
+ { ...word, parts: [] },
1060
+ ];
1061
+ for (const part of word.parts) {
1062
+ if (!charge(word.span, variants.length)) return [];
1063
+ if (part.kind !== "brace") {
1064
+ for (const variant of variants) variant.parts.push(part);
1065
+ continue;
1066
+ }
1067
+ if (scope.braceMode === "literal") {
1068
+ for (const variant of variants)
1069
+ variant.parts.push({
1070
+ kind: "literal",
1071
+ text: "{" + part.alternatives.join(",") + "}",
1072
+ quoted: false,
1073
+ });
1074
+ continue;
1075
+ }
1076
+ const choices = part.alternatives;
1077
+ if (variants.length * choices.length > MAX_VALUE_ALTERNATIVES) {
1078
+ emit("analysis-limit", "brace expansion word limit", word.span, true);
1079
+ return [];
1080
+ }
1081
+ variants = variants.flatMap((variant) =>
1082
+ choices.map((text) => ({
1083
+ split: variant.split,
1084
+ span: variant.span,
1085
+ parts: [
1086
+ ...variant.parts,
1087
+ { kind: "literal", text, quoted: false } satisfies Word["parts"][number],
1088
+ ],
1089
+ })),
1090
+ );
1091
+ }
1092
+ for (const variant of variants) {
1093
+ if (
1094
+ scope.braceMode === "bash" &&
1095
+ variant.parts.every((part) => part.kind === "literal" && !part.quoted && part.text === "")
1096
+ )
1097
+ continue;
1098
+ expanded.push(variant);
1099
+ }
1100
+ if (expanded.length > MAX_VALUE_ALTERNATIVES) {
1101
+ emit("analysis-limit", "brace expansion word limit", word.span, true);
1102
+ return [];
1103
+ }
1104
+ }
1105
+ return expanded;
1106
+ };
1107
+ const foldCommand = (command: Command, scope: Scope, input: Value, depth: number): Flow => {
1108
+ if (!charge(command.span)) return { success: scope, failure: scope, stdout: opaque() };
1109
+ const braceWords =
1110
+ command.kind === "simple" ? command.argv : command.kind === "for" ? command.items : [];
1111
+ if (
1112
+ (scope.braceMode === "host" || scope.braceMode === "sh") &&
1113
+ braceWords.some((word) => word.parts.some((part) => part.kind === "brace"))
1114
+ ) {
1115
+ const expanded = foldCommand(command, { ...scope, braceMode: "bash" }, input, depth + 1);
1116
+ const other = foldCommand(
1117
+ command,
1118
+ { ...scope, braceMode: scope.braceMode === "sh" ? "literal" : "zsh" },
1119
+ input,
1120
+ depth + 1,
1121
+ );
1122
+ const restore = (state: Scope | null): Scope | null =>
1123
+ state && { ...state, braceMode: scope.braceMode };
1124
+ return {
1125
+ success: restore(merge(expanded.success, other.success)),
1126
+ failure: restore(merge(expanded.failure, other.failure)),
1127
+ stdout: opaque(),
1128
+ };
1129
+ }
1130
+
1131
+ if (command.kind === "test") {
1132
+ command.words.forEach((word) => evaluate(word, scope, depth));
1133
+ return { success: scope, failure: scope, stdout: literal("") };
1134
+ }
1135
+ if (
1136
+ scope.dialect === "bash" &&
1137
+ ["literal", "sh"].includes(scope.braceMode) &&
1138
+ command.kind !== "simple" &&
1139
+ command.redirects.some((item) => item.kind === "file" && ["&>", "&>>"].includes(item.op))
1140
+ ) {
1141
+ const child = childShell(() =>
1142
+ foldCommand(
1143
+ {
1144
+ ...command,
1145
+ redirects: command.redirects.filter(
1146
+ (item) => !(item.kind === "file" && ["&>", "&>>"].includes(item.op)),
1147
+ ),
1148
+ },
1149
+ scope,
1150
+ input,
1151
+ depth + 1,
1152
+ ),
1153
+ );
1154
+ redirects(command.redirects, scope, input, depth, command.span);
1155
+ return { ...child, success: scope, failure: scope };
1156
+ }
1157
+ const redirected = redirects(command.redirects, scope, input, depth, command.span);
1158
+ let flow: Flow;
1159
+ if (command.kind === "simple") {
1160
+ const names = scope.dialect === "bash" ? readTargets(command) : null;
1161
+ const assignments = command.assignments.map((a) => ({
1162
+ ...a,
1163
+ literalValue: wordText(a.value) !== undefined,
1164
+ value:
1165
+ a.operation === "opaque"
1166
+ ? opaque(...mentions(evaluate(a.value, scope, depth)))
1167
+ : evaluate(a.value, scope, depth),
1168
+ }));
1169
+ const executable = command.argv[0];
1170
+ const declaration = executable !== undefined && wordText(executable) === "export";
1171
+ const values = expandWords(command.argv, scope).flatMap((word, index) => {
1172
+ const value = evaluate(word, scope, depth);
1173
+ const first = word.parts[0];
1174
+ const declared =
1175
+ declaration &&
1176
+ index > 0 &&
1177
+ first?.kind === "literal" &&
1178
+ /^[A-Za-z_][A-Za-z0-9_]*=/.test(first.text);
1179
+ if (declared || !word.split || value.kind !== "literal") return [value];
1180
+ if (value.texts.length !== 1) return [opaque(...value.texts)];
1181
+ return (value.texts[0] ?? "")
1182
+ .split(/[ \t\n]+/)
1183
+ .filter(Boolean)
1184
+ .map((text) => (/[*?[]/.test(text) ? glob(text) : literal(text)));
1185
+ });
1186
+ if (
1187
+ values.length > MAX_VALUE_ALTERNATIVES &&
1188
+ command.argv.some((word) => word.parts.some((part) => part.kind === "brace"))
1189
+ )
1190
+ emit("analysis-limit", "brace expansion word limit", command.span, true);
1191
+ const bindings = new Map(redirected.scope.bindings);
1192
+ for (const assignment of assignments) {
1193
+ const readSeparator =
1194
+ names !== null && assignment.name === "IFS" && assignment.literalValue;
1195
+ if (
1196
+ startup(assignment.name) &&
1197
+ !readSeparator &&
1198
+ !safeStartup(assignment.name, assignment.value, command.argv.map(wordText))
1199
+ )
1200
+ emit("startup-mutation", assignment.name, command.span, true);
1201
+ bindings.set(bindingName(assignment.name, scope), assignment.value);
1202
+ }
1203
+ if (
1204
+ scope.dialect === "powershell" &&
1205
+ command.argv.length === 1 &&
1206
+ executable?.parts.every((part) => part.kind === "literal" && part.quoted)
1207
+ )
1208
+ return {
1209
+ success: redirected.scope,
1210
+ failure: null,
1211
+ stdout: evaluate(executable, redirected.scope, depth),
1212
+ };
1213
+ const transfer = executable ? wordText(executable) : undefined;
1214
+ if (
1215
+ scope.dialect !== "powershell" &&
1216
+ transfer === "exit" &&
1217
+ redirectsCannotFail(command.redirects) &&
1218
+ !(
1219
+ (scope.braceMode === "literal" || scope.braceMode === "sh") &&
1220
+ command.redirects.some((item) => item.kind === "file" && ["&>", "&>>"].includes(item.op))
1221
+ ) &&
1222
+ command.assignments.length === 0 &&
1223
+ command.argv.length <= 2 &&
1224
+ (command.argv.length === 1 ||
1225
+ /^-?\d+$/.test((command.argv[1] ? wordText(command.argv[1]) : undefined) ?? ""))
1226
+ ) {
1227
+ return { success: null, failure: null, stdout: literal("") };
1228
+ }
1229
+ if (
1230
+ scope.dialect !== "powershell" &&
1231
+ transfer === "return" &&
1232
+ command.assignments.length === 0 &&
1233
+ command.argv.length <= 2 &&
1234
+ (command.argv.length === 1 ||
1235
+ /^-?\d+$/.test((command.argv[1] ? wordText(command.argv[1]) : undefined) ?? ""))
1236
+ )
1237
+ return { success: null, failure: redirected.scope, stdout: literal("") };
1238
+ const frame = loopFrames.at(-1);
1239
+ if (
1240
+ scope.dialect !== "powershell" &&
1241
+ frame?.allowed &&
1242
+ (transfer === "break" || transfer === "continue") &&
1243
+ command.assignments.length === 0 &&
1244
+ command.redirects.length === 0 &&
1245
+ (command.argv.length === 1 ||
1246
+ (command.argv.length === 2 && command.argv[1] && wordText(command.argv[1]) === "1"))
1247
+ ) {
1248
+ if (transfer === "break") frame.breaks = merge(frame.breaks, redirected.scope);
1249
+ else frame.continues = merge(frame.continues, redirected.scope);
1250
+ return { success: null, failure: null, stdout: literal("") };
1251
+ }
1252
+ let readFlow: Flow | undefined;
1253
+ if (names) {
1254
+ const readBindings = new Map(bindings);
1255
+ for (const name of names) {
1256
+ if (
1257
+ startup(name) ||
1258
+ /^[A-Z_]/.test(name) ||
1259
+ scope.exported.has(name) ||
1260
+ /^(?:path|cdpath|fpath|manpath|module_path|mailpath|watch|psvar|argv|status|pipestatus|reply|commands|functions|options|parameters|history|dirstack|aliases|signals|zsh_eval_context|galiases|keymaps|jobdirs|patchars|functrace|funcsourcetrace|zsh_scheduled_events|terminfo|reswords|modules|histchars|fignore|usergroups|saliases|funcstack|widgets|userdirs|historywords|nameddirs|termcap|functions_source|jobtexts|funcfiletrace|builtins|prompt|rprompt|jobstates|http_proxy|https_proxy|all_proxy|no_proxy|dis_.*|.*_functions)$/.test(
1261
+ name,
1262
+ )
1263
+ )
1264
+ emit(
1265
+ "startup-mutation",
1266
+ "read into shell control or exported variable " + name,
1267
+ command.span,
1268
+ true,
1269
+ );
1270
+ readBindings.set(name, opaque());
1271
+ }
1272
+ const success = { ...redirected.scope, bindings: readBindings };
1273
+ const failure = merge({ ...redirected.scope, bindings }, success);
1274
+ readFlow = { success, failure, stdout: opaque() };
1275
+ }
1276
+ flow =
1277
+ readFlow ??
1278
+ (values.length
1279
+ ? invoke(
1280
+ values,
1281
+ {
1282
+ ...redirected.scope,
1283
+ bindings,
1284
+ exported: new Set([
1285
+ ...scope.exported,
1286
+ ...assignments.map((assignment) => bindingName(assignment.name, scope)),
1287
+ ]),
1288
+ },
1289
+ redirected.input,
1290
+ command.span,
1291
+ depth,
1292
+ )
1293
+ : {
1294
+ success: { ...redirected.scope, bindings },
1295
+ failure:
1296
+ command.redirects.length ||
1297
+ command.assignments.some((a) =>
1298
+ a.value.parts.some((part) => part.kind === "command-substitution"),
1299
+ )
1300
+ ? { ...redirected.scope, bindings }
1301
+ : null,
1302
+ stdout: literal(""),
1303
+ });
1304
+ if (values.length && assignments.length) {
1305
+ const restore = (state: Scope | null): Scope | null => {
1306
+ if (!state) return null;
1307
+ const restored = new Map(state.bindings);
1308
+ for (const assignment of assignments) {
1309
+ const name = bindingName(assignment.name, scope);
1310
+ const old = scope.bindings.get(name);
1311
+ const persistent = [
1312
+ ":",
1313
+ "export",
1314
+ "readonly",
1315
+ "eval",
1316
+ "set",
1317
+ "shift",
1318
+ "times",
1319
+ "trap",
1320
+ ".",
1321
+ "exec",
1322
+ "return",
1323
+ "break",
1324
+ "continue",
1325
+ "unset",
1326
+ ].includes(transfer ?? "");
1327
+ if (old)
1328
+ restored.set(
1329
+ name,
1330
+ persistent ? join(old, state.bindings.get(name) ?? opaque()) : old,
1331
+ );
1332
+ else restored.delete(name);
1333
+ }
1334
+ return { ...state, bindings: restored, exported: scope.exported };
1335
+ };
1336
+ flow = { ...flow, success: restore(flow.success), failure: restore(flow.failure) };
1337
+ }
1338
+ } else if (command.kind === "group") {
1339
+ const run = (): Flow => foldList(command.body, redirected.scope, redirected.input, depth + 1);
1340
+ flow = command.subshell ? childShell(run) : run();
1341
+ if (command.subshell) flow = { ...flow, success: scope, failure: scope };
1342
+ } else if (command.kind === "for") {
1343
+ if (startup(command.name)) emit("startup-mutation", command.name, command.span, true);
1344
+ const items: Value[] = [];
1345
+ for (const word of expandWords(command.items, scope)) {
1346
+ const value = evaluate(word, scope, depth);
1347
+ if (value.kind === "literal" && value.texts.length === 1) {
1348
+ const text = value.texts[0] ?? "";
1349
+ if (!word.split) items.push(literal(text));
1350
+ else
1351
+ for (const part of text.split(/[ \t\n]+/).filter(Boolean))
1352
+ items.push(/[*?[]/.test(part) ? glob(part) : literal(part));
1353
+ } else items.push(opaque(...mentions(value)));
1354
+ }
1355
+ if (items.length > MAX_VALUE_ALTERNATIVES) {
1356
+ emit("analysis-limit", "loop item limit", command.span, true);
1357
+ flow = { success: scope, failure: scope, stdout: opaque() };
1358
+ } else {
1359
+ let current: Scope | null = redirected.scope;
1360
+ const frame: LoopFrame = {
1361
+ allowed: items.every((item) => item.kind === "literal"),
1362
+ breaks: null,
1363
+ continues: null,
1364
+ };
1365
+ loopFrames.push(frame);
1366
+ for (const item of items) {
1367
+ if (!current) break;
1368
+ const bindings = new Map(current.bindings);
1369
+ bindings.set(bindingName(command.name, scope), item);
1370
+ const step = foldList(
1371
+ command.body,
1372
+ { ...current, bindings },
1373
+ redirected.input,
1374
+ depth + 1,
1375
+ );
1376
+ current = merge(after(step), frame.continues);
1377
+ frame.continues = null;
1378
+ }
1379
+ loopFrames.pop();
1380
+ const exit = merge(current, frame.breaks);
1381
+ flow = { success: exit, failure: exit, stdout: opaque() };
1382
+ }
1383
+ } else if (command.kind === "while" || command.kind === "until") {
1384
+ let current = redirected.scope;
1385
+ let stable = false;
1386
+ let conditionExits: Scope | null = null;
1387
+ const frame: LoopFrame = { allowed: true, breaks: null, continues: null };
1388
+ loopFrames.push(frame);
1389
+ for (let pass = 0; pass < MAX_LOOP_PASSES; pass++) {
1390
+ const condition = foldList(command.condition, current, redirected.input, depth + 1);
1391
+ const entry = command.kind === "while" ? condition.success : condition.failure;
1392
+ conditionExits = merge(
1393
+ conditionExits,
1394
+ command.kind === "while" ? condition.failure : condition.success,
1395
+ );
1396
+ const body = entry ? foldList(command.body, entry, redirected.input, depth + 1) : null;
1397
+ const next =
1398
+ merge(redirected.scope, merge(body ? after(body) : null, frame.continues)) ?? current;
1399
+ frame.continues = null;
1400
+ if (stateKey(next) === stateKey(current)) {
1401
+ stable = true;
1402
+ break;
1403
+ }
1404
+ current = next;
1405
+ }
1406
+ loopFrames.pop();
1407
+ if (!stable) emit("analysis-limit", "loop state did not converge", command.span, true);
1408
+ const exit = merge(merge(current, frame.breaks), conditionExits);
1409
+ flow = { success: exit, failure: exit, stdout: opaque() };
1410
+ } else {
1411
+ const branches: Flow[] = [];
1412
+ if (command.kind === "if") {
1413
+ let entry = redirected.scope;
1414
+ for (const branch of command.branches) {
1415
+ const condition = foldList(branch.condition, entry, redirected.input, depth + 1);
1416
+ if (condition.success)
1417
+ branches.push(foldList(branch.body, condition.success, redirected.input, depth + 1));
1418
+ entry = condition.failure ?? entry;
1419
+ }
1420
+ branches.push(
1421
+ command.otherwise
1422
+ ? foldList(command.otherwise, entry, redirected.input, depth + 1)
1423
+ : { success: entry, failure: null, stdout: literal("") },
1424
+ );
1425
+ } else {
1426
+ evaluate(command.subject, scope, depth);
1427
+ for (const arm of command.arms) {
1428
+ arm.patterns.forEach((word) => evaluate(word, scope, depth));
1429
+ branches.push(foldList(arm.body, redirected.scope, redirected.input, depth + 1));
1430
+ }
1431
+ branches.push({ success: scope, failure: null, stdout: literal("") });
1432
+ }
1433
+ flow = branches.reduce<Flow>(
1434
+ (a, b) => ({
1435
+ success: merge(a.success, b.success),
1436
+ failure: merge(a.failure, b.failure),
1437
+ stdout: join(a.stdout, b.stdout),
1438
+ }),
1439
+ { success: null, failure: null, stdout: literal() },
1440
+ );
1441
+ }
1442
+ if (
1443
+ !["simple", "group", "test"].includes(command.kind) ||
1444
+ (command.kind === "simple" &&
1445
+ command.argv.some((word) => word.parts.some((part) => part.kind !== "literal")))
1446
+ )
1447
+ flow = { ...flow, stdout: opaque() };
1448
+ if (redirected.outputs.length) {
1449
+ if (!after(flow))
1450
+ for (const output of redirected.outputs) observeWrite(output.path, opaque());
1451
+ const remember = (state: Scope | null): Scope | null => {
1452
+ if (!state) return null;
1453
+ const written = new Map(state.written);
1454
+ for (const output of redirected.outputs) {
1455
+ const contents =
1456
+ output.append ||
1457
+ output.fd !== 1 ||
1458
+ redirected.outputs.length !== 1 ||
1459
+ command.redirects.some((redirect) => redirect.kind === "duplicate")
1460
+ ? opaque()
1461
+ : flow.stdout;
1462
+ written.set(output.path, contents);
1463
+ observeWrite(output.path, contents);
1464
+ }
1465
+ return { ...state, written };
1466
+ };
1467
+ flow = { ...flow, success: remember(flow.success), failure: remember(flow.failure) };
1468
+ }
1469
+ if (
1470
+ command.redirects.some(
1471
+ (redirect) =>
1472
+ redirect.kind === "duplicate" || (redirect.kind === "file" && redirect.op !== "<"),
1473
+ )
1474
+ )
1475
+ flow = { ...flow, stdout: opaque() };
1476
+ return flow;
1477
+ };
1478
+ const foldPipeline = (pipeline: Pipeline, scope: Scope, input: Value, depth: number): Flow => {
1479
+ let current: Flow = { success: scope, failure: null, stdout: input };
1480
+ for (const [index, command] of pipeline.commands.entries()) {
1481
+ const incoming = pipeline.stderr
1482
+ ? { ...opaque(), supplied: true }
1483
+ : index > 0 && current.stdout.kind === "opaque"
1484
+ ? { ...current.stdout, supplied: true }
1485
+ : current.stdout;
1486
+ current =
1487
+ pipeline.commands.length > 1
1488
+ ? childShell(() => foldCommand(command, scope, incoming, depth))
1489
+ : foldCommand(command, scope, incoming, depth);
1490
+ }
1491
+ if (pipeline.stderr) current = { ...current, stdout: opaque() };
1492
+ if (pipeline.commands.length > 1)
1493
+ current = {
1494
+ ...current,
1495
+ success: merge(scope, current.success),
1496
+ failure: merge(scope, current.failure),
1497
+ };
1498
+ return pipeline.negated
1499
+ ? { ...current, success: current.failure, failure: current.success }
1500
+ : current;
1501
+ };
1502
+ function foldList(list: List, scope: Scope, input: Value, depth: number): Flow {
1503
+ if (depth > MAX_PARSE_DEPTH) {
1504
+ emit("analysis-limit", "composition depth", { start: 0, end: 0 }, true);
1505
+ return { success: scope, failure: scope, stdout: opaque() };
1506
+ }
1507
+ let current: Flow = { success: scope, failure: null, stdout: literal() };
1508
+ let output: Value = literal();
1509
+ for (const item of list.items) {
1510
+ const entry = after(current);
1511
+ if (!entry) break;
1512
+ const last = (item.rest.at(-1)?.pipeline ?? item.first).commands.at(-1);
1513
+ const background =
1514
+ item.background ||
1515
+ (scope.dialect === "bash" &&
1516
+ ["literal", "sh"].includes(scope.braceMode) &&
1517
+ last?.kind !== "test" &&
1518
+ last?.redirects.some(
1519
+ (redirect) => redirect.kind === "file" && ["&>", "&>>"].includes(redirect.op),
1520
+ ));
1521
+ const run = (pipeline: Pipeline, state: Scope): Flow =>
1522
+ background
1523
+ ? childShell(() => foldPipeline(pipeline, state, input, depth))
1524
+ : foldPipeline(pipeline, state, input, depth);
1525
+ current = run(item.first, entry);
1526
+ for (const part of item.rest) {
1527
+ const selected = part.op === "&&" ? current.success : current.failure;
1528
+ if (!selected) continue;
1529
+ const next = run(part.pipeline, selected);
1530
+ current =
1531
+ part.op === "&&"
1532
+ ? {
1533
+ success: next.success,
1534
+ failure: merge(current.failure, next.failure),
1535
+ stdout: join(current.stdout, next.stdout),
1536
+ }
1537
+ : {
1538
+ success: merge(current.success, next.success),
1539
+ failure: next.failure,
1540
+ stdout: join(current.stdout, next.stdout),
1541
+ };
1542
+ }
1543
+ if (background) current = { ...current, success: entry, failure: entry };
1544
+ output = join(output, current.stdout);
1545
+ }
1546
+ const single =
1547
+ list.items.length === 1 && list.items[0]?.rest.length === 0 && !list.items[0]?.background;
1548
+ return { ...current, stdout: single ? output : opaque() };
1549
+ }
1550
+ if (script.kind === "parse-error")
1551
+ emit(
1552
+ script.code === "work-limit" ? "analysis-limit" : "unparseable",
1553
+ script.code,
1554
+ script.span,
1555
+ true,
1556
+ );
1557
+ else {
1558
+ const scope: Scope = {
1559
+ bindings: new Map(),
1560
+ cwd: literal(normalizePath(context.cwd, context.home, "/")),
1561
+ positional: null,
1562
+ written: new Map(),
1563
+ exported: new Set(),
1564
+ dirtyStartup: [],
1565
+ dialect: script.dialect,
1566
+ braceMode: "host",
1567
+ };
1568
+ foldList(script.body, scope, opaque(), 0);
1569
+ if (herdrSeen || context.chainCopyDestinations?.length) for (const prove of copyProofs) prove();
1570
+ checkConfigured(script.source, script.span);
1571
+ }
1572
+ const [first, ...rest] = findings;
1573
+ return first
1574
+ ? { kind: detected ? "violation" : "missing-knowledge", findings: [first, ...rest] }
1575
+ : { kind: "pass" };
1576
+ }