1use rmcp::model::{CallToolResult, Content};
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![Content::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 })
193 }
194
195 #[test]
196 fn no_pack_allows_everything() {
197 let r = evaluate(None, "ctx_shell");
198 assert!(!r.blocked);
199 }
200
201 #[test]
202 fn deny_tool_is_blocked_with_message() {
203 let p = active(None, vec!["ctx_url_read"]);
204 let r = evaluate(Some(&p), "ctx_url_read");
205 assert!(r.blocked);
206 assert_eq!(r.policy_name.as_deref(), Some("acme"));
207 assert!(r.message.unwrap().contains("[POLICY DENIED]"));
208 }
209
210 #[test]
211 fn allowlist_blocks_unlisted_tool() {
212 let p = active(Some(vec!["ctx_read"]), vec![]);
213 assert!(!evaluate(Some(&p), "ctx_read").blocked);
214 assert!(evaluate(Some(&p), "ctx_shell").blocked);
215 }
216
217 #[test]
218 fn exempt_tools_never_blocked_even_under_allowlist() {
219 let p = active(Some(vec!["ctx_read"]), vec![]);
222 for t in ["ctx", "ctx_session", "ctx_policy"] {
223 assert!(!evaluate(Some(&p), t).blocked, "{t} must be exempt");
224 }
225 }
226
227 #[test]
228 fn into_result_renders_denial() {
229 let p = active(None, vec!["ctx_shell"]);
230 let r = evaluate(Some(&p), "ctx_shell");
231 assert!(into_call_tool_result(&r).is_some());
232 let allowed = evaluate(None, "ctx_shell");
233 assert!(into_call_tool_result(&allowed).is_none());
234 }
235
236 #[test]
241 fn global_active_drives_allow_and_redaction() {
242 let mut redaction = BTreeMap::new();
243 redaction.insert("employee_id".to_string(), r"EMP-\d{4}".to_string());
244 runtime::set_active_for_test(Some(ResolvedPolicy {
245 name: "itest".into(),
246 version: "1.0.0".into(),
247 description: "t".into(),
248 chain: vec![],
249 default_read_mode: Some("map".into()),
250 allow_tools: None,
251 deny_tools: vec!["ctx_url_read".into()],
252 max_context_tokens: Some(5_000),
253 audit_retention_days: None,
254 redaction,
255 filters: crate::core::policy::FilterRules::default(),
256 egress: crate::core::policy::EgressRules::default(),
257 }));
258
259 assert!(!check_tool_access("ctx_read").blocked);
260 assert!(!check_tool_access("ctx_session").blocked, "exempt tool");
261 let (out, hits) = redact_result("contact EMP-1234 today");
262 assert_eq!(hits, 1);
263 assert!(out.contains("[REDACTED:employee_id]"));
264
265 runtime::set_active_for_test(None);
266 assert!(
267 !check_tool_access("ctx_url_read").blocked,
268 "no pack → allow"
269 );
270 assert_eq!(redact_result("contact EMP-1234 today").1, 0);
271 }
272}