@blogic-cz/agent-tools 1.10.1 → 1.12.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.
- package/README.md +21 -44
- package/dist/credential-guard/bash.d.ts +3 -0
- package/dist/credential-guard/bash.d.ts.map +1 -0
- package/dist/credential-guard/claude-hook.d.ts.map +1 -1
- package/dist/credential-guard/engine.test.d.ts +5 -0
- package/dist/credential-guard/engine.test.d.ts.map +1 -0
- package/dist/credential-guard/index.d.ts +8 -35
- package/dist/credential-guard/index.d.ts.map +1 -1
- package/dist/credential-guard/ir.d.ts +150 -0
- package/dist/credential-guard/ir.d.ts.map +1 -0
- package/dist/credential-guard/literal-output.d.ts +2 -0
- package/dist/credential-guard/literal-output.d.ts.map +1 -0
- package/dist/credential-guard/passive-awk.d.ts +2 -0
- package/dist/credential-guard/passive-awk.d.ts.map +1 -0
- package/dist/credential-guard/passive-jq.d.ts.map +1 -1
- package/dist/credential-guard/passive-sed.d.ts +2 -0
- package/dist/credential-guard/passive-sed.d.ts.map +1 -0
- package/dist/credential-guard/passive.test.d.ts +2 -0
- package/dist/credential-guard/passive.test.d.ts.map +1 -0
- package/dist/credential-guard/paths.d.ts +14 -0
- package/dist/credential-guard/paths.d.ts.map +1 -0
- package/dist/credential-guard/policy.d.ts +47 -0
- package/dist/credential-guard/policy.d.ts.map +1 -0
- package/dist/credential-guard/powershell.d.ts +3 -0
- package/dist/credential-guard/powershell.d.ts.map +1 -0
- package/dist/credential-guard/registry.d.ts +89 -0
- package/dist/credential-guard/registry.d.ts.map +1 -0
- package/dist/credential-guard/review4.test.d.ts +2 -0
- package/dist/credential-guard/review4.test.d.ts.map +1 -0
- package/dist/credential-guard/review7.test.d.ts +2 -0
- package/dist/credential-guard/review7.test.d.ts.map +1 -0
- package/dist/credential-guard/shell-oracle.d.ts +2 -0
- package/dist/credential-guard/shell-oracle.d.ts.map +1 -0
- package/dist/credential-guard/xargs.d.ts +7 -0
- package/dist/credential-guard/xargs.d.ts.map +1 -0
- package/dist/db-tool/service.d.ts +2 -1
- package/dist/db-tool/service.d.ts.map +1 -1
- package/dist/db-tool/sql-client.d.ts +1 -0
- package/dist/db-tool/sql-client.d.ts.map +1 -1
- package/dist/gh-tool/pr/commands.d.ts.map +1 -1
- package/dist/gh-tool/read/api.d.ts +8 -0
- package/dist/gh-tool/read/api.d.ts.map +1 -1
- package/dist/gh-tool/read/cache.d.ts.map +1 -1
- package/dist/gh-tool/read/commands.d.ts +5 -0
- package/dist/gh-tool/read/commands.d.ts.map +1 -1
- package/dist/gh-tool/read/reads.d.ts +38 -0
- package/dist/gh-tool/read/reads.d.ts.map +1 -1
- package/dist/gh-tool/read/telemetry-command.d.ts +7 -0
- package/dist/gh-tool/read/telemetry-command.d.ts.map +1 -0
- package/dist/gh-tool/read/telemetry-store.d.ts +3 -0
- package/dist/gh-tool/read/telemetry-store.d.ts.map +1 -0
- package/dist/gh-tool/read/telemetry.d.ts +59 -0
- package/dist/gh-tool/read/telemetry.d.ts.map +1 -0
- package/dist/gh-tool/read/transport.d.ts.map +1 -1
- package/dist/observability-tool/logs.d.ts +1 -1
- package/dist/observability-tool/logs.d.ts.map +1 -1
- package/dist/observability-tool/metrics.d.ts +1 -1
- package/dist/observability-tool/metrics.d.ts.map +1 -1
- package/dist/observability-tool/shared.d.ts +2 -1
- package/dist/observability-tool/shared.d.ts.map +1 -1
- package/dist/observability-tool/trace.d.ts +1 -1
- package/dist/observability-tool/trace.d.ts.map +1 -1
- package/dist/shared/content-security.d.ts +3 -1
- package/dist/shared/content-security.d.ts.map +1 -1
- package/dist/shared/secret-env.d.ts +22 -0
- package/dist/shared/secret-env.d.ts.map +1 -0
- package/package.json +2 -1
- package/schemas/agent-tools.schema.json +1 -1
- package/src/credential-guard/bash.ts +769 -0
- package/src/credential-guard/claude-hook.ts +14 -14
- package/src/credential-guard/engine.test.ts +2050 -0
- package/src/credential-guard/index.ts +285 -4268
- package/src/credential-guard/ir.ts +139 -0
- package/src/credential-guard/literal-output.ts +8 -0
- package/src/credential-guard/passive-awk.ts +239 -0
- package/src/credential-guard/passive-jq.ts +183 -24
- package/src/credential-guard/passive-sed.ts +78 -0
- package/src/credential-guard/passive.test.ts +141 -0
- package/src/credential-guard/paths.ts +108 -0
- package/src/credential-guard/policy.ts +1576 -0
- package/src/credential-guard/powershell.ts +220 -0
- package/src/credential-guard/registry.ts +4720 -0
- package/src/credential-guard/review4.test.ts +83 -0
- package/src/credential-guard/review7.test.ts +51 -0
- package/src/credential-guard/rg-types.json +389 -0
- package/src/credential-guard/shell-oracle.ts +187 -0
- package/src/credential-guard/xargs.ts +65 -0
- package/src/db-tool/main.ts +2 -0
- package/src/db-tool/service.ts +122 -140
- package/src/db-tool/sql-client.ts +7 -4
- package/src/gh-tool/main.ts +30 -6
- package/src/gh-tool/pr/commands.ts +9 -2
- package/src/gh-tool/pr/core.ts +2 -2
- package/src/gh-tool/read/api.ts +73 -6
- package/src/gh-tool/read/cache.ts +13 -4
- package/src/gh-tool/read/commands.ts +17 -1
- package/src/gh-tool/read/reads.ts +84 -1
- package/src/gh-tool/read/telemetry-command.ts +45 -0
- package/src/gh-tool/read/telemetry-store.ts +85 -0
- package/src/gh-tool/read/telemetry.ts +191 -0
- package/src/gh-tool/read/transport.ts +10 -5
- package/src/gh-tool/workflow.ts +2 -2
- package/src/observability-tool/main.ts +7 -1
- package/src/observability-tool/shared.ts +20 -14
- package/src/shared/content-security.ts +7 -2
- package/src/shared/secret-env.ts +230 -0
|
@@ -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
|
+
}
|