feat(permissions): LP4-0 — fondations domaine de l'enforcement OS (pur)
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<SandboxPlan> (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 <noreply@anthropic.com>
This commit is contained in:
@ -2331,6 +2331,7 @@ mod mcp_serve_peer_tests {
|
|||||||
cwd: cwd.clone(),
|
cwd: cwd.clone(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
context_plan: Some(ContextInjectionPlan::Stdin),
|
context_plan: Some(ContextInjectionPlan::Stdin),
|
||||||
|
sandbox: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3311,6 +3312,7 @@ mod mcp_e2e_loopback_tests {
|
|||||||
cwd: cwd.clone(),
|
cwd: cwd.clone(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
context_plan: Some(ContextInjectionPlan::Stdin),
|
context_plan: Some(ContextInjectionPlan::Stdin),
|
||||||
|
sandbox: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,6 +79,7 @@ impl OpenTerminal {
|
|||||||
cwd: cwd.clone(),
|
cwd: cwd.clone(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
context_plan: None,
|
context_plan: None,
|
||||||
|
sandbox: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// The PTY layer owns the session identity; we adopt the returned handle's
|
// The PTY layer owns the session identity; we adopt the returned handle's
|
||||||
|
|||||||
@ -336,6 +336,7 @@ impl AgentRuntime for FakeRuntime {
|
|||||||
cwd: cwd.clone(),
|
cwd: cwd.clone(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
context_plan: self.plan.clone(),
|
context_plan: self.plan.clone(),
|
||||||
|
sandbox: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -363,6 +363,7 @@ impl AgentRuntime for FakeRuntime {
|
|||||||
context_plan: Some(ContextInjectionPlan::File {
|
context_plan: Some(ContextInjectionPlan::File {
|
||||||
target: "CLAUDE.md".to_owned(),
|
target: "CLAUDE.md".to_owned(),
|
||||||
}),
|
}),
|
||||||
|
sandbox: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -270,6 +270,7 @@ impl AgentRuntime for FakeRuntime {
|
|||||||
cwd: cwd.clone(),
|
cwd: cwd.clone(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
context_plan: Some(ContextInjectionPlan::Stdin),
|
context_plan: Some(ContextInjectionPlan::Stdin),
|
||||||
|
sandbox: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -303,6 +303,7 @@ impl AgentRuntime for FakeRuntime {
|
|||||||
context_plan: Some(ContextInjectionPlan::File {
|
context_plan: Some(ContextInjectionPlan::File {
|
||||||
target: "CLAUDE.md".to_owned(),
|
target: "CLAUDE.md".to_owned(),
|
||||||
}),
|
}),
|
||||||
|
sandbox: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,6 +49,7 @@ pub mod ports;
|
|||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod readiness;
|
pub mod readiness;
|
||||||
|
pub mod sandbox;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
pub mod template;
|
pub mod template;
|
||||||
@ -119,10 +120,15 @@ pub use layout::{
|
|||||||
pub use events::{DomainEvent, OrchestrationSource};
|
pub use events::{DomainEvent, OrchestrationSource};
|
||||||
|
|
||||||
pub use permission::{
|
pub use permission::{
|
||||||
resolve as resolve_permissions, AgentPermissionOverride, Capability, CommandMatcher,
|
render_permission_summary, resolve as resolve_permissions, AgentPermissionOverride, Capability,
|
||||||
CommandRule, Effect, EffectivePermissions, Glob, PathScope, PermissionError, PermissionProjection,
|
CommandMatcher, CommandRule, Effect, EffectivePermissions, Glob, PathScope, PermissionError,
|
||||||
PermissionProjector, PermissionRule, PermissionSet, Posture, ProjectedFile, ProjectionContext,
|
PermissionProjection, PermissionProjector, PermissionRule, PermissionSet, Posture,
|
||||||
ProjectPermissions, ProjectorKey, PERMISSIONS_VERSION,
|
ProjectedFile, ProjectionContext, ProjectPermissions, ProjectorKey, PERMISSIONS_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub use sandbox::{
|
||||||
|
compile_sandbox_plan, PathAccess, PathGrant, SandboxContext, SandboxEnforcer, SandboxError,
|
||||||
|
SandboxKind, SandboxPlan, SandboxStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use orchestrator::{
|
pub use orchestrator::{
|
||||||
|
|||||||
@ -683,6 +683,111 @@ pub fn resolve(
|
|||||||
Some(EffectivePermissions { rules, fallback })
|
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<String> {
|
||||||
|
let eff = eff?;
|
||||||
|
|
||||||
|
let mut file_lines: Vec<String> = Vec::new();
|
||||||
|
let mut bash_lines: Vec<String> = 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::<Vec<_>>()
|
||||||
|
.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
|
// Per-CLI projection (lot LP3) — the PURE contract that translates the resolved
|
||||||
// `EffectivePermissions` into a concrete, file/args/env-level *plan* for one CLI.
|
// `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.agents.len(), 1);
|
||||||
assert_eq!(doc.resolve_for(agent).unwrap().fallback(), Posture::Deny);
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,6 +98,10 @@ pub struct SpawnSpec {
|
|||||||
pub env: Vec<(String, String)>,
|
pub env: Vec<(String, String)>,
|
||||||
/// How the context is injected, if any.
|
/// How the context is injected, if any.
|
||||||
pub context_plan: Option<ContextInjectionPlan>,
|
pub context_plan: Option<ContextInjectionPlan>,
|
||||||
|
/// 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<crate::sandbox::SandboxPlan>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The agent context prepared for injection (content + the on-disk path it maps
|
/// The agent context prepared for injection (content + the on-disk path it maps
|
||||||
|
|||||||
715
crates/domain/src/sandbox.rs
Normal file
715
crates/domain/src/sandbox.rs
Normal file
@ -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<PathGrant>,
|
||||||
|
/// 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/<agent-id>/`).
|
||||||
|
///
|
||||||
|
/// 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<SandboxStatus, SandboxError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<SandboxPlan> {
|
||||||
|
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<String> = Vec::new();
|
||||||
|
let mut rw_fences: Vec<String> = 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<PathAccess> {
|
||||||
|
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<PermissionRule>, 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 `//`"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -77,6 +77,7 @@ impl CliAgentRuntime {
|
|||||||
cwd,
|
cwd,
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
context_plan: None,
|
context_plan: None,
|
||||||
|
sandbox: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,6 +220,7 @@ impl AgentRuntime for CliAgentRuntime {
|
|||||||
cwd: resolved_cwd,
|
cwd: resolved_cwd,
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
context_plan: Some(plan),
|
context_plan: Some(plan),
|
||||||
|
sandbox: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -242,6 +242,7 @@ impl AgentRuntime for FakeRuntime {
|
|||||||
cwd: cwd.clone(),
|
cwd: cwd.clone(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
context_plan: Some(ContextInjectionPlan::Stdin),
|
context_plan: Some(ContextInjectionPlan::Stdin),
|
||||||
|
sandbox: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -241,6 +241,7 @@ impl AgentRuntime for FakeRuntime {
|
|||||||
cwd: cwd.clone(),
|
cwd: cwd.clone(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
context_plan: Some(ContextInjectionPlan::Stdin),
|
context_plan: Some(ContextInjectionPlan::Stdin),
|
||||||
|
sandbox: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ fn sh_spec(script: &str) -> SpawnSpec {
|
|||||||
cwd: ProjectPath::new("/").unwrap(),
|
cwd: ProjectPath::new("/").unwrap(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
context_plan: None,
|
context_plan: None,
|
||||||
|
sandbox: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user