Files
IdeA/crates/domain/src/permission.rs
Blomios b05d04ab7a 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>
2026-06-15 21:37:34 +02:00

1483 lines
51 KiB
Rust

//! Agent permission model (cadrage « permissions des agents », lot LP0).
//!
//! A **pure, declarative** model of what an agent may do on its project root,
//! plus the single pure function that **resolves** a project-level and an
//! agent-level [`PermissionSet`] into the normalised [`EffectivePermissions`]
//! consumed by the (later) projectors that translate it to a concrete CLI
//! permission config (Claude `settings.json`, Codex sandbox…).
//!
//! This module is **pure** (ARCHITECTURE dependency rule): no `tokio`, no
//! `std::fs`, no `std::process`. It owns the value objects, their validating
//! constructors and invariants, and the [`resolve`] function. The store, the
//! OS-sandbox application and the per-CLI projection are **out of scope** here
//! (lots LP1/LP2/LP3) and live in `application`/`infrastructure`.
//!
//! ## The product invariant of [`resolve`]
//!
//! When **nothing** is posed (neither a project nor an agent set), [`resolve`]
//! returns [`None`]. A `None` result means «we project nothing» — the AI engine
//! keeps its own native prompting at run time. Any `Some` result means IdeA has
//! an explicit policy to project. This distinction is load-bearing: it is what
//! keeps an unconfigured project on the CLI's native behaviour instead of
//! silently locking it down.
//!
//! ## Deny-wins
//!
//! For a given *capability + concrete target*, a matching `Deny` (whether it
//! comes from the project set or the agent set, at the rule level or a command
//! level) **always** wins over any `Allow`. It is never overridable by a more
//! specific allow. The agent set may only **tighten** the inherited policy
//! (add denies, raise the fallback posture); it cannot loosen a project deny.
use serde::{Deserialize, Serialize};
use crate::ids::AgentId;
use crate::validation::relative_safe;
/// Current schema version for `.ideai/permissions.json`.
pub const PERMISSIONS_VERSION: u32 = 1;
/// What an agent may attempt. Closed set.
///
/// [`Capability::ExecuteBash`] is the **only** capability that carries
/// [`CommandRule`]s; the three file capabilities ([`Capability::Read`],
/// [`Capability::Write`], [`Capability::Delete`]) carry a [`PathScope`] instead.
/// This split is enforced by [`PermissionRule::new`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Capability {
/// Read a file under the project root.
Read,
/// Write (create/modify) a file under the project root.
Write,
/// Delete a file under the project root.
Delete,
/// Run a shell command.
ExecuteBash,
}
impl Capability {
/// Whether this capability is one of the three **file** capabilities
/// (scoped by paths, never by commands).
#[must_use]
pub const fn is_file(self) -> bool {
matches!(self, Self::Read | Self::Write | Self::Delete)
}
/// Whether this capability is [`Capability::ExecuteBash`] (scoped by
/// commands, never by paths).
#[must_use]
pub const fn is_bash(self) -> bool {
matches!(self, Self::ExecuteBash)
}
}
/// The verdict a [`PermissionRule`] or [`CommandRule`] carries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Effect {
/// Grant the capability for the matched target.
Allow,
/// Forbid the capability for the matched target. Wins over any `Allow`.
Deny,
}
/// The default stance applied when **no** rule yields a verdict for a target.
///
/// Unlike [`Effect`], a posture has a third, neutral value [`Posture::Ask`]:
/// «no decision posed, let the engine prompt». Postures are ordered by
/// restrictiveness (`Allow < Ask < Deny`) so an agent set can only **tighten**
/// an inherited fallback (cf. [`Posture::tighten`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Posture {
/// Defer to the engine's native prompting.
Ask,
/// Allow by default.
Allow,
/// Deny by default.
Deny,
}
impl Posture {
/// Restrictiveness rank used by [`Posture::tighten`]: `Allow < Ask < Deny`.
#[must_use]
const fn restrictiveness(self) -> u8 {
match self {
Self::Allow => 0,
Self::Ask => 1,
Self::Deny => 2,
}
}
/// Returns the **more restrictive** of `self` and `other`.
///
/// Used to merge the project and agent fallbacks: the agent may resserrer
/// (raise restrictiveness) but never loosen the project's stance.
#[must_use]
pub fn tighten(self, other: Self) -> Self {
if other.restrictiveness() >= self.restrictiveness() {
other
} else {
self
}
}
}
/// A single glob pattern, validated **non-empty and compilable**.
///
/// The supported syntax (pure, no external glob crate) is the usual shell glob:
/// `*` (any run of non-`/` chars), `**` (any run including `/`), `?` (one
/// non-`/` char), `[...]` character classes (with ranges `a-z` and negation via
/// a leading `!`/`^`), everything else literal. Compilability here means the
/// pattern is non-empty and every `[` opens a terminated character class.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Glob(String);
impl Glob {
/// Builds a validated glob.
///
/// # Errors
/// - [`PermissionError::EmptyGlob`] if `pattern` is empty.
/// - [`PermissionError::InvalidGlob`] if a character class is unterminated.
pub fn new(pattern: impl Into<String>) -> Result<Self, PermissionError> {
let pattern = pattern.into();
if pattern.is_empty() {
return Err(PermissionError::EmptyGlob);
}
validate_glob(&pattern).map_err(|reason| PermissionError::InvalidGlob {
pattern: pattern.clone(),
reason,
})?;
Ok(Self(pattern))
}
/// The raw pattern.
#[must_use]
pub fn pattern(&self) -> &str {
&self.0
}
/// Whether this glob matches `text` in full.
#[must_use]
pub fn matches(&self, text: &str) -> bool {
let pat: Vec<char> = self.0.chars().collect();
let txt: Vec<char> = text.chars().collect();
glob_match(&pat, &txt)
}
}
/// A set of globs, **all relative to the project root**.
///
/// Every glob must be relative, must not escape the root via `..`, and must not
/// be an absolute path — the same guard [`crate::profile::ContextInjection`]
/// applies to its convention-file targets. An empty [`PathScope`] matches
/// nothing.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PathScope {
globs: Vec<Glob>,
}
impl PathScope {
/// Builds a validated path scope.
///
/// # Errors
/// - any error from [`Glob::new`] (empty / uncompilable pattern);
/// - [`PermissionError::PathNotRelativeSafe`] if a pattern is absolute or
/// contains a `..` traversal component.
pub fn new(patterns: impl IntoIterator<Item = String>) -> Result<Self, PermissionError> {
let mut globs = Vec::new();
for pattern in patterns {
// Path-safety is a PathScope concern (commands are not paths), so we
// check it here rather than inside `Glob`.
relative_safe(&pattern).map_err(|_| PermissionError::PathNotRelativeSafe {
pattern: pattern.clone(),
})?;
globs.push(Glob::new(pattern)?);
}
Ok(Self { globs })
}
/// An empty scope (matches nothing).
#[must_use]
pub fn empty() -> Self {
Self { globs: Vec::new() }
}
/// The globs of this scope.
#[must_use]
pub fn globs(&self) -> &[Glob] {
&self.globs
}
/// Whether the scope is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.globs.is_empty()
}
/// Whether any glob matches `path`.
#[must_use]
pub fn matches(&self, path: &str) -> bool {
self.globs.iter().any(|g| g.matches(path))
}
}
/// How a [`CommandRule`] matches a candidate shell command.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
pub enum CommandMatcher {
/// Matches the command verbatim.
Exact(String),
/// Matches when the command starts with this prefix.
Prefix(String),
/// Matches via a [`Glob`].
Glob(Glob),
}
impl CommandMatcher {
/// Builds an [`CommandMatcher::Exact`] matcher.
///
/// # Errors
/// [`PermissionError::EmptyCommandMatcher`] if `value` is empty.
pub fn exact(value: impl Into<String>) -> Result<Self, PermissionError> {
let value = value.into();
if value.is_empty() {
return Err(PermissionError::EmptyCommandMatcher);
}
Ok(Self::Exact(value))
}
/// Builds a [`CommandMatcher::Prefix`] matcher.
///
/// # Errors
/// [`PermissionError::EmptyCommandMatcher`] if `value` is empty.
pub fn prefix(value: impl Into<String>) -> Result<Self, PermissionError> {
let value = value.into();
if value.is_empty() {
return Err(PermissionError::EmptyCommandMatcher);
}
Ok(Self::Prefix(value))
}
/// Builds a [`CommandMatcher::Glob`] matcher.
///
/// # Errors
/// any error from [`Glob::new`].
pub fn glob(pattern: impl Into<String>) -> Result<Self, PermissionError> {
Ok(Self::Glob(Glob::new(pattern)?))
}
/// Whether this matcher matches `command`.
#[must_use]
pub fn matches(&self, command: &str) -> bool {
match self {
Self::Exact(s) => command == s,
Self::Prefix(s) => command.starts_with(s.as_str()),
Self::Glob(g) => g.matches(command),
}
}
}
/// A per-command verdict carried by a bash [`PermissionRule`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandRule {
/// How the command is matched.
pub matcher: CommandMatcher,
/// The verdict applied to a matched command.
pub effect: Effect,
}
impl CommandRule {
/// Builds a command rule.
#[must_use]
pub fn new(matcher: CommandMatcher, effect: Effect) -> Self {
Self { matcher, effect }
}
}
/// One permission rule: a capability, a rule-level [`Effect`], a [`PathScope`]
/// (file capabilities) **or** a list of [`CommandRule`]s ([`Capability::ExecuteBash`]).
///
/// Built through [`PermissionRule::file`] / [`PermissionRule::bash`] which
/// enforce the structural invariants. The fields are read-only accessors.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionRule {
capability: Capability,
effect: Effect,
#[serde(default)]
paths: PathScope,
#[serde(default)]
commands: Vec<CommandRule>,
}
impl PermissionRule {
/// Builds a **file** rule (`Read`/`Write`/`Delete`) scoped by `paths`.
///
/// # Errors
/// - [`PermissionError::NotAFileCapability`] if `capability` is
/// [`Capability::ExecuteBash`].
pub fn file(
capability: Capability,
effect: Effect,
paths: PathScope,
) -> Result<Self, PermissionError> {
if !capability.is_file() {
return Err(PermissionError::NotAFileCapability { capability });
}
Ok(Self {
capability,
effect,
paths,
commands: Vec::new(),
})
}
/// Builds a **bash** rule ([`Capability::ExecuteBash`]).
///
/// A rule with an **empty** `commands` list applies its `effect` as a
/// blanket verdict to every command. A rule with a **non-empty** `commands`
/// list contributes a verdict only for matched commands (the rule-level
/// `effect` is not consulted for unmatched commands — they fall through to
/// the resolved fallback).
#[must_use]
pub fn bash(effect: Effect, commands: Vec<CommandRule>) -> Self {
Self {
capability: Capability::ExecuteBash,
effect,
paths: PathScope::empty(),
commands,
}
}
/// Generic validating constructor used by deserialised input.
///
/// # Errors
/// - [`PermissionError::BashRuleHasPaths`] if a bash rule carries a
/// non-empty [`PathScope`];
/// - [`PermissionError::FileRuleHasCommands`] if a file rule carries
/// commands.
pub fn new(
capability: Capability,
effect: Effect,
paths: PathScope,
commands: Vec<CommandRule>,
) -> Result<Self, PermissionError> {
if capability.is_bash() {
if !paths.is_empty() {
return Err(PermissionError::BashRuleHasPaths);
}
} else if !commands.is_empty() {
return Err(PermissionError::FileRuleHasCommands);
}
Ok(Self {
capability,
effect,
paths,
commands,
})
}
/// The capability this rule governs.
#[must_use]
pub fn capability(&self) -> Capability {
self.capability
}
/// The rule-level effect.
#[must_use]
pub fn effect(&self) -> Effect {
self.effect
}
/// The path scope (empty for bash rules).
#[must_use]
pub fn paths(&self) -> &PathScope {
&self.paths
}
/// The command rules (empty for file rules).
#[must_use]
pub fn commands(&self) -> &[CommandRule] {
&self.commands
}
}
/// A bundle of [`PermissionRule`]s plus the default [`Posture`] applied when no
/// rule yields a verdict.
///
/// A set may legally contain **both** an `Allow` and a `Deny` rule for the same
/// capability (over different scopes): deny-wins is resolved per target, not per
/// capability.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionSet {
rules: Vec<PermissionRule>,
fallback: Posture,
}
impl PermissionSet {
/// Builds a permission set from already-validated rules.
#[must_use]
pub fn new(rules: Vec<PermissionRule>, fallback: Posture) -> Self {
Self { rules, fallback }
}
/// The rules of this set.
#[must_use]
pub fn rules(&self) -> &[PermissionRule] {
&self.rules
}
/// The fallback posture of this set.
#[must_use]
pub fn fallback(&self) -> Posture {
self.fallback
}
}
/// One agent-specific permission override stored in `.ideai/permissions.json`.
///
/// The agent policy is intentionally separate from `agents.json`: permissions are
/// a project security concern, not part of the agent's identity/template link.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentPermissionOverride {
/// The agent whose default project permissions are tightened.
pub agent_id: AgentId,
/// The agent-specific policy.
pub permissions: PermissionSet,
}
impl AgentPermissionOverride {
/// Builds an override for `agent_id`.
#[must_use]
pub const fn new(agent_id: AgentId, permissions: PermissionSet) -> Self {
Self {
agent_id,
permissions,
}
}
}
/// Persisted project permission document (`.ideai/permissions.json`).
///
/// `project_defaults == None` means the project does not define an IdeA-level
/// policy; engines keep their legacy/native behavior unless an agent override is
/// present. Agent entries are sparse: only agents with a custom policy are listed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectPermissions {
/// Schema version.
pub version: u32,
/// Project-level defaults inherited by every agent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_defaults: Option<PermissionSet>,
/// Sparse agent-specific overrides.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agents: Vec<AgentPermissionOverride>,
}
impl Default for ProjectPermissions {
fn default() -> Self {
Self {
version: PERMISSIONS_VERSION,
project_defaults: None,
agents: Vec::new(),
}
}
}
impl ProjectPermissions {
/// Builds a document and normalises duplicate agent entries by keeping the
/// last value for a given agent id.
#[must_use]
pub fn new(
project_defaults: Option<PermissionSet>,
agents: Vec<AgentPermissionOverride>,
) -> Self {
let mut doc = Self {
version: PERMISSIONS_VERSION,
project_defaults,
agents: Vec::new(),
};
for entry in agents {
doc.set_agent_permissions(entry.agent_id, Some(entry.permissions));
}
doc
}
/// Returns the agent-specific override, if any.
#[must_use]
pub fn agent_permissions(&self, agent_id: AgentId) -> Option<&PermissionSet> {
self.agents
.iter()
.find(|entry| entry.agent_id == agent_id)
.map(|entry| &entry.permissions)
}
/// Replaces the project defaults.
pub fn set_project_defaults(&mut self, defaults: Option<PermissionSet>) {
self.project_defaults = defaults;
}
/// Replaces or removes one agent override.
pub fn set_agent_permissions(&mut self, agent_id: AgentId, permissions: Option<PermissionSet>) {
self.agents.retain(|entry| entry.agent_id != agent_id);
if let Some(permissions) = permissions {
self.agents
.push(AgentPermissionOverride::new(agent_id, permissions));
}
}
/// Resolves effective permissions for `agent_id`.
#[must_use]
pub fn resolve_for(&self, agent_id: AgentId) -> Option<EffectivePermissions> {
resolve(
self.project_defaults.as_ref(),
self.agent_permissions(agent_id),
)
}
}
/// The normalised, flattened output of [`resolve`] — the **sole input** of the
/// future per-CLI projectors.
///
/// It holds the union of the project and agent rules (project first, then agent
/// superposed) plus the resolved fallback. Deny-wins is applied at decision time
/// by [`EffectivePermissions::decide_file`] / [`EffectivePermissions::decide_bash`]
/// so the projectors can either emit the raw allow/deny lists or ask for a
/// concrete verdict.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EffectivePermissions {
rules: Vec<PermissionRule>,
fallback: Posture,
}
impl EffectivePermissions {
/// All flattened rules (project rules followed by agent rules).
#[must_use]
pub fn rules(&self) -> &[PermissionRule] {
&self.rules
}
/// The resolved fallback posture.
#[must_use]
pub fn fallback(&self) -> Posture {
self.fallback
}
/// Resolves the verdict for a **file** capability against `path`.
///
/// Deny-wins: any matching `Deny` (project or agent) yields
/// [`Posture::Deny`]; otherwise any matching `Allow` yields
/// [`Posture::Allow`]; otherwise the [`EffectivePermissions::fallback`].
///
/// A bash capability passed here always falls through to the fallback (file
/// rules and bash rules never cross).
#[must_use]
pub fn decide_file(&self, capability: Capability, path: &str) -> Posture {
let mut allowed = false;
for rule in &self.rules {
if rule.capability != capability || !rule.capability.is_file() {
continue;
}
if rule.paths.matches(path) {
match rule.effect {
Effect::Deny => return Posture::Deny,
Effect::Allow => allowed = true,
}
}
}
if allowed {
Posture::Allow
} else {
self.fallback
}
}
/// Resolves the verdict for running `command`.
///
/// Each bash rule contributes a verdict: a rule with a non-empty `commands`
/// list contributes the effect of every matching [`CommandRule`]; a rule
/// with an empty list contributes its rule-level effect for every command.
/// Deny-wins across every contribution; absent any contribution, the
/// fallback applies.
#[must_use]
pub fn decide_bash(&self, command: &str) -> Posture {
let mut allowed = false;
for rule in &self.rules {
if !rule.capability.is_bash() {
continue;
}
if rule.commands.is_empty() {
// Blanket bash rule: applies to every command.
match rule.effect {
Effect::Deny => return Posture::Deny,
Effect::Allow => allowed = true,
}
} else {
for cmd in &rule.commands {
if cmd.matcher.matches(command) {
match cmd.effect {
Effect::Deny => return Posture::Deny,
Effect::Allow => allowed = true,
}
}
}
}
}
if allowed {
Posture::Allow
} else {
self.fallback
}
}
}
/// Resolves a project-level and an agent-level [`PermissionSet`] into the
/// normalised [`EffectivePermissions`].
///
/// 1. **`project == None && agent == None ⇒ None`** — nothing posed, nothing
/// projected; the engine keeps its native prompting (the product invariant).
/// 2. Otherwise the project rules are taken as the inherited base and the agent
/// rules are superposed on top (`project` first, then `agent`).
/// 3. The fallback is the **more restrictive** of the two present fallbacks
/// ([`Posture::tighten`]): the agent may resserrer, never loosen.
///
/// Deny-wins itself is enforced by the decision methods of the returned
/// [`EffectivePermissions`], over the union of both sets' rules — a project deny
/// is therefore never overridable by an agent allow.
///
/// The function is **total** and **deterministic**.
#[must_use]
pub fn resolve(
project: Option<&PermissionSet>,
agent: Option<&PermissionSet>,
) -> Option<EffectivePermissions> {
if project.is_none() && agent.is_none() {
return None;
}
let mut rules = Vec::new();
if let Some(p) = project {
rules.extend(p.rules.iter().cloned());
}
if let Some(a) = agent {
rules.extend(a.rules.iter().cloned());
}
let fallback = match (project, agent) {
(Some(p), Some(a)) => p.fallback.tighten(a.fallback),
(Some(p), None) => p.fallback,
(None, Some(a)) => a.fallback,
// Unreachable: the both-`None` case returned above.
(None, None) => Posture::Ask,
};
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
// `EffectivePermissions` into a concrete, file/args/env-level *plan* for one CLI.
// This module owns only the contract (a value + a trait); the concrete Claude /
// Codex projectors and the launch-time application live in `infrastructure`.
// ---------------------------------------------------------------------------
/// Stable key identifying **which** per-CLI projector a profile uses.
///
/// Closed set, mirroring [`crate::profile::StructuredAdapter`]: a projector is a
/// declarative choice (data, not code), and each variant has exactly one concrete
/// implementation in `infrastructure`. A closed enum (rather than a `String`
/// newtype) is the idiomatic shape here — it matches the other engine-family keys
/// of the domain, makes the match in the launch path exhaustive, and cannot carry
/// an unknown value at run time.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ProjectorKey {
/// Projects to Claude Code's `settings.json` permission shape.
Claude,
/// Projects to Codex's sandbox / `config.toml` permission shape.
Codex,
}
/// Immutable inputs a [`PermissionProjector`] may interpolate into the paths it
/// emits. Borrowed (no ownership): a projection is computed and consumed at the
/// launch site, never stored.
pub struct ProjectionContext<'a> {
/// Absolute project root.
pub project_root: &'a str,
/// Absolute isolated run dir of the agent (`.ideai/run/<agent-id>/`).
pub run_dir: &'a str,
}
/// One file a projector wants materialised at launch, tagged by **ownership**.
///
/// Ownership decides the launch/swap lifecycle: a [`Self::Replace`] file is 100 %
/// IdeA's (clobbered at launch, removed when the agent swaps away from this
/// projector), whereas a [`Self::MergeToml`] file is co-owned with the CLI (only
/// the managed tables/keys are merged in, and it is **never** deleted on swap).
pub enum ProjectedFile {
/// File fully owned by IdeA → clobber at launch, delete on swap-away.
Replace {
/// Run-dir-relative path of the file.
rel_path: String,
/// Full contents to write.
contents: String,
},
/// File co-owned with the CLI (e.g. Codex `config.toml`) → merge only the
/// managed tables/keys, never deleted on swap.
MergeToml {
/// Run-dir-relative path of the file.
rel_path: String,
/// TOML tables IdeA manages (everything else is preserved).
managed_tables: Vec<String>,
/// Top-level keys IdeA manages (everything else is preserved).
managed_keys: Vec<String>,
/// The managed fragment to merge in.
contents: String,
},
}
/// The concrete, CLI-specific **plan** a [`PermissionProjector`] produces from the
/// resolved [`EffectivePermissions`]: the files to materialise, plus any launch
/// args and environment variables. It is a *value* (a plan), not an action — the
/// infrastructure applies it.
pub struct PermissionProjection {
/// Files to materialise (ownership-tagged, cf. [`ProjectedFile`]).
pub files: Vec<ProjectedFile>,
/// Extra launch arguments the CLI needs to honour the policy.
pub args: Vec<String>,
/// Extra environment variables (`name`, `value`).
pub env: Vec<(String, String)>,
}
impl PermissionProjection {
/// The empty projection: nothing materialised, no args, no env.
///
/// This is the **invariant value** a projector returns when `eff == None`:
/// nothing is posed ⇒ we project nothing and the CLI keeps its native
/// prompting (mirrors [`resolve`]'s product invariant).
#[must_use]
pub fn empty() -> Self {
Self {
files: Vec::new(),
args: Vec::new(),
env: Vec::new(),
}
}
}
/// Translates the resolved [`EffectivePermissions`] into a CLI-specific
/// [`PermissionProjection`].
///
/// **Pure**: an implementation only *computes* a plan; it writes nothing and
/// touches no I/O. The launch path (infrastructure) is what materialises the
/// returned files / args / env. Concrete impls (Claude, Codex) live in
/// `infrastructure`, keyed by [`ProjectorKey`].
pub trait PermissionProjector: Send + Sync {
/// The key this projector answers to.
fn key(&self) -> ProjectorKey;
/// Computes the projection.
///
/// `eff == None` ⇒ the **empty** projection ([`PermissionProjection::empty`]):
/// we keep the CLI's native prompting (the product invariant of [`resolve`]).
fn project(&self, eff: Option<&EffectivePermissions>, ctx: &ProjectionContext)
-> PermissionProjection;
/// Run-dir-relative paths of the [`ProjectedFile::Replace`] files this
/// projector owns, so the swap path can clean them up when an agent moves
/// away from this projector.
fn owned_replace_paths(&self) -> Vec<String>;
}
/// Errors raised by the validating constructors of this module.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PermissionError {
/// A glob pattern was empty.
#[error("glob pattern must not be empty")]
EmptyGlob,
/// A glob pattern was not compilable.
#[error("glob `{pattern}` is not compilable: {reason}")]
InvalidGlob {
/// The offending pattern.
pattern: String,
/// Why it failed to compile.
reason: String,
},
/// An `Exact`/`Prefix` command matcher was empty.
#[error("command matcher must not be empty")]
EmptyCommandMatcher,
/// A path-scope glob was absolute or contained a `..` traversal component.
#[error("path glob `{pattern}` must be relative and must not contain `..`")]
PathNotRelativeSafe {
/// The offending pattern.
pattern: String,
},
/// A bash rule carried a non-empty path scope.
#[error("an ExecuteBash rule must not carry a path scope")]
BashRuleHasPaths,
/// A file rule carried command rules.
#[error("a Read/Write/Delete rule must not carry commands")]
FileRuleHasCommands,
/// [`PermissionRule::file`] was given [`Capability::ExecuteBash`].
#[error("expected a file capability (Read/Write/Delete), got {capability:?}")]
NotAFileCapability {
/// The offending capability.
capability: Capability,
},
}
// ---------------------------------------------------------------------------
// Pure glob engine (no external crate; supports `*`, `**`, `?`, `[...]`).
// ---------------------------------------------------------------------------
/// Validates that every `[` in `pattern` opens a terminated character class.
fn validate_glob(pattern: &str) -> Result<(), String> {
let chars: Vec<char> = pattern.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '[' {
i = class_end(&chars, i)
.ok_or_else(|| "unterminated character class `[`".to_string())?;
} else {
i += 1;
}
}
Ok(())
}
/// Given `chars[start] == '['`, returns the index **after** the closing `]`, or
/// `None` if the class is unterminated. A `]` immediately after the (optional)
/// negation marker is treated as a literal member (POSIX rule).
fn class_end(chars: &[char], start: usize) -> Option<usize> {
let mut i = start + 1;
if i < chars.len() && (chars[i] == '!' || chars[i] == '^') {
i += 1;
}
// A leading ']' is a literal member, not the terminator.
if i < chars.len() && chars[i] == ']' {
i += 1;
}
while i < chars.len() {
if chars[i] == ']' {
return Some(i + 1);
}
i += 1;
}
None
}
/// Whether `pat` matches the whole of `text`.
fn glob_match(pat: &[char], text: &[char]) -> bool {
if pat.is_empty() {
return text.is_empty();
}
match pat[0] {
'*' => {
if pat.get(1) == Some(&'*') {
// `**` — match any run of chars, including `/`.
let rest = &pat[2..];
(0..=text.len()).any(|i| glob_match(rest, &text[i..]))
} else {
// `*` — match any run of non-`/` chars.
let rest = &pat[1..];
let mut i = 0;
loop {
if glob_match(rest, &text[i..]) {
return true;
}
if i >= text.len() || text[i] == '/' {
return false;
}
i += 1;
}
}
}
'?' => match text.first() {
Some(&c) if c != '/' => glob_match(&pat[1..], &text[1..]),
_ => false,
},
'[' => {
if let Some(end) = class_end(pat, 0) {
match text.first() {
Some(&c) if c != '/' && class_contains(&pat[..end], c) => {
glob_match(&pat[end..], &text[1..])
}
_ => false,
}
} else {
// Not a valid class (should not happen post-validation): literal.
matches_literal(pat, text, '[')
}
}
c => matches_literal(pat, text, c),
}
}
/// Matches a single literal char `c` at the head of `pat` against `text`.
fn matches_literal(pat: &[char], text: &[char], c: char) -> bool {
match text.first() {
Some(&t) if t == c => glob_match(&pat[1..], &text[1..]),
_ => false,
}
}
/// Whether character `c` belongs to the class `class` (`class[0] == '['`,
/// `class` ends just after the closing `]`).
fn class_contains(class: &[char], c: char) -> bool {
let mut i = 1;
let mut negated = false;
if class.get(i) == Some(&'!') || class.get(i) == Some(&'^') {
negated = true;
i += 1;
}
let end = class.len() - 1; // index of the closing ']'
let mut found = false;
let mut first = true;
while i < end {
// A literal ']' is only possible as the first member.
if class[i] == ']' && !first {
break;
}
first = false;
if i + 2 < end && class[i + 1] == '-' && class[i + 2] != ']' {
let (lo, hi) = (class[i], class[i + 2]);
if lo <= c && c <= hi {
found = true;
}
i += 3;
} else {
if class[i] == c {
found = true;
}
i += 1;
}
}
found ^ negated
}
#[cfg(test)]
mod tests {
use super::*;
fn glob(p: &str) -> Glob {
Glob::new(p).unwrap()
}
fn path_scope(patterns: &[&str]) -> PathScope {
PathScope::new(patterns.iter().map(|s| s.to_string())).unwrap()
}
// ---- VO construction & invariants -----------------------------------
#[test]
fn glob_rejects_empty_and_unterminated_class() {
assert_eq!(Glob::new(""), Err(PermissionError::EmptyGlob));
assert!(matches!(
Glob::new("src/[abc"),
Err(PermissionError::InvalidGlob { .. })
));
assert!(Glob::new("src/[abc]/*.rs").is_ok());
}
#[test]
fn path_scope_rejects_absolute_and_traversal() {
assert!(matches!(
PathScope::new(["/etc/passwd".to_string()]),
Err(PermissionError::PathNotRelativeSafe { .. })
));
assert!(matches!(
PathScope::new(["../secret".to_string()]),
Err(PermissionError::PathNotRelativeSafe { .. })
));
assert!(PathScope::new(["src/**/*.rs".to_string()]).is_ok());
}
#[test]
fn file_rule_rejects_bash_capability() {
assert!(matches!(
PermissionRule::file(Capability::ExecuteBash, Effect::Allow, PathScope::empty()),
Err(PermissionError::NotAFileCapability { .. })
));
assert!(
PermissionRule::file(Capability::Read, Effect::Allow, path_scope(&["src/**"])).is_ok()
);
}
#[test]
fn new_enforces_bash_paths_and_file_commands_invariants() {
// Bash rule with paths => error.
assert_eq!(
PermissionRule::new(
Capability::ExecuteBash,
Effect::Allow,
path_scope(&["src/**"]),
vec![],
),
Err(PermissionError::BashRuleHasPaths)
);
// File rule with commands => error.
let cmd = CommandRule::new(CommandMatcher::exact("ls").unwrap(), Effect::Allow);
assert_eq!(
PermissionRule::new(
Capability::Read,
Effect::Allow,
PathScope::empty(),
vec![cmd],
),
Err(PermissionError::FileRuleHasCommands)
);
}
#[test]
fn empty_command_matchers_are_rejected() {
assert_eq!(
CommandMatcher::exact(""),
Err(PermissionError::EmptyCommandMatcher)
);
assert_eq!(
CommandMatcher::prefix(""),
Err(PermissionError::EmptyCommandMatcher)
);
}
// ---- glob matching ---------------------------------------------------
#[test]
fn star_does_not_cross_slash_but_doublestar_does() {
assert!(glob("src/*.rs").matches("src/main.rs"));
assert!(!glob("src/*.rs").matches("src/a/main.rs"));
assert!(glob("src/**/*.rs").matches("src/a/b/main.rs"));
assert!(glob("**").matches("any/deep/path"));
assert!(glob("*.rs").matches("main.rs"));
}
#[test]
fn question_and_class_match_single_char() {
assert!(glob("?.rs").matches("a.rs"));
assert!(!glob("?.rs").matches("ab.rs"));
assert!(glob("[abc].rs").matches("b.rs"));
assert!(!glob("[abc].rs").matches("d.rs"));
assert!(glob("[a-z].rs").matches("q.rs"));
assert!(glob("[!a-z].rs").matches("Q.rs"));
assert!(!glob("[!a-z].rs").matches("q.rs"));
}
// ---- command matchers ------------------------------------------------
#[test]
fn command_matchers_behave() {
assert!(CommandMatcher::exact("git status")
.unwrap()
.matches("git status"));
assert!(!CommandMatcher::exact("git").unwrap().matches("git status"));
assert!(CommandMatcher::prefix("git ")
.unwrap()
.matches("git status"));
assert!(CommandMatcher::glob("git *").unwrap().matches("git status"));
assert!(!CommandMatcher::glob("git *").unwrap().matches("npm test"));
}
// ---- resolve: product invariant -------------------------------------
#[test]
fn resolve_none_when_nothing_posed() {
assert!(resolve(None, None).is_none());
}
#[test]
fn resolve_some_when_anything_posed() {
let set = PermissionSet::new(vec![], Posture::Ask);
assert!(resolve(Some(&set), None).is_some());
assert!(resolve(None, Some(&set)).is_some());
}
// ---- resolve: merge + deny-wins -------------------------------------
#[test]
fn merge_superposes_project_then_agent() {
let project = PermissionSet::new(
vec![
PermissionRule::file(Capability::Read, Effect::Allow, path_scope(&["src/**"]))
.unwrap(),
],
Posture::Ask,
);
let agent = PermissionSet::new(
vec![
PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["out/**"]))
.unwrap(),
],
Posture::Ask,
);
let eff = resolve(Some(&project), Some(&agent)).unwrap();
assert_eq!(eff.rules().len(), 2);
assert_eq!(
eff.decide_file(Capability::Read, "src/main.rs"),
Posture::Allow
);
assert_eq!(eff.decide_file(Capability::Write, "out/x"), Posture::Allow);
}
#[test]
fn project_deny_beats_agent_allow() {
let project = PermissionSet::new(
vec![
PermissionRule::file(Capability::Write, Effect::Deny, path_scope(&[".ideai/**"]))
.unwrap(),
],
Posture::Allow,
);
let agent = PermissionSet::new(
vec![
PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["**"]))
.unwrap(),
],
Posture::Allow,
);
let eff = resolve(Some(&project), Some(&agent)).unwrap();
// The agent's broad allow does NOT override the project's specific deny.
assert_eq!(
eff.decide_file(Capability::Write, ".ideai/agents.json"),
Posture::Deny
);
// A path outside the deny scope is allowed.
assert_eq!(
eff.decide_file(Capability::Write, "src/main.rs"),
Posture::Allow
);
}
#[test]
fn decide_file_falls_back_when_no_rule_matches() {
let set = PermissionSet::new(
vec![
PermissionRule::file(Capability::Read, Effect::Allow, path_scope(&["src/**"]))
.unwrap(),
],
Posture::Ask,
);
let eff = resolve(Some(&set), None).unwrap();
assert_eq!(
eff.decide_file(Capability::Read, "doc/readme.md"),
Posture::Ask
);
// A bash query never crosses into file rules.
assert_eq!(eff.decide_bash("ls"), Posture::Ask);
}
// ---- resolve: bash semantics ----------------------------------------
#[test]
fn bash_blanket_allow_with_specific_deny() {
let set = PermissionSet::new(
vec![
PermissionRule::bash(Effect::Allow, vec![]),
PermissionRule::bash(
Effect::Deny,
vec![CommandRule::new(
CommandMatcher::prefix("rm ").unwrap(),
Effect::Deny,
)],
),
],
Posture::Deny,
);
let eff = resolve(Some(&set), None).unwrap();
assert_eq!(eff.decide_bash("ls -la"), Posture::Allow);
assert_eq!(eff.decide_bash("rm -rf /"), Posture::Deny);
}
#[test]
fn bash_specific_only_falls_back_for_unmatched() {
let set = PermissionSet::new(
vec![PermissionRule::bash(
Effect::Deny, // ignored: commands non-empty
vec![CommandRule::new(
CommandMatcher::glob("git *").unwrap(),
Effect::Allow,
)],
)],
Posture::Ask,
);
let eff = resolve(Some(&set), None).unwrap();
assert_eq!(eff.decide_bash("git status"), Posture::Allow);
assert_eq!(eff.decide_bash("npm test"), Posture::Ask);
}
// ---- resolve: fallback tightening -----------------------------------
#[test]
fn fallback_takes_more_restrictive_of_both() {
let project = PermissionSet::new(vec![], Posture::Allow);
let agent = PermissionSet::new(vec![], Posture::Deny);
let eff = resolve(Some(&project), Some(&agent)).unwrap();
assert_eq!(eff.fallback(), Posture::Deny);
let project2 = PermissionSet::new(vec![], Posture::Deny);
let agent2 = PermissionSet::new(vec![], Posture::Allow);
// Agent cannot loosen the project's stance.
assert_eq!(
resolve(Some(&project2), Some(&agent2)).unwrap().fallback(),
Posture::Deny
);
}
#[test]
fn posture_tighten_orders_allow_ask_deny() {
assert_eq!(Posture::Allow.tighten(Posture::Ask), Posture::Ask);
assert_eq!(Posture::Ask.tighten(Posture::Allow), Posture::Ask);
assert_eq!(Posture::Ask.tighten(Posture::Deny), Posture::Deny);
assert_eq!(Posture::Deny.tighten(Posture::Allow), Posture::Deny);
}
#[test]
fn project_permissions_resolves_sparse_agent_override() {
let agent = AgentId::new_random();
let project = PermissionSet::new(vec![], Posture::Ask);
let custom = PermissionSet::new(vec![], Posture::Deny);
let doc = ProjectPermissions::new(
Some(project),
vec![AgentPermissionOverride::new(agent, custom)],
);
assert_eq!(doc.resolve_for(agent).unwrap().fallback(), Posture::Deny);
assert_eq!(
doc.resolve_for(AgentId::new_random()).unwrap().fallback(),
Posture::Ask
);
}
// ---- LP3: ProjectorKey serde + PermissionProjection invariant ------
#[test]
fn projector_key_serialises_to_stable_camel_case() {
// The wire form is load-bearing (it keys the concrete projector in infra).
assert_eq!(
serde_json::to_string(&ProjectorKey::Claude).unwrap(),
"\"claude\""
);
assert_eq!(
serde_json::to_string(&ProjectorKey::Codex).unwrap(),
"\"codex\""
);
}
#[test]
fn projector_key_round_trips() {
for key in [ProjectorKey::Claude, ProjectorKey::Codex] {
let json = serde_json::to_string(&key).unwrap();
let back: ProjectorKey = serde_json::from_str(&json).unwrap();
assert_eq!(key, back);
}
// And the literal wire form deserialises back to the expected variant.
assert_eq!(
serde_json::from_str::<ProjectorKey>("\"claude\"").unwrap(),
ProjectorKey::Claude
);
assert_eq!(
serde_json::from_str::<ProjectorKey>("\"codex\"").unwrap(),
ProjectorKey::Codex
);
}
#[test]
fn permission_projection_empty_is_fully_empty() {
// The invariant value returned when `eff == None`: project nothing.
let proj = PermissionProjection::empty();
assert!(proj.files.is_empty(), "no files materialised");
assert!(proj.args.is_empty(), "no launch args");
assert!(proj.env.is_empty(), "no env vars");
}
#[test]
fn project_permissions_keeps_last_override_for_agent() {
let agent = AgentId::new_random();
let doc = ProjectPermissions::new(
None,
vec![
AgentPermissionOverride::new(agent, PermissionSet::new(vec![], Posture::Ask)),
AgentPermissionOverride::new(agent, PermissionSet::new(vec![], Posture::Deny)),
],
);
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"));
}
}