use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Operation {
Read,
Edit,
Execute,
Network,
Mcp,
Memory,
Skill,
Agent,
Meta,
Plugin,
Other,
}
impl Operation {
pub fn is_side_effecting(self) -> bool {
matches!(
self,
Operation::Edit
| Operation::Execute
| Operation::Network
| Operation::Mcp
| Operation::Agent
| Operation::Plugin
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resource {
Path {
raw: String,
resolved: PathBuf,
in_cwd: bool,
dev_null: bool,
},
Command {
raw: String,
head: String,
complex: bool,
},
Mcp {
server: String,
name: String,
raw: String,
},
Url(String),
Bareword(String),
}
impl Resource {
pub fn command(raw: impl Into<String>) -> Self {
let raw = raw.into();
let head = command_head(&raw);
Resource::Command {
raw,
head,
complex: false,
}
}
pub fn command_complex(raw: impl Into<String>) -> Self {
let raw = raw.into();
let head = command_head(&raw);
Resource::Command {
raw,
head,
complex: true,
}
}
pub fn is_complex_command(&self) -> bool {
matches!(self, Resource::Command { complex: true, .. })
}
pub fn match_key(&self) -> &str {
match self {
Resource::Path { resolved, raw, .. } => resolved.to_str().unwrap_or(raw),
Resource::Command { raw, .. } => raw,
Resource::Mcp { raw, .. } => raw,
Resource::Url(u) => u,
Resource::Bareword(b) => b,
}
}
pub fn match_candidates(&self) -> Vec<&str> {
match self {
Resource::Path { raw, resolved, .. } => {
let r = resolved.to_str().unwrap_or(raw);
if r == raw { vec![r] } else { vec![r, raw] }
}
_ => vec![self.match_key()],
}
}
pub fn deny_match_candidates(&self) -> Vec<&str> {
match self {
Resource::Command { raw, .. } => {
let stripped = strip_exec_prefix(raw);
if stripped.is_empty() || stripped == raw.as_str() {
vec![raw]
} else {
vec![raw, stripped]
}
}
_ => self.match_candidates(),
}
}
}
fn command_head(raw: &str) -> String {
strip_exec_prefix(raw)
.split_whitespace()
.next()
.unwrap_or("")
.to_string()
}
fn is_env_assignment(tok: &str) -> bool {
match tok.split_once('=') {
Some((name, _)) if !name.is_empty() => {
let mut chars = name.chars();
let first = chars.next().unwrap();
(first.is_ascii_alphabetic() || first == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
_ => false,
}
}
fn basename(tok: &str) -> &str {
tok.rsplit('/').next().unwrap_or(tok)
}
pub fn exec_head_index(tokens: &[&str]) -> usize {
let mut i = 0;
loop {
while i < tokens.len() && is_env_assignment(tokens[i]) {
i += 1;
}
if i >= tokens.len() {
return i;
}
match basename(tokens[i]) {
"nohup" | "setsid" => i += 1,
"time" => {
i += 1;
while i < tokens.len() && tokens[i].starts_with('-') {
i += 1;
}
}
"env" => {
i += 1;
while i < tokens.len() {
let t = tokens[i];
if matches!(t, "-u" | "-C" | "-S" | "--unset" | "--chdir") {
i += 2;
} else if t.starts_with('-') || is_env_assignment(t) {
i += 1;
} else {
break;
}
}
}
"nice" => {
i += 1;
while i < tokens.len() {
let t = tokens[i];
if matches!(t, "-n" | "--adjustment") {
i += 2;
} else if t.starts_with('-') {
i += 1;
} else {
break;
}
}
}
"timeout" => {
i += 1;
while i < tokens.len() && tokens[i].starts_with('-') {
if matches!(tokens[i], "-s" | "--signal" | "-k" | "--kill-after") {
i += 2;
} else {
i += 1;
}
}
if i < tokens.len() {
i += 1;
}
}
_ => return i,
}
}
}
pub fn strip_exec_prefix(cmd: &str) -> &str {
let bytes = cmd.as_bytes();
let mut offsets: Vec<usize> = Vec::new();
let mut toks: Vec<&str> = Vec::new();
let mut i = 0;
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
let start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
i += 1;
}
offsets.push(start);
toks.push(&cmd[start..i]);
}
let idx = exec_head_index(&toks);
if idx == 0 {
return cmd;
}
match offsets.get(idx) {
Some(&off) => &cmd[off..],
None => cmd,
}
}
#[derive(Debug, Clone)]
pub struct Claim {
pub op: Operation,
pub resource: Resource,
}
impl Claim {
pub fn new(op: Operation, resource: Resource) -> Self {
Claim { op, resource }
}
}
#[derive(Debug, Clone)]
pub struct AccessRequest {
pub tool: String,
pub claims: Vec<Claim>,
pub mode: crate::permission::SecurityMode,
pub display_input: String,
}
impl AccessRequest {
pub fn single(
tool: impl Into<String>,
op: Operation,
resource: Resource,
mode: crate::permission::SecurityMode,
display_input: impl Into<String>,
) -> Self {
let display_input = display_input.into();
AccessRequest {
tool: tool.into(),
claims: vec![Claim::new(op, resource)],
mode,
display_input,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Effect {
Allow,
Ask,
Deny,
}
impl Effect {
pub fn meet(self, other: Effect) -> Effect {
self.max(other)
}
}
#[derive(Debug, Clone)]
pub struct Verdict {
pub effect: Effect,
pub why: String,
}
impl Verdict {
pub fn new(effect: Effect, why: impl Into<String>) -> Self {
Verdict {
effect,
why: why.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct Refined {
pub(crate) effect: Effect,
pub(crate) by: Option<&'static str>,
pub(crate) why: String,
}
impl Refined {
pub fn noop(current: Effect) -> Self {
Refined {
effect: current,
by: None,
why: String::new(),
}
}
pub fn tighten(
current: Effect,
proposed: Effect,
by: &'static str,
why: impl Into<String>,
) -> Self {
let effect = current.meet(proposed);
Refined {
effect,
by: if effect != current { Some(by) } else { None },
why: if effect != current {
why.into()
} else {
String::new()
},
}
}
pub fn effect(&self) -> Effect {
self.effect
}
}
#[derive(Debug, Clone)]
pub struct TraceEntry {
pub policy: &'static str,
pub resource: usize,
pub effect: Option<Effect>,
pub why: String,
pub applied: bool,
}
#[derive(Debug, Clone)]
pub struct Decision {
pub effect: Effect,
pub deciding: Option<TraceEntry>,
pub trace: Vec<TraceEntry>,
pub resolved_paths: Vec<PathBuf>,
}
impl Decision {
pub fn reason(&self) -> String {
match &self.deciding {
Some(e) if !e.why.is_empty() => format!("{} ({})", e.why, e.policy),
Some(e) => format!("decided by {}", e.policy),
None => "no resources to authorize".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn effect_lattice_order() {
assert!(Effect::Allow < Effect::Ask);
assert!(Effect::Ask < Effect::Deny);
assert!(Effect::Allow < Effect::Deny);
}
#[test]
fn meet_is_most_restrictive() {
assert_eq!(Effect::Allow.meet(Effect::Ask), Effect::Ask);
assert_eq!(Effect::Ask.meet(Effect::Allow), Effect::Ask);
assert_eq!(Effect::Allow.meet(Effect::Deny), Effect::Deny);
assert_eq!(Effect::Ask.meet(Effect::Deny), Effect::Deny);
assert_eq!(Effect::Allow.meet(Effect::Allow), Effect::Allow);
assert_eq!(Effect::Deny.meet(Effect::Deny), Effect::Deny);
}
#[test]
fn meet_lattice_laws() {
let all = [Effect::Allow, Effect::Ask, Effect::Deny];
for a in all {
assert_eq!(a.meet(a), a);
for b in all {
assert_eq!(a.meet(b), b.meet(a));
for c in all {
assert_eq!(a.meet(b).meet(c), a.meet(b.meet(c)));
assert!(a.meet(b) >= a);
}
}
}
}
#[test]
fn refined_cannot_loosen() {
let r = Refined::tighten(Effect::Deny, Effect::Allow, "x", "tried to loosen");
assert_eq!(r.effect(), Effect::Deny);
assert!(r.by.is_none(), "no-op tighten records no author");
let r = Refined::tighten(Effect::Allow, Effect::Ask, "loopguard", "retry loop");
assert_eq!(r.effect(), Effect::Ask);
assert_eq!(r.by, Some("loopguard"));
assert_eq!(Refined::noop(Effect::Ask).effect(), Effect::Ask);
}
#[test]
fn side_effecting_classification() {
assert!(Operation::Edit.is_side_effecting());
assert!(Operation::Execute.is_side_effecting());
assert!(Operation::Network.is_side_effecting());
assert!(Operation::Mcp.is_side_effecting());
assert!(!Operation::Read.is_side_effecting());
assert!(!Operation::Meta.is_side_effecting());
assert!(!Operation::Memory.is_side_effecting());
}
}