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:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-22 18:40:31 -07:00
1 parent a518b2f9b4
commit b90590369a
7 files changed
+194 -6

No files matched your search

+9 -4
View File
@@ -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"),
}
}
+33 -2
View File
@@ -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)
+99
View File
@@ -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() {
+45
View File
@@ -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);
}
+1
View File
@@ -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(),
+1
View File
@@ -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(),
+6
View File
@@ -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