@blogic-cz/agent-tools 1.6.10 → 1.6.12
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 +1 -1
- package/dist/credential-guard/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/credential-guard/index.ts +192 -46
package/README.md
CHANGED
|
@@ -654,7 +654,7 @@ Claude Code uses shell command hooks. The package ships a ready-made wrapper scr
|
|
|
654
654
|
|
|
655
655
|
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).
|
|
656
656
|
|
|
657
|
-
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.
|
|
657
|
+
The guard permits static `printenv` reads and simple `echo`/`printf` expansions only for exact names in `credentialGuard.allowedEnvironmentVariables`. Quoted empty defaults such as `"${NAME:-}"` follow the same display rules, including `printf` data arguments with a literal format; unquoted defaults, nonempty defaults, and other parameter operators stay blocked. 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.
|
|
658
658
|
|
|
659
659
|
Python inline programs and quoted stdin heredocs can also display approved values through `os.getenv("NAME")`, `os.environ.get("NAME", "default")`, or `os.environ["NAME"]`. The complete program must import unaliased `os`, plus `json` for JSON display, followed by `print(...)` statements. `json.dumps(...)` supports dictionaries and lists of approved reads, including comprehensions over a nonempty literal list of approved names. Every dictionary key and environment read must use an approved name. Each projection has at most 16 entries; the program has at most 65,536 characters. Computed or unbounded names, aliases, escaped or interpolated strings, additional statements, and whole-environment output remain unsupported. The same redirection and pipeline restrictions apply.
|
|
660
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;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;AAg4FF,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,CAoWrF;AAQD,kEAAkE;AAClE,eAAO,MAAM,uBAAuB,UA9wGD,SAAS,UAAU,UAAU,KAAK,IA8wGM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YAhxGC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAgxGjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aAlxGE,MAAM,KAAK,OAkxGgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aApxGE,MAAM,KAAK,OAoxGgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YAtxGC,MAAM,KAAK,OAsxG0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YAxxGC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IAwxGf,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YA1xGC,MAAM,KAAK,OA0xGsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YA5xGC,MAAM,KAAK,MAAM,GAAG,IA4xGa,CAAC"}
|
package/package.json
CHANGED
|
@@ -226,11 +226,13 @@ function isSafeHerdrTabCreate(argv: string[]): boolean {
|
|
|
226
226
|
function hasSafeLocalAssignmentCommands(
|
|
227
227
|
command: string,
|
|
228
228
|
assignments: Map<string, { value: string; end: number }>,
|
|
229
|
+
statusCaptures: Set<string>,
|
|
229
230
|
): boolean {
|
|
230
231
|
if (!assignments.size) return true;
|
|
231
232
|
const parsed = parseStaticShellCommands(command);
|
|
232
233
|
if (!parsed || typeof parsed === "string") return false;
|
|
233
234
|
return parsed.pipelines.flat().every((words) => {
|
|
235
|
+
if (words.length === 1 && statusCaptures.has(words[0] ?? "")) return true;
|
|
234
236
|
const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9_./-]+)$/.exec(words[0] ?? "");
|
|
235
237
|
if (assignment) {
|
|
236
238
|
return (
|
|
@@ -1890,6 +1892,37 @@ function isStaticNavigationCommand(argv: string[]): boolean {
|
|
|
1890
1892
|
);
|
|
1891
1893
|
}
|
|
1892
1894
|
|
|
1895
|
+
/** Literal wrappers and exact directory-operation roles; unknown consumers keep refusal. */
|
|
1896
|
+
function isProvedDirectoryCommand(words: string[]): boolean {
|
|
1897
|
+
const argv = unwrapStaticCommand(words);
|
|
1898
|
+
if (
|
|
1899
|
+
!words
|
|
1900
|
+
.slice(0, words.length - argv.length)
|
|
1901
|
+
.every((word) => ["rtk", "proxy", "command", "--"].includes(word))
|
|
1902
|
+
)
|
|
1903
|
+
return false;
|
|
1904
|
+
if (argv[0] === "cd") return isStaticNavigationCommand(argv);
|
|
1905
|
+
if (argv[0] !== "git") return false;
|
|
1906
|
+
let index = 1;
|
|
1907
|
+
while (argv[index] === "-C") {
|
|
1908
|
+
if (!isStaticNavigationCommand(["cd", argv[index + 1] ?? ""])) return false;
|
|
1909
|
+
index += 2;
|
|
1910
|
+
}
|
|
1911
|
+
const args = argv.slice(index);
|
|
1912
|
+
if (args[0] === "status")
|
|
1913
|
+
return args.length === 1 || (args.length === 2 && args[1] === "--short");
|
|
1914
|
+
if (args[0] === "fetch")
|
|
1915
|
+
return (
|
|
1916
|
+
args.length >= 2 &&
|
|
1917
|
+
args.length <= 3 &&
|
|
1918
|
+
args.slice(1).every((arg) => /^[A-Za-z0-9_][A-Za-z0-9_./-]*$/.test(arg))
|
|
1919
|
+
);
|
|
1920
|
+
return (
|
|
1921
|
+
(args.length === 2 && args[0] === "rev-parse" && args[1] === "HEAD") ||
|
|
1922
|
+
(args.length === 3 && args[0] === "rev-list" && args[1] === "--count" && args[2] === "HEAD")
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1893
1926
|
/** Prove a single cd whose uses are gated on success, without simulating shell state. */
|
|
1894
1927
|
function hasProvedNavigationSequence(command: string): boolean {
|
|
1895
1928
|
const literal = literalBindingText(command, "", "");
|
|
@@ -2279,6 +2312,7 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
2279
2312
|
if (parsed && parsed !== "brace-expansion") {
|
|
2280
2313
|
let directory = "";
|
|
2281
2314
|
const trackDirectory = hasProvedNavigationSequence(command);
|
|
2315
|
+
const safeDirectorySequence = parsed.pipelines.flat().every(isProvedDirectoryCommand);
|
|
2282
2316
|
return parsed.pipelines.flat().some((words) => {
|
|
2283
2317
|
const argv = unwrapStaticCommand(words);
|
|
2284
2318
|
const inspectedArgv =
|
|
@@ -2291,19 +2325,36 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
2291
2325
|
path.startsWith("/") ? posix.normalize(path) : posix.join(commandDirectory, path);
|
|
2292
2326
|
const blockedOperand = (path: string) =>
|
|
2293
2327
|
[path, inDirectory(path)].some((value) => isPathBlocked(value) || isSensitivePath(value));
|
|
2294
|
-
|
|
2328
|
+
// Directory slots use path policies, not the broad credential-file name heuristic.
|
|
2329
|
+
const blockedDirectory = (path: string) =>
|
|
2330
|
+
[path, inDirectory(path)].some(
|
|
2331
|
+
(value) => isPathBlocked(value) || isPathBlocked(value + "/"),
|
|
2332
|
+
);
|
|
2295
2333
|
if (name === "cd") {
|
|
2296
2334
|
const path = inspected[inspected[1] === "--" ? 2 : 1];
|
|
2297
2335
|
if (path === undefined) return false;
|
|
2298
2336
|
if (blockedDirectory(path)) return true;
|
|
2337
|
+
// A successful cd alone proves neither its later readers nor pipeline consumers safe.
|
|
2338
|
+
if (
|
|
2339
|
+
(!trackDirectory || !safeDirectorySequence) &&
|
|
2340
|
+
(blockedOperand(path) || blockedOperand(path + "/"))
|
|
2341
|
+
)
|
|
2342
|
+
return true;
|
|
2299
2343
|
if (trackDirectory && isStaticNavigationCommand(inspected)) directory = inDirectory(path);
|
|
2300
2344
|
return false;
|
|
2301
2345
|
}
|
|
2302
2346
|
if (name === "git") {
|
|
2303
2347
|
let index = 1;
|
|
2304
2348
|
while (inspected[index] === "-C" && inspected[index + 1]) {
|
|
2305
|
-
|
|
2306
|
-
|
|
2349
|
+
const path = inspected[index + 1] ?? "";
|
|
2350
|
+
if (blockedDirectory(path)) return true;
|
|
2351
|
+
// Git -C affects this command only; following consumers keep the shell directory.
|
|
2352
|
+
if (
|
|
2353
|
+
!isProvedDirectoryCommand(words) &&
|
|
2354
|
+
(blockedOperand(path) || blockedOperand(path + "/"))
|
|
2355
|
+
)
|
|
2356
|
+
return true;
|
|
2357
|
+
commandDirectory = inDirectory(path);
|
|
2307
2358
|
index += 2;
|
|
2308
2359
|
}
|
|
2309
2360
|
// Git grep has the same pattern-versus-file roles as grep below.
|
|
@@ -2483,6 +2534,7 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
2483
2534
|
command: string,
|
|
2484
2535
|
allowedNames: Set<string>,
|
|
2485
2536
|
isPathBlocked: (path: string) => boolean,
|
|
2537
|
+
onProvedStatus?: (command: string) => void,
|
|
2486
2538
|
): boolean {
|
|
2487
2539
|
let heredoc = boundedHeredoc(command);
|
|
2488
2540
|
while (heredoc) {
|
|
@@ -2494,8 +2546,15 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
2494
2546
|
if (!command.includes("$") && !command.includes("`")) return false;
|
|
2495
2547
|
let invalidExpansion = false;
|
|
2496
2548
|
const assignments = getLeadingLiteralAssignments(command);
|
|
2497
|
-
const localValues = new Map<string, string>();
|
|
2498
2549
|
let localIndex = 0;
|
|
2550
|
+
const directCaptures = new Map<string, string>();
|
|
2551
|
+
const statusReferences = new Map<string, { name: string; fallback: string }>();
|
|
2552
|
+
const marker = (prefix: string): string => {
|
|
2553
|
+
let value: string;
|
|
2554
|
+
do value = `${prefix}${localIndex++}END`;
|
|
2555
|
+
while (command.includes(value));
|
|
2556
|
+
return value;
|
|
2557
|
+
};
|
|
2499
2558
|
let expansions = 0;
|
|
2500
2559
|
let normalized = "";
|
|
2501
2560
|
let quote: "'" | '"' | undefined;
|
|
@@ -2534,7 +2593,18 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
2534
2593
|
}
|
|
2535
2594
|
if (command[i + 1] === "?") {
|
|
2536
2595
|
expansions++;
|
|
2537
|
-
|
|
2596
|
+
const value = marker("NUMERICSTATUSVALUE");
|
|
2597
|
+
// Only an exact standalone assignment token captures in the parent shell.
|
|
2598
|
+
// Quoting/escaping the name can instead produce an executable command word.
|
|
2599
|
+
const capture = /(?:^|[;\n])[ \t]*([A-Za-z_][A-Za-z0-9_]*)=("?)$/.exec(command.slice(0, i));
|
|
2600
|
+
const ending = capture?.[2] === '"' ? '"' : "";
|
|
2601
|
+
if (
|
|
2602
|
+
capture &&
|
|
2603
|
+
command.slice(i + 2).startsWith(ending) &&
|
|
2604
|
+
/^[ \t]*(?:[;\n]|$)/.test(command.slice(i + 2 + ending.length))
|
|
2605
|
+
)
|
|
2606
|
+
directCaptures.set(value, capture[1] ?? "");
|
|
2607
|
+
normalized += value;
|
|
2538
2608
|
i++;
|
|
2539
2609
|
continue;
|
|
2540
2610
|
}
|
|
@@ -2552,32 +2622,107 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
2552
2622
|
const name = variable[1] ?? variable[3] ?? "";
|
|
2553
2623
|
if (variable[2]) {
|
|
2554
2624
|
if (quote !== '"' || !allowedNames.has(name)) invalidExpansion = true;
|
|
2555
|
-
normalized += "
|
|
2625
|
+
normalized += "ENVVALUE";
|
|
2556
2626
|
i += variable[0].length - 1;
|
|
2557
2627
|
continue;
|
|
2558
2628
|
}
|
|
2559
2629
|
const assignment = assignments.get(name);
|
|
2560
2630
|
if (assignment && assignment.end >= i) invalidExpansion = true;
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2631
|
+
const reference = marker("STATUSREFERENCE");
|
|
2632
|
+
const fallback =
|
|
2633
|
+
assignment && assignment.end < i
|
|
2634
|
+
? assignment.value
|
|
2635
|
+
: allowedNames.has(name)
|
|
2636
|
+
? quote
|
|
2637
|
+
? "ENVVALUE"
|
|
2638
|
+
: "ENVVALUEUNQUOTED"
|
|
2639
|
+
: quote
|
|
2640
|
+
? "UNKNOWNVALUE"
|
|
2641
|
+
: "UNKNOWNVALUEUNQUOTED";
|
|
2642
|
+
statusReferences.set(reference, { name, fallback });
|
|
2643
|
+
normalized += reference;
|
|
2570
2644
|
i += variable[0].length - 1;
|
|
2571
2645
|
}
|
|
2572
2646
|
if (!expansions) return invalidExpansion;
|
|
2573
2647
|
if (invalidExpansion) return true;
|
|
2574
2648
|
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
);
|
|
2649
|
+
// Resolve every reference at its command position; a later capture cannot prove
|
|
2650
|
+
// an earlier read, and only proved passive intermediates preserve the binding.
|
|
2651
|
+
const initial = parseStaticShellCommands(normalized);
|
|
2652
|
+
if (!initial || typeof initial === "string") return true;
|
|
2653
|
+
const captures = new Map<string, string>();
|
|
2654
|
+
for (const words of initial.pipelines.flat()) {
|
|
2655
|
+
const capture = /^([A-Za-z_][A-Za-z0-9_]*)=(.+)$/.exec(words[0] ?? "");
|
|
2656
|
+
if (capture && directCaptures.get(capture[2] ?? "") === capture[1])
|
|
2657
|
+
captures.set(words[0] ?? "", capture[1] ?? "");
|
|
2658
|
+
}
|
|
2659
|
+
const statusCaptures = new Set<string>();
|
|
2660
|
+
const localStatuses = new Set<string>();
|
|
2661
|
+
const resolutions = new Map<string, string>();
|
|
2662
|
+
const capturedNames = new Set(captures.values());
|
|
2663
|
+
const bound = new Set<string>();
|
|
2664
|
+
if (captures.size) {
|
|
2665
|
+
const syntax = literalBindingText(normalized, "", "")?.syntax;
|
|
2666
|
+
if (syntax === undefined || /&&|\|\||&/.test(syntax.replace(/[<>]&[0-9-]+/g, ""))) return true;
|
|
2667
|
+
}
|
|
2668
|
+
for (const [index, pipeline] of initial.pipelines.entries()) {
|
|
2669
|
+
for (const words of pipeline) {
|
|
2670
|
+
for (const arg of words) {
|
|
2671
|
+
for (const [reference, { name, fallback }] of statusReferences) {
|
|
2672
|
+
if (!arg.includes(reference)) continue;
|
|
2673
|
+
if (bound.has(name)) {
|
|
2674
|
+
const value = marker("NUMERICSTATUSVALUE");
|
|
2675
|
+
localStatuses.add(value);
|
|
2676
|
+
resolutions.set(reference, value);
|
|
2677
|
+
} else {
|
|
2678
|
+
if (capturedNames.has(name)) return true;
|
|
2679
|
+
resolutions.set(reference, fallback);
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
const capture = captures.get(words[0] ?? "");
|
|
2684
|
+
if (capture) {
|
|
2685
|
+
if (
|
|
2686
|
+
pipeline.length !== 1 ||
|
|
2687
|
+
words.length !== 1 ||
|
|
2688
|
+
initial.redirects.some((redirect) => redirect.pipeline === index) ||
|
|
2689
|
+
// Inherited export attributes can make a numeric value select code or paths.
|
|
2690
|
+
/^(?:LD_|DYLD_|_RLD_|LDR_|GIT_CONFIG_(?:KEY|VALUE)_)/.test(capture) ||
|
|
2691
|
+
/^(?:PATH|IFS|CDPATH|BASH_ENV|ENV|FPATH|ZDOTDIR|SHELLOPTS|BASHOPTS|PROMPT_COMMAND|PS[0-4]|GLOBIGNORE|RANDOM|SECONDS|PIPESTATUS|UID|EUID|BASH_XTRACEFD|HOME|XDG_CONFIG_HOME|LIBPATH|SHLIB_PATH|NODE_OPTIONS|NODE_PATH|BUN_OPTIONS|BUN_PRELOAD|PYTHONPATH|PYTHONSTARTUP|PYTHONHOME|RUBYOPT|RUBYLIB|GEM_HOME|GEM_PATH|PERL5OPT|PERL5LIB|PERLLIB|JAVA_TOOL_OPTIONS|JDK_JAVA_OPTIONS|CLASSPATH|_JAVA_OPTIONS|JAVA_HOME|GIT_SSH_COMMAND|GIT_SSH|GIT_PROXY_COMMAND|GIT_EXEC_PATH|GIT_CONFIG|GIT_CONFIG_GLOBAL|GIT_CONFIG_SYSTEM|GIT_CONFIG_PARAMETERS|GIT_CONFIG_COUNT|GIT_ASKPASS|SSH_ASKPASS|GIT_EDITOR|GIT_SEQUENCE_EDITOR|GIT_PAGER|PAGER|EDITOR|VISUAL)$/.test(
|
|
2692
|
+
capture,
|
|
2693
|
+
)
|
|
2694
|
+
)
|
|
2695
|
+
return true;
|
|
2696
|
+
bound.add(capture);
|
|
2697
|
+
statusCaptures.add(words[0] ?? "");
|
|
2698
|
+
continue;
|
|
2699
|
+
}
|
|
2700
|
+
const argv = unwrapStaticCommand(words);
|
|
2701
|
+
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
2702
|
+
if (
|
|
2703
|
+
captures.size &&
|
|
2704
|
+
/^(?:alias|unalias|readonly|declare|typeset|local|export|eval|source|\.|trap|set|read|unset|let)$/.test(
|
|
2705
|
+
name,
|
|
2706
|
+
)
|
|
2707
|
+
)
|
|
2708
|
+
return true;
|
|
2709
|
+
if (!isPassiveTextCommand(argv) && name !== "cat") bound.clear();
|
|
2710
|
+
}
|
|
2711
|
+
for (const redirect of initial.redirects.filter((value) => value.pipeline === index)) {
|
|
2712
|
+
if (
|
|
2713
|
+
[...statusReferences].some(
|
|
2714
|
+
([reference, { name }]) => capturedNames.has(name) && redirect.target.includes(reference),
|
|
2715
|
+
)
|
|
2716
|
+
)
|
|
2717
|
+
return true;
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
for (const [reference, { fallback }] of statusReferences) {
|
|
2721
|
+
normalized = normalized.replaceAll(reference, () => resolutions.get(reference) ?? fallback);
|
|
2722
|
+
}
|
|
2723
|
+
const localMaterialized = normalized;
|
|
2579
2724
|
// Options must be checked after local literals become actual argv, never as placeholders.
|
|
2580
|
-
if (!hasSafeLocalAssignmentCommands(localMaterialized, assignments)) return true;
|
|
2725
|
+
if (!hasSafeLocalAssignmentCommands(localMaterialized, assignments, statusCaptures)) return true;
|
|
2581
2726
|
const parsed = parseStaticShellCommands(localMaterialized);
|
|
2582
2727
|
if (!parsed || typeof parsed === "string") return true;
|
|
2583
2728
|
for (const [index, pipeline] of parsed.pipelines.entries()) {
|
|
@@ -2586,6 +2731,7 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
2586
2731
|
if (parsed.redirects.some((redirect) => redirect.pipeline === index)) return true;
|
|
2587
2732
|
if (
|
|
2588
2733
|
pipeline.some((words) => {
|
|
2734
|
+
if (words.length === 1 && statusCaptures.has(words[0] ?? "")) return false;
|
|
2589
2735
|
const argv = unwrapStaticCommand(words);
|
|
2590
2736
|
if (!words.some((arg) => arg.includes("NUMERICSTATUSVALUE")))
|
|
2591
2737
|
return !isPassiveTextCommand(argv);
|
|
@@ -2594,6 +2740,9 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
2594
2740
|
return (
|
|
2595
2741
|
!["echo", "printf"].includes(name ?? "") ||
|
|
2596
2742
|
!isPassiveTextCommand(argv) ||
|
|
2743
|
+
argv.some(
|
|
2744
|
+
(arg) => arg.startsWith("-") && [...localStatuses].some((value) => arg.includes(value)),
|
|
2745
|
+
) ||
|
|
2597
2746
|
words
|
|
2598
2747
|
.slice(0, words.length - argv.length)
|
|
2599
2748
|
.some((arg) => arg.includes("NUMERICSTATUSVALUE")) ||
|
|
@@ -2606,13 +2755,6 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
2606
2755
|
)
|
|
2607
2756
|
return true;
|
|
2608
2757
|
}
|
|
2609
|
-
for (const words of parsed.pipelines.flat()) {
|
|
2610
|
-
if (
|
|
2611
|
-
words.some((arg) => arg.includes("ENVTESTVALUE")) &&
|
|
2612
|
-
!isApprovedEnvironmentPredicate(unwrapStaticCommand(words))
|
|
2613
|
-
)
|
|
2614
|
-
return true;
|
|
2615
|
-
}
|
|
2616
2758
|
for (const words of parsed.pipelines.flat()) {
|
|
2617
2759
|
if (!words.some((arg) => arg.includes("UNKNOWNVALUE"))) continue;
|
|
2618
2760
|
if (words.some((arg) => arg.includes("UNKNOWNVALUEUNQUOTED"))) return true;
|
|
@@ -2623,17 +2765,18 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
2623
2765
|
)
|
|
2624
2766
|
return true;
|
|
2625
2767
|
}
|
|
2626
|
-
if (!localMaterialized.includes("ENVVALUE")
|
|
2627
|
-
|
|
2768
|
+
if (!localMaterialized.includes("ENVVALUE")) {
|
|
2769
|
+
const blocked =
|
|
2628
2770
|
hasSensitiveFileRead(localMaterialized, isPathBlocked) ||
|
|
2629
2771
|
hasSensitivePathRedirect(localMaterialized, isPathBlocked) ||
|
|
2630
|
-
hasEnvironmentRead(localMaterialized, allowedNames)
|
|
2631
|
-
);
|
|
2772
|
+
hasEnvironmentRead(localMaterialized, allowedNames);
|
|
2773
|
+
if (!blocked && statusCaptures.size) onProvedStatus?.(localMaterialized);
|
|
2774
|
+
return blocked;
|
|
2632
2775
|
}
|
|
2633
2776
|
|
|
2634
2777
|
for (const [index, pipeline] of parsed.pipelines.entries()) {
|
|
2635
2778
|
const hasEnvironmentValue = pipeline.some((argv) =>
|
|
2636
|
-
argv.some((arg) => arg.includes("ENVVALUE")
|
|
2779
|
+
argv.some((arg) => arg.includes("ENVVALUE")),
|
|
2637
2780
|
);
|
|
2638
2781
|
if (!hasEnvironmentValue) continue;
|
|
2639
2782
|
if (parsed.redirects.some((redirect) => redirect.pipeline === index)) return true;
|
|
@@ -2641,12 +2784,8 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
2641
2784
|
pipeline.some((argv) => {
|
|
2642
2785
|
const unwrapped = unwrapStaticCommand(argv);
|
|
2643
2786
|
const name = unwrapped[0]?.split("/").at(-1) ?? "";
|
|
2644
|
-
if (argv.some((arg) => arg.includes("ENVVALUE")
|
|
2645
|
-
if (
|
|
2646
|
-
argv
|
|
2647
|
-
.slice(0, argv.length - unwrapped.length)
|
|
2648
|
-
.some((arg) => arg.includes("ENVVALUE") || arg.includes("ENVTESTVALUE"))
|
|
2649
|
-
)
|
|
2787
|
+
if (argv.some((arg) => arg.includes("ENVVALUE"))) {
|
|
2788
|
+
if (argv.slice(0, argv.length - unwrapped.length).some((arg) => arg.includes("ENVVALUE")))
|
|
2650
2789
|
return true;
|
|
2651
2790
|
if (isApprovedEnvironmentPredicate(unwrapped)) return false;
|
|
2652
2791
|
if (isNavigationOperand(unwrapped, "ENVVALUE")) {
|
|
@@ -2668,11 +2807,10 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
2668
2807
|
return true;
|
|
2669
2808
|
}
|
|
2670
2809
|
}
|
|
2671
|
-
const materialized =
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
return hasSensitiveFileRead(materialized, isPathBlocked);
|
|
2810
|
+
const materialized = normalized.replaceAll("ENVVALUE", "SAFEENVVALUE");
|
|
2811
|
+
const blocked = hasSensitiveFileRead(materialized, isPathBlocked);
|
|
2812
|
+
if (!blocked && statusCaptures.size) onProvedStatus?.(materialized);
|
|
2813
|
+
return blocked;
|
|
2676
2814
|
}
|
|
2677
2815
|
|
|
2678
2816
|
/** Quoted approved metadata can be compared or checked for blankness, never evaluated. */
|
|
@@ -2682,7 +2820,7 @@ function isApprovedEnvironmentPredicate(argv: string[]): boolean {
|
|
|
2682
2820
|
if (args.at(-1) !== "]") return false;
|
|
2683
2821
|
args = args.slice(0, -1);
|
|
2684
2822
|
} else if (argv[0] !== "test") return false;
|
|
2685
|
-
const value = (arg: string | undefined) => arg === "ENVVALUE"
|
|
2823
|
+
const value = (arg: string | undefined) => arg === "ENVVALUE";
|
|
2686
2824
|
const literal = (arg: string | undefined) =>
|
|
2687
2825
|
/^[A-Za-z0-9_.:-]*$/.test(arg ?? "") && arg !== undefined;
|
|
2688
2826
|
return (
|
|
@@ -3010,16 +3148,24 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
3010
3148
|
hasSensitivePathRedirect(command, isPathBlocked)
|
|
3011
3149
|
)
|
|
3012
3150
|
return "blocked by the file-access policy; a sensitive path matched or this command form could not be verified";
|
|
3151
|
+
let environmentCommand = command;
|
|
3013
3152
|
if (
|
|
3014
3153
|
!isLiteralTextWrite(command) &&
|
|
3015
|
-
hasEnvironmentVariableExpansionRead(
|
|
3154
|
+
hasEnvironmentVariableExpansionRead(
|
|
3155
|
+
command,
|
|
3156
|
+
allowedEnvironmentVariables,
|
|
3157
|
+
isPathBlocked,
|
|
3158
|
+
(proved) => {
|
|
3159
|
+
environmentCommand = proved;
|
|
3160
|
+
},
|
|
3161
|
+
)
|
|
3016
3162
|
) {
|
|
3017
3163
|
return "expands an unapproved or executable environment variable value";
|
|
3018
3164
|
}
|
|
3019
3165
|
if (
|
|
3020
3166
|
hasEnvironmentSourceAccess(command, rawEnvironmentSource, allowedEnvironmentVariables) ||
|
|
3021
3167
|
hasEnvironmentRead(
|
|
3022
|
-
|
|
3168
|
+
environmentCommand,
|
|
3023
3169
|
allowedEnvironmentVariables,
|
|
3024
3170
|
allowPackageInventory,
|
|
3025
3171
|
shellBodies.filter((body) => {
|