Files
msd-core/hooks/gsd-secret-read-guard.js
Cody Anderson 77e2472ca0 enhance(#4221): replace installer Read() deny rules with a managed secret-read guard hook (#4236)
* feat(#4221): gsd-secret-read-guard PreToolUse hook + registration

Add hooks/gsd-secret-read-guard.js, a blocking PreToolUse guard on
Read|Grep|Bash that denies reads of .env, .env.<suffix> and .secrets
(the .env.example/.sample/.template/.dist templates stay readable).
Read checks file_path; Grep checks an explicit path and judges the glob
per brace alternative; Bash runs a two-pass token scan (quotes, comments,
redirects with fd digits, separators, $( )/backtick/<( ) recursion,
heredoc bodies never scanned as commands, nested bash -c/eval rescans,
git <ref>:<path> shapes) with a closed non-reading exemption set for
existence checks. Fail-open crash policy; 1 MiB commands are denied as
command-too-large; more than 64 glob alternatives as glob-too-complex.

Why: Claude Code 2.1.259 makes every `cd DIR && grep …` compound prompt
for approval whenever any Read() deny rule exists, even in auto mode. A
hook denial is not a permission rule and never arms that check. The
installer-written deny rules are retired in the follow-up commit.

Registration: hooks.json (Read|Grep|Bash, timeout 5), build-hooks
HOOKS_TO_COPY, managed-hooks-registry, runtime-hooks-surface (blocking
guard with BLOCKING_GUARD_TIMEOUT_S; Kimi ReadFile|Grep|Shell),
shell-command-projection managed sets, installer-migration-report,
OpenCode/Kilo plugin (grep tool mapping, include -> glob, dispatch),
docs tables in five locales, ADR-766 always-on list, regen:derived
fixtures, and a new table-driven unit suite.

* test(#4221): pin the secret-read guard in existing hook gates

Register gsd-secret-read-guard.js in every existing hook gate: the
hooks-crash-policy table (deny row; 6 -> 7 deny cases), plugin-manifest
REQUIRED_HOOKS and its Read|Grep|Bash group, docs-hooks-table-parity
EXPECTED_SURFACE_HOOKS, install.test MANAGED_JS_HOOKS, install-minimal-
hooks JS_HOOKS/BLOCKING_GUARDS, portable-node-runner GUARD_HOOKS,
kilo-upgrades PLUGIN_GUARD_HOOKS, the Kimi normalization-parity and
typed-payload floors, the OpenCode adapter (grep mapping, include ->
glob, three dispatch tests) and a Kimi TOML matcher assertion.

* fix(#4221): retire installer Read() deny rules (legacy filter)

Rename GSD_CLAUDE_DENY_PERMISSIONS to GSD_CLAUDE_LEGACY_DENY_PERMISSIONS
and stop adding the three Read(.env) / Read(.env.*) / Read(.secrets)
strings. mergeClaudePermissions now only filters them out of an existing
permissions.deny: an absent deny key stays absent, a malformed one is
still repaired to [], and an array emptied by the filter is deleted so
no `"deny": []` residue is left. Uninstall filters the same legacy list
and, symmetric with the Antigravity branch, drops an emptied allow or
deny key and an emptied permissions object.

Unlike the #2278 allow-side migration there is no surviving current
deny list, so the constant is renamed rather than mirrored. Removal is
byte-exact: a hand-written identical rule is indistinguishable from the
installer's and is removed too (the manifest never recorded permission
strings). USER-GUIDE and CONTEXT.md updated.

* test(#4221): flip install-regressions deny-rule assertions to the retired shape

The fresh-merge, non-destructive merge, idempotency, end-to-end install,
reinstall and uninstall assertions now expect no Read(.env*) deny rules
and no permissions.deny key on a fresh install; the deny:null repair case
is kept. A new describe block covers the legacy filter: retired strings
removed with a user entry kept, partial sets, near-miss strings
untouched, idempotency, GSD-only deny array deleted, a pre-existing
empty deny preserved, and uninstall symmetry for allow/deny/permissions.

* chore(#4221): add changeset fragment for PR #4236

* fix(#4221): case-fold names; scan shell stdin and xargs pipes

Review round 1 (trek-e):

- Blocker: secret-name matching is now case-insensitive in the Read,
  Grep (path and glob) and Bash paths, so `.ENV` / `.Secrets` on a
  case-insensitive filesystem are recognized as the same secret file.
- Major: a shell interpreter's script is now scanned wherever it comes
  from. The tokenizer keeps heredoc bodies as per-segment tokens and
  records separator operators; pass 2 groups by segment id and resolves
  bash/sh/zsh/dash/ksh/su invocation mode: `-c` (including combined
  `-lc`) scans the script operand, a file operand is checked as a file
  (a `<( )` operand's echo/printf output is reconstructed), otherwise
  stdin is the script and heredocs, here-strings and a piped echo/printf
  source are scanned. `eval` joins all its operands; `source`/`.` handle
  process substitution. Data heredocs (`cat <<EOF`, the commit-message
  shape) stay unscanned.
- Major: `… | xargs <cmd>` checks the upstream segment's operands as
  file names when the sub-command reads (`echo .env | xargs cat`,
  `find . -name .env | xargs cat`); `-a`/`--arg-file` suppresses the
  inference; a shell sub-command's `-c` script is scanned.

Header, USER-GUIDE bullet and changeset updated; documented gaps now
include piped scripts from non-echo sources and `exec`/`timeout`
wrappers. 60 new suite cases pin the block and allow shapes.

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 04:00:08 -04:00

1080 lines
42 KiB
JavaScript

#!/usr/bin/env node
// gsd-hook-version: {{GSD_VERSION}}
// GSD 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. GSD 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 GSD's own
// phase prompt tells executors to read.
// Stated cost: this is narrower than the retired `Read(.env.*)` rule — a
// real secret stored in `.env.example` is not protected.
// 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. No whitespace trimming: the
// commit message `fix: .env parsing` yields ` .env parsing`, not a name.
//
// 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 GSD'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
// GSD'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.
//
// Triggers on: Read, Grep, Bash tool calls (Kimi: ReadFile, Grep, Shell)
// 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');
// 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 (same set as hooks/gsd-windsurf-pre-command.js).
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',
]);
// 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) {
if (name === '.env' || name === '.secrets') return true;
if (name.startsWith('.env.')) {
const suffix = name.slice('.env.'.length);
return suffix !== '' && !NON_SECRET_ENV_SUFFIXES.has(suffix.toLowerCase());
}
return false;
}
// Last `/`- or `\`-separated segment, ignoring trailing separators.
function lastSegment(tok) {
const s = tok.replace(/[\\/]+$/, '');
const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'));
return i === -1 ? s : s.slice(i + 1);
}
// 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);
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) {
const segIdx = glob.replace(/\/+$/, '').lastIndexOf('/');
// Case-fold the last segment (GLOB_PROBES are lower case) so `.ENV*` and
// `*.ENV` select the secret namespace on case-insensitive filesystems.
const segment = (segIdx === -1 ? glob : glob.slice(segIdx + 1)).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;
for (const w of operands) {
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
// (Kimi's hook bus reads 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);
}
// Strips a `module:` prefix so Kimi's `kimi_cli.tools.file:Grep` (not in the
// KIMI_TOOL_NAMES map — Grep has the same name on both buses) matches.
function bareToolName(raw) {
return typeof raw === 'string' ? raw.slice(raw.lastIndexOf(':') + 1) : '';
}
// #2304: Kimi's native hook bus delivers Kimi's tool vocabulary in the
// payload (ReadFile / Shell) and `path` instead of `file_path`; the map and
// normalizer below are the byte-identical copy every guard carries (bound by
// tests/kimi-guard-normalization-parity.test.cjs — do not edit locally).
// Grep keeps its name on Kimi and is not in the map; bareToolName() above
// strips the module prefix for it.
const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write'], ['StrReplaceFile', 'Edit'], ['ReadFile', 'Read'], ['Shell', 'Bash']]);
function normalizeKimiPayload(data) {
// #2595 (review nit): `JSON.parse('null')` is null, and null/primitive
// payloads reached the `data.tool_name` read below and threw — falsifying
// this function's own "total over the inputs JSON can express" claim, which
// property (e) now tests directly. Harmless in practice (a null payload has
// nothing to guard, and the throw landed in the same fail-open catch as the
// exit-0 it now takes deliberately) but the claim should be true as stated.
if (data === null || typeof data !== 'object') return data;
const raw = data.tool_name;
if (typeof raw !== 'string') return data;
const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
if (!mapped) return data;
data.tool_name = mapped;
if (data.tool_response === undefined && data.tool_output !== undefined) {
data.tool_response = data.tool_output;
}
const input = data.tool_input;
if (input && typeof input === 'object') {
// #2547 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
// not merely fill in when `file_path` happens to be absent. kimi-cli's file
// tools carry no `file_path` field at all (src/kimi_cli/tools/file/write.py,
// replace.py, @ 4a550ef — the SHA #2547 pins), and soul/toolset.py hands the
// model's raw json-parsed
// arguments to PreToolUse verbatim, doing typed validation only later inside
// tool.call() — after the hook has already decided. So a `file_path` in a
// Kimi payload is ALWAYS model-supplied, and under the old `=== undefined`
// condition it SHADOWED the field kimi-cli actually executes on. A payload
// pairing a cross-root `path` with a spurious `file_path: ""` left every
// guard reading an empty string and exiting 0, while the identical write
// without the extra key blocked — a bypass needing no crash at all. The same
// shadowing also preserved a NON-STRING `file_path` (`[]`), which threw
// inside gsd-worktree-path-guard's path.isAbsolute() and reached its outer
// `catch { process.exit(0) }`: the same crash-to-allow this fix closes
// elsewhere, reached through the guard's own read rather than through
// normalization. Overwriting can only ever narrow what a guard inspects to
// the path that will actually be written, so it cannot under-block.
if (typeof input.path === 'string') {
input.file_path = input.path;
}
const edits = Array.isArray(input.edit) ? input.edit
: (input.edit && typeof input.edit === 'object') ? [input.edit] : [];
if (edits.length) {
// #2547: `e?.old`, not `e.old` — `??` guards the value, not the
// dereference, so a NULLISH entry (`edit: [null]`) threw a TypeError
// here. normalizeKimiPayload runs before any tool dispatch, so that throw
// reached each guard's outer `catch { process.exit(0) }` and silently
// downgraded a should-BLOCK call into an allow. (A string/number entry
// never threw — `('x').old` is a legal read yielding undefined.)
//
// The String() coercion is guarded for the same reason: `{"toString":
// null}` is valid JSON that throws "Cannot convert object to primitive
// value", which is the identical crash-to-allow with a different
// trigger. Degrading only the non-coercible entry to '' keeps
// stringification intact for every value that CAN coerce (numbers,
// arrays, plain objects), so nothing downstream — including
// gsd-prompt-guard's scan of new_string — loses content it saw before.
const editText = (v) => { try { return String(v ?? ''); } catch { return ''; } };
// #2595 (review Major 2): reconstruct UNCONDITIONALLY, mirroring the
// `path` decision above rather than merely filling in when the field
// happens to be absent. kimi-cli's StrReplaceFile schema is `path` +
// `edit` only (src/kimi_cli/tools/file/replace.py @ 4a550ef) — it carries
// no `old_string`/`new_string` at all, so either field appearing in a
// Kimi payload is ALWAYS model-supplied, exactly like `file_path`. Under
// the old `=== undefined` condition a model-supplied `new_string: ""`
// SHADOWED the reconstruction, leaving gsd-prompt-guard's injection scan
// reading '' and exiting at its `if (!content)` before it ever saw the
// real `edit[].new` — a one-key bypass of the very scan this fix's
// guarded coercion exists to keep fed. A `typeof` test would NOT close
// it: a benign non-empty string shadows just as effectively as ''.
input.old_string = edits.map((e) => editText(e?.old)).join('\n');
input.new_string = edits.map((e) => editText(e?.new)).join('\n');
}
}
return data;
}
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 = normalizeKimiPayload(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 = bareToolName(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);
}
});