feat(permissions): expose network permission state (#103)
This commit is contained in:
@ -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,
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
324
crates/domain/src/system_permissions.rs
Normal file
324
crates/domain/src/system_permissions.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user