install_secret_guard.py

Claude / Hooks / install_secret_guard

Type: block (can deny or stop) · Events: PreToolUse · Registrations: 1

install_secret_guard.py is the PreToolUse(Bash) guard against unpinned package installs, registry overrides and credential materialization. It fires before every Bash call, in every project, and exits 2 to block.

The command is split into shell segments by a quote-aware splitter: quoted text is masked first, so a | inside a grep pattern does not cut the command in the wrong place. Each segment is tokenised with shlex and judged twice. The install verdict refuses uv add, uv run --with, pip install <name> and uv pip install <name>, any install carrying a registry flag (--index-url, --extra-index-url, --index, -i), and an install under PIP_INDEX_URL or UV_INDEX_URL; it lets -r requirements.txt, -e ., uv sync, uv run, uv venv and uv lock through because those use the declared, pinned set. The secret verdict refuses secrets_cli.py with a read subcommand and a reader command (cat, head, less and similar) applied to a known secret store path. A refusal prints BLOCKED: <reason> on stderr and exits 2. A payload for a tool other than Bash, or an unreadable payload, exits 0; an exception while evaluating a readable command exits 2 so the guard fails closed.

Diagram claude-hooks-install_secret_guard-m537c9366d3

To satisfy it, declare the dependency in pyproject.toml, run uv sync, or ask Mohamed to approve the package by name; read secrets through the code's own environment seam. This is the mechanism behind RUL-003 and the dependency discipline in no-banned-deps. The same script is also copied into the plugin's project-hooks directory but only the hooks/ copy is registered. Related: convention_guard asks about pip install for a different reason (use the project's uv venv).

Registration

# Event Matcher Timeout Registered in Live in a $HOME session
54 PreToolUse Bash 10s orchestrata plugin 1.5.0 hooks.json:403 yes

Commands (as registered, abridged):

Location

Source

install_secret_guard.py (deployed copy)

291 lines.

#!/usr/bin/env python3
"""PreToolUse(Bash) guard: unpinned installs and credential materialization.

WHY A MECHANISM AND NOT A RULE. These four boundaries were written twice as
prose first -- once in ``autoMode.allow``, once in ``~/.claude/CLAUDE.md`` --
and both times they were read by a *model*: the auto-mode classifier weighs
them against competing entries and reaches a judgement. ``claude auto-mode
critique`` measured the result on 2026-08-23 and named two live holes: the
``Project Toolchain Execution`` allow entry "currently pre-approves ``uv run
scripts/secrets_cli.py decrypt ...``, which would otherwise be Credential
Materialization", and ``uv run --with <pkg>`` "could shadow Declared
Dependencies, Package Registry Bypass, and Untrusted Code Integration".

A boundary a model can weigh is advisory. This file is the same four boundaries
as a mechanism: a regex verdict over the command text, exit 2, no negotiation.
Per the hooks contract, "a hook that exits with code 2 stops the tool call
BEFORE permission rules are evaluated, so the block applies even when an allow
rule would otherwise let the call proceed" -- the ordering the prose version
could never reach, because a permission DECISION can be outranked by an allow
rule and an exit-2 cannot.

WHAT IT BLOCKS -- only an actual invocation, never a mention. ``grep -rn --
--index-url docs/`` is a search and passes: rule 2 only looks inside a segment
that already resolved to a pip/uv install. That is the same distinction
``force-graph-query.sh`` draws between naming ``GraphDatabase.driver`` and
constructing one, and it exists for the same reason -- a guard that fires on
vocabulary teaches people to route around it.

WHAT IT LEAVES OPEN -- the pinned path: ``uv sync``, ``uv sync --frozen``,
``uv pip install -r <file>``, ``uv pip install -e .``, ``uv run pytest``,
``.venv/bin/python``. Those resolve from the committed lockfile or manifest and
introduce no agent-chosen code.
"""

from __future__ import annotations

import json
import re
import shlex
import sys

# ---------------------------------------------------------------------------
# Command segmentation
# ---------------------------------------------------------------------------

_SEGMENT_SPLIT = re.compile(r"\|\||&&|[;|&\n\r]")


def mask_quoted(command: str) -> str:
    """Blank the INSIDE of quoted runs, preserving length and the quotes.

    Splitting on shell operators before masking is a measured defect: a ``|``
    inside a quoted grep pattern splits the command mid-quote and every offset
    after it is wrong. Masking first keeps indices aligned with the original,
    so a segment can be sliced back out of the raw text.
    """
    out = list(command)
    quote: str | None = None
    for i, ch in enumerate(command):
        if quote is None:
            if ch in ("'", '"'):
                quote = ch
        elif ch == quote:
            quote = None
        else:
            out[i] = " "
    return "".join(out)


def segments(command: str) -> list[str]:
    """Yield the raw text of each shell segment, quote-aware."""
    masked = mask_quoted(command)
    spans: list[tuple[int, int]] = []
    cursor = 0
    for match in _SEGMENT_SPLIT.finditer(masked):
        spans.append((cursor, match.start()))
        cursor = match.end()
    spans.append((cursor, len(masked)))
    return [command[a:b] for a, b in spans if command[a:b].strip()]


def tokens_of(segment: str) -> list[str]:
    try:
        return shlex.split(segment)
    except ValueError:
        return segment.split()


# ---------------------------------------------------------------------------
# Rules 1 and 2: installers and registry overrides
# ---------------------------------------------------------------------------

_PIP_EXE = re.compile(r"(^|/)pip[0-9.]*$")
_UV_EXE = re.compile(r"(^|/)uv$")
_PY_EXE = re.compile(r"(^|/)(python[0-9.t]*)$")
_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")

_MANIFEST_FLAGS = {"-r", "--requirement", "-e", "--editable", "-c", "--constraint"}
_REGISTRY_FLAGS = ("--index-url", "--extra-index-url", "--find-links", "-i")
_REGISTRY_ENV = re.compile(r"\b(PIP|UV)_(EXTRA_)?INDEX_URL=", re.IGNORECASE)


def install_verdict(tokens: list[str], segment: str) -> str | None:
    """Judge one segment that may be a package install."""
    if not tokens:
        return None

    idx = 0
    while idx < len(tokens) and _ASSIGNMENT.match(tokens[idx]):
        idx += 1
    if idx >= len(tokens):
        return None
    exe = tokens[idx]
    rest = tokens[idx + 1 :]

    is_uv = bool(_UV_EXE.search(exe))
    is_pip = bool(_PIP_EXE.search(exe))
    if _PY_EXE.search(exe) and rest[:2] == ["-m", "pip"]:
        is_pip, rest = True, rest[2:]

    if not (is_uv or is_pip):
        return None

    if is_uv:
        sub = rest[0] if rest else ""
        if sub == "add":
            return (
                f"`uv add` installs an agent-chosen package: {segment.strip()}\n\n"
                "Dependencies enter this project through pyproject.toml + uv.lock, "
                "reviewed, not through an install command. Add the requirement to "
                "pyproject.toml and run `uv sync`, or have Mohamed approve the new "
                "dependency by name."
            )
        if sub == "run" and (
            "--with" in rest or any(t.startswith("--with=") for t in rest)
        ):
            return (
                f"`uv run --with` pulls a package outside uv.lock: {segment.strip()}\n\n"
                "That is an unpinned, unreviewed dependency for the duration of the "
                "run. Declare it in pyproject.toml and `uv sync`, or run the command "
                "against the project venv without --with."
            )
        if sub == "pip" and len(rest) > 1 and rest[1] == "install":
            rest = rest[2:]
        else:
            return None  # uv sync / uv run / uv venv / uv lock: the pinned path
    else:
        if not rest or rest[0] != "install":
            return None
        rest = rest[1:]

    for tok in rest:
        if tok in _REGISTRY_FLAGS or any(
            tok.startswith(f"{flag}=") for flag in _REGISTRY_FLAGS
        ):
            return (
                f"Registry override in an install: {segment.strip()}\n\n"
                "Routing an install away from the lockfile's configured source is how "
                "a typosquat or a poisoned mirror enters the tree. Install from "
                "uv.lock, or have Mohamed change the configured index."
            )
    if _REGISTRY_ENV.search(segment):
        return (
            f"Registry override via environment: {segment.strip()}\n\n"
            "PIP_INDEX_URL / UV_INDEX_URL redirect the install to an unreviewed "
            "source. Install from uv.lock instead."
        )

    if any(tok in _MANIFEST_FLAGS for tok in rest):
        return None  # -r requirements.txt / -e . : declared and reviewed
    named = [tok for tok in rest if not tok.startswith("-")]
    if not named:
        return None
    return (
        f"Installing agent-chosen package(s) {', '.join(named)}: {segment.strip()}\n\n"
        "Every dependency in this project is declared in pyproject.toml and pinned in "
        "uv.lock. Add it there and `uv sync`, or have Mohamed approve the package by "
        "name."
    )


# ---------------------------------------------------------------------------
# Rule 3: credential materialization
# ---------------------------------------------------------------------------

_SECRET_TOOL = re.compile(r"(^|/)secrets_cli\.py$")
_SECRET_READ_SUBCOMMANDS = {"decrypt", "show", "cat", "print", "export", "edit", "view"}

_SECRET_PATH = re.compile(r"""(?x)
    (^|/)\.env($|\.)
  | /env/[^/\s]*\.env$
  | (^|/)secrets/
  | \.credentials\.json$
  | (^|/)credentials$
  | /huggingface/token$
  | client_secret_[^/\s]*\.json$
    """)

_READERS = re.compile(
    r"(^|/)(cat|bat|less|more|head|tail|strings|xxd|od|base64|cp|scp|rsync|curl|http)$"
)


def secret_verdict(tokens: list[str], segment: str) -> str | None:
    if not tokens:
        return None

    for i, tok in enumerate(tokens):
        if _SECRET_TOOL.search(tok):
            following = {t for t in tokens[i + 1 :] if not t.startswith("-")}
            if following & _SECRET_READ_SUBCOMMANDS:
                return (
                    f"secrets_cli.py would materialize a credential: {segment.strip()}\n\n"
                    "Decrypting a secret into the transcript or a file is Credential "
                    "Materialization. If a service needs the value, let the code read "
                    "it through its own env seam. If Mohamed wants a specific secret, "
                    "he names it and runs the command himself."
                )
            return None

    exe = next((t for t in tokens if not _ASSIGNMENT.match(t)), "")
    if not _READERS.search(exe):
        return None
    for tok in tokens[1:]:
        if tok.startswith("-"):
            continue
        if _SECRET_PATH.search(tok):
            return (
                f"Reading a credential file into the transcript: {segment.strip()}\n\n"
                f"{tok} is a secret store. Its contents must not enter chat, a commit, "
                "or an outbound request. Read the specific key through the code's env "
                "seam, or ask Mohamed."
            )
    return None


# ---------------------------------------------------------------------------
# Verdict + entry point
# ---------------------------------------------------------------------------


def verdict(command: str) -> str | None:
    """Return a denial reason for ``command``, or None to let it through."""
    if not command or not command.strip():
        return None
    for segment in segments(command):
        tokens = tokens_of(segment)
        for reason in (
            install_verdict(tokens, segment),
            secret_verdict(tokens, segment),
        ):
            if reason:
                return reason
    return None


def main() -> None:
    try:
        payload = json.load(sys.stdin)
    except Exception:  # noqa: BLE001
        # An unreadable payload means the harness contract moved, not that a
        # dangerous command was presented. Blocking every Bash call in every
        # project over a payload shape we have never seen would brick the
        # session; say so on stderr and stand down.
        print("install_secret_guard: unreadable hook payload", file=sys.stderr)
        sys.exit(0)

    if (payload.get("tool_name") or "") != "Bash":
        sys.exit(0)
    command = str((payload.get("tool_input") or {}).get("command") or "")

    try:
        reason = verdict(command)
    except Exception as exc:  # noqa: BLE001
        # A crash while judging a SPECIFIC command is different from an
        # unreadable payload: a command WAS presented and this guard could not
        # clear it. Denying is the honest answer.
        print(
            f"install_secret_guard could not evaluate this command: {exc}",
            file=sys.stderr,
        )
        sys.exit(2)

    if reason is not None:
        print(f"BLOCKED: {reason}", file=sys.stderr)
        sys.exit(2)
    sys.exit(0)


if __name__ == "__main__":
    main()