Removes kilo, kimi, kimi-code, copilot, windsurf, augment, trae, qwen, hermes, cline, codebuddy and pi end to end: capability descriptors, installer branches and converters (bin/install.js 14.9k -> 11.2k lines), TypeScript converters, hook surfaces and runtime homes, review lanes qwen/kimi-code, the two pi migrations, Kimi payload normalization in the hook guards, dead hostBehaviors vocabulary, launcher home probes, fixtures, runtime-specific tests and the prose that presented them as supported. Installer output for the six kept runtimes is byte-identical to before the prune. The Kimi tool-vocabulary tests in workflow-guard, read-guard and read-injection-scanner are left in place pending a decision.
1043 lines
40 KiB
JavaScript
1043 lines
40 KiB
JavaScript
#!/usr/bin/env node
|
|
// msd-hook-version: {{MSD_VERSION}}
|
|
// MSD Secret Read Guard — PreToolUse hook (Read | Grep | Bash)
|
|
//
|
|
// Blocks reads of secret files — `.env`, `.env.<suffix>`, `.secrets` — by any
|
|
// of the three tools that can put file contents into the conversation: the
|
|
// Read tool (file_path), the Grep tool (an explicit path or a glob that
|
|
// selects the secret namespace), and Bash (a command whose operands or input
|
|
// redirects name a secret file, including inside `$( )`, backticks, `<( )`,
|
|
// `bash -c '…'` / `eval "…"` bodies, and `git show <ref>:<path>` shapes).
|
|
//
|
|
// Why a hook and not permission rules (#4221): since #768 the installer wrote
|
|
// three `Read(.env)` / `Read(.env.*)` / `Read(.secrets)` deny rules into
|
|
// settings.json. Claude Code 2.1.259 hardened the Bash-side enforcement of
|
|
// Read() deny rules so that ANY `cd DIR && cat/grep relative-path` compound
|
|
// prompts for approval whenever any Read() deny rule exists — even in `auto`
|
|
// permission mode. MSD subagents emit hundreds of those per session. A
|
|
// PreToolUse denial is not a permission rule, so it never arms that check,
|
|
// and it applies in `auto` and `bypassPermissions` modes alike. The three
|
|
// installer-written strings are retired by the same installer change (they
|
|
// are filtered out as legacy entries on install and uninstall).
|
|
//
|
|
// What counts as a secret name (basename match, no path resolution, matched
|
|
// case-INSENSITIVELY so `.ENV` / `.Secrets` are caught on the macOS/Windows
|
|
// filesystems where they ARE the secret file — the write guard's `/i` stance):
|
|
// .env, .secrets, and .env.<suffix> — EXCEPT .env.example / .env.sample /
|
|
// .env.template / .env.dist, which are the non-secret templates MSD's own
|
|
// phase prompt tells executors to read.
|
|
// Stated cost (#4580): the exemption matches the token's FINAL EXTENSION,
|
|
// not the whole name, so the trusted set is `.env.<anything>.example` /
|
|
// `.sample` / `.template` / `.dist` — an unbounded family, not four fixed
|
|
// names. A real secret named `.env.prod-real-secrets.example` is NOT
|
|
// protected, and renaming any secret to end in one of those four
|
|
// extensions bypasses the guard across Read, Grep and Bash alike. This is
|
|
// the deliberate cost of #4580, which fixed the prior whole-name
|
|
// comparison wrongly refusing committed, secret-free templates like
|
|
// `.env.local.example`.
|
|
// A token containing `:` is also tested on the part after its LAST `:`,
|
|
// so `git show HEAD:.env`, `origin/main:config/.env` and `C:\proj\.env`
|
|
// are caught without git-specific parsing. Leading/interior whitespace is
|
|
// still NOT trimmed: the commit message `fix: .env parsing` yields
|
|
// ` .env parsing`, which is prose, not a name. TRAILING dots and spaces ARE
|
|
// stripped from the basename before classification (`.env.`, `.env..`,
|
|
// `.env `, `.env. ` all normalize to `.env`), because Win32 strips trailing
|
|
// dots and spaces from each path component, so these are aliases for the
|
|
// same on-disk file, not distinct names.
|
|
//
|
|
// Bash analysis is a two-pass token scan, not a shell:
|
|
// pass 1 tokenizes with quote state, comments, redirect operators (with fd
|
|
// digits and `>&N` dups), separators (recording the operator text), `$( )` /
|
|
// backtick / `<( )` / `>( )` spans (recursed as nested commands, depth ≤ 3),
|
|
// and heredocs (one token per body, carrying its `<<` segment). A heredoc
|
|
// body is only ever run as a script when its segment's command is a shell
|
|
// interpreter (below); a DATA heredoc — `cat <<EOF … EOF`, the agent-
|
|
// populated bodies in MSD's own workflows, `git commit -m "$(cat <<'EOF' …
|
|
// EOF)"` — is never operand-checked, so prose mentioning `.env` is safe.
|
|
// pass 2 groups tokens by segment and evaluates each on its own:
|
|
// input redirect targets (`<`, `N<`) are always checked; the command word is
|
|
// located past `sudo`/`env VAR=x`/`nohup`-style prefixes; a closed set of
|
|
// NON-READING commands (test/[/ls/stat/rm/touch/echo/…) exempts that
|
|
// segment's operands — `[ -f .env ]` and `ls .env*` are existence checks
|
|
// MSD's own agents run — while `cp`/`mv`/`ln`/`git` are deliberately NOT
|
|
// exempt (`cp .env x && cat x` launders the name; `git show HEAD:.env`
|
|
// reads). A shell interpreter (bash/sh/zsh/dash/ksh/su) has its script scanned
|
|
// whether it arrives via `-c '…'`, a `<( )` file operand, a heredoc /
|
|
// here-string, or a pipe from a knowable `echo`/`printf` source
|
|
// (`echo cat .env | bash`); `eval` scans its joined operands; `source`/`.`
|
|
// scans a `<( )` operand; and `find … | xargs cat` infers the upstream
|
|
// segment's names as the sub-command's read operands.
|
|
//
|
|
// Grep globs are judged per brace alternative (never on the whole glob, so
|
|
// `{.env.local,zzz.ts}` cannot hide behind a benign sibling): a pure-wildcard
|
|
// alternative (`*`, `**`) is allowed — Grep already skips gitignored files,
|
|
// so it is equivalent to no glob; any other alternative is denied when its
|
|
// literal prefix is a prefix of `.env.`/`.secrets` (`.e*`, `.env*`, `.s*`) or
|
|
// when it matches a probe secret name (`*.local`, `*.*`, `*.env*`). More than
|
|
// 64 alternatives is denied as `glob-too-complex` (cheap to retry narrower).
|
|
//
|
|
// Documented gaps (none are statically resolvable by a hook, and Claude
|
|
// Code's own 2.1.259 Bash-side enforcement does not resolve them either):
|
|
// `$VAR` indirection (`bash -c "$TEST_CMD"`, `cat "$F"`), shell globs
|
|
// (`cat .e*`), interpreter one-liners (`python -c "open('.env')"`), a piped
|
|
// script from a non-echo source (`cat gen.sh | bash`, `curl … | sh`), reads
|
|
// inside scripts the agent executes, and `glob: '*'` reaching a
|
|
// NON-gitignored `.env`. The promise is "no looser than the retired rules
|
|
// on plain commands, without arming the compound-`cd` prompt". Writes to
|
|
// secret files are out of scope (Write/Edit were never gated). Commands
|
|
// over 1 MiB are denied outright (`command-too-large`) rather than
|
|
// scanned partially or waved through. (#4639 adds one more, by design: the
|
|
// value of `--env-file` under a container runtime is exempt, so the
|
|
// container's own command can print the interpolated environment
|
|
// (`alpine printenv`, `docker compose config`) — the same exposure class
|
|
// as the pre-existing volume-mount gap (`-v .env:/s`); the flag's value
|
|
// itself is a name, never contents.)
|
|
//
|
|
// Triggers on: Read, Grep, Bash tool calls
|
|
// Action: BLOCK (decision: 'block', exit 2) — codes secret-read |
|
|
// glob-too-complex | command-too-large
|
|
// No-op: other tools, non-secret targets, hook errors (fail open — a parser
|
|
// bug in a hook that runs on EVERY Bash call must never brick a
|
|
// session; the crash policy is declared once below).
|
|
|
|
'use strict';
|
|
|
|
const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js');
|
|
const { finalExtension, normalizeWindowsBasename, lastSegment } = require('./lib/filename-classification.js');
|
|
|
|
// Fail open on a hook-internal error (see header). Declared ONCE so the
|
|
// outer catch states its policy explicitly (#3911).
|
|
const ON_CRASH = HOOK_ON_CRASH.ALLOW;
|
|
|
|
// Commands longer than this are denied rather than scanned (see header).
|
|
const MAX_COMMAND_LENGTH = 1024 * 1024;
|
|
|
|
// Recursion budget for `$( )` / backtick / `<( )` / nested-shell rescans.
|
|
const MAX_NESTING_DEPTH = 3;
|
|
|
|
// Brace-alternative budget for a Grep glob before it is denied as too complex.
|
|
const MAX_GLOB_ALTERNATIVES = 64;
|
|
|
|
// `.env.<suffix>` names that are templates, not secrets (case-insensitive).
|
|
const NON_SECRET_ENV_SUFFIXES = new Set(['example', 'sample', 'template', 'dist']);
|
|
|
|
// Command-prefix wrappers to look through when locating the command word at
|
|
// the head of a segment.
|
|
const CMD_PREFIXES = new Set(['sudo', 'env', 'command', 'nice', 'nohup', 'time', 'doas']);
|
|
|
|
// Commands whose ordinary operands are file NAMES, never file CONTENTS. A
|
|
// closed set on purpose: anything not listed is assumed to read.
|
|
const NON_READING_COMMANDS = new Set([
|
|
'test', '[', '[[', 'ls', 'stat', 'touch', 'rm', 'chmod', 'chown', 'mkdir',
|
|
'basename', 'dirname', 'realpath', 'file', 'echo', 'printf',
|
|
]);
|
|
|
|
// #4639: container runtimes take `--env-file <file>` — the runtime opens the
|
|
// file itself, interpolates it into the container environment, and returns
|
|
// nothing to the agent, so the flag's VALUE is a name, never contents. The
|
|
// same category NON_READING_COMMANDS encodes, expressed as a flag value. Only
|
|
// the flag's value under these runtimes is exempt; every other operand in the
|
|
// segment is still checked, so the carve-out cannot launder a read.
|
|
const CONTAINER_RUNTIMES = new Set(['docker', 'docker-compose', 'podman', 'nerdctl']);
|
|
const ENV_FILE_FLAG_RE = /^--env-file(=|$)/;
|
|
|
|
// Shell interpreters that run a script from `-c`, a file operand, or stdin
|
|
// (heredoc / here-string / piped `echo`|`printf`). `su` is here for its `-c`
|
|
// form (`su [user] -c 'cmd'`); a bare `su user` resolves to file mode, which
|
|
// only runs the ordinary operand check. `eval`, `source`/`.` and `xargs` are
|
|
// their own cases below; they are not in this set.
|
|
const SHELL_INTERPRETERS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'su']);
|
|
|
|
// Shell flags whose VALUE is the next operand (`bash -o pipefail`,
|
|
// `bash --rcfile x <<EOF`): skipped when locating a script-file operand, so a
|
|
// flag value is not mistaken for the script and stdin mode still applies.
|
|
const SHELL_VALUE_FLAGS = new Set(['-o', '-O', '+o', '+O', '--rcfile', '--init-file']);
|
|
|
|
// Value-taking `xargs` flags (long `--flag=value` forms are single words).
|
|
// `-a`/`--arg-file` additionally replaces stdin, so it suppresses the pipeline
|
|
// inference below.
|
|
const XARGS_VALUE_FLAGS = new Set(['-n', '-I', '-i', '-L', '-l', '-P', '-s', '-d', '-E', '-a']);
|
|
|
|
// Probe names a Grep glob alternative is matched against. `.env` and
|
|
// `.secrets` are the exact names; the rest stand in for the open-ended
|
|
// `.env.<suffix>` family so empty-literal-prefix selectors (`*.local`,
|
|
// `*.production`, `*env.*`) are caught. Residual, stated in the header:
|
|
// an alternative like `*.ts` matches no probe and is allowed even though a
|
|
// `.env.foo.ts` would satisfy the name predicate.
|
|
const GLOB_PROBES = [
|
|
'.env', '.secrets', '.env.local', '.env.development', '.env.production',
|
|
'.env.staging', '.env.test', '.env.development.local', '.env.production.local',
|
|
'.env.zzq',
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Secret-name predicate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function isSecretBasename(name) {
|
|
// Win32 strips trailing dots/spaces per path component, so `.env.`,
|
|
// `.env ` etc. resolve to the real `.env` on Windows — normalize FIRST so
|
|
// those aliases can't bypass classification.
|
|
const n = normalizeWindowsBasename(name);
|
|
if (n === '.env' || n === '.secrets') return true;
|
|
if (n.startsWith('.env.')) {
|
|
const suffix = n.slice('.env.'.length);
|
|
return suffix !== '' && !NON_SECRET_ENV_SUFFIXES.has(finalExtension(suffix).toLowerCase());
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// True when the token's basename — or the basename of the part after its
|
|
// last `:` (git `<ref>:<path>`, Windows drive) — is a secret name. Folded to
|
|
// lower case once at the top so `.ENV` / `.Secrets` match on the
|
|
// case-insensitive filesystems (macOS, Windows) where they ARE the secret file
|
|
// — the same stance as the write guard's `/i` patterns.
|
|
function namesSecret(tok) {
|
|
if (typeof tok !== 'string' || tok === '') return false;
|
|
const lower = tok.toLowerCase();
|
|
if (isSecretBasename(lastSegment(lower))) return true;
|
|
const colon = lower.lastIndexOf(':');
|
|
return colon !== -1 && isSecretBasename(lastSegment(lower.slice(colon + 1)));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Grep glob analysis
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Expand `{a,b,…}` (nested allowed) into the list of alternatives, or null
|
|
// when the list would exceed MAX_GLOB_ALTERNATIVES. Malformed braces are
|
|
// treated literally.
|
|
function expandBraces(glob) {
|
|
const open = glob.indexOf('{');
|
|
if (open === -1) return [glob];
|
|
let depth = 0;
|
|
let close = -1;
|
|
const commas = [];
|
|
for (let i = open; i < glob.length; i++) {
|
|
const ch = glob[i];
|
|
if (ch === '{') depth++;
|
|
else if (ch === '}') {
|
|
depth--;
|
|
if (depth === 0) { close = i; break; }
|
|
} else if (ch === ',' && depth === 1) commas.push(i);
|
|
}
|
|
if (close === -1) return [glob];
|
|
const pre = glob.slice(0, open);
|
|
const post = glob.slice(close + 1);
|
|
const inner = glob.slice(open + 1, close);
|
|
const parts = [];
|
|
let start = 0;
|
|
for (const c of commas) {
|
|
parts.push(inner.slice(start, c - open - 1));
|
|
start = c - open;
|
|
}
|
|
parts.push(inner.slice(start));
|
|
const out = [];
|
|
for (const part of parts) {
|
|
const expanded = expandBraces(pre + part + post);
|
|
if (expanded === null) return null;
|
|
for (const alt of expanded) {
|
|
out.push(alt);
|
|
if (out.length > MAX_GLOB_ALTERNATIVES) return null;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Anchored regex for one brace-free glob alternative (`*` → `[^/]*`,
|
|
// `?` → `[^/]`, `[…]` classes passed through with `[!` → `[^`).
|
|
function globAltToRegex(alt) {
|
|
let out = '^';
|
|
for (let i = 0; i < alt.length; i++) {
|
|
const ch = alt[i];
|
|
if (ch === '*') out += '[^/]*';
|
|
else if (ch === '?') out += '[^/]';
|
|
else if (ch === '[') {
|
|
const j = alt.indexOf(']', i + 1);
|
|
if (j === -1) out += '\\[';
|
|
else {
|
|
const body = alt.slice(i + 1, j);
|
|
out += '[' + (body.startsWith('!') ? '^' + body.slice(1) : body).replace(/\\/g, '\\\\') + ']';
|
|
i = j;
|
|
}
|
|
} else out += ch.replace(/[.+^${}()|\\]/g, '\\$&');
|
|
}
|
|
return new RegExp(out + '$');
|
|
}
|
|
|
|
// Does this single alternative select any secret name? (See header.)
|
|
function globAltSelectsSecret(alt) {
|
|
if (alt === '') return false;
|
|
if (/^[*?]+$/.test(alt)) return false; // pure wildcard: equivalent to no glob
|
|
const wild = alt.search(/[*?[]/);
|
|
const lit = wild === -1 ? alt : alt.slice(0, wild);
|
|
// #4580: when there is no wildcard, `alt` (== `lit`) is a WHOLE literal
|
|
// filename, so classify it exactly the same way Read/Bash do (by its
|
|
// FINAL extension, via isSecretBasename) rather than by a `.env.`-prefix
|
|
// heuristic — that heuristic mis-blocked multi-dot templates like
|
|
// `.env.local.example`. When a wildcard IS present, `lit` is only a
|
|
// PARTIAL literal prefix (`.env.local.exam*` can still select
|
|
// `.env.local`), which cannot be classified exactly, so the original
|
|
// conservative prefix rule stays.
|
|
if (wild === -1) {
|
|
if (isSecretBasename(lit)) return true;
|
|
} else if (lit.startsWith('.env.')) {
|
|
return true;
|
|
}
|
|
if (lit !== '' && ('.env.'.startsWith(lit) || '.secrets'.startsWith(lit))) return true;
|
|
let re;
|
|
try {
|
|
re = globAltToRegex(alt);
|
|
} catch {
|
|
return true; // an unparsable class — Grep would reject it too; deny is the safe side
|
|
}
|
|
return GLOB_PROBES.some((probe) => re.test(probe));
|
|
}
|
|
|
|
// Returns null (allowed), 'secret-read', or 'glob-too-complex'.
|
|
function classifyGrepGlob(glob) {
|
|
// Segment via the SAME `lastSegment` helper Read/Bash use (namesSecret),
|
|
// rather than a hand-rolled forward-slash-only split — the two used to
|
|
// diverge on a backslash-bearing glob (`config\.env`), which `lastSegment`
|
|
// reduces to `.env` but a `/`-only split left untouched, letting it escape
|
|
// this arm's predicate while Read/Bash still blocked it.
|
|
// Case-fold the last segment (GLOB_PROBES are lower case) so `.ENV*` and
|
|
// `*.ENV` select the secret namespace on case-insensitive filesystems.
|
|
const segment = lastSegment(glob).toLowerCase();
|
|
const alts = expandBraces(segment);
|
|
if (alts === null) return 'glob-too-complex';
|
|
return alts.some(globAltSelectsSecret) ? 'secret-read' : null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bash command scan — pass 1: tokenizer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Index of the `)` closing a `$(` / `<(` / `>(` opened just before `i`, or
|
|
// str.length when unterminated. Quote- and heredoc-aware so a `)` inside a
|
|
// quoted string or a heredoc body never closes the span early.
|
|
function findParenClose(str, i) {
|
|
let depth = 1;
|
|
let heredocTags = [];
|
|
while (i < str.length) {
|
|
const ch = str[i];
|
|
if (ch === '\\') { i += 2; continue; }
|
|
if (ch === "'") {
|
|
const j = str.indexOf("'", i + 1);
|
|
i = j === -1 ? str.length : j + 1;
|
|
continue;
|
|
}
|
|
if (ch === '"') {
|
|
i++;
|
|
while (i < str.length && str[i] !== '"') {
|
|
if (str[i] === '\\') { i += 2; continue; }
|
|
if (str[i] === '$' && str[i + 1] === '(') { i = findParenClose(str, i + 2) + 1; continue; }
|
|
if (str[i] === '`') {
|
|
const j = str.indexOf('`', i + 1);
|
|
i = j === -1 ? str.length : j + 1;
|
|
continue;
|
|
}
|
|
i++;
|
|
}
|
|
i++;
|
|
continue;
|
|
}
|
|
if (ch === '`') {
|
|
const j = str.indexOf('`', i + 1);
|
|
i = j === -1 ? str.length : j + 1;
|
|
continue;
|
|
}
|
|
if (ch === '<' && str[i + 1] === '<' && str[i + 2] !== '<') {
|
|
const tag = readHeredocTag(str, i + 2);
|
|
heredocTags.push(tag);
|
|
i = tag.end;
|
|
continue;
|
|
}
|
|
if (ch === '\n' && heredocTags.length) {
|
|
i = consumeHeredocBodies(str, i + 1, heredocTags).end;
|
|
heredocTags = [];
|
|
continue;
|
|
}
|
|
if (ch === '(') depth++;
|
|
else if (ch === ')') {
|
|
depth--;
|
|
if (depth === 0) return i;
|
|
}
|
|
i++;
|
|
}
|
|
return str.length;
|
|
}
|
|
|
|
// Reads the tag word after `<<` / `<<-` starting at `i`.
|
|
function readHeredocTag(str, i) {
|
|
let stripTabs = false;
|
|
if (str[i] === '-') { stripTabs = true; i++; }
|
|
while (str[i] === ' ' || str[i] === '\t') i++;
|
|
let quoted = false;
|
|
let tag = '';
|
|
if (str[i] === "'" || str[i] === '"') {
|
|
const q = str[i];
|
|
const j = str.indexOf(q, i + 1);
|
|
tag = str.slice(i + 1, j === -1 ? str.length : j);
|
|
quoted = true;
|
|
i = j === -1 ? str.length : j + 1;
|
|
} else {
|
|
if (str[i] === '\\') { quoted = true; i++; }
|
|
while (i < str.length && !/[\s;&|<>()]/.test(str[i])) tag += str[i++];
|
|
}
|
|
return { tag, quoted, stripTabs, end: i };
|
|
}
|
|
|
|
// From `i` (start of the line after the heredoc-opening line), consume one
|
|
// body per pending tag in order. Returns every body with its `quoted`/`seg`
|
|
// (the caller emits a token per body and recurses substitutions only for
|
|
// unquoted ones) and the index just past the last terminator line. An
|
|
// unterminated body consumes to end of input.
|
|
function consumeHeredocBodies(str, i, tags) {
|
|
const bodies = [];
|
|
for (const t of tags) {
|
|
let body = '';
|
|
let terminated = false;
|
|
while (i < str.length) {
|
|
const nl = str.indexOf('\n', i);
|
|
const lineEnd = nl === -1 ? str.length : nl;
|
|
const line = str.slice(i, lineEnd);
|
|
i = nl === -1 ? str.length : nl + 1;
|
|
const probe = t.stripTabs ? line.replace(/^\t+/, '') : line;
|
|
if (probe === t.tag) { terminated = true; break; }
|
|
body += line + '\n';
|
|
}
|
|
bodies.push({ body, quoted: t.quoted, seg: t.seg });
|
|
if (!terminated) break;
|
|
}
|
|
return { bodies, end: i };
|
|
}
|
|
|
|
// `$( )` and backtick spans inside an unquoted heredoc body.
|
|
function collectSubstitutions(body, nested) {
|
|
let i = 0;
|
|
while (i < body.length) {
|
|
if (body[i] === '$' && body[i + 1] === '(') {
|
|
const e = findParenClose(body, i + 2);
|
|
nested.push(body.slice(i + 2, e));
|
|
i = e + 1;
|
|
continue;
|
|
}
|
|
if (body[i] === '`') {
|
|
const j = body.indexOf('`', i + 1);
|
|
const e = j === -1 ? body.length : j;
|
|
nested.push(body.slice(i + 1, e));
|
|
i = e + 1;
|
|
continue;
|
|
}
|
|
i++;
|
|
}
|
|
}
|
|
|
|
// Tokens: { kind: 'word'|'op'|'sep', text, quoted: 'none'|'single'|'double', seg }.
|
|
// `op` tokens carry `read` (an input redirect) and `dup` (`>&N`, consumes no
|
|
// target). Nested command strings are collected separately.
|
|
function tokenize(str) {
|
|
const tokens = [];
|
|
const nested = [];
|
|
let buf = '';
|
|
let quoted = 'none';
|
|
let hasWord = false;
|
|
let seg = 0;
|
|
let heredocs = [];
|
|
let expectTag = null;
|
|
|
|
const flush = () => {
|
|
if (!hasWord) return;
|
|
if (expectTag) {
|
|
// Record the current seg (still the `<<` segment — flush runs before the
|
|
// newline sep increments it) so pass 2 can attach the body to the shell.
|
|
heredocs.push({ tag: buf, quoted: quoted !== 'none', stripTabs: expectTag.stripTabs, seg });
|
|
expectTag = null;
|
|
} else {
|
|
tokens.push({ kind: 'word', text: buf, quoted, seg });
|
|
}
|
|
buf = '';
|
|
quoted = 'none';
|
|
hasWord = false;
|
|
};
|
|
// The operator text ends segment `seg`; pass 2 reads it to tell `a | bash`
|
|
// (pipe inference) from `a || bash` and to skip grouping seps.
|
|
const sep = (text) => {
|
|
flush();
|
|
tokens.push({ kind: 'sep', text, quoted: 'none', seg });
|
|
seg++;
|
|
};
|
|
const op = (text, read, dup) => {
|
|
tokens.push({ kind: 'op', text, quoted: 'none', seg, read, dup });
|
|
};
|
|
|
|
let i = 0;
|
|
while (i < str.length) {
|
|
const ch = str[i];
|
|
|
|
if (ch === "'") {
|
|
hasWord = true;
|
|
if (quoted === 'none') quoted = 'single';
|
|
const j = str.indexOf("'", i + 1);
|
|
const end = j === -1 ? str.length : j;
|
|
buf += str.slice(i + 1, end);
|
|
i = end + 1;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '"') {
|
|
hasWord = true;
|
|
if (quoted === 'none') quoted = 'double';
|
|
i++;
|
|
while (i < str.length && str[i] !== '"') {
|
|
const c = str[i];
|
|
if (c === '\\' && i + 1 < str.length && '"\\$`\n'.includes(str[i + 1])) {
|
|
if (str[i + 1] !== '\n') buf += str[i + 1];
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (c === '$' && str[i + 1] === '(') {
|
|
const e = findParenClose(str, i + 2);
|
|
nested.push(str.slice(i + 2, e));
|
|
i = e + 1;
|
|
continue;
|
|
}
|
|
if (c === '`') {
|
|
const j = str.indexOf('`', i + 1);
|
|
const e = j === -1 ? str.length : j;
|
|
nested.push(str.slice(i + 1, e));
|
|
i = e + 1;
|
|
continue;
|
|
}
|
|
buf += c;
|
|
i++;
|
|
}
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '\\') {
|
|
if (str[i + 1] === '\n') { i += 2; continue; } // line continuation
|
|
hasWord = true;
|
|
if (i + 1 < str.length) buf += str[i + 1];
|
|
i += 2;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '$' && str[i + 1] === '(') {
|
|
hasWord = true;
|
|
const e = findParenClose(str, i + 2);
|
|
nested.push(str.slice(i + 2, e));
|
|
i = e + 1;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '$' && str[i + 1] === '{') {
|
|
hasWord = true;
|
|
const j = str.indexOf('}', i);
|
|
const e = j === -1 ? str.length - 1 : j;
|
|
buf += str.slice(i, e + 1);
|
|
i = e + 1;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '`') {
|
|
hasWord = true;
|
|
const j = str.indexOf('`', i + 1);
|
|
const e = j === -1 ? str.length : j;
|
|
nested.push(str.slice(i + 1, e));
|
|
i = e + 1;
|
|
continue;
|
|
}
|
|
|
|
if ((ch === '<' || ch === '>') && str[i + 1] === '(') {
|
|
flush();
|
|
const e = findParenClose(str, i + 2);
|
|
const inner = str.slice(i + 2, e);
|
|
nested.push(inner);
|
|
// Emit a word carrying the inner script so a shell / `source` operand
|
|
// (`sh <(echo 'cat .env')`) can reconstruct it; the bare nested recursion
|
|
// above only sees `echo …`, whose operands are not read.
|
|
tokens.push({ kind: 'word', text: str.slice(i, e + 1), quoted: 'none', seg, procsub: inner });
|
|
i = e + 1;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '\n') {
|
|
sep('\n');
|
|
i++;
|
|
if (heredocs.length) {
|
|
const r = consumeHeredocBodies(str, i, heredocs);
|
|
for (const b of r.bodies) {
|
|
// Emit a heredoc token per body (quoted included) — the body is the
|
|
// stdin script only a shell interpreter runs. Kept out of `words`.
|
|
tokens.push({ kind: 'heredoc', text: b.body, quoted: b.quoted, seg: b.seg });
|
|
if (!b.quoted) collectSubstitutions(b.body, nested); // bash expands $( ) here
|
|
}
|
|
heredocs = [];
|
|
i = r.end;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (ch === ' ' || ch === '\t' || ch === '\r') {
|
|
flush();
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '#' && !hasWord) {
|
|
const j = str.indexOf('\n', i);
|
|
i = j === -1 ? str.length : j;
|
|
continue;
|
|
}
|
|
|
|
if (ch === '<' || ch === '>' || (ch === '&' && str[i + 1] === '>')) {
|
|
let fd = '';
|
|
if (hasWord && quoted === 'none' && /^\d+$/.test(buf)) {
|
|
fd = buf;
|
|
buf = '';
|
|
hasWord = false;
|
|
} else {
|
|
flush();
|
|
}
|
|
let j = i;
|
|
let text;
|
|
if (str.startsWith('<<<', j)) { text = '<<<'; j += 3; }
|
|
else if (str.startsWith('<<-', j)) { text = '<<-'; j += 3; }
|
|
else if (str.startsWith('<<', j)) { text = '<<'; j += 2; }
|
|
else if (str.startsWith('&>>', j)) { text = '&>>'; j += 3; }
|
|
else if (str.startsWith('&>', j)) { text = '&>'; j += 2; }
|
|
else if (str.startsWith('>>', j)) { text = '>>'; j += 2; }
|
|
else if (str.startsWith('>|', j)) { text = '>|'; j += 2; }
|
|
else { text = ch; j += 1; }
|
|
if (text === '<<' || text === '<<-') {
|
|
expectTag = { stripTabs: text === '<<-' };
|
|
i = j;
|
|
continue;
|
|
}
|
|
let dup = false;
|
|
if ((text === '<' || text === '>') && str[j] === '&' && /[\d-]/.test(str[j + 1] || '')) {
|
|
let k = j + 1;
|
|
while (k < str.length && /[\d-]/.test(str[k])) k++;
|
|
text += str.slice(j, k);
|
|
j = k;
|
|
dup = true;
|
|
}
|
|
op(fd + text, text[0] === '<' && text !== '<<<', dup);
|
|
i = j;
|
|
continue;
|
|
}
|
|
|
|
// Lookahead first, THEN record the full operator, so `a || bash` reports
|
|
// `||` (no pipe inference) and `a | bash` reports `|` (pipe inference).
|
|
if (ch === ';') {
|
|
let text = ';';
|
|
i++;
|
|
if (str[i] === ';') { text = ';;'; i++; }
|
|
sep(text);
|
|
continue;
|
|
}
|
|
if (ch === '|') {
|
|
let text = '|';
|
|
i++;
|
|
if (str[i] === '|') { text = '||'; i++; }
|
|
else if (str[i] === '&') { text = '|&'; i++; }
|
|
sep(text);
|
|
continue;
|
|
}
|
|
if (ch === '&') {
|
|
let text = '&';
|
|
i++;
|
|
if (str[i] === '&') { text = '&&'; i++; }
|
|
sep(text);
|
|
continue;
|
|
}
|
|
if (ch === '(' || ch === ')') {
|
|
sep(ch);
|
|
i++;
|
|
continue;
|
|
}
|
|
if ((ch === '{' || ch === '}') && !hasWord && (i + 1 >= str.length || /[\s;&|)]/.test(str[i + 1]))) {
|
|
sep(ch);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
hasWord = true;
|
|
buf += ch;
|
|
i++;
|
|
}
|
|
flush();
|
|
return { tokens, nested };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bash command scan — pass 2: per-segment evaluation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// `@file` (curl -d), `--flag=value`, `-Xvalue` → the operand that names the file.
|
|
function normalizeOperand(text) {
|
|
let v = text;
|
|
if (v.startsWith('@')) v = v.slice(1);
|
|
if (v.startsWith('--')) {
|
|
const eq = v.indexOf('=');
|
|
if (eq !== -1) v = v.slice(eq + 1);
|
|
} else if (/^-[A-Za-z]./.test(v)) {
|
|
v = v.slice(2);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
const ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
|
|
// `-c`, or a combined short flag ending in `c` (`-lc`, `-ec`, `-euc`): mode c.
|
|
const DASH_C_RE = /^-[A-Za-z]*c$/;
|
|
|
|
const GROUPING_SEPS = new Set(['(', ')', '{', '}']);
|
|
|
|
// Command base + operands after leading `VAR=val` assignments and prefix
|
|
// wrappers (`sudo`, `env VAR=x`, …), or null when nothing but prefixes remain.
|
|
function resolveCommand(words) {
|
|
let idx = 0;
|
|
while (idx < words.length && ASSIGNMENT_RE.test(words[idx].text)) idx++;
|
|
while (idx < words.length) {
|
|
const base = lastSegment(words[idx].text).toLowerCase();
|
|
if (!CMD_PREFIXES.has(base)) break;
|
|
idx++;
|
|
if (base === 'env') {
|
|
while (idx < words.length && ASSIGNMENT_RE.test(words[idx].text)) idx++;
|
|
}
|
|
}
|
|
if (idx >= words.length) return null;
|
|
return { base: lastSegment(words[idx].text).toLowerCase(), operands: words.slice(idx + 1) };
|
|
}
|
|
|
|
// The statically-knowable stdin a segment writes: `echo`/`printf` operands
|
|
// joined by a space (for `echo`, leading `-neE` flags dropped). Any other
|
|
// source (`cat gen.sh | bash`, `curl … | sh`) is not knowable → null.
|
|
function reconstructedScript(words) {
|
|
const cmd = resolveCommand(words);
|
|
if (!cmd) return null;
|
|
if (cmd.base === 'echo') {
|
|
let start = 0;
|
|
while (start < cmd.operands.length && /^-[neE]+$/.test(cmd.operands[start].text)) start++;
|
|
return cmd.operands.slice(start).map((w) => w.text).join(' ');
|
|
}
|
|
if (cmd.base === 'printf') return cmd.operands.map((w) => w.text).join(' ');
|
|
return null;
|
|
}
|
|
|
|
// Same rule applied to a `<( … )` / `>( … )` inner script's first segment.
|
|
function reconstructedProcsub(inner) {
|
|
const { tokens } = tokenize(inner);
|
|
const words = [];
|
|
for (const t of tokens) {
|
|
if (t.kind === 'sep') break;
|
|
if (t.kind === 'word') words.push(t);
|
|
}
|
|
return reconstructedScript(words);
|
|
}
|
|
|
|
// The operator connecting segment `s` to the nearest PRECEDING segment that has
|
|
// word tokens, skipping empty grouping segments (`(echo cat .env) | bash` has an
|
|
// empty segment between `)` and `|`). Returns { op, prevSeg }.
|
|
function precedingOp(s, bySeg, sepAfter) {
|
|
let p = s - 1;
|
|
while (p >= 0 && !(bySeg.get(p) || []).some((t) => t.kind === 'word')) p--;
|
|
if (p < 0) return { op: undefined, prevSeg: -1 };
|
|
let op;
|
|
for (let q = p; q < s; q++) {
|
|
const text = sepAfter.get(q);
|
|
if (text !== undefined && !GROUPING_SEPS.has(text)) op = text; // last non-grouping wins
|
|
}
|
|
return { op, prevSeg: p };
|
|
}
|
|
|
|
// Returns the offending token text, or null.
|
|
function findSecretRead(command, depth) {
|
|
const { tokens, nested } = tokenize(command);
|
|
|
|
for (const sub of nested) {
|
|
if (depth < MAX_NESTING_DEPTH) {
|
|
const hit = findSecretRead(sub, depth + 1);
|
|
if (hit) return hit;
|
|
}
|
|
}
|
|
|
|
// Group by seg, not separator order: heredoc tokens carry their `<<`
|
|
// segment's seg and must reach the shell even though a data heredoc sits
|
|
// between other separators. Heredocs are kept OUT of `words` so a data body
|
|
// is never operand-checked (`cat <<EOF\n.env\nEOF` stays allowed).
|
|
const bySeg = new Map();
|
|
const heredocsBySeg = new Map();
|
|
const sepAfter = new Map();
|
|
let maxSeg = 0;
|
|
for (const t of tokens) {
|
|
if (t.seg > maxSeg) maxSeg = t.seg;
|
|
if (t.kind === 'sep') {
|
|
sepAfter.set(t.seg, t.text);
|
|
} else if (t.kind === 'heredoc') {
|
|
if (!heredocsBySeg.has(t.seg)) heredocsBySeg.set(t.seg, []);
|
|
heredocsBySeg.get(t.seg).push(t);
|
|
} else {
|
|
if (!bySeg.has(t.seg)) bySeg.set(t.seg, []);
|
|
bySeg.get(t.seg).push(t);
|
|
}
|
|
}
|
|
|
|
for (let s = 0; s <= maxSeg; s++) {
|
|
const segTokens = bySeg.get(s);
|
|
if (!segTokens) continue;
|
|
|
|
const words = [];
|
|
const hereStrings = [];
|
|
for (let k = 0; k < segTokens.length; k++) {
|
|
const t = segTokens[k];
|
|
if (t.kind === 'op') {
|
|
if (t.dup) continue;
|
|
const target = segTokens[k + 1];
|
|
if (target && target.kind === 'word') {
|
|
k++;
|
|
if (t.text.endsWith('<<<')) hereStrings.push(target.text); // stdin data for a shell
|
|
// Input redirects are reads regardless of the command's exemption.
|
|
else if (t.read && namesSecret(target.text)) return target.text;
|
|
}
|
|
continue;
|
|
}
|
|
words.push(t);
|
|
}
|
|
if (!words.length) continue;
|
|
|
|
const cmd = resolveCommand(words);
|
|
if (!cmd) continue;
|
|
const { base, operands } = cmd;
|
|
const heredocs = heredocsBySeg.get(s) || [];
|
|
|
|
// eval concatenates ALL its operands and runs the result.
|
|
if (base === 'eval') {
|
|
if (depth < MAX_NESTING_DEPTH) {
|
|
const hit = findSecretRead(operands.map((w) => w.text).join(' '), depth + 1);
|
|
if (hit) return hit;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// `source` / `.` reads a file (or a process-substitution script).
|
|
if (base === 'source' || base === '.') {
|
|
for (const w of operands) {
|
|
if (w.procsub !== undefined && depth < MAX_NESTING_DEPTH) {
|
|
const src = reconstructedProcsub(w.procsub);
|
|
if (src !== null) {
|
|
const hit = findSecretRead(src, depth + 1);
|
|
if (hit) return hit;
|
|
}
|
|
} else if (namesSecret(normalizeOperand(w.text))) return w.text;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// xargs turns stdin file names into a sub-command's operands.
|
|
if (base === 'xargs' && depth < MAX_NESTING_DEPTH) {
|
|
const hit = scanXargsPipe(operands, s, bySeg, sepAfter, depth);
|
|
if (hit) return hit;
|
|
// `.env` given to xargs itself (`xargs -a .env cat`) is an ordinary
|
|
// operand — fall through to the operand check below.
|
|
}
|
|
|
|
if (SHELL_INTERPRETERS.has(base) && depth < MAX_NESTING_DEPTH) {
|
|
const hit = scanShellInterpreter(operands, heredocs, hereStrings, s, bySeg, sepAfter, depth);
|
|
if (hit) return hit;
|
|
// `bash .env` (file mode) is caught by the operand check below.
|
|
}
|
|
|
|
if (NON_READING_COMMANDS.has(base)) continue;
|
|
|
|
// #4639: exempt ONLY the value of `--env-file`, and only when the
|
|
// segment's command word is a container runtime. A bare `--env-file`
|
|
// consumes the next operand; `--env-file=<value>` is a single word.
|
|
const envFileExempt = CONTAINER_RUNTIMES.has(base);
|
|
let skipNext = false;
|
|
for (const w of operands) {
|
|
if (envFileExempt) {
|
|
if (skipNext) { skipNext = false; continue; }
|
|
if (ENV_FILE_FLAG_RE.test(w.text)) {
|
|
if (!w.text.includes('=')) skipNext = true; // bare flag consumes the next operand
|
|
continue;
|
|
}
|
|
}
|
|
if (namesSecret(normalizeOperand(w.text))) return w.text;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// A shell interpreter's script comes from `-c`, a file operand, or stdin.
|
|
function scanShellInterpreter(operands, heredocs, hereStrings, s, bySeg, sepAfter, depth) {
|
|
const cIdx = operands.findIndex((w) => DASH_C_RE.test(w.text));
|
|
if (cIdx !== -1) {
|
|
// Mode c: the next operand is the script; stdin is DATA (not scanned).
|
|
const script = operands[cIdx + 1];
|
|
if (script) return findSecretRead(script.text, depth + 1);
|
|
return null;
|
|
}
|
|
let fileTok;
|
|
for (let m = 0; m < operands.length; m++) {
|
|
if (SHELL_VALUE_FLAGS.has(operands[m].text)) { m++; continue; }
|
|
if (!operands[m].text.startsWith('-')) { fileTok = operands[m]; break; }
|
|
}
|
|
if (fileTok) {
|
|
// Mode file: `bash <(echo 'cat .env')`; a plain file is checked as an operand.
|
|
if (fileTok.procsub !== undefined) {
|
|
const src = reconstructedProcsub(fileTok.procsub);
|
|
if (src !== null) return findSecretRead(src, depth + 1);
|
|
}
|
|
return null;
|
|
}
|
|
// Mode stdin: heredoc bodies, here-strings, and a piped echo/printf source.
|
|
for (const h of heredocs) {
|
|
const hit = findSecretRead(h.text, depth + 1);
|
|
if (hit) return hit;
|
|
}
|
|
for (const hs of hereStrings) {
|
|
const hit = findSecretRead(hs, depth + 1);
|
|
if (hit) return hit;
|
|
}
|
|
const { op, prevSeg } = precedingOp(s, bySeg, sepAfter);
|
|
if ((op === '|' || op === '|&') && prevSeg >= 0) {
|
|
const src = reconstructedScript((bySeg.get(prevSeg) || []).filter((t) => t.kind === 'word'));
|
|
if (src !== null) return findSecretRead(src, depth + 1);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// `find … | xargs cat`: the upstream segment's operands become file names the
|
|
// sub-command reads. Only inferred across a real pipe and when stdin is not
|
|
// redirected by `-a`/`--arg-file`. A sub-command that is itself a shell
|
|
// (`xargs -I{} sh -c 'cat .env'`) carries a literal script and is scanned in
|
|
// mode c whether or not a pipe feeds it.
|
|
function scanXargsPipe(operands, s, bySeg, sepAfter, depth) {
|
|
let argFile = false;
|
|
let subIdx = -1;
|
|
for (let m = 0; m < operands.length; m++) {
|
|
const t = operands[m].text;
|
|
if (t === '-a' || t === '--arg-file') { argFile = true; m++; continue; }
|
|
if (t.startsWith('--arg-file=')) { argFile = true; continue; }
|
|
if (XARGS_VALUE_FLAGS.has(t)) { m++; continue; }
|
|
if (t.startsWith('--') && t.includes('=')) continue;
|
|
if (t.startsWith('-')) continue; // no-value flag (-0 -r -t -p) or long flag
|
|
subIdx = m;
|
|
break;
|
|
}
|
|
if (subIdx === -1) return null; // no sub-command: xargs defaults to echo
|
|
|
|
const subBase = lastSegment(operands[subIdx].text).toLowerCase();
|
|
if (SHELL_INTERPRETERS.has(subBase)) {
|
|
// Heredocs/here-strings belong to xargs, not the sub-shell; pass none.
|
|
const hit = scanShellInterpreter(operands.slice(subIdx + 1), [], [], s, bySeg, sepAfter, depth);
|
|
if (hit) return hit;
|
|
}
|
|
if (argFile) return null; // stdin replaced by a file — no pipeline inference
|
|
if (NON_READING_COMMANDS.has(subBase)) return null;
|
|
|
|
const { op, prevSeg } = precedingOp(s, bySeg, sepAfter);
|
|
if (op !== '|' && op !== '|&') return null;
|
|
if (prevSeg < 0) return null;
|
|
|
|
// Every upstream operand is a candidate file name — the NON_READING
|
|
// exemption is bypassed for it, but the `.env.example|…` suffix exemption in
|
|
// isSecretBasename still holds.
|
|
const prevCmd = resolveCommand((bySeg.get(prevSeg) || []).filter((t) => t.kind === 'word'));
|
|
if (!prevCmd) return null;
|
|
for (const w of prevCmd.operands) {
|
|
if (namesSecret(normalizeOperand(w.text))) return w.text;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Emission
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const PATTERN_TEXT = '.env, .env.<suffix> (except .env.example/.sample/.template/.dist), .secrets';
|
|
|
|
function reasonFor(code, tool, target) {
|
|
if (code === 'command-too-large') {
|
|
return `Secret read guard: this Bash command is over ${MAX_COMMAND_LENGTH} characters and ` +
|
|
'cannot be checked for secret-file reads. Split it into smaller commands.';
|
|
}
|
|
if (code === 'glob-too-complex') {
|
|
return `Secret read guard: the Grep glob '${target}' expands to more than ${MAX_GLOB_ALTERNATIVES} ` +
|
|
'alternatives and cannot be checked for secret-file matches. Use a narrower glob.';
|
|
}
|
|
return `Secret read guard: ${tool} would read '${target}', which matches a protected secret-file ` +
|
|
`pattern (${PATTERN_TEXT}). Secret values must not be read into the conversation. ` +
|
|
'If you need a specific value, ask the user for it; if you need the variable NAMES, ' +
|
|
'read the non-secret template (.env.example) instead.';
|
|
}
|
|
|
|
// stdout gets the typed JSON block; stderr gets the plain reason string
|
|
// (a native hook bus may read stderr verbatim back to the model — #3911).
|
|
function emitBlock(code, tool, target) {
|
|
const reason = reasonFor(code, tool, target);
|
|
deny({ decision: 'block', code, tool, path: target, reason }, reason);
|
|
}
|
|
|
|
let input = '';
|
|
const stdinTimeout = setTimeout(() => allow(undefined), 3000);
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', chunk => input += chunk);
|
|
process.stdin.on('end', () => {
|
|
clearTimeout(stdinTimeout);
|
|
try {
|
|
const data = JSON.parse(input);
|
|
|
|
// A null/primitive payload has nothing to guard — exit deliberately
|
|
// rather than throwing into the fail-open catch below (#2595 class).
|
|
if (data === null || typeof data !== 'object') {
|
|
allow(undefined);
|
|
}
|
|
|
|
const tool = typeof data.tool_name === 'string' ? data.tool_name : '';
|
|
if (tool !== 'Read' && tool !== 'Grep' && tool !== 'Bash') {
|
|
allow(undefined);
|
|
}
|
|
if (!data.tool_input || typeof data.tool_input !== 'object') {
|
|
allow(undefined);
|
|
}
|
|
|
|
// Every payload field is read TYPED in a single statement (#2547 class):
|
|
// `[]`/`{}` are truthy and a non-string degrades to '' here.
|
|
if (tool === 'Read') {
|
|
const filePath = typeof data.tool_input.file_path === 'string' ? data.tool_input.file_path : '';
|
|
if (namesSecret(filePath)) emitBlock('secret-read', tool, filePath);
|
|
allow(undefined);
|
|
}
|
|
|
|
if (tool === 'Grep') {
|
|
const grepPath = typeof data.tool_input.path === 'string' ? data.tool_input.path
|
|
: (typeof data.tool_input.file_path === 'string' ? data.tool_input.file_path : '');
|
|
if (namesSecret(grepPath)) emitBlock('secret-read', tool, grepPath);
|
|
const glob = typeof data.tool_input.glob === 'string' ? data.tool_input.glob : '';
|
|
if (glob !== '') {
|
|
const verdict = classifyGrepGlob(glob);
|
|
if (verdict) emitBlock(verdict, tool, glob);
|
|
}
|
|
allow(undefined);
|
|
}
|
|
|
|
// Bash
|
|
const command = typeof data.tool_input.command === 'string' ? data.tool_input.command : '';
|
|
if (command === '') allow(undefined);
|
|
if (command.length > MAX_COMMAND_LENGTH) emitBlock('command-too-large', tool, '');
|
|
const hit = findSecretRead(command, 0);
|
|
if (hit !== null) emitBlock('secret-read', tool, hit);
|
|
allow(undefined);
|
|
} catch {
|
|
// Fail open — never block valid tool calls due to hook errors.
|
|
// ON_CRASH is declared ALLOW at module top (#3911).
|
|
crash(ON_CRASH, undefined);
|
|
}
|
|
});
|