Skip to content

Shell blocklist bypassed when PermissionPolicy is set (autonomy_level != None) #1525

@bug-ops

Description

@bug-ops

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_commands config is silently ignored
  • allow_network = false has 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsecuritySecurity-related issue

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions