intercept_bash.py

Claude / Hooks / intercept_bash

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

intercept_bash.py is the PreToolUse(Bash) guard that stops files being written through the shell and stops any deletion command, in every project. It fires before each Bash call from the orchestrata plugin registration, so it is live in a $HOME-rooted session.

The script reads the payload from stdin and takes the command from tool_input.command (an earlier version read only a top-level command key, saw an empty string for every call and approved everything for its first day). It then runs two passes over the raw command text. Pass one looks for write-through-Bash shapes: .write_text(, open(..., 'w') or 'a', sed -i, tee without -a, and a > redirect whose target ends in .py, .sh, .json, .md, .toml, .yaml or .yml. For a redirect or tee the targets can be extracted from the text, and when every target starts with /tmp/claude- the command passes (the scratch escape). For write_text(), open() and sed -i the target cannot be read reliably, so they are refused unconditionally. Pass two looks for rm, unlink, rmdir and shred followed by whitespace, and for a >> into a path containing .claude/. The first match prints a deprecated top-level decision object and the hook returns: {"decision": "block", "reason": "Blocked: this writes a file through Bash (sed -i). Use Write or Edit ...", "additionalContext": "Original command: ..."}. Malformed stdin prints {} and allows.

Diagram claude-hooks-intercept_bash-m899b830f71

Known defects. The matcher reads raw text, not parsed shell, so text inside a quoted argument trips it: a commit message that mentions sed -i, a grep pattern containing rm , git rm, docker rm and rm inside a heredoc body are all blocked. It uses the top-level decision: block form that Claude Code has deprecated in favour of hookSpecificOutput.permissionDecision. It overlaps with the Write-tool guard intercept_write and with the formatter and convention hooks that Write and Edit trigger and Bash does not.

To satisfy it, use the Write or Edit tool for repository files, send throwaway output to a path under /tmp/claude-, and delete through a tool the harness allows rather than rm. If the pattern is only text in an argument, reword it or pass the text through a file. The shell-guards skill lists every guard and the satisfying form. Related: RUL-037 (Python only through guarded tools), RUL-035 (why this is a user-level hook), intercept_edit, test_run_context.

Earlier documentation

Source: claude/hooks/intercept_bash.py (orchestrata-ultimate-architecture) · 114 lines · Python 3

What it does

A minimal Claude Code PreToolUse(Bash) hook that reads the hook event JSON from stdin and blocks destructive shell commands before execution: any command containing rm, unlink, rmdir, or shred, plus any redirection into a .claude/ path. It emits a legacy-style JSON decision ({"decision": "block"|"allow", "reason", "additionalContext"}) on stdout. The @traces decorator from orchestrata.core.traceability marks it as implementing safety-guard requirements REQ-020-131 through REQ-020-143 (US-004, spec 020); a no-op fallback decorator keeps the script runnable standalone outside the repo. Note: it reads the command from the top-level command key of stdin rather than tool_input.command, and it prints {} (not an explicit allow) when stdin fails to parse.

Functions

traces(spec=None, story=None, reqs=None) (fallback definition)

No-op decorator factory defined only when orchestrata.core.traceability.traces cannot be imported (the real import is attempted after prepending the repo root — three directories up from the hook file — to sys.path). Returns the wrapped function unchanged so the script runs standalone.

Parameter Type Description
spec str | None Spec identifier for traceability (ignored in fallback).
story str | None User-story identifier (ignored in fallback).
reqs list | None Requirement ids (ignored in fallback).

main()

Hook entry point, decorated with @traces(spec="020", story="US-004", reqs=["REQ-020-131" ... "REQ-020-143"]). Parses stdin JSON, extracts command, and: (1) for any delete-command regex match, blocks with Bash-Delete-Blocked — with a special-case reason when the command touches a .claude/ path; (2) blocks any >/>> redirect into .claude/ with reason Bash-Claude-Dir-Blocked; (3) otherwise prints {"decision": "allow"}. Prints {} on malformed stdin.

Execution flow

Diagram claude-hooks-intercept_bash-1

Full source

#!/usr/bin/env python3
"""Intercept dangerous bash commands before execution.

PreToolUse(Bash) hook that blocks destructive commands.
"""

import json
import re
import sys

# Import @traces decorator if available (for RTM generation)
try:
    from pathlib import Path as ProjectPath

    sys.path.insert(0, str(ProjectPath(__file__).parent.parent.parent))
    from orchestrata.core.traceability import traces
except ImportError:
    # Define a no-op decorator for standalone execution
    def traces(spec=None, story=None, reqs=None):
        def decorator(func):
            return func

        return decorator


# ============================================================================
# US-004: Safety Guards (REQ-020-131 to REQ-020-143)
# ============================================================================


@traces(
    spec="020",
    story="US-004",
    reqs=[
        "REQ-020-131",
        "REQ-020-132",
        "REQ-020-133",
        "REQ-020-134",
        "REQ-020-135",
        "REQ-020-136",
        "REQ-020-137",
        "REQ-020-138",
        "REQ-020-139",
        "REQ-020-140",
        "REQ-020-141",
        "REQ-020-142",
        "REQ-020-143",
    ],
)
def main():
    """Block destructive bash commands."""
    try:
        data = json.load(sys.stdin)
    except json.JSONDecodeError:
        print(json.dumps({}))
        return

    command = data.get("command", "")

    # Check for destructive commands
    destructive_patterns = [
        (r"\brm\s", "Bash-Delete-Blocked"),
        (r"\brm\s+-", "Bash-Delete-Blocked"),
        (r"\bunlink\s", "Bash-Delete-Blocked"),
        (r"\brmdir\s", "Bash-Delete-Blocked"),
        (r"\bshred\s", "Bash-Delete-Blocked"),
    ]

    for pattern, block_type in destructive_patterns:
        if re.search(pattern, command):
            # Check for ~/.claude/ redirects
            if re.search(r"[>>]>.*\.claude/", command) or re.search(
                r"\.claude/", command
            ):
                print(
                    json.dumps(
                        {
                            "decision": "block",
                            "reason": "Blocked: redirect to ~/.claude/ is not allowed",
                            "additionalContext": f"Original command: {command}",
                        }
                    )
                )
                return

            print(
                json.dumps(
                    {
                        "decision": "block",
                        "reason": f"Blocked: {block_type}",
                        "additionalContext": f"Original command: {command}",
                    }
                )
            )
            return

    # Check for claude dir redirects
    if re.search(r"[>>]>", command) and re.search(r"\.claude/", command):
        print(
            json.dumps(
                {
                    "decision": "block",
                    "reason": "Bash-Claude-Dir-Blocked",
                    "additionalContext": f"Original command: {command}",
                }
            )
        )
        return

    print(json.dumps({"decision": "allow"}))


if __name__ == "__main__":
    main()

Registration

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

Commands (as registered, abridged):

Location

Source

intercept_bash.py (deployed copy)

210 lines.

#!/usr/bin/env python3
"""Intercept dangerous bash commands before execution.

PreToolUse(Bash) hook that blocks destructive commands.
"""

import json
import re
import sys


# ``traces`` is a no-op here by design: this hook runs standalone, outside the
# package, so the real decorator is not importable. Trace harvesting reads the
# decorator from the AST, not from the runtime object, so citations still reach
# the RTM.
def traces(spec=None, story=None, reqs=None):
    def decorator(func):
        return func

    return decorator


# ============================================================================
# US-004: Safety Guards (REQ-020-131 to REQ-020-143)
# ============================================================================


@traces(
    spec="020",
    story="US-004",
    reqs=[
        "REQ-020-131",
        "REQ-020-132",
        "REQ-020-133",
        "REQ-020-134",
        "REQ-020-135",
        "REQ-020-136",
        "REQ-020-137",
        "REQ-020-138",
        "REQ-020-139",
        "REQ-020-140",
        "REQ-020-141",
        "REQ-020-142",
        "REQ-020-143",
    ],
)
def main():
    """Block destructive bash commands."""
    try:
        data = json.load(sys.stdin)
    except json.JSONDecodeError:
        print(json.dumps({}))
        return

    # The live PreToolUse payload nests the tool's arguments under
    # "tool_input"; the top-level fallback keeps direct/legacy invocation
    # working. Reading only the top level made this guard see "" for EVERY
    # command and allow everything — including the write-through-Bash
    # bypasses it exists to stop. Measured 2026-08-14, the day it was first
    # registered: a python-heredoc write_text() edit of a repo test file
    # passed through unchallenged. The guard was deployed, registered, and
    # inert — it had never once run against the real schema.
    command = (data.get("tool_input") or {}).get("command") or data.get("command", "")

    # ── writing a file through Bash ─────────────────────────────────────────
    #
    # Write and Edit enforce read-before-write and fire the PostToolUse
    # formatter/convention guards. A Python heredoc calling write_text(), a
    # `sed -i`, a `tee`, or a `>` redirect reaches the same file with none of
    # that — the read-first rule binds the TOOL, not the filesystem.
    #
    # Measured 2026-08-14: an entire session's edits to test_server.py,
    # affected_tests.py, ship.sh, pytest_live_observer.py, register_hooks.py and
    # the memory files went through `uv run python - <<'PY' ... write_text()`,
    # every one of them bypassing a guard that was assumed to be covering them.
    # Nobody noticed, because from the outside the edits looked identical.
    #
    # Reads, greps and pipes are untouched: this fires only on a WRITE.
    #
    # Scratch escape: the block message has always promised "For a scratch
    # file outside the repo, say so and re-run", but until 2026-08-16 no
    # code path honored it — the same command re-ran into the same block
    # (measured: a pyright-report redirect into the session scratchpad).
    # For the two pattern classes whose TARGET is extractable from the
    # command text (redirect, tee), a command whose every write target
    # lives under the harness scratchpad namespace is allowed through.
    # write_text()/open()/sed -i stay unconditionally blocked: their
    # targets are not reliably extractable, so conservative wins there.
    scratch_prefix = "/tmp/claude-"

    def _extracted_targets(how: str) -> list[str] | None:
        """Write targets for pattern classes that expose them; None when
        the class hides its target (always block)."""
        if how == "shell redirect":
            return re.findall(
                r">>?\s*([^|&\s]+\.(?:py|sh|json|md|toml|yaml|yml))\b", command
            )
        if how == "tee":
            return re.findall(r"\btee\s+(?:-a\s+)?([^|&\s]+)", command)
        return None

    write_through_bash = [
        (r"\.write_text\s*\(", "python write_text()"),
        (r"\bopen\s*\([^)]*['\"][wa]\+?['\"]", "python open(..., 'w')"),
        (r"\bsed\s+-i\b", "sed -i"),
        (r"\btee\s+(?!-a\b)", "tee"),
        (r">\s*[^|&\s]*\.(py|sh|json|md|toml|yaml|yml)\b", "shell redirect"),
    ]
    for pattern, how in write_through_bash:
        if re.search(pattern, command):
            targets = _extracted_targets(how)
            if targets and all(t.startswith(scratch_prefix) for t in targets):
                continue
            print(
                json.dumps(
                    {
                        "decision": "block",
                        "reason": (
                            f"Blocked: this writes a file through Bash ({how}). "
                            "Use Write or Edit — they require the file to have "
                            "been read first and they run the formatter and "
                            "convention guards. Bash does neither, which is how "
                            "a whole session of edits skipped both on "
                            "2026-08-14.\n"
                            + (
                                # Say which escape EXISTS for this class. The
                                # message used to promise "say so and re-run"
                                # for all five, but only redirect and tee ever
                                # honored it -- measured 2026-08-21, three
                                # commands re-run verbatim into the identical
                                # block, then a fourth that said so explicitly
                                # in a comment. A stated affordance that does
                                # not exist costs more turns than none at all.
                                "Scratch: send it to a path under "
                                f"{scratch_prefix} and this is allowed."
                                if _extracted_targets(how) is not None
                                else "This pattern has NO scratch escape — its "
                                "target is not extractable from the command "
                                "text, so it is refused unconditionally. For a "
                                "throwaway file, redirect or tee to "
                                f"{scratch_prefix}… instead."
                            )
                            + "\nIf the pattern is only TEXT inside an argument "
                            "(a grep pattern, a --notes string, a commit "
                            "message) then nothing is being written: reword it "
                            "or pass the text via a file. This matcher reads "
                            "the raw command, not parsed shell."
                        ),
                        "additionalContext": f"Original command: {command}",
                    }
                )
            )
            return

    # Check for destructive commands
    destructive_patterns = [
        (r"\brm\s", "Bash-Delete-Blocked"),
        (r"\brm\s+-", "Bash-Delete-Blocked"),
        (r"\bunlink\s", "Bash-Delete-Blocked"),
        (r"\brmdir\s", "Bash-Delete-Blocked"),
        (r"\bshred\s", "Bash-Delete-Blocked"),
    ]

    for pattern, block_type in destructive_patterns:
        if re.search(pattern, command):
            # Check for ~/.claude/ redirects
            if re.search(r"[>>]>.*\.claude/", command) or re.search(
                r"\.claude/", command
            ):
                print(
                    json.dumps(
                        {
                            "decision": "block",
                            "reason": "Blocked: redirect to ~/.claude/ is not allowed",
                            "additionalContext": f"Original command: {command}",
                        }
                    )
                )
                return

            print(
                json.dumps(
                    {
                        "decision": "block",
                        "reason": f"Blocked: {block_type}",
                        "additionalContext": f"Original command: {command}",
                    }
                )
            )
            return

    # Check for claude dir redirects
    if re.search(r"[>>]>", command) and re.search(r"\.claude/", command):
        print(
            json.dumps(
                {
                    "decision": "block",
                    "reason": "Bash-Claude-Dir-Blocked",
                    "additionalContext": f"Original command: {command}",
                }
            )
        )
        return

    # Silent allow (contract 559c6920): emit nothing, exit 0 — the harness
    # rejects a top-level {"decision": "allow"} as invalid PreToolUse JSON.


if __name__ == "__main__":
    main()