use std::path::PathBuf;
use super::policies::{
BuiltinAllowPolicy, ConfiguredDenyPolicy, ConfiguredRulePolicy, DefaultActionPolicy,
ExternalDirPolicy, LoopGuardPolicy, OpMatch, PromptDenyPolicy, Rule, SessionAllowlistPolicy,
YoloPolicy,
};
use super::policy::{Decider, Modifier, PolicyCtx};
use super::types::{Effect, Operation, Resource};
use super::{Engine, classify::pattern_for_tool};
use crate::permission::path::{canonicalize_for_cache, resolve_absolute};
use crate::permission::{Action, PermissionConfig};
const LOOP_GUARD_THRESHOLD: u32 = 3;
impl From<Action> for Effect {
fn from(a: Action) -> Effect {
match a {
Action::Allow => Effect::Allow,
Action::Ask => Effect::Ask,
Action::Deny => Effect::Deny,
}
}
}
pub fn tool_operation(tool: &str) -> Operation {
match tool {
"read" | "read_minified" | "grep" | "find_files" | "glob" | "list_dir"
| "repo_overview" | "lsp" | "list_symbols" | "get_symbol_body" | "find_definition"
| "find_callers" | "find_callees" => Operation::Read,
"write" => Operation::Edit,
"edit" | "apply_patch" | "edit_minified" => Operation::Edit,
"bash" | "shell" => Operation::Execute,
"webfetch" | "websearch" => Operation::Network,
"mcp_tool" => Operation::Mcp,
"plugin_tool" => Operation::Plugin,
"memory" => Operation::Memory,
"skill" => Operation::Skill,
"task" => Operation::Agent,
"task_status" | "question" | "write_todo_list" => Operation::Meta,
_ => Operation::Other,
}
}
pub fn classify_path(raw: &str, working_dir: &str) -> Resource {
let resolved_str = resolve_absolute(raw, working_dir);
let dev_null = resolved_str == "/dev/null" || raw == "/dev/null";
let under = |base: &str| {
let b = base.trim_end_matches('/');
!b.is_empty()
&& b != "/"
&& (resolved_str == b || resolved_str.starts_with(&format!("{b}/")))
};
let cwd_has_glob = working_dir.contains(['*', '?', '[', '{']);
let in_cwd = !cwd_has_glob
&& (under(&resolve_absolute(working_dir, working_dir))
|| under(&canonicalize_for_cache(working_dir))
|| under(working_dir));
Resource::Path {
raw: raw.to_string(),
resolved: PathBuf::from(resolved_str),
in_cwd,
dev_null,
}
}
fn op_match(op: crate::permission::OpSpec) -> OpMatch {
use crate::permission::OpSpec;
match op {
OpSpec::Any => OpMatch::Any,
OpSpec::Read => OpMatch::One(Operation::Read),
OpSpec::Edit => OpMatch::One(Operation::Edit),
OpSpec::Execute => OpMatch::One(Operation::Execute),
OpSpec::Network => OpMatch::One(Operation::Network),
OpSpec::Mcp => OpMatch::One(Operation::Mcp),
OpSpec::Memory => OpMatch::One(Operation::Memory),
OpSpec::Skill => OpMatch::One(Operation::Skill),
OpSpec::Agent => OpMatch::One(Operation::Agent),
OpSpec::Meta => OpMatch::One(Operation::Meta),
}
}
fn pattern_for_op(op: crate::permission::OpSpec, pat: &str) -> crate::permission::pattern::Pattern {
use crate::permission::OpSpec;
use crate::permission::pattern::Pattern;
match op {
OpSpec::Read | OpSpec::Edit => Pattern::new(pat),
_ => Pattern::new_command(pat),
}
}
fn rule_from_config(rc: &crate::permission::RuleConfig) -> Rule {
Rule {
op: op_match(rc.op),
tool: rc.tool.clone(),
pattern: pattern_for_op(rc.op, &rc.pattern),
effect: rc.effect.into(),
original: rc.pattern.clone(),
}
}
impl Engine {
pub fn from_config(config: &PermissionConfig) -> Engine {
let mut rules: Vec<Rule> = Vec::new();
for (pat, action) in crate::permission::default_bash_rules() {
rules.push(Rule {
op: OpMatch::One(Operation::Execute),
tool: Some("bash".to_string()),
pattern: pattern_for_tool("bash", pat),
effect: action.into(),
original: format!("bash:{pat}"),
});
}
rules.push(Rule {
op: OpMatch::One(Operation::Mcp),
tool: None,
pattern: pattern_for_tool("mcp_tool", "*"),
effect: Effect::Ask,
original: "mcp:*".to_string(),
});
rules.extend(config.rules.iter().map(rule_from_config));
let ext_rules: Vec<Rule> = config
.external_directory
.iter()
.map(rule_from_config)
.collect();
let default: Effect = config.default.unwrap_or(Action::Ask).into();
let deny_rules = rules
.iter()
.chain(ext_rules.iter())
.filter(|r| r.effect == Effect::Deny)
.count();
let deciders: Vec<Box<dyn Decider>> = vec![
Box::new(PromptDenyPolicy),
Box::new(YoloPolicy),
Box::new(ConfiguredDenyPolicy {
rules: rules.clone(),
ext_rules: ext_rules.clone(),
}),
Box::new(SessionAllowlistPolicy),
Box::new(ConfiguredRulePolicy { rules }),
Box::new(BuiltinAllowPolicy),
Box::new(ExternalDirPolicy { rules: ext_rules }),
Box::new(DefaultActionPolicy { default }),
];
let threshold = if config.doom_loop == Some(Action::Allow) {
u32::MAX
} else {
LOOP_GUARD_THRESHOLD
};
let modifiers: Vec<Box<dyn Modifier>> = vec![Box::new(LoopGuardPolicy { threshold })];
let mut engine = Engine::new(deciders, modifiers, PolicyCtx::default());
engine.deny_rules = deny_rules;
engine
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permission::SecurityMode;
use crate::permission::engine::types::AccessRequest;
fn req(
op: Operation,
tool: &str,
mode: SecurityMode,
resources: Vec<Resource>,
) -> AccessRequest {
AccessRequest {
tool: tool.to_string(),
claims: resources
.into_iter()
.map(|r| crate::permission::engine::types::Claim::new(op, r))
.collect(),
mode,
display_input: String::new(),
}
}
#[test]
fn tool_operation_mapping() {
assert_eq!(tool_operation("read"), Operation::Read);
assert_eq!(tool_operation("grep"), Operation::Read);
assert_eq!(tool_operation("write"), Operation::Edit);
assert_eq!(tool_operation("edit"), Operation::Edit);
assert_eq!(tool_operation("apply_patch"), Operation::Edit);
assert_eq!(tool_operation("bash"), Operation::Execute);
assert_eq!(tool_operation("webfetch"), Operation::Network);
assert_eq!(tool_operation("mcp_tool"), Operation::Mcp);
assert_eq!(tool_operation("plugin_tool"), Operation::Plugin);
assert_eq!(tool_operation("edit_minified"), Operation::Edit);
assert_eq!(tool_operation("read_minified"), Operation::Read);
assert_eq!(tool_operation("memory"), Operation::Memory);
assert_eq!(tool_operation("skill"), Operation::Skill);
assert_eq!(tool_operation("question"), Operation::Meta);
}
#[test]
fn classify_path_in_cwd_dev_null_external() {
let p = classify_path("/proj/src/x.rs", "/proj");
assert!(matches!(
p,
Resource::Path {
in_cwd: true,
dev_null: false,
..
}
));
let p = classify_path("/dev/null", "/proj");
assert!(matches!(p, Resource::Path { dev_null: true, .. }));
let p = classify_path("/etc/passwd", "/proj");
assert!(matches!(
p,
Resource::Path {
in_cwd: false,
dev_null: false,
..
}
));
}
#[test]
fn default_config_bash_defaults_present() {
let e = Engine::from_config(&PermissionConfig::default());
let d = e.authorize(&req(
Operation::Execute,
"bash",
SecurityMode::Standard,
vec![Resource::Command {
raw: "git status -s".into(),
head: "git".into(),
}],
));
assert_eq!(
d.effect,
Effect::Allow,
"git status -s is a default-allowed bash command"
);
let d = e.authorize(&req(
Operation::Execute,
"bash",
SecurityMode::Standard,
vec![Resource::Command {
raw: "frobnicate --hard".into(),
head: "frobnicate".into(),
}],
));
assert_eq!(d.effect, Effect::Ask, "unknown bash command prompts");
}
fn rule(
op: crate::permission::OpSpec,
m: &str,
effect: Action,
) -> crate::permission::RuleConfig {
crate::permission::RuleConfig {
op,
pattern: m.to_string(),
effect,
tool: None,
}
}
#[test]
fn user_execute_rule_overrides_default() {
use crate::permission::OpSpec;
let cfg = PermissionConfig {
rules: vec![rule(OpSpec::Execute, "**", Action::Allow)],
..Default::default()
};
let e = Engine::from_config(&cfg);
let d = e.authorize(&req(
Operation::Execute,
"bash",
SecurityMode::Standard,
vec![Resource::Command {
raw: "frobnicate".into(),
head: "frobnicate".into(),
}],
));
assert_eq!(d.effect, Effect::Allow);
}
#[test]
fn user_rule_overrides_builtin_allow() {
use crate::permission::OpSpec;
let cfg = PermissionConfig {
rules: vec![rule(OpSpec::Read, "/secret/**", Action::Deny)],
..Default::default()
};
let e = Engine::from_config(&cfg);
let d = e.authorize(&req(
Operation::Read,
"read",
SecurityMode::Standard,
vec![classify_path("/secret/k", "/proj")],
));
assert_eq!(
d.effect,
Effect::Deny,
"user read deny rule beats builtin-allow"
);
let d = e.authorize(&req(
Operation::Read,
"read",
SecurityMode::Standard,
vec![classify_path("/proj/ok.rs", "/proj")],
));
assert_eq!(d.effect, Effect::Allow);
}
#[test]
fn external_directory_rule_allows_outside_path() {
use crate::permission::OpSpec;
let cfg = PermissionConfig {
external_directory: vec![rule(OpSpec::Any, "/shared/**", Action::Allow)],
..Default::default()
};
let e = Engine::from_config(&cfg);
let d = e.authorize(&req(
Operation::Edit,
"write",
SecurityMode::Standard,
vec![classify_path("/shared/lib/x", "/proj")],
));
assert_eq!(d.effect, Effect::Allow);
let d = e.authorize(&req(
Operation::Edit,
"write",
SecurityMode::Standard,
vec![classify_path("/etc/x", "/proj")],
));
assert_eq!(d.effect, Effect::Ask);
}
#[test]
fn any_op_rule_matches_command_across_slash() {
use crate::permission::OpSpec;
let cfg = PermissionConfig {
rules: vec![rule(OpSpec::Any, "git *", Action::Allow)],
..Default::default()
};
let e = Engine::from_config(&cfg);
let d = e.authorize(&req(
Operation::Execute,
"bash",
SecurityMode::Standard,
vec![Resource::Command {
raw: "git push origin/main".into(),
head: "git".into(),
}],
));
assert_eq!(
d.effect,
Effect::Allow,
"an op:* rule `git *` must match a command containing a slash",
);
}
#[test]
fn commit_records_once_per_request_despite_duplicate_claims() {
let mut e = Engine::from_config(&PermissionConfig::default());
let claim = || Resource::Command {
raw: "frobnicate x".into(),
head: "frobnicate".into(),
};
let request = req(
Operation::Execute,
"bash",
SecurityMode::Standard,
vec![claim(), claim()],
);
let d = e.authorize(&request);
assert_eq!(d.effect, Effect::Ask, "unknown command prompts");
e.commit(&request, &d);
assert_eq!(
e.ctx().repeat.prior(Operation::Execute, "frobnicate x"),
1,
"duplicate claims in one request must not double-count the loop guard",
);
}
#[test]
fn configured_deny_rule_beats_session_allow_via_from_config() {
use crate::permission::OpSpec;
let cfg = PermissionConfig {
rules: vec![rule(OpSpec::Edit, "/etc/secret/**", Action::Deny)],
..Default::default()
};
let mut e = Engine::from_config(&cfg);
e.allow_always(Operation::Edit, "/etc/**"); let d = e.authorize(&req(
Operation::Edit,
"edit",
SecurityMode::Standard,
vec![classify_path("/etc/secret/k", "/proj")],
));
assert_eq!(
d.effect,
Effect::Deny,
"configured deny must beat a session allow-always grant",
);
assert_eq!(d.deciding.unwrap().policy, "configured-deny");
}
#[test]
fn external_directory_deny_beats_session_allow_via_from_config() {
use crate::permission::OpSpec;
let cfg = PermissionConfig {
external_directory: vec![rule(OpSpec::Any, "/shared/secret/**", Action::Deny)],
..Default::default()
};
let mut e = Engine::from_config(&cfg);
e.allow_always(Operation::Edit, "/shared/**");
let d = e.authorize(&req(
Operation::Edit,
"write",
SecurityMode::Standard,
vec![classify_path("/shared/secret/k", "/proj")],
));
assert_eq!(
d.effect,
Effect::Deny,
"external_directory deny must beat a session allow-always grant",
);
assert_eq!(d.deciding.unwrap().policy, "configured-deny");
let d = e.authorize(&req(
Operation::Edit,
"write",
SecurityMode::Standard,
vec![classify_path("/shared/ok/k", "/proj")],
));
assert_eq!(d.effect, Effect::Allow);
}
}