use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
Deny,
Passkey,
Ask,
Approve,
}
impl Verdict {
pub fn precedence(self) -> u8 {
match self {
Self::Deny => 0,
Self::Passkey => 1,
Self::Ask => 2,
Self::Approve => 3,
}
}
pub fn filename(self) -> &'static str {
match self {
Self::Deny => "deny.toml",
Self::Passkey => "passkey.toml",
Self::Ask => "ask.toml",
Self::Approve => "approve.toml",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExecEntry {
pub target: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub granted: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FsEntry {
pub path: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub write: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub granted: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetEntry {
pub host: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub granted: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PolicyFile {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exec: Vec<ExecEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fs: Vec<FsEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub net: Vec<NetEntry>,
}
impl PolicyFile {
pub fn parse(contents: &str) -> Result<Self, String> {
toml::from_str(contents).map_err(|e| format!("ocap policy TOML: {e}"))
}
pub fn to_toml(&self) -> Result<String, String> {
toml::to_string(self).map_err(|e| format!("ocap policy serialize: {e}"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CapabilityClass {
Exec,
Fs,
Net,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PolicySet {
pub files: BTreeMap<Verdict, PolicyFile>,
}
impl PolicySet {
pub fn evaluate(&self, class: CapabilityClass, target: &str) -> Option<Verdict> {
let mut best: Option<Verdict> = None;
for (&verdict, file) in &self.files {
let hit = match class {
CapabilityClass::Exec => file.exec.iter().any(|e| e.target == target),
CapabilityClass::Fs => file.fs.iter().any(|e| e.path == target),
CapabilityClass::Net => file.net.iter().any(|e| e.host == target),
};
if hit && best.is_none_or(|b| verdict.precedence() < b.precedence()) {
best = Some(verdict);
}
}
best
}
pub fn validate_approve(
class: CapabilityClass,
target: &str,
is_high_danger: impl Fn(CapabilityClass, &str) -> bool,
) -> Result<(), String> {
if is_high_danger(class, target) {
return Err(format!(
"`{target}` is a high-danger target and cannot be durably approved \
(offer passkey step-up instead)"
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn set_with(verdict: Verdict, file: PolicyFile) -> PolicySet {
let mut s = PolicySet::default();
s.files.insert(verdict, file);
s
}
#[test]
fn verdict_files_and_precedence_are_canonical() {
assert_eq!(Verdict::Deny.filename(), "deny.toml");
assert_eq!(Verdict::Approve.filename(), "approve.toml");
assert!(Verdict::Deny.precedence() < Verdict::Passkey.precedence());
assert!(Verdict::Passkey.precedence() < Verdict::Ask.precedence());
assert!(Verdict::Ask.precedence() < Verdict::Approve.precedence());
}
#[test]
fn parses_the_documented_approve_file() {
let f = PolicyFile::parse(
"[[exec]]\ntarget = \"cargo\"\nnote = \"build tooling\"\ngranted = \"2026-07-14\"\n\n\
[[fs]]\npath = \"~/workspaces\"\nwrite = true\n\n[[net]]\nhost = \"crates.io\"\n",
)
.unwrap();
assert_eq!(f.exec[0].target, "cargo");
assert!(f.fs[0].write);
assert_eq!(f.net[0].host, "crates.io");
let out = f.to_toml().unwrap();
assert!(!out.contains("by ="), "unset provenance skipped: {out}");
}
#[test]
fn empty_and_unknown_field_behavior() {
assert_eq!(PolicyFile::parse("").unwrap(), PolicyFile::default());
assert!(PolicyFile::parse("[[exec]]\ntarget=\"x\"\nnotee=\"typo\"\n").is_err());
}
#[test]
fn deny_shadows_approve_and_passkey_outranks_ask() {
let mut s = PolicySet::default();
s.files.insert(
Verdict::Approve,
PolicyFile::parse("[[exec]]\ntarget=\"rm\"\n").unwrap(),
);
s.files.insert(
Verdict::Deny,
PolicyFile::parse("[[exec]]\ntarget=\"rm\"\n").unwrap(),
);
assert_eq!(s.evaluate(CapabilityClass::Exec, "rm"), Some(Verdict::Deny));
let mut s = PolicySet::default();
s.files.insert(
Verdict::Ask,
PolicyFile::parse("[[net]]\nhost=\"api.example\"\n").unwrap(),
);
s.files.insert(
Verdict::Passkey,
PolicyFile::parse("[[net]]\nhost=\"api.example\"\n").unwrap(),
);
assert_eq!(
s.evaluate(CapabilityClass::Net, "api.example"),
Some(Verdict::Passkey)
);
}
#[test]
fn no_match_falls_through_to_the_interactive_floor() {
let s = set_with(
Verdict::Approve,
PolicyFile::parse("[[exec]]\ntarget=\"cargo\"\n").unwrap(),
);
assert_eq!(s.evaluate(CapabilityClass::Exec, "python3"), None);
assert_eq!(
s.evaluate(CapabilityClass::Fs, "cargo"),
None,
"class-scoped"
);
}
#[test]
fn high_danger_targets_are_never_durably_approvable() {
let judge = |class: CapabilityClass, target: &str| {
class == CapabilityClass::Exec && (target == "bash" || target == "python3")
};
assert!(PolicySet::validate_approve(CapabilityClass::Exec, "bash", judge).is_err());
assert!(PolicySet::validate_approve(CapabilityClass::Exec, "cargo", judge).is_ok());
assert!(PolicySet::validate_approve(CapabilityClass::Net, "bash", judge).is_ok());
}
}