#!/usr/bin/env bash
#
# PreToolUse guard: a Bash call must not surface the contents of a secret file,
# meaning an env file or a credential store.
#
# Threat model: this is a guard against an honest
# agent's mistake (a recursive grep, a `cat` of the wrong file), not the
# enforcement boundary. A determined agent can build a path at run time or
# open the file from a script, and nothing that reads the command text can see
# that. The deny rules in .claude/settings.json and CI's secret scanning are
# the other layers.
#
# Course-Studio/baseplate's ADR BP-102 draws the line. In scope: a command an
# agent could plausibly type while working, such as a literal or quoted path
# in any reader, inline code, a simple glob, a path relative to where the
# command runs, a recursive sweep, an exclusion written the way the deny
# message advises, or a CLI that prints a stored credential. Out of scope, as
# deliberate evasion: brace expansion that builds a name, a glob or `./` or
# `..` segment in a store path, a path quoted or escaped in part, an
# abbreviated long option, flags or `--` slipped between a launcher and the
# program, `POSIXLY_CORRECT`. The boundary for evasion is the OS sandbox
# (Claude Code's sandboxed Bash, Codex's sandbox), which checks the file a
# process opens, however the command spells it. A review of this hook grades
# an out-of-scope bypass as minor and an in-scope one as blocking; a blocking
# finding names the ordinary work that would produce the command.
#
# Given that, the one place this guard is strict is failing
# CLOSED: a malformed settings.json, a crash, a scan past its deadline or a
# kill by the hook timeout all deny, because a guard that silently stops
# guarding is worse than a false deny.
#
# Credential stores are the built-in list below plus the `Read(~/...)`
# deny rules in .claude/settings.json, read at run time. Settings can add a
# path but never remove one, and the tests hold the two lists equal. Those
# rules stop the Read tool and a plain `cat`, but not `bash -c`, an
# interpreter, or a `cat` a rewriting hook has turned into `rtk read`. This
# guard applies rule 1 below to them, plus the inline-code check, whatever
# program names them. The rest of this header is about env files, where the
# guard started.
#
# The deny rules in .claude/settings.json used to name the *commands* that read
# `.env.local` (`cat`, `printenv`, `env`), so the protected thing was the
# command list, not the file. On 2026-07-31 a recursive `grep` from the
# repository root printed a live `HYGRAPH_TOKEN` into an agent transcript,
# because `grep` was not on the list. Nor are `sed`, `awk`, `head`,
# `strings`, `xxd`, or any script that opens the file itself. The set of
# programs that can read a file is open, so an allowlist of forbidden command
# names cannot be completed.
#
# This guard asks about the target instead:
#
#   1. Does any token in the command name a protected env file? Then deny,
#      whatever the program is. `sed .env.local`, `xxd .env.local` and
#      `./my-script .env.local` are all the same call as `cat .env.local`.
#   2. Does the command sweep the repository root without excluding env files
#      and Wrangler's `.dev.vars` (`--exclude='.env*' --exclude=.dev.vars`)?
#      Then deny, and say how to scope it. That is the shape that leaked; a
#      sweep that leaves out `.env*` alone still reads `.dev.vars`. An
#      exclusion counts only when its glob leaves out every env file name
#      (`--exclude=.env` keeps `.env.local`). A sweep below or beside the
#      root is looked up on disk instead: it is denied when the directory
#      holds an env file (unless excluded) or a credential store, and a sweep
#      of the home directory reads the stores whatever it excludes.
#
# Names are matched without regard to case, since macOS and Windows
# filesystems ignore it: `cat .ENV.LOCAL` opens `.env.local`. A CLI that
# prints a credential from a store no path names (`security
# find-generic-password`, `op read`, `gh auth token`, `git credential fill`,
# `printenv`) is denied
# wherever it sits, past a launcher too (`pnpm exec op read`), since the
# settings.json rules that deny it by command never look past one.
#
# Rule 1 is the structural half: it holds for a reader nobody has thought of.
# Rule 2 is a heuristic net: it has to recognise that a command traverses, and
# that means knowing the program, which is the very enumeration that failed. It
# covers the accident, not a determined reader; the honest limits are written
# out next to the deny list in `.claude/settings.json`.
#
# Precision matters as much as coverage: a command that merely *quotes*
# the name is not a command that reads the file. `git commit -m "document
# .env.local"` must go through. So the command is split into words the way the
# shell splits it (a backslash escapes, in `$'...'` too, `#` starts a comment
# only where a word starts, a heredoc body is data and never words), with each
# word's quotes kept, and a word counts only when the whole of it, minus its
# quotes wherever they sit (`".env".local` is `.env.local`), is the path: a
# multi-word string that happens to end in `.env.local` is prose, not an
# argument.
#
# Three deliberate exemptions, all cases where naming the file is the point and
# no value reaches the transcript:
#
#   - `.env.example` is checked in and carries no values.
#   - `node --env-file=.env.local <script>` loads the file into the process
#     environment, the way a `package.json` script loads its env. Combined
#     with an eval flag in any spelling (`-e`, `-p`, `-pe`, `--eval=`), though,
#     it becomes `node --env-file=.env.local -pe process.env.HYGRAPH_TOKEN`,
#     which is a reader again, so that combination is denied.
#   - `vercel env pull .env.local` writes the file; it is Vercel's documented way
#     to pull env vars. A guard that blocks a documented step is a
#     guard someone turns off.
#
# A command is judged one segment at a time, split on `&&`, `||`, `;`, `|` and
# a line break, with or without spaces around them: `cd app;grep -r X .` is two
# commands, as the shell runs it, and so is a sweep with no path followed by a
# second line. Whole-string judgement lets an exemption earned
# in one segment cover a reader in the next: `vercel env pull .env.local &&
# cat .env.local`. Within a
# segment, every word that names a reader or an interpreter is judged as one,
# so no wrapper, launcher, flag value or line break in front of it hides it:
# `pnpm --filter web exec grep -r X .`, `LC_ALL=C grep -r X .`. Shell code
# inside a word is a command of its own and gets every rule: a `$(...)` or
# backticks in any part of the word outside single quotes (`echo "$(cat
# .env.local)"`, and JSON spliced between single-quoted parts), the string a
# shell or a launcher's shell mode runs (`bash -c`, `npx -c`, `pnpm exec -c`),
# and the words `eval` runs. A variable that holds a protected name is that
# name wherever it is expanded (`x=.env.local; cat $x`). Code a shell reads on
# stdin (a pipe, a herestring, `source <(...)`) is never words of the command,
# so it is denied whenever the command names a protected file at all.
# A heredoc body inside a quoted `$(...)` stays prose (`--body "$(cat <<EOF`).
# A `$(...)` or `( ... )` is read the same way at every depth: its own quotes
# end nothing outside it, a case pattern's `)` in it closes nothing, and a
# comment in it (`# what's stored`) is skipped.
#
# `rg`, `git grep` and `find` get narrower rules than `grep`, because their
# defaults already do the right thing: `rg` skips hidden and gitignored files,
# `git grep` searches tracked files only, `find` prints paths rather than
# contents, and `.env`/`.env.local`/`.env.*.local` are all gitignored. Each is
# denied only when a flag switches that default off: `--no-ignore`/`--hidden`,
# `--untracked`, `-exec`. A glob or name that picks env files (`rg -g '.env*'`,
# `grep --include='.env*'`, `find -name '.env*' -exec`) is a read however
# narrow the path, and a find whose paths go on to `xargs`, a read loop or a
# substitution sweeps as `-exec` does, unless its expression keeps to other
# names (`-name '*.ts'`, with no `-o`); a find that only prints is a listing.
# So is `ls -a` or `git ls-files -o`, until its list reaches a reader. A
# redirect is not a path to search: `grep -rn X 2>/dev/null` has no path, so
# it sweeps the directory it runs in. A `cd` moves the relative paths after
# it: `cd .. && grep -rn X sibling/` searches the sibling, and `cd ~ && cat
# .npmrc` reads a credential store, as does inline or heredoc code that opens
# `.npmrc` there, or `"open(\".npmrc\")"`. So does a `cd` that opens a
# subshell, a group or a substitution (`( cd ~ &&`, `{ cd ~;`, `x=$(cd ~ &&`),
# and one in a subshell or a substitution ends with it. A `cd` keeps the path as bash does, symlinks and all, and a
# store rule also covers where its directories resolve, so a `~/.config` that
# links into a dotfiles repo is still `~/.config`. An exclusion after a bare
# `--` is a file name to grep, so it leaves nothing out.
#
# When the command cannot be tokenized, this guard denies if the raw text names
# a protected file at all, and otherwise judges it by every rule with the quote
# that never closes read as a literal, since bash runs the lines before the one
# it cannot parse.
#
# Cases both ways live in `env-file-guard.test.sh`; run it from the repo root.

