car-policy 0.37.0

Policy engine for Common Agent Runtime
Documentation
//! Per-agent approval policy.
//!
//! The action-level [`permission`](crate::permission) tiers answer "how risky
//! is this action?" (`read_only` ⊂ `sandbox_edit` ⊂ `full_access`). This module
//! answers the orthogonal question the user actually configures: **for THIS
//! agent, at THIS risk tier, what should CAR do** — always allow, require
//! approval, or deny.
//!
//! The policy is a fully-specified `default` posture plus sparse per-agent
//! overrides, so a brand-new agent inherits sensible defaults and an operator
//! only tunes the agents they care about. Resolution is agent-override → default
//! → a safe `RequireApproval` floor.
//!
//! Pure + serde; the durable store and the `agent_permissions.*` wire surface
//! live in `car-server-core`.

use crate::permission::PermissionTier;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

/// What CAR does when an agent attempts an action of a given risk tier.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalMode {
    /// Run without asking.
    AlwaysAllow,
    /// Pause and ask the operator (routes through the HITL `ApprovalLedger`).
    RequireApproval,
    /// Never run; the action is refused.
    Deny,
}

impl ApprovalMode {
    pub fn as_str(self) -> &'static str {
        match self {
            ApprovalMode::AlwaysAllow => "always_allow",
            ApprovalMode::RequireApproval => "require_approval",
            ApprovalMode::Deny => "deny",
        }
    }

    pub fn from_str_opt(s: &str) -> Option<ApprovalMode> {
        match s {
            "always_allow" | "allow" => Some(ApprovalMode::AlwaysAllow),
            "require_approval" | "approval" | "ask" => Some(ApprovalMode::RequireApproval),
            "deny" | "block" => Some(ApprovalMode::Deny),
            _ => None,
        }
    }
}

/// A subject's posture: an optional mode per risk tier. `None` at a tier means
/// "fall through" (used for sparse per-agent overrides; the `default` posture is
/// fully specified).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TierPosture {
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub read_only: Option<ApprovalMode>,
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub sandbox_edit: Option<ApprovalMode>,
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub full_access: Option<ApprovalMode>,
}

impl TierPosture {
    pub fn get(&self, tier: PermissionTier) -> Option<ApprovalMode> {
        match tier {
            PermissionTier::ReadOnly => self.read_only,
            PermissionTier::SandboxEdit => self.sandbox_edit,
            PermissionTier::FullAccess => self.full_access,
        }
    }

    pub fn set(&mut self, tier: PermissionTier, mode: ApprovalMode) {
        match tier {
            PermissionTier::ReadOnly => self.read_only = Some(mode),
            PermissionTier::SandboxEdit => self.sandbox_edit = Some(mode),
            PermissionTier::FullAccess => self.full_access = Some(mode),
        }
    }

    /// A uniform posture at every tier (used to build presets).
    pub fn uniform(mode: ApprovalMode) -> Self {
        TierPosture {
            read_only: Some(mode),
            sandbox_edit: Some(mode),
            full_access: Some(mode),
        }
    }
}

/// A named starting posture the onboarding flow offers, mapped onto per-tier
/// modes. Mirrors the host's `ApprovalDefault`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalPreset {
    /// Ask before anything beyond reading.
    Cautious,
    /// Auto-allow safe reads; ask before edits and consequential actions.
    Balanced,
    /// Let agents work; only ask for the highest-risk actions.
    Trusting,
}

impl ApprovalPreset {
    pub fn posture(self) -> TierPosture {
        use ApprovalMode::*;
        match self {
            ApprovalPreset::Cautious => TierPosture {
                read_only: Some(RequireApproval),
                sandbox_edit: Some(RequireApproval),
                full_access: Some(RequireApproval),
            },
            ApprovalPreset::Balanced => TierPosture {
                read_only: Some(AlwaysAllow),
                sandbox_edit: Some(RequireApproval),
                full_access: Some(RequireApproval),
            },
            ApprovalPreset::Trusting => TierPosture {
                read_only: Some(AlwaysAllow),
                sandbox_edit: Some(AlwaysAllow),
                full_access: Some(RequireApproval),
            },
        }
    }

    pub fn from_str_opt(s: &str) -> Option<ApprovalPreset> {
        match s {
            "cautious" => Some(ApprovalPreset::Cautious),
            "balanced" => Some(ApprovalPreset::Balanced),
            "trusting" => Some(ApprovalPreset::Trusting),
            _ => None,
        }
    }
}

/// The whole per-agent approval policy: a fully-specified default posture plus
/// sparse per-agent overrides.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentPermissionPolicy {
    /// Applied to any agent without a specific override. Always fully specified.
    pub default: TierPosture,
    /// Per-agent overrides, keyed by agent id. May be partial.
    #[serde(default)]
    pub agents: BTreeMap<String, TierPosture>,
}

