#![allow(dead_code)]
mod build;
mod classify;
pub mod policies;
pub mod policy;
pub mod types;
pub use build::{classify_path, tool_operation};
pub use classify::{is_path_tool_name, pattern_for_tool};
use policy::{Decider, Modifier, PolicyCtx};
use types::{AccessRequest, Decision, Effect, Operation, Resource, TraceEntry};
fn accept_eligible(op: Operation, resource: &Resource) -> bool {
let high_risk = matches!(
op,
Operation::Execute
| Operation::Mcp
| Operation::Network
| Operation::Agent
| Operation::Plugin
);
let external_path = matches!(
resource,
Resource::Path {
in_cwd: false,
dev_null: false,
..
}
);
!high_risk && !external_path
}
pub struct Engine {
deciders: Vec<Box<dyn Decider>>,
modifiers: Vec<Box<dyn Modifier>>,
ctx: PolicyCtx,
pub(super) deny_rules: usize,
}
impl Engine {
pub fn new(
deciders: Vec<Box<dyn Decider>>,
modifiers: Vec<Box<dyn Modifier>>,
ctx: PolicyCtx,
) -> Self {
Engine {
deciders,
modifiers,
ctx,
deny_rules: 0,
}
}
pub fn deny_rule_count(&self) -> usize {
self.deny_rules
}
pub fn ctx(&self) -> &PolicyCtx {
&self.ctx
}
pub fn ctx_mut(&mut self) -> &mut PolicyCtx {
&mut self.ctx
}
pub fn authorize(&self, req: &AccessRequest) -> Decision {
let mut trace: Vec<TraceEntry> = Vec::new();
let mut resolved_paths = Vec::new();
let mut per_resource: Vec<(Effect, Option<TraceEntry>)> = Vec::new();
for (ri, claim) in req.claims.iter().enumerate() {
let op = claim.op;
let resource = &claim.resource;
if let Resource::Path { resolved, .. } = resource {
resolved_paths.push(resolved.clone());
}
let mut base = Effect::Ask; let mut binding: Option<TraceEntry> = None;
for d in &self.deciders {
if !d.applies_to(op, resource) {
trace.push(TraceEntry {
policy: d.id(),
resource: ri,
effect: None,
why: "not applicable".to_string(),
applied: false,
});
continue;
}
match d.decide(req, op, resource, &self.ctx) {
Some(v) => {
let entry = TraceEntry {
policy: d.id(),
resource: ri,
effect: Some(v.effect),
why: v.why,
applied: true,
};
base = v.effect;
trace.push(entry.clone());
binding = Some(entry);
break; }
None => trace.push(TraceEntry {
policy: d.id(),
resource: ri,
effect: None,
why: "passed".to_string(),
applied: true,
}),
}
}
if req.mode == crate::permission::SecurityMode::Accept
&& base == Effect::Ask
&& accept_eligible(op, resource)
{
base = Effect::Allow;
let entry = TraceEntry {
policy: "accept-mode",
resource: ri,
effect: Some(Effect::Allow),
why: "accept mode coerced Ask→Allow".to_string(),
applied: true,
};
trace.push(entry.clone());
binding = Some(entry);
}
let mut eff = base;
for m in &self.modifiers {
if !m.applies_to(op, resource) {
trace.push(TraceEntry {
policy: m.id(),
resource: ri,
effect: None,
why: "not applicable".to_string(),
applied: false,
});
continue;
}
let refined = m.refine(req, op, resource, eff, &self.ctx);
if let Some(by) = refined.by {
let entry = TraceEntry {
policy: by,
resource: ri,
effect: Some(refined.effect()),
why: refined.why.clone(),
applied: true,
};
trace.push(entry.clone());
eff = refined.effect();
binding = Some(entry); } else {
trace.push(TraceEntry {
policy: m.id(),
resource: ri,
effect: None,
why: "no change".to_string(),
applied: true,
});
}
}
per_resource.push((eff, binding));
}
let final_effect = per_resource
.iter()
.map(|(e, _)| *e)
.fold(Effect::Allow, Effect::meet);
let deciding = per_resource
.into_iter()
.find(|(e, _)| *e == final_effect)
.and_then(|(_, b)| b);
Decision {
effect: final_effect,
deciding,
trace,
resolved_paths,
}
}
pub fn commit(&mut self, req: &AccessRequest, decision: &Decision) {
if decision.effect == Effect::Ask {
let mut seen = std::collections::HashSet::new();
for claim in &req.claims {
let key = claim.resource.match_key();
if seen.insert((claim.op, key)) {
self.ctx.repeat.record(claim.op, key);
}
}
}
}
pub fn note_allowed(&mut self, req: &AccessRequest) {
let mut seen = std::collections::HashSet::new();
for claim in &req.claims {
let key = claim.resource.match_key();
if seen.insert((claim.op, key)) {
self.ctx.repeat.reset(claim.op, key);
}
}
}
pub fn allow_always(&mut self, op: Operation, original: &str) {
let pattern = if op == Operation::Execute || op == Operation::Mcp {
crate::permission::pattern::Pattern::new_command(original)
} else {
crate::permission::pattern::Pattern::new(original)
};
self.ctx.allowlist.add(op, original, pattern);
}
}
#[cfg(test)]
mod tests {
use super::policy::*;
use super::types::*;
use super::*;
use crate::permission::SecurityMode;
use std::path::PathBuf;
struct AlwaysDecide(&'static str, Effect, bool );
impl Decider for AlwaysDecide {
fn id(&self) -> &'static str {
self.0
}
fn applies_to(&self, _: Operation, _: &Resource) -> bool {
self.2
}
fn decide(
&self,
_: &AccessRequest,
_: Operation,
_: &Resource,
_: &PolicyCtx,
) -> Option<Verdict> {
Some(Verdict::new(self.1, "stub"))
}
}
struct TightenTo(&'static str, Effect);
impl Modifier for TightenTo {
fn id(&self) -> &'static str {
self.0
}
fn applies_to(&self, _: Operation, _: &Resource) -> bool {
true
}
fn refine(
&self,
_: &AccessRequest,
_: Operation,
_: &Resource,
cur: Effect,
_: &PolicyCtx,
) -> Refined {
Refined::tighten(cur, self.1, self.0, "stub tighten")
}
}
fn req(resources: Vec<Resource>) -> AccessRequest {
AccessRequest {
tool: "test".to_string(),
claims: resources
.into_iter()
.map(|r| Claim::new(Operation::Execute, r))
.collect(),
mode: SecurityMode::Standard,
display_input: "test".to_string(),
}
}
fn cmd(s: &str) -> Resource {
Resource::Command {
raw: s.to_string(),
head: s.split_whitespace().next().unwrap_or("").to_string(),
}
}
fn path(p: &str) -> Resource {
Resource::Path {
raw: p.to_string(),
resolved: PathBuf::from(p),
in_cwd: false,
dev_null: false,
}
}
#[test]
fn first_decider_claim_wins() {
let e = Engine::new(
vec![
Box::new(AlwaysDecide("a", Effect::Allow, true)),
Box::new(AlwaysDecide("b", Effect::Deny, true)),
],
vec![],
PolicyCtx::default(),
);
let d = e.authorize(&req(vec![cmd("x")]));
assert_eq!(d.effect, Effect::Allow);
assert_eq!(d.deciding.unwrap().policy, "a");
}
#[test]
fn non_applicable_decider_is_skipped() {
let e = Engine::new(
vec![
Box::new(AlwaysDecide("skipme", Effect::Allow, false)),
Box::new(AlwaysDecide("real", Effect::Deny, true)),
],
vec![],
PolicyCtx::default(),
);
let d = e.authorize(&req(vec![cmd("x")]));
assert_eq!(d.effect, Effect::Deny);
assert_eq!(d.deciding.unwrap().policy, "real");
assert!(d.trace.iter().any(|t| t.policy == "skipme" && !t.applied));
}
#[test]
fn modifier_tightens_but_cannot_loosen() {
let e = Engine::new(
vec![Box::new(AlwaysDecide("base", Effect::Allow, true))],
vec![Box::new(TightenTo("tighten", Effect::Ask))],
PolicyCtx::default(),
);
let d = e.authorize(&req(vec![cmd("x")]));
assert_eq!(d.effect, Effect::Ask);
assert_eq!(d.deciding.unwrap().policy, "tighten");
let e = Engine::new(
vec![Box::new(AlwaysDecide("base", Effect::Deny, true))],
vec![Box::new(TightenTo("tighten", Effect::Ask))],
PolicyCtx::default(),
);
let d = e.authorize(&req(vec![cmd("x")]));
assert_eq!(d.effect, Effect::Deny);
assert_eq!(d.deciding.unwrap().policy, "base");
}
#[test]
fn multi_resource_folds_most_restrictive() {
struct PerResource;
impl Decider for PerResource {
fn id(&self) -> &'static str {
"perres"
}
fn applies_to(&self, _: Operation, _: &Resource) -> bool {
true
}
fn decide(
&self,
_: &AccessRequest,
_: Operation,
r: &Resource,
_: &PolicyCtx,
) -> Option<Verdict> {
let eff = if r.match_key().contains("bad") {
Effect::Deny
} else {
Effect::Allow
};
Some(Verdict::new(eff, "perres"))
}
}
let e = Engine::new(vec![Box::new(PerResource)], vec![], PolicyCtx::default());
let d = e.authorize(&req(vec![cmd("good"), cmd("bad"), path("/x")]));
assert_eq!(d.effect, Effect::Deny);
assert_eq!(d.resolved_paths, vec![PathBuf::from("/x")]);
}
#[test]
fn commit_only_counts_prompted_requests() {
let mut e = Engine::new(
vec![Box::new(AlwaysDecide("ask", Effect::Ask, true))],
vec![],
PolicyCtx::default(),
);
let r = req(vec![cmd("loopy")]);
assert_eq!(e.ctx().repeat.prior(Operation::Execute, "loopy"), 0);
let d = e.authorize(&r);
e.commit(&r, &d);
assert_eq!(e.ctx().repeat.prior(Operation::Execute, "loopy"), 1);
let mut e2 = Engine::new(
vec![Box::new(AlwaysDecide("allow", Effect::Allow, true))],
vec![],
PolicyCtx::default(),
);
let d2 = e2.authorize(&r);
e2.commit(&r, &d2);
assert_eq!(e2.ctx().repeat.prior(Operation::Execute, "loopy"), 0);
}
}