From b05d04ab7a6be53f1c6b53830ecc388ec91c07ee Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 15 Jun 2026 21:37:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(permissions):=20LP4-0=20=E2=80=94=20fondat?= =?UTF-8?q?ions=20domaine=20de=20l'enforcement=20OS=20(pur)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fondations pures (zéro I/O, zéro dépendance landlock, aucun câblage runtime — SpawnSpec.sandbox posé mais jamais lu ⇒ zéro régression) de la voie « airtight » des permissions, complément de la voie projection LP3. - domain/sandbox.rs : SandboxPlan/PathGrant/PathAccess (RO|RW|EXEC), SandboxContext, SandboxKind/Status/Error, port SandboxEnforcer, et la fonction pure compile_sandbox_plan(EffectivePermissions → plan OS). - domain/permission.rs : render_permission_summary (bloc Markdown injecté plus tard ; mentionne explicitement fichiers OS-enforced vs commandes advisory). - domain/ports.rs : SpawnSpec.sandbox: Option (None ⇒ natif), propagé à tous les sites de construction. Sémantique de compile_sandbox_plan : - Invariant produit : eff == None ⇒ None (rien posé ⇒ CLI 100 % native). - Borne Landlock : seules les capabilities fichier produisent des grants (Read→RO, Write/Delete→RW) ; ExecuteBash reste advisory (non verrouillable par chemin). - Deny-wins PAR CLASSE D'ACCÈS (RO/RW), fail-closed intra-classe : un Deny ne ferme que les grants de sa propre classe (un Deny Write n'ampute pas un Allow Read). Choix retenu pour maximiser l'autonomie des agents : on respecte exactement la politique pré-renseignée sans sur-restreindre, donc moins de blocages qui forceraient l'agent à redemander l'utilisateur. - Globs réduits à leur préfixe statique ; grant abandonné si une barrière de même classe chevauche (égal/ancêtre/descendant) — sous-approximation conservatrice (un sandbox additif ne peut pas carver un deny sous-arbre). Tests : 16 tests purs sur sandbox + 3 sur render_permission_summary, cargo test --workspace 100 % vert, 0 ignored. Reste LP4 : LP4-1 adapter LandlockSandbox + pre_exec PTY (fail-open+warning sauf posture Deny), LP4-2 câblage application, LP4-3 composition root. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/state.rs | 2 + crates/application/src/terminal/usecases.rs | 1 + crates/application/tests/agent_lifecycle.rs | 1 + .../application/tests/change_agent_profile.rs | 1 + .../application/tests/orchestrator_service.rs | 1 + .../application/tests/structured_launch_d3.rs | 1 + crates/domain/src/lib.rs | 14 +- crates/domain/src/permission.rs | 167 ++++ crates/domain/src/ports.rs | 4 + crates/domain/src/sandbox.rs | 715 ++++++++++++++++++ crates/infrastructure/src/runtime/mod.rs | 2 + crates/infrastructure/tests/mcp_server.rs | 1 + .../tests/orchestrator_watcher.rs | 1 + crates/infrastructure/tests/pty_adapter.rs | 1 + 14 files changed, 908 insertions(+), 4 deletions(-) create mode 100644 crates/domain/src/sandbox.rs diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index b9e0694..b383983 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -2331,6 +2331,7 @@ mod mcp_serve_peer_tests { cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::Stdin), + sandbox: None, }) } } @@ -3311,6 +3312,7 @@ mod mcp_e2e_loopback_tests { cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::Stdin), + sandbox: None, }) } } diff --git a/crates/application/src/terminal/usecases.rs b/crates/application/src/terminal/usecases.rs index c9b8e03..013281b 100644 --- a/crates/application/src/terminal/usecases.rs +++ b/crates/application/src/terminal/usecases.rs @@ -79,6 +79,7 @@ impl OpenTerminal { cwd: cwd.clone(), env: Vec::new(), context_plan: None, + sandbox: None, }; // The PTY layer owns the session identity; we adopt the returned handle's diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index fe0033a..3b9988e 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -336,6 +336,7 @@ impl AgentRuntime for FakeRuntime { cwd: cwd.clone(), env: Vec::new(), context_plan: self.plan.clone(), + sandbox: None, }) } } diff --git a/crates/application/tests/change_agent_profile.rs b/crates/application/tests/change_agent_profile.rs index 315b178..defb033 100644 --- a/crates/application/tests/change_agent_profile.rs +++ b/crates/application/tests/change_agent_profile.rs @@ -363,6 +363,7 @@ impl AgentRuntime for FakeRuntime { context_plan: Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), + sandbox: None, }) } } diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index fccb5cc..fd2ba75 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -270,6 +270,7 @@ impl AgentRuntime for FakeRuntime { cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::Stdin), + sandbox: None, }) } } diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index a6b2e33..c3e1b25 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -303,6 +303,7 @@ impl AgentRuntime for FakeRuntime { context_plan: Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), + sandbox: None, }) } } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 9c38a96..da35044 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -49,6 +49,7 @@ pub mod ports; pub mod profile; pub mod project; pub mod readiness; +pub mod sandbox; pub mod remote; pub mod skill; pub mod template; @@ -119,10 +120,15 @@ pub use layout::{ pub use events::{DomainEvent, OrchestrationSource}; pub use permission::{ - resolve as resolve_permissions, AgentPermissionOverride, Capability, CommandMatcher, - CommandRule, Effect, EffectivePermissions, Glob, PathScope, PermissionError, PermissionProjection, - PermissionProjector, PermissionRule, PermissionSet, Posture, ProjectedFile, ProjectionContext, - ProjectPermissions, ProjectorKey, PERMISSIONS_VERSION, + render_permission_summary, resolve as resolve_permissions, AgentPermissionOverride, Capability, + CommandMatcher, CommandRule, Effect, EffectivePermissions, Glob, PathScope, PermissionError, + PermissionProjection, PermissionProjector, PermissionRule, PermissionSet, Posture, + ProjectedFile, ProjectionContext, ProjectPermissions, ProjectorKey, PERMISSIONS_VERSION, +}; + +pub use sandbox::{ + compile_sandbox_plan, PathAccess, PathGrant, SandboxContext, SandboxEnforcer, SandboxError, + SandboxKind, SandboxPlan, SandboxStatus, }; pub use orchestrator::{ diff --git a/crates/domain/src/permission.rs b/crates/domain/src/permission.rs index b65fd77..38e3d46 100644 --- a/crates/domain/src/permission.rs +++ b/crates/domain/src/permission.rs @@ -683,6 +683,111 @@ pub fn resolve( Some(EffectivePermissions { rules, fallback }) } +/// Renders a human-readable Markdown **summary** of the resolved policy, suitable +/// for injection into an agent's context (lot LP4-0). +/// +/// `eff == None ⇒ None` (mirrors [`resolve`]'s product invariant: nothing posed ⇒ +/// nothing to summarise). A `Some` result is a self-contained Markdown block. +/// +/// The summary makes the **enforcement boundary** explicit: file rules are locked +/// by the OS sandbox **when supported** (Landlock), whereas command rules +/// ([`Capability::ExecuteBash`]) remain **advisory** — honoured only by the CLI's +/// own prompting, never by the OS. This honesty is load-bearing: an agent must not +/// believe a command deny is airtight. +#[must_use] +pub fn render_permission_summary(eff: Option<&EffectivePermissions>) -> Option { + let eff = eff?; + + let mut file_lines: Vec = Vec::new(); + let mut bash_lines: Vec = Vec::new(); + for rule in eff.rules() { + if rule.capability().is_file() { + let verb = match rule.effect() { + Effect::Allow => "Allow", + Effect::Deny => "Deny", + }; + let cap = match rule.capability() { + Capability::Read => "read", + Capability::Write => "write", + Capability::Delete => "delete", + Capability::ExecuteBash => "run", // unreachable (is_file filtered) + }; + let scope = if rule.paths().is_empty() { + "(nothing)".to_string() + } else { + rule.paths() + .globs() + .iter() + .map(|g| format!("`{}`", g.pattern())) + .collect::>() + .join(", ") + }; + file_lines.push(format!("- {verb} {cap}: {scope}")); + } else { + // Bash rule: blanket (empty commands) or per-command. + if rule.commands().is_empty() { + let verb = match rule.effect() { + Effect::Allow => "Allow", + Effect::Deny => "Deny", + }; + bash_lines.push(format!("- {verb}: every command")); + } else { + for cmd in rule.commands() { + let verb = match cmd.effect { + Effect::Allow => "Allow", + Effect::Deny => "Deny", + }; + let shown = match &cmd.matcher { + CommandMatcher::Exact(s) => format!("`{s}` (exact)"), + CommandMatcher::Prefix(s) => format!("`{s}*` (prefix)"), + CommandMatcher::Glob(g) => format!("`{}` (glob)", g.pattern()), + }; + bash_lines.push(format!("- {verb}: {shown}")); + } + } + } + } + + let mut out = String::new(); + out.push_str("## Permissions (IdeA)\n\n"); + out.push_str(&format!( + "**Default posture:** {}\n\n", + match eff.fallback() { + Posture::Allow => "Allow", + Posture::Ask => "Ask", + Posture::Deny => "Deny", + } + )); + + out.push_str("### Files — OS-enforced when the sandbox is supported (Landlock)\n"); + if file_lines.is_empty() { + out.push_str("- (no file rule; only the default posture applies)\n"); + } else { + for line in &file_lines { + out.push_str(line); + out.push('\n'); + } + } + out.push('\n'); + + out.push_str("### Commands — advisory, NOT OS-locked\n"); + out.push_str( + "These rules are honoured only by the AI CLI's own prompting, not by the OS \ +sandbox: Landlock locks file access only, so command execution (`ExecuteBash`) \ +cannot be sandboxed and stays advisory.\n", + ); + if bash_lines.is_empty() { + out.push_str("- (no command rule; only the default posture applies)\n"); + } else { + for line in &bash_lines { + out.push_str(line); + out.push('\n'); + } + } + + Some(out) +} + // --------------------------------------------------------------------------- // Per-CLI projection (lot LP3) — the PURE contract that translates the resolved // `EffectivePermissions` into a concrete, file/args/env-level *plan* for one CLI. @@ -1312,4 +1417,66 @@ mod tests { assert_eq!(doc.agents.len(), 1); assert_eq!(doc.resolve_for(agent).unwrap().fallback(), Posture::Deny); } + + // ---- LP4-0: render_permission_summary ------------------------------- + + #[test] + fn summary_is_none_when_nothing_posed() { + // Mirrors resolve's product invariant: nothing posed ⇒ nothing to render. + assert!(render_permission_summary(None).is_none()); + } + + #[test] + fn summary_states_files_os_enforced_and_commands_advisory() { + // The honesty invariant: files are OS-enforced (Landlock when supported) + // while commands stay advisory / NOT OS-locked. Both must be explicit. + let set = PermissionSet::new( + vec![ + PermissionRule::file(Capability::Read, Effect::Allow, path_scope(&["src/**"])) + .unwrap(), + PermissionRule::bash( + Effect::Deny, + vec![CommandRule::new( + CommandMatcher::prefix("rm ").unwrap(), + Effect::Deny, + )], + ), + ], + Posture::Ask, + ); + let eff = resolve(Some(&set), None).unwrap(); + let md = render_permission_summary(Some(&eff)).expect("a posed policy renders a summary"); + + // Files: OS-enforced / Landlock when supported. + assert!(md.contains("OS-enforced"), "files block must say OS-enforced"); + assert!(md.contains("Landlock"), "files block must name Landlock"); + // Commands: advisory, NOT OS-locked, and the why (ExecuteBash). + assert!(md.contains("advisory"), "commands must be called advisory"); + assert!( + md.contains("NOT OS-locked"), + "commands must be flagged NOT OS-locked" + ); + assert!( + md.contains("ExecuteBash"), + "the rationale must name the ExecuteBash capability" + ); + // The actual rules surface in their respective sections. + assert!(md.contains("`src/**`"), "the file scope is shown"); + assert!(md.contains("`rm *` (prefix)"), "the command matcher is shown"); + // Resolved posture is surfaced. + assert!(md.contains("**Default posture:** Ask")); + } + + #[test] + fn summary_handles_empty_rule_lists_per_section() { + // A policy with only a fallback still renders both sections honestly. + let set = PermissionSet::new(vec![], Posture::Deny); + let eff = resolve(Some(&set), None).unwrap(); + let md = render_permission_summary(Some(&eff)).unwrap(); + assert!(md.contains("no file rule")); + assert!(md.contains("no command rule")); + // The enforcement-boundary wording is present even with no rules. + assert!(md.contains("OS-enforced") && md.contains("NOT OS-locked")); + assert!(md.contains("**Default posture:** Deny")); + } } diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 627e236..84238ef 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -98,6 +98,10 @@ pub struct SpawnSpec { pub env: Vec<(String, String)>, /// How the context is injected, if any. pub context_plan: Option, + /// OS-sandbox plan to enforce on the spawned process (lot LP4). `None` ⇒ no + /// enforcement (the agent runs natively); the field is wired but unused until + /// the Landlock adapter (LP4-1) and launch path (LP4-2) consume it. + pub sandbox: Option, } /// The agent context prepared for injection (content + the on-disk path it maps diff --git a/crates/domain/src/sandbox.rs b/crates/domain/src/sandbox.rs new file mode 100644 index 0000000..9af337e --- /dev/null +++ b/crates/domain/src/sandbox.rs @@ -0,0 +1,715 @@ +//! 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::{PathScope, PermissionRule, PermissionSet, resolve}; + + // ---- 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 `//`" + ); + } +} diff --git a/crates/infrastructure/src/runtime/mod.rs b/crates/infrastructure/src/runtime/mod.rs index b17d1a2..0e2a730 100644 --- a/crates/infrastructure/src/runtime/mod.rs +++ b/crates/infrastructure/src/runtime/mod.rs @@ -77,6 +77,7 @@ impl CliAgentRuntime { cwd, env: Vec::new(), context_plan: None, + sandbox: None, }) } @@ -219,6 +220,7 @@ impl AgentRuntime for CliAgentRuntime { cwd: resolved_cwd, env: Vec::new(), context_plan: Some(plan), + sandbox: None, }) } } diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 762d8d1..bbff51f 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -242,6 +242,7 @@ impl AgentRuntime for FakeRuntime { cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::Stdin), + sandbox: None, }) } } diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index 81cce99..5806837 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -241,6 +241,7 @@ impl AgentRuntime for FakeRuntime { cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::Stdin), + sandbox: None, }) } } diff --git a/crates/infrastructure/tests/pty_adapter.rs b/crates/infrastructure/tests/pty_adapter.rs index a07f60a..f2ecb92 100644 --- a/crates/infrastructure/tests/pty_adapter.rs +++ b/crates/infrastructure/tests/pty_adapter.rs @@ -26,6 +26,7 @@ fn sh_spec(script: &str) -> SpawnSpec { cwd: ProjectPath::new("/").unwrap(), env: Vec::new(), context_plan: None, + sandbox: None, } }