Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion crates/aionui-app/src/router/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 16 additions & 1 deletion crates/aionui-app/src/router/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<std::sync::Arc<dyn PermissionPolicyAdapter>> =
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).
Expand Down
2 changes: 2 additions & 0 deletions crates/aionui-mcp/src/adapters/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod codebuddy;
mod codex;
mod gemini;
mod opencode;
mod opencode_permission;
mod qwen;

pub use aionrs::AionrsAdapter;
Expand All @@ -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;
6 changes: 3 additions & 3 deletions crates/aionui-mcp/src/adapters/opencode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,12 @@ impl McpAgentAdapter for OpencodeAdapter {
// ---------------------------------------------------------------------------

/// Returns `~/.config/opencode/` if HOME is available.
fn config_dir() -> Option<PathBuf> {
pub(crate) fn config_dir() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("opencode"))
}

/// Returns `~/.config/opencode/opencode.json` if HOME is available.
fn config_file_path() -> Option<PathBuf> {
pub(crate) fn config_file_path() -> Option<PathBuf> {
config_dir().map(|d| d.join("opencode.json"))
}

Expand Down Expand Up @@ -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<serde_json::Value, McpError> {
pub(crate) fn parse_jsonc(input: &str) -> Result<serde_json::Value, McpError> {
let stripped = strip_json_comments(input);
serde_json::from_str(&stripped).map_err(McpError::from)
}
Expand Down
210 changes: 210 additions & 0 deletions crates/aionui-mcp/src/adapters/opencode_permission.rs
Original file line number Diff line number Diff line change
@@ -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", "<tool>": "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<serde_json::Value> {
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<PermissionLevel> {
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<serde_json::Value, McpError> {
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<PathBuf> {
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<bool, McpError> {
Ok(config_dir().is_some_and(|d| d.exists()))
}

fn config_path(&self) -> Option<String> {
config_file_path().map(|p| p.display().to_string())
}

async fn read_current(&self) -> Result<Option<PermissionLevel>, 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);
}
}
8 changes: 6 additions & 2 deletions crates/aionui-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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::{
Expand Down
Loading