---
title: "Check shell commands"
description: "Understand command parsing and approval behaviour on current and older nodes."
canonical_url: "https://docs.oz0.ai/build/plugins/verified/guardrails/commands"
---
# Check shell commands

> Understand command parsing and approval behaviour on current and older nodes.

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](https://docs.oz0.ai/govern/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](https://docs.oz0.ai/agents/approvals). 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](https://docs.oz0.ai/govern/command-rules/command-reader#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:

<table>
<thead>
  <tr>
    <th>
      Default rule
    </th>
    
    <th>
      It sends to a person
    </th>
    
    <th>
      Such as
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        cmd.delete-home
      </code>
    </td>
    
    <td>
      Deleting a home, the root, a top-level folder, a folder above the working directory, or a path it cannot resolve, recursively
    </td>
    
    <td>
      <code>
        rm -rf ~
      </code>
      
      , <code>
        rm -rf /*
      </code>
      
      , <code>
        cd ~ && rm -rf *
      </code>
      
      , <code>
        rm -rf "$DIR"/
      </code>
      
      , <code>
        find ~ -delete
      </code>
      
      , <code>
        find / -exec rm {} +
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        cmd.disk-erase
      </code>
    </td>
    
    <td>
      Erasing a disk
    </td>
    
    <td>
      <code>
        mkfs.ext4 /dev/sdb1
      </code>
      
      , <code>
        wipefs -a /dev/sda
      </code>
      
      , <code>
        dd of=/dev/sda
      </code>
      
      , <code>
        > /dev/nvme0n1
      </code>
      
      , <code>
        shred /dev/sda
      </code>
      
      , <code>
        sgdisk --zap-all
      </code>
      
      , <code>
        diskutil eraseDisk
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        cmd.chmod-root
      </code>
    </td>
    
    <td>
      Changing permissions or owners of the root recursively
    </td>
    
    <td>
      <code>
        chmod -R 777 /
      </code>
      
      , <code>
        chown -R nobody /usr
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        cmd.fork-bomb
      </code>
    </td>
    
    <td>
      A fork bomb: a function that calls itself
    </td>
    
    <td>
      <code>
        :(){ :|:& };:
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        cmd.download-run
      </code>
    </td>
    
    <td>
      Running what it downloads, unread
    </td>
    
    <td>
      <code>
        curl ... | sh
      </code>
      
      , <code>
        wget -qO- ... | sudo bash
      </code>
      
      , <code>
        bash &lt;(curl ...)
      </code>
      
      , <code>
        sh -c "$(curl ...)"
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        cmd.default-branch
      </code>
    </td>
    
    <td>
      Rewriting or deleting a default branch
    </td>
    
    <td>
      <code>
        git push --force origin main
      </code>
      
       (and <code>
        main
      </code>
      
      , <code>
        master
      </code>
      
      , <code>
        trunk
      </code>
      
      , <code>
        develop
      </code>
      
      , or the current branch when none is named), <code>
        git push origin --delete main
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        cmd.git-clean-home
      </code>
    </td>
    
    <td>
      Deleting what git does not track in a home
    </td>
    
    <td>
      <code>
        cd ~ && git clean -fdx
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        cmd.cloud-delete
      </code>
    </td>
    
    <td>
      Deleting cloud resources
    </td>
    
    <td>
      <code>
        gh repo delete
      </code>
      
      , <code>
        gh release delete
      </code>
      
      , <code>
        kubectl delete namespace
      </code>
      
      , <code>
        kubectl delete ... --all -A
      </code>
      
      , <code>
        terraform destroy
      </code>
      
       and <code>
        apply -destroy
      </code>
      
      , <code>
        aws s3 rb --force
      </code>
      
      , <code>
        aws s3 rm s3://bucket --recursive
      </code>
      
      , <code>
        docker system prune -a --volumes
      </code>
      
      , <code>
        docker volume prune -a
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        cmd.unreadable
      </code>
    </td>
    
    <td>
      A command it cannot read with certainty
    </td>
    
    <td>
      an unclosed quote, a here-document without its end line, <code>
        eval
      </code>
      
      , a parameter or a substitution as the program (<code>
        $CMD
      </code>
      
      ), a shell that reads its commands from a pipe (<code>
        base64 -d | sh
      </code>
      
      ) or from a parameter (<code>
        bash -c "$X"
      </code>
      
      ), a <code>
        case
      </code>
      
       arm that falls through (<code>
        ;&
      </code>
      
      ) and <code>
        select
      </code>
      
      , a program that runs commands it does not read (<code>
        parallel
      </code>
      
      , <code>
        strace
      </code>
      
      , <code>
        tmux
      </code>
      
      , <code>
        unshare
      </code>
      
      , <code>
        systemd-run
      </code>
      
      )
    </td>
  </tr>
</tbody>
</table>

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](https://docs.oz0.ai/govern/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:

```bash [Terminal]
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](https://docs.oz0.ai/concepts/security/sandbox-tiers#sandboxes) 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](https://docs.oz0.ai/build/parts/harnesses/acp#tools-hooks-and-approvals)), 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 `Write` tool 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.


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
