//! OS-sandbox **contract & planning** (lot LP4-0, domaine pur). //! //! This module owns the *declarative plan* an OS sandbox adapter applies, plus //! the **pure** translation from the resolved [`EffectivePermissions`] into that //! plan. It is the domain half of the «sandbox OS» concern that the permission //! model (`permission.rs`) deliberately left out of scope. //! //! Like the rest of `domain`, it is **pure** (ARCHITECTURE dependency rule): no //! `tokio`, no `std::fs`, no `std::process`, no `landlock`. The concrete Landlock //! adapter (lot LP4-1) and the launch-path wiring (lot LP4-2) live in //! `infrastructure`/`application`; here we only define the contract //! ([`SandboxEnforcer`], [`SandboxPlan`]) and compute the plan //! ([`compile_sandbox_plan`]). //! //! ## Reality bound: Landlock locks **files only** //! //! Landlock can only restrict **filesystem** access. The command capability //! ([`crate::permission::Capability::ExecuteBash`]) cannot be enforced by the OS //! sandbox — argv matching is not a kernel concept — so command rules stay //! **advisory** (honoured by the CLI's own prompting via the LP3 projection), //! never OS-locked. [`compile_sandbox_plan`] therefore only ever derives grants //! from the three **file** capabilities. //! //! ## The fail-closed, per-access-class translation invariant //! //! Landlock is **additive only**: a grant opens a whole directory subtree and a //! sub-path cannot be carved back out. So whenever a `Deny` would fall inside (or //! over) an `Allow`'s granted root without a directory boundary cleanly //! separating them, we **drop the allow** rather than grant a root that would //! leak the denied path. We always prefer losing an allow to leaking a deny — a //! conservative under-approximation. //! //! Crucially this is computed **per access class** (read vs write/delete), never //! capability-blind: a `Deny Write` fences only the write grants, it never //! amputates a `Read` allow. This maximises agent autonomy — over-restricting a //! read because some write was denied would force the agent to keep asking, the //! opposite of the product goal. It is the same per-`capability`+target deny-wins //! as [`crate::permission`], lifted to the directory granularity Landlock works //! at and made fail-closed within each class. Encoded as a testable invariant in //! [`compile_sandbox_plan`]. use serde::{Deserialize, Serialize}; use crate::permission::{Capability, Effect, EffectivePermissions, Posture}; /// The set of filesystem accesses a [`PathGrant`] opens on its root, as an /// additive bit set. /// /// Hand-rolled (no external `bitflags` crate — the domain forbids extra deps). /// The three bits are **independent**: [`PathAccess::RW`] does not imply /// [`PathAccess::RO`]; a grant carries exactly the accesses that were posed. /// /// Capability → bit mapping used by [`compile_sandbox_plan`]: /// [`Capability::Read`] ⇒ [`PathAccess::RO`], [`Capability::Write`] and /// [`Capability::Delete`] ⇒ [`PathAccess::RW`]. [`PathAccess::EXEC`] is reserved /// for the adapter/future use — the current permission model carries no file /// execute capability, so the compiler never emits it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct PathAccess(u8); impl PathAccess { /// Read access. pub const RO: Self = Self(0b001); /// Write access (create / modify / delete). pub const RW: Self = Self(0b010); /// Execute access (reserved; not emitted by [`compile_sandbox_plan`]). pub const EXEC: Self = Self(0b100); /// The empty access set. #[must_use] pub const fn empty() -> Self { Self(0) } /// Whether `self` contains **all** bits of `other`. #[must_use] pub const fn contains(self, other: Self) -> bool { self.0 & other.0 == other.0 } /// The union of `self` and `other`. #[must_use] pub const fn union(self, other: Self) -> Self { Self(self.0 | other.0) } /// Adds the bits of `other` to `self` in place. pub fn insert(&mut self, other: Self) { self.0 |= other.0; } /// Whether no bit is set. #[must_use] pub const fn is_empty(self) -> bool { self.0 == 0 } /// The raw bits. #[must_use] pub const fn bits(self) -> u8 { self.0 } } impl std::ops::BitOr for PathAccess { type Output = Self; fn bitor(self, rhs: Self) -> Self { self.union(rhs) } } impl std::ops::BitOrAssign for PathAccess { fn bitor_assign(&mut self, rhs: Self) { self.insert(rhs); } } /// One granted directory/file root and the accesses it opens. /// /// `abs_root` is an **absolute** path (the project root joined with the glob's /// static prefix). The adapter applies it as a Landlock `path_beneath` rule. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PathGrant { /// Absolute root the grant opens (file or directory subtree). pub abs_root: String, /// Accesses opened on that root. pub access: PathAccess, } /// The declarative, OS-neutral plan an OS sandbox adapter enforces. /// /// It is a **value** (a plan), never an action: the adapter ([`SandboxEnforcer`]) /// turns it into a concrete ruleset. `allowed` may legally be empty (a policy that /// posed a posture but no file allow) — that is **not** the same as «no plan»; /// `compile_sandbox_plan` returns `None` for «no plan». #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SandboxPlan { /// The granted roots (deduplicated, deterministic order). pub allowed: Vec, /// The default posture for paths matched by no grant (mirrors /// [`EffectivePermissions::fallback`]). pub default_posture: Posture, } /// Immutable inputs [`compile_sandbox_plan`] interpolates into the absolute roots /// it emits. Borrowed: a plan is computed at the launch site, never stored. pub struct SandboxContext<'a> { /// Absolute project root (glob static prefixes are joined onto this). pub project_root: &'a str, /// Absolute isolated run dir of the agent (`.ideai/run//`). /// /// Reserved for the adapter (the run dir must stay reachable by the agent); /// the pure file-rule translation does not consume it. pub run_dir: &'a str, } /// Which concrete OS-sandbox mechanism an [`SandboxEnforcer`] implements. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SandboxKind { /// Linux Landlock LSM. Landlock, /// No OS sandbox available on this platform/build. Unsupported, } /// The outcome of applying a [`SandboxPlan`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SandboxStatus { /// The plan was fully enforced by the OS. Enforced, /// No OS sandbox is available; nothing was enforced (the caller keeps the /// advisory LP3 projection as its only guard). Unsupported, /// The plan was only **partially** enforced; the string explains what was /// dropped (e.g. an older Landlock ABI lacking a needed access right). Degraded(String), } /// Errors an [`SandboxEnforcer::enforce`] may return. /// /// An adapter raises these only for **fail-closed** situations it must surface to /// the caller (kernel too old when a `Deny` posture demands enforcement, ruleset /// application failure). A platform that simply has no sandbox does **not** error: /// it returns [`SandboxStatus::Unsupported`]. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum SandboxError { /// The running kernel / Landlock ABI is too old to honour a plan that must be /// enforced (fail-closed: a required `Deny` cannot be guaranteed). #[error("kernel/landlock too old to enforce sandbox: {0}")] KernelTooOld(String), /// Building or applying the OS ruleset failed. #[error("failed to apply sandbox ruleset: {0}")] Ruleset(String), } /// The OS-sandbox **port**: applies a [`SandboxPlan`] to the current process. /// /// ## Liskov contract /// /// `enforce` is a one-shot, **irreversible** tightening of the *current* process /// and is meant to be called **after `fork`, before `exec`, in the child** — never /// on the IdeA process itself. Every implementation must honour: /// /// - it only ever **removes** access (a sandbox never grants more than the host); /// - an [`SandboxKind::Unsupported`] adapter is a valid no-op: its `enforce` /// returns `Ok(`[`SandboxStatus::Unsupported`]`)` and changes nothing; /// - `enforce` is total for any well-formed [`SandboxPlan`]; it returns /// [`SandboxError`] only for the fail-closed cases above. pub trait SandboxEnforcer: Send + Sync { /// Which mechanism this enforcer implements. fn kind(&self) -> SandboxKind; /// Applies `plan` to the current process (post-fork / pre-exec, in the child). /// /// # Errors /// [`SandboxError`] only for fail-closed situations (cf. the type docs); an /// absent sandbox returns `Ok(`[`SandboxStatus::Unsupported`]`)`. fn enforce(&self, plan: &SandboxPlan) -> Result; } /// Compiles the resolved [`EffectivePermissions`] into a [`SandboxPlan`]. /// /// **Pure**: only computes a plan; writes nothing, touches no I/O. /// /// ## Invariants /// /// 1. **`eff == None ⇒ None`** — nothing posed ⇒ no sandbox ⇒ the agent runs /// natively (mirrors [`crate::permission::resolve`]'s product invariant). A /// `Some` result (even with an empty `allowed`) means IdeA has a policy to /// enforce. /// 2. Only the three **file** capabilities feed the grants; bash rules are /// skipped (Landlock cannot lock command execution — they stay advisory). /// 3. **Per-access-class, fail-closed roots**: grants are computed **separately /// for each access class** so an agent keeps the widest autonomy possible. /// - The **RO** class is fed by `Allow Read`; its fences are `Deny Read`. /// - The **RW** class is fed by `Allow Write` **and** `Allow Delete`; its /// fences are `Deny Write` **and** `Deny Delete`. /// /// Within a class, each `Allow` glob is reduced to its *static prefix* root and /// **dropped** if a fence **of the same class** overlaps it (equal, ancestor, or /// descendant) — an additive sandbox cannot carve a sub-path deny out of a /// granted subtree, so we lose the allow rather than leak the deny. A fence of /// the **other** class has **no effect**: a `Deny Write` never amputates an /// `Allow Read` (that would over-restrict and force the agent to keep asking). /// This is exactly the per-`capability`+target deny-wins of /// [`crate::permission`], applied at the (coarser) directory granularity Landlock /// allows, fail-closed inside each class. /// /// The surviving roots of both classes are then merged by `abs_root`, unioning /// their accesses: a root surviving only in RO ⇒ [`PathAccess::RO`]; surviving /// in both ⇒ `RO | RW`; surviving only in RW ⇒ [`PathAccess::RW`]. #[must_use] pub fn compile_sandbox_plan( eff: Option<&EffectivePermissions>, ctx: &SandboxContext, ) -> Option { let eff = eff?; // 1. Collect the static-prefix fences of every Deny file rule, **per access // class** (RO = Deny Read; RW = Deny Write/Delete). A fence only ever // blocks allows of its own class. Kept relative for the overlap tests. let mut ro_fences: Vec = Vec::new(); let mut rw_fences: Vec = Vec::new(); for rule in eff.rules() { if rule.effect() != Effect::Deny { continue; } let fences = match access_class(rule.capability()) { Some(PathAccess::RO) => &mut ro_fences, Some(PathAccess::RW) => &mut rw_fences, // Non-file (ExecuteBash) ⇒ no class ⇒ never a filesystem fence. _ => continue, }; for glob in rule.paths().globs() { fences.push(static_prefix(glob.pattern())); } } // 2. For each Allow file rule, reduce every glob to its static-prefix root and // keep it only if no same-class fence overlaps it. Merge by relative root, // unioning the access bits across classes. let mut grants: Vec<(String, PathAccess)> = Vec::new(); for rule in eff.rules() { if rule.effect() != Effect::Allow { continue; } let Some(access) = access_class(rule.capability()) else { continue; }; let fences: &[String] = if access == PathAccess::RO { &ro_fences } else { &rw_fences }; for glob in rule.paths().globs() { let rel = static_prefix(glob.pattern()); if fences.iter().any(|d| paths_conflict(&rel, d)) { // A same-class deny falls inside/over this root ⇒ we cannot grant // it for this class without leaking the deny. continue; } match grants.iter_mut().find(|(r, _)| *r == rel) { Some((_, acc)) => acc.insert(access), None => grants.push((rel, access)), } } } // 3. Join the surviving relative roots onto the absolute project root. let base = ctx.project_root.trim_end_matches('/'); let allowed = grants .into_iter() .map(|(rel, access)| PathGrant { abs_root: join_root(base, &rel), access, }) .collect(); Some(SandboxPlan { allowed, default_posture: eff.fallback(), }) } /// The access **class** a file capability belongs to: [`Capability::Read`] ⇒ /// [`PathAccess::RO`]; [`Capability::Write`]/[`Capability::Delete`] ⇒ /// [`PathAccess::RW`]. Non-file capabilities ([`Capability::ExecuteBash`]) have no /// class (`None`) — they never produce a filesystem grant or fence. fn access_class(cap: Capability) -> Option { match cap { Capability::Read => Some(PathAccess::RO), Capability::Write | Capability::Delete => Some(PathAccess::RW), Capability::ExecuteBash => None, } } /// The **static prefix** of a glob: the leading literal directory path before the /// first glob metacharacter (`*`, `?`, `[`), with any trailing `/` trimmed. /// /// - `"**"` / `"*.rs"` ⇒ `""` (the project root itself), /// - `"src/**"` / `"src/*.rs"` ⇒ `"src"`, /// - `"a/b/**/*.rs"` ⇒ `"a/b"`, /// - `"src/foo.rs"` (no metachar) ⇒ `"src/foo.rs"` (the file itself). fn static_prefix(pattern: &str) -> String { let first_meta = pattern.find(['*', '?', '[']); let literal = match first_meta { // Cut back to the last '/' *before* the metacharacter: everything up to // and including that slash is a settled directory path. Some(idx) => match pattern[..idx].rfind('/') { Some(slash) => &pattern[..slash], None => "", }, // No metacharacter: the whole pattern is a literal path. None => pattern, }; literal.trim_end_matches('/').to_string() } /// Whether two project-relative roots overlap such that an additive sandbox could /// not keep them apart: they are equal, or one is a path-ancestor of the other. fn paths_conflict(a: &str, b: &str) -> bool { a == b || is_descendant(a, b) || is_descendant(b, a) } /// Whether `child` is a **strict** path-descendant of `ancestor` (component /// boundary aware). The empty string denotes the project root, an ancestor of /// every non-empty path. fn is_descendant(child: &str, ancestor: &str) -> bool { if ancestor.is_empty() { return !child.is_empty(); } child.len() > ancestor.len() && child.starts_with(ancestor) && child.as_bytes()[ancestor.len()] == b'/' } /// Joins a relative root onto the absolute base. An empty `rel` denotes the base /// itself. fn join_root(base: &str, rel: &str) -> String { if rel.is_empty() { base.to_string() } else { format!("{base}/{rel}") } } #[cfg(test)] mod tests { use super::*; use crate::permission::{resolve, PathScope, PermissionRule, PermissionSet}; // ---- helpers --------------------------------------------------------- const ROOT: &str = "/home/anthony/proj"; fn ctx() -> SandboxContext<'static> { SandboxContext { project_root: ROOT, run_dir: "/home/anthony/proj/.ideai/run/agent-x", } } fn scope(patterns: &[&str]) -> PathScope { PathScope::new(patterns.iter().map(|s| s.to_string())).unwrap() } fn file_rule(cap: Capability, effect: Effect, patterns: &[&str]) -> PermissionRule { PermissionRule::file(cap, effect, scope(patterns)).unwrap() } /// Resolves a single-set policy into `EffectivePermissions` (project set only). fn eff(rules: Vec, fallback: Posture) -> EffectivePermissions { let set = PermissionSet::new(rules, fallback); resolve(Some(&set), None).unwrap() } fn grant<'a>(plan: &'a SandboxPlan, abs_root: &str) -> Option<&'a PathGrant> { plan.allowed.iter().find(|g| g.abs_root == abs_root) } // ---- invariant 1: presence of a policy != non-empty grants ---------- #[test] fn none_eff_yields_no_plan() { // Nothing posed ⇒ no sandbox ⇒ the agent runs natively. assert!(compile_sandbox_plan(None, &ctx()).is_none()); } #[test] fn policy_with_no_allow_still_yields_some_plan() { // A posed policy with an empty `allowed` is NOT the same as "no plan": // it must compile to `Some` with an empty grant list. let e = eff(vec![], Posture::Deny); let plan = compile_sandbox_plan(Some(&e), &ctx()).expect("a posed policy yields a plan"); assert!( plan.allowed.is_empty(), "no allow rule ⇒ no grant, but still a plan" ); // Same with only a Deny file rule present (still a policy, still no grant). let e = eff( vec![file_rule(Capability::Write, Effect::Deny, &["secret/**"])], Posture::Ask, ); let plan = compile_sandbox_plan(Some(&e), &ctx()).expect("deny-only is still a plan"); assert!(plan.allowed.is_empty()); } // ---- invariant 2: capability → access mapping; bash never a grant ---- #[test] fn read_maps_to_ro() { let e = eff( vec![file_rule(Capability::Read, Effect::Allow, &["src/**"])], Posture::Ask, ); let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); let g = grant(&plan, "/home/anthony/proj/src").expect("src granted"); assert_eq!(g.access, PathAccess::RO); assert!(!g.access.contains(PathAccess::RW)); } #[test] fn write_and_delete_map_to_rw() { let e = eff( vec![ file_rule(Capability::Write, Effect::Allow, &["out/**"]), file_rule(Capability::Delete, Effect::Allow, &["tmp/**"]), ], Posture::Ask, ); let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); assert_eq!( grant(&plan, "/home/anthony/proj/out").unwrap().access, PathAccess::RW ); assert_eq!( grant(&plan, "/home/anthony/proj/tmp").unwrap().access, PathAccess::RW ); } #[test] fn bash_only_policy_produces_no_path_grant() { // ExecuteBash is never translated to a PathGrant (Landlock = files only). let e = eff( vec![ PermissionRule::bash(Effect::Allow, vec![]), PermissionRule::bash( Effect::Deny, vec![crate::permission::CommandRule::new( crate::permission::CommandMatcher::prefix("rm ").unwrap(), Effect::Deny, )], ), ], Posture::Allow, ); let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); assert!( plan.allowed.is_empty(), "a purely-bash policy yields zero PathGrant" ); } // ---- invariant 3: fail-closed glob → static-prefix translation ------- #[test] fn root_glob_with_same_class_deny_drops_root_grant() { // Allow Read `**` (root) + a single SAME-CLASS Deny Read file ⇒ the RO root // grant is abandoned: an additive sandbox cannot carve the denied file back // out of the granted subtree (fail-closed within the RO class). let e = eff( vec![ file_rule(Capability::Read, Effect::Allow, &["**"]), file_rule(Capability::Read, Effect::Deny, &["secret.txt"]), ], Posture::Ask, ); let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); assert!( plan.allowed.is_empty(), "a same-class deny under the root fence drops the root grant" ); } #[test] fn descendant_same_class_deny_drops_overlapping_allow() { // Allow Read `src/**` (root `src`) + Deny Read `src/secret/**` // (root `src/secret`): the same-class deny is a descendant of the granted // root ⇒ the RO grant on `src` is dropped (fail-closed within RO). let e = eff( vec![ file_rule(Capability::Read, Effect::Allow, &["src/**"]), file_rule(Capability::Read, Effect::Deny, &["src/secret/**"]), ], Posture::Ask, ); let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); assert!( grant(&plan, "/home/anthony/proj/src").is_none(), "an inside same-class deny abandons the enclosing allow" ); } #[test] fn other_class_deny_does_not_amputate_read_allow() { // The DUAL of the case above and the key autonomy guarantee: a Deny of a // DIFFERENT class (Write) over a descendant must NOT touch the Read allow. // `Allow Read src/**` + `Deny Write src/secret/**` ⇒ `src` keeps its RO. let e = eff( vec![ file_rule(Capability::Read, Effect::Allow, &["src/**"]), file_rule(Capability::Write, Effect::Deny, &["src/secret/**"]), ], Posture::Ask, ); let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); assert_eq!( grant(&plan, "/home/anthony/proj/src").map(|g| g.access), Some(PathAccess::RO), "a Deny Write must never amputate an Allow Read (per-class autonomy)" ); } #[test] fn disjoint_same_class_deny_keeps_allow() { // Allow Read `src/**` + SAME-CLASS Deny Read `other/**`: disjoint roots ⇒ // grant `src` kept. Same-class fence so the test proves disjointness, not // merely a class mismatch. let e = eff( vec![ file_rule(Capability::Read, Effect::Allow, &["src/**"]), file_rule(Capability::Read, Effect::Deny, &["other/**"]), ], Posture::Ask, ); let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); assert_eq!( grant(&plan, "/home/anthony/proj/src").unwrap().access, PathAccess::RO, "a disjoint same-class deny does not touch the allow" ); } #[test] fn ancestor_same_class_deny_also_drops_allow() { // Deny Read `src/**` (root `src`) is an ancestor of Allow Read `src/sub/**` // (root `src/sub`) ⇒ same-class overlap ⇒ allow dropped (fence symmetry). let e = eff( vec![ file_rule(Capability::Read, Effect::Allow, &["src/sub/**"]), file_rule(Capability::Read, Effect::Deny, &["src/**"]), ], Posture::Ask, ); let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); assert!(grant(&plan, "/home/anthony/proj/src/sub").is_none()); } #[test] fn sibling_prefix_is_not_a_descendant() { // Component-boundary awareness: `src2` must NOT be treated as inside `src` // just because the string `src` is a prefix of `src2`. Uses a SAME-CLASS // (Deny Read) fence so the survival proves boundary-awareness, not a class // mismatch. let e = eff( vec![ file_rule(Capability::Read, Effect::Allow, &["src2/**"]), file_rule(Capability::Read, Effect::Deny, &["src/**"]), ], Posture::Ask, ); let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); assert!( grant(&plan, "/home/anthony/proj/src2").is_some(), "`src2` is a sibling of `src`, not a descendant" ); } #[test] fn accesses_union_on_a_shared_root() { // Read + Write allows on the same root merge into RO|RW on one grant. let e = eff( vec![ file_rule(Capability::Read, Effect::Allow, &["src/**"]), file_rule(Capability::Write, Effect::Allow, &["src/**"]), ], Posture::Ask, ); let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); let g = grant(&plan, "/home/anthony/proj/src").expect("single merged grant"); assert!(g.access.contains(PathAccess::RO)); assert!(g.access.contains(PathAccess::RW)); assert_eq!( plan.allowed .iter() .filter(|g| g.abs_root == "/home/anthony/proj/src") .count(), 1, "the two allows merge onto one root, not two grants" ); } #[test] fn same_root_drops_rw_but_keeps_ro_under_a_write_deny() { // Direct proof of per-access-class granularity on a single root: // `Allow Read src/**` + `Allow Write src/**` + `Deny Write src/secret/**`. // The RW class is fenced (deny descendant) ⇒ RW dropped; the RO class has no // fence ⇒ RO survives. The grant on `src` ends up RO-only. let e = eff( vec![ file_rule(Capability::Read, Effect::Allow, &["src/**"]), file_rule(Capability::Write, Effect::Allow, &["src/**"]), file_rule(Capability::Write, Effect::Deny, &["src/secret/**"]), ], Posture::Ask, ); let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); let g = grant(&plan, "/home/anthony/proj/src").expect("RO survives the write deny"); assert_eq!( g.access, PathAccess::RO, "RW class fenced out, RO class preserved on the same root" ); assert!( !g.access.contains(PathAccess::RW), "the RW grant was dropped" ); } #[test] fn static_prefix_of_literal_file_is_the_file_itself() { // A metacharacter-free allow grants exactly that file path. let e = eff( vec![file_rule(Capability::Read, Effect::Allow, &["src/lib.rs"])], Posture::Ask, ); let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); assert!(grant(&plan, "/home/anthony/proj/src/lib.rs").is_some()); } // ---- invariant 4: residual posture reflected in the plan ------------- #[test] fn default_posture_mirrors_resolved_fallback() { for posture in [Posture::Allow, Posture::Ask, Posture::Deny] { let e = eff( vec![file_rule(Capability::Read, Effect::Allow, &["src/**"])], posture, ); let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); assert_eq!(plan.default_posture, posture); } } #[test] fn trailing_slash_on_project_root_is_normalised() { let e = eff( vec![file_rule(Capability::Read, Effect::Allow, &["src/**"])], Posture::Ask, ); let ctx = SandboxContext { project_root: "/home/anthony/proj/", run_dir: "/x", }; let plan = compile_sandbox_plan(Some(&e), &ctx).unwrap(); assert!( grant(&plan, "/home/anthony/proj/src").is_some(), "the trailing slash must not produce `//`" ); } }