use rmcp::model::{CallToolResult, ContentBlock};
use crate::core::policy::runtime::{self, ActivePolicy};
const EXEMPT_TOOLS: &[&str] = &["ctx", "ctx_session", "ctx_policy"];
pub struct PolicyCheckResult {
pub blocked: bool,
pub policy_name: Option<String>,
pub message: Option<String>,
}
pub fn check_tool_access(tool_name: &str) -> PolicyCheckResult {
let check = evaluate(runtime::active().as_deref(), tool_name);
if check.blocked
&& let Some(policy) = &check.policy_name
{
crate::core::events::emit_policy_violation(
policy,
tool_name,
"tool denied by context policy pack",
);
crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
agent_id: "unknown".into(),
tool: tool_name.to_string(),
action: None,
input_hash: String::new(),
output_tokens: 0,
role: policy.clone(),
event_type: crate::core::audit_trail::AuditEventType::ToolDenied,
});
}
check
}
fn evaluate(active: Option<&ActivePolicy>, tool_name: &str) -> PolicyCheckResult {
if EXEMPT_TOOLS.contains(&tool_name) {
return PolicyCheckResult {
blocked: false,
policy_name: None,
message: None,
};
}
let Some(active) = active else {
return PolicyCheckResult {
blocked: false,
policy_name: None,
message: None,
};
};
if active.tool_allowed(tool_name) {
return PolicyCheckResult {
blocked: false,
policy_name: Some(active.resolved.name.clone()),
message: None,
};
}
let policy_name = active.resolved.name.clone();
let detail = match &active.resolved.allow_tools {
Some(allow) => format!("Allowed tools: {}", allow.join(", ")),
None => format!("Denied tools: {}", active.resolved.deny_tools.join(", ")),
};
let message = format!(
"[POLICY DENIED] Tool '{tool_name}' is blocked by context policy pack '{policy_name}'.\n{detail}\n\
Adjust .lean-ctx/policy.toml or switch policy to proceed."
);
PolicyCheckResult {
blocked: true,
policy_name: Some(policy_name),
message: Some(message),
}
}
pub fn into_call_tool_result(check: &PolicyCheckResult) -> Option<CallToolResult> {
check.blocked.then(|| {
CallToolResult::success(vec![ContentBlock::text(
check
.message
.as_deref()
.unwrap_or("Blocked by context policy"),
)])
})
}
#[must_use]
pub fn redact_result(text: &str) -> (String, usize) {
match runtime::active() {
Some(active) if !active.redaction.is_empty() => {
crate::core::redaction::redact_with_patterns(text, &active.redaction)
}
_ => (text.to_string(), 0),
}
}
pub fn audit_filter(tool: &str, audit: &[(String, usize)], blocked: bool) {
if audit.is_empty() {
return;
}
let policy =
runtime::active().map_or_else(|| "policy".to_string(), |a| a.resolved.name.clone());
let summary = audit
.iter()
.map(|(class, n)| format!("{class}×{n}"))
.collect::<Vec<_>>()
.join(", ");
if blocked {
crate::core::events::emit_policy_violation(
&policy,
tool,
&format!("input filter blocked: {summary}"),
);
}
crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
agent_id: "unknown".into(),
tool: tool.to_string(),
action: None,
input_hash: String::new(),
output_tokens: 0,
role: policy,
event_type: if blocked {
crate::core::audit_trail::AuditEventType::ToolDenied
} else {
crate::core::audit_trail::AuditEventType::SecretDetected
},
});
}
pub fn audit_egress(tool: &str, reason: &str) {
let policy =
runtime::active().map_or_else(|| "policy".to_string(), |a| a.resolved.name.clone());
crate::core::events::emit_policy_violation(&policy, tool, &format!("egress blocked: {reason}"));
crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
agent_id: "unknown".into(),
tool: tool.to_string(),
action: None,
input_hash: String::new(),
output_tokens: 0,
role: policy,
event_type: crate::core::audit_trail::AuditEventType::ToolDenied,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::policy::ResolvedPolicy;
use std::collections::BTreeMap;
fn active(allow: Option<Vec<&str>>, deny: Vec<&str>) -> ActivePolicy {
ActivePolicy::from_resolved(ResolvedPolicy {
name: "acme".into(),
version: "1.0.0".into(),
description: "t".into(),
chain: vec![],
default_read_mode: None,
allow_tools: allow.map(|a| a.into_iter().map(String::from).collect()),
deny_tools: deny.into_iter().map(String::from).collect(),
max_context_tokens: None,
audit_retention_days: None,
redaction: BTreeMap::new(),
filters: crate::core::policy::FilterRules::default(),
egress: crate::core::policy::EgressRules::default(),
routing: crate::core::policy::RoutingPolicyRules::default(),
budgets: crate::core::policy::BudgetRules::default(),
})
}
#[test]
fn no_pack_allows_everything() {
let r = evaluate(None, "ctx_shell");
assert!(!r.blocked);
}
#[test]
fn deny_tool_is_blocked_with_message() {
let p = active(None, vec!["ctx_url_read"]);
let r = evaluate(Some(&p), "ctx_url_read");
assert!(r.blocked);
assert_eq!(r.policy_name.as_deref(), Some("acme"));
assert!(r.message.unwrap().contains("[POLICY DENIED]"));
}
#[test]
fn allowlist_blocks_unlisted_tool() {
let p = active(Some(vec!["ctx_read"]), vec![]);
assert!(!evaluate(Some(&p), "ctx_read").blocked);
assert!(evaluate(Some(&p), "ctx_shell").blocked);
}
#[test]
fn exempt_tools_never_blocked_even_under_allowlist() {
let p = active(Some(vec!["ctx_read"]), vec![]);
for t in ["ctx", "ctx_session", "ctx_policy"] {
assert!(!evaluate(Some(&p), t).blocked, "{t} must be exempt");
}
}
#[test]
fn into_result_renders_denial() {
let p = active(None, vec!["ctx_shell"]);
let r = evaluate(Some(&p), "ctx_shell");
assert!(into_call_tool_result(&r).is_some());
let allowed = evaluate(None, "ctx_shell");
assert!(into_call_tool_result(&allowed).is_none());
}
#[test]
fn global_active_drives_allow_and_redaction() {
let mut redaction = BTreeMap::new();
redaction.insert("employee_id".to_string(), r"EMP-\d{4}".to_string());
runtime::set_active_for_test(Some(ResolvedPolicy {
name: "itest".into(),
version: "1.0.0".into(),
description: "t".into(),
chain: vec![],
default_read_mode: Some("map".into()),
allow_tools: None,
deny_tools: vec!["ctx_url_read".into()],
max_context_tokens: Some(5_000),
audit_retention_days: None,
redaction,
filters: crate::core::policy::FilterRules::default(),
egress: crate::core::policy::EgressRules::default(),
routing: crate::core::policy::RoutingPolicyRules::default(),
budgets: crate::core::policy::BudgetRules::default(),
}));
assert!(!check_tool_access("ctx_read").blocked);
assert!(!check_tool_access("ctx_session").blocked, "exempt tool");
let (out, hits) = redact_result("contact EMP-1234 today");
assert_eq!(hits, 1);
assert!(out.contains("[REDACTED:employee_id]"));
runtime::set_active_for_test(None);
assert!(
!check_tool_access("ctx_url_read").blocked,
"no pack → allow"
);
assert_eq!(redact_result("contact EMP-1234 today").1, 0);
}
}