use serde::{Deserialize, Serialize};
use crate::report::AxisEnforcement;
use crate::HumanGate;
fn to_vec(v: &[&str]) -> Vec<String> {
v.iter().map(|s| (*s).to_string()).collect()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PathList {
pub base: Vec<String>,
#[serde(default)]
pub extra: Vec<String>,
#[serde(default)]
pub replace: bool,
}
impl PathList {
#[must_use]
pub fn from_defaults(base: &[&str]) -> Self {
Self {
base: to_vec(base),
extra: Vec::new(),
replace: false,
}
}
#[must_use]
pub fn resolve(&self) -> Vec<String> {
if self.replace {
return self.extra.clone();
}
let mut out = self.base.clone();
for e in &self.extra {
if !out.contains(e) {
out.push(e.clone());
}
}
out
}
#[must_use]
pub fn widens(&self) -> bool {
!self.replace && !self.extra.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum BridleMode {
#[default]
Bridled,
Unbridle,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GatePolicy {
pub default_strength_floor: AxisEnforcement,
pub max_freshness_window: u64,
#[serde(default)]
pub step_up: HumanGate,
}
impl Default for GatePolicy {
fn default() -> Self {
Self {
default_strength_floor: AxisEnforcement::Advisory,
max_freshness_window: 4096,
step_up: HumanGate::Passkey,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackendToggles {
#[serde(default)]
pub require_landlock: bool,
#[serde(default)]
pub require_seatbelt: bool,
#[serde(default)]
pub disable: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SandboxPolicy {
#[serde(default)]
pub backends: BackendToggles,
pub base_read_paths: PathList,
pub bin_read_paths: PathList,
pub loader_paths: PathList,
pub loopback_hosts: Vec<String>,
pub landlock_abi_floor: u32,
pub landlock_net_abi_floor: u32,
}
impl Default for SandboxPolicy {
fn default() -> Self {
#[cfg(target_os = "macos")]
let base_read: &[&str] = &[
"/usr",
"/bin",
"/sbin",
"/System",
"/Library",
"/opt",
"/private/etc",
"/private/var/db/dyld",
"/dev",
];
#[cfg(not(target_os = "macos"))]
let base_read: &[&str] = &[
"/lib",
"/lib64",
"/lib32",
"/libx32",
"/usr/lib",
"/usr/lib64",
"/usr/libexec",
"/usr/share",
"/etc/ld.so.cache",
"/etc/ld.so.preload",
"/etc/alternatives",
"/etc/nsswitch.conf",
"/etc/localtime",
"/etc/resolv.conf",
"/etc/ssl",
"/etc/ca-certificates",
"/proc/self",
"/dev/null",
"/dev/zero",
"/dev/full",
"/dev/urandom",
"/dev/random",
];
Self {
backends: BackendToggles::default(),
base_read_paths: PathList::from_defaults(base_read),
bin_read_paths: PathList::from_defaults(&[
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin",
"/usr/local/bin",
"/usr/local/sbin",
"/opt",
]),
loader_paths: PathList::from_defaults(&[
"/lib64/ld-linux-x86-64.so.2",
"/lib/ld-linux-x86-64.so.2",
"/lib/ld-linux.so.2",
"/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2",
"/lib64/ld64.so.2",
"/lib/ld-linux-aarch64.so.1",
"/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1",
"/lib/ld-linux-armhf.so.3",
"/lib/ld-musl-x86-64.so.1",
"/lib/ld-musl-aarch64.so.1",
]),
loopback_hosts: to_vec(&["localhost", "127.0.0.1", "::1"]),
landlock_abi_floor: 3,
landlock_net_abi_floor: 4,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RootfsPolicy {
pub data_paths: PathList,
pub search_dirs: Vec<String>,
}
impl Default for RootfsPolicy {
fn default() -> Self {
Self {
data_paths: PathList::from_defaults(&[
"/usr/share",
"/usr/lib/locale",
"/etc/ld.so.cache",
"/etc/ld.so.preload",
"/etc/alternatives",
"/etc/nsswitch.conf",
"/etc/localtime",
"/etc/resolv.conf",
"/etc/ssl",
"/etc/ca-certificates",
"/proc/self",
"/dev/null",
"/dev/zero",
"/dev/full",
"/dev/urandom",
"/dev/random",
]),
search_dirs: to_vec(&[
"/usr/local/bin",
"/usr/bin",
"/bin",
"/usr/local/sbin",
"/usr/sbin",
"/sbin",
]),
}
}
}
#[must_use]
pub fn default_exec_path() -> String {
if let Ok(path) = std::env::var("PATH") {
if !path.is_empty() {
return path;
}
}
let dirs = RootfsPolicy::default().search_dirs;
std::env::join_paths(&dirs)
.ok()
.and_then(|joined| joined.into_string().ok())
.unwrap_or_else(|| dirs.join(if cfg!(windows) { ";" } else { ":" }))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NormalizationPolicy {
pub exec_basename_match: bool,
pub ldd_closure: bool,
pub nss_closure_fallback: bool,
pub python_closure_fallback: bool,
pub missing_so_canary: bool,
pub rootfs_cache: bool,
}
impl Default for NormalizationPolicy {
fn default() -> Self {
Self {
exec_basename_match: true,
ldd_closure: true,
nss_closure_fallback: true,
python_closure_fallback: true,
missing_so_canary: true,
rootfs_cache: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum NetDefault {
#[default]
Deny,
Allow,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum HostMatch {
Exact(String),
Suffix(String),
Glob(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum NetRule {
Host(HostMatch),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NetPolicy {
#[serde(default)]
pub default: NetDefault,
#[serde(default)]
pub rules: Vec<NetRule>,
}
impl Default for NetPolicy {
fn default() -> Self {
Self {
default: NetDefault::Deny,
rules: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LimitsPolicy {
pub max_timeout_secs: u64,
pub default_timeout_secs: u64,
pub max_output_bytes: usize,
pub var_allowlist: Vec<String>,
pub max_glob_depth: usize,
pub max_glob_matches: usize,
pub proxy_max_head: usize,
pub proxy_conn_timeout_secs: u64,
pub proxy_bind: String,
#[serde(default)]
pub audit_sink: Option<String>,
}
impl Default for LimitsPolicy {
fn default() -> Self {
Self {
max_timeout_secs: 300,
default_timeout_secs: 60,
max_output_bytes: 1 << 20,
var_allowlist: to_vec(&[
"HOME", "PWD", "OLDPWD", "USER", "LOGNAME", "TMPDIR", "LANG", "LC_ALL", "SHELL",
"HOSTNAME", "TERM",
]),
max_glob_depth: 64,
max_glob_matches: 4096,
proxy_max_head: 8 * 1024,
proxy_conn_timeout_secs: 30,
proxy_bind: "127.0.0.1:0".to_string(),
audit_sink: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebPolicy {
pub max_redirects: usize,
pub default_max_bytes: usize,
pub hard_max_bytes: usize,
pub request_timeout_secs: u64,
}
impl Default for WebPolicy {
fn default() -> Self {
Self {
max_redirects: 10,
default_max_bytes: 5 * 1024 * 1024,
hard_max_bytes: 25 * 1024 * 1024,
request_timeout_secs: 30,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VmPolicy {
pub qemu_path: Vec<String>,
pub kernel_search: Vec<String>,
pub memory_mb: u32,
pub accel: String,
pub kernel_cmdline: String,
pub merged_usr_links: Vec<String>,
pub max_frame: usize,
#[serde(default)]
pub jaild_socket: Option<String>,
#[serde(default)]
pub jail_init: Option<String>,
}
impl Default for VmPolicy {
fn default() -> Self {
Self {
qemu_path: to_vec(&["/usr/bin/qemu-system-x86_64"]),
kernel_search: to_vec(&["/boot/vmlinuz"]),
memory_mb: 512,
accel: "kvm:tcg".to_string(),
kernel_cmdline: "console=ttyS0 panic=1 loglevel=4".to_string(),
merged_usr_links: to_vec(&["bin", "sbin", "lib", "lib32", "lib64", "libx32"]),
max_frame: 64 * 1024 * 1024,
jaild_socket: None,
jail_init: None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct BridleConfig {
pub mode: BridleMode,
pub gate: GatePolicy,
pub sandbox: SandboxPolicy,
pub normalization: NormalizationPolicy,
pub rootfs: RootfsPolicy,
pub net: NetPolicy,
pub limits: LimitsPolicy,
pub web: WebPolicy,
pub vm: VmPolicy,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_defaults_to_bridled() {
assert_eq!(BridleMode::default(), BridleMode::Bridled);
assert_eq!(BridleConfig::default().mode, BridleMode::Bridled);
}
#[test]
fn pathlist_extends_by_default_and_replaces_on_opt_in() {
let mut p = PathList::from_defaults(&["/a", "/b"]);
assert_eq!(p.resolve(), vec!["/a".to_string(), "/b".to_string()]);
assert!(!p.widens());
p.extra = vec!["/c".to_string(), "/a".to_string()]; assert_eq!(
p.resolve(),
vec!["/a".to_string(), "/b".to_string(), "/c".to_string()],
"extend dedups and preserves order"
);
assert!(p.widens(), "adding entries without replace is a widening");
p.replace = true;
assert_eq!(
p.resolve(),
vec!["/c".to_string(), "/a".to_string()],
"replace uses only extra (the shrink opt-in)"
);
assert!(!p.widens(), "replace is not a widening");
}
#[test]
fn gate_policy_defaults_match_constants() {
let g = GatePolicy::default();
assert_eq!(g.default_strength_floor, AxisEnforcement::Advisory);
assert_eq!(g.max_freshness_window, 4096);
assert_eq!(g.step_up, HumanGate::Passkey);
}
#[test]
fn gate_step_up_floor_round_trips_each_posture() {
for (gate, tok) in [
(HumanGate::None, "none"),
(HumanGate::Prompt, "prompt"),
(HumanGate::Passkey, "passkey"),
] {
let g = GatePolicy {
step_up: gate,
..GatePolicy::default()
};
let json = serde_json::to_value(&g).unwrap();
assert_eq!(json["step_up"], tok, "serializes snake_case");
let back: GatePolicy = serde_json::from_value(json).unwrap();
assert_eq!(back.step_up, gate);
}
}
#[test]
fn default_config_round_trips_through_json() {
let c = BridleConfig::default();
let json = serde_json::to_string(&c).unwrap();
let back: BridleConfig = serde_json::from_str(&json).unwrap();
assert_eq!(c, back);
}
#[test]
fn net_policy_defaults_to_deny_with_no_rules() {
let n = NetPolicy::default();
assert_eq!(n.default, NetDefault::Deny);
assert!(n.rules.is_empty(), "empty rules ⇒ pure exact-host behavior");
}
#[test]
fn gate_defaults_match_source_constants() {
let g = GatePolicy::default();
assert_eq!(
g.default_strength_floor,
crate::gate::DEFAULT_STRENGTH_FLOOR
);
assert_eq!(g.max_freshness_window, crate::gate::MAX_FRESHNESS_WINDOW);
}
#[test]
fn sandbox_loopback_default_matches_constant() {
assert_eq!(
SandboxPolicy::default().loopback_hosts,
to_vec(crate::sandbox::LOOPBACK_HOSTS)
);
}
#[test]
fn sandbox_path_and_abi_defaults_are_byte_for_byte() {
let d = SandboxPolicy::default();
#[cfg(target_os = "macos")]
let want_base = to_vec(&[
"/usr",
"/bin",
"/sbin",
"/System",
"/Library",
"/opt",
"/private/etc",
"/private/var/db/dyld",
"/dev",
]);
#[cfg(not(target_os = "macos"))]
let want_base = to_vec(&[
"/lib",
"/lib64",
"/lib32",
"/libx32",
"/usr/lib",
"/usr/lib64",
"/usr/libexec",
"/usr/share",
"/etc/ld.so.cache",
"/etc/ld.so.preload",
"/etc/alternatives",
"/etc/nsswitch.conf",
"/etc/localtime",
"/etc/resolv.conf",
"/etc/ssl",
"/etc/ca-certificates",
"/proc/self",
"/dev/null",
"/dev/zero",
"/dev/full",
"/dev/urandom",
"/dev/random",
]);
assert_eq!(d.base_read_paths.resolve(), want_base, "base_read drift");
assert_eq!(
d.bin_read_paths.resolve(),
to_vec(&[
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin",
"/usr/local/bin",
"/usr/local/sbin",
"/opt",
]),
"bin_read drift"
);
assert_eq!(
d.loader_paths.resolve(),
to_vec(&[
"/lib64/ld-linux-x86-64.so.2",
"/lib/ld-linux-x86-64.so.2",
"/lib/ld-linux.so.2",
"/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2",
"/lib64/ld64.so.2",
"/lib/ld-linux-aarch64.so.1",
"/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1",
"/lib/ld-linux-armhf.so.3",
"/lib/ld-musl-x86-64.so.1",
"/lib/ld-musl-aarch64.so.1",
]),
"loader drift"
);
assert_eq!(d.landlock_abi_floor, 3, "fs ABI floor drift");
assert_eq!(d.landlock_net_abi_floor, 4, "net ABI floor drift");
#[cfg(not(target_os = "macos"))]
for bin in ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] {
assert!(
!d.base_read_paths.resolve().iter().any(|p| p == bin),
"Linux base read must exclude the executable dir {bin} (trampoline corpus)"
);
}
}
}