@blogic-cz/agent-tools 1.5.0 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -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;AAM5E,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;AAM5E,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;AA8lBF,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,CAuNrF;AAQD,kEAAkE;AAClE,eAAO,MAAM,uBAAuB,UA/1BD,SAAS,UAAU,UAAU,KAAK,IA+1BM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YAj2BC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAi2BjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aAn2BE,MAAM,KAAK,OAm2BgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aAr2BE,MAAM,KAAK,OAq2BgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YAv2BC,MAAM,KAAK,OAu2B0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YAz2BC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IAy2Bf,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YA32BC,MAAM,KAAK,OA22BsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YA72BC,MAAM,KAAK,MAAM,GAAG,IA62Ba,CAAC"}
|
package/package.json
CHANGED
|
@@ -139,19 +139,6 @@ const SECRET_PATTERNS = [
|
|
|
139
139
|
},
|
|
140
140
|
];
|
|
141
141
|
|
|
142
|
-
/**
|
|
143
|
-
* Dangerous bash patterns that might expose secrets.
|
|
144
|
-
*/
|
|
145
|
-
const DEFAULT_DANGEROUS_BASH_PATTERNS: RegExp[] = [
|
|
146
|
-
/\bcat\s+\S*\.env/i,
|
|
147
|
-
/\bcat\s+\S*\.pem/i,
|
|
148
|
-
/\bcat\s+\S*\.key/i,
|
|
149
|
-
/\bcat\s+\S*secret/i,
|
|
150
|
-
/\bcat\s+\S*credential/i,
|
|
151
|
-
/\bcat\s+\S*\/\.ssh\//i,
|
|
152
|
-
/\bcat\s+\S*\/\.aws\//i,
|
|
153
|
-
];
|
|
154
|
-
|
|
155
142
|
/**
|
|
156
143
|
* CLI tools that must use wrapper tools for security and audit.
|
|
157
144
|
*/
|
|
@@ -312,13 +299,141 @@ function parseStaticShellCommands(
|
|
|
312
299
|
}
|
|
313
300
|
|
|
314
301
|
function mentionsEnvironmentRead(text: string): boolean {
|
|
315
|
-
|
|
302
|
+
// `--env dev` is a flag of another command, not the env command.
|
|
303
|
+
return /(?<![\w.-])printenv\b(?!-)|(?<![\w.-])-\w*Oprintenv\b|(?<![\w.-])\benv\b(?!-)/i.test(
|
|
304
|
+
text.replace(/['"\\]/g, ""),
|
|
305
|
+
);
|
|
316
306
|
}
|
|
317
307
|
|
|
318
308
|
function hasBraceExpansion(text: string): boolean {
|
|
319
309
|
return /\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(text);
|
|
320
310
|
}
|
|
321
311
|
|
|
312
|
+
function hasNonExecutingAwkProgram(text: string): boolean {
|
|
313
|
+
const match = /\bawk\s+(['"])([\s\S]*?)\1/.exec(text);
|
|
314
|
+
if (!match) return false;
|
|
315
|
+
const program = match[2] ?? "";
|
|
316
|
+
return !/\b(?:system|getline)\s*\(|\||\b(?:print|printf)\b[^\n]*>/.test(program);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function hasShellBraceExpansion(text: string): boolean {
|
|
320
|
+
if (!hasBraceExpansion(text)) return false;
|
|
321
|
+
if (!hasNonExecutingAwkProgram(text)) return true;
|
|
322
|
+
return hasBraceExpansion(text.replace(/(\bawk\s+(['"]))[\s\S]*?\2/, "awk "));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The command without quoted regex quantifiers such as `{0,80}`. Many commands re-parse quoted
|
|
327
|
+
* text as shell code, so every other quoted brace stays. When both sides are digits, the expansion
|
|
328
|
+
* cannot spell a command name. An empty side is not safe: `e{,}nv` and `e{0,}nv` expand to `env`.
|
|
329
|
+
* An unclosed quote keeps the raw command.
|
|
330
|
+
*/
|
|
331
|
+
function withoutQuotedQuantifiers(command: string): string {
|
|
332
|
+
let text = "";
|
|
333
|
+
let quote: "'" | '"' | "$'" | undefined;
|
|
334
|
+
for (let i = 0; i < command.length; i++) {
|
|
335
|
+
const char = command.charAt(i);
|
|
336
|
+
const quantifier = quote ? /^\{\d+,\d+\}/.exec(command.slice(i))?.[0] : undefined;
|
|
337
|
+
if (quantifier) {
|
|
338
|
+
i += quantifier.length - 1;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
text += char;
|
|
342
|
+
if (quote === "'") {
|
|
343
|
+
if (char === "'") quote = undefined;
|
|
344
|
+
} else if (char === "\\") {
|
|
345
|
+
text += command[++i] ?? "";
|
|
346
|
+
} else if (quote) {
|
|
347
|
+
if (char === quote.at(-1)) quote = undefined;
|
|
348
|
+
} else if (char === "$" && command[i + 1] === "'") {
|
|
349
|
+
quote = "$'";
|
|
350
|
+
text += command[++i];
|
|
351
|
+
} else if (char === "'" || char === '"') {
|
|
352
|
+
quote = char;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return quote ? command : text;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Braces in static words, except `{m,n}` with digits on both sides (see above). */
|
|
359
|
+
function hasArgumentBraceExpansion(text: string): boolean {
|
|
360
|
+
return hasShellBraceExpansion(text.replace(/\{\d+,\d+\}/g, ""));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function hasCommandArgumentBraceExpansion(argv: string[]): boolean {
|
|
364
|
+
return (argv[0]?.split("/").at(-1) === "awk" && !isAwkExecution(argv)) ||
|
|
365
|
+
isJqObjectConstruction(argv)
|
|
366
|
+
? false
|
|
367
|
+
: hasArgumentBraceExpansion(argv.join(" "));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** echo and printf print their arguments; grep -o, rg and git grep print matched pattern text. */
|
|
371
|
+
function isLiteralTextProducer(argv: string[]): boolean {
|
|
372
|
+
return isPassiveTextCommand(argv) && argv[0]?.split("/").at(-1) !== "head";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function isAwkExecution(argv: string[]): boolean {
|
|
376
|
+
if (argv[0]?.split("/").at(-1) !== "awk") return false;
|
|
377
|
+
const program = argv.slice(1).join(" ");
|
|
378
|
+
return /\b(?:system|getline)\s*\(|\||\b(?:print|printf)\b[^\n]*>/.test(program);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function isJqObjectConstruction(argv: string[]): boolean {
|
|
382
|
+
return (
|
|
383
|
+
argv[0]?.split("/").at(-1) === "jq" &&
|
|
384
|
+
argv
|
|
385
|
+
.slice(1)
|
|
386
|
+
.some((arg) => /^\{[A-Za-z_][A-Za-z0-9_-]*(?:,\s*[A-Za-z_][A-Za-z0-9_-]*)*\}$/.test(arg)) &&
|
|
387
|
+
!argv.slice(1).some((arg) => /\benv\b|\binput(?:s)?\b/.test(arg))
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Index in `args` where the command wrapped by `env` starts, or null when `env` only lists. */
|
|
392
|
+
function environmentCommandStart(args: string[]): number | null {
|
|
393
|
+
for (let i = 0; i < args.length; i++) {
|
|
394
|
+
const arg = args[i] ?? "";
|
|
395
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) continue;
|
|
396
|
+
if (arg === "--") return i + 1 === args.length ? null : i + 1;
|
|
397
|
+
if (arg === "-u" || arg === "--unset" || arg === "-C" || arg === "--chdir") {
|
|
398
|
+
i++;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (arg === "-i" || arg === "--ignore-environment" || arg.startsWith("--chdir=")) continue;
|
|
402
|
+
if (arg.startsWith("-")) continue;
|
|
403
|
+
return i;
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function isEnvironmentListing(argv: string[]): boolean {
|
|
409
|
+
return environmentCommandStart(argv.slice(1)) === null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function unwrapEnvironmentCommand(argv: string[]): string[] | null {
|
|
413
|
+
if (argv[0]?.split("/").at(-1) !== "env") return null;
|
|
414
|
+
const args = argv.slice(1);
|
|
415
|
+
const start = environmentCommandStart(args);
|
|
416
|
+
return start === null ? null : args.slice(start);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function isSafeLiteralInspection(argv: string[]): boolean {
|
|
420
|
+
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
421
|
+
return (
|
|
422
|
+
["head", "tail"].includes(name) &&
|
|
423
|
+
!argv.slice(1).some((arg) => /\.env(?:\.|$)|\.(?:pem|key)\b|secret|credential/i.test(arg))
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function isSafeLiteralConsumer(argv: string[]): boolean {
|
|
428
|
+
return (
|
|
429
|
+
isSafeLiteralInspection(argv) ||
|
|
430
|
+
(argv[0]?.split("/").at(-1) === "bun" &&
|
|
431
|
+
argv[1] === "run" &&
|
|
432
|
+
argv[2] === "gh-tool" &&
|
|
433
|
+
argv.includes("--body-file"))
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
322
437
|
function unwrapStaticCommand(words: string[]): string[] {
|
|
323
438
|
const argv = [...words];
|
|
324
439
|
const executable = () => argv[0]?.split("/").at(-1);
|
|
@@ -357,6 +472,8 @@ function isPassiveTextCommand(argv: string[]): boolean {
|
|
|
357
472
|
return true;
|
|
358
473
|
}
|
|
359
474
|
if (name === "rg") return !hasExecutionOption(args, ["pre", "hostname-bin"]);
|
|
475
|
+
if (name === "awk") return !isAwkExecution(argv);
|
|
476
|
+
if (name === "jq") return isJqObjectConstruction(argv);
|
|
360
477
|
return (
|
|
361
478
|
name === "git" &&
|
|
362
479
|
args[0] === "grep" &&
|
|
@@ -367,45 +484,81 @@ function isPassiveTextCommand(argv: string[]): boolean {
|
|
|
367
484
|
function hasStaticEnvironmentRead(argv: string[], allowedNames: Set<string>): boolean {
|
|
368
485
|
const name = argv[0]?.split("/").at(-1);
|
|
369
486
|
if (name === "printenv") return !isAllowedEnvironmentRead(argv, allowedNames);
|
|
370
|
-
if (name === "env")
|
|
487
|
+
if (name === "env") {
|
|
488
|
+
const wrapped = unwrapEnvironmentCommand(argv);
|
|
489
|
+
return wrapped === null
|
|
490
|
+
? isEnvironmentListing(argv)
|
|
491
|
+
: hasStaticEnvironmentRead(unwrapStaticCommand(wrapped), allowedNames);
|
|
492
|
+
}
|
|
371
493
|
if (isPassiveTextCommand(argv)) return false;
|
|
494
|
+
// Any command may re-parse an argument as shell code (trap, find -exec, awk system()).
|
|
495
|
+
return (
|
|
496
|
+
hasArgumentBraceExpansion(argv.slice(1).join(" ")) || mentionsEnvironmentRead(argv.join(" "))
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function isStaticHerdrPrompt(command: string): boolean {
|
|
501
|
+
if (/^herdr\s+agent\s+prompt\s+\S+\s+(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*')$/.test(command.trim())) {
|
|
502
|
+
if (!/[$`]/.test(command)) return true;
|
|
503
|
+
return !hasSensitiveFileRead(command) && !mentionsEnvironmentRead(command);
|
|
504
|
+
}
|
|
505
|
+
const parsed = parseStaticShellCommands(command);
|
|
372
506
|
if (
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
) &&
|
|
378
|
-
hasBraceExpansion(argv.slice(1).join(" "))
|
|
507
|
+
!parsed ||
|
|
508
|
+
typeof parsed === "string" ||
|
|
509
|
+
parsed.pipelines.length !== 1 ||
|
|
510
|
+
parsed.pipelines[0]?.length !== 1
|
|
379
511
|
) {
|
|
380
|
-
return
|
|
512
|
+
return false;
|
|
381
513
|
}
|
|
382
|
-
|
|
514
|
+
const argv = unwrapStaticCommand(parsed.pipelines[0]?.[0] ?? []);
|
|
515
|
+
return argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt";
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function isHerdrPrompt(command: string): boolean {
|
|
519
|
+
return /^herdr\s+agent\s+prompt\b/.test(command.trim());
|
|
383
520
|
}
|
|
384
521
|
|
|
385
522
|
function hasEnvironmentRead(command: string, allowedNames: Set<string>): boolean {
|
|
523
|
+
if (isStaticHerdrPrompt(command)) return false;
|
|
524
|
+
if (isHerdrPrompt(command)) return true;
|
|
386
525
|
if (isLiteralTextWrite(command)) return false;
|
|
387
526
|
// ponytail: complex shell syntax stays conservative; use a shell AST if more exceptions are needed.
|
|
388
527
|
const parsed = parseStaticShellCommands(command);
|
|
389
528
|
if (parsed === "brace-expansion") return true;
|
|
390
|
-
if (!parsed)
|
|
529
|
+
if (!parsed) {
|
|
530
|
+
const unquoted = command.replace(/'(?:\\.|[^'])*'/g, " ").replace(/"(?:\\.|[^"$`])*"/g, " ");
|
|
531
|
+
const hasExecutor =
|
|
532
|
+
/\|\s*(?:sh|bash|zsh|xargs)|\b(?:sh|bash|zsh)\b|\$\(|`|\b(?:eval|source)\b|\bfind\b.*\b-exec\b/i.test(
|
|
533
|
+
command,
|
|
534
|
+
);
|
|
535
|
+
return (
|
|
536
|
+
mentionsEnvironmentRead(unquoted) ||
|
|
537
|
+
(mentionsEnvironmentRead(command) && hasExecutor) ||
|
|
538
|
+
hasShellBraceExpansion(withoutQuotedQuantifiers(command))
|
|
539
|
+
);
|
|
540
|
+
}
|
|
391
541
|
const pipelines = parsed.pipelines.map((commands) => commands.map(unwrapStaticCommand));
|
|
392
542
|
const unwrapped = pipelines.flat();
|
|
393
|
-
if (unwrapped.some((argv) => hasStaticEnvironmentRead(argv, allowedNames)))
|
|
543
|
+
if (unwrapped.some((argv) => hasStaticEnvironmentRead(argv, allowedNames))) {
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
394
546
|
|
|
395
547
|
// A literal producer can feed executable text to a shell, xargs, or an unknown consumer.
|
|
396
|
-
|
|
548
|
+
const result = pipelines.some(
|
|
397
549
|
(pipeline) =>
|
|
398
550
|
pipeline.length > 1 &&
|
|
399
551
|
pipeline.some(
|
|
400
552
|
(argv) =>
|
|
401
553
|
isAllowedEnvironmentRead(argv, allowedNames) ||
|
|
402
|
-
(
|
|
403
|
-
(mentionsEnvironmentRead(argv.join(" ")) ||
|
|
554
|
+
(isLiteralTextProducer(argv) &&
|
|
555
|
+
(mentionsEnvironmentRead(argv.join(" ")) || hasCommandArgumentBraceExpansion(argv))),
|
|
404
556
|
) &&
|
|
405
557
|
pipeline.some(
|
|
406
558
|
(argv) => !isPassiveTextCommand(argv) && !isAllowedEnvironmentRead(argv, allowedNames),
|
|
407
559
|
),
|
|
408
560
|
);
|
|
561
|
+
return result;
|
|
409
562
|
}
|
|
410
563
|
|
|
411
564
|
/** A single literal writer cannot execute its body or feed another command. */
|
|
@@ -430,12 +583,73 @@ function isLiteralTextWrite(command: string): boolean {
|
|
|
430
583
|
}
|
|
431
584
|
|
|
432
585
|
// Output redirection does not execute text. Other unsupported syntax still fails parsing.
|
|
433
|
-
const parsed = parseStaticShellCommands(header.replace(
|
|
434
|
-
if (!parsed || parsed === "brace-expansion"
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
586
|
+
const parsed = parseStaticShellCommands(header.replace(/2>&1/g, " ").replace(/>>?/g, " "));
|
|
587
|
+
if (!parsed || parsed === "brace-expansion") {
|
|
588
|
+
const balancedQuotes =
|
|
589
|
+
(command.match(/'/g)?.length ?? 0) % 2 === 0 && (command.match(/"/g)?.length ?? 0) % 2 === 0;
|
|
590
|
+
return (
|
|
591
|
+
balancedQuotes &&
|
|
592
|
+
/^(?:cd\s+\S+\s+&&\s+)?(?:echo|printf)\b[\s\S]*>>?\s+\S+\s*(?:&&\s+tail\b.*)?$/i.test(command)
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
|
|
596
|
+
const writerIndexes = commands
|
|
597
|
+
.map((argv, index) => (writers.includes(argv[0]?.split("/").at(-1) ?? "") ? index : -1))
|
|
598
|
+
.filter((index) => index >= 0);
|
|
599
|
+
return (
|
|
600
|
+
writerIndexes.length === 1 &&
|
|
601
|
+
commands.every((argv, index) => {
|
|
602
|
+
if (index === writerIndexes[0]) return true;
|
|
603
|
+
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
604
|
+
return name === "cd" || isSafeLiteralConsumer(argv);
|
|
605
|
+
})
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function hasSensitiveFileRead(command: string): boolean {
|
|
610
|
+
const sensitivePath =
|
|
611
|
+
/(?:^|[/\s'"])(?:[.]env(?\b)|\S*[.](?:pem|key)\b|\S*[.](?:ssh|aws)[/]|(?:\S*secret(?:-|[/\s.]|$))|(?:(?:secrets?|credentials?)(?:[/\s.]|$)|[A-Za-z0-9_.-]*(?:secrets?|credentials?)(?:[/\s.]|$)|[A-Za-z0-9_.-]*-(?:secrets?|credentials?)(?:[/\s.-]|$)))/i;
|
|
612
|
+
const sensitiveCamelCase =
|
|
613
|
+
/(?:^|[/\s'"])[A-Za-z0-9_.-]*(?:(?:secret|credential)[A-Z]|(?:Secret|Credential)[A-Z])[^/\s'"]*/;
|
|
614
|
+
const sensitiveName = /secret|credential/i;
|
|
615
|
+
const isSensitivePath = (text: string) =>
|
|
616
|
+
sensitivePath.test(text) || sensitiveCamelCase.test(text) || sensitiveName.test(text);
|
|
617
|
+
if (/(?:^|[\s;&|])--env-file(?:=|\s)[^;&|]*\.env\b/i.test(command)) return true;
|
|
618
|
+
if (/\bcat\b[^;&|]*<<-?\s*\S+[\s\S]*\b(?:secret|credential)\b/i.test(command)) return true;
|
|
619
|
+
const parsed = parseStaticShellCommands(command);
|
|
620
|
+
const readers = /\b(?:cat|cp|grep|rg|sed|awk|head|tail|less|more|source|ls)\b/i;
|
|
621
|
+
if (parsed && parsed !== "brace-expansion") {
|
|
622
|
+
return parsed.pipelines.flat().some((words) => {
|
|
623
|
+
const argv = unwrapStaticCommand(words);
|
|
624
|
+
const inspectedArgv =
|
|
625
|
+
argv[0]?.split("/").at(-1) === "env" ? unwrapEnvironmentCommand(argv) : argv;
|
|
626
|
+
if (inspectedArgv === null) return false;
|
|
627
|
+
const inspected = unwrapStaticCommand(inspectedArgv);
|
|
628
|
+
const name = inspected[0]?.split("/").at(-1) ?? "";
|
|
629
|
+
return readers.test(name) && inspected.slice(1).some(isSensitivePath);
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
const unquoted = command.replace(/'(?:\\.|[^'])*'/g, " ").replace(/"(?:\\.|[^"$`])*"/g, " ");
|
|
633
|
+
if (/\b(?:bun|node|python3?|ruby)\b/i.test(command) && isSensitivePath(command)) {
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
return (
|
|
637
|
+
new RegExp(readers.source + "[^;&|]*" + sensitivePath.source, "i").test(unquoted) ||
|
|
638
|
+
new RegExp(readers.source + "[^;&|]*" + sensitiveCamelCase.source).test(unquoted) ||
|
|
639
|
+
new RegExp(readers.source + "[^;&|]*" + sensitiveName.source, "i").test(unquoted)
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function hasEnvironmentVariableExpansionRead(command: string): boolean {
|
|
644
|
+
if (!/\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/.test(command)) return false;
|
|
645
|
+
return /\b(?:echo|printf)\b[^;&|]*\$|\b(?:sh|bash|zsh)\b[^;&|]*\s-c\b/i.test(command);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function hasEnvironmentSourceAccess(command: string): boolean {
|
|
649
|
+
return (
|
|
650
|
+
/\b(?:os\.environ|process\.env|Environment\.GetEnvironmentVariable)\b/.test(command) &&
|
|
651
|
+
/\b(?:python3?|node|bun)\b/.test(command)
|
|
652
|
+
);
|
|
439
653
|
}
|
|
440
654
|
|
|
441
655
|
/** Extract file path from hook arguments. */
|
|
@@ -488,8 +702,7 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
488
702
|
];
|
|
489
703
|
|
|
490
704
|
const dangerousBashPatterns = [
|
|
491
|
-
...
|
|
492
|
-
...(config?.additionalDangerousBashPatterns ?? []).map((p) => new RegExp(p)),
|
|
705
|
+
...(config?.additionalDangerousBashPatterns?.map((p) => new RegExp(p)) ?? []),
|
|
493
706
|
];
|
|
494
707
|
|
|
495
708
|
const blockedCliTools: BlockedCliTool[] = [
|
|
@@ -540,6 +753,9 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
540
753
|
function isDangerousBashCommand(command: string): boolean {
|
|
541
754
|
return (
|
|
542
755
|
dangerousBashPatterns.some((pattern) => pattern.test(command)) ||
|
|
756
|
+
hasSensitiveFileRead(command) ||
|
|
757
|
+
hasEnvironmentVariableExpansionRead(command) ||
|
|
758
|
+
hasEnvironmentSourceAccess(command) ||
|
|
543
759
|
hasEnvironmentRead(command, allowedEnvironmentVariables)
|
|
544
760
|
);
|
|
545
761
|
}
|
|
@@ -593,7 +809,7 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
593
809
|
|
|
594
810
|
function handleToolExecuteBefore(input: HookInput, output: HookOutput): void {
|
|
595
811
|
// Normalize tool name across platforms:
|
|
596
|
-
// -
|
|
812
|
+
// - AI coding agents pass capitalized: "Bash", "Read", "Write", "Edit"
|
|
597
813
|
// - OpenCode MCP tools pass prefixed: "mcp_bash", "mcp_read", "mcp_write", "mcp_edit"
|
|
598
814
|
const tool = input.tool.toLowerCase().replace(/^mcp_/, "");
|
|
599
815
|
const args = output.args;
|
|
@@ -606,7 +822,7 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
606
822
|
`\u{1F6AB} Access blocked: "${filePath}" is a sensitive file.\n\n` +
|
|
607
823
|
`This file may contain credentials or secrets.\n` +
|
|
608
824
|
`If you need this file's content, ask the user to provide relevant parts.\n\n` +
|
|
609
|
-
`Think this should be allowed?
|
|
825
|
+
`Think this should be allowed? Review the project repository, extend the guard, and submit a PR.`,
|
|
610
826
|
);
|
|
611
827
|
}
|
|
612
828
|
}
|
|
@@ -622,7 +838,7 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
622
838
|
`\u{1F6AB} Secret detected: Potential ${detected.name} found in content.\n\n` +
|
|
623
839
|
`Matched: ${detected.match}\n\n` +
|
|
624
840
|
`Never commit secrets to code. Use environment variables or secret managers.\n\n` +
|
|
625
|
-
`Think this is a false positive?
|
|
841
|
+
`Think this is a false positive? Review the project repository, fix the pattern, and submit a PR.`,
|
|
626
842
|
);
|
|
627
843
|
}
|
|
628
844
|
}
|
|
@@ -637,7 +853,7 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
637
853
|
`\u{1F6AB} Command blocked: This command might expose secrets.\n\n` +
|
|
638
854
|
`Command: ${command}\n\n` +
|
|
639
855
|
`If you need environment info, ask the user directly.\n\n` +
|
|
640
|
-
`Think this is wrong?
|
|
856
|
+
`Think this is wrong? Review the project repository, adjust the patterns, and submit a PR.`,
|
|
641
857
|
);
|
|
642
858
|
}
|
|
643
859
|
|
|
@@ -659,7 +875,7 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
659
875
|
`AI agents must use wrapper tools for security and audit.\n\n` +
|
|
660
876
|
`Use instead: bun ${skillName}\n\n` +
|
|
661
877
|
`Example: bun ${skillName} --help\n\n` +
|
|
662
|
-
`Think this tool should be allowed?
|
|
878
|
+
`Think this tool should be allowed? Review the project repository, extend the whitelist, and submit a PR.\n` +
|
|
663
879
|
`→ Skill "${skillName}"`,
|
|
664
880
|
);
|
|
665
881
|
}
|