Conditions compose
Commands are one attack surface. Files are the other: an agent that writes TOKEN=sk-live-... into .env has just committed your secret to its transcript. This guard watches for exactly that — and nothing else. Three conditions, one veto: pick a tool, a path, and file content, and see which combination fires:
from captain_hook import Content, Event, FilePath, Tool, hook
hook(
Event.PreToolUse,
"BLOCKED: That looks like a live secret headed for a dotenv file. Reference an env var instead.",
# all three must match: a write, to a dotenv file, containing a secret-shaped assignment
only_if=[Tool("Edit|Write"), FilePath("**/.env*"), Content(r"(KEY|TOKEN|SECRET|PASSWORD)\s*=\s*\S")],
# the sanctioned exception: placeholder values in the committed example file
skip_if=[FilePath("**/.env.example")],
block=True,
)
The composition rules, in the order the widget just taught them:
only_if conditions AND together. Tool must match, path must match, content must match — miss any one and the hook stays quiet. Writing a secret to src/config.py passes (wrong path); writing prose to .env passes (no secret pattern).
skip_if is a veto. .env.example matches every only_if condition, and the skip wins anyway. Vetoes are how you carve the sanctioned exception out of a broad rule without weakening the rule.
A Read never trips a write guard. Tool("Edit|Write") scopes the hook to mutations. The agent can read .env all day — this hook has no opinion about that.