diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 7b9734e..96ba644 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -12,6 +12,7 @@ mod mcp_client; mod mcp_stdio; pub mod mcp_tool_bridge; mod oauth; +pub mod permission_enforcer; mod permissions; mod prompt; mod remote; diff --git a/rust/crates/runtime/src/permission_enforcer.rs b/rust/crates/runtime/src/permission_enforcer.rs new file mode 100644 index 0000000..9abbcc8 --- /dev/null +++ b/rust/crates/runtime/src/permission_enforcer.rs @@ -0,0 +1,340 @@ +//! Permission enforcement layer that gates tool execution based on the +//! active `PermissionPolicy`. +//! +//! This module provides `PermissionEnforcer` which wraps tool dispatch +//! and validates that the active permission mode allows the requested tool +//! before executing it. + +use crate::permissions::{PermissionMode, PermissionOutcome, PermissionPolicy}; +use serde::{Deserialize, Serialize}; + +/// Result of a permission check before tool execution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "outcome")] +pub enum EnforcementResult { + /// Tool execution is allowed. + Allowed, + /// Tool execution was denied due to insufficient permissions. + Denied { + tool: String, + active_mode: String, + required_mode: String, + reason: String, + }, +} + +/// Permission enforcer that gates tool execution through the permission policy. +#[derive(Debug, Clone)] +pub struct PermissionEnforcer { + policy: PermissionPolicy, +} + +impl PermissionEnforcer { + #[must_use] + pub fn new(policy: PermissionPolicy) -> Self { + Self { policy } + } + + /// Check whether a tool can be executed under the current permission policy. + /// Uses the policy's `authorize` method with no prompter (auto-deny on prompt-required). + pub fn check(&self, tool_name: &str, input: &str) -> EnforcementResult { + let outcome = self.policy.authorize(tool_name, input, None); + + match outcome { + PermissionOutcome::Allow => EnforcementResult::Allowed, + PermissionOutcome::Deny { reason } => { + let active_mode = self.policy.active_mode(); + let required_mode = self.policy.required_mode_for(tool_name); + EnforcementResult::Denied { + tool: tool_name.to_owned(), + active_mode: active_mode.as_str().to_owned(), + required_mode: required_mode.as_str().to_owned(), + reason, + } + } + } + } + + /// Check if a tool is allowed (returns true for Allow, false for Deny). + #[must_use] + pub fn is_allowed(&self, tool_name: &str, input: &str) -> bool { + matches!(self.check(tool_name, input), EnforcementResult::Allowed) + } + + /// Get the active permission mode. + #[must_use] + pub fn active_mode(&self) -> PermissionMode { + self.policy.active_mode() + } + + /// Classify a file operation against workspace boundaries. + pub fn check_file_write(&self, path: &str, workspace_root: &str) -> EnforcementResult { + let mode = self.policy.active_mode(); + + match mode { + PermissionMode::ReadOnly => EnforcementResult::Denied { + tool: "write_file".to_owned(), + active_mode: mode.as_str().to_owned(), + required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(), + reason: format!("file writes are not allowed in '{}' mode", mode.as_str()), + }, + PermissionMode::WorkspaceWrite => { + if is_within_workspace(path, workspace_root) { + EnforcementResult::Allowed + } else { + EnforcementResult::Denied { + tool: "write_file".to_owned(), + active_mode: mode.as_str().to_owned(), + required_mode: PermissionMode::DangerFullAccess.as_str().to_owned(), + reason: format!( + "path '{}' is outside workspace root '{}'", + path, workspace_root + ), + } + } + } + // Allow and DangerFullAccess permit all writes + PermissionMode::Allow | PermissionMode::DangerFullAccess => EnforcementResult::Allowed, + PermissionMode::Prompt => EnforcementResult::Denied { + tool: "write_file".to_owned(), + active_mode: mode.as_str().to_owned(), + required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(), + reason: "file write requires confirmation in prompt mode".to_owned(), + }, + } + } + + /// Check if a bash command should be allowed based on current mode. + pub fn check_bash(&self, command: &str) -> EnforcementResult { + let mode = self.policy.active_mode(); + + match mode { + PermissionMode::ReadOnly => { + if is_read_only_command(command) { + EnforcementResult::Allowed + } else { + EnforcementResult::Denied { + tool: "bash".to_owned(), + active_mode: mode.as_str().to_owned(), + required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(), + reason: format!( + "command may modify state; not allowed in '{}' mode", + mode.as_str() + ), + } + } + } + PermissionMode::Prompt => EnforcementResult::Denied { + tool: "bash".to_owned(), + active_mode: mode.as_str().to_owned(), + required_mode: PermissionMode::DangerFullAccess.as_str().to_owned(), + reason: "bash requires confirmation in prompt mode".to_owned(), + }, + // WorkspaceWrite, Allow, DangerFullAccess: permit bash + _ => EnforcementResult::Allowed, + } + } +} + +/// Simple workspace boundary check via string prefix. +fn is_within_workspace(path: &str, workspace_root: &str) -> bool { + let normalized = if path.starts_with('/') { + path.to_owned() + } else { + format!("{workspace_root}/{path}") + }; + + let root = if workspace_root.ends_with('/') { + workspace_root.to_owned() + } else { + format!("{workspace_root}/") + }; + + normalized.starts_with(&root) || normalized == workspace_root.trim_end_matches('/') +} + +/// Conservative heuristic: is this bash command read-only? +fn is_read_only_command(command: &str) -> bool { + let first_token = command + .split_whitespace() + .next() + .unwrap_or("") + .rsplit('/') + .next() + .unwrap_or(""); + + matches!( + first_token, + "cat" + | "head" + | "tail" + | "less" + | "more" + | "wc" + | "ls" + | "find" + | "grep" + | "rg" + | "awk" + | "sed" + | "echo" + | "printf" + | "which" + | "where" + | "whoami" + | "pwd" + | "env" + | "printenv" + | "date" + | "cal" + | "df" + | "du" + | "free" + | "uptime" + | "uname" + | "file" + | "stat" + | "diff" + | "sort" + | "uniq" + | "tr" + | "cut" + | "paste" + | "tee" + | "xargs" + | "test" + | "true" + | "false" + | "type" + | "readlink" + | "realpath" + | "basename" + | "dirname" + | "sha256sum" + | "md5sum" + | "b3sum" + | "xxd" + | "hexdump" + | "od" + | "strings" + | "tree" + | "jq" + | "yq" + | "python3" + | "python" + | "node" + | "ruby" + | "cargo" + | "rustc" + | "git" + | "gh" + ) && !command.contains("-i ") + && !command.contains("--in-place") + && !command.contains(" > ") + && !command.contains(" >> ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_enforcer(mode: PermissionMode) -> PermissionEnforcer { + let policy = PermissionPolicy::new(mode); + PermissionEnforcer::new(policy) + } + + #[test] + fn allow_mode_permits_everything() { + let enforcer = make_enforcer(PermissionMode::Allow); + assert!(enforcer.is_allowed("bash", "")); + assert!(enforcer.is_allowed("write_file", "")); + assert!(enforcer.is_allowed("edit_file", "")); + assert_eq!( + enforcer.check_file_write("/outside/path", "/workspace"), + EnforcementResult::Allowed + ); + assert_eq!(enforcer.check_bash("rm -rf /"), EnforcementResult::Allowed); + } + + #[test] + fn read_only_denies_writes() { + let policy = PermissionPolicy::new(PermissionMode::ReadOnly) + .with_tool_requirement("read_file", PermissionMode::ReadOnly) + .with_tool_requirement("grep_search", PermissionMode::ReadOnly) + .with_tool_requirement("write_file", PermissionMode::WorkspaceWrite); + + let enforcer = PermissionEnforcer::new(policy); + assert!(enforcer.is_allowed("read_file", "")); + assert!(enforcer.is_allowed("grep_search", "")); + + // write_file requires WorkspaceWrite but we're in ReadOnly + let result = enforcer.check("write_file", ""); + assert!(matches!(result, EnforcementResult::Denied { .. })); + + let result = enforcer.check_file_write("/workspace/file.rs", "/workspace"); + assert!(matches!(result, EnforcementResult::Denied { .. })); + } + + #[test] + fn read_only_allows_read_commands() { + let enforcer = make_enforcer(PermissionMode::ReadOnly); + assert_eq!( + enforcer.check_bash("cat src/main.rs"), + EnforcementResult::Allowed + ); + assert_eq!( + enforcer.check_bash("grep -r 'pattern' ."), + EnforcementResult::Allowed + ); + assert_eq!(enforcer.check_bash("ls -la"), EnforcementResult::Allowed); + } + + #[test] + fn read_only_denies_write_commands() { + let enforcer = make_enforcer(PermissionMode::ReadOnly); + let result = enforcer.check_bash("rm file.txt"); + assert!(matches!(result, EnforcementResult::Denied { .. })); + } + + #[test] + fn workspace_write_allows_within_workspace() { + let enforcer = make_enforcer(PermissionMode::WorkspaceWrite); + let result = enforcer.check_file_write("/workspace/src/main.rs", "/workspace"); + assert_eq!(result, EnforcementResult::Allowed); + } + + #[test] + fn workspace_write_denies_outside_workspace() { + let enforcer = make_enforcer(PermissionMode::WorkspaceWrite); + let result = enforcer.check_file_write("/etc/passwd", "/workspace"); + assert!(matches!(result, EnforcementResult::Denied { .. })); + } + + #[test] + fn prompt_mode_denies_without_prompter() { + let enforcer = make_enforcer(PermissionMode::Prompt); + let result = enforcer.check_bash("echo test"); + assert!(matches!(result, EnforcementResult::Denied { .. })); + + let result = enforcer.check_file_write("/workspace/file.rs", "/workspace"); + assert!(matches!(result, EnforcementResult::Denied { .. })); + } + + #[test] + fn workspace_boundary_check() { + assert!(is_within_workspace("/workspace/src/main.rs", "/workspace")); + assert!(is_within_workspace("/workspace", "/workspace")); + assert!(!is_within_workspace("/etc/passwd", "/workspace")); + assert!(!is_within_workspace("/workspacex/hack", "/workspace")); + } + + #[test] + fn read_only_command_heuristic() { + assert!(is_read_only_command("cat file.txt")); + assert!(is_read_only_command("grep pattern file")); + assert!(is_read_only_command("git log --oneline")); + assert!(!is_read_only_command("rm file.txt")); + assert!(!is_read_only_command("echo test > file.txt")); + assert!(!is_read_only_command("sed -i 's/a/b/' file")); + } +} diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index fc8891b..55132c1 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -14,6 +14,7 @@ use runtime::{ edit_file, execute_bash, glob_search, grep_search, load_system_prompt, lsp_client::LspRegistry, mcp_tool_bridge::McpToolRegistry, + permission_enforcer::{EnforcementResult, PermissionEnforcer}, read_file, task_registry::TaskRegistry, team_cron_registry::{CronRegistry, TeamRegistry}, @@ -872,6 +873,21 @@ pub fn mvp_tool_specs() -> Vec { ] } +/// Check permission before executing a tool. Returns Err with denial reason if blocked. +pub fn enforce_permission_check( + enforcer: &PermissionEnforcer, + tool_name: &str, + input: &Value, +) -> Result<(), String> { + let input_str = serde_json::to_string(input).unwrap_or_default(); + let result = enforcer.check(tool_name, &input_str); + + match result { + EnforcementResult::Allowed => Ok(()), + EnforcementResult::Denied { reason, .. } => Err(reason), + } +} + pub fn execute_tool(name: &str, input: &Value) -> Result { match name { "bash" => from_value::(input).and_then(run_bash),