use crate::{EnforcementReport, HumanGate, SandboxKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DenialKind {
Exec,
Open,
Net,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Denial {
pub kind: DenialKind,
pub target: String,
pub reason: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Disclosure {
#[serde(skip_serializing_if = "is_false")]
pub unbridled: bool,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub normalizations_disabled: Vec<String>,
#[serde(skip_serializing_if = "is_false")]
pub net_over_delivery: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub backend_forced: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub engine: Option<String>,
pub human_gate: HumanGate,
}
impl Disclosure {
#[must_use]
pub fn is_quiet(&self) -> bool {
!self.unbridled
&& self.normalizations_disabled.is_empty()
&& !self.net_over_delivery
&& self.backend_forced.is_none()
&& self.engine.is_none()
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct ToolEnvelope {
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stdout: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stderr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timed_out: Option<bool>,
#[serde(skip_serializing_if = "is_false")]
pub stdout_truncated: bool,
#[serde(skip_serializing_if = "is_false")]
pub stderr_truncated: bool,
#[serde(skip_serializing_if = "is_false")]
pub denied: bool,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub denials: Vec<Denial>,
pub sandbox_kind: SandboxKind,
#[serde(skip_serializing_if = "EnforcementReport::is_empty", default)]
pub enforcement: EnforcementReport,
#[serde(skip_serializing_if = "Disclosure::is_quiet", default)]
pub disclosure: Disclosure,
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(b: &bool) -> bool {
!*b
}
impl ToolEnvelope {
#[must_use]
pub fn new(sandbox_kind: SandboxKind) -> Self {
Self {
sandbox_kind,
..Self::default()
}
}
#[must_use]
pub fn with_exit_code(mut self, code: i32) -> Self {
self.exit_code = Some(code);
self
}
#[must_use]
pub fn with_stdout(mut self, stdout: impl Into<String>) -> Self {
self.stdout = Some(stdout.into());
self
}
#[must_use]
pub fn with_stderr(mut self, stderr: impl Into<String>) -> Self {
self.stderr = Some(stderr.into());
self
}
#[must_use]
pub fn with_timed_out(mut self, timed_out: bool) -> Self {
self.timed_out = Some(timed_out);
self
}
#[must_use]
pub fn with_truncation(mut self, stdout_truncated: bool, stderr_truncated: bool) -> Self {
self.stdout_truncated = stdout_truncated;
self.stderr_truncated = stderr_truncated;
self
}
#[must_use]
pub fn with_denials(mut self, denials: Vec<Denial>) -> Self {
self.denied = !denials.is_empty();
self.denials = denials;
self
}
#[must_use]
pub fn with_enforcement(mut self, enforcement: EnforcementReport) -> Self {
self.enforcement = enforcement;
self
}
#[must_use]
pub fn with_disclosure(mut self, disclosure: Disclosure) -> Self {
self.disclosure = disclosure;
self
}
#[must_use]
pub fn into_json(self) -> serde_json::Value {
serde_json::to_value(self).expect("ToolEnvelope is always JSON-serializable")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn omits_absent_fields_keeps_sandbox_kind() {
let v = ToolEnvelope::new(SandboxKind::None)
.with_exit_code(0)
.with_stdout("hi\n")
.into_json();
assert_eq!(v["exit_code"], 0);
assert_eq!(v["stdout"], "hi\n");
assert!(v.get("stderr").is_none());
assert!(v.get("timed_out").is_none());
assert_eq!(v["sandbox_kind"], "none");
}
#[test]
fn no_denials_omits_denied_and_denials() {
let v = ToolEnvelope::new(SandboxKind::None)
.with_exit_code(0)
.with_denials(Vec::new())
.into_json();
assert!(v.get("denied").is_none(), "denied must be omitted: {v}");
assert!(v.get("denials").is_none(), "denials must be omitted: {v}");
}
#[test]
fn recorded_denials_set_denied_true_and_list() {
let denials = vec![Denial {
kind: DenialKind::Exec,
target: "rm".to_string(),
reason: "exec of \"rm\" is not within the granted authority".to_string(),
}];
let v = ToolEnvelope::new(SandboxKind::None)
.with_exit_code(126)
.with_denials(denials)
.into_json();
assert_eq!(v["denied"], true);
assert_eq!(v["denials"][0]["kind"], "exec");
assert_eq!(v["denials"][0]["target"], "rm");
assert!(v["denials"][0]["reason"]
.as_str()
.unwrap()
.contains("not within the granted"));
}
#[test]
fn enforcement_report_is_threaded_and_omitted_when_empty() {
use crate::{enforcement_report, AxisEnforcement, Caveats, Scope};
let caveats = Caveats {
fs_write: Scope::only(["/w".to_string()]),
..Caveats::top()
};
let report = enforcement_report(&caveats, SandboxKind::Landlock);
assert_eq!(report.fs_write, Some(AxisEnforcement::Kernel));
let v = ToolEnvelope::new(SandboxKind::Landlock)
.with_enforcement(report)
.with_exit_code(0)
.into_json();
assert_eq!(v["enforcement"]["fs_write"], "kernel");
assert!(
v["enforcement"].get("exec").is_none(),
"unrestricted axis omitted"
);
let empty = enforcement_report(&Caveats::top(), SandboxKind::None);
let v2 = ToolEnvelope::new(SandboxKind::None)
.with_enforcement(empty)
.into_json();
assert!(
v2.get("enforcement").is_none(),
"empty report omitted: {v2}"
);
}
#[test]
fn disclosure_is_quiet_by_default_and_omitted() {
let v = ToolEnvelope::new(SandboxKind::Landlock)
.with_exit_code(0)
.into_json();
assert!(
v.get("disclosure").is_none(),
"a quiet disclosure must be omitted: {v}"
);
assert!(Disclosure::default().is_quiet());
}
#[test]
fn unbridled_disclosure_always_surfaces() {
let v = ToolEnvelope::new(SandboxKind::None)
.with_disclosure(Disclosure {
unbridled: true,
..Disclosure::default()
})
.into_json();
assert_eq!(
v["disclosure"]["unbridled"], true,
"an unbridled run must never be quietly hidden: {v}"
);
}
#[test]
fn disclosure_fields_surface_when_set() {
let v = ToolEnvelope::new(SandboxKind::Seatbelt)
.with_disclosure(Disclosure {
normalizations_disabled: vec!["ldd_closure".to_string()],
net_over_delivery: true,
backend_forced: Some("seatbelt".to_string()),
..Disclosure::default()
})
.into_json();
assert_eq!(v["disclosure"]["normalizations_disabled"][0], "ldd_closure");
assert_eq!(v["disclosure"]["net_over_delivery"], true);
assert_eq!(v["disclosure"]["backend_forced"], "seatbelt");
assert!(v["disclosure"].get("unbridled").is_none());
}
#[test]
fn disclosure_human_gate_distinguishes_postures() {
let sf = ToolEnvelope::new(SandboxKind::None)
.with_disclosure(Disclosure {
unbridled: true,
human_gate: HumanGate::Passkey,
..Disclosure::default()
})
.into_json();
assert_eq!(sf["disclosure"]["human_gate"], "passkey");
let auto = ToolEnvelope::new(SandboxKind::None)
.with_disclosure(Disclosure {
unbridled: true,
human_gate: HumanGate::None,
..Disclosure::default()
})
.into_json();
assert_eq!(auto["disclosure"]["human_gate"], "none");
}
#[test]
fn disclosure_never_affects_the_enforcement_claim() {
use crate::{enforcement_report, Caveats, Scope};
let caveats = Caveats {
fs_write: Scope::only(["/w".to_string()]),
..Caveats::top()
};
let report = enforcement_report(&caveats, SandboxKind::Landlock);
let bare = ToolEnvelope::new(SandboxKind::Landlock).with_enforcement(report);
let disclosed = ToolEnvelope::new(SandboxKind::Landlock)
.with_enforcement(report)
.with_disclosure(Disclosure {
unbridled: true,
net_over_delivery: true,
..Disclosure::default()
});
assert_eq!(bare.sandbox_kind, disclosed.sandbox_kind);
assert_eq!(bare.enforcement, disclosed.enforcement);
let (bv, dv) = (bare.into_json(), disclosed.into_json());
assert_eq!(bv["sandbox_kind"], dv["sandbox_kind"]);
assert_eq!(bv["enforcement"], dv["enforcement"]);
}
#[test]
fn denial_kind_serializes_snake_case() {
assert_eq!(
serde_json::to_value(DenialKind::Exec).unwrap(),
serde_json::json!("exec")
);
assert_eq!(
serde_json::to_value(DenialKind::Open).unwrap(),
serde_json::json!("open")
);
assert_eq!(
serde_json::to_value(DenialKind::Net).unwrap(),
serde_json::json!("net")
);
assert_eq!(
serde_json::from_value::<DenialKind>(serde_json::json!("net")).unwrap(),
DenialKind::Net
);
}
}