diff --git a/.ideai/memory/ticket103-network-permission-ux-surface.md b/.ideai/memory/ticket103-network-permission-ux-surface.md new file mode 100644 index 0000000..322b70c --- /dev/null +++ b/.ideai/memory/ticket103-network-permission-ux-surface.md @@ -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. \ No newline at end of file diff --git a/.ideai/tickets/103/carnet.md b/.ideai/tickets/103/carnet.md new file mode 100644 index 0000000..c74429c --- /dev/null +++ b/.ideai/tickets/103/carnet.md @@ -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. \ No newline at end of file diff --git a/.ideai/tickets/103/issue.md b/.ideai/tickets/103/issue.md new file mode 100644 index 0000000..8232181 --- /dev/null +++ b/.ideai/tickets/103/issue.md @@ -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. \ No newline at end of file diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 0923798..dd9d93d 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -14,19 +14,21 @@ use application::{ CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, - GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, - GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, - LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput, - ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime, - MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput, - ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, - ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, - ResolveAgentPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, + GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, + GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, + InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListDevicesInput, + ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, + LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, + ReadConversationPageInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput, + ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, ReconcileLiveStateInput, + RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, + ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, - UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, - UpdateProjectPermissionsInput, UpdateSkillInput, + UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, + UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, + UpdateProjectSystemPermissionsInput, UpdateSkillInput, }; use domain::ports::ModelServerRuntime; use domain::ports::PtyHandle; @@ -52,19 +54,22 @@ use crate::dto::{ MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, - ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectWorkStateDto, - ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto, - ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, - ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto, - SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, + ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto, + ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto, + ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, + ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, + ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, + ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, UpdateAgentMcpToolPermissionsRequestDto, - UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, + UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto, + UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, - UpdateSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto, + UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, + WriteTerminalRequestDto, }; use crate::embedded_server::{ EmbeddedServerStatusDto, ServerExposurePreviewDto, ServerExposureSettingsDto, @@ -549,6 +554,87 @@ pub async fn resolve_agent_permissions( .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 { + 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 { + 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 { + 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 { + 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. /// /// # Errors diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 216cf9f..19c8ab1 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -229,6 +229,10 @@ pub fn run() { commands::update_project_permissions, commands::update_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::update_project_mcp_tool_permissions, commands::update_agent_mcp_tool_permissions, diff --git a/crates/app-tauri/tests/dto_system_permissions.rs b/crates/app-tauri/tests/dto_system_permissions.rs new file mode 100644 index 0000000..e71896d --- /dev/null +++ b/crates/app-tauri/tests/dto_system_permissions.rs @@ -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"); +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index ddc49b0..851181c 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -32,6 +32,7 @@ pub mod project; pub mod remote; pub mod skill; pub mod sprints; +pub mod system_permissions; pub mod template; pub mod terminal; pub mod ticket_assistant; @@ -170,6 +171,13 @@ pub use sprints::{ RenameSprintInput, ReorderSprints, ReorderSprintsInput, ReorderSprintsOutput, SprintListEntry, SprintOutput, UnassignTicketFromSprint, UnassignTicketFromSprintInput, }; +pub use system_permissions::{ + GetProjectSystemPermissions, GetProjectSystemPermissionsInput, + GetProjectSystemPermissionsOutput, ResolveAgentSystemPermissions, + ResolveAgentSystemPermissionsInput, ResolveAgentSystemPermissionsOutput, + UpdateAgentSystemPermissions, UpdateAgentSystemPermissionsInput, + UpdateProjectSystemPermissions, UpdateProjectSystemPermissionsInput, +}; pub use template::{ AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput, CreateAgentFromTemplateOutput, CreateTemplate, CreateTemplateInput, CreateTemplateOutput, diff --git a/crates/application/src/system_permissions.rs b/crates/application/src/system_permissions.rs new file mode 100644 index 0000000..cf44536 --- /dev/null +++ b/crates/application/src/system_permissions.rs @@ -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, +} + +impl GetProjectSystemPermissions { + /// Builds the use case. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Executes the read. + pub async fn execute( + &self, + input: GetProjectSystemPermissionsInput, + ) -> Result { + 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, +} + +impl UpdateProjectSystemPermissions { + /// Builds the use case. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Executes the mutation. + pub async fn execute( + &self, + input: UpdateProjectSystemPermissionsInput, + ) -> Result { + 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, +} + +/// Replaces one agent system permission override. +pub struct UpdateAgentSystemPermissions { + store: Arc, +} + +impl UpdateAgentSystemPermissions { + /// Builds the use case. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Executes the mutation. + pub async fn execute( + &self, + input: UpdateAgentSystemPermissionsInput, + ) -> Result { + 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, +} + +/// Resolves effective system permissions for one agent. +pub struct ResolveAgentSystemPermissions { + store: Arc, + runtime_probe: Arc, +} + +impl ResolveAgentSystemPermissions { + /// Builds the use case. + #[must_use] + pub fn new( + store: Arc, + runtime_probe: Arc, + ) -> Self { + Self { + store, + runtime_probe, + } + } + + /// Executes the resolution. + pub async fn execute( + &self, + input: ResolveAgentSystemPermissionsInput, + ) -> Result { + 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, +} diff --git a/crates/application/tests/system_permission_usecases.rs b/crates/application/tests/system_permission_usecases.rs new file mode 100644 index 0000000..bb5e8df --- /dev/null +++ b/crates/application/tests/system_permission_usecases.rs @@ -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, + saves: Mutex, +} + +#[async_trait] +impl SystemPermissionStore for FakeSystemPermissionStore { + async fn load_system_permissions( + &self, + _project: &Project, + ) -> Result { + 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 { + 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 + ); +} diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index aad9c81..258065a 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -16,7 +16,10 @@ use application::{ ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState, 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::*; @@ -1889,6 +1892,16 @@ pub struct ProjectPermissionsDto(pub ProjectPermissions); #[serde(transparent)] 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. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1959,6 +1972,38 @@ pub struct ResolveAgentPermissionsRequestDto { 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, +} + +/// 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, +} + +/// 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. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 351f750..276c7ef 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -22,33 +22,35 @@ use application::{ DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, - GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, - GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, - InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory, - JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, - ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, - ListModelServers, ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, - ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, - LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider, - LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, - MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant, - OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, - ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, - ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext, - ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, - ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, RecordTurnProvider, - ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, - ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, - ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, - SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile, - SessionLimitService, SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows, - SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, - StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, - UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, - UpdateAgentContext, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateIssue, - UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, - UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, - WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, GitBranches, + GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, + HarvestMemoryFromTurn, HealthUseCase, InspectConversation, InstallPluginFromArchive, + InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, + LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues, + ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders, + ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents, + ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, + LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, + MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, + OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, + PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, + ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, + ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, + ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, + RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, + ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveAgentSystemPermissions, + ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, + RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer, + SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout, + SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, + StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, + SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, + UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext, + UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions, + UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, + UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions, + UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, + AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ @@ -58,9 +60,10 @@ use domain::ports::{ EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore, PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore, - PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, - Scheduler, SecretStore, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, - TemplateStore, ToolInvoker, WakeError, WakeReason, WindowStateStore, + PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, + RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore, + StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker, + WakeError, WakeReason, WindowStateStore, }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -83,16 +86,16 @@ use infrastructure::{ FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore, - FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsTemplateStore, - FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, - HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, - InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, - LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, - PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, - TemplateToolProvider, TicketAssistantEnvironmentPreparer, TicketToolProvider, - TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, - DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, - VECTOR_ONNX_ENABLED, + FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore, + FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, + HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore, + InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, + LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, + NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, ReadOnlyRuntimePermissionProbe, + RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider, + TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, + ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, + ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; pub mod dto; @@ -1063,6 +1066,14 @@ pub struct BackendCore { pub update_agent_permissions: Arc, /// Resolve effective permissions for one agent. pub resolve_agent_permissions: Arc, + /// Read the project system permission document. + pub get_project_system_permissions: Arc, + /// Update project-level default system permissions. + pub update_project_system_permissions: Arc, + /// Update one agent system permission override. + pub update_agent_system_permissions: Arc, + /// Resolve effective system permissions for one agent. + pub resolve_agent_system_permissions: Arc, // --- Windows (L10) --- /// Detach a tab into a new OS window (persists the workspace topology). pub move_tab: Arc, @@ -1640,6 +1651,24 @@ impl BackendCore { // --- Project permissions (LP1) --- let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port))); let permission_store_port = Arc::clone(&permission_store) as Arc; + let system_permission_store = Arc::new(FsSystemPermissionStore::new(Arc::clone(&fs_port))); + let system_permission_store_port = + Arc::clone(&system_permission_store) as Arc; + let runtime_permission_probe = + Arc::new(ReadOnlyRuntimePermissionProbe) as Arc; + 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 = Arc::new(FsMcpToolPermissionStore::new(Arc::clone(&fs_port))); let mcp_tool_permission_store_port = @@ -2686,6 +2715,10 @@ impl BackendCore { update_project_permissions, update_agent_permissions, resolve_agent_permissions, + get_project_system_permissions, + update_project_system_permissions, + update_agent_system_permissions, + resolve_agent_system_permissions, create_template, read_template, update_template, diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 56ff7df..d3c5a4e 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -64,6 +64,7 @@ pub mod sandbox; pub mod session_limit; pub mod skill; pub mod sprint; +pub mod system_permissions; pub mod template; pub mod terminal; @@ -195,6 +196,13 @@ pub use permission::{ PERMISSIONS_VERSION, }; +pub use system_permissions::{ + resolve_agent_system_permissions, AgentSystemPermissionOverride, NetworkPolicy, + ProjectSystemPermissions, ResolvedAgentSystemPermissions, RuntimeLock, RuntimeLockState, + RuntimePermissionSnapshot, SystemPermissionControl, SystemPermissionControlMode, + SystemPermissionSet, SYSTEM_PERMISSIONS_VERSION, +}; + pub use plugin::{ ContentHash, CustomPluginLayout, PluginBundleUrl, PluginCapability, PluginCommandId, PluginContributionSet, PluginDescriptor, PluginError, PluginId, PluginInstallSource, @@ -228,7 +236,8 @@ pub use ports::{ PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, - PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, - SpawnSpec, SprintStore, SprintStoreError, StoreError, StructuredSessionEnvironment, - StructuredSessionEnvironmentPreparer, TemplateStore, WindowStateStore, + PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe, + ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, + StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore, + TemplateStore, WindowStateStore, }; diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 83cf50f..6db7b93 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -59,6 +59,7 @@ use crate::project::{Project, ProjectPath}; use crate::remote::RemoteKind; use crate::skill::{Skill, SkillScope}; use crate::sprint::{Sprint, SprintIndexEntry, SprintVersion}; +use crate::system_permissions::{ProjectSystemPermissions, RuntimePermissionSnapshot}; use crate::template::AgentTemplate; use crate::terminal::PtySize; @@ -2026,6 +2027,44 @@ pub trait PermissionStore: Send + Sync { ) -> Result<(), StoreError>; } +/// Reads/writes a project's `.ideai/system-permissions.json`. +#[async_trait] +pub trait SystemPermissionStore: Send + Sync { + /// Loads the project's system permission document. Missing file returns the + /// default empty document. + /// + /// # Errors + /// [`StoreError`] on I/O or deserialisation failure. + async fn load_system_permissions( + &self, + project: &Project, + ) -> Result; + + /// 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; +} + /// Reads/writes a project's `.ideai/mcp-tool-permissions.json`. /// /// This is intentionally distinct from [`PermissionStore`]: it governs IdeA MCP diff --git a/crates/domain/src/system_permissions.rs b/crates/domain/src/system_permissions.rs new file mode 100644 index 0000000..a0dc778 --- /dev/null +++ b/crates/domain/src/system_permissions.rs @@ -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, +} + +impl SystemPermissionSet { + /// Builds a set from an optional network policy. + #[must_use] + pub const fn new(network: Option) -> 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, + /// Per-agent overrides. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub agents: Vec, +} + +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, + agents: Vec, + ) -> Self { + Self { + version: SYSTEM_PERMISSIONS_VERSION, + project_default, + agents, + } + } + + /// Replaces the project default permissions. + pub fn set_project_default(&mut self, permissions: Option) { + 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, + ) { + 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 { + 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, + /// Human-readable reason. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +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, reason: impl Into) -> 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, +} + +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) -> 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, + /// 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); + } +} diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index dc25270..18963df 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -36,6 +36,7 @@ pub mod pty; pub mod ratelimit; pub mod remote; pub mod runtime; +pub mod runtime_permission; pub mod sandbox; pub mod scheduler; pub mod session; @@ -87,6 +88,7 @@ pub use pty::PortablePtyAdapter; pub use ratelimit::RateLimitParser; pub use remote::{remote_host, LocalHost}; pub use runtime::CliAgentRuntime; +pub use runtime_permission::ReadOnlyRuntimePermissionProbe; #[cfg(target_os = "linux")] pub use sandbox::LandlockSandbox; pub use sandbox::{default_enforcer, NoopSandbox}; @@ -102,8 +104,8 @@ pub use store::{ AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, - FsSecretStore, FsSkillStore, FsTemplateStore, FsWindowStateStore, HashEmbedder, - IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall, - DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, - VECTOR_ONNX_ENABLED, + FsSecretStore, FsSkillStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, + HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, + VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, + VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; diff --git a/crates/infrastructure/src/runtime_permission.rs b/crates/infrastructure/src/runtime_permission.rs new file mode 100644 index 0000000..2d6ce83 --- /dev/null +++ b/crates/infrastructure/src/runtime_permission.rs @@ -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 { + Ok(RuntimePermissionSnapshot::locked_uninspectable()) + } +} diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index d3fac5b..9fd3137 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -16,6 +16,7 @@ mod profile; mod project; mod secrets; mod skill; +mod system_permission; mod template; mod vector; mod window_state; @@ -40,6 +41,7 @@ pub use profile::{FsEmbedderProfileStore, FsProfileStore}; pub use project::FsProjectStore; pub use secrets::FsSecretStore; pub use skill::FsSkillStore; +pub use system_permission::FsSystemPermissionStore; pub use template::FsTemplateStore; pub use vector::{should_use_vector, AdaptiveMemoryRecall, VectorMemoryRecall}; pub use window_state::FsWindowStateStore; diff --git a/crates/infrastructure/src/store/system_permission.rs b/crates/infrastructure/src/store/system_permission.rs new file mode 100644 index 0000000..208a5fc --- /dev/null +++ b/crates/infrastructure/src/store/system_permission.rs @@ -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 `/.ideai/system-permissions.json`. +#[derive(Clone)] +pub struct FsSystemPermissionStore { + fs: Arc, +} + +impl FsSystemPermissionStore { + /// Builds the store from an injected filesystem port. + #[must_use] + pub fn new(fs: Arc) -> 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 { + 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())) + } +} diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 7d05407..0e6f82b 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -39,21 +39,22 @@ use application::{ CreateAgentInput, CreateMemoryInput, CreateSkillInput, CreateSprintInput, DeleteAgentInput, DeleteEmbedderProfileInput, DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput, DeleteSprintInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, - GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, - GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, - LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput, ListIssuesInput, - ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, ListSprintsInput, LiveSessions, - McpRuntime, OpenProjectInput, PairAttemptDecision, PairDeviceInput, RateLimitKey, - ReadAgentContextInput, ReadConversationPageInput, ReadIssueCarnetInput, ReadIssueInput, - ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, - RenameDeviceInput, RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, - ResolveAgentPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, + GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, + GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, + InspectConversationInput, LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput, + ListIssuesInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, + ListSprintsInput, LiveSessions, McpRuntime, OpenProjectInput, PairAttemptDecision, + PairDeviceInput, RateLimitKey, ReadAgentContextInput, ReadConversationPageInput, + ReadIssueCarnetInput, ReadIssueInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput, + ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput, RenameSprintInput, + ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput, + ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, - UpdateIssueCarnetInput, UpdateMemoryInput, UpdateProjectContextInput, - UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, UpdateSkillInput, - WriteToTerminalInput, + UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput, + UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, + UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput, }; use domain::ports::PtyHandle; use domain::IssueActor; @@ -78,22 +79,23 @@ use backend::dto::{ GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, - ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectWorkStateDto, - ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto, - ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, - SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, - SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto, - SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, - SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, - TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto, TicketCreateRequestDto, - TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput, - TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto, + ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto, + ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto, + RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto, + ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, + ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SkillDto, + SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, + SprintListRequestDto, SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto, + StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, + TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto, + TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, + TicketListPageInput, TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto, TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto, TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, - UpdateMemoryRequestDto, UpdateProjectContextRequestDto, + UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, - UpdateSkillRequestDto, UpdateTemplateRequestDto, + UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, }; use backend::events::DomainEventDto; type PtyChunk = Vec; @@ -2396,6 +2398,18 @@ async fn invoke( "resolve_agent_permissions" => { 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" => { 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) } +async fn invoke_get_project_system_permissions( + args: &Value, + state: &BackendCore, +) -> Result { + 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 { + let request = required_request::( + "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 { + let request = required_request::( + "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 { + let request = required_request::( + "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( args: &Value, state: &BackendCore, @@ -7617,6 +7713,10 @@ mod tests { "update_project_permissions", "update_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", "update_project_mcp_tool_permissions", "update_agent_mcp_tool_permissions", diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index 8d01553..c2c7dfc 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -40,7 +40,10 @@ import type { ProjectMcpToolPermissions, ProjectPermissions, ProjectWorkState, + ProjectSystemPermissions, ProfileAvailability, + ResolvedAgentSystemPermissions, + SystemPermissionSet, Skill, SkillScope, Template, @@ -364,6 +367,37 @@ export class HttpPermissionGateway implements PermissionGateway { request: { projectId, agentId }, }); } + getProjectSystemPermissions(projectId: string): Promise { + return this.http.invoke("get_project_system_permissions", { + projectId, + }); + } + updateProjectSystemPermissions( + projectId: string, + permissions: SystemPermissionSet | null, + ): Promise { + return this.http.invoke("update_project_system_permissions", { + request: { projectId, permissions }, + }); + } + updateAgentSystemPermissions( + projectId: string, + agentId: string, + permissions: SystemPermissionSet | null, + ): Promise { + return this.http.invoke("update_agent_system_permissions", { + request: { projectId, agentId, permissions }, + }); + } + resolveAgentSystemPermissions( + projectId: string, + agentId: string, + ): Promise { + return this.http.invoke( + "resolve_agent_system_permissions", + { request: { projectId, agentId } }, + ); + } getMcpToolPermissions(projectId: string): Promise { return this.http.invoke("get_mcp_tool_permissions", { projectId }); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 09ba941..45d5888 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -50,8 +50,10 @@ import type { ProjectMcpToolPermissions, ProjectPermissions, ProjectWorkState, + ProjectSystemPermissions, ProfileAvailability, ResumableAgent, + ResolvedAgentSystemPermissions, ServerExposurePreview, ServerExposureSettings, Skill, @@ -59,6 +61,7 @@ import type { SkillScope, Sprint, Template, + SystemPermissionSet, TerminalSession, Ticket, TicketCarnet, @@ -2186,6 +2189,8 @@ export class MockDeviceGateway implements DeviceGateway { /** In-memory permissions gateway. */ export class MockPermissionGateway implements PermissionGateway { private docs = new Map(); + private systemDocs = new Map(); + private systemRuntime = new Map(); private doc(projectId: string): ProjectPermissions { 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 { + return structuredClone(this.systemDoc(projectId)); + } + + async updateProjectSystemPermissions( + projectId: string, + permissions: SystemPermissionSet | null, + ): Promise { + 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 { + 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 { + 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 // `crates/infrastructure/src/orchestrator/mcp/tools.rs`, a separate durable // document from the file/command permissions above. ───────────────────── diff --git a/frontend/src/adapters/permission.ts b/frontend/src/adapters/permission.ts index 4c7537c..db8ad1a 100644 --- a/frontend/src/adapters/permission.ts +++ b/frontend/src/adapters/permission.ts @@ -6,6 +6,9 @@ import type { PermissionSet, ProjectMcpToolPermissions, ProjectPermissions, + ProjectSystemPermissions, + ResolvedAgentSystemPermissions, + SystemPermissionSet, } from "@/domain"; import type { PermissionGateway } from "@/ports"; @@ -43,6 +46,41 @@ export class TauriPermissionGateway implements PermissionGateway { }); } + getProjectSystemPermissions(projectId: string): Promise { + return invoke("get_project_system_permissions", { + projectId, + }); + } + + updateProjectSystemPermissions( + projectId: string, + permissions: SystemPermissionSet | null, + ): Promise { + return invoke("update_project_system_permissions", { + request: { projectId, permissions }, + }); + } + + updateAgentSystemPermissions( + projectId: string, + agentId: string, + permissions: SystemPermissionSet | null, + ): Promise { + return invoke("update_agent_system_permissions", { + request: { projectId, agentId, permissions }, + }); + } + + resolveAgentSystemPermissions( + projectId: string, + agentId: string, + ): Promise { + return invoke( + "resolve_agent_system_permissions", + { request: { projectId, agentId } }, + ); + } + getMcpToolPermissions(projectId: string): Promise { return invoke("get_mcp_tool_permissions", { projectId, diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index e702f4f..51b1326 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -748,6 +748,48 @@ export interface EffectivePermissions { 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 // permissions above: an allowlist of exact MCP tool names, applied by the MCP diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 2a9440c..1c12268 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -14,7 +14,7 @@ * 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 { TerminalView } from "@/features/terminals/TerminalView"; @@ -25,6 +25,7 @@ import { useAgents } from "./useAgents"; import { correlateModelServerStatus } from "./modelServerLaunch"; import { AgentLimitBadge } from "./AgentLimitBadge"; import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge"; +import type { ResolvedAgentSystemPermissions } from "@/domain"; export interface AgentsPanelProps { /** The project whose agents to manage. */ @@ -41,6 +42,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { const drift = useDrift(projectId); const gateways = useGateways(); const templateGw = gateways.template ?? null; + const permissionGw = gateways.permission ?? null; // Create form state const [newName, setNewName] = useState(""); @@ -130,6 +132,45 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { const [agentSessions, setAgentSessions] = useState>( {}, ); + const [networkByAgent, setNetworkByAgent] = useState< + Record + >({}); + + 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 @@ -342,6 +383,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { vm.modelServerStatusByServer, ); const launchFailure = vm.launchFailureByAgent[a.id]; + const network = networkByAgent[a.id] ?? null; return (
  • )} + {network && } {profileName} {live && ( @@ -513,6 +556,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { onSessionId={(sid) => setAgentSessions((prev) => ({ ...prev, [activeAgentId]: sid })) } + systemPermissions={networkByAgent[activeAgentId] ?? null} /> @@ -667,3 +711,40 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { ); } + +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 ( + + Réseau: {label} + + ); +} diff --git a/frontend/src/features/agents/agents.test.tsx b/frontend/src/features/agents/agents.test.tsx index 4461dd5..36f63b1 100644 --- a/frontend/src/features/agents/agents.test.tsx +++ b/frontend/src/features/agents/agents.test.tsx @@ -21,6 +21,7 @@ import { import { MockAgentGateway, + MockPermissionGateway, MockProfileGateway, MockSystemGateway, MockTemplateGateway, @@ -44,13 +45,15 @@ function renderPanel( profile: MockProfileGateway = new MockProfileGateway(), projectRoot = "/home/me/proj", template?: MockTemplateGateway, + permission: MockPermissionGateway = new MockPermissionGateway(), ) { 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 { agent, profile, template: tmpl, + permission, ...render( @@ -108,6 +111,27 @@ describe("AgentsPanel (with MockAgentGateway)", () => { 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 () => { renderPanel(); await waitForIdle(); diff --git a/frontend/src/features/permissions/PermissionsPanel.tsx b/frontend/src/features/permissions/PermissionsPanel.tsx index e3015ba..b24d0a8 100644 --- a/frontend/src/features/permissions/PermissionsPanel.tsx +++ b/frontend/src/features/permissions/PermissionsPanel.tsx @@ -1,7 +1,13 @@ import { useEffect, useMemo, useRef, useState } from "react"; 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 { type CapabilityChoice, @@ -42,6 +48,12 @@ const POSTURE_LABELS: Record = { deny: "Deny", }; +const NETWORK_LABELS: Record = { + allow: "Autorisé", + deny: "Interdit", + ask: "Demande", +}; + export function PermissionsPanel({ projectId }: PermissionsPanelProps) { const vm = usePermissions(projectId); const [target, setTarget] = useState({ type: "project" }); @@ -51,6 +63,16 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) { ? vm.rows.find((row) => row.agent.id === target.agentId) ?? null : 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" ? vm.projectDraft : 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 ( void handleSave(draft)} onClear={() => void handleClear()} /> + + void handleSaveNetwork(permissions)} + />
    void; +} + +function NetworkPermissionEditor({ + title, + source, + inherited, + resolved, + busy, + onSave, +}: NetworkPermissionEditorProps) { + const current = source?.network ?? ""; + const [local, setLocal] = useState(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 ( +
    +
    +

    {title}

    +

    + Politique voulue, état effectif et verrou runtime. +

    +
    + +
    +
    + + + + +
    + + {readOnly && ( +

    + {lockReason ?? "Le runtime actif ne permet pas de modifier cette permission."} +

    + )} + + + + {inherited?.network && !source?.network && ( +

    + Hérite du projet : Réseau {NETWORK_LABELS[inherited.network].toLowerCase()}. +

    + )} + +
    + + +
    +
    +
    + ); +} + +function StatusLine({ label, value }: { label: string; value: string }) { + return ( +
    + + {label} + + {value} +
    + ); +} + interface PermissionEditorProps { title: string; draft: PolicyDraft; diff --git a/frontend/src/features/permissions/permissions.test.tsx b/frontend/src/features/permissions/permissions.test.tsx index 70219af..ee1cb07 100644 --- a/frontend/src/features/permissions/permissions.test.tsx +++ b/frontend/src/features/permissions/permissions.test.tsx @@ -86,4 +86,58 @@ describe("PermissionsPanel", () => { 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( + + + , + ); + + 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); + }); }); diff --git a/frontend/src/features/permissions/usePermissions.ts b/frontend/src/features/permissions/usePermissions.ts index 1eb6fe3..1dd25fd 100644 --- a/frontend/src/features/permissions/usePermissions.ts +++ b/frontend/src/features/permissions/usePermissions.ts @@ -9,6 +9,9 @@ import type { PermissionRule, PermissionSet, ProjectPermissions, + ProjectSystemPermissions, + ResolvedAgentSystemPermissions, + SystemPermissionSet, } from "@/domain"; export type CapabilityChoice = "none" | PermissionEffect; @@ -24,12 +27,15 @@ export interface PolicyDraft { export interface AgentPermissionRow { agent: Agent; override: PermissionSet | null; + systemOverride: SystemPermissionSet | null; + resolvedSystem: ResolvedAgentSystemPermissions | null; } export interface PermissionsViewModel { agents: Agent[]; rows: AgentPermissionRow[]; document: ProjectPermissions | null; + systemDocument: ProjectSystemPermissions | null; projectDraft: PolicyDraft; busy: boolean; error: string | null; @@ -38,6 +44,13 @@ export interface PermissionsViewModel { clearProjectDefaults: () => Promise; saveAgentOverride: (agentId: string, draft: PolicyDraft) => Promise; clearAgentOverride: (agentId: string) => Promise; + saveProjectSystemPermissions: ( + permissions: SystemPermissionSet | null, + ) => Promise; + saveAgentSystemPermissions: ( + agentId: string, + permissions: SystemPermissionSet | null, + ) => Promise; } const DEFAULT_DRAFT: PolicyDraft = { @@ -114,6 +127,11 @@ export function usePermissions(projectId: string): PermissionsViewModel { const { agent, permission } = useGateways(); const [agents, setAgents] = useState([]); const [document, setDocument] = useState(null); + const [systemDocument, setSystemDocument] = + useState(null); + const [resolvedSystemByAgent, setResolvedSystemByAgent] = useState< + Record + >({}); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -121,12 +139,34 @@ export function usePermissions(projectId: string): PermissionsViewModel { setBusy(true); setError(null); try { - const [agentList, permissionDoc] = await Promise.all([ + const [agentList, permissionDoc, systemPermissionDoc] = await Promise.all([ agent.listAgents(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); setDocument(permissionDoc); + setSystemDocument(systemPermissionDoc); + setResolvedSystemByAgent( + Object.fromEntries( + resolvedPairs.filter( + (pair): pair is readonly [string, ResolvedAgentSystemPermissions] => + pair[1] !== null, + ), + ), + ); } catch (e) { setError(describe(e)); } finally { @@ -142,11 +182,19 @@ export function usePermissions(projectId: string): PermissionsViewModel { const overrides = new Map( (document?.agents ?? []).map((entry) => [entry.agentId, entry.permissions]), ); + const systemOverrides = new Map( + (systemDocument?.agents ?? []).map((entry) => [ + entry.agentId, + entry.permissions, + ]), + ); return agents.map((candidate) => ({ agent: candidate, 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( () => draftFromSet(document?.projectDefaults ?? null), @@ -220,10 +268,52 @@ export function usePermissions(projectId: string): PermissionsViewModel { [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 { agents, rows, document, + systemDocument, projectDraft, busy, error, @@ -232,5 +322,7 @@ export function usePermissions(projectId: string): PermissionsViewModel { clearProjectDefaults, saveAgentOverride, clearAgentOverride, + saveProjectSystemPermissions, + saveAgentSystemPermissions, }; } diff --git a/frontend/src/features/terminals/TerminalView.test.tsx b/frontend/src/features/terminals/TerminalView.test.tsx index 2768842..8c237bb 100644 --- a/frontend/src/features/terminals/TerminalView.test.tsx +++ b/frontend/src/features/terminals/TerminalView.test.tsx @@ -55,6 +55,22 @@ describe("TerminalView (with MockTerminalGateway)", () => { 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 () => { const gw = new MockTerminalGateway(); const openSpy = vi.spyOn(gw, "openTerminal"); diff --git a/frontend/src/features/terminals/TerminalView.tsx b/frontend/src/features/terminals/TerminalView.tsx index 47ef2df..27009c6 100644 --- a/frontend/src/features/terminals/TerminalView.tsx +++ b/frontend/src/features/terminals/TerminalView.tsx @@ -39,6 +39,7 @@ import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import { useGateways } from "@/app/di"; +import type { ResolvedAgentSystemPermissions } from "@/domain"; import type { OpenTerminalOptions, ReattachResult, @@ -113,6 +114,8 @@ interface TerminalViewProps { * it never remounts/reopens the terminal. */ refitSignal?: number; + /** Optional resolved system permissions for this agent/cell. */ + systemPermissions?: ResolvedAgentSystemPermissions | null; } /** @@ -144,6 +147,7 @@ export function TerminalView({ portal, onReady, refitSignal, + systemPermissions, }: TerminalViewProps) { const { terminal } = useGateways(); const containerRef = useRef(null); @@ -414,6 +418,15 @@ export function TerminalView({ refitRef.current?.(); }, [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 (
    + {showNetworkBanner && ( +
    + {systemPermissions.runtimeLock.state === "locked" + ? "Réseau verrouillé par le runtime." + : "Réseau interdit pour cet agent."}{" "} + {networkReason} +
    + )} {openError && (
    ; + /** Reads the full project system-permission document. */ + getProjectSystemPermissions(projectId: string): Promise; + /** Replaces or removes project-level default system permissions. */ + updateProjectSystemPermissions( + projectId: string, + permissions: SystemPermissionSet | null, + ): Promise; + /** Replaces or removes one agent-specific system-permission override. */ + updateAgentSystemPermissions( + projectId: string, + agentId: string, + permissions: SystemPermissionSet | null, + ): Promise; + /** Resolves wanted/effective runtime-constrained system permissions. */ + resolveAgentSystemPermissions( + projectId: string, + agentId: string, + ): Promise; /** * Reads the project's durable MCP tool permission document plus the * backend-canonical catalogue classification (ticket #82). Distinct