Files
msd-core/hooks/gsd-secret-read-guard.js
Tom Boucher 241646a43a fix(#4651): classify .env names by final extension, and close the trailing-dot alias bypass — Phase 1 of #4636 (#4659)
* test(#4651): failing-first coverage for final-extension classification

Phase 1 of epic #4636, absorbing #4580. Tests only; no fix. These MUST fail.

The guard classifies a name by comparing everything after `.env.` as one
token against a set whose members are FINAL EXTENSIONS. So `.env.local.example`
yields suffix `local.example`, which is not a member, and a committed
secret-free template is refused. That is a category error, not strictness.

Two arms are covered because the same classification is hand-rolled twice in
one file: `isSecretBasename` for Read/Bash, and `globAltSelectsSecret`
(`lit.startsWith('.env.')`) for Grep globs. Fixing one alone would ship a
guard that allows `cat .env.local.example` while refusing
`Grep --glob '.env.local.example'` — the same file, the same hook, opposite
answers. A cross-arm parity loop over one shared list asserts the two cannot
drift.

Rows that exist because they are the ones nobody enumerates:

- `.env.example.local` must stay BLOCKED. Final extension is `local`; this is
  dotenv's documented local-override convention and a real secret. Any fix
  shaped as "contains example" admits it.
- `.env.local.` must stay BLOCKED — empty final extension is not a member.
- `.env.` must stay ALLOWED. Note #4580's proposed patch adds
  `if (suffix === '') return true;`, which flips it to blocked; that breaks the
  existing `allows` assertion in this suite and broadens the protected set,
  which epic #4636's non-goals forbid. Not applied.
- `.env.local.exam*` (partial glob literal) must stay BLOCKED — it can select
  `.env.local`, and a partial literal cannot be classified.
- `*.example` and `*` must stay ALLOWED — regression protection on the arm
  that already works.

Local behavioral repro of the current guard, confirming the tests fail for the
right reason rather than by construction:

  .env.local.example  rc=2 (blocked)   <- the defect
  .env.example        rc=0 (allowed)
  .env.local          rc=2 (blocked)
  .env.example.local  rc=2 (blocked)
  .env.               rc=0 (allowed)
  glob .env.local.example  rc=2        <- the second arm

Regressions are folded into the owning module's suite rather than a new
tests/fix-NNNN-*.test.cjs file, per scripts/lint-regression-test-names.cjs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4651): classify by final extension so .env.<name>.example is readable

Phase 1 of epic #4636, absorbing #4580. Implements ADR-4650 decision 5.

The guard compared everything after `.env.` as ONE token against a set whose
members are FINAL EXTENSIONS. `.env.local.example` yielded `local.example`,
which is not a member, so a committed, secret-free template was refused — the
guard blocked the one file that exists so nobody has to open the real `.env`.

That is a category error, not strictness. The fix is not "add local.example to
the set"; it is to compare the right token. hooks/lib/filename-classification.js
now owns that distinction and is the only place it is expressed.

Both arms are fixed, because the same classification was hand-rolled twice in
this one file:

  - isSecretBasename (Read/Bash) now tests finalExtension(suffix).
  - globAltSelectsSecret (Grep --glob) split its first branch. With no
    wildcard the alternative IS a whole filename, so it is classified exactly
    via isSecretBasename. With a wildcard present the literal is only a
    PARTIAL prefix (`.env.local.exam*` can still select `.env.local`) and
    cannot be classified, so the original conservative rule stays.

Fixing only the first would have shipped a self-contradicting guard: `cat
.env.local.example` allowed while `Grep --glob '.env.local.example'` refused —
same file, same hook, opposite answers. A cross-arm parity loop over one shared
list now asserts the two cannot drift.

Two deliberate departures from #4580's suggested patch, both verified:

  - Its `if (suffix === '') return true;` is NOT applied. That flips `.env.`
    from allowed to blocked, breaking an existing assertion in this suite and
    broadening the protected set, which epic #4636's non-goals forbid.
  - `fullSuffix` was drafted alongside finalExtension and removed before
    commit: zero production consumers, and none planned (Phases 2-4 are
    containment, duplicate draining and the path-join ratchet, none of which
    classify filenames). A zero-caller export is dead code. The distinction is
    pinned instead by a test asserting finalExtension('local.example') is
    'example' and explicitly NOT 'local.example'.

The protected set is unchanged. `.env.example.local` stays BLOCKED — its final
extension is `local`, dotenv's local-override convention and a real secret;
any fix shaped as "contains example" admits it.

Scoped out by measurement, not assumption: src/validate.cts:395 and
src/phase.cts:1674 also hand-roll lastIndexOf('.'), but both parse phase
identifiers (`3.2` -> parent `3`), owned by the phase-id.cts seam. Folding
them in would repeat this same category error in the opposite direction.

Checkpoint 1 (prove RED) on the tests-only commit 91d3d6e1: outcome=failed,
26 failures / 45330, all 26 in the two new test files, zero pre-existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4651): document the widened template exemption and cover the Bash arm

