@mortz.prk/opencode-auto-mode 1.0.2 → 1.1.1
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/CONTRIBUTING.md +234 -0
- package/README.llm.md +314 -0
- package/README.md +174 -295
- package/dist/gui-experimental.cjs +1 -1
- package/package.json +9 -8
- package/src/audit.ts +130 -49
- package/src/classifier.ts +44 -47
- package/src/code-mode.ts +55 -0
- package/src/config.ts +102 -105
- package/src/debug-events.ts +223 -0
- package/src/decision.ts +72 -0
- package/src/gates.ts +16 -0
- package/src/global-options.ts +85 -0
- package/src/gui-state.ts +230 -121
- package/src/gui.tsx +56 -107
- package/src/index.ts +482 -373
- package/src/mode.ts +237 -0
- package/src/reviewer.ts +70 -94
- package/src/rpc.ts +48 -38
- package/src/score/files.ts +49 -0
- package/src/score/history.ts +141 -0
- package/src/score/index.ts +37 -0
- package/src/score/limits.ts +24 -0
- package/src/score/shell-argv.ts +74 -0
- package/src/score/shell-baseline.ts +113 -0
- package/src/score/shell-safety.ts +54 -0
- package/src/score/shell.ts +30 -0
- package/src/score/types.ts +14 -0
- package/src/score.ts +9 -0
- package/src/shell-policy.ts +672 -30
- package/src/shell.ts +272 -52
- package/src/status.ts +49 -36
- package/src/tiers.ts +285 -19
- package/src/tui.tsx +44 -34
- package/src/user-files.ts +75 -0
- package/src/worktree.ts +30 -0
- package/src/decisions.ts +0 -236
- package/src/logger.ts +0 -26
- package/src/otlp.ts +0 -77
- package/src/policy.ts +0 -36
- package/src/state.ts +0 -135
- package/src/storage.ts +0 -26
- package/src/traces.ts +0 -90
- package/src/transcript.ts +0 -204
- package/src/tui-state.ts +0 -13
package/src/shell-policy.ts
CHANGED
|
@@ -197,6 +197,537 @@ export function resolveExecutable(words: readonly string[]): { name: string; ind
|
|
|
197
197
|
return index < words.length ? { name: basename(words[index]), index } : undefined;
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
/** Commands whose non-option operands are files they read or modify. */
|
|
201
|
+
const PATH_OPERAND_COMMANDS = new Set(["cat", "less", "more", "head", "tail", "strings", "xxd", "od",
|
|
202
|
+
"hexdump", "file", "stat", "wc", "md5sum", "diff", "sort", "uniq", "cut", "bat", "cp", "mv", "rm",
|
|
203
|
+
"chmod", "chown", "truncate", "install", "open", "ls", "du"]);
|
|
204
|
+
/** Commands whose first non-option operand is a pattern and whose rest are paths. */
|
|
205
|
+
const PATTERN_FIRST_COMMANDS = new Set(["grep", "rg"]);
|
|
206
|
+
/** `shasum`, `sha1sum`, `sha256sum`, `sha384sum`, `sha512sum`. */
|
|
207
|
+
const CHECKSUM_COMMAND = /^(?:shasum|sha(?:1|224|256|384|512)sum)$/;
|
|
208
|
+
|
|
209
|
+
/** How a value-taking option consumes its value: pattern data, or a file path. */
|
|
210
|
+
type ValueRole = "data" | "path";
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Known long options and the role of their value, used for exact matches and
|
|
214
|
+
* unambiguous abbreviations: `--reg=.` is `--regexp=`, `--fil=` is `--file=`.
|
|
215
|
+
* `data` values are patterns, types, encodings, labels, formats or numbers and
|
|
216
|
+
* must never be read as paths; `path` values name files.
|
|
217
|
+
*/
|
|
218
|
+
const LONG_OPTION_VALUES = new Map<string, ValueRole>([
|
|
219
|
+
// grep
|
|
220
|
+
["--regexp", "data"], ["--file", "path"], ["--exclude", "data"], ["--include", "data"],
|
|
221
|
+
["--exclude-dir", "data"], ["--include-dir", "data"], ["--exclude-from", "path"],
|
|
222
|
+
["--ignore-file", "path"],
|
|
223
|
+
["--label", "data"], ["--directories", "data"], ["--devices", "data"], ["--binary-files", "data"],
|
|
224
|
+
["--after-context", "data"], ["--before-context", "data"], ["--context", "data"], ["--max-count", "data"],
|
|
225
|
+
// rg
|
|
226
|
+
["--type", "data"], ["--type-not", "data"], ["--type-add", "data"], ["--type-clear", "data"],
|
|
227
|
+
["--encoding", "data"], ["--replace", "data"], ["--glob", "data"], ["--iglob", "data"],
|
|
228
|
+
["--max-columns", "data"], ["--threads", "data"], ["--max-depth", "data"],
|
|
229
|
+
["--sort", "data"], ["--sortr", "data"], ["--field-match-separator", "data"],
|
|
230
|
+
["--field-context-separator", "data"], ["--context-separator", "data"],
|
|
231
|
+
// sed
|
|
232
|
+
["--expression", "data"], ["--line-length", "data"],
|
|
233
|
+
// jq
|
|
234
|
+
["--from-file", "path"],
|
|
235
|
+
// git
|
|
236
|
+
["--grep", "data"], ["--format", "data"],
|
|
237
|
+
// sort: a file of random bytes; value options that must not shift a path.
|
|
238
|
+
["--random-source", "path"], ["--key", "data"], ["--field-separator", "data"],
|
|
239
|
+
["--batch-size", "data"], ["--buffer-size", "data"], ["--parallel", "data"],
|
|
240
|
+
// diff: a file compared from/to; formats and labels are data.
|
|
241
|
+
["--to-file", "path"], ["--line-format", "data"], ["--old-line-format", "data"],
|
|
242
|
+
["--new-line-format", "data"], ["--unchanged-line-format", "data"], ["--width", "data"],
|
|
243
|
+
["--horizon-lines", "data"], ["--palette", "data"], ["--ignore-matching-lines", "data"],
|
|
244
|
+
// file: an alternate magic file.
|
|
245
|
+
["--magic-file", "path"],
|
|
246
|
+
// tree: an input file of paths.
|
|
247
|
+
["--gitfile", "path"],
|
|
248
|
+
// Writing, temporary, program and library paths; attached values are still
|
|
249
|
+
// file operands for the scope and symlink checks. jq's --arg/--argjson are
|
|
250
|
+
// data names, kept exact so `--arg` does not abbreviate to `--argfile`.
|
|
251
|
+
["--output", "path"], ["--temporary-directory", "path"], ["--compress-program", "path"],
|
|
252
|
+
["--library-path", "path"], ["--slurpfile", "path"], ["--rawfile", "path"], ["--argfile", "path"],
|
|
253
|
+
["--arg", "data"], ["--argjson", "data"],
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
/** Resolve a long option or its unambiguous abbreviation, when it is known. */
|
|
257
|
+
export function resolveLongOption(option: string): string | undefined {
|
|
258
|
+
if (LONG_OPTION_VALUES.has(option)) return option;
|
|
259
|
+
if (!option.startsWith("--") || option.length <= 2) return undefined;
|
|
260
|
+
const matches = [...LONG_OPTION_VALUES.keys()].filter((name) => name.startsWith(option));
|
|
261
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** True when a long option's value is data this policy must not treat as a path. */
|
|
265
|
+
export function isDataOption(option: string): boolean {
|
|
266
|
+
const resolved = resolveLongOption(option);
|
|
267
|
+
return resolved !== undefined && LONG_OPTION_VALUES.get(resolved) === "data";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Value-taking letters for `grep -A 2`, `-m 1`, `-d skip`, `-e PATTERN`, `-f FILE`. */
|
|
271
|
+
const GREP_SHORT_VALUES: Record<string, ValueRole> = {
|
|
272
|
+
e: "data", f: "path", A: "data", B: "data", C: "data", m: "data", d: "data", D: "data",
|
|
273
|
+
};
|
|
274
|
+
/** Value-taking letters for `rg -t ts`, `-g GLOB`, `-e PATTERN`, `-f FILE`, ... */
|
|
275
|
+
const RG_SHORT_VALUES: Record<string, ValueRole> = {
|
|
276
|
+
e: "data", f: "path", g: "data", t: "data", T: "data", E: "data", j: "data", M: "data",
|
|
277
|
+
r: "data", A: "data", B: "data", C: "data", m: "data",
|
|
278
|
+
};
|
|
279
|
+
/** sed's expression/script files and the numeric line length. */
|
|
280
|
+
const SED_SHORT_VALUES: Record<string, ValueRole> = { e: "data", f: "path", l: "data" };
|
|
281
|
+
/** jq's filter and library files. */
|
|
282
|
+
const JQ_SHORT_VALUES: Record<string, ValueRole> = { f: "path", L: "path" };
|
|
283
|
+
/** Numeric/format/size values of the plain readers and file tools. */
|
|
284
|
+
const PLAIN_SHORT_VALUES: Record<string, Record<string, ValueRole>> = {
|
|
285
|
+
head: { n: "data", c: "data" },
|
|
286
|
+
tail: { n: "data", c: "data", s: "data" },
|
|
287
|
+
sort: { k: "data", t: "data", S: "data", o: "path", T: "path" },
|
|
288
|
+
uniq: { f: "data", s: "data", w: "data" },
|
|
289
|
+
diff: { U: "data", C: "data", I: "data", x: "data", X: "path", L: "data" },
|
|
290
|
+
cut: { d: "data", f: "data", b: "data", c: "data" },
|
|
291
|
+
xxd: { l: "data", s: "data", c: "data", g: "data" },
|
|
292
|
+
od: { N: "data", j: "data", t: "data" },
|
|
293
|
+
hexdump: { n: "data", s: "data", e: "data", f: "path" },
|
|
294
|
+
strings: { n: "data", t: "data", e: "data" },
|
|
295
|
+
stat: { c: "data" },
|
|
296
|
+
less: { p: "data", o: "path", b: "data", h: "data", j: "data", x: "data", y: "data", z: "data" },
|
|
297
|
+
more: { n: "data" },
|
|
298
|
+
open: { a: "data", b: "data" },
|
|
299
|
+
file: { f: "path", m: "path", e: "data", F: "data" },
|
|
300
|
+
// `ls -I PATTERN` is an ignore glob; `-w`/`-T` take column/tab-size numbers.
|
|
301
|
+
ls: { I: "data", w: "data", T: "data" },
|
|
302
|
+
// `du -d N`/`--max-depth` is a number; `-X FILE` is the exclude-from file.
|
|
303
|
+
du: { d: "data", X: "path" },
|
|
304
|
+
};
|
|
305
|
+
/** Git subcommands whose operands this policy can place. */
|
|
306
|
+
const GIT_HANDLED = new Set(["commit", "tag", "branch", "checkout", "switch", "push", "fetch"]);
|
|
307
|
+
const GIT_LONG_VALUES: Record<string, Record<string, ValueRole>> = {
|
|
308
|
+
commit: { "--message": "data", "--file": "path", "--author": "data", "--date": "data",
|
|
309
|
+
"--reuse-message": "data", "--reedit-message": "data", "--fixup": "data", "--squash": "data" },
|
|
310
|
+
tag: { "--message": "data", "--file": "path" },
|
|
311
|
+
branch: { "--contains": "data", "--points-at": "data", "--merged": "data", "--no-merged": "data" },
|
|
312
|
+
checkout: { "--orphan": "data", "--conflict": "data" },
|
|
313
|
+
switch: { "--orphan": "data", "--conflict": "data" },
|
|
314
|
+
push: { "--push-option": "data", "--repo": "path" },
|
|
315
|
+
fetch: { "--depth": "data", "--deepen": "data", "--upload-pack": "path" },
|
|
316
|
+
};
|
|
317
|
+
const GIT_SHORT_VALUES: Record<string, Record<string, ValueRole>> = {
|
|
318
|
+
commit: { m: "data", F: "path", C: "data", c: "data" },
|
|
319
|
+
tag: { m: "data", F: "path" },
|
|
320
|
+
branch: { m: "data", M: "data", c: "data", C: "data" },
|
|
321
|
+
checkout: { b: "data", B: "data" },
|
|
322
|
+
switch: { c: "data", C: "data" },
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Which words of a simple command are files and which are data.
|
|
327
|
+
*
|
|
328
|
+
* A shell word is data or a path depending on the position a command gives it:
|
|
329
|
+
* `cat auth` reads a file named `auth`, `grep auth file` searches for `auth`,
|
|
330
|
+
* `sed s/.env/.x/p file` runs a script, and `jq .name file` evaluates a filter.
|
|
331
|
+
* `paths` are indices of standalone file operands, `data` indices of pattern,
|
|
332
|
+
* script, filter or option-value words, and `pathValues` are file values
|
|
333
|
+
* attached to an option (`-fFILE`, `--file=FILE`). Every other word (the
|
|
334
|
+
* executable, ordinary options) is neither; callers keep their own check for
|
|
335
|
+
* it. Commands outside the grammar below report no roles at all.
|
|
336
|
+
*/
|
|
337
|
+
export interface OperandRoles {
|
|
338
|
+
/** Indices of words the command consumes as file paths. */
|
|
339
|
+
paths: number[];
|
|
340
|
+
/** Indices of words the command consumes as data. */
|
|
341
|
+
data: number[];
|
|
342
|
+
/** File values attached to an option (`-fFILE`, `--file=FILE`). */
|
|
343
|
+
pathValues: string[];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
interface RoleSets { paths: Set<number>; data: Set<number>; pathValues: string[] }
|
|
347
|
+
|
|
348
|
+
interface ShortValue { letter: string; role: ValueRole; attached?: string }
|
|
349
|
+
|
|
350
|
+
/** Splits `--name=value` into its option name and attached value. */
|
|
351
|
+
function longOptionPart(word: string): { option: string; attached?: string } | undefined {
|
|
352
|
+
if (!word.startsWith("--") || word.length <= 2) return undefined;
|
|
353
|
+
const separator = word.indexOf("=");
|
|
354
|
+
return separator === -1 ? { option: word } : { option: word.slice(0, separator), attached: word.slice(separator + 1) };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Reads a short-option bundle letter by letter. The first letter that takes a
|
|
359
|
+
* value wins: the rest of the word is its attached value (`-ifpats` is `-i -f
|
|
360
|
+
* pats`), or the next word when the bundle ends at that letter (`-if pats`).
|
|
361
|
+
* Unknown letters are ordinary flags.
|
|
362
|
+
*/
|
|
363
|
+
function shortOption(word: string, table: Record<string, ValueRole>): ShortValue | undefined {
|
|
364
|
+
if (!word.startsWith("-") || word.startsWith("--") || word.length < 2) return undefined;
|
|
365
|
+
const letters = word.slice(1);
|
|
366
|
+
for (let index = 0; index < letters.length; index++) {
|
|
367
|
+
const role = table[letters[index]];
|
|
368
|
+
if (role === undefined) continue;
|
|
369
|
+
const attached = letters.slice(index + 1);
|
|
370
|
+
return { letter: letters[index], role, attached: attached.length ? attached : undefined };
|
|
371
|
+
}
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The flag letters of one short bundle, stopping at the first letter that takes
|
|
377
|
+
* a value. An attached value (`-fpatterns`, `-Xcredentials`) is data or a path,
|
|
378
|
+
* never a flag, so recursion checks cannot be spoofed by a value's spelling.
|
|
379
|
+
*/
|
|
380
|
+
function shortFlags(word: string, table: Record<string, ValueRole>): string {
|
|
381
|
+
if (!word.startsWith("-") || word.startsWith("--") || word.length < 2) return "";
|
|
382
|
+
const letters = word.slice(1);
|
|
383
|
+
let position = 0;
|
|
384
|
+
while (position < letters.length && table[letters[position]] === undefined) position++;
|
|
385
|
+
return letters.slice(0, position);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Marks the value a short option consumes and returns the new word index. */
|
|
389
|
+
function takeShortValue(parsed: ShortValue, index: number, base: number,
|
|
390
|
+
args: readonly string[], roles: RoleSets): number {
|
|
391
|
+
if (parsed.role === "path") {
|
|
392
|
+
if (parsed.attached !== undefined) roles.pathValues.push(parsed.attached);
|
|
393
|
+
else if (index + 1 < args.length) {
|
|
394
|
+
roles.paths.add(base + index + 1);
|
|
395
|
+
return index + 1;
|
|
396
|
+
}
|
|
397
|
+
} else if (parsed.attached === undefined && index + 1 < args.length) {
|
|
398
|
+
roles.data.add(base + index + 1);
|
|
399
|
+
return index + 1;
|
|
400
|
+
}
|
|
401
|
+
return index;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Marks attached (`--grep=X`) and separate (`--grep X`) data option values. */
|
|
405
|
+
function dataOptionRoles(args: readonly string[], base: number): RoleSets {
|
|
406
|
+
const roles: RoleSets = { paths: new Set(), data: new Set(), pathValues: [] };
|
|
407
|
+
let endOfOptions = false;
|
|
408
|
+
for (let index = 0; index < args.length; index++) {
|
|
409
|
+
const arg = args[index];
|
|
410
|
+
if (endOfOptions) continue;
|
|
411
|
+
if (arg === "--") { endOfOptions = true; continue; }
|
|
412
|
+
const part = longOptionPart(arg);
|
|
413
|
+
if (part === undefined) continue;
|
|
414
|
+
const resolved = resolveLongOption(part.option);
|
|
415
|
+
if (resolved === undefined || LONG_OPTION_VALUES.get(resolved) !== "data") continue;
|
|
416
|
+
if (part.attached !== undefined) { roles.data.add(base + index); continue; }
|
|
417
|
+
if (index + 1 < args.length) { roles.data.add(base + index + 1); index++; }
|
|
418
|
+
}
|
|
419
|
+
return roles;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Marks attached (`--file=X`) path option values as file operands. */
|
|
423
|
+
function pathOptionRoles(args: readonly string[], roles: RoleSets): void {
|
|
424
|
+
let endOfOptions = false;
|
|
425
|
+
for (const arg of args) {
|
|
426
|
+
if (endOfOptions) continue;
|
|
427
|
+
if (arg === "--") { endOfOptions = true; continue; }
|
|
428
|
+
const part = longOptionPart(arg);
|
|
429
|
+
if (part === undefined || part.attached === undefined) continue;
|
|
430
|
+
const resolved = resolveLongOption(part.option);
|
|
431
|
+
if (resolved === undefined || LONG_OPTION_VALUES.get(resolved) !== "path") continue;
|
|
432
|
+
roles.pathValues.push(part.attached);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Adds every non-option positional as a path (`cat`, `sort`, checksums, ...). */
|
|
437
|
+
function plainRoles(name: string, args: readonly string[], base: number, roles: RoleSets): void {
|
|
438
|
+
const table = PLAIN_SHORT_VALUES[name] ?? {};
|
|
439
|
+
let endOfOptions = false;
|
|
440
|
+
for (let index = 0; index < args.length; index++) {
|
|
441
|
+
const word = args[index];
|
|
442
|
+
if (!endOfOptions && word === "--") { endOfOptions = true; continue; }
|
|
443
|
+
if (!endOfOptions && word.startsWith("-") && word.length > 1) {
|
|
444
|
+
if (word.startsWith("--")) continue;
|
|
445
|
+
const parsed = shortOption(word, table);
|
|
446
|
+
if (parsed !== undefined) index = takeShortValue(parsed, index, base, args, roles);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (roles.data.has(base + index)) continue;
|
|
450
|
+
roles.paths.add(base + index);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* `grep`/`rg`: the first positional is a pattern unless `-e`/`--regexp` already
|
|
456
|
+
* supplied one, and `-f`/`--file` names a pattern file. Every positional after
|
|
457
|
+
* a pattern is a path.
|
|
458
|
+
*/
|
|
459
|
+
function grepRoles(name: string, args: readonly string[], base: number, roles: RoleSets): void {
|
|
460
|
+
const table = name === "rg" ? RG_SHORT_VALUES : GREP_SHORT_VALUES;
|
|
461
|
+
let seenPattern = false;
|
|
462
|
+
let endOfOptions = false;
|
|
463
|
+
for (let index = 0; index < args.length; index++) {
|
|
464
|
+
const word = args[index];
|
|
465
|
+
if (!endOfOptions && word === "--") { endOfOptions = true; continue; }
|
|
466
|
+
if (!endOfOptions && word.startsWith("-") && word.length > 1) {
|
|
467
|
+
if (word.startsWith("--")) {
|
|
468
|
+
const part = longOptionPart(word);
|
|
469
|
+
const resolved = part === undefined ? undefined : resolveLongOption(part.option);
|
|
470
|
+
if (resolved === "--regexp") { seenPattern = true; continue; }
|
|
471
|
+
// Any path-valued long option names a file: `--file` (pattern file),
|
|
472
|
+
// `--exclude-from`/`--ignore-file` (files of patterns). Only `--file`
|
|
473
|
+
// supplies the pattern; the others leave the next positional a pattern.
|
|
474
|
+
if (resolved !== undefined && LONG_OPTION_VALUES.get(resolved) === "path") {
|
|
475
|
+
if (part?.attached !== undefined) roles.pathValues.push(part.attached);
|
|
476
|
+
else if (index + 1 < args.length) roles.paths.add(base + ++index);
|
|
477
|
+
if (resolved === "--file") seenPattern = true;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const parsed = shortOption(word, table);
|
|
483
|
+
if (parsed === undefined) continue;
|
|
484
|
+
index = takeShortValue(parsed, index, base, args, roles);
|
|
485
|
+
if (parsed.letter === "e" || parsed.letter === "f") seenPattern = true;
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (roles.data.has(base + index)) continue;
|
|
489
|
+
if (!seenPattern) { roles.data.add(base + index); seenPattern = true; continue; }
|
|
490
|
+
roles.paths.add(base + index);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* `sed`: the first positional is the script unless `-e`/`--expression` supplied
|
|
496
|
+
* one. `-f`/`--file` names a script file and also means every positional is a
|
|
497
|
+
* file; `-l`/`--line-length` takes a number that is not an operand.
|
|
498
|
+
*/
|
|
499
|
+
function sedRoles(args: readonly string[], base: number, roles: RoleSets): void {
|
|
500
|
+
let scripted = false;
|
|
501
|
+
let endOfOptions = false;
|
|
502
|
+
for (let index = 0; index < args.length; index++) {
|
|
503
|
+
const word = args[index];
|
|
504
|
+
if (!endOfOptions && word === "--") { endOfOptions = true; continue; }
|
|
505
|
+
if (!endOfOptions && word.startsWith("-") && word.length > 1) {
|
|
506
|
+
if (word.startsWith("--")) {
|
|
507
|
+
const part = longOptionPart(word);
|
|
508
|
+
const resolved = part === undefined ? undefined : resolveLongOption(part.option);
|
|
509
|
+
if (resolved === "--expression") { scripted = true; continue; }
|
|
510
|
+
if (resolved === "--file") {
|
|
511
|
+
if (part?.attached !== undefined) roles.pathValues.push(part.attached);
|
|
512
|
+
else if (index + 1 < args.length) roles.paths.add(base + ++index);
|
|
513
|
+
scripted = true;
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
const parsed = shortOption(word, SED_SHORT_VALUES);
|
|
519
|
+
if (parsed === undefined) continue;
|
|
520
|
+
index = takeShortValue(parsed, index, base, args, roles);
|
|
521
|
+
if (parsed.letter === "e" || parsed.letter === "f") scripted = true;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (roles.data.has(base + index)) continue;
|
|
525
|
+
if (!scripted) { roles.data.add(base + index); scripted = true; continue; }
|
|
526
|
+
roles.paths.add(base + index);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* `jq`: the first positional is the filter unless `-f`/`--from-file` supplies
|
|
532
|
+
* one. All value-taking options are stated explicitly, so a bare `1` after a
|
|
533
|
+
* flag (`-e`, `-r`, `--tab`) is the filter. `--arg`/`--argjson` take a name and
|
|
534
|
+
* a value as data, `--slurpfile`/`--rawfile`/`--argfile` take a name (data) and
|
|
535
|
+
* a file (path), and `--args`/`--jsonargs` make positionals string data.
|
|
536
|
+
*/
|
|
537
|
+
function jqRoles(args: readonly string[], base: number, roles: RoleSets): void {
|
|
538
|
+
let filterSupplied = false;
|
|
539
|
+
let positionalData = false;
|
|
540
|
+
let endOfOptions = false;
|
|
541
|
+
const takeValues = (index: number, count: number, role: ValueRole): number => {
|
|
542
|
+
let taken = 0;
|
|
543
|
+
while (taken < count && index + 1 < args.length) {
|
|
544
|
+
const next = base + ++index;
|
|
545
|
+
(role === "data" ? roles.data : roles.paths).add(next);
|
|
546
|
+
taken++;
|
|
547
|
+
}
|
|
548
|
+
return index;
|
|
549
|
+
};
|
|
550
|
+
for (let index = 0; index < args.length; index++) {
|
|
551
|
+
const word = args[index];
|
|
552
|
+
if (!endOfOptions && word === "--") { endOfOptions = true; continue; }
|
|
553
|
+
if (!endOfOptions && word.startsWith("-") && word.length > 1) {
|
|
554
|
+
if (word.startsWith("--")) {
|
|
555
|
+
const part = longOptionPart(word);
|
|
556
|
+
const option = part?.option;
|
|
557
|
+
const resolved = part === undefined ? undefined : resolveLongOption(part.option);
|
|
558
|
+
if (resolved === "--from-file") {
|
|
559
|
+
if (part?.attached !== undefined) roles.pathValues.push(part.attached);
|
|
560
|
+
else if (index + 1 < args.length) roles.paths.add(base + ++index);
|
|
561
|
+
filterSupplied = true;
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (option === "--arg" || option === "--argjson") { index = takeValues(index, 2, "data"); continue; }
|
|
565
|
+
if (option === "--slurpfile" || option === "--rawfile" || option === "--argfile") {
|
|
566
|
+
index = takeValues(index, 1, "data");
|
|
567
|
+
index = takeValues(index, 1, "path");
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (option === "--args" || option === "--jsonargs") { positionalData = true; continue; }
|
|
571
|
+
if (option === "--library-path") { index = takeValues(index, 1, "path"); continue; }
|
|
572
|
+
if (option === "--indent") { index = takeValues(index, 1, "data"); continue; }
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
const parsed = shortOption(word, JQ_SHORT_VALUES);
|
|
576
|
+
if (parsed !== undefined) {
|
|
577
|
+
index = takeShortValue(parsed, index, base, args, roles);
|
|
578
|
+
if (parsed.letter === "f") filterSupplied = true;
|
|
579
|
+
}
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (roles.data.has(base + index)) continue;
|
|
583
|
+
if (positionalData) { roles.data.add(base + index); continue; }
|
|
584
|
+
if (!filterSupplied) { roles.data.add(base + index); filterSupplied = true; continue; }
|
|
585
|
+
roles.paths.add(base + index);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** `find` tests whose value is a path read for comparison, not a pattern. */
|
|
590
|
+
const FIND_PATH_VALUE_TESTS = new Set(["-newer", "-anewer", "-cnewer", "-samefile",
|
|
591
|
+
"-fprint", "-fprint0", "-fprintf", "-fls"]);
|
|
592
|
+
/** `find` tests whose value is data: patterns, types, sizes, times, ids. */
|
|
593
|
+
const FIND_DATA_VALUE_TESTS = new Set(["-name", "-iname", "-path", "-ipath", "-regex", "-iregex",
|
|
594
|
+
"-wholename", "-iwholename", "-type", "-mtime", "-atime", "-ctime", "-mmin", "-amin", "-cmin",
|
|
595
|
+
"-size", "-perm", "-user", "-group", "-uid", "-gid", "-links", "-inum", "-maxdepth", "-mindepth",
|
|
596
|
+
"-printf", "-regextype", "-used", "-context"]);
|
|
597
|
+
/** `-exec`/`-execdir`/`-ok`/`-okdir` consume an inline command, not a path test. */
|
|
598
|
+
const FIND_EXEC_TESTS = new Set(["-exec", "-execdir", "-ok", "-okdir"]);
|
|
599
|
+
|
|
600
|
+
/** True for the tokens (an option, `(`, `)`, `!`) that start `find`'s expression. */
|
|
601
|
+
function startsFindExpression(word: string): boolean {
|
|
602
|
+
return word === "(" || word === ")" || word === "!" || word.startsWith("-");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Options `find` accepts before its starting paths: the symlink modes
|
|
607
|
+
* `-H`/`-L`/`-P`, the optimisation level `-O[level]` and `-D debugopts`.
|
|
608
|
+
*/
|
|
609
|
+
function findLeadingOption(word: string): "flag" | "value" | undefined {
|
|
610
|
+
if (word === "-H" || word === "-L" || word === "-P") return "flag";
|
|
611
|
+
if (word === "-D") return "value";
|
|
612
|
+
return /^-O\d*$/.test(word) ? "flag" : undefined;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Marks an inline `-exec ... ;`/`-exec ... +` command as data and returns its end index. */
|
|
616
|
+
function consumeFindExecArgs(args: readonly string[], start: number, base: number, roles: RoleSets): number {
|
|
617
|
+
let index = start + 1;
|
|
618
|
+
while (index < args.length && args[index] !== ";" && args[index] !== "+") { roles.data.add(base + index); index++; }
|
|
619
|
+
return index;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Marks `find`'s leading options as data and returns the index its starting paths begin at. */
|
|
623
|
+
function consumeFindLeadingOptions(args: readonly string[], base: number, roles: RoleSets): number {
|
|
624
|
+
let index = 0;
|
|
625
|
+
while (index < args.length) {
|
|
626
|
+
const option = findLeadingOption(args[index]);
|
|
627
|
+
if (option === undefined) break;
|
|
628
|
+
roles.data.add(base + index);
|
|
629
|
+
if (option === "value" && index + 1 < args.length) roles.data.add(base + ++index);
|
|
630
|
+
index++;
|
|
631
|
+
}
|
|
632
|
+
return index;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* `find [-H|-L|-P|-O[level]|-D debugopts] [path...] [expression]`: positionals
|
|
637
|
+
* before the first expression token (an option, `(`, `)` or `!`) are starting
|
|
638
|
+
* paths; `-exec`/`-execdir`/`-ok`/`-okdir` consume a command up to `;`/`+` as
|
|
639
|
+
* data, and other tests consume their value as a path or data per the tables
|
|
640
|
+
* above.
|
|
641
|
+
*/
|
|
642
|
+
function findRoles(args: readonly string[], base: number, roles: RoleSets): void {
|
|
643
|
+
let sawExpression = false;
|
|
644
|
+
for (let index = consumeFindLeadingOptions(args, base, roles); index < args.length; index++) {
|
|
645
|
+
const word = args[index];
|
|
646
|
+
if (!sawExpression) {
|
|
647
|
+
if (!startsFindExpression(word)) { roles.paths.add(base + index); continue; }
|
|
648
|
+
sawExpression = true;
|
|
649
|
+
}
|
|
650
|
+
if (FIND_EXEC_TESTS.has(word)) { index = consumeFindExecArgs(args, index, base, roles); continue; }
|
|
651
|
+
if (FIND_PATH_VALUE_TESTS.has(word) && index + 1 < args.length) { roles.paths.add(base + ++index); continue; }
|
|
652
|
+
if (FIND_DATA_VALUE_TESTS.has(word) && index + 1 < args.length) { roles.data.add(base + ++index); continue; }
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Git subcommands whose operands are refs, messages or other data rather than
|
|
658
|
+
* files: `commit -m` messages, `branch <name>`, `checkout`/`switch` branches,
|
|
659
|
+
* `push`/`fetch` refspecs and `tag` names. `git add .env` stays an ordinary
|
|
660
|
+
* word, so a file operand is still protected once `--` ends ref parsing.
|
|
661
|
+
*/
|
|
662
|
+
function gitRoles(args: readonly string[], base: number, roles: RoleSets): void {
|
|
663
|
+
const subcommand = args[0];
|
|
664
|
+
if (subcommand === undefined || !GIT_HANDLED.has(subcommand)) return;
|
|
665
|
+
const rest = args.slice(1);
|
|
666
|
+
const long = GIT_LONG_VALUES[subcommand] ?? {};
|
|
667
|
+
const short = GIT_SHORT_VALUES[subcommand] ?? {};
|
|
668
|
+
const refs = subcommand === "tag" || subcommand === "branch" || subcommand === "push" || subcommand === "fetch";
|
|
669
|
+
let endOfOptions = false;
|
|
670
|
+
for (let index = 0; index < rest.length; index++) {
|
|
671
|
+
const word = rest[index];
|
|
672
|
+
const position = base + 1 + index;
|
|
673
|
+
if (!endOfOptions && word === "--") { endOfOptions = true; continue; }
|
|
674
|
+
if (!endOfOptions && word.startsWith("--") && word.length > 2) {
|
|
675
|
+
const part = longOptionPart(word);
|
|
676
|
+
const role = part === undefined ? undefined : long[part.option];
|
|
677
|
+
if (part === undefined || role === undefined) continue;
|
|
678
|
+
if (part.attached !== undefined) {
|
|
679
|
+
if (role === "path") roles.pathValues.push(part.attached);
|
|
680
|
+
else roles.data.add(position);
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
if (index + 1 < rest.length) {
|
|
684
|
+
const next = base + 1 + ++index;
|
|
685
|
+
(role === "data" ? roles.data : roles.paths).add(next);
|
|
686
|
+
}
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
if (!endOfOptions && word.startsWith("-") && word.length > 1) {
|
|
690
|
+
const parsed = shortOption(word, short);
|
|
691
|
+
if (parsed !== undefined) index = takeShortValue(parsed, index, base + 1, rest, roles);
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
if (roles.data.has(position)) continue;
|
|
695
|
+
// `checkout`/`switch` treat positionals before `--` as refs and after it as
|
|
696
|
+
// paths; `commit` positionals are always paths.
|
|
697
|
+
if (refs || ((subcommand === "checkout" || subcommand === "switch") && !endOfOptions))
|
|
698
|
+
roles.data.add(position);
|
|
699
|
+
else roles.paths.add(position);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** The path and data roles of one simple command's argv. */
|
|
704
|
+
export function operandRoles(words: readonly string[]): OperandRoles {
|
|
705
|
+
const executable = resolveExecutable(words);
|
|
706
|
+
if (executable === undefined) return { paths: [], data: [], pathValues: [] };
|
|
707
|
+
const args = words.slice(executable.index + 1);
|
|
708
|
+
const roles = dataOptionRoles(args, executable.index + 1);
|
|
709
|
+
pathOptionRoles(args, roles);
|
|
710
|
+
const name = executable.name;
|
|
711
|
+
if (PATTERN_FIRST_COMMANDS.has(name)) grepRoles(name, args, executable.index + 1, roles);
|
|
712
|
+
else if (name === "sed") sedRoles(args, executable.index + 1, roles);
|
|
713
|
+
else if (name === "jq") jqRoles(args, executable.index + 1, roles);
|
|
714
|
+
else if (name === "git") gitRoles(args, executable.index + 1, roles);
|
|
715
|
+
else if (name === "find") findRoles(args, executable.index + 1, roles);
|
|
716
|
+
else if (PATH_OPERAND_COMMANDS.has(name) || CHECKSUM_COMMAND.test(name))
|
|
717
|
+
plainRoles(name, args, executable.index + 1, roles);
|
|
718
|
+
const ascending = (left: number, right: number) => left - right;
|
|
719
|
+
return {
|
|
720
|
+
paths: [...roles.paths].sort(ascending),
|
|
721
|
+
data: [...roles.data].sort(ascending),
|
|
722
|
+
pathValues: [...new Set(roles.pathValues)],
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** The standalone words one simple command consumes as files. */
|
|
727
|
+
export function pathOperands(words: readonly string[]): string[] {
|
|
728
|
+
return operandRoles(words).paths.map((index) => words[index]);
|
|
729
|
+
}
|
|
730
|
+
|
|
200
731
|
/**
|
|
201
732
|
* True when an argument names one of the given long options, including the
|
|
202
733
|
* abbreviations GNU option parsing accepts (`sort --out=/tmp/out` means
|
|
@@ -211,14 +742,27 @@ function hasLongOption(args: readonly string[], names: readonly string[]): boole
|
|
|
211
742
|
});
|
|
212
743
|
}
|
|
213
744
|
|
|
745
|
+
/**
|
|
746
|
+
* True when an argument is a long option or an unambiguous GNU-style abbreviation
|
|
747
|
+
* of it (`--forc` means `--force`). Abbreviations fail closed: any prefix of a
|
|
748
|
+
* dangerous option is treated as that option.
|
|
749
|
+
*/
|
|
750
|
+
export function isLongOption(argument: string, name: string): boolean {
|
|
751
|
+
const option = argument.split("=", 1)[0];
|
|
752
|
+
return option === name || (option.length > 2 && option.startsWith("--") && name.startsWith(option));
|
|
753
|
+
}
|
|
754
|
+
|
|
214
755
|
function hasShortOption(args: readonly string[], letters: string): boolean {
|
|
215
756
|
return args.some((arg) => arg.startsWith("-") && !arg.startsWith("--") &&
|
|
216
757
|
[...arg.slice(1)].some((character) => letters.includes(character)));
|
|
217
758
|
}
|
|
218
759
|
|
|
219
760
|
function isWorldWritableMode(arg: string): boolean {
|
|
220
|
-
|
|
221
|
-
|
|
761
|
+
// `chmod +w` and `chmod +rwx` omit the `who` part, which means "all users".
|
|
762
|
+
// Only a leading `-` is a removal; a leading `+` has to be read as `a+`.
|
|
763
|
+
const mode = arg.replace(/^-+/, "").replace(/^\+/, "a+");
|
|
764
|
+
return /^[0-7]{3,4}$/.test(mode) ? (Number.parseInt(mode, 8) & 0o002) !== 0 :
|
|
765
|
+
/(?:^|,)(?:[ug]*o[ugoa]*|a|)[+=][rwxXst]*w/.test(mode);
|
|
222
766
|
}
|
|
223
767
|
|
|
224
768
|
/** chmod options, including the BSD flags, that never consume the mode operand. */
|
|
@@ -261,7 +805,8 @@ function isRecursiveRootRemoval(args: readonly string[]): boolean {
|
|
|
261
805
|
if (endOfOptions) { targets.push(arg); continue; }
|
|
262
806
|
if (arg === "--") { endOfOptions = true; continue; }
|
|
263
807
|
if (arg.startsWith("--")) {
|
|
264
|
-
|
|
808
|
+
// GNU option parsing accepts `--recursi` for `--recursive`.
|
|
809
|
+
if (isLongOption(arg, "--recursive")) recursive = true;
|
|
265
810
|
continue;
|
|
266
811
|
}
|
|
267
812
|
if (arg.startsWith("-") && arg.length > 1) {
|
|
@@ -666,22 +1211,7 @@ export function rawHeredocScript(command: string): RawHeredocScript {
|
|
|
666
1211
|
return { lines, ambiguous: false };
|
|
667
1212
|
}
|
|
668
1213
|
|
|
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
|
-
|
|
1214
|
+
/** The copied bodies and the offset where the command script resumes. */
|
|
685
1215
|
interface HeredocCopy {
|
|
686
1216
|
text: string;
|
|
687
1217
|
end: number;
|
|
@@ -1138,6 +1668,21 @@ export function denyInvocation(words: readonly string[], depth = 0): string | un
|
|
|
1138
1668
|
const executable = resolveExecutable(words);
|
|
1139
1669
|
if (executable === undefined) return undefined;
|
|
1140
1670
|
const args = words.slice(executable.index + 1);
|
|
1671
|
+
if (executable.name === "sshpass" || executable.name === "printenv" ||
|
|
1672
|
+
(executable.name === "env" && args.length === 0)) return DANGEROUS;
|
|
1673
|
+
if (["terraform", "tofu", "pulumi"].includes(executable.name) && args.includes("destroy")) return DANGEROUS;
|
|
1674
|
+
if (executable.name === "printf") {
|
|
1675
|
+
// `printf -vPATH x` sets a variable exactly like `printf -v PATH x`; `--` ends
|
|
1676
|
+
// option parsing, so `printf -- -v` prints the literal text.
|
|
1677
|
+
const flag = args[0] ?? "";
|
|
1678
|
+
const variable = flag === "-v" ? args[1] : flag.startsWith("-v") && flag.length > 2 ? flag.slice(2) : undefined;
|
|
1679
|
+
if (variable !== undefined && /^(?:PATH|CDPATH|ENV|BASH_ENV|SHELLOPTS|LD_.*|DYLD_.*)$/.test(variable)) return DANGEROUS;
|
|
1680
|
+
}
|
|
1681
|
+
if (executable.name === "jq" && args.some((arg) => /(?:^|[^a-zA-Z_])(?:env|\$ENV)(?:$|[^a-zA-Z_])/.test(arg))) return DANGEROUS;
|
|
1682
|
+
if (executable.name === "gh" && ((args[0] === "auth" && args[1] === "token") ||
|
|
1683
|
+
(args[0] === "api" && args.some((arg) => /^(?:-[fF]|--(?:raw-)?field(?:=|$)|--input(?:=|$))/.test(arg))))) return DANGEROUS;
|
|
1684
|
+
if (["npm", "pnpm", "yarn", "bun", "node"].includes(executable.name) &&
|
|
1685
|
+
args.some((arg) => /^(?:--(?:preload|require|import)(?:=|$)|-r)/.test(arg))) return DANGEROUS;
|
|
1141
1686
|
if (executable.name.startsWith("mkfs")) return DANGEROUS;
|
|
1142
1687
|
if (DENY_EXECUTABLES.has(executable.name)) return DANGEROUS;
|
|
1143
1688
|
if (executable.name === "chmod" && isWorldWritableMode(chmodMode(args) ?? "")) return DANGEROUS;
|
|
@@ -1223,6 +1768,8 @@ function isReadOnlyDate(args: readonly string[]): boolean {
|
|
|
1223
1768
|
if (arg.startsWith("--") && arg.length > 2) {
|
|
1224
1769
|
const name = arg.split("=", 1)[0];
|
|
1225
1770
|
const attached = arg.length > name.length;
|
|
1771
|
+
// `--file DATEFILE` reads dates from a file, which may be project data.
|
|
1772
|
+
if (name === "--file" || resolveLongOption(name) === "--file") return false;
|
|
1226
1773
|
if (DATE_LONG_VALUE_FLAGS.has(name)) {
|
|
1227
1774
|
if (!attached) index++;
|
|
1228
1775
|
continue;
|
|
@@ -1234,6 +1781,15 @@ function isReadOnlyDate(args: readonly string[]): boolean {
|
|
|
1234
1781
|
for (let position = 1; position < arg.length; position++) {
|
|
1235
1782
|
const letter = arg[position];
|
|
1236
1783
|
if (letter === "I") break;
|
|
1784
|
+
// `-f FILE` / `-fFILE` also reads a date file.
|
|
1785
|
+
if (letter === "f") return false;
|
|
1786
|
+
// `-rFILE` hides the reference file from the word scan; only the separate
|
|
1787
|
+
// `-r FILE` form stays available to the path check.
|
|
1788
|
+
if (letter === "r") {
|
|
1789
|
+
if (position !== arg.length - 1) return false;
|
|
1790
|
+
index++;
|
|
1791
|
+
break;
|
|
1792
|
+
}
|
|
1237
1793
|
if (DATE_VALUE_FLAGS.has(letter)) {
|
|
1238
1794
|
if (position === arg.length - 1) index++;
|
|
1239
1795
|
break;
|
|
@@ -1244,14 +1800,87 @@ function isReadOnlyDate(args: readonly string[]): boolean {
|
|
|
1244
1800
|
return true;
|
|
1245
1801
|
}
|
|
1246
1802
|
|
|
1803
|
+
/**
|
|
1804
|
+
* `jq -f`/`--from-file` runs a filter file and `--run-tests` runs a test file;
|
|
1805
|
+
* either can read the environment or any file it names, so all their spellings
|
|
1806
|
+
* (including attached `-fscript` and `--from-file=script`) need review.
|
|
1807
|
+
*/
|
|
1808
|
+
function isReadOnlyJq(args: readonly string[]): boolean {
|
|
1809
|
+
for (const arg of args) {
|
|
1810
|
+
if (arg === "--") break;
|
|
1811
|
+
if (arg.startsWith("--")) {
|
|
1812
|
+
const separator = arg.indexOf("=");
|
|
1813
|
+
const option = separator === -1 ? arg : arg.slice(0, separator);
|
|
1814
|
+
if (option === "--run-tests" || resolveLongOption(option) === "--from-file") return false;
|
|
1815
|
+
continue;
|
|
1816
|
+
}
|
|
1817
|
+
if (shortOption(arg, JQ_SHORT_VALUES)?.letter === "f") return false;
|
|
1818
|
+
}
|
|
1819
|
+
return true;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* `grep -r`/`-R` and `-d recurse` follow directories instead of reading only
|
|
1824
|
+
* the named operands, so they can dump protected or external trees. `-R` also
|
|
1825
|
+
* follows symlinks. A named protected path still denies before this policy.
|
|
1826
|
+
*/
|
|
1827
|
+
function isReadOnlyGrep(args: readonly string[]): boolean {
|
|
1828
|
+
for (let index = 0; index < args.length; index++) {
|
|
1829
|
+
const arg = args[index];
|
|
1830
|
+
if (/[rR]/.test(shortFlags(arg, GREP_SHORT_VALUES))) return false;
|
|
1831
|
+
const parsed = shortOption(arg, GREP_SHORT_VALUES);
|
|
1832
|
+
if (parsed?.letter === "d" && (parsed.attached ?? args[index + 1]) === "recurse") return false;
|
|
1833
|
+
if (!arg.startsWith("--") || arg.length <= 2) continue;
|
|
1834
|
+
const separator = arg.indexOf("=");
|
|
1835
|
+
const option = separator === -1 ? arg : arg.slice(0, separator);
|
|
1836
|
+
if ("--recursive".startsWith(option) || "--dereference-recursive".startsWith(option)) return false;
|
|
1837
|
+
if ("--directories".startsWith(option) &&
|
|
1838
|
+
(separator === -1 ? args[index + 1] : arg.slice(separator + 1)) === "recurse") return false;
|
|
1839
|
+
}
|
|
1840
|
+
return true;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
/**
|
|
1844
|
+
* `rg --hidden`, `-u`/`--no-ignore` and `-L`/`--follow` reach files the default
|
|
1845
|
+
* ignore rules and the named operands exclude (hidden config, ignored trees,
|
|
1846
|
+
* symlink targets), so those searches need review rather than a low score.
|
|
1847
|
+
*/
|
|
1848
|
+
function isReadOnlyRg(args: readonly string[]): boolean {
|
|
1849
|
+
if (hasLongOption(args, ["--pre", "--pre-glob", "--hostname-bin", "--search-zip", "--hidden",
|
|
1850
|
+
"--follow", "--unrestricted", "--no-ignore", "--no-ignore-vcs", "--no-ignore-parent",
|
|
1851
|
+
"--no-ignore-global", "--no-ignore-dot", "--no-ignore-files", "--no-ignore-exclude"])) return false;
|
|
1852
|
+
return !args.some((arg) => /[uLz]/.test(shortFlags(arg, RG_SHORT_VALUES)));
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/**
|
|
1856
|
+
* `diff -N`/`--new-file` treats absent files as empty and `-r` walks whole
|
|
1857
|
+
* trees, so a directory comparison can dump protected or external files. A
|
|
1858
|
+
* named protected path still denies before this policy.
|
|
1859
|
+
*/
|
|
1860
|
+
function isReadOnlyDiff(args: readonly string[]): boolean {
|
|
1861
|
+
if (hasLongOption(args, ["--new-file", "--unidirectional-new-file", "--recursive"])) return false;
|
|
1862
|
+
return !args.some((arg) => /[rN]/.test(shortFlags(arg, PLAIN_SHORT_VALUES.diff ?? {})));
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1247
1865
|
const POLICIES: Record<string, (args: readonly string[]) => boolean> = {
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1866
|
+
rg: isReadOnlyRg,
|
|
1867
|
+
// `grep -r`/`-R`/`-d recurse` walks whole trees, including protected and
|
|
1868
|
+
// external directories: the search is no longer a bounded read.
|
|
1869
|
+
grep: isReadOnlyGrep,
|
|
1870
|
+
// `diff -N`/`--new-file` and `-r` dump whole trees, including files that do
|
|
1871
|
+
// not exist on the other side.
|
|
1872
|
+
diff: isReadOnlyDiff,
|
|
1873
|
+
sort: (args) => !hasLongOption(args, ["--output", "--compress-program", "--temporary-directory",
|
|
1874
|
+
"--files0-from"]) && !hasShortOption(args, "oT"),
|
|
1875
|
+
// `wc --files0-from FILE` reads a file of paths.
|
|
1876
|
+
wc: (args) => !hasLongOption(args, ["--files0-from"]),
|
|
1877
|
+
tree: (args) => !hasLongOption(args, ["--output", "--fromfile", "--fromtabfile", "--gitfile"]) &&
|
|
1878
|
+
!hasShortOption(args, "o"),
|
|
1879
|
+
// `file -f/--files-from FILE` reads a list of files; `-C` compiles magic.
|
|
1880
|
+
file: (args) => !hasLongOption(args, ["--compile", "--files-from"]) && !hasShortOption(args, "Cf"),
|
|
1881
|
+
// `--files0-from` and `--exclude-from` read a file of paths; `-X` is the
|
|
1882
|
+
// short form of `--exclude-from`.
|
|
1883
|
+
du: (args) => !hasLongOption(args, ["--files0-from", "--exclude-from"]) && !hasShortOption(args, "X"),
|
|
1255
1884
|
// `cache --build` / `--clear` mutate the cache, and `--lessopen` runs the
|
|
1256
1885
|
// configured pager preprocessor.
|
|
1257
1886
|
bat: (args) => !hasLongOption(args, ["--pager", "--config-file", "--config-dir", "--generate-config-file",
|
|
@@ -1269,6 +1898,7 @@ const POLICIES: Record<string, (args: readonly string[]) => boolean> = {
|
|
|
1269
1898
|
return !hasLongOption(args, ["--file", "--boot"]) && !hasShortOption(args, "Fb");
|
|
1270
1899
|
},
|
|
1271
1900
|
date: isReadOnlyDate,
|
|
1901
|
+
jq: isReadOnlyJq,
|
|
1272
1902
|
};
|
|
1273
1903
|
|
|
1274
1904
|
const READ_ONLY = new Set(["pwd", "whoami", "id", "groups", "ls", "cat", "head", "tail", "wc", "echo", "printf",
|
|
@@ -1290,9 +1920,10 @@ function isReadOnlyGit(args: readonly string[]): boolean {
|
|
|
1290
1920
|
}
|
|
1291
1921
|
|
|
1292
1922
|
function isReadOnlySed(args: readonly string[]): boolean {
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
return args[0] === "-n" && args[1] !== undefined && /^\d+(?:,\d+)?p$/.test(args[1])
|
|
1923
|
+
// One narrowly decoded print script, then literal filenames only. Attached
|
|
1924
|
+
// -e/-f forms are scripts too, not inert filenames.
|
|
1925
|
+
return args[0] === "-n" && args[1] !== undefined && /^\d+(?:,\d+)?p$/.test(args[1]) &&
|
|
1926
|
+
args.slice(2).every((arg) => !arg.startsWith("-"));
|
|
1296
1927
|
}
|
|
1297
1928
|
|
|
1298
1929
|
function isReadOnlyGh(args: readonly string[]): boolean {
|
|
@@ -1321,13 +1952,24 @@ function isReadOnlyGh(args: readonly string[]): boolean {
|
|
|
1321
1952
|
*/
|
|
1322
1953
|
export function isReadOnlyInvocation(words: readonly string[], globs: readonly boolean[] = []): boolean {
|
|
1323
1954
|
if (globs.some(Boolean)) return false;
|
|
1955
|
+
// Path-qualified binaries may be repo-controlled lookalikes, not system tools.
|
|
1956
|
+
if ((words[0] ?? "").includes("/")) return false;
|
|
1324
1957
|
const command = basename(words[0] ?? "");
|
|
1325
1958
|
const args = words.slice(1);
|
|
1959
|
+
if (command === "printf") {
|
|
1960
|
+
// Any `-v` option assigns a shell variable, attached or separate. `--` ends
|
|
1961
|
+
// option parsing, so `printf -- -v` keeps `-v` as literal format text.
|
|
1962
|
+
for (const arg of args) {
|
|
1963
|
+
if (arg === "--") break;
|
|
1964
|
+
if (/^-v/.test(arg)) return false;
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1326
1967
|
// Own properties only: `./constructor` must not resolve to `Object`.
|
|
1327
1968
|
if (Object.hasOwn(POLICIES, command)) return POLICIES[command](args);
|
|
1328
1969
|
if (READ_ONLY.has(command)) return true;
|
|
1329
1970
|
if (command === "git") return isReadOnlyGit(args);
|
|
1330
|
-
if (command === "find")
|
|
1971
|
+
if (command === "find")
|
|
1972
|
+
return !args.some((arg) => /^--?(?:fprint|fprint0|fprintf|fls)$/.test(arg) || /^--?files0/.test(arg));
|
|
1331
1973
|
if (command === "sed") return isReadOnlySed(args);
|
|
1332
1974
|
if (command === "gh") return isReadOnlyGh(args);
|
|
1333
1975
|
if (command === "docker") return args[0] === "logs";
|