mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 10:36:05 -10:00
feat(approvals): honour readOnlyHint from reviewed plugins (CW-11)
Every MCP tool outside the five built-in resource helpers prepared as Suggest, so each Chromewhale page_snapshot and Computer Use screenshot prompted, while session auto-approve let every tool through, destructive or not. The MCP tool annotations the servers already declare were dropped at parse time. - McpTool now parses the MCP `annotations` object (readOnlyHint, destructiveHint). - McpPool::to_api_tools, which builds the catalog each turn, records one approval hint per model tool name: TrustedReadOnly only when the server is a reviewed plugin (reviewed_plugin provenance, catalog still authorized) and the tool declares readOnlyHint true without destructiveHint; the same claim from any other server is ignored. Destructive for any tool that declares destructiveHint true. A server that loses its review or a hint loses the relaxation on the next catalog build. - prepare_tool_call treats a TrustedReadOnly tool like the built-in read helpers (Auto, read_only), and never lets session auto-approve cover a Destructive one. The approval card text says which applies. - Computer Use consent and app_script gates still run first and are unchanged: request_access declares readOnlyHint but consent calls keep their own required card. Known limitation (in mcp.rs): the hint map is process-wide, keyed by model tool name, because call preparation has no pool handle; two pools exposing the same model name from different servers overwrite each other. Plugin servers carry synthesized plugin-* names, so this needs a user server named like a plugin server. docs/MODES.md states the rule. No-Issue: 0.10.1 addendum C5 (CW-11) Checks (targeted, local, shared dirty tree): - cargo test -p codewhale-tui --lib -- only_a_reviewed_plugin mcp_annotation_hints mcp_write_preparation: 3 passed, 0 failed - cargo test -p codewhale-tui --lib -- mcp::tests tool_preparation (within the 448-test touched-module run): 0 failed Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
a518b2f9b4
commit
b90590369a
7 files changed
+194
-6
No files matched your search
@@ -873,10 +873,15 @@ pub(super) fn mcp_tool_approval_description(name: &str, input: &serde_json::Valu
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if mcp_tool_is_read_only(name) {
|
||||
format!("Read-only MCP tool '{name}'")
|
||||
} else {
|
||||
format!("MCP tool '{name}' may have side effects")
|
||||
match crate::mcp::mcp_tool_approval_hint(name) {
|
||||
_ if mcp_tool_is_read_only(name) => format!("Read-only MCP tool '{name}'"),
|
||||
Some(crate::mcp::McpToolApprovalHint::TrustedReadOnly) => {
|
||||
format!("Read-only MCP tool '{name}' (declared by a reviewed plugin)")
|
||||
}
|
||||
Some(crate::mcp::McpToolApprovalHint::Destructive) => {
|
||||
format!("MCP tool '{name}' is marked destructive by its server")
|
||||
}
|
||||
None => format!("MCP tool '{name}' may have side effects"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,13 @@ pub(super) fn prepare_tool_call(
|
||||
session_auto_approve: bool,
|
||||
) -> Result<PreparedToolPolicy, ToolError> {
|
||||
if McpPool::is_mcp_tool(name) {
|
||||
let read_only = mcp_tool_is_read_only(name);
|
||||
// CW-11: a reviewed plugin's `readOnlyHint` makes its tool run like
|
||||
// the built-in resource reads; a declared `destructiveHint` keeps the
|
||||
// prompt even when the session auto-approves tools.
|
||||
let hint = crate::mcp::mcp_tool_approval_hint(name);
|
||||
let read_only = mcp_tool_is_read_only(name)
|
||||
|| hint == Some(crate::mcp::McpToolApprovalHint::TrustedReadOnly);
|
||||
let destructive = hint == Some(crate::mcp::McpToolApprovalHint::Destructive);
|
||||
if !read_only
|
||||
&& let Some(authority) =
|
||||
registry.and_then(|registry| registry.context().tool_authority.as_ref())
|
||||
@@ -107,7 +113,7 @@ pub(super) fn prepare_tool_call(
|
||||
},
|
||||
resources: vec![ResourceClaim::GlobalExclusive],
|
||||
},
|
||||
auto_approve: session_auto_approve,
|
||||
auto_approve: session_auto_approve && !destructive,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -518,6 +524,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_annotation_hints_drive_approval() {
|
||||
use crate::mcp::{McpToolApprovalHint, set_mcp_tool_approval_hint_for_test};
|
||||
|
||||
let read_only = "mcp_plugin-9-cw11test_page_snapshot";
|
||||
set_mcp_tool_approval_hint_for_test(read_only, Some(McpToolApprovalHint::TrustedReadOnly));
|
||||
let prepared = prepare_tool_call(read_only, json!({}), None, false)
|
||||
.expect("prepare trusted read-only MCP tool");
|
||||
assert_eq!(prepared.call.approval, ApprovalRequirement::Auto);
|
||||
assert!(prepared.call.read_only);
|
||||
|
||||
let destructive = "mcp_cw11test_drop_table";
|
||||
set_mcp_tool_approval_hint_for_test(destructive, Some(McpToolApprovalHint::Destructive));
|
||||
let prepared = prepare_tool_call(destructive, json!({}), None, true)
|
||||
.expect("prepare destructive MCP tool");
|
||||
assert_eq!(prepared.call.approval, ApprovalRequirement::Suggest);
|
||||
assert!(
|
||||
!prepared.auto_approve,
|
||||
"session auto-approve must not cover a destructive tool"
|
||||
);
|
||||
|
||||
set_mcp_tool_approval_hint_for_test(read_only, None);
|
||||
set_mcp_tool_approval_hint_for_test(destructive, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_write_preparation_respects_session_auto_approval() {
|
||||
let prepared = prepare_tool_call("mcp_filesystem_write", json!({}), None, true)
|
||||
|
||||
@@ -1195,6 +1195,82 @@ pub struct McpTool {
|
||||
pub description: Option<String>,
|
||||
#[serde(rename = "inputSchema", default)]
|
||||
pub input_schema: serde_json::Value,
|
||||
/// Behaviour hints the server declares (MCP `ToolAnnotations`). They are
|
||||
/// claims, not proof: only a reviewed plugin's hints relax approval, and
|
||||
/// only toward what the plugin review already covers (CW-11).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub annotations: Option<McpToolAnnotations>,
|
||||
}
|
||||
|
||||
/// The subset of MCP `ToolAnnotations` the approval path reads.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
pub struct McpToolAnnotations {
|
||||
#[serde(
|
||||
rename = "readOnlyHint",
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub read_only_hint: Option<bool>,
|
||||
#[serde(
|
||||
rename = "destructiveHint",
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub destructive_hint: Option<bool>,
|
||||
}
|
||||
|
||||
/// How the approval path may treat one model-visible MCP tool, from its
|
||||
/// server's declared annotations (CW-11).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum McpToolApprovalHint {
|
||||
/// A reviewed, enabled plugin declares the tool read-only and not
|
||||
/// destructive: it runs without a prompt, like the built-in read tools.
|
||||
TrustedReadOnly,
|
||||
/// The server declares the tool destructive: session-wide auto-approve
|
||||
/// does not cover it, so each call keeps its prompt.
|
||||
Destructive,
|
||||
}
|
||||
|
||||
/// Annotation-derived approval hints for the MCP tools of every live
|
||||
/// catalog, keyed by model tool name. Filled where the catalog is built
|
||||
/// (`McpPool::to_api_tools`, once per turn) and read by the side-effect-free
|
||||
/// call preparation, which has no pool handle.
|
||||
///
|
||||
/// Known limitation: the map is process-wide. Two pools in one process that
|
||||
/// expose the same model tool name from different servers overwrite each
|
||||
/// other's hint; the last catalog built wins. Plugin servers carry
|
||||
/// synthesized `plugin-…` names, so this needs a user server deliberately
|
||||
/// named like a plugin server.
|
||||
static MCP_TOOL_APPROVAL_HINTS: std::sync::LazyLock<RwLock<HashMap<String, McpToolApprovalHint>>> =
|
||||
std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
|
||||
|
||||
/// The approval hint recorded for a model-visible MCP tool name, if any.
|
||||
#[must_use]
|
||||
pub fn mcp_tool_approval_hint(model_tool_name: &str) -> Option<McpToolApprovalHint> {
|
||||
MCP_TOOL_APPROVAL_HINTS.read().get(model_tool_name).copied()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_mcp_tool_approval_hint_for_test(
|
||||
model_tool_name: &str,
|
||||
hint: Option<McpToolApprovalHint>,
|
||||
) {
|
||||
let mut hints = MCP_TOOL_APPROVAL_HINTS.write();
|
||||
match hint {
|
||||
Some(hint) => hints.insert(model_tool_name.to_string(), hint),
|
||||
None => hints.remove(model_tool_name),
|
||||
};
|
||||
}
|
||||
|
||||
fn approval_hint_for(tool: &McpTool, reviewed_plugin: bool) -> Option<McpToolApprovalHint> {
|
||||
let annotations = tool.annotations.unwrap_or_default();
|
||||
// An absent destructiveHint defaults to true in the MCP spec, but only
|
||||
// when readOnlyHint is false; a read-only tool is not destructive.
|
||||
if annotations.destructive_hint == Some(true) {
|
||||
return Some(McpToolApprovalHint::Destructive);
|
||||
}
|
||||
(reviewed_plugin && annotations.read_only_hint == Some(true))
|
||||
.then_some(McpToolApprovalHint::TrustedReadOnly)
|
||||
}
|
||||
|
||||
const MCP_TOOL_DESCRIPTION_MAX_CHARS: usize = 80;
|
||||
@@ -4573,8 +4649,31 @@ impl McpPool {
|
||||
names
|
||||
}
|
||||
|
||||
/// Record the approval hints for this catalog's tools (CW-11). Every
|
||||
/// server this pool lists is rewritten, so a tool whose server lost its
|
||||
/// plugin review, or dropped a hint, loses the relaxation with it.
|
||||
fn record_tool_approval_hints(&self) {
|
||||
let mut hints = MCP_TOOL_APPROVAL_HINTS.write();
|
||||
for (server, conn) in &self.connections {
|
||||
let authorized = self.server_allowed(server) && conn.catalog_authorized();
|
||||
let reviewed_plugin = conn.config().reviewed_plugin.is_some();
|
||||
for tool in conn.tools() {
|
||||
let name = Self::mcp_model_tool_name(server, &tool.name);
|
||||
match approval_hint_for(tool, reviewed_plugin).filter(|_| authorized) {
|
||||
Some(hint) => {
|
||||
hints.insert(name, hint);
|
||||
}
|
||||
None => {
|
||||
hints.remove(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert discovered tools to API Tool format
|
||||
pub fn to_api_tools(&self) -> Vec<codewhale_models::Tool> {
|
||||
self.record_tool_approval_hints();
|
||||
let mut api_tools = Vec::new();
|
||||
// Add regular tools
|
||||
for (name, tool) in self.all_tools() {
|
||||
|
||||
@@ -2031,6 +2031,7 @@ async fn revoked_plugin_mcp_denies_catalog_tool_resource_and_prompt_operations()
|
||||
name: "echo".to_string(),
|
||||
description: None,
|
||||
input_schema: serde_json::json!({}),
|
||||
annotations: None,
|
||||
});
|
||||
connection.resources.push(McpResource {
|
||||
uri: "memory://one".to_string(),
|
||||
@@ -2137,6 +2138,7 @@ fn cached_reviewed_plugin_catalog_fixture() -> (tempfile::TempDir, PathBuf, Path
|
||||
name: "echo".to_string(),
|
||||
description: None,
|
||||
input_schema: serde_json::json!({}),
|
||||
annotations: None,
|
||||
});
|
||||
connection.resources.push(McpResource {
|
||||
uri: "memory://one".to_string(),
|
||||
@@ -3249,6 +3251,7 @@ async fn pool_stops_advertising_a_server_whose_write_side_died() {
|
||||
name: "echo".to_string(),
|
||||
description: None,
|
||||
input_schema: serde_json::json!({"type": "object"}),
|
||||
annotations: None,
|
||||
});
|
||||
pool.connections.insert("mock".to_string(), conn);
|
||||
assert_eq!(pool.connected_servers(), vec!["mock"]);
|
||||
@@ -3311,6 +3314,7 @@ async fn failed_reconnect_restores_last_good_catalog() {
|
||||
name: "echo".to_string(),
|
||||
description: None,
|
||||
input_schema: serde_json::json!({"type": "object"}),
|
||||
annotations: None,
|
||||
});
|
||||
pool.connections.insert("mock".to_string(), conn);
|
||||
|
||||
@@ -4043,6 +4047,7 @@ async fn mcp_pool_call_tool_preserves_tool_names_with_dashes() {
|
||||
name: "company--search".to_string(),
|
||||
description: None,
|
||||
input_schema: serde_json::json!({}),
|
||||
annotations: None,
|
||||
}];
|
||||
|
||||
let mut pool = McpPool::new(McpConfig {
|
||||
@@ -4086,6 +4091,7 @@ async fn mcp_pool_rejects_unadvertised_tool_without_sending_tools_call() {
|
||||
name: "read".to_string(),
|
||||
description: None,
|
||||
input_schema: serde_json::json!({}),
|
||||
annotations: None,
|
||||
}];
|
||||
let mut pool = McpPool::new(McpConfig::default());
|
||||
pool.connections.insert("spy".to_string(), conn);
|
||||
@@ -4165,6 +4171,7 @@ async fn mcp_pool_call_tool_preserves_server_names_with_underscores() {
|
||||
name: "execute_sql".to_string(),
|
||||
description: None,
|
||||
input_schema: serde_json::json!({}),
|
||||
annotations: None,
|
||||
}];
|
||||
|
||||
let mut pool = McpPool::new(McpConfig {
|
||||
@@ -4208,6 +4215,7 @@ async fn mcp_pool_hides_and_rejects_ambiguous_model_tool_names() {
|
||||
name: "db_execute_sql".to_string(),
|
||||
description: None,
|
||||
input_schema: serde_json::json!({}),
|
||||
annotations: None,
|
||||
}];
|
||||
|
||||
let sent_long = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -4225,6 +4233,7 @@ async fn mcp_pool_hides_and_rejects_ambiguous_model_tool_names() {
|
||||
name: "execute_sql".to_string(),
|
||||
description: None,
|
||||
input_schema: serde_json::json!({}),
|
||||
annotations: None,
|
||||
}];
|
||||
|
||||
let mut pool = McpPool::new(McpConfig {
|
||||
@@ -7842,6 +7851,7 @@ fn ceiling_test_connection(name: &str, sent: Arc<Mutex<Vec<serde_json::Value>>>)
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
input_schema: serde_json::json!({}),
|
||||
annotations: None,
|
||||
})
|
||||
.collect();
|
||||
connection.resources = vec![McpResource {
|
||||
@@ -8507,3 +8517,38 @@ fn mcp_transaction_fails_closed_for_malformed_document_and_symlink() {
|
||||
assert!(init_config(&link, true).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_reviewed_plugin_read_only_hint_relaxes_approval() {
|
||||
let tool: McpTool = serde_json::from_value(serde_json::json!({
|
||||
"name": "page_snapshot",
|
||||
"inputSchema": {"type": "object"},
|
||||
"annotations": {"readOnlyHint": true, "destructiveHint": false}
|
||||
}))
|
||||
.expect("annotated tool parses");
|
||||
assert_eq!(
|
||||
approval_hint_for(&tool, true),
|
||||
Some(McpToolApprovalHint::TrustedReadOnly)
|
||||
);
|
||||
// The same claim from a server no plugin review covers is not trusted.
|
||||
assert_eq!(approval_hint_for(&tool, false), None);
|
||||
|
||||
let destructive: McpTool = serde_json::from_value(serde_json::json!({
|
||||
"name": "delete_rows",
|
||||
"annotations": {"readOnlyHint": true, "destructiveHint": true}
|
||||
}))
|
||||
.expect("annotated tool parses");
|
||||
// A tool that claims both keeps its prompt, from any server.
|
||||
assert_eq!(
|
||||
approval_hint_for(&destructive, true),
|
||||
Some(McpToolApprovalHint::Destructive)
|
||||
);
|
||||
assert_eq!(
|
||||
approval_hint_for(&destructive, false),
|
||||
Some(McpToolApprovalHint::Destructive)
|
||||
);
|
||||
|
||||
let bare: McpTool = serde_json::from_value(serde_json::json!({"name": "echo"}))
|
||||
.expect("unannotated tool parses");
|
||||
assert_eq!(approval_hint_for(&bare, true), None);
|
||||
}
|
||||
@@ -1660,6 +1660,7 @@ pub(super) fn mcp_tool_adapter_for_test(name: &str) -> Arc<dyn ToolSpec> {
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
input_schema: serde_json::json!({"type": "object"}),
|
||||
annotations: None,
|
||||
},
|
||||
pool: Arc::new(tokio::sync::Mutex::new(crate::mcp::McpPool::new(
|
||||
crate::mcp::McpConfig::default(),
|
||||
|
||||
@@ -2175,6 +2175,7 @@ fn registration_adapter_origins_are_bounded_and_exclude_execution_payloads() {
|
||||
name: hostile.clone(),
|
||||
description: Some(command.into()),
|
||||
input_schema: json!({"description":schema_payload}),
|
||||
annotations: None,
|
||||
},
|
||||
pool: Arc::new(tokio::sync::Mutex::new(crate::mcp::McpPool::new(
|
||||
crate::mcp::McpConfig::default(),
|
||||
|
||||
@@ -314,6 +314,12 @@ built-in tools. Read-only MCP helpers may auto-run in Ask and Auto-Review when
|
||||
policy permits; MCP tools with possible side effects require approval. Full
|
||||
Access does not bypass hard policy holds.
|
||||
|
||||
A tool's own MCP annotations count only as far as their source is trusted. A
|
||||
tool from a reviewed, enabled plugin that declares `readOnlyHint: true` runs
|
||||
like the built-in read helpers, with no prompt; the same claim from any other
|
||||
server is ignored. A tool that declares `destructiveHint: true` keeps its
|
||||
prompt even when you have approved tools for the rest of the session.
|
||||
|
||||
See `MCP.md`.
|
||||
|
||||
## Related CLI Flags
|
||||
|
||||
Reference in new issue
Block a user