-
Notifications
You must be signed in to change notification settings - Fork 2
Shell blocklist bypassed when PermissionPolicy is set (autonomy_level != None) #1525
Description
Summary
When a PermissionPolicy is configured on ShellExecutor (which happens whenever autonomy_level is set in config), the command blocklist check (find_blocked_command()) is completely skipped. This allows blocked commands (including user-configured blocked_commands and network commands when allow_network = false) to execute freely via subshell bypass vectors.
Root Cause
In crates/zeph-tools/src/shell.rs, execute_inner() lines 221-263:
if let Some(ref policy) = self.permission_policy {
match policy.check("bash", block) {
Deny => return Err(Blocked),
Ask if !skip_confirm => return Err(ConfirmationRequired),
_ => {} // Allow falls through WITHOUT blocklist check
}
} else {
// find_blocked_command ONLY called in else branch
if let Some(blocked) = self.find_blocked_command(block) { ... }
}find_blocked_command() is in the else branch and only runs when permission_policy is None. Since agent_setup.rs:37 always calls .with_permissions(permission_policy), the blocklist is never checked in normal operation.
Reproduction
Config:
[tools.shell]
blocked_commands = ["curl", "wget"]
allow_network = false
[security]
autonomy_level = "full"Prompt: Run this exact shell command: echo $(curl --version)
Expected: Command blocked by policy
Actual: curl executes successfully, output returned to LLM
All three bypass vectors pass through:
- Backtick:
echo `curl --version` - Subshell:
echo $(curl --version) - Process substitution:
cat <(wget --version)
Impact
- CRITICAL:
blocked_commandsconfig is silently ignored allow_network = falsehas no effect (network commands not blocked)- DEFAULT_BLOCKED commands (sudo, rm -rf, mkfs, etc.) are not checked
- Affects ALL autonomy levels (full, supervised, restricted) since PermissionPolicy is always set
Fix
find_blocked_command() must run BEFORE or ALONGSIDE the PermissionPolicy check, not in an exclusive else branch. The blocklist is a hard security boundary; the permission policy is about user confirmation UX.
Suggested fix:
// Always check blocklist first (hard boundary)
if let Some(blocked) = self.find_blocked_command(block) {
return Err(ToolError::Blocked { command: blocked.to_owned() });
}
// Then check permission policy (UX layer)
if let Some(ref policy) = self.permission_policy {
match policy.check("bash", block) { ... }
} else if !skip_confirm {
if let Some(pattern) = self.find_confirm_command(block) { ... }
}Regression Note
REG-003 in .local/testing/regressions.md was marked Pass at v0.14.2 — this may have been tested before TrustGateExecutor started passing PermissionPolicy to ShellExecutor, or the test config did not set autonomy_level. Updated to Fail.
Discovered
Continuous improvement testing session, 2026-03-10, v0.14.3