@blogic-cz/agent-tools 1.6.2 → 1.6.4
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 +6 -0
- package/dist/credential-guard/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/credential-guard/index.ts +352 -37
package/README.md
CHANGED
|
@@ -656,6 +656,8 @@ Ordinary navigation operands such as `cd "$HOME"` and `git -C "$WORKTREE_ROOT" s
|
|
|
656
656
|
|
|
657
657
|
Literal `herdr agent prompt` commands can be composed with independent `cat` readers and passive text pipelines. Prompt text stays data; command substitutions, environment mutation, unknown consumers, and executable pipelines cannot establish the proof. All reader operands and redirects still receive the ordinary sensitive-path checks. Literal loops also support `herdr pane read ID --source recent-unwrapped --lines N`, with the binder restricted to `ID`, and `2>&1` file-descriptor duplication. A bounded Python stdin processor supports only `import json,sys; d=json.load(sys.stdin); print(d["result"].get("text","") if isinstance(d.get("result"),dict) else d)`, including insignificant spacing and single lowercase-letter local renaming. Extra imports, statements, calls, runtime options, arguments, and other Python programs cannot establish this loop proof. The existing loop size and command limits apply.
|
|
658
658
|
|
|
659
|
+
Prompt composition also supports one `cd [--] PATH` with a literal relative or absolute directory at the start of the final `&&` chain. All subsequent inspection must remain in that chain, so a failed `cd` cannot reach its readers. Directory names may contain quoted spaces. Multiple `cd` commands, later `;` or newline boundaries, tilde or variable expansion, other `cd` options, background jobs, `||`, and navigation in pipelines remain unproved. Supported Git inspection can use literal `-C PATH` prefixes with `grep`, `status`, `rev-parse`, or `rev-list`; execution and startup options remain unsupported. Navigation composition refuses relative file redirects, including redirects on `cd` and Git commands, because those use the shell directory before the command runs. Absolute redirects still receive ordinary path checks. Named directories receive sensitive-path checks with directory-prefix semantics. Only a proved `cd` chain qualifies relative reader operands with that directory; Git `-C` qualifies that command's file operands without changing shell state. Git grep uses the shared grep pattern-versus-file checks. This checks textual operands and does not resolve aliases, symlinks, ambient directories, or command lookup.
|
|
660
|
+
|
|
659
661
|
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.
|
|
660
662
|
|
|
661
663
|
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.
|
|
@@ -666,6 +668,10 @@ Single literal `echo`/`printf` writes with output redirection and single `cat`/`
|
|
|
666
668
|
|
|
667
669
|
Python JSON parsing, dictionary iteration, and printing are not themselves evidence of credential access. They follow the same access and execution rules as other supported inline programs. This intentionally permits ordinary JSON output that older versions refused. Unknown secrets in ordinary files can therefore reach arbitrary shell output; wrapper content checks do not protect that output. A refusal caused by unsupported syntax means the guard could not establish its policy conditions, not that it observed a secret being read.
|
|
668
670
|
|
|
671
|
+
The bounded Python subprocess proof supports one immutable literal argv binding followed by one `subprocess.run` call. A literal `cwd` is replayed as a quoted, success-gated directory change before the nested argv, so relative reader operands and named directories receive the ordinary path policies. Supported directories are literal relative or absolute paths, including quoted spaces. Empty, dynamic, Unicode, escaped, tilde and other unsupported directory forms refuse rather than dropping the context. Cwd proof recursively unwraps only supported command, RTK proxy and environment wrappers. Environment directory/split-string options and unproved wrapper forms refuse. Only supported passive text, reader, metadata, literal Bun script and blocked CLI roles receive nested replay; shell executors, inline interpreter programs, unproved sed programs, find execution actions and unknown command roles refuse. Nested blocked CLI, environment, sensitive-path and custom-command policies remain active. Outer shell redirects retain their original shell directory. Arbitrary multi-call Python and `check_output` programs remain unsupported; direct Git metadata commands provide index/tree inspection.
|
|
672
|
+
|
|
673
|
+
Supported runtime script/tool operands form an argument boundary: a later tool `-c` configuration flag is data. Genuine eval and loader flags, Bun exec/repl and unproved startup-option arity still refuse. Literal Herdr prompts can compose with the existing literal agent-get and pane-read forms and passive consumers. One literal `--sql` operand on the supported `bun run db-tool sql/query` shape can contain SQL braces; substitutions, duplicate SQL operands and unknown wrapper options receive no exception. SQL output pipelines require closed passive consumers, so shell, xargs and unknown executors refuse. Numeric `$?` can appear only in passive echo/printf data operands, with passive pipeline consumers. Explicitly approved environment names can appear in quoted equality or blankness tests, including the quoted empty default `${NAME:-}`. This does not add default-approved names or permit shell evaluators. One sed print program with numeric or escaped-regex addresses/ranges and known `-n`/`-E` flags is passive; execution/read/write callbacks, extra programs and script files receive no print proof. Local file bindings use that same closed print proof and never a generic sed-word fallback. Ordinary path and configured command policies still apply to all these forms.
|
|
674
|
+
|
|
669
675
|
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.
|
|
670
676
|
|
|
671
677
|
### Setup for OpenCode
|
|
@@ -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;AAK5E,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;AAK5E,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;AAwhFF,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,CA4TrF;AAQD,kEAAkE;AAClE,eAAO,MAAM,uBAAuB,UA93FD,SAAS,UAAU,UAAU,KAAK,IA83FM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YAh4FC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAg4FjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aAl4FE,MAAM,KAAK,OAk4FgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aAp4FE,MAAM,KAAK,OAo4FgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YAt4FC,MAAM,KAAK,OAs4F0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YAx4FC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IAw4Ff,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YA14FC,MAAM,KAAK,OA04FsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YA54FC,MAAM,KAAK,MAAM,GAAG,IA44Fa,CAAC"}
|
package/package.json
CHANGED
|
@@ -262,21 +262,12 @@ function isSafeLocalAssignmentCommand(words: string[]): boolean {
|
|
|
262
262
|
return !argv.slice(1).some((arg) => /^-(?:exec|ok|delete|fprint|fprintf)/.test(arg));
|
|
263
263
|
}
|
|
264
264
|
if (name === "bun") return argv[1] === "run" && ["db-tool", "gh-tool"].includes(argv[2] ?? "");
|
|
265
|
+
if (name === "sed") return isReadonlySedPrint(argv);
|
|
265
266
|
return (
|
|
266
267
|
isPassiveTextCommand(argv) ||
|
|
267
268
|
["ls", "cat", "tail", "wc", "uniq", "cut", "mv"].includes(name) ||
|
|
268
269
|
(name === "sort" &&
|
|
269
|
-
!hasExecutionOption(argv.slice(1), ["compress-program", "files0-from", "output"], "o"))
|
|
270
|
-
(name === "sed" &&
|
|
271
|
-
argv
|
|
272
|
-
.slice(1)
|
|
273
|
-
.every(
|
|
274
|
-
(arg) =>
|
|
275
|
-
/^-[En]+$/.test(arg) ||
|
|
276
|
-
/^s([/|]).*\1[^/|]*\1[gp]*$/.test(arg) ||
|
|
277
|
-
/^[A-Za-z0-9_./-]+$/.test(arg),
|
|
278
|
-
) &&
|
|
279
|
-
!argv.includes("-f"))
|
|
270
|
+
!hasExecutionOption(argv.slice(1), ["compress-program", "files0-from", "output"], "o"))
|
|
280
271
|
);
|
|
281
272
|
}
|
|
282
273
|
|
|
@@ -502,13 +493,7 @@ function isLiteralLoopCommand(words: string[], marker: string, value: string): b
|
|
|
502
493
|
const format = passivePrintfFormatIndex(argv);
|
|
503
494
|
return format !== undefined && uses.every((index) => index > format);
|
|
504
495
|
}
|
|
505
|
-
if (name === "herdr")
|
|
506
|
-
return (
|
|
507
|
-
((argv.length === 4 && argv[1] === "agent" && argv[2] === "get") ||
|
|
508
|
-
isLiteralHerdrPaneRead(argv)) &&
|
|
509
|
-
/^[A-Za-z0-9_.:-]+$/.test(argv[3] ?? "") &&
|
|
510
|
-
uses.every((index) => index === 3)
|
|
511
|
-
);
|
|
496
|
+
if (name === "herdr") return isLiteralHerdrRead(argv) && uses.every((index) => index === 3);
|
|
512
497
|
if (name === "bun")
|
|
513
498
|
return (
|
|
514
499
|
argv.length === 9 &&
|
|
@@ -537,6 +522,14 @@ function isLiteralHerdrPaneRead(argv: string[]): boolean {
|
|
|
537
522
|
);
|
|
538
523
|
}
|
|
539
524
|
|
|
525
|
+
function isLiteralHerdrRead(argv: string[]): boolean {
|
|
526
|
+
return (
|
|
527
|
+
((argv.length === 4 && argv.slice(0, 3).join(" ") === "herdr agent get") ||
|
|
528
|
+
isLiteralHerdrPaneRead(argv)) &&
|
|
529
|
+
/^[A-Za-z0-9_.:-]+$/.test(argv[3] ?? "")
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
540
533
|
/** A complete stdin-only JSON projection, with no suffix, imports or executable input. */
|
|
541
534
|
function isLiteralStdinJsonDisplay(argv: string[]): boolean {
|
|
542
535
|
if (argv.length !== 3 || !["python", "python3"].includes(argv[0] ?? "") || argv[1] !== "-c")
|
|
@@ -852,15 +845,54 @@ function inlineProgramIndex(argv: string[]): number | undefined {
|
|
|
852
845
|
}
|
|
853
846
|
|
|
854
847
|
function isUnsupportedInlineRuntime(argv: string[]): boolean {
|
|
855
|
-
|
|
848
|
+
const runtime = inlineRuntimeKind(argv[0]);
|
|
849
|
+
if (!runtime) return false;
|
|
856
850
|
if (inlineProgramIndex(argv) !== undefined) return false;
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
851
|
+
let index = 1;
|
|
852
|
+
for (; index < argv.length; index++) {
|
|
853
|
+
const arg = argv[index] ?? "";
|
|
854
|
+
if (arg === "--" || !arg.startsWith("-")) break;
|
|
855
|
+
if (
|
|
860
856
|
/^(?:-[A-Za-z]*[cep]|-[rmp]|--(?:eval|print|require|import|loader|preload|experimental-loader))(?:$|=|[^-])/.test(
|
|
861
857
|
arg,
|
|
862
|
-
)
|
|
863
|
-
)
|
|
858
|
+
)
|
|
859
|
+
)
|
|
860
|
+
return true;
|
|
861
|
+
if (
|
|
862
|
+
(runtime === "python" && arg === "--check-hash-based-pycs") ||
|
|
863
|
+
(runtime === "node" &&
|
|
864
|
+
["--conditions", "-C", "--icu-data-dir", "--dns-result-order"].includes(arg))
|
|
865
|
+
) {
|
|
866
|
+
if (argv[index + 1] === undefined) return true;
|
|
867
|
+
index++;
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
// Unknown startup-option arity cannot establish where the script/tool begins.
|
|
871
|
+
if (
|
|
872
|
+
!(
|
|
873
|
+
(runtime === "python" && /^-[uBISsEOqvV]+$/.test(arg)) ||
|
|
874
|
+
(runtime === "node" &&
|
|
875
|
+
/^(?:--(?:trace-warnings|no-warnings|version|help)|--(?:conditions|icu-data-dir|dns-result-order)=.+)$/.test(
|
|
876
|
+
arg,
|
|
877
|
+
)) ||
|
|
878
|
+
(runtime === "bun" && ["--no-env-file", "--version", "--help"].includes(arg)) ||
|
|
879
|
+
["--version", "--help", "-h"].includes(arg)
|
|
880
|
+
)
|
|
881
|
+
)
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
if (runtime === "bun" && ["exec", "repl"].includes(argv[index] ?? "")) return true;
|
|
885
|
+
// Bun retains eval/loader controls around its tool operand; only a later -c is tool data.
|
|
886
|
+
return (
|
|
887
|
+
runtime === "bun" &&
|
|
888
|
+
argv
|
|
889
|
+
.slice(index)
|
|
890
|
+
.some((arg) =>
|
|
891
|
+
/^(?:-[A-Za-z]*[ep]|-[rmp]|--(?:eval|print|require|import|loader|preload|experimental-loader))(?:$|=|[^-])/.test(
|
|
892
|
+
arg,
|
|
893
|
+
),
|
|
894
|
+
)
|
|
895
|
+
);
|
|
864
896
|
}
|
|
865
897
|
|
|
866
898
|
/** Move a complete, literal Python stdin program into the existing inline argv boundary. */
|
|
@@ -1045,7 +1077,64 @@ function staticSubprocessCommand(program: string): string | undefined {
|
|
|
1045
1077
|
(match) => match[1] ?? match[2] ?? "",
|
|
1046
1078
|
);
|
|
1047
1079
|
if (!values.length) return undefined;
|
|
1048
|
-
|
|
1080
|
+
const quote = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'";
|
|
1081
|
+
const command = values.map(quote).join(" ");
|
|
1082
|
+
const directories = [...call[0].matchAll(/\bcwd\s*=\s*(?:"([^"\\]*)"|'([^'\\]*)')/g)];
|
|
1083
|
+
if (!directories.length) return command;
|
|
1084
|
+
const cwd = directories[0]?.[1] ?? directories[0]?.[2] ?? "";
|
|
1085
|
+
// Never replay a cwd-bearing call as if it ran in the original shell directory.
|
|
1086
|
+
if (directories.length !== 1 || !isStaticNavigationCommand(["cd", "--", cwd])) return undefined;
|
|
1087
|
+
const argv = unwrapCwdSubprocessCommand(values);
|
|
1088
|
+
if (!argv || !isProvedCwdSubprocessRole(argv)) return undefined;
|
|
1089
|
+
// Keep wrapper assignments/options in policy replay after proving the nested command role.
|
|
1090
|
+
const replay = `cd -- ${quote(cwd)} && ${command}`;
|
|
1091
|
+
return hasProvedNavigationSequence(replay) ? replay : undefined;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function unwrapCwdSubprocessCommand(words: string[]): string[] | undefined {
|
|
1095
|
+
let argv = words;
|
|
1096
|
+
// Each successful wrapper removes words; the original word count bounds recursion.
|
|
1097
|
+
for (let depth = 0; depth < words.length; depth++) {
|
|
1098
|
+
const unwrapped = unwrapProofCommand(argv, "\0");
|
|
1099
|
+
if (!unwrapped) return undefined;
|
|
1100
|
+
argv = unwrapped;
|
|
1101
|
+
if (argv[0]?.split("/").at(-1) !== "env") return argv;
|
|
1102
|
+
const nested = unwrapEnvironmentCommand(argv);
|
|
1103
|
+
if (!nested) return undefined;
|
|
1104
|
+
// Inspect only env's prefix, not options belonging to its child command.
|
|
1105
|
+
// env directory, split-string and unknown options do not preserve the proved context.
|
|
1106
|
+
if (
|
|
1107
|
+
argv
|
|
1108
|
+
.slice(1, argv.length - nested.length)
|
|
1109
|
+
.some(
|
|
1110
|
+
(arg) =>
|
|
1111
|
+
arg.startsWith("-") && arg !== "--" && arg !== "-i" && arg !== "--ignore-environment",
|
|
1112
|
+
)
|
|
1113
|
+
)
|
|
1114
|
+
return undefined;
|
|
1115
|
+
argv = nested;
|
|
1116
|
+
}
|
|
1117
|
+
return undefined;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function isProvedCwdSubprocessRole(argv: string[]): boolean {
|
|
1121
|
+
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
1122
|
+
if (inlineProgramIndex(argv) !== undefined || isUnsupportedInlineRuntime(argv)) return false;
|
|
1123
|
+
if (isPassiveTextCommand(argv)) return true;
|
|
1124
|
+
if (name === "find") return isSafeLocalAssignmentCommand(argv);
|
|
1125
|
+
if (["ls", "cat", "uniq", "cut"].includes(name)) return true;
|
|
1126
|
+
if (name === "wc") return !hasExecutionOption(argv.slice(1), ["files0-from"]);
|
|
1127
|
+
if (name === "sort")
|
|
1128
|
+
return !hasExecutionOption(argv.slice(1), ["compress-program", "files0-from"]);
|
|
1129
|
+
if (name === "git") {
|
|
1130
|
+
let index = 1;
|
|
1131
|
+
while (argv[index] === "-C" && argv[index + 1]) index += 2;
|
|
1132
|
+
return ["ls-files", "ls-tree", "status", "rev-parse", "rev-list"].includes(argv[index] ?? "");
|
|
1133
|
+
}
|
|
1134
|
+
// A literal Bun script keeps the existing supported test-runner route.
|
|
1135
|
+
if (name === "bun") return /^[A-Za-z0-9_./-]+\.(?:[cm]?[jt]s)$/.test(argv[1] ?? "");
|
|
1136
|
+
// Retain extraction so the nested CLI policy returns its ordinary descriptor.
|
|
1137
|
+
return DEFAULT_BLOCKED_CLI_TOOLS.some((tool) => tool.name === name);
|
|
1049
1138
|
}
|
|
1050
1139
|
|
|
1051
1140
|
function inlineSubprocessCommands(command: string): string[] {
|
|
@@ -1207,6 +1296,9 @@ function hasCommandArgumentBraceExpansion(argv: string[]): boolean {
|
|
|
1207
1296
|
argv[4] === "query"
|
|
1208
1297
|
)
|
|
1209
1298
|
return false;
|
|
1299
|
+
const sql = literalSqlArgumentIndex(argv);
|
|
1300
|
+
if (sql !== undefined)
|
|
1301
|
+
return argv.some((arg, index) => index !== sql && hasArgumentBraceExpansion(arg));
|
|
1210
1302
|
return (argv[0]?.split("/").at(-1) === "awk" && !isAwkExecution(argv)) ||
|
|
1211
1303
|
inlineProgramIndex(argv) !== undefined ||
|
|
1212
1304
|
isJqObjectConstruction(argv)
|
|
@@ -1214,6 +1306,32 @@ function hasCommandArgumentBraceExpansion(argv: string[]): boolean {
|
|
|
1214
1306
|
: hasArgumentBraceExpansion(argv.join(" "));
|
|
1215
1307
|
}
|
|
1216
1308
|
|
|
1309
|
+
function literalSqlArgumentIndex(argv: string[]): number | undefined {
|
|
1310
|
+
if (
|
|
1311
|
+
argv[0]?.split("/").at(-1) !== "bun" ||
|
|
1312
|
+
argv[1] !== "run" ||
|
|
1313
|
+
argv[2] !== "db-tool" ||
|
|
1314
|
+
!["sql", "query"].includes(argv[3] ?? "")
|
|
1315
|
+
)
|
|
1316
|
+
return undefined;
|
|
1317
|
+
let sql: number | undefined;
|
|
1318
|
+
const seen = new Set<string>();
|
|
1319
|
+
for (let index = 4; index < argv.length; index++) {
|
|
1320
|
+
const [option = "", ...attached] = (argv[index] ?? "").split("=");
|
|
1321
|
+
if (
|
|
1322
|
+
!["--sql", "--env", "--profile", "--limit", "--format"].includes(option) ||
|
|
1323
|
+
seen.has(option)
|
|
1324
|
+
)
|
|
1325
|
+
return undefined;
|
|
1326
|
+
seen.add(option);
|
|
1327
|
+
const value = attached.length ? attached.join("=") : argv[++index];
|
|
1328
|
+
if (!value || value.startsWith("--")) return undefined;
|
|
1329
|
+
if (option === "--sql") sql = index;
|
|
1330
|
+
else if (!/^[A-Za-z0-9_./-]+$/.test(value)) return undefined;
|
|
1331
|
+
}
|
|
1332
|
+
return sql;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1217
1335
|
/** echo and printf print their arguments; grep -o, rg and git grep print matched pattern text. */
|
|
1218
1336
|
function isLiteralTextProducer(argv: string[]): boolean {
|
|
1219
1337
|
return isPassiveTextCommand(argv) && argv[0]?.split("/").at(-1) !== "head";
|
|
@@ -1548,6 +1666,57 @@ function isNavigationOperand(argv: string[], marker: string): boolean {
|
|
|
1548
1666
|
);
|
|
1549
1667
|
}
|
|
1550
1668
|
|
|
1669
|
+
/** Literal directory operands only; navigation is never a pipeline consumer. */
|
|
1670
|
+
function isStaticNavigationCommand(argv: string[]): boolean {
|
|
1671
|
+
const path = (value: string | undefined) => /^[A-Za-z0-9_./][A-Za-z0-9_./ -]*$/.test(value ?? "");
|
|
1672
|
+
if (argv[0] === "cd")
|
|
1673
|
+
return isNavigationOperand(argv, "\0") && path(argv[argv[1] === "--" ? 2 : 1]);
|
|
1674
|
+
if (argv[0] !== "git") return false;
|
|
1675
|
+
let index = 1;
|
|
1676
|
+
while (argv[index] === "-C") {
|
|
1677
|
+
if (!path(argv[index + 1])) return false;
|
|
1678
|
+
index += 2;
|
|
1679
|
+
}
|
|
1680
|
+
return (
|
|
1681
|
+
isNavigationOperand(argv, "\0") ||
|
|
1682
|
+
(index > 1 && isPassiveTextCommand(["git", ...argv.slice(index)]))
|
|
1683
|
+
);
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/** Prove a single cd whose uses are gated on success, without simulating shell state. */
|
|
1687
|
+
function hasProvedNavigationSequence(command: string): boolean {
|
|
1688
|
+
const literal = literalBindingText(command, "", "");
|
|
1689
|
+
const parsed = parseStaticShellCommands(command);
|
|
1690
|
+
if (!literal || !parsed || typeof parsed === "string") return false;
|
|
1691
|
+
const commands = parsed.pipelines.flat().map((words) => unwrapProofCommand(words, "\0"));
|
|
1692
|
+
const changesDirectory = commands.filter((argv) => argv?.[0] === "cd");
|
|
1693
|
+
if (
|
|
1694
|
+
!commands.some((argv) => argv && (argv[0] === "cd" || (argv[0] === "git" && argv[1] === "-C")))
|
|
1695
|
+
)
|
|
1696
|
+
return true;
|
|
1697
|
+
if (
|
|
1698
|
+
/\|\||(^|[^&])&(?!&)/.test(literal.syntax.replace(/[<>]&[0-9-]+/g, "")) ||
|
|
1699
|
+
parsed.redirects.some(({ target }) => !target.startsWith("/")) ||
|
|
1700
|
+
changesDirectory.length > 1
|
|
1701
|
+
)
|
|
1702
|
+
return false;
|
|
1703
|
+
if (!changesDirectory.length) return true;
|
|
1704
|
+
// Only the final AND list can use this directory. A failing cd never reaches its readers.
|
|
1705
|
+
// Trim real trailing whitespace, not the spaces masking final quoted argv words.
|
|
1706
|
+
const syntax = literal.syntax.slice(0, command.replace(/[ \t\n]+$/g, "").length);
|
|
1707
|
+
const boundary = Math.max(syntax.lastIndexOf(";"), syntax.lastIndexOf("\n"));
|
|
1708
|
+
const tail = parseStaticShellCommands(command.slice(boundary + 1, syntax.length));
|
|
1709
|
+
if (!tail || typeof tail === "string" || tail.pipelines.length < 2) return false;
|
|
1710
|
+
const first = tail.pipelines[0];
|
|
1711
|
+
const argv = unwrapProofCommand(first?.[0] ?? [], "\0");
|
|
1712
|
+
return (
|
|
1713
|
+
first?.length === 1 &&
|
|
1714
|
+
argv?.[0] === "cd" &&
|
|
1715
|
+
isStaticNavigationCommand(argv) &&
|
|
1716
|
+
syntax.slice(boundary + 1).match(/&&|[;&|\n]/)?.[0] === "&&"
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1551
1720
|
function isPassiveTextCommand(argv: string[]): boolean {
|
|
1552
1721
|
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
1553
1722
|
const args = argv.slice(1);
|
|
@@ -1558,6 +1727,7 @@ function isPassiveTextCommand(argv: string[]): boolean {
|
|
|
1558
1727
|
}
|
|
1559
1728
|
if (name === "rg") return !hasExecutionOption(args, ["pre", "hostname-bin"]);
|
|
1560
1729
|
if (name === "awk") return !isAwkExecution(argv);
|
|
1730
|
+
if (name === "sed") return isReadonlySedPrint(argv);
|
|
1561
1731
|
if (name === "jq") return isJqObjectConstruction(argv);
|
|
1562
1732
|
return (
|
|
1563
1733
|
(name === "git" && args[0] === "status") ||
|
|
@@ -1567,6 +1737,19 @@ function isPassiveTextCommand(argv: string[]): boolean {
|
|
|
1567
1737
|
);
|
|
1568
1738
|
}
|
|
1569
1739
|
|
|
1740
|
+
/** One print program with optional numeric or escaped-regex addresses; no callbacks. */
|
|
1741
|
+
function isReadonlySedPrint(argv: string[]): boolean {
|
|
1742
|
+
if (argv[0]?.split("/").at(-1) !== "sed") return false;
|
|
1743
|
+
let index = 1;
|
|
1744
|
+
while (/^-[nE]+$/.test(argv[index] ?? "")) index++;
|
|
1745
|
+
if (argv[index] === "--") index++;
|
|
1746
|
+
const address = String.raw`(?:[0-9]+|\$|/(?:\\[^\r\n]|[^/\\\r\n])*/)`;
|
|
1747
|
+
return (
|
|
1748
|
+
new RegExp(`^(?:${address}(?:,${address})?)?p$`).test(argv[index] ?? "") &&
|
|
1749
|
+
argv.slice(index + 1).every((arg) => !arg.startsWith("-"))
|
|
1750
|
+
);
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1570
1753
|
function hasStaticEnvironmentRead(
|
|
1571
1754
|
argv: string[],
|
|
1572
1755
|
allowedNames: Set<string>,
|
|
@@ -1627,13 +1810,15 @@ function hasStaticEnvironmentRead(
|
|
|
1627
1810
|
|
|
1628
1811
|
function isStaticHerdrPrompt(command: string): boolean {
|
|
1629
1812
|
// Preserve shell word boundaries and executable identity before proving composition.
|
|
1630
|
-
|
|
1813
|
+
const literal = literalBindingText(command, "", "");
|
|
1814
|
+
if (!literal) return false;
|
|
1631
1815
|
const parsed = parseStaticShellCommands(command);
|
|
1632
1816
|
if (!parsed || typeof parsed === "string") return false;
|
|
1633
1817
|
const commands = parsed.pipelines.flat().map((words) => unwrapProofCommand(words, "\0"));
|
|
1634
1818
|
const prompts = commands.filter(
|
|
1635
1819
|
(argv) => argv?.[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt",
|
|
1636
1820
|
);
|
|
1821
|
+
if (!hasProvedNavigationSequence(command)) return false;
|
|
1637
1822
|
return (
|
|
1638
1823
|
prompts.length > 0 &&
|
|
1639
1824
|
parsed.pipelines.every((pipeline) =>
|
|
@@ -1649,7 +1834,11 @@ function isStaticHerdrPrompt(command: string): boolean {
|
|
|
1649
1834
|
argv.length > 1 &&
|
|
1650
1835
|
argv.slice(1).every((arg) => arg === "--" || !arg.startsWith("-"))
|
|
1651
1836
|
);
|
|
1652
|
-
|
|
1837
|
+
if (isPassiveTextCommand(argv)) return true;
|
|
1838
|
+
if (isLiteralHerdrRead(argv)) return index === 0;
|
|
1839
|
+
if (isStaticNavigationCommand(argv))
|
|
1840
|
+
return index === 0 && (argv[0] !== "cd" || pipeline.length === 1);
|
|
1841
|
+
return false;
|
|
1653
1842
|
}),
|
|
1654
1843
|
)
|
|
1655
1844
|
);
|
|
@@ -1677,6 +1866,26 @@ function hasEnvironmentRead(
|
|
|
1677
1866
|
const parsed = parseStaticShellCommands(command);
|
|
1678
1867
|
if (parsed === "brace-expansion") return true;
|
|
1679
1868
|
if (!parsed) {
|
|
1869
|
+
const operands = parseStaticShellCommands(command, true);
|
|
1870
|
+
if (
|
|
1871
|
+
operands &&
|
|
1872
|
+
typeof operands !== "string" &&
|
|
1873
|
+
operands.pipelines.flat().some((words) => {
|
|
1874
|
+
const argv = unwrapStaticCommand(words);
|
|
1875
|
+
return (
|
|
1876
|
+
argv[0]?.split("/").at(-1) === "bun" &&
|
|
1877
|
+
argv[1] === "run" &&
|
|
1878
|
+
argv[2] === "db-tool" &&
|
|
1879
|
+
["sql", "query"].includes(argv[3] ?? "") &&
|
|
1880
|
+
argv.some(
|
|
1881
|
+
(arg, index) =>
|
|
1882
|
+
(arg === "--sql" && /\$\(|`/.test(argv[index + 1] ?? "")) ||
|
|
1883
|
+
(arg.startsWith("--sql=") && /\$\(|`/.test(arg)),
|
|
1884
|
+
)
|
|
1885
|
+
);
|
|
1886
|
+
})
|
|
1887
|
+
)
|
|
1888
|
+
return true;
|
|
1680
1889
|
const unquoted = command.replace(/'(?:\\.|[^'])*'/g, " ").replace(/"(?:\\.|[^"$`])*"/g, " ");
|
|
1681
1890
|
const hasExecutor =
|
|
1682
1891
|
/\|\s*(?:sh|bash|zsh|xargs)|\b(?:sh|bash|zsh)\b|\$\(|`|\b(?:eval|source)\b|\bfind\b.*\b-exec\b/i.test(
|
|
@@ -1690,6 +1899,16 @@ function hasEnvironmentRead(
|
|
|
1690
1899
|
}
|
|
1691
1900
|
const pipelines = parsed.pipelines.map((commands) => commands.map(unwrapStaticCommand));
|
|
1692
1901
|
const unwrapped = pipelines.flat();
|
|
1902
|
+
if (
|
|
1903
|
+
pipelines.some((pipeline) =>
|
|
1904
|
+
pipeline.some(
|
|
1905
|
+
(argv, index) =>
|
|
1906
|
+
literalSqlArgumentIndex(argv) !== undefined &&
|
|
1907
|
+
pipeline.slice(index + 1).some((consumer) => !isPassiveTextCommand(consumer)),
|
|
1908
|
+
),
|
|
1909
|
+
)
|
|
1910
|
+
)
|
|
1911
|
+
return true;
|
|
1693
1912
|
if (
|
|
1694
1913
|
parsed.redirects.length > 0 &&
|
|
1695
1914
|
unwrapped.some((argv) => isAllowedEnvironmentRead(argv, allowedNames))
|
|
@@ -1836,13 +2055,41 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
1836
2055
|
const readers =
|
|
1837
2056
|
/\b(?:cat|cp|grep|rg|sed|awk|jq|head|tail|less|more|source|ls|sort|uniq|cut|wc|mv|find|shasum|sha1sum|sha256sum|sha512sum|md5sum|cksum)\b/i;
|
|
1838
2057
|
if (parsed && parsed !== "brace-expansion") {
|
|
2058
|
+
let directory = "";
|
|
2059
|
+
const trackDirectory = hasProvedNavigationSequence(command);
|
|
1839
2060
|
return parsed.pipelines.flat().some((words) => {
|
|
1840
2061
|
const argv = unwrapStaticCommand(words);
|
|
1841
2062
|
const inspectedArgv =
|
|
1842
2063
|
argv[0]?.split("/").at(-1) === "env" ? unwrapEnvironmentCommand(argv) : argv;
|
|
1843
2064
|
if (inspectedArgv === null) return false;
|
|
1844
|
-
|
|
1845
|
-
|
|
2065
|
+
let inspected = unwrapStaticCommand(inspectedArgv);
|
|
2066
|
+
let name = inspected[0]?.split("/").at(-1) ?? "";
|
|
2067
|
+
let commandDirectory = directory;
|
|
2068
|
+
const inDirectory = (path: string) =>
|
|
2069
|
+
path.startsWith("/") ? posix.normalize(path) : posix.join(commandDirectory, path);
|
|
2070
|
+
const blockedOperand = (path: string) =>
|
|
2071
|
+
[path, inDirectory(path)].some((value) => isPathBlocked(value) || isSensitivePath(value));
|
|
2072
|
+
const blockedDirectory = (path: string) => blockedOperand(path) || blockedOperand(path + "/");
|
|
2073
|
+
if (name === "cd") {
|
|
2074
|
+
const path = inspected[inspected[1] === "--" ? 2 : 1];
|
|
2075
|
+
if (path === undefined) return false;
|
|
2076
|
+
if (blockedDirectory(path)) return true;
|
|
2077
|
+
if (trackDirectory && isStaticNavigationCommand(inspected)) directory = inDirectory(path);
|
|
2078
|
+
return false;
|
|
2079
|
+
}
|
|
2080
|
+
if (name === "git") {
|
|
2081
|
+
let index = 1;
|
|
2082
|
+
while (inspected[index] === "-C" && inspected[index + 1]) {
|
|
2083
|
+
if (blockedDirectory(inspected[index + 1] ?? "")) return true;
|
|
2084
|
+
commandDirectory = inDirectory(inspected[index + 1] ?? "");
|
|
2085
|
+
index += 2;
|
|
2086
|
+
}
|
|
2087
|
+
// Git grep has the same pattern-versus-file roles as grep below.
|
|
2088
|
+
if (inspected[index] === "grep") {
|
|
2089
|
+
inspected = ["grep", ...inspected.slice(index + 1)];
|
|
2090
|
+
name = "grep";
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
1846
2093
|
if (name === "bun" && inspected[1] === "run" && inspected[2] === "gh-tool") {
|
|
1847
2094
|
return inspected.some((arg, index) => {
|
|
1848
2095
|
const path =
|
|
@@ -1982,10 +2229,10 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
1982
2229
|
}
|
|
1983
2230
|
paths.push(arg);
|
|
1984
2231
|
}
|
|
1985
|
-
return paths.some(
|
|
2232
|
+
return paths.some(blockedOperand);
|
|
1986
2233
|
}
|
|
1987
2234
|
const paths = name === "jq" ? jqFileArguments(inspected) : inspected.slice(1);
|
|
1988
|
-
return paths.some(
|
|
2235
|
+
return paths.some(blockedOperand);
|
|
1989
2236
|
});
|
|
1990
2237
|
}
|
|
1991
2238
|
const operands = parseStaticShellCommands(command, true);
|
|
@@ -2063,7 +2310,13 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
2063
2310
|
normalized += char;
|
|
2064
2311
|
continue;
|
|
2065
2312
|
}
|
|
2066
|
-
|
|
2313
|
+
if (command[i + 1] === "?") {
|
|
2314
|
+
expansions++;
|
|
2315
|
+
normalized += "NUMERICSTATUSVALUE";
|
|
2316
|
+
i++;
|
|
2317
|
+
continue;
|
|
2318
|
+
}
|
|
2319
|
+
const variable = /^\$\{([A-Za-z_][A-Za-z0-9_]*)(:-)?\}|^\$([A-Za-z_][A-Za-z0-9_]*)/.exec(
|
|
2067
2320
|
command.slice(i),
|
|
2068
2321
|
);
|
|
2069
2322
|
if (!variable) {
|
|
@@ -2074,7 +2327,13 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
2074
2327
|
continue;
|
|
2075
2328
|
}
|
|
2076
2329
|
expansions++;
|
|
2077
|
-
const name = variable[1] ?? variable[
|
|
2330
|
+
const name = variable[1] ?? variable[3] ?? "";
|
|
2331
|
+
if (variable[2]) {
|
|
2332
|
+
if (quote !== '"' || !allowedNames.has(name)) invalidExpansion = true;
|
|
2333
|
+
normalized += "ENVTESTVALUE";
|
|
2334
|
+
i += variable[0].length - 1;
|
|
2335
|
+
continue;
|
|
2336
|
+
}
|
|
2078
2337
|
const assignment = assignments.get(name);
|
|
2079
2338
|
if (assignment && assignment.end >= i) invalidExpansion = true;
|
|
2080
2339
|
if (assignment && assignment.end < i) {
|
|
@@ -2099,6 +2358,39 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
2099
2358
|
if (!hasSafeLocalAssignmentCommands(localMaterialized, assignments)) return true;
|
|
2100
2359
|
const parsed = parseStaticShellCommands(localMaterialized);
|
|
2101
2360
|
if (!parsed || typeof parsed === "string") return true;
|
|
2361
|
+
for (const [index, pipeline] of parsed.pipelines.entries()) {
|
|
2362
|
+
if (!pipeline.some((words) => words.some((arg) => arg.includes("NUMERICSTATUSVALUE"))))
|
|
2363
|
+
continue;
|
|
2364
|
+
if (parsed.redirects.some((redirect) => redirect.pipeline === index)) return true;
|
|
2365
|
+
if (
|
|
2366
|
+
pipeline.some((words) => {
|
|
2367
|
+
const argv = unwrapStaticCommand(words);
|
|
2368
|
+
if (!words.some((arg) => arg.includes("NUMERICSTATUSVALUE")))
|
|
2369
|
+
return !isPassiveTextCommand(argv);
|
|
2370
|
+
const name = argv[0]?.split("/").at(-1);
|
|
2371
|
+
const format = name === "printf" ? passivePrintfFormatIndex(argv) : undefined;
|
|
2372
|
+
return (
|
|
2373
|
+
!["echo", "printf"].includes(name ?? "") ||
|
|
2374
|
+
!isPassiveTextCommand(argv) ||
|
|
2375
|
+
words
|
|
2376
|
+
.slice(0, words.length - argv.length)
|
|
2377
|
+
.some((arg) => arg.includes("NUMERICSTATUSVALUE")) ||
|
|
2378
|
+
argv[0]?.includes("NUMERICSTATUSVALUE") === true ||
|
|
2379
|
+
(name === "printf" &&
|
|
2380
|
+
(format === undefined ||
|
|
2381
|
+
argv.slice(0, format + 1).some((arg) => arg.includes("NUMERICSTATUSVALUE"))))
|
|
2382
|
+
);
|
|
2383
|
+
})
|
|
2384
|
+
)
|
|
2385
|
+
return true;
|
|
2386
|
+
}
|
|
2387
|
+
for (const words of parsed.pipelines.flat()) {
|
|
2388
|
+
if (
|
|
2389
|
+
words.some((arg) => arg.includes("ENVTESTVALUE")) &&
|
|
2390
|
+
!isApprovedEnvironmentPredicate(unwrapStaticCommand(words))
|
|
2391
|
+
)
|
|
2392
|
+
return true;
|
|
2393
|
+
}
|
|
2102
2394
|
for (const words of parsed.pipelines.flat()) {
|
|
2103
2395
|
if (!words.some((arg) => arg.includes("UNKNOWNVALUE"))) continue;
|
|
2104
2396
|
if (words.some((arg) => arg.includes("UNKNOWNVALUEUNQUOTED"))) return true;
|
|
@@ -2109,7 +2401,7 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
2109
2401
|
)
|
|
2110
2402
|
return true;
|
|
2111
2403
|
}
|
|
2112
|
-
if (!localMaterialized.includes("ENVVALUE")) {
|
|
2404
|
+
if (!localMaterialized.includes("ENVVALUE") && !localMaterialized.includes("ENVTESTVALUE")) {
|
|
2113
2405
|
return (
|
|
2114
2406
|
hasSensitiveFileRead(localMaterialized, isPathBlocked) ||
|
|
2115
2407
|
hasSensitivePathRedirect(localMaterialized, isPathBlocked) ||
|
|
@@ -2119,7 +2411,7 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
2119
2411
|
|
|
2120
2412
|
for (const [index, pipeline] of parsed.pipelines.entries()) {
|
|
2121
2413
|
const hasEnvironmentValue = pipeline.some((argv) =>
|
|
2122
|
-
argv.some((arg) => arg.includes("ENVVALUE")),
|
|
2414
|
+
argv.some((arg) => arg.includes("ENVVALUE") || arg.includes("ENVTESTVALUE")),
|
|
2123
2415
|
);
|
|
2124
2416
|
if (!hasEnvironmentValue) continue;
|
|
2125
2417
|
if (parsed.redirects.some((redirect) => redirect.pipeline === index)) return true;
|
|
@@ -2127,9 +2419,14 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
2127
2419
|
pipeline.some((argv) => {
|
|
2128
2420
|
const unwrapped = unwrapStaticCommand(argv);
|
|
2129
2421
|
const name = unwrapped[0]?.split("/").at(-1) ?? "";
|
|
2130
|
-
if (argv.some((arg) => arg.includes("ENVVALUE"))) {
|
|
2131
|
-
if (
|
|
2422
|
+
if (argv.some((arg) => arg.includes("ENVVALUE") || arg.includes("ENVTESTVALUE"))) {
|
|
2423
|
+
if (
|
|
2424
|
+
argv
|
|
2425
|
+
.slice(0, argv.length - unwrapped.length)
|
|
2426
|
+
.some((arg) => arg.includes("ENVVALUE") || arg.includes("ENVTESTVALUE"))
|
|
2427
|
+
)
|
|
2132
2428
|
return true;
|
|
2429
|
+
if (isApprovedEnvironmentPredicate(unwrapped)) return false;
|
|
2133
2430
|
if (isNavigationOperand(unwrapped, "ENVVALUE")) {
|
|
2134
2431
|
return unwrapped.some((arg) => arg.includes("ENVVALUEUNQUOTED"));
|
|
2135
2432
|
}
|
|
@@ -2156,6 +2453,24 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
2156
2453
|
return hasSensitiveFileRead(materialized, isPathBlocked);
|
|
2157
2454
|
}
|
|
2158
2455
|
|
|
2456
|
+
/** Quoted approved metadata can be compared or checked for blankness, never evaluated. */
|
|
2457
|
+
function isApprovedEnvironmentPredicate(argv: string[]): boolean {
|
|
2458
|
+
let args = argv.slice(1);
|
|
2459
|
+
if (argv[0] === "[") {
|
|
2460
|
+
if (args.at(-1) !== "]") return false;
|
|
2461
|
+
args = args.slice(0, -1);
|
|
2462
|
+
} else if (argv[0] !== "test") return false;
|
|
2463
|
+
const value = (arg: string | undefined) => arg === "ENVVALUE" || arg === "ENVTESTVALUE";
|
|
2464
|
+
const literal = (arg: string | undefined) =>
|
|
2465
|
+
/^[A-Za-z0-9_.:-]*$/.test(arg ?? "") && arg !== undefined;
|
|
2466
|
+
return (
|
|
2467
|
+
(args.length === 2 && ["-n", "-z"].includes(args[0] ?? "") && value(args[1])) ||
|
|
2468
|
+
(args.length === 3 &&
|
|
2469
|
+
args[1] === "=" &&
|
|
2470
|
+
((value(args[0]) && literal(args[2])) || (literal(args[0]) && value(args[2]))))
|
|
2471
|
+
);
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2159
2474
|
function mentionsEnvironmentSource(command: string): boolean {
|
|
2160
2475
|
return /\b(?:os\s*\.\s*(?:environ|getenv)|process\s*\.\s*env|Environment\s*\.\s*GetEnvironmentVariable)\b/.test(
|
|
2161
2476
|
command,
|