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}