Parcourir la source

Merge jobdori/permission-enforcement: PermissionEnforcer with workspace + bash enforcement

Jobdori il y a 2 mois
Parent
commit
336f820f27

+ 1 - 0
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;

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

+ 16 - 0
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<ToolSpec> {
     ]
 }
 
+/// 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<String, String> {
     match name {
         "bash" => from_value::<BashCommandInput>(input).and_then(run_bash),