use std::collections::HashSet;
use crate::flight_recorder::{FlightCapture, ShadowAxis};
use crate::ocap_store::{CapabilityClass, ExecEntry, FsEntry, NetEntry, PolicyFile, PolicySet};
pub const PROVENANCE: &str = "flight-recorder";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeferredCaveat {
pub class: &'static str,
pub target: String,
pub command: String,
pub count: u64,
pub reason: String,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Proposal {
pub additions: PolicyFile,
pub deferred: Vec<DeferredCaveat>,
}
impl Proposal {
pub fn additions_len(&self) -> usize {
self.additions.exec.len() + self.additions.fs.len() + self.additions.net.len()
}
pub fn is_empty(&self) -> bool {
self.additions_len() == 0 && self.deferred.is_empty()
}
}
pub fn in_policy_pairs(set: &PolicySet) -> HashSet<(String, String)> {
let mut pairs = HashSet::new();
for file in set.files.values() {
for e in &file.exec {
pairs.insert(("exec".to_string(), e.target.clone()));
}
for e in &file.fs {
pairs.insert(("fs".to_string(), e.path.clone()));
}
for e in &file.net {
pairs.insert(("net".to_string(), e.host.clone()));
}
}
pairs
}
fn axis_class(axis: ShadowAxis) -> CapabilityClass {
match axis {
ShadowAxis::Exec => CapabilityClass::Exec,
ShadowAxis::FsRead | ShadowAxis::FsWrite => CapabilityClass::Fs,
ShadowAxis::Net => CapabilityClass::Net,
}
}
fn defer_reason(class: CapabilityClass) -> String {
match class {
CapabilityClass::Exec => "high-danger exec (interpreter / shell / command-runner) — \
never durably approvable; keep prompting, or add to passkey.toml for WebAuthn step-up"
.to_string(),
CapabilityClass::Fs => "high-danger fs (a broad root at or above $HOME, the workspace, \
or /) — never durably approvable; narrow the path, or use passkey.toml"
.to_string(),
CapabilityClass::Net => {
"high-danger net — never durably approvable; use passkey.toml".to_string()
}
}
}
pub fn propose_from_capture(
capture: &FlightCapture,
in_policy: &HashSet<(String, String)>,
is_high_danger: impl Fn(CapabilityClass, &str) -> bool,
now: &str,
) -> Proposal {
let mut proposal = Proposal::default();
for c in capture.gaps(in_policy) {
let class = axis_class(c.axis);
if is_high_danger(class, &c.target) {
proposal.deferred.push(DeferredCaveat {
class: c.axis.class(),
target: c.target.clone(),
command: c.command.clone(),
count: c.count,
reason: defer_reason(class),
});
continue;
}
let note = Some(format!("learned from: {} ({}x)", c.command, c.count));
let granted = Some(now.to_string());
let by = Some(PROVENANCE.to_string());
match c.axis {
ShadowAxis::Exec => proposal.additions.exec.push(ExecEntry {
target: c.target.clone(),
note,
granted,
by,
sig: None,
}),
ShadowAxis::FsRead => proposal.additions.fs.push(FsEntry {
path: c.target.clone(),
write: false,
note,
granted,
by,
sig: None,
}),
ShadowAxis::FsWrite => proposal.additions.fs.push(FsEntry {
path: c.target.clone(),
write: true,
note,
granted,
by,
sig: None,
}),
ShadowAxis::Net => proposal.additions.net.push(NetEntry {
host: c.target.clone(),
note,
granted,
by,
sig: None,
}),
}
}
proposal
}
#[cfg(test)]
mod tests {
use super::*;
use crate::flight_recorder::read_capture_jsonl;
use crate::ocap_store::{build_store, Verdict};
fn danger(class: CapabilityClass, target: &str) -> bool {
match class {
CapabilityClass::Exec => matches!(target, "bash" | "sh" | "python3" | "env"),
CapabilityClass::Fs => target == "/" || target.starts_with("/home"),
CapabilityClass::Net => false,
}
}
fn capture(lines: &[&str]) -> FlightCapture {
read_capture_jsonl(&lines.join("\n"))
}
#[test]
fn low_danger_exec_gaps_become_unsigned_candidates() {
let cap =
capture(&[r#"{"axis":"exec","target":"cargo","command":"cargo build","count":3}"#]);
let p = propose_from_capture(&cap, &HashSet::new(), danger, "2026-07-17");
assert_eq!(p.additions.exec.len(), 1);
let e = &p.additions.exec[0];
assert_eq!(e.target, "cargo");
assert_eq!(e.by.as_deref(), Some("flight-recorder"));
assert_eq!(e.granted.as_deref(), Some("2026-07-17"));
assert!(e.sig.is_none(), "candidates are unsigned until blessed");
assert_eq!(e.note.as_deref(), Some("learned from: cargo build (3x)"));
assert!(p.deferred.is_empty());
}
#[test]
fn high_danger_exec_is_deferred_never_proposed() {
let cap = capture(&[
r#"{"axis":"exec","target":"cargo","command":"cargo test","count":1}"#,
r#"{"axis":"exec","target":"bash","command":"bash -c 'x'","count":2}"#,
]);
let p = propose_from_capture(&cap, &HashSet::new(), danger, "2026-07-17");
assert_eq!(p.additions.exec.len(), 1, "only cargo proposed");
assert_eq!(p.additions.exec[0].target, "cargo");
assert_eq!(p.deferred.len(), 1);
assert_eq!(p.deferred[0].target, "bash");
assert_eq!(p.deferred[0].class, "exec");
assert_eq!(p.deferred[0].count, 2);
assert!(p.deferred[0].reason.contains("passkey"));
}
#[test]
fn fs_read_and_write_axes_map_to_the_write_flag() {
let cap = capture(&[
r#"{"axis":"fs_read","target":"/ws/src","command":"cat x","count":1}"#,
r#"{"axis":"fs_write","target":"/ws/out","command":"tee y","count":1}"#,
r#"{"axis":"fs_write","target":"/home/x","command":"rm z","count":1}"#,
]);
let p = propose_from_capture(&cap, &HashSet::new(), danger, "2026-07-17");
assert_eq!(p.additions.fs.len(), 2);
let read = p.additions.fs.iter().find(|e| e.path == "/ws/src").unwrap();
assert!(!read.write, "fs_read → read-only");
let write = p.additions.fs.iter().find(|e| e.path == "/ws/out").unwrap();
assert!(write.write, "fs_write → write coverage");
assert_eq!(p.deferred.len(), 1);
assert_eq!(p.deferred[0].target, "/home/x");
assert_eq!(p.deferred[0].class, "fs");
}
#[test]
fn net_gaps_become_candidates() {
let cap = capture(&[r#"{"axis":"net","target":"crates.io","command":"curl","count":1}"#]);
let p = propose_from_capture(&cap, &HashSet::new(), danger, "2026-07-17");
assert_eq!(p.additions.net.len(), 1);
assert_eq!(p.additions.net[0].host, "crates.io");
assert!(p.additions.net[0].sig.is_none());
}
#[test]
fn gaps_already_in_any_verdict_are_not_reproposed() {
let (set, _) = build_store(&[
(
Verdict::Approve,
Some("[[exec]]\ntarget = \"cargo\"\n".to_string()),
),
(
Verdict::Deny,
Some("[[exec]]\ntarget = \"rm\"\n".to_string()),
),
]);
let in_policy = in_policy_pairs(&set);
let cap = capture(&[
r#"{"axis":"exec","target":"cargo","command":"cargo build","count":1}"#,
r#"{"axis":"exec","target":"rm","command":"rm -rf t","count":1}"#,
r#"{"axis":"exec","target":"git","command":"git push","count":1}"#,
]);
let p = propose_from_capture(&cap, &in_policy, danger, "2026-07-17");
assert_eq!(p.additions.exec.len(), 1, "only the genuinely-new git");
assert_eq!(p.additions.exec[0].target, "git");
assert!(p.deferred.is_empty());
}
#[test]
fn empty_capture_yields_an_empty_proposal() {
let p = propose_from_capture(
&FlightCapture::default(),
&HashSet::new(),
danger,
"2026-07-17",
);
assert!(p.is_empty());
assert_eq!(p.additions_len(), 0);
}
#[test]
fn in_policy_pairs_walks_all_classes_and_verdicts() {
let (set, _) = build_store(&[
(
Verdict::Approve,
Some("[[exec]]\ntarget = \"cargo\"\n[[net]]\nhost = \"crates.io\"\n".to_string()),
),
(
Verdict::Ask,
Some("[[fs]]\npath = \"/ws\"\nwrite = true\n".to_string()),
),
]);
let pairs = in_policy_pairs(&set);
assert!(pairs.contains(&("exec".to_string(), "cargo".to_string())));
assert!(pairs.contains(&("net".to_string(), "crates.io".to_string())));
assert!(pairs.contains(&("fs".to_string(), "/ws".to_string())));
assert_eq!(pairs.len(), 3);
}
}