364 lines
12 KiB
Rust
364 lines
12 KiB
Rust
//! 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<NetworkPolicy>,
|
|
}
|
|
|
|
impl SystemPermissionSet {
|
|
/// Builds a set from an optional network policy.
|
|
#[must_use]
|
|
pub const fn new(network: Option<NetworkPolicy>) -> 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<SystemPermissionSet>,
|
|
/// Per-agent overrides.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub agents: Vec<AgentSystemPermissionOverride>,
|
|
}
|
|
|
|
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<SystemPermissionSet>,
|
|
agents: Vec<AgentSystemPermissionOverride>,
|
|
) -> Self {
|
|
Self {
|
|
version: SYSTEM_PERMISSIONS_VERSION,
|
|
project_default,
|
|
agents,
|
|
}
|
|
}
|
|
|
|
/// Replaces the project default permissions.
|
|
pub fn set_project_default(&mut self, permissions: Option<SystemPermissionSet>) {
|
|
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<SystemPermissionSet>,
|
|
) {
|
|
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<NetworkPolicy> {
|
|
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<String>,
|
|
/// Human-readable reason.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub reason: Option<String>,
|
|
}
|
|
|
|
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<String>, reason: impl Into<String>) -> 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<String>,
|
|
}
|
|
|
|
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<String>) -> 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<NetworkPolicy>,
|
|
/// 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
|
|
);
|
|
}
|
|
}
|