Blocking destructive commands before an agent runs them
How a hook denylist and a local classifier screen coding agent shell commands, what each one missed when I tested them, and why I removed one.
Part of my notes on running coding agents with least privilege.
A coding agent runs shell commands with my permissions. If it decides that a recursive delete on the wrong directory or a force push to main is the fix, the shell does not ask who wrote the command. This post covers what I shipped to stop that, what I tested, and one layer I built and then removed.
While drafting this post my own tooling gave me a small example. I put a loop of test strings in a single shell command, and the guard blocked the whole command because one string contained a recursive delete of the root path. It scans text inside a command, not only the first word, and I had not expected that. Nothing ran.
Why a prompt is not a control
I have instructions telling agents not to do destructive things. Those instructions are guidance, and the model can ignore them or misread them. A control has to sit outside the model and run whether or not the model cooperates. In both Claude Code and OpenCode that place is a hook that runs before each tool call.
Layer 1: a pattern denylist
The guard is dcg (Destructive Command Guard), a third-party Rust binary. I use version 0.14.1. My Nix configuration downloads the release archive for my architecture from the project's GitHub releases page into ~/.local/bin/dcg if it is missing. I did not build it from source and I have not read its code, so I can describe its behavior only from its help output and my tests.
Claude Code calls it directly. config/claude/settings.json has a PreToolUse hook on Bash|PowerShell whose command is the dcg binary. OpenCode gets the same check through a plugin, ~/.config/opencode/plugins/dcg-guard.js, which dcg install --opencode generated. The plugin passes the command to dcg on stdin and throws an error if the verdict is deny or ask. Throwing stops the tool call.
dcg test reports a verdict without running anything. Here is what it said for nine strings. Exit code 1 means blocked.
| Command | dcg 0.14.1 |
|---|---|
rm -rf / |
blocked |
rm -rf /tmp/x |
allowed |
git push --force origin main |
blocked |
sh -c "rm -rf ~/x" |
blocked |
echo cm0gLXJmIH4v | base64 -d | sh |
blocked |
find . -delete |
blocked |
chmod -R 000 . |
allowed |
mv flake.nix flake.nix.disabled |
allowed |
cp -f /dev/null flake.lock |
allowed |
The encoded string decodes to a recursive delete of the home directory. The output pointed at the final sh in the pipe, so I read it as a rule about piping into a shell, not about decoding. I did not test other encodings, so I make no claim about them.
The last three rows are the interesting ones. Each can damage a working tree, and none matches a known-bad shape.
Layer 2: a local classifier, added and removed
Those misses led me to add a second gate on 2026-09-23 (commit 1a645b5). It used Laya, a small local model that answers yes or no questions with no language model call. My wrapper, scripts/laya-gate.py, asks whether a command is destructive and returns a probability.
I scored four commands the denylist missed and recorded the numbers in the plugin's header comment:
| Command | Score |
|---|---|
chmod -R 000 ~/dotfiles |
0.83 |
cp -f /dev/null flake.lock |
0.64 |
mv flake.nix flake.nix.disabled |
0.29 |
sd 'usman' 'nobody' *.nix |
0.24 |
Only the first was a clear catch. The plugin used two bands: block at 0.8 or above, log a warning at 0.55 or above. The rename and the mass replacement scored low with confidence. A low score there is a blind spot, not evidence of safety, and the plugin comment says so.
A later run of the chmod case through scripts/laya-gate.py gave 0.6854, not 0.83. I do not know why. The model may have changed, or my earlier run may have used different wording. Under the two-band rule, that score would have been a warning, not a block.
The gate is gone. Commit 5edcb95 on 2026-09-30 removed config/opencode/plugins/laya-approval-gate.js and its wiring in nix/home/ai-tools/opencode.nix. I expected to find an uncommitted deletion, but git status shows the removal is already committed. The commit message gives no reason, so I will not invent one. What the data shows is a layer that blocked one of four misses.
Guarding fetched content
Shell commands are not the only way an agent gets steered. A fetched web page can carry instructions. On 2026-10-15 (commit e20264f in my dotfiles repo) I extended webfetch-filter with a sanitiser and a session taint plugin. When a fetched page has a high-severity finding, the taint rule blocks side-effecting tools, such as writes, subagents and non-allowlisted bash, until my next real message. Read-only tools keep working.
I also measured laya as a prompt shield and as a critic, and rejected it as a blocker for both. On 12 hand-written attacks and 12 benign texts it caught 9 of 12 attacks against 5 of 12 for the regex sanitiser, but it flagged 3 to 4 of 12 benign texts against 1 of 12. As a critic it did not separate aligned from drifted tool calls. The corpus is small, so I treat these as smoke-test figures. Details are in the restricted MCP servers post.
I removed the git-restricted MCP server on 2026-10-15. It was never registered, and it only governed its own tools, so it never constrained bash. Removing it did not change what protects git.
Git rules on top of dcg
My earlier tests showed that dcg blocks git push --force, but I had not checked pushes to main. When I did, the live setup, before I added a pack, allowed git push origin main and every refspec form, while it blocked force pushes and reset --hard. The defaults do not protect main.
So I wrote a custom pack. It blocks pushes to protected branches in any form, force variants, and transport option injection. It also has an optional branch-prefix rule. Home-manager generates the config and the pack files. A test script, scripts/dcg-pack-test.py, passes 41 of 41 cases for my config, according to the handoff.
dcg loads a broken custom pack fail-open, with a warning, so I must rerun the test script after every pack edit. I have not verified any of this in a live agent session, because the config takes effect only after I apply it. The full story is in declarative git policy for agents.
The fail-open rule
Both layers fail open. The header of scripts/laya-gate.py says callers must treat a non-zero exit as no verdict, never as a block. The dcg plugin does the same: if the binary is missing or its output is not JSON, it lets the command through, and for a missing binary it logs to stderr. dcg also has a DCG_FAIL_CLOSED=1 setting for unparseable hook input, which I have not enabled.
The cost is plain. If the guard breaks, agents run unguarded and I might not notice. I chose this because a guard that blocks all work when it crashes is the guard I would switch off within a week.
What I would trust
I trust the pattern denylist for the shapes it names. Force pushes and recursive deletes outside temp paths were blocked in every form I tried, including inside sh -c. It is cheap, needs no model, and gives the same verdict every time.
I do not trust it for anything without a pattern. The chmod, the rename and the truncation all passed. I also do not trust the classifier as a gate. At 0.29 and 0.24 it called two damaging commands safe, and a score that moved between runs cannot be a threshold.
Neither layer limits what a permitted command can reach. If the agent may write to a directory, a guard cannot make that safe. That is a job for a sandbox with narrow mounts.
What I could not verify
- The internals of
dcg. I cite the version and install path only. - Whether
dcghandles encodings other than the one I tried. - Why the classifier score changed between runs.
- Whether the taint plugin works inside a live OpenCode session. The handoff says I tested it under node with a shim, not live.
- Whether the git pack blocks anything in a live session, since the config is not applied yet.
- Paraphrased injections that no regex catches, which never taint the session.
- Why I removed the gate, beyond the measurements above.