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.
Desktop agent apps intercept shell commands before execution using one of three methods:
execve() or equivalent syscalls to capture the command string before the kernel sees it.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.
A denylist policy maintains a list of dangerous patterns and auto-approves everything else:
rm, curl, wget, eval).
The problem: agents generate novel command patterns faster than humans can write denylist rules.
| Loophole | Example | Why Denylist Fails |
|---|---|---|
| Command chaining | ls && rm -rf /tmp/data |
Denylist checks ls (safe), misses the chainedrm |
| 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.
An allowlist policy inverts the logic: define the set of permitted command shapes and reject everything else.
Decision flow:
git status, npm install, ls <directory>).
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:
ls && rm -rf /tmp/data fails because && is not in the allowlist.git log | xargs rm fails because | is not in the allowlist.CMD=rm && $CMD -rf /tmp/data fails because echo ... | base64 -d | sh fails because 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:
git commit -m "fix" will trigger a dialog if commit is not in the allowlist. The human must approve once, then the allowlist is updated.
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.
Allowlists require active curation. Every new command shape must be added explicitly. This creates operational overhead but also creates an audit trail.
Maintenance strategies:
.agent-config.json in the repository. The team reviews and updates it during code review.
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.
Allowlists fail in predictable ways:
ALLOWED_COMMANDS = {"sh": ["*"]} defeats the purpose. The agent can run sh -c "rm -rf /" and the allowlist will approve it.git commit without checking the -m argument means the agent can run git 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, .).
| 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) |
Use an allowlist when:
npm, cat).
Use a denylist when:
Do not use a denylist when:
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.