@mortz.prk/opencode-auto-mode 1.0.0 → 1.0.2
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 +143 -10
- package/dist/gui-experimental.cjs +1 -0
- package/package.json +23 -13
- package/src/audit.ts +24 -7
- package/src/classifier.ts +19 -3
- package/src/config.ts +10 -1
- package/src/decisions.ts +38 -15
- package/src/errors.ts +39 -0
- package/src/gui-state.ts +160 -0
- package/src/gui.tsx +129 -0
- package/src/index.ts +377 -98
- package/src/otlp.ts +18 -15
- package/src/reviewer.ts +11 -1
- package/src/rpc.ts +117 -0
- package/src/shell-policy.ts +1337 -0
- package/src/shell.ts +379 -18
- package/src/state.ts +75 -0
- package/src/status.ts +72 -0
- package/src/tiers.ts +2 -20
- package/src/timeout.ts +30 -0
- package/src/traces.ts +13 -7
- package/src/transcript.ts +145 -29
- package/src/tui-state.ts +7 -1
- package/src/tui.tsx +20 -5
|
@@ -0,0 +1,1337 @@
|
|
|
1
|
+
import { basename, posix } from "node:path";
|
|
2
|
+
import { parse, type ParseEntry } from "shell-quote";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Command vocabulary and per-command safety policy for shell approval.
|
|
6
|
+
*
|
|
7
|
+
* Everything here works on parsed words rather than raw command text, so text that
|
|
8
|
+
* merely mentions a dangerous word (a commit message, a search pattern, a heredoc
|
|
9
|
+
* body) is never treated as a dangerous operation.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const SHELLS = new Set(["sh", "bash", "dash", "zsh", "ksh", "fish", "pwsh", "powershell", "cmd"]);
|
|
13
|
+
export const SCRIPT_HOSTS = new Set(["python", "python3", "node", "perl", "ruby", "php", "awk", "gawk", "lua"]);
|
|
14
|
+
export const STATEFUL = new Set(["alias", "unalias", "export", "unset", "set", "shopt", "trap", "umask",
|
|
15
|
+
"readonly", "declare", "typeset", "local", "function"]);
|
|
16
|
+
export const DYNAMIC = new Set(["eval", "source", ".", "exec", "xargs"]);
|
|
17
|
+
export const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
18
|
+
export const CONTROL_OPERATORS = new Set([";", "&&", "||", "|", "|&", "&"]);
|
|
19
|
+
export const REDIRECT_OPERATORS = new Set([">", ">>", ">&", "<&", "<", "<<", "<<-", "<<<", "<>", ">|", "&>", "&>>"]);
|
|
20
|
+
/**
|
|
21
|
+
* Characters a shell treats as word separators.
|
|
22
|
+
*
|
|
23
|
+
* JS `\s` is wider — it also matches CR, FF, VT, NBSP, U+2028/U+2029, and other
|
|
24
|
+
* Unicode spaces — so every boundary scan here spells the shell set out, and
|
|
25
|
+
* `hasNonShellWhitespace` rejects anything wider before parsing.
|
|
26
|
+
*/
|
|
27
|
+
const WORD_BREAK = /[ \t\n;|&()<>]/;
|
|
28
|
+
|
|
29
|
+
/** True when the text contains whitespace a shell does not treat as a separator. */
|
|
30
|
+
export function hasNonShellWhitespace(text: string): boolean {
|
|
31
|
+
return /[^\S \t\n]/.test(text);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Syntax this policy does not decode: whitespace a shell does not separate words
|
|
36
|
+
* on, legacy `$[...]` arithmetic, and ANSI-C / locale quoting.
|
|
37
|
+
*
|
|
38
|
+
* Callers check both the raw text and the text after continuation removal:
|
|
39
|
+
* normalization deletes `\`-newlines, which can assemble one of these forms from
|
|
40
|
+
* characters that were separate in the raw source (`$`+`\`-newline+`'...'` is
|
|
41
|
+
* ANSI-C quoting to a shell).
|
|
42
|
+
*/
|
|
43
|
+
export function hasUndecodedSyntax(text: string): boolean {
|
|
44
|
+
return hasNonShellWhitespace(text) || text.includes("$[") ||
|
|
45
|
+
text.includes("$'") || text.includes('$"');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Matches text whose quotes are balanced and whose backslashes escape a character
|
|
50
|
+
* on the same line.
|
|
51
|
+
*
|
|
52
|
+
* A prefix cut before an undecoded opener is only a complete command when this
|
|
53
|
+
* matches; otherwise an open quote could turn quoted data into commands.
|
|
54
|
+
*/
|
|
55
|
+
export const BALANCED_QUOTES = /^(?:[^'"\\]|\\[^\r\n]|'[^']*'|"(?:[^"\\]|\\[^\r\n])*")*$/;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* True when removing `\`-newlines would assemble syntax this policy does not
|
|
59
|
+
* decode. Parsing such a line can place a quote or word boundary differently
|
|
60
|
+
* from the shell, so callers must not take a deny from it.
|
|
61
|
+
*/
|
|
62
|
+
export function hasAssembledUndecodedSyntax(text: string): boolean {
|
|
63
|
+
return !hasUndecodedSyntax(text) && hasUndecodedSyntax(normalizeContinuations(text));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type OptionArity = "flag" | "value";
|
|
67
|
+
|
|
68
|
+
interface WrapperGrammar {
|
|
69
|
+
short: Readonly<Record<string, OptionArity>>;
|
|
70
|
+
long: Readonly<Record<string, OptionArity>>;
|
|
71
|
+
/** A bare `-N` adjustment (`nice -5 cmd`) is an option rather than a command word. */
|
|
72
|
+
numeric?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Parses a short-option spec; a letter followed by `:` takes a value. */
|
|
76
|
+
function shortOptions(spec: string): Record<string, OptionArity> {
|
|
77
|
+
const options: Record<string, OptionArity> = {};
|
|
78
|
+
for (let index = 0; index < spec.length; index++) {
|
|
79
|
+
if (spec[index] === ":") continue;
|
|
80
|
+
options[spec[index]] = spec[index + 1] === ":" ? "value" : "flag";
|
|
81
|
+
}
|
|
82
|
+
return options;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Parses a whitespace-separated long-option spec; a trailing `:` takes a value. */
|
|
86
|
+
function longOptions(spec: string): Record<string, OptionArity> {
|
|
87
|
+
const options: Record<string, OptionArity> = {};
|
|
88
|
+
for (const option of spec.split(/\s+/).filter(Boolean)) {
|
|
89
|
+
const takesValue = option.endsWith(":");
|
|
90
|
+
options[takesValue ? option.slice(0, -1) : option] = takesValue ? "value" : "flag";
|
|
91
|
+
}
|
|
92
|
+
return options;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Option grammar for every command wrapper. Options that take a value consume
|
|
97
|
+
* the following word, so `stdbuf -o L sudo id` and `ionice -c 3 sudo id` resolve
|
|
98
|
+
* to `sudo` rather than to the option value.
|
|
99
|
+
*/
|
|
100
|
+
const WRAPPER_GRAMMARS: Record<string, WrapperGrammar> = {
|
|
101
|
+
// `env -S` / `--split-string` reparses its value into command words, so the
|
|
102
|
+
// effective executable is not the next word. Leaving the option unknown makes
|
|
103
|
+
// the invocation fail closed to manual approval instead of resolving past it.
|
|
104
|
+
env: { short: shortOptions("u:C:i0v"), long: longOptions(
|
|
105
|
+
"--unset: --chdir: --ignore-environment --null --debug --block-signal --default-signal --ignore-signal") },
|
|
106
|
+
command: { short: shortOptions("pvV"), long: longOptions("") },
|
|
107
|
+
builtin: { short: shortOptions(""), long: longOptions("") },
|
|
108
|
+
exec: { short: shortOptions("a:cl"), long: longOptions("") },
|
|
109
|
+
time: { short: shortOptions("f:o:pavql"), long: longOptions("--format: --output: --verbose --quiet --portability --append") },
|
|
110
|
+
"!": { short: shortOptions(""), long: longOptions("") },
|
|
111
|
+
nohup: { short: shortOptions(""), long: longOptions("--help --version") },
|
|
112
|
+
setsid: { short: shortOptions("fwc"), long: longOptions("--fork --wait --ctty --help --version") },
|
|
113
|
+
nice: { short: shortOptions("n:"), long: longOptions("--adjustment: --help --version"), numeric: true },
|
|
114
|
+
ionice: { short: shortOptions("c:n:p:P:u:t"), long: longOptions(
|
|
115
|
+
"--class: --classdata: --pid: --pgid: --uid: --ignore --help --version") },
|
|
116
|
+
stdbuf: { short: shortOptions("i:o:e:"), long: longOptions("--input: --output: --error: --help --version") },
|
|
117
|
+
timeout: { short: shortOptions("s:k:"), long: longOptions(
|
|
118
|
+
"--signal: --kill-after: --foreground --preserve-status --verbose --help --version") },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const WRAPPERS = new Set(Object.keys(WRAPPER_GRAMMARS));
|
|
122
|
+
|
|
123
|
+
const DANGEROUS = "dangerous shell operation";
|
|
124
|
+
const FORK_BOMB = /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
|
|
125
|
+
const MAX_NESTING = 2;
|
|
126
|
+
|
|
127
|
+
const DENY_EXECUTABLES = new Set(["sudo", "doas", "su", "shutdown", "reboot", "halt", "poweroff"]);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The arity of a long option, accepting the unambiguous abbreviations GNU
|
|
131
|
+
* option parsing allows (`stdbuf --out=L` means `--output`). An abbreviation
|
|
132
|
+
* that could name options of different arity is unknowable.
|
|
133
|
+
*/
|
|
134
|
+
function longOptionArity(grammar: WrapperGrammar, option: string): OptionArity | undefined {
|
|
135
|
+
if (Object.hasOwn(grammar.long, option)) return grammar.long[option];
|
|
136
|
+
const arities = new Set(Object.keys(grammar.long)
|
|
137
|
+
.filter((name) => name.startsWith(option))
|
|
138
|
+
.map((name) => grammar.long[name]));
|
|
139
|
+
return arities.size === 1 ? [...arities][0] : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Index of the first word after a wrapper's options, or undefined when an
|
|
144
|
+
* unknown option makes its arity — and therefore the effective executable —
|
|
145
|
+
* unknowable. Unknown options fail closed to manual approval.
|
|
146
|
+
*/
|
|
147
|
+
function wrapperCommand(grammar: WrapperGrammar, words: readonly string[], start: number): number | undefined {
|
|
148
|
+
let index = start;
|
|
149
|
+
while (index < words.length) {
|
|
150
|
+
const word = words[index];
|
|
151
|
+
if (word === "--") return index + 1;
|
|
152
|
+
if (word.startsWith("--")) {
|
|
153
|
+
const separator = word.indexOf("=");
|
|
154
|
+
const arity = longOptionArity(grammar, separator === -1 ? word : word.slice(0, separator));
|
|
155
|
+
if (arity === undefined) return undefined;
|
|
156
|
+
index += arity === "value" && separator === -1 ? 2 : 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (word.startsWith("-") && word.length > 1) {
|
|
160
|
+
const letters = word.slice(1);
|
|
161
|
+
if (grammar.numeric && /^\d+$/.test(letters)) { index++; continue; }
|
|
162
|
+
let separateValue = false;
|
|
163
|
+
for (let position = 0; position < letters.length; position++) {
|
|
164
|
+
const arity = grammar.short[letters[position]];
|
|
165
|
+
if (arity === undefined) return undefined;
|
|
166
|
+
if (arity === "flag") continue;
|
|
167
|
+
// Any remaining letters are the attached value (`-n5`).
|
|
168
|
+
separateValue = position === letters.length - 1;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
index += separateValue ? 2 : 1;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
return index;
|
|
175
|
+
}
|
|
176
|
+
return index;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Resolves leading assignments and wrapper options to the effective executable. */
|
|
180
|
+
export function resolveExecutable(words: readonly string[]): { name: string; index: number } | undefined {
|
|
181
|
+
let index = 0;
|
|
182
|
+
while (index < words.length && ASSIGNMENT.test(words[index])) index++;
|
|
183
|
+
while (index < words.length) {
|
|
184
|
+
const name = basename(words[index]);
|
|
185
|
+
const grammar = WRAPPERS.has(name) ? WRAPPER_GRAMMARS[name] : undefined;
|
|
186
|
+
if (grammar === undefined) break;
|
|
187
|
+
const command = wrapperCommand(grammar, words, index + 1);
|
|
188
|
+
if (command === undefined) return undefined;
|
|
189
|
+
index = command;
|
|
190
|
+
// `timeout DURATION COMMAND` consumes the duration before the command.
|
|
191
|
+
if (name === "timeout") {
|
|
192
|
+
if (index >= words.length) return undefined;
|
|
193
|
+
index++;
|
|
194
|
+
}
|
|
195
|
+
while (index < words.length && ASSIGNMENT.test(words[index])) index++;
|
|
196
|
+
}
|
|
197
|
+
return index < words.length ? { name: basename(words[index]), index } : undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* True when an argument names one of the given long options, including the
|
|
202
|
+
* abbreviations GNU option parsing accepts (`sort --out=/tmp/out` means
|
|
203
|
+
* `--output`). Abbreviations fail closed: any prefix that could name a dangerous
|
|
204
|
+
* option requires review rather than approval.
|
|
205
|
+
*/
|
|
206
|
+
function hasLongOption(args: readonly string[], names: readonly string[]): boolean {
|
|
207
|
+
return args.some((arg) => {
|
|
208
|
+
const option = arg.split("=", 1)[0];
|
|
209
|
+
if (!option.startsWith("--") || option.length === 2) return false;
|
|
210
|
+
return names.some((name) => name.startsWith(option));
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function hasShortOption(args: readonly string[], letters: string): boolean {
|
|
215
|
+
return args.some((arg) => arg.startsWith("-") && !arg.startsWith("--") &&
|
|
216
|
+
[...arg.slice(1)].some((character) => letters.includes(character)));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isWorldWritableMode(arg: string): boolean {
|
|
220
|
+
const mode = arg.replace(/^[-+]+/, "");
|
|
221
|
+
return mode === "777" || mode === "0777";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** chmod options, including the BSD flags, that never consume the mode operand. */
|
|
225
|
+
const CHMOD_FLAGS = /^-[RcfvHhLP]+$/;
|
|
226
|
+
|
|
227
|
+
/** The mode operand chmod applies, ignoring options and earlier operands. */
|
|
228
|
+
function chmodMode(args: readonly string[]): string | undefined {
|
|
229
|
+
let end = false;
|
|
230
|
+
for (const arg of args) {
|
|
231
|
+
if (end) return arg;
|
|
232
|
+
if (arg === "--") { end = true; continue; }
|
|
233
|
+
// `--reference` copies a mode from another file, so there is no mode operand.
|
|
234
|
+
if (arg === "--reference" || arg.startsWith("--reference=")) return undefined;
|
|
235
|
+
if (arg.startsWith("--") || CHMOD_FLAGS.test(arg)) continue;
|
|
236
|
+
return arg;
|
|
237
|
+
}
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** True when a path names the filesystem root rather than a directory below it. */
|
|
242
|
+
function isFilesystemRoot(target: string): boolean {
|
|
243
|
+
if (!target.startsWith("/")) return false;
|
|
244
|
+
const trimmed = target.replace(/\/+$/, "").replace(/\/\*+$/, "");
|
|
245
|
+
return trimmed === "" || posix.normalize(trimmed) === "/";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* True when `rm` removes the filesystem root recursively. Force is irrelevant:
|
|
250
|
+
* a writable root is already destroyed by `rm -r / --no-preserve-root`.
|
|
251
|
+
*
|
|
252
|
+
* `--` ends option parsing, so every later argument is a target: `rm -- -r /`
|
|
253
|
+
* asks `rm` to remove the files `-r` and `/` without recursion, which fails on
|
|
254
|
+
* the root instead of deleting it, while `rm -r -- /` still denies.
|
|
255
|
+
*/
|
|
256
|
+
function isRecursiveRootRemoval(args: readonly string[]): boolean {
|
|
257
|
+
let recursive = false;
|
|
258
|
+
let endOfOptions = false;
|
|
259
|
+
const targets: string[] = [];
|
|
260
|
+
for (const arg of args) {
|
|
261
|
+
if (endOfOptions) { targets.push(arg); continue; }
|
|
262
|
+
if (arg === "--") { endOfOptions = true; continue; }
|
|
263
|
+
if (arg.startsWith("--")) {
|
|
264
|
+
if (arg === "--recursive") recursive = true;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (arg.startsWith("-") && arg.length > 1) {
|
|
268
|
+
const letters = arg.slice(1);
|
|
269
|
+
if (letters.includes("r") || letters.includes("R")) recursive = true;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
targets.push(arg);
|
|
273
|
+
}
|
|
274
|
+
if (!recursive || targets.length === 0) return false;
|
|
275
|
+
return targets.some(isFilesystemRoot);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Shell options that take no value and may follow `-c` before its script. */
|
|
279
|
+
const SHELL_FLAGS = new Set(["-a", "-b", "-C", "-e", "-f", "-h", "-i", "-k", "-l", "-m", "-n", "-p", "-r", "-s",
|
|
280
|
+
"-t", "-u", "-v", "-x", "-B", "-E", "-H", "-P", "-T", "--login", "--noprofile", "--norc", "--posix",
|
|
281
|
+
"--restricted", "--verbose", "--noediting"]);
|
|
282
|
+
/** Shell options that consume the next word as their value. */
|
|
283
|
+
const SHELL_VALUE_OPTIONS = new Set(["-o", "-O", "--rcfile", "--init-file"]);
|
|
284
|
+
const SHELL_FLAG_LETTERS = "abcCefhiklmnprstuvxBEHPT";
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The script operand that follows the `-c` option at `start`.
|
|
288
|
+
*
|
|
289
|
+
* A shell keeps parsing options after `-c`, so `sh -c -e 'pwd`+newline+`sudo id'`
|
|
290
|
+
* runs the quoted string, not `-e`, and `sh -c -- script` runs `script`. Known
|
|
291
|
+
* flags and their values are skipped; an unknown option could consume the script
|
|
292
|
+
* itself, so its arity is unknowable and the caller must fail closed.
|
|
293
|
+
*/
|
|
294
|
+
function shellScript(args: readonly string[], start: number): string | undefined {
|
|
295
|
+
let index = start;
|
|
296
|
+
while (index < args.length) {
|
|
297
|
+
const arg = args[index];
|
|
298
|
+
if (arg === "--") return args[index + 1];
|
|
299
|
+
if (SHELL_VALUE_OPTIONS.has(arg)) { index += 2; continue; }
|
|
300
|
+
if (SHELL_FLAGS.has(arg)) { index++; continue; }
|
|
301
|
+
if (/^-[^-]+$/.test(arg) && [...arg.slice(1)].every((letter) => SHELL_FLAG_LETTERS.includes(letter))) {
|
|
302
|
+
index++;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (arg.startsWith("-") && arg.length > 1) return undefined;
|
|
306
|
+
return arg;
|
|
307
|
+
}
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The script a shell or `eval` would run, when invoked non-interactively. */
|
|
312
|
+
function scriptArgument(name: string, args: readonly string[]): string | undefined {
|
|
313
|
+
if (name === "eval") return args.length ? args.join(" ") : undefined;
|
|
314
|
+
for (let index = 0; index < args.length; index++) {
|
|
315
|
+
const arg = args[index];
|
|
316
|
+
if (arg === "--") return undefined;
|
|
317
|
+
if (!arg.startsWith("-") || arg.startsWith("--")) continue;
|
|
318
|
+
if (arg.slice(1).includes("c")) return shellScript(args, index + 1);
|
|
319
|
+
}
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Removes file-descriptor prefixes (`2` in `2>/dev/null`) that are adjacent to
|
|
325
|
+
* the redirection operator in the source.
|
|
326
|
+
*
|
|
327
|
+
* Only an unquoted, unescaped digit run that starts a word is a descriptor. A
|
|
328
|
+
* numeric operand separated by whitespace (`chmod 777 > /dev/null secret`) is
|
|
329
|
+
* preserved, as is a quoted (`"2">file`) or escaped (`\2>file`) digit argument.
|
|
330
|
+
*/
|
|
331
|
+
export function stripDescriptorPrefixes(command: string): string {
|
|
332
|
+
let result = "";
|
|
333
|
+
let boundary = true;
|
|
334
|
+
let index = 0;
|
|
335
|
+
while (index < command.length) {
|
|
336
|
+
const character = command[index];
|
|
337
|
+
if (character === "\\") {
|
|
338
|
+
result += command.slice(index, index + 2);
|
|
339
|
+
boundary = false;
|
|
340
|
+
index += 2;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (character === "'") {
|
|
344
|
+
const end = command.indexOf("'", index + 1);
|
|
345
|
+
const stop = end === -1 ? command.length : end + 1;
|
|
346
|
+
result += command.slice(index, stop);
|
|
347
|
+
boundary = false;
|
|
348
|
+
index = stop;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
// ANSI-C quoting (`$'...'`) is the one quoted form where a backslash escapes
|
|
352
|
+
// the closing quote, so its body needs the same escape handling as `"..."`.
|
|
353
|
+
if (character === "$" && command[index + 1] === "'") {
|
|
354
|
+
let cursor = index + 2;
|
|
355
|
+
while (cursor < command.length && command[cursor] !== "'") cursor += command[cursor] === "\\" ? 2 : 1;
|
|
356
|
+
const stop = Math.min(cursor + 1, command.length);
|
|
357
|
+
result += command.slice(index, stop);
|
|
358
|
+
boundary = false;
|
|
359
|
+
index = stop;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (character === '"') {
|
|
363
|
+
let cursor = index + 1;
|
|
364
|
+
while (cursor < command.length && command[cursor] !== '"') cursor += command[cursor] === "\\" ? 2 : 1;
|
|
365
|
+
const stop = Math.min(cursor + 1, command.length);
|
|
366
|
+
result += command.slice(index, stop);
|
|
367
|
+
boundary = false;
|
|
368
|
+
index = stop;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (boundary && /\d/.test(character)) {
|
|
372
|
+
let cursor = index;
|
|
373
|
+
while (cursor < command.length && /\d/.test(command[cursor])) cursor++;
|
|
374
|
+
if (command[cursor] === ">" || command[cursor] === "<") {
|
|
375
|
+
// Replace the run with a space instead of deleting it: `&2>` is `&`
|
|
376
|
+
// followed by a redirect, and deleting the `2` would fuse `&` and `>`
|
|
377
|
+
// into the different `&>` operator.
|
|
378
|
+
result += " ";
|
|
379
|
+
index = cursor;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
result += command.slice(index, cursor);
|
|
383
|
+
boundary = false;
|
|
384
|
+
index = cursor;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
result += character;
|
|
388
|
+
boundary = /[ \t\n;|&()<>]/.test(character);
|
|
389
|
+
index++;
|
|
390
|
+
}
|
|
391
|
+
return result;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Escapes a `#` that is part of a word so `shell-quote` does not mistake it for
|
|
396
|
+
* the start of a comment.
|
|
397
|
+
*
|
|
398
|
+
* Shell comments begin only at the start of a word. `shell-quote` instead ends
|
|
399
|
+
* the command at any unquoted `#`, which can hide a later command
|
|
400
|
+
* (`echo a#b ; sudo id`). Escaping that literal hash preserves the word text and
|
|
401
|
+
* lets the deny pass see the rest of the line. A real word-start comment is left
|
|
402
|
+
* untouched, and quoted or escaped hashes are already literal.
|
|
403
|
+
*/
|
|
404
|
+
export function escapeLiteralHashes(command: string): string {
|
|
405
|
+
let result = "";
|
|
406
|
+
let wordStart = true;
|
|
407
|
+
let quote: "'" | '"' | "$'" | undefined;
|
|
408
|
+
for (let index = 0; index < command.length; index++) {
|
|
409
|
+
const character = command[index];
|
|
410
|
+
if (quote === "'") {
|
|
411
|
+
result += character;
|
|
412
|
+
if (character === "'") quote = undefined;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (quote === "$'") {
|
|
416
|
+
result += character;
|
|
417
|
+
if (character === "\\") {
|
|
418
|
+
if (index + 1 < command.length) result += command[++index];
|
|
419
|
+
} else if (character === "'") quote = undefined;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (quote === '"') {
|
|
423
|
+
result += character;
|
|
424
|
+
if (character === "\\") {
|
|
425
|
+
if (index + 1 < command.length) result += command[++index];
|
|
426
|
+
} else if (character === '"') quote = undefined;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (character === "\\") {
|
|
430
|
+
result += character;
|
|
431
|
+
if (index + 1 < command.length) {
|
|
432
|
+
const escaped = command[++index];
|
|
433
|
+
result += escaped;
|
|
434
|
+
if (escaped !== "\n") wordStart = false;
|
|
435
|
+
} else wordStart = false;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (character === "'" || character === '"' || (character === "$" && command[index + 1] === "'")) {
|
|
439
|
+
result += character;
|
|
440
|
+
wordStart = false;
|
|
441
|
+
if (character === "'" || character === '"') quote = character;
|
|
442
|
+
else {
|
|
443
|
+
quote = "$'";
|
|
444
|
+
result += command[++index];
|
|
445
|
+
}
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (character === "#") {
|
|
449
|
+
if (wordStart) {
|
|
450
|
+
result += command.slice(index);
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
result += "\\#";
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (WORD_BREAK.test(character)) {
|
|
457
|
+
result += character;
|
|
458
|
+
wordStart = true;
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
result += character;
|
|
462
|
+
wordStart = false;
|
|
463
|
+
}
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export interface HeredocMarker { delimiter: string; quoted: boolean; stripTabs: boolean }
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Reads the here-document marker that starts at `<<`, returning the marker and
|
|
471
|
+
* the offset just past its delimiter.
|
|
472
|
+
*
|
|
473
|
+
* Only delimiters this parser decodes exactly are accepted: a bare word of
|
|
474
|
+
* `[A-Za-z0-9_]`, or one fully quoted word. Single quotes keep a backslash
|
|
475
|
+
* literal, so `'E\OF'` decodes to `E\OF`; a double-quoted word decodes `\\`,
|
|
476
|
+
* `\$`, `` \` ``, and `\"`, so the raw text is not the delimiter and any
|
|
477
|
+
* backslash there is unsupported. Escapes, expansion, concatenated words, and
|
|
478
|
+
* an unterminated quote all return undefined, and callers fail closed.
|
|
479
|
+
*/
|
|
480
|
+
function heredocMarkerAt(line: string, index: number): { marker: HeredocMarker; end: number } | undefined {
|
|
481
|
+
if (line[index] !== "<" || line[index + 1] !== "<" || line[index + 2] === "<" || line[index - 1] === "<")
|
|
482
|
+
return undefined;
|
|
483
|
+
let cursor = index + 2;
|
|
484
|
+
const stripTabs = line[cursor] === "-";
|
|
485
|
+
if (stripTabs) cursor++;
|
|
486
|
+
while (line[cursor] === " " || line[cursor] === "\t") cursor++;
|
|
487
|
+
let delimiter = "";
|
|
488
|
+
let quoted = false;
|
|
489
|
+
if (line[cursor] === "'" || line[cursor] === '"') {
|
|
490
|
+
const closing = line[cursor];
|
|
491
|
+
quoted = true;
|
|
492
|
+
cursor++;
|
|
493
|
+
let closed = false;
|
|
494
|
+
while (cursor < line.length) {
|
|
495
|
+
const character = line[cursor];
|
|
496
|
+
if (character === closing) { closed = true; cursor++; break; }
|
|
497
|
+
if (closing === '"' && character === "\\") return undefined;
|
|
498
|
+
delimiter += character;
|
|
499
|
+
cursor++;
|
|
500
|
+
}
|
|
501
|
+
if (!closed) return undefined;
|
|
502
|
+
} else {
|
|
503
|
+
while (cursor < line.length && /[A-Za-z0-9_]/.test(line[cursor])) delimiter += line[cursor++];
|
|
504
|
+
}
|
|
505
|
+
if (!delimiter) return undefined;
|
|
506
|
+
// The delimiter word must end here; a quoted run glued to more word text is a
|
|
507
|
+
// concatenation this parser does not decode.
|
|
508
|
+
if (cursor < line.length && !WORD_BREAK.test(line[cursor])) return undefined;
|
|
509
|
+
return { marker: { delimiter, quoted, stripTabs }, end: cursor };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
interface HeredocScan { markers: HeredocMarker[]; unsupported: boolean }
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Scans one command line for here-document markers, ignoring markers inside
|
|
516
|
+
* quoted strings and comments.
|
|
517
|
+
*
|
|
518
|
+
* `unsupported` reports a marker this parser cannot decode exactly: an escaped
|
|
519
|
+
* or expanded delimiter (`<<\EOF`, `<<$name`), a delimiter word assembled from
|
|
520
|
+
* quoted and unquoted parts (`<<E'OF'`, `<<'EOF'x`), a backslash inside a
|
|
521
|
+
* double-quoted delimiter, or an unterminated quote. A wrong delimiter would
|
|
522
|
+
* move the body boundary, so callers fail closed instead.
|
|
523
|
+
*/
|
|
524
|
+
function scanHeredoc(line: string): HeredocScan {
|
|
525
|
+
const markers: HeredocMarker[] = [];
|
|
526
|
+
let unsupported = false;
|
|
527
|
+
let wordStart = true;
|
|
528
|
+
let quote: "'" | '"' | undefined;
|
|
529
|
+
for (let index = 0; index < line.length; index++) {
|
|
530
|
+
const character = line[index];
|
|
531
|
+
if (quote === "'") { if (character === "'") quote = undefined; continue; }
|
|
532
|
+
if (quote === '"') {
|
|
533
|
+
if (character === "\\") { index++; continue; }
|
|
534
|
+
if (character === '"') quote = undefined;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (character === "'" || character === '"') { quote = character; wordStart = false; continue; }
|
|
538
|
+
if (character === "\\") { index++; wordStart = false; continue; }
|
|
539
|
+
if (character === "#" && wordStart) break;
|
|
540
|
+
if (character === "<") {
|
|
541
|
+
const parsed = heredocMarkerAt(line, index);
|
|
542
|
+
if (parsed === undefined) {
|
|
543
|
+
if (line[index + 1] === "<" && line[index + 2] !== "<" && line[index - 1] !== "<") unsupported = true;
|
|
544
|
+
} else {
|
|
545
|
+
markers.push(parsed.marker);
|
|
546
|
+
index = parsed.end - 1;
|
|
547
|
+
wordStart = false;
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
wordStart = WORD_BREAK.test(character);
|
|
552
|
+
}
|
|
553
|
+
return { markers, unsupported };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Reads heredoc markers on one line, ignoring markers inside quoted strings and comments. */
|
|
557
|
+
export function heredocMarkers(line: string): HeredocMarker[] {
|
|
558
|
+
return scanHeredoc(line).markers;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* True when the text contains a here-document operator outside quotes.
|
|
563
|
+
* Heredoc bodies are data, so callers that already located a body scan only
|
|
564
|
+
* the command line.
|
|
565
|
+
*/
|
|
566
|
+
export function hasHeredoc(text: string): boolean {
|
|
567
|
+
let quote: "'" | '"' | undefined;
|
|
568
|
+
for (let index = 0; index < text.length; index++) {
|
|
569
|
+
const character = text[index];
|
|
570
|
+
if (quote === "'") { if (character === "'") quote = undefined; continue; }
|
|
571
|
+
if (quote === '"') {
|
|
572
|
+
if (character === "\\") { index++; continue; }
|
|
573
|
+
if (character === '"') quote = undefined;
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
if (character === "'" || character === '"') { quote = character; continue; }
|
|
577
|
+
if (character === "\\") { index++; continue; }
|
|
578
|
+
if (character === "<" && text[index + 1] === "<" && text[index + 2] !== "<" &&
|
|
579
|
+
text[index - 1] !== "<") return true;
|
|
580
|
+
}
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* The raw command line that starts at `start`, and the offset of the newline
|
|
586
|
+
* that ends it. Quoted newlines and `\`-newlines stay inside the line; a
|
|
587
|
+
* comment's newline ends it.
|
|
588
|
+
*/
|
|
589
|
+
function commandLineAt(command: string, start: number): { line: string; end: number } {
|
|
590
|
+
let quote: "'" | '"' | "$'" | undefined;
|
|
591
|
+
let wordStart = true;
|
|
592
|
+
for (let index = start; index < command.length; index++) {
|
|
593
|
+
const character = command[index];
|
|
594
|
+
if (quote === "'") { if (character === "'") quote = undefined; continue; }
|
|
595
|
+
if (quote === "$'") {
|
|
596
|
+
if (character === "\\") index++;
|
|
597
|
+
else if (character === "'") quote = undefined;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (quote === '"') {
|
|
601
|
+
if (character === "\\") index++;
|
|
602
|
+
else if (character === '"') quote = undefined;
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if (character === "\\") { index++; continue; }
|
|
606
|
+
if (character === "'" || character === '"') { quote = character; wordStart = false; continue; }
|
|
607
|
+
if (character === "$" && command[index + 1] === "'") { quote = "$'"; index++; wordStart = false; continue; }
|
|
608
|
+
if (character === "#" && wordStart) {
|
|
609
|
+
const end = command.indexOf("\n", index);
|
|
610
|
+
return { line: command.slice(start, end === -1 ? command.length : end),
|
|
611
|
+
end: end === -1 ? command.length : end };
|
|
612
|
+
}
|
|
613
|
+
if (character === "\n") return { line: command.slice(start, index), end: index };
|
|
614
|
+
wordStart = WORD_BREAK.test(character);
|
|
615
|
+
}
|
|
616
|
+
return { line: command.slice(start), end: command.length };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export interface RawHeredocScript {
|
|
620
|
+
/**
|
|
621
|
+
* Raw logical command lines that are certainly script, through the first header
|
|
622
|
+
* whose boundary cannot be read. Here-document bodies are omitted.
|
|
623
|
+
*/
|
|
624
|
+
lines: string[];
|
|
625
|
+
/** True when a raw header's marker or delimiter cannot be placed exactly. */
|
|
626
|
+
ambiguous: boolean;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Projects the raw source onto its command lines up to the first here-document
|
|
631
|
+
* header this reader cannot decode, skipping the bodies of headers it can.
|
|
632
|
+
*
|
|
633
|
+
* Every header is validated here, not only the first line: a continuation in a
|
|
634
|
+
* later header (`cat <<'A'`+newline+`a`+newline+`A`+newline+`cat <<\`+newline+
|
|
635
|
+
* `'EOF'`) is invisible to a first-line check, and normalization would then join
|
|
636
|
+
* the marker and treat a following `\`-newline body line as script, hiding a
|
|
637
|
+
* command Bash runs. Bodies are copied with the same raw comparison as
|
|
638
|
+
* `logicalLines`, so data is never returned as a script line; an unquoted body
|
|
639
|
+
* whose continuation can move its own terminator stops the scan because the
|
|
640
|
+
* existing projection already asks for it.
|
|
641
|
+
*
|
|
642
|
+
* A `\`-newline in the header, an undecodable marker, or a marker that only
|
|
643
|
+
* appears after normalization ends the certain prefix: the header line itself
|
|
644
|
+
* still runs, but nothing after it can be trusted.
|
|
645
|
+
*/
|
|
646
|
+
export function rawHeredocScript(command: string): RawHeredocScript {
|
|
647
|
+
const lines: string[] = [];
|
|
648
|
+
let start = 0;
|
|
649
|
+
while (start < command.length) {
|
|
650
|
+
const { line, end } = commandLineAt(command, start);
|
|
651
|
+
lines.push(line);
|
|
652
|
+
const scan = scanHeredoc(line);
|
|
653
|
+
const rawMarker = hasHeredoc(line);
|
|
654
|
+
const normalizedMarker = hasHeredoc(normalizeContinuations(line));
|
|
655
|
+
if (rawMarker || normalizedMarker) {
|
|
656
|
+
if (line.includes("\\\n") || scan.unsupported) return { lines, ambiguous: true };
|
|
657
|
+
// A `<<` the reader cannot place without a continuation is just as unknown.
|
|
658
|
+
if (!scan.markers.length) return { lines, ambiguous: true };
|
|
659
|
+
const bodies = heredocBodies(command, end + 1, scan.markers);
|
|
660
|
+
if (bodies.unstable) return { lines, ambiguous: false };
|
|
661
|
+
start = bodies.end;
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
start = end + 1;
|
|
665
|
+
}
|
|
666
|
+
return { lines, ambiguous: false };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* True when any raw here-document header cannot be tracked reliably, so the
|
|
671
|
+
* guard must fail closed instead of normalizing bytes it cannot place.
|
|
672
|
+
*
|
|
673
|
+
* Deleting a `\`-newline is only safe once the marker and its delimiter are
|
|
674
|
+
* visible: a continuation in the header can split `<`+`<`, split the delimiter
|
|
675
|
+
* (`<<`+newline+`EOF`, `<<E`+newline+`OF`), or hide the entire marker
|
|
676
|
+
* (`cat <<`+newline+`'EOF'`). Guessing there can move the body boundary past a
|
|
677
|
+
* trailing command, so these and delimiters the reader cannot decode exactly
|
|
678
|
+
* (escapes, expansion, concatenated words, a backslash inside double quotes)
|
|
679
|
+
* require approval. Every header is checked, not only the first.
|
|
680
|
+
*/
|
|
681
|
+
export function hasAmbiguousHeredoc(command: string): boolean {
|
|
682
|
+
return rawHeredocScript(command).ambiguous;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
interface HeredocCopy {
|
|
686
|
+
text: string;
|
|
687
|
+
end: number;
|
|
688
|
+
/**
|
|
689
|
+
* True when an unquoted body line ends with a backslash. A shell removes that
|
|
690
|
+
* `\`-newline before matching the terminator, so the body can run past the line
|
|
691
|
+
* the raw text compares and the terminator may match somewhere else entirely —
|
|
692
|
+
* including on a line the collector treats as script.
|
|
693
|
+
*/
|
|
694
|
+
unstable: boolean;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Copies here-document bodies verbatim, returning the copied text and the
|
|
699
|
+
* offset where the command script resumes.
|
|
700
|
+
*/
|
|
701
|
+
function heredocBodies(command: string, start: number, markers: readonly HeredocMarker[]): HeredocCopy {
|
|
702
|
+
let index = start;
|
|
703
|
+
let text = "";
|
|
704
|
+
let unstable = false;
|
|
705
|
+
for (const marker of markers) {
|
|
706
|
+
let closed = false;
|
|
707
|
+
while (index < command.length) {
|
|
708
|
+
const newline = command.indexOf("\n", index);
|
|
709
|
+
const stop = newline === -1 ? command.length : newline + 1;
|
|
710
|
+
const line = command.slice(index, newline === -1 ? command.length : newline);
|
|
711
|
+
text += command.slice(index, stop);
|
|
712
|
+
index = stop;
|
|
713
|
+
if ((marker.stripTabs ? line.replace(/^\t+/, "") : line) === marker.delimiter) { closed = true; break; }
|
|
714
|
+
// A quoted body keeps its backslashes, so only an unquoted one can move.
|
|
715
|
+
if (!marker.quoted && line.endsWith("\\")) unstable = true;
|
|
716
|
+
}
|
|
717
|
+
if (!closed) break;
|
|
718
|
+
}
|
|
719
|
+
return { text, end: index, unstable };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Removes `\`-newline continuations so the command is validated and parsed as
|
|
724
|
+
* the shell would run it.
|
|
725
|
+
*
|
|
726
|
+
* A shell deletes a backslash-newline pair while reading a command line, so
|
|
727
|
+
* `sudo echo first \`+newline+`second` is one `sudo` invocation, and a continued
|
|
728
|
+
* flag such as `rm -r`+newline+`f` is the dangerous `-rf`. Removing the pair
|
|
729
|
+
* before quote validation and before the source scans keeps every later check
|
|
730
|
+
* on the same text the shell sees.
|
|
731
|
+
*
|
|
732
|
+
* Continuations are kept wherever the shell keeps them:
|
|
733
|
+
* - single quotes and `$'...'` hold the backslash and the newline as data,
|
|
734
|
+
* - an escaped backslash (`\\`+newline) leaves a real newline that ends the line,
|
|
735
|
+
* - a comment ends at its newline, so a trailing backslash does not continue it,
|
|
736
|
+
* - here-document bodies are data and are copied unchanged.
|
|
737
|
+
*/
|
|
738
|
+
export function normalizeContinuations(command: string): string {
|
|
739
|
+
if (!command.includes("\n")) return command;
|
|
740
|
+
let result = "";
|
|
741
|
+
let index = 0;
|
|
742
|
+
let wordStart = true;
|
|
743
|
+
let pending: HeredocMarker[] = [];
|
|
744
|
+
while (index < command.length) {
|
|
745
|
+
const character = command[index];
|
|
746
|
+
if (character === "\\") {
|
|
747
|
+
const next = command[index + 1];
|
|
748
|
+
if (next === "\n") { index += 2; continue; }
|
|
749
|
+
if (next === undefined) { result += character; break; }
|
|
750
|
+
result += character + next;
|
|
751
|
+
wordStart = false;
|
|
752
|
+
index += 2;
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
if (character === "'") {
|
|
756
|
+
const end = command.indexOf("'", index + 1);
|
|
757
|
+
const stop = end === -1 ? command.length : end + 1;
|
|
758
|
+
result += command.slice(index, stop);
|
|
759
|
+
wordStart = false;
|
|
760
|
+
index = stop;
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (character === "$" && command[index + 1] === "'") {
|
|
764
|
+
let cursor = index + 2;
|
|
765
|
+
while (cursor < command.length && command[cursor] !== "'") cursor += command[cursor] === "\\" ? 2 : 1;
|
|
766
|
+
const stop = Math.min(cursor + 1, command.length);
|
|
767
|
+
result += command.slice(index, stop);
|
|
768
|
+
wordStart = false;
|
|
769
|
+
index = stop;
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (character === '"') {
|
|
773
|
+
let cursor = index + 1;
|
|
774
|
+
let quoted = "\"";
|
|
775
|
+
while (cursor < command.length) {
|
|
776
|
+
const inner = command[cursor];
|
|
777
|
+
if (inner === "\\") {
|
|
778
|
+
const next = command[cursor + 1];
|
|
779
|
+
if (next === "\n") { cursor += 2; continue; }
|
|
780
|
+
quoted += next === undefined ? inner : inner + next;
|
|
781
|
+
cursor += next === undefined ? 1 : 2;
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
quoted += inner;
|
|
785
|
+
cursor++;
|
|
786
|
+
if (inner === '"') break;
|
|
787
|
+
}
|
|
788
|
+
result += quoted;
|
|
789
|
+
wordStart = false;
|
|
790
|
+
index = cursor;
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
if (character === "#" && wordStart) {
|
|
794
|
+
const end = command.indexOf("\n", index);
|
|
795
|
+
const stop = end === -1 ? command.length : end;
|
|
796
|
+
result += command.slice(index, stop);
|
|
797
|
+
index = stop;
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
if (character === "<") {
|
|
801
|
+
const parsed = heredocMarkerAt(command, index);
|
|
802
|
+
if (parsed !== undefined) {
|
|
803
|
+
result += command.slice(index, parsed.end);
|
|
804
|
+
pending.push(parsed.marker);
|
|
805
|
+
wordStart = false;
|
|
806
|
+
index = parsed.end;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
if (character === "\n") {
|
|
811
|
+
result += character;
|
|
812
|
+
index++;
|
|
813
|
+
wordStart = true;
|
|
814
|
+
if (pending.length) {
|
|
815
|
+
const bodies = heredocBodies(command, index, pending);
|
|
816
|
+
result += bodies.text;
|
|
817
|
+
index = bodies.end;
|
|
818
|
+
pending = [];
|
|
819
|
+
}
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
result += character;
|
|
823
|
+
wordStart = WORD_BREAK.test(character);
|
|
824
|
+
index++;
|
|
825
|
+
}
|
|
826
|
+
return result;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
export interface LogicalScript {
|
|
830
|
+
/** One entry per logical command line, in source order; heredoc bodies are omitted. */
|
|
831
|
+
lines: string[];
|
|
832
|
+
/**
|
|
833
|
+
* How many leading entries in `lines` are certainly script. A here-document
|
|
834
|
+
* header this scanner cannot decode, or an unquoted body whose continuation can
|
|
835
|
+
* move its own terminator, ends that prefix: the header line itself still runs,
|
|
836
|
+
* but later entries may be body data.
|
|
837
|
+
*/
|
|
838
|
+
certain: number;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Splits a script into the logical command lines a shell would run.
|
|
843
|
+
*
|
|
844
|
+
* The scan is quote-aware: a newline inside `'...'`, `"..."`, or `$'...'` is part
|
|
845
|
+
* of the word being built, so `sudo echo 'first`+newline+`second'` stays one
|
|
846
|
+
* invocation and a blocked command is visible as a whole instead of becoming two
|
|
847
|
+
* harmless-looking fragments. A `\`-newline keeps an escaped character attached
|
|
848
|
+
* to its word (callers normalize continuations first, so only the escaped form
|
|
849
|
+
* remains here), and a comment ends its own physical line without hiding the next.
|
|
850
|
+
*
|
|
851
|
+
* Here-document bodies are data, not commands: they are skipped, so the `sudo` in
|
|
852
|
+
* `cat <<'EOF'`+newline+`sudo id`+newline+`EOF` is plain text, while a command
|
|
853
|
+
* after the terminator is still analyzed. `certain` reports the prefix of lines
|
|
854
|
+
* whose script/data split is known; callers must not auto-approve past it.
|
|
855
|
+
*/
|
|
856
|
+
export function logicalLines(command: string): LogicalScript {
|
|
857
|
+
const lines: string[] = [];
|
|
858
|
+
let certain: number | undefined;
|
|
859
|
+
let start = 0;
|
|
860
|
+
let wordStart = true;
|
|
861
|
+
let quote: "'" | '"' | "$'" | undefined;
|
|
862
|
+
let pending: HeredocMarker[] = [];
|
|
863
|
+
/** Records that everything from the line at `line` onwards may be data. */
|
|
864
|
+
const markUncertain = (line: number) => {
|
|
865
|
+
if (certain === undefined) certain = line + 1;
|
|
866
|
+
};
|
|
867
|
+
/** Ends the current line at `end` and jumps past the bodies of its markers. */
|
|
868
|
+
const endLine = (end: number) => {
|
|
869
|
+
lines.push(command.slice(start, end));
|
|
870
|
+
start = end + 1;
|
|
871
|
+
if (!pending.length) return;
|
|
872
|
+
const bodies = heredocBodies(command, start, pending);
|
|
873
|
+
if (bodies.unstable) markUncertain(lines.length - 1);
|
|
874
|
+
start = bodies.end;
|
|
875
|
+
pending = [];
|
|
876
|
+
};
|
|
877
|
+
for (let index = 0; index < command.length; index++) {
|
|
878
|
+
const character = command[index];
|
|
879
|
+
if (quote === "'") {
|
|
880
|
+
if (character === "'") quote = undefined;
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (quote === "$'") {
|
|
884
|
+
if (character === "\\") index++;
|
|
885
|
+
else if (character === "'") quote = undefined;
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
if (quote === '"') {
|
|
889
|
+
if (character === "\\") index++;
|
|
890
|
+
else if (character === '"') quote = undefined;
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
if (character === "\\") {
|
|
894
|
+
if (index + 1 < command.length) {
|
|
895
|
+
if (command[++index] !== "\n") wordStart = false;
|
|
896
|
+
} else wordStart = false;
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
if (character === "'" || character === '"' || (character === "$" && command[index + 1] === "'")) {
|
|
900
|
+
wordStart = false;
|
|
901
|
+
if (character === "'" || character === '"') quote = character;
|
|
902
|
+
else { quote = "$'"; index++; }
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
if (character === "#" && wordStart) {
|
|
906
|
+
// A comment runs to the end of the physical line; quotes inside it are text.
|
|
907
|
+
const end = command.indexOf("\n", index);
|
|
908
|
+
if (end === -1) break;
|
|
909
|
+
endLine(end);
|
|
910
|
+
index = start - 1;
|
|
911
|
+
wordStart = true;
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
if (character === "<") {
|
|
915
|
+
const parsed = heredocMarkerAt(command, index);
|
|
916
|
+
if (parsed !== undefined) {
|
|
917
|
+
pending.push(parsed.marker);
|
|
918
|
+
wordStart = false;
|
|
919
|
+
index = parsed.end - 1;
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
if (command[index + 1] === "<" && command[index + 2] !== "<" && command[index - 1] !== "<")
|
|
923
|
+
markUncertain(lines.length);
|
|
924
|
+
}
|
|
925
|
+
if (character === "\n") {
|
|
926
|
+
endLine(index);
|
|
927
|
+
// Resume after the bodies, if this line opened any.
|
|
928
|
+
index = start - 1;
|
|
929
|
+
wordStart = true;
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
wordStart = WORD_BREAK.test(character);
|
|
933
|
+
}
|
|
934
|
+
lines.push(command.slice(start));
|
|
935
|
+
return { lines, certain: certain ?? lines.length };
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Parses the command a shell would run: continuations are removed, descriptor
|
|
940
|
+
* prefixes and midword comment markers are stripped, and the remainder is handed
|
|
941
|
+
* to `shell-quote`. Nested scripts (`sh -c`, `eval`) reach this same parser, so
|
|
942
|
+
* they get the same normalization as a top-level command line.
|
|
943
|
+
*
|
|
944
|
+
* Text with whitespace a shell does not treat as a separator is refused instead
|
|
945
|
+
* of parsed: `shell-quote` splits on JS `\s`, which would move word and heredoc
|
|
946
|
+
* boundaries. Callers treat the throw as unparseable and ask.
|
|
947
|
+
*/
|
|
948
|
+
export function parseShell(command: string): ParseEntry[] {
|
|
949
|
+
if (hasNonShellWhitespace(command)) throw new Error("shell whitespace is not supported");
|
|
950
|
+
return parse(escapeLiteralHashes(stripDescriptorPrefixes(normalizeContinuations(command))));
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* True when a descriptor close (`>&-` or `<&-`) is immediately followed by more
|
|
955
|
+
* word characters.
|
|
956
|
+
*
|
|
957
|
+
* Bash reads `2>&-sudo id` as closing file descriptor 2 and then running
|
|
958
|
+
* `sudo id`, while shell-quote and other shells read `-sudo` as the redirection
|
|
959
|
+
* word and drop the command. The two readings disagree about which command
|
|
960
|
+
* runs, so ambiguous invocations must not be approved automatically.
|
|
961
|
+
*/
|
|
962
|
+
export function hasAmbiguousDescriptorClose(command: string): boolean {
|
|
963
|
+
const delimiter = /[ \t\n;|&()<>]/;
|
|
964
|
+
let boundary = true;
|
|
965
|
+
let index = 0;
|
|
966
|
+
while (index < command.length) {
|
|
967
|
+
const character = command[index];
|
|
968
|
+
if (character === "\\") { index += 2; boundary = false; continue; }
|
|
969
|
+
if (character === "'") {
|
|
970
|
+
const end = command.indexOf("'", index + 1);
|
|
971
|
+
index = end === -1 ? command.length : end + 1;
|
|
972
|
+
boundary = false;
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
if (character === "$" && command[index + 1] === "'") {
|
|
976
|
+
let cursor = index + 2;
|
|
977
|
+
while (cursor < command.length && command[cursor] !== "'") cursor += command[cursor] === "\\" ? 2 : 1;
|
|
978
|
+
index = Math.min(cursor + 1, command.length);
|
|
979
|
+
boundary = false;
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
if (character === '"') {
|
|
983
|
+
let cursor = index + 1;
|
|
984
|
+
while (cursor < command.length && command[cursor] !== '"') cursor += command[cursor] === "\\" ? 2 : 1;
|
|
985
|
+
index = Math.min(cursor + 1, command.length);
|
|
986
|
+
boundary = false;
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
if (boundary && character === "#") {
|
|
990
|
+
const end = command.indexOf("\n", index);
|
|
991
|
+
index = end === -1 ? command.length : end;
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
if ((character === ">" || character === "<") && command[index + 1] === "&" && command[index + 2] === "-") {
|
|
995
|
+
const next = command[index + 3];
|
|
996
|
+
if (next !== undefined && !delimiter.test(next)) return true;
|
|
997
|
+
}
|
|
998
|
+
boundary = delimiter.test(character);
|
|
999
|
+
index++;
|
|
1000
|
+
}
|
|
1001
|
+
return false;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** Splits a script into simple commands, dropping redirection targets. */
|
|
1005
|
+
function invocations(command: string): string[][] {
|
|
1006
|
+
let tokens;
|
|
1007
|
+
try { tokens = parseShell(command); } catch { return []; }
|
|
1008
|
+
const result: string[][] = [];
|
|
1009
|
+
let words: string[] = [];
|
|
1010
|
+
let skipNext = false;
|
|
1011
|
+
for (const token of tokens) {
|
|
1012
|
+
if (typeof token === "string") {
|
|
1013
|
+
if (!skipNext) words.push(token);
|
|
1014
|
+
skipNext = false;
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
if ("comment" in token) continue;
|
|
1018
|
+
if ("op" in token) {
|
|
1019
|
+
if (token.op === "glob") {
|
|
1020
|
+
if (!skipNext) words.push(token.pattern);
|
|
1021
|
+
skipNext = false;
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
if (CONTROL_OPERATORS.has(token.op)) {
|
|
1025
|
+
if (words.length) result.push(words);
|
|
1026
|
+
words = [];
|
|
1027
|
+
skipNext = false;
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
// A redirection does not end a simple command; only its target is dropped.
|
|
1031
|
+
// Descriptor prefixes were already removed from the source before parsing.
|
|
1032
|
+
if (REDIRECT_OPERATORS.has(token.op)) skipNext = true;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
if (words.length) result.push(words);
|
|
1036
|
+
return result;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/** Offset of the first `$'`, `$"`, or `$[` in the normalized text. */
|
|
1040
|
+
function undecodedSyntaxIndex(text: string): number | undefined {
|
|
1041
|
+
let earliest: number | undefined;
|
|
1042
|
+
for (const opener of ["$'", '$"', "$["]) {
|
|
1043
|
+
const index = text.indexOf(opener);
|
|
1044
|
+
if (index !== -1 && (earliest === undefined || index < earliest)) earliest = index;
|
|
1045
|
+
}
|
|
1046
|
+
return earliest;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Code whose contents this prefix parse does not project. A line containing one of
|
|
1051
|
+
* these forms is skipped rather than guessed at, because the words inside it are
|
|
1052
|
+
* commands or data that this scan cannot place.
|
|
1053
|
+
*/
|
|
1054
|
+
const NESTED_DYNAMIC_SYNTAX = /\$\(|`|[<>]\(/;
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Deny reason visible in the clean prefix before the first syntax this policy
|
|
1058
|
+
* refuses to decode, or undefined when that prefix has no mandatory deny.
|
|
1059
|
+
*
|
|
1060
|
+
* The marker and everything after it are never parsed: an undecoded quote can
|
|
1061
|
+
* move word and line boundaries, and normalization may have assembled it from a
|
|
1062
|
+
* `\`-newline. The prefix is projected with the shared `logicalLines` scan, so
|
|
1063
|
+
* heredoc bodies stay data. A line whose quotes are not balanced at the cut, or
|
|
1064
|
+
* that still contains nested dynamic syntax, is skipped instead of guessed at.
|
|
1065
|
+
* Callers use this before falling back to ask.
|
|
1066
|
+
*/
|
|
1067
|
+
export function denyCleanPrefix(normalized: string, depth = 0): string | undefined {
|
|
1068
|
+
const cut = undecodedSyntaxIndex(normalized);
|
|
1069
|
+
if (cut === undefined) return undefined;
|
|
1070
|
+
const { lines, certain } = logicalLines(normalized.slice(0, cut));
|
|
1071
|
+
for (const line of lines.slice(0, certain)) {
|
|
1072
|
+
if (NESTED_DYNAMIC_SYNTAX.test(line) || !BALANCED_QUOTES.test(line)) continue;
|
|
1073
|
+
for (const words of invocations(line)) {
|
|
1074
|
+
const reason = denyInvocation(words, depth);
|
|
1075
|
+
if (reason !== undefined) return reason;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return undefined;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Deny reason for a nested script (`sh -c`, `eval`), or undefined when nothing in
|
|
1083
|
+
* it is a dangerous operation.
|
|
1084
|
+
*
|
|
1085
|
+
* The script is projected onto the commands a shell would actually run, using the
|
|
1086
|
+
* same quote-aware `logicalLines` split as a top-level script: newlines separate
|
|
1087
|
+
* commands only outside quotes, so `sh -c 'pwd`+newline+`sudo id'` still shows the
|
|
1088
|
+
* mandatory deny, while `sh -c 'echo "first`+newline+`second"'` stays one word.
|
|
1089
|
+
* Here-document bodies are data and are never scanned as commands.
|
|
1090
|
+
*
|
|
1091
|
+
* When the split between script and data is unknown — an undecodable header, a
|
|
1092
|
+
* continuation in a raw header, or an unquoted body that can move its own
|
|
1093
|
+
* terminator — only the raw command lines that are certainly script are checked.
|
|
1094
|
+
* The header line always runs: `cat <<\EOF; sudo id` still runs `sudo id`, and a
|
|
1095
|
+
* marker's delimiter is only a redirection target. Every header is validated, not
|
|
1096
|
+
* just the first, because a later continued header has the same boundary problem.
|
|
1097
|
+
* Guessing past an undecodable header could deny quoted here-document data, so it
|
|
1098
|
+
* is left to manual approval.
|
|
1099
|
+
*/
|
|
1100
|
+
function denyScript(command: string, depth: number): string | undefined {
|
|
1101
|
+
if (FORK_BOMB.test(command)) return DANGEROUS;
|
|
1102
|
+
const normalized = normalizeContinuations(command);
|
|
1103
|
+
const raw = rawHeredocScript(command);
|
|
1104
|
+
if (raw.ambiguous) {
|
|
1105
|
+
for (const line of raw.lines) {
|
|
1106
|
+
// A line whose own normalization assembles unsupported quoting cannot be
|
|
1107
|
+
// parsed safely; its deny is left to the caller's manual approval.
|
|
1108
|
+
if (hasAssembledUndecodedSyntax(line)) continue;
|
|
1109
|
+
for (const words of invocations(line)) {
|
|
1110
|
+
const reason = denyInvocation(words, depth);
|
|
1111
|
+
if (reason !== undefined) return reason;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return undefined;
|
|
1115
|
+
}
|
|
1116
|
+
// Normalization can assemble quoting this reader does not decode. The inner
|
|
1117
|
+
// shell could then run a command the projection does not place, so the rest of
|
|
1118
|
+
// the projection stands down; a deny already visible in the clean prefix still
|
|
1119
|
+
// keeps its precedence, and the caller asks when there is none.
|
|
1120
|
+
if (!hasUndecodedSyntax(command) && hasUndecodedSyntax(normalized))
|
|
1121
|
+
return denyCleanPrefix(normalized, depth);
|
|
1122
|
+
const { lines, certain } = logicalLines(normalized);
|
|
1123
|
+
for (const line of lines.slice(0, certain)) {
|
|
1124
|
+
for (const words of invocations(line)) {
|
|
1125
|
+
const reason = denyInvocation(words, depth);
|
|
1126
|
+
if (reason !== undefined) return reason;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return undefined;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Deny reason for one parsed simple command, or undefined when it is not a
|
|
1134
|
+
* dangerous operation. Nested shell scripts are checked recursively.
|
|
1135
|
+
*/
|
|
1136
|
+
export function denyInvocation(words: readonly string[], depth = 0): string | undefined {
|
|
1137
|
+
if (depth > MAX_NESTING) return undefined;
|
|
1138
|
+
const executable = resolveExecutable(words);
|
|
1139
|
+
if (executable === undefined) return undefined;
|
|
1140
|
+
const args = words.slice(executable.index + 1);
|
|
1141
|
+
if (executable.name.startsWith("mkfs")) return DANGEROUS;
|
|
1142
|
+
if (DENY_EXECUTABLES.has(executable.name)) return DANGEROUS;
|
|
1143
|
+
if (executable.name === "chmod" && isWorldWritableMode(chmodMode(args) ?? "")) return DANGEROUS;
|
|
1144
|
+
if (executable.name === "rm" && isRecursiveRootRemoval(args)) return DANGEROUS;
|
|
1145
|
+
if (executable.name === "dd" && args.some((arg) => /^of=\/(?:dev|sd|nvme|hd)/.test(arg))) return DANGEROUS;
|
|
1146
|
+
if (SHELLS.has(executable.name) || executable.name === "eval") {
|
|
1147
|
+
const script = scriptArgument(executable.name, args);
|
|
1148
|
+
if (script !== undefined) return denyScript(script, depth + 1);
|
|
1149
|
+
}
|
|
1150
|
+
return undefined;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const UNIQ_FLAGS = new Set(["c", "d", "D", "i", "u"]);
|
|
1154
|
+
const UNIQ_VALUE_FLAGS = new Set(["f", "s", "w"]);
|
|
1155
|
+
const UNIQ_LONG_FLAGS = new Set(["--count", "--repeated", "--ignore-case", "--unique", "--zero-terminated",
|
|
1156
|
+
"--help", "--version"]);
|
|
1157
|
+
const UNIQ_LONG_OPTIONAL_FLAGS = new Set(["--all-repeated", "--group"]);
|
|
1158
|
+
const UNIQ_LONG_VALUE_FLAGS = new Set(["--skip-fields", "--skip-chars", "--check-chars"]);
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* `uniq INPUT OUTPUT` writes OUTPUT. macOS stops option parsing at the first
|
|
1162
|
+
* operand, so `uniq in.txt -out.txt` writes a file named `-out.txt`; every
|
|
1163
|
+
* argument after an operand is therefore counted as an operand. Unknown leading
|
|
1164
|
+
* flags fail closed, while known flags keep `uniq -f 2 file` read-only.
|
|
1165
|
+
*
|
|
1166
|
+
* An unquoted expansion can turn one operand into several (`uniq *.txt` becomes
|
|
1167
|
+
* `uniq a.txt b.txt` and overwrites the last match), which
|
|
1168
|
+
* `isReadOnlyInvocation` already refuses before any policy runs.
|
|
1169
|
+
*/
|
|
1170
|
+
function isReadOnlyUniq(args: readonly string[]): boolean {
|
|
1171
|
+
let operands = 0;
|
|
1172
|
+
let endOfOptions = false;
|
|
1173
|
+
for (let index = 0; index < args.length; index++) {
|
|
1174
|
+
const arg = args[index];
|
|
1175
|
+
if (endOfOptions || operands > 0) { operands++; continue; }
|
|
1176
|
+
if (arg === "--") { endOfOptions = true; continue; }
|
|
1177
|
+
if (arg === "-") { operands++; continue; }
|
|
1178
|
+
if (arg.startsWith("--") && arg.length > 2) {
|
|
1179
|
+
const name = arg.split("=", 1)[0];
|
|
1180
|
+
const attached = arg.length > name.length;
|
|
1181
|
+
if (UNIQ_LONG_VALUE_FLAGS.has(name)) {
|
|
1182
|
+
if (!attached) index++;
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
if (UNIQ_LONG_OPTIONAL_FLAGS.has(name) || (UNIQ_LONG_FLAGS.has(name) && !attached)) continue;
|
|
1186
|
+
operands++;
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
if (arg.startsWith("-")) {
|
|
1190
|
+
const letters = arg.slice(1);
|
|
1191
|
+
let known = true;
|
|
1192
|
+
for (let position = 0; position < letters.length; position++) {
|
|
1193
|
+
if (UNIQ_VALUE_FLAGS.has(letters[position])) {
|
|
1194
|
+
if (position === letters.length - 1) index++;
|
|
1195
|
+
break;
|
|
1196
|
+
}
|
|
1197
|
+
if (!UNIQ_FLAGS.has(letters[position])) { known = false; break; }
|
|
1198
|
+
}
|
|
1199
|
+
if (!known) operands++;
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
operands++;
|
|
1203
|
+
}
|
|
1204
|
+
return operands <= 1;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const DATE_FLAGS = new Set(["u", "R", "j"]);
|
|
1208
|
+
const DATE_VALUE_FLAGS = new Set(["d", "r", "f"]);
|
|
1209
|
+
const DATE_LONG_FLAGS = new Set(["--utc", "--universal", "--rfc-email", "--rfc-2822", "--iso-8601",
|
|
1210
|
+
"--debug", "--help", "--version"]);
|
|
1211
|
+
const DATE_LONG_VALUE_FLAGS = new Set(["--date", "--reference", "--file", "--rfc-3339"]);
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* `date` is read-only only when every argument is a known read-only option or a
|
|
1215
|
+
* `+FORMAT` operand. A positional operand sets the system clock
|
|
1216
|
+
* (`date 010100002030`), so it requires review.
|
|
1217
|
+
*/
|
|
1218
|
+
function isReadOnlyDate(args: readonly string[]): boolean {
|
|
1219
|
+
for (let index = 0; index < args.length; index++) {
|
|
1220
|
+
const arg = args[index];
|
|
1221
|
+
if (arg.startsWith("+")) continue;
|
|
1222
|
+
if (arg === "--") return args.slice(index + 1).every((operand) => operand.startsWith("+"));
|
|
1223
|
+
if (arg.startsWith("--") && arg.length > 2) {
|
|
1224
|
+
const name = arg.split("=", 1)[0];
|
|
1225
|
+
const attached = arg.length > name.length;
|
|
1226
|
+
if (DATE_LONG_VALUE_FLAGS.has(name)) {
|
|
1227
|
+
if (!attached) index++;
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
if (DATE_LONG_FLAGS.has(name)) continue;
|
|
1231
|
+
return false;
|
|
1232
|
+
}
|
|
1233
|
+
if (!arg.startsWith("-") || arg === "-") return false;
|
|
1234
|
+
for (let position = 1; position < arg.length; position++) {
|
|
1235
|
+
const letter = arg[position];
|
|
1236
|
+
if (letter === "I") break;
|
|
1237
|
+
if (DATE_VALUE_FLAGS.has(letter)) {
|
|
1238
|
+
if (position === arg.length - 1) index++;
|
|
1239
|
+
break;
|
|
1240
|
+
}
|
|
1241
|
+
if (!DATE_FLAGS.has(letter)) return false;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
return true;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const POLICIES: Record<string, (args: readonly string[]) => boolean> = {
|
|
1248
|
+
// Flags that read a program from a file, run a command, or write output.
|
|
1249
|
+
rg: (args) => !hasLongOption(args, ["--pre", "--pre-glob", "--hostname-bin", "--search-zip"]) &&
|
|
1250
|
+
!hasShortOption(args, "z"),
|
|
1251
|
+
sort: (args) => !hasLongOption(args, ["--output", "--compress-program", "--temporary-directory"]) &&
|
|
1252
|
+
!hasShortOption(args, "oT"),
|
|
1253
|
+
tree: (args) => !hasLongOption(args, ["--output"]) && !hasShortOption(args, "o"),
|
|
1254
|
+
file: (args) => !hasLongOption(args, ["--compile"]) && !hasShortOption(args, "C"),
|
|
1255
|
+
// `cache --build` / `--clear` mutate the cache, and `--lessopen` runs the
|
|
1256
|
+
// configured pager preprocessor.
|
|
1257
|
+
bat: (args) => !hasLongOption(args, ["--pager", "--config-file", "--config-dir", "--generate-config-file",
|
|
1258
|
+
"--build", "--clear", "--lessopen"]),
|
|
1259
|
+
// `uniq INPUT OUTPUT` writes OUTPUT; only a single literal operand is read-only.
|
|
1260
|
+
uniq: isReadOnlyUniq,
|
|
1261
|
+
// A bare argument sets the host name; `--` ends options and `-` is an operand.
|
|
1262
|
+
hostname: (args) => {
|
|
1263
|
+
let endOfOptions = false;
|
|
1264
|
+
for (const arg of args) {
|
|
1265
|
+
if (endOfOptions) return false;
|
|
1266
|
+
if (arg === "--") { endOfOptions = true; continue; }
|
|
1267
|
+
if (arg === "-" || !arg.startsWith("-")) return false;
|
|
1268
|
+
}
|
|
1269
|
+
return !hasLongOption(args, ["--file", "--boot"]) && !hasShortOption(args, "Fb");
|
|
1270
|
+
},
|
|
1271
|
+
date: isReadOnlyDate,
|
|
1272
|
+
};
|
|
1273
|
+
|
|
1274
|
+
const READ_ONLY = new Set(["pwd", "whoami", "id", "groups", "ls", "cat", "head", "tail", "wc", "echo", "printf",
|
|
1275
|
+
"true", ":", "grep", "cut", "tr", "jq", "stat", "du", "df", "which", "type", "basename", "dirname", "realpath",
|
|
1276
|
+
"diff", "test", "sleep", "uname"]);
|
|
1277
|
+
|
|
1278
|
+
function isReadOnlyGit(args: readonly string[]): boolean {
|
|
1279
|
+
if (args.some((arg) =>
|
|
1280
|
+
/^(?:-c|--git-dir|--work-tree|--exec-path|--config-env)(?:=|$)/.test(arg) ||
|
|
1281
|
+
arg === "--ext-diff" || arg === "--exec" || arg.startsWith("--output"))) return false;
|
|
1282
|
+
const subcommand = args[0];
|
|
1283
|
+
if (subcommand === undefined) return false;
|
|
1284
|
+
if (["status", "diff", "log", "show", "rev-parse"].includes(subcommand)) return true;
|
|
1285
|
+
if (subcommand !== "branch") return false;
|
|
1286
|
+
if (args.length === 1) return true;
|
|
1287
|
+
return args.slice(1).every((arg) =>
|
|
1288
|
+
["--show-current", "--list", "-a", "-r", "-v", "-vv", "--merged", "--no-merged", "--contains", "--points-at", "--format"]
|
|
1289
|
+
.some((safe) => arg === safe || arg.startsWith(`${safe}=`)));
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function isReadOnlySed(args: readonly string[]): boolean {
|
|
1293
|
+
if (args.some((arg) => arg === "-i" || arg.startsWith("--in-place") || arg === "-e" ||
|
|
1294
|
+
arg === "--expression" || arg === "-f" || arg === "--file")) return false;
|
|
1295
|
+
return args[0] === "-n" && args[1] !== undefined && /^\d+(?:,\d+)?p$/.test(args[1]);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function isReadOnlyGh(args: readonly string[]): boolean {
|
|
1299
|
+
const [group, subcommand] = args;
|
|
1300
|
+
if (group === "label" && subcommand === "list") return true;
|
|
1301
|
+
if (group === "api") {
|
|
1302
|
+
return !args.some((arg) => arg === "-X" || arg === "--method" || arg.startsWith("-X") ||
|
|
1303
|
+
arg.startsWith("--method=") || ["-f", "--field", "-F", "--raw-field", "--input"].includes(arg));
|
|
1304
|
+
}
|
|
1305
|
+
return new Set(["pr view", "pr list", "pr diff", "pr checks", "issue view", "issue list", "run view",
|
|
1306
|
+
"run list", "repo view", "auth status", "status", "search"]).has(`${group} ${subcommand}`);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* True only when one parsed simple command is read-only. Callers pass an
|
|
1311
|
+
* already-split segment, so chaining, redirection and wrappers are handled
|
|
1312
|
+
* before this point.
|
|
1313
|
+
*
|
|
1314
|
+
* `globs` marks words that came from an unquoted expansion (`*`, `?`, `[ab]`,
|
|
1315
|
+
* `{a,b}`, `$name`), which Bash can turn into several arguments. A literal flag
|
|
1316
|
+
* policy cannot see what those arguments will be: `sort --out{put,put}=out file`
|
|
1317
|
+
* becomes two `--output=out` options and writes a file, and `uniq *.txt` can
|
|
1318
|
+
* become `uniq a.txt b.txt`. An invocation with an uncertain word is therefore
|
|
1319
|
+
* never read-only. Quoted expansions stay one literal word and are judged as
|
|
1320
|
+
* written, so `uniq '*.txt'` remains a single operand.
|
|
1321
|
+
*/
|
|
1322
|
+
export function isReadOnlyInvocation(words: readonly string[], globs: readonly boolean[] = []): boolean {
|
|
1323
|
+
if (globs.some(Boolean)) return false;
|
|
1324
|
+
const command = basename(words[0] ?? "");
|
|
1325
|
+
const args = words.slice(1);
|
|
1326
|
+
// Own properties only: `./constructor` must not resolve to `Object`.
|
|
1327
|
+
if (Object.hasOwn(POLICIES, command)) return POLICIES[command](args);
|
|
1328
|
+
if (READ_ONLY.has(command)) return true;
|
|
1329
|
+
if (command === "git") return isReadOnlyGit(args);
|
|
1330
|
+
if (command === "find") return !args.some((arg) => /^-(?:fprint|fprint0|fprintf|fls)$/.test(arg));
|
|
1331
|
+
if (command === "sed") return isReadOnlySed(args);
|
|
1332
|
+
if (command === "gh") return isReadOnlyGh(args);
|
|
1333
|
+
if (command === "docker") return args[0] === "logs";
|
|
1334
|
+
if (command === "bun")
|
|
1335
|
+
return args[0] === "test" || (args[0] === "run" && (args[1] === "lint" || args[1] === "typecheck"));
|
|
1336
|
+
return false;
|
|
1337
|
+
}
|