fix(approvals): model cannot grant itself Computer Use consent (K1, K2 engine half)

Stopgap until MCP elicitation exists: a Computer Use consent allow/revoke
(including scope:"foreground") or app_script call needs a human decision.

- approval_cache: computer_use_user_gate classifies those calls; their
  session grant key is the exact call (cu:<tool>:<hash>), not the MCP kind,
  so a grant for app X never covers app Y and an approved script never
  covers a changed one. status/deny keep the ordinary kind grant.
- tool_preparation: gated calls are always Required, never session
  auto-approved, and refused outright in Full Access, Auto-Review and Never
  (postures that cannot show a card). A run_actions batch carrying
  consent_allow/consent_revoke/consent allow/app_script as a step is
  refused in every posture, since the plugin accepts unlisted tool names
  as steps.
- dispatch: the approval card names the app, bundle id, scope and
  lifetime for consent, and language + sha256 + first line for app_script.

Known limit (documented in approval_cache.rs): matched by MCP tool-name
suffix; the decision still travels through a model tool call until
elicitation lands.

Checks (cargo test -p codewhale-tui --lib):
- `computer_use app_script model_issued`: 7 passed; 0 failed
- `tools::approval_cache core::engine::tool_preparation mcp_tool`: 38 passed; 0 failed
- rustfmt --check clean on the three files after formatting.
A later re-run was blocked by another lane's in-flight plugin_cta edits
(E0599 in the tui crate, not these files).

Refs #5856

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 14:50:08 -07:00
1 parent 6dca8a8a61
commit 5b1dcbdb1a
3 files changed
+444 -2

No files matched your search

