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

@ -0,0 +1,19 @@
---
name: ticket103-network-permission-ux-surface
description: memory note ticket103-network-permission-ux-surface
metadata:
type: project
---
---
title: "Ticket #103 - UX surface for network permission"
type: reference
description: "Stable UX convention for exposing network permission in IdeA: Permissions panel as primary editor, Agents as compact summary, Terminal as contextual failure surface, and explicit runtime-locked state."
---
# Ticket #103 — UX surface for network permission
- Keep a single primary edit surface in `Permissions > Système`.
- Mirror the effective network state in the Agents list as a compact badge.
- Use the Terminal only for contextual, actionable failures.
- Distinguish clearly between user policy, effective state, and runtime lock.
- If the runtime cannot elevate permission in the active session, the control must be read-only and the UI must say so explicitly.

View File

@ -0,0 +1,99 @@
---
issueRef: "#103"
version: 4
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1785013507979
---
# Carnet #103 — permission réseau exposée dans IdeA
## Problème
Certaines commandes lancées par les agents s'exécutent dans un environnement où le réseau est restreint, sans visibilité ni action claire dans IdeA. Exemple live : rebuild AppImage OK jusqu'au bundling, puis `appimagetool` échoue à télécharger le runtime GitHub car la session agent est `network restricted` + `approval policy: never`.
## Décision UX
Mémoire : `ticket103-network-permission-ux-surface`.
- Surface principale : `Permissions > Système`.
- Miroirs de lecture : badge compact dans `Agents`, bannière/erreur contextualisée dans `Terminal`.
- États visibles : `Réseau autorisé`, `Réseau interdit`, `Demande d'autorisation`, `Verrouillé par le runtime`.
- Distinction obligatoire : politique voulue, état effectif, verrou runtime.
- Si le runtime externe ne permet pas l'élévation dans la session active : contrôle read-only + explication explicite.
## Cadrage architecture
Ne pas étendre le modèle existant `ProjectPermissions` fichier/bash : le réseau n'est ni une capability filesystem ni une règle Landlock. Ajouter un modèle/read-model séparé de permissions système.
### Domaine / DTO V1
- `NetworkPolicy = "allow" | "deny" | "ask"`.
- `SystemPermissionSet { network?: NetworkPolicy }`.
- `ProjectSystemPermissions { version, projectDefault?: SystemPermissionSet, agents?: [{ agentId, permissions: SystemPermissionSet }] }`.
- `ResolvedAgentSystemPermissions` :
- `wanted: NetworkPolicy | null`
- `effective: NetworkPolicy`
- `runtimeLock: { state: "none" | "locked", source?: string, reason?: string }`
- `control: { mode: "editable" | "readOnly", reason?: string }`
### Ports / use cases
- `SystemPermissionStore`.
- `GetProjectSystemPermissions`.
- `UpdateProjectSystemPermissions`.
- `UpdateAgentSystemPermissions`.
- `ResolveAgentSystemPermissions`.
- `RuntimePermissionProbe` : expose ce que le runtime hôte/fournisseur autorise réellement et s'il verrouille le réseau.
### API/commands attendus
- `get_project_system_permissions(projectId) -> ProjectSystemPermissionsDto`
- `update_project_system_permissions({ projectId, permissions }) -> ProjectSystemPermissionsDto`
- `update_agent_system_permissions({ projectId, agentId, permissions }) -> ProjectSystemPermissionsDto`
- `resolve_agent_system_permissions({ projectId, agentId }) -> ResolvedAgentSystemPermissionsDto`
### Limite produit V1
Implémentable maintenant : persister la politique voulue, afficher `wanted/effective/runtimeLock`, rendre le contrôle read-only si runtime verrouillé/non inspectable, badges agents, bannière terminal.
Non promis en V1 : changer effectivement la permission réseau d'une session fournisseur déjà lancée ou élever un runtime externe `network restricted` / `approval never`. IdeA doit l'expliquer plutôt que simuler une élévation.
## Découpage
### DevBackend
1. Ajouter domaine `system permissions` séparé de LP1 permissions fichier/bash.
2. Ajouter store + use cases + DTO + commands Tauri/HTTP.
3. Ajouter `RuntimePermissionProbe` read-only au composition root.
4. Ajouter read-model `resolve_agent_system_permissions`.
5. Optionnel si simple : mapper des échecs réseau vers un code stable `NETWORK_LOCKED` ou `NETWORK_UNAVAILABLE`.
### DevFrontend
1. Étendre types domaine, ports, adapters Tauri/HTTP/mock.
2. Ajouter la sous-section `Réseau` dans `Permissions > Système`.
3. Ajouter badge compact dans `Agents`.
4. Ajouter bannière/erreur terminal contextualisée pour état verrouillé/échec réseau.
### QA
- Projet sans config : état cohérent, pas de faux `allow`.
- Save project default puis override agent : relecture identique.
- Resolve distingue `wanted`, `effective`, `runtimeLock`.
- Probe `locked` : contrôle read-only + message visible.
- Non-régression `Permissions > Système` existant fichier/bash.
- Aucun moteur ne reçoit de faux flag réseau au spawn.
- Badge agents et bannière terminal reflètent l'état effectif.
## Validation 2026-07-25
QA verte sur le périmètre #103.
Commandes exécutées par QA :
- `cargo test -p domain system_permissions`
- `cargo test -p application --test system_permission_usecases`
- `cargo test -p infrastructure --test system_permission_store`
- `cargo test -p app-tauri --test dto_system_permissions`
- `cargo test -p web-server allowlisted`
- `npm run typecheck`
- `npx vitest run src/features/permissions/permissions.test.tsx src/features/agents/agents.test.tsx src/features/terminals/TerminalView.test.tsx`
- `npx vitest run src/features/permissions/permissions.test.tsx`
Résultats : backend ciblé vert, frontend typecheck vert, tests frontend ciblés 49 passed.
## État Git
Implémentation validée dans le working tree courant, mais commit/merge non réalisé par Main :
- l'agent Git headless renvoie un pseudo-appel outil au lieu d'agir ;
- Main ne peut pas écrire `.git/index.lock` depuis cette session (`Read-only file system`).
Le commit local reste à faire manuellement ou via un agent Git fonctionnel.
## Critère de clôture
Tests pertinents verts, commit/merge local par Git, puis rebuild AppImage.

View File

@ -0,0 +1,39 @@
---
id: "3d9021da-26c3-439d-9463-8d206bd06f1b"
number: 103
title: "Exposer et piloter la permission réseau des agents/commandes dans IdeA"
status: "qa"
priority: "high"
sprint: null
links: []
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
createdAt: 1785011668081
updatedAt: 1785013507979
version: 4
---
## Problème
Aujourd'hui certaines commandes lancées par les agents s'exécutent dans un environnement où le réseau est restreint, sans que l'utilisateur puisse le voir ni l'autoriser depuis IdeA. Exemple réel : rebuild AppImage OK jusqu'au bundling, puis `appimagetool` échoue à télécharger le runtime AppImage depuis GitHub (`Failed to download runtime: server returned status code 0`) parce que la session agent a `network restricted` et `approval policy: never`.
## Besoin utilisateur
Depuis IdeA, l'utilisateur doit pouvoir comprendre et piloter cette permission réseau :
- voir qu'un agent/profil/session est en mode réseau interdit ou autorisé ;
- configurer la politique réseau attendue pour les agents/commandes ;
- éviter les échecs opaques de commandes qui ont légitimement besoin d'Internet (build, install, téléchargement runtime, docs, dépendances) ;
- conserver un comportement sûr par défaut et explicite.
## Attendu produit
Définir puis implémenter une surface IdeA pour exposer cette permission. La solution doit respecter le modèle de permissions/sandbox existant et clarifier la limite éventuelle : si le sandbox fournisseur impose `network restricted` sans possibilité d'élévation runtime, IdeA doit l'expliquer plutôt que faire croire que le réseau est activable.
## Critères d'acceptation
- Une surface UI ou configuration explicite permet de voir la politique réseau applicable aux agents/commandes.
- L'utilisateur dispose d'une action ou d'un réglage clair quand IdeA peut piloter cette permission.
- Si la permission est imposée par le runtime externe et non modifiable, l'UI l'indique clairement.
- Les agents/commandes ne gagnent pas l'accès réseau silencieusement.
- Tests pertinents verts.
- AppImage reconstruite après livraison.

View File

