use std::path::Path;
pub use agent_bridle::policy::{
ApproveVerifier, CapabilityClass, Ed25519ApproveVerifier, ExecEntry, FsEntry, NetEntry,
PolicyFile, PolicySet, Verdict,
};
use crate::agentic::DenialKind;
pub const REPAIR_HINT: &str = "run `newt doctor` to inspect and repair";
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);
for warning in &mut warnings {
warning.push_str("; ");
warning.push_str(REPAIR_HINT);
}
(set, warnings)
}
pub fn evaluate_request(set: &PolicySet, kind: DenialKind, target: &str) -> Option<Verdict> {
let mut request_policy = std::borrow::Cow::Borrowed(set);
if kind == DenialKind::FsWrite {
if let Some(approve) = request_policy.to_mut().files.get_mut(&Verdict::Approve) {
approve.fs.retain(|entry| entry.write);
}
}
#[cfg(target_os = "macos")]
if matches!(kind, DenialKind::FsRead | DenialKind::FsWrite) {
let target = crate::fs_aliases::rewrite(target);
return request_policy
.files
.iter()
.filter(|(_, file)| {
file.fs
.iter()
.any(|entry| crate::fs_aliases::rewrite(&entry.path) == target)
})
.map(|(verdict, _)| *verdict)
.min_by_key(|verdict| verdict.precedence());
}
request_policy.evaluate(class_for(kind)?, target)
}
pub fn approved_grants(set: &PolicySet) -> Vec<(DenialKind, String)> {
let Some(approve) = set.files.get(&Verdict::Approve) else {
return Vec::new();
};
let mut grants = Vec::new();
for entry in &approve.exec {
grants.push((DenialKind::Exec, entry.target.clone()));
}
for entry in &approve.fs {
grants.push((DenialKind::FsRead, entry.path.clone()));
if entry.write {
grants.push((DenialKind::FsWrite, entry.path.clone()));
}
}
for entry in &approve.net {
grants.push((DenialKind::Net, entry.host.clone()));
}
grants
}
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(sign_entry(&payload, &sign));
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)
}
#[derive(Debug, Clone)]
pub enum ApproveEntry {
Exec { target: String },
Fs { path: String, write: bool },
Net { host: String },
}
impl ApproveEntry {
pub fn class(&self) -> CapabilityClass {
match self {
Self::Exec { .. } => CapabilityClass::Exec,
Self::Fs { .. } => CapabilityClass::Fs,
Self::Net { .. } => CapabilityClass::Net,
}
}
pub fn target(&self) -> &str {
match self {
Self::Exec { target } => target,
Self::Fs { path, .. } => path,
Self::Net { host } => host,
}
}
}
pub fn sign_entry(payload: &[u8], sign: &impl Fn(&[u8]) -> [u8; 64]) -> String {
agent_bridle::policy::hex_encode(&sign(payload))
}
pub fn persist_approve(
config_path: &Path,
entry: ApproveEntry,
is_high_danger: impl Fn(CapabilityClass, &str) -> bool,
sign: impl Fn(&[u8]) -> [u8; 64],
) -> anyhow::Result<bool> {
let entry = normalize_fs_target(entry)?;
PolicySet::validate_approve(entry.class(), entry.target(), &is_high_danger)
.map_err(anyhow::Error::msg)?;
let (destination, _lock) = lock_approve_file(config_path)?;
let text = match std::fs::read_to_string(destination.as_path()) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.into()),
};
let had_comments = text.contains('#');
let mut file = PolicyFile::parse(&text).map_err(|e| anyhow::anyhow!(e))?;
match entry {
ApproveEntry::Exec { target } => {
let e = ExecEntry {
target,
..Default::default()
};
let sig = sign_entry(&e.signing_payload(), &sign);
file.exec.push(ExecEntry {
sig: Some(sig),
..e
});
}
ApproveEntry::Fs { path, write } => {
let e = FsEntry {
path,
write,
..Default::default()
};
let sig = sign_entry(&e.signing_payload(), &sign);
file.fs.push(FsEntry {
sig: Some(sig),
..e
});
}
ApproveEntry::Net { host } => {
let e = NetEntry {
host,
..Default::default()
};
let sig = sign_entry(&e.signing_payload(), &sign);
file.net.push(NetEntry {
sig: Some(sig),
..e
});
}
}
let toml = file.to_toml().map_err(|e| anyhow::anyhow!(e))?;
destination.atomic_write(toml.as_bytes())?;
Ok(had_comments)
}
pub fn lock_approve_file(
config_path: &Path,
) -> anyhow::Result<(crate::atomic_fs::ResolvedPath, crate::atomic_fs::LockGuard)> {
let dir = config_path.with_file_name("ocap");
std::fs::create_dir_all(&dir)?;
let path = dir.join(Verdict::Approve.filename());
let destination = crate::atomic_fs::ResolvedPath::resolve(&path)?;
let lock = crate::atomic_fs::acquire_lock(&destination.lock_path())?;
Ok((destination, lock))
}
fn normalize_fs_target(entry: ApproveEntry) -> anyhow::Result<ApproveEntry> {
let ApproveEntry::Fs { path, write } = entry else {
return Ok(entry);
};
Ok(ApproveEntry::Fs {
path: normalize_fs_path(&path)?,
write,
})
}
pub fn normalize_fs_path(path: &str) -> anyhow::Result<String> {
if !Path::new(path).is_absolute() {
anyhow::bail!("permanent allow refused: fs target `{path}` must be an absolute path");
}
let normalized = crate::caveats::lexically_normalize(path);
if normalized
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
anyhow::bail!("permanent allow refused: fs target `{path}` escapes above its root");
}
Ok(normalized.to_string_lossy().into_owned())
}
#[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)
);
}
fn signed_approve_store(approve: &str) -> PolicySet {
let key = agent_mesh_protocol::UserKey::generate();
let mut file = PolicyFile::parse(approve).unwrap();
let (signed, refused) = sign_approves(
&mut file,
|_, _| false,
|payload| key.sign(payload).to_bytes(),
);
assert_eq!(signed, file.exec.len() + file.fs.len() + file.net.len());
assert!(refused.is_empty());
let (set, warnings) = build_store(&[(Verdict::Approve, Some(file.to_toml().unwrap()))]);
assert!(warnings.is_empty(), "{warnings:?}");
let verifier = Ed25519ApproveVerifier {
verifying_key: key.public().as_bytes(),
};
let (set, warnings) = verify_approves(set, Some(&verifier));
assert!(warnings.is_empty(), "{warnings:?}");
set
}
#[test]
fn signed_readonly_fs_approval_does_not_cover_a_write_request() {
for mode in ["", "write = false\n"] {
let set = signed_approve_store(&format!(
"[[fs]]\npath = \"/fixture/notes.txt\"\n{mode}\
[[fs]]\npath = \"/fixture/writable.txt\"\nwrite = true\n"
));
assert_eq!(
evaluate_request(&set, DenialKind::FsRead, "/fixture/notes.txt"),
Some(Verdict::Approve),
"a verified read grant must remain usable"
);
assert_eq!(
evaluate_request(&set, DenialKind::FsWrite, "/fixture/writable.txt"),
Some(Verdict::Approve),
"a different target's write grant remains usable only there"
);
assert_eq!(
evaluate_request(&set, DenialKind::FsWrite, "/fixture/notes.txt"),
None,
"a read-only signature must not pre-answer a write request"
);
}
}
#[test]
fn signed_fs_write_approval_covers_both_modes_regardless_of_entry_order() {
for modes in [[false, true], [true, false]] {
let set = signed_approve_store(&format!(
"[[fs]]\npath = \"/fixture/notes.txt\"\nwrite = {}\n\
[[fs]]\npath = \"/fixture/notes.txt\"\nwrite = {}\n",
modes[0], modes[1]
));
for kind in [DenialKind::FsRead, DenialKind::FsWrite] {
assert_eq!(
evaluate_request(&set, kind, "/fixture/notes.txt"),
Some(Verdict::Approve)
);
assert_eq!(evaluate_request(&set, kind, "/fixture/other.txt"), None);
}
}
}
#[test]
fn readonly_fs_restrictions_still_outrank_a_signed_write_approval() {
for verdict in [Verdict::Deny, Verdict::Passkey, Verdict::Ask] {
let mut set =
signed_approve_store("[[fs]]\npath = \"/fixture/notes.txt\"\nwrite = true\n");
set.files.insert(
verdict,
PolicyFile::parse("[[fs]]\npath = \"/fixture/notes.txt\"\n").unwrap(),
);
for kind in [DenialKind::FsRead, DenialKind::FsWrite] {
assert_eq!(
evaluate_request(&set, kind, "/fixture/notes.txt"),
Some(verdict),
"filtering read-only approvals must not weaken narrowing verdicts"
);
}
}
}
#[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);
}
#[test]
fn approved_grants_lists_approve_entries_and_never_the_narrowing_verdicts() {
let (set, warnings) = build_store(&[
(
Verdict::Approve,
Some(
"[[exec]]\ntarget = \"cargo\"\n\
[[fs]]\npath = \"/ro\"\n\
[[fs]]\npath = \"/rw\"\nwrite = true\n\
[[net]]\nhost = \"example.test\"\n"
.to_string(),
),
),
(
Verdict::Deny,
Some("[[exec]]\ntarget = \"rm\"\n".to_string()),
),
]);
assert!(warnings.is_empty(), "{warnings:?}");
let mut grants = approved_grants(&set);
grants.sort();
let mut expected = vec![
(DenialKind::Exec, "cargo".to_string()),
(DenialKind::FsRead, "/ro".to_string()),
(DenialKind::FsRead, "/rw".to_string()),
(DenialKind::FsWrite, "/rw".to_string()),
(DenialKind::Net, "example.test".to_string()),
];
expected.sort();
assert_eq!(grants, expected, "must never include the denied `rm`");
}
#[test]
fn approved_grants_of_an_empty_or_approve_less_store_is_empty() {
assert!(approved_grants(&PolicySet::default()).is_empty());
let (set, warnings) = build_store(&[(
Verdict::Deny,
Some("[[exec]]\ntarget = \"rm\"\n".to_string()),
)]);
assert!(warnings.is_empty());
assert!(approved_grants(&set).is_empty());
}
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:?}");
}
fn key() -> agent_mesh_protocol::UserKey {
agent_mesh_protocol::UserKey::generate()
}
#[test]
fn persist_approve_refuses_a_relative_fs_target_and_writes_nothing() {
let dir = tempfile::TempDir::new().unwrap();
let config = dir.path().join("config.toml");
let k = key();
let result = persist_approve(
&config,
ApproveEntry::Fs {
path: "../../".to_string(),
write: false,
},
|_, _| false,
|payload| k.sign(payload).to_bytes(),
);
assert!(result.is_err(), "a relative fs target must be refused");
assert!(
!dir.path().join("ocap").join("approve.toml").exists(),
"nothing should be written"
);
}
#[test]
fn persist_approve_refuses_an_absolute_path_that_still_climbs_above_its_root() {
let dir = tempfile::TempDir::new().unwrap();
let config = dir.path().join("config.toml");
let k = key();
let result = persist_approve(
&config,
ApproveEntry::Fs {
path: "/a/../../b".to_string(),
write: false,
},
|_, _| false,
|payload| k.sign(payload).to_bytes(),
);
assert!(
result.is_err(),
"a climbing absolute target must be refused"
);
assert!(!dir.path().join("ocap").join("approve.toml").exists());
}
#[test]
fn persist_approve_normalizes_a_contained_relative_segment_before_signing() {
let dir = tempfile::TempDir::new().unwrap();
let config = dir.path().join("config.toml");
let k = key();
let ws = dir.path().join("ws");
let raw = ws.join("x").join("..").join("y");
persist_approve(
&config,
ApproveEntry::Fs {
path: raw.to_string_lossy().into_owned(),
write: false,
},
|_, _| false,
|payload| k.sign(payload).to_bytes(),
)
.unwrap();
let text = std::fs::read_to_string(dir.path().join("ocap").join("approve.toml")).unwrap();
let file = PolicyFile::parse(&text).unwrap();
assert_eq!(file.fs.len(), 1, "{text}");
assert_eq!(
std::path::Path::new(&file.fs[0].path),
ws.join("y").as_path(),
"{text}"
);
}
#[test]
fn persist_approve_fails_cleanly_when_the_approve_lock_is_already_held() {
let dir = tempfile::TempDir::new().unwrap();
let config = dir.path().join("config.toml");
let (_destination, _held) = lock_approve_file(&config).unwrap();
let k = key();
let result = persist_approve(
&config,
ApproveEntry::Exec {
target: "cargo".to_string(),
},
|_, _| false,
|payload| k.sign(payload).to_bytes(),
);
assert!(
result.is_err(),
"a held lock must fail the write cleanly, not interleave"
);
}
}