mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
1 parent
6dca8a8a61
commit
5b1dcbdb1a
3 files changed
+444
-2
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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(®istry), 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(®istry), 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(®istry), 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(®istry), 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(®istry), false).is_ok());
|
||||
|
||||
// Reading the ledger is unaffected.
|
||||
let status = prepare_tool_call(consent, json!({"action": "status"}), Some(®istry), 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");
|
||||
|
||||
@@ -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"}));
|
||||
|
||||
Reference in new issue
Block a user