1use rmcp::model::{CallToolResult, ContentBlock};
15
16use crate::core::policy::runtime::{self, ActivePolicy};
17
18const EXEMPT_TOOLS: &[&str] = &["ctx", "ctx_session", "ctx_policy"];
21
22pub struct PolicyCheckResult {
23 pub blocked: bool,
24 pub policy_name: Option<String>,
25 pub message: Option<String>,
26}
27
28pub fn check_tool_access(tool_name: &str) -> PolicyCheckResult {
31 let check = evaluate(runtime::active().as_deref(), tool_name);
32 if check.blocked
33 && let Some(policy) = &check.policy_name
34 {
35 crate::core::events::emit_policy_violation(
36 policy,
37 tool_name,
38 "tool denied by context policy pack",
39 );
40 crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
41 agent_id: "unknown".into(),
42 tool: tool_name.to_string(),
43 action: None,
44 input_hash: String::new(),
45 output_tokens: 0,
46 role: policy.clone(),
47 event_type: crate::core::audit_trail::AuditEventType::ToolDenied,
48 });
49 }
50 check
51}
52
53fn evaluate(active: Option<&ActivePolicy>, tool_name: &str) -> PolicyCheckResult {
55 if EXEMPT_TOOLS.contains(&tool_name) {
56 return PolicyCheckResult {
57 blocked: false,
58 policy_name: None,
59 message: None,
60 };
61 }
62 let Some(active) = active else {
63 return PolicyCheckResult {
64 blocked: false,
65 policy_name: None,
66 message: None,
67 };
68 };
69 if active.tool_allowed(tool_name) {
70 return PolicyCheckResult {
71 blocked: false,
72 policy_name: Some(active.resolved.name.clone()),
73 message: None,
74 };
75 }
76 let policy_name = active.resolved.name.clone();
77 let detail = match &active.resolved.allow_tools {
78 Some(allow) => format!("Allowed tools: {}", allow.join(", ")),
79 None => format!("Denied tools: {}", active.resolved.deny_tools.join(", ")),
80 };
81 let message = format!(
82 "[POLICY DENIED] Tool '{tool_name}' is blocked by context policy pack '{policy_name}'.\n{detail}\n\
83 Adjust .lean-ctx/policy.toml or switch policy to proceed."
84 );
85 PolicyCheckResult {
86 blocked: true,
87 policy_name: Some(policy_name),
88 message: Some(message),
89 }
90}
91
92pub fn into_call_tool_result(check: &PolicyCheckResult) -> Option<CallToolResult> {
93 check.blocked.then(|| {
94 CallToolResult::success(vec![ContentBlock::text(
95 check
96 .message
97 .as_deref()
98 .unwrap_or("Blocked by context policy"),
99 )])
100 })
101}
102
103#[must_use]
108pub fn redact_result(text: &str) -> (String, usize) {
109 match runtime::active() {
110 Some(active) if !active.redaction.is_empty() => {
111 crate::core::redaction::redact_with_patterns(text, &active.redaction)
112 }
113 _ => (text.to_string(), 0),
114 }
115}
116
117pub fn audit_filter(tool: &str, audit: &[(String, usize)], blocked: bool) {
122 if audit.is_empty() {
123 return;
124 }
125 let policy =
126 runtime::active().map_or_else(|| "policy".to_string(), |a| a.resolved.name.clone());
127 let summary = audit
128 .iter()
129 .map(|(class, n)| format!("{class}×{n}"))
130 .collect::<Vec<_>>()
131 .join(", ");
132 if blocked {
133 crate::core::events::emit_policy_violation(
134 &policy,
135 tool,
136 &format!("input filter blocked: {summary}"),
137 );
138 }
139 crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
140 agent_id: "unknown".into(),
141 tool: tool.to_string(),
142 action: None,
143 input_hash: String::new(),
144 output_tokens: 0,
145 role: policy,
146 event_type: if blocked {
147 crate::core::audit_trail::AuditEventType::ToolDenied
148 } else {
149 crate::core::audit_trail::AuditEventType::SecretDetected
150 },
151 });
152}
153
154pub fn audit_egress(tool: &str, reason: &str) {
158 let policy =
159 runtime::active().map_or_else(|| "policy".to_string(), |a| a.resolved.name.clone());
160 crate::core::events::emit_policy_violation(&policy, tool, &format!("egress blocked: {reason}"));
161 crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
162 agent_id: "unknown".into(),
163 tool: tool.to_string(),
164 action: None,
165 input_hash: String::new(),
166 output_tokens: 0,
167 role: policy,
168 event_type: crate::core::audit_trail::AuditEventType::ToolDenied,
169 });
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::core::policy::ResolvedPolicy;
176 use std::collections::BTreeMap;
177
178 fn active(allow: Option<Vec<&str>>, deny: Vec<&str>) -> ActivePolicy {
179 ActivePolicy::from_resolved(ResolvedPolicy {
180 name: "acme".into(),
181 version: "1.0.0".into(),
182 description: "t".into(),
183 chain: vec![],
184 default_read_mode: None,
185 allow_tools: allow.map(|a| a.into_iter().map(String::from).collect()),
186 deny_tools: deny.into_iter().map(String::from).collect(),
187 max_context_tokens: None,
188 audit_retention_days: None,
189 redaction: BTreeMap::new(),
190 filters: crate::core::policy::FilterRules::default(),
191 egress: crate::core::policy::EgressRules::default(),
192 routing: crate::core::policy::RoutingPolicyRules::default(),
193 budgets: crate::core::policy::BudgetRules::default(),
194 })
195 }
196
197 #[test]
198 fn no_pack_allows_everything() {
199 let r = evaluate(None, "ctx_shell");
200 assert!(!r.blocked);
201 }
202
203 #[test]
204 fn deny_tool_is_blocked_with_message() {
205 let p = active(None, vec!["ctx_url_read"]);
206 let r = evaluate(Some(&p), "ctx_url_read");
207 assert!(r.blocked);
208 assert_eq!(r.policy_name.as_deref(), Some("acme"));
209 assert!(r.message.unwrap().contains("[POLICY DENIED]"));
210 }
211
212 #[test]
213 fn allowlist_blocks_unlisted_tool() {
214 let p = active(Some(vec!["ctx_read"]), vec![]);
215 assert!(!evaluate(Some(&p), "ctx_read").blocked);
216 assert!(evaluate(Some(&p), "ctx_shell").blocked);
217 }
218
219 #[test]
220 fn exempt_tools_never_blocked_even_under_allowlist() {
221 let p = active(Some(vec!["ctx_read"]), vec![]);
224 for t in ["ctx", "ctx_session", "ctx_policy"] {
225 assert!(!evaluate(Some(&p), t).blocked, "{t} must be exempt");
226 }
227 }
228
229 #[test]
230 fn into_result_renders_denial() {
231 let p = active(None, vec!["ctx_shell"]);
232 let r = evaluate(Some(&p), "ctx_shell");
233 assert!(into_call_tool_result(&r).is_some());
234 let allowed = evaluate(None, "ctx_shell");
235 assert!(into_call_tool_result(&allowed).is_none());
236 }
237
238 #[test]
243 fn global_active_drives_allow_and_redaction() {
244 let mut redaction = BTreeMap::new();
245 redaction.insert("employee_id".to_string(), r"EMP-\d{4}".to_string());
246 runtime::set_active_for_test(Some(ResolvedPolicy {
247 name: "itest".into(),
248 version: "1.0.0".into(),
249 description: "t".into(),
250 chain: vec![],
251 default_read_mode: Some("map".into()),
252 allow_tools: None,
253 deny_tools: vec!["ctx_url_read".into()],
254 max_context_tokens: Some(5_000),
255 audit_retention_days: None,
256 redaction,
257 filters: crate::core::policy::FilterRules::default(),
258 egress: crate::core::policy::EgressRules::default(),
259 routing: crate::core::policy::RoutingPolicyRules::default(),
260 budgets: crate::core::policy::BudgetRules::default(),
261 }));
262
263 assert!(!check_tool_access("ctx_read").blocked);
264 assert!(!check_tool_access("ctx_session").blocked, "exempt tool");
265 let (out, hits) = redact_result("contact EMP-1234 today");
266 assert_eq!(hits, 1);
267 assert!(out.contains("[REDACTED:employee_id]"));
268
269 runtime::set_active_for_test(None);
270 assert!(
271 !check_tool_access("ctx_url_read").blocked,
272 "no pack → allow"
273 );
274 assert_eq!(redact_result("contact EMP-1234 today").1, 0);
275 }
276}