use serde_json::Value;
use std::sync::Arc;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ToolInvocation {
pub name: String,
pub args: Value,
pub id: Option<String>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ToolOutcome {
pub name: String,
pub result: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum PreToolDecision {
Allow,
Deny {
reason: String,
},
}
impl PreToolDecision {
#[must_use]
pub fn deny(reason: impl Into<String>) -> Self {
Self::Deny {
reason: reason.into(),
}
}
}
pub type PreToolHook = Arc<dyn Fn(&ToolInvocation) -> PreToolDecision + Send + Sync>;
pub type PostToolHook = Arc<dyn Fn(&ToolOutcome) + Send + Sync>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum PolicyDecision {
Allow,
Deny,
Confirm,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Policy {
target: String,
decision: PolicyDecision,
}
impl Policy {
#[must_use]
pub fn target(&self) -> &str {
&self.target
}
#[must_use]
pub fn decision(&self) -> PolicyDecision {
self.decision
}
fn is_wildcard(&self) -> bool {
self.target == WILDCARD
}
fn matches(&self, tool_name: &str) -> bool {
self.target == WILDCARD || self.target == tool_name
}
}
const WILDCARD: &str = "*";
pub mod policy {
use super::{Policy, PolicyDecision, WILDCARD};
#[must_use]
pub fn allow(tool: impl Into<String>) -> Policy {
Policy {
target: tool.into(),
decision: PolicyDecision::Allow,
}
}
#[must_use]
pub fn deny(tool: impl Into<String>) -> Policy {
Policy {
target: tool.into(),
decision: PolicyDecision::Deny,
}
}
#[must_use]
pub fn confirm(tool: impl Into<String>) -> Policy {
Policy {
target: tool.into(),
decision: PolicyDecision::Confirm,
}
}
#[must_use]
pub fn allow_all() -> Policy {
allow(WILDCARD)
}
#[must_use]
pub fn deny_all() -> Policy {
deny(WILDCARD)
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct PolicyEngine {
policies: Vec<Policy>,
}
impl PolicyEngine {
pub(crate) fn new(policies: Vec<Policy>) -> Self {
Self { policies }
}
pub(crate) fn evaluate(&self, tool_name: &str) -> Option<PolicyDecision> {
self.policies
.iter()
.find(|p| !p.is_wildcard() && p.matches(tool_name))
.or_else(|| self.policies.iter().find(|p| p.is_wildcard()))
.map(Policy::decision)
}
}
pub(crate) fn decide(
engine: &PolicyEngine,
pre_tool: Option<&PreToolHook>,
invocation: &ToolInvocation,
) -> PreToolDecision {
decide_with_default(engine, pre_tool, invocation, PreToolDecision::Allow)
}
pub(crate) fn decide_with_default(
engine: &PolicyEngine,
pre_tool: Option<&PreToolHook>,
invocation: &ToolInvocation,
unmatched: PreToolDecision,
) -> PreToolDecision {
match engine.evaluate(&invocation.name) {
Some(PolicyDecision::Deny) => {
PreToolDecision::deny(format!("Denied by policy for tool '{}'.", invocation.name))
}
Some(PolicyDecision::Confirm) => match pre_tool {
Some(hook) => hook(invocation),
None => PreToolDecision::deny(format!(
"Tool '{}' requires confirmation but no pre-tool hook is configured \
(failing closed).",
invocation.name
)),
},
Some(PolicyDecision::Allow) => {
match pre_tool {
Some(hook) => hook(invocation),
None => PreToolDecision::Allow,
}
}
None => {
match pre_tool {
Some(hook) => hook(invocation),
None => unmatched,
}
}
}
}
#[must_use]
pub(crate) fn mcp_tool_name(server: &str, tool: &str) -> String {
format!("mcp_{server}_{tool}")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn invocation(name: &str) -> ToolInvocation {
ToolInvocation {
name: name.to_string(),
args: json!({}),
id: None,
}
}
fn engine(policies: Vec<Policy>) -> PolicyEngine {
PolicyEngine::new(policies)
}
#[test]
fn test_policy_decision_table() {
use PolicyDecision::{Allow, Confirm, Deny};
let cases: Vec<(Vec<Policy>, &str, Option<PolicyDecision>)> = vec![
(vec![], "anything", None),
(vec![policy::allow("a")], "a", Some(Allow)),
(vec![policy::deny("a")], "a", Some(Deny)),
(vec![policy::confirm("a")], "a", Some(Confirm)),
(vec![policy::allow("a")], "b", None),
(vec![policy::allow_all()], "x", Some(Allow)),
(vec![policy::deny_all()], "x", Some(Deny)),
(
vec![policy::deny_all(), policy::allow("get_weather")],
"get_weather",
Some(Allow),
),
(
vec![policy::deny_all(), policy::allow("get_weather")],
"run_command",
Some(Deny),
),
(
vec![policy::allow_all(), policy::deny("run_command")],
"run_command",
Some(Deny),
),
(
vec![policy::allow_all(), policy::deny("run_command")],
"view_file",
Some(Allow),
),
(vec![policy::deny("t"), policy::allow("t")], "t", Some(Deny)),
(
vec![policy::allow("t"), policy::deny("t")],
"t",
Some(Allow),
),
(
vec![policy::deny_all(), policy::allow_all()],
"t",
Some(Deny),
),
(
vec![policy::confirm("run_command"), policy::allow_all()],
"run_command",
Some(Confirm),
),
(
vec![policy::confirm("run_command"), policy::allow_all()],
"edit_file",
Some(Allow),
),
(
vec![
policy::deny_all(),
policy::allow(mcp_tool_name("git", "status")),
],
"mcp_git_status",
Some(Allow),
),
(
vec![
policy::deny_all(),
policy::allow(mcp_tool_name("git", "status")),
],
"mcp_git_push",
Some(Deny),
),
];
for (policies, tool, expected) in cases {
let engine = engine(policies.clone());
assert_eq!(
engine.evaluate(tool),
expected,
"policies {policies:?} evaluating {tool:?}"
);
}
}
#[test]
fn test_decide_no_policies_no_hook_allows() {
let engine = engine(vec![]);
assert_eq!(
decide(&engine, None, &invocation("t")),
PreToolDecision::Allow
);
}
#[test]
fn test_decide_policy_deny_short_circuits_hook() {
let engine = engine(vec![policy::deny_all()]);
let hook: PreToolHook = Arc::new(|_| PreToolDecision::Allow);
let decision = decide(&engine, Some(&hook), &invocation("t"));
assert!(matches!(decision, PreToolDecision::Deny { .. }));
}
#[test]
fn test_decide_policy_allow_still_consults_hook() {
let engine = engine(vec![policy::allow_all()]);
let hook: PreToolHook = Arc::new(|inv| {
if inv.name == "blocked" {
PreToolDecision::deny("hook says no")
} else {
PreToolDecision::Allow
}
});
assert_eq!(
decide(&engine, Some(&hook), &invocation("fine")),
PreToolDecision::Allow
);
assert_eq!(
decide(&engine, Some(&hook), &invocation("blocked")),
PreToolDecision::deny("hook says no")
);
}
#[test]
fn test_decide_confirm_without_hook_fails_closed() {
let engine = engine(vec![policy::confirm("danger")]);
let decision = decide(&engine, None, &invocation("danger"));
let PreToolDecision::Deny { reason } = decision else {
panic!("confirm without hook must deny");
};
assert!(reason.contains("failing closed"));
}
#[test]
fn test_decide_confirm_defers_to_hook() {
let engine = engine(vec![policy::confirm("danger")]);
let approve: PreToolHook = Arc::new(|_| PreToolDecision::Allow);
let reject: PreToolHook = Arc::new(|_| PreToolDecision::deny("nope"));
assert_eq!(
decide(&engine, Some(&approve), &invocation("danger")),
PreToolDecision::Allow
);
assert_eq!(
decide(&engine, Some(&reject), &invocation("danger")),
PreToolDecision::deny("nope")
);
}
#[test]
fn test_decide_hook_receives_invocation_details() {
let engine = engine(vec![]);
let hook: PreToolHook = Arc::new(|inv| {
assert_eq!(inv.name, "run_command");
assert_eq!(inv.args["commandLine"], "ls");
assert_eq!(inv.id.as_deref(), Some("call-7"));
PreToolDecision::Allow
});
let inv = ToolInvocation {
name: "run_command".to_string(),
args: json!({"commandLine": "ls"}),
id: Some("call-7".to_string()),
};
assert_eq!(decide(&engine, Some(&hook), &inv), PreToolDecision::Allow);
}
#[test]
fn test_decide_with_default_deny_applies_only_when_unmatched() {
let closed = || PreToolDecision::deny("unrecognized");
assert!(matches!(
decide_with_default(&engine(vec![]), None, &invocation("t"), closed()),
PreToolDecision::Deny { .. }
));
assert!(matches!(
decide_with_default(
&engine(vec![policy::allow("other")]),
None,
&invocation("t"),
closed()
),
PreToolDecision::Deny { .. }
));
assert_eq!(
decide_with_default(
&engine(vec![policy::allow_all()]),
None,
&invocation("t"),
closed()
),
PreToolDecision::Allow
);
let hook: PreToolHook = Arc::new(|_| PreToolDecision::Allow);
assert_eq!(
decide_with_default(&engine(vec![]), Some(&hook), &invocation("t"), closed()),
PreToolDecision::Allow
);
}
#[test]
fn test_mcp_tool_name_format() {
assert_eq!(mcp_tool_name("git", "status"), "mcp_git_status");
}
}