//! 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")); } }