@ -14,19 +14,21 @@ use application::{
CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput, CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput,
DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput, DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput,
DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput,
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput,
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListDevicesInput,
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime, ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions,
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput, LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput,
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReadConversationPageInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput,
ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, ReconcileLiveStateInput,
ResolveAgentPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
UpdateProjectPermissionsInput, UpdateSkillInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
}; };
use domain::ports::ModelServerRuntime; use domain::ports::ModelServerRuntime;
use domain::ports::PtyHandle; use domain::ports::PtyHandle;
@ -52,19 +54,22 @@ use crate::dto::{
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectWorkStateDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
UpdateAgentContextRequestDto, UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentContextRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto,
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
UpdateSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto, UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
WriteTerminalRequestDto,
}; };
use crate::embedded_server::{ use crate::embedded_server::{
EmbeddedServerStatusDto, ServerExposurePreviewDto, ServerExposureSettingsDto, EmbeddedServerStatusDto, ServerExposurePreviewDto, ServerExposureSettingsDto,
@ -549,6 +554,87 @@ pub async fn resolve_agent_permissions(
.map_err(ErrorDto::from) .map_err(ErrorDto::from)
} }
/// `get_project_system_permissions` — read `.ideai/system-permissions.json`.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid project id or store failure.
#[tauri::command]
pub async fn get_project_system_permissions(
project_id: String,
state: State<'_, AppState>,
) -> Result<ProjectSystemPermissionsDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
state
.get_project_system_permissions
.execute(GetProjectSystemPermissionsInput { project })
.await
.map(|out| ProjectSystemPermissionsDto(out.permissions))
.map_err(ErrorDto::from)
}
/// `update_project_system_permissions` — replace project default system permissions.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid project id or store failure.
#[tauri::command]
pub async fn update_project_system_permissions(
request: UpdateProjectSystemPermissionsRequestDto,
state: State<'_, AppState>,
) -> Result<ProjectSystemPermissionsDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
state
.update_project_system_permissions
.execute(UpdateProjectSystemPermissionsInput {
project,
permissions: request.permissions,
})
.await
.map(|out| ProjectSystemPermissionsDto(out.permissions))
.map_err(ErrorDto::from)
}
/// `update_agent_system_permissions` — replace or remove one agent system override.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid ids or store failure.
#[tauri::command]
pub async fn update_agent_system_permissions(
request: UpdateAgentSystemPermissionsRequestDto,
state: State<'_, AppState>,
) -> Result<ProjectSystemPermissionsDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
state
.update_agent_system_permissions
.execute(UpdateAgentSystemPermissionsInput {
project,
agent_id,
permissions: request.permissions,
})
.await
.map(|out| ProjectSystemPermissionsDto(out.permissions))
.map_err(ErrorDto::from)
}
/// `resolve_agent_system_permissions` — resolve wanted plus runtime-constrained system permissions.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid ids or store/probe failure.
#[tauri::command]
pub async fn resolve_agent_system_permissions(
request: ResolveAgentSystemPermissionsRequestDto,
state: State<'_, AppState>,
) -> Result<ResolvedAgentSystemPermissionsDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
state
.resolve_agent_system_permissions
.execute(ResolveAgentSystemPermissionsInput { project, agent_id })
.await
.map(|out| ResolvedAgentSystemPermissionsDto(out.permissions))
.map_err(ErrorDto::from)
}
/// `get_mcp_tool_permissions` — read `.ideai/mcp-tool-permissions.json` plus catalogue. /// `get_mcp_tool_permissions` — read `.ideai/mcp-tool-permissions.json` plus catalogue.
/// ///
/// # Errors /// # Errors

View File

@ -229,6 +229,10 @@ pub fn run() {
commands::update_project_permissions, commands::update_project_permissions,
commands::update_agent_permissions, commands::update_agent_permissions,
commands::resolve_agent_permissions, commands::resolve_agent_permissions,
commands::get_project_system_permissions,
commands::update_project_system_permissions,
commands::update_agent_system_permissions,
commands::resolve_agent_system_permissions,
commands::get_mcp_tool_permissions, commands::get_mcp_tool_permissions,
commands::update_project_mcp_tool_permissions, commands::update_project_mcp_tool_permissions,
commands::update_agent_mcp_tool_permissions, commands::update_agent_mcp_tool_permissions,

View File

@ -0,0 +1,69 @@
use app_tauri_lib::dto::{
ProjectSystemPermissionsDto, ResolvedAgentSystemPermissionsDto,
UpdateProjectSystemPermissionsRequestDto,
};
use domain::{
NetworkPolicy, ProjectSystemPermissions, ResolvedAgentSystemPermissions, RuntimeLock,
RuntimeLockState, SystemPermissionControl, SystemPermissionControlMode, SystemPermissionSet,
};
use serde_json::json;
#[test]
fn project_system_permissions_dto_serializes_network_policy_contract() {
let dto = ProjectSystemPermissionsDto(ProjectSystemPermissions::new(
Some(SystemPermissionSet::new(Some(NetworkPolicy::Ask))),
vec![],
));
let value = serde_json::to_value(dto).unwrap();
assert_eq!(
value,
json!({
"version": 1,
"projectDefault": {
"network": "ask",
},
})
);
}
#[test]
fn update_project_system_permissions_request_deserializes_allow_deny_ask() {
let dto: UpdateProjectSystemPermissionsRequestDto = serde_json::from_value(json!({
"projectId": "project",
"permissions": {
"network": "allow",
},
}))
.unwrap();
assert_eq!(
dto.permissions.and_then(|permissions| permissions.network),
Some(NetworkPolicy::Allow)
);
}
#[test]
fn resolved_agent_system_permissions_dto_carries_runtime_lock_and_read_only_control() {
let dto = ResolvedAgentSystemPermissionsDto(ResolvedAgentSystemPermissions {
wanted: Some(NetworkPolicy::Allow),
effective: NetworkPolicy::Deny,
runtime_lock: RuntimeLock {
state: RuntimeLockState::Locked,
source: Some("external-runtime".to_owned()),
reason: Some("not inspectable".to_owned()),
},
control: SystemPermissionControl {
mode: SystemPermissionControlMode::ReadOnly,
reason: Some("not editable".to_owned()),
},
});
let value = serde_json::to_value(dto).unwrap();
assert_eq!(value["wanted"], "allow");
assert_eq!(value["effective"], "deny");
assert_eq!(value["runtimeLock"]["state"], "locked");
assert_eq!(value["control"]["mode"], "readOnly");
}

View File

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

View File

@ -16,7 +16,10 @@ use application::{
ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState, ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState,
StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView,
}; };
use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole}; use domain::{
AgentBusyState, PageCursor, PageDirection, Project, ProjectId, ProjectSystemPermissions,
ResolvedAgentSystemPermissions, SystemPermissionSet, TurnRole,
};
pub use crate::ticket_dto::*; pub use crate::ticket_dto::*;
@ -1889,6 +1892,16 @@ pub struct ProjectPermissionsDto(pub ProjectPermissions);
#[serde(transparent)] #[serde(transparent)]
pub struct EffectivePermissionsDto(pub EffectivePermissions); pub struct EffectivePermissionsDto(pub EffectivePermissions);
/// Full project system permission document crossing the wire.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ProjectSystemPermissionsDto(pub ProjectSystemPermissions);
/// Resolved agent system permissions crossing the wire.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ResolvedAgentSystemPermissionsDto(pub ResolvedAgentSystemPermissions);
/// Canonical MCP tool catalogue classification crossing the wire. /// Canonical MCP tool catalogue classification crossing the wire.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -1959,6 +1972,38 @@ pub struct ResolveAgentPermissionsRequestDto {
pub agent_id: String, pub agent_id: String,
} }
/// Request DTO for updating project default system permissions.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateProjectSystemPermissionsRequestDto {
/// Id of the owning project.
pub project_id: String,
/// New project defaults. `null` removes defaults.
pub permissions: Option<SystemPermissionSet>,
}
/// Request DTO for updating one agent system permission override.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAgentSystemPermissionsRequestDto {
/// Id of the owning project.
pub project_id: String,
/// Target agent id.
pub agent_id: String,
/// New override. `null` removes the override.
pub permissions: Option<SystemPermissionSet>,
}
/// Request DTO for resolving one agent's effective system permissions.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveAgentSystemPermissionsRequestDto {
/// Id of the owning project.
pub project_id: String,
/// Target agent id.
pub agent_id: String,
}
/// Request DTO for updating project default MCP tool permissions. /// Request DTO for updating project default MCP tool permissions.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]

View File

