Skip to main content

agent_bridle_core/
config.rs

1//! Layered runtime configuration for the bridle (agent-bridle#141 / epic #139).
2//!
3//! `Caveats` is **authority** (per-invocation, rides [`crate::ToolContext`]).
4//! [`BridleConfig`] is **mechanism**: the tunable knobs, limits, path lists, and
5//! feature toggles that shape *how* confinement is applied — a separate channel
6//! that never amplifies authority and never touches the mint chokepoint or the
7//! honesty lattice (ADR 0017).
8//!
9//! These are pure, serde-only **types**; the file/env loader and its precedence
10//! (`defaults → file → env → API`) live in the `agent-bridle-config` crate (#142).
11//! Every [`Default`] here reproduces today's hard-coded constants **byte-for-byte**
12//! — the anti-drift tests below assert each `Policy::default()` equals the
13//! constant it mirrors (`gate.rs`, `sandbox.rs`, `rootfs.rs`), so this file can
14//! never silently diverge from current behavior. Nothing consumes `BridleConfig`
15//! yet (this issue is inert); wiring lands in #143–#153.
16
17use serde::{Deserialize, Serialize};
18
19use crate::report::AxisEnforcement;
20use crate::HumanGate;
21
22fn to_vec(v: &[&str]) -> Vec<String> {
23    v.iter().map(|s| (*s).to_string()).collect()
24}
25
26/// A configurable path list with **extend-by-default** semantics: `resolve()`
27/// returns `base ∪ extra` unless `replace` is set, in which case only `extra` is
28/// used. This lets config *widen* a security-relevant list (add a read path)
29/// safely, while **shrinking** one (dropping a loader path that would break
30/// confinement) requires an explicit `replace = true` opt-in. A widening is
31/// surfaced via [`PathList::widens`] so it can be disclosed (never silent).
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct PathList {
34    /// Built-in defaults (today's constant). Not normally set from config.
35    pub base: Vec<String>,
36    /// Operator additions (extend) — or the full set when `replace`.
37    #[serde(default)]
38    pub extra: Vec<String>,
39    /// `true` ⇒ ignore `base`, use only `extra` (the shrink opt-in).
40    #[serde(default)]
41    pub replace: bool,
42}
43
44impl PathList {
45    /// A default-backed list from a static slice (the const-mirroring constructor).
46    #[must_use]
47    pub fn from_defaults(base: &[&str]) -> Self {
48        Self {
49            base: to_vec(base),
50            extra: Vec::new(),
51            replace: false,
52        }
53    }
54
55    /// The effective list: `base ∪ extra` (dedup, order-preserving), or `extra`
56    /// alone when `replace`.
57    #[must_use]
58    pub fn resolve(&self) -> Vec<String> {
59        if self.replace {
60            return self.extra.clone();
61        }
62        let mut out = self.base.clone();
63        for e in &self.extra {
64            if !out.contains(e) {
65                out.push(e.clone());
66            }
67        }
68        out
69    }
70
71    /// `true` when the operator has *widened* the built-in list (added entries
72    /// without replacing) — a disclosure-worthy loosening.
73    #[must_use]
74    pub fn widens(&self) -> bool {
75        !self.replace && !self.extra.is_empty()
76    }
77}
78
79/// The top-level confinement mode. `Bridled` (default) confines per the caveats +
80/// backends; `Unbridle` is the explicit, acknowledged, honest "off" (grant
81/// `Caveats::top()`, advisory floor, `SandboxKind::None`) — resolved by the loader
82/// (#151), never reachable by omission.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
84#[serde(rename_all = "snake_case")]
85pub enum BridleMode {
86    /// Confine normally (the default).
87    #[default]
88    Bridled,
89    /// No confinement — advisory only, loudly disclosed (#151).
90    Unbridle,
91}
92
93/// Gate defaults (`gate.rs` constants) — and the **human-gesture axis** of the
94/// ADR 0018 mode lattice.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct GatePolicy {
97    /// The fence-strength floor stamped when none is set (`DEFAULT_STRENGTH_FLOOR`).
98    pub default_strength_floor: AxisEnforcement,
99    /// Cap on the discharge freshness-window scan (`MAX_FRESHNESS_WINDOW`).
100    pub max_freshness_window: u64,
101    /// The **step-up floor** — the human-gate posture the host enforces for a
102    /// HIGH-consequence act (ADR 0018 D9/D11, R6). This is the human-gesture axis
103    /// of the mode lattice, the config sibling of the capability axis
104    /// [`BridleConfig::mode`]. Defaults to [`HumanGate::Passkey`] (the human leash
105    /// is on unless deliberately lowered). `none` is the *legal* "no ceremony"
106    /// case (e.g. CI) — distinct from the illegal *acked-off* combination the
107    /// loader refuses (the D10 no-step-up ack **while bridled**; that ack rides a
108    /// separate env-only channel, never a config file — ADR 0018 D3). The
109    /// Autonomous posture (`none` while unbridled) is reached at runtime via that
110    /// second ack, never by a config file lowering this floor.
111    #[serde(default)]
112    pub step_up: HumanGate,
113}
114
115impl Default for GatePolicy {
116    fn default() -> Self {
117        Self {
118            default_strength_floor: AxisEnforcement::Advisory,
119            max_freshness_window: 4096,
120            step_up: HumanGate::Passkey,
121        }
122    }
123}
124
125/// Backend availability toggles (subsumes `BRIDLE_REQUIRE_*`).
126#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
127pub struct BackendToggles {
128    /// Require Landlock (fail closed if unavailable).
129    #[serde(default)]
130    pub require_landlock: bool,
131    /// Require Seatbelt (fail closed if unavailable).
132    #[serde(default)]
133    pub require_seatbelt: bool,
134    /// Backends to force off by name (e.g. `["seatbelt"]`).
135    #[serde(default)]
136    pub disable: Vec<String>,
137}
138
139/// How a confined child's *direct* network-socket authority is enforced, beyond
140/// the caveat-driven Landlock TCP rule. A **mechanism** knob (it rides
141/// [`SandboxPolicy`]), never authority (`Caveats`): the caller states the
142/// required floor and the backend owns the enforcement (ADR 0017).
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
144#[serde(rename_all = "kebab-case")]
145pub enum ChildNetworkPolicy {
146    /// Historical behavior: rely on the caveat-driven Landlock net rule alone.
147    /// An empty `net` scope kernel-denies TCP **connect/bind** on an ABI-v4
148    /// kernel, but Landlock cannot filter UDP, DNS, raw or packet sockets — a
149    /// child under `net: none` can still create them. Backward-compatible
150    /// default, so existing configs and callers are unchanged.
151    #[default]
152    LandlockOnly,
153    /// Additionally install a seccomp `socket()`-family deny (AF_INET / AF_INET6
154    /// / AF_PACKET → `EACCES`) on the confining thread immediately before spawn,
155    /// so the child and every fork/exec descendant cannot create ANY off-box
156    /// socket regardless of protocol — closing the UDP/DNS/raw leg Landlock
157    /// misses. `AF_UNIX` is deliberately still allowed (a path-named unix socket
158    /// is already governed by the fs fence; abstract-namespace unix sockets are a
159    /// bounded residual). Takes effect only when the `net` caveat is already
160    /// deny-all (`net: none`); a granted net scope leaves it inert (the caller
161    /// asked for egress). Requires the `linux-landlock` backend (which pulls the
162    /// safe `seccompiler` install path); on other platforms it is inert, so a
163    /// caller that *requires* it must fail closed via `backends.require_landlock`.
164    DenyDirect,
165}
166
167/// Sandbox path lists + ABI floors (`sandbox.rs` constants).
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct SandboxPolicy {
170    /// Backend enable/require toggles.
171    #[serde(default)]
172    pub backends: BackendToggles,
173    /// Direct child network-socket confinement beyond the Landlock TCP rule
174    /// ([`ChildNetworkPolicy`]). `#[serde(default)]` so configs written before
175    /// this field existed keep parsing (they get `LandlockOnly`).
176    #[serde(default)]
177    pub child_network: ChildNetworkPolicy,
178    /// Read base when `fs_read` restricted (`BASE_READ_PATHS`).
179    pub base_read_paths: PathList,
180    /// Executable dirs read-allowed only when `exec` is ambient (`BIN_READ_PATHS`).
181    pub bin_read_paths: PathList,
182    /// Device sinks that are ALWAYS openable read+write inside the jail
183    /// (#1220): discard/zero devices carry no authority, and tools open them
184    /// as plumbing — git opens `/dev/null` O_RDWR and dies inside a confined
185    /// child otherwise. Folded into every write ruleset/profile regardless of
186    /// the granted `fs_write` scope. `#[serde(default)]` so configs written
187    /// before this field existed keep parsing (they get the defaults).
188    #[serde(default = "default_device_sink_paths")]
189    pub device_sink_paths: PathList,
190    /// Execute allow-list: the dynamic loader files only (`LOADER_PATHS`).
191    pub loader_paths: PathList,
192    /// Loopback identifiers for the net axis (`LOOPBACK_HOSTS`).
193    pub loopback_hosts: Vec<String>,
194    /// Minimum Landlock ABI (`ABI_FLOOR`).
195    pub landlock_abi_floor: u32,
196    /// Minimum Landlock ABI for TCP net rules (`NET_ABI_FLOOR`).
197    pub landlock_net_abi_floor: u32,
198}
199
200/// The `device_sink_paths` default: the null/zero/full discard devices —
201/// every write to them is a no-op (or ENOSPC by contract, for `/dev/full`),
202/// so granting fresh opens carries no authority. Deliberately excludes
203/// `/dev/tty` (interactive prompts should fail fast inside confinement) and
204/// `/dev/std{out,err}` (inherited fds need no open; the procfs symlinks
205/// resolve to arbitrary files and would widen the grant).
206fn default_device_sink_paths() -> PathList {
207    PathList::from_defaults(&["/dev/null", "/dev/zero", "/dev/full"])
208}
209
210impl Default for SandboxPolicy {
211    fn default() -> Self {
212        // The read base is backend-specific: the Landlock (Linux) loader/library
213        // trees vs the Seatbelt (macOS) dyld/system base. One `base_read_paths`
214        // field defaults to whichever backend this host runs (I5-B, #144). The
215        // literals are duplicated here (not referenced from the cfg-gated sandbox
216        // consts) so `BridleConfig` constructs on every platform.
217        #[cfg(target_os = "macos")]
218        let base_read: &[&str] = &[
219            "/usr",
220            "/bin",
221            "/sbin",
222            "/System",
223            "/Library",
224            "/opt",
225            "/private/etc",
226            "/private/var/db/dyld",
227            // /etc/localtime and /usr/share/zoneinfo resolve into this
228            // OS-owned database on macOS. TZ never adds arbitrary read roots.
229            "/private/var/db/timezone",
230            "/dev",
231        ];
232        #[cfg(not(target_os = "macos"))]
233        let base_read: &[&str] = &[
234            "/lib",
235            "/lib64",
236            "/lib32",
237            "/libx32",
238            "/usr/lib",
239            "/usr/lib64",
240            "/usr/libexec",
241            "/usr/share",
242            "/etc/ld.so.cache",
243            "/etc/ld.so.preload",
244            "/etc/alternatives",
245            "/etc/nsswitch.conf",
246            "/etc/localtime",
247            "/etc/resolv.conf",
248            "/etc/ssl",
249            "/etc/ca-certificates",
250            "/proc/self",
251            "/dev/null",
252            "/dev/zero",
253            "/dev/full",
254            "/dev/urandom",
255            "/dev/random",
256        ];
257        Self {
258            backends: BackendToggles::default(),
259            child_network: ChildNetworkPolicy::default(),
260            base_read_paths: PathList::from_defaults(base_read),
261            device_sink_paths: default_device_sink_paths(),
262            bin_read_paths: PathList::from_defaults(&[
263                "/usr/bin",
264                "/bin",
265                "/usr/sbin",
266                "/sbin",
267                "/usr/local/bin",
268                "/usr/local/sbin",
269                "/opt",
270            ]),
271            loader_paths: PathList::from_defaults(&[
272                "/lib64/ld-linux-x86-64.so.2",
273                "/lib/ld-linux-x86-64.so.2",
274                "/lib/ld-linux.so.2",
275                "/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2",
276                "/lib64/ld64.so.2",
277                "/lib/ld-linux-aarch64.so.1",
278                "/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1",
279                "/lib/ld-linux-armhf.so.3",
280                "/lib/ld-musl-x86-64.so.1",
281                "/lib/ld-musl-aarch64.so.1",
282            ]),
283            loopback_hosts: to_vec(&["localhost", "127.0.0.1", "::1"]),
284            landlock_abi_floor: 3,
285            landlock_net_abi_floor: 4,
286        }
287    }
288}
289
290/// Minimal-rootfs builder inputs (`rootfs.rs` constants).
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292pub struct RootfsPolicy {
293    /// Curated runtime data injected into every plan (`DATA_PATHS`).
294    pub data_paths: PathList,
295    /// `$PATH` fallback for bare-name program resolution (`search_dirs`).
296    pub search_dirs: Vec<String>,
297}
298
299impl Default for RootfsPolicy {
300    fn default() -> Self {
301        Self {
302            // The curated runtime data paths (formerly `rootfs::DATA_PATHS`).
303            // This policy is the single source of truth — the rootfs builder
304            // reads it directly (I5, #144). Kept here (not in `rootfs.rs`) so the
305            // default is platform-independent: `rootfs` is a Linux-only module,
306            // but `BridleConfig` must construct on every host.
307            data_paths: PathList::from_defaults(&[
308                "/usr/share",
309                "/usr/lib/locale",
310                "/etc/ld.so.cache",
311                "/etc/ld.so.preload",
312                "/etc/alternatives",
313                "/etc/nsswitch.conf",
314                "/etc/localtime",
315                "/etc/resolv.conf",
316                "/etc/ssl",
317                "/etc/ca-certificates",
318                "/proc/self",
319                "/dev/null",
320                "/dev/zero",
321                "/dev/full",
322                "/dev/urandom",
323                "/dev/random",
324            ]),
325            search_dirs: to_vec(&[
326                "/usr/local/bin",
327                "/usr/bin",
328                "/bin",
329                "/usr/local/sbin",
330                "/usr/sbin",
331                "/sbin",
332            ]),
333        }
334    }
335}
336
337/// The default `PATH` for a **fully-authorized** (`exec = Scope::All`) confined
338/// child: the ambient `$PATH` when set and non-empty, else the conventional
339/// [`RootfsPolicy`] search dirs, joined with the platform separator.
340///
341/// The sandbox-host engine (`HostShellTool`, in `agent-bridle-tool-shell`) seeds
342/// this into the child so bare program names (`grep`/`ls`/`find`) resolve like
343/// the host shell would, instead of leaning on the shell's fragile compiled
344/// `_CS_PATH` fallback. It is only meaningful — and only called — when
345/// `exec` is unrestricted, so seeding it grants nothing the caller's exec
346/// authority does not already permit; `env_clear` still scrubs everything else.
347/// Mirrors the exec-search-dir precedence that anchors the L3 `Execute`
348/// allow-list (`sandbox::exec_search_dirs`); kept here so it constructs on every
349/// platform.
350#[must_use]
351pub fn default_exec_path() -> String {
352    if let Ok(path) = std::env::var("PATH") {
353        if !path.is_empty() {
354            return path;
355        }
356    }
357    let dirs = RootfsPolicy::default().search_dirs;
358    std::env::join_paths(&dirs)
359        .ok()
360        .and_then(|joined| joined.into_string().ok())
361        .unwrap_or_else(|| dirs.join(if cfg!(windows) { ";" } else { ":" }))
362}
363
364/// Toggles for the automatic "normalizations" (assists) — each defaults to today's
365/// always-on behavior; only *loosening*-safe ones are exposed as on/off (safety
366/// normalizations like fs canonicalization and env-scrub are intentionally absent
367/// here — see ADR 0017).
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369pub struct NormalizationPolicy {
370    /// Match a granted bare exec name against a program's basename (`context.rs`).
371    pub exec_basename_match: bool,
372    /// Resolve a dynamic program's `ldd` shared-library closure (`rootfs.rs`).
373    pub ldd_closure: bool,
374    /// #113 fallback: add glibc NSS modules to the closure.
375    pub nss_closure_fallback: bool,
376    /// #113 fallback: add the Python stdlib dirs when a `python*` is granted.
377    pub python_closure_fallback: bool,
378    /// Emit the missing-`.so` deny-of-function canary diagnostic (`jaild`).
379    pub missing_so_canary: bool,
380    /// Use the content-addressed rootfs build cache.
381    pub rootfs_cache: bool,
382}
383
384impl Default for NormalizationPolicy {
385    fn default() -> Self {
386        Self {
387            exec_basename_match: true,
388            ldd_closure: true,
389            nss_closure_fallback: true,
390            python_closure_fallback: true,
391            missing_so_canary: true,
392            rootfs_cache: true,
393        }
394    }
395}
396
397/// Default network posture when no rule matches.
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
399#[serde(rename_all = "snake_case")]
400pub enum NetDefault {
401    /// Fail-closed default (today's behavior).
402    #[default]
403    Deny,
404    /// Allow — only meaningful under an explicit relaxed/unbridle posture.
405    Allow,
406}
407
408/// How a host is matched. `#[non_exhaustive]` so REST/gRPC predicate variants
409/// (#153) can be added later without breaking existing configs.
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
411#[serde(rename_all = "snake_case")]
412#[non_exhaustive]
413pub enum HostMatch {
414    /// Exact hostname (today's semantics).
415    Exact(String),
416    /// Domain suffix (e.g. `.example.com`).
417    Suffix(String),
418    /// Glob pattern.
419    Glob(String),
420}
421
422/// One network rule. `#[non_exhaustive]` to admit `Rest {..}` / `Grpc {..}`
423/// variants additively (#153/#153-followup).
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425#[serde(rename_all = "snake_case")]
426#[non_exhaustive]
427pub enum NetRule {
428    /// Host-level allow (the v1 predicate; a superset of exact-host).
429    Host(HostMatch),
430}
431
432/// Network policy — *refines* how the `net` authority axis (`Scope<String>`) is
433/// interpreted/enforced; defaults to today's exact-host behavior (empty rules).
434/// A structured rule is proxy-enforced (userspace) ⇒ the honesty report keeps a
435/// non-loopback allow-list `advisory`, never `kernel` (#152).
436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
437pub struct NetPolicy {
438    /// Posture when no rule matches.
439    #[serde(default)]
440    pub default: NetDefault,
441    /// Ordered match rules (empty ⇒ pure `Scope<String>` exact-host behavior).
442    #[serde(default)]
443    pub rules: Vec<NetRule>,
444}
445
446impl Default for NetPolicy {
447    fn default() -> Self {
448        Self {
449            default: NetDefault::Deny,
450            rules: Vec::new(),
451        }
452    }
453}
454
455/// Shell-tool + egress-proxy limits (`shell_tool.rs` / `net_proxy.rs` constants).
456/// Anti-drift tests for these land with the wiring PR (#143) where the consts live.
457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
458pub struct LimitsPolicy {
459    /// Max permitted wall-clock timeout, seconds (`MAX_TIMEOUT_SECS`).
460    pub max_timeout_secs: u64,
461    /// Default timeout when unspecified, seconds (`DEFAULT_TIMEOUT_SECS`).
462    pub default_timeout_secs: u64,
463    /// Captured stdout/stderr cap, bytes (`MAX_OUTPUT_BYTES`).
464    pub max_output_bytes: usize,
465    /// Env vars expandable in redirect targets (`VAR_ALLOWLIST`).
466    pub var_allowlist: Vec<String>,
467    /// Max glob nesting depth (`MAX_GLOB_DEPTH`).
468    pub max_glob_depth: usize,
469    /// Max glob matches (`MAX_GLOB_MATCHES`).
470    pub max_glob_matches: usize,
471    /// Proxy request-header cap, bytes (`MAX_HEAD`).
472    pub proxy_max_head: usize,
473    /// Proxy per-connection socket timeout, seconds (`CONN_TIMEOUT`).
474    pub proxy_conn_timeout_secs: u64,
475    /// Proxy bind address (loopback ephemeral).
476    pub proxy_bind: String,
477    /// Egress audit sink path (subsumes `BRIDLE_NET_AUDIT`); `None` = off.
478    #[serde(default)]
479    pub audit_sink: Option<String>,
480    /// AB-004: environment variable names DROPPED before spawning a child,
481    /// regardless of the exec leash. Loader / interpreter / hook vars
482    /// (`LD_PRELOAD`, `PYTHONPATH`, `GIT_SSH_COMMAND`, `BASH_ENV`, …) change what
483    /// an *allowed* program actually executes, so they can never be caller-set.
484    /// Overridable config data (three-Cs).
485    #[serde(default = "default_env_denylist")]
486    pub env_denylist: Vec<String>,
487}
488
489/// The default loader/interpreter/hook environment denylist (AB-004). Each name
490/// here can hijack code execution inside an otherwise-allowed program.
491#[must_use]
492pub fn default_env_denylist() -> Vec<String> {
493    to_vec(&[
494        "LD_PRELOAD",
495        "LD_LIBRARY_PATH",
496        "LD_AUDIT",
497        "DYLD_INSERT_LIBRARIES",
498        "DYLD_LIBRARY_PATH",
499        "PYTHONPATH",
500        "PYTHONSTARTUP",
501        "NODE_OPTIONS",
502        "RUBYOPT",
503        "RUBYLIB",
504        "PERL5OPT",
505        "PERL5LIB",
506        "CLASSPATH",
507        "JAVA_TOOL_OPTIONS",
508        "_JAVA_OPTIONS",
509        "GIT_SSH_COMMAND",
510        "GIT_EXEC_PATH",
511        "GIT_TEMPLATE_DIR",
512        "BASH_ENV",
513        "ENV",
514        "SHELLOPTS",
515        "BASHOPTS",
516        "PROMPT_COMMAND",
517    ])
518}
519
520/// Drop denied loader/hook keys from `env`, returning the surviving map plus the
521/// dropped names (for disclosure). See [`LimitsPolicy::env_denylist`] (AB-004).
522#[must_use]
523pub fn fence_env(
524    env: &std::collections::BTreeMap<String, String>,
525    denylist: &[String],
526) -> (std::collections::BTreeMap<String, String>, Vec<String>) {
527    let denied: std::collections::BTreeSet<&str> = denylist.iter().map(String::as_str).collect();
528    let mut fenced = std::collections::BTreeMap::new();
529    let mut dropped = Vec::new();
530    for (k, v) in env {
531        if denied.contains(k.as_str()) {
532            dropped.push(k.clone());
533        } else {
534            fenced.insert(k.clone(), v.clone());
535        }
536    }
537    (fenced, dropped)
538}
539
540impl Default for LimitsPolicy {
541    fn default() -> Self {
542        Self {
543            max_timeout_secs: 300,
544            default_timeout_secs: 60,
545            max_output_bytes: 1 << 20,
546            var_allowlist: to_vec(&[
547                "HOME", "PWD", "OLDPWD", "USER", "LOGNAME", "TMPDIR", "LANG", "LC_ALL", "SHELL",
548                "HOSTNAME", "TERM",
549            ]),
550            max_glob_depth: 64,
551            max_glob_matches: 4096,
552            proxy_max_head: 8 * 1024,
553            proxy_conn_timeout_secs: 30,
554            proxy_bind: "127.0.0.1:0".to_string(),
555            audit_sink: None,
556            env_denylist: default_env_denylist(),
557        }
558    }
559}
560
561/// Web-fetch limits (`web_fetch.rs` constants). Anti-drift lands with wiring.
562#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
563pub struct WebPolicy {
564    /// Max redirect hops (`MAX_REDIRECTS`).
565    pub max_redirects: usize,
566    /// Default response body cap, bytes (`DEFAULT_MAX_BYTES`).
567    pub default_max_bytes: usize,
568    /// Absolute ceiling on the body cap, bytes (`HARD_MAX_BYTES`).
569    pub hard_max_bytes: usize,
570    /// Per-request timeout, seconds (`REQUEST_TIMEOUT_SECS`).
571    pub request_timeout_secs: u64,
572}
573
574impl Default for WebPolicy {
575    fn default() -> Self {
576        Self {
577            max_redirects: 10,
578            default_max_bytes: 5 * 1024 * 1024,
579            hard_max_bytes: 25 * 1024 * 1024,
580            request_timeout_secs: 30,
581        }
582    }
583}
584
585/// Micro-VM / jail parameters (`jaild` constants). Anti-drift lands with wiring (#147).
586#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
587pub struct VmPolicy {
588    /// Candidate qemu binaries (`QEMU_PATH`).
589    pub qemu_path: Vec<String>,
590    /// Guest kernel search paths (`/boot/vmlinuz`).
591    pub kernel_search: Vec<String>,
592    /// Guest memory, MiB (`VM_MEMORY`).
593    pub memory_mb: u32,
594    /// qemu accelerator spec (`accel=kvm:tcg`).
595    pub accel: String,
596    /// Guest kernel command line.
597    pub kernel_cmdline: String,
598    /// Merged-usr top-level symlinks to reproduce (`MERGED_USR_LINKS`).
599    pub merged_usr_links: Vec<String>,
600    /// Broker protocol max frame, bytes (`MAX_FRAME`).
601    pub max_frame: usize,
602    /// Broker socket path (subsumes `BRIDLE_JAILD_SOCKET`).
603    #[serde(default)]
604    pub jaild_socket: Option<String>,
605    /// Guest-init binary path (subsumes `BRIDLE_JAIL_INIT`).
606    #[serde(default)]
607    pub jail_init: Option<String>,
608}
609
610impl Default for VmPolicy {
611    fn default() -> Self {
612        Self {
613            qemu_path: to_vec(&["/usr/bin/qemu-system-x86_64"]),
614            kernel_search: to_vec(&["/boot/vmlinuz"]),
615            memory_mb: 512,
616            accel: "kvm:tcg".to_string(),
617            kernel_cmdline: "console=ttyS0 panic=1 loglevel=4".to_string(),
618            merged_usr_links: to_vec(&["bin", "sbin", "lib", "lib32", "lib64", "libx32"]),
619            max_frame: 64 * 1024 * 1024,
620            jaild_socket: None,
621            jail_init: None,
622        }
623    }
624}
625
626/// The complete, layered bridle configuration (mechanism). Every field defaults to
627/// today's behavior; see the module docs for the authority-vs-mechanism split.
628#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
629#[serde(default)]
630pub struct BridleConfig {
631    /// Top-level confinement mode.
632    pub mode: BridleMode,
633    /// Gate defaults.
634    pub gate: GatePolicy,
635    /// Sandbox path lists + backend toggles.
636    pub sandbox: SandboxPolicy,
637    /// Automatic-normalization toggles.
638    pub normalization: NormalizationPolicy,
639    /// Minimal-rootfs builder inputs.
640    pub rootfs: RootfsPolicy,
641    /// Network refinement policy.
642    pub net: NetPolicy,
643    /// Shell + proxy limits.
644    pub limits: LimitsPolicy,
645    /// Web-fetch limits.
646    pub web: WebPolicy,
647    /// Micro-VM / jail parameters.
648    pub vm: VmPolicy,
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654
655    /// AB-004: `fence_env` drops every denied loader/hook key and keeps the rest,
656    /// reporting what it dropped.
657    #[test]
658    fn fence_env_drops_denied_keeps_others() {
659        let mut env = std::collections::BTreeMap::new();
660        env.insert("LD_PRELOAD".to_string(), "/evil.so".to_string());
661        env.insert("GIT_SSH_COMMAND".to_string(), "evil".to_string());
662        env.insert("SAFE".to_string(), "keep".to_string());
663        let (fenced, dropped) = fence_env(&env, &default_env_denylist());
664        assert!(!fenced.contains_key("LD_PRELOAD"));
665        assert!(!fenced.contains_key("GIT_SSH_COMMAND"));
666        assert_eq!(fenced.get("SAFE").map(String::as_str), Some("keep"));
667        assert_eq!(dropped.len(), 2);
668        assert!(dropped.contains(&"LD_PRELOAD".to_string()));
669        assert!(dropped.contains(&"GIT_SSH_COMMAND".to_string()));
670    }
671
672    #[test]
673    fn mode_defaults_to_bridled() {
674        assert_eq!(BridleMode::default(), BridleMode::Bridled);
675        assert_eq!(BridleConfig::default().mode, BridleMode::Bridled);
676    }
677
678    #[test]
679    fn pathlist_extends_by_default_and_replaces_on_opt_in() {
680        let mut p = PathList::from_defaults(&["/a", "/b"]);
681        assert_eq!(p.resolve(), vec!["/a".to_string(), "/b".to_string()]);
682        assert!(!p.widens());
683
684        p.extra = vec!["/c".to_string(), "/a".to_string()]; // /a is a dup
685        assert_eq!(
686            p.resolve(),
687            vec!["/a".to_string(), "/b".to_string(), "/c".to_string()],
688            "extend dedups and preserves order"
689        );
690        assert!(p.widens(), "adding entries without replace is a widening");
691
692        p.replace = true;
693        assert_eq!(
694            p.resolve(),
695            vec!["/c".to_string(), "/a".to_string()],
696            "replace uses only extra (the shrink opt-in)"
697        );
698        assert!(!p.widens(), "replace is not a widening");
699    }
700
701    #[test]
702    fn gate_policy_defaults_match_constants() {
703        let g = GatePolicy::default();
704        assert_eq!(g.default_strength_floor, AxisEnforcement::Advisory);
705        assert_eq!(g.max_freshness_window, 4096);
706        // The human-gesture axis floor (ADR 0018 R6) defaults ON: the human leash
707        // is Passkey unless deliberately lowered — Autonomous is never by omission.
708        assert_eq!(g.step_up, HumanGate::Passkey);
709    }
710
711    #[test]
712    fn gate_step_up_floor_round_trips_each_posture() {
713        // Each human-gesture posture survives serialize→deserialize as snake_case,
714        // so the config file / env / API can express any of them.
715        for (gate, tok) in [
716            (HumanGate::None, "none"),
717            (HumanGate::Prompt, "prompt"),
718            (HumanGate::Passkey, "passkey"),
719        ] {
720            let g = GatePolicy {
721                step_up: gate,
722                ..GatePolicy::default()
723            };
724            let json = serde_json::to_value(&g).unwrap();
725            assert_eq!(json["step_up"], tok, "serializes snake_case");
726            let back: GatePolicy = serde_json::from_value(json).unwrap();
727            assert_eq!(back.step_up, gate);
728        }
729    }
730
731    #[test]
732    fn default_config_round_trips_through_json() {
733        let c = BridleConfig::default();
734        let json = serde_json::to_string(&c).unwrap();
735        let back: BridleConfig = serde_json::from_str(&json).unwrap();
736        assert_eq!(c, back);
737    }
738
739    #[test]
740    fn net_policy_defaults_to_deny_with_no_rules() {
741        let n = NetPolicy::default();
742        assert_eq!(n.default, NetDefault::Deny);
743        assert!(n.rules.is_empty(), "empty rules ⇒ pure exact-host behavior");
744    }
745
746    // ── Anti-drift: defaults must equal the source constants byte-for-byte, so
747    //    this inert config can never silently diverge from today's behavior. ──
748
749    #[test]
750    fn gate_defaults_match_source_constants() {
751        let g = GatePolicy::default();
752        assert_eq!(
753            g.default_strength_floor,
754            crate::gate::DEFAULT_STRENGTH_FLOOR
755        );
756        assert_eq!(g.max_freshness_window, crate::gate::MAX_FRESHNESS_WINDOW);
757    }
758
759    #[test]
760    fn sandbox_loopback_default_matches_constant() {
761        assert_eq!(
762            SandboxPolicy::default().loopback_hosts,
763            to_vec(crate::sandbox::LOOPBACK_HOSTS)
764        );
765    }
766
767    #[test]
768    fn sandbox_path_and_abi_defaults_are_byte_for_byte() {
769        // Byte-for-byte anti-drift for the kernel-confinement allow-lists this
770        // config now owns (#144, I5-B). The old `sandbox.rs` consts were deleted,
771        // so `SandboxPolicy::default()` is the single source of truth — pin its
772        // exact contents (mirroring the retained `LOOPBACK_HOSTS` / gate guards)
773        // so any silent edit to these security-relevant lists is caught, honoring
774        // the PR's "defaults == today, byte-for-byte" claim.
775        let d = SandboxPolicy::default();
776
777        // `base_read_paths` is platform-conditional: Landlock's read base on
778        // Linux, Seatbelt's on macOS.
779        #[cfg(target_os = "macos")]
780        let want_base = to_vec(&[
781            "/usr",
782            "/bin",
783            "/sbin",
784            "/System",
785            "/Library",
786            "/opt",
787            "/private/etc",
788            "/private/var/db/dyld",
789            "/private/var/db/timezone",
790            "/dev",
791        ]);
792        #[cfg(not(target_os = "macos"))]
793        let want_base = to_vec(&[
794            "/lib",
795            "/lib64",
796            "/lib32",
797            "/libx32",
798            "/usr/lib",
799            "/usr/lib64",
800            "/usr/libexec",
801            "/usr/share",
802            "/etc/ld.so.cache",
803            "/etc/ld.so.preload",
804            "/etc/alternatives",
805            "/etc/nsswitch.conf",
806            "/etc/localtime",
807            "/etc/resolv.conf",
808            "/etc/ssl",
809            "/etc/ca-certificates",
810            "/proc/self",
811            "/dev/null",
812            "/dev/zero",
813            "/dev/full",
814            "/dev/urandom",
815            "/dev/random",
816        ]);
817        assert_eq!(d.base_read_paths.resolve(), want_base, "base_read drift");
818
819        // `bin_read_paths` + `loader_paths` are platform-independent.
820        assert_eq!(
821            d.bin_read_paths.resolve(),
822            to_vec(&[
823                "/usr/bin",
824                "/bin",
825                "/usr/sbin",
826                "/sbin",
827                "/usr/local/bin",
828                "/usr/local/sbin",
829                "/opt",
830            ]),
831            "bin_read drift"
832        );
833        assert_eq!(
834            d.loader_paths.resolve(),
835            to_vec(&[
836                "/lib64/ld-linux-x86-64.so.2",
837                "/lib/ld-linux-x86-64.so.2",
838                "/lib/ld-linux.so.2",
839                "/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2",
840                "/lib64/ld64.so.2",
841                "/lib/ld-linux-aarch64.so.1",
842                "/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1",
843                "/lib/ld-linux-armhf.so.3",
844                "/lib/ld-musl-x86-64.so.1",
845                "/lib/ld-musl-aarch64.so.1",
846            ]),
847            "loader drift"
848        );
849
850        // ABI floors reproduce the old `ABI::V3` / `ABI::V4` constants.
851        assert_eq!(d.landlock_abi_floor, 3, "fs ABI floor drift");
852        assert_eq!(d.landlock_net_abi_floor, 4, "net ABI floor drift");
853
854        // Landlock invariant (ADR 0011 D3): the read base must NOT contain the
855        // executable dirs — keeping `/usr/bin` etc. out of the read set shrinks
856        // the ld.so-trampoline corpus. A widening that re-admits a bin dir would
857        // silently re-open it, so guard against it here. (macOS Seatbelt read
858        // confinement is content-level and intentionally admits `/bin` etc.)
859        #[cfg(not(target_os = "macos"))]
860        for bin in ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] {
861            assert!(
862                !d.base_read_paths.resolve().iter().any(|p| p == bin),
863                "Linux base read must exclude the executable dir {bin} (trampoline corpus)"
864            );
865        }
866    }
867}