diff --git a/crates/aionui-app/src/router/routes.rs b/crates/aionui-app/src/router/routes.rs index 60ca58a1b..7c0429813 100644 --- a/crates/aionui-app/src/router/routes.rs +++ b/crates/aionui-app/src/router/routes.rs @@ -31,7 +31,7 @@ use aionui_conversation::{conversation_ops_routes, conversation_routes}; use aionui_cron::cron_routes; use aionui_extension::{extension_routes, hub_routes, skill_routes}; use aionui_file::file_routes; -use aionui_mcp::mcp_routes; +use aionui_mcp::{mcp_routes, permission_policy_routes}; use aionui_office::{office_proxy_routes, office_routes}; use aionui_project::project_routes; use aionui_realtime::{NoopMessageRouter, WsHandlerState, ws_upgrade_handler}; @@ -292,6 +292,10 @@ pub fn create_router_with_all_state(services: &AppServices, states: ModuleStates let mcp_authenticated = mcp_routes(states.mcp).route_layer(from_fn_with_state(auth_mw_state.clone(), auth_middleware)); + // Agent permission-policy routes protected by auth middleware + let permission_policy_authenticated = permission_policy_routes(states.permission_policy.clone()) + .route_layer(from_fn_with_state(auth_mw_state.clone(), auth_middleware)); + // Extension routes protected by auth middleware let extension_authenticated = extension_routes(states.extension).route_layer(from_fn_with_state(auth_mw_state.clone(), auth_middleware)); @@ -371,6 +375,7 @@ pub fn create_router_with_all_state(services: &AppServices, states: ModuleStates .merge(project_authenticated) .merge(sidebar_authenticated) .merge(mcp_authenticated) + .merge(permission_policy_authenticated) .merge(extension_authenticated) .merge(hub_authenticated) .merge(skill_authenticated) diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index 570edae25..efc70c8bf 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -31,7 +31,8 @@ use aionui_extension::{ use aionui_file::{FileRouterState, FileService, SnapshotService}; use aionui_mcp::{ AionrsAdapter, AionuiAdapter, ClaudeAdapter, CodeBuddyAdapter, CodexAdapter, GeminiAdapter, McpAgentAdapter, - McpConfigService, McpConnectionTestService, McpRouterState, McpSyncService, OpencodeAdapter, QwenAdapter, + McpConfigService, McpConnectionTestService, McpRouterState, McpSyncService, OpenCodePermissionAdapter, + OpencodeAdapter, PermissionPolicyAdapter, PermissionRouterState, QwenAdapter, }; use aionui_office::{ConversionService, OfficeRouterState, OfficecliWatchManager, ProxyService}; use aionui_project::{ProjectRouterState, ProjectService}; @@ -132,6 +133,7 @@ pub struct ModuleStates { pub project: ProjectRouterState, pub sidebar: SidebarRouterState, pub mcp: McpRouterState, + pub permission_policy: PermissionRouterState, pub extension: ExtensionRouterState, pub hub: HubRouterState, pub skill: SkillRouterState, @@ -313,6 +315,9 @@ pub async fn build_module_states( project: build_module_state_phase(&boot, "project", || build_project_state(services)), sidebar: build_module_state_phase(&boot, "sidebar", || build_sidebar_state(services)), mcp: build_module_state_phase(&boot, "mcp", || build_mcp_state(services)), + permission_policy: build_module_state_phase(&boot, "permission_policy", || { + build_permission_policy_state(services) + }), extension: ext_state, hub: hub_state, skill: skill_state, @@ -598,6 +603,16 @@ pub fn build_mcp_state(services: &AppServices) -> McpRouterState { } } +/// Build the `PermissionRouterState` with all permission-policy adapters. +/// +/// Only the OpenCode pilot is wired today; future agents (Claude Code, Codex, +/// Gemini) add an adapter here. Each agent shares one adapter instance (state-free). +pub fn build_permission_policy_state(_services: &AppServices) -> PermissionRouterState { + let adapters: Vec> = + vec![std::sync::Arc::new(OpenCodePermissionAdapter)]; + PermissionRouterState { adapters } +} + /// Adapter exposing the assistant service's lazy generated-assistant /// materialization to the channel settings service (avoids a channel→assistant /// crate dependency; the binding happens here in the composition layer). diff --git a/crates/aionui-mcp/src/adapters/mod.rs b/crates/aionui-mcp/src/adapters/mod.rs index 98c2fa79d..b244ce427 100644 --- a/crates/aionui-mcp/src/adapters/mod.rs +++ b/crates/aionui-mcp/src/adapters/mod.rs @@ -6,6 +6,7 @@ mod codebuddy; mod codex; mod gemini; mod opencode; +mod opencode_permission; mod qwen; pub use aionrs::AionrsAdapter; @@ -15,4 +16,5 @@ pub use codebuddy::CodeBuddyAdapter; pub use codex::CodexAdapter; pub use gemini::GeminiAdapter; pub use opencode::OpencodeAdapter; +pub use opencode_permission::OpenCodePermissionAdapter; pub use qwen::QwenAdapter; diff --git a/crates/aionui-mcp/src/adapters/opencode.rs b/crates/aionui-mcp/src/adapters/opencode.rs index 1f3de3cda..120721ec8 100644 --- a/crates/aionui-mcp/src/adapters/opencode.rs +++ b/crates/aionui-mcp/src/adapters/opencode.rs @@ -144,12 +144,12 @@ impl McpAgentAdapter for OpencodeAdapter { // --------------------------------------------------------------------------- /// Returns `~/.config/opencode/` if HOME is available. -fn config_dir() -> Option { +pub(crate) fn config_dir() -> Option { dirs::config_dir().map(|d| d.join("opencode")) } /// Returns `~/.config/opencode/opencode.json` if HOME is available. -fn config_file_path() -> Option { +pub(crate) fn config_file_path() -> Option { config_dir().map(|d| d.join("opencode.json")) } @@ -217,7 +217,7 @@ fn strip_json_comments(input: &str) -> String { } /// Parse JSONC (JSON with comments) into a `serde_json::Value`. -fn parse_jsonc(input: &str) -> Result { +pub(crate) fn parse_jsonc(input: &str) -> Result { let stripped = strip_json_comments(input); serde_json::from_str(&stripped).map_err(McpError::from) } diff --git a/crates/aionui-mcp/src/adapters/opencode_permission.rs b/crates/aionui-mcp/src/adapters/opencode_permission.rs new file mode 100644 index 000000000..193137bce --- /dev/null +++ b/crates/aionui-mcp/src/adapters/opencode_permission.rs @@ -0,0 +1,210 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ +// OpenCode permission-policy adapter — the pilot for issue #4018. +// +// OpenCode stores its permission policy in `~/.config/opencode/opencode.json` +// under the `permission` field. This adapter reuses the JSONC read/write +// helpers from the MCP adapter (`adapters/opencode.rs`). +// +// Reference (opencode.ai/docs/permissions): +// - `"permission": { "*": "ask" }` -> ask for everything +// - `"permission": { "*": "allow", "": "ask" }` -> auto for most, ask for listed +// - `"permission": "allow"` -> auto-approve everything not denied +// +// Normalized level -> OpenCode schema: +// Ask -> `{ "*": "ask" }` +// AutoEdit -> `{ "*": "allow", "bash": "ask", "webfetch": "ask" }` +// FullAuto -> `"allow"` +use std::path::PathBuf; + +use async_trait::async_trait; + +use crate::adapters::opencode::{config_dir, config_file_path, parse_jsonc}; +use crate::error::McpError; +use crate::permission::{PermissionLevel, PermissionPolicyAdapter}; + +/// Adapter for managing OpenCode's permission policy via `opencode.json`. +pub struct OpenCodePermissionAdapter; + +const PERMS: [&str; 9] = [ + "bash", + "read", + "edit", + "glob", + "grep", + "webfetch", + "task", + "todowrite", + "websearch", +]; + +/// Build the `permission` JSON value for a normalized level (None = drop key). +fn permission_value_for(level: PermissionLevel) -> Option { + match level { + PermissionLevel::Ask => Some(serde_json::json!({ "*": "ask" })), + PermissionLevel::AutoEdit => { + let mut object = serde_json::Map::new(); + for tool in PERMS { + object.insert(tool.to_string(), serde_json::json!("ask")); + } + object.insert("*".to_string(), serde_json::json!("allow")); + Some(serde_json::Value::Object(object)) + } + PermissionLevel::FullAuto => Some(serde_json::json!("allow")), + } +} + +/// Interpret an opencode `permission` value back into a normalized level. +fn level_from_permission(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(s) if s.eq_ignore_ascii_case("allow") => Some(PermissionLevel::FullAuto), + serde_json::Value::String(s) if s.eq_ignore_ascii_case("ask") => Some(PermissionLevel::Ask), + serde_json::Value::String(_) => None, + serde_json::Value::Object(map) => { + let allow_all = map.get("*").and_then(|v| v.as_str()).is_some_and(|s| s == "allow"); + if !allow_all { + // `{ "*": "ask" }` or any ask-centric map. + return Some(PermissionLevel::Ask); + } + // allow-all plus ask-for-shell => auto-edit; allow-all alone => full-auto. + let has_shell_ask = ["bash", "webfetch", "task"] + .iter() + .any(|t| map.get(*t).and_then(|v| v.as_str()).is_some_and(|s| s == "ask")); + if has_shell_ask { + Some(PermissionLevel::AutoEdit) + } else { + Some(PermissionLevel::FullAuto) + } + } + _ => None, + } +} + +fn read_root() -> Result { + let path = config_file_path().ok_or_else(|| McpError::AgentNotInstalled("opencode".to_string()))?; + if !path.exists() { + return Ok(serde_json::json!({})); + } + let content = std::fs::read_to_string(&path) + .map_err(|e| McpError::AgentOperationFailed(format!("failed to read {}: {e}", path.display())))?; + parse_jsonc(&content) +} + +/// Atomically persist the config (temp file + rename) since it may hold secrets. +fn persist_root(root: serde_json::Value) -> Result<(), McpError> { + let path = config_file_path().ok_or_else(|| McpError::AgentNotInstalled("opencode".to_string()))?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| McpError::AgentOperationFailed(format!("failed to create dir: {e}")))?; + } + let serialized = serde_json::to_string_pretty(&root) + .map_err(|e| McpError::AgentOperationFailed(format!("failed to serialize: {e}")))?; + let tmp = + tempfile_like(&path).map_err(|e| McpError::AgentOperationFailed(format!("failed to create temp file: {e}")))?; + std::fs::write(&tmp, serialized) + .map_err(|e| McpError::AgentOperationFailed(format!("failed to write {}: {e}", tmp.display())))?; + std::fs::rename(&tmp, &path) + .map_err(|e| McpError::AgentOperationFailed(format!("failed to rename over {}: {e}", path.display())))?; + Ok(()) +} + +/// Build a sibling temp path for atomic rename. +fn tempfile_like(path: &std::path::Path) -> std::io::Result { + let parent = path.parent().unwrap_or_else(|| std::path::Path::new(".")); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("opencode.json"); + let tmp = parent.join(format!(".{name}.{}.tmp", std::process::id())); + Ok(tmp) +} + +#[async_trait] +impl PermissionPolicyAdapter for OpenCodePermissionAdapter { + fn agent(&self) -> &'static str { + "opencode" + } + + async fn installed(&self) -> Result { + Ok(config_dir().is_some_and(|d| d.exists())) + } + + fn config_path(&self) -> Option { + config_file_path().map(|p| p.display().to_string()) + } + + async fn read_current(&self) -> Result, McpError> { + let root = read_root()?; + let Some(permission) = root.get("permission") else { + return Ok(None); + }; + Ok(level_from_permission(permission)) + } + + async fn apply(&self, level: PermissionLevel) -> Result<(), McpError> { + let mut root = read_root()?; + let object = root + .as_object_mut() + .ok_or_else(|| McpError::AgentOperationFailed("config root is not an object".to_string()))?; + object.insert("permission".to_string(), permission_value_for(level).unwrap()); + persist_root(root) + } + + async fn clear(&self) -> Result<(), McpError> { + let mut root = read_root()?; + let object = root + .as_object_mut() + .ok_or_else(|| McpError::AgentOperationFailed("config root is not an object".to_string()))?; + object.remove("permission"); + persist_root(root) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn level_roundtrip_str() { + assert_eq!(PermissionLevel::from_name("ask"), Some(PermissionLevel::Ask)); + assert_eq!(PermissionLevel::from_name("full_auto"), Some(PermissionLevel::FullAuto)); + assert_eq!(PermissionLevel::from_name("AutoEdit"), Some(PermissionLevel::AutoEdit)); + assert_eq!(PermissionLevel::from_name("nope"), None); + } + + #[test] + fn permission_value_maps() { + let ask = permission_value_for(PermissionLevel::Ask).unwrap(); + assert_eq!(ask["*"], "ask"); + let auto_edit = permission_value_for(PermissionLevel::AutoEdit).unwrap(); + assert_eq!(auto_edit["*"], "allow"); + assert_eq!(auto_edit["bash"], "ask"); + let full = permission_value_for(PermissionLevel::FullAuto).unwrap(); + assert_eq!(full, "allow"); + } + + #[test] + fn level_from_permission_parses() { + assert_eq!( + level_from_permission(&serde_json::json!("allow")), + Some(PermissionLevel::FullAuto) + ); + assert_eq!( + level_from_permission(&serde_json::json!({ "*": "ask" })), + Some(PermissionLevel::Ask) + ); + assert_eq!( + level_from_permission(&serde_json::json!({ "*": "allow", "bash": "ask" })), + Some(PermissionLevel::AutoEdit) + ); + assert_eq!( + level_from_permission(&serde_json::json!({ "*": "allow" })), + Some(PermissionLevel::FullAuto) + ); + assert_eq!( + level_from_permission(&serde_json::json!({"bash": "ask"})), + Some(PermissionLevel::Ask) + ); + assert_eq!(level_from_permission(&serde_json::json!(42)), None); + } +} diff --git a/crates/aionui-mcp/src/lib.rs b/crates/aionui-mcp/src/lib.rs index 6ea9d814b..00ec61327 100644 --- a/crates/aionui-mcp/src/lib.rs +++ b/crates/aionui-mcp/src/lib.rs @@ -6,6 +6,8 @@ pub mod adapters; pub mod connection_test; pub mod error; pub mod oauth_service; +pub mod permission; +pub mod permission_routes; pub mod routes; pub mod service; pub mod session_injection; @@ -14,12 +16,14 @@ pub mod types; pub use adapter::{DetectedServer, McpAgentAdapter}; pub use adapters::{ - AionrsAdapter, AionuiAdapter, ClaudeAdapter, CodeBuddyAdapter, CodexAdapter, GeminiAdapter, OpencodeAdapter, - QwenAdapter, + AionrsAdapter, AionuiAdapter, ClaudeAdapter, CodeBuddyAdapter, CodexAdapter, GeminiAdapter, + OpenCodePermissionAdapter, OpencodeAdapter, QwenAdapter, }; pub use connection_test::McpConnectionTestService; pub use error::McpError; pub use oauth_service::McpOAuthService; +pub use permission::{PermissionLevel, PermissionPolicyAdapter, PermissionPolicyView, policy_view}; +pub use permission_routes::{PermissionRouterState, permission_policy_routes}; pub use routes::{McpRouterState, mcp_routes}; pub use service::McpConfigService; pub use session_injection::{ diff --git a/crates/aionui-mcp/src/permission.rs b/crates/aionui-mcp/src/permission.rs new file mode 100644 index 000000000..2c991d97d --- /dev/null +++ b/crates/aionui-mcp/src/permission.rs @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ +// Agent-side permission policy management (write-through to agent config). +// +// AionUi already handles permissions at **runtime** (approval cards). This +// module adds the missing **agent-side policy** control: it reads and writes +// each agent's own permission-policy file so users can get "full auto" / +// "auto-edit" behaviour without hand-editing JSON. +// +// The adapter shape mirrors `McpAgentAdapter`: a trait + one adapter per agent, +// so more agents (Claude Code `~/.claude/settings.json`, Codex, Gemini, ...) +// can follow the OpenCode pilot later. Only OpenCode is wired today. +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::error::McpError; + +/// Normalized agent-side permission policy level exposed by the AionUi UI. +/// +/// These three levels are intentionally agent-agnostic; each `PermissionPolicyAdapter` +/// maps them to the agent's own permission schema. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PermissionLevel { + /// Prompt for approval on potentially destructive actions (ask). + Ask, + /// Auto-approve file edits; still ask for shell / network. + AutoEdit, + /// Auto-approve everything that is not explicitly denied. + FullAuto, +} + +impl PermissionLevel { + /// All supported levels in display order. + pub const ALL: [PermissionLevel; 3] = [ + PermissionLevel::Ask, + PermissionLevel::AutoEdit, + PermissionLevel::FullAuto, + ]; + + /// Wire value used in HTTP payloads (serde snake_case). + pub fn as_str(self) -> &'static str { + match self { + PermissionLevel::Ask => "ask", + PermissionLevel::AutoEdit => "auto_edit", + PermissionLevel::FullAuto => "full_auto", + } + } + + /// Parse a wire/level string, case-insensitive. + pub fn from_name(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "ask" => Some(PermissionLevel::Ask), + "auto_edit" | "autoedit" => Some(PermissionLevel::AutoEdit), + "full_auto" | "fullauto" | "yolo" | "auto" => Some(PermissionLevel::FullAuto), + _ => None, + } + } +} + +/// Read-model returned to the UI for one agent's permission policy. +#[derive(Debug, Clone, Serialize)] +pub struct PermissionPolicyView { + /// Agent identifier, e.g. `"opencode"`. + pub agent: String, + /// Whether an adapter exists for this agent (true for OpenCode, false otherwise). + pub supported: bool, + /// Whether the agent is present on this machine (config file / binary resolved). + pub installed: bool, + /// The effective permission level, or `None` when the agent config has no + /// recognizable policy (agent default behaviour applies). + pub current_level: Option, + /// Absolute path of the config file that holds the policy (for display). + pub config_path: Option, +} + +/// Abstraction for reading/writing an agent's permission-policy file. +/// +/// Implementations do **not** need to handle concurrency internally. +/// +/// # Error handling +/// +/// Methods return `McpError` to keep the adapter layer independent of HTTP +/// concerns (mirrors `McpAgentAdapter`). +#[async_trait] +pub trait PermissionPolicyAdapter: Send + Sync { + /// Agent identifier (e.g. `"opencode"`). + fn agent(&self) -> &'static str; + + /// Whether the agent + its policy file are available to manage on this machine. + async fn installed(&self) -> Result; + + /// Absolute path of the policy config file, when resolvable. + fn config_path(&self) -> Option; + + /// Read the current effective permission level from the agent config. + /// Returns `Ok(None)` when there is no recognizable explicit policy. + async fn read_current(&self) -> Result, McpError>; + + /// Write-through a permission level to the agent config (creates/updates the policy). + async fn apply(&self, level: PermissionLevel) -> Result<(), McpError>; + + /// Remove the agent-side policy so the agent falls back to its default behaviour. + async fn clear(&self) -> Result<(), McpError>; +} + +/// Read-only projection of an adapter for listing, with any I/O already done. +pub async fn policy_view(adapter: &dyn PermissionPolicyAdapter) -> PermissionPolicyView { + PermissionPolicyView { + agent: adapter.agent().to_string(), + supported: true, + installed: adapter.installed().await.unwrap_or(false), + current_level: adapter.read_current().await.ok().flatten(), + config_path: adapter.config_path(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn level_serde_roundtrip_snake_case() { + for level in PermissionLevel::ALL { + let wire = serde_json::to_string(&level).unwrap(); + let back: PermissionLevel = serde_json::from_str(&wire).unwrap(); + assert_eq!(back, level); + assert!(wire.starts_with('"') && wire.ends_with('"')); + assert!(!wire.contains("PascalCase")); + } + // wire values use snake_case + assert_eq!( + serde_json::to_string(&PermissionLevel::FullAuto).unwrap(), + "\"full_auto\"" + ); + assert_eq!( + serde_json::from_str::("\"auto_edit\"").unwrap(), + PermissionLevel::AutoEdit + ); + } + + #[test] + fn level_names_map_uniquely() { + let names: Vec = PermissionLevel::ALL.iter().map(|l| l.as_str().to_uppercase()).collect(); + let uniq: std::collections::HashSet = names.iter().cloned().collect(); + assert_eq!(names.len(), uniq.len(), "level wire names must be unique"); + } +} diff --git a/crates/aionui-mcp/src/permission_routes.rs b/crates/aionui-mcp/src/permission_routes.rs new file mode 100644 index 000000000..898346232 --- /dev/null +++ b/crates/aionui-mcp/src/permission_routes.rs @@ -0,0 +1,103 @@ +#![allow(clippy::disallowed_types)] +// HTTP router for agent-side permission-policy management. +// +// Routes (all behind the app's auth middleware, wired in `aionui-app`): +// GET /api/agents/permission-policy -> list all supported agents' policy +// GET /api/agents/permission-policy/{agent} -> one agent's policy +// PUT /api/agents/permission-policy/{agent} -> write-through a level +// POST /api/agents/permission-policy/{agent}/full-auto-free +use std::sync::Arc; + +use axum::Router; +use axum::extract::{Json, Path, State}; +use axum::routing::{get, post, put}; + +use aionui_api_types::ApiResponse; +use aionui_common::ApiError; + +use crate::error::McpError; +use crate::permission::{PermissionLevel, PermissionPolicyAdapter, PermissionPolicyView, policy_view}; + +/// Shared state for permission-policy route handlers. +#[derive(Clone)] +pub struct PermissionRouterState { + /// All registered permission-policy adapters (one per supported agent). + pub adapters: Vec>, +} + +impl PermissionRouterState { + fn find(&self, agent: &str) -> Option> { + self.adapters.iter().find(|a| a.agent() == agent).cloned() + } +} + +/// Build the `/api/agents/permission-policy/*` routes. +/// +/// Returns a `Vec` view that includes every known agent (supported or not) so the +/// frontend can render the control only for supported ones. +pub fn permission_policy_routes(state: PermissionRouterState) -> Router { + Router::new() + .route("/api/agents/permission-policy", get(list_policies)) + .route("/api/agents/permission-policy/{agent}", get(get_policy)) + .route("/api/agents/permission-policy/{agent}", put(set_policy)) + .route("/api/agents/permission-policy/{agent}/clear", post(clear_policy)) + .with_state(state) +} + +/// `GET /api/agents/permission-policy` — list all known agents' policy views. +async fn list_policies( + State(state): State, +) -> Result>>, ApiError> { + let mut out = Vec::with_capacity(state.adapters.len()); + for adapter in &state.adapters { + out.push(policy_view(adapter.as_ref()).await); + } + Ok(Json(ApiResponse::ok(out))) +} + +/// `GET /api/agents/permission-policy/{agent}` — single agent policy view. +async fn get_policy( + State(state): State, + Path(agent): Path, +) -> Result>, ApiError> { + let adapter = state + .find(&agent) + .ok_or_else(|| ApiError::NotFound(format!("no permission-policy adapter for agent '{agent}'")))?; + Ok(Json(ApiResponse::ok(policy_view(adapter.as_ref()).await))) +} + +/// Request body for applying a permission level. +#[derive(serde::Deserialize)] +struct ApplyPermissionRequest { + level: String, +} + +/// `PUT /api/agents/permission-policy/{agent}` — write-through a permission level. +async fn set_policy( + State(state): State, + Path(agent): Path, + Json(body): Json, +) -> Result>, ApiError> { + let adapter = state + .find(&agent) + .ok_or_else(|| ApiError::NotFound(format!("no permission-policy adapter for agent '{agent}'")))?; + let level = PermissionLevel::from_name(&body.level) + .ok_or_else(|| ApiError::BadRequest(format!("unknown permission level '{}'", body.level)))?; + if !adapter.installed().await.map_err(ApiError::from)? { + return Err(McpError::AgentNotInstalled(agent.to_string()).into()); + } + adapter.apply(level).await.map_err(ApiError::from)?; + Ok(Json(ApiResponse::ok(policy_view(adapter.as_ref()).await))) +} + +/// `POST /api/agents/permission-policy/{agent}/clear` — remove the agent-side policy. +async fn clear_policy( + State(state): State, + Path(agent): Path, +) -> Result>, ApiError> { + let adapter = state + .find(&agent) + .ok_or_else(|| ApiError::NotFound(format!("no permission-policy adapter for agent '{agent}'")))?; + adapter.clear().await.map_err(ApiError::from)?; + Ok(Json(ApiResponse::ok(policy_view(adapter.as_ref()).await))) +}