use std::path::Path;
pub use agent_bridle::policy::{
ApproveVerifier, CapabilityClass, Ed25519ApproveVerifier, ExecEntry, FsEntry, NetEntry,
PolicyFile, PolicySet, Verdict,
};
use crate::agentic::DenialKind;
pub const VERDICTS: [Verdict; 4] = [
Verdict::Deny,
Verdict::Passkey,
Verdict::Ask,
Verdict::Approve,
];
pub fn class_for(kind: DenialKind) -> Option<CapabilityClass> {
match kind {
DenialKind::Exec => Some(CapabilityClass::Exec),
DenialKind::FsRead | DenialKind::FsWrite => Some(CapabilityClass::Fs),
DenialKind::Net => Some(CapabilityClass::Net),
_ => None,
}
}
pub fn build_store(files: &[(Verdict, Option<String>)]) -> (PolicySet, Vec<String>) {
let mut set = PolicySet::default();
let mut warnings = Vec::new();
for (verdict, contents) in files {
let Some(text) = contents else { continue };
match PolicyFile::parse(text) {
Ok(file) => {
set.files.insert(*verdict, file);
}
Err(e) => warnings.push(format!("{}: {e}", verdict.filename())),
}
}
(set, warnings)
}
pub fn verify_approves(
mut set: PolicySet,
verifier: Option<&dyn ApproveVerifier>,
) -> (PolicySet, Vec<String>) {
let Some(approves) = set.files.remove(&Verdict::Approve) else {
return (set, Vec::new());
};
match verifier {
Some(v) => {
let (kept, warnings) = approves.verified_approves(v);
set.files.insert(Verdict::Approve, kept);
(set, warnings)
}
None => {
let dropped = approves.exec.len() + approves.fs.len() + approves.net.len();
let warnings = if dropped == 0 {
Vec::new()
} else {
vec![format!(
"approve.toml: no root signing key available — {dropped} \
durable grant(s) dropped (fail-closed; the gate will prompt)"
)]
};
(set, warnings)
}
}
}
pub fn load_store(
config_path: &Path,
root_verifying_key: Option<[u8; 32]>,
) -> (PolicySet, Vec<String>) {
let dir = config_path.with_file_name("ocap");
let files: Vec<(Verdict, Option<String>)> = VERDICTS
.iter()
.map(|&v| {
let path = dir.join(v.filename());
(v, std::fs::read_to_string(&path).ok())
})
.collect();
let (set, mut warnings) = build_store(&files);
let verifier = root_verifying_key.map(|verifying_key| Ed25519ApproveVerifier { verifying_key });
let (set, mut sig_warnings) =
verify_approves(set, verifier.as_ref().map(|v| v as &dyn ApproveVerifier));
warnings.append(&mut sig_warnings);
(set, warnings)
}
pub fn evaluate_request(set: &PolicySet, kind: DenialKind, target: &str) -> Option<Verdict> {
set.evaluate(class_for(kind)?, target)
}
pub fn sign_approves(
file: &mut PolicyFile,
is_high_danger: impl Fn(CapabilityClass, &str) -> bool,
sign: impl Fn(&[u8]) -> [u8; 64],
) -> (usize, Vec<String>) {
let mut signed = 0;
let mut refused = Vec::new();
let mut bless =
|class: CapabilityClass, what: &str, payload: Vec<u8>, sig: &mut Option<String>| {
match PolicySet::validate_approve(class, what, &is_high_danger) {
Ok(()) => {
*sig = Some(agent_bridle::policy::hex_encode(&sign(&payload)));
signed += 1;
}
Err(reason) => refused.push(reason),
}
};
for e in &mut file.exec {
let payload = e.signing_payload();
let target = e.target.clone();
bless(CapabilityClass::Exec, &target, payload, &mut e.sig);
}
for e in &mut file.fs {
let payload = e.signing_payload();
let path = e.path.clone();
bless(CapabilityClass::Fs, &path, payload, &mut e.sig);
}
for e in &mut file.net {
let payload = e.signing_payload();
let host = e.host.clone();
bless(CapabilityClass::Net, &host, payload, &mut e.sig);
}
(signed, refused)
}
#[cfg(test)]
mod tests {
use super::*;
fn store(approve: &str, deny: &str) -> PolicySet {
build_store(&[
(Verdict::Approve, Some(approve.to_string())),
(Verdict::Deny, Some(deny.to_string())),
])
.0
}
#[test]
fn class_mapping_covers_the_durable_axes() {
assert_eq!(class_for(DenialKind::Exec), Some(CapabilityClass::Exec));
assert_eq!(class_for(DenialKind::FsRead), Some(CapabilityClass::Fs));
assert_eq!(class_for(DenialKind::FsWrite), Some(CapabilityClass::Fs));
assert_eq!(class_for(DenialKind::Net), Some(CapabilityClass::Net));
}
#[test]
fn evaluate_applies_the_contract_precedence() {
let s = store(
"[[exec]]\ntarget = \"cargo\"\n",
"[[exec]]\ntarget = \"rm\"\n",
);
assert_eq!(
evaluate_request(&s, DenialKind::Exec, "cargo"),
Some(Verdict::Approve)
);
assert_eq!(
evaluate_request(&s, DenialKind::Exec, "rm"),
Some(Verdict::Deny)
);
assert_eq!(evaluate_request(&s, DenialKind::Exec, "python3"), None);
assert_eq!(evaluate_request(&s, DenialKind::FsWrite, "cargo"), None);
}
#[test]
fn fs_axis_matches_both_read_and_write_against_the_fs_class() {
let s = build_store(&[(
Verdict::Approve,
Some("[[fs]]\npath = \"/ws\"\nwrite = true\n".to_string()),
)])
.0;
assert_eq!(
evaluate_request(&s, DenialKind::FsRead, "/ws"),
Some(Verdict::Approve)
);
assert_eq!(
evaluate_request(&s, DenialKind::FsWrite, "/ws"),
Some(Verdict::Approve)
);
}
#[test]
fn a_malformed_file_is_skipped_loudly_not_fatal() {
let (set, warnings) = build_store(&[
(
Verdict::Approve,
Some("[[exec]]\ntarget = \"cargo\"\n".to_string()),
),
(
Verdict::Deny,
Some("[[exec]]\ntarget=\"rm\"\nbad_key=1\n".to_string()),
),
]);
assert_eq!(
evaluate_request(&set, DenialKind::Exec, "cargo"),
Some(Verdict::Approve)
);
assert!(
warnings.iter().any(|w| w.contains("deny.toml")),
"{warnings:?}"
);
assert_eq!(evaluate_request(&set, DenialKind::Exec, "rm"), None);
}
#[test]
fn missing_files_yield_an_empty_store() {
let (set, warnings) = build_store(&[(Verdict::Approve, None), (Verdict::Deny, None)]);
assert!(warnings.is_empty());
assert_eq!(evaluate_request(&set, DenialKind::Exec, "anything"), None);
}
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 unsigned_approve_drops_but_unsigned_deny_survives() {
let (set, _) = build_store(&[
(
Verdict::Approve,
Some("[[exec]]\ntarget = \"git\"\n".to_string()),
),
(
Verdict::Deny,
Some("[[exec]]\ntarget = \"rm\"\n".to_string()),
),
]);
let (set, warnings) = verify_approves(set, Some(&SigEquals(&[1])));
assert_eq!(evaluate_request(&set, DenialKind::Exec, "git"), None);
assert!(
warnings
.iter()
.any(|w| w.contains("git") && w.contains("unsigned")),
"{warnings:?}"
);
assert_eq!(
evaluate_request(&set, DenialKind::Exec, "rm"),
Some(Verdict::Deny)
);
}
#[test]
fn signed_approve_survives_and_bad_sig_drops() {
let (set, _) = build_store(&[(
Verdict::Approve,
Some(
"[[exec]]\ntarget = \"cargo\"\nsig = \"0a\"\n\
[[net]]\nhost = \"crates.io\"\nsig = \"ff\"\n"
.to_string(),
),
)]);
let (set, warnings) = verify_approves(set, Some(&SigEquals(&[0x0a])));
assert_eq!(
evaluate_request(&set, DenialKind::Exec, "cargo"),
Some(Verdict::Approve),
"the validly-signed grant answers the gate"
);
assert_eq!(evaluate_request(&set, DenialKind::Net, "crates.io"), None);
assert_eq!(warnings.len(), 1, "{warnings:?}");
}
#[test]
fn no_root_key_drops_all_approves_fail_closed() {
let (set, _) = build_store(&[(
Verdict::Approve,
Some("[[exec]]\ntarget = \"cargo\"\nsig = \"0a\"\n".to_string()),
)]);
let (set, warnings) = verify_approves(set, None);
assert_eq!(evaluate_request(&set, DenialKind::Exec, "cargo"), None);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("no root signing key"), "{warnings:?}");
let (empty, warnings) = verify_approves(PolicySet::default(), None);
assert!(warnings.is_empty(), "nothing to drop, nothing to warn");
assert_eq!(evaluate_request(&empty, DenialKind::Exec, "cargo"), None);
}
#[test]
fn sign_approves_blesses_safe_entries_and_refuses_high_danger() {
let mut file = PolicyFile::parse(
"[[exec]]\ntarget = \"cargo\"\n\
[[exec]]\ntarget = \"bash\"\n\
[[fs]]\npath = \"/ws\"\nwrite = true\n",
)
.unwrap();
let is_high = |class: CapabilityClass, target: &str| {
class == CapabilityClass::Exec && target == "bash"
};
let sign = |payload: &[u8]| {
let mut sig = [0u8; 64];
sig[0] = payload.len() as u8;
sig
};
let (signed, refused) = sign_approves(&mut file, is_high, sign);
assert_eq!(signed, 2, "cargo + the fs path");
assert_eq!(refused.len(), 1);
assert!(refused[0].contains("bash"), "{refused:?}");
assert!(file.exec[0].sig.is_some());
assert!(file.exec[1].sig.is_none(), "the interpreter stays unsigned");
assert!(file.fs[0].sig.is_some());
struct LenChecker;
impl ApproveVerifier for LenChecker {
fn verify(&self, payload: &[u8], sig: &[u8]) -> Result<(), String> {
(sig.first() == Some(&(payload.len() as u8)))
.then_some(())
.ok_or("mismatch".into())
}
}
let mut set = PolicySet::default();
set.files.insert(Verdict::Approve, file);
let (set, warnings) = verify_approves(set, Some(&LenChecker));
assert_eq!(
evaluate_request(&set, DenialKind::Exec, "cargo"),
Some(Verdict::Approve)
);
assert_eq!(
evaluate_request(&set, DenialKind::FsWrite, "/ws"),
Some(Verdict::Approve)
);
assert_eq!(evaluate_request(&set, DenialKind::Exec, "bash"), None);
assert_eq!(warnings.len(), 1, "{warnings:?}");
}
}