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

@ -32,6 +32,7 @@ pub mod project;
pub mod remote;
pub mod skill;
pub mod sprints;
pub mod system_permissions;
pub mod template;
pub mod terminal;
pub mod ticket_assistant;
@ -170,6 +171,13 @@ pub use sprints::{
RenameSprintInput, ReorderSprints, ReorderSprintsInput, ReorderSprintsOutput, SprintListEntry,
SprintOutput, UnassignTicketFromSprint, UnassignTicketFromSprintInput,
};
pub use system_permissions::{
GetProjectSystemPermissions, GetProjectSystemPermissionsInput,
GetProjectSystemPermissionsOutput, ResolveAgentSystemPermissions,
ResolveAgentSystemPermissionsInput, ResolveAgentSystemPermissionsOutput,
UpdateAgentSystemPermissions, UpdateAgentSystemPermissionsInput,
UpdateProjectSystemPermissions, UpdateProjectSystemPermissionsInput,
};
pub use template::{
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput,

View File

@ -0,0 +1,168 @@
//! System permission use cases.
//!
//! These use cases persist the wanted project/agent policies separately from
//! filesystem/bash permissions and resolve them through a read-only runtime
//! probe.
use std::sync::Arc;
use domain::ports::{RuntimePermissionProbe, SystemPermissionStore};
use domain::{
resolve_agent_system_permissions, AgentId, Project, ProjectSystemPermissions,
ResolvedAgentSystemPermissions, SystemPermissionSet,
};
use crate::error::AppError;
/// Reads the full project system permission document.
pub struct GetProjectSystemPermissions {
store: Arc<dyn SystemPermissionStore>,
}
impl GetProjectSystemPermissions {
/// Builds the use case.
#[must_use]
pub fn new(store: Arc<dyn SystemPermissionStore>) -> Self {
Self { store }
}
/// Executes the read.
pub async fn execute(
&self,
input: GetProjectSystemPermissionsInput,
) -> Result<GetProjectSystemPermissionsOutput, AppError> {
let permissions = self.store.load_system_permissions(&input.project).await?;
Ok(GetProjectSystemPermissionsOutput { permissions })
}
}
/// Input for [`GetProjectSystemPermissions`].
pub struct GetProjectSystemPermissionsInput {
/// Target project.
pub project: Project,
}
/// Output for project system permission reads/mutations.
pub struct GetProjectSystemPermissionsOutput {
/// Persisted system permission document.
pub permissions: ProjectSystemPermissions,
}
/// Replaces the project default system permissions.
pub struct UpdateProjectSystemPermissions {
store: Arc<dyn SystemPermissionStore>,
}
impl UpdateProjectSystemPermissions {
/// Builds the use case.
#[must_use]
pub fn new(store: Arc<dyn SystemPermissionStore>) -> Self {
Self { store }
}
/// Executes the mutation.
pub async fn execute(
&self,
input: UpdateProjectSystemPermissionsInput,
) -> Result<GetProjectSystemPermissionsOutput, AppError> {
let mut doc = self.store.load_system_permissions(&input.project).await?;
doc.set_project_default(input.permissions);
self.store
.save_system_permissions(&input.project, &doc)
.await?;
Ok(GetProjectSystemPermissionsOutput { permissions: doc })
}
}
/// Input for [`UpdateProjectSystemPermissions`].
pub struct UpdateProjectSystemPermissionsInput {
/// Target project.
pub project: Project,
/// New project default policy. `None` removes project defaults.
pub permissions: Option<SystemPermissionSet>,
}
/// Replaces one agent system permission override.
pub struct UpdateAgentSystemPermissions {
store: Arc<dyn SystemPermissionStore>,
}
impl UpdateAgentSystemPermissions {
/// Builds the use case.
#[must_use]
pub fn new(store: Arc<dyn SystemPermissionStore>) -> Self {
Self { store }
}
/// Executes the mutation.
pub async fn execute(
&self,
input: UpdateAgentSystemPermissionsInput,
) -> Result<GetProjectSystemPermissionsOutput, AppError> {
let mut doc = self.store.load_system_permissions(&input.project).await?;
doc.set_agent_permissions(input.agent_id, input.permissions);
self.store
.save_system_permissions(&input.project, &doc)
.await?;
Ok(GetProjectSystemPermissionsOutput { permissions: doc })
}
}
/// Input for [`UpdateAgentSystemPermissions`].
pub struct UpdateAgentSystemPermissionsInput {
/// Target project.
pub project: Project,
/// Target agent.
pub agent_id: AgentId,
/// New agent policy. `None` removes the override.
pub permissions: Option<SystemPermissionSet>,
}
/// Resolves effective system permissions for one agent.
pub struct ResolveAgentSystemPermissions {
store: Arc<dyn SystemPermissionStore>,
runtime_probe: Arc<dyn RuntimePermissionProbe>,
}
impl ResolveAgentSystemPermissions {
/// Builds the use case.
#[must_use]
pub fn new(
store: Arc<dyn SystemPermissionStore>,
runtime_probe: Arc<dyn RuntimePermissionProbe>,
) -> Self {
Self {
store,
runtime_probe,
}
}
/// Executes the resolution.
pub async fn execute(
&self,
input: ResolveAgentSystemPermissionsInput,
) -> Result<ResolveAgentSystemPermissionsOutput, AppError> {
let doc = self.store.load_system_permissions(&input.project).await?;
let runtime = self
.runtime_probe
.probe_runtime_permissions(&input.project, input.agent_id)
.await?;
Ok(ResolveAgentSystemPermissionsOutput {
permissions: resolve_agent_system_permissions(&doc, input.agent_id, runtime),
})
}
}
/// Input for [`ResolveAgentSystemPermissions`].
pub struct ResolveAgentSystemPermissionsInput {
/// Target project.
pub project: Project,
/// Target agent.
pub agent_id: AgentId,
}
/// Output for [`ResolveAgentSystemPermissions`].
pub struct ResolveAgentSystemPermissionsOutput {
/// Resolved system permissions.
pub permissions: ResolvedAgentSystemPermissions,
}

View File

@ -0,0 +1,154 @@
use std::sync::{Arc, Mutex};
use application::{
ResolveAgentSystemPermissions, ResolveAgentSystemPermissionsInput,
UpdateAgentSystemPermissions, UpdateAgentSystemPermissionsInput,
UpdateProjectSystemPermissions, UpdateProjectSystemPermissionsInput,
};
use async_trait::async_trait;
use domain::ids::{AgentId, ProjectId};
use domain::ports::{RuntimeError, RuntimePermissionProbe, StoreError, SystemPermissionStore};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::{
NetworkPolicy, ProjectSystemPermissions, RuntimeLockState, RuntimePermissionSnapshot,
SystemPermissionControlMode, SystemPermissionSet,
};
#[derive(Default)]
struct FakeSystemPermissionStore {
doc: Mutex<ProjectSystemPermissions>,
saves: Mutex<usize>,
}
#[async_trait]
impl SystemPermissionStore for FakeSystemPermissionStore {
async fn load_system_permissions(
&self,
_project: &Project,
) -> Result<ProjectSystemPermissions, StoreError> {
Ok(self.doc.lock().unwrap().clone())
}
async fn save_system_permissions(
&self,
_project: &Project,
permissions: &ProjectSystemPermissions,
) -> Result<(), StoreError> {
*self.doc.lock().unwrap() = permissions.clone();
*self.saves.lock().unwrap() += 1;
Ok(())
}
}
struct LockedProbe;
#[async_trait]
impl RuntimePermissionProbe for LockedProbe {
async fn probe_runtime_permissions(
&self,
_project: &Project,
_agent_id: AgentId,
) -> Result<RuntimePermissionSnapshot, RuntimeError> {
Ok(RuntimePermissionSnapshot::locked_uninspectable())
}
}
fn project() -> Project {
Project::new(
ProjectId::new_random(),
"system-permissions",
ProjectPath::new("/home/me/proj").unwrap(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap()
}
#[tokio::test]
async fn update_project_system_permissions_replaces_defaults_and_persists() {
let store = Arc::new(FakeSystemPermissionStore::default());
let use_case = UpdateProjectSystemPermissions::new(store.clone());
let out = use_case
.execute(UpdateProjectSystemPermissionsInput {
project: project(),
permissions: Some(SystemPermissionSet::new(Some(NetworkPolicy::Ask))),
})
.await
.unwrap();
assert_eq!(
out.permissions.project_default.unwrap().network,
Some(NetworkPolicy::Ask)
);
assert_eq!(*store.saves.lock().unwrap(), 1);
}
#[tokio::test]
async fn update_agent_system_permissions_adds_and_removes_sparse_override() {
let store = Arc::new(FakeSystemPermissionStore::default());
let use_case = UpdateAgentSystemPermissions::new(store.clone());
let agent = AgentId::new_random();
use_case
.execute(UpdateAgentSystemPermissionsInput {
project: project(),
agent_id: agent,
permissions: Some(SystemPermissionSet::new(Some(NetworkPolicy::Deny))),
})
.await
.unwrap();
assert_eq!(
store
.doc
.lock()
.unwrap()
.agent_permissions(agent)
.unwrap()
.network,
Some(NetworkPolicy::Deny)
);
let out = use_case
.execute(UpdateAgentSystemPermissionsInput {
project: project(),
agent_id: agent,
permissions: None,
})
.await
.unwrap();
assert!(out.permissions.agent_permissions(agent).is_none());
assert_eq!(*store.saves.lock().unwrap(), 2);
}
#[tokio::test]
async fn resolve_agent_system_permissions_exposes_locked_runtime_read_only() {
let agent = AgentId::new_random();
let store = Arc::new(FakeSystemPermissionStore {
doc: Mutex::new(ProjectSystemPermissions::new(
Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
vec![],
)),
saves: Mutex::new(0),
});
let use_case = ResolveAgentSystemPermissions::new(store, Arc::new(LockedProbe));
let out = use_case
.execute(ResolveAgentSystemPermissionsInput {
project: project(),
agent_id: agent,
})
.await
.unwrap();
assert_eq!(out.permissions.wanted, Some(NetworkPolicy::Allow));
assert_eq!(out.permissions.effective, NetworkPolicy::Deny);
assert_eq!(out.permissions.runtime_lock.state, RuntimeLockState::Locked);
assert_eq!(
out.permissions.control.mode,
SystemPermissionControlMode::ReadOnly
);
}