Files
claude-code/rust/crates/runtime/src/permission_enforcer.rs
bellman 503d515f38 style: cargo fmt after merged PRs (#3164, #3209, #3214, #3216)
Fix formatting inconsistencies introduced by merged external PRs.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 10:52:37 +09:00

739 lines
25 KiB
Rust

#![allow(
clippy::match_wildcard_for_single_variants,
clippy::must_use_candidate,
clippy::uninlined_format_args
)]
//! 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)
}
/// Check permission with an explicitly provided required mode.
/// Used when the required mode is determined dynamically (e.g., bash command classification).
pub fn check_with_required_mode(
&self,
tool_name: &str,
input: &str,
required_mode: PermissionMode,
) -> EnforcementResult {
// When the active mode is Prompt, defer to the caller's interactive
// prompt flow rather than hard-denying.
if self.policy.active_mode() == PermissionMode::Prompt {
return EnforcementResult::Allowed;
}
let active_mode = self.policy.active_mode();
// Check if active mode meets the dynamically determined required mode
if active_mode >= required_mode {
return EnforcementResult::Allowed;
}
// Permission denied - active mode is insufficient
EnforcementResult::Denied {
tool: tool_name.to_owned(),
active_mode: active_mode.as_str().to_owned(),
required_mode: required_mode.as_str().to_owned(),
reason: format!(
"'{tool_name}' with input '{input}' requires '{}' permission, but current mode is '{}'",
required_mode.as_str(),
active_mode.as_str()
),
}
}
#[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,
}
}
}
/// Workspace boundary check.
///
/// Resolves `.` and `..` components lexically *before* comparing against the
/// workspace root, so that traversal sequences like `/workspace/../../etc`
/// cannot escape the sandbox via a naive string prefix match. Normalization is
/// lexical (it does not touch the filesystem) because the target path may not
/// exist yet on a write, and we must not depend on CWD.
fn is_within_workspace(path: &str, workspace_root: &str) -> bool {
let combined = if path.starts_with('/') {
path.to_owned()
} else {
format!("{workspace_root}/{path}")
};
let normalized = lexically_normalize(&combined);
let root = lexically_normalize(workspace_root);
let root_with_slash = if root.ends_with('/') {
root.clone()
} else {
format!("{root}/")
};
normalized == root || normalized.starts_with(&root_with_slash)
}
/// Collapse `.` and `..` segments without consulting the filesystem.
/// `..` that would climb above an absolute root is clamped at `/`, so the
/// result can never be a prefix-match for a deeper workspace root.
fn lexically_normalize(path: &str) -> String {
let is_absolute = path.starts_with('/');
let mut stack: Vec<&str> = Vec::new();
for component in path.split('/') {
match component {
"" | "." => {}
".." => {
stack.pop();
}
other => stack.push(other),
}
}
let joined = stack.join("/");
if is_absolute {
format!("/{joined}")
} else {
joined
}
}
/// Conservative heuristic: is this bash command read-only?
///
/// Hardening notes:
/// - Any shell metacharacter that could chain, substitute, pipe, or redirect
/// into a state-changing command rejects the whole line. This blocks
/// `cat x; rm -rf y`, `cat x | sh`, `$(...)`, backticks, redirects, and
/// subshells regardless of the leading token.
/// - Language interpreters (`python`, `node`, `ruby`) and build drivers
/// (`cargo`, `rustc`) are NOT read-only: they execute arbitrary code, so they
/// are excluded from the allow-list.
/// - `git` is allowed only for a known set of non-mutating subcommands.
/// - `find` is rejected when it carries an action that can execute or delete.
///
/// Residual known gaps (documented, not yet closed): `sed`'s `w`/`e` script
/// commands and `awk`'s `system()` can still mutate — these require quoting or
/// metacharacters that the checks above usually catch, but a dedicated parser
/// would be more robust. Tracked as follow-up.
fn is_read_only_command(command: &str) -> bool {
// Shell metacharacters that enable command chaining, substitution,
// piping, redirection, or subshells. Presence of any of these means we
// cannot reason about the command from its leading token alone.
const SHELL_METACHARS: &[char] = &[';', '|', '&', '$', '`', '>', '<', '(', ')', '{', '}', '\n'];
if command.contains(SHELL_METACHARS) {
return false;
}
let mut tokens = command.split_whitespace();
let first_token = tokens.next().unwrap_or("").rsplit('/').next().unwrap_or("");
// `git` is only read-only for a curated set of subcommands.
if first_token == "git" {
let subcommand = tokens.next().unwrap_or("");
return matches!(
subcommand,
"status"
| "log"
| "diff"
| "show"
| "branch"
| "rev-parse"
| "ls-files"
| "blame"
| "describe"
| "tag"
| "remote"
);
}
// `find` can execute or delete via actions; reject those forms.
if first_token == "find"
&& (command.contains("-exec")
|| command.contains("-execdir")
|| command.contains("-delete")
|| command.contains("-ok")
|| command.contains("-fprintf"))
{
return false;
}
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"
| "test"
| "true"
| "false"
| "type"
| "readlink"
| "realpath"
| "basename"
| "dirname"
| "sha256sum"
| "md5sum"
| "b3sum"
| "xxd"
| "hexdump"
| "od"
| "strings"
| "tree"
| "jq"
| "yq"
) && !command.contains("-i ")
&& !command.contains("--in-place")
}
#[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"));
}
// --- Hardening regression tests (#2: read-only bypasses) ---
#[test]
fn read_only_rejects_command_chaining() {
// A leading read-only token must not launder a trailing destructive one.
assert!(!is_read_only_command("cat foo; rm -rf bar"));
assert!(!is_read_only_command("cat foo && rm -rf bar"));
assert!(!is_read_only_command("ls || rm bar"));
assert!(!is_read_only_command("cat foo | sh"));
assert!(!is_read_only_command("echo `rm bar`"));
assert!(!is_read_only_command("echo $(rm bar)"));
assert!(!is_read_only_command("echo x>file")); // redirect without spaces
}
#[test]
fn read_only_rejects_interpreters_and_build_drivers() {
// These execute arbitrary code and are no longer read-only.
assert!(!is_read_only_command(
"python3 -c \"import os; os.system('rm -rf .')\""
));
assert!(!is_read_only_command("python script.py"));
assert!(!is_read_only_command("node app.js"));
assert!(!is_read_only_command("ruby x.rb"));
assert!(!is_read_only_command("cargo run"));
assert!(!is_read_only_command("rustc evil.rs"));
}
#[test]
fn read_only_gates_git_subcommands() {
// Read-only git subcommands remain allowed...
assert!(is_read_only_command("git status"));
assert!(is_read_only_command("git diff HEAD~1"));
assert!(is_read_only_command("git show abc123"));
// ...but mutating/exfiltrating ones are rejected.
assert!(!is_read_only_command("git commit -m x"));
assert!(!is_read_only_command("git push origin main"));
assert!(!is_read_only_command("git reset --hard"));
assert!(!is_read_only_command("git clean -fd"));
assert!(!is_read_only_command("git config user.email a@b.c"));
}
#[test]
fn read_only_rejects_find_actions() {
assert!(is_read_only_command("find . -name Cargo.toml"));
assert!(!is_read_only_command("find . -delete"));
// -exec uses braces/semicolon which also trip the metachar guard,
// but the explicit action check is the primary defense.
assert!(!is_read_only_command("find . -execdir rm rf"));
}
// --- Hardening regression tests (#1: workspace path traversal) ---
#[test]
fn workspace_rejects_parent_traversal() {
assert!(!is_within_workspace(
"/workspace/../etc/passwd",
"/workspace"
));
assert!(!is_within_workspace(
"/workspace/../../etc/crontab",
"/workspace"
));
assert!(!is_within_workspace("../etc/passwd", "/workspace"));
assert!(!is_within_workspace(
"/workspace/sub/../../outside",
"/workspace"
));
// Legitimate paths still resolve inside.
assert!(is_within_workspace(
"/workspace/./src/main.rs",
"/workspace"
));
assert!(is_within_workspace(
"/workspace/src/../src/main.rs",
"/workspace"
));
}
#[test]
fn workspace_write_denies_traversal_escape() {
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
let result = enforcer.check_file_write("/workspace/../../etc/crontab", "/workspace");
assert!(matches!(result, EnforcementResult::Denied { .. }));
}
#[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:?}"),
}
}
}