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 ShadowAxis {
Exec,
FsRead,
FsWrite,
Net,
}
impl ShadowAxis {
pub fn class(self) -> &'static str {
match self {
Self::Exec => "exec",
Self::FsRead | Self::FsWrite => "fs",
Self::Net => "net",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShadowCaveat {
pub axis: ShadowAxis,
pub target: String,
pub command: String,
pub count: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct FlightCapture {
#[serde(with = "caveat_map")]
pub caveats: BTreeMap<(ShadowAxis, String), ShadowCaveat>,
}
impl FlightCapture {
pub fn observe(&mut self, axis: ShadowAxis, target: &str, command: &str) {
let target = target.trim();
if target.is_empty() {
return;
}
let key = (axis, target.to_string());
self.caveats
.entry(key)
.and_modify(|c| c.count += 1)
.or_insert_with(|| ShadowCaveat {
axis,
target: target.to_string(),
command: command.to_string(),
count: 1,
});
}
pub fn observe_command(&mut self, command: &str) {
if let Some(program) = exec_program(command) {
self.observe(ShadowAxis::Exec, program, command);
}
}
pub fn gaps<'a>(
&'a self,
in_policy: &std::collections::HashSet<(String, String)>,
) -> Vec<&'a ShadowCaveat> {
self.caveats
.values()
.filter(|c| !in_policy.contains(&(c.axis.class().to_string(), c.target.clone())))
.collect()
}
pub fn to_json(&self) -> Result<String, String> {
serde_json::to_string_pretty(self).map_err(|e| format!("flight capture serialize: {e}"))
}
pub fn parse(text: &str) -> Result<Self, String> {
serde_json::from_str(text).map_err(|e| format!("flight capture parse: {e}"))
}
}
pub fn exec_program(command: &str) -> Option<&str> {
let first = command.split_ascii_whitespace().next()?;
Some(
first
.rsplit(['/', '\\'])
.find(|p| !p.is_empty())
.unwrap_or(first),
)
}
mod caveat_map {
use super::{ShadowAxis, ShadowCaveat};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::BTreeMap;
pub fn serialize<S: Serializer>(
map: &BTreeMap<(ShadowAxis, String), ShadowCaveat>,
s: S,
) -> Result<S::Ok, S::Error> {
map.values().collect::<Vec<_>>().serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(
d: D,
) -> Result<BTreeMap<(ShadowAxis, String), ShadowCaveat>, D::Error> {
let list = Vec::<ShadowCaveat>::deserialize(d)?;
Ok(list
.into_iter()
.map(|c| ((c.axis, c.target.clone()), c))
.collect())
}
}
pub const CAPTURE_PATH_ENV: &str = "NEWT_FLIGHT_RECORDER";
pub fn log_unconfined(command: &str) {
let mut cap = FlightCapture::default();
cap.observe_command(command);
append_capture(&cap);
}
pub fn log_observed(axis: ShadowAxis, target: &str, command: &str) {
let mut cap = FlightCapture::default();
cap.observe(axis, target, command);
append_capture(&cap);
}
fn append_capture(cap: &FlightCapture) {
if cap.caveats.is_empty() {
return;
}
let Some(path) = std::env::var_os(CAPTURE_PATH_ENV) else {
return;
};
use std::io::Write as _;
if let Some(parent) = std::path::Path::new(&path).parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
for c in cap.caveats.values() {
if let Ok(line) = serde_json::to_string(c) {
let _ = writeln!(f, "{line}");
}
}
}
}
pub fn read_capture_jsonl(text: &str) -> FlightCapture {
let mut cap = FlightCapture::default();
for line in text.lines().filter(|l| !l.trim().is_empty()) {
if let Ok(c) = serde_json::from_str::<ShadowCaveat>(line) {
for _ in 0..c.count.max(1) {
cap.observe(c.axis, &c.target, &c.command);
}
}
}
cap
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn exec_program_takes_the_basename_of_the_leading_token() {
assert_eq!(exec_program("cargo build --release"), Some("cargo"));
assert_eq!(exec_program("/usr/bin/npm ci"), Some("npm"));
assert_eq!(exec_program(" git push "), Some("git"));
assert_eq!(exec_program(""), None);
}
#[test]
fn observe_dedupes_by_axis_target_and_counts() {
let mut cap = FlightCapture::default();
cap.observe_command("cargo build");
cap.observe_command("cargo test");
cap.observe_command("git push origin main");
assert_eq!(cap.caveats.len(), 2, "cargo merged, git distinct");
let cargo = &cap.caveats[&(ShadowAxis::Exec, "cargo".into())];
assert_eq!(cargo.count, 2);
assert_eq!(
cargo.command, "cargo build",
"first command kept as fixture"
);
cap.observe(ShadowAxis::Exec, " ", " ");
assert_eq!(cap.caveats.len(), 2);
}
#[test]
fn gaps_are_the_set_diff_never_an_auto_grant() {
let mut cap = FlightCapture::default();
cap.observe_command("cargo build");
cap.observe_command("rm -rf target");
let mut in_policy = HashSet::new();
in_policy.insert(("exec".to_string(), "cargo".to_string()));
let gaps = cap.gaps(&in_policy);
assert_eq!(gaps.len(), 1);
assert_eq!(gaps[0].target, "rm");
assert_eq!(
gaps[0].command, "rm -rf target",
"gap carries the repro fixture"
);
}
#[test]
fn jsonl_capture_folds_by_axis_target() {
let lines = concat!(
"{\"axis\":\"exec\",\"target\":\"cargo\",\"command\":\"cargo build\",\"count\":1}\n",
"{\"axis\":\"exec\",\"target\":\"cargo\",\"command\":\"cargo test\",\"count\":1}\n",
"\n",
"{\"axis\":\"net\",\"target\":\"crates.io\",\"command\":\"curl x\",\"count\":1}\n",
);
let cap = read_capture_jsonl(lines);
assert_eq!(cap.caveats.len(), 2);
assert_eq!(cap.caveats[&(ShadowAxis::Exec, "cargo".into())].count, 2);
}
#[test]
fn fs_and_net_axes_fold_and_map_to_the_policy_classes() {
let lines = concat!(
"{\"axis\":\"fs_read\",\"target\":\"/ws/src\",\"command\":\"read_file\",\"count\":1}\n",
"{\"axis\":\"fs_read\",\"target\":\"/ws/src\",\"command\":\"list_dir\",\"count\":1}\n",
"{\"axis\":\"fs_write\",\"target\":\"/ws/out\",\"command\":\"write_file\",\"count\":1}\n",
"{\"axis\":\"net\",\"target\":\"docs.rs\",\"command\":\"https://docs.rs/x\",\"count\":1}\n",
);
let cap = read_capture_jsonl(lines);
assert_eq!(cap.caveats.len(), 3);
let read = &cap.caveats[&(ShadowAxis::FsRead, "/ws/src".into())];
assert_eq!(read.count, 2);
assert_eq!(read.axis.class(), "fs");
assert_eq!(
cap.caveats[&(ShadowAxis::FsWrite, "/ws/out".into())]
.axis
.class(),
"fs"
);
assert_eq!(
cap.caveats[&(ShadowAxis::Net, "docs.rs".into())]
.axis
.class(),
"net"
);
}
#[test]
fn log_observed_is_a_no_op_when_recording_is_off() {
assert!(std::env::var_os(CAPTURE_PATH_ENV).is_none());
log_observed(ShadowAxis::FsRead, "/ws/x", "read_file");
log_observed(ShadowAxis::Net, "example.com", "https://example.com");
}
#[test]
fn capture_round_trips_through_json() {
let mut cap = FlightCapture::default();
cap.observe_command("cargo build");
cap.observe_command("cargo build");
cap.observe(ShadowAxis::Net, "crates.io", "curl https://crates.io");
let json = cap.to_json().unwrap();
let back = FlightCapture::parse(&json).unwrap();
assert_eq!(cap, back);
assert_eq!(back.caveats[&(ShadowAxis::Exec, "cargo".into())].count, 2);
assert_eq!(
back.caveats[&(ShadowAxis::Net, "crates.io".into())]
.axis
.class(),
"net"
);
}
}