Command rules

What the command reader understands

What the command reader understands for tenant admins and governance reviewers.

For: tenant admins and governance reviewers.

What the reader reads

The reader reads a command as bash runs it, and it reads only what it models exactly. A command is readable when every construct in it is one of those below; anything else is unreadable, and the floor asks. This is the safe way round: a reader that tries to know every way a command can be dangerous never stops finding more of them, while one that asks about everything it has not modelled can only be wrong by asking too often.

Evidence for supported syntax

Each construct below has a row in the shared cases that bash 5.2 runs in a sandbox with stand-in programs, so the reader's reading of it is checked against what bash does, and the list grows only that way.

The wrappers coreutils and util-linux install (timeout, nice, env, nohup, setsid, flock and the like) run as themselves there, so how the reader reads their options is checked against theirs; sudo, doas, chrt and the other wrappers that need a privilege run as stand-ins, and how it reads their options is checked by the cases' answers alone.

Supported command forms

  • Simple commands: a program and its words, each literal or plainly quoted (single quotes, double quotes, a backslash, a $'...' string whose escapes make ASCII characters other than a NUL, where bash ends the string and zsh does not), with comments and lines joined by a backslash.
  • Lists and pipelines: commands joined by ;, a newline, &&, ||, | and |&, a pipeline after ! or the time keyword.
  • Redirections to or from a literal path (<, >, >>, >|, <>, &>, &>>), a descriptor copied or closed (2>&1, >&2, 2>&-), a here-document whose end word is quoted or whose body holds no $, backquote or backslash, and a here-string of literal text.
  • Variables the command sets itself: name=text for a name neither bash nor zsh gives a meaning of its own, before a command for that command alone, or in export name=text; and $name, ${name} and "$name" of such a name where the assignment certainly ran (d=build; rm -r "$d", true && d=build && rm -r $d, but not after false || d=build). Unquoted, its value must hold no blank, newline, pattern character or backslash, so bash neither splits nor matches it. LANG and the LC_ names may be set before a command (LC_ALL=C sort).
  • The home folder: ~ and ~/path at a word's start. The command cannot change HOME, so ~ is the account's home; cd with no operand goes there. The reader does not know how deep that home stands: a path that climbs out of it is read both from /home and, as a home one deep such as /root puts it, from the root, and the fixed aliases below are read after ~ too, as a container's account may have the root for a home.
  • cd to one literal path (after -L, -P or --), in this shell.
  • A few builtins: echo, printf without -v, true, false, :, pwd, test and [ without -v or -R, set with -e, -u, -x, -v, -f, -C and -o for errexit, nounset, xtrace, verbose, noglob, noclobber or pipefail, shopt for how patterns match, and exec alone with its output sent to /dev/null or another descriptor.
  • Wrappers: sudo, env, nice, nohup, timeout, command, exec and the others the defaults list, their options read as each documents them, and the command each runs read as a command.
  • A shell that runs no text of the command: a script file (bash scripts/build.sh), or what a download writes to it (curl ... | sh, which cmd.download-run asks about, and a tenant may allow), when no other part of the command may have written a file first (below).
    A script that may be the shell's own input is no file of the folder, however a path spells it, so such a shell is unreadable, and so is one with no plain file to run that has a here-document or a here-string on any descriptor, or that takes its input from a descriptor.

Path resolution

The reader resolves paths against the working directory and a cd earlier in the command. A path that climbs two or more folders above the working directory is read as the same path from the root would be, since the working directory may be that shallow: rm -rf ../../../etc asks as /etc would.

A fixed alias reads as the path it names, however the path reaches it, one that climbed above the working directory by any number of folders included: /proc/self/root, /proc/thread-self/root and /proc/<pid>/root (and those under task/<tid>) as the root, /proc/self/cwd as the working directory, and macOS's /System/Volumes/Data as the root and /private/etc, /private/var and /private/tmp as /etc, /var and /tmp, and /Volumes/Macintosh HD as the root, so rm -rf /proc/self/root/home/kevin asks as /home/kevin would.

A working directory the call itself names is read the same way, as a cd to it is: rm -rf kevin with /proc/self/root/home as its cwd asks too.

Another process's working folder (/proc/<pid>/cwd) and a path through an open descriptor (/proc/self/fd/3/x, /dev/fd/3/x, /dev/stdin/x, and with a final /, a . or a .. after it, or a cd to one, which all open what it holds) may lead anywhere, so they are read as a place the reader cannot tell, a write there as a write to a disk as well, since what is open may be /dev; and a pattern that may make one of these (/proc/*/root) is read as both.

The descriptor alone is a file, except to a program that follows a link it is given into what it holds: to a recursive chmod, to find with its options or primary that follow links, and to chown and chgrp with theirs, it is a place the reader cannot tell.

The folders that are links on every Linux system leave a path where the link leads, so a .. straight after one (/dev/fd, /proc/net, /proc/thread-self, /var/run and Debian's /usr/bin/X11) is read as that same place: /dev/fd/../root is /proc/self/root.

The reader reads no file system, so any other link, one a command made or one of a system's own that this list leaves out, is read as its text says; that a .. after it climbs elsewhere is the sandbox's to bound.

That is how a path gets its class (home, root, device), and how an Ask's or a Refuse's own path is compared (Patterns).

Supported filename patterns

The reader supports patterns in a program's file arguments (*, ? and [...]) when every word bash can make of a pattern is a word the reader reads the same way, whatever the folder holds: none, many or the pattern itself. These forms qualify:

  • A word whose text before its first pattern character is literal and starts with a character other than - or + (docs/*.md, src/[ab]?.go, x*), in the files of ls, cat, wc, head, tail, du, rm, grep (after its pattern) or sed (after a script given with -e): every match starts with that text, so none is an option, and each is read as a file with the class a literal path with that start has (rm -rf ./* deletes in the working folder; rm -rf ../* asks as rm -rf .. would).
    For rm and sed, which remove or rewrite what they name, the pattern must stand in the word's last part (rm -rf build/cache/*, not rm -rf build/*/cache): a match the word goes on through may be a link the folder already holds to a folder outside it.
  • A name pattern attached to an option that only matches names against it: grep's --include=*.go, --exclude and --exclude-dir, du's --exclude, ls's --hide and --ignore, named in full. Every match starts with the option's own text, so it is the same option.
  • A word that starts with a pattern character after a -- that ends the program's options (grep -n x -- *.md, rm -- *.tmp), in the same files: every match is a file, even one named like an option.

A program's options are read as its GNU, BSD and busybox forms take them, and for grep the ugrep a harness may run under that name too: a word counts as a file only when it is one in every one of them, so sed -i 's/a/b/' src/*.go asks (BSD's sed reads 's/a/b/' as the backup's suffix and the first match as its script) and sed -i -e 's/a/b/' src/*.go does not.

The reading holds whatever matching options an account's startup file gives the shell (dotglob, nullglob, nocaseglob, globstar, set -f, and zsh's markdirs, globdots, nullglob, nocaseglob, extendedglob, globstarshort and noglob), which the reader cannot see: the cases run under each.

A bracket expression is read as the shell reads it, a ] first in it and a [:class:] inside it included, so rm -rf /usr/[[:print:]]* or rm -rf ../[!]]* asks as rm -rf /usr/* does.

Unreadable constructs

Everything else is unreadable, and the approval card names the construct that made it so. Among them:

Shell evaluation

The reader asks about what bash evaluates or reads again: arithmetic ($((...)), ((...)), let), eval, trap, source, a command or process substitution, a parameter expansion with an operator (${x:-y}, ${#x}, ${!x}), arrays, and a shell that reads text of the command (sh -c '...', a here-document or here-string fed to a shell, bash -i, su, sudo -s or -i, flock -c, watch, a command ssh runs on another machine).

Values that steer the shell

The reader asks about values or filenames that can steer bash:

  • Patterns outside the supported file positions, where a match could become an option, program, cd directory or redirection target. Examples are ls *.txt without --, grep *.go (its first match becomes the pattern), cd src/*, cat < in*, patterns in a wrapper's words or an option's value, and programs not listed above.
  • **, a part beginning with a dot (.* matches .. in bash before 5.2), a pattern with a variable or ~ in its word, and extended patterns.
  • Every pattern in a command that runs shopt, set -f or set -o noglob, or sets a non-locale name for the program. For example, X=1 ls docs/* may read program options from its environment.
  • Brace expansion and tildes other than ~ or ~/path: ~user, ~+, ~-, ~1, or a tilde after = or :.
  • A variable not certainly set to literal text by the command, including environment variables and assignments after ||; positional or special parameters such as $1, $@ and $?.
  • Any name bash assigns a special meaning, whether read or set: HOME, PWD, OLDPWD, IFS, PATH, CDPATH, BASH_ENV, ENV, prompts, PROMPT_COMMAND, SHELLOPTS, BASHOPTS, GLOBIGNORE and the rest in bash's manual.

Bash and zsh differences

The reader asks about constructs zsh reads differently from bash: the command's shell may be zsh (macOS's, and an account's at sandbox tier 0), so a command is read only where its shape means the same in both.

The zsh forms that the reader cannot safely read as bash are:

  • Parameter flags and modifiers without braces: $=x, $~x, $^x, $+x, $x[1], $x:h, and a name with letters past ASCII, which zsh reads on.
  • A word or value starting with an unquoted =: rm =python or x==ls. zsh puts the program's path there. A word starting with ==, such as echo ====, names a program zsh fails to find; that command runs nothing and is read.
  • >! and >>!, which mean zsh's >|; bash writes to a file named !... instead.
  • Numeric patterns such as <1-10> and <->, which bash reads as two redirections.
  • Redirections without a program, unless each sends to or from /dev/null. < file runs zsh's $READNULLCMD, a pager, and > file runs its $NULLCMD, cat.

Any name zsh gives a meaning of its own is unreadable too, case for case, read or set, before a command or alone: path, cdpath, fpath, manpath, module_path, mailpath and psvar (where a program, a folder, a function or a module is looked up), NULLCMD, READNULLCMD, STTY and ARGV0 (a program zsh runs, or the name it runs one as), status, argv, USERNAME and the rest of zsh's manual.

So are the words zsh runs as its own builtins, reserved words and precommand modifiers where bash runs a program of that name: noglob, nocorrect and - (the command after them is read too, so noglob rm -rf ~ asks as cmd.delete-home), repeat, foreach, emulate, setopt, unsetopt, zmodload, autoload, zpty, sched, print, r, whence and the rest; zsh's which, which only prints where a program is, as the which program does, is read.

Compound commands

The reader asks about compound commands: if, case, for, while and until loops, functions, subshells and { ...; } groups, [[ ... ]] tests, and commands run in the background with &.

Write then execute

The reader asks about files a command writes and then runs. A part may have written a file if it uses a file redirection, /usr/bin/time -o, or a program outside the reader's short list of programs that only print.

Examples include tee, cp, sed -i, curl -o, git clone, and git fetch or git pull from a URL or path on the command line.

After such a part, every later part and every other stage of its pipeline is unreadable unless its program is known to run no code or command from files it reads. That short list includes cat, grep, ls, rm, mkdir, cp, mv, jq, sed, curl, tar and sort.

Any other program may run what the command wrote: a shell its script, make its makefile, git its configuration and hooks, ssh, scp and rsync the commands of their configuration, pip install . a project's setup.py, and the code would then be text of the command the reader did not read.

A program run by its path (./x) or by the name of a file the command wrote always counts.

Writing does not count for what the working folder's own code writes while its output goes to no file (make, go build, cargo test, npm run, pytest), a formatter rewriting files of the folder it names (gofmt -w, ruff), an install from the folder's lockfile (npm ci), git add, git commit, a git fetch or git pull of a remote the repository names, and what an interpreter or awk writes, whose code the reader does not read and which could run it itself.

It does count for go list -f, a formatter reading its input, curl -w with %output{}, and the output of any of them sent to a file, since what they print may be the command's own words.

Shell network redirections

The reader asks about network connections bash opens for a redirection: /dev/tcp/host/port and /dev/udp/host/port are not file paths, so bash < /dev/tcp/... is a download run unread (cmd.download-run asks about it too), and cat secret > /dev/tcp/... sends a file away.

The reader asks about a link the command may make before another part runs: ln, mv, cp -s and cp -l, cp copying links as links (-r, -R, -a, -d, -P, --no-dereference, --preserve=links, unless -L), rsync -l or -a, tar extracting, and any program not known to make none. A path through it may lead anywhere, the root or a home included: /proc/self/root is a link to the root, and copying it copies the link. A link a shell's script, an interpreter's code or the working folder's own code makes is theirs, as one already on disk is.

Shell state changes

The reader asks about builtins that change the shell: declare, typeset, local, readonly, unset, read, mapfile, getopts, alias, hash, enable, pushd, popd, fc, history, kill and the rest, set and shopt options beyond those above, export with an option, and cd -, cd to more than one operand or through a program that is not the shell's.

Programs that launch commands

The reader asks about programs that run a command their options, configuration or environment name: xargs, find -exec, -execdir and -ok, git -c, git config and the git subcommands that run a command (rebase -x, bisect run, submodule foreach, instaweb, help -w, --upload-pack, --receive-pack and --exec on clone, fetch, pull, ls-remote, fetch-pack, send-pack, push and archive, --template on init and clone, and the like).

git's subcommands are read by a reviewed list of the built-in ones that run no program their options name (status, log, diff, add, commit, checkout, push and the rest): any other name asks, since git runs an alias of that name from its configuration, or a git- program, and the command may have written the alias (git st, git lfs, git maintenance, git credential, git hook), and so does an empty subcommand (git "" status), which git runs as the program git-.

git's own options before the subcommand are read the same way: an option the reader does not know asks, since it may take the next word as its value and leave another word as the subcommand, and those that take a value (-C, --git-dir, --work-tree, --attr-source, --shallow-file and the like) take the next word, as git does; git --help is git help.

Options that name code or configuration

The reader also asks about these options that can load code or run another program:

ProgramOptions or environment
tar-I, --use-compress-program
rsync-e, -M, --daemon
scp, sftp-o, -F, -S
sftp-b: a batch file runs its ! lines in a local shell
split--filter
install--strip-program
sdiff, diff3--diff-program
cpio--rsh-command
curl--engine (an OpenSSL engine is code), -K
man-C
fakeroot--faked, -f, -l
torsocks--shell
ssh-keygen-D, -w: a library it loads
ssh-add-s, -S: a library it loads
env-i, -u, -S, -C

Environment names from which programs run or load code also ask, including GIT_SSH_COMMAND, other GIT_ names, PAGER, EDITOR, VISUAL, LESSOPEN and LD_PRELOAD. So do programs whose commands the reader does not read at all, including parallel, tmux, strace and systemd-run.

Wrappers and package runners

The reader asks about programs that run a command their operands, a subcommand or an option name: setarch, prlimit, sshpass, perf, run0, gosu, hyperfine, start-stop-daemon, runcon, setuidgid and daemontools' other programs, dumb-init, tini, systemd-cat, logsave, dtach, abduco, nq, daemonize, flatpak-spawn and the like.

The reader asks about package runners:

  • uvx, npx, npm exec (including npm's exe and x), npm explore, npm edit and --browser.
  • exec and dlx in pnpm and yarn, including through yarn workspace <name>, yarn workspaces foreach and pnpm recursive.
  • npm init, npm create, pnpm create, yarn create and bun create of a package, which fetch and run it.

A pnpm word that is not one of its commands, and the word after bun run, names a folder script or a program. The reader asks when it reads that program as more than its name, as in pnpm sh -c '...' or pnpm find ~ -delete.

Other command-launching forms include:

  • uv run, poetry run, ip netns exec and ssh-agent with a command.
  • sed's e command and flag, man -P, zip -TT, tcpdump -z, wget --use-askpass, nc -e and socat with EXEC: or SYSTEM:.
  • make with a variable, --eval or -f on its command line.
  • go with -exec or -toolexec, and go run or go tool of a module at a version such as example.com/x@latest, which is fetched and run.
  • cargo --config, rg --pre, rg --hostname-bin, fd -x and fd -X.

A word straight after an option may be its value. The reader therefore looks past it for the subcommand, as in go -C . run and npm --prefix . exec.

Containers and remote execution

For docker, podman and nerdctl, the reader asks about:

FormWhat it can run or expose
run, exec, create, start, restart, restore, debugA container's command, with any host files it mounts.
attachStandard input sent to a container's main process.
compose, service, stack, plugin, kube, play, podCommands managed through these interfaces.
--configThe folder from which the tool's own plugins run.
A build with -v or --volumeBuild steps receive host folders.

Podman's unshare runs a command on the host; runlabel runs the command an image's label holds; machine and auto-update can run a newer image's command. These ask too, as do its global options that name a program run for any subcommand: --runtime, --runtime-flag, --conmon, --network-cmd-path, --hooks-dir, --module and --storage-opt.

It also asks about docker-compose and podman-compose, and kubectl and oc with exec, attach, debug, run, rsh or rsync, create of a job, a cron job or a deployment (its image's command runs, as with run), --kubeconfig (which may name a program that gets credentials) or kustomize's exec and helm options, oc login --exec-plugin.

Languages with shell escapes

The reader asks about shell escapes in languages with their own commands. It checks their words, here-documents, here-strings and here-text supplied through a descriptor. It also asks when their text is unseen: another command pipes it in, a file supplies standard input, or the command names a script file.

This applies to psql, usql, mysql, mariadb, sqlite3, duckdb, lftp, dc, ed and m4. Commands such as psql -f schema.sql, mysql db < dump.sql, m4 file.m4 and echo .tables | sqlite3 db ask, whereas an interpreter's script file does not. These languages are checked for escapes, and unseen text may contain any of them.

The escape list is a best effort:

  • For psql and usql: \!, a | target of -o, \o or \g after options or a newline, \copy, \i, an editor, a backquote, \setenv for its pager or editor, and the word PROGRAM.
  • For the mysql client: system, pager, edit and source with their backslash forms, and options naming a pager, option file or plugin.
  • For lftp: !, shell, |, source, module, edit, a connect-program setting and mirror --on-change. Each counts with any prefix lftp accepts and with quotes anywhere in the word.
  • For sqlite3 and duckdb: .shell, .system and similar commands, including accepted prefixes, preceding space and quoted dot-command names; load_extension and edit(); and DuckDB's LOAD and INSTALL.
  • For m4: syscmd, include, sinclude and undivert, including their m4_ forms under -P.

Configuration that runs code later

Commands that keep a program in configuration for a later command to run ask as git config does: kubectl and oc config set and config set-credentials with its --exec- options (both read an option's underscores as dashes, so --exec_command is one too), go env -w, npm, pnpm and yarn config set (with npm's c, con, conf and confi for config, npm set, --script-shell, --node-gyp, --userconfig, --globalconfig, each behind any number of dashes as npm reads it, pnpm's --config.<name> and yarn's --use-yarnrc), gh config set, gh alias set and import and gh extension, and aws configure set.

Other program-launching options include cmake with -E, -P, -C, -D or --toolchain, sort --compress-program, grep with ugrep's --filter, --pager, --view, --config or -Q (a harness may run ugrep as grep) and ug, the editors (vim, nvim, vi, ex, emacs and the like, which run their startup files and the commands their options give), toybox, coreutils, wsl, powershell and the dynamic loader run by its path (ld-linux-x86-64.so.2 prog).

The reader also asks about a program named by a link that may be any program, a shell among them (/proc/self/exe, /proc/<pid>/exe, /proc/self/fd/0, /dev/fd/3, /dev/stdin, /proc/self/map_files/..., whatever is read through a descriptor, and ./exe or ./3 from a folder the reader cannot tell).

Environment that loads code

The reader asks about environment names that make a program load code (ZDOTDIR, XDG_CONFIG_HOME, GOFLAGS naming a program, such as -toolexec, RUSTC_WRAPPER, RUSTFLAGS and the CARGO_ names, JAVA_TOOL_OPTIONS, GCONV_PATH, PERL5DB, CC and the other compilers make runs, CFLAGS and the other compilers' flags, CCACHE_PREFIX, GNUMAKEFLAGS and MAKEFILES beside MAKEFLAGS, KUBECONFIG, DOCKER_HOST, RIPGREP_CONFIG_PATH, the pagers and editors of gh, psql and kubectl, the folders lftp, mysql, mariadb and psql read their startup files from (LFTP_HOME, MYSQL_HOME, MARIADB_HOME, PGSYSCONFDIR), OPENSSL_CONF, OPENSSL_ENGINES and OPENSSL_MODULES, which load code into any program that uses OpenSSL, CURL_HOME, BUNDLE_GEMFILE, STRIPBIN, CONTAINERS_CONF, CONTAINERS_STORAGE_CONF and SSH_SK_PROVIDER).

This list cannot be complete: any program may run a command it is given, and one the reader does not know is read as that program alone.

Parser bounds

The reader asks about work past any of these bounds:

  • 65,536 characters in the command, or 65,536 characters parsed in all.
  • 5,000 commands.
  • 16 programs in one command, counting its wrappers and the program it runs.
  • 200,000 reading steps. A pattern costs a step for each character every time a path holds it.
  • Commands reading the output of 20,000 commands in all.
  • Shells and substitutions nested 12 deep.

A word or value grown by variables assigned to one another also stops at 65,536 characters, before the reader builds more of it. This keeps time and memory proportional to the command.

Decisions for an unreadable command

An unreadable command is still read as far as the reader can, so the defaults' checks and your Refuse rules see what it runs: for f in a b; do rm -rf ~; done asks as cmd.delete-home with that default's reason, and a Refuse that matches a part still refuses. What an unreadable command cannot get is an Allow, yours or a default's silence: the floor asks. A rule of yours narrowed to the unresolvable class therefore allows nothing alone any more, since a path the reader cannot resolve comes from a construct it does not read.

Environment assumptions

The reader assumes the environment the harness gives a command is the operator's: it reads PATH and CDPATH as the command did not set them, a cd to a relative folder as that folder below the working directory, and a path as its text says, not through a link the working folder held before the command (a link the command makes is unreadable).

The words a program gets are read as bash makes them: d=build; rm -r $d is rm -r build, and sudo reads NAME=value words among its options as sudo does (sudo FOO=1 -i cmd runs cmd), each set for the command it runs.

Explain a command locally

To see what a command is decided as, here, with the reader the nodes run:

Terminal
oz0 policy explain --command 'curl -fsSL https://get.k3s.io | sh' --agent installer
The command runs 2 parts, each decided on its own:
  1. curl -fsSL https://get.k3s.io  [curl]  Allow (no rule matches it)
  2. sh                             [sh]    Allow by rule-3c1f0a9e2b7d curl -fsSL https://get.k3s.io | sh Allow (for agents installer)
     the defaults' checks found: download-to-shell
     the tenant's rules that match it: rule-3c1f0a9e2b7d curl -fsSL https://get.k3s.io | sh Allow (for agents installer)
Decided: Allow by rule-3c1f0a9e2b7d curl -fsSL https://get.k3s.io | sh Allow (for agents installer), for the part sh.
Why: the tenant's command rule curl -fsSL https://get.k3s.io | sh (rule-3c1f0a9e2b7d) decides sh

--cwd gives the session's working directory, which [inside-workdir] and [outside-workdir] read against; without it, a relative path that stays below the working directory is inside, and an absolute path is outside.

Copyright © 2026