# How I stop Claude Code from running destructive shell commands

> Source: <https://dev.to/martinpisacreta/how-i-stop-claude-code-from-running-destructive-shell-commands-5c98>
> Published: 2026-10-29 13:14:07+00:00

I want Claude Code to help me make small, reviewable changes. Recursive deletion and rewriting shared Git history deserve a separate decision. To catch some obvious cases before execution, I use a small Python command filter as an example in my consultation kit.

The idea is straightforward: inspect the proposed Bash command, look for selected patterns, and return a blocking response when one matches. The implementation is short enough to read alongside its tests.

There is an important limit behind the title. This is a text filter. It cannot establish that an arbitrary shell command is safe. I combine it with Claude Code permissions, restricted access, and GitHub branch protections.

The configuration example uses a `PreToolUse` hook with this matcher:

```
"matcher": "Bash"
```

That scope matters. The script understands the command field of a Bash tool event. Matching every tool would suggest coverage the implementation does not provide. If another tool name reaches the script, it returns successfully without inspecting that tool's input.

Inside the command hook entry, the example launches Python with:

```
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard.py"
```

I keep this in an example settings file until someone deliberately merges it into `.claude/settings.json`. Existing settings should be preserved. On Windows, the available Python launcher and the hook shell need checking. The kit suggests `py -3` as one possible replacement and calls out environment variable expansion for verification.

The script reads a JSON event from standard input and extracts `tool_input.command`. It never executes that string.

The filtering function starts with Python's `shlex`:

```
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
lexer.whitespace_split = True
tokens = list(lexer)
```

It walks those tokens, checks command names, and collects following arguments until it encounters a recognized separator. Using the basename lets the examples recognize both `rm` and `/bin/rm`.

This gives me a compact way to demonstrate command inspection. It does not reproduce Bash execution semantics. Quoting, nested expressions, scripts, and shell state make the real problem much larger than token matching.

When the filter finds a rejection reason, the entry point responds with:

```
if reason:
    print(reason, file=sys.stderr)
    return 2
```

The kit documents that exit code 2 blocks the tool call and returns stderr to Claude. Those fields and blocking semantics were checked against a working hook in a real Claude Code project. That check is separate from running this exact script inside Claude Code, which remains pending.

For deletion, the example looks for recursive `rm` options. Its tests cover `-rf`, `-R`, `--recursive`, and a command following `&&`. The message asks for a separate human review.

The filter allows a simple single-file deletion example. That does not mean every single-file deletion is appropriate. It means this particular policy targets recursive deletion rather than deciding whether every filesystem operation is authorized.

For Git, the policy is deliberately conservative about force pushes. It rejects selected force options on every branch, including `--force-with-lease`, and recognizes the leading-plus refspec example and `--mirror`. I do not describe those as equivalent Git operations. They are operations this example chooses to block.

The protected names are explicit:

```
PROTECTED = {'main', 'master', 'refs/heads/main', 'refs/heads/master'}
```

The implementation also checks selected ways to delete, recreate, or directly change those references. The test cases include force-moving a branch, recreating it with checkout, updating its reference, and deleting a remote branch.

A repository with another default branch name needs adaptation. The script does not discover that name or inspect the current branch.

My test suite launches the Python guard as a subprocess. It sends a JSON event through standard input and inspects the return code and, where asserted, stderr. The potentially destructive command remains a string.

The suite has seven test methods covering recursive deletion, force pushes, protected branch changes, ordinary commands, other tool names, malformed Bash events, and an unparseable command. Some methods contain several cases.

The kit records all seven passing on Linux and Windows. I keep that claim narrow: selected inputs produced the expected responses. It does not demonstrate complete shell coverage or validate every configuration detail in a customer's environment.

To run those harmless checks from the kit directory:

```
python3 -m unittest discover -s tests -v
```

I would verify hook activation separately in a disposable repository before relying on it. A passing Python test and an active Claude Code hook answer different questions.

The filter does not inspect the contents of scripts, aliases, interpreter programs, or alternate tools. It does not reliably understand nested commands. Equivalent destructive operations can escape its checks.

There is also a particularly relevant Git limitation: reset or rebase can change the current main branch without spelling its name in the command. This example does not detect that situation. Its protected-name checks cannot substitute for knowing repository state.

False positives are possible too. Harmless quoted text can resemble a command or an option the filter rejects. Token matching loses information about how the shell will interpret the original text.

I explain these limits before presenting the hook as useful. Its value is catching selected obvious patterns and making a rejected operation visible for review. Passing the filter is never proof of safety.

I pair the hook with Claude Code permissions and restricted filesystem and credential access. In GitHub, I use branch protections, required reviews, and checks to control shared changes. Deployment environment controls cover another set of decisions.

The repository instructions support that workflow: use a feature branch, inspect the diff, run relevant checks, and explain unresolved limitations in the pull request. Instructions describe expectations. Permissions and repository settings enforce the boundaries they actually control.

If your team wants help adapting this workflow, I offer paid consultations in English. We can work through repository instructions, tool permissions, a small reviewed change, and the path to staging. I use this hook as a readable starting point, with its assumptions and gaps visible throughout the discussion.
