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}