| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546 |
- //! Permission enforcement layer that gates tool execution based on the
- //! active `PermissionPolicy`.
- use crate::permissions::{PermissionMode, PermissionOutcome, PermissionPolicy};
- use serde::{Deserialize, Serialize};
- #[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,
- },
- }
- #[derive(Debug, Clone, PartialEq)]
- 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.
- /// Auto-denies when prompting is required but no prompter is provided.
- pub fn check(&self, tool_name: &str, input: &str) -> EnforcementResult {
- // When the active mode is Prompt, defer to the caller's interactive
- // prompt flow rather than hard-denying (the enforcer has no prompter).
- if self.policy.active_mode() == PermissionMode::Prompt {
- return EnforcementResult::Allowed;
- }
- 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,
- }
- }
- }
- }
- #[must_use]
- pub fn is_allowed(&self, tool_name: &str, input: &str) -> bool {
- matches!(self.check(tool_name, input), EnforcementResult::Allowed)
- }
- #[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"));
- }
- #[test]
- fn active_mode_returns_policy_mode() {
- // given
- let modes = [
- PermissionMode::ReadOnly,
- PermissionMode::WorkspaceWrite,
- PermissionMode::DangerFullAccess,
- PermissionMode::Prompt,
- PermissionMode::Allow,
- ];
- // when
- let active_modes: Vec<_> = modes
- .into_iter()
- .map(|mode| make_enforcer(mode).active_mode())
- .collect();
- // then
- assert_eq!(active_modes, modes);
- }
- #[test]
- fn danger_full_access_permits_file_writes_and_bash() {
- // given
- let enforcer = make_enforcer(PermissionMode::DangerFullAccess);
- // when
- let file_result = enforcer.check_file_write("/outside/workspace/file.txt", "/workspace");
- let bash_result = enforcer.check_bash("rm -rf /tmp/scratch");
- // then
- assert_eq!(file_result, EnforcementResult::Allowed);
- assert_eq!(bash_result, EnforcementResult::Allowed);
- }
- #[test]
- fn check_denied_payload_contains_tool_and_modes() {
- // given
- let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
- .with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
- let enforcer = PermissionEnforcer::new(policy);
- // when
- let result = enforcer.check("write_file", "{}");
- // then
- match result {
- EnforcementResult::Denied {
- tool,
- active_mode,
- required_mode,
- reason,
- } => {
- assert_eq!(tool, "write_file");
- assert_eq!(active_mode, "read-only");
- assert_eq!(required_mode, "workspace-write");
- assert!(reason.contains("requires workspace-write permission"));
- }
- other => panic!("expected denied result, got {other:?}"),
- }
- }
- #[test]
- fn workspace_write_relative_path_resolved() {
- // given
- let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
- // when
- let result = enforcer.check_file_write("src/main.rs", "/workspace");
- // then
- assert_eq!(result, EnforcementResult::Allowed);
- }
- #[test]
- fn workspace_root_with_trailing_slash() {
- // given
- let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
- // when
- let result = enforcer.check_file_write("/workspace/src/main.rs", "/workspace/");
- // then
- assert_eq!(result, EnforcementResult::Allowed);
- }
- #[test]
- fn workspace_root_equality() {
- // given
- let root = "/workspace/";
- // when
- let equal_to_root = is_within_workspace("/workspace", root);
- // then
- assert!(equal_to_root);
- }
- #[test]
- fn bash_heuristic_full_path_prefix() {
- // given
- let full_path_command = "/usr/bin/cat Cargo.toml";
- let git_path_command = "/usr/local/bin/git status";
- // when
- let cat_result = is_read_only_command(full_path_command);
- let git_result = is_read_only_command(git_path_command);
- // then
- assert!(cat_result);
- assert!(git_result);
- }
- #[test]
- fn bash_heuristic_redirects_block_read_only_commands() {
- // given
- let overwrite = "cat Cargo.toml > out.txt";
- let append = "echo test >> out.txt";
- // when
- let overwrite_result = is_read_only_command(overwrite);
- let append_result = is_read_only_command(append);
- // then
- assert!(!overwrite_result);
- assert!(!append_result);
- }
- #[test]
- fn bash_heuristic_in_place_flag_blocks() {
- // given
- let interactive_python = "python -i script.py";
- let in_place_sed = "sed --in-place 's/a/b/' file.txt";
- // when
- let interactive_result = is_read_only_command(interactive_python);
- let in_place_result = is_read_only_command(in_place_sed);
- // then
- assert!(!interactive_result);
- assert!(!in_place_result);
- }
- #[test]
- fn bash_heuristic_empty_command() {
- // given
- let empty = "";
- let whitespace = " ";
- // when
- let empty_result = is_read_only_command(empty);
- let whitespace_result = is_read_only_command(whitespace);
- // then
- assert!(!empty_result);
- assert!(!whitespace_result);
- }
- #[test]
- fn prompt_mode_check_bash_denied_payload_fields() {
- // given
- let enforcer = make_enforcer(PermissionMode::Prompt);
- // when
- let result = enforcer.check_bash("git status");
- // then
- match result {
- EnforcementResult::Denied {
- tool,
- active_mode,
- required_mode,
- reason,
- } => {
- assert_eq!(tool, "bash");
- assert_eq!(active_mode, "prompt");
- assert_eq!(required_mode, "danger-full-access");
- assert_eq!(reason, "bash requires confirmation in prompt mode");
- }
- other => panic!("expected denied result, got {other:?}"),
- }
- }
- #[test]
- fn read_only_check_file_write_denied_payload() {
- // given
- let enforcer = make_enforcer(PermissionMode::ReadOnly);
- // when
- let result = enforcer.check_file_write("/workspace/file.txt", "/workspace");
- // then
- match result {
- EnforcementResult::Denied {
- tool,
- active_mode,
- required_mode,
- reason,
- } => {
- assert_eq!(tool, "write_file");
- assert_eq!(active_mode, "read-only");
- assert_eq!(required_mode, "workspace-write");
- assert!(reason.contains("file writes are not allowed"));
- }
- other => panic!("expected denied result, got {other:?}"),
- }
- }
- }
|