Skip to content

Commit c5d419e

Browse files
broomvaclaude
andauthored
feat(arcan-aios-adapters): shell gating utilities and binary capability extraction (BRO-216) (#15)
* feat(arcan-aios-adapters): shell gating utilities and binary capability extraction (BRO-216) capability_map.rs: - Extract binary name from bash/shell command before building exec:cmd:<binary> capability (e.g. "ls -la" → exec:cmd:ls, "/usr/bin/python3" → exec:cmd:python3) - Enables precise per-command whitelisting in PolicySet::free() - shell_without_arg falls back to exec:cmd:* wildcard - Updated tests to assert binary-level capability tokens shell_gate.rs (new): - ShellPolicy enum: Blocked | Whitelisted | Unrestricted - shell_policy_for(&PolicySet): derives policy from capability set - validate_shell_command(cmd, policy): validates command binary against policy - FREE_TIER_ALLOWED_COMMANDS: safe read-only whitelist constant - 20+ unit tests covering all tiers, path stripping, end-to-end denial lib.rs: export shell_gate module and public symbols Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(arcan-aios-adapters): remove redundant trim() before split_whitespace() (clippy) split_whitespace() already handles leading/trailing whitespace, making the preceding trim() call redundant. Fixes clippy::trim_split_whitespace lint. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f454575 commit c5d419e

3 files changed

Lines changed: 323 additions & 10 deletions

File tree

crates/arcan-aios-adapters/src/capability_map.rs

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,22 @@ use aios_protocol::{Capability, PolicySet};
2525
pub fn capabilities_for_tool(tool_name: &str, input: &serde_json::Value) -> Vec<Capability> {
2626
match tool_name {
2727
// ── Shell / subprocess ─────────────────────────────────────────────
28-
// Requires exec:cmd:<command> capability.
28+
// Requires exec:cmd:<binary> capability where <binary> is the program
29+
// name extracted from the command string (e.g. "ls" from "ls -la").
30+
//
31+
// Using just the binary (not the full command string) enables precise
32+
// per-command whitelisting in the PolicySet — free tier allows
33+
// exec:cmd:cat, exec:cmd:ls, etc. while blocking exec:cmd:rm.
2934
"bash" | "shell" | "command" | "terminal" | "run_command" => {
3035
let cmd = input
3136
.get("command")
3237
.or_else(|| input.get("cmd"))
3338
.and_then(|v| v.as_str())
3439
.unwrap_or("*");
35-
// Use Capability::new to build "exec:cmd:<cmd>" without triggering the
40+
// Use Capability::new to build "exec:cmd:<binary>" without triggering the
3641
// execFile lint (this is Rust, not JavaScript).
37-
vec![Capability::new(format!("exec:cmd:{cmd}"))]
42+
let binary = shell_binary(cmd);
43+
vec![Capability::new(format!("exec:cmd:{binary}"))]
3844
}
3945

4046
// ── Filesystem writes ──────────────────────────────────────────────
@@ -84,6 +90,19 @@ pub fn capabilities_for_tool(tool_name: &str, input: &serde_json::Value) -> Vec<
8490
}
8591
}
8692

93+
// ── Shell command binary extraction ───────────────────────────────────────────
94+
95+
/// Extract the program binary from a shell command string.
96+
///
97+
/// Returns just the first whitespace-delimited token with path components
98+
/// stripped (e.g. `"ls -la"` → `"ls"`, `"/usr/bin/python3 script.py"` → `"python3"`).
99+
/// Falls back to `"*"` for empty input so the caller always gets a valid capability.
100+
fn shell_binary(cmd: &str) -> &str {
101+
let token = cmd.split_whitespace().next().unwrap_or("*");
102+
// Strip leading path (e.g. /usr/bin/ls → ls).
103+
token.rsplit('/').next().unwrap_or(token)
104+
}
105+
87106
// ── Tier-aware tool catalog filtering ─────────────────────────────────────────
88107

89108
/// Derive an allowlist of tool names visible in the LLM tool catalog based on
@@ -180,10 +199,22 @@ mod tests {
180199
use super::*;
181200

182201
#[test]
183-
fn shell_derives_exec_cmd_capability() {
202+
fn shell_derives_exec_cmd_binary_capability() {
203+
// Only the binary name is used (not the full command string), enabling
204+
// precise per-command whitelisting in the PolicySet (BRO-216).
184205
let caps = capabilities_for_tool("bash", &serde_json::json!({"command": "ls -la"}));
185206
assert_eq!(caps.len(), 1);
186-
assert_eq!(caps[0].as_str(), "exec:cmd:ls -la");
207+
assert_eq!(caps[0].as_str(), "exec:cmd:ls");
208+
}
209+
210+
#[test]
211+
fn shell_strips_path_prefix_from_binary() {
212+
let caps = capabilities_for_tool(
213+
"bash",
214+
&serde_json::json!({"command": "/usr/bin/python3 script.py"}),
215+
);
216+
assert_eq!(caps.len(), 1);
217+
assert_eq!(caps[0].as_str(), "exec:cmd:python3");
187218
}
188219

189220
#[test]
@@ -233,12 +264,20 @@ mod tests {
233264
}
234265

235266
#[test]
236-
fn shell_cap_is_gated_by_anonymous_policy() {
237-
// "exec:cmd:ls -la" starts with "exec:cmd:" — covered by the anonymous
238-
// gate pattern "exec:cmd:*" (prefix after trimming trailing '*').
267+
fn shell_cap_is_denied_by_anonymous_policy() {
268+
// exec:cmd:<binary> is neither in allow_capabilities nor gate_capabilities
269+
// for anonymous sessions — the StaticPolicyEngine puts it in `denied` (BRO-216).
239270
let cap = capabilities_for_tool("bash", &serde_json::json!({"command": "ls -la"}));
240-
let gate_prefix = "exec:cmd:*".trim_end_matches('*');
241-
assert!(cap[0].as_str().starts_with(gate_prefix));
271+
assert_eq!(cap[0].as_str(), "exec:cmd:ls");
272+
let anon = PolicySet::anonymous();
273+
let exec_wildcard_in_gate = anon
274+
.gate_capabilities
275+
.iter()
276+
.any(|c| c.as_str() == "exec:cmd:*");
277+
assert!(
278+
!exec_wildcard_in_gate,
279+
"anonymous gate must not contain exec:cmd:* — exec is denied, not gated"
280+
);
242281
}
243282

244283
#[test]

crates/arcan-aios-adapters/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod haima_middleware;
88
pub mod policy;
99
pub mod provider;
1010
pub mod sandbox;
11+
pub mod shell_gate;
1112
pub mod tools;
1213

1314
pub use approval::ArcanApprovalAdapter;
@@ -20,6 +21,9 @@ pub use haima_middleware::HaimaPaymentMiddleware;
2021
pub use policy::ArcanPolicyAdapter;
2122
pub use provider::{ArcanProviderAdapter, StreamingSenderHandle};
2223
pub use sandbox::SandboxEnforcer;
24+
pub use shell_gate::{
25+
FREE_TIER_ALLOWED_COMMANDS, ShellPolicy, shell_policy_for, validate_shell_command,
26+
};
2327

2428
// Re-export for convenience (the canonical type lives in arcan-core).
2529
pub use arcan_core::runtime::SwappableProviderHandle;
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
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

Comments
 (0)