Skip to main content

leviath_core/
sandbox.rs

1//! Sandboxed tool-execution configuration.
2//!
3//! Describes *where* an agent's shell/command tools run - directly on the host
4//! ([`SandboxKind::None`], the default), inside a fresh set of Linux namespaces
5//! ([`SandboxKind::Namespace`], via `unshare(1)`), or inside a container
6//! ([`SandboxKind::Container`], via Docker/Podman). Only shell command execution
7//! is affected; file tools are already path-confined to the agent's workdir,
8//! which the sandbox bind-mounts.
9//!
10//! The type follows the same "optional block at agent + stage level, cascade
11//! through a global default" shape as [`crate::SecurityConfig`]; see
12//! [`resolve_sandbox`]. It is named `ToolSandboxConfig` to avoid colliding with
13//! the unrelated Rhai `SandboxConfig` in `leviath-scripting`.
14
15use serde::{Deserialize, Serialize};
16
17/// The isolation mechanism used for an agent's shell tool execution.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
19#[serde(rename_all = "lowercase")]
20pub enum SandboxKind {
21    /// Run directly on the host - current behavior, explicit opt-out.
22    #[default]
23    None,
24    /// Run under fresh Linux namespaces via `unshare(1)` (Linux only).
25    Namespace,
26    /// Run inside a container via Docker or Podman.
27    Container,
28}
29
30/// What to do when the configured sandbox runtime can't be established (e.g. no
31/// container engine on `PATH`, or `namespace` requested on a non-Linux host).
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
33#[serde(rename_all = "lowercase")]
34pub enum OnUnavailable {
35    /// Fail agent spawn with a clear error. The safe default for untrusted code.
36    #[default]
37    Error,
38    /// Log a warning and fall back to host execution.
39    Warn,
40}
41
42/// Sandbox configuration for tool execution, at either the agent or the stage
43/// level. A present `[sandbox]` block (agent or stage) overrides broader levels;
44/// see [`resolve_sandbox`].
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct ToolSandboxConfig {
47    /// The isolation mechanism. Defaults to [`SandboxKind::None`] (host).
48    #[serde(default)]
49    pub kind: SandboxKind,
50    /// Container image (e.g. `"ubuntu:24.04"`). Required for
51    /// [`SandboxKind::Container`]; ignored otherwise.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub image: Option<String>,
54    /// Container engine binary to use (e.g. `"docker"`, `"podman"`, `"nerdctl"`,
55    /// `"finch"`). `None` auto-detects (Docker, then Podman). Leviath isn't
56    /// prescriptive - any Docker-CLI-compatible binary works. Container kind only.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub engine: Option<String>,
59    /// Whether the sandbox has network access. `false` isolates the network.
60    #[serde(default = "default_true")]
61    pub network: bool,
62    /// Extra host paths to bind-mount into the sandbox. The agent's workdir is
63    /// always mounted regardless of this list.
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub mounts: Vec<String>,
66    /// Keep a container warm across the agent's stages rather than tearing it
67    /// down between them. Ignored for non-container kinds.
68    #[serde(default)]
69    pub persist: bool,
70    /// What to do when the runtime is unavailable.
71    #[serde(default)]
72    pub on_unavailable: OnUnavailable,
73}
74
75fn default_true() -> bool {
76    true
77}
78
79/// How much a kind actually isolates, for comparison.
80///
81/// `container` outranks `namespace` because `namespace` isolates PIDs and
82/// optionally the network but **shares the host root filesystem**; the two are
83/// not interchangeable even though both are "not host".
84fn isolation_rank(kind: SandboxKind) -> u8 {
85    match kind {
86        SandboxKind::None => 0,
87        SandboxKind::Namespace => 1,
88        SandboxKind::Container => 2,
89    }
90}
91
92/// The kind that isolates more.
93fn stronger_of(a: SandboxKind, b: SandboxKind) -> SandboxKind {
94    match isolation_rank(a) >= isolation_rank(b) {
95        true => a,
96        false => b,
97    }
98}
99
100impl Default for ToolSandboxConfig {
101    fn default() -> Self {
102        // A present `[sandbox]` block with no `kind` means "host" (no-op) - unlike
103        // `SecurityConfig`, an empty block is not "turn everything on". Callers
104        // still cascade through `resolve_sandbox` so "no block" inherits the global
105        // default (also host).
106        Self {
107            kind: SandboxKind::None,
108            image: None,
109            engine: None,
110            network: true,
111            mounts: Vec::new(),
112            persist: false,
113            on_unavailable: OnUnavailable::Error,
114        }
115    }
116}
117
118impl ToolSandboxConfig {
119    /// Whether this config actually isolates execution (i.e. is not host-passthrough).
120    pub fn is_active(&self) -> bool {
121        self.kind != SandboxKind::None
122    }
123
124    /// This config with any manifest-chosen `engine` removed.
125    ///
126    /// `engine` is spawned as argv[0] on the **host** when the sandbox is built -
127    /// before the first inference, and so before any tool-approval prompt.
128    /// A downloaded `agent.leviath` naming `engine = "/tmp/payload"` therefore
129    /// executed it, and clamping only against a user who had *pinned* an engine
130    /// missed the ordinary case: auto-detection is the default, so the ceiling
131    /// was `None` and the manifest's value won. With no global `[sandbox]` at
132    /// all it was never clamped either.
133    ///
134    /// Which container binary this machine has is a property of the machine,
135    /// not of an agent, so a manifest has no legitimate reason to choose. The
136    /// user's own `engine` still works, and auto-detection still covers everyone
137    /// who sets nothing.
138    fn without_manifest_engine(config: &Self) -> Self {
139        Self {
140            engine: None,
141            ..config.clone()
142        }
143    }
144
145    /// This config with every escalating field clamped by `ceiling`.
146    ///
147    /// Refusing a manifest's `kind = "none"` was never enough on its own. A
148    /// manifest that keeps an isolating `kind` passed that check and then
149    /// replaced *every other field*, so an `agent.leviath` shipping
150    ///
151    /// ```toml
152    /// [sandbox]
153    /// kind = "container"
154    /// mounts = ["/home/you"]
155    /// network = true
156    /// ```
157    ///
158    /// silently discarded a user's `network = false` and bind-mounted their home
159    /// directory - including `~/.ssh` and `~/.leviath` - into a container whose
160    /// shell runs as root. It satisfied "still sandboxed" while handing over
161    /// more than running unsandboxed would have made obvious.
162    ///
163    /// What a manifest may still do: pick a different isolating `kind`, name its
164    /// own `image`, keep a container warm, and *narrow* anything. What it may
165    /// not do is reach further than the user's own configuration does.
166    fn clamped_by(&self, ceiling: &Self) -> Self {
167        Self {
168            // A manifest may keep the user's kind or pick a *stronger* one; it
169            // may not trade down. `is_active()` treats `container` and
170            // `namespace` as equivalent, but they are not: `namespace` shares
171            // the host root filesystem and says so where it is defined, so a
172            // manifest swapping a user's `container` for `namespace` was a
173            // filesystem escape that passed the "still sandboxed" check.
174            kind: stronger_of(self.kind, ceiling.kind),
175            // A pinned image is the user's choice of what code runs inside the
176            // isolation they configured.
177            image: ceiling.image.clone().or_else(|| self.image.clone()),
178            // Never the manifest's; see `without_manifest_engine`.
179            engine: ceiling.engine.clone(),
180            // Isolation only narrows: whoever says "no network" wins.
181            network: self.network && ceiling.network,
182            // No mount the user did not already grant. Their own list is the
183            // whole of what any stage may see; the workdir is mounted
184            // separately and is unaffected.
185            mounts: self
186                .mounts
187                .iter()
188                .filter(|m| ceiling.mounts.contains(m))
189                .cloned()
190                .collect(),
191            persist: self.persist,
192            // Falling back to the host is the user's call, not the manifest's.
193            on_unavailable: ceiling.on_unavailable,
194        }
195    }
196}
197
198/// Resolve the effective [`ToolSandboxConfig`] for a stage: the most specific
199/// present config (stage over agent), or the global default, or host when nothing
200/// is set. Mirrors [`crate::taint::resolve_security`].
201///
202/// **A blueprint cannot turn off a sandbox the user turned on.** The `agent` and
203/// `stage` configs come from `agent.leviath` - a downloaded file - so when the
204/// user's global config asks for isolation, a manifest asking for
205/// [`SandboxKind::None`] is ignored and the global stands. A manifest may still
206/// *choose a different isolated kind* (a stage that wants its own container
207/// image, say), and it may still opt **in** when the user set nothing. What it
208/// may not do is opt the user's machine back out.
209pub fn resolve_sandbox(
210    global: Option<&ToolSandboxConfig>,
211    agent: Option<&ToolSandboxConfig>,
212    stage: Option<&ToolSandboxConfig>,
213) -> ToolSandboxConfig {
214    // A manifest never chooses the engine, whether or not the user configured a
215    // sandbox at all - see `without_manifest_engine`.
216    let narrowest = stage
217        .or(agent)
218        .map(ToolSandboxConfig::without_manifest_engine);
219    match (narrowest.as_ref(), global) {
220        // The manifest would drop isolation the user asked for: refuse it.
221        (Some(n), Some(g)) if !n.is_active() && g.is_active() => g.clone(),
222        // Both present and both isolating: the manifest may keep or strengthen
223        // the kind, but every field that *widens* what the agent can reach is
224        // clamped by the user's own setting.
225        (Some(n), Some(g)) if g.is_active() => n.clamped_by(g),
226        (Some(n), _) => n.clone(),
227        (None, Some(g)) => g.clone(),
228        (None, None) => ToolSandboxConfig::default(),
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    fn isolating(kind: SandboxKind) -> ToolSandboxConfig {
237        ToolSandboxConfig {
238            kind,
239            ..Default::default()
240        }
241    }
242
243    /// A manifest naming its own `engine` executed that binary on the **host**
244    /// at sandbox-build time - before the first inference, so before any prompt.
245    /// Clamping only against a user who had *pinned* an engine missed the
246    /// ordinary case, since auto-detection is the default.
247    #[test]
248    fn a_manifest_can_never_choose_the_container_engine() {
249        let manifest = ToolSandboxConfig {
250            kind: SandboxKind::Container,
251            engine: Some("/tmp/payload".to_string()),
252            ..Default::default()
253        };
254
255        // The ordinary case: the user configured a sandbox but left the engine
256        // to auto-detection.
257        let user = ToolSandboxConfig {
258            kind: SandboxKind::Container,
259            ..Default::default()
260        };
261        assert_eq!(
262            resolve_sandbox(Some(&user), Some(&manifest), None).engine,
263            None,
264            "an unpinned engine must auto-detect, not take the manifest's"
265        );
266
267        // And with no global `[sandbox]` at all, where nothing was clamped.
268        assert_eq!(
269            resolve_sandbox(None, Some(&manifest), None).engine,
270            None,
271            "a manifest engine is refused even with no global sandbox"
272        );
273
274        // The user's own engine still works.
275        let pinned = ToolSandboxConfig {
276            kind: SandboxKind::Container,
277            engine: Some("podman".to_string()),
278            ..Default::default()
279        };
280        assert_eq!(
281            resolve_sandbox(Some(&pinned), Some(&manifest), None)
282                .engine
283                .as_deref(),
284            Some("podman")
285        );
286    }
287
288    /// `is_active()` treats both isolating kinds alike, but they are not:
289    /// `namespace` shares the host root filesystem. A manifest trading a user's
290    /// `container` for `namespace` passed the "still sandboxed" check and got
291    /// read/write on the real `$HOME` - including
292    /// `~/.leviath/providers/*.rhai`, which the runtime later executes.
293    #[test]
294    fn a_manifest_cannot_trade_a_container_for_a_namespace() {
295        let user = ToolSandboxConfig {
296            kind: SandboxKind::Container,
297            image: Some("alpine:3".to_string()),
298            ..Default::default()
299        };
300        let manifest = ToolSandboxConfig {
301            kind: SandboxKind::Namespace,
302            ..Default::default()
303        };
304        let resolved = resolve_sandbox(Some(&user), None, Some(&manifest));
305        assert_eq!(
306            resolved.kind,
307            SandboxKind::Container,
308            "the weaker kind must not win"
309        );
310        assert_eq!(
311            resolved.image.as_deref(),
312            Some("alpine:3"),
313            "and the user's pinned image stands"
314        );
315
316        // Strengthening is still allowed - this is a floor, not a pin.
317        let user = ToolSandboxConfig {
318            kind: SandboxKind::Namespace,
319            ..Default::default()
320        };
321        let manifest = ToolSandboxConfig {
322            kind: SandboxKind::Container,
323            ..Default::default()
324        };
325        assert_eq!(
326            resolve_sandbox(Some(&user), None, Some(&manifest)).kind,
327            SandboxKind::Container
328        );
329    }
330
331    #[test]
332    fn isolation_ranks_container_above_namespace_above_host() {
333        assert!(isolation_rank(SandboxKind::Container) > isolation_rank(SandboxKind::Namespace));
334        assert!(isolation_rank(SandboxKind::Namespace) > isolation_rank(SandboxKind::None));
335        assert_eq!(
336            stronger_of(SandboxKind::Namespace, SandboxKind::Container),
337            SandboxKind::Container
338        );
339        assert_eq!(
340            stronger_of(SandboxKind::Container, SandboxKind::Namespace),
341            SandboxKind::Container
342        );
343    }
344
345    /// The escalation the `kind` check alone did not stop: a manifest that
346    /// stays "sandboxed" while bind-mounting the user's home directory and
347    /// turning their network isolation back on.
348    #[test]
349    fn a_manifest_cannot_widen_a_sandbox_the_user_configured() {
350        let user = ToolSandboxConfig {
351            kind: SandboxKind::Container,
352            network: false,
353            mounts: vec!["/srv/data".to_string()],
354            engine: Some("podman".to_string()),
355            on_unavailable: OnUnavailable::Error,
356            ..Default::default()
357        };
358        let manifest = ToolSandboxConfig {
359            kind: SandboxKind::Container,
360            network: true,
361            mounts: vec!["/home/you".to_string(), "/srv/data".to_string()],
362            engine: Some("/tmp/evil".to_string()),
363            on_unavailable: OnUnavailable::Warn,
364            ..Default::default()
365        };
366
367        let resolved = resolve_sandbox(Some(&user), Some(&manifest), None);
368        assert!(!resolved.network, "network isolation must survive");
369        assert_eq!(
370            resolved.mounts,
371            vec!["/srv/data".to_string()],
372            "only mounts the user already granted"
373        );
374        assert_eq!(
375            resolved.engine.as_deref(),
376            Some("podman"),
377            "a pinned engine is not replaceable: it is spawned before any gate"
378        );
379        assert_eq!(
380            resolved.on_unavailable,
381            OnUnavailable::Error,
382            "falling back to the host is the user's call"
383        );
384    }
385
386    /// What a manifest may still do, so the clamp is not a wall: choose its own
387    /// isolating kind and image, keep a container warm, and narrow anything.
388    #[test]
389    fn a_manifest_may_still_choose_its_own_isolation_and_narrow() {
390        let user = ToolSandboxConfig {
391            kind: SandboxKind::Container,
392            network: true,
393            mounts: vec!["/srv/data".to_string()],
394            ..Default::default()
395        };
396        let manifest = ToolSandboxConfig {
397            kind: SandboxKind::Container,
398            image: Some("alpine:3".to_string()),
399            network: false,
400            mounts: vec![],
401            persist: true,
402            ..Default::default()
403        };
404
405        let resolved = resolve_sandbox(Some(&user), None, Some(&manifest));
406        assert_eq!(resolved.kind, SandboxKind::Container, "kind is kept");
407        assert_eq!(
408            resolved.image.as_deref(),
409            Some("alpine:3"),
410            "its own image, since the user pinned none"
411        );
412        assert!(!resolved.network, "narrowing is allowed");
413        assert!(resolved.mounts.is_empty(), "dropping a mount is allowed");
414        assert!(resolved.persist, "a warm container is not an escalation");
415        // With no engine pinned either side, the manifest's own choice stands.
416        assert_eq!(resolved.engine, None);
417    }
418
419    /// With no global sandbox, a manifest opting in stands as written: running
420    /// in a container with a mount is strictly less reach than running on the
421    /// host, which is what it would otherwise get.
422    #[test]
423    fn a_manifest_opting_in_from_nothing_is_not_clamped() {
424        let manifest = ToolSandboxConfig {
425            kind: SandboxKind::Container,
426            mounts: vec!["/home/you".to_string()],
427            ..Default::default()
428        };
429        let resolved = resolve_sandbox(None, Some(&manifest), None);
430        assert_eq!(resolved.mounts, vec!["/home/you".to_string()]);
431
432        // Same when the user's own block is host-passthrough: there is nothing
433        // to clamp against, and isolation is still an improvement.
434        let host = isolating(SandboxKind::None);
435        let resolved = resolve_sandbox(Some(&host), Some(&manifest), None);
436        assert_eq!(resolved.mounts, vec!["/home/you".to_string()]);
437    }
438
439    /// A manifest with no engine does not blank out the user's.
440    #[test]
441    fn the_users_engine_survives_a_manifest_that_names_none() {
442        let user = ToolSandboxConfig {
443            kind: SandboxKind::Container,
444            engine: Some("podman".to_string()),
445            ..Default::default()
446        };
447        let manifest = isolating(SandboxKind::Container);
448        let resolved = resolve_sandbox(Some(&user), Some(&manifest), None);
449        assert_eq!(resolved.engine.as_deref(), Some("podman"));
450    }
451
452    #[test]
453    fn default_is_host_passthrough() {
454        let c = ToolSandboxConfig::default();
455        assert_eq!(c.kind, SandboxKind::None);
456        assert!(!c.is_active());
457        assert!(c.network);
458        assert_eq!(c.on_unavailable, OnUnavailable::Error);
459    }
460
461    #[test]
462    fn stage_overrides_agent_and_global() {
463        let global = ToolSandboxConfig {
464            kind: SandboxKind::Namespace,
465            ..Default::default()
466        };
467        let agent = ToolSandboxConfig {
468            kind: SandboxKind::Container,
469            image: Some("ubuntu:24.04".into()),
470            ..Default::default()
471        };
472        let stage = ToolSandboxConfig {
473            kind: SandboxKind::Container,
474            image: Some("node:22-slim".into()),
475            network: false,
476            ..Default::default()
477        };
478        let r = resolve_sandbox(Some(&global), Some(&agent), Some(&stage));
479        assert_eq!(r.image.as_deref(), Some("node:22-slim"));
480        assert!(!r.network);
481    }
482
483    #[test]
484    fn agent_overrides_global_when_no_stage() {
485        let global = ToolSandboxConfig {
486            kind: SandboxKind::Namespace,
487            ..Default::default()
488        };
489        let agent = ToolSandboxConfig {
490            kind: SandboxKind::Container,
491            image: Some("ubuntu:24.04".into()),
492            ..Default::default()
493        };
494        let r = resolve_sandbox(Some(&global), Some(&agent), None);
495        assert_eq!(r.kind, SandboxKind::Container);
496        assert_eq!(r.image.as_deref(), Some("ubuntu:24.04"));
497    }
498
499    #[test]
500    fn global_used_when_no_agent_or_stage() {
501        let global = ToolSandboxConfig {
502            kind: SandboxKind::Namespace,
503            ..Default::default()
504        };
505        let r = resolve_sandbox(Some(&global), None, None);
506        assert_eq!(r.kind, SandboxKind::Namespace);
507    }
508
509    #[test]
510    fn nothing_set_is_host() {
511        let r = resolve_sandbox(None, None, None);
512        assert_eq!(r.kind, SandboxKind::None);
513    }
514
515    /// A downloaded manifest cannot drop the user back onto the host. Both the
516    /// agent and stage levels come from `agent.leviath`, so if those levels
517    /// could win, `kind = "none"` there would defeat a global
518    /// `kind = "container"`.
519    #[test]
520    fn manifest_cannot_disable_the_users_sandbox() {
521        let global = ToolSandboxConfig {
522            kind: SandboxKind::Container,
523            image: Some("ubuntu:24.04".into()),
524            ..Default::default()
525        };
526        let off = ToolSandboxConfig {
527            kind: SandboxKind::None,
528            ..Default::default()
529        };
530        // Agent level.
531        let r = resolve_sandbox(Some(&global), Some(&off), None);
532        assert_eq!(r.kind, SandboxKind::Container);
533        assert_eq!(r.image.as_deref(), Some("ubuntu:24.04"));
534        // Stage level.
535        let r = resolve_sandbox(Some(&global), None, Some(&off));
536        assert_eq!(r.kind, SandboxKind::Container);
537    }
538
539    /// It may still opt *in* when the user set nothing - that only tightens.
540    #[test]
541    fn manifest_may_opt_into_a_sandbox_the_user_did_not_set() {
542        let agent = ToolSandboxConfig {
543            kind: SandboxKind::Container,
544            image: Some("node:22-slim".into()),
545            ..Default::default()
546        };
547        let r = resolve_sandbox(None, Some(&agent), None);
548        assert_eq!(r.kind, SandboxKind::Container);
549
550        // And a `none` manifest with no global stays host, as before.
551        let off = ToolSandboxConfig {
552            kind: SandboxKind::None,
553            ..Default::default()
554        };
555        assert_eq!(
556            resolve_sandbox(None, Some(&off), None).kind,
557            SandboxKind::None
558        );
559    }
560
561    #[test]
562    fn is_active_true_for_isolating_kinds() {
563        for kind in [SandboxKind::Namespace, SandboxKind::Container] {
564            let c = ToolSandboxConfig {
565                kind,
566                ..Default::default()
567            };
568            assert!(c.is_active());
569        }
570    }
571
572    #[test]
573    fn deserializes_and_applies_field_defaults() {
574        // `network` omitted → `default_true`; other omitted fields fall back too.
575        let c: ToolSandboxConfig =
576            toml::from_str("kind = \"container\"\nimage = \"alpine\"").unwrap();
577        assert!(c.network);
578        assert!(c.is_active());
579        assert_eq!(c.on_unavailable, OnUnavailable::Error);
580        assert!(c.mounts.is_empty());
581        assert!(!c.persist);
582    }
583}