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.
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):
python3 "${CLAUDE_PLUGIN_ROOT}"/hooks/install_secret_guard.py
Location
- Script:
plugins/orchestrata/hooks/install_secret_guard.py(repository path at8a2cb42ad) - Deployed:
/home/mohamed/.claude/plugins/cache/orchestrata/orchestrata/1.5.0/hooks/install_secret_guard.py - Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/8a2cb42ad/plugins/orchestrata/hooks/install_secret_guard.py
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()