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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sig: Option<String>,
}
impl ExecEntry {
pub fn signing_payload(&self) -> Vec<u8> {
format!("{SIGNING_DOMAIN}\nexec\n{}", self.target).into_bytes()
}
}
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sig: Option<String>,
}
impl FsEntry {
pub fn signing_payload(&self) -> Vec<u8> {
format!("{SIGNING_DOMAIN}\nfs\n{}\nwrite={}", self.path, self.write).into_bytes()
}
}
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sig: Option<String>,
}
impl NetEntry {
pub fn signing_payload(&self) -> Vec<u8> {
format!("{SIGNING_DOMAIN}\nnet\n{}", self.host).into_bytes()
}
}
pub const SIGNING_DOMAIN: &str = "agent-bridle:ocap-approve:v1";
pub trait ApproveVerifier {
fn verify(&self, payload: &[u8], sig: &[u8]) -> Result<(), String>;
}
#[cfg(feature = "verifier-ed25519")]
#[derive(Debug, Clone, Copy)]
pub struct Ed25519ApproveVerifier {
pub verifying_key: [u8; 32],
}
#[cfg(feature = "verifier-ed25519")]
impl ApproveVerifier for Ed25519ApproveVerifier {
fn verify(&self, payload: &[u8], sig: &[u8]) -> Result<(), String> {
use ed25519_dalek::{Signature, VerifyingKey};
let vk = VerifyingKey::from_bytes(&self.verifying_key).map_err(|e| e.to_string())?;
let sig_bytes: [u8; 64] = sig
.try_into()
.map_err(|_| "signature is not 64 bytes".to_string())?;
vk.verify_strict(payload, &Signature::from_bytes(&sig_bytes))
.map_err(|e| e.to_string())
}
}
fn hex_decode(s: &str) -> Result<Vec<u8>, String> {
if !s.len().is_multiple_of(2) {
return Err("hex string has odd length".into());
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| e.to_string()))
.collect()
}
pub fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[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}"))
}
pub fn verified_approves(&self, verifier: &dyn ApproveVerifier) -> (Self, Vec<String>) {
let mut warnings = Vec::new();
let mut check = |what: &str, payload: Vec<u8>, sig: &Option<String>| -> bool {
let verdict = match sig {
None => Err("missing `sig` (unsigned approve)".to_string()),
Some(s) => hex_decode(s).and_then(|raw| verifier.verify(&payload, &raw)),
};
match verdict {
Ok(()) => true,
Err(reason) => {
warnings.push(format!("approve entry `{what}` dropped: {reason}"));
false
}
}
};
let kept = Self {
exec: self
.exec
.iter()
.filter(|e| check(&e.target, e.signing_payload(), &e.sig))
.cloned()
.collect(),
fs: self
.fs
.iter()
.filter(|e| check(&e.path, e.signing_payload(), &e.sig))
.cloned()
.collect(),
net: self
.net
.iter()
.filter(|e| check(&e.host, e.signing_payload(), &e.sig))
.cloned()
.collect(),
};
(kept, warnings)
}
}
#[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());
}
#[test]
fn signing_payloads_are_canonical_and_authority_scoped() {
let e = ExecEntry {
target: "cargo".into(),
note: Some("anything".into()),
..Default::default()
};
assert_eq!(
e.signing_payload(),
b"agent-bridle:ocap-approve:v1\nexec\ncargo"
);
let noteless = ExecEntry {
target: "cargo".into(),
..Default::default()
};
assert_eq!(e.signing_payload(), noteless.signing_payload());
let read = FsEntry {
path: "/ws".into(),
..Default::default()
};
let write = FsEntry {
path: "/ws".into(),
write: true,
..Default::default()
};
assert_ne!(
read.signing_payload(),
write.signing_payload(),
"write is authority-bearing"
);
let net = NetEntry {
host: "cargo".into(),
..Default::default()
};
assert_ne!(noteless.signing_payload(), net.signing_payload());
}
struct SigEquals(&'static [u8]);
impl ApproveVerifier for SigEquals {
fn verify(&self, _payload: &[u8], sig: &[u8]) -> Result<(), String> {
(sig == self.0).then_some(()).ok_or("bad signature".into())
}
}
#[test]
fn verified_approves_drops_unsigned_and_invalid_fail_closed() {
let file = PolicyFile::parse(
"[[exec]]\ntarget=\"cargo\"\nsig=\"0a0b\"\n\
[[exec]]\ntarget=\"git\"\n\
[[net]]\nhost=\"crates.io\"\nsig=\"ff\"\n",
)
.unwrap();
let (kept, warnings) = file.verified_approves(&SigEquals(&[0x0a, 0x0b]));
assert_eq!(kept.exec.len(), 1, "only the valid-sig entry survives");
assert_eq!(kept.exec[0].target, "cargo");
assert!(kept.net.is_empty(), "bad sig dropped");
assert_eq!(warnings.len(), 2, "{warnings:?}");
assert!(warnings
.iter()
.any(|w| w.contains("git") && w.contains("unsigned")));
assert!(warnings.iter().any(|w| w.contains("crates.io")));
let toml = kept.to_toml().unwrap();
assert!(toml.contains("sig = \"0a0b\""), "{toml}");
}
#[test]
fn hex_codec_round_trips_and_rejects_odd_length() {
assert_eq!(hex_encode(&[0x00, 0xff, 0x1a]), "00ff1a");
assert_eq!(hex_decode("00ff1a").unwrap(), vec![0x00, 0xff, 0x1a]);
assert_eq!(hex_decode("00FF1A").unwrap(), vec![0x00, 0xff, 0x1a]);
assert!(hex_decode("abc").is_err());
assert!(hex_decode("zz").is_err());
}
#[cfg(feature = "verifier-ed25519")]
#[test]
fn ed25519_sign_and_verify_roundtrip_rejects_tamper() {
use ed25519_dalek::{Signer, SigningKey};
let sk = SigningKey::from_bytes(&[7u8; 32]);
let verifier = Ed25519ApproveVerifier {
verifying_key: sk.verifying_key().to_bytes(),
};
let mut entry = ExecEntry {
target: "cargo".into(),
..Default::default()
};
entry.sig = Some(hex_encode(&sk.sign(&entry.signing_payload()).to_bytes()));
let file = PolicyFile {
exec: vec![entry.clone()],
..Default::default()
};
let (kept, warnings) = file.verified_approves(&verifier);
assert_eq!(kept.exec.len(), 1);
assert!(warnings.is_empty(), "{warnings:?}");
let mut forged = entry.clone();
forged.target = "rm".into();
let file = PolicyFile {
exec: vec![forged],
..Default::default()
};
let (kept, warnings) = file.verified_approves(&verifier);
assert!(
kept.exec.is_empty(),
"replayed sig must not carry to a new target"
);
assert_eq!(warnings.len(), 1);
let other = SigningKey::from_bytes(&[9u8; 32]);
let mut foreign = ExecEntry {
target: "cargo".into(),
..Default::default()
};
foreign.sig = Some(hex_encode(
&other.sign(&foreign.signing_payload()).to_bytes(),
));
let file = PolicyFile {
exec: vec![foreign],
..Default::default()
};
let (kept, _) = file.verified_approves(&verifier);
assert!(kept.exec.is_empty());
}
}