@blogic-cz/agent-tools 1.5.8 → 1.5.9
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 +12 -7
- package/dist/credential-guard/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/credential-guard/index.ts +911 -31
package/README.md
CHANGED
|
@@ -642,14 +642,19 @@ Claude Code uses shell command hooks. The package ships a ready-made wrapper scr
|
|
|
642
642
|
|
|
643
643
|
That's it. The hook reads tool input from stdin, runs the guard, and exits with code 2 (blocked + reason on stderr) or 0 (allowed).
|
|
644
644
|
|
|
645
|
-
The guard permits static `printenv` reads and simple `echo`/`printf` expansions only for exact names in `credentialGuard.allowedEnvironmentVariables`. The default list is empty. Multiple names are allowed only when each name is approved.
|
|
645
|
+
The guard permits static `printenv` reads and simple `echo`/`printf` expansions only for exact names in `credentialGuard.allowedEnvironmentVariables`. The default list is empty. Multiple names are allowed only when each name is approved. Approved environment values may be displayed, but redirecting their pipeline or passing them to an executor stays blocked.
|
|
646
|
+
|
|
647
|
+
Literal assignments such as `F=README.md; cat "$F"` work in supported static commands. Bounded proofs also support a literal `for` loop over a fixed list, the adjacent `cd PATH && T=LITERAL && git add -- "$T/file"` form, and `rg -n PATTERN src/{a,b}` with a single literal path-brace component. Loop binders are a single lowercase letter or `T`, and their uses must occupy supported data operands. Every body command and expanded path is checked; unsupported control flow, mutations such as `printf -v`, and unknown command roles cannot establish the proof. Other variable assignments and brace expansions retain conservative refusal. Fully quoted literal braces remain usable in static searches.
|
|
648
|
+
|
|
646
649
|
Ordinary navigation operands such as `cd "$HOME"` and `git -C "$WORKTREE_ROOT" status --short` do not need display permission. This does not authorize printing those values, using them as code/options, or reading unknown file paths. In `printf`, approved values belong in data arguments after a fixed nonmutating format, for example `printf -- '%s' "$WORKSPACE_LABEL"`.
|
|
647
|
-
|
|
648
|
-
Literal environment-command text in `rg`, `grep`, `git grep`, `echo`, and `printf` is allowed.
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
Single literal `echo`/`printf` writes with output redirection and single `cat`/`tee` heredoc writes are allowed. Heredoc braces are data.
|
|
650
|
+
|
|
651
|
+
The guard recognizes `rtk`, `rtk proxy`, `command`, and supported `env` prefixes and checks commands in pipelines and chains. Quoted filenames, comments, and redirects retain sensitive-path checks. Jq object selectors and inline awk programs qualify only when their actual program and options establish passive behavior. Literal environment-command text in `rg`, `grep`, `git grep`, `echo`, and `printf` is allowed only with supported command roles and passive pipeline consumers. Environment-source evidence and startup identity checks also apply to the complete original invocation. Custom dangerous-command patterns apply to both the original input and proved expansions.
|
|
652
|
+
|
|
653
|
+
Paths are checked against sensitive filename patterns and configured path rules. Supported proofs apply those checks to their expanded operands. Ordinary parent-directory and absolute paths may be allowed; the guard does not resolve filesystem paths or symlinks, or enforce repository confinement.
|
|
654
|
+
|
|
655
|
+
Single literal `echo`/`printf` writes with output redirection and single `cat`/`tee` heredoc writes are allowed. Heredoc braces are data. Use a quoted delimiter for literal code; unquoted heredocs containing `$`, backticks, or backslashes do not receive the literal-write exception. The exception permits only supported combinations with navigation or selected inspections; unsupported following commands remain outside it. Supported complete Python stdin heredocs are checked as inline programs, with the normal runtime-argument, path, execution, and environment checks.
|
|
656
|
+
|
|
657
|
+
A separate bounded Node proof supports literal `package.json` inventory loops that print `bin` metadata or use the supported scripts filter. The complete invocation must preserve loader identity through known passive commands, closed literal writers, or proved inventories. Startup/environment mutations, unknown commands, loader changes, and other dynamic loading remain unproved and are refused.
|
|
653
658
|
|
|
654
659
|
### Setup for OpenCode
|
|
655
660
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/credential-guard/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAmB,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAI5E,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAM/D,8CAA8C;AAC9C,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,+CAA+C;AAC/C,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B,CAAC;AAQF,+CAA+C;AAC/C,MAAM,MAAM,eAAe,GAAG;IAC5B,uBAAuB,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,KAAK,IAAI,CAAC;IACxE,aAAa,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC3E,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,sBAAsB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IACrD,iBAAiB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACjF,kBAAkB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IACjD,kBAAkB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;CACxD,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/credential-guard/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAmB,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAI5E,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAM/D,8CAA8C;AAC9C,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,+CAA+C;AAC/C,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B,CAAC;AAQF,+CAA+C;AAC/C,MAAM,MAAM,eAAe,GAAG;IAC5B,uBAAuB,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,KAAK,IAAI,CAAC;IACxE,aAAa,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC3E,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,sBAAsB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IACrD,iBAAiB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACjF,kBAAkB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IACjD,kBAAkB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;CACxD,CAAC;AA07DF,6CAA6C;AAC7C,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAErE;AAED,2CAA2C;AAC3C,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAEpE;AAED,2CAA2C;AAC3C,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAEpE;AAMD;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,eAAe,CAiTrF;AAQD,kEAAkE;AAClE,eAAO,MAAM,uBAAuB,UArxED,SAAS,UAAU,UAAU,KAAK,IAqxEM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YAvxEC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAuxEjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aAzxEE,MAAM,KAAK,OAyxEgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aA3xEE,MAAM,KAAK,OA2xEgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YA7xEC,MAAM,KAAK,OA6xE0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YA/xEC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IA+xEf,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YAjyEC,MAAM,KAAK,OAiyEsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YAnyEC,MAAM,KAAK,MAAM,GAAG,IAmyEa,CAAC"}
|
package/package.json
CHANGED
|
@@ -414,6 +414,628 @@ function parseStaticShellCommands(
|
|
|
414
414
|
return { pipelines, redirects };
|
|
415
415
|
}
|
|
416
416
|
|
|
417
|
+
/** A lexical binding proof, preserving shell quotes and rejecting every other expansion. */
|
|
418
|
+
function literalBindingText(
|
|
419
|
+
command: string,
|
|
420
|
+
name: string,
|
|
421
|
+
value: string,
|
|
422
|
+
): { text: string; syntax: string } | undefined {
|
|
423
|
+
let text = "";
|
|
424
|
+
let syntax = "";
|
|
425
|
+
let quote: "'" | '"' | undefined;
|
|
426
|
+
for (let i = 0; i < command.length; i++) {
|
|
427
|
+
const char = command[i] ?? "";
|
|
428
|
+
if (char === "\\" && quote !== "'") {
|
|
429
|
+
const next = command[++i];
|
|
430
|
+
if (next === undefined || /[\r\n]/.test(next)) return undefined;
|
|
431
|
+
text += char + next;
|
|
432
|
+
syntax += " ";
|
|
433
|
+
} else if (quote === "'") {
|
|
434
|
+
text += char;
|
|
435
|
+
syntax += " ";
|
|
436
|
+
if (char === quote) quote = undefined;
|
|
437
|
+
} else if (char === "'" && !quote) {
|
|
438
|
+
quote = char;
|
|
439
|
+
text += char;
|
|
440
|
+
syntax += " ";
|
|
441
|
+
} else if (char === '"') {
|
|
442
|
+
quote = quote ? undefined : char;
|
|
443
|
+
text += char;
|
|
444
|
+
syntax += " ";
|
|
445
|
+
} else if (char === "$") {
|
|
446
|
+
const match = /^\$\{([A-Za-z_]\w*)\}|^\$([A-Za-z_]\w*)/.exec(command.slice(i));
|
|
447
|
+
if (!match || (match[1] ?? match[2]) !== name) return undefined;
|
|
448
|
+
text += value;
|
|
449
|
+
syntax += quote ? " ".repeat(value.length) : value;
|
|
450
|
+
i += match[0].length - 1;
|
|
451
|
+
} else {
|
|
452
|
+
if (char === "`" || (!quote && /[(){}*?[\]#]/.test(char))) return undefined;
|
|
453
|
+
text += char;
|
|
454
|
+
syntax += quote ? " " : char;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return quote ? undefined : { text, syntax };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** These single-letter binders cannot name shell lookup, splitting or generated variables. */
|
|
461
|
+
function isLiteralBinder(name: string): boolean {
|
|
462
|
+
return /^(?:[a-z]|T)$/.test(name);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function unwrapProofCommand(words: string[], marker: string): string[] | undefined {
|
|
466
|
+
let index = 0;
|
|
467
|
+
while (words[index] === "rtk" || words[index] === "command") {
|
|
468
|
+
if (words[index++] === "rtk") {
|
|
469
|
+
if (words[index++] !== "proxy") return undefined;
|
|
470
|
+
} else if (words[index] === "--") index++;
|
|
471
|
+
}
|
|
472
|
+
if (words.slice(0, index + 1).some((word) => word.includes(marker))) return undefined;
|
|
473
|
+
const argv = words.slice(index);
|
|
474
|
+
if (!argv.length || /^[A-Za-z_]\w*=/.test(argv[0] ?? "")) return undefined;
|
|
475
|
+
return argv;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function isLiteralLoopCommand(words: string[], marker: string, value: string): boolean {
|
|
479
|
+
const marked = unwrapProofCommand(words, marker);
|
|
480
|
+
if (!marked) return false;
|
|
481
|
+
const argv = marked.map((word) => word.replaceAll(marker, value));
|
|
482
|
+
const name = argv[0];
|
|
483
|
+
const uses = marked.flatMap((word, index) => (word.includes(marker) ? [index] : []));
|
|
484
|
+
if (name === "cat")
|
|
485
|
+
return (
|
|
486
|
+
argv.slice(1).every((word) => word === "--" || !word.startsWith("-")) &&
|
|
487
|
+
uses.every((index) => index > 0 && !argv[index]?.startsWith("-"))
|
|
488
|
+
);
|
|
489
|
+
if (name === "echo") return uses.every((index) => index > 0 && !argv[index]?.startsWith("-"));
|
|
490
|
+
if (name === "printf") {
|
|
491
|
+
const format = passivePrintfFormatIndex(argv);
|
|
492
|
+
return format !== undefined && uses.every((index) => index > format);
|
|
493
|
+
}
|
|
494
|
+
if (name === "herdr")
|
|
495
|
+
return (
|
|
496
|
+
argv.length === 4 &&
|
|
497
|
+
argv[1] === "agent" &&
|
|
498
|
+
argv[2] === "get" &&
|
|
499
|
+
/^[A-Za-z0-9_.:-]+$/.test(argv[3] ?? "") &&
|
|
500
|
+
uses.every((index) => index === 3)
|
|
501
|
+
);
|
|
502
|
+
if (name === "bun")
|
|
503
|
+
return (
|
|
504
|
+
argv.length === 9 &&
|
|
505
|
+
argv.slice(0, 6).join(" ") === "bun run gh-tool pr review-triage --repo" &&
|
|
506
|
+
/^[A-Za-z0-9_.-]+$/.test(argv[6] ?? "") &&
|
|
507
|
+
argv[7] === "--pr" &&
|
|
508
|
+
/^\d+$/.test(argv[8] ?? "") &&
|
|
509
|
+
uses.every((index) => index === 8)
|
|
510
|
+
);
|
|
511
|
+
return (
|
|
512
|
+
["grep", "head", "tail"].includes(name ?? "") && uses.length === 0 && isPassiveTextCommand(argv)
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
type LiteralShellProof = { command: string; paths: string[] };
|
|
517
|
+
|
|
518
|
+
/** Bounded observed shell forms only; failure leaves the original command under normal policy. */
|
|
519
|
+
function materializeLiteralShell(command: string): LiteralShellProof | undefined {
|
|
520
|
+
if (new TextEncoder().encode(command).byteLength > 65_536) return undefined;
|
|
521
|
+
const marker = "GUARDLITERALBINDING";
|
|
522
|
+
if (command.includes(marker)) return undefined;
|
|
523
|
+
let script = command.trim();
|
|
524
|
+
const wrapped = parseStaticShellCommands(script);
|
|
525
|
+
if (
|
|
526
|
+
wrapped &&
|
|
527
|
+
typeof wrapped !== "string" &&
|
|
528
|
+
wrapped.pipelines.length === 1 &&
|
|
529
|
+
wrapped.pipelines[0]?.length === 1 &&
|
|
530
|
+
wrapped.redirects.length === 0
|
|
531
|
+
) {
|
|
532
|
+
const argv = unwrapProofCommand(wrapped.pipelines[0]?.[0] ?? [], marker);
|
|
533
|
+
if (argv?.[0] === "sh" && argv[1] === "-c" && argv.length === 3) script = argv[2] ?? "";
|
|
534
|
+
}
|
|
535
|
+
const loop =
|
|
536
|
+
/^for[ \t]+([A-Za-z_]\w*)[ \t]+in[ \t]+([^;\n]+);[ \t\r\n]*do[ \t\r\n]+([\s\S]*);[ \t\r\n]*done[ \t\r\n]*$/.exec(
|
|
537
|
+
script,
|
|
538
|
+
);
|
|
539
|
+
if (loop) {
|
|
540
|
+
const name = loop[1] ?? "";
|
|
541
|
+
if (!isLiteralBinder(name)) return undefined;
|
|
542
|
+
const items = loop[2] ?? "";
|
|
543
|
+
const rawValues = items.trim().split(/[ \t]+/);
|
|
544
|
+
if (
|
|
545
|
+
rawValues.some(
|
|
546
|
+
(item) =>
|
|
547
|
+
!/^(?:[A-Za-z0-9_./:][A-Za-z0-9_./:-]*|'[A-Za-z0-9_./:][A-Za-z0-9_./:-]*'|"[A-Za-z0-9_./:][A-Za-z0-9_./:-]*")$/.test(
|
|
548
|
+
item,
|
|
549
|
+
),
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
return undefined;
|
|
553
|
+
const values = rawValues.map((item) => item.replace(/^['"]|['"]$/g, ""));
|
|
554
|
+
if (!values.length || values.length > 16) return undefined;
|
|
555
|
+
const marked = literalBindingText(loop[3] ?? "", name, marker);
|
|
556
|
+
if (!marked || /&&|\|\||[<>]|&/.test(marked.syntax.replace(/\b2>&1\b/g, ""))) return undefined;
|
|
557
|
+
const parsed = parseStaticShellCommands(marked.text);
|
|
558
|
+
if (
|
|
559
|
+
!parsed ||
|
|
560
|
+
typeof parsed === "string" ||
|
|
561
|
+
parsed.redirects.length ||
|
|
562
|
+
parsed.pipelines.flat().length > 16 ||
|
|
563
|
+
!parsed.pipelines.flat().length
|
|
564
|
+
)
|
|
565
|
+
return undefined;
|
|
566
|
+
if (
|
|
567
|
+
!values.every((value) =>
|
|
568
|
+
parsed.pipelines.flat().every((words) => isLiteralLoopCommand(words, marker, value)),
|
|
569
|
+
)
|
|
570
|
+
)
|
|
571
|
+
return undefined;
|
|
572
|
+
const size = values.reduce(
|
|
573
|
+
(total, value) =>
|
|
574
|
+
total +
|
|
575
|
+
new TextEncoder().encode(marked.text).byteLength +
|
|
576
|
+
(marked.text.split(marker).length - 1) * (value.length - marker.length) +
|
|
577
|
+
2,
|
|
578
|
+
0,
|
|
579
|
+
);
|
|
580
|
+
if (size > 65_536) return undefined;
|
|
581
|
+
return {
|
|
582
|
+
command: values.map((value) => marked.text.replaceAll(marker, value)).join(";\n"),
|
|
583
|
+
paths: [],
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
const assignment =
|
|
587
|
+
/^cd[ \t]+(~?\/?[A-Za-z0-9_./-]+)[ \t]+&&[ \t]+([A-Za-z_]\w*)=([A-Za-z0-9_./][A-Za-z0-9_./-]*)[ \t]+&&[ \t]+/.exec(
|
|
588
|
+
command,
|
|
589
|
+
);
|
|
590
|
+
if (assignment && isLiteralBinder(assignment[2] ?? "")) {
|
|
591
|
+
const rest = literalBindingText(
|
|
592
|
+
command.slice(assignment[0].length),
|
|
593
|
+
assignment[2] ?? "",
|
|
594
|
+
marker,
|
|
595
|
+
);
|
|
596
|
+
if (!rest) return undefined;
|
|
597
|
+
const end = rest.syntax.search(/[;&|\r\n]/);
|
|
598
|
+
const use = rest.text.slice(0, end < 0 ? undefined : end);
|
|
599
|
+
const suffix = end < 0 ? "" : rest.text.slice(end);
|
|
600
|
+
if (suffix.includes(marker)) return undefined;
|
|
601
|
+
const parsed = parseStaticShellCommands(use);
|
|
602
|
+
const argv =
|
|
603
|
+
parsed &&
|
|
604
|
+
typeof parsed !== "string" &&
|
|
605
|
+
parsed.pipelines.length === 1 &&
|
|
606
|
+
parsed.pipelines[0]?.length === 1 &&
|
|
607
|
+
parsed.redirects.length === 0
|
|
608
|
+
? parsed.pipelines[0]?.[0]
|
|
609
|
+
: undefined;
|
|
610
|
+
if (
|
|
611
|
+
!argv ||
|
|
612
|
+
argv.length < 4 ||
|
|
613
|
+
argv.slice(0, 3).join(" ") !== "git add --" ||
|
|
614
|
+
argv.slice(3).some((word) => !/^[A-Za-z0-9_./-]+$/.test(word))
|
|
615
|
+
)
|
|
616
|
+
return undefined;
|
|
617
|
+
const value = assignment[3] ?? "";
|
|
618
|
+
const size =
|
|
619
|
+
new TextEncoder().encode(rest.text).byteLength +
|
|
620
|
+
(use.split(marker).length - 1) * (value.length - marker.length) +
|
|
621
|
+
(assignment[1]?.length ?? 0) +
|
|
622
|
+
7;
|
|
623
|
+
if (size > 65_536) return undefined;
|
|
624
|
+
const paths = argv.slice(3).map((word) => word.replaceAll(marker, value));
|
|
625
|
+
return { command: `cd ${assignment[1]} && ${use.replaceAll(marker, value)}${suffix}`, paths };
|
|
626
|
+
}
|
|
627
|
+
// Only the observed rg -n PATTERN PATH form; marker position proves a real file operand.
|
|
628
|
+
const brace =
|
|
629
|
+
/(?:^|\s)([A-Za-z0-9_./-]*\/[A-Za-z0-9_./-]*\{[A-Za-z_]\w*(?:,[A-Za-z_]\w*){1,7}\}[A-Za-z0-9_./-]*)$/.exec(
|
|
630
|
+
command,
|
|
631
|
+
);
|
|
632
|
+
if (!brace) return undefined;
|
|
633
|
+
const path = brace[1] ?? "";
|
|
634
|
+
const start = command.length - path.length;
|
|
635
|
+
const marked = command.slice(0, start) + marker;
|
|
636
|
+
const lexical = literalBindingText(marked, "", "");
|
|
637
|
+
const parsed = parseStaticShellCommands(marked);
|
|
638
|
+
if (
|
|
639
|
+
!lexical ||
|
|
640
|
+
/[;&|<>\r\n]/.test(lexical.syntax) ||
|
|
641
|
+
!parsed ||
|
|
642
|
+
typeof parsed === "string" ||
|
|
643
|
+
parsed.redirects.length ||
|
|
644
|
+
parsed.pipelines.length !== 1 ||
|
|
645
|
+
parsed.pipelines[0]?.length !== 1
|
|
646
|
+
)
|
|
647
|
+
return undefined;
|
|
648
|
+
const argv = unwrapProofCommand(parsed.pipelines[0]?.[0] ?? [], marker);
|
|
649
|
+
if (
|
|
650
|
+
!argv ||
|
|
651
|
+
argv.length !== 4 ||
|
|
652
|
+
argv[0] !== "rg" ||
|
|
653
|
+
argv[1] !== "-n" ||
|
|
654
|
+
argv[2]?.startsWith("-") ||
|
|
655
|
+
argv[3] !== marker
|
|
656
|
+
)
|
|
657
|
+
return undefined;
|
|
658
|
+
const members = /\{([^{}]+)\}/.exec(path);
|
|
659
|
+
if (!members) return undefined;
|
|
660
|
+
const values = (members[1] ?? "").split(",");
|
|
661
|
+
const size =
|
|
662
|
+
new TextEncoder().encode(command.slice(0, start)).byteLength +
|
|
663
|
+
values.reduce((total, value) => total + path.length - members[0].length + value.length + 1, -1);
|
|
664
|
+
if (size > 65_536) return undefined;
|
|
665
|
+
const paths = values.map((member) => path.replace(members[0], member));
|
|
666
|
+
return { command: command.slice(0, start) + paths.join(" "), paths };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Complete package metadata loops: no prelude, loader shadowing, mutation or arbitrary suffix. */
|
|
670
|
+
function staticPackageInventory(program: string): string[] | undefined {
|
|
671
|
+
if (program.length > 65_536) return undefined;
|
|
672
|
+
const prefix =
|
|
673
|
+
/^for\s*\(\s*const\s+([a-z])\s+of\s+(\[\s*(?:"[A-Za-z0-9_./@-]+"|'[A-Za-z0-9_./@-]+')(?:\s*,\s*(?:"[A-Za-z0-9_./@-]+"|'[A-Za-z0-9_./@-]+'))*\s*\])\s*\)\s*\{\s*(try\s*\{\s*)?const\s+([a-z])\s*=\s*require\s*\(\s*(["'])\.\/\5\s*\+\s*\1\s*\)\s*;\s*/.exec(
|
|
674
|
+
program.trim(),
|
|
675
|
+
);
|
|
676
|
+
if (!prefix || prefix[1] === prefix[4]) return undefined;
|
|
677
|
+
const paths = [...(prefix[2] ?? "").matchAll(/["']([^"']+)["']/g)].map(
|
|
678
|
+
(match) => "./" + (match[1] ?? ""),
|
|
679
|
+
);
|
|
680
|
+
if (
|
|
681
|
+
!paths.length ||
|
|
682
|
+
paths.length > 16 ||
|
|
683
|
+
// Four bytes per character bounds encoded text and shell quote escaping before allocation.
|
|
684
|
+
(program.length + Math.max(...paths.map((path) => path.length)) + 64) * paths.length * 4 >
|
|
685
|
+
65_536 ||
|
|
686
|
+
paths.some((path) => !/(?:^|\/)package\.json$/.test(path))
|
|
687
|
+
)
|
|
688
|
+
return undefined;
|
|
689
|
+
const name = prefix[1];
|
|
690
|
+
const result = prefix[4];
|
|
691
|
+
const end = prefix[3] ? String.raw`\s*\}\s*catch\s*\{\s*\}\s*\}` : String.raw`\s*\}`;
|
|
692
|
+
const suffix = program.trim().slice(prefix[0].length);
|
|
693
|
+
const bin = new RegExp(
|
|
694
|
+
String.raw`^console\.log\(\s*${name}\s*,\s*${result}\.bin\s*\)\s*;?${end}\s*$`,
|
|
695
|
+
);
|
|
696
|
+
const scripts = new RegExp(
|
|
697
|
+
String.raw`^console\.log\(\s*${name}\s*,\s*Object\.entries\(\s*${result}\.scripts\s*\|\|\s*\{\}\s*\)\.filter\(\(\[n\]\)=>n\.endsWith\("-tool"\)\|\|n\.endsWith\(":cli"\)\|\|n\.endsWith\(":auth"\)\|\|n==="ui:check"\)\s*\)\s*;?${end}\s*$`,
|
|
698
|
+
);
|
|
699
|
+
return bin.test(suffix) || scripts.test(suffix) ? paths : undefined;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Shared identity for refusal paths, including versioned/free-threaded Python and Windows names. */
|
|
703
|
+
function inlineRuntimeKind(executable: string | undefined): "python" | "node" | "bun" | undefined {
|
|
704
|
+
const name =
|
|
705
|
+
executable
|
|
706
|
+
?.split(/[/\\]/)
|
|
707
|
+
.at(-1)
|
|
708
|
+
?.replace(/\.exe$/i, "") ?? "";
|
|
709
|
+
if (/^python(?:\d+(?:\.\d+)*)?t?$/i.test(name)) return "python";
|
|
710
|
+
if (/^node$/i.test(name)) return "node";
|
|
711
|
+
if (/^bun$/i.test(name)) return "bun";
|
|
712
|
+
return undefined;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function mentionsInlineRuntime(command: string): boolean {
|
|
716
|
+
return command.split(/[\s'"`()<>;&|]+/).some((word) => inlineRuntimeKind(word) !== undefined);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Locate code only in an actual inline-program operand, after supported runtime flags. */
|
|
720
|
+
function inlineProgramIndex(argv: string[]): number | undefined {
|
|
721
|
+
const executable = argv[0]?.split("/").at(-1) ?? "";
|
|
722
|
+
const name = inlineRuntimeKind(argv[0]);
|
|
723
|
+
// Other recognized runtime spellings retain conservative refusal; this exception is bounded.
|
|
724
|
+
if (!name || !["python", "python3", "node", "bun"].includes(executable)) return undefined;
|
|
725
|
+
const python = name === "python";
|
|
726
|
+
const flags = python ? ["-u", "-B", "-I", "-S"] : name === "bun" ? ["--no-env-file"] : [];
|
|
727
|
+
let index = 1;
|
|
728
|
+
while (flags.includes(argv[index] ?? "")) index++;
|
|
729
|
+
if (
|
|
730
|
+
!(python ? argv[index] === "-c" : ["-e", "--eval"].includes(argv[index] ?? "")) ||
|
|
731
|
+
argv[index + 1] === undefined
|
|
732
|
+
)
|
|
733
|
+
return undefined;
|
|
734
|
+
// Python consumes the remaining words as sys.argv. Node can still parse startup options
|
|
735
|
+
// after eval, until -- or an ordinary positional operand. Bun suffixes require explicit --.
|
|
736
|
+
const suffix = argv[index + 2];
|
|
737
|
+
if (name === "bun" && suffix !== undefined && suffix !== "--") return undefined;
|
|
738
|
+
if (name === "node" && suffix?.startsWith("-") && suffix !== "--") return undefined;
|
|
739
|
+
return index + 1;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function isUnsupportedInlineRuntime(argv: string[]): boolean {
|
|
743
|
+
if (!inlineRuntimeKind(argv[0])) return false;
|
|
744
|
+
if (inlineProgramIndex(argv) !== undefined) return false;
|
|
745
|
+
return argv
|
|
746
|
+
.slice(1)
|
|
747
|
+
.some((arg) =>
|
|
748
|
+
/^(?:-[A-Za-z]*[cep]|-[rmp]|--(?:eval|print|require|import|loader|preload|experimental-loader))(?:$|=|[^-])/.test(
|
|
749
|
+
arg,
|
|
750
|
+
),
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Move a complete, literal Python stdin program into the existing inline argv boundary. */
|
|
755
|
+
function normalizeProgramHeredocs(command: string): string {
|
|
756
|
+
const heredoc = boundedHeredoc(command);
|
|
757
|
+
if (!heredoc || !heredoc.closed || (!heredoc.quoted && /[$`\\]/.test(heredoc.body)))
|
|
758
|
+
return command;
|
|
759
|
+
const parsed = parseStaticShellCommands(heredoc.header);
|
|
760
|
+
if (!parsed || typeof parsed === "string") return command;
|
|
761
|
+
const last = parsed.pipelines.at(-1)?.at(-1);
|
|
762
|
+
const argv = last ? unwrapStaticCommand(last) : [];
|
|
763
|
+
if (
|
|
764
|
+
inlineRuntimeKind(argv[0]) !== "python" ||
|
|
765
|
+
inlineProgramIndex([argv[0] ?? "", "-c", ""]) === undefined ||
|
|
766
|
+
argv.length !== 2 ||
|
|
767
|
+
argv[1] !== "-"
|
|
768
|
+
)
|
|
769
|
+
return command;
|
|
770
|
+
if (!/-\s*$/.test(heredoc.header)) return command;
|
|
771
|
+
const quoted = "'" + heredoc.body.replaceAll("'", "'\"'\"'") + "'";
|
|
772
|
+
return (
|
|
773
|
+
heredoc.header.replace(/-\s*$/, "-c " + quoted) +
|
|
774
|
+
"\n" +
|
|
775
|
+
normalizeProgramHeredocs(heredoc.following)
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Strings are data in these languages; retain their values separately for path checks. */
|
|
780
|
+
function programParts(
|
|
781
|
+
program: string,
|
|
782
|
+
python: boolean,
|
|
783
|
+
):
|
|
784
|
+
| { code: string; literals: string[]; pathLiterals: string[]; loadedModules: string[] }
|
|
785
|
+
| undefined {
|
|
786
|
+
const literals: string[] = [];
|
|
787
|
+
const pathLiterals: string[] = [];
|
|
788
|
+
const loadedModules: string[] = [];
|
|
789
|
+
let code = "";
|
|
790
|
+
for (let i = 0; i < program.length; i++) {
|
|
791
|
+
const char = program[i] ?? "";
|
|
792
|
+
if ((python && char === "#") || (!python && program.startsWith("//", i))) {
|
|
793
|
+
const terminator = python ? /[\r\n]/ : /[\r\n\u2028\u2029]/;
|
|
794
|
+
while (i < program.length && !terminator.test(program[i] ?? "")) i++;
|
|
795
|
+
code += "\n";
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
if (!python && program.startsWith("/*", i)) {
|
|
799
|
+
const end = program.indexOf("*/", i + 2);
|
|
800
|
+
if (end < 0) return undefined;
|
|
801
|
+
i = end + 1;
|
|
802
|
+
code += " ";
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
if (!python && char === "/") {
|
|
806
|
+
// Recognize regex only at an expression start; ambiguous slash syntax stays closed.
|
|
807
|
+
if (!/[=(,:!&|?;[{]\s*$/.test(code) && !/\breturn\s+$/.test(code)) return undefined;
|
|
808
|
+
let inClass = false;
|
|
809
|
+
let closed = false;
|
|
810
|
+
for (i++; i < program.length; i++) {
|
|
811
|
+
const next = program[i];
|
|
812
|
+
if (next === "\\") {
|
|
813
|
+
i++;
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
if (next === "\n") return undefined;
|
|
817
|
+
if (next === "[") inClass = true;
|
|
818
|
+
else if (next === "]") inClass = false;
|
|
819
|
+
else if (next === "/" && !inClass) {
|
|
820
|
+
closed = true;
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (!closed) return undefined;
|
|
825
|
+
code += " REGEX ";
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
if (char !== "'" && char !== '"' && char !== "`") {
|
|
829
|
+
code += char;
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
// Bun.write(PATH, TEXT) differs from file-object write(TEXT).
|
|
833
|
+
const literalTextArgument =
|
|
834
|
+
(/\.(?:write_text|write)\s*\(\s*$/.test(code) && !/\bBun\s*\.\s*write\s*\(\s*$/.test(code)) ||
|
|
835
|
+
/\bBun\s*\.\s*write\s*\(\s*STRING\s*,\s*$/.test(code);
|
|
836
|
+
const interpolated = python && /[fF]/.test(/[rRuUbBfF]+$/.exec(program.slice(0, i))?.[0] ?? "");
|
|
837
|
+
const delimiter = program.startsWith(char.repeat(3), i) ? char.repeat(3) : char;
|
|
838
|
+
i += delimiter.length;
|
|
839
|
+
let value = "";
|
|
840
|
+
let expressionDepth = 0;
|
|
841
|
+
while (i < program.length && !program.startsWith(delimiter, i)) {
|
|
842
|
+
if (program[i] === "\\") {
|
|
843
|
+
value += program[i] + (program[++i] ?? "");
|
|
844
|
+
i++;
|
|
845
|
+
} else {
|
|
846
|
+
const current = program[i];
|
|
847
|
+
if (expressionDepth > 0 && (current === "'" || current === '"')) {
|
|
848
|
+
if (program.startsWith(current.repeat(3), i)) return undefined;
|
|
849
|
+
let end = i + 1;
|
|
850
|
+
while (end < program.length && program[end] !== current) {
|
|
851
|
+
if (program[end] === "\\") end++;
|
|
852
|
+
end++;
|
|
853
|
+
}
|
|
854
|
+
if (end >= program.length) return undefined;
|
|
855
|
+
value += program.slice(i, end + 1);
|
|
856
|
+
i = end + 1;
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
if (
|
|
860
|
+
interpolated &&
|
|
861
|
+
expressionDepth === 0 &&
|
|
862
|
+
(program.startsWith("{{", i) || program.startsWith("}}", i))
|
|
863
|
+
) {
|
|
864
|
+
value += program.slice(i, i + 2);
|
|
865
|
+
i += 2;
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
if (
|
|
869
|
+
(interpolated && current === "{") ||
|
|
870
|
+
(char === "`" && current === "{" && (expressionDepth > 0 || program[i - 1] === "$"))
|
|
871
|
+
)
|
|
872
|
+
expressionDepth++;
|
|
873
|
+
if ((interpolated || char === "`") && current === "}" && expressionDepth > 0)
|
|
874
|
+
expressionDepth--;
|
|
875
|
+
value += program[i++];
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
if (i >= program.length || expressionDepth !== 0) return undefined;
|
|
879
|
+
// Path values need exact decoding. Other escapes (including continuations and
|
|
880
|
+
// identity escapes) remain unproved; payload strings do not require this proof.
|
|
881
|
+
if (
|
|
882
|
+
/\b(?:open|Path|readFile|readFileSync|writeFile|writeFileSync|file|Bun\s*\.\s*write)\s*\(\s*$/.test(
|
|
883
|
+
code,
|
|
884
|
+
) &&
|
|
885
|
+
value.replace(/\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4})/g, "").includes("\\")
|
|
886
|
+
)
|
|
887
|
+
return undefined;
|
|
888
|
+
value = value.replace(
|
|
889
|
+
/\\(?:x([0-9a-fA-F]{2})|u([0-9a-fA-F]{4})|([0-7]{1,3}))/g,
|
|
890
|
+
(_match, hex: string | undefined, unicode: string | undefined, octal: string | undefined) =>
|
|
891
|
+
String.fromCharCode(Number.parseInt(hex ?? unicode ?? octal ?? "0", octal ? 8 : 16)),
|
|
892
|
+
);
|
|
893
|
+
if (
|
|
894
|
+
!python &&
|
|
895
|
+
/\b(?:require|import)\s*\(\s*$|\bimport\s*$|\b(?:import|export)\s+[^;()\n]*\bfrom\s*$/.test(
|
|
896
|
+
code,
|
|
897
|
+
)
|
|
898
|
+
)
|
|
899
|
+
loadedModules.push(value);
|
|
900
|
+
literals.push(value);
|
|
901
|
+
if (!literalTextArgument) pathLiterals.push(value);
|
|
902
|
+
// Template expressions remain executable; do not use their quoted boundary as an exemption.
|
|
903
|
+
if (interpolated || (char === "`" && value.includes("${"))) code += value;
|
|
904
|
+
code += " STRING ";
|
|
905
|
+
i += delimiter.length - 1;
|
|
906
|
+
}
|
|
907
|
+
return { code, literals, pathLiterals, loadedModules };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** One immutable literal argv list passed once to subprocess.run, without a shell. */
|
|
911
|
+
function staticSubprocessCommand(program: string): string | undefined {
|
|
912
|
+
// The binding and use must be adjacent top-level statements at the start of the program.
|
|
913
|
+
// A literal-looking assignment in a string, branch, or imported scope proves nothing.
|
|
914
|
+
const prefix =
|
|
915
|
+
/^import subprocess[ \t]*\r?\n([A-Za-z_]\w*)[ \t]*=[ \t]*(\[\s*(?:"[^"\\]*"|'[^'\\]*')(?:\s*,\s*(?:"[^"\\]*"|'[^'\\]*'))*\s*,?\s*\])[ \t]*\r?\n(?:[A-Za-z_]\w*[ \t]*=[ \t]*)?subprocess\.run\(/.exec(
|
|
916
|
+
program.trimStart(),
|
|
917
|
+
);
|
|
918
|
+
if (!prefix) return undefined;
|
|
919
|
+
const variable = prefix[1] ?? "";
|
|
920
|
+
const rest = program.trimStart().slice(prefix[0].length);
|
|
921
|
+
const call = new RegExp(
|
|
922
|
+
`^${variable}\\s*(?:,\\s*(?:cwd\\s*=\\s*(?:"[^"\\\\]*"|'[^'\\\\]*')|(?:capture_output|text|check)\\s*=\\s*(?:True|False)|timeout\\s*=\\s*\\d+))*\\s*\\)`,
|
|
923
|
+
).exec(rest);
|
|
924
|
+
if (!call) return undefined;
|
|
925
|
+
const parts = programParts(program, true);
|
|
926
|
+
if (
|
|
927
|
+
!parts ||
|
|
928
|
+
(parts.code.match(new RegExp(`\\b${variable}\\b`, "g"))?.length ?? 0) !== 2 ||
|
|
929
|
+
(parts.code.match(/\bsubprocess\b/g)?.length ?? 0) !== 2
|
|
930
|
+
)
|
|
931
|
+
return undefined;
|
|
932
|
+
const values = [...(prefix[2] ?? "").matchAll(/"([^"\\]*)"|'([^'\\]*)'/g)].map(
|
|
933
|
+
(match) => match[1] ?? match[2] ?? "",
|
|
934
|
+
);
|
|
935
|
+
if (!values.length) return undefined;
|
|
936
|
+
return values.map((value) => "'" + value.replaceAll("'", "'\"'\"'") + "'").join(" ");
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function inlineSubprocessCommands(command: string): string[] {
|
|
940
|
+
const parsed = parseStaticShellCommands(command);
|
|
941
|
+
if (!parsed || typeof parsed === "string") return [];
|
|
942
|
+
return parsed.pipelines.flat().flatMap((words) => {
|
|
943
|
+
const argv = unwrapStaticCommand(words);
|
|
944
|
+
const index = inlineProgramIndex(argv);
|
|
945
|
+
const nested = index === undefined ? undefined : staticSubprocessCommand(argv[index] ?? "");
|
|
946
|
+
return nested ? [nested] : [];
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** The complete invocation must preserve the package loader's startup and lookup identity. */
|
|
951
|
+
function hasProvedPackageInventoryInvocation(command: string): boolean {
|
|
952
|
+
if (hasEnvironmentMutationPrefix(command)) return false;
|
|
953
|
+
const parsed = parseStaticShellCommands(command);
|
|
954
|
+
if (!parsed || typeof parsed === "string") return false;
|
|
955
|
+
return parsed.pipelines.flat().every((words) => {
|
|
956
|
+
const argv = unwrapStaticCommand(words);
|
|
957
|
+
if (
|
|
958
|
+
argv[0]?.split("/").at(-1) === "cat" ||
|
|
959
|
+
isPassiveTextCommand(argv) ||
|
|
960
|
+
isLiteralInlineWriter(argv)
|
|
961
|
+
)
|
|
962
|
+
return true;
|
|
963
|
+
const index = inlineProgramIndex(argv);
|
|
964
|
+
return (
|
|
965
|
+
inlineRuntimeKind(argv[0]) === "node" &&
|
|
966
|
+
index !== undefined &&
|
|
967
|
+
staticPackageInventory(argv[index] ?? "") !== undefined
|
|
968
|
+
);
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** Replay each proved JSON loader operand through ordinary command policies as well. */
|
|
973
|
+
function inlinePackageCommands(command: string, allowInventory: boolean): string[] {
|
|
974
|
+
if (!allowInventory) return [];
|
|
975
|
+
const parsed = parseStaticShellCommands(command);
|
|
976
|
+
if (!parsed || typeof parsed === "string") return [];
|
|
977
|
+
return parsed.pipelines.flat().flatMap((words) => {
|
|
978
|
+
const argv = unwrapStaticCommand(words);
|
|
979
|
+
const index = inlineProgramIndex(argv);
|
|
980
|
+
if (index === undefined || inlineRuntimeKind(argv[0]) !== "node") return [];
|
|
981
|
+
const program = argv[index] ?? "";
|
|
982
|
+
const paths = staticPackageInventory(program);
|
|
983
|
+
return (paths ?? []).map((path) =>
|
|
984
|
+
["node", "-e", program]
|
|
985
|
+
.map((word, position) => {
|
|
986
|
+
const materialized =
|
|
987
|
+
position === 2
|
|
988
|
+
? program.replace(
|
|
989
|
+
/\brequire\s*\(\s*(["'])\.\/\1\s*\+\s*[a-z]\s*\)/,
|
|
990
|
+
`require(${JSON.stringify(path)})`,
|
|
991
|
+
)
|
|
992
|
+
: word;
|
|
993
|
+
return "'" + materialized.replaceAll("'", "'\"'\"'") + "'";
|
|
994
|
+
})
|
|
995
|
+
.join(" "),
|
|
996
|
+
);
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** JSON values selected by arbitrary keys or printed wholesale have no metadata proof. */
|
|
1001
|
+
function hasUnverifiedJsonValueOutput(code: string): boolean {
|
|
1002
|
+
if (!/\bjson\.loads?\s*\(/.test(code) || !/\bprint\s*\(/.test(code)) return false;
|
|
1003
|
+
if (/\.items\s*\(/.test(code) || /\bprint\s*\(\s*json\.loads?\s*\(/.test(code)) return true;
|
|
1004
|
+
const names = [...code.matchAll(/\b([A-Za-z_]\w*)\s*=\s*json\.loads?\s*\(/g)].map(
|
|
1005
|
+
(match) => match[1] ?? "",
|
|
1006
|
+
);
|
|
1007
|
+
return names.some((name) =>
|
|
1008
|
+
new RegExp(`\\bprint\\s*\\(\\s*(?:json\\.dumps\\s*\\(\\s*)?${name}\\s*[,)]`).test(code),
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function hasInlineProgramExecution(
|
|
1013
|
+
program: string,
|
|
1014
|
+
python: boolean,
|
|
1015
|
+
allowInventory = false,
|
|
1016
|
+
): boolean {
|
|
1017
|
+
const parts = programParts(program, python);
|
|
1018
|
+
if (!parts) return true;
|
|
1019
|
+
if (python && hasUnverifiedJsonValueOutput(parts.code)) return true;
|
|
1020
|
+
if (parts.loadedModules.some((name) => /^(?:node:)?vm$/.test(name))) return true;
|
|
1021
|
+
// Dynamic loading and reflective access cannot establish a non-executing code operand.
|
|
1022
|
+
if (
|
|
1023
|
+
(/\b(?:require|import)\s*\((?!\s*STRING\s*\))/.test(parts.code) &&
|
|
1024
|
+
(!allowInventory || !staticPackageInventory(program))) ||
|
|
1025
|
+
/\[\s*STRING\s*\+/.test(parts.code) ||
|
|
1026
|
+
/\b(?:process|os)\s*\[/.test(parts.code)
|
|
1027
|
+
)
|
|
1028
|
+
return true;
|
|
1029
|
+
const code = staticSubprocessCommand(program)
|
|
1030
|
+
? parts.code.replace(/\bsubprocess\b/g, "")
|
|
1031
|
+
: parts.code;
|
|
1032
|
+
return (
|
|
1033
|
+
/\b(?:subprocess|child_process|execSync|execFile|execFileSync|spawn|spawnSync|eval|exec|Function|Reflect|getattr|setattr|globals|locals|vars|compile|__import__)\b|\bos\s*\.\s*(?:system|popen)|\bBun\s*\.\s*(?:spawn|spawnSync)|\bDeno\s*\.\s*Command/.test(
|
|
1034
|
+
code,
|
|
1035
|
+
) || parts.literals.some((value) => /^(?:node:)?child_process$/.test(value))
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
417
1039
|
function mentionsEnvironmentRead(text: string): boolean {
|
|
418
1040
|
// `--env dev` is a flag of another command, not the env command.
|
|
419
1041
|
return /(?<![\w.-])printenv\b(?!-)|(?<![\w.-])-\w*Oprintenv\b|(?<![\w.-])\benv\b(?!-)/i.test(
|
|
@@ -487,6 +1109,7 @@ function hasCommandArgumentBraceExpansion(argv: string[]): boolean {
|
|
|
487
1109
|
)
|
|
488
1110
|
return false;
|
|
489
1111
|
return (argv[0]?.split("/").at(-1) === "awk" && !isAwkExecution(argv)) ||
|
|
1112
|
+
inlineProgramIndex(argv) !== undefined ||
|
|
490
1113
|
isJqObjectConstruction(argv)
|
|
491
1114
|
? false
|
|
492
1115
|
: hasArgumentBraceExpansion(argv.join(" "));
|
|
@@ -540,6 +1163,11 @@ function isJqObjectConstruction(argv: string[]): boolean {
|
|
|
540
1163
|
if (argv[index] === "--") index++;
|
|
541
1164
|
const filter = argv[index] ?? "";
|
|
542
1165
|
if (argv.slice(index + 1).some((arg) => arg.startsWith("-"))) return false;
|
|
1166
|
+
const expression = filter
|
|
1167
|
+
.replace(/\.[A-Za-z_][A-Za-z0-9_.-]*/g, "FIELD")
|
|
1168
|
+
.replace(/\b[A-Za-z_][A-Za-z0-9_-]*\s*:/g, ":");
|
|
1169
|
+
if (/^\{[\s\S]*\}$/.test(filter) && /^(?:FIELD|length|map|[\s{}():,|>0-9])+$/.test(expression))
|
|
1170
|
+
return true;
|
|
543
1171
|
return (
|
|
544
1172
|
/^\{\s*[A-Za-z_][A-Za-z0-9_-]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_-]*)*\s*\}$/.test(filter) ||
|
|
545
1173
|
/^\{\s*[A-Za-z_][A-Za-z0-9_-]*\s*:\s*\.[A-Za-z_][A-Za-z0-9_.-]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_-]*\s*:\s*\.[A-Za-z_][A-Za-z0-9_.-]*)*\s*\}$/.test(
|
|
@@ -694,7 +1322,7 @@ function isPassiveTextCommand(argv: string[]): boolean {
|
|
|
694
1322
|
const args = argv.slice(1);
|
|
695
1323
|
// These commands consume literal text. Execution options are not exceptions.
|
|
696
1324
|
if (name === "printf") return passivePrintfFormatIndex(argv) !== undefined;
|
|
697
|
-
if (["echo", "grep", "head"].includes(name)) {
|
|
1325
|
+
if (["echo", "grep", "head", "tail"].includes(name)) {
|
|
698
1326
|
return true;
|
|
699
1327
|
}
|
|
700
1328
|
if (name === "rg") return !hasExecutionOption(args, ["pre", "hostname-bin"]);
|
|
@@ -708,7 +1336,11 @@ function isPassiveTextCommand(argv: string[]): boolean {
|
|
|
708
1336
|
);
|
|
709
1337
|
}
|
|
710
1338
|
|
|
711
|
-
function hasStaticEnvironmentRead(
|
|
1339
|
+
function hasStaticEnvironmentRead(
|
|
1340
|
+
argv: string[],
|
|
1341
|
+
allowedNames: Set<string>,
|
|
1342
|
+
allowInventory = false,
|
|
1343
|
+
): boolean {
|
|
712
1344
|
const name = argv[0]?.split("/").at(-1);
|
|
713
1345
|
if (
|
|
714
1346
|
["sh", "bash", "zsh"].includes(name ?? "") &&
|
|
@@ -717,17 +1349,49 @@ function hasStaticEnvironmentRead(argv: string[], allowedNames: Set<string>): bo
|
|
|
717
1349
|
) {
|
|
718
1350
|
return true;
|
|
719
1351
|
}
|
|
1352
|
+
if (
|
|
1353
|
+
name === "docker" &&
|
|
1354
|
+
argv.some((arg) => ["sh", "bash", "zsh"].includes(arg)) &&
|
|
1355
|
+
argv.some((arg) => /[$`]/.test(arg))
|
|
1356
|
+
)
|
|
1357
|
+
return true;
|
|
720
1358
|
if (name === "awk" && argv.slice(1).some((arg) => /\bENVIRON\b/.test(arg))) return true;
|
|
721
1359
|
if (name === "printenv") return !isAllowedEnvironmentRead(argv, allowedNames);
|
|
722
1360
|
if (name === "env") {
|
|
723
1361
|
const wrapped = unwrapEnvironmentCommand(argv);
|
|
724
1362
|
return wrapped === null
|
|
725
1363
|
? isEnvironmentListing(argv)
|
|
726
|
-
: hasStaticEnvironmentRead(unwrapStaticCommand(wrapped), allowedNames);
|
|
1364
|
+
: hasStaticEnvironmentRead(unwrapStaticCommand(wrapped), allowedNames, allowInventory);
|
|
727
1365
|
}
|
|
1366
|
+
if (isUnsupportedInlineRuntime(argv)) return true;
|
|
1367
|
+
const programIndex = inlineProgramIndex(argv);
|
|
1368
|
+
if (programIndex !== undefined)
|
|
1369
|
+
return hasInlineProgramExecution(
|
|
1370
|
+
argv[programIndex] ?? "",
|
|
1371
|
+
inlineRuntimeKind(argv[0]) === "python",
|
|
1372
|
+
allowInventory && inlineRuntimeKind(argv[0]) === "node",
|
|
1373
|
+
);
|
|
728
1374
|
if (isPassiveTextCommand(argv)) return false;
|
|
729
1375
|
// Any command may re-parse an argument as shell code (trap, find -exec, awk system()).
|
|
730
|
-
return
|
|
1376
|
+
return (
|
|
1377
|
+
hasCommandArgumentBraceExpansion(argv) ||
|
|
1378
|
+
([
|
|
1379
|
+
"sh",
|
|
1380
|
+
"bash",
|
|
1381
|
+
"zsh",
|
|
1382
|
+
"eval",
|
|
1383
|
+
"source",
|
|
1384
|
+
"find",
|
|
1385
|
+
"xargs",
|
|
1386
|
+
"trap",
|
|
1387
|
+
"awk",
|
|
1388
|
+
"git",
|
|
1389
|
+
"sort",
|
|
1390
|
+
"jq",
|
|
1391
|
+
"docker",
|
|
1392
|
+
].includes(name ?? "") &&
|
|
1393
|
+
mentionsEnvironmentRead(argv.join(" ")))
|
|
1394
|
+
);
|
|
731
1395
|
}
|
|
732
1396
|
|
|
733
1397
|
function isStaticHerdrPrompt(command: string): boolean {
|
|
@@ -751,7 +1415,11 @@ function isHerdrPrompt(command: string): boolean {
|
|
|
751
1415
|
return /^herdr\s+agent\s+prompt\b/.test(command.trim());
|
|
752
1416
|
}
|
|
753
1417
|
|
|
754
|
-
function hasEnvironmentRead(
|
|
1418
|
+
function hasEnvironmentRead(
|
|
1419
|
+
command: string,
|
|
1420
|
+
allowedNames: Set<string>,
|
|
1421
|
+
allowInventory = hasProvedPackageInventoryInvocation(command),
|
|
1422
|
+
): boolean {
|
|
755
1423
|
if (isStaticHerdrPrompt(command)) return false;
|
|
756
1424
|
if (isHerdrPrompt(command)) return true;
|
|
757
1425
|
if (isLiteralTextWrite(command)) return false;
|
|
@@ -789,23 +1457,24 @@ function hasEnvironmentRead(command: string, allowedNames: Set<string>): boolean
|
|
|
789
1457
|
) {
|
|
790
1458
|
return true;
|
|
791
1459
|
}
|
|
792
|
-
if (unwrapped.some((argv) => hasStaticEnvironmentRead(argv, allowedNames))) {
|
|
1460
|
+
if (unwrapped.some((argv) => hasStaticEnvironmentRead(argv, allowedNames, allowInventory))) {
|
|
793
1461
|
return true;
|
|
794
1462
|
}
|
|
795
1463
|
|
|
796
1464
|
// A literal producer can feed executable text to a shell, xargs, or an unknown consumer.
|
|
797
|
-
const result = pipelines.some(
|
|
798
|
-
(
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
(argv) =>
|
|
802
|
-
isAllowedEnvironmentRead(argv, allowedNames) ||
|
|
1465
|
+
const result = pipelines.some((pipeline) =>
|
|
1466
|
+
pipeline.some(
|
|
1467
|
+
(argv, index) =>
|
|
1468
|
+
(isAllowedEnvironmentRead(argv, allowedNames) ||
|
|
803
1469
|
(isLiteralTextProducer(argv) &&
|
|
804
|
-
(mentionsEnvironmentRead(argv.join(" ")) || hasCommandArgumentBraceExpansion(argv)))
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1470
|
+
(mentionsEnvironmentRead(argv.join(" ")) || hasCommandArgumentBraceExpansion(argv)))) &&
|
|
1471
|
+
pipeline
|
|
1472
|
+
.slice(index + 1)
|
|
1473
|
+
.some(
|
|
1474
|
+
(consumer) =>
|
|
1475
|
+
!isPassiveTextCommand(consumer) && !isAllowedEnvironmentRead(consumer, allowedNames),
|
|
1476
|
+
),
|
|
1477
|
+
),
|
|
809
1478
|
);
|
|
810
1479
|
return result;
|
|
811
1480
|
}
|
|
@@ -897,8 +1566,21 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
897
1566
|
const isSensitivePath = (text: string) =>
|
|
898
1567
|
sensitivePath.test(text) ||
|
|
899
1568
|
sensitiveCamelCase.test(text) ||
|
|
900
|
-
(sensitiveName.test(text) &&
|
|
1569
|
+
(sensitiveName.test(text) &&
|
|
1570
|
+
!/(?:^|\/)credential-guard(?:(?:-[^/]+)?\.(?:ts|md|js)|\*|\/(?:index\.(?:ts|js)))?$/.test(
|
|
1571
|
+
text,
|
|
1572
|
+
));
|
|
901
1573
|
if (/(?:^|[\s;&|])--env-file(?:=|\s)[^;&|]*\.env\b/i.test(command)) return true;
|
|
1574
|
+
const heredoc = boundedHeredoc(command);
|
|
1575
|
+
if (
|
|
1576
|
+
heredoc &&
|
|
1577
|
+
(isLiteralTextWrite(command) ||
|
|
1578
|
+
(isLiteralTextWrite(command.slice(0, command.length - heredoc.following.length).trimEnd()) &&
|
|
1579
|
+
!mentionsInlineRuntime(heredoc.following) &&
|
|
1580
|
+
!/\b(?:sh|bash|zsh|eval|source|xargs)\b/.test(heredoc.following)))
|
|
1581
|
+
) {
|
|
1582
|
+
return hasSensitiveFileRead(heredoc.header + "\n" + heredoc.following, isPathBlocked);
|
|
1583
|
+
}
|
|
902
1584
|
if (/\bcat\b[^;&|]*<<-?\s*\S+[\s\S]*\b(?:secret|credential)\b/i.test(command)) return true;
|
|
903
1585
|
const parsed = parseStaticShellCommands(command);
|
|
904
1586
|
const readers =
|
|
@@ -922,13 +1604,47 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
922
1604
|
return path !== undefined && (isPathBlocked(path) || isSensitivePath(path));
|
|
923
1605
|
});
|
|
924
1606
|
}
|
|
1607
|
+
const programIndex = inlineProgramIndex(inspected);
|
|
1608
|
+
if (programIndex !== undefined) {
|
|
1609
|
+
const parts = programParts(
|
|
1610
|
+
inspected[programIndex] ?? "",
|
|
1611
|
+
inlineRuntimeKind(inspected[0]) === "python",
|
|
1612
|
+
);
|
|
1613
|
+
if (!parts) return true;
|
|
1614
|
+
const inventory = staticPackageInventory(inspected[programIndex] ?? "");
|
|
1615
|
+
if (inventory?.some((path) => isPathBlocked(path) || isSensitivePath(path))) return true;
|
|
1616
|
+
if (parts.code.split(/[\s'"`()<>;&|]+/).some(isPathBlocked)) return true;
|
|
1617
|
+
const accessesFiles =
|
|
1618
|
+
/\b(?:open|Path|read_text|read_bytes|readFile|readFileSync|file|write_text|writeFile|writeFileSync)\b/.test(
|
|
1619
|
+
parts.code,
|
|
1620
|
+
) || /\bBun\s*\.\s*write\s*\(/.test(parts.code);
|
|
1621
|
+
if (
|
|
1622
|
+
accessesFiles &&
|
|
1623
|
+
/\b(?:sys|process)\s*\.\s*argv\b/.test(parts.code) &&
|
|
1624
|
+
inspected
|
|
1625
|
+
.slice(programIndex + 1)
|
|
1626
|
+
.some((arg) => isPathBlocked(arg) || isSensitivePath(arg))
|
|
1627
|
+
)
|
|
1628
|
+
return true;
|
|
1629
|
+
// Interpolation expressions remain in code; inspect their nested literal operands too.
|
|
1630
|
+
const expressionParts = /['"]/.test(parts.code)
|
|
1631
|
+
? programParts(parts.code, inlineRuntimeKind(inspected[0]) === "python")
|
|
1632
|
+
: { pathLiterals: [] };
|
|
1633
|
+
if (!expressionParts) return true;
|
|
1634
|
+
return [...parts.pathLiterals, ...expressionParts.pathLiterals].some(
|
|
1635
|
+
(value) =>
|
|
1636
|
+
(accessesFiles && isPathBlocked(value)) ||
|
|
1637
|
+
(!/\s/.test(value) && /[/.]/.test(value) && isSensitivePath(value)),
|
|
1638
|
+
);
|
|
1639
|
+
}
|
|
925
1640
|
if (!readers.test(name)) return false;
|
|
926
1641
|
if (name === "rg" || name === "grep") {
|
|
927
1642
|
const args = inspected.slice(1);
|
|
928
1643
|
const paths: string[] = [];
|
|
929
1644
|
let hasPattern = false;
|
|
930
1645
|
let options = true;
|
|
931
|
-
|
|
1646
|
+
const enumeratesFiles = name === "rg" && args.includes("--files");
|
|
1647
|
+
let filesOnly = enumeratesFiles;
|
|
932
1648
|
for (let i = 0; i < args.length; i++) {
|
|
933
1649
|
const arg = args[i] ?? "";
|
|
934
1650
|
if (options && arg === "--") {
|
|
@@ -966,7 +1682,11 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
966
1682
|
: arg.startsWith("--glob=")
|
|
967
1683
|
? arg.slice(7)
|
|
968
1684
|
: arg.slice(2);
|
|
969
|
-
if (
|
|
1685
|
+
if (
|
|
1686
|
+
!enumeratesFiles &&
|
|
1687
|
+
(isSensitivePath(glob) || isPathBlocked(glob.replaceAll("*", "")))
|
|
1688
|
+
)
|
|
1689
|
+
paths.push(glob);
|
|
970
1690
|
continue;
|
|
971
1691
|
}
|
|
972
1692
|
if (
|
|
@@ -977,8 +1697,9 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
977
1697
|
if (arg === "-f" || arg === "--file") paths.push(value ?? "");
|
|
978
1698
|
if (
|
|
979
1699
|
(arg === "-g" || arg === "--glob" || arg === "--iglob" || arg === "--include") &&
|
|
1700
|
+
!enumeratesFiles &&
|
|
980
1701
|
value &&
|
|
981
|
-
isSensitivePath(value)
|
|
1702
|
+
(isSensitivePath(value) || isPathBlocked(value.replaceAll("*", "")))
|
|
982
1703
|
)
|
|
983
1704
|
paths.push(value);
|
|
984
1705
|
if (arg === "-e" || arg === "--regexp" || arg === "-f" || arg === "--file")
|
|
@@ -1029,7 +1750,7 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
1029
1750
|
return true;
|
|
1030
1751
|
if (readers.test(command) && command.split(/[\s'"`()<>;&|]+/).some(isPathBlocked)) return true;
|
|
1031
1752
|
const unquoted = command.replace(/'(?:\\.|[^'])*'/g, " ").replace(/"(?:\\.|[^"$`])*"/g, " ");
|
|
1032
|
-
if (
|
|
1753
|
+
if ((mentionsInlineRuntime(command) || /\bruby\b/i.test(command)) && isSensitivePath(command)) {
|
|
1033
1754
|
return true;
|
|
1034
1755
|
}
|
|
1035
1756
|
return (
|
|
@@ -1051,7 +1772,7 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
1051
1772
|
command = [heredoc.header, heredoc.following].join("\n");
|
|
1052
1773
|
heredoc = boundedHeredoc(command);
|
|
1053
1774
|
}
|
|
1054
|
-
if (!command.includes("$")) return false;
|
|
1775
|
+
if (!command.includes("$") && !command.includes("`")) return false;
|
|
1055
1776
|
let invalidExpansion = false;
|
|
1056
1777
|
const assignments = getLeadingLiteralAssignments(command);
|
|
1057
1778
|
const localValues = new Map<string, string>();
|
|
@@ -1087,6 +1808,7 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
1087
1808
|
quote = quote === '"' ? undefined : '"';
|
|
1088
1809
|
continue;
|
|
1089
1810
|
}
|
|
1811
|
+
if (char === "`") return true;
|
|
1090
1812
|
if (char !== "$") {
|
|
1091
1813
|
normalized += char;
|
|
1092
1814
|
continue;
|
|
@@ -1184,13 +1906,129 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
1184
1906
|
return hasSensitiveFileRead(materialized, isPathBlocked);
|
|
1185
1907
|
}
|
|
1186
1908
|
|
|
1187
|
-
function
|
|
1188
|
-
return (
|
|
1189
|
-
|
|
1190
|
-
/\b(?:python3?|node|bun)\b/.test(command)
|
|
1909
|
+
function mentionsEnvironmentSource(command: string): boolean {
|
|
1910
|
+
return /\b(?:os\s*\.\s*(?:environ|getenv)|process\s*\.\s*env|Environment\s*\.\s*GetEnvironmentVariable)\b/.test(
|
|
1911
|
+
command,
|
|
1191
1912
|
);
|
|
1192
1913
|
}
|
|
1193
1914
|
|
|
1915
|
+
/** Closed raw-source grammar: literal display/write only, without projection or evaluation. */
|
|
1916
|
+
function isLiteralInlineWriter(argv: string[]): boolean {
|
|
1917
|
+
const index = inlineProgramIndex(argv);
|
|
1918
|
+
if (index === undefined || argv.length !== index + 1) return false;
|
|
1919
|
+
const kind = inlineRuntimeKind(argv[0]);
|
|
1920
|
+
const python = kind === "python";
|
|
1921
|
+
const program = (argv[index] ?? "").trim();
|
|
1922
|
+
let position = 0;
|
|
1923
|
+
const take = (pattern: RegExp): boolean => {
|
|
1924
|
+
const match = pattern.exec(program.slice(position));
|
|
1925
|
+
if (!match) return false;
|
|
1926
|
+
position += match[0].length;
|
|
1927
|
+
return true;
|
|
1928
|
+
};
|
|
1929
|
+
const literal = (): string | undefined => {
|
|
1930
|
+
take(/^\s*/);
|
|
1931
|
+
const start = position;
|
|
1932
|
+
const quote = program[position];
|
|
1933
|
+
if (quote !== "'" && quote !== '"') return undefined;
|
|
1934
|
+
const delimiter =
|
|
1935
|
+
python && program.startsWith(quote.repeat(3), position) ? quote.repeat(3) : quote;
|
|
1936
|
+
position += delimiter.length;
|
|
1937
|
+
while (position < program.length) {
|
|
1938
|
+
if (program.startsWith(delimiter, position)) {
|
|
1939
|
+
position += delimiter.length;
|
|
1940
|
+
return program.slice(start, position);
|
|
1941
|
+
}
|
|
1942
|
+
if (program[position] === "\\") position += 2;
|
|
1943
|
+
else if (delimiter.length === 1 && /[\r\n\u2028\u2029]/.test(program[position] ?? ""))
|
|
1944
|
+
return undefined;
|
|
1945
|
+
else position++;
|
|
1946
|
+
}
|
|
1947
|
+
return undefined;
|
|
1948
|
+
};
|
|
1949
|
+
let prefix: RegExp;
|
|
1950
|
+
let middle: RegExp | undefined;
|
|
1951
|
+
let mode = false;
|
|
1952
|
+
if (python && /^print\s*\(/.test(program)) prefix = /^print\s*\(\s*/;
|
|
1953
|
+
else if (!python && /^console\s*\.\s*log\s*\(/.test(program))
|
|
1954
|
+
prefix = /^console\s*\.\s*log\s*\(\s*/;
|
|
1955
|
+
else if (python && /^open\s*\(/.test(program)) {
|
|
1956
|
+
prefix = /^open\s*\(\s*/;
|
|
1957
|
+
mode = true;
|
|
1958
|
+
middle = /^\s*\)\s*\.\s*write\s*\(\s*/;
|
|
1959
|
+
} else if (kind === "bun" && /^Bun\s*\.\s*write\s*\(/.test(program)) {
|
|
1960
|
+
prefix = /^Bun\s*\.\s*write\s*\(\s*/;
|
|
1961
|
+
middle = /^\s*,\s*/;
|
|
1962
|
+
} else if (python) {
|
|
1963
|
+
prefix = /^from[ \t]+pathlib[ \t]+import[ \t]+Path[ \t]*(?:;|\r?\n)\s*Path\s*\(\s*/;
|
|
1964
|
+
middle = /^\s*\)\s*\.\s*write_text\s*\(\s*/;
|
|
1965
|
+
} else return false;
|
|
1966
|
+
if (!take(prefix) || literal() === undefined) return false;
|
|
1967
|
+
if (mode) {
|
|
1968
|
+
if (!take(/^\s*,\s*/)) return false;
|
|
1969
|
+
const value = literal();
|
|
1970
|
+
if (value !== "'w'" && value !== '"w"') return false;
|
|
1971
|
+
}
|
|
1972
|
+
if (middle && (!take(middle) || literal() === undefined)) return false;
|
|
1973
|
+
return take(/^\s*\)\s*;?\s*$/) && position === program.length;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
/** Inert command identity is unproved after an explicit environment mutation. */
|
|
1977
|
+
function hasEnvironmentMutationPrefix(command: string): boolean {
|
|
1978
|
+
const heredoc = boundedHeredoc(command);
|
|
1979
|
+
if (heredoc) {
|
|
1980
|
+
if (!heredoc.closed) return true;
|
|
1981
|
+
return hasEnvironmentMutationPrefix(heredoc.header + "\n" + heredoc.following);
|
|
1982
|
+
}
|
|
1983
|
+
const parsed = parseStaticShellCommands(command);
|
|
1984
|
+
if (!parsed || typeof parsed === "string") return true;
|
|
1985
|
+
return parsed.pipelines.flat().some((words) => {
|
|
1986
|
+
let index = 0;
|
|
1987
|
+
while (index < words.length) {
|
|
1988
|
+
const word = words[index] ?? "";
|
|
1989
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) return true;
|
|
1990
|
+
const name = word.split("/").at(-1);
|
|
1991
|
+
if (name === "env") {
|
|
1992
|
+
index++;
|
|
1993
|
+
if (words[index] === "--") index++;
|
|
1994
|
+
if (words[index]?.startsWith("-")) return true;
|
|
1995
|
+
} else if (name === "rtk" || name === "command") {
|
|
1996
|
+
index++;
|
|
1997
|
+
if ((name === "rtk" && words[index] === "proxy") || words[index] === "--") index++;
|
|
1998
|
+
} else return false;
|
|
1999
|
+
}
|
|
2000
|
+
return false;
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
function hasEnvironmentSourceAccess(command: string, rawTrigger: boolean): boolean {
|
|
2005
|
+
if (rawTrigger && hasEnvironmentMutationPrefix(command)) return true;
|
|
2006
|
+
if (isLiteralTextWrite(command) || isStaticHerdrPrompt(command)) return false;
|
|
2007
|
+
const parsed = parseStaticShellCommands(command);
|
|
2008
|
+
if (!parsed || typeof parsed === "string") return rawTrigger;
|
|
2009
|
+
const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
|
|
2010
|
+
if (rawTrigger) {
|
|
2011
|
+
// Original invocation-wide evidence survives split writers and inline quote projection.
|
|
2012
|
+
return !commands.every(
|
|
2013
|
+
(argv) =>
|
|
2014
|
+
argv[0]?.split("/").at(-1) === "cat" ||
|
|
2015
|
+
isPassiveTextCommand(argv) ||
|
|
2016
|
+
isLiteralInlineWriter(argv),
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
return commands.some((argv) => {
|
|
2020
|
+
const index = inlineProgramIndex(argv);
|
|
2021
|
+
if (index === undefined) return false;
|
|
2022
|
+
const parts = programParts(argv[index] ?? "", inlineRuntimeKind(argv[0]) === "python");
|
|
2023
|
+
if (!parts) return true;
|
|
2024
|
+
return (
|
|
2025
|
+
mentionsEnvironmentSource(parts.code) ||
|
|
2026
|
+
(/\b(?:os|process)\s*\[/.test(parts.code) &&
|
|
2027
|
+
parts.literals.some((value) => /^(?:env|environ|getenv)$/.test(value)))
|
|
2028
|
+
);
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
|
|
1194
2032
|
/** Extract file path from hook arguments. */
|
|
1195
2033
|
export function extractFilePath(args: Record<string, unknown>): string {
|
|
1196
2034
|
return (args.filePath as string) || (args.file_path as string) || (args.path as string) || "";
|
|
@@ -1294,6 +2132,29 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
1294
2132
|
}
|
|
1295
2133
|
|
|
1296
2134
|
function getDangerousBashReason(command: string): string | null {
|
|
2135
|
+
const originalCommand = command;
|
|
2136
|
+
const allowPackageInventory = hasProvedPackageInventoryInvocation(originalCommand);
|
|
2137
|
+
const rawEnvironmentSource =
|
|
2138
|
+
mentionsEnvironmentSource(command) && mentionsInlineRuntime(command);
|
|
2139
|
+
if (rawEnvironmentSource && hasEnvironmentMutationPrefix(originalCommand))
|
|
2140
|
+
return "reads process environment after an unproved environment mutation";
|
|
2141
|
+
command = normalizeProgramHeredocs(command);
|
|
2142
|
+
const proof = materializeLiteralShell(command);
|
|
2143
|
+
if (!proof && /^\s*for\b/.test(command)) return "cannot prove a bounded literal shell loop";
|
|
2144
|
+
if (proof) {
|
|
2145
|
+
if (proof.paths.some((path) => hasSensitiveFileRead("cat " + path, isPathBlocked)))
|
|
2146
|
+
return "accesses a sensitive file path";
|
|
2147
|
+
command = proof.command;
|
|
2148
|
+
if (getBlockedCliTool(command)) return "invokes a blocked CLI from a literal expansion";
|
|
2149
|
+
}
|
|
2150
|
+
for (const nested of [
|
|
2151
|
+
...inlineSubprocessCommands(command),
|
|
2152
|
+
...inlinePackageCommands(command, allowPackageInventory),
|
|
2153
|
+
]) {
|
|
2154
|
+
const reason = getDangerousBashReason(nested);
|
|
2155
|
+
if (reason || getBlockedCliTool(nested))
|
|
2156
|
+
return reason ?? "invokes a blocked CLI from a script";
|
|
2157
|
+
}
|
|
1297
2158
|
if (
|
|
1298
2159
|
hasSensitiveFileRead(command, isPathBlocked) ||
|
|
1299
2160
|
hasSensitivePathRedirect(command, isPathBlocked)
|
|
@@ -1306,11 +2167,16 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
1306
2167
|
) {
|
|
1307
2168
|
return "expands an unapproved or executable environment variable value";
|
|
1308
2169
|
}
|
|
1309
|
-
if (hasEnvironmentSourceAccess(command))
|
|
1310
|
-
|
|
2170
|
+
if (hasEnvironmentSourceAccess(command, rawEnvironmentSource))
|
|
2171
|
+
return "reads process environment from a script";
|
|
2172
|
+
if (hasEnvironmentRead(command, allowedEnvironmentVariables, allowPackageInventory)) {
|
|
1311
2173
|
return "reads environment variables or passes them to an executor";
|
|
1312
2174
|
}
|
|
1313
|
-
if (
|
|
2175
|
+
if (
|
|
2176
|
+
dangerousBashPatterns.some(
|
|
2177
|
+
(pattern) => pattern.test(originalCommand) || pattern.test(command),
|
|
2178
|
+
)
|
|
2179
|
+
) {
|
|
1314
2180
|
return "matches a configured dangerous-command pattern";
|
|
1315
2181
|
}
|
|
1316
2182
|
return null;
|
|
@@ -1341,7 +2207,21 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
1341
2207
|
return null;
|
|
1342
2208
|
}
|
|
1343
2209
|
|
|
1344
|
-
function getBlockedCliTool(
|
|
2210
|
+
function getBlockedCliTool(
|
|
2211
|
+
command: string,
|
|
2212
|
+
expand = true,
|
|
2213
|
+
): { name: string; wrapper: string } | null {
|
|
2214
|
+
command = normalizeProgramHeredocs(command);
|
|
2215
|
+
const proof = expand ? materializeLiteralShell(command) : undefined;
|
|
2216
|
+
if (proof) {
|
|
2217
|
+
const originalBlocked = getBlockedCliTool(command, false);
|
|
2218
|
+
if (originalBlocked) return originalBlocked;
|
|
2219
|
+
command = proof.command;
|
|
2220
|
+
}
|
|
2221
|
+
for (const nested of inlineSubprocessCommands(command)) {
|
|
2222
|
+
const blocked = getBlockedCliTool(nested);
|
|
2223
|
+
if (blocked) return blocked;
|
|
2224
|
+
}
|
|
1345
2225
|
const staticCommands = parseStaticShellCommands(command);
|
|
1346
2226
|
const parsed = staticCommands ?? parseStaticShellCommands(command, true);
|
|
1347
2227
|
if (parsed && typeof parsed !== "string") {
|