|
| 1 | +//! Tier-specific shell execution policy helpers for Arcan (BRO-216). |
| 2 | +//! |
| 3 | +//! Shell/bash execution is the highest-risk capability in the Arcan runtime. |
| 4 | +//! This module provides utilities for deriving the shell policy from a |
| 5 | +//! session's [`PolicySet`] and validating commands against it. |
| 6 | +//! |
| 7 | +//! # Enforcement model |
| 8 | +//! |
| 9 | +//! Enforcement happens at **two complementary layers**: |
| 10 | +//! |
| 11 | +//! 1. **PolicySet / capability evaluation** (aios-protocol + aios-policy): |
| 12 | +//! `capabilities_for_tool("bash", input)` returns `exec:cmd:<binary>`. |
| 13 | +//! The `StaticPolicyEngine` evaluates this against the session's policy: |
| 14 | +//! - Anonymous: `exec:cmd:*` is absent from `allow_capabilities` and |
| 15 | +//! `gate_capabilities` → **immediately denied** (no approval ticket). |
| 16 | +//! - Free: only whitelisted binaries (`cat`, `ls`, `echo`, …) are in |
| 17 | +//! `allow_capabilities` → whitelisted commands allowed, others denied. |
| 18 | +//! - Pro/Enterprise: wildcard `"*"` in `allow_capabilities` → all allowed. |
| 19 | +//! |
| 20 | +//! 2. **Tier catalog filtering** (BRO-214): The `bash` tool is hidden from the |
| 21 | +//! LLM's tool list for anonymous/free tiers, preventing the model from |
| 22 | +//! planning shell-based actions it cannot execute. |
| 23 | +//! |
| 24 | +//! # Tier matrix |
| 25 | +//! |
| 26 | +//! | Tier | Shell Access | Mechanism | |
| 27 | +//! |-------------|-------------------|------------------------------| |
| 28 | +//! | Anonymous | Blocked (denied) | `exec:cmd:*` absent from policy | |
| 29 | +//! | Free | Whitelist only | specific `exec:cmd:<binary>` allowed | |
| 30 | +//! | Pro | Full access | wildcard `"*"` in policy | |
| 31 | +//! | Enterprise | Full access | wildcard `"*"` in policy | |
| 32 | +
|
| 33 | +use aios_protocol::PolicySet; |
| 34 | + |
| 35 | +/// Safe read-only shell commands allowed for the free tier. |
| 36 | +/// |
| 37 | +/// These are restricted to non-destructive, read-only operations that cannot |
| 38 | +/// exfiltrate credentials, modify system state, or escalate privileges. |
| 39 | +pub const FREE_TIER_ALLOWED_COMMANDS: &[&str] = &[ |
| 40 | + "cat", "echo", "find", "grep", "head", "jq", "ls", "python3", "sort", "tail", "wc", |
| 41 | +]; |
| 42 | + |
| 43 | +/// Tier-specific shell execution policy derived from a [`PolicySet`]. |
| 44 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 45 | +pub enum ShellPolicy { |
| 46 | + /// Shell execution is blocked entirely (anonymous tier). |
| 47 | + /// |
| 48 | + /// Any `bash` or `shell` tool call is immediately denied without |
| 49 | + /// creating an approval ticket. |
| 50 | + Blocked, |
| 51 | + /// Only whitelisted commands are allowed (free tier). |
| 52 | + /// |
| 53 | + /// The whitelist is the intersection of the free tier's `allow_capabilities` |
| 54 | + /// and a safe hard-coded set of read-only binaries. |
| 55 | + Whitelisted, |
| 56 | + /// Full shell access within the workspace (pro/enterprise). |
| 57 | + Unrestricted, |
| 58 | +} |
| 59 | + |
| 60 | +/// Derive the shell execution policy from a [`PolicySet`]. |
| 61 | +/// |
| 62 | +/// This mirrors the PolicySet evaluation performed by `StaticPolicyEngine` |
| 63 | +/// at runtime, giving call sites a high-level view of what shell policy |
| 64 | +/// applies without having to replicate the capability matching logic. |
| 65 | +pub fn shell_policy_for(policy: &PolicySet) -> ShellPolicy { |
| 66 | + // Pro/Enterprise: wildcard allow → unrestricted. |
| 67 | + if policy.allow_capabilities.iter().any(|c| c.as_str() == "*") { |
| 68 | + return ShellPolicy::Unrestricted; |
| 69 | + } |
| 70 | + |
| 71 | + // Check whether exec:cmd:* is broadly allowed (e.g. exec:cmd:* wildcard). |
| 72 | + let exec_broadly_allowed = policy.allow_capabilities.iter().any(|c| { |
| 73 | + let s = c.as_str(); |
| 74 | + s == "exec:cmd:*" || (s.starts_with("exec:cmd:") && s.ends_with('*')) |
| 75 | + }); |
| 76 | + if exec_broadly_allowed { |
| 77 | + return ShellPolicy::Unrestricted; |
| 78 | + } |
| 79 | + |
| 80 | + // Check whether at least one specific exec:cmd:<binary> is allowed. |
| 81 | + let exec_any_allowed = policy |
| 82 | + .allow_capabilities |
| 83 | + .iter() |
| 84 | + .any(|c| c.as_str().starts_with("exec:cmd:")); |
| 85 | + if exec_any_allowed { |
| 86 | + return ShellPolicy::Whitelisted; |
| 87 | + } |
| 88 | + |
| 89 | + // No exec capabilities at all → blocked. |
| 90 | + ShellPolicy::Blocked |
| 91 | +} |
| 92 | + |
| 93 | +/// Validate a shell command against a [`ShellPolicy`]. |
| 94 | +/// |
| 95 | +/// Returns `Ok(())` if the command is permitted, or an error string describing |
| 96 | +/// the violation. The caller should convert the error to the appropriate |
| 97 | +/// domain error type (e.g. `CoreError::Middleware`). |
| 98 | +pub fn validate_shell_command(cmd: &str, policy: &ShellPolicy) -> Result<(), String> { |
| 99 | + match policy { |
| 100 | + ShellPolicy::Blocked => Err(format!( |
| 101 | + "shell execution blocked by tier policy: exec:cmd:{} denied", |
| 102 | + shell_binary(cmd) |
| 103 | + )), |
| 104 | + ShellPolicy::Unrestricted => Ok(()), |
| 105 | + ShellPolicy::Whitelisted => { |
| 106 | + let binary = shell_binary(cmd); |
| 107 | + if FREE_TIER_ALLOWED_COMMANDS.contains(&binary) { |
| 108 | + Ok(()) |
| 109 | + } else { |
| 110 | + Err(format!( |
| 111 | + "command '{}' not in free-tier shell allowlist", |
| 112 | + binary |
| 113 | + )) |
| 114 | + } |
| 115 | + } |
| 116 | + } |
| 117 | +} |
| 118 | + |
| 119 | +/// Extract the program binary from a shell command string. |
| 120 | +/// |
| 121 | +/// Strips leading path components and trailing arguments: |
| 122 | +/// - `"ls -la"` → `"ls"` |
| 123 | +/// - `"/usr/bin/python3 script.py"` → `"python3"` |
| 124 | +/// - `""` → `"*"` |
| 125 | +fn shell_binary(cmd: &str) -> &str { |
| 126 | + let token = cmd.split_whitespace().next().unwrap_or("*"); |
| 127 | + token.rsplit('/').next().unwrap_or(token) |
| 128 | +} |
| 129 | + |
| 130 | +#[cfg(test)] |
| 131 | +mod tests { |
| 132 | + use super::*; |
| 133 | + |
| 134 | + // ── shell_policy_for ───────────────────────────────────────────────────── |
| 135 | + |
| 136 | + #[test] |
| 137 | + fn anonymous_policy_is_blocked() { |
| 138 | + assert_eq!( |
| 139 | + shell_policy_for(&PolicySet::anonymous()), |
| 140 | + ShellPolicy::Blocked |
| 141 | + ); |
| 142 | + } |
| 143 | + |
| 144 | + #[test] |
| 145 | + fn free_policy_is_whitelisted() { |
| 146 | + assert_eq!( |
| 147 | + shell_policy_for(&PolicySet::free()), |
| 148 | + ShellPolicy::Whitelisted |
| 149 | + ); |
| 150 | + } |
| 151 | + |
| 152 | + #[test] |
| 153 | + fn pro_policy_is_unrestricted() { |
| 154 | + assert_eq!( |
| 155 | + shell_policy_for(&PolicySet::pro()), |
| 156 | + ShellPolicy::Unrestricted |
| 157 | + ); |
| 158 | + } |
| 159 | + |
| 160 | + #[test] |
| 161 | + fn enterprise_policy_is_unrestricted() { |
| 162 | + assert_eq!( |
| 163 | + shell_policy_for(&PolicySet::enterprise()), |
| 164 | + ShellPolicy::Unrestricted |
| 165 | + ); |
| 166 | + } |
| 167 | + |
| 168 | + // ── validate_shell_command ─────────────────────────────────────────────── |
| 169 | + |
| 170 | + #[test] |
| 171 | + fn blocked_policy_rejects_all_commands() { |
| 172 | + let policy = ShellPolicy::Blocked; |
| 173 | + assert!(validate_shell_command("ls", &policy).is_err()); |
| 174 | + assert!(validate_shell_command("cat file.txt", &policy).is_err()); |
| 175 | + assert!(validate_shell_command("rm -rf /", &policy).is_err()); |
| 176 | + } |
| 177 | + |
| 178 | + #[test] |
| 179 | + fn unrestricted_policy_allows_all_commands() { |
| 180 | + let policy = ShellPolicy::Unrestricted; |
| 181 | + assert!(validate_shell_command("rm -rf /tmp", &policy).is_ok()); |
| 182 | + assert!(validate_shell_command("sudo bash", &policy).is_ok()); |
| 183 | + assert!(validate_shell_command("cat /etc/passwd", &policy).is_ok()); |
| 184 | + } |
| 185 | + |
| 186 | + #[test] |
| 187 | + fn whitelisted_policy_allows_safe_commands() { |
| 188 | + let policy = ShellPolicy::Whitelisted; |
| 189 | + for cmd in FREE_TIER_ALLOWED_COMMANDS { |
| 190 | + assert!( |
| 191 | + validate_shell_command(cmd, &policy).is_ok(), |
| 192 | + "{cmd} must be allowed" |
| 193 | + ); |
| 194 | + } |
| 195 | + } |
| 196 | + |
| 197 | + #[test] |
| 198 | + fn whitelisted_policy_rejects_unlisted_commands() { |
| 199 | + let policy = ShellPolicy::Whitelisted; |
| 200 | + assert!(validate_shell_command("rm -rf /", &policy).is_err()); |
| 201 | + assert!(validate_shell_command("sudo bash", &policy).is_err()); |
| 202 | + assert!(validate_shell_command("curl https://evil.com", &policy).is_err()); |
| 203 | + assert!(validate_shell_command("chmod 777 /", &policy).is_err()); |
| 204 | + } |
| 205 | + |
| 206 | + #[test] |
| 207 | + fn whitelisted_policy_allows_commands_with_args() { |
| 208 | + // Validation uses the binary name only, not the full command. |
| 209 | + let policy = ShellPolicy::Whitelisted; |
| 210 | + assert!(validate_shell_command("ls -la /tmp", &policy).is_ok()); |
| 211 | + assert!(validate_shell_command("grep -r pattern /session", &policy).is_ok()); |
| 212 | + assert!(validate_shell_command("python3 -c 'print(1)'", &policy).is_ok()); |
| 213 | + } |
| 214 | + |
| 215 | + #[test] |
| 216 | + fn whitelisted_policy_strips_absolute_path() { |
| 217 | + let policy = ShellPolicy::Whitelisted; |
| 218 | + assert!(validate_shell_command("/bin/ls -la", &policy).is_ok()); |
| 219 | + assert!(validate_shell_command("/usr/bin/grep -r foo /tmp", &policy).is_ok()); |
| 220 | + } |
| 221 | + |
| 222 | + // ── shell_binary ───────────────────────────────────────────────────────── |
| 223 | + |
| 224 | + #[test] |
| 225 | + fn shell_binary_strips_args() { |
| 226 | + assert_eq!(shell_binary("ls -la"), "ls"); |
| 227 | + assert_eq!(shell_binary("cat file.txt"), "cat"); |
| 228 | + } |
| 229 | + |
| 230 | + #[test] |
| 231 | + fn shell_binary_strips_path() { |
| 232 | + assert_eq!(shell_binary("/usr/bin/python3 script.py"), "python3"); |
| 233 | + assert_eq!(shell_binary("/bin/ls -la"), "ls"); |
| 234 | + } |
| 235 | + |
| 236 | + #[test] |
| 237 | + fn shell_binary_empty_input() { |
| 238 | + assert_eq!(shell_binary(""), "*"); |
| 239 | + assert_eq!(shell_binary(" "), "*"); |
| 240 | + } |
| 241 | + |
| 242 | + #[test] |
| 243 | + fn shell_binary_bare_name() { |
| 244 | + assert_eq!(shell_binary("grep"), "grep"); |
| 245 | + } |
| 246 | + |
| 247 | + // ── end-to-end: anonymous tier denies bash ──────────────────────────────── |
| 248 | + |
| 249 | + #[test] |
| 250 | + fn anonymous_bash_call_is_denied() { |
| 251 | + let policy = shell_policy_for(&PolicySet::anonymous()); |
| 252 | + let result = validate_shell_command("ls -la", &policy); |
| 253 | + assert!(result.is_err(), "anonymous bash must be denied"); |
| 254 | + assert!(result.unwrap_err().contains("blocked")); |
| 255 | + } |
| 256 | + |
| 257 | + #[test] |
| 258 | + fn free_tier_ls_is_allowed() { |
| 259 | + let policy = shell_policy_for(&PolicySet::free()); |
| 260 | + assert!(validate_shell_command("ls -la /session", &policy).is_ok()); |
| 261 | + } |
| 262 | + |
| 263 | + #[test] |
| 264 | + fn free_tier_rm_is_denied() { |
| 265 | + let policy = shell_policy_for(&PolicySet::free()); |
| 266 | + let result = validate_shell_command("rm -rf /tmp/x", &policy); |
| 267 | + assert!(result.is_err()); |
| 268 | + assert!(result.unwrap_err().contains("allowlist")); |
| 269 | + } |
| 270 | +} |
0 commit comments