+52 -1
View File
@@ -806,7 +806,58 @@ pub(super) fn mcp_tool_is_read_only(name: &str) -> bool {
)
}
pub(super) fn mcp_tool_approval_description(name: &str) -> String {
pub(super) fn mcp_tool_approval_description(name: &str, input: &serde_json::Value) -> String {
use crate::tools::approval_cache::{ComputerUseUserGate, computer_use_user_gate};
// K1/K2: a Computer Use consent or script card names exactly what the
// person is granting. Generic "may have side effects" text is how a
// model-issued consent used to read as routine.
match computer_use_user_gate(name, input) {
Some(ComputerUseUserGate::Consent {
action,
app,
bundle_id,
scope,
remember,
}) => {
let target = match scope {
"foreground" => {
"shared-desktop foreground control (take the pointer and focus)".to_string()
}
_ => {
let app = app.as_deref().unwrap_or("<unnamed app>");
match bundle_id.as_deref() {
Some(bundle) => format!("app '{app}' (bundle id {bundle})"),
None => format!("app '{app}' (bundle id not given)"),
}
}
};
let lifetime = if remember {
"persisted until revoked"
} else {
"this session"
};
let verb = if action == "revoke" {
"revoke recorded decisions for"
} else {
"allow"
};
return format!(
"Computer Use consent requested by the model: {verb} {target}; scope: {scope}; {lifetime}. Approve only if you want this."
);
}
Some(ComputerUseUserGate::AppScript {
language,
script_sha256,
first_line,
}) => {
return format!(
"Computer Use app_script: run this exact {language} script outside the sandbox (sha256 {}): {first_line}",
&script_sha256[..16]
);
}
None => {}
}
if mcp_tool_is_read_only(name) {
format!("Read-only MCP tool '{name}'")
} else {
+174 -1
View File
@@ -9,8 +9,11 @@ use std::path::PathBuf;
use serde_json::Value;
use codewhale_execpolicy::ApprovalMode;
use crate::mcp::McpPool;
use crate::tools::ToolRegistry;
use crate::tools::approval_cache::{computer_use_batch_hidden_gate, computer_use_user_gate};
use crate::tools::spec::{ApprovalRequirement, PreparedToolCall, ResourceClaim, ToolError};
use super::dispatch::{
@@ -44,11 +47,56 @@ pub(super) fn prepare_tool_call(
authority.owner
)));
}
// K1/K2 stopgap: Computer Use consent and `app_script` need a human
// decision. Never auto-approve them, and refuse them outright in a
// posture that cannot open the approval card (Full Access,
// Auto-Review, Never) — otherwise the model's own tool call would be
// the consent.
if let Some(inner) = computer_use_batch_hidden_gate(name, &input) {
return Err(ToolError::permission_denied(format!(
"Computer Use {inner} cannot run inside {name}: consent and scripts need their own approval card. Call it on its own so the user can decide."
)));
}
if computer_use_user_gate(name, &input).is_some() {
let posture = registry.map(|registry| {
let context = registry.context();
(context.auto_approve, context.approval_mode)
});
let card_available = !session_auto_approve
&& posture.is_none_or(|(auto_approve, approval_mode)| {
!auto_approve && approval_mode == ApprovalMode::Suggest
});
if !card_available {
let label = posture.map_or("Full Access", |(auto_approve, approval_mode)| {
if auto_approve {
ApprovalMode::Bypass.permission_chip_label()
} else {
approval_mode.permission_chip_label()
}
});
return Err(ToolError::permission_denied(format!(
"Computer Use call {name} needs your own approval: consent and scripts cannot be granted by a model tool call, and the current {label} posture cannot show an approval card. Switch to Ask mode to review it."
)));
}
return Ok(PreparedToolPolicy {
call: PreparedToolCall {
name: name.to_string(),
description: mcp_tool_approval_description(name, &input),
input,
read_only: false,
supports_parallel: false,
starts_detached: false,
approval: ApprovalRequirement::Required,
resources: vec![ResourceClaim::GlobalExclusive],
},
auto_approve: false,
});
}
return Ok(PreparedToolPolicy {
call: PreparedToolCall {
name: name.to_string(),
description: mcp_tool_approval_description(name, &input),
input,
description: mcp_tool_approval_description(name),
read_only,
supports_parallel: mcp_tool_is_parallel_safe(name),
starts_detached: false,
@@ -490,6 +538,131 @@ mod tests {
));
}
/// K1: the model cannot grant itself Computer Use consent. In a posture
/// that cannot show a human card the call is refused at preparation; in
/// Ask it always requires approval, is never session auto-approved, and a
/// session grant for one app does not cover another.
#[test]
fn model_issued_computer_use_consent_is_rejected_without_a_human_card() {
let consent = "mcp_plugin-12-computer-use-computer_consent";
let allow_safari =
json!({"action": "allow", "app": "Safari", "bundle_id": "com.apple.Safari"});
let foreground = json!({"action": "allow", "scope": "foreground"});
// Full Access (session bit, or the registry context) and every
// no-card posture refuse the call before any approval routing.
for (session_auto, context_auto, mode) in [
(true, false, ApprovalMode::Suggest),
(false, true, ApprovalMode::Suggest),
(false, false, ApprovalMode::Bypass),
(false, false, ApprovalMode::Auto),
(false, false, ApprovalMode::Never),
] {
let root = tempdir().expect("tempdir");
let mut context = ToolContext::new(root.path().to_path_buf());
context.auto_approve = context_auto;
context.approval_mode = mode;
let registry = ToolRegistry::new(context);
for (name, input) in [
(consent, allow_safari.clone()),
(consent, foreground.clone()),
(
"mcp_codewhale-cu_consent_revoke",
json!({"app": "Terminal"}),
),
(
"mcp_plugin-12-computer-use-computer_app_script",
json!({"script": "do shell script \"id\""}),
),
] {
let error = prepare_tool_call(name, input.clone(), Some(&registry), session_auto)
.expect_err("model-issued consent must not run without a human");
assert!(
matches!(error, ToolError::PermissionDenied { .. }),
"{name} {mode:?}: {error}"
);
}
}
// No registry: the session bit alone decides.
assert!(prepare_tool_call(consent, allow_safari.clone(), None, true).is_err());
// Ask posture: a Required card that names the app, bundle and scope.
let root = tempdir().expect("tempdir");
let registry = ToolRegistry::new(ToolContext::new(root.path().to_path_buf()));
let prepared = prepare_tool_call(consent, allow_safari.clone(), Some(&registry), false)
.expect("Ask posture opens a card");
assert_eq!(prepared.call.approval, ApprovalRequirement::Required);
assert!(!prepared.auto_approve);
assert!(!prepared.call.read_only);
assert!(super::super::turn_loop::registered_tool_approval_required(
&prepared.call.name,
prepared.call.approval,
prepared.auto_approve,
));
let description = &prepared.call.description;
assert!(description.contains("Safari"), "{description}");
assert!(description.contains("com.apple.Safari"), "{description}");
assert!(description.contains("scope: app"), "{description}");
let foreground_card = prepare_tool_call(consent, foreground, Some(&registry), false)
.expect("foreground card");
assert!(
foreground_card
.call
.description
.contains("scope: foreground")
);
// After a session grant for Safari, a consent for Terminal still
// prompts: the grant key is the exact call, not the MCP kind.
let granted =
crate::tools::approval_cache::build_approval_grouping_key(consent, &allow_safari);
let terminal = crate::tools::approval_cache::build_approval_grouping_key(
consent,
&json!({"action": "allow", "app": "Terminal", "bundle_id": "com.apple.Terminal"}),
);
assert_ne!(granted, terminal);
// K1: run_actions cannot smuggle a consent grant or a script past
// the per-call card, in any posture (Ask included).
let batch = "mcp_plugin-12-computer-use-computer_run_actions";
for (step, session_auto) in [
(
json!({"tool": "consent_allow", "arguments": {"app": "Terminal"}}),
false,
),
(
json!({"tool": "consent", "arguments": {"action": "allow", "scope": "foreground"}}),
false,
),
(
json!({"tool": "consent_revoke", "arguments": {"app": "Terminal"}}),
true,
),
(
json!({"tool": "app_script", "arguments": {"script": "do shell script \"id\""}}),
false,
),
] {
let input = json!({"steps": [{"tool": "click", "arguments": {"x": 1, "y": 1}}, step]});
let error = prepare_tool_call(batch, input, Some(&registry), session_auto)
.expect_err("a batched consent or script must be refused");
assert!(
matches!(error, ToolError::PermissionDenied { .. }),
"{error}"
);
}
let plain_batch = json!({"steps": [
{"tool": "click", "arguments": {"x": 1, "y": 1}},
{"tool": "consent", "arguments": {"action": "status"}},
]});
assert!(prepare_tool_call(batch, plain_batch, Some(&registry), false).is_ok());
// Reading the ledger is unaffected.
let status = prepare_tool_call(consent, json!({"action": "status"}), Some(&registry), true)
.expect("status is not gated");
assert!(status.auto_approve);
}
#[test]
fn hook_rewrite_reprepares_resource_claims_from_final_input() {
let root = tempdir().expect("tempdir");
+218
View File
@@ -29,8 +29,28 @@
//! | `apply_patch` | `patch:<hash of file paths>` |
//! | shell tools | `shell:<command prefix>` |
//! | `fetch_url` | `net:<hostname>` |
//! | Computer Use consent / `app_script` | `cu:<tool_name>:<hash of input>` |
//! | other MCP tools| `mcp:<tool_name>` (the reviewed kind) |
//! | everything else| `tool:<tool_name>:<hash of input>` |
//!
//! ## Computer Use calls that need a human (K1 / K2)
//!
//! [`computer_use_user_gate`] names the Computer Use calls whose approval must
//! come from a person: granting or revoking per-app consent (which includes the
//! shared-pointer `scope: "foreground"` decision) and `app_script`, an
//! unsandboxed osascript. Those calls are never covered by the MCP kind grant:
//! their session grant is the exact call, so allowing app X never allows app Y
//! and approving one script never approves a changed one. Engine preparation
//! also refuses them in any posture that cannot open a human approval card,
//! and refuses a `run_actions` batch that carries one as a step
//! ([`computer_use_batch_hidden_gate`]).
//!
//! Known limits of this stopgap: the calls are matched by MCP tool-name suffix
//! (`_consent`, `_consent_allow`, `_consent_revoke`, `_app_script`), so a
//! different MCP server exposing a tool with one of those names is gated the
//! same way (fail closed). The consent decision still travels through a model
//! tool call; MCP elicitation, where the plugin asks the host for the user's
//! answer directly, is the real fix and is not built.
use std::fmt::Write as _;
use serde_json::Value;
@@ -107,12 +127,133 @@ pub fn build_approval_grouping_key(tool_name: &str, input: &serde_json::Value) -
// narrow the granted kind into a one-call grant (the regression the
// plugin e2e acceptance catches). Shell keeps its command-family
// key (R2); this arm never widens shell or file tools.
//
// Computer Use consent and `app_script` are the exception (K1/K2):
// a kind grant there would let one approval cover every app or every
// script, so their session grant is the exact call.
name if computer_use_user_gate(name, input).is_some() => {
format!("cu:{name}:{}", hash_json_value(input))
}
name if crate::mcp::McpPool::is_mcp_tool(name) => format!("mcp:{name}"),
_ => format!("tool:{tool_name}:{}", hash_json_value(input)),
};
ApprovalKey(fingerprint)
}
/// A Computer Use call whose approval must come from a person (K1 / K2).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ComputerUseUserGate {
/// A consent ledger write that widens what the model may do: `allow`, or
/// `revoke` (which can clear a persisted deny).
Consent {
action: &'static str,
app: Option<String>,
bundle_id: Option<String>,
scope: &'static str,
remember: bool,
},
/// `app_script`: arbitrary AppleScript/JXA through osascript.
AppScript {
language: &'static str,
script_sha256: String,
first_line: String,
},
}
/// Classify an MCP tool call as a Computer Use call that needs a human
/// decision. See the module docs for the matching rule and its limits.
#[must_use]
pub(crate) fn computer_use_user_gate(
tool_name: &str,
input: &Value,
) -> Option<ComputerUseUserGate> {
if !tool_name.starts_with("mcp_") {
return None;
}
let text = |key: &str| {
input
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
};
let action = if tool_name.ends_with("_consent_allow") {
"allow"
} else if tool_name.ends_with("_consent_revoke") {
"revoke"
} else if tool_name.ends_with("_consent") {
match input.get("action").and_then(Value::as_str) {
Some("allow") => "allow",
Some("revoke") => "revoke",
// `status` reads; `deny` only narrows what the model may do.
_ => return None,
}
} else if tool_name.ends_with("_app_script") {
let script = input.get("script").and_then(Value::as_str).unwrap_or("");
let language = match input.get("language").and_then(Value::as_str) {
Some("javascript") => "JXA",
_ => "AppleScript",
};
let digest = Sha256::digest(script.as_bytes());
let mut script_sha256 = String::with_capacity(64);
for byte in digest {
write!(&mut script_sha256, "{byte:02x}").expect("writing to String cannot fail");
}
let first_line = script
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("")
.chars()
.take(120)
.collect();
return Some(ComputerUseUserGate::AppScript {
language,
script_sha256,
first_line,
});
} else {
return None;
};
let pid = input
.get("pid")
.and_then(Value::as_i64)
.map(|pid| format!("pid:{pid}"));
Some(ComputerUseUserGate::Consent {
action,
app: text("app").or_else(|| text("name")).or(pid),
bundle_id: text("bundle_id"),
scope: if input.get("scope").and_then(Value::as_str) == Some("foreground") {
"foreground"
} else {
"app"
},
remember: input.get("remember").and_then(Value::as_bool) == Some(true),
})
}
/// The inner tool of the first `run_actions` step that would need a human
/// decision (K1 / K2). Computer Use `run_actions` accepts any plugin tool name
/// as a step — including the unlisted `consent_allow` / `consent_revoke` and
/// `app_script` — so a batch would otherwise carry a consent grant or a
/// script past the per-call card. Engine preparation refuses such a batch.
#[must_use]
pub(crate) fn computer_use_batch_hidden_gate(tool_name: &str, input: &Value) -> Option<String> {
if !tool_name.starts_with("mcp_") || !tool_name.ends_with("_run_actions") {
return None;
}
input
.get("steps")
.and_then(Value::as_array)?
.iter()
.find_map(|step| {
let inner = step.get("tool").and_then(Value::as_str)?;
let arguments = step.get("arguments").cloned().unwrap_or(Value::Null);
computer_use_user_gate(&format!("mcp_{inner}"), &arguments).map(|_| inner.to_string())
})
}
/// Return the canonical command prefix for the shell command in `input`.
///
/// Uses [`classify_command`] from the arity dictionary so that approving
@@ -361,6 +502,83 @@ mod tests {
assert_ne!(exact_a, exact_b, "denial keys remain argument-exact");
}
/// K1: one session grant for Computer Use consent must not cover a
/// consent request for a different app, a different scope, or a
/// persisted (`remember`) variant — the MCP kind grant is not used here.
#[test]
fn computer_use_consent_grants_are_per_exact_call_not_per_kind() {
let tool = "mcp_plugin-12-computer-use-computer_consent";
let safari = build_approval_grouping_key(
tool,
&json!({"action": "allow", "app": "Safari", "bundle_id": "com.apple.Safari"}),
);
let terminal = build_approval_grouping_key(
tool,
&json!({"action": "allow", "app": "Terminal", "bundle_id": "com.apple.Terminal"}),
);
let foreground =
build_approval_grouping_key(tool, &json!({"action": "allow", "scope": "foreground"}));
let persisted = build_approval_grouping_key(
tool,
&json!({"action": "allow", "app": "Safari", "bundle_id": "com.apple.Safari", "remember": true}),
);
assert_ne!(safari, terminal, "allowing app X must never allow app Y");
assert_ne!(safari, foreground);
assert_ne!(safari, persisted);
assert!(safari.0.starts_with("cu:"), "{safari:?}");
for name in [
"mcp_codewhale-cu_consent_allow",
"mcp_codewhale-cu_consent_revoke",
] {
let a = build_approval_grouping_key(name, &json!({"app": "Safari"}));
let b = build_approval_grouping_key(name, &json!({"app": "Terminal"}));
assert_ne!(a, b, "{name}");
}
// Reads and self-narrowing decisions keep the ordinary kind grant.
assert_eq!(
build_approval_grouping_key(tool, &json!({"action": "status"})).0,
format!("mcp:{tool}")
);
assert!(
computer_use_user_gate(tool, &json!({"action": "deny", "app": "Safari"})).is_none()
);
assert!(computer_use_user_gate("mcp_codewhale-cu_consent_status", &json!({})).is_none());
assert!(computer_use_user_gate("consent_allow", &json!({"app": "Safari"})).is_none());
}
/// K2: an `app_script` session grant is the exact script; a changed
/// script is a new approval.
#[test]
fn app_script_grants_are_per_exact_script() {
let tool = "mcp_plugin-12-computer-use-computer_app_script";
let a = build_approval_grouping_key(
tool,
&json!({"script": "tell application \"Finder\" to get name of front window"}),
);
let same = build_approval_grouping_key(
tool,
&json!({"script": "tell application \"Finder\" to get name of front window"}),
);
let changed =
build_approval_grouping_key(tool, &json!({"script": "do shell script \"id\""}));
assert_eq!(a, same);
assert_ne!(a, changed, "a changed script must prompt again");
let Some(ComputerUseUserGate::AppScript {
language,
script_sha256,
first_line,
}) = computer_use_user_gate(
tool,
&json!({"script": "\n ObjC.import('Foundation')\nrest", "language": "javascript"}),
)
else {
panic!("app_script must be gated");
};
assert_eq!(language, "JXA");
assert_eq!(script_sha256.len(), 64);
assert_eq!(first_line, "ObjC.import('Foundation')");
}
#[test]
fn grouping_key_still_separates_distinct_commands() {
let key_a = build_approval_grouping_key("exec_shell", &json!({"command": "git status"}));