set -euo pipefail
# Claude Code lets a call through when a hook exits with anything but 0 or 2,
# so a failure anywhere in this wrapper exits 2, which blocks: fail closed.
trap 'echo "env-file-guard failed and fails closed; run sh .claude/hooks/env-file-guard.test.sh" >&2; exit 2' ERR
# A kill by the hook timeout (settings.json gives 15s; the scan below stops
# itself at 6s) takes the same blocking exit.
trap 'echo "env-file-guard was stopped and fails closed" >&2; exit 2' TERM INT

input=$(cat)
command=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')
cwd=$(printf '%s' "$input" | jq -r '.cwd // ""')

[ -n "$command" ] || exit 0

reason=$(printf '%s' "$command" | python3 -c '
import os
import signal
import sys

# The scan stops itself well under the hook timeout, because a timeout kill
# lets the command through. ENV_FILE_GUARD_DEADLINE can only shorten it (the
# tests stub it to milliseconds); anything else means the default.
try:
    DEADLINE = min(float(os.environ.get("ENV_FILE_GUARD_DEADLINE", "6")), 6.0)
except ValueError:
    DEADLINE = 6.0
if not DEADLINE > 0:
    DEADLINE = 6.0


def timed_out(signum, frame):
    sys.stdout.write("cannot be checked: env-file-guard timed out after "
                     + str(DEADLINE) + "s and fails closed")
    sys.stdout.flush()
    os._exit(0)


signal.signal(signal.SIGALRM, timed_out)
signal.setitimer(signal.ITIMER_REAL, DEADLINE)

# A sweep below or beside the root is looked up on disk, and a walk that
# passes this many entries denies, since it could not look at the rest.
# ENV_FILE_GUARD_WALK_LIMIT can only lower it (the tests stub it).
try:
    WALK_LIMIT = min(int(os.environ.get("ENV_FILE_GUARD_WALK_LIMIT", "50000")), 50000)
except ValueError:
    WALK_LIMIT = 50000

import fnmatch
import glob
import itertools
import json
import re
import shlex

command = sys.stdin.read()

EXEMPT = re.compile(r"^\.env\.example$", re.I)
# A filename component, so the prefixed form matches `app.env.local` and not the
# tail of a sentence that happens to end in `.env.local`. `.dev.vars` is
# the local secrets file of Wrangler, denied to Read in settings.json. Case
# is ignored, as macOS and Windows filesystems ignore it.
PROTECTED = re.compile(
    r"^([A-Za-z0-9_.-]*\.env\.local"
    r"|\.env"
    r"|\.env\..+\.local"
    r"|\.env\.(production|development|test)"
    r"|\.dev\.vars)$",
    re.I,
)
# The same names as they appear inside a string of code.
EMBEDDED = re.compile(r"[A-Za-z0-9_./-]*\.env[A-Za-z0-9_.-]*", re.I)

# Flags whose value is a pattern, not a path: `--exclude .env*` names an env
# file in order to leave it out, which is the fix rather than the leak.
PATTERN_FLAGS = (
    "--exclude",
    "--exclude-dir",
    "--glob",
    "--iglob",
    "-g",
    "--ignore",
    "-name",
    "-iname",
    "-path",
    "-ipath",
)
# Names a glob is tried against: one that matches any of them picks env files.
ENV_NAMES = (".env", ".env.local", ".env.production", ".env.development", ".env.test", ".env.development.local", ".dev.vars")
ENV_FILE_FLAGS = ("--env-file", "--env-file-if-exists")
# What negates the next find primary, as a one-word list: `!`, `\!`, `-not`.
NEGATIONS = (["!"], ["\\!"], ["-not"])

# Programs that read many files without being told which.
RECURSIVE_BY_FLAG = {"grep", "egrep", "fgrep", "ggrep"}
GREPS = RECURSIVE_BY_FLAG
# `rg`-family: recursive by default, but hidden and gitignored files are
# skipped unless one of these is passed, or `-u`/`-.` alone or in a cluster.
RIPGREP = {"rg", "ag", "ack"}
IGNORE_OVERRIDES = {"--no-ignore", "--no-ignore-vcs", "--hidden", "--unrestricted"}
READERS = GREPS | RIPGREP | {"find"}
# Flags of the grep-likes that take the next word as their value: short letters,
# then long names (`--name=value` is one word already). Reading a value as the
# pattern reads the pattern as a path: `grep -rn -C 3 TOKEN` has no path.
# ponytail: the common flags only; a rare one whose value is a separate word
# still reads that value as the pattern. Add it here when one turns up.
GREP_VALUES = ("ABCDdefm", {
    "--after-context", "--before-context", "--context", "--devices", "--directories",
    "--regexp", "--file", "--max-count", "--label", "--include", "--exclude",
    "--exclude-dir", "--exclude-from", "--binary-files", "--group-separator",
    "--max-depth", "--ignore",
})
RG_VALUES = ("ABCEMTdefgjmrt", {
    "--after-context", "--before-context", "--context", "--regexp", "--file",
    "--glob", "--iglob", "--type", "--type-not", "--type-add", "--max-count",
    "--max-depth", "--max-filesize", "--max-columns", "--threads", "--encoding",
    "--replace", "--sort", "--sortr", "--pre", "--pre-glob", "--ignore-file",
    "--color", "--colors", "--engine",
})
# For these, the value of `-c`/`-e` is code rather than prose, so a filename
# inside it is a file about to be opened. So is a heredoc handed to one. Any
# word spelled like one counts, wherever it sits, so no launcher hides it:
# `pnpm --filter web exec node -e`, `NODE_ENV=test node -e`.
INTERPRETER = re.compile(r"python[0-9.]*|node|deno|bun|tsx|ruby|perl|php|sh|bash|zsh")
# The shell mode of a package manager runs its value too: `npx -c`, `pnpm exec -c`.
LAUNCHERS = {"npx", "pnpx", "bunx", "pnpm", "npm", "yarn"}
# A flag whose value is code: `-c`, `-e`, `-p` alone, in a cluster (`-pe`,
# `-lc`) or with the code attached (`-c"..."`), and the long forms, `=` or not.
CODE_FLAG = re.compile(r"-[A-Za-z]*[cep]|--(eval|print|call|shell-mode)(=|$)")
# The shells, and the flag that hands one, or a launcher in shell mode, a
# command string: `bash -c`, `bash -lc`, `npx -c`, `npm exec --call=`.
SHELL = re.compile(r"sh|bash|zsh")
SHELL_FLAG = re.compile(r"-[A-Za-z]*c[A-Za-z]*$|--(call|shell-mode)(=|$)")
# Search roots wide enough to reach the repository root, as written. A path
# that resolves to the root or above it counts too; see reaches_root.
ROOTS = {".", "./", "..", "../", "*", "./*", "/", "~", "~/", "$HOME", "$HOME/", "${HOME}", "${HOME}/"}
# The directory the command runs in (`cwd` in the hook input, argv[2]) and the
# project root: a sweep of either, or of a directory above them, reaches
# `.env.local`.
CWD = (sys.argv[2] if len(sys.argv) > 2 else "") or os.getcwd()
# Every directory the command runs in or a `cd` moves it to, for heredoc code,
# which is judged apart from the segments (see heredoc_code).
CWDS = {CWD}
DIRS = {
    os.path.realpath(path)
    for path in (CWD, os.environ.get("CLAUDE_PROJECT_DIR", ""), os.getcwd())
    if path
}

# Credential stores. The list is the `Read(~/...)` deny rules in
# .claude/settings.json, read at run time so there is one source of truth: a
# path those rules deny to the Read tool is denied here to every program, the
# shell and inline code included. The settings path is argv[1].
#
# The list below is always guarded, and the settings rules are added to it, so
# a settings.json that is missing, empty or stripped of its Read rules cannot
# switch the check off. The test suite holds the two lists equal rule for rule.
# A settings.json that is there but malformed fails closed: every command is
# denied until it is fixed.
DEFAULT_SECRET_RULES = [
    "Read(~/**/.env)",
    "Read(~/**/.env.local)",
    "Read(~/**/.env.production)",
    "Read(~/.ssh/**)",
    "Read(~/**/id_rsa)",
    "Read(~/**/id_ed25519)",
    "Read(~/.aws/**)",
    "Read(~/.gnupg/**)",
    "Read(~/Library/Keychains/**)",
    "Read(~/.claude/.credentials.json)",
    "Read(~/.config/gh/**)",
    "Read(~/.npmrc)",
    "Read(~/.config/op/**)",
    "Read(~/Library/Application Support/com.vercel.cli/**)",
    "Read(~/.config/neonctl/**)",
    "Read(~/.sentryclirc)",
    "Read(~/Library/Preferences/.wrangler/**)",
    "Read(~/.docker/config.json)",
    "Read(~/.git-credentials)",
    "Read(~/.config/git/credentials)",
    "Read(~/.netrc)",
    "Read(~/.pgpass)",
    "Read(~/.kube/config)",
    "Read(~/.config/gcloud/**)",
    "Read(~/.pypirc)",
    "Read(~/.cargo/credentials.toml)",
]
HOME = os.path.expanduser("~")
REAL_HOME = os.path.realpath(HOME)
# Home as written and as the filesystem resolves it, which is how a walk
# spells it.
HOMES = "(?:" + "|".join(re.escape(home) for home in sorted({HOME, REAL_HOME})) + ")"
# One path character: anything but whitespace, a quote or a shell operator.
PATH_CHAR = "[^\\s\x22\x27`;|&()<>]"


def glob_regex(pattern):
    """A settings glob (`**`, `*`) as a regex over one path."""
    tail = ""
    if pattern.endswith("/**"):
        pattern, tail = pattern[:-3], "(?:/" + PATH_CHAR + "*)?"
    out, index = "", 0
    while index < len(pattern):
        if pattern.startswith("**/", index):
            out, index = out + "(?:" + PATH_CHAR + "*/)?", index + 3
        elif pattern[index] == "*":
            out, index = out + "[^/\\s\x22\x27]*", index + 1
        else:
            out, index = out + re.escape(pattern[index]), index + 1
    # A longer name is a different file: `.npmrc.example` is not `.npmrc`.
    return out + tail + "(?![A-Za-z0-9_.-])"


def deny_list(path):
    """The settings deny rules, or the reason settings.json cannot be trusted."""
    try:
        with open(path, encoding="utf-8") as handle:
            text = handle.read()
    except FileNotFoundError:
        return [], None
    except (OSError, UnicodeDecodeError):
        return None, "cannot be read"
    try:
        settings = json.loads(text)
    except ValueError:
        return None, "is not valid JSON"
    if not isinstance(settings, dict):
        return None, "is not a JSON object"
    if "permissions" not in settings:
        return [], None
    if not isinstance(settings["permissions"], dict):
        return None, "has a permissions value that is not an object"
    if "deny" not in settings["permissions"]:
        return [], None
    deny_rules = settings["permissions"]["deny"]
    if not isinstance(deny_rules, list) or not all(isinstance(rule, str) for rule in deny_rules):
        return None, "has a permissions.deny that is not a list of strings"
    return deny_rules, None


def secret_rules(deny_rules):
    rules = []
    for rule in deny_rules:
        if rule.startswith("Read(~/") and rule.endswith(")"):
            pattern = rule[len("Read(~/") : -1]
            anywhere = pattern.startswith("**/")
            # The directories a rule names before any wildcard, as the
            # filesystem resolves them too: with `~/.config` a link into a
            # dotfiles repo, a walk or a `cd` meets `gh/hosts.yml` there.
            fixed = "/".join(itertools.takewhile(lambda part: not re.search(r"[*?[]", part), pattern.split("/")))
            real = os.path.realpath(os.path.join(HOME, fixed))
            spelled = HOMES + "/" + glob_regex(pattern)
            if fixed and real != os.path.join(REAL_HOME, fixed):
                inside = os.path.relpath(real, REAL_HOME)
                anchor = re.escape(real) if inside.startswith("..") else HOMES + "/" + re.escape(inside)
                spelled = "(?:" + spelled + "|" + anchor + glob_regex(pattern[len(fixed) :]) + ")"
            rules.append((
                re.compile(spelled, re.I),
                re.compile(glob_regex(pattern[3:]), re.I) if anywhere else None,
            ))
    return rules


DENY_RULES, SETTINGS_PROBLEM = deny_list(sys.argv[1] if len(sys.argv) > 1 else "")
if SETTINGS_PROBLEM:
    print(
        "cannot be checked: .claude/settings.json " + SETTINGS_PROBLEM + ", so the"
        " credential-store list is unknown and the guard fails closed. Fix the file"
        " with the Edit tool; Bash is blocked until then."
    )
    sys.exit(0)
SECRETS = secret_rules(DEFAULT_SECRET_RULES + DENY_RULES)
# Every store at once, for a walk that tests each path it meets.
STORE = re.compile("|".join("(?:" + full.pattern + ")" for full, _ in SECRETS), re.I)


def expand(text):
    """`~`, `$HOME`, `${HOME}` and an escaped space, as a shell would see them."""
    text = text.replace("\\ ", " ").replace("${HOME}", HOME).replace("$HOME", HOME)
    return re.sub(r"(^|[\s\x22\x27=:(])~(?=/)", lambda found: found.group(1) + HOME, text)


def secret_path(token):
    """Rule 1 for credential stores: the whole token is a protected path."""
    path = expand(dequote(token))
    for full, anywhere in SECRETS:
        if full.fullmatch(path):
            return path
        # `~/**/id_rsa` also covers a relative `backup/id_rsa`, but not prose.
        relative = not path.startswith("/") and not re.search(r"\s", path)
        if anywhere and relative and anywhere.fullmatch(path.rsplit("/", 1)[-1]):
            return path
    return None


def embedded_secret(text, cwds=()):
    """The first protected credential path inside a longer string, if any, a
    relative one looked up from each of `cwds`: `open(".npmrc")` run in the
    home directory opens `~/.npmrc`. A shell-escaped quote is the quote the
    code sees: `-c "open(\\".npmrc\\")"` opens `.npmrc`."""
    text = expand(dequote(text)).replace("\\\x22", "\x22")
    for full, _ in SECRETS:
        found = full.search(text)
        if found:
            return found.group(0)
    for word in re.findall(PATH_CHAR + "+", text) if cwds else ():
        if any(from_cwd(word, cwd) for cwd in cwds):
            return word
    return None


def from_cwd(path, cwd):
    """A relative path names a credential store from `cwd`, joined as `cd`
    keeps the directory (symlinks and all) or as the filesystem resolves it."""
    if not cwd or path.startswith(("/", "~", "$")):
        return False
    joined = os.path.join(cwd, path)
    return any(secret_path(where) for where in (os.path.normpath(joined), os.path.realpath(joined)))


def dequote(token):
    for quote in ("\"", "\x27"):
        if len(token) > 1 and token.startswith(quote) and token.endswith(quote):
            return token[1:-1]
    return token


def basename(token):
    return dequote(token).rsplit("/", 1)[-1]


def deny(message):
    print(message)
    sys.exit(0)


def embedded_name(text):
    """The first protected env filename inside a longer string, if any."""
    for match in EMBEDDED.findall(dequote(text)):
        name = basename(match)
        if PROTECTED.match(name) and not EXEMPT.match(name):
            return name
    return None


def picks_env(glob):
    """A glob, or a path glob'"'"'s last part, that matches an env file'"'"'s name.
    A name with no wildcard is one: any name PROTECTED matches, so
    `-name .env.staging.local` counts although no sample in ENV_NAMES is it.
    A glob counts whatever letters it spells: `.*` and `.[e]nv.local` expand
    to `.env.local`."""
    name = glob.rsplit("/", 1)[-1].lower()
    if PROTECTED.match(name) and not EXEMPT.match(name):
        return True
    return any(fnmatch.fnmatchcase(env, name) for env in ENV_NAMES)


def leaves_out(flag, pattern, name):
    """Whether an exclusion glob leaves out the env file `name` at every
    depth: find -path matches the whole path, grep, rg and find -name the
    name alone, so a slash there never matches one."""
    if flag in ("-path", "-ipath"):
        return all(fnmatch.fnmatchcase(where + name, pattern) for where in ("./", "./d/"))
    return "/" not in pattern and fnmatch.fnmatchcase(name, pattern)


def env_patterns(rest):
    """(excluded, picked): whether the pattern flags leave every env file and
    Wrangler'"'"'s `.dev.vars` out (`--exclude=.env* --exclude=.dev.vars`,
    `-g !.env* -g !.dev.vars`, `! -name .env* ! -name .dev.vars`), or pick
    them (`-g .env*`, `--include .env*`, `-name .env*`). An exclusion counts
    only for the names its glob matches: `--exclude=.env` keeps `.env.local`,
    and `--exclude-dir` skips directories, never a file."""
    out, picked = set(), False
    flags = PATTERN_FLAGS + ("--include",)
    index = 0
    while index < len(rest):
        start, token, value = index, rest[index], None
        if token in flags and index + 1 < len(rest):
            value = rest[index + 1]
            index += 1
        elif token.startswith(tuple(flag + "=" for flag in flags)):
            token, value = token.split("=", 1)
        elif token.startswith("-g") and len(token) > 2:
            token, value = "-g", token[2:]
        if value:
            value = dequote(value.rstrip(")`;"))
            negated = value.startswith("!") or rest[start - 1 : start] in NEGATIONS
            if token == "--exclude-dir":
                pass
            elif negated or token in ("--exclude", "--ignore"):
                out.update(name for name in ENV_NAMES if leaves_out(token, value.lstrip("!"), name))
            else:
                picked = picked or picks_env(value)
        index += 1
    return out == set(ENV_NAMES), picked


# A heredoc body is the stdin of its pipeline, and an interpreter, or a
# launcher of one (`pnpm tsx`, `uv run python -`), runs its stdin as code. So a
# body handed to one is judged as a `-c` value is. A body handed to
# anything else is data or prose, and rule 1 already reads its bare tokens.
HEREDOC = re.compile(
    r"(?<!<)<<(?!<)(-?)[ \t]*(?:\x27([^\x27\n]*)\x27|\x22([^\x22\n]*)\x22|\\?([^\s;&|<>()\x27\x22`]+))"
)
# What ends a pipeline; a pipe does not, so `cat <<EOF | node` counts. Nor
# does a substitution, so `node -e "$(cat <<EOF` and `x=$(node <<EOF` count.
PIPELINE_END = re.compile(r"&&|\|\||(?<![<>])&(?!>)|;")
# A whole quoted string in the pipeline is prose (`--title "fix node"`).
QUOTED = re.compile(r"\x22[^\x22]*\x22|\x27[^\x27]*\x27")


def heredoc_code(command):
    """The protected file a heredoc handed to an interpreter opens, if any.

    Not every `<<` opens a heredoc (`$((1 << 2))`, a quoted or commented one),
    so none may hide the lines after it: every line is read as an opener,
    body lines too, and every body runs from the next line to its delimiter.
    A body is at worst too long, which only denies more. A relative path in
    one is looked up from every directory the command reaches (CWDS).
    ponytail: prose quoting `python3 - <<EOF` before an env name now denies,
    and so does a relative store path after a `cd` that comes later; a shell
    lexer that knows real openers and where each runs would lift both."""
    lines = command.replace("\\\n", " ").split("\n")
    for number, line in enumerate(lines):
        for match in HEREDOC.finditer(line):
            delimiter = "".join(group or "" for group in match.groups()[1:])
            body = []
            for text in lines[number + 1 :]:
                if (text.lstrip("\t") if match.group(1) else text) == delimiter:
                    break
                body.append(text)
            # Each side on its own: a quote open across the heredoc is `"$(`.
            pipeline = (
                QUOTED.sub(" ", PIPELINE_END.split(line[: match.start()])[-1])
                + " "
                + QUOTED.sub(" ", PIPELINE_END.split(line[match.end() :])[0])
            )
            words = re.split(r"[\s()`\x22\x27]+", pipeline)
            if not any(INTERPRETER.fullmatch(basename(word)) for word in words):
                continue
            code = "\n".join(body)
            found = embedded_name(code)
            if found:
                return "opens the env file `" + found + "` from heredoc code"
            found = embedded_secret(code, CWDS)
            if found:
                return "opens the credential store `" + found + "` from heredoc code"
    return None


def heredoc_end(text, index, pending, bodies):
    """Where lexing resumes after the heredoc bodies that start at `index`,
    each body added to `bodies`; None when a delimiter never comes, so a
    `<<` that opened nothing cannot hide the lines after it."""
    found = []
    for strip, delimiter in pending:
        start = index
        while True:
            end = text.find("\n", index)
            line = text[index : end if end >= 0 else len(text)]
            if (line.lstrip("\t") if strip else line) == delimiter:
                found.append(text[start:index])
                index = end + 1 if end >= 0 else len(text)
                break
            if end < 0:
                return None
            index = end + 1
    bodies.extend(found)
    return index


def split_words(text, breaks="", bodies=None):
    """The words of `text` as the shell splits them, each with its quotes
    kept, which is how a quoted mention inside a longer string stays
    distinguishable from a quoted path. A quote runs to its closing quote, a
    backslash escapes the next character (inside double quotes and
    `$'"'"'...'"'"'` too), `#` starts a comment only where a word would
    start, and an unquoted character in `breaks` ends a word as whitespace
    does. An unquoted run of `;`, `&` and `|` is an operator word of its
    own, glued or not (`cd app;grep`),
    unless it belongs to a redirect (`2>&1`, `&>`, `>|`), and an unquoted
    line break is a `;`.

    A heredoc body is data, never words: an unquoted `<<` names a delimiter,
    and the lines from the next newline to it go to `bodies` instead, so an
    apostrophe in one (`It'"'"'s the plan`) opens no quote. Not in `$((1 << 2))`,
    and not when the delimiter never comes. An unclosed quote raises
    ValueError(text, where it opened)."""
    text = text.replace("\\\n", " ")
    bodies = [] if bodies is None else bodies
    words, start, quote, opened, pending, index = [], None, None, 0, [], 0
    while index < len(text):
        char = text[index]
        if quote:
            if char == quote[-1]:
                quote = None
            elif char == "\\" and quote != "\x27":
                index += 1
            elif quote == "\x22" and text.startswith("$(", index):
                # Its quotes are its own and end nothing out here:
                # `"$(echo "$(case a in a) :;; esac)" && cd ~)"` is one word.
                index = (closing(text, index + 2) or index + 1) - 1
        elif char.isspace() or char in breaks:
            if start is not None:
                words.append(text[start:index])
            start = None
            # A line break ends a command as `;` does, unless an operator
            # before it carries the command on: `a |` then `sh` is one pipeline.
            if char == "\n" and words and not re.fullmatch(r"[;&|]+", words[-1]):
                words.append(";")
            resume = char == "\n" and pending and heredoc_end(text, index + 1, pending, bodies)
            pending = [] if char == "\n" else pending
            if resume:
                index = resume
                continue
        elif char in ";&|" and not (
            char in "&|" and index and text[index - 1] in "<>" or text.startswith("&>", index)
        ):
            if start is not None:
                words.append(text[start:index])
            start = index
            while index + 1 < len(text) and text[index + 1] in ";&|":
                index += 1
            words.append(text[start : index + 1])
            start = None
        elif char == "#" and start is None:
            index = text.find("\n", index)
            if index < 0:
                break
            continue
        else:
            if start is None:
                start = index
            if char in "\x22\x27":
                quote, opened = "$\x27" if char == "\x27" and text[index - 1 : index] == "$" else char, index
            elif char == "\\":
                index += 1
            elif char == "<":
                opener = HEREDOC.match(text, index)
                before = text[text.rfind("\n", 0, index) + 1 : index]
                if opener and before.count("((") <= before.count("))"):
                    pending.append((opener.group(1), "".join(group or "" for group in opener.groups()[1:])))
        index += 1
    if quote:
        raise ValueError(text, opened)
    if start is not None:
        words.append(text[start:])
    return words


def unquoted(word):
    """A word after the shell removes its quotes: `"a \"b\""` is `a "b"`."""
    try:
        return "".join(shlex.split(word))
    except ValueError:
        return dequote(word)


def without_heredoc_bodies(text):
    """The text minus its heredoc bodies. A body in a quoted `$(...)` is
    data (a PR body, a commit message), and heredoc_code has already judged
    every body an interpreter runs."""
    kept, closing = [], []
    for line in text.split("\n"):
        if closing:
            if line.lstrip("\t") == closing[0]:
                closing.pop(0)
            continue
        kept.append(line)
        closing = ["".join(group or "" for group in match.groups()[1:]) for match in HEREDOC.finditer(line)]
    return "\n".join(kept)


def substitutions(word):
    """The commands a word runs in `$(...)` or backticks, in every part of it
    outside single quotes and `$'"'"'...'"'"'`, so JSON that splices a
    double-quoted `$(...)` in between single-quoted parts runs it. An escaped
    `\\$(` or backtick runs none. Each comes with whether it closes inside
    the word: the shell splits an unquoted `$(find . -type f)` across words."""
    word = without_heredoc_bodies(word)
    found, quote, index = [], None, 0
    while index < len(word):
        char = word[index]
        if quote == "\x27":
            quote = None if char == quote else quote
        elif char == "\\":
            index += 1
        elif quote == "$\x27":
            quote = None if char == "\x27" else quote
        elif char == "\x27" and not quote:
            quote = "$\x27" if word[index - 1 : index] == "$" else char
        elif char == "\x22":
            quote = None if quote else char
        elif char == "`":
            end = index + 1
            while end < len(word) and word[end] != "`":
                end += 2 if word[end] == "\\" else 1
            found.append((word[index + 1 : end], end < len(word)))
            index = end
        elif word.startswith("$(", index):
            end = closing(word, index + 2)
            found.append((word[index + 2 : (end or len(word) + 1) - 1], end is not None))
            index = (end or len(word)) - 1
        index += 1
    return found


def closing(text, start):
    """The index past the `)` that closes the `$(` or `(` whose code starts
    at `start`, or None when none does. The code is read the same way at
    every depth, each nested `$(` or `(` by this same scan: a quote, a
    heredoc body, a comment or a nested one hides a `)`, and so does an
    open case pattern, so the `cd` in `"$(x=$(case a in a) :;; esac); cd ~
    && cat .npmrc)"` runs inside the substitution. A backslash escapes in
    `$'"'"'...'"'"'` as it does outside quotes, and an apostrophe in a
    comment opens nothing (`# what'"'"'s stored for github.com`). A `#`
    inside a word starts none: after an escaped blank (`a\\ #b`) or the `)`
    of a `$(`, `<(`, `$((` or glob group (`$(git rev-parse HEAD)#L10`,
    `x(a)#`). After `name()` or `a=(...)` it starts one."""
    quote, pending, index, glued = None, [], start, None

    def word_start(at):
        return at != glued and (at == start or text[at - 1] in " \t\n;&|()")

    while index < len(text):
        char = text[index]
        if char == "\\" and quote != "\x27":
            index += 1
            # A line continuation is a blank, as split_words reads it.
            glued = glued if text[index : index + 1] == "\n" else index + 1
        elif quote and char == quote[-1]:
            quote = None
        elif char == "(" and (not quote or quote == "\x22" and text[index - 1] == "$"):
            # A `$(`, `$((`, `<(`, `>(` or zsh glob group (`x(a)`) ends inside
            # its word; a subshell, `name()`, `a=(...)` or `{(` does not, so
            # zsh reads a `#` after it as a comment.
            before = text[index - 1]
            inside = before in "$<>" or not (word_start(index) or before in "={" or text[index + 1 : index + 2] == ")")
            index = closing(text, index + 1)
            if index is None:
                return None
            glued = index if inside else glued
            continue
        elif quote:
            pass
        elif char in "\x22\x27":
            quote = "$\x27" if char == "\x27" and text[index - 1] == "$" else char
        elif char == "#" and word_start(index):
            index = text.find("\n", index)
            if index < 0:
                return None
            continue
        elif char == "\n" and pending:
            # As split_words: a `<<` whose delimiter never comes opened nothing.
            resume, pending = heredoc_end(text, index + 1, pending, []), []
            if resume:
                index = resume
                continue
        elif char == "<" and HEREDOC.match(text, index):
            opener = HEREDOC.match(text, index)
            pending.append((opener.group(1), "".join(group or "" for group in opener.groups()[1:])))
        elif char == ")" and not open_cases(text[start:index]):
            return index + 1
        index += 1
    return None


def shell_code(rest):
    """The command string a shell, or a launcher in shell mode, runs."""
    for index, token in enumerate(rest):
        token = dequote(token)
        if SHELL_FLAG.match(token):
            if token.startswith("--") and "=" in token:
                return unquoted(token.split("=", 1)[1])
            value = next((word for word in rest[index + 1 :] if not word.startswith("-")), None)
            return unquoted(value) if value else None
    return None


def eval_code(words):
    """The code `eval` runs: its words, unquoted and joined. When the first is
    a flag (`-m`; `-` and `--` end eval'"'"'s options instead), the first command
    names no program: bash'"'"'s eval rejects the flag and zsh finds nothing to
    run, so `git commit -m "eval" -m "see .env.local"` runs nothing. zsh still
    runs what follows an operator, and a first command that expands or
    redirects anything is judged whole."""
    code = " ".join(unquoted(word) for word in words)
    try:
        words = split_words(code)
    except ValueError:
        return code
    end = next((i for i, word in enumerate(words) if re.fullmatch(r"[;&|]+", word)), len(words))
    first = words[:end]
    if first and re.fullmatch(r"--?[A-Za-z][A-Za-z0-9-]*", first[0]) and not any(re.search(r"[$`<>(]", w) for w in first):
        return " ".join(words[end:])
    return code


def named_target(segment, cwd=None):
    """Rule 1: a token in this segment is the path of a protected env file
    or credential store, a relative one looked up from `cwd`."""
    # Indices whose env path is a destination or a load, not something printed:
    # `node --env-file=.env.local <script>` and `vercel env pull .env.local`.
    exempt, index = set(), 0
    while index < len(segment):
        token = dequote(segment[index])
        if token.startswith(ENV_FILE_FLAGS):
            if "=" in token:
                exempt.add(index)
            elif index + 1 < len(segment):
                exempt.add(index + 1)
                index += 1
        index += 1
    if [dequote(token) for token in segment[:3]] == ["vercel", "env", "pull"]:
        exempt.add(3)

    # An eval flag turns a load into a read, so it withdraws the exemption.
    if any(CODE_FLAG.match(dequote(token)) for token in segment):
        exempt = set()

    index = 0
    while index < len(segment):
        token = dequote(segment[index])

        # Skip a pattern flag and the value it takes.
        if token in PATTERN_FLAGS:
            index += 2
            continue
        if token.startswith(tuple(flag + "=" for flag in PATTERN_FLAGS)) or index in exempt:
            index += 1
            continue

        # A redirect or an operator glued to a path is not part of it:
        # `<.env.local`, `cat<.env.local`, `".env.local";`, `$(cat .env.local)`.
        # Only outside quotes, and not `(`, which would pull `.env.local` out of
        # code quoted in prose: `readFileSync(".env.local")`.
        for piece in split_words(segment[index], "<>);|&`"):
            found = names_secret(piece, cwd)
            if found:
                return found

        index += 1
    return None


def names_secret(raw, cwd=None):
    """Rule 1 for one token: it is a protected path, or a glob of env files.
    The token is judged as the shell hands it over, quotes removed wherever
    they sit: `".env".local` is `.env.local`. A multi-word string
    that ends in the name is still prose, since a name has no space. A
    relative path is looked up from `cwd`, the directory the command runs in
    or a `cd` moved it to: `cd ~ && cat .npmrc` names `~/.npmrc`."""
    token = unquoted(raw)
    candidate = token.split("=", 1)[1] if token.startswith("-") and "=" in token else token
    name = basename(candidate)
    if PROTECTED.match(name) and not EXEMPT.match(name):
        return "names the env file `" + candidate + "`"
    if secret_path(candidate) or from_cwd(candidate, cwd):
        return "names the credential store `" + candidate + "`"
    # A glob the shell expands to env files names them too, and it expands
    # only where a wildcard sits outside quotes: `".env"*`, not `".env*"`.
    if (
        re.search(r"[*?[]", QUOTED.sub("", raw))
        and name.startswith(".")
        and picks_env(name)
    ):
        return "names env files with the glob `" + candidate + "`"
    return None


def inline_code(rest, cwd):
    """An interpreter'"'"'s `-c`/`-e` value is code, and code that names the file
    opens it, a relative path from `cwd`. Prose that mentions it (a commit
    message, a PR body) does not. The code runs from the flag to the end of
    the segment, since the flag itself can carry it (`-c"..."`, `--eval=...`)."""
    for index, token in enumerate(rest):
        if CODE_FLAG.match(token):
            code = " ".join(rest[index:])
            found = embedded_name(code)
            if found:
                return "opens the env file `" + found + "` from inline code"
            found = embedded_secret(code, [cwd])
            if found:
                return "opens the credential store `" + found + "` from inline code"
            return None
    return None


def grep_args(program, rest):
    """A grep-like'"'"'s short flag letters, its long flags and its paths. A flag
    value is skipped, and the first bare word is the pattern unless `-e`/`-f`
    gave it. The letters of a cluster stop at a value flag: in `rg -trust`,
    `rust` is a value, not four flags. A bare `--` ends the flags: every word
    after it is the pattern or a path, `--exclude=...` included."""
    short, long_values = RG_VALUES if program == "rg" else GREP_VALUES
    letters, longs, bare, pattern_given, index = "", set(), [], False, 0
    while index < len(rest):
        token = rest[index]
        index += 1
        if token == "--":
            bare.extend(rest[index:])
            break
        if token.startswith("--"):
            name = token.split("=", 1)[0]
            longs.add(name)
            pattern_given = pattern_given or name in ("--regexp", "--file")
            if "=" not in token and name in long_values:
                index += 1
        elif token.startswith("-") and len(token) > 1:
            for position, letter in enumerate(token[1:], 2):
                letters += letter
                if letter in short:
                    pattern_given = pattern_given or letter in "ef"
                    if position == len(token):
                        index += 1
                    break
        else:
            bare.append(token)
    return letters, longs, bare if pattern_given else bare[1:]


def names_other_files(rest):
    """A find expression that cannot list an env file: every side of each
    `-o`, `-or` or `,` has a `-name`/`-iname`, not negated, that picks none
    (`-name "*.ts" -o -name "*.md"`). A negated group can list anything."""
    if any([token] in NEGATIONS and rest[index + 1 : index + 2] in (["("], ["\\("]) for index, token in enumerate(rest)):
        return False
    sides = [[]]
    for token in rest:
        if token in ("-o", "-or", ","):
            sides.append([])
        else:
            sides[-1].append(token)
    return all(
        any(
            token in ("-name", "-iname")
            and side[index - 1 : index] not in NEGATIONS
            and not picks_env(dequote(side[index + 1].rstrip(")`;")))
            for index, token in enumerate(side[:-1])
        )
        for side in sides
    )


def sweeps_root(program, rest, cwd, feeds):
    """Rule 2: this segment walks the tree wide enough to reach a secret file,
    or picks env files by glob or name for a program that reads them. `feeds`
    is whether its output reaches a program that opens the paths in it.
    A grep-like reads an exclusion after a bare `--` as a file name, so only
    the flags before one count: `grep -rn -- X . --exclude='.env*'` sweeps."""
    flags = rest if program == "find" or "--" not in rest else rest[: rest.index("--")]
    excluded, picked = env_patterns(flags)
    if program == "find":
        # Its paths come before the expression: `find apps -name x -exec ...`.
        # An escaped `\(` or `\!` starts the expression too.
        letters, longs = "", set()
        paths = list(itertools.takewhile(lambda token: not token.startswith(("-", "!", "(", "\\(", "\\!")), rest))
    else:
        letters, longs, paths = grep_args(program, rest)

    if program in RECURSIVE_BY_FLAG:
        # `-r`/`-R` alone or in a cluster, the long forms, and `-d recurse`.
        sweeps = (
            "r" in letters.lower()
            or "recurse" in rest
            or "--directories=recurse" in rest
            or bool(longs & {"--recursive", "--dereference-recursive"})
        )
        follow = "R" in letters or "--dereference-recursive" in longs
    elif program in RIPGREP:
        # A glob that picks a file overrides rg'"'"'s ignore rules for it.
        sweeps = picked or bool(longs & IGNORE_OVERRIDES) or "u" in letters or "." in letters
        follow = "L" in letters or "--follow" in longs
    elif program == "git-grep":
        sweeps = bool(longs & {"--untracked", "--no-exclude-standard"})
        follow = False
    elif program == "find":
        # `find` prints paths, and a path is not a value, until it hands the
        # file to something else: `-exec`, or its output to xargs, a read
        # loop or a substitution. Not when it keeps to other names.
        sweeps = (feeds or any(token in ("-exec", "-execdir", "-ok", "-okdir") for token in rest)) and (
            picked or not names_other_files(rest)
        )
        follow = "-follow" in rest
    else:
        sweeps = False
    if not sweeps:
        return None
    if picked:
        return "picks env files by glob or name for a program that reads them"

    # A bare `)` closes a subshell and is no path: `( grep -rn X )` sweeps.
    paths = [path for path in paths if path != ")"] or ["."]
    for path in paths:
        found = swept_secret(path, excluded, follow, cwd)
        if found:
            return found
    return None


def within(path, top):
    """`path` is `top` or below it."""
    return path == top or path.startswith(top.rstrip("/") + "/")


def resolve(path, cwd, absolute=os.path.realpath):
    """The absolute path a path word names, or None when only run time knows
    it: a variable or substitution (`"$PWD"`, `$(pwd)`), the `{}` find fills
    in, or a relative path after a `cd` this guard could not follow.
    `absolute` resolves symlinks, or with os.path.normpath keeps them."""
    # An operator glued after it is not part of it: `.;`, `.)`, `` .` ``.
    path = expand(re.split(r"(?<=.)[;&|)`]", path)[0])
    path = HOME if path == "~" else path
    if re.search(r"[$`]|\{\}", path) or (cwd is None and not path.startswith("/")):
        return None
    return absolute(os.path.join(cwd or "/", path))


def swept_secret(path, excluded, follow, cwd):
    """Rule 2 for one search path: why sweeping it reads a secret, if it does.
    The home directory or above holds the credential stores. The repository
    root or above, however it is spelled (`.`, `app/..`, `~/repos/app`, a
    path only run time knows), holds `.env.local`. Anywhere else is looked up
    on disk, except below the root when env files are excluded.
    ponytail: a launcher that runs elsewhere (`pnpm --filter`, `git -C`) is
    not followed, so a relative path under one is looked up from the cwd;
    follow it if one turns up."""
    where = resolve(path, cwd)
    # A glob reaches what its leading fixed directories do: `*` is the cwd.
    base = where and re.split(r"/[^/]*[*?[]", where, maxsplit=1)[0]
    if base is not None and within(REAL_HOME, base):
        return "sweeps the home directory, where the credential stores are"
    if base is None or re.split(r"(?<=.)[;&|)`]", path)[0] in ROOTS or any(within(root, base) for root in DIRS):
        return None if excluded else "sweeps the repository root without excluding env files and .dev.vars"
    if excluded and any(within(base, root) for root in DIRS):
        return None
    for top in glob.glob(where) if base != where else [where]:
        found = holds_secret(top, excluded, follow)
        if found:
            return "sweeps `" + path + "`, which holds " + found
    return None


def holds_secret(top, excluded, follow):
    """The first env file (unless excluded) or credential store at or below
    `top`, as the reason to give; None when there is none."""
    seen = 0
    for folder, dirs, files in os.walk(top, followlinks=follow):
        for name in files:
            path = os.path.join(folder, name)
            if PROTECTED.match(name) and not EXEMPT.match(name):
                if not excluded:
                    return "the env file `" + path + "`, without excluding env files and .dev.vars"
            elif STORE.fullmatch(path):
                return "the credential store `" + path + "`"
        seen += len(dirs) + len(files)
        if seen > WALK_LIMIT:
            return "more than " + str(WALK_LIMIT) + " entries, too many to check for env files and credential stores"
    return None


def changed_dir(words, cwd):
    """Where `cd` or `pushd` leaves the shell, or None when only run time
    knows (`cd -`, `cd "$DIR"`). The path is the logical one bash keeps in
    $PWD, symlinks and all, which is how a store rule spells it."""
    target = next((unquoted(word) for word in words if word == "-" or not word.startswith("-")), "~")
    return None if target == "-" else resolve(target, cwd, os.path.normpath)


# What opens a subshell, a group or a substitution before its program, alone
# (`(`, `{`, `$(`) or glued to it (`(cd`, `$(cd`, `` `cd ``, `x=$(cd`).
OPENER = re.compile(r"(?:[A-Za-z_][A-Za-z0-9_]*=)?(?:\$\(|[<>]?\(|`)+|[{!]$")
# A reserved word in command position runs the word after it:
# `if cd ~; then cat .npmrc; fi`, `while true; do env; done`.
RESERVED = {"if", "then", "else", "elif", "do", "while", "until", "time"}


def programs(segment):
    """The words a segment runs as programs, as (index, word minus its
    opener): the first past its assignments and reserved words, and the
    first after each opener."""
    found, start = [], True
    for index, token in enumerate(segment):
        opener = OPENER.match(token)
        word = token[opener.end() :] if opener else token
        if opener or start:
            start = not word or word in RESERVED or bool(re.match(r"[A-Za-z_][A-Za-z0-9_]*=", word))
            if not start:
                found.append((index, word))
    return found


def pattern_end(token, depth):
    """Where the case pattern that `token` is part of ends: the index past its
    `)`, or None, with the `(` depth it leaves open for the next word. A `(`
    in it opens its own `)` first (`$(echo x))`, `@(a|b))`), and the
    pattern'"'"'s commands may follow the `)` glued (`x)cd ~`)."""
    quote, index = None, 0
    while index < len(token):
        char = token[index]
        if quote:
            if char == quote:
                quote = None
            elif char == "\\" and quote == "\x22":
                index += 1
        elif char == "\\":
            index += 1
        elif char in "\x22\x27":
            quote = char
        elif char == "(":
            depth += 1
        elif char == ")":
            if not depth:
                return index + 1, 0
            depth -= 1
        index += 1
    return None, depth


def nesting(token):
    """How many subshells or substitutions a word opens (above 0) or closes
    (below 0) outside quotes, and whether it opens or closes a backtick one.
    A `$(...)` in double quotes is skipped whole, as `closing` reads it, so
    its own quotes and a case pattern'"'"'s `)` in it count for nothing:
    `x="$(echo "$(case a in a) :;; esac)")"` opens and closes none."""
    depth, ticks, quote, index = 0, 0, None, 0
    while index < len(token):
        char = token[index]
        if char == "\\" and quote != "\x27":
            index += 1
        elif quote and char == quote[-1]:
            quote = None
        elif quote == "\x22" and token.startswith("$(", index):
            index = (closing(token, index + 2) or index + 1) - 1
        elif quote:
            pass
        elif char in "\x22\x27":
            quote = "$\x27" if char == "\x27" and token[index - 1 : index] == "$" else char
        else:
            depth += {"(": 1, ")": -1}.get(char, 0)
            ticks += char == "`"
        index += 1
    return depth, ticks % 2 == 1


# CLIs that print a credential from a store no path names, and the words that
# make them print it: the macOS keychain, 1Password and gh (with `auth`).
CREDENTIAL_CLIS = {
    "security": {"find-generic-password", "find-internet-password", "dump-keychain", "export"},
    "op": {"read", "inject", "run", "item", "document", "signin"},
    "gh": {"token", "-t", "--show-token"},
}
# Words after which `env` or `printenv` is the program run: a launcher verb
# or a wrapper. Elsewhere `env` is often a name: `python3 -m venv env`.
RUNS_NEXT = {"exec", "dlx", "npx", "pnpx", "bunx", "xargs", "sudo", "command", "nohup", "time", "proxy", "rtk"}


def credential_cli(word, rest, runs):
    """Why running `word` with `rest` prints a credential, if it does; `runs`
    is whether `word` is a program the segment runs rather than a name in
    it. settings.json denies these by command, and those rules never look
    past a launcher (`pnpm exec op read`)."""
    if CREDENTIAL_CLIS.get(word, set()) & set(rest) and (word != "gh" or "auth" in rest):
        return "prints a credential with `" + word + "`"
    # A git credential helper asked for what it stores prints it:
    # `git credential fill`, `git credential-osxkeychain get`,
    # `git-credential-store get`, `gh auth git-credential get`.
    helper = (
        word == "git" and any(t == "credential" or t.startswith("credential-") for t in rest)
        or word.startswith("git-credential-")
        or word == "gh" and "auth" in rest and "git-credential" in rest
    )
    if helper and {"fill", "get"} & set(rest):
        return "prints a stored credential with `" + word + "`"
    if word == "printenv" and runs:
        return "prints the environment with `printenv`"
    if word == "env" and runs:
        # `env` with no command to run prints the environment.
        words = iter(rest)
        for token in words:
            if token in ("-u", "--unset", "-C", "--chdir"):
                next(words, None)
            elif not token.startswith("-") and "=" not in token:
                return None
        return "prints the environment with `env`"
    return None


# A redirect, its target glued (`2>/dev/null`, `2>&1`, `<<EOF`) or the next
# word (`> out.txt`). `<(...)` is a process substitution, not a redirect.
REDIRECT = re.compile(r"[0-9]*&?[<>](?!\()[<>&|]*(.*)", re.S)


def without_redirects(words):
    """The words minus redirects: `grep -rn X 2>/dev/null` has no path."""
    kept, target = [], False
    for word in words:
        found = None if target else REDIRECT.fullmatch(word)
        if not target and not found:
            kept.append(word)
        target = bool(found) and not found.group(1)
    return kept


def split_segments(tokens):
    """The segments of a command, each (words, fed, pattern), and how many
    case statements are still open at its end, past `in` with no `esac`.

    `vercel env pull .env.local && cat .env.local` is two commands: judging
    the whole string as one would let the exemption the first earns cover
    the second. A command after a `|` reads the one before on its stdin.
    A case pattern (`Darwin)`, `x|y)`, `(x)`), after `case WORD in` and
    after each `;;`, `;&`, `;;&` or `;|` until `esac`, is a segment of its
    own that runs nothing, so the arm'"'"'s commands start a segment
    (`case "$(uname)" in Darwin) cd ~ && cat .npmrc ;; esac`) and its `)`
    closes no subshell. Words that never reach a `)` are judged as commands."""
    segments, current, fed, pattern, parens, cases = [], [], False, False, 0, 0
    for token in tokens:
        op = dequote(token)
        operator = re.fullmatch(r"[;&|]+", op)
        # zsh ends `esac` at a glued `)`, redirect or `}`: `$(case ... esac)#L10`,
        # `esac>/dev/null`, `esac&>/dev/null`, `{ case ... esac}`.
        closes = not current and re.match(r"esac(?:[)`<>&}]|$)", token)
        cases -= 1 if closes and cases else 0
        if pattern and (operator or closes):
            # A `|` between alternatives, or a line break before a pattern.
            if op == "|" or op == ";" and not current:
                continue
            pattern = False
        if pattern:
            end, parens = pattern_end(token, parens if current else -1 if token.startswith("(") else 0)
            current.append(token[:end])
            if end is None:
                continue
            segments.append((current, False, True))
            current, fed, pattern, token = [], False, False, token[end:]
            if not token:
                continue
        if operator:
            if current:
                segments.append((current, fed, False))
            current, fed = [], op in ("|", "|&")
            pattern = bool(re.fullmatch(r";;&?|;[&|]", op))
        else:
            current.append(token)
            if token == "in" and any(word == "case" for _, word in programs(current)):
                segments.append((current, fed, False))
                current, pattern, cases = [], True, cases + 1
    if current:
        segments.append((current, fed, False))
    return segments, cases


def open_cases(code):
    """How many case statements `code` leaves open; 0 when it cannot be
    split, which closes the substitution where it did before. Tokenizing
    and segment-splitting is real work, paid at every `)` that would close
    a `$(` or `(` (`closing` calls this each time), so a code
    string with no `case` word at all -- most of them -- short-circuits:
    it can never open one."""
    if "case" not in code:
        return 0
    try:
        return split_segments(split_words(code))[1]
    except ValueError:
        return 0


def check(command, cwd=CWD, feeding=False):
    """Deny when a segment of this command reads a secret. Shell code inside
    it (a `$(...)` or backticks outside single quotes, the string a shell or
    a launcher in shell mode runs) is a command of its own, checked the same
    way. `cwd` is where it runs, None when only run time knows; `feeding` is
    whether its output becomes the words of a command, as a substitution does."""
    bodies = []
    try:
        tokens = split_words(command, bodies=bodies)
    except ValueError as error:
        found = embedded_name(command) or embedded_secret(command)
        if found:
            deny("mentions `" + found + "` in a command that could not be parsed")
        # Bash runs the lines before the one it cannot parse, so every rule
        # still applies, with the quote that never closes read as a literal.
        text, opened = error.args
        check(text[:opened] + "\\" + text[opened:], cwd)
        return

    # Rule 1 still reads a heredoc body'"'"'s bare words: `xargs cat <<EOF`
    # opens the paths in it.
    for body in bodies:
        for word in re.split(r"[\s<>);|&`]+", body):
            found = word and names_secret(word, cwd)
            if found:
                deny(found)

    # A variable that holds a protected name is that name wherever it is
    # expanded: `x=.env.local; cat $x`, `x="cat .env.local"; eval "$x"`.
    for token in tokens:
        name, _, value = token.partition("=")
        if value and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
            found = embedded_name(value) or embedded_secret(value)
            if found and re.search(r"\$\{?" + name + r"(?![A-Za-z0-9_])", command):
                deny("keeps `" + found + "` in `$" + name + "` and expands it")

    segments, _ = split_segments(tokens)

    # xargs and a read loop open the paths that arrive on their stdin, and a
    # command the output of this one becomes the words of may open them.
    reads_paths = feeding or any(dequote(token) in ("xargs", "read") for token in tokens)

    # The directory to go back to as each subshell or substitution closes: a
    # `cd` in `( ... )` or `$(...)` ends with it, one in `{ ...; }` lasts.
    scopes, ticked = [], False
    for segment, fed, pattern in segments:
        found = named_target(segment, cwd)
        if found:
            deny(found)

        # A shell or interpreter that reads its code from a pipe, a herestring
        # or `source <(...)` runs text this guard never sees as words, so the
        # command is denied when it names a protected file anywhere.
        # The interpreter counts wherever it sits in the fed segment, past an
        # assignment or a wrapper: `| FOO=1 sh`, `| env sh`.
        # A grep never runs its stdin, so an interpreter name after it is a
        # pattern: `| grep node`.
        # ponytail: a mention check, not a data flow one: a root sweep piped
        # into `sh` still passes. Follow the pipe if one turns up.
        runs = [] if pattern else programs(segment)
        program = basename(unquoted(runs[0][1])) if runs else ""
        streamed = False
        for index, token in enumerate(segment):
            word = basename(unquoted(token))
            nxt = dequote(segment[index + 1]) if index + 1 < len(segment) else ""
            if INTERPRETER.fullmatch(word) and program not in GREPS and (fed or any(dequote(t).startswith("<<<") for t in segment)):
                streamed = True
            if word in ("source", ".") and nxt.startswith(("<(", "/dev/")):
                streamed = True
        found = streamed and (embedded_name(command) or embedded_secret(command, [cwd]))
        if found:
            deny("feeds shell code to a program on its stdin in a command that names `" + found + "`")

        # Every interpreter or launcher shell mode, every reader, every file
        # list that reaches a reader, and every CLI that prints a credential,
        # wherever the word sits: past a wrapper or a launcher and their flags
        # and values (`rtk proxy grep`, `pnpm --filter web exec grep`), an
        # assignment, a line break, or inside `$(...)`, backticks or `<(...)`.
        for index, token in enumerate(segment):
            # A whole one becomes the words of this command; a split one is
            # judged by the words below, `$(find` as a find that feeds.
            for code, closed in substitutions(token):
                check(code, cwd, closed)
            # The word as the shell runs it, quotes and escapes removed:
            # `"eval"`, `e"val"` and `\grep` are `eval` and `grep`. A `)` or
            # backtick glued to the end closes a subshell or a substitution
            # split across words: `(git credential fill)`, `$(gh auth token)`.
            word = basename(unquoted(token.lstrip("$(<`").rstrip(")`")))
            rest = [dequote(t.rstrip(")`")) for t in segment[index + 1 :]]
            # Its output goes to a program that opens the paths in it.
            feeds = token[0] in "$<`" or reads_paths
            if INTERPRETER.fullmatch(word) or word in LAUNCHERS:
                found = inline_code(rest, cwd)
                if found:
                    deny(found)
            if SHELL.fullmatch(word) or word in LAUNCHERS:
                code = shell_code(segment[index + 1 :])
                if code:
                    check(code, cwd)
            # `eval` runs its words, joined, as a command: `eval "cat .env.local"`,
            # after any flag of a wrapper too (`exec -c eval`, `watch -t eval`).
            if word == "eval":
                check(eval_code(segment[index + 1 :]), cwd)
            # `printenv` is run as a program, not named: `( printenv )`, not
            # `echo printenv`.
            ran = any(at == index for at, _ in runs) or index and dequote(segment[index - 1]) in RUNS_NEXT
            found = credential_cli(word, rest, ran)
            if found:
                deny(found)
            # `ls -a` and `git ls-files -o` list `.env.local`; a reader opens it.
            if feeds and word in ("ls", "ls-files"):
                flags = [t for t in rest if t.startswith("-")]
                shows = "aA" if word == "ls" else "oi"
                longs = ("--all", "--almost-all") if word == "ls" else ("--others", "--ignored")
                if any(t in longs or not t.startswith("--") and set(t[1:]) & set(shows) for t in flags):
                    deny("hands the dotfiles `" + word + "` lists, env files among them, to a program that reads them")
            if word in READERS:
                # `git grep` is a grep that searches tracked files only.
                if word == "grep" and index and basename(segment[index - 1]) == "git":
                    word = "git-grep"
                found = sweeps_root(word, [dequote(t) for t in without_redirects(segment[index + 1 :])], cwd, feeds)
                if found:
                    deny(found)

        # A `cd` moves the relative paths of the segments after it, wherever
        # it runs: `( cd ~`, `{ cd ~`, `x=$(cd ~`, `` echo `cd ~ ``.
        moved = cwd
        for index, word in runs:
            if basename(unquoted(word)) in ("cd", "pushd"):
                moved = changed_dir(segment[index + 1 :], cwd)
        for token in [] if pattern else segment:
            depth, tick = nesting(token)
            if tick:
                depth, ticked = depth + (-1 if ticked else 1), not ticked
            scopes.extend([cwd] * depth)
            for _ in range(-depth):
                moved = scopes.pop() if scopes else moved
        cwd = moved
        CWDS.add(cwd)


check(command)
# After check, which has collected every directory a `cd` reaches (CWDS):
# `cd ~ && python3 - <<PY` opens `.npmrc` in the home directory.
found = heredoc_code(command)
if found:
    deny(found)
' "$(dirname "$0")/../settings.json" "$cwd" 2>/dev/null) && status=0 || status=$?

# A guard that crashed has not checked anything, so it must not allow.
if [ "$status" -ne 0 ]; then
  reason="cannot be checked: env-file-guard failed (exit $status) and fails closed. Run \`sh .claude/hooks/env-file-guard.test.sh\` to find the fault."
fi

[ -n "$reason" ] || exit 0

jq -n --arg reason "$reason" '{
  hookSpecificOutput: {
    hookEventName: "PreToolUse",
    permissionDecision: "deny",
    permissionDecisionReason: ("Blocked: the command " + $reason),
    additionalContext: "Env files and credential stores (the `Read(~/...)` deny rules in .claude/settings.json: ssh and gpg keys, cloud and CLI logins) hold live credentials, and this command would put their contents in the transcript. This guard matches on the target, not on the reading program, so switching from `cat` to `sed`, `awk`, `xxd`, or a script of your own changes nothing.\n\nIf you were searching the tree, scope the path to a directory that holds no env file (`grep -rn PATTERN app/`) or exclude both kinds of secret file (`grep -rn PATTERN . --exclude='"'"'.env*'"'"' --exclude=.dev.vars`, or `-g '"'"'!.env*'"'"' -g '"'"'!.dev.vars'"'"'` for rg); plain `rg PATTERN` is fine as it stands, since it skips hidden and gitignored files. If you need to know which variables exist, read `.env.example`: it is checked in and carries no values. If you need a value, ask the user for it; prefer a command that consumes the value without printing it, since anything printed here stays in the transcript."
  }
}'
