Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 49 additions & 10 deletions crates/arcan-aios-adapters/src/capability_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,22 @@ use aios_protocol::{Capability, PolicySet};
pub fn capabilities_for_tool(tool_name: &str, input: &serde_json::Value) -> Vec<Capability> {
match tool_name {
// ── Shell / subprocess ─────────────────────────────────────────────
// Requires exec:cmd:<command> capability.
// Requires exec:cmd:<binary> capability where <binary> is the program
// name extracted from the command string (e.g. "ls" from "ls -la").
//
// Using just the binary (not the full command string) enables precise
// per-command whitelisting in the PolicySet — free tier allows
// exec:cmd:cat, exec:cmd:ls, etc. while blocking exec:cmd:rm.
"bash" | "shell" | "command" | "terminal" | "run_command" => {
let cmd = input
.get("command")
.or_else(|| input.get("cmd"))
.and_then(|v| v.as_str())
.unwrap_or("*");
// Use Capability::new to build "exec:cmd:<cmd>" without triggering the
// Use Capability::new to build "exec:cmd:<binary>" without triggering the
// execFile lint (this is Rust, not JavaScript).
vec![Capability::new(format!("exec:cmd:{cmd}"))]
let binary = shell_binary(cmd);
vec![Capability::new(format!("exec:cmd:{binary}"))]
}

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

// ── Shell command binary extraction ───────────────────────────────────────────

/// Extract the program binary from a shell command string.
///
/// Returns just the first whitespace-delimited token with path components
/// stripped (e.g. `"ls -la"` → `"ls"`, `"/usr/bin/python3 script.py"` → `"python3"`).
/// Falls back to `"*"` for empty input so the caller always gets a valid capability.
fn shell_binary(cmd: &str) -> &str {
let token = cmd.split_whitespace().next().unwrap_or("*");
// Strip leading path (e.g. /usr/bin/ls → ls).
token.rsplit('/').next().unwrap_or(token)
}

// ── Tier-aware tool catalog filtering ─────────────────────────────────────────

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

#[test]
fn shell_derives_exec_cmd_capability() {
fn shell_derives_exec_cmd_binary_capability() {
// Only the binary name is used (not the full command string), enabling
// precise per-command whitelisting in the PolicySet (BRO-216).
let caps = capabilities_for_tool("bash", &serde_json::json!({"command": "ls -la"}));
assert_eq!(caps.len(), 1);
assert_eq!(caps[0].as_str(), "exec:cmd:ls -la");
assert_eq!(caps[0].as_str(), "exec:cmd:ls");
}

#[test]
fn shell_strips_path_prefix_from_binary() {
let caps = capabilities_for_tool(
"bash",
&serde_json::json!({"command": "/usr/bin/python3 script.py"}),
);
assert_eq!(caps.len(), 1);
assert_eq!(caps[0].as_str(), "exec:cmd:python3");
}

#[test]
Expand Down Expand Up @@ -233,12 +264,20 @@ mod tests {
}

#[test]
fn shell_cap_is_gated_by_anonymous_policy() {
// "exec:cmd:ls -la" starts with "exec:cmd:" — covered by the anonymous
// gate pattern "exec:cmd:*" (prefix after trimming trailing '*').
fn shell_cap_is_denied_by_anonymous_policy() {
// exec:cmd:<binary> is neither in allow_capabilities nor gate_capabilities
// for anonymous sessions — the StaticPolicyEngine puts it in `denied` (BRO-216).
let cap = capabilities_for_tool("bash", &serde_json::json!({"command": "ls -la"}));
let gate_prefix = "exec:cmd:*".trim_end_matches('*');
assert!(cap[0].as_str().starts_with(gate_prefix));
assert_eq!(cap[0].as_str(), "exec:cmd:ls");
let anon = PolicySet::anonymous();
let exec_wildcard_in_gate = anon
.gate_capabilities
.iter()
.any(|c| c.as_str() == "exec:cmd:*");
assert!(
!exec_wildcard_in_gate,
"anonymous gate must not contain exec:cmd:* — exec is denied, not gated"
);
}

#[test]
Expand Down
4 changes: 4 additions & 0 deletions crates/arcan-aios-adapters/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod haima_middleware;
pub mod policy;
pub mod provider;
pub mod sandbox;
pub mod shell_gate;
pub mod tools;

pub use approval::ArcanApprovalAdapter;
Expand All @@ -20,6 +21,9 @@ pub use haima_middleware::HaimaPaymentMiddleware;
pub use policy::ArcanPolicyAdapter;
pub use provider::{ArcanProviderAdapter, StreamingSenderHandle};
pub use sandbox::SandboxEnforcer;
pub use shell_gate::{
FREE_TIER_ALLOWED_COMMANDS, ShellPolicy, shell_policy_for, validate_shell_command,
};

// Re-export for convenience (the canonical type lives in arcan-core).
pub use arcan_core::runtime::SwappableProviderHandle;
Expand Down
270 changes: 270 additions & 0 deletions crates/arcan-aios-adapters/src/shell_gate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
//! Tier-specific shell execution policy helpers for Arcan (BRO-216).
//!
//! Shell/bash execution is the highest-risk capability in the Arcan runtime.
//! This module provides utilities for deriving the shell policy from a
//! session's [`PolicySet`] and validating commands against it.
//!
//! # Enforcement model
//!
//! Enforcement happens at **two complementary layers**:
//!
//! 1. **PolicySet / capability evaluation** (aios-protocol + aios-policy):
//! `capabilities_for_tool("bash", input)` returns `exec:cmd:<binary>`.
//! The `StaticPolicyEngine` evaluates this against the session's policy:
//! - Anonymous: `exec:cmd:*` is absent from `allow_capabilities` and
//! `gate_capabilities` → **immediately denied** (no approval ticket).
//! - Free: only whitelisted binaries (`cat`, `ls`, `echo`, …) are in
//! `allow_capabilities` → whitelisted commands allowed, others denied.
//! - Pro/Enterprise: wildcard `"*"` in `allow_capabilities` → all allowed.
//!
//! 2. **Tier catalog filtering** (BRO-214): The `bash` tool is hidden from the
//! LLM's tool list for anonymous/free tiers, preventing the model from
//! planning shell-based actions it cannot execute.
//!
//! # Tier matrix
//!
//! | Tier | Shell Access | Mechanism |
//! |-------------|-------------------|------------------------------|
//! | Anonymous | Blocked (denied) | `exec:cmd:*` absent from policy |
//! | Free | Whitelist only | specific `exec:cmd:<binary>` allowed |
//! | Pro | Full access | wildcard `"*"` in policy |
//! | Enterprise | Full access | wildcard `"*"` in policy |

use aios_protocol::PolicySet;

/// Safe read-only shell commands allowed for the free tier.
///
/// These are restricted to non-destructive, read-only operations that cannot
/// exfiltrate credentials, modify system state, or escalate privileges.
pub const FREE_TIER_ALLOWED_COMMANDS: &[&str] = &[
"cat", "echo", "find", "grep", "head", "jq", "ls", "python3", "sort", "tail", "wc",
];

/// Tier-specific shell execution policy derived from a [`PolicySet`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShellPolicy {
/// Shell execution is blocked entirely (anonymous tier).
///
/// Any `bash` or `shell` tool call is immediately denied without
/// creating an approval ticket.
Blocked,
/// Only whitelisted commands are allowed (free tier).
///
/// The whitelist is the intersection of the free tier's `allow_capabilities`
/// and a safe hard-coded set of read-only binaries.
Whitelisted,
/// Full shell access within the workspace (pro/enterprise).
Unrestricted,
}

/// Derive the shell execution policy from a [`PolicySet`].
///
/// This mirrors the PolicySet evaluation performed by `StaticPolicyEngine`
/// at runtime, giving call sites a high-level view of what shell policy
/// applies without having to replicate the capability matching logic.
pub fn shell_policy_for(policy: &PolicySet) -> ShellPolicy {
// Pro/Enterprise: wildcard allow → unrestricted.
if policy.allow_capabilities.iter().any(|c| c.as_str() == "*") {
return ShellPolicy::Unrestricted;
}

// Check whether exec:cmd:* is broadly allowed (e.g. exec:cmd:* wildcard).
let exec_broadly_allowed = policy.allow_capabilities.iter().any(|c| {
let s = c.as_str();
s == "exec:cmd:*" || (s.starts_with("exec:cmd:") && s.ends_with('*'))
});
if exec_broadly_allowed {
return ShellPolicy::Unrestricted;
}

// Check whether at least one specific exec:cmd:<binary> is allowed.
let exec_any_allowed = policy
.allow_capabilities
.iter()
.any(|c| c.as_str().starts_with("exec:cmd:"));
if exec_any_allowed {
return ShellPolicy::Whitelisted;
}

// No exec capabilities at all → blocked.
ShellPolicy::Blocked
}

/// Validate a shell command against a [`ShellPolicy`].
///
/// Returns `Ok(())` if the command is permitted, or an error string describing
/// the violation. The caller should convert the error to the appropriate
/// domain error type (e.g. `CoreError::Middleware`).
pub fn validate_shell_command(cmd: &str, policy: &ShellPolicy) -> Result<(), String> {
match policy {
ShellPolicy::Blocked => Err(format!(
"shell execution blocked by tier policy: exec:cmd:{} denied",
shell_binary(cmd)
)),
ShellPolicy::Unrestricted => Ok(()),
ShellPolicy::Whitelisted => {
let binary = shell_binary(cmd);
if FREE_TIER_ALLOWED_COMMANDS.contains(&binary) {
Ok(())
} else {
Err(format!(
"command '{}' not in free-tier shell allowlist",
binary
))
}
}
}
}

/// Extract the program binary from a shell command string.
///
/// Strips leading path components and trailing arguments:
/// - `"ls -la"` → `"ls"`
/// - `"/usr/bin/python3 script.py"` → `"python3"`
/// - `""` → `"*"`
fn shell_binary(cmd: &str) -> &str {
let token = cmd.split_whitespace().next().unwrap_or("*");
token.rsplit('/').next().unwrap_or(token)
}

#[cfg(test)]
mod tests {
use super::*;

// ── shell_policy_for ─────────────────────────────────────────────────────

#[test]
fn anonymous_policy_is_blocked() {
assert_eq!(
shell_policy_for(&PolicySet::anonymous()),
ShellPolicy::Blocked
);
}

#[test]
fn free_policy_is_whitelisted() {
assert_eq!(
shell_policy_for(&PolicySet::free()),
ShellPolicy::Whitelisted
);
}

#[test]
fn pro_policy_is_unrestricted() {
assert_eq!(
shell_policy_for(&PolicySet::pro()),
ShellPolicy::Unrestricted
);
}

#[test]
fn enterprise_policy_is_unrestricted() {
assert_eq!(
shell_policy_for(&PolicySet::enterprise()),
ShellPolicy::Unrestricted
);
}

// ── validate_shell_command ───────────────────────────────────────────────

#[test]
fn blocked_policy_rejects_all_commands() {
let policy = ShellPolicy::Blocked;
assert!(validate_shell_command("ls", &policy).is_err());
assert!(validate_shell_command("cat file.txt", &policy).is_err());
assert!(validate_shell_command("rm -rf /", &policy).is_err());
}

#[test]
fn unrestricted_policy_allows_all_commands() {
let policy = ShellPolicy::Unrestricted;
assert!(validate_shell_command("rm -rf /tmp", &policy).is_ok());
assert!(validate_shell_command("sudo bash", &policy).is_ok());
assert!(validate_shell_command("cat /etc/passwd", &policy).is_ok());
}

#[test]
fn whitelisted_policy_allows_safe_commands() {
let policy = ShellPolicy::Whitelisted;
for cmd in FREE_TIER_ALLOWED_COMMANDS {
assert!(
validate_shell_command(cmd, &policy).is_ok(),
"{cmd} must be allowed"
);
}
}

#[test]
fn whitelisted_policy_rejects_unlisted_commands() {
let policy = ShellPolicy::Whitelisted;
assert!(validate_shell_command("rm -rf /", &policy).is_err());
assert!(validate_shell_command("sudo bash", &policy).is_err());
assert!(validate_shell_command("curl https://evil.com", &policy).is_err());
assert!(validate_shell_command("chmod 777 /", &policy).is_err());
}

#[test]
fn whitelisted_policy_allows_commands_with_args() {
// Validation uses the binary name only, not the full command.
let policy = ShellPolicy::Whitelisted;
assert!(validate_shell_command("ls -la /tmp", &policy).is_ok());
assert!(validate_shell_command("grep -r pattern /session", &policy).is_ok());
assert!(validate_shell_command("python3 -c 'print(1)'", &policy).is_ok());
}

#[test]
fn whitelisted_policy_strips_absolute_path() {
let policy = ShellPolicy::Whitelisted;
assert!(validate_shell_command("/bin/ls -la", &policy).is_ok());
assert!(validate_shell_command("/usr/bin/grep -r foo /tmp", &policy).is_ok());
}

// ── shell_binary ─────────────────────────────────────────────────────────

#[test]
fn shell_binary_strips_args() {
assert_eq!(shell_binary("ls -la"), "ls");
assert_eq!(shell_binary("cat file.txt"), "cat");
}

#[test]
fn shell_binary_strips_path() {
assert_eq!(shell_binary("/usr/bin/python3 script.py"), "python3");
assert_eq!(shell_binary("/bin/ls -la"), "ls");
}

#[test]
fn shell_binary_empty_input() {
assert_eq!(shell_binary(""), "*");
assert_eq!(shell_binary(" "), "*");
}

#[test]
fn shell_binary_bare_name() {
assert_eq!(shell_binary("grep"), "grep");
}

// ── end-to-end: anonymous tier denies bash ────────────────────────────────

#[test]
fn anonymous_bash_call_is_denied() {
let policy = shell_policy_for(&PolicySet::anonymous());
let result = validate_shell_command("ls -la", &policy);
assert!(result.is_err(), "anonymous bash must be denied");
assert!(result.unwrap_err().contains("blocked"));
}

#[test]
fn free_tier_ls_is_allowed() {
let policy = shell_policy_for(&PolicySet::free());
assert!(validate_shell_command("ls -la /session", &policy).is_ok());
}

#[test]
fn free_tier_rm_is_denied() {
let policy = shell_policy_for(&PolicySet::free());
let result = validate_shell_command("rm -rf /tmp/x", &policy);
assert!(result.is_err());
assert!(result.unwrap_err().contains("allowlist"));
}
}
Loading