//! System-level agent permissions, distinct from filesystem/bash permissions. //! //! This model stores the policy the user wants IdeA to apply. It does not claim //! that an external assistant runtime can be elevated live: resolution exposes //! the wanted policy separately from the observed runtime state. use serde::{Deserialize, Serialize}; use crate::ids::AgentId; /// Current schema version for `.ideai/system-permissions.json`. pub const SYSTEM_PERMISSIONS_VERSION: u32 = 1; /// Wanted/effective network policy. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum NetworkPolicy { /// Network access is wanted/observed as allowed. Allow, /// Network access is wanted/observed as denied. Deny, /// Ask before allowing network access when the runtime supports it. Ask, } /// Optional system permissions. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SystemPermissionSet { /// Network policy. `None` means no IdeA-level policy has been set. #[serde(default, skip_serializing_if = "Option::is_none")] pub network: Option, } impl SystemPermissionSet { /// Builds a set from an optional network policy. #[must_use] pub const fn new(network: Option) -> Self { Self { network } } /// Whether the set carries no policy. #[must_use] pub const fn is_empty(&self) -> bool { self.network.is_none() } } /// Per-agent system permission override. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSystemPermissionOverride { /// Agent id. pub agent_id: AgentId, /// Agent-specific permissions. pub permissions: SystemPermissionSet, } impl AgentSystemPermissionOverride { /// Builds an override. #[must_use] pub const fn new(agent_id: AgentId, permissions: SystemPermissionSet) -> Self { Self { agent_id, permissions, } } } /// Persisted project system permission document. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProjectSystemPermissions { /// Document format version. pub version: u32, /// Optional project-wide default permissions. #[serde(default, skip_serializing_if = "Option::is_none")] pub project_default: Option, /// Per-agent overrides. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub agents: Vec, } impl Default for ProjectSystemPermissions { fn default() -> Self { Self { version: SYSTEM_PERMISSIONS_VERSION, project_default: None, agents: Vec::new(), } } } impl ProjectSystemPermissions { /// Builds a document. #[must_use] pub fn new( project_default: Option, agents: Vec, ) -> Self { Self { version: SYSTEM_PERMISSIONS_VERSION, project_default, agents, } } /// Replaces the project default permissions. pub fn set_project_default(&mut self, permissions: Option) { self.project_default = permissions.filter(|set| !set.is_empty()); } /// Returns the override for one agent, if present. #[must_use] pub fn agent_permissions(&self, agent_id: AgentId) -> Option<&SystemPermissionSet> { self.agents .iter() .find(|entry| entry.agent_id == agent_id) .map(|entry| &entry.permissions) } /// Replaces or removes an 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.filter(|set| !set.is_empty()) { self.agents .push(AgentSystemPermissionOverride::new(agent_id, permissions)); } } /// Resolves the wanted network policy before runtime constraints. #[must_use] pub fn wanted_network_for(&self, agent_id: AgentId) -> Option { self.agent_permissions(agent_id) .and_then(|set| set.network) .or_else(|| self.project_default.as_ref().and_then(|set| set.network)) } } /// Runtime lock state. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum RuntimeLockState { /// No runtime lock is known. None, /// Runtime locks the effective network policy. Locked, } /// Runtime lock details. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeLock { /// Lock state. pub state: RuntimeLockState, /// Runtime/source that imposes the lock, when known. #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, /// Human-readable reason. #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option, } impl RuntimeLock { /// No known runtime lock. #[must_use] pub const fn none() -> Self { Self { state: RuntimeLockState::None, source: None, reason: None, } } /// Runtime-locked state. #[must_use] pub fn locked(source: impl Into, reason: impl Into) -> Self { Self { state: RuntimeLockState::Locked, source: Some(source.into()), reason: Some(reason.into()), } } } /// UI control mode. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SystemPermissionControlMode { /// IdeA can edit the wanted policy. Editable, /// IdeA can only display the state. ReadOnly, } /// UI control state. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SystemPermissionControl { /// Control mode. pub mode: SystemPermissionControlMode, /// Human-readable reason for read-only state. #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option, } impl SystemPermissionControl { /// Editable control. #[must_use] pub const fn editable() -> Self { Self { mode: SystemPermissionControlMode::Editable, reason: None, } } /// Read-only control. #[must_use] pub fn read_only(reason: impl Into) -> Self { Self { mode: SystemPermissionControlMode::ReadOnly, reason: Some(reason.into()), } } } /// Read-only snapshot from the host/provider runtime. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimePermissionSnapshot { /// Effective network policy observed or conservatively inferred. pub effective_network: NetworkPolicy, /// Runtime lock details. pub runtime_lock: RuntimeLock, /// Runtime control state, distinct from editing the persisted wanted policy. pub runtime_control: SystemPermissionControl, } impl RuntimePermissionSnapshot { /// Snapshot for an agent with no known active runtime lock. #[must_use] pub fn unobserved() -> Self { const REASON: &str = "No active runtime network permission state is currently observed."; Self { effective_network: NetworkPolicy::Deny, runtime_lock: RuntimeLock::none(), runtime_control: SystemPermissionControl::read_only(REASON), } } /// Snapshot for a runtime that IdeA cannot inspect or elevate. #[must_use] pub fn locked_uninspectable() -> Self { const REASON: &str = "IdeA cannot inspect or change network access for the active external runtime."; Self { effective_network: NetworkPolicy::Deny, runtime_lock: RuntimeLock::locked("external-runtime", REASON), runtime_control: SystemPermissionControl::read_only(REASON), } } } /// Resolved read-model for one agent. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResolvedAgentSystemPermissions { /// Wanted network policy from project/agent configuration. pub wanted: Option, /// Effective network policy after runtime constraints. pub effective: NetworkPolicy, /// Runtime lock state. pub runtime_lock: RuntimeLock, /// Whether the UI can edit the persisted wanted policy. pub control: SystemPermissionControl, /// Whether IdeA can inspect or change the active runtime state. pub runtime_control: SystemPermissionControl, } /// Resolves wanted system permissions against the runtime snapshot. #[must_use] pub fn resolve_agent_system_permissions( doc: &ProjectSystemPermissions, agent_id: AgentId, runtime: RuntimePermissionSnapshot, ) -> ResolvedAgentSystemPermissions { let wanted = doc.wanted_network_for(agent_id); let effective = match runtime.runtime_lock.state { RuntimeLockState::Locked => runtime.effective_network, RuntimeLockState::None => wanted.unwrap_or(runtime.effective_network), }; ResolvedAgentSystemPermissions { wanted, effective, runtime_lock: runtime.runtime_lock, control: SystemPermissionControl::editable(), runtime_control: runtime.runtime_control, } } #[cfg(test)] mod tests { use super::*; #[test] fn agent_override_wins_over_project_default_for_wanted_policy() { let agent = AgentId::new_random(); let doc = ProjectSystemPermissions::new( Some(SystemPermissionSet::new(Some(NetworkPolicy::Ask))), vec![AgentSystemPermissionOverride::new( agent, SystemPermissionSet::new(Some(NetworkPolicy::Deny)), )], ); assert_eq!(doc.wanted_network_for(agent), Some(NetworkPolicy::Deny)); } #[test] fn locked_runtime_controls_effective_policy_without_making_wanted_read_only() { let agent = AgentId::new_random(); let doc = ProjectSystemPermissions::default(); let resolved = resolve_agent_system_permissions( &doc, agent, RuntimePermissionSnapshot::locked_uninspectable(), ); assert_eq!(resolved.wanted, None); assert_eq!(resolved.effective, NetworkPolicy::Deny); assert_eq!(resolved.runtime_lock.state, RuntimeLockState::Locked); assert_eq!(resolved.control.mode, SystemPermissionControlMode::Editable); assert_eq!( resolved.runtime_control.mode, SystemPermissionControlMode::ReadOnly ); } #[test] fn unobserved_runtime_has_no_active_lock_and_preserves_wanted_effective_policy() { let agent = AgentId::new_random(); let doc = ProjectSystemPermissions::new( Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))), vec![], ); let resolved = resolve_agent_system_permissions(&doc, agent, RuntimePermissionSnapshot::unobserved()); assert_eq!(resolved.wanted, Some(NetworkPolicy::Allow)); assert_eq!(resolved.effective, NetworkPolicy::Allow); assert_eq!(resolved.runtime_lock.state, RuntimeLockState::None); assert_eq!(resolved.control.mode, SystemPermissionControlMode::Editable); assert_eq!( resolved.runtime_control.mode, SystemPermissionControlMode::ReadOnly ); } }