From cd6108e8199433a263705e3752eeeb651ab8337a Mon Sep 17 00:00:00 2001 From: yyjeqhc <1772413353@qq.com> Date: Sat, 5 Sep 2026 15:34:51 +0800 Subject: [PATCH 1/2] Govern specialized Plugin and SSH invocations --- .../webcodex_cli/connect/shared_key_oauth.rs | 60 ++- crates/webcodex-cli/src/webcodex_cli/usage.rs | 4 +- crates/webcodex-core/src/authority.rs | 8 +- docs/PLUGINS.md | 25 +- docs/PLUGINS.zh-CN.md | 20 +- src/auth/mod.rs | 5 +- src/auth/scopes.rs | 5 +- src/mcp/tools.rs | 308 +++++++++-- src/mcp_tests.rs | 21 + src/mcp_tests/oauth_scope.rs | 32 +- src/mcp_tests/plugin_check.rs | 11 +- src/mcp_tests/plugin_tools.rs | 440 ++++++++++++++- src/mcp_tests/ssh_resource.rs | 138 +++++ src/oauth_http/scope_registry.rs | 4 +- src/oauth_http/shared_key_bridge.rs | 45 +- src/oauth_http/tests/shared_key_bridge.rs | 37 +- src/plugin_gateway.rs | 317 ++++++++++- src/ssh_resource_gateway.rs | 130 ++++- src/tool_runtime/mod.rs | 5 + src/tool_runtime/permissions/evaluator.rs | 29 +- src/tool_runtime/specialized.rs | 507 ++++++++++++++++++ 21 files changed, 1999 insertions(+), 152 deletions(-) create mode 100644 src/tool_runtime/specialized.rs diff --git a/crates/webcodex-cli/src/webcodex_cli/connect/shared_key_oauth.rs b/crates/webcodex-cli/src/webcodex_cli/connect/shared_key_oauth.rs index ece58e4d5..9823c2efb 100644 --- a/crates/webcodex-cli/src/webcodex_cli/connect/shared_key_oauth.rs +++ b/crates/webcodex-cli/src/webcodex_cli/connect/shared_key_oauth.rs @@ -12,7 +12,8 @@ const BRIDGE_PROFILE_VERSION: u32 = 1; const BRIDGE_PROFILE_PREFIX: &str = "shared-key-oauth-"; const BRIDGE_SECRET_DISCLOSED_PREFIX: &str = ".shared-key-oauth-secret-disclosed-"; const LOCAL_MCP_SCOPE: &str = "mcp:local"; -const LOCAL_PLUGIN_SCOPE: &str = "plugin:local"; +const LOCAL_PLUGIN_INSPECT_SCOPE: &str = "plugin:inspect"; +const LOCAL_PLUGIN_INVOKE_SCOPE: &str = "plugin:invoke"; const LOCAL_SSH_SCOPE: &str = "ssh:local"; const CODING_AGENT_SCOPE: &str = "coding_agent:run"; const BRIDGE_BASELINE_SCOPES: &[&str] = &[ @@ -144,7 +145,11 @@ fn without_optional_class_scopes(scopes: &[String]) -> Vec { .filter(|scope| { !matches!( scope.as_str(), - LOCAL_MCP_SCOPE | LOCAL_PLUGIN_SCOPE | LOCAL_SSH_SCOPE | CODING_AGENT_SCOPE + LOCAL_MCP_SCOPE + | LOCAL_PLUGIN_INSPECT_SCOPE + | LOCAL_PLUGIN_INVOKE_SCOPE + | LOCAL_SSH_SCOPE + | CODING_AGENT_SCOPE ) }) .cloned() @@ -204,11 +209,17 @@ fn profile_scope_ceiling_is_valid(profile: &SharedKeyOAuthProfile) -> bool { if local_mcp_present != profile.local_mcp_enabled { return false; } - let local_plugins_present = profile + let local_plugin_inspect_present = profile .allowed_scopes .iter() - .any(|scope| scope == LOCAL_PLUGIN_SCOPE); - if local_plugins_present != profile.local_plugins_enabled { + .any(|scope| scope == LOCAL_PLUGIN_INSPECT_SCOPE); + let local_plugin_invoke_present = profile + .allowed_scopes + .iter() + .any(|scope| scope == LOCAL_PLUGIN_INVOKE_SCOPE); + if local_plugin_inspect_present != profile.local_plugins_enabled + || local_plugin_invoke_present != profile.local_plugins_enabled + { return false; } let local_ssh_present = profile @@ -355,10 +366,15 @@ async fn provision_client( .to_string(), ); } - let local_plugins_present = allowed_scopes + let local_plugin_inspect_present = allowed_scopes .iter() - .any(|scope| scope == LOCAL_PLUGIN_SCOPE); - if local_plugins_present != opts.oauth_local_plugins { + .any(|scope| scope == LOCAL_PLUGIN_INSPECT_SCOPE); + let local_plugin_invoke_present = allowed_scopes + .iter() + .any(|scope| scope == LOCAL_PLUGIN_INVOKE_SCOPE); + if local_plugin_inspect_present != opts.oauth_local_plugins + || local_plugin_invoke_present != opts.oauth_local_plugins + { return Err( "Server changed local Plugin OAuth authority without matching the explicit connect opt-in" .to_string(), @@ -935,11 +951,37 @@ mod tests { local_plugins.local_plugins_enabled = true; local_plugins .allowed_scopes - .push(LOCAL_PLUGIN_SCOPE.to_string()); + .push(LOCAL_PLUGIN_INSPECT_SCOPE.to_string()); + local_plugins + .allowed_scopes + .push(LOCAL_PLUGIN_INVOKE_SCOPE.to_string()); assert!(profile_scope_ceiling_is_valid(&local_plugins)); let mut mismatched_local_plugins = local_plugins.clone(); mismatched_local_plugins.local_plugins_enabled = false; assert!(!profile_scope_ceiling_is_valid(&mismatched_local_plugins)); + let mut inspect_only_plugins = baseline.clone(); + inspect_only_plugins.local_plugins_enabled = true; + inspect_only_plugins + .allowed_scopes + .push(LOCAL_PLUGIN_INSPECT_SCOPE.to_string()); + assert!(!profile_scope_ceiling_is_valid(&inspect_only_plugins)); + let mut invoke_only_plugins = baseline.clone(); + invoke_only_plugins.local_plugins_enabled = true; + invoke_only_plugins + .allowed_scopes + .push(LOCAL_PLUGIN_INVOKE_SCOPE.to_string()); + assert!(!profile_scope_ceiling_is_valid(&invoke_only_plugins)); + let mut legacy_plugin_scope = baseline.clone(); + legacy_plugin_scope.local_plugins_enabled = true; + legacy_plugin_scope + .allowed_scopes + .push("plugin:local".to_string()); + assert!(!profile_scope_ceiling_is_valid(&legacy_plugin_scope)); + let mut manage_plugin_scope = local_plugins.clone(); + manage_plugin_scope + .allowed_scopes + .push("plugin:manage".to_string()); + assert!(!profile_scope_ceiling_is_valid(&manage_plugin_scope)); let mut local_ssh = baseline.clone(); local_ssh.local_ssh_enabled = true; diff --git a/crates/webcodex-cli/src/webcodex_cli/usage.rs b/crates/webcodex-cli/src/webcodex_cli/usage.rs index a056a35cf..1180c16cf 100644 --- a/crates/webcodex-cli/src/webcodex_cli/usage.rs +++ b/crates/webcodex-cli/src/webcodex_cli/usage.rs @@ -49,7 +49,7 @@ Options:\n\ --oauth-computer-permissions\n\ Allow ordinary OAuth browser consent to offer optional Computer permissions\n\ --oauth-local-mcp Explicitly allow this OAuth client to request mcp:local authority\n\ - --oauth-local-plugins Explicitly allow this OAuth client to request plugin:local authority\n\ + --oauth-local-plugins Explicitly allow this OAuth client to request plugin:inspect + plugin:invoke authority\n\ --oauth-local-ssh Explicitly allow this OAuth client to request ssh:local authority\n\ --oauth-coding-agent Explicitly allow this OAuth client to request coding_agent:run authority\n\ --user USER Select a logged-in managed user; managed-oauth only\n\ @@ -68,7 +68,7 @@ Without explicit opt-ins the bridge keeps the direct shared-key model-facing bas --oauth-computer-permissions adds only the fixed launch/display/pointer/clipboard Computer\n\ ceiling; browser checkboxes decide the actual grant. --oauth-local-mcp adds class-level\n\ mcp:local authority for Runner-owned MCP providers in this shared-key group.\n\ ---oauth-local-plugins independently adds plugin:local authority for Runner-owned native Tool Plugins.\n\ +--oauth-local-plugins independently adds plugin:inspect + plugin:invoke authority for Runner-owned native Tool Plugins; it never grants plugin:manage.\n\ --oauth-local-ssh independently adds ssh:local authority for Runner-local managed SSH resources.\n\ --oauth-coding-agent adds only coding_agent:run delegated coding-agent authority. Existing\n\ clients are never widened implicitly. managed-oauth remains a separate managed-user flow.\n" diff --git a/crates/webcodex-core/src/authority.rs b/crates/webcodex-core/src/authority.rs index a4e9198f9..e1c244fbb 100644 --- a/crates/webcodex-core/src/authority.rs +++ b/crates/webcodex-core/src/authority.rs @@ -22,7 +22,9 @@ pub const SCOPE_COMPUTER_POINTER_CONTROL: &str = "computer:pointer_control"; pub const SCOPE_COMPUTER_CLIPBOARD_READ: &str = "computer:clipboard_read"; pub const SCOPE_COMPUTER_CLIPBOARD_WRITE: &str = "computer:clipboard_write"; pub const SCOPE_MCP_LOCAL: &str = "mcp:local"; -pub const SCOPE_PLUGIN_LOCAL: &str = "plugin:local"; +pub const SCOPE_PLUGIN_INSPECT: &str = "plugin:inspect"; +pub const SCOPE_PLUGIN_INVOKE: &str = "plugin:invoke"; +pub const SCOPE_PLUGIN_MANAGE: &str = "plugin:manage"; pub const SCOPE_SSH_LOCAL: &str = "ssh:local"; pub const SCOPE_CODING_AGENT_RUN: &str = "coding_agent:run"; pub const SCOPE_AGENT_REGISTER: &str = "agent:register"; @@ -120,7 +122,9 @@ pub const KNOWN_SCOPES: &[&str] = &[ SCOPE_COMPUTER_LAUNCH, SCOPE_COMPUTER_DISPLAY_READ, SCOPE_MCP_LOCAL, - SCOPE_PLUGIN_LOCAL, + SCOPE_PLUGIN_INSPECT, + SCOPE_PLUGIN_INVOKE, + SCOPE_PLUGIN_MANAGE, SCOPE_SSH_LOCAL, SCOPE_CODING_AGENT_RUN, SCOPE_ACCOUNT_MANAGE, diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 13ab1dae0..41c58bb32 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -309,7 +309,7 @@ provider contract violation and retires that provider instance fail-closed. `plugin_tool call` requires an opaque binding from a preceding `describe`. Bindings are bounded server-side observations, not bearer authorization tokens: -every call still requires current `plugin:local` authority and current access to +every call still requires current `plugin:invoke` authority and current access to the logical Runner. A binding can also be evicted. If its Runner/provider instance disappears, the tool is removed, or its schema changes, the stale call fails `NotStarted` and must be described again. WebCodex never re-resolves the @@ -337,16 +337,23 @@ registration catalog. ## OAuth -Native Plugin access is a separate authority: `plugin:local`. +Native Plugin authority is operation-specific: -- Without `plugin:local`, `plugin_tool` and first-class startup Plugin tools are - omitted from MCP `tools/list` and direct spoofed calls are rejected. -- `plugin:local` is not part of the shared-key OAuth baseline. +- `plugin:inspect` allows metadata observation such as list and describe. +- `plugin:invoke` allows `plugin_tool call` and first-class startup Plugin tools. +- `plugin:manage` allows development/management operations that can start or + change local Plugin processes, currently check and reload. It does not imply + `plugin:invoke`. +- None of these scopes is part of the direct shared-key model baseline. - For the shared-key OAuth bridge, opt in explicitly with - `webcodex connect ... --auth oauth --oauth-local-plugins`. - -`mcp:local` does not grant Plugin access, and `plugin:local` does not grant -Runner-owned MCP provider access. + `webcodex connect ... --auth oauth --oauth-local-plugins`; that opt-in grants + only `plugin:inspect` + `plugin:invoke`, never `plugin:manage`. + +`mcp:local` does not grant Plugin access, and Plugin scopes do not grant +Runner-owned MCP provider access. Effectful Plugin operations also pass the +same Workflow Session guard and authority-mode permission policy as other +consequential WebCodex execution when an explicit `recording_session_id` is +supplied; WebCodex never infers that Session from MCP transport identity. ## Troubleshooting diff --git a/docs/PLUGINS.zh-CN.md b/docs/PLUGINS.zh-CN.md index e3f334aba..bf2d4ef9e 100644 --- a/docs/PLUGINS.zh-CN.md +++ b/docs/PLUGINS.zh-CN.md @@ -274,7 +274,7 @@ WebCodex 会 fail closed 并 retire 该 provider instance。 `plugin_tool call` 必须使用前一次 `describe` 返回的 opaque binding。binding 是 Server 端有界保存的一次 exact observation,不是 bearer authorization token:每次 call -仍然重新要求当前 credential 具有 `plugin:local`,并且当前 caller 仍有权访问对应 logical +仍然重新要求当前 credential 具有 `plugin:invoke`,并且当前 caller 仍有权访问对应 logical Runner。binding 也可能因为容量上限被 eviction。Runner/provider instance 被替换、tool 被 删除或 schema 改变时,旧 binding 以 `NotStarted` fail closed,必须重新 describe; WebCodex 不会把它 re-resolve 到新的同名 provider/tool,不会自动生成新 binding,也不会 @@ -297,15 +297,21 @@ registration catalog。 ## OAuth -Native Plugin 使用独立 authority:`plugin:local`。 +Native Plugin authority 按 operation 拆分: -- credential 没有 `plugin:local` 时,MCP `tools/list` 不显示 `plugin_tool` 和 startup - Plugin 一级工具,伪造 direct call 也会被拒绝; -- `plugin:local` 不属于 shared-key OAuth baseline; +- `plugin:inspect` 允许 list、describe 等纯 metadata observation; +- `plugin:invoke` 允许 `plugin_tool call` 和 startup Plugin 一级工具; +- `plugin:manage` 允许会启动或改变本地 Plugin process 的开发/管理操作,目前是 check 和 + reload;它本身不会隐式授予 `plugin:invoke`; +- 以上 scope 都不属于 direct shared-key model baseline; - 使用 shared-key OAuth bridge 时,需要显式 - `webcodex connect ... --auth oauth --oauth-local-plugins`。 + `webcodex connect ... --auth oauth --oauth-local-plugins`;该 opt-in 只授予 + `plugin:inspect` + `plugin:invoke`,绝不会授予 `plugin:manage`。 -`mcp:local` 不授予 Plugin 权限;`plugin:local` 也不授予 Runner-owned MCP provider 权限。 +`mcp:local` 不授予 Plugin 权限;Plugin scopes 也不授予 Runner-owned MCP provider 权限。 +effectful Plugin operation 如果携带显式 `recording_session_id`,还必须通过与其他 +consequential WebCodex execution 相同的 Workflow Session guard 和 authority-mode +permission policy;WebCodex 不会从 MCP transport identity 推断 Workflow Session。 ## 排障 diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 92b94213e..e65ee39e8 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -77,8 +77,9 @@ pub use scopes::{ SCOPE_COMMUNICATION_READ, SCOPE_COMPUTER_CLIPBOARD_READ, SCOPE_COMPUTER_CLIPBOARD_WRITE, SCOPE_COMPUTER_CONTROL, SCOPE_COMPUTER_DISPLAY_READ, SCOPE_COMPUTER_LAUNCH, SCOPE_COMPUTER_POINTER_CONTROL, SCOPE_COMPUTER_READ, SCOPE_JOB_RUN, SCOPE_MCP_LOCAL, - SCOPE_MEMORY_MANAGE, SCOPE_MEMORY_READ, SCOPE_PLUGIN_LOCAL, SCOPE_PROJECT_READ, - SCOPE_PROJECT_WRITE, SCOPE_RUNTIME_READ, SCOPE_SESSION_COLLABORATE, SCOPE_SSH_LOCAL, + SCOPE_MEMORY_MANAGE, SCOPE_MEMORY_READ, SCOPE_PLUGIN_INSPECT, SCOPE_PLUGIN_INVOKE, + SCOPE_PLUGIN_MANAGE, SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, SCOPE_RUNTIME_READ, + SCOPE_SESSION_COLLABORATE, SCOPE_SSH_LOCAL, }; #[cfg(test)] pub use scopes::{SCOPE_ACCOUNT_MANAGE, SCOPE_JOB_DETACH}; diff --git a/src/auth/scopes.rs b/src/auth/scopes.rs index 31c702227..a5c836e7b 100644 --- a/src/auth/scopes.rs +++ b/src/auth/scopes.rs @@ -23,8 +23,9 @@ pub use webcodex_core::authority::{ SCOPE_COMPUTER_CLIPBOARD_READ, SCOPE_COMPUTER_CLIPBOARD_WRITE, SCOPE_COMPUTER_CONTROL, SCOPE_COMPUTER_DISPLAY_READ, SCOPE_COMPUTER_LAUNCH, SCOPE_COMPUTER_POINTER_CONTROL, SCOPE_COMPUTER_READ, SCOPE_JOB_DETACH, SCOPE_JOB_RUN, SCOPE_MCP_LOCAL, SCOPE_MEMORY_MANAGE, - SCOPE_MEMORY_READ, SCOPE_PLUGIN_LOCAL, SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, - SCOPE_RUNTIME_READ, SCOPE_SESSION_COLLABORATE, SCOPE_SSH_LOCAL, + SCOPE_MEMORY_READ, SCOPE_PLUGIN_INSPECT, SCOPE_PLUGIN_INVOKE, SCOPE_PLUGIN_MANAGE, + SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, SCOPE_RUNTIME_READ, SCOPE_SESSION_COLLABORATE, + SCOPE_SSH_LOCAL, }; /// True when `scope` is one of the Runner transport scopes. diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 48eb14cf1..f590565bd 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -17,6 +17,7 @@ use crate::tool_runtime::kernel::{ use crate::tool_runtime::model_ergonomics_telemetry::{ ModelErgonomicsRecord, ModelErgonomicsTimer, }; +use crate::tool_runtime::specialized::SpecializedGovernanceDenial; use crate::tool_runtime::tool_definition::{ is_adaptive_runtime_direct_tool, runtime_tool_accepts_context_ack, LOCAL_CODING_TOOL_NAMES, }; @@ -197,7 +198,7 @@ async fn append_startup_plugin_direct_tools( auth: Option<&AuthContext>, result: &mut Value, ) { - if !crate::plugin_gateway::authorized(auth) { + if !crate::plugin_gateway::invoke_authorized(auth) { return; } let reserved = startup_plugin_reserved_tool_names(); @@ -1162,13 +1163,8 @@ pub(super) async fn handle_call( return McpOutcome::BadRequest(rpc_error(id, -32602, format!("Invalid params: {}", e))); } }; - if let Some(lc) = lifecycle.as_deref() { - if params.name == crate::ssh_resource_gateway::SSH_RESOURCE_TOOL_NAME { - lc.capture_payload( - "raw_arguments", - &crate::ssh_resource_gateway::audit_arguments(¶ms.arguments), - ); - } else { + if runtime.runtime_exposure() == RuntimeExposure::ProjectConnector { + if let Some(lc) = lifecycle.as_deref() { lc.capture_payload("raw_arguments", ¶ms.arguments); } } @@ -1274,6 +1270,42 @@ pub(super) async fn handle_call( params.name = target; params.arguments = arguments; } + let startup_plugin_resolution = if matches!( + params.name.as_str(), + crate::mcp_gateway::MCP_TOOL_NAME + | crate::plugin_gateway::PLUGIN_TOOL_NAME + | crate::ssh_resource_gateway::SSH_RESOURCE_TOOL_NAME + ) { + StartupPluginDirectResolution::None + } else { + resolve_startup_plugin_direct_tool(runtime, auth, ¶ms.name).await + }; + if let Some(lc) = lifecycle.as_deref() { + let audit = if params.name == crate::plugin_gateway::PLUGIN_TOOL_NAME { + crate::plugin_gateway::audit_arguments_with_identity(runtime, ¶ms.arguments, auth) + .await + } else if params.name == crate::ssh_resource_gateway::SSH_RESOURCE_TOOL_NAME { + crate::ssh_resource_gateway::audit_arguments(¶ms.arguments) + } else if let StartupPluginDirectResolution::Unique(candidate) = &startup_plugin_resolution + { + crate::plugin_gateway::audit_startup_direct(candidate) + } else if matches!( + startup_plugin_resolution, + StartupPluginDirectResolution::Ambiguous + ) { + json!({ + "source": "plugin", + "tool": params.name, + "arguments_present": true, + "provider_identity": "ambiguous" + }) + } else if via_adaptive_runtime_gateway { + json!({"tool": params.name, "arguments_present": true}) + } else { + params.arguments.clone() + }; + lc.capture_payload("raw_arguments", &audit); + } // Emit dispatch_started only after params parse succeeds and before // ToolRuntime work begins. if let Some(lc) = lifecycle.as_deref_mut() { @@ -1306,18 +1338,99 @@ pub(super) async fn handle_call( )); } if params.name == crate::plugin_gateway::PLUGIN_TOOL_NAME { - if let Some(outcome) = require_mcp_scope(auth, crate::auth::SCOPE_PLUGIN_LOCAL) { - if let Some(lc) = lifecycle.as_deref() { - lc.dispatch_failed("forbidden"); - lc.dispatch_finished(false, Some(false), "forbidden"); + let recording_session_id = match strip_recording_session_id(&mut params.arguments) { + Ok(session_id) => session_id, + Err(message) => { + if let Some(lc) = lifecycle.as_deref() { + lc.dispatch_failed("invalid_arguments"); + lc.dispatch_finished(false, Some(false), "invalid_arguments"); + } + return McpOutcome::BadRequest(rpc_error(id, -32602, message)); } - return outcome; - } + }; + let policy = match crate::plugin_gateway::operation_policy(¶ms.arguments) { + Ok(policy) => policy, + Err(_) => { + let audit = crate::plugin_gateway::audit_arguments_with_identity( + runtime, + ¶ms.arguments, + auth, + ) + .await; + if let Some(lc) = lifecycle.as_deref() { + lc.capture_payload("effective_arguments", &audit); + } + let result = crate::plugin_gateway::call(runtime, params.arguments, auth).await; + let ok = result.get("isError").and_then(Value::as_bool) != Some(true); + if let Some(lc) = lifecycle.as_deref() { + lc.dispatch_finished(true, Some(ok), if ok { "success" } else { "tool_error" }); + } + return McpOutcome::Ok(rpc_result( + id, + if stateless_2026 { + mcp_stateless_result(result, false) + } else { + result + }, + )); + } + }; + let audit = + crate::plugin_gateway::audit_arguments_with_identity(runtime, ¶ms.arguments, auth) + .await; + let permit = match runtime + .govern_specialized_invocation( + ¶ms.name, + policy, + recording_session_id.as_deref(), + auth, + &audit, + ) + .await + { + Ok(permit) => permit, + Err(SpecializedGovernanceDenial::Scope { + required_scope, + description, + }) => { + if let Some(lc) = lifecycle.as_deref() { + lc.capture_payload("specialized_governance", &policy.audit_projection()); + lc.dispatch_failed("forbidden"); + lc.dispatch_finished(false, Some(false), "forbidden"); + } + return scope_forbidden(auth, Some(required_scope), description); + } + Err(SpecializedGovernanceDenial::Tool(result)) => { + if let Some(lc) = lifecycle.as_deref() { + lc.capture_payload("specialized_governance", &policy.audit_projection()); + lc.dispatch_failed("specialized_governance_denied"); + lc.dispatch_finished(true, Some(false), "tool_error"); + } + let result = mcp_runtime_tool_result_fallback(result); + return McpOutcome::Ok(rpc_result( + id, + if stateless_2026 { + mcp_stateless_result(result, false) + } else { + result + }, + )); + } + }; if let Some(lc) = lifecycle.as_deref() { - lc.capture_payload("effective_arguments", ¶ms.arguments); + lc.capture_payload("effective_arguments", &audit); + lc.capture_payload("specialized_governance", &permit.audit_projection()); } let result = crate::plugin_gateway::call(runtime, params.arguments, auth).await; let ok = result.get("isError").and_then(Value::as_bool) != Some(true); + let failure_kind = result + .pointer("/structuredContent/error/code") + .and_then(Value::as_str); + let dispatch_certainty = result + .pointer("/structuredContent/dispatchState") + .and_then(Value::as_str) + .unwrap_or("completed"); + runtime.finish_specialized_invocation(permit, ok, dispatch_certainty, failure_kind); if let Some(lc) = lifecycle.as_deref() { lc.dispatch_finished(true, Some(ok), if ok { "success" } else { "tool_error" }); } @@ -1331,21 +1444,95 @@ pub(super) async fn handle_call( )); } if params.name == crate::ssh_resource_gateway::SSH_RESOURCE_TOOL_NAME { - if let Some(outcome) = require_mcp_scope(auth, crate::auth::SCOPE_SSH_LOCAL) { - if let Some(lc) = lifecycle.as_deref() { - lc.dispatch_failed("forbidden"); - lc.dispatch_finished(false, Some(false), "forbidden"); + let recording_session_id = match strip_recording_session_id(&mut params.arguments) { + Ok(session_id) => session_id, + Err(message) => { + if let Some(lc) = lifecycle.as_deref() { + lc.dispatch_failed("invalid_arguments"); + lc.dispatch_finished(false, Some(false), "invalid_arguments"); + } + return McpOutcome::BadRequest(rpc_error(id, -32602, message)); } - return outcome; - } + }; + let policy = match crate::ssh_resource_gateway::operation_policy(¶ms.arguments) { + Ok(policy) => policy, + Err(_) => { + if let Some(lc) = lifecycle.as_deref() { + lc.capture_payload( + "effective_arguments", + &crate::ssh_resource_gateway::audit_arguments(¶ms.arguments), + ); + } + let result = + crate::ssh_resource_gateway::call(runtime, params.arguments, auth).await; + let ok = result.get("isError").and_then(Value::as_bool) != Some(true); + if let Some(lc) = lifecycle.as_deref() { + lc.dispatch_finished(true, Some(ok), if ok { "success" } else { "tool_error" }); + } + return McpOutcome::Ok(rpc_result( + id, + if stateless_2026 { + mcp_stateless_result(result, false) + } else { + result + }, + )); + } + }; + let audit = crate::ssh_resource_gateway::audit_arguments(¶ms.arguments); + let permit = match runtime + .govern_specialized_invocation( + ¶ms.name, + policy, + recording_session_id.as_deref(), + auth, + &audit, + ) + .await + { + Ok(permit) => permit, + Err(SpecializedGovernanceDenial::Scope { + required_scope, + description, + }) => { + if let Some(lc) = lifecycle.as_deref() { + lc.capture_payload("specialized_governance", &policy.audit_projection()); + lc.dispatch_failed("forbidden"); + lc.dispatch_finished(false, Some(false), "forbidden"); + } + return scope_forbidden(auth, Some(required_scope), description); + } + Err(SpecializedGovernanceDenial::Tool(result)) => { + if let Some(lc) = lifecycle.as_deref() { + lc.capture_payload("specialized_governance", &policy.audit_projection()); + lc.dispatch_failed("specialized_governance_denied"); + lc.dispatch_finished(true, Some(false), "tool_error"); + } + let result = mcp_runtime_tool_result_fallback(result); + return McpOutcome::Ok(rpc_result( + id, + if stateless_2026 { + mcp_stateless_result(result, false) + } else { + result + }, + )); + } + }; if let Some(lc) = lifecycle.as_deref() { - lc.capture_payload( - "effective_arguments", - &crate::ssh_resource_gateway::audit_arguments(¶ms.arguments), - ); + lc.capture_payload("effective_arguments", &audit); + lc.capture_payload("specialized_governance", &permit.audit_projection()); } let result = crate::ssh_resource_gateway::call(runtime, params.arguments, auth).await; let ok = result.get("isError").and_then(Value::as_bool) != Some(true); + let failure_kind = result + .pointer("/structuredContent/error/code") + .and_then(Value::as_str); + let dispatch_certainty = result + .pointer("/structuredContent/dispatchState") + .and_then(Value::as_str) + .unwrap_or("completed"); + runtime.finish_specialized_invocation(permit, ok, dispatch_certainty, failure_kind); if let Some(lc) = lifecycle.as_deref() { lc.dispatch_finished(true, Some(ok), if ok { "success" } else { "tool_error" }); } @@ -1358,17 +1545,62 @@ pub(super) async fn handle_call( }, )); } - match resolve_startup_plugin_direct_tool(runtime, auth, ¶ms.name).await { + match startup_plugin_resolution { StartupPluginDirectResolution::Unique(candidate) => { - if let Some(outcome) = require_mcp_scope(auth, crate::auth::SCOPE_PLUGIN_LOCAL) { - if let Some(lc) = lifecycle.as_deref() { - lc.dispatch_failed("forbidden"); - lc.dispatch_finished(false, Some(false), "forbidden"); + let recording_session_id = match strip_recording_session_id(&mut params.arguments) { + Ok(session_id) => session_id, + Err(message) => { + if let Some(lc) = lifecycle.as_deref() { + lc.dispatch_failed("invalid_arguments"); + lc.dispatch_finished(false, Some(false), "invalid_arguments"); + } + return McpOutcome::BadRequest(rpc_error(id, -32602, message)); } - return outcome; - } + }; + let policy = crate::plugin_gateway::PluginOperation::Call.policy(); + let audit = crate::plugin_gateway::audit_startup_direct(&candidate); + let permit = match runtime + .govern_specialized_invocation( + ¶ms.name, + policy, + recording_session_id.as_deref(), + auth, + &audit, + ) + .await + { + Ok(permit) => permit, + Err(SpecializedGovernanceDenial::Scope { + required_scope, + description, + }) => { + if let Some(lc) = lifecycle.as_deref() { + lc.capture_payload("specialized_governance", &policy.audit_projection()); + lc.dispatch_failed("forbidden"); + lc.dispatch_finished(false, Some(false), "forbidden"); + } + return scope_forbidden(auth, Some(required_scope), description); + } + Err(SpecializedGovernanceDenial::Tool(result)) => { + if let Some(lc) = lifecycle.as_deref() { + lc.capture_payload("specialized_governance", &policy.audit_projection()); + lc.dispatch_failed("specialized_governance_denied"); + lc.dispatch_finished(true, Some(false), "tool_error"); + } + let result = mcp_runtime_tool_result_fallback(result); + return McpOutcome::Ok(rpc_result( + id, + if stateless_2026 { + mcp_stateless_result(result, false) + } else { + result + }, + )); + } + }; if let Some(lc) = lifecycle.as_deref() { - lc.capture_payload("effective_arguments", ¶ms.arguments); + lc.capture_payload("effective_arguments", &audit); + lc.capture_payload("specialized_governance", &permit.audit_projection()); } let result = crate::plugin_gateway::call_startup_direct( runtime, @@ -1378,6 +1610,14 @@ pub(super) async fn handle_call( ) .await; let ok = result.get("isError").and_then(Value::as_bool) != Some(true); + let failure_kind = result + .pointer("/structuredContent/error/code") + .and_then(Value::as_str); + let dispatch_certainty = result + .pointer("/structuredContent/dispatchState") + .and_then(Value::as_str) + .unwrap_or("completed"); + runtime.finish_specialized_invocation(permit, ok, dispatch_certainty, failure_kind); if let Some(lc) = lifecycle.as_deref() { lc.dispatch_finished(true, Some(ok), if ok { "success" } else { "tool_error" }); } @@ -1391,7 +1631,7 @@ pub(super) async fn handle_call( )); } StartupPluginDirectResolution::Ambiguous => { - if let Some(outcome) = require_mcp_scope(auth, crate::auth::SCOPE_PLUGIN_LOCAL) { + if let Some(outcome) = require_mcp_scope(auth, crate::auth::SCOPE_PLUGIN_INVOKE) { if let Some(lc) = lifecycle.as_deref() { lc.dispatch_failed("forbidden"); lc.dispatch_finished(false, Some(false), "forbidden"); diff --git a/src/mcp_tests.rs b/src/mcp_tests.rs index ed333b79e..a54173e06 100644 --- a/src/mcp_tests.rs +++ b/src/mcp_tests.rs @@ -40,6 +40,27 @@ fn test_runtime_with_surface(model_surface: ModelSurface) -> ToolRuntime { test_runtime_with_exposure(RuntimeExposure::Runtime(model_surface)) } +fn start_authorized_test_session( + runtime: &ToolRuntime, + auth: &crate::auth::AuthContext, + mode: crate::tool_runtime::SessionMode, +) -> crate::tool_runtime::SessionSummary { + let fingerprint = crate::tool_runtime::workflow_session_authority_fingerprint(Some(auth)) + .expect("test authority must have a stable identity"); + runtime + .sessions + .start_session_with_options( + crate::tool_runtime::SessionCreateOptions::new( + None, + Some("specialized governance test".to_string()), + mode, + crate::tool_runtime::SessionGuards::default(), + ) + .with_owner_authority_fingerprint(Some(fingerprint)), + ) + .unwrap() +} + /// Run one synchronous operation with a temporary model-surface env value. /// The previous value is restored while the shared env lock is still held, /// including during unwinding. Async request tests receive an already-built diff --git a/src/mcp_tests/oauth_scope.rs b/src/mcp_tests/oauth_scope.rs index 60a2c6062..279ce25dd 100644 --- a/src/mcp_tests/oauth_scope.rs +++ b/src/mcp_tests/oauth_scope.rs @@ -230,10 +230,10 @@ async fn oauth2_native_plugin_catalog_and_call_require_explicit_plugin_scope() { status, &body, challenge.as_deref(), - Some(crate::auth::SCOPE_PLUGIN_LOCAL), + Some(crate::auth::SCOPE_PLUGIN_INSPECT), ); - let (_tmp, service, token) = oauth_mcp_service("runtime:read plugin:local"); + let (_tmp, service, token) = oauth_mcp_service("runtime:read plugin:inspect plugin:invoke"); let (status, body, _) = oauth_mcp_request(&service, &token, "tools/list", json!({})).await; assert_eq!(status, StatusCode::OK, "body: {body:?}"); assert!(body["result"]["tools"] @@ -278,6 +278,23 @@ async fn oauth2_native_plugin_catalog_and_call_require_explicit_plugin_scope() { body["result"]["structuredContent"]["error"]["code"], "invalid_arguments", "the pre-binding call shape must be rejected instead of treated as a describe lookup" ); + + let (status, body, challenge) = oauth_mcp_request( + &service, + &token, + "tools/call", + json!({ + "name": crate::plugin_gateway::PLUGIN_TOOL_NAME, + "arguments": {"action": "check", "runner": "runner-a"} + }), + ) + .await; + assert_mcp_oauth_scope_rejected( + status, + &body, + challenge.as_deref(), + Some(crate::auth::SCOPE_PLUGIN_MANAGE), + ); } #[tokio::test] @@ -359,11 +376,14 @@ async fn oauth2_first_class_startup_plugin_visibility_and_direct_spoof_require_p status, &body, challenge.as_deref(), - Some(crate::auth::SCOPE_PLUGIN_LOCAL), + Some(crate::auth::SCOPE_PLUGIN_INVOKE), ); - let (_tmp, service, token) = - oauth_mcp_service_with_startup_plugin("runtime:read plugin:local", tool_name).await; + let (_tmp, service, token) = oauth_mcp_service_with_startup_plugin( + "runtime:read plugin:inspect plugin:invoke", + tool_name, + ) + .await; let (status, body, _) = oauth_mcp_request(&service, &token, "tools/list", json!({})).await; assert_eq!(status, StatusCode::OK, "body: {body:?}"); assert!(body["result"]["tools"] @@ -459,7 +479,7 @@ async fn oauth2_adaptive_gateway_preserves_canonical_target_scope_errors() { status, &body, challenge.as_deref(), - Some(crate::auth::SCOPE_PLUGIN_LOCAL), + Some(crate::auth::SCOPE_PLUGIN_INSPECT), ); let (status, body, _) = oauth_mcp_request(&service, &token, "tools/list", json!({})).await; diff --git a/src/mcp_tests/plugin_check.rs b/src/mcp_tests/plugin_check.rs index ea2f98df8..e19207325 100644 --- a/src/mcp_tests/plugin_check.rs +++ b/src/mcp_tests/plugin_check.rs @@ -10,8 +10,11 @@ use webcodex_core::plugin::{ fn plugin_auth(include_scope: bool) -> crate::auth::AuthContext { let mut auth = mcp_export_api_auth("plugin-check-test-pat", "alice"); if include_scope { - auth.scopes - .push(crate::auth::SCOPE_PLUGIN_LOCAL.to_string()); + auth.scopes.extend([ + crate::auth::SCOPE_PLUGIN_INSPECT.to_string(), + crate::auth::SCOPE_PLUGIN_INVOKE.to_string(), + crate::auth::SCOPE_PLUGIN_MANAGE.to_string(), + ]); } auth } @@ -184,9 +187,9 @@ async fn plugin_check_tool_spec_and_argument_contract_fail_closed_before_dispatc .await; match no_scope { McpOutcome::Forbidden { required_scope, .. } => { - assert_eq!(required_scope, Some(crate::auth::SCOPE_PLUGIN_LOCAL)); + assert_eq!(required_scope, Some(crate::auth::SCOPE_PLUGIN_MANAGE)); } - other => panic!("check without plugin:local must be forbidden: {other:?}"), + other => panic!("check without plugin:manage must be forbidden: {other:?}"), } let auth = plugin_auth(true); diff --git a/src/mcp_tests/plugin_tools.rs b/src/mcp_tests/plugin_tools.rs index 64e2c5ceb..3468350b6 100644 --- a/src/mcp_tests/plugin_tools.rs +++ b/src/mcp_tests/plugin_tools.rs @@ -67,12 +67,23 @@ fn plugin_auth(include_scope: bool) -> crate::auth::AuthContext { fn plugin_auth_for(owner: &str, include_scope: bool) -> crate::auth::AuthContext { let mut auth = mcp_export_api_auth("plugin-test-pat", owner); if include_scope { - auth.scopes - .push(crate::auth::SCOPE_PLUGIN_LOCAL.to_string()); + auth.scopes.extend([ + crate::auth::SCOPE_PLUGIN_INSPECT.to_string(), + crate::auth::SCOPE_PLUGIN_INVOKE.to_string(), + crate::auth::SCOPE_PLUGIN_MANAGE.to_string(), + ]); } auth } +fn plugin_auth_with_scopes(scopes: &[&str]) -> crate::auth::AuthContext { + let mut auth = mcp_export_api_auth("plugin-test-pat", "alice"); + auth.user_id = Some("plugin-test-user-alice".to_string()); + auth.scopes + .extend(scopes.iter().map(|scope| (*scope).to_string())); + auth +} + fn plugin_tool(name: &str) -> PluginTool { PluginTool { name: name.to_string(), @@ -383,6 +394,364 @@ fn spawn_plugin_metadata_call( }) } +#[tokio::test] +async fn plugin_operation_scopes_are_independent_and_fail_closed() { + let runtime = test_runtime(); + let inspect = plugin_auth_with_scopes(&[crate::auth::SCOPE_PLUGIN_INSPECT]); + let invoke = plugin_auth_with_scopes(&[crate::auth::SCOPE_PLUGIN_INVOKE]); + let manage = plugin_auth_with_scopes(&[crate::auth::SCOPE_PLUGIN_MANAGE]); + + let inspect_list = handle_mcp_request( + &runtime, + rpc( + "tools/call", + Some(json!(680)), + json!({ + "name": crate::plugin_gateway::PLUGIN_TOOL_NAME, + "arguments": {"action":"list"} + }), + ), + Some(&inspect), + ) + .await; + let McpOutcome::Ok(inspect_list) = inspect_list else { + panic!("plugin:inspect must allow list"); + }; + assert_eq!(inspect_list["result"]["isError"], false); + + for (id, arguments, required) in [ + ( + 681, + json!({"action":"call"}), + crate::auth::SCOPE_PLUGIN_INVOKE, + ), + ( + 682, + json!({"action":"check"}), + crate::auth::SCOPE_PLUGIN_MANAGE, + ), + ( + 683, + json!({"action":"reload"}), + crate::auth::SCOPE_PLUGIN_MANAGE, + ), + ] { + let outcome = handle_mcp_request( + &runtime, + rpc( + "tools/call", + Some(json!(id)), + json!({"name": crate::plugin_gateway::PLUGIN_TOOL_NAME, "arguments": arguments}), + ), + Some(&inspect), + ) + .await; + match outcome { + McpOutcome::Forbidden { required_scope, .. } => { + assert_eq!(required_scope, Some(required)); + } + other => panic!("plugin:inspect must not escalate to {required}: {other:?}"), + } + } + + let invoke_call = handle_mcp_request( + &runtime, + rpc( + "tools/call", + Some(json!(684)), + json!({ + "name": crate::plugin_gateway::PLUGIN_TOOL_NAME, + "arguments": {"action":"call"} + }), + ), + Some(&invoke), + ) + .await; + let McpOutcome::Ok(invoke_call) = invoke_call else { + panic!("plugin:invoke must pass scope governance for call"); + }; + assert_eq!(invoke_call["result"]["isError"], true); + assert_eq!( + invoke_call["result"]["structuredContent"]["error"]["code"], + "invalid_arguments" + ); + for (id, action) in [(685, "check"), (686, "reload")] { + let outcome = handle_mcp_request( + &runtime, + rpc( + "tools/call", + Some(json!(id)), + json!({ + "name": crate::plugin_gateway::PLUGIN_TOOL_NAME, + "arguments": {"action":action} + }), + ), + Some(&invoke), + ) + .await; + match outcome { + McpOutcome::Forbidden { required_scope, .. } => { + assert_eq!(required_scope, Some(crate::auth::SCOPE_PLUGIN_MANAGE)); + } + other => panic!("plugin:invoke must not manage Plugins: {other:?}"), + } + } + + let manage_call = handle_mcp_request( + &runtime, + rpc( + "tools/call", + Some(json!(687)), + json!({ + "name": crate::plugin_gateway::PLUGIN_TOOL_NAME, + "arguments": {"action":"call"} + }), + ), + Some(&manage), + ) + .await; + match manage_call { + McpOutcome::Forbidden { required_scope, .. } => { + assert_eq!(required_scope, Some(crate::auth::SCOPE_PLUGIN_INVOKE)); + } + other => panic!("plugin:manage must not imply plugin:invoke: {other:?}"), + } + + let manage_reload = handle_mcp_request( + &runtime, + rpc( + "tools/call", + Some(json!(688)), + json!({ + "name": crate::plugin_gateway::PLUGIN_TOOL_NAME, + "arguments": {"action":"reload"} + }), + ), + Some(&manage), + ) + .await; + let McpOutcome::Ok(manage_reload) = manage_reload else { + panic!("plugin:manage must pass scope governance for reload"); + }; + assert_eq!(manage_reload["result"]["isError"], true); + assert_eq!( + manage_reload["result"]["structuredContent"]["error"]["code"], + "invalid_arguments" + ); +} + +#[tokio::test] +async fn read_only_session_allows_plugin_inspect_but_denies_call_before_provider_dispatch() { + let runtime = test_runtime(); + let auth = plugin_auth_with_scopes(&[ + crate::auth::SCOPE_PLUGIN_INSPECT, + crate::auth::SCOPE_PLUGIN_INVOKE, + ]); + register_plugin_runner( + &runtime, + "runner-a", + "runner-instance-a", + "repo-tools", + "provider-instance-a", + vec![plugin_tool("search_symbol")], + ) + .await; + let session = + start_authorized_test_session(&runtime, &auth, crate::tool_runtime::SessionMode::ReadOnly); + + let inspect = handle_mcp_request( + &runtime, + rpc( + "tools/call", + Some(json!(689)), + json!({ + "name": crate::plugin_gateway::PLUGIN_TOOL_NAME, + "arguments": { + "action":"list", + "recording_session_id":session.session_id + } + }), + ), + Some(&auth), + ) + .await; + let McpOutcome::Ok(inspect) = inspect else { + panic!("read-only Session must allow Plugin inspection"); + }; + assert_eq!(inspect["result"]["isError"], false); + + let denied = handle_mcp_request( + &runtime, + rpc( + "tools/call", + Some(json!(690)), + json!({ + "name": crate::plugin_gateway::PLUGIN_TOOL_NAME, + "arguments": { + "action":"call", + "binding":"wc_pbind_0123456789abcdef0123456789abcdef", + "arguments":{"query":"must-not-run"}, + "recording_session_id":session.session_id + } + }), + ), + Some(&auth), + ) + .await; + let McpOutcome::Ok(denied) = denied else { + panic!("Session guard denial must render a normal MCP tool result"); + }; + assert_eq!(denied["result"]["isError"], true); + assert_eq!( + denied["result"]["structuredContent"]["output"]["error_kind"], + "session_guard_denied" + ); + assert_eq!( + denied["result"]["structuredContent"]["output"]["dispatch_certainty"], + "not_started" + ); + assert!(runtime + .runner_registry + .poll(RunnerPollRequest { + client_id: "runner-a".to_string(), + runner_instance_id: "runner-instance-a".to_string(), + }) + .await + .unwrap() + .is_none()); + let ledger = format!( + "{:?}", + runtime.sessions.summary(&session.session_id, Some(100)) + ); + assert!(!ledger.contains("must-not-run")); + assert!(!ledger.contains("wc_pbind_0123456789abcdef0123456789abcdef")); +} + +#[tokio::test] +async fn specialized_recording_session_authority_fails_closed_at_mcp_boundary() { + let runtime = test_runtime(); + let owner = plugin_auth_with_scopes(&[crate::auth::SCOPE_PLUGIN_INSPECT]); + let session = + start_authorized_test_session(&runtime, &owner, crate::tool_runtime::SessionMode::Normal); + let mut foreign = plugin_auth_with_scopes(&[crate::auth::SCOPE_PLUGIN_INSPECT]); + foreign.username = Some("bob".to_string()); + foreign.user_id = Some("plugin-test-user-bob".to_string()); + foreign.api_key_id = Some("plugin-test-pat-bob".to_string()); + + let denied = handle_mcp_request( + &runtime, + rpc( + "tools/call", + Some(json!(693)), + json!({ + "name": crate::plugin_gateway::PLUGIN_TOOL_NAME, + "arguments": { + "action":"list", + "recording_session_id":session.session_id + } + }), + ), + Some(&foreign), + ) + .await; + let McpOutcome::Ok(denied) = denied else { + panic!("Session authority denial must render a normal MCP tool result"); + }; + assert_eq!(denied["result"]["isError"], true); + assert_eq!( + denied["result"]["structuredContent"]["output"]["failure_kind"], + "session_authority_denied" + ); + assert_eq!( + denied["result"]["structuredContent"]["output"]["dispatch_certainty"], + "not_started" + ); +} + +#[tokio::test] +async fn restricted_permission_denies_plugin_call_and_direct_tool_before_provider_dispatch() { + let runtime = test_runtime().with_permission_evaluator( + crate::tool_runtime::PermissionEvaluator::with_mode( + crate::tool_runtime::AuthorityMode::Restricted, + ), + ); + let auth = plugin_auth_with_scopes(&[crate::auth::SCOPE_PLUGIN_INVOKE]); + register_plugin_runner( + &runtime, + "runner-a", + "runner-instance-a", + "repo-tools", + "provider-instance-a", + vec![plugin_tool("permission_search")], + ) + .await; + + let call = handle_mcp_request( + &runtime, + rpc( + "tools/call", + Some(json!(691)), + json!({ + "name": crate::plugin_gateway::PLUGIN_TOOL_NAME, + "arguments": { + "action":"call", + "binding":"wc_pbind_0123456789abcdef0123456789abcdef", + "arguments":{"query":"must-not-run"} + } + }), + ), + Some(&auth), + ) + .await; + let McpOutcome::Ok(call) = call else { + panic!("permission denial must render a normal MCP tool result"); + }; + assert_eq!(call["result"]["isError"], true); + assert_eq!( + call["result"]["structuredContent"]["output"]["failure_kind"], + "permission_denied" + ); + assert_eq!( + call["result"]["structuredContent"]["output"]["dispatch_certainty"], + "not_started" + ); + + let direct = handle_mcp_request( + &runtime, + rpc( + "tools/call", + Some(json!(692)), + json!({ + "name":"permission_search", + "arguments":{"query":"must-not-run"} + }), + ), + Some(&auth), + ) + .await; + let McpOutcome::Ok(direct) = direct else { + panic!("direct Plugin permission denial must render a normal MCP tool result"); + }; + assert_eq!(direct["result"]["isError"], true); + assert_eq!( + direct["result"]["structuredContent"]["output"]["failure_kind"], + "permission_denied" + ); + assert_eq!( + direct["result"]["structuredContent"]["output"]["dispatch_certainty"], + "not_started" + ); + assert!(runtime + .runner_registry + .poll(RunnerPollRequest { + client_id: "runner-a".to_string(), + runner_instance_id: "runner-instance-a".to_string(), + }) + .await + .unwrap() + .is_none()); +} + #[tokio::test] async fn plugin_tool_list_discovers_only_visible_plugin_capable_runners() { let runtime = test_runtime(); @@ -1114,6 +1483,69 @@ async fn startup_plugin_direct_tools_are_scoped_unique_and_keep_exact_schema() { .any(|tool| tool["name"] == "search_symbol")); } +#[tokio::test] +async fn startup_plugin_direct_inventory_requires_invoke_not_inspect_or_manage() { + for (id, scope) in [ + (704, crate::auth::SCOPE_PLUGIN_INSPECT), + (705, crate::auth::SCOPE_PLUGIN_MANAGE), + ] { + let runtime = test_runtime_with_surface(ModelSurface::LocalCoding); + register_plugin_runner( + &runtime, + "runner-a", + "runner-instance-a", + "repo-tools", + "provider-instance-a", + vec![plugin_tool("invoke_only_direct")], + ) + .await; + let auth = plugin_auth_with_scopes(&[scope]); + let outcome = handle_mcp_request( + &runtime, + rpc("tools/list", Some(json!(id)), json!({})), + Some(&auth), + ) + .await; + let McpOutcome::Ok(value) = outcome else { + panic!("tools/list must succeed for {scope}"); + }; + let names = value["result"]["tools"] + .as_array() + .unwrap() + .iter() + .filter_map(|tool| tool["name"].as_str()) + .collect::>(); + assert!(names.contains(&crate::plugin_gateway::PLUGIN_TOOL_NAME)); + assert!(!names.contains(&"invoke_only_direct")); + + let spoof = handle_mcp_request( + &runtime, + rpc( + "tools/call", + Some(json!(id + 10)), + json!({"name":"invoke_only_direct","arguments":{"query":"x"}}), + ), + Some(&auth), + ) + .await; + match spoof { + McpOutcome::Forbidden { required_scope, .. } => { + assert_eq!(required_scope, Some(crate::auth::SCOPE_PLUGIN_INVOKE)); + } + other => panic!("direct Plugin spoof must require invoke: {other:?}"), + } + assert!(runtime + .runner_registry + .poll(RunnerPollRequest { + client_id: "runner-a".to_string(), + runner_instance_id: "runner-instance-a".to_string(), + }) + .await + .unwrap() + .is_none()); + } +} + #[tokio::test] async fn startup_plugin_reserved_and_duplicate_names_are_not_directly_exposed() { let runtime = test_runtime_with_surface(ModelSurface::LocalCoding); @@ -1157,7 +1589,7 @@ async fn startup_plugin_reserved_and_duplicate_names_are_not_directly_exposed() #[tokio::test] async fn direct_startup_plugin_call_routes_exact_startup_provider_and_renders_result() { let runtime = Arc::new(test_runtime_with_surface(ModelSurface::LocalCoding)); - let auth = plugin_auth(true); + let auth = plugin_auth_with_scopes(&[crate::auth::SCOPE_PLUGIN_INVOKE]); register_plugin_runner( &runtime, "runner-a", @@ -1281,7 +1713,7 @@ async fn direct_plugin_scope_and_ambiguity_fail_before_runner_dispatch() { .await; match outcome { McpOutcome::Forbidden { required_scope, .. } => { - assert_eq!(required_scope, Some(crate::auth::SCOPE_PLUGIN_LOCAL)); + assert_eq!(required_scope, Some(crate::auth::SCOPE_PLUGIN_INVOKE)); } other => panic!("missing Plugin scope must be forbidden, got {other:?}"), } diff --git a/src/mcp_tests/ssh_resource.rs b/src/mcp_tests/ssh_resource.rs index 7927e1890..cae50fdf8 100644 --- a/src/mcp_tests/ssh_resource.rs +++ b/src/mcp_tests/ssh_resource.rs @@ -6,6 +6,7 @@ use webcodex_core::ssh_resource::{ fn ssh_auth() -> crate::auth::AuthContext { let mut auth = mcp_export_api_auth("ssh-resource-test-pat", "alice"); + auth.user_id = Some("ssh-resource-test-user-alice".to_string()); auth.scopes.push(crate::auth::SCOPE_SSH_LOCAL.to_string()); auth } @@ -388,3 +389,140 @@ async fn managed_ssh_invalid_post_dispatch_response_is_outcome_unknown_and_bindi "ssh_resource_binding_required" ); } + +#[tokio::test] +async fn read_only_session_allows_ssh_inspect_but_denies_management_before_runner_dispatch() { + let runtime = Arc::new(test_runtime()); + let auth = ssh_auth(); + register_managed_runner(&runtime, "instance-a").await; + let session = + start_authorized_test_session(&runtime, &auth, crate::tool_runtime::SessionMode::ReadOnly); + + let list_task = call_in_task( + Arc::clone(&runtime), + auth.clone(), + json!({ + "action":"list", + "runner":"runner-a", + "recording_session_id":session.session_id + }), + 808, + ) + .await; + let list_request = wait_for_request(&runtime, "instance-a").await; + complete_response( + &runtime, + list_request, + "instance-a", + SshResourceResponse::List { + revision: 5, + resources: vec![], + }, + ) + .await; + let list_result = tool_result(list_task.await.unwrap()); + assert_eq!(list_result["isError"], false); + let binding = list_result["structuredContent"]["binding"] + .as_str() + .unwrap() + .to_string(); + + let denied = handle_mcp_request( + &runtime, + rpc( + "tools/call", + Some(json!(809)), + json!({ + "name": crate::ssh_resource_gateway::SSH_RESOURCE_TOOL_NAME, + "arguments": { + "action":"register", + "binding":binding, + "name":"w10", + "target":"private-user@private-host", + "recording_session_id":session.session_id + } + }), + ), + Some(&auth), + ) + .await; + let result = tool_result(denied); + assert_eq!(result["isError"], true); + assert_eq!( + result["structuredContent"]["output"]["error_kind"], + "session_guard_denied" + ); + assert_eq!( + result["structuredContent"]["output"]["dispatch_certainty"], + "not_started" + ); + assert!(!serde_json::to_string(&result) + .unwrap() + .contains("private-user@private-host")); + let ledger = format!( + "{:?}", + runtime.sessions.summary(&session.session_id, Some(100)) + ); + assert!(!ledger.contains("private-user@private-host")); + assert!(runtime + .runner_registry + .poll(RunnerPollRequest { + client_id: "runner-a".to_string(), + runner_instance_id: "instance-a".to_string(), + }) + .await + .unwrap() + .is_none()); +} + +#[tokio::test] +async fn restricted_permission_denies_ssh_management_before_runner_dispatch() { + let runtime = Arc::new(test_runtime().with_permission_evaluator( + crate::tool_runtime::PermissionEvaluator::with_mode( + crate::tool_runtime::AuthorityMode::Restricted, + ), + )); + let auth = ssh_auth(); + register_managed_runner(&runtime, "instance-a").await; + + let denied = handle_mcp_request( + &runtime, + rpc( + "tools/call", + Some(json!(810)), + json!({ + "name": crate::ssh_resource_gateway::SSH_RESOURCE_TOOL_NAME, + "arguments": { + "action":"register", + "binding":"wc_sshbind_0123456789abcdef0123456789abcdef", + "name":"w10", + "target":"private-user@private-host" + } + }), + ), + Some(&auth), + ) + .await; + let result = tool_result(denied); + assert_eq!(result["isError"], true); + assert_eq!( + result["structuredContent"]["output"]["failure_kind"], + "permission_denied" + ); + assert_eq!( + result["structuredContent"]["output"]["dispatch_certainty"], + "not_started" + ); + assert!(!serde_json::to_string(&result) + .unwrap() + .contains("private-user@private-host")); + assert!(runtime + .runner_registry + .poll(RunnerPollRequest { + client_id: "runner-a".to_string(), + runner_instance_id: "instance-a".to_string(), + }) + .await + .unwrap() + .is_none()); +} diff --git a/src/oauth_http/scope_registry.rs b/src/oauth_http/scope_registry.rs index 035d1e0b0..c0c6c0c47 100644 --- a/src/oauth_http/scope_registry.rs +++ b/src/oauth_http/scope_registry.rs @@ -25,7 +25,9 @@ const OAUTH_SCOPES_SUPPORTED: &[&str] = &[ scopes::SCOPE_COMPUTER_CLIPBOARD_READ, scopes::SCOPE_COMPUTER_CLIPBOARD_WRITE, scopes::SCOPE_MCP_LOCAL, - scopes::SCOPE_PLUGIN_LOCAL, + scopes::SCOPE_PLUGIN_INSPECT, + scopes::SCOPE_PLUGIN_INVOKE, + scopes::SCOPE_PLUGIN_MANAGE, scopes::SCOPE_SSH_LOCAL, scopes::SCOPE_CODING_AGENT_RUN, scopes::SCOPE_ACCOUNT_MANAGE, diff --git a/src/oauth_http/shared_key_bridge.rs b/src/oauth_http/shared_key_bridge.rs index d1341969a..b5eeffd2d 100644 --- a/src/oauth_http/shared_key_bridge.rs +++ b/src/oauth_http/shared_key_bridge.rs @@ -6,8 +6,9 @@ use crate::auth::{ SCOPE_COMMUNICATION_READ, SCOPE_COMPUTER_CLIPBOARD_READ, SCOPE_COMPUTER_CLIPBOARD_WRITE, SCOPE_COMPUTER_CONTROL, SCOPE_COMPUTER_DISPLAY_READ, SCOPE_COMPUTER_LAUNCH, SCOPE_COMPUTER_POINTER_CONTROL, SCOPE_COMPUTER_READ, SCOPE_JOB_RUN, SCOPE_MCP_LOCAL, - SCOPE_MEMORY_MANAGE, SCOPE_MEMORY_READ, SCOPE_PLUGIN_LOCAL, SCOPE_PROJECT_READ, - SCOPE_PROJECT_WRITE, SCOPE_RUNTIME_READ, SCOPE_SESSION_COLLABORATE, SCOPE_SSH_LOCAL, + SCOPE_MEMORY_MANAGE, SCOPE_MEMORY_READ, SCOPE_PLUGIN_INSPECT, SCOPE_PLUGIN_INVOKE, + SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, SCOPE_RUNTIME_READ, SCOPE_SESSION_COLLABORATE, + SCOPE_SSH_LOCAL, }; use crate::models::OAuthAuthorizationCodeRecord; use crate::runner_http::{RunnerFeature, RunnerFeatureSet}; @@ -177,7 +178,11 @@ fn bridge_scope_ceiling_without_optional_class_scopes(scopes: &[String]) -> Opti .filter(|scope| { !matches!( scope.as_str(), - SCOPE_MCP_LOCAL | SCOPE_PLUGIN_LOCAL | SCOPE_SSH_LOCAL | SCOPE_CODING_AGENT_RUN + SCOPE_MCP_LOCAL + | SCOPE_PLUGIN_INSPECT + | SCOPE_PLUGIN_INVOKE + | SCOPE_SSH_LOCAL + | SCOPE_CODING_AGENT_RUN ) }) .cloned() @@ -212,7 +217,8 @@ fn bridge_scope_ceiling_with_options( desired.push(SCOPE_MCP_LOCAL.to_string()); } if local_plugins { - desired.push(SCOPE_PLUGIN_LOCAL.to_string()); + desired.push(SCOPE_PLUGIN_INSPECT.to_string()); + desired.push(SCOPE_PLUGIN_INVOKE.to_string()); } if local_ssh { desired.push(SCOPE_SSH_LOCAL.to_string()); @@ -255,11 +261,10 @@ fn bridge_client_has_local_mcp_scope(client: &crate::models::OAuthClientRecord) .any(|scope| scope == SCOPE_MCP_LOCAL) } -fn bridge_client_has_local_plugin_scope(client: &crate::models::OAuthClientRecord) -> bool { - client - .allowed_scopes_vec() - .iter() - .any(|scope| scope == SCOPE_PLUGIN_LOCAL) +fn bridge_client_has_local_plugin_scopes(client: &crate::models::OAuthClientRecord) -> bool { + let scopes = client.allowed_scopes_vec(); + scopes.iter().any(|scope| scope == SCOPE_PLUGIN_INSPECT) + && scopes.iter().any(|scope| scope == SCOPE_PLUGIN_INVOKE) } fn bridge_client_has_local_ssh_scope(client: &crate::models::OAuthClientRecord) -> bool { @@ -307,7 +312,8 @@ pub(crate) fn normalize_bridge_oauth_scopes( if normalized.split_whitespace().any(|scope| { scope != OAUTH_OFFLINE_ACCESS_SCOPE && scope != SCOPE_MCP_LOCAL - && scope != SCOPE_PLUGIN_LOCAL + && scope != SCOPE_PLUGIN_INSPECT + && scope != SCOPE_PLUGIN_INVOKE && scope != SCOPE_SSH_LOCAL && scope != SCOPE_CODING_AGENT_RUN && !SHARED_KEY_OAUTH_COMPUTER_ENABLED_SCOPES.contains(&scope) @@ -337,7 +343,8 @@ impl BridgeAuthorizeValidated { || matches!( *scope, SCOPE_MCP_LOCAL - | SCOPE_PLUGIN_LOCAL + | SCOPE_PLUGIN_INSPECT + | SCOPE_PLUGIN_INVOKE | SCOPE_SSH_LOCAL | SCOPE_CODING_AGENT_RUN ) @@ -449,7 +456,8 @@ fn selected_bridge_grant_scopes( *scope == OAUTH_OFFLINE_ACCESS_SCOPE || bridge_oauth_scopes().contains(scope) || *scope == SCOPE_MCP_LOCAL - || *scope == SCOPE_PLUGIN_LOCAL + || *scope == SCOPE_PLUGIN_INSPECT + || *scope == SCOPE_PLUGIN_INVOKE || *scope == SCOPE_SSH_LOCAL || *scope == SCOPE_CODING_AGENT_RUN || optional_scopes.contains(scope) @@ -584,7 +592,7 @@ pub(super) fn validate_bridge_authorize_request( let local_mcp_enabled = client.is_shared_key_owned() && bridge_client_has_local_mcp_scope(&client); let local_plugins_enabled = - client.is_shared_key_owned() && bridge_client_has_local_plugin_scope(&client); + client.is_shared_key_owned() && bridge_client_has_local_plugin_scopes(&client); let local_ssh_enabled = client.is_shared_key_owned() && bridge_client_has_local_ssh_scope(&client); let coding_agent_enabled = @@ -597,7 +605,8 @@ pub(super) fn validate_bridge_authorize_request( if requestable_scopes.split_whitespace().any(|scope| { scope != OAUTH_OFFLINE_ACCESS_SCOPE && !(scope == SCOPE_MCP_LOCAL && local_mcp_enabled) - && !(scope == SCOPE_PLUGIN_LOCAL && local_plugins_enabled) + && !(matches!(scope, SCOPE_PLUGIN_INSPECT | SCOPE_PLUGIN_INVOKE) + && local_plugins_enabled) && !(scope == SCOPE_SSH_LOCAL && local_ssh_enabled) && !(scope == SCOPE_CODING_AGENT_RUN && coding_agent_enabled) && !client_bridge_ceiling.contains(&scope) @@ -855,7 +864,7 @@ pub(crate) async fn oauth_shared_key_client_provision( }))); return; } - if !body.local_plugins && bridge_client_has_local_plugin_scope(&client) { + if !body.local_plugins && bridge_client_has_local_plugin_scopes(&client) { res.status_code(StatusCode::CONFLICT); res.render(Json(serde_json::json!({ "error": "OAuth client has local Plugin authority enabled; reconnect with --oauth-local-plugins to reuse this client" @@ -977,7 +986,11 @@ pub(crate) async fn oauth_shared_key_client_provision( }))); return; } - if !body.local_plugins && base_scopes.iter().any(|scope| scope == SCOPE_PLUGIN_LOCAL) { + if !body.local_plugins + && base_scopes + .iter() + .any(|scope| matches!(scope.as_str(), SCOPE_PLUGIN_INSPECT | SCOPE_PLUGIN_INVOKE)) + { res.status_code(StatusCode::CONFLICT); res.render(Json(serde_json::json!({ "error": "persisted OAuth profile has local Plugin authority enabled; reconnect with --oauth-local-plugins" diff --git a/src/oauth_http/tests/shared_key_bridge.rs b/src/oauth_http/tests/shared_key_bridge.rs index 5d23542d0..7e7e80511 100644 --- a/src/oauth_http/tests/shared_key_bridge.rs +++ b/src/oauth_http/tests/shared_key_bridge.rs @@ -106,25 +106,39 @@ fn bridge_local_mcp_scope_requires_explicit_client_ceiling_opt_in() { #[test] fn bridge_local_plugin_scope_requires_explicit_client_ceiling_opt_in() { - assert!(!bridge_oauth_scopes().contains(&crate::auth::SCOPE_PLUGIN_LOCAL)); - assert!(!crate::auth::DIRECT_SHARED_KEY_MODEL_SCOPES.contains(&crate::auth::SCOPE_PLUGIN_LOCAL)); + for scope in [ + crate::auth::SCOPE_PLUGIN_INSPECT, + crate::auth::SCOPE_PLUGIN_INVOKE, + crate::auth::SCOPE_PLUGIN_MANAGE, + ] { + assert!(!bridge_oauth_scopes().contains(&scope)); + assert!(!crate::auth::DIRECT_SHARED_KEY_MODEL_SCOPES.contains(&scope)); + } let baseline = bridge_oauth_scopes() .iter() .map(|scope| (*scope).to_string()) .collect::>(); let mut opted_in = baseline.clone(); - opted_in.push(crate::auth::SCOPE_PLUGIN_LOCAL.to_string()); + opted_in.push(crate::auth::SCOPE_PLUGIN_INSPECT.to_string()); + opted_in.push(crate::auth::SCOPE_PLUGIN_INVOKE.to_string()); assert!(normalize_bridge_oauth_scopes( - Some(crate::auth::SCOPE_PLUGIN_LOCAL), + Some("plugin:inspect plugin:invoke"), &opted_in.join(" "), ) .is_ok()); assert!(normalize_bridge_oauth_scopes( - Some(crate::auth::SCOPE_PLUGIN_LOCAL), + Some(crate::auth::SCOPE_PLUGIN_INSPECT), &baseline.join(" "), ) .is_err()); + let mut manage_ceiling = opted_in; + manage_ceiling.push(crate::auth::SCOPE_PLUGIN_MANAGE.to_string()); + assert!(normalize_bridge_oauth_scopes( + Some(crate::auth::SCOPE_PLUGIN_MANAGE), + &manage_ceiling.join(" "), + ) + .is_err()); } #[test] @@ -154,9 +168,10 @@ async fn bridge_authorize_local_plugin_requires_shared_key_owned_opt_in() { let (_tmp, db) = test_db(); let shared_key = "local-plugin-owned-shared-key"; let allowed_scopes = format!( - "{} {}", + "{} {} {}", bridge_oauth_scopes().join(" "), - crate::auth::SCOPE_PLUGIN_LOCAL + crate::auth::SCOPE_PLUGIN_INSPECT, + crate::auth::SCOPE_PLUGIN_INVOKE ); let (owned, _) = seed_shared_key_bridge_client( &db, @@ -168,12 +183,14 @@ async fn bridge_authorize_local_plugin_requires_shared_key_owned_opt_in() { let owned_url = valid_bridge_authorize_url( &owned, "https://local-plugin.example/callback", - "runtime:read plugin:local", + "runtime:read plugin:inspect plugin:invoke", ); let mut owned_response = TestClient::get(&owned_url).send(&service).await; assert_eq!(owned_response.status_code, Some(StatusCode::OK)); let owned_html = owned_response.take_string().await.unwrap_or_default(); - assert!(owned_html.contains("plugin:local")); + assert!(owned_html.contains("plugin:inspect")); + assert!(owned_html.contains("plugin:invoke")); + assert!(!owned_html.contains("plugin:manage")); let user = seed_user(&db, "local-plugin-legacy-owner"); let legacy = seed_client_with_redirects_and_scopes( @@ -185,7 +202,7 @@ async fn bridge_authorize_local_plugin_requires_shared_key_owned_opt_in() { let legacy_url = valid_bridge_authorize_url( &legacy, "https://legacy-local-plugin.example/callback", - "runtime:read plugin:local", + "runtime:read plugin:inspect plugin:invoke", ); let legacy_response = TestClient::get(&legacy_url).send(&service).await; assert_eq!(legacy_response.status_code, Some(StatusCode::FOUND)); diff --git a/src/plugin_gateway.rs b/src/plugin_gateway.rs index f75c6822f..529ab3a01 100644 --- a/src/plugin_gateway.rs +++ b/src/plugin_gateway.rs @@ -7,7 +7,8 @@ pub(crate) use webcodex_core::plugin::*; -use crate::auth::{AuthContext, SCOPE_PLUGIN_LOCAL}; +use crate::auth::{AuthContext, SCOPE_PLUGIN_INSPECT, SCOPE_PLUGIN_INVOKE, SCOPE_PLUGIN_MANAGE}; +use crate::tool_runtime::specialized::{SpecializedOperationPolicy, SpecializedSource}; use crate::tool_runtime::ToolRuntime; use serde::Deserialize; use serde_json::{json, Value}; @@ -157,8 +158,174 @@ struct PluginToolArguments { arguments: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PluginOperation { + List, + Check, + Reload, + Describe, + Call, +} + +impl PluginOperation { + fn parse(action: &str) -> Option { + match action { + "list" => Some(Self::List), + "check" => Some(Self::Check), + "reload" => Some(Self::Reload), + "describe" => Some(Self::Describe), + "call" => Some(Self::Call), + _ => None, + } + } + + pub(crate) fn policy(self) -> SpecializedOperationPolicy { + match self { + Self::List => SpecializedOperationPolicy::read( + SpecializedSource::Plugin, + "list", + SCOPE_PLUGIN_INSPECT, + ), + Self::Describe => SpecializedOperationPolicy::read( + SpecializedSource::Plugin, + "describe", + SCOPE_PLUGIN_INSPECT, + ), + Self::Call => SpecializedOperationPolicy::local_execution( + SpecializedSource::Plugin, + "call", + SCOPE_PLUGIN_INVOKE, + ), + Self::Check => SpecializedOperationPolicy::local_execution( + SpecializedSource::Plugin, + "check", + SCOPE_PLUGIN_MANAGE, + ), + Self::Reload => SpecializedOperationPolicy::management( + SpecializedSource::Plugin, + "reload", + SCOPE_PLUGIN_MANAGE, + true, + ), + } + } +} + +pub(crate) fn operation_policy( + arguments: &Value, +) -> Result { + let action = arguments + .get("action") + .and_then(Value::as_str) + .ok_or("plugin_tool action is required")?; + PluginOperation::parse(action) + .map(PluginOperation::policy) + .ok_or("plugin_tool action must be one of list, check, reload, describe, or call") +} + pub(crate) fn authorized(auth: Option<&AuthContext>) -> bool { - auth.is_some_and(|auth| auth.has_scope(SCOPE_PLUGIN_LOCAL)) + auth.is_some_and(|auth| { + auth.has_scope(SCOPE_PLUGIN_INSPECT) + || auth.has_scope(SCOPE_PLUGIN_INVOKE) + || auth.has_scope(SCOPE_PLUGIN_MANAGE) + }) +} + +pub(crate) fn invoke_authorized(auth: Option<&AuthContext>) -> bool { + auth.is_some_and(|auth| auth.has_scope(SCOPE_PLUGIN_INVOKE)) +} + +fn authorized_for_operation(auth: Option<&AuthContext>, operation: PluginOperation) -> bool { + let required = operation.policy().required_scope; + auth.is_some_and(|auth| auth.has_scope(required)) +} + +/// Bounded model/ledger projection. Arbitrary Plugin arguments and opaque +/// bindings are deliberately represented only by presence bits. +pub(crate) fn audit_arguments(arguments: &Value) -> Value { + let object = arguments.as_object(); + let action = object + .and_then(|o| o.get("action")) + .and_then(Value::as_str) + .and_then(PluginOperation::parse) + .map(|operation| operation.policy().operation); + let runner = object + .and_then(|o| o.get("runner")) + .and_then(Value::as_str) + .filter(|value| bounded_runner_id(value)); + let plugin = object + .and_then(|o| o.get("plugin")) + .and_then(Value::as_str) + .filter(|value| validate_provider_id(value).is_ok()); + let tool = object + .and_then(|o| o.get("tool")) + .and_then(Value::as_str) + .filter(|value| validate_tool_name(value).is_ok()); + json!({ + "action": action, + "runner": runner, + "plugin": plugin, + "tool": tool, + "binding_present": object.is_some_and(|o| o.get("binding").is_some()), + "arguments_present": object.is_some_and(|o| o.get("arguments").is_some()), + }) +} + +fn bounded_runner_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 80 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +/// Add authoritative bounded provider identity when an opaque call binding can +/// be resolved without executing the Plugin. The binding value, provider +/// instance identity, schema observation, and arbitrary arguments stay out of +/// the projection. +pub(crate) async fn audit_arguments_with_identity( + runtime: &ToolRuntime, + arguments: &Value, + auth: Option<&AuthContext>, +) -> Value { + let audit = audit_arguments(arguments); + if !invoke_authorized(auth) { + return audit; + } + let Some(binding) = arguments + .as_object() + .filter(|object| object.get("action").and_then(Value::as_str) == Some("call")) + .and_then(|object| object.get("binding")) + .and_then(Value::as_str) + .and_then(|binding| runtime.plugin_gateway.binding(binding)) + else { + return audit; + }; + let Ok(runner) = resolve_runner(runtime, &binding.client_id, auth).await else { + return audit; + }; + if runner.runner_instance_id != binding.runner_instance_id { + return audit; + } + audit_arguments_with_resolved_binding(audit, &binding) +} + +fn audit_arguments_with_resolved_binding(mut audit: Value, binding: &PluginBinding) -> Value { + audit["runner"] = Value::String(binding.client_id.clone()); + audit["plugin"] = Value::String(binding.provider_id.clone()); + audit["tool"] = Value::String(binding.tool_name.clone()); + audit["binding_resolved"] = Value::Bool(true); + audit +} + +pub(crate) fn audit_startup_direct(candidate: &StartupPluginToolCandidate) -> Value { + json!({ + "runner": candidate.client_id, + "plugin": candidate.provider_id, + "tool": candidate.tool.name, + "plane": "startup", + "arguments_present": true, + }) } pub(crate) fn tool_spec() -> Value { @@ -192,6 +359,11 @@ pub(crate) fn tool_spec() -> Value { "arguments": { "type": "object", "description": "Required for action=call. Arguments must match the schema returned by the describe that created binding." + }, + "recording_session_id": { + "type": "string", + "pattern": "^wc_sess_[A-Za-z0-9_]+$", + "description": "Optional explicit Workflow Session used for authority, read-only/guard, permission, and audit governance. It is never inferred from MCP transport identity." } }, "required": ["action"], @@ -208,12 +380,6 @@ pub(crate) async fn call( arguments: Value, auth: Option<&AuthContext>, ) -> Value { - if !authorized(auth) { - return gateway_error_result(GatewayError::local( - "insufficient_scope", - "local native Plugin access requires the plugin:local scope", - )); - } let parsed: PluginToolArguments = match serde_json::from_value(arguments) { Ok(parsed) => parsed, Err(_) => { @@ -223,26 +389,38 @@ pub(crate) async fn call( )) } }; - let result = match parsed.action.as_str() { - "list" => list(runtime, parsed, auth) + let Some(operation) = PluginOperation::parse(&parsed.action) else { + return gateway_error_result(GatewayError::local( + "invalid_action", + "action must be one of list, check, reload, describe, or call", + )); + }; + if !authorized_for_operation(auth, operation) { + return gateway_error_result(GatewayError::local( + "insufficient_scope", + format!( + "Plugin operation '{}' requires the {} scope", + operation.policy().operation, + operation.policy().required_scope + ), + )); + } + let result = match operation { + PluginOperation::List => list(runtime, parsed, auth) .await .map(GatewaySuccess::Metadata), - "check" => check(runtime, parsed, auth) + PluginOperation::Check => check(runtime, parsed, auth) .await .map(GatewaySuccess::Metadata), - "reload" => reload(runtime, parsed, auth) + PluginOperation::Reload => reload(runtime, parsed, auth) .await .map(GatewaySuccess::Metadata), - "describe" => describe(runtime, parsed, auth) + PluginOperation::Describe => describe(runtime, parsed, auth) .await .map(GatewaySuccess::Metadata), - "call" => call_plugin(runtime, parsed, auth) + PluginOperation::Call => call_plugin(runtime, parsed, auth) .await .map(GatewaySuccess::ToolResult), - _ => Err(GatewayError::local( - "invalid_action", - "action must be one of list, check, reload, describe, or call", - )), }; render_gateway_result(result) } @@ -635,7 +813,7 @@ pub(crate) async fn visible_plugin_runners( } /// Return exact startup Tool candidates from sanitized immutable registration -/// inventory. This helper intentionally does not require `plugin:local`: call +/// inventory. This helper intentionally does not require Plugin invocation authority: call /// dispatch uses it before the scope check so a spoofed direct Plugin name is /// rejected as forbidden instead of falling through to an unrelated unknown /// static tool path. Runner visibility policy remains authoritative. @@ -693,10 +871,10 @@ pub(crate) async fn call_startup_direct( arguments: Value, auth: Option<&AuthContext>, ) -> Value { - if !authorized(auth) { + if !invoke_authorized(auth) { return gateway_error_result(GatewayError::local( "insufficient_scope", - "direct native Plugin calls require the plugin:local scope", + "direct native Plugin calls require the plugin:invoke scope", )); } if !arguments.is_object() { @@ -1175,4 +1353,101 @@ mod tests { assert!(!encoded.contains("cwd")); assert!(!encoded.contains("\"env\"")); } + + #[test] + fn plugin_audit_projection_never_contains_binding_or_arbitrary_arguments() { + let binding = "wc_pbind_private_binding_marker"; + let secret = "PLUGIN_PRIVATE_ARGUMENT_MARKER"; + let audit = audit_arguments(&json!({ + "action": "call", + "binding": binding, + "arguments": { + "token": secret, + "nested": {"credential": "also-private"} + } + })); + let encoded = serde_json::to_string(&audit).unwrap(); + assert!(!encoded.contains(binding)); + assert!(!encoded.contains(secret)); + assert!(!encoded.contains("also-private")); + assert_eq!(audit["binding_present"], true); + assert_eq!(audit["arguments_present"], true); + } + + #[test] + fn plugin_audit_projection_drops_unbounded_or_unrecognized_identity_fields() { + let secret = "PRIVATE\nPLUGIN\nFIELD"; + let audit = audit_arguments(&json!({ + "action": secret, + "runner": secret, + "plugin": secret, + "tool": secret, + "arguments": {"token": "never-project-this"} + })); + let encoded = serde_json::to_string(&audit).unwrap(); + assert!(!encoded.contains("PRIVATE")); + assert!(!encoded.contains("never-project-this")); + assert!(audit["action"].is_null()); + assert!(audit["runner"].is_null()); + assert!(audit["plugin"].is_null()); + assert!(audit["tool"].is_null()); + assert_eq!(audit["arguments_present"], true); + } + + #[test] + fn plugin_audit_projection_resolves_bounded_binding_identity_without_opaque_values() { + let binding = test_binding("provider-a", "tool-a"); + let opaque = "wc_pbind_private_binding_marker"; + let secret = "PLUGIN_PRIVATE_ARGUMENT_MARKER"; + let audit = audit_arguments_with_resolved_binding( + audit_arguments(&json!({ + "action": "call", + "binding": opaque, + "arguments": {"token": secret} + })), + &binding, + ); + let encoded = serde_json::to_string(&audit).unwrap(); + assert_eq!(audit["runner"], "runner-a"); + assert_eq!(audit["plugin"], "provider-a"); + assert_eq!(audit["tool"], "tool-a"); + assert_eq!(audit["binding_resolved"], true); + assert!(!encoded.contains(&opaque)); + assert!(!encoded.contains(secret)); + assert!(!encoded.contains("provider-a-instance")); + } + + #[test] + fn plugin_operation_policy_distinguishes_inspect_execution_and_management() { + use crate::tool_runtime::specialized::SpecializedEffect; + + assert_eq!( + PluginOperation::List.policy().effect, + SpecializedEffect::Read + ); + assert_eq!( + PluginOperation::Describe.policy().effect, + SpecializedEffect::Read + ); + assert_eq!( + PluginOperation::Call.policy().effect, + SpecializedEffect::LocalExecution + ); + assert_eq!( + PluginOperation::Check.policy().effect, + SpecializedEffect::LocalExecution + ); + assert_eq!( + PluginOperation::Reload.policy().effect, + SpecializedEffect::Management + ); + assert_eq!( + PluginOperation::Check.policy().required_scope, + SCOPE_PLUGIN_MANAGE + ); + assert_eq!( + PluginOperation::Call.policy().required_scope, + SCOPE_PLUGIN_INVOKE + ); + } } diff --git a/src/ssh_resource_gateway.rs b/src/ssh_resource_gateway.rs index d8340d572..eee67dfc1 100644 --- a/src/ssh_resource_gateway.rs +++ b/src/ssh_resource_gateway.rs @@ -5,6 +5,7 @@ //! authentication material. use crate::auth::{AuthContext, AuthKind, SCOPE_SSH_LOCAL}; +use crate::tool_runtime::specialized::{SpecializedOperationPolicy, SpecializedSource}; use crate::tool_runtime::ToolRuntime; use serde::Deserialize; use serde_json::{json, Value}; @@ -128,6 +129,58 @@ struct Arguments { default_cwd: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SshResourceOperation { + List, + Register, + Remove, +} + +impl SshResourceOperation { + fn parse(action: &str) -> Option { + match action { + "list" => Some(Self::List), + "register" => Some(Self::Register), + "remove" => Some(Self::Remove), + _ => None, + } + } + + pub(crate) fn policy(self) -> SpecializedOperationPolicy { + match self { + Self::List => SpecializedOperationPolicy::read( + SpecializedSource::SshResource, + "list", + SCOPE_SSH_LOCAL, + ), + Self::Register => SpecializedOperationPolicy::management( + SpecializedSource::SshResource, + "register", + SCOPE_SSH_LOCAL, + false, + ), + Self::Remove => SpecializedOperationPolicy::management( + SpecializedSource::SshResource, + "remove", + SCOPE_SSH_LOCAL, + false, + ), + } + } +} + +pub(crate) fn operation_policy( + arguments: &Value, +) -> Result { + let action = arguments + .get("action") + .and_then(Value::as_str) + .ok_or("ssh_resource action is required")?; + SshResourceOperation::parse(action) + .map(SshResourceOperation::policy) + .ok_or("ssh_resource action must be one of list, register, or remove") +} + #[derive(Debug, Clone)] struct ResolvedRunner { client_id: String, @@ -139,6 +192,7 @@ struct GatewayError { code: &'static str, message: &'static str, recovery: Option<&'static str>, + dispatch_state: &'static str, } impl GatewayError { @@ -147,6 +201,7 @@ impl GatewayError { code, message, recovery: None, + dispatch_state: "not_started", } } @@ -154,6 +209,16 @@ impl GatewayError { self.recovery = Some(recovery); self } + + fn completed(mut self) -> Self { + self.dispatch_state = "completed"; + self + } + + fn outcome_unknown(mut self) -> Self { + self.dispatch_state = "outcome_unknown"; + self + } } pub(crate) fn authorized(auth: Option<&AuthContext>) -> bool { @@ -197,6 +262,11 @@ pub(crate) fn tool_spec() -> Value { "minLength": 1, "maxLength": SSH_RESOURCE_DEFAULT_CWD_MAX_BYTES, "description": "Optional remote default cwd for register." + }, + "recording_session_id": { + "type": "string", + "pattern": "^wc_sess_[A-Za-z0-9_]+$", + "description": "Optional explicit Workflow Session used for authority, read-only/guard, permission, and audit governance. It is never inferred from MCP transport identity." } }, "required": ["action"], @@ -225,12 +295,6 @@ pub(crate) async fn call( arguments: Value, auth: Option<&AuthContext>, ) -> Value { - if !authorized(auth) { - return error_result(GatewayError::new( - "insufficient_scope", - "Runner-local SSH resource management requires the ssh:local scope", - )); - } let parsed: Arguments = match serde_json::from_value(arguments) { Ok(parsed) => parsed, Err(_) => { @@ -240,14 +304,22 @@ pub(crate) async fn call( )) } }; - let result = match parsed.action.as_str() { - "list" => list(runtime, parsed, auth).await, - "register" => register(runtime, parsed, auth).await, - "remove" => remove(runtime, parsed, auth).await, - _ => Err(GatewayError::new( + let Some(operation) = SshResourceOperation::parse(&parsed.action) else { + return error_result(GatewayError::new( "ssh_resource_invalid", "action must be one of list, register, or remove", - )), + )); + }; + if !authorized(auth) { + return error_result(GatewayError::new( + "insufficient_scope", + "Runner-local SSH resource access requires the ssh:local scope", + )); + } + let result = match operation { + SshResourceOperation::List => list(runtime, parsed, auth).await, + SshResourceOperation::Register => register(runtime, parsed, auth).await, + SshResourceOperation::Remove => remove(runtime, parsed, auth).await, }; match result { Ok(value) => success_result(value), @@ -541,6 +613,7 @@ async fn execute_exact( "ssh_resource_outcome_unknown", "managed SSH resource request may have reached the Runner; list resources before any retry", ) + .outcome_unknown() .recovery("List SSH resources again before attempting another mutation.") }); } @@ -586,12 +659,14 @@ fn invalid_runner_response_error(request: &SshResourceRequest) -> GatewayError { "ssh_resource_outcome_unknown", "managed SSH resource request reached the Runner but no valid correlated response was returned", ) + .outcome_unknown() .recovery("List SSH resources again before attempting another mutation.") } else { GatewayError::new( "ssh_resource_registry_unavailable", "Runner returned an invalid managed SSH resource response", ) + .completed() } } @@ -642,12 +717,18 @@ fn response_to_error(response: SshResourceResponse) -> Result Err(GatewayError::new( "ssh_resource_registry_unavailable", "Runner returned an unexpected managed SSH resource response", - )), + ) + .completed()), } } @@ -685,7 +766,10 @@ fn success_result(value: Value) -> Value { } fn error_result(error: GatewayError) -> Value { - let mut structured = json!({"error": {"code": error.code, "message": error.message}}); + let mut structured = json!({ + "error": {"code": error.code, "message": error.message}, + "dispatchState": error.dispatch_state, + }); if let Some(recovery) = error.recovery { structured["recovery"] = Value::String(recovery.to_string()); } @@ -719,4 +803,22 @@ mod tests { assert_eq!(audit["target_present"], true); assert_eq!(audit["default_cwd_present"], true); } + + #[test] + fn operation_policy_keeps_list_read_like_and_mutations_management_like() { + use crate::tool_runtime::specialized::SpecializedEffect; + + assert_eq!( + SshResourceOperation::List.policy().effect, + SpecializedEffect::Read + ); + assert_eq!( + SshResourceOperation::Register.policy().effect, + SpecializedEffect::Management + ); + assert_eq!( + SshResourceOperation::Remove.policy().effect, + SpecializedEffect::Management + ); + } } diff --git a/src/tool_runtime/mod.rs b/src/tool_runtime/mod.rs index b37dd0040..2c5318aa7 100644 --- a/src/tool_runtime/mod.rs +++ b/src/tool_runtime/mod.rs @@ -68,6 +68,7 @@ pub(crate) mod sessions; mod shell; mod shell_tools; pub(crate) mod skills; +pub(crate) mod specialized; pub(crate) mod startup_brief; mod structured_execution; mod surface; @@ -98,11 +99,15 @@ pub(crate) use files::{ }; pub(crate) use patch::MAX_UNIFIED_DIFF_BYTES; #[cfg(test)] +pub(crate) use permissions::{AuthorityMode, PermissionEvaluator}; +#[cfg(test)] pub(crate) use runner_authorization::required_runner_capability; pub use runtime::ToolRuntime; pub use runtime_info::RuntimeInfo; #[cfg(test)] pub(crate) use session_context::workflow_session_authority_fingerprint; +#[cfg(test)] +pub(crate) use sessions::{SessionCreateOptions, SessionGuards, SessionSummary}; pub use tool_call::{ ObserveJobsItem, ReadFilesItem, SearchPatternMode, SearchProjectTextsQuery, SearchResultMode, ToolCall, diff --git a/src/tool_runtime/permissions/evaluator.rs b/src/tool_runtime/permissions/evaluator.rs index cc546629d..11efb7865 100644 --- a/src/tool_runtime/permissions/evaluator.rs +++ b/src/tool_runtime/permissions/evaluator.rs @@ -79,19 +79,30 @@ impl PermissionEvaluator { tool_name: &str, project: Option<&str>, ) -> Option { - if let Some(counter) = self.eval_count.as_ref() { - counter.fetch_add(1, Ordering::SeqCst); - } if !tool_requires_permission(tool_name) { + if let Some(counter) = self.eval_count.as_ref() { + counter.fetch_add(1, Ordering::SeqCst); + } return None; } let risk = classify_tool_risk(tool_name); - Some(decide_for_required_tool( - &self.config, - tool_name, - project, - risk, - )) + Some(self.evaluate_resolved_required(tool_name, project, risk)) + } + + /// Evaluate a consequential operation whose risk/effect classification was + /// authoritatively resolved outside the static ToolDefinition catalog. + /// Specialized gateways use this entry after their concrete operation is + /// known; annotations and provider metadata never select the policy. + pub(crate) fn evaluate_resolved_required( + &self, + tool_name: &str, + project: Option<&str>, + risk: &str, + ) -> PermissionDecision { + if let Some(counter) = self.eval_count.as_ref() { + counter.fetch_add(1, Ordering::SeqCst); + } + decide_for_required_tool(&self.config, tool_name, project, risk) } } diff --git a/src/tool_runtime/specialized.rs b/src/tool_runtime/specialized.rs new file mode 100644 index 000000000..b23313490 --- /dev/null +++ b/src/tool_runtime/specialized.rs @@ -0,0 +1,507 @@ +//! Server-owned governance for specialized operations that intentionally do not +//! enter the static ToolDefinition / ToolCall catalog. +//! +//! Plugin and managed SSH gateways keep their native execution protocols. This +//! layer owns only the authority facts that must be resolved before any effect: +//! exact scope, Workflow Session authority/guards, permission policy, and a +//! bounded ledger lifecycle. + +use serde_json::{json, Value}; + +use super::permissions::{ + add_permission_to_result, permission_execution_denied_result, PermissionDecision, +}; +use super::session_context::{session_guard_denied_result, session_lifecycle_denied_result}; +use super::sessions::{ + SessionPathHint, SessionToolContract, SessionTransport, ToolCallRecorderMetadata, ToolCallStart, +}; +use super::{ToolResult, ToolRuntime}; +use crate::auth::AuthContext; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SpecializedSource { + Plugin, + SshResource, +} + +impl SpecializedSource { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Plugin => "plugin", + Self::SshResource => "ssh-resource", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SpecializedEffect { + Read, + LocalExecution, + Management, +} + +impl SpecializedEffect { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Read => "read", + Self::LocalExecution => "local_execution", + Self::Management => "management", + } + } + + fn consequential(self) -> bool { + !matches!(self, Self::Read) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SpecializedOperationPolicy { + pub(crate) source: SpecializedSource, + pub(crate) operation: &'static str, + pub(crate) required_scope: &'static str, + pub(crate) effect: SpecializedEffect, + pub(crate) risk: &'static str, + /// Management operations which mutate durable/local state are write-like. + pub(crate) write_like: bool, + /// Operations that start or invoke local executable code are shell-like for + /// Workflow Session guard purposes, irrespective of provider annotations. + pub(crate) shell_like: bool, +} + +impl SpecializedOperationPolicy { + pub(crate) fn read( + source: SpecializedSource, + operation: &'static str, + required_scope: &'static str, + ) -> Self { + Self { + source, + operation, + required_scope, + effect: SpecializedEffect::Read, + risk: "specialized_read", + write_like: false, + shell_like: false, + } + } + + pub(crate) fn local_execution( + source: SpecializedSource, + operation: &'static str, + required_scope: &'static str, + ) -> Self { + Self { + source, + operation, + required_scope, + effect: SpecializedEffect::LocalExecution, + risk: "specialized_local_execution", + write_like: false, + shell_like: true, + } + } + + pub(crate) fn management( + source: SpecializedSource, + operation: &'static str, + required_scope: &'static str, + shell_like: bool, + ) -> Self { + Self { + source, + operation, + required_scope, + effect: SpecializedEffect::Management, + risk: "specialized_management", + write_like: true, + shell_like, + } + } + + fn session_contract(self) -> SessionToolContract { + SessionToolContract { + risk_class: self.risk, + read_like: self.effect == SpecializedEffect::Read, + write_like: self.write_like, + shell_like: self.shell_like, + git_like: false, + change_summary_like: false, + project_write: false, + path_hint: SessionPathHint::None, + accepts_context_ack: false, + advances_context_checkpoint: false, + } + } + + pub(crate) fn audit_projection(self) -> Value { + json!({ + "source": self.source.as_str(), + "operation": self.operation, + "effect": self.effect.as_str(), + "risk": self.risk, + "required_scope": self.required_scope, + "permission_required": self.effect.consequential(), + }) + } +} + +#[derive(Debug)] +pub(crate) enum SpecializedGovernanceDenial { + Scope { + required_scope: &'static str, + description: String, + }, + Tool(ToolResult), +} + +#[derive(Debug)] +pub(crate) struct SpecializedInvocationPermit { + policy: SpecializedOperationPolicy, + session_start: Option, + permission: Option, +} + +impl SpecializedInvocationPermit { + pub(crate) fn audit_projection(&self) -> Value { + let mut audit = self.policy.audit_projection(); + audit["decision"] = match self.permission.as_ref() { + Some(permission) => json!({ + "required": permission.required, + "status": permission.status, + "policy": permission.policy, + "reason": permission.reason, + }), + None => json!({"required": false, "status": "not_required"}), + }; + audit["dispatch_certainty"] = Value::String("not_started".to_string()); + audit + } +} + +fn bounded_ledger_arguments(policy: SpecializedOperationPolicy, identity: &Value) -> Value { + json!({ + "specialized": policy.audit_projection(), + "identity": identity, + }) +} + +fn denial_terminal_projection(policy: SpecializedOperationPolicy, kind: &str) -> Value { + json!({ + "specialized": policy.audit_projection(), + "dispatch_certainty": "not_started", + "success": false, + "failure_kind": kind, + }) +} + +impl ToolRuntime { + /// Resolve the shared authority boundary for one already-classified + /// specialized operation. A returned permit is the only path to effectful + /// gateway execution from MCP. + pub(crate) async fn govern_specialized_invocation( + &self, + external_tool_name: &str, + policy: SpecializedOperationPolicy, + recording_session_id: Option<&str>, + auth: Option<&AuthContext>, + identity: &Value, + ) -> Result { + if !auth.is_some_and(|auth| auth.has_scope(policy.required_scope)) { + return Err(SpecializedGovernanceDenial::Scope { + required_scope: policy.required_scope, + description: format!( + "{} operation '{}' requires the {} scope", + policy.source.as_str(), + policy.operation, + policy.required_scope + ), + }); + } + + let mut resolved_session_project = None; + if let Some(session_id) = recording_session_id { + match self + .authorize_session_target(session_id, external_tool_name, auth) + .await + { + Ok(resolved) => { + resolved_session_project = resolved.map(|project| project.resolved_id); + } + Err(mut result) => { + result.output["dispatch_certainty"] = Value::String("not_started".to_string()); + return Err(SpecializedGovernanceDenial::Tool(result)); + } + } + } + + let contract = policy.session_contract(); + let recorder_metadata = ToolCallRecorderMetadata { + recording_session_id: recording_session_id.map(str::to_string), + recording_session_project: resolved_session_project.clone(), + recording_session_authorized: recording_session_id.is_some(), + ..Default::default() + }; + let mut session_start = self.sessions.record_tool_call_started_with_metadata( + recording_session_id, + SessionTransport::Mcp, + external_tool_name, + &bounded_ledger_arguments(policy, identity), + resolved_session_project.clone(), + recorder_metadata, + contract, + ); + + if let Some(session_id) = recording_session_id { + if let Some(denial) = + self.sessions + .lifecycle_denial(session_id, external_tool_name, contract) + { + let mut result = + session_lifecycle_denied_result(session_id, external_tool_name, denial); + result.output["dispatch_certainty"] = Value::String("not_started".to_string()); + self.sessions.record_model_facing_tool_call_finished( + session_start, + false, + &denial_terminal_projection(policy, "session_lifecycle_denied"), + result.error.as_deref(), + Some("session_lifecycle_denied"), + ); + return Err(SpecializedGovernanceDenial::Tool(result)); + } + if let Some(denial) = self.sessions.guard_denial(session_id, contract) { + let mut result = + session_guard_denied_result(session_id, external_tool_name, denial); + result.output["dispatch_certainty"] = Value::String("not_started".to_string()); + self.sessions.record_model_facing_tool_call_finished( + session_start, + false, + &denial_terminal_projection(policy, "session_guard_denied"), + result.error.as_deref(), + Some("session_guard_denied"), + ); + return Err(SpecializedGovernanceDenial::Tool(result)); + } + } + + let permission = if policy.effect.consequential() { + let decision = self.permission_evaluator.evaluate_resolved_required( + external_tool_name, + resolved_session_project.as_deref(), + policy.risk, + ); + if let Some(start) = session_start.as_mut() { + self.sessions + .record_permission_decision(start, decision.clone()); + } + if decision.status == "denied" || decision.status == "pending" { + let mut result = permission_execution_denied_result(&decision); + add_permission_to_result(&mut result, &decision); + result.output["dispatch_certainty"] = Value::String("not_started".to_string()); + self.sessions.record_model_facing_tool_call_finished( + session_start, + false, + &denial_terminal_projection(policy, "permission_denied"), + result.error.as_deref(), + Some("permission_denied"), + ); + return Err(SpecializedGovernanceDenial::Tool(result)); + } + Some(decision) + } else { + None + }; + + Ok(SpecializedInvocationPermit { + policy, + session_start, + permission, + }) + } + + /// Close the authoritative specialized ledger lifecycle with only bounded + /// terminal facts. Provider output and raw arguments never enter Session + /// evidence here. + pub(crate) fn finish_specialized_invocation( + &self, + permit: SpecializedInvocationPermit, + success: bool, + dispatch_certainty: &str, + failure_kind: Option<&str>, + ) { + let terminal = json!({ + "specialized": permit.policy.audit_projection(), + "dispatch_certainty": dispatch_certainty, + "success": success, + "failure_kind": failure_kind, + "permission_status": permit.permission.as_ref().map(|decision| decision.status.as_str()), + }); + self.sessions.record_model_facing_tool_call_finished( + permit.session_start, + success, + &terminal, + (!success).then_some("specialized operation failed"), + failure_kind, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::{AuthContext, AuthKind, SCOPE_PLUGIN_INSPECT, SCOPE_PLUGIN_INVOKE}; + use crate::tool_runtime::permissions::{AuthorityMode, PermissionEvaluator}; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + + fn auth(owner: &str, scopes: &[&str]) -> AuthContext { + let mut auth = AuthContext::new(AuthKind::ApiToken); + auth.user_id = Some(format!("user-{owner}")); + auth.username = Some(owner.to_string()); + auth.api_key_id = Some(format!("key-{owner}")); + auth.scopes + .extend(scopes.iter().map(|scope| (*scope).to_string())); + auth + } + + fn session( + runtime: &ToolRuntime, + owner: &AuthContext, + mode: crate::tool_runtime::SessionMode, + ) -> crate::tool_runtime::sessions::SessionSummary { + let fingerprint = crate::tool_runtime::workflow_session_authority_fingerprint(Some(owner)) + .expect("test authority fingerprint"); + runtime + .sessions + .start_session_with_options( + crate::tool_runtime::sessions::SessionCreateOptions::new( + None, + Some("specialized governance".to_string()), + mode, + crate::tool_runtime::sessions::SessionGuards::default(), + ) + .with_owner_authority_fingerprint(Some(fingerprint)), + ) + .unwrap() + } + + #[tokio::test] + async fn specialized_read_only_session_allows_read_and_denies_local_execution() { + let runtime = ToolRuntime::new_for_tests(); + let auth = auth("alice", &[SCOPE_PLUGIN_INSPECT, SCOPE_PLUGIN_INVOKE]); + let session = session(&runtime, &auth, crate::tool_runtime::SessionMode::ReadOnly); + + let read = runtime + .govern_specialized_invocation( + "plugin_tool", + SpecializedOperationPolicy::read( + SpecializedSource::Plugin, + "list", + SCOPE_PLUGIN_INSPECT, + ), + Some(&session.session_id), + Some(&auth), + &json!({"plugin": "repo-tools"}), + ) + .await + .expect("read-like Plugin inspection remains allowed"); + runtime.finish_specialized_invocation(read, true, "completed", None); + + let denied = runtime + .govern_specialized_invocation( + "plugin_tool", + SpecializedOperationPolicy::local_execution( + SpecializedSource::Plugin, + "call", + SCOPE_PLUGIN_INVOKE, + ), + Some(&session.session_id), + Some(&auth), + &json!({"plugin": "repo-tools"}), + ) + .await + .expect_err("read-only Session must deny local Plugin execution"); + let SpecializedGovernanceDenial::Tool(result) = denied else { + panic!("expected Session guard denial"); + }; + assert_eq!(result.output["error_kind"], "session_guard_denied"); + assert_eq!(result.output["dispatch_certainty"], "not_started"); + } + + #[tokio::test] + async fn specialized_permission_evaluator_denies_effect_before_dispatch_but_skips_reads() { + let counter = Arc::new(AtomicUsize::new(0)); + let runtime = ToolRuntime::new_for_tests().with_permission_evaluator( + PermissionEvaluator::with_mode(AuthorityMode::Restricted) + .with_eval_counter(counter.clone()), + ); + let auth = auth("alice", &[SCOPE_PLUGIN_INSPECT, SCOPE_PLUGIN_INVOKE]); + + let read = runtime + .govern_specialized_invocation( + "plugin_tool", + SpecializedOperationPolicy::read( + SpecializedSource::Plugin, + "list", + SCOPE_PLUGIN_INSPECT, + ), + None, + Some(&auth), + &json!({}), + ) + .await + .expect("read-like operation skips permission approval"); + runtime.finish_specialized_invocation(read, true, "completed", None); + assert_eq!(counter.load(Ordering::SeqCst), 0); + + let denied = runtime + .govern_specialized_invocation( + "plugin_tool", + SpecializedOperationPolicy::local_execution( + SpecializedSource::Plugin, + "call", + SCOPE_PLUGIN_INVOKE, + ), + None, + Some(&auth), + &json!({}), + ) + .await + .expect_err("restricted authority must deny specialized local execution"); + let SpecializedGovernanceDenial::Tool(result) = denied else { + panic!("expected permission denial"); + }; + assert_eq!(counter.load(Ordering::SeqCst), 1); + assert_eq!(result.output["failure_kind"], "permission_denied"); + assert_eq!(result.output["dispatch_certainty"], "not_started"); + } + + #[tokio::test] + async fn specialized_recording_session_authority_is_exact_and_fail_closed() { + let runtime = ToolRuntime::new_for_tests(); + let owner = auth("alice", &[SCOPE_PLUGIN_INSPECT]); + let foreign = auth("bob", &[SCOPE_PLUGIN_INSPECT]); + let session = session(&runtime, &owner, crate::tool_runtime::SessionMode::Normal); + + let denied = runtime + .govern_specialized_invocation( + "plugin_tool", + SpecializedOperationPolicy::read( + SpecializedSource::Plugin, + "list", + SCOPE_PLUGIN_INSPECT, + ), + Some(&session.session_id), + Some(&foreign), + &json!({}), + ) + .await + .expect_err("foreign authority must not attach to exact recording Session"); + let SpecializedGovernanceDenial::Tool(result) = denied else { + panic!("expected exact Session authority denial"); + }; + assert_eq!(result.output["failure_kind"], "session_authority_denied"); + assert_eq!(result.output["dispatch_certainty"], "not_started"); + } +} From 92aeab68eb838b71a6102ecebc886a6b9a1931cc Mon Sep 17 00:00:00 2001 From: yyjeqhc <1772413353@qq.com> Date: Sat, 5 Sep 2026 16:02:52 +0800 Subject: [PATCH 2/2] Classify Plugin check as management --- src/plugin_gateway.rs | 11 +++++++++-- src/ssh_resource_gateway.rs | 6 ++++++ src/tool_runtime/specialized.rs | 3 ++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/plugin_gateway.rs b/src/plugin_gateway.rs index 529ab3a01..27476e5ab 100644 --- a/src/plugin_gateway.rs +++ b/src/plugin_gateway.rs @@ -196,16 +196,19 @@ impl PluginOperation { "call", SCOPE_PLUGIN_INVOKE, ), - Self::Check => SpecializedOperationPolicy::local_execution( + Self::Check => SpecializedOperationPolicy::management( SpecializedSource::Plugin, "check", SCOPE_PLUGIN_MANAGE, + false, + true, ), Self::Reload => SpecializedOperationPolicy::management( SpecializedSource::Plugin, "reload", SCOPE_PLUGIN_MANAGE, true, + true, ), } } @@ -1435,7 +1438,7 @@ mod tests { ); assert_eq!( PluginOperation::Check.policy().effect, - SpecializedEffect::LocalExecution + SpecializedEffect::Management ); assert_eq!( PluginOperation::Reload.policy().effect, @@ -1445,6 +1448,10 @@ mod tests { PluginOperation::Check.policy().required_scope, SCOPE_PLUGIN_MANAGE ); + assert!(!PluginOperation::Check.policy().write_like); + assert!(PluginOperation::Check.policy().shell_like); + assert!(PluginOperation::Reload.policy().write_like); + assert!(PluginOperation::Reload.policy().shell_like); assert_eq!( PluginOperation::Call.policy().required_scope, SCOPE_PLUGIN_INVOKE diff --git a/src/ssh_resource_gateway.rs b/src/ssh_resource_gateway.rs index eee67dfc1..b066bd758 100644 --- a/src/ssh_resource_gateway.rs +++ b/src/ssh_resource_gateway.rs @@ -157,12 +157,14 @@ impl SshResourceOperation { SpecializedSource::SshResource, "register", SCOPE_SSH_LOCAL, + true, false, ), Self::Remove => SpecializedOperationPolicy::management( SpecializedSource::SshResource, "remove", SCOPE_SSH_LOCAL, + true, false, ), } @@ -820,5 +822,9 @@ mod tests { SshResourceOperation::Remove.policy().effect, SpecializedEffect::Management ); + assert!(SshResourceOperation::Register.policy().write_like); + assert!(!SshResourceOperation::Register.policy().shell_like); + assert!(SshResourceOperation::Remove.policy().write_like); + assert!(!SshResourceOperation::Remove.policy().shell_like); } } diff --git a/src/tool_runtime/specialized.rs b/src/tool_runtime/specialized.rs index b23313490..f1676663d 100644 --- a/src/tool_runtime/specialized.rs +++ b/src/tool_runtime/specialized.rs @@ -105,6 +105,7 @@ impl SpecializedOperationPolicy { source: SpecializedSource, operation: &'static str, required_scope: &'static str, + write_like: bool, shell_like: bool, ) -> Self { Self { @@ -113,7 +114,7 @@ impl SpecializedOperationPolicy { required_scope, effect: SpecializedEffect::Management, risk: "specialized_management", - write_like: true, + write_like, shell_like, } }