Two findings from the isolated adversarial review, both fixed in place.

1. The header's "Stated cost" passage named only the four literal template
   names, but since this change the exemption keys on the FINAL EXTENSION, so
   the trusted set is `.env.<anything>.{example,sample,template,dist}` — an
   unbounded family. The reviewer demonstrated it: `.env.prod-real-secrets.example`
   is allowed. That is the deliberate and necessary cost of fixing #4580, but
   it was materially larger than what the header disclosed, and a silent
   expansion of a security guard's trusted set is not acceptable. The passage
   now states the family, the concrete bypass, and that it applies across
   Read, Grep and Bash alike.

2. The cross-arm parity loop asserted Read and the exact-literal Grep glob but
   not Bash, whose `namesSecret` -> `isSecretBasename` path is genuinely
   distinct. The Bash arm was covered only by two one-off tests outside the
   shared table, so the table could not have caught a drift there. The loop now
   drives all three arms from the same TEMPLATES/SECRETS arrays.

No classification logic changed. The Read-arm behavioral table is byte-identical
before and after: rc=0 for .env.local.example / .env.example / .env. ; rc=2 for
.env.local / .env.example.local / .env / .secrets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4651): treat trailing dots and spaces as aliases of the protected file

Closes a Windows path-alias bypass surfaced by the isolated adversarial review
of this phase. Maintainer-approved as in scope.

Win32 strips trailing dots and spaces from every path component, so `.env.`,
`.env..`, `.env `, `.env. `, `.env .`, `.secrets.` and `.secrets ` all resolve
to the real `.env` / `.secrets` on Windows. The guard allowed every one of them
— a bypass of a file it already protects, reachable from Read, Grep and Bash
alike. `isSecretBasename` now normalizes the basename before classifying.

The whole class is fixed, not the reported name. `.env.` alone would have left
`.secrets.` and the trailing-space forms open, which is the same
one-cause-explains-every-failure trap this epic exists to close.

Two consequences, both measured rather than assumed:

  - `.env.example.` flips blocked -> ALLOWED. It aliases the already-trusted
    `.env.example` template, so this is correct; it was previously blocked only
    because the trailing dot broke final-extension parsing.
  - A Bash token that is exactly `.env` plus trailing whitespace flips
    allowed -> BLOCKED. Verified this is CONSISTENCY, not a new false-positive
    class: the bare `.env` token was ALREADY blocked as an operand in the same
    position before this change, so the alias now simply behaves like the thing
    it aliases.

The header's "No whitespace trimming" guarantee is preserved and now stated
precisely: leading and interior whitespace is still never trimmed, so prose
like a commit message mentioning `.env` in a sentence stays prose and stays
allowed. Only TRAILING dots and spaces are stripped. Two tests pin that.

