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>
1483 lines
51 KiB
Rust
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"));
|
|
}
|
|
}
|