|
|
@@ -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"));
|
|
|
+ }
|
|
|
+}
|