Skip to main content

agent_bridle_core/
envelope.rs

1//! The result envelope — the MCP-shaped JSON a tool returns.
2//!
3//! Tools that run a subprocess-like operation (the shell, exec-style tools)
4//! return a uniform shape so a frontend can render them identically and so the
5//! recorded [`crate::SandboxKind`] travels with every result (DESIGN §6: "the
6//! Gate records `sandbox_kind` in **every** `ToolResult`").
7
8use crate::{EnforcementReport, HumanGate, SandboxKind};
9
10/// Which kind of capability operation the leash refused.
11///
12/// Mirrors the brush `CommandInterceptor` hooks: an `exec` denial comes from
13/// `before_exec` (an out-of-scope program, including a path-separator-spelled
14/// one like `/bin/rm`), an `open` denial from `before_open` (a redirection or
15/// `source` target outside `fs_read`/`fs_write`). A `net` denial comes from the
16/// loopback egress proxy refusing an out-of-allow-list host (#196) — the one
17/// axis observed *during* the run rather than at pre-spawn admission.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum DenialKind {
21    /// An external command spawn was denied (`before_exec`).
22    Exec,
23    /// A file open (redirection/`source`) was denied (`before_open`).
24    Open,
25    /// A network egress to an out-of-allow-list host was refused by the egress
26    /// proxy (#196). The [`Denial::target`] is the CONNECT host, so a consumer
27    /// can prompt per-host. Only observable where a proxy is in the data path
28    /// (a non-empty `net` allow-list); a pure deny-all is kernel-fenced and
29    /// surfaces no host.
30    Net,
31}
32
33/// One structured leash denial recorded by the in-process interceptor.
34///
35/// This is the **structured security signal** that replaces stderr
36/// string-matching: a denial is present here *only* when the interceptor
37/// actually decided `Deny`, never merely because a permitted command exited
38/// non-zero on its own.
39#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
40pub struct Denial {
41    /// Whether an `exec` (spawn), an `open` (file), or a `net` egress was refused.
42    pub kind: DenialKind,
43    /// The exact target the interceptor saw: the program (e.g. `rm`,
44    /// `/bin/rm`) for an `exec` denial, the path for an `open` denial, or the
45    /// CONNECT host (e.g. `github.com`) for a `net` denial (#196).
46    pub target: String,
47    /// The human-readable reason from the leash (safe to surface to an agent).
48    pub reason: String,
49}
50
51/// Operator-facing **disclosure** — what an operator *should know* about how this
52/// run was shaped, kept **strictly separate** from the [`EnforcementReport`]
53/// (ADR 0016 precedent / ADR 0017 D6). Disclosure is informational: it records
54/// over-delivery, disabled normalizations, a forced backend, and the loud
55/// `unbridled` opt-in. It **never** participates in [`crate::fence_strength`] or
56/// the enforcement claim — a run can never *raise* its confinement claim by
57/// disclosing something, and disclosing something can never *lower* it either.
58///
59/// Quiet by default: the whole block is omitted from JSON when nothing is worth
60/// disclosing (the common bridled path). The one field that always surfaces when
61/// set is [`Self::unbridled`] — an unbridled run is never quietly hidden.
62#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63pub struct Disclosure {
64    /// The run was explicitly **unbridled** (confinement off — `Caveats::top()` +
65    /// advisory floor + `SandboxKind::None`), an acknowledged operator opt-in
66    /// (#151/I12). Always emitted when `true`; never reachable by omission.
67    #[serde(skip_serializing_if = "is_false")]
68    pub unbridled: bool,
69    /// Automatic normalizations the operator turned off, by name (e.g.
70    /// `ldd_closure`, `nss_closure_fallback`) — so a degraded run is legible.
71    #[serde(skip_serializing_if = "Vec::is_empty", default)]
72    pub normalizations_disabled: Vec<String>,
73    /// A restricted `net` allow-list is enforced **above** the reported floor —
74    /// the loopback egress proxy admits exactly the granted hosts while the report
75    /// honestly keeps the axis `advisory` (proxy-, not kernel-, enforced; #124/#128,
76    /// ADR 0016). Discloses the over-delivery without raising the claim.
77    #[serde(skip_serializing_if = "is_false")]
78    pub net_over_delivery: bool,
79    /// A sandbox backend was overridden from the default selection (downgrade /
80    /// select-available only; #149/I10). Names the backend actually applied.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub backend_forced: Option<String>,
83    /// Which shell **engine** ran this operation (ADR 0019 D4 / #194) — e.g.
84    /// `"safe-subset"` or `"sandbox-host"`. Lets an embedder log which engine a
85    /// dispatch used when more than one is registered. `None` when unset.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub engine: Option<String>,
88    /// The human step-up gate still in force (ADR 0018 D11 / R5). Distinguishes an
89    /// unbridled run's two postures — `passkey`/`prompt` = *Supervised-free* (a
90    /// gesture still gates HIGH-consequence acts), `none` = *Autonomous* (no human
91    /// in the loop). Shown whenever the block is emitted (i.e. when unbridled), so
92    /// a consumer can never confuse "free but FIDO-gated" with "no human at all".
93    pub human_gate: HumanGate,
94}
95
96impl Disclosure {
97    /// `true` when there is nothing to disclose — the whole block is then omitted
98    /// from JSON. (An `unbridled` run is *not* quiet, so it always surfaces.)
99    #[must_use]
100    pub fn is_quiet(&self) -> bool {
101        !self.unbridled
102            && self.normalizations_disabled.is_empty()
103            && !self.net_over_delivery
104            && self.backend_forced.is_none()
105            && self.engine.is_none()
106    }
107}
108
109/// A structured execution result. Serialized to the MCP content shape via
110/// [`ToolEnvelope::into_json`]; absent fields are omitted.
111#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
112pub struct ToolEnvelope {
113    /// Process exit code, when the operation had one.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub exit_code: Option<i32>,
116    /// Captured standard output, when relevant.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub stdout: Option<String>,
119    /// Captured standard error, when relevant.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub stderr: Option<String>,
122    /// Whether the operation was cut short by a timeout.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub timed_out: Option<bool>,
125    /// Whether captured stdout was clipped at the output cap (more was produced
126    /// than was kept). Lets a consumer tell a complete result from a truncated
127    /// one. Omitted (treated as `false`) when output was not clipped.
128    #[serde(skip_serializing_if = "is_false")]
129    pub stdout_truncated: bool,
130    /// Whether captured stderr was clipped at the output cap. Omitted when not.
131    #[serde(skip_serializing_if = "is_false")]
132    pub stderr_truncated: bool,
133    /// Whether the in-process leash recorded at least one denial during this
134    /// invocation. This is a **structured** signal: it is set iff
135    /// [`Self::denials`] is non-empty, so a consumer never has to string-match
136    /// stderr to detect a security refusal. Omitted (treated as `false`) when
137    /// no denial was recorded.
138    #[serde(skip_serializing_if = "is_false")]
139    pub denied: bool,
140    /// The denials the interceptor recorded, in the order they occurred. Empty
141    /// (and omitted from JSON) unless [`Self::denied`] is `true`.
142    #[serde(skip_serializing_if = "Vec::is_empty", default)]
143    pub denials: Vec<Denial>,
144    /// The OS-level sandbox in force when this ran. Always present so callers
145    /// can tell whether the leash was kernel-enforced or advisory.
146    pub sandbox_kind: SandboxKind,
147    /// Per-axis confinement report (ADR 0004 D1): for each **restricted** axis,
148    /// whether it is `kernel` / `interceptor` / `advisory`. Refines the coarse
149    /// `sandbox_kind` (which stays the *minimum* claim) at axis grain. Omitted
150    /// from JSON when no axis is restricted.
151    #[serde(skip_serializing_if = "EnforcementReport::is_empty", default)]
152    pub enforcement: EnforcementReport,
153    /// Operator-facing disclosure (ADR 0017 D6) — informational only, **never**
154    /// part of the enforcement claim. Quiet by default (omitted when nothing is
155    /// worth disclosing); an `unbridled` run always surfaces here.
156    #[serde(skip_serializing_if = "Disclosure::is_quiet", default)]
157    pub disclosure: Disclosure,
158}
159
160/// `skip_serializing_if` helper: omit `denied` from JSON when it is `false`.
161#[allow(clippy::trivially_copy_pass_by_ref)]
162fn is_false(b: &bool) -> bool {
163    !*b
164}
165
166impl ToolEnvelope {
167    /// An envelope stamped with the sandbox kind and nothing else set.
168    #[must_use]
169    pub fn new(sandbox_kind: SandboxKind) -> Self {
170        Self {
171            sandbox_kind,
172            ..Self::default()
173        }
174    }
175
176    /// Set the exit code (builder style).
177    #[must_use]
178    pub fn with_exit_code(mut self, code: i32) -> Self {
179        self.exit_code = Some(code);
180        self
181    }
182
183    /// Set captured stdout (builder style).
184    #[must_use]
185    pub fn with_stdout(mut self, stdout: impl Into<String>) -> Self {
186        self.stdout = Some(stdout.into());
187        self
188    }
189
190    /// Set captured stderr (builder style).
191    #[must_use]
192    pub fn with_stderr(mut self, stderr: impl Into<String>) -> Self {
193        self.stderr = Some(stderr.into());
194        self
195    }
196
197    /// Mark whether the operation timed out (builder style).
198    #[must_use]
199    pub fn with_timed_out(mut self, timed_out: bool) -> Self {
200        self.timed_out = Some(timed_out);
201        self
202    }
203
204    /// Mark whether captured stdout/stderr were clipped at the cap (builder
205    /// style). A truncated stream is a *bounded* read: peak buffering never
206    /// exceeds the cap regardless of how much the child produced.
207    #[must_use]
208    pub fn with_truncation(mut self, stdout_truncated: bool, stderr_truncated: bool) -> Self {
209        self.stdout_truncated = stdout_truncated;
210        self.stderr_truncated = stderr_truncated;
211        self
212    }
213
214    /// Attach the leash denials the interceptor recorded (builder style).
215    ///
216    /// [`Self::denied`] is set to `true` iff `denials` is non-empty, so the
217    /// boolean flag and the list can never disagree. Passing an empty vec is a
218    /// no-op (the result stays un-denied), which keeps the common
219    /// nothing-was-denied path clean.
220    #[must_use]
221    pub fn with_denials(mut self, denials: Vec<Denial>) -> Self {
222        self.denied = !denials.is_empty();
223        self.denials = denials;
224        self
225    }
226
227    /// Attach the per-axis confinement report (builder style; ADR 0004 D1).
228    #[must_use]
229    pub fn with_enforcement(mut self, enforcement: EnforcementReport) -> Self {
230        self.enforcement = enforcement;
231        self
232    }
233
234    /// Attach the operator-facing disclosure (builder style; ADR 0017 D6).
235    /// Purely informational — it does not affect [`Self::enforcement`],
236    /// [`Self::sandbox_kind`], or any confinement claim.
237    #[must_use]
238    pub fn with_disclosure(mut self, disclosure: Disclosure) -> Self {
239        self.disclosure = disclosure;
240        self
241    }
242
243    /// Serialize to the JSON content shape tools return.
244    ///
245    /// # Panics
246    /// Never in practice: the envelope contains only JSON-representable scalars.
247    #[must_use]
248    pub fn into_json(self) -> serde_json::Value {
249        serde_json::to_value(self).expect("ToolEnvelope is always JSON-serializable")
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn omits_absent_fields_keeps_sandbox_kind() {
259        let v = ToolEnvelope::new(SandboxKind::None)
260            .with_exit_code(0)
261            .with_stdout("hi\n")
262            .into_json();
263        assert_eq!(v["exit_code"], 0);
264        assert_eq!(v["stdout"], "hi\n");
265        assert!(v.get("stderr").is_none());
266        assert!(v.get("timed_out").is_none());
267        assert_eq!(v["sandbox_kind"], "none");
268    }
269
270    #[test]
271    fn no_denials_omits_denied_and_denials() {
272        // The common case: nothing was denied → neither structured field
273        // appears in the JSON, so `denied` defaults to false for consumers.
274        let v = ToolEnvelope::new(SandboxKind::None)
275            .with_exit_code(0)
276            .with_denials(Vec::new())
277            .into_json();
278        assert!(v.get("denied").is_none(), "denied must be omitted: {v}");
279        assert!(v.get("denials").is_none(), "denials must be omitted: {v}");
280    }
281
282    #[test]
283    fn recorded_denials_set_denied_true_and_list() {
284        let denials = vec![Denial {
285            kind: DenialKind::Exec,
286            target: "rm".to_string(),
287            reason: "exec of \"rm\" is not within the granted authority".to_string(),
288        }];
289        let v = ToolEnvelope::new(SandboxKind::None)
290            .with_exit_code(126)
291            .with_denials(denials)
292            .into_json();
293        assert_eq!(v["denied"], true);
294        assert_eq!(v["denials"][0]["kind"], "exec");
295        assert_eq!(v["denials"][0]["target"], "rm");
296        assert!(v["denials"][0]["reason"]
297            .as_str()
298            .unwrap()
299            .contains("not within the granted"));
300    }
301
302    #[test]
303    fn enforcement_report_is_threaded_and_omitted_when_empty() {
304        use crate::{enforcement_report, AxisEnforcement, Caveats, Scope};
305        // Restricted fs_write under Landlock → the envelope carries a kernel claim.
306        let caveats = Caveats {
307            fs_write: Scope::only(["/w".to_string()]),
308            ..Caveats::top()
309        };
310        let report = enforcement_report(&caveats, SandboxKind::Landlock);
311        assert_eq!(report.fs_write, Some(AxisEnforcement::Kernel));
312        let v = ToolEnvelope::new(SandboxKind::Landlock)
313            .with_enforcement(report)
314            .with_exit_code(0)
315            .into_json();
316        assert_eq!(v["enforcement"]["fs_write"], "kernel");
317        assert!(
318            v["enforcement"].get("exec").is_none(),
319            "unrestricted axis omitted"
320        );
321
322        // An all-`All` grant produces an empty report → the field is omitted.
323        let empty = enforcement_report(&Caveats::top(), SandboxKind::None);
324        let v2 = ToolEnvelope::new(SandboxKind::None)
325            .with_enforcement(empty)
326            .into_json();
327        assert!(
328            v2.get("enforcement").is_none(),
329            "empty report omitted: {v2}"
330        );
331    }
332
333    #[test]
334    fn disclosure_is_quiet_by_default_and_omitted() {
335        // The common bridled path discloses nothing → the block is absent.
336        let v = ToolEnvelope::new(SandboxKind::Landlock)
337            .with_exit_code(0)
338            .into_json();
339        assert!(
340            v.get("disclosure").is_none(),
341            "a quiet disclosure must be omitted: {v}"
342        );
343        assert!(Disclosure::default().is_quiet());
344    }
345
346    #[test]
347    fn unbridled_disclosure_always_surfaces() {
348        let v = ToolEnvelope::new(SandboxKind::None)
349            .with_disclosure(Disclosure {
350                unbridled: true,
351                ..Disclosure::default()
352            })
353            .into_json();
354        assert_eq!(
355            v["disclosure"]["unbridled"], true,
356            "an unbridled run must never be quietly hidden: {v}"
357        );
358    }
359
360    #[test]
361    fn disclosure_fields_surface_when_set() {
362        let v = ToolEnvelope::new(SandboxKind::Seatbelt)
363            .with_disclosure(Disclosure {
364                normalizations_disabled: vec!["ldd_closure".to_string()],
365                net_over_delivery: true,
366                backend_forced: Some("seatbelt".to_string()),
367                ..Disclosure::default()
368            })
369            .into_json();
370        assert_eq!(v["disclosure"]["normalizations_disabled"][0], "ldd_closure");
371        assert_eq!(v["disclosure"]["net_over_delivery"], true);
372        assert_eq!(v["disclosure"]["backend_forced"], "seatbelt");
373        // A quiet sub-field (unbridled=false) stays omitted within the block.
374        assert!(v["disclosure"].get("unbridled").is_none());
375    }
376
377    #[test]
378    fn disclosure_human_gate_distinguishes_postures() {
379        // Supervised-free: unbridled but the passkey gate remains.
380        let sf = ToolEnvelope::new(SandboxKind::None)
381            .with_disclosure(Disclosure {
382                unbridled: true,
383                human_gate: HumanGate::Passkey,
384                ..Disclosure::default()
385            })
386            .into_json();
387        assert_eq!(sf["disclosure"]["human_gate"], "passkey");
388        // Autonomous: unbridled AND no human in the loop — must be distinguishable.
389        let auto = ToolEnvelope::new(SandboxKind::None)
390            .with_disclosure(Disclosure {
391                unbridled: true,
392                human_gate: HumanGate::None,
393                ..Disclosure::default()
394            })
395            .into_json();
396        assert_eq!(auto["disclosure"]["human_gate"], "none");
397    }
398
399    #[test]
400    fn disclosure_never_affects_the_enforcement_claim() {
401        use crate::{enforcement_report, Caveats, Scope};
402        // The honesty invariant (ADR 0017 D6): disclosure is informational — it
403        // cannot change the sandbox_kind or the enforcement report.
404        let caveats = Caveats {
405            fs_write: Scope::only(["/w".to_string()]),
406            ..Caveats::top()
407        };
408        let report = enforcement_report(&caveats, SandboxKind::Landlock);
409        let bare = ToolEnvelope::new(SandboxKind::Landlock).with_enforcement(report);
410        let disclosed = ToolEnvelope::new(SandboxKind::Landlock)
411            .with_enforcement(report)
412            .with_disclosure(Disclosure {
413                unbridled: true,
414                net_over_delivery: true,
415                ..Disclosure::default()
416            });
417        assert_eq!(bare.sandbox_kind, disclosed.sandbox_kind);
418        assert_eq!(bare.enforcement, disclosed.enforcement);
419        let (bv, dv) = (bare.into_json(), disclosed.into_json());
420        assert_eq!(bv["sandbox_kind"], dv["sandbox_kind"]);
421        assert_eq!(bv["enforcement"], dv["enforcement"]);
422    }
423
424    #[test]
425    fn denial_kind_serializes_snake_case() {
426        assert_eq!(
427            serde_json::to_value(DenialKind::Exec).unwrap(),
428            serde_json::json!("exec")
429        );
430        assert_eq!(
431            serde_json::to_value(DenialKind::Open).unwrap(),
432            serde_json::json!("open")
433        );
434        // #196: the net axis serializes as "net" — the exact string a downstream
435        // consumer (newt) matches to lift it into a per-host prompt.
436        assert_eq!(
437            serde_json::to_value(DenialKind::Net).unwrap(),
438            serde_json::json!("net")
439        );
440        assert_eq!(
441            serde_json::from_value::<DenialKind>(serde_json::json!("net")).unwrap(),
442            DenialKind::Net
443        );
444    }
445}