//! 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) -> Result { 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 = self.0.chars().collect(); let txt: Vec = 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, } 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) -> Result { 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) -> Result { 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) -> Result { 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) -> Result { 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, } 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 { 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) -> 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, ) -> Result { 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, fallback: Posture, } impl PermissionSet { /// Builds a permission set from already-validated rules. #[must_use] pub fn new(rules: Vec, 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, /// Sparse agent-specific overrides. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub agents: Vec, } 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, agents: Vec, ) -> 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) { self.project_defaults = defaults; } /// Replaces or removes one agent override. pub fn set_agent_permissions(&mut self, agent_id: AgentId, permissions: Option) { 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 { 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, 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 { 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 { let eff = eff?; let mut file_lines: Vec = Vec::new(); let mut bash_lines: Vec = Vec::new(); for rule in eff.rules() { if rule.capability().is_file() { let verb = match rule.effect() { Effect::Allow => "Allow", Effect::Deny => "Deny", }; let cap = match rule.capability() { Capability::Read => "read", Capability::Write => "write", Capability::Delete => "delete", Capability::ExecuteBash => "run", // unreachable (is_file filtered) }; let scope = if rule.paths().is_empty() { "(nothing)".to_string() } else { rule.paths() .globs() .iter() .map(|g| format!("`{}`", g.pattern())) .collect::>() .join(", ") }; file_lines.push(format!("- {verb} {cap}: {scope}")); } else { // Bash rule: blanket (empty commands) or per-command. if rule.commands().is_empty() { let verb = match rule.effect() { Effect::Allow => "Allow", Effect::Deny => "Deny", }; bash_lines.push(format!("- {verb}: every command")); } else { for cmd in rule.commands() { let verb = match cmd.effect { Effect::Allow => "Allow", Effect::Deny => "Deny", }; let shown = match &cmd.matcher { CommandMatcher::Exact(s) => format!("`{s}` (exact)"), CommandMatcher::Prefix(s) => format!("`{s}*` (prefix)"), CommandMatcher::Glob(g) => format!("`{}` (glob)", g.pattern()), }; bash_lines.push(format!("- {verb}: {shown}")); } } } } let mut out = String::new(); out.push_str("## Permissions (IdeA)\n\n"); out.push_str(&format!( "**Default posture:** {}\n\n", match eff.fallback() { Posture::Allow => "Allow", Posture::Ask => "Ask", Posture::Deny => "Deny", } )); out.push_str("### Files — OS-enforced when the sandbox is supported (Landlock)\n"); if file_lines.is_empty() { out.push_str("- (no file rule; only the default posture applies)\n"); } else { for line in &file_lines { out.push_str(line); out.push('\n'); } } out.push('\n'); out.push_str("### Commands — advisory, NOT OS-locked\n"); out.push_str( "These rules are honoured only by the AI CLI's own prompting, not by the OS \ sandbox: Landlock locks file access only, so command execution (`ExecuteBash`) \ cannot be sandboxed and stays advisory.\n", ); if bash_lines.is_empty() { out.push_str("- (no command rule; only the default posture applies)\n"); } else { for line in &bash_lines { out.push_str(line); out.push('\n'); } } Some(out) } // --------------------------------------------------------------------------- // Per-CLI projection (lot LP3) — the PURE contract that translates the resolved // `EffectivePermissions` into a concrete, file/args/env-level *plan* for one CLI. // 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//`). 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, /// Top-level keys IdeA manages (everything else is preserved). managed_keys: Vec, /// 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, /// Extra launch arguments the CLI needs to honour the policy. pub args: Vec, /// 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; } /// 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 = 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 { 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::("\"claude\"").unwrap(), ProjectorKey::Claude ); assert_eq!( serde_json::from_str::("\"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")); } }