use std::collections::BTreeSet;
use std::fmt;
use globset::{Glob, GlobSet, GlobSetBuilder};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::request::ActionClass;
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PolicyVersion(String);
impl PolicyVersion {
pub fn empty() -> Self {
Self("policy-empty".to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for PolicyVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LlmPolicy {
#[serde(default)]
pub providers: BTreeSet<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost_ceiling_usd_cents: Option<u64>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RedactionPolicy {
#[serde(default)]
pub transcript: Vec<String>,
#[serde(default)]
pub logs: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PermissionPolicy {
#[serde(default)]
pub read: Vec<String>,
#[serde(default)]
pub write: Vec<String>,
#[serde(default)]
pub exec: Vec<String>,
#[serde(default)]
pub net: Vec<String>,
#[serde(default)]
pub llm: LlmPolicy,
#[serde(default)]
pub redact: RedactionPolicy,
#[serde(default)]
pub escalate_to: Vec<String>,
}
impl PermissionPolicy {
pub fn empty() -> Self {
Self::default()
}
pub fn local_dev() -> Self {
Self {
read: vec!["**".to_string()],
write: vec!["**".to_string()],
exec: vec!["**".to_string()],
net: vec![],
llm: LlmPolicy::default(),
redact: RedactionPolicy::default(),
escalate_to: vec!["user".to_string()],
}
}
pub fn compose(&self, higher: &PermissionPolicy) -> PermissionPolicy {
let mut composed = self.clone();
merge_unique(&mut composed.read, &higher.read);
merge_unique(&mut composed.write, &higher.write);
merge_unique(&mut composed.exec, &higher.exec);
merge_unique(&mut composed.net, &higher.net);
composed
.llm
.providers
.extend(higher.llm.providers.iter().cloned());
composed.llm.cost_ceiling_usd_cents = match (
composed.llm.cost_ceiling_usd_cents,
higher.llm.cost_ceiling_usd_cents,
) {
(None, other) | (other, None) => other,
(Some(a), Some(b)) => Some(a.min(b)),
};
merge_unique(&mut composed.redact.transcript, &higher.redact.transcript);
merge_unique(&mut composed.redact.logs, &higher.redact.logs);
let mut new_chain = higher.escalate_to.clone();
new_chain.extend(
composed
.escalate_to
.iter()
.filter(|item| !higher.escalate_to.contains(item))
.cloned(),
);
composed.escalate_to = new_chain;
composed
}
pub fn version(&self) -> PolicyVersion {
let canonical = self.canonical();
let serialized = serde_json::to_vec(&canonical).expect("policy serializes");
let mut hasher = Sha256::new();
hasher.update(&serialized);
let digest = hasher.finalize();
PolicyVersion(format!("policy-{}", hex_short(&digest)))
}
pub fn lint(&self) -> Result<(), Vec<PolicyLintError>> {
let mut errors = Vec::new();
for (label, patterns) in [
("read", &self.read),
("write", &self.write),
("exec", &self.exec),
("net", &self.net),
] {
for pattern in patterns {
if pattern.is_empty() {
errors.push(PolicyLintError::EmptyPattern { axis: label });
continue;
}
if Glob::new(pattern).is_err() {
errors.push(PolicyLintError::InvalidGlob {
axis: label,
pattern: pattern.clone(),
});
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub fn matcher_for(&self, class: ActionClass) -> CompiledMatcher {
let patterns = match class {
ActionClass::Read => &self.read,
ActionClass::Write => &self.write,
ActionClass::Exec => &self.exec,
ActionClass::Net => &self.net,
ActionClass::Llm | ActionClass::Custom => return CompiledMatcher::empty(),
};
CompiledMatcher::compile(patterns)
}
fn canonical(&self) -> CanonicalPolicy {
let mut policy = self.clone();
for slice in [
&mut policy.read,
&mut policy.write,
&mut policy.exec,
&mut policy.net,
] {
slice.sort();
slice.dedup();
}
policy.redact.transcript.sort();
policy.redact.transcript.dedup();
policy.redact.logs.sort();
policy.redact.logs.dedup();
CanonicalPolicy {
read: policy.read,
write: policy.write,
exec: policy.exec,
net: policy.net,
llm_providers: policy.llm.providers,
llm_cost_ceiling_usd_cents: policy.llm.cost_ceiling_usd_cents,
redact_transcript: policy.redact.transcript,
redact_logs: policy.redact.logs,
escalate_to: policy.escalate_to,
}
}
}
fn merge_unique(into: &mut Vec<String>, source: &[String]) {
for item in source {
if !into.iter().any(|existing| existing == item) {
into.push(item.clone());
}
}
}
fn hex_short(bytes: &[u8]) -> String {
bytes.iter().take(12).map(|b| format!("{b:02x}")).collect()
}
#[derive(Serialize)]
struct CanonicalPolicy {
read: Vec<String>,
write: Vec<String>,
exec: Vec<String>,
net: Vec<String>,
llm_providers: BTreeSet<String>,
#[serde(skip_serializing_if = "Option::is_none")]
llm_cost_ceiling_usd_cents: Option<u64>,
redact_transcript: Vec<String>,
redact_logs: Vec<String>,
escalate_to: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PolicyLintError {
EmptyPattern { axis: &'static str },
InvalidGlob { axis: &'static str, pattern: String },
}
impl fmt::Display for PolicyLintError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PolicyLintError::EmptyPattern { axis } => {
write!(f, "{axis} policy contains an empty pattern")
}
PolicyLintError::InvalidGlob { axis, pattern } => {
write!(f, "{axis} policy has invalid glob `{pattern}`")
}
}
}
}
#[derive(Clone)]
pub struct CompiledMatcher {
set: Option<GlobSet>,
}
impl CompiledMatcher {
fn empty() -> Self {
Self { set: None }
}
fn compile(patterns: &[String]) -> Self {
if patterns.is_empty() {
return Self::empty();
}
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
if let Ok(glob) = Glob::new(pattern) {
builder.add(glob);
}
}
builder
.build()
.ok()
.map(|set| Self { set: Some(set) })
.unwrap_or_else(Self::empty)
}
pub fn matches(&self, target: &str) -> bool {
self.set.as_ref().is_some_and(|set| set.is_match(target))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_policy_matches_nothing() {
let policy = PermissionPolicy::empty();
for class in [
ActionClass::Read,
ActionClass::Write,
ActionClass::Exec,
ActionClass::Net,
] {
assert!(!policy.matcher_for(class).matches("anything"));
}
}
#[test]
fn local_dev_allows_workspace_globs() {
let policy = PermissionPolicy::local_dev();
assert!(policy.matcher_for(ActionClass::Read).matches("src/main.rs"));
assert!(policy.matcher_for(ActionClass::Write).matches("Cargo.toml"));
assert!(policy.matcher_for(ActionClass::Exec).matches("ls"));
assert!(!policy.matcher_for(ActionClass::Net).matches("github.com"));
}
#[test]
fn version_is_stable_under_reordering() {
let mut a = PermissionPolicy::empty();
a.read = vec!["src/**".to_string(), "tests/**".to_string()];
let mut b = PermissionPolicy::empty();
b.read = vec!["tests/**".to_string(), "src/**".to_string()];
assert_eq!(a.version(), b.version());
}
#[test]
fn lint_rejects_empty_and_invalid_globs() {
let mut policy = PermissionPolicy::empty();
policy.read = vec![String::new(), "src/[".to_string()];
let errors = policy.lint().expect_err("expected lint errors");
assert!(errors
.iter()
.any(|e| matches!(e, PolicyLintError::EmptyPattern { axis: "read" })));
assert!(errors
.iter()
.any(|e| matches!(e, PolicyLintError::InvalidGlob { axis: "read", .. })));
}
#[test]
fn compose_unions_patterns_and_tightens_cost_ceiling() {
let mut workspace = PermissionPolicy::empty();
workspace.read = vec!["src/**".to_string()];
workspace.llm.cost_ceiling_usd_cents = Some(500);
let mut user = PermissionPolicy::empty();
user.read = vec!["tests/**".to_string()];
user.llm.cost_ceiling_usd_cents = Some(200);
let composed = workspace.compose(&user);
assert_eq!(
composed.read,
vec!["src/**".to_string(), "tests/**".to_string()]
);
assert_eq!(composed.llm.cost_ceiling_usd_cents, Some(200));
}
#[test]
fn compose_chains_escalation_higher_first() {
let mut workspace = PermissionPolicy::empty();
workspace.escalate_to = vec!["user".to_string()];
let mut persona = PermissionPolicy::empty();
persona.escalate_to = vec!["persona://review-captain".to_string(), "user".to_string()];
let composed = workspace.compose(&persona);
assert_eq!(
composed.escalate_to,
vec!["persona://review-captain".to_string(), "user".to_string()]
);
}
}