use super::policy::{Decider, Modifier, PolicyCtx};
use super::types::{AccessRequest, Effect, Operation, Refined, Resource, Verdict};
use crate::permission::SecurityMode;
use crate::permission::pattern::Pattern;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpMatch {
Any,
One(Operation),
}
impl OpMatch {
fn matches(self, op: Operation) -> bool {
match self {
OpMatch::Any => true,
OpMatch::One(o) => o == op,
}
}
}
#[derive(Debug, Clone)]
pub struct Rule {
pub op: OpMatch,
pub tool: Option<String>,
pub pattern: Pattern,
pub effect: Effect,
pub original: String,
}
impl Rule {
fn matches(&self, req: &AccessRequest, op: Operation, resource: &Resource) -> bool {
self.op.matches(op)
&& self.tool.as_deref().is_none_or(|t| t == req.tool)
&& resource
.match_candidates()
.iter()
.any(|k| self.pattern.matches(k))
}
}
fn last_match<'r>(
rules: &'r [Rule],
req: &AccessRequest,
op: Operation,
resource: &Resource,
) -> Option<&'r Rule> {
rules.iter().rfind(|r| r.matches(req, op, resource))
}
fn is_external_path(resource: &Resource) -> bool {
matches!(
resource,
Resource::Path {
in_cwd: false,
dev_null: false,
..
}
)
}
pub struct PromptDenyPolicy;
impl Decider for PromptDenyPolicy {
fn id(&self) -> &'static str {
"prompt-deny"
}
fn applies_to(&self, _: Operation, _: &Resource) -> bool {
true
}
fn decide(
&self,
req: &AccessRequest,
_op: Operation,
resource: &Resource,
ctx: &PolicyCtx,
) -> Option<Verdict> {
let denied = |name: &str| ctx.prompt_deny.iter().any(|d| d.eq_ignore_ascii_case(name));
let hit =
denied(&req.tool) || matches!(resource, Resource::Mcp { name, .. } if denied(name));
hit.then(|| {
Verdict::new(
Effect::Deny,
format!(
"tool {:?} denied by the active prompt's deny_tools",
req.tool
),
)
})
}
}
pub struct YoloPolicy;
impl Decider for YoloPolicy {
fn id(&self) -> &'static str {
"yolo"
}
fn applies_to(&self, _: Operation, _: &Resource) -> bool {
true
}
fn decide(
&self,
req: &AccessRequest,
_op: Operation,
_: &Resource,
_: &PolicyCtx,
) -> Option<Verdict> {
(req.mode == SecurityMode::Yolo).then(|| Verdict::new(Effect::Allow, "yolo mode"))
}
}
pub struct SessionAllowlistPolicy;
impl Decider for SessionAllowlistPolicy {
fn id(&self) -> &'static str {
"session-allow"
}
fn applies_to(&self, _: Operation, _: &Resource) -> bool {
true
}
fn decide(
&self,
_req: &AccessRequest,
op: Operation,
resource: &Resource,
ctx: &PolicyCtx,
) -> Option<Verdict> {
resource
.match_candidates()
.iter()
.any(|k| ctx.allowlist.allows(op, k))
.then(|| Verdict::new(Effect::Allow, "allowed for this session"))
}
}
pub struct ConfiguredRulePolicy {
pub rules: Vec<Rule>,
}
impl Decider for ConfiguredRulePolicy {
fn id(&self) -> &'static str {
"configured-rule"
}
fn applies_to(&self, op: Operation, resource: &Resource) -> bool {
let cands = resource.match_candidates();
self.rules
.iter()
.any(|r| r.op.matches(op) && cands.iter().any(|k| r.pattern.matches(k)))
}
fn decide(
&self,
req: &AccessRequest,
op: Operation,
resource: &Resource,
_: &PolicyCtx,
) -> Option<Verdict> {
last_match(&self.rules, req, op, resource)
.map(|r| Verdict::new(r.effect, format!("rule {:?} → {:?}", r.original, r.effect)))
}
}
pub struct ConfiguredDenyPolicy {
pub rules: Vec<Rule>,
pub ext_rules: Vec<Rule>,
}
impl Decider for ConfiguredDenyPolicy {
fn id(&self) -> &'static str {
"configured-deny"
}
fn applies_to(&self, _: Operation, _: &Resource) -> bool {
true }
fn decide(
&self,
req: &AccessRequest,
op: Operation,
resource: &Resource,
_: &PolicyCtx,
) -> Option<Verdict> {
if let Some(r) = last_match(&self.rules, req, op, resource) {
return (r.effect == Effect::Deny).then(|| {
Verdict::new(
Effect::Deny,
format!("rule {:?} → Deny (terminal)", r.original),
)
});
}
if matches!(op, Operation::Edit)
&& is_external_path(resource)
&& let Some(r) = last_match(&self.ext_rules, req, op, resource)
{
return (r.effect == Effect::Deny).then(|| {
Verdict::new(
Effect::Deny,
format!("external_directory {:?} → Deny (terminal)", r.original),
)
});
}
None
}
}
pub struct BuiltinAllowPolicy;
impl BuiltinAllowPolicy {
fn effect_for(op: Operation, req: &AccessRequest, resource: &Resource) -> Option<Effect> {
let restrictive = req.mode == SecurityMode::Restrictive;
match op {
Operation::Read => Some(Effect::Allow),
Operation::Memory | Operation::Skill => {
let is_read = matches!(resource, Resource::Bareword(a) if is_read_action(op, a));
if is_read || !restrictive {
Some(Effect::Allow)
} else {
Some(Effect::Ask)
}
}
Operation::Edit => match resource {
Resource::Path { dev_null: true, .. } => Some(Effect::Allow),
Resource::Path { in_cwd: true, .. } => Some(if restrictive {
Effect::Ask
} else {
Effect::Allow
}),
_ => None,
},
Operation::Meta => Some(Effect::Allow),
Operation::Execute
| Operation::Network
| Operation::Mcp
| Operation::Agent
| Operation::Plugin
| Operation::Other => None,
}
}
}
impl Decider for BuiltinAllowPolicy {
fn id(&self) -> &'static str {
"builtin-allow"
}
fn applies_to(&self, op: Operation, resource: &Resource) -> bool {
matches!(
op,
Operation::Read | Operation::Memory | Operation::Skill | Operation::Meta
) || matches!(
(op, resource),
(Operation::Edit, Resource::Path { in_cwd: true, .. })
| (Operation::Edit, Resource::Path { dev_null: true, .. })
)
}
fn decide(
&self,
req: &AccessRequest,
op: Operation,
resource: &Resource,
_: &PolicyCtx,
) -> Option<Verdict> {
Self::effect_for(op, req, resource).map(|e| {
let why = match e {
Effect::Allow => "built-in allow",
_ => "restrictive mode confirms writes",
};
Verdict::new(e, why)
})
}
}
fn is_read_action(op: Operation, action: &str) -> bool {
match op {
Operation::Memory => action == "view",
Operation::Skill => action == "load" || action == "list",
_ => false,
}
}
pub struct ExternalDirPolicy {
pub rules: Vec<Rule>,
}
impl Decider for ExternalDirPolicy {
fn id(&self) -> &'static str {
"external-dir"
}
fn applies_to(&self, op: Operation, resource: &Resource) -> bool {
matches!(op, Operation::Edit) && is_external_path(resource)
}
fn decide(
&self,
req: &AccessRequest,
op: Operation,
resource: &Resource,
_: &PolicyCtx,
) -> Option<Verdict> {
if !is_external_path(resource) {
return None;
}
let matched = last_match(&self.rules, req, op, resource);
match matched {
Some(r) => Some(Verdict::new(
r.effect,
format!("external_directory {:?} → {:?}", r.original, r.effect),
)),
None => Some(Verdict::new(Effect::Ask, "outside the working directory")),
}
}
}
pub struct DefaultActionPolicy {
pub default: Effect,
}
impl Decider for DefaultActionPolicy {
fn id(&self) -> &'static str {
"default"
}
fn applies_to(&self, _: Operation, _: &Resource) -> bool {
true
}
fn decide(
&self,
req: &AccessRequest,
_op: Operation,
_: &Resource,
_: &PolicyCtx,
) -> Option<Verdict> {
let eff = if req.mode == SecurityMode::Restrictive && self.default == Effect::Allow {
Effect::Ask
} else {
self.default
};
Some(Verdict::new(eff, "default action"))
}
}
pub struct LoopGuardPolicy {
pub threshold: u32,
}
impl Modifier for LoopGuardPolicy {
fn id(&self) -> &'static str {
"loop-guard"
}
fn applies_to(&self, _: Operation, _: &Resource) -> bool {
true }
fn refine(
&self,
req: &AccessRequest,
op: Operation,
resource: &Resource,
current: Effect,
ctx: &PolicyCtx,
) -> Refined {
if current != Effect::Ask {
return Refined::noop(current);
}
let prior = ctx.repeat.prior(op, resource.match_key());
if prior >= self.threshold {
let preview: String = resource.match_key().chars().take(60).collect();
Refined::tighten(
current,
Effect::Deny,
"loop-guard",
format!(
"Doom loop: repeated identical {} call ({preview}) prompted {prior}× — breaking retry loop",
req.tool
),
)
} else {
Refined::noop(current)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permission::engine::Engine;
use std::path::PathBuf;
fn engine(default: Effect) -> Engine {
Engine::new(
vec![
Box::new(PromptDenyPolicy),
Box::new(YoloPolicy),
Box::new(ConfiguredDenyPolicy {
rules: vec![],
ext_rules: vec![],
}),
Box::new(SessionAllowlistPolicy),
Box::new(ConfiguredRulePolicy { rules: vec![] }),
Box::new(BuiltinAllowPolicy),
Box::new(ExternalDirPolicy { rules: vec![] }),
Box::new(DefaultActionPolicy { default }),
],
vec![Box::new(LoopGuardPolicy { threshold: 3 })],
PolicyCtx::default(),
)
}
fn req(op: Operation, mode: SecurityMode, resources: Vec<Resource>) -> AccessRequest {
AccessRequest {
tool: format!("{op:?}").to_lowercase(),
claims: resources
.into_iter()
.map(|r| crate::permission::engine::types::Claim::new(op, r))
.collect(),
mode,
display_input: String::new(),
}
}
fn path(p: &str, in_cwd: bool, dev_null: bool) -> Resource {
Resource::Path {
raw: p.to_string(),
resolved: PathBuf::from(p),
in_cwd,
dev_null,
}
}
fn cmd(s: &str) -> Resource {
Resource::Command {
raw: s.to_string(),
head: s.split_whitespace().next().unwrap_or("").to_string(),
}
}
fn url(u: &str) -> Resource {
Resource::Url(u.to_string())
}
fn word(s: &str) -> Resource {
Resource::Bareword(s.to_string())
}
#[test]
fn read_always_allowed_even_external_and_restrictive() {
for mode in [SecurityMode::Standard, SecurityMode::Restrictive] {
let e = engine(Effect::Ask);
let d = e.authorize(&req(
Operation::Read,
mode,
vec![path("/etc/x", false, false)],
));
assert_eq!(d.effect, Effect::Allow, "read external in {mode:?}");
assert_eq!(d.deciding.unwrap().policy, "builtin-allow");
}
}
#[test]
fn in_cwd_write_allowed_standard_confirms_restrictive() {
let e = engine(Effect::Ask);
let d = e.authorize(&req(
Operation::Edit,
SecurityMode::Standard,
vec![path("/proj/x", true, false)],
));
assert_eq!(d.effect, Effect::Allow);
let d = e.authorize(&req(
Operation::Edit,
SecurityMode::Restrictive,
vec![path("/proj/x", true, false)],
));
assert_eq!(d.effect, Effect::Ask);
}
#[test]
fn external_write_asks_dev_null_allows() {
let e = engine(Effect::Ask);
let d = e.authorize(&req(
Operation::Edit,
SecurityMode::Standard,
vec![path("/etc/x", false, false)],
));
assert_eq!(d.effect, Effect::Ask);
assert_eq!(d.deciding.unwrap().policy, "external-dir");
let d = e.authorize(&req(
Operation::Edit,
SecurityMode::Standard,
vec![path("/dev/null", false, true)],
));
assert_eq!(d.effect, Effect::Allow);
}
#[test]
fn memory_skill_transparent_standard_writes_confirm_restrictive() {
let e = engine(Effect::Ask);
for (op, action) in [
(Operation::Memory, "add"),
(Operation::Memory, "view"),
(Operation::Skill, "create:x"),
(Operation::Skill, "load"),
] {
let d = e.authorize(&req(op, SecurityMode::Standard, vec![word(action)]));
assert_eq!(d.effect, Effect::Allow, "{op:?} {action} standard");
}
let d = e.authorize(&req(
Operation::Memory,
SecurityMode::Restrictive,
vec![word("view")],
));
assert_eq!(d.effect, Effect::Allow);
let d = e.authorize(&req(
Operation::Memory,
SecurityMode::Restrictive,
vec![word("add")],
));
assert_eq!(d.effect, Effect::Ask);
let d = e.authorize(&req(
Operation::Skill,
SecurityMode::Restrictive,
vec![word("load")],
));
assert_eq!(d.effect, Effect::Allow);
let d = e.authorize(&req(
Operation::Skill,
SecurityMode::Restrictive,
vec![word("create:x")],
));
assert_eq!(d.effect, Effect::Ask);
}
#[test]
fn execute_falls_to_default_ask_not_builtin() {
let e = engine(Effect::Ask);
let d = e.authorize(&req(
Operation::Execute,
SecurityMode::Standard,
vec![cmd("rm -rf x")],
));
assert_eq!(d.effect, Effect::Ask);
assert_eq!(d.deciding.unwrap().policy, "default");
}
#[test]
fn prompt_deny_beats_yolo() {
let mut e = engine(Effect::Ask);
e.ctx_mut().prompt_deny = vec!["execute".to_string()];
let d = e.authorize(&req(
Operation::Execute,
SecurityMode::Yolo,
vec![cmd("anything")],
));
assert_eq!(d.effect, Effect::Deny);
assert_eq!(d.deciding.unwrap().policy, "prompt-deny");
}
#[test]
fn yolo_allows_otherwise() {
let e = engine(Effect::Ask);
let d = e.authorize(&req(
Operation::Execute,
SecurityMode::Yolo,
vec![cmd("rm -rf /")],
));
assert_eq!(d.effect, Effect::Allow);
assert_eq!(d.deciding.unwrap().policy, "yolo");
}
#[test]
fn accept_coerces_default_but_not_execute() {
let e = engine(Effect::Ask);
let d = e.authorize(&req(
Operation::Network,
SecurityMode::Accept,
vec![Resource::Url("http://x".into())],
));
assert_eq!(d.effect, Effect::Ask);
let d = e.authorize(&req(
Operation::Execute,
SecurityMode::Accept,
vec![cmd("x")],
));
assert_eq!(d.effect, Effect::Ask);
}
fn rule(op: OpMatch, pattern: Pattern, effect: Effect) -> Rule {
let original = pattern.original.clone();
Rule {
op,
tool: None,
pattern,
effect,
original,
}
}
fn engine_with_rules(rules: Vec<Rule>) -> Engine {
Engine::new(
vec![
Box::new(PromptDenyPolicy),
Box::new(YoloPolicy),
Box::new(ConfiguredDenyPolicy {
rules: rules.clone(),
ext_rules: vec![],
}),
Box::new(SessionAllowlistPolicy),
Box::new(ConfiguredRulePolicy { rules }),
Box::new(BuiltinAllowPolicy),
Box::new(ExternalDirPolicy { rules: vec![] }),
Box::new(DefaultActionPolicy {
default: Effect::Ask,
}),
],
vec![Box::new(LoopGuardPolicy { threshold: 3 })],
PolicyCtx::default(),
)
}
#[test]
fn configured_rule_last_match_wins_and_beats_builtin() {
let e = engine_with_rules(vec![
rule(
OpMatch::One(Operation::Execute),
Pattern::new_command("cargo *"),
Effect::Allow,
),
rule(
OpMatch::One(Operation::Read),
Pattern::new("/secret/**"),
Effect::Deny,
),
]);
let d = e.authorize(&req(
Operation::Execute,
SecurityMode::Standard,
vec![cmd("cargo test")],
));
assert_eq!(d.effect, Effect::Allow);
assert_eq!(d.deciding.unwrap().policy, "configured-rule");
let d = e.authorize(&req(
Operation::Read,
SecurityMode::Standard,
vec![path("/secret/k", false, false)],
));
assert_eq!(d.effect, Effect::Deny);
assert_eq!(d.deciding.unwrap().policy, "configured-deny");
}
#[test]
fn last_match_wins_within_rule_list() {
let e = engine_with_rules(vec![
rule(OpMatch::Any, Pattern::new("/proj/**"), Effect::Deny),
rule(
OpMatch::One(Operation::Edit),
Pattern::new("/proj/ok/**"),
Effect::Allow,
),
]);
let d = e.authorize(&req(
Operation::Edit,
SecurityMode::Standard,
vec![path("/proj/ok/a.rs", true, false)],
));
assert_eq!(d.effect, Effect::Allow);
let d = e.authorize(&req(
Operation::Edit,
SecurityMode::Standard,
vec![path("/proj/no/a.rs", true, false)],
));
assert_eq!(d.effect, Effect::Deny);
}
#[test]
fn loop_guard_never_gates_allowed_only_hard_denies_ask_retries() {
let mut e = engine(Effect::Ask);
let r = req(
Operation::Read,
SecurityMode::Standard,
vec![path("/proj/x", true, false)],
);
for _ in 0..6 {
let d = e.authorize(&r);
assert_eq!(d.effect, Effect::Allow, "repeated read must never prompt");
e.commit(&r, &d);
}
let mut e = engine(Effect::Ask);
let r = req(
Operation::Execute,
SecurityMode::Standard,
vec![cmd("frobnicate")],
);
let mut effects = vec![];
for _ in 0..5 {
let d = e.authorize(&r);
effects.push(d.effect);
e.commit(&r, &d);
}
assert_eq!(effects[0], Effect::Ask);
assert_eq!(effects[1], Effect::Ask);
assert_eq!(effects[2], Effect::Ask);
assert_eq!(
effects[3],
Effect::Deny,
"4th identical Ask retry hard-denied"
);
assert_eq!(effects[4], Effect::Deny);
}
#[test]
fn approved_repeats_never_trip_loop_guard() {
let mut e = engine(Effect::Ask);
let r = req(
Operation::Network,
SecurityMode::Standard,
vec![url("https://example.com/x")],
);
for i in 0..10 {
let d = e.authorize(&r);
assert_eq!(d.effect, Effect::Ask, "iteration {i}: must keep asking");
e.commit(&r, &d);
e.note_allowed(&r); }
}
#[test]
fn denials_still_hard_deny_even_after_an_earlier_approval() {
let mut e = engine(Effect::Ask);
let r = req(
Operation::Execute,
SecurityMode::Standard,
vec![cmd("frobnicate")],
);
let d = e.authorize(&r);
e.commit(&r, &d);
e.note_allowed(&r); let mut effects = vec![];
for _ in 0..5 {
let d = e.authorize(&r);
effects.push(d.effect);
e.commit(&r, &d); }
assert_eq!(
effects[3],
Effect::Deny,
"hard-deny still trips on pure denials"
);
}
#[test]
fn configured_deny_beats_session_allow() {
let mut e = engine_with_rules(vec![rule(
OpMatch::One(Operation::Edit),
Pattern::new("/etc/secret/**"),
Effect::Deny,
)]);
e.allow_always(Operation::Edit, "/etc/**");
let d = e.authorize(&req(
Operation::Edit,
SecurityMode::Standard,
vec![path("/etc/secret/k", false, false)],
));
assert_eq!(
d.effect,
Effect::Deny,
"a session allow-always must not override a configured deny",
);
assert_eq!(d.deciding.unwrap().policy, "configured-deny");
}
#[test]
fn configured_deny_respects_last_match_allow() {
let e = engine_with_rules(vec![
rule(OpMatch::Any, Pattern::new("/proj/**"), Effect::Deny),
rule(
OpMatch::One(Operation::Edit),
Pattern::new("/proj/ok/**"),
Effect::Allow,
),
]);
let d = e.authorize(&req(
Operation::Edit,
SecurityMode::Standard,
vec![path("/proj/ok/a.rs", true, false)],
));
assert_eq!(
d.effect,
Effect::Allow,
"later allow still wins by last-match"
);
let d = e.authorize(&req(
Operation::Edit,
SecurityMode::Standard,
vec![path("/proj/no/a.rs", true, false)],
));
assert_eq!(d.effect, Effect::Deny);
assert_eq!(d.deciding.unwrap().policy, "configured-deny");
}
#[test]
fn yolo_still_overrides_configured_deny() {
let e = engine_with_rules(vec![rule(
OpMatch::One(Operation::Edit),
Pattern::new("/etc/**"),
Effect::Deny,
)]);
let d = e.authorize(&req(
Operation::Edit,
SecurityMode::Yolo,
vec![path("/etc/x", false, false)],
));
assert_eq!(d.effect, Effect::Allow);
assert_eq!(d.deciding.unwrap().policy, "yolo");
}
}