use crate::SandboxKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DenialKind {
Exec,
Open,
}
#[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, 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 denied: bool,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub denials: Vec<Denial>,
pub sandbox_kind: SandboxKind,
}
#[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_denials(mut self, denials: Vec<Denial>) -> Self {
self.denied = !denials.is_empty();
self.denials = denials;
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 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")
);
}
}