impl Default for AgentPermissionPolicy {
    /// The recommended balanced default — safe reads run, edits and
    /// consequential actions ask first.
    fn default() -> Self {
        AgentPermissionPolicy {
            default: ApprovalPreset::Balanced.posture(),
            agents: BTreeMap::new(),
        }
    }
}

impl AgentPermissionPolicy {
    /// The effective mode for `(agent_id, tier)`: an agent override wins, else
    /// the default, else a safe `RequireApproval` floor (never silently allow).
    pub fn resolve(&self, agent_id: &str, tier: PermissionTier) -> ApprovalMode {
        if let Some(posture) = self.agents.get(agent_id) {
            if let Some(mode) = posture.get(tier) {
                return mode;
            }
        }
        self.default
            .get(tier)
            .unwrap_or(ApprovalMode::RequireApproval)
    }

    /// The effective posture for an agent, tier-by-tier (override → default).
    pub fn effective(&self, agent_id: &str) -> TierPosture {
        TierPosture {
            read_only: Some(self.resolve(agent_id, PermissionTier::ReadOnly)),
            sandbox_edit: Some(self.resolve(agent_id, PermissionTier::SandboxEdit)),
            full_access: Some(self.resolve(agent_id, PermissionTier::FullAccess)),
        }
    }

    /// Whether an agent has any explicit override (vs. running on defaults).
    pub fn has_override(&self, agent_id: &str) -> bool {
        self.agents.contains_key(agent_id)
    }

    pub fn set_agent(&mut self, agent_id: &str, tier: PermissionTier, mode: ApprovalMode) {
        self.agents
            .entry(agent_id.to_string())
            .or_default()
            .set(tier, mode);
    }

    /// Set every tier for an agent at once (e.g. "deny this agent entirely").
    pub fn set_agent_uniform(&mut self, agent_id: &str, mode: ApprovalMode) {
        self.agents
            .insert(agent_id.to_string(), TierPosture::uniform(mode));
    }

    pub fn set_default(&mut self, tier: PermissionTier, mode: ApprovalMode) {
        self.default.set(tier, mode);
    }

    pub fn set_default_preset(&mut self, preset: ApprovalPreset) {
        self.default = preset.posture();
    }

    /// Drop an agent's override so it reverts to the default posture.
    pub fn reset_agent(&mut self, agent_id: &str) {
        self.agents.remove(agent_id);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_is_balanced() {
        let p = AgentPermissionPolicy::default();
        assert_eq!(
            p.resolve("any", PermissionTier::ReadOnly),
            ApprovalMode::AlwaysAllow
        );
        assert_eq!(
            p.resolve("any", PermissionTier::SandboxEdit),
            ApprovalMode::RequireApproval
        );
        assert_eq!(
            p.resolve("any", PermissionTier::FullAccess),
            ApprovalMode::RequireApproval
        );
    }

    #[test]
    fn agent_override_wins_then_falls_through() {
        let mut p = AgentPermissionPolicy::default();
        // Override only full_access for one agent; other tiers fall through.
        p.set_agent("risky", PermissionTier::FullAccess, ApprovalMode::Deny);
        assert_eq!(
            p.resolve("risky", PermissionTier::FullAccess),
            ApprovalMode::Deny
        );
        assert_eq!(
            p.resolve("risky", PermissionTier::ReadOnly),
            ApprovalMode::AlwaysAllow
        );
        // A different agent is unaffected.
        assert_eq!(
            p.resolve("other", PermissionTier::FullAccess),
            ApprovalMode::RequireApproval
        );
    }

    #[test]
    fn uniform_and_reset() {
        let mut p = AgentPermissionPolicy::default();
        p.set_agent_uniform("blocked", ApprovalMode::Deny);
        assert_eq!(
            p.resolve("blocked", PermissionTier::ReadOnly),
            ApprovalMode::Deny
        );
        assert!(p.has_override("blocked"));
        p.reset_agent("blocked");
        assert!(!p.has_override("blocked"));
        assert_eq!(
            p.resolve("blocked", PermissionTier::ReadOnly),
            ApprovalMode::AlwaysAllow
        );
    }

    #[test]
    fn presets_map_as_expected() {
        assert_eq!(
            ApprovalPreset::Cautious.posture().read_only,
            Some(ApprovalMode::RequireApproval)
        );
        assert_eq!(
            ApprovalPreset::Trusting.posture().sandbox_edit,
            Some(ApprovalMode::AlwaysAllow)
        );
        assert_eq!(
            ApprovalPreset::Trusting.posture().full_access,
            Some(ApprovalMode::RequireApproval)
        );
    }

    #[test]
    fn roundtrips_through_json() {
        let mut p = AgentPermissionPolicy::default();
        p.set_agent("a", PermissionTier::SandboxEdit, ApprovalMode::AlwaysAllow);
        let json = serde_json::to_string(&p).unwrap();
        let back: AgentPermissionPolicy = serde_json::from_str(&json).unwrap();
        assert_eq!(p, back);
    }
}