JJ Juras.

Python and Claude · 3 of 6

The guard.

Before any tool call runs, a Python process reads the call as JSON and can say no. The rules live where a prompt cannot reach them.

Why Python holds this

A rule in a system prompt is a suggestion. The model reads it once, under pressure from everything else in the context, and a good enough argument talks it out of the rule. A rule in a hook runs every time, outside the model's context, and the model cannot see it, let alone argue with it.

The chief-of-staff agent runs on the Claude Agent SDK. A pre-tool hook receives each call and exits 2 to block it, feeding the reason back to the model. The deny list mirrors the agent's policy file: no payments, no bank connections, no repository edits, no package installs, no phone-line changes.

Honest status, October 15, 2026: a reviewer reading the write-up against the code found that the hook mediated only the Bash tool, so file edits were unmediated and an injected model could, in principle, have edited the hook itself. The fix was written and tested the same day: the hook now fires for every tool, denies any write under its own directory or the audit sink, denies on a parse error instead of allowing, and applies the deny list to MCP tool names as well. It is covered by 101 offline tests that feed JSON to the script and assert the exit code, and it merged to the main branch the same night. The code below is the version the reviewer read, kept here because the gap is the lesson.

The code

.claude/hooks/cos-guard.py, chief-of-staff agent, as it stood before the October 15 fix. Exit code 2 blocks the call.

DENY_PATTERNS = [
    (r"\bstripe\b",                              "stripe"),
    (r"\b(payment|payout|transfer|wire|ach)\b",  "payments"),
    (r"\b(git|gh)\b",                            "repo_edit"),
    (r"\b(curl|wget|nc|ssh|scp)\b",              "unknown_mutating"),
    (r"\b(pip|npm|npx|apt|brew)\s+(install|i)\b","unknown_mutating"),
    (r"\btwilio\b|\bphone.?line\b",              "phone_line_publish"),
    (r"\bplaid\b|\bbank\b",                     "bank_connect"),
    (r"\brm\s+-rf\b|\bsudo\b",                   "shell"),
]

def main() -> int:
    payload = json.load(sys.stdin)
    if payload.get("tool_name") != "Bash":
        return 0
    command = str((payload.get("tool_input") or {}).get("command", "")).lower()
    for pattern, code in DENY_PATTERNS:
        if re.search(pattern, command):
            print(f"Denied by policy ({code}).", file=sys.stderr)
            return 2
    return 0

What it does not stop

Obfuscation. A regex deny list is a tripwire, not a wall; g''it walks past it. The real guarantee is that this runtime holds no credentials for the systems on the list. The hook exists so an honest model gets a clear no, and so the attempt is visible in the record.

All six · Next: the chain · The write-up