Files
msd-core/hooks/msd-secret-read-guard.js
Jakub Zych 6cfa0c55d2 refactor: drop 12 runtimes, keep Claude, Codex, OpenCode, Cursor, ZCode, Antigravity
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.
2026-10-06 20:02:40 +02:00

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);
}
});