Another guard's test list found holes in mine
Contents
Builds on My guard blocked bash -c. Models write bash -lc., which explains the guard this post tests.
Comparing two guards for coding agents sounds like a code review: read theirs, read mine, note the differences, nod. I walked in with the posture of a visiting inspector. It turned into the best test run my guard has had, none of the test cases came from my own list, and the inspection went the other way.
Mine ships with the agent configs I maintain for six terminal coding agents, OpenCode among them, and its job is to deny destructive commands like rm -rf even under auto-approval, when no human clicks yes or no. It's a set of regex rules in one JSON file, read by two implementations, one in TypeScript and one in Python, which must agree on every one of about 1,130 test cases. The other one lives on a second test machine, in an OpenCode setup built separately, and it's a different design: it parses the shell, tracks the working directory and resolves paths against the project root. It comes with a deny list, a snapshot and restore of git internals, and a sandbox launcher. Mine has a sandbox too, a bubblewrap wrapper the agents can run inside.
I compared the two guards read-only, then ran each example command from the other setup through my guard's check, a command that prints the guard's verdict (deny, ask or allow) for one command line: 114 commands. I expected to feel smug, and for most of them I did. Mine was as strict or stricter. Then came the rest, and I kept my face very still.
What got through#
Every gap in this table now has test cases: the command that must be denied or must ask, plus a near miss that must still run. Each new case failed on the old rules before the fix, because a test that passes on the old code proves nothing.
| Command | My guard before | What it does, or why it slipped |
|---|---|---|
fuser -k 8080/tcp | allowed | kills whatever holds the port |
pgrep java | xargs kill | allowed | the process-control rule wanted a space after kill |
loginctl terminate-user, kill-session, disable-linger | not denied | ends your own session, or stops your services at logout, without root |
podman system reset | not denied | rootless; wipes all your containers, images and volumes |
sgdisk -Z /dev/sda | allowed | \bgdisk does not match inside sgdisk |
curl -o x.sh URL && bash x.sh | allowed | only the pipe form was denied |
unbuffer rm -rf ~/x | asked | unbuffer was missing from the command-position prefix |
yarn global add | not asked | the install rule expected the verb right after yarn |
| writes to submodule and worktree git hooks and config | allowed | only top-level .git/hooks and .git/config were protected |
Regex holes: a boundary, a space, a prefix, a verb#
Four of these are the same kind of mistake: a rule that matched what I pictured, not what can be typed. I had been enforcing my mental picture of the shell as if the shell had signed it.
gdisk was denied, and sgdisk -Z /dev/sda was not. The rule started with \bgdisk, and a word boundary needs a non-word character, or nothing at all, in front of the g. In sgdisk there's an s, so the rule never saw it. sgdisk is in the rule now, and cryptsetup luksFormat joined it.
kill $(lsof -t ...) asked for approval, as it should. pgrep java | xargs kill was allowed. The process-control rule needed a space after kill, a demand that kill at the end of a line has no way to meet. It asks now.
unbuffer rm -rf ~/x only asked where rm -rf ~/x is denied outright. The deny rules recognise a command after a set of wrappers, and unbuffer wasn't in the set. That prefix is pasted into nine rules, which is eight more places to forget the same word. And yarn global add slipped past the install rule because the rule expected the verb straight after yarn.
Commands I never pictured an agent typing#
fuser -k 8080/tcp kills whatever holds a port. This one wasn't hypothetical: on the other machine an agent ran exactly that and SIGKILLed the model router, the server handing out the models the agents run on. It ended the conversation from the agent's side of the table. fuser -k is now an entry in my kill-everything rule, so fuser -k 8080/tcp is denied, and fuser -v 8080/tcp is the near miss that still runs.
loginctl terminate-user, kill-session and disable-linger need no root when it's your own session. The first two end that session with every agent running in it; the third stops your services at logout. podman system reset is just as rootless and wipes every container, image and volume you have. All four are denied now.
curl URL | sh was denied, while curl -o x.sh URL && bash x.sh did the same thing in two steps and was allowed. The new rule ties the run to the downloaded name with a regex backreference, so it fires only when the file curl just wrote is the one being executed. cat x.sh, jq . out.json and tar -xzf go.tgz still run.
Git runs more files than I was protecting#
My guard protected .git/hooks and .git/config at the top level. Git also runs hooks and reads config from a submodule's directory under .git/modules/<name>/, nested submodules included, from a linked worktree's directory, and from config.worktree. Writes to all of those were allowed. So were git config gpg.program, which runs on every signed commit, and git --git-dir=... config core.pager. They're denied now, and inside my sandbox wrapper those files are mounted read-only, so a write that gets past the regex still hits a wall there. I took the news calmly, in the sense that I answered it with rules instead of adjectives.
The worst one wasn't a rule#
The most serious finding wasn't one of the 114 commands. The other setup's launcher refused to start OpenCode unless the guard plugin had loaded. It did that for a reason, and the reason applied to mine.
My TypeScript guard parsed its JSON rule files at import time, with no catch around it. OpenCode handles a plugin that throws while loading by logging it and running without it. So a corrupt rules file didn't mean a broken guard; it meant no guard, under auto-approval, with a log line to say so. OpenCode would have been perfectly honest about it: it logs that the plugin failed to load, then carries on. A guard removed with full paperwork.
A parse error now gives the same fallback as a missing file: every shell command is blocked. The Python guard already did that. I've written about a guard that couldn't start and blocked even echo hi. This was the opposite: a guard that couldn't start, and nothing blocked at all. Of the two, I know which one I'd rather have to explain.
What I didn't take#
Some of the other design I left where it was, with reasons, entered into the record:
- A third guard implementation, the shell parser. Two implementations already have to agree on about 1,130 cases. Two is a contract. Three is a committee.
- Its "allow everything" permission list and log-only mode. My templates promise destructive commands are blocked even under auto-approval, and logging isn't blocking.
- Restoring git internals after a session. In sandboxed runs the read-only mounts stop the write in the first place, which beats cleaning up after it.
Two OpenCode findings from the same machine#
The other setup also knew two things about OpenCode that mine didn't.
OpenCode's default header and chunk timeouts are 300 seconds. llama.cpp reads a long prompt before it sends anything back, and on slow hardware a dense 27B model needed about 270 seconds for a 12k-token first request. That's half a minute of headroom: a slightly longer prompt or a slightly slower box crosses 300 seconds, and OpenCode aborts a request that was working fine. Model discovery now sets the timeouts to 30 minutes.
The second is a trap worth knowing: opencode run "<prompt>" keeps the prompt in its argv. An agent's pkill -f <word from the prompt> matches OpenCode's own command line and kills it, rc 143: an agent ended by quoting its own instructions.
The credential paths that didn't match#
The comparison also showed my read rules were inconsistent. The gh token was denied; the glab token, which can delete GitLab projects, and rclone.conf, which holds cloud-drive tokens, were not. In this project a read restriction is a deliberate decision, never a side effect of a fix. I made that decision here, and both are denied now. Due process, observed in full.
The second guard had different blind spots from mine, which is the whole value of it: its example list is a test suite I couldn't have written, because I'd have written mine. My guard is stricter than it was by every row of the table above, and another design thought of each of them first. For the minutes: I took this well. The minutes are the table above, so mostly they show that I needed to.
Filed under: ai-agents, security, testing, opencode
Read next#
- My guard blocked bash -c. Models write bash -lc.: how the guard works, and another spelling it missed.
- One space in a path, and Hermes blocked echo hi: the fail-closed failure, the mirror image of the one above.
- Tests that could not fail: why every new case here had to fail on the old rules first.
- The test rig was the unsafe part: what isolating the suites that run these commands took.
Sources#
- Session artifacts (local, not published): the read-only comparison of the two guards, the 114
checkresults, the new guard cases with their near misses and their runs on the old rules, and the OpenCode timeout andpkillnotes from the second test machine.