Check shell commands
For: tenant administrators and plugin users.
Understand command parsing and approval behaviour on current and older nodes.
The command guard
A harness agent runs commands of its own: Claude's Bash, a Codex, Gemini CLI, OpenCode or goose session's execute calls and the terminals it opens through the client, and a remote session's bash. The platform decides each command such a session asks to run by the tenant's command rules, on by default on every node, with nothing installed: their default rules ask a person about the catastrophic commands below.
This plugin's command guard is the same check as a hook: it reads commands with the same reader and the same default rules (its command_rules.json is a copy of the platform's), and sends one that is catastrophic, or that it cannot read with certainty, to a person with the reason and the check's ID on the approval. A runtime that decides commands itself says so in the hook's event, and the guard then leaves the command to the command rules, so a person is asked once; the guard still asks on a node whose runtime does not.
It acts on functions named Bash, bash and execute. It never refuses a command: the person decides, and their no reaches the agent as a refused call, as any denial does. It asks no model, so it adds no model call and works without --accept-model's grant.
It reads a command as the shell would, never as text to match: quotes, parameters, $(...) and backquotes (read again as commands), brace expansion as bash and zsh make it ({rm,-rf,~} is rm -rf ~), lists, pipelines, subshells, if, while and for, functions, redirections and here-documents. It reads the wrappers that run another command again: sudo, doas, env, xargs, nice, nohup, timeout, command, exec, time, stdbuf, setsid, chroot, flock, watch, su -c, ssh's remote command, and sh -c, bash -lc and the other shells, nested as deep as they go.
Flags are read as the programs read them (-rf is -r -f, --recursive and -R are the same, -- ends the options, and so does a shell's lone -). A shortened long option counts as the option it shortens, as GNU tools and git read it (rm --rec is rm --recursive, git push --mirr is --mirror).
A wrapper's options decide which word is the command, so an option of a wrapper the check does not know, or a shortened one that could be more than one, makes the command one it cannot read. zsh's =rm, which bash runs as a program named =rm, is one it cannot read, and so is anything else zsh reads otherwise than bash (noglob rm, $=x, >!, zsh's path and NULLCMD). echo 'rm -rf ~' is only an echo.
It reads with certainty only what the command rules' reader models exactly (what the reader reads): a command with any other construct (a loop, a pattern where a match could be an option, such as ls *.txt, $(...), a variable the command did not set, sh -c '...', xargs, git -c) goes to a person as one it cannot read with certainty, and the card names the construct. A pattern among a listed program's files (ls docs/*.md, grep -r --include=*.go x .) is read, as the command rules read it.
It still reads such a command as far as it can, so a catastrophic one inside it (for f in a; do rm -rf ~; done) is sent with that check's reason.
A session's working directory and home are not known to the check, so it resolves paths against what the call carries: ~, $HOME, /home/*, /Users/*, /root, / and patterns over them count as a home or the root, .. is resolved, and a cd earlier in the command moves where relative paths start. A path that climbs two or more folders above the working directory counts as the same path from the root would, since the working directory may be that shallow.
A fixed alias counts as the path it names, on a path that climbed above the working directory too: /proc/self/root and /proc/<pid>/root as the root, /proc/self/cwd as the working directory, macOS's /System/Volumes/Data as the root and /private/etc as /etc; a path through an open descriptor (/dev/fd/3/x), a .. straight after a folder that is a link (/dev/fd, /proc/net, /proc/thread-self, /var/run) or another process's working folder is one it cannot resolve.
A path that starts with a parameter it was not given ("$DIR"), a substitution's output, or what xargs reads, is a path it cannot resolve, and counts as dangerous where a home would; so does a home with such a parameter after it ("$HOME"$X, ~$X), which is the home itself when the parameter is empty. In a path, a brace sequence counts as any name (~/{a..z} reads as ~/*), and alternatives beside a pattern count as one pattern over their folder (~/{a,b,c}* is most of a home).
The start points find -files0-from reads from a file are paths it cannot resolve.
The defaults, by the IDs the command rules name them by:
| Default rule | It sends to a person | Such as |
|---|---|---|
cmd.delete-home | Deleting a home, the root, a top-level folder, a folder above the working directory, or a path it cannot resolve, recursively | rm -rf ~, rm -rf /*, cd ~ && rm -rf *, rm -rf "$DIR"/, find ~ -delete, find / -exec rm {} + |
cmd.disk-erase | Erasing a disk | mkfs.ext4 /dev/sdb1, wipefs -a /dev/sda, dd of=/dev/sda, > /dev/nvme0n1, shred /dev/sda, sgdisk --zap-all, diskutil eraseDisk |
cmd.chmod-root | Changing permissions or owners of the root recursively | chmod -R 777 /, chown -R nobody /usr |
cmd.fork-bomb | A fork bomb: a function that calls itself | :(){ :|:& };: |
cmd.download-run | Running what it downloads, unread | curl ... | sh, wget -qO- ... | sudo bash, bash <(curl ...), sh -c "$(curl ...)" |
cmd.default-branch | Rewriting or deleting a default branch | git push --force origin main (and main, master, trunk, develop, or the current branch when none is named), git push origin --delete main |
cmd.git-clean-home | Deleting what git does not track in a home | cd ~ && git clean -fdx |
cmd.cloud-delete | Deleting cloud resources | gh repo delete, gh release delete, kubectl delete namespace, kubectl delete ... --all -A, terraform destroy and apply -destroy, aws s3 rb --force, aws s3 rm s3://bucket --recursive, docker system prune -a --volumes, docker volume prune -a |
cmd.unreadable | A command it cannot read with certainty | an unclosed quote, a here-document without its end line, eval, a parameter or a substitution as the program ($CMD), a shell that reads its commands from a pipe (base64 -d | sh) or from a parameter (bash -c "$X"), a case arm that falls through (;&) and select, a program that runs commands it does not read (parallel, strace, tmux, unshare, systemd-run) |
The card's reason says which: "a catastrophic command: it deletes a home folder (~) and everything in it (rm -rf ~)",
or "could not read this command: a single quote is not closed". The rules are data, each with its reason and examples
that are checked when they load. Other commands run as the job's policy decides; rm -rf build, git push -f origin feature and chmod -R u+w ~/project are not catastrophic.
To change what is asked about, write command rules: turn a default off for one agent, make it refuse, or allow one narrow command. They take a reason, and a loosening waits for a second admin where four eyes is on:
oz0 policy allow --command 'terraform destroy -target=module.test.*' --agent infra-bot \
--reason "infra-bot destroys its test modules"
Turning this plugin's guard off for the tenant or an agent (GUARDRAILS_CONFIG commands: off, a governed change
with a reason) stops only its own hook; the platform's command rules still decide.
The guard is not a boundary; the sandbox is. It sees only the commands a harness asks about before it runs them. It does not see:
- commands a harness runs without asking: Codex runs commands in its read-only sandbox, and Gemini CLI runs its
read-only tools, without asking (see which harnesses ask), and
a harness that does not ask before its calls at all (one without
permissions) never reaches it; - what a program does once it runs: a script file (
bash deploy.sh,make clean), code in another language (python -c,node -e), a downloaded file run later (curl -o x.sh ... && sh x.sh), or a package's binary (npx,uvx); - what a session's other tools do: a file written by its
Writetool and run later, or a deletion through a tool that is not a shell; - the session's real directory and variables: a path it cannot resolve is sent to a person rather than guessed, but
each call is read alone: a parameter set by an earlier call of the session is unknown to it, and a relative path is
read against the working directory, so after an earlier call's
cd ~(a harness's shell may keep its directory between calls)rm -rf *looks like clearing the working directory, which it allows.
So it catches a model's mistake and the plain forms of an attack, not an attacker who knows these rules and writes a
script around them. Keep sessions in tier 1, give an agent only the tools it needs, and keep harness.requires_approval
where a person should see every command.