feat(permissions): expose network permission state (#103)

This commit is contained in:
2026-07-25 23:05:55 +02:00
parent e8731834f4
commit 3047dc9195
31 changed files with 2080 additions and 94 deletions

View File

@ -64,6 +64,7 @@ pub mod sandbox;
pub mod session_limit;
pub mod skill;
pub mod sprint;
pub mod system_permissions;
pub mod template;
pub mod terminal;
@ -195,6 +196,13 @@ pub use permission::{
PERMISSIONS_VERSION,
};
pub use system_permissions::{
resolve_agent_system_permissions, AgentSystemPermissionOverride, NetworkPolicy,
ProjectSystemPermissions, ResolvedAgentSystemPermissions, RuntimeLock, RuntimeLockState,
RuntimePermissionSnapshot, SystemPermissionControl, SystemPermissionControlMode,
SystemPermissionSet, SYSTEM_PERMISSIONS_VERSION,
};
pub use plugin::{
ContentHash, CustomPluginLayout, PluginBundleUrl, PluginCapability, PluginCommandId,
PluginContributionSet, PluginDescriptor, PluginError, PluginId, PluginInstallSource,
@ -228,7 +236,8 @@ pub use ports::{
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError,
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler,
SpawnSpec, SprintStore, SprintStoreError, StoreError, StructuredSessionEnvironment,
StructuredSessionEnvironmentPreparer, TemplateStore, WindowStateStore,
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe,
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore,
TemplateStore, WindowStateStore,
};

View File

@ -59,6 +59,7 @@ use crate::project::{Project, ProjectPath};
use crate::remote::RemoteKind;
use crate::skill::{Skill, SkillScope};
use crate::sprint::{Sprint, SprintIndexEntry, SprintVersion};
use crate::system_permissions::{ProjectSystemPermissions, RuntimePermissionSnapshot};
use crate::template::AgentTemplate;
use crate::terminal::PtySize;
@ -2026,6 +2027,44 @@ pub trait PermissionStore: Send + Sync {
) -> Result<(), StoreError>;
}
/// Reads/writes a project's `.ideai/system-permissions.json`.
#[async_trait]
pub trait SystemPermissionStore: Send + Sync {
/// Loads the project's system permission document. Missing file returns the
/// default empty document.
///
/// # Errors
/// [`StoreError`] on I/O or deserialisation failure.
async fn load_system_permissions(
&self,
project: &Project,
) -> Result<ProjectSystemPermissions, StoreError>;
/// Saves the project's system permission document.
///
/// # Errors
/// [`StoreError`] on I/O or serialisation failure.
async fn save_system_permissions(
&self,
project: &Project,
permissions: &ProjectSystemPermissions,
) -> Result<(), StoreError>;
}
/// Read-only probe for host/provider system permission constraints.
#[async_trait]
pub trait RuntimePermissionProbe: Send + Sync {
/// Returns the effective runtime permission state visible to IdeA.
///
/// # Errors
/// [`RuntimeError`] when probing itself fails.
async fn probe_runtime_permissions(
&self,
project: &Project,
agent_id: AgentId,
) -> Result<RuntimePermissionSnapshot, RuntimeError>;
}
/// Reads/writes a project's `.ideai/mcp-tool-permissions.json`.
///
/// This is intentionally distinct from [`PermissionStore`]: it governs IdeA MCP

View File

@ -0,0 +1,324 @@
//! 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 combines
//! the wanted policy with a read-only runtime probe.
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,
/// Control state exposed to the UI.
pub control: SystemPermissionControl,
}
impl RuntimePermissionSnapshot {
/// 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),
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 policy live.
pub 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: 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_fake_allow() {
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::ReadOnly);
}
}