This lands at the same behavior #4580's proposed `if (suffix === '') return
true;` would have produced for `.env.`, which this phase earlier rejected. The
rejection was correct on its stated grounds — that line broadens the protected
set, which epic #4636's non-goals forbid. The Windows framing is different:
normalizing an alias of an already-protected file is not a broadening, and the
fix is reached by normalization rather than by special-casing an empty suffix,
so it generalizes to `.secrets.` and the space forms.

Cannot be reproduced on this host — the remote matrix is Linux-only and Windows
coverage arrives from CI — so this ships on the Win32 path-normalization
contract plus the CI lane, and that limitation is stated rather than implied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4651): one owner for path segmentation, closing a Read/Grep divergence

Four findings from the two-axis review, all fixed in place.

The real one: the guard had TWO path-segmentation rules. `lastSegment` (used
by Read and Bash via `namesSecret`) splits on both `/` and `\`, while
`classifyGrepGlob` hand-rolled its own on `/` only. Measured:

  Read  of `config\.env`      rc=2  BLOCKED
  Grep  --glob 'config\.env'  rc=0  ALLOWED

Same logical file, opposite answers — precisely the divergence this epic
exists to remove, sitting inside the file this phase was already fixing.
`lastSegment` now lives in hooks/lib/filename-classification.js and both arms
call it. All five path-bearing cases (both separators) now agree.

Note on how this was nearly missed: the first measurement of it reported
"both allow", which looked like the reviewer was wrong. That reading was a
measurement artifact — `config\.env` inside a printf'd JSON payload is an
invalid escape, so the hook fails open at rc=0 and the test was observing
JSON breakage rather than the predicate. Re-measured with correct escaping,
the divergence is real. The tests added here use properly escaped literals
and were verified by running, not by reasoning about the escaping.

Also fixed:

  - Both fast-check properties were satisfied by a degenerate
    always-return-'' implementation: "never contains a dot / is a suffix" and
    "never ends with dot-or-space / is a prefix" are both trivially true of
    the empty string. They now additionally pin content preservation — the
    removed tail must match /^[. ]*$/, and a name with nothing to strip must
    come back unchanged.
  - The cross-arm parity loop used only bare basenames, so it could not have
    caught the divergence above. It now covers path-bearing names with both
    separators.
  - That loop's description overclaimed: Read and Bash BOTH route through
    `namesSecret`, so they are not independent paths; only the Grep glob arm
    is genuinely separate. The description now says so rather than implying
    three-way independence.
  - `normalizeWindowsBasename` runs on every platform, not only Windows. Its
    doc now states that explicitly: the guard must answer identically
    everywhere, and a name is judged by what Win32 would resolve it to.

No classification logic changed; the 12-name regression sweep is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4651): regenerate install-tree goldens, correct the guard's user-facing docs

Three things, all consequences of the fix rather than new behavior.

1. Install-tree goldens. `hooks/lib/filename-classification.js` is a SHIPPED
   file — package.json `files` includes `hooks` — so every per-runtime install
   tree gains a path. Checkpoint 2 failed on exactly this: 11 failures, all in
   tests/golden-install-tree.test.cjs, against 45356 passing. Regenerated via
   scripts/gen-install-tree-fixtures.cjs; 11 goldens changed, matching the 11
   failures one-for-one.

   This ripple was identified at design time and then not acted on. Fleet's
   impact preview named golden-install-tree.test.cjs before any code was
   written, and 40-design.md records it under "Ripples identified". Writing a
   risk down is not the same as discharging it, and a full matrix run was spent
   discovering something already known.

2. docs/USER-GUIDE.md made a precise and now-false claim about the guard's
   protected set: it named `.env.example` / `.sample` / `.template` / `.dist`
   as the four exempt names. The exemption keys on the FINAL EXTENSION, so the
   exempt set is the unbounded family `.env.<anything>.{example,sample,template,dist}`.
   The page now states that family, the widened residual, that order matters
   and only the last segment counts (`.env.example.local` is a secret), and
   that trailing dots and spaces are stripped because Windows resolves them to
   the protected file. A wrong user-facing model of what a security guard
   protects is worth correcting even though Fixed/Security changesets are
   exempt from the required-docs rule.

   docs/ARCHITECTURE.md and docs/INVENTORY.md say "templates such as
   `.env.example` exempt" — non-exhaustive, still true, deliberately left
   alone. Same for the ja-JP / zh-CN / ko-KR / pt-BR rows, which carry the same
   hedged phrasing; hand-translating a security description unreviewed is not
   something to do silently.

3. Two changeset fragments, not one. A refusal corrected is `Fixed`; a bypass
   closed is `Security`. Folding the second into the first would under-report
   it in the release notes. Both carry `pr: 0` for backfill once the PR exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4651): backfill changeset PR number to 4659

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 08:42:43 -04:00

1106 lines
44 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 (#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 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');
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 (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) {
// 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;
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);
}
});