Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions libs/cua-driver/rust/crates/cua-driver-core/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,28 @@ impl PolicyEngine {
Self::Disabled => PolicyDecision::Error("policy support is not enabled".to_owned()),
}
}

/// Returns `true` when the tool should appear in `tools/list`.
///
/// For YAML policies a tool is potentially listable when it is not
/// explicitly denied and has at least one allow path (unconditional or
/// rule-constrained). For Rego, the policy is evaluated with empty
/// arguments as an approximation; Rego rules can implement their own
/// "no-args" sentinel if finer control is needed.
pub fn is_potentially_listable(&self, tool: &str) -> bool {
let tool = canonical_tool_name(tool);
match self {
#[cfg(feature = "yaml")]
Self::Yaml(policy) => policy.is_potentially_listable(tool),
#[cfg(feature = "rego")]
Self::Rego(policy) => {
let empty_args = Value::Object(serde_json::Map::new());
matches!(policy.evaluate(tool, &empty_args), PolicyDecision::Allow)
}
#[cfg(not(any(feature = "yaml", feature = "rego")))]
Self::Disabled => false,
}
}
}

fn canonical_tool_name(tool: &str) -> &str {
Expand Down Expand Up @@ -288,6 +310,34 @@ pub fn authorize_tool_call(tool: &str, args: &Value) -> Result<(), Authorization
authorize_policy_layers(tool, args, layers)
}

/// Returns `true` when the tool should be advertised in `tools/list`.
///
/// Unlike [`authorize_tool_call`], this does not require a full argument set:
/// it checks only whether the tool has any potential allow path (is not
/// unconditionally denied). Tools that are conditionally allowed via
/// `allow.rules` are still listed so that callers can attempt a constrained
/// invocation. When no policy is configured, all tools are listable.
///
/// On a policy loading error, this returns `true` (fail-open for listing).
/// The error will surface at invocation time through [`authorize_tool_call`],
/// and [`validate_configured_policy`] is expected to have caught it at startup.
pub fn is_tool_listable(tool: &str) -> bool {
let managed = match configured_managed_policy() {
Ok(policy) => policy,
Err(_) => return true,
};
let user = match configured_policy() {
Ok(policy) => policy,
Err(_) => return true,
};
for policy in [managed, user].into_iter().flatten() {
if !policy.is_potentially_listable(tool) {
return false;
}
}
true
}

/// Eagerly validate the immutable process policy before any action endpoint is
/// exposed. This prevents a configured typo from producing a listening daemon
/// that only discovers the error after clients begin issuing calls.
Expand Down Expand Up @@ -364,6 +414,20 @@ impl YamlPolicy {
failures.join("; ")
))
}

/// Returns `true` when the tool is not explicitly denied and has at least
/// one potential allow path (an unconditional entry in `allow.tools` or an
/// entry in `allow.rules`). This is the correct predicate for advertising
/// a tool in `tools/list`: the tool may be conditionally allowed, so we
/// must not hide it just because a call with empty arguments would be
/// denied by unsatisfied constraints.
fn is_potentially_listable(&self, tool: &str) -> bool {
if self.denied_tools.iter().any(|denied| denied == tool) {
return false;
}
self.allowed_tools.iter().any(|allowed| allowed == tool)
|| self.rules.iter().any(|rule| rule.tool == tool)
}
}

#[cfg(feature = "yaml")]
Expand Down
5 changes: 3 additions & 2 deletions libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -813,8 +813,9 @@ impl ToolRegistry {
let list: Vec<Value> = self
.order
.iter()
.filter_map(|n| self.tools.get(n))
.map(|t| t.def().to_list_entry())
.filter(|name| crate::policy::is_tool_listable(name))
.filter_map(|name| self.tools.get(name))
.map(|tool| tool.def().to_list_entry())
.collect();
// `capability_version` is the contract version for the
// capability tokens claimed by each tool entry. Bumped on
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//! Permission policy must shape the MCP tool roster as well as invocation.

#![cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]

use std::collections::HashSet;

use cua_driver_testkit::RawDriver;
use serde_json::json;

#[test]
fn tools_list_hides_policy_denied_tools_and_calls_stay_denied() {
let directory = tempfile::tempdir().expect("temporary policy directory");
let policy_path = directory.path().join("policy.yaml");
// `get_config` is unconditionally allowed via `allow.tools`.
// `screenshot` is conditionally allowed via `allow.rules` with a
// constraint. Both must appear in `tools/list` because `tools/list`
// should not hide tools that are *potentially* allowed.
// `list_apps` is explicitly denied and must be absent.
std::fs::write(
&policy_path,
"allow:\n tools: [get_config]\n rules:\n - tool: screenshot\n constraints:\n display_id: {\"const\": 0}\ndeny:\n tools: [list_apps]\n",
)
.expect("write permission policy");
let policy = policy_path.display().to_string();

let Some(mut driver) = RawDriver::spawn_with_env(&[("CUA_DRIVER_POLICY_FILE", &policy)]) else {
return;
};

driver.send(&json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}));
driver.recv();

driver.send(&json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}));
let response = driver.recv();
let names: HashSet<&str> = response["result"]["tools"]
.as_array()
.expect("tools/list tools array")
.iter()
.filter_map(|tool| tool["name"].as_str())
.collect();
assert!(
names.contains("get_config"),
"unconditionally allowed tool must remain listed"
);
assert!(
names.contains("screenshot"),
"rule-conditionally allowed tool must remain listed even though empty-arg evaluation would deny it"
);
assert!(
!names.contains("list_apps"),
"policy-denied tool must not be advertised"
);

driver.send(&json!({
"jsonrpc":"2.0",
"id":3,
"method":"tools/call",
"params":{"name":"list_apps","arguments":{}}
}));
let response = driver.recv();
assert_eq!(response["result"]["isError"], true);
assert!(
response["result"]["content"][0]["text"]
.as_str()
.is_some_and(|message| message.contains("Permission denied")),
"policy-denied invocation must remain rejected: {response}"
);
}
Loading