@ -22,33 +22,35 @@ use application::{
DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate,
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory,
GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, GitBranches,
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, InstallPluginFromArchive,
JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput,
ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues,
ListModelServers, ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders,
ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents,
LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue,
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, RecordTurnProvider, ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn,
ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveAgentSystemPermissions,
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage,
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer,
SessionLimitService, SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows, SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
UpdateAgentContext, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateIssue, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext,
UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal,
AGENT_MEMORY_RECALL_BUDGET,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use domain::ports::{ use domain::ports::{
@ -58,9 +60,10 @@ use domain::ports::{
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore, PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore,
PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort,
Scheduler, SecretStore, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
TemplateStore, ToolInvoker, WakeError, WakeReason, WindowStateStore, StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
WakeError, WakeReason, WindowStateStore,
}; };
use domain::profile::{ use domain::profile::{
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
@ -83,16 +86,16 @@ use infrastructure::{
FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore,
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore,
FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore, FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore,
FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsTemplateStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore,
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer,
HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore,
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox,
PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, ReadOnlyRuntimePermissionProbe,
TemplateToolProvider, TicketAssistantEnvironmentPreparer, TicketToolProvider, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider,
TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
VECTOR_ONNX_ENABLED, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
}; };
pub mod dto; pub mod dto;
@ -1063,6 +1066,14 @@ pub struct BackendCore {
pub update_agent_permissions: Arc<UpdateAgentPermissions>, pub update_agent_permissions: Arc<UpdateAgentPermissions>,
/// Resolve effective permissions for one agent. /// Resolve effective permissions for one agent.
pub resolve_agent_permissions: Arc<ResolveAgentPermissions>, pub resolve_agent_permissions: Arc<ResolveAgentPermissions>,
/// Read the project system permission document.
pub get_project_system_permissions: Arc<GetProjectSystemPermissions>,
/// Update project-level default system permissions.
pub update_project_system_permissions: Arc<UpdateProjectSystemPermissions>,
/// Update one agent system permission override.
pub update_agent_system_permissions: Arc<UpdateAgentSystemPermissions>,
/// Resolve effective system permissions for one agent.
pub resolve_agent_system_permissions: Arc<ResolveAgentSystemPermissions>,
// --- Windows (L10) --- // --- Windows (L10) ---
/// Detach a tab into a new OS window (persists the workspace topology). /// Detach a tab into a new OS window (persists the workspace topology).
pub move_tab: Arc<MoveTabToNewWindow>, pub move_tab: Arc<MoveTabToNewWindow>,
@ -1640,6 +1651,24 @@ impl BackendCore {
// --- Project permissions (LP1) --- // --- Project permissions (LP1) ---
let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port))); let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port)));
let permission_store_port = Arc::clone(&permission_store) as Arc<dyn PermissionStore>; let permission_store_port = Arc::clone(&permission_store) as Arc<dyn PermissionStore>;
let system_permission_store = Arc::new(FsSystemPermissionStore::new(Arc::clone(&fs_port)));
let system_permission_store_port =
Arc::clone(&system_permission_store) as Arc<dyn SystemPermissionStore>;
let runtime_permission_probe =
Arc::new(ReadOnlyRuntimePermissionProbe) as Arc<dyn RuntimePermissionProbe>;
let get_project_system_permissions = Arc::new(GetProjectSystemPermissions::new(
Arc::clone(&system_permission_store_port),
));
let update_project_system_permissions = Arc::new(UpdateProjectSystemPermissions::new(
Arc::clone(&system_permission_store_port),
));
let update_agent_system_permissions = Arc::new(UpdateAgentSystemPermissions::new(
Arc::clone(&system_permission_store_port),
));
let resolve_agent_system_permissions = Arc::new(ResolveAgentSystemPermissions::new(
Arc::clone(&system_permission_store_port),
Arc::clone(&runtime_permission_probe),
));
let mcp_tool_permission_store = let mcp_tool_permission_store =
Arc::new(FsMcpToolPermissionStore::new(Arc::clone(&fs_port))); Arc::new(FsMcpToolPermissionStore::new(Arc::clone(&fs_port)));
let mcp_tool_permission_store_port = let mcp_tool_permission_store_port =
@ -2686,6 +2715,10 @@ impl BackendCore {
update_project_permissions, update_project_permissions,
update_agent_permissions, update_agent_permissions,
resolve_agent_permissions, resolve_agent_permissions,
get_project_system_permissions,
update_project_system_permissions,
update_agent_system_permissions,
resolve_agent_system_permissions,
create_template, create_template,
read_template, read_template,
update_template, update_template,

View File

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

View File

@ -59,6 +59,7 @@ use crate::project::{Project, ProjectPath};
use crate::remote::RemoteKind; use crate::remote::RemoteKind;
use crate::skill::{Skill, SkillScope}; use crate::skill::{Skill, SkillScope};
use crate::sprint::{Sprint, SprintIndexEntry, SprintVersion}; use crate::sprint::{Sprint, SprintIndexEntry, SprintVersion};
use crate::system_permissions::{ProjectSystemPermissions, RuntimePermissionSnapshot};
use crate::template::AgentTemplate; use crate::template::AgentTemplate;
use crate::terminal::PtySize; use crate::terminal::PtySize;
@ -2026,6 +2027,44 @@ pub trait PermissionStore: Send + Sync {
) -> Result<(), StoreError>; ) -> 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`. /// Reads/writes a project's `.ideai/mcp-tool-permissions.json`.
/// ///
/// This is intentionally distinct from [`PermissionStore`]: it governs IdeA MCP /// 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);
}
}

View File

@ -36,6 +36,7 @@ pub mod pty;
pub mod ratelimit; pub mod ratelimit;
pub mod remote; pub mod remote;
pub mod runtime; pub mod runtime;
pub mod runtime_permission;
pub mod sandbox; pub mod sandbox;
pub mod scheduler; pub mod scheduler;
pub mod session; pub mod session;
@ -87,6 +88,7 @@ pub use pty::PortablePtyAdapter;
pub use ratelimit::RateLimitParser; pub use ratelimit::RateLimitParser;
pub use remote::{remote_host, LocalHost}; pub use remote::{remote_host, LocalHost};
pub use runtime::CliAgentRuntime; pub use runtime::CliAgentRuntime;
pub use runtime_permission::ReadOnlyRuntimePermissionProbe;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub use sandbox::LandlockSandbox; pub use sandbox::LandlockSandbox;
pub use sandbox::{default_enforcer, NoopSandbox}; pub use sandbox::{default_enforcer, NoopSandbox};
@ -102,8 +104,8 @@ pub use store::{
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore, AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
FsMcpToolPermissionStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsMcpToolPermissionStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore,
FsSecretStore, FsSkillStore, FsTemplateStore, FsWindowStateStore, HashEmbedder, FsSecretStore, FsSkillStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore,
IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder,
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
VECTOR_ONNX_ENABLED, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
}; };

View File

@ -0,0 +1,24 @@
//! Read-only runtime permission probe.
//!
//! V1 deliberately does not claim live control over provider/network sandboxing.
use async_trait::async_trait;
use domain::ports::{RuntimeError, RuntimePermissionProbe};
use domain::{AgentId, Project, RuntimePermissionSnapshot};
/// Conservative probe used when IdeA cannot inspect or pilot runtime network
/// permissions.
#[derive(Debug, Clone, Default)]
pub struct ReadOnlyRuntimePermissionProbe;
#[async_trait]
impl RuntimePermissionProbe for ReadOnlyRuntimePermissionProbe {
async fn probe_runtime_permissions(
&self,
_project: &Project,
_agent_id: AgentId,
) -> Result<RuntimePermissionSnapshot, RuntimeError> {
Ok(RuntimePermissionSnapshot::locked_uninspectable())
}
}

View File

@ -16,6 +16,7 @@ mod profile;
mod project; mod project;
mod secrets; mod secrets;
mod skill; mod skill;
mod system_permission;
mod template; mod template;
mod vector; mod vector;
mod window_state; mod window_state;
@ -40,6 +41,7 @@ pub use profile::{FsEmbedderProfileStore, FsProfileStore};
pub use project::FsProjectStore; pub use project::FsProjectStore;
pub use secrets::FsSecretStore; pub use secrets::FsSecretStore;
pub use skill::FsSkillStore; pub use skill::FsSkillStore;
pub use system_permission::FsSystemPermissionStore;
pub use template::FsTemplateStore; pub use template::FsTemplateStore;
pub use vector::{should_use_vector, AdaptiveMemoryRecall, VectorMemoryRecall}; pub use vector::{should_use_vector, AdaptiveMemoryRecall, VectorMemoryRecall};
pub use window_state::FsWindowStateStore; pub use window_state::FsWindowStateStore;

View File

@ -0,0 +1,68 @@
//! Filesystem-backed [`SystemPermissionStore`] for project system permissions.
use std::sync::Arc;
use async_trait::async_trait;
use domain::ports::{FileSystem, FsError, RemotePath, StoreError, SystemPermissionStore};
use domain::{Project, ProjectSystemPermissions};
const SYSTEM_PERMISSIONS_FILE: &str = "system-permissions.json";
/// JSON-file implementation for `<project>/.ideai/system-permissions.json`.
#[derive(Clone)]
pub struct FsSystemPermissionStore {
fs: Arc<dyn FileSystem>,
}
impl FsSystemPermissionStore {
/// Builds the store from an injected filesystem port.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self { fs }
}
fn path(project: &Project) -> RemotePath {
let root = project.root.as_str().trim_end_matches(['/', '\\']);
RemotePath::new(format!("{root}/.ideai/{SYSTEM_PERMISSIONS_FILE}"))
}
async fn ensure_ideai(&self, project: &Project) -> Result<(), StoreError> {
let root = project.root.as_str().trim_end_matches(['/', '\\']);
self.fs
.create_dir_all(&RemotePath::new(format!("{root}/.ideai")))
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
}
#[async_trait]
impl SystemPermissionStore for FsSystemPermissionStore {
async fn load_system_permissions(
&self,
project: &Project,
) -> Result<ProjectSystemPermissions, StoreError> {
match self.fs.read(&Self::path(project)).await {
Ok(bytes) => {
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
}
Err(FsError::NotFound(_)) => Ok(ProjectSystemPermissions::default()),
Err(e) => Err(StoreError::Io(e.to_string())),
}
}
async fn save_system_permissions(
&self,
project: &Project,
permissions: &ProjectSystemPermissions,
) -> Result<(), StoreError> {
self.ensure_ideai(project).await?;
let mut bytes = serde_json::to_vec_pretty(permissions)
.map_err(|e| StoreError::Serialization(e.to_string()))?;
bytes.push(b'\n');
self.fs
.write(&Self::path(project), &bytes)
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
}

View File

@ -39,21 +39,22 @@ use application::{
CreateAgentInput, CreateMemoryInput, CreateSkillInput, CreateSprintInput, DeleteAgentInput, CreateAgentInput, CreateMemoryInput, CreateSkillInput, CreateSprintInput, DeleteAgentInput,
DeleteEmbedderProfileInput, DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput, DeleteEmbedderProfileInput, DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput,
DeleteSprintInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, DeleteSprintInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput,
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput,
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput, ListIssuesInput, InspectConversationInput, LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput,
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, ListSprintsInput, LiveSessions, ListIssuesInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput,
McpRuntime, OpenProjectInput, PairAttemptDecision, PairDeviceInput, RateLimitKey, ListSprintsInput, LiveSessions, McpRuntime, OpenProjectInput, PairAttemptDecision,
ReadAgentContextInput, ReadConversationPageInput, ReadIssueCarnetInput, ReadIssueInput, PairDeviceInput, RateLimitKey, ReadAgentContextInput, ReadConversationPageInput,
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReadIssueCarnetInput, ReadIssueInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput,
RenameDeviceInput, RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput, RenameSprintInput,
ResolveAgentPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput,
UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput,
UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UpdateIssueCarnetInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, UpdateSkillInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
WriteToTerminalInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput,
}; };
use domain::ports::PtyHandle; use domain::ports::PtyHandle;
use domain::IssueActor; use domain::IssueActor;
@ -78,22 +79,23 @@ use backend::dto::{
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto,
MemoryListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectWorkStateDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto, ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto,
SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SkillDto,
SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto,
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, SprintListRequestDto, SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto,
TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto, TicketCreateRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput, TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto,
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto, TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto,
TicketListPageInput, TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto, TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
UpdateSkillRequestDto, UpdateTemplateRequestDto, UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
}; };
use backend::events::DomainEventDto; use backend::events::DomainEventDto;
type PtyChunk = Vec<u8>; type PtyChunk = Vec<u8>;
@ -2396,6 +2398,18 @@ async fn invoke(
"resolve_agent_permissions" => { "resolve_agent_permissions" => {
invoke_resolve_agent_permissions(&request.args, &state.app).await invoke_resolve_agent_permissions(&request.args, &state.app).await
} }
"get_project_system_permissions" => {
invoke_get_project_system_permissions(&request.args, &state.app).await
}
"update_project_system_permissions" => {
invoke_update_project_system_permissions(&request.args, &state.app).await
}
"update_agent_system_permissions" => {
invoke_update_agent_system_permissions(&request.args, &state.app).await
}
"resolve_agent_system_permissions" => {
invoke_resolve_agent_system_permissions(&request.args, &state.app).await
}
"get_mcp_tool_permissions" => { "get_mcp_tool_permissions" => {
invoke_get_mcp_tool_permissions(&request.args, &state.app).await invoke_get_mcp_tool_permissions(&request.args, &state.app).await
} }
@ -3472,6 +3486,88 @@ async fn invoke_resolve_agent_permissions(
serde_json::to_value(output).map_err(serialization_error) serde_json::to_value(output).map_err(serialization_error)
} }
async fn invoke_get_project_system_permissions(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let project = resolve_project_readonly(
string_arg(args, "projectId", "get_project_system_permissions")?,
state,
)
.await?;
let output = state
.get_project_system_permissions
.execute(GetProjectSystemPermissionsInput { project })
.await
.map(|out| ProjectSystemPermissionsDto(out.permissions))
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_project_system_permissions(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<UpdateProjectSystemPermissionsRequestDto>(
"update_project_system_permissions",
args,
)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.update_project_system_permissions
.execute(UpdateProjectSystemPermissionsInput {
project,
permissions: request.permissions,
})
.await
.map(|out| ProjectSystemPermissionsDto(out.permissions))
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_agent_system_permissions(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<UpdateAgentSystemPermissionsRequestDto>(
"update_agent_system_permissions",
args,
)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.update_agent_system_permissions
.execute(UpdateAgentSystemPermissionsInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
permissions: request.permissions,
})
.await
.map(|out| ProjectSystemPermissionsDto(out.permissions))
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_resolve_agent_system_permissions(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<ResolveAgentSystemPermissionsRequestDto>(
"resolve_agent_system_permissions",
args,
)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.resolve_agent_system_permissions
.execute(ResolveAgentSystemPermissionsInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
})
.await
.map(|out| ResolvedAgentSystemPermissionsDto(out.permissions))
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_get_mcp_tool_permissions( async fn invoke_get_mcp_tool_permissions(
args: &Value, args: &Value,
state: &BackendCore, state: &BackendCore,
@ -7617,6 +7713,10 @@ mod tests {
"update_project_permissions", "update_project_permissions",
"update_agent_permissions", "update_agent_permissions",
"resolve_agent_permissions", "resolve_agent_permissions",
"get_project_system_permissions",
"update_project_system_permissions",
"update_agent_system_permissions",
"resolve_agent_system_permissions",
"get_mcp_tool_permissions", "get_mcp_tool_permissions",
"update_project_mcp_tool_permissions", "update_project_mcp_tool_permissions",
"update_agent_mcp_tool_permissions", "update_agent_mcp_tool_permissions",

View File

@ -40,7 +40,10 @@ import type {
ProjectMcpToolPermissions, ProjectMcpToolPermissions,
ProjectPermissions, ProjectPermissions,
ProjectWorkState, ProjectWorkState,
ProjectSystemPermissions,
ProfileAvailability, ProfileAvailability,
ResolvedAgentSystemPermissions,
SystemPermissionSet,
Skill, Skill,
SkillScope, SkillScope,
Template, Template,
@ -364,6 +367,37 @@ export class HttpPermissionGateway implements PermissionGateway {
request: { projectId, agentId }, request: { projectId, agentId },
}); });
} }
getProjectSystemPermissions(projectId: string): Promise<ProjectSystemPermissions> {
return this.http.invoke<ProjectSystemPermissions>("get_project_system_permissions", {
projectId,
});
}
updateProjectSystemPermissions(
projectId: string,
permissions: SystemPermissionSet | null,
): Promise<ProjectSystemPermissions> {
return this.http.invoke<ProjectSystemPermissions>("update_project_system_permissions", {
request: { projectId, permissions },
});
}
updateAgentSystemPermissions(
projectId: string,
agentId: string,
permissions: SystemPermissionSet | null,
): Promise<ProjectSystemPermissions> {
return this.http.invoke<ProjectSystemPermissions>("update_agent_system_permissions", {
request: { projectId, agentId, permissions },
});
}
resolveAgentSystemPermissions(
projectId: string,
agentId: string,
): Promise<ResolvedAgentSystemPermissions> {
return this.http.invoke<ResolvedAgentSystemPermissions>(
"resolve_agent_system_permissions",
{ request: { projectId, agentId } },
);
}
getMcpToolPermissions(projectId: string): Promise<ProjectMcpToolPermissions> { getMcpToolPermissions(projectId: string): Promise<ProjectMcpToolPermissions> {
return this.http.invoke<ProjectMcpToolPermissions>("get_mcp_tool_permissions", { projectId }); return this.http.invoke<ProjectMcpToolPermissions>("get_mcp_tool_permissions", { projectId });
} }

View File

@ -50,8 +50,10 @@ import type {
ProjectMcpToolPermissions, ProjectMcpToolPermissions,
ProjectPermissions, ProjectPermissions,
ProjectWorkState, ProjectWorkState,
ProjectSystemPermissions,
ProfileAvailability, ProfileAvailability,
ResumableAgent, ResumableAgent,
ResolvedAgentSystemPermissions,
ServerExposurePreview, ServerExposurePreview,
ServerExposureSettings, ServerExposureSettings,
Skill, Skill,
@ -59,6 +61,7 @@ import type {
SkillScope, SkillScope,
Sprint, Sprint,
Template, Template,
SystemPermissionSet,
TerminalSession, TerminalSession,
Ticket, Ticket,
TicketCarnet, TicketCarnet,
@ -2186,6 +2189,8 @@ export class MockDeviceGateway implements DeviceGateway {
/** In-memory permissions gateway. */ /** In-memory permissions gateway. */
export class MockPermissionGateway implements PermissionGateway { export class MockPermissionGateway implements PermissionGateway {
private docs = new Map<string, ProjectPermissions>(); private docs = new Map<string, ProjectPermissions>();
private systemDocs = new Map<string, ProjectSystemPermissions>();
private systemRuntime = new Map<string, ResolvedAgentSystemPermissions>();
private doc(projectId: string): ProjectPermissions { private doc(projectId: string): ProjectPermissions {
if (!this.docs.has(projectId)) { if (!this.docs.has(projectId)) {
@ -2234,6 +2239,72 @@ export class MockPermissionGateway implements PermissionGateway {
}; };
} }
private systemDoc(projectId: string): ProjectSystemPermissions {
if (!this.systemDocs.has(projectId)) {
this.systemDocs.set(projectId, { version: 1, agents: [] });
}
return this.systemDocs.get(projectId)!;
}
async getProjectSystemPermissions(
projectId: string,
): Promise<ProjectSystemPermissions> {
return structuredClone(this.systemDoc(projectId));
}
async updateProjectSystemPermissions(
projectId: string,
permissions: SystemPermissionSet | null,
): Promise<ProjectSystemPermissions> {
const doc = this.systemDoc(projectId);
if (permissions && Object.keys(permissions).length > 0) {
doc.projectDefault = structuredClone(permissions);
} else {
delete doc.projectDefault;
}
return structuredClone(doc);
}
async updateAgentSystemPermissions(
projectId: string,
agentId: string,
permissions: SystemPermissionSet | null,
): Promise<ProjectSystemPermissions> {
const doc = this.systemDoc(projectId);
const agents = (doc.agents ?? []).filter((entry) => entry.agentId !== agentId);
if (permissions && Object.keys(permissions).length > 0) {
agents.push({ agentId, permissions: structuredClone(permissions) });
}
doc.agents = agents;
return structuredClone(doc);
}
async resolveAgentSystemPermissions(
projectId: string,
agentId: string,
): Promise<ResolvedAgentSystemPermissions> {
const runtime = this.systemRuntime.get(agentId);
if (runtime) return structuredClone(runtime);
const doc = this.systemDoc(projectId);
const wanted =
doc.agents?.find((entry) => entry.agentId === agentId)?.permissions.network ??
doc.projectDefault?.network ??
null;
return {
wanted,
effective: wanted ?? "ask",
runtimeLock: { state: "none" },
control: { mode: "editable" },
};
}
setResolvedAgentSystemPermissions(
agentId: string,
resolved: ResolvedAgentSystemPermissions,
): void {
this.systemRuntime.set(agentId, structuredClone(resolved));
}
// ── MCP tool permissions (ticket #82) — mirrors the backend catalogue in // ── MCP tool permissions (ticket #82) — mirrors the backend catalogue in
// `crates/infrastructure/src/orchestrator/mcp/tools.rs`, a separate durable // `crates/infrastructure/src/orchestrator/mcp/tools.rs`, a separate durable
// document from the file/command permissions above. ───────────────────── // document from the file/command permissions above. ─────────────────────

View File

@ -6,6 +6,9 @@ import type {
PermissionSet, PermissionSet,
ProjectMcpToolPermissions, ProjectMcpToolPermissions,
ProjectPermissions, ProjectPermissions,
ProjectSystemPermissions,
ResolvedAgentSystemPermissions,
SystemPermissionSet,
} from "@/domain"; } from "@/domain";
import type { PermissionGateway } from "@/ports"; import type { PermissionGateway } from "@/ports";
@ -43,6 +46,41 @@ export class TauriPermissionGateway implements PermissionGateway {
}); });
} }
getProjectSystemPermissions(projectId: string): Promise<ProjectSystemPermissions> {
return invoke<ProjectSystemPermissions>("get_project_system_permissions", {
projectId,
});
}
updateProjectSystemPermissions(
projectId: string,
permissions: SystemPermissionSet | null,
): Promise<ProjectSystemPermissions> {
return invoke<ProjectSystemPermissions>("update_project_system_permissions", {
request: { projectId, permissions },
});
}
updateAgentSystemPermissions(
projectId: string,
agentId: string,
permissions: SystemPermissionSet | null,
): Promise<ProjectSystemPermissions> {
return invoke<ProjectSystemPermissions>("update_agent_system_permissions", {
request: { projectId, agentId, permissions },
});
}
resolveAgentSystemPermissions(
projectId: string,
agentId: string,
): Promise<ResolvedAgentSystemPermissions> {
return invoke<ResolvedAgentSystemPermissions>(
"resolve_agent_system_permissions",
{ request: { projectId, agentId } },
);
}
getMcpToolPermissions(projectId: string): Promise<ProjectMcpToolPermissions> { getMcpToolPermissions(projectId: string): Promise<ProjectMcpToolPermissions> {
return invoke<ProjectMcpToolPermissions>("get_mcp_tool_permissions", { return invoke<ProjectMcpToolPermissions>("get_mcp_tool_permissions", {
projectId, projectId,

View File

@ -748,6 +748,48 @@ export interface EffectivePermissions {
fallback: PermissionPosture; fallback: PermissionPosture;
} }
/** Wanted/effective network policy for system permissions. */
export type NetworkPolicy = "allow" | "deny" | "ask";
/** Optional system permission bundle, distinct from file/bash permissions. */
export interface SystemPermissionSet {
network?: NetworkPolicy;
}
/** One sparse agent system-permission override. */
export interface AgentSystemPermissionOverride {
agentId: string;
permissions: SystemPermissionSet;
}
/** Full project system-permission document. */
export interface ProjectSystemPermissions {
version: number;
projectDefault?: SystemPermissionSet;
agents?: AgentSystemPermissionOverride[];
}
/** Runtime lock details for resolved system permissions. */
export interface RuntimeLock {
state: "none" | "locked";
source?: string;
reason?: string;
}
/** Whether the UI can edit the wanted system policy. */
export interface SystemPermissionControl {
mode: "editable" | "readOnly";
reason?: string;
}
/** Resolved wanted/effective system permissions for one agent. */
export interface ResolvedAgentSystemPermissions {
wanted: NetworkPolicy | null;
effective: NetworkPolicy;
runtimeLock: RuntimeLock;
control: SystemPermissionControl;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// MCP tool permissions (ticket #82) — distinct from the file/command // MCP tool permissions (ticket #82) — distinct from the file/command
// permissions above: an allowlist of exact MCP tool names, applied by the MCP // permissions above: an allowlist of exact MCP tool names, applied by the MCP

View File

@ -14,7 +14,7 @@
* the existing `createAgent` path with the selected profile. * the existing `createAgent` path with the selected profile.
*/ */
import { useState } from "react"; import { useEffect, useState } from "react";
import { Button, Input, Panel, Spinner, cn } from "@/shared"; import { Button, Input, Panel, Spinner, cn } from "@/shared";
import { TerminalView } from "@/features/terminals/TerminalView"; import { TerminalView } from "@/features/terminals/TerminalView";
@ -25,6 +25,7 @@ import { useAgents } from "./useAgents";
import { correlateModelServerStatus } from "./modelServerLaunch"; import { correlateModelServerStatus } from "./modelServerLaunch";
import { AgentLimitBadge } from "./AgentLimitBadge"; import { AgentLimitBadge } from "./AgentLimitBadge";
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge"; import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
import type { ResolvedAgentSystemPermissions } from "@/domain";
export interface AgentsPanelProps { export interface AgentsPanelProps {
/** The project whose agents to manage. */ /** The project whose agents to manage. */
@ -41,6 +42,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
const drift = useDrift(projectId); const drift = useDrift(projectId);
const gateways = useGateways(); const gateways = useGateways();
const templateGw = gateways.template ?? null; const templateGw = gateways.template ?? null;
const permissionGw = gateways.permission ?? null;
// Create form state // Create form state
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
@ -130,6 +132,45 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
const [agentSessions, setAgentSessions] = useState<Record<string, string>>( const [agentSessions, setAgentSessions] = useState<Record<string, string>>(
{}, {},
); );
const [networkByAgent, setNetworkByAgent] = useState<
Record<string, ResolvedAgentSystemPermissions>
>({});
useEffect(() => {
if (!permissionGw || vm.agents.length === 0) {
setNetworkByAgent({});
return;
}
let cancelled = false;
void Promise.all(
vm.agents.map(async (candidate) => {
try {
return [
candidate.id,
await permissionGw.resolveAgentSystemPermissions(
projectId,
candidate.id,
),
] as const;
} catch {
return [candidate.id, null] as const;
}
}),
).then((pairs) => {
if (cancelled) return;
setNetworkByAgent(
Object.fromEntries(
pairs.filter(
(pair): pair is readonly [string, ResolvedAgentSystemPermissions] =>
pair[1] !== null,
),
),
);
});
return () => {
cancelled = true;
};
}, [permissionGw, projectId, vm.agents]);
/** /**
* Pending profile change awaiting confirmation: the target agent + the chosen * Pending profile change awaiting confirmation: the target agent + the chosen
@ -342,6 +383,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
vm.modelServerStatusByServer, vm.modelServerStatusByServer,
); );
const launchFailure = vm.launchFailureByAgent[a.id]; const launchFailure = vm.launchFailureByAgent[a.id];
const network = networkByAgent[a.id] ?? null;
return ( return (
<li <li
key={a.id} key={a.id}
@ -379,6 +421,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
{delegationSource} {delegationSource}
</span> </span>
)} )}
{network && <NetworkPermissionBadge state={network} />}
</span> </span>
<span className="text-xs text-muted">{profileName}</span> <span className="text-xs text-muted">{profileName}</span>
{live && ( {live && (
@ -513,6 +556,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
onSessionId={(sid) => onSessionId={(sid) =>
setAgentSessions((prev) => ({ ...prev, [activeAgentId]: sid })) setAgentSessions((prev) => ({ ...prev, [activeAgentId]: sid }))
} }
systemPermissions={networkByAgent[activeAgentId] ?? null}
/> />
</div> </div>
</div> </div>
@ -667,3 +711,40 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
</Panel> </Panel>
); );
} }
function NetworkPermissionBadge({
state,
}: {
state: ResolvedAgentSystemPermissions;
}) {
const locked = state.runtimeLock.state === "locked";
const label = locked
? "Verrouillé"
: state.effective === "allow"
? "Autorisé"
: state.effective === "deny"
? "Interdit"
: "Demande";
return (
<span
aria-label={`network ${label}`}
title={
locked
? (state.runtimeLock.reason ?? "Réseau verrouillé par le runtime")
: `État effectif : ${label}`
}
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
locked
? "bg-warning/15 text-warning"
: state.effective === "allow"
? "bg-success/15 text-success"
: state.effective === "deny"
? "bg-danger/15 text-danger"
: "bg-primary/15 text-primary",
)}
>
Réseau: {label}
</span>
);
}

View File

@ -21,6 +21,7 @@ import {
import { import {
MockAgentGateway, MockAgentGateway,
MockPermissionGateway,
MockProfileGateway, MockProfileGateway,
MockSystemGateway, MockSystemGateway,
MockTemplateGateway, MockTemplateGateway,
@ -44,13 +45,15 @@ function renderPanel(
profile: MockProfileGateway = new MockProfileGateway(), profile: MockProfileGateway = new MockProfileGateway(),
projectRoot = "/home/me/proj", projectRoot = "/home/me/proj",
template?: MockTemplateGateway, template?: MockTemplateGateway,
permission: MockPermissionGateway = new MockPermissionGateway(),
) { ) {
const tmpl = template ?? new MockTemplateGateway(agent); const tmpl = template ?? new MockTemplateGateway(agent);
const gateways = { agent, profile, template: tmpl } as unknown as Gateways; const gateways = { agent, profile, template: tmpl, permission } as unknown as Gateways;
return { return {
agent, agent,
profile, profile,
template: tmpl, template: tmpl,
permission,
...render( ...render(
<DIProvider gateways={gateways}> <DIProvider gateways={gateways}>
<AgentsPanel projectId={PROJECT_ID} projectRoot={projectRoot} /> <AgentsPanel projectId={PROJECT_ID} projectRoot={projectRoot} />
@ -108,6 +111,27 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
expect(agent).toBeTruthy(); expect(agent).toBeTruthy();
}); });
it("shows the resolved network permission badge for an agent", async () => {
const agent = new MockAgentGateway();
const permission = new MockPermissionGateway();
const created = await agent.createAgent(PROJECT_ID, {
name: "Builder",
profileId: "p1",
});
permission.setResolvedAgentSystemPermissions(created.id, {
wanted: "allow",
effective: "deny",
runtimeLock: { state: "locked", reason: "Runtime locked." },
control: { mode: "readOnly", reason: "Runtime locked." },
});
renderPanel(agent, new MockProfileGateway(), "/home/me/proj", undefined, permission);
await waitFor(() => {
expect(screen.getByText("Réseau: Verrouillé")).toBeTruthy();
});
});
it("the Create button is disabled when the name is empty", async () => { it("the Create button is disabled when the name is empty", async () => {
renderPanel(); renderPanel();
await waitForIdle(); await waitForIdle();

View File

@ -1,7 +1,13 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Button, Panel, Spinner, cn } from "@/shared"; import { Button, Panel, Spinner, cn } from "@/shared";
import type { PermissionSet, PermissionPosture } from "@/domain"; import type {
NetworkPolicy,
PermissionSet,
PermissionPosture,
ResolvedAgentSystemPermissions,
SystemPermissionSet,
} from "@/domain";
import { McpToolPermissionsPanel } from "./McpToolPermissionsPanel"; import { McpToolPermissionsPanel } from "./McpToolPermissionsPanel";
import { import {
type CapabilityChoice, type CapabilityChoice,
@ -42,6 +48,12 @@ const POSTURE_LABELS: Record<PermissionPosture, string> = {
deny: "Deny", deny: "Deny",
}; };
const NETWORK_LABELS: Record<NetworkPolicy, string> = {
allow: "Autorisé",
deny: "Interdit",
ask: "Demande",
};
export function PermissionsPanel({ projectId }: PermissionsPanelProps) { export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
const vm = usePermissions(projectId); const vm = usePermissions(projectId);
const [target, setTarget] = useState<EditorTarget>({ type: "project" }); const [target, setTarget] = useState<EditorTarget>({ type: "project" });
@ -51,6 +63,16 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
? vm.rows.find((row) => row.agent.id === target.agentId) ?? null ? vm.rows.find((row) => row.agent.id === target.agentId) ?? null
: null; : null;
const activePolicy = selectedAgent?.override ?? vm.document?.projectDefaults ?? null; const activePolicy = selectedAgent?.override ?? vm.document?.projectDefaults ?? null;
const activeSystemSet =
target.type === "project"
? vm.systemDocument?.projectDefault ?? null
: selectedAgent?.systemOverride ??
vm.systemDocument?.projectDefault ??
null;
const activeResolvedSystem =
target.type === "project"
? vm.rows[0]?.resolvedSystem ?? null
: selectedAgent?.resolvedSystem ?? null;
const activeDraft = target.type === "project" const activeDraft = target.type === "project"
? vm.projectDraft ? vm.projectDraft
: draftFromSet(selectedAgent?.override ?? vm.document?.projectDefaults ?? null); : draftFromSet(selectedAgent?.override ?? vm.document?.projectDefaults ?? null);
@ -75,6 +97,14 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
} }
} }
async function handleSaveNetwork(permissions: SystemPermissionSet | null) {
if (target.type === "project") {
await vm.saveProjectSystemPermissions(permissions);
} else {
await vm.saveAgentSystemPermissions(target.agentId, permissions);
}
}
return ( return (
<Panel <Panel
title="Permissions" title="Permissions"
@ -202,6 +232,24 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
onSave={(draft) => void handleSave(draft)} onSave={(draft) => void handleSave(draft)}
onClear={() => void handleClear()} onClear={() => void handleClear()}
/> />
<NetworkPermissionEditor
key={`network-${target.type === "project" ? "project" : target.agentId}`}
title={
target.type === "project"
? "Réseau — defaults projet"
: `Réseau — ${target.agentName}`
}
source={activeSystemSet}
resolved={activeResolvedSystem}
inherited={
target.type === "agent" && selectedAgent?.systemOverride == null
? vm.systemDocument?.projectDefault ?? null
: null
}
busy={vm.busy}
onSave={(permissions) => void handleSaveNetwork(permissions)}
/>
</div> </div>
<div <div
@ -267,6 +315,138 @@ function PolicyCard({
); );
} }
interface NetworkPermissionEditorProps {
title: string;
source: SystemPermissionSet | null;
inherited: SystemPermissionSet | null;
resolved: ResolvedAgentSystemPermissions | null;
busy: boolean;
onSave: (permissions: SystemPermissionSet | null) => void;
}
function NetworkPermissionEditor({
title,
source,
inherited,
resolved,
busy,
onSave,
}: NetworkPermissionEditorProps) {
const current = source?.network ?? "";
const [local, setLocal] = useState<NetworkPolicy | "">(current);
useEffect(() => {
setLocal(current);
}, [current]);
const readOnly = resolved?.control.mode === "readOnly";
const changed = local !== current;
const wanted = resolved?.wanted ?? source?.network ?? inherited?.network ?? null;
const effective = resolved?.effective ?? null;
const runtimeLocked = resolved?.runtimeLock.state === "locked";
const lockReason = resolved?.runtimeLock.reason ?? resolved?.control.reason ?? null;
return (
<section className="rounded-md border border-border bg-surface">
<header className="border-b border-border px-3 py-2.5">
<h4 className="text-sm font-semibold text-content">{title}</h4>
<p className="text-xs text-muted">
Politique voulue, état effectif et verrou runtime.
</p>
</header>
<div className="flex flex-col gap-3 p-3">
<div className="grid grid-cols-1 gap-2 text-xs sm:grid-cols-2">
<StatusLine
label="Wanted"
value={wanted ? `Réseau ${NETWORK_LABELS[wanted].toLowerCase()}` : "Non configuré"}
/>
<StatusLine
label="Effective"
value={
effective
? `Réseau ${NETWORK_LABELS[effective].toLowerCase()}`
: "Indisponible"
}
/>
<StatusLine
label="Runtime"
value={runtimeLocked ? "Verrouillé par le runtime" : "Aucun verrou connu"}
/>
<StatusLine
label="Control"
value={readOnly ? "Lecture seule" : "Modifiable"}
/>
</div>
{readOnly && (
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{lockReason ?? "Le runtime actif ne permet pas de modifier cette permission."}
</p>
)}
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Réseau</span>
<select
aria-label={`${title} network`}
value={local}
disabled={busy || readOnly}
onChange={(e) => setLocal(e.target.value as NetworkPolicy | "")}
className={cn(
"h-9 rounded-md border border-border bg-raised px-3 text-sm text-content",
"outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<option value="">Non configuré</option>
<option value="allow">Réseau autorisé</option>
<option value="deny">Réseau interdit</option>
<option value="ask">Demande d&apos;autorisation</option>
</select>
</label>
{inherited?.network && !source?.network && (
<p className="text-xs text-muted">
Hérite du projet : Réseau {NETWORK_LABELS[inherited.network].toLowerCase()}.
</p>
)}
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
<Button
size="sm"
variant="ghost"
aria-label="Clear network"
disabled={busy || readOnly || !source?.network}
onClick={() => onSave(null)}
>
Clear
</Button>
<Button
size="sm"
variant="primary"
aria-label="Save network"
disabled={busy || readOnly || !changed}
loading={busy && changed}
onClick={() => onSave(local ? { network: local } : null)}
>
Save
</Button>
</div>
</div>
</section>
);
}
function StatusLine({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-border/70 bg-raised px-3 py-2">
<span className="block text-[11px] font-medium uppercase text-faint">
{label}
</span>
<span className="text-xs text-content">{value}</span>
</div>
);
}
interface PermissionEditorProps { interface PermissionEditorProps {
title: string; title: string;
draft: PolicyDraft; draft: PolicyDraft;

View File

@ -86,4 +86,58 @@ describe("PermissionsPanel", () => {
expect(doc.agents).toEqual([]); expect(doc.agents).toEqual([]);
}); });
}); });
it("saves project network defaults in the separate system permissions document", async () => {
const { permission } = await renderPanel();
fireEvent.change(screen.getByLabelText("Réseau — defaults projet network"), {
target: { value: "allow" },
});
fireEvent.click(screen.getByRole("button", { name: "Save network" }));
await waitFor(async () => {
const doc = await permission.getProjectSystemPermissions(PROJECT_ID);
expect(doc.projectDefault?.network).toBe("allow");
});
expect(screen.getAllByText("Réseau autorisé").length).toBeGreaterThan(0);
});
it("renders the network control read-only when the resolved runtime says so", async () => {
const agent = new MockAgentGateway();
const permission = new MockPermissionGateway();
const created = await agent.createAgent(PROJECT_ID, {
name: "Builder",
profileId: "p1",
});
permission.setResolvedAgentSystemPermissions(created.id, {
wanted: "allow",
effective: "deny",
runtimeLock: {
state: "locked",
source: "external-runtime",
reason: "Runtime network is locked.",
},
control: { mode: "readOnly", reason: "Runtime network is locked." },
});
const gateways = {
agent,
permission,
profile: new MockProfileGateway(),
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<PermissionsPanel projectId={PROJECT_ID} />
</DIProvider>,
);
await waitFor(() => {
expect(screen.getByText("Verrouillé par le runtime")).toBeTruthy();
});
expect(screen.getByText("Runtime network is locked.")).toBeTruthy();
expect(
(screen.getByLabelText("Réseau — defaults projet network") as HTMLSelectElement)
.disabled,
).toBe(true);
});
}); });

View File

@ -9,6 +9,9 @@ import type {
PermissionRule, PermissionRule,
PermissionSet, PermissionSet,
ProjectPermissions, ProjectPermissions,
ProjectSystemPermissions,
ResolvedAgentSystemPermissions,
SystemPermissionSet,
} from "@/domain"; } from "@/domain";
export type CapabilityChoice = "none" | PermissionEffect; export type CapabilityChoice = "none" | PermissionEffect;
@ -24,12 +27,15 @@ export interface PolicyDraft {
export interface AgentPermissionRow { export interface AgentPermissionRow {
agent: Agent; agent: Agent;
override: PermissionSet | null; override: PermissionSet | null;
systemOverride: SystemPermissionSet | null;
resolvedSystem: ResolvedAgentSystemPermissions | null;
} }
export interface PermissionsViewModel { export interface PermissionsViewModel {
agents: Agent[]; agents: Agent[];
rows: AgentPermissionRow[]; rows: AgentPermissionRow[];
document: ProjectPermissions | null; document: ProjectPermissions | null;
systemDocument: ProjectSystemPermissions | null;
projectDraft: PolicyDraft; projectDraft: PolicyDraft;
busy: boolean; busy: boolean;
error: string | null; error: string | null;
@ -38,6 +44,13 @@ export interface PermissionsViewModel {
clearProjectDefaults: () => Promise<void>; clearProjectDefaults: () => Promise<void>;
saveAgentOverride: (agentId: string, draft: PolicyDraft) => Promise<void>; saveAgentOverride: (agentId: string, draft: PolicyDraft) => Promise<void>;
clearAgentOverride: (agentId: string) => Promise<void>; clearAgentOverride: (agentId: string) => Promise<void>;
saveProjectSystemPermissions: (
permissions: SystemPermissionSet | null,
) => Promise<void>;
saveAgentSystemPermissions: (
agentId: string,
permissions: SystemPermissionSet | null,
) => Promise<void>;
} }
const DEFAULT_DRAFT: PolicyDraft = { const DEFAULT_DRAFT: PolicyDraft = {
@ -114,6 +127,11 @@ export function usePermissions(projectId: string): PermissionsViewModel {
const { agent, permission } = useGateways(); const { agent, permission } = useGateways();
const [agents, setAgents] = useState<Agent[]>([]); const [agents, setAgents] = useState<Agent[]>([]);
const [document, setDocument] = useState<ProjectPermissions | null>(null); const [document, setDocument] = useState<ProjectPermissions | null>(null);
const [systemDocument, setSystemDocument] =
useState<ProjectSystemPermissions | null>(null);
const [resolvedSystemByAgent, setResolvedSystemByAgent] = useState<
Record<string, ResolvedAgentSystemPermissions>
>({});
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -121,12 +139,34 @@ export function usePermissions(projectId: string): PermissionsViewModel {
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
const [agentList, permissionDoc] = await Promise.all([ const [agentList, permissionDoc, systemPermissionDoc] = await Promise.all([
agent.listAgents(projectId), agent.listAgents(projectId),
permission.getProjectPermissions(projectId), permission.getProjectPermissions(projectId),
permission.getProjectSystemPermissions(projectId),
]); ]);
const resolvedPairs = await Promise.all(
agentList.map(async (candidate) => {
try {
return [
candidate.id,
await permission.resolveAgentSystemPermissions(projectId, candidate.id),
] as const;
} catch {
return [candidate.id, null] as const;
}
}),
);
setAgents(agentList); setAgents(agentList);
setDocument(permissionDoc); setDocument(permissionDoc);
setSystemDocument(systemPermissionDoc);
setResolvedSystemByAgent(
Object.fromEntries(
resolvedPairs.filter(
(pair): pair is readonly [string, ResolvedAgentSystemPermissions] =>
pair[1] !== null,
),
),
);
} catch (e) { } catch (e) {
setError(describe(e)); setError(describe(e));
} finally { } finally {
@ -142,11 +182,19 @@ export function usePermissions(projectId: string): PermissionsViewModel {
const overrides = new Map( const overrides = new Map(
(document?.agents ?? []).map((entry) => [entry.agentId, entry.permissions]), (document?.agents ?? []).map((entry) => [entry.agentId, entry.permissions]),
); );
const systemOverrides = new Map(
(systemDocument?.agents ?? []).map((entry) => [
entry.agentId,
entry.permissions,
]),
);
return agents.map((candidate) => ({ return agents.map((candidate) => ({
agent: candidate, agent: candidate,
override: overrides.get(candidate.id) ?? null, override: overrides.get(candidate.id) ?? null,
systemOverride: systemOverrides.get(candidate.id) ?? null,
resolvedSystem: resolvedSystemByAgent[candidate.id] ?? null,
})); }));
}, [agents, document]); }, [agents, document, systemDocument, resolvedSystemByAgent]);
const projectDraft = useMemo( const projectDraft = useMemo(
() => draftFromSet(document?.projectDefaults ?? null), () => draftFromSet(document?.projectDefaults ?? null),
@ -220,10 +268,52 @@ export function usePermissions(projectId: string): PermissionsViewModel {
[permission, projectId], [permission, projectId],
); );
const saveProjectSystemPermissions = useCallback(
async (permissionsToSave: SystemPermissionSet | null) => {
setBusy(true);
setError(null);
try {
const next = await permission.updateProjectSystemPermissions(
projectId,
permissionsToSave,
);
setSystemDocument(next);
await refresh();
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[permission, projectId, refresh],
);
const saveAgentSystemPermissions = useCallback(
async (agentId: string, permissionsToSave: SystemPermissionSet | null) => {
setBusy(true);
setError(null);
try {
const next = await permission.updateAgentSystemPermissions(
projectId,
agentId,
permissionsToSave,
);
setSystemDocument(next);
await refresh();
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[permission, projectId, refresh],
);
return { return {
agents, agents,
rows, rows,
document, document,
systemDocument,
projectDraft, projectDraft,
busy, busy,
error, error,
@ -232,5 +322,7 @@ export function usePermissions(projectId: string): PermissionsViewModel {
clearProjectDefaults, clearProjectDefaults,
saveAgentOverride, saveAgentOverride,
clearAgentOverride, clearAgentOverride,
saveProjectSystemPermissions,
saveAgentSystemPermissions,
}; };
} }

View File

@ -55,6 +55,22 @@ describe("TerminalView (with MockTerminalGateway)", () => {
expect(screen.getByTestId("terminal-view")).toBeTruthy(); expect(screen.getByTestId("terminal-view")).toBeTruthy();
}); });
it("shows a non-blocking network banner when system permissions are locked", () => {
renderView(new MockTerminalGateway(), "/home/me/proj", {
systemPermissions: {
wanted: "allow",
effective: "deny",
runtimeLock: { state: "locked", reason: "Runtime network locked." },
control: { mode: "readOnly", reason: "Runtime network locked." },
},
});
expect(screen.getByTestId("terminal-network-banner").textContent).toContain(
"Réseau verrouillé par le runtime.",
);
expect(screen.getByTestId("terminal-view")).toBeTruthy();
});
it("opens a terminal through the gateway with the given cwd", async () => { it("opens a terminal through the gateway with the given cwd", async () => {
const gw = new MockTerminalGateway(); const gw = new MockTerminalGateway();
const openSpy = vi.spyOn(gw, "openTerminal"); const openSpy = vi.spyOn(gw, "openTerminal");

View File

@ -39,6 +39,7 @@ import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useGateways } from "@/app/di"; import { useGateways } from "@/app/di";
import type { ResolvedAgentSystemPermissions } from "@/domain";
import type { import type {
OpenTerminalOptions, OpenTerminalOptions,
ReattachResult, ReattachResult,
@ -113,6 +114,8 @@ interface TerminalViewProps {
* it never remounts/reopens the terminal. * it never remounts/reopens the terminal.
*/ */
refitSignal?: number; refitSignal?: number;
/** Optional resolved system permissions for this agent/cell. */
systemPermissions?: ResolvedAgentSystemPermissions | null;
} }
/** /**
@ -144,6 +147,7 @@ export function TerminalView({
portal, portal,
onReady, onReady,
refitSignal, refitSignal,
systemPermissions,
}: TerminalViewProps) { }: TerminalViewProps) {
const { terminal } = useGateways(); const { terminal } = useGateways();
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@ -414,6 +418,15 @@ export function TerminalView({
refitRef.current?.(); refitRef.current?.();
}, [refitSignal]); }, [refitSignal]);
const showNetworkBanner =
systemPermissions != null &&
(systemPermissions.runtimeLock.state === "locked" ||
systemPermissions.effective === "deny");
const networkReason =
systemPermissions?.runtimeLock.reason ??
systemPermissions?.control.reason ??
"Le réseau est interdit pour cette cellule.";
return ( return (
<div <div
data-testid="terminal-view" data-testid="terminal-view"
@ -427,6 +440,34 @@ export function TerminalView({
{/* xterm mounts into this inner node; the error banner is a sibling so {/* xterm mounts into this inner node; the error banner is a sibling so
React never fights xterm over the same subtree. */} React never fights xterm over the same subtree. */}
<div ref={containerRef} style={{ width: "100%", height: "100%" }} /> <div ref={containerRef} style={{ width: "100%", height: "100%" }} />
{showNetworkBanner && (
<div
role="status"
data-testid="terminal-network-banner"
style={{
position: "absolute",
top: 8,
left: 8,
right: 8,
padding: "0.5rem 0.75rem",
border: "1px solid rgba(245, 158, 11, 0.45)",
borderRadius: 6,
background: "rgba(24, 24, 27, 0.94)",
color: "var(--color-warning, #f59e0b)",
fontSize: 12,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
whiteSpace: "pre-wrap",
wordBreak: "break-word",
zIndex: 2,
}}
>
{systemPermissions.runtimeLock.state === "locked"
? "Réseau verrouillé par le runtime."
: "Réseau interdit pour cet agent."}{" "}
{networkReason}
</div>
)}
{openError && ( {openError && (
<div <div
role="alert" role="alert"

View File

@ -52,13 +52,16 @@ import type {
Project, Project,
ProjectPermissions, ProjectPermissions,
ProjectWorkState, ProjectWorkState,
ProjectSystemPermissions,
ProfileAvailability, ProfileAvailability,
ResumableAgent, ResumableAgent,
ResolvedAgentSystemPermissions,
ReplyChunk, ReplyChunk,
ServerExposurePreview, ServerExposurePreview,
ServerExposureSettings, ServerExposureSettings,
Skill, Skill,
SkillScope, SkillScope,
SystemPermissionSet,
Sprint, Sprint,
Template, Template,
TerminalSession, TerminalSession,
@ -861,6 +864,24 @@ export interface PermissionGateway {
projectId: string, projectId: string,
agentId: string, agentId: string,
): Promise<EffectivePermissions | null>; ): Promise<EffectivePermissions | null>;
/** Reads the full project system-permission document. */
getProjectSystemPermissions(projectId: string): Promise<ProjectSystemPermissions>;
/** Replaces or removes project-level default system permissions. */
updateProjectSystemPermissions(
projectId: string,
permissions: SystemPermissionSet | null,
): Promise<ProjectSystemPermissions>;
/** Replaces or removes one agent-specific system-permission override. */
updateAgentSystemPermissions(
projectId: string,
agentId: string,
permissions: SystemPermissionSet | null,
): Promise<ProjectSystemPermissions>;
/** Resolves wanted/effective runtime-constrained system permissions. */
resolveAgentSystemPermissions(
projectId: string,
agentId: string,
): Promise<ResolvedAgentSystemPermissions>;
/** /**
* Reads the project's durable MCP tool permission document plus the * Reads the project's durable MCP tool permission document plus the
* backend-canonical catalogue classification (ticket #82). Distinct * backend-canonical catalogue classification (ticket #82). Distinct