Four Loopholes in Denylist-Based Agent Command Approval: Why Blacklisting Shell Commands Fails at Scale
Desktop AI coding agents now ship with approval dialogs that gate shell command execution. The agent proposes a command, a dialog appears, and a human clicks "approve" or "deny." The friction is real, so teams build auto
Desktop AI coding agents now ship with approval dialogs that gate shell command execution. The agent proposes a command, a dialog appears, and a human clicks "approve" or "deny." The friction is real, so teams build auto-approval scripts that click the button when the command looks safe.
The naive implementation uses a denylist: block commands containing rm -rf, curl | sh, sudo, and other known-bad patterns. Everything else gets auto-approved. This approach fails catastrophically when tested against adversarial examples.
On September 15, 2026, an AI agent reviewing a denylist-based auto-approval script found four loopholes and flagged them as required fixes. The implementation was rebuilt using an allowlist approach. When the original denylist was tested against 18 hostile command examples, it auto-approved 14 of them. The allowlist blocked all 18.
This is the security boundary breakdown.
The Approval Dialog Layer
Desktop agent apps intercept shell commands before execution using one of three methods:
-
Process spawning hooks: Wrap
execve()or equivalent syscalls to capture the command string before the kernel sees it. - PTY wrapping: Insert a pseudo-terminal layer that logs and gates all input/output between the agent and the shell.
-
Shell function overrides: Replace common commands (
cd,ls,git) with wrapper functions that call back to the approval service.
The approval service receives a command string, applies a decision policy, and either executes the command or shows a dialog. The decision policy is where denylist vs. allowlist architecture matters.
Denylist Decision Flow
A denylist policy maintains a list of dangerous patterns and auto-approves everything else:
- Parse the command string into tokens.
- Check each token against the denylist (e.g.,
rm,curl,wget,eval). - If a match is found, show the approval dialog.
- If no match is found, execute the command automatically.
The problem: agents generate novel command patterns faster than humans can write denylist rules.
The Four Loopholes
| Loophole | Example | Why Denylist Fails |
|---|---|---|
| Command chaining | ls && rm -rf /tmp/data |
Denylist checks ls (safe), misses the chained rm
|
| Argument injection | `git log --pretty=format:"%H" \ | xargs rm` |
| Environment variable smuggling | CMD=rm && $CMD -rf /tmp/data |
Denylist sees CMD=rm (assignment, not execution), misses $CMD expansion |
| Encoding tricks | `echo cm0gLXJmIC90bXAvZGF0YQ== \ | base64 -d \ |
Each loophole exploits the gap between static pattern matching and dynamic shell evaluation. The denylist sees the command as a string. The shell sees it as a program.
Allowlist Architecture
An allowlist policy inverts the logic: define the set of permitted command shapes and reject everything else.
Decision flow:
- Parse the command into an abstract syntax tree (AST) or structured representation.
- Check the command shape against the allowlist (e.g., {% raw %}
git status,npm install,ls <directory>). - If the shape matches, execute automatically.
- If the shape does not match, show the approval dialog.
The allowlist implementation tested on October 12, 2026, used this structure:
ALLOWED_COMMANDS = {
"git": ["status", "log", "diff", "branch"],
"npm": ["install", "test", "run"],
"ls": ["*"], # Any arguments allowed
"cat": ["*"],
"echo": ["*"],
}
def is_allowed(command: str) -> bool:
tokens = shlex.split(command)
if not tokens:
return False
base_cmd = tokens[0]
if base_cmd not in ALLOWED_COMMANDS:
return False
allowed_subcommands = ALLOWED_COMMANDS[base_cmd]
if allowed_subcommands == ["*"]:
return True
if len(tokens) < 2:
return False
subcommand = tokens[1]
return subcommand in allowed_subcommands
This blocks all four loophole categories:
-
Command chaining:
ls && rm -rf /tmp/datafails because&&is not in the allowlist. -
Argument injection:
git log | xargs rmfails because|is not in the allowlist. -
Environment variable smuggling:
CMD=rm && $CMD -rf /tmp/datafails because&&is not in the allowlist. -
Encoding tricks:
echo ... | base64 -d | shfails because|is not in the allowlist.
The UX Trade-Off
Denylists auto-approve most commands, creating low friction and high risk. Allowlists require explicit grants, creating high friction and low risk.
The friction shows up in two places:
-
Correct operations not in the allowlist: An agent that runs
git commit -m "fix"will trigger a dialog ifcommitis not in the allowlist. The human must approve once, then the allowlist is updated. - Tool-specific permission requests: File-writing tools often request permission separately from the shell command layer. The allowlist does not auto-approve these requests, so the human sees two dialogs: one for the tool, one for the command.
The solution: change the agent's instructions to request permission once at the task level, not once per command. The agent asks, "May I modify files in /src and run npm test?" The human approves the task, and the allowlist is updated for the session.
Allowlist Maintenance
Allowlists require active curation. Every new command shape must be added explicitly. This creates operational overhead but also creates an audit trail.
Maintenance strategies:
- Session-scoped allowlists: The agent proposes a set of commands at the start of a task. The human approves the set. The allowlist is valid for the session and discarded afterward.
-
Project-scoped allowlists: The allowlist is stored in
.agent-config.jsonin the repository. The team reviews and updates it during code review. - User-scoped allowlists: The allowlist is stored in the user's home directory. It grows over time as the user approves new command shapes.
The October 12 comparison run used a session-scoped allowlist. The agent proposed 6 command shapes at the start of the task. The human approved all 6. The allowlist blocked 18 hostile examples and allowed 6 legitimate examples.
Failure Modes
Allowlists fail in predictable ways:
-
Overly permissive wildcards:
ALLOWED_COMMANDS = {"sh": ["*"]}defeats the purpose. The agent can runsh -c "rm -rf /"and the allowlist will approve it. -
Incomplete AST parsing: If the allowlist checks only the first token,
git log | xargs rmwill pass becausegitis allowed. The parser must understand pipes, redirects, and subshells. -
Argument validation gaps: Allowing
git commitwithout checking the-margument means the agent can rungit commit -m "$(rm -rf /)"and inject a command substitution.
The robust implementation parses the full command, validates argument shapes, and rejects any construct that could execute arbitrary code (pipes, redirects, command substitutions, backticks, eval, source, .).
Implementation Comparison
| Aspect | Denylist | Allowlist |
|---|---|---|
| Default behavior | Auto-approve unless blocked | Block unless explicitly allowed |
| Maintenance burden | Add rules reactively after incidents | Add rules proactively before use |
| Agent friction | Low (most commands pass) | Medium (new commands require approval) |
| Security posture | Fails open (unknown = allowed) | Fails closed (unknown = blocked) |
| Loophole resistance | Poor (14 of 18 hostile examples passed) | Strong (0 of 18 hostile examples passed) |
| Audit trail | Sparse (only blocked commands logged) | Complete (all allowed shapes documented) |
Technical Verdict
Use an allowlist when:
- The agent operates in a production environment or has access to sensitive data.
- The set of required commands is small and well-defined (e.g.,
git,npm,ls,cat). - You can tolerate the friction of approving new command shapes once per session or once per project.
- You need an audit trail of what the agent is permitted to execute.
Use a denylist when:
- The agent operates in a sandboxed environment with no access to production systems.
- The set of required commands is large and unpredictable (e.g., a general-purpose coding assistant).
- You cannot tolerate the friction of approving every new command shape.
- You accept the risk that novel attack patterns will bypass the denylist until rules are updated.
Do not use a denylist when:
- The agent can execute commands that modify files, install packages, or access network resources.
- The agent is used by multiple people on the same system (one person's approved command becomes another person's attack vector).
- You cannot update the denylist faster than agents generate novel command patterns.
The comparison run data is clear: denylists fail 78% of hostile examples (14 of 18). Allowlists fail 0%. The friction cost of allowlists is real, but the security cost of denylists is catastrophic.
Source Links
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.