1use rmcp::model::{CallToolResult, ContentBlock};
16
17use lean_ctx::core::policy::runtime::{self, ActivePolicy};
18
19pub use lean_ctx::core::policy::audit_filter::{audit_filter, audit_filter_as};
20
21mod release;
22pub use release::{release_error, release_outcome, release_result};
23
24const EXEMPT_TOOLS: &[&str] = &["ctx", "ctx_session", "ctx_policy"];
27
28pub struct PolicyCheckResult {
29 pub blocked: bool,
30 pub policy_name: Option<String>,
31 pub message: Option<String>,
32}
33
34pub fn check_tool_access(tool_name: &str) -> PolicyCheckResult {
37 check_tool_access_as(
38 tool_name,
39 lean_ctx::core::agent_identity::current_agent_id(),
40 )
41}
42
43pub fn check_tool_access_as(tool_name: &str, agent_id: &str) -> PolicyCheckResult {
46 let check = evaluate_with_exemptions(
47 runtime::active().as_deref(),
48 tool_name,
49 std::env::var_os(runtime::REQUIRED_POLICY_ROOT_ENV).is_none(),
50 );
51 if check.blocked
52 && let Some(policy) = &check.policy_name
53 {
54 lean_ctx::core::events::emit_policy_violation(
55 policy,
56 tool_name,
57 "tool denied by context policy pack",
58 );
59 lean_ctx::core::audit_trail::record(lean_ctx::core::audit_trail::AuditEntryData {
60 agent_id: agent_id.to_string(),
61 tool: tool_name.to_string(),
62 action: None,
63 input_hash: String::new(),
64 output_tokens: 0,
65 role: policy.clone(),
66 event_type: lean_ctx::core::audit_trail::AuditEventType::ToolDenied,
67 });
68 }
69 check
70}
71
72#[cfg(test)]
74fn evaluate(active: Option<&ActivePolicy>, tool_name: &str) -> PolicyCheckResult {
75 evaluate_with_exemptions(active, tool_name, true)
76}
77
78fn evaluate_with_exemptions(
79 active: Option<&ActivePolicy>,
80 tool_name: &str,
81 allow_operator_recovery: bool,
82) -> PolicyCheckResult {
83 if allow_operator_recovery && EXEMPT_TOOLS.contains(&tool_name) {
86 return PolicyCheckResult {
87 blocked: false,
88 policy_name: None,
89 message: None,
90 };
91 }
92 let Some(active) = active else {
93 return PolicyCheckResult {
94 blocked: false,
95 policy_name: None,
96 message: None,
97 };
98 };
99 if active.tool_allowed(tool_name) {
100 return PolicyCheckResult {
101 blocked: false,
102 policy_name: Some(active.resolved.name.clone()),
103 message: None,
104 };
105 }
106 let policy_name = active.resolved.name.clone();
107 let detail = match &active.resolved.allow_tools {
108 Some(allow) => format!("Allowed tools: {}", allow.join(", ")),
109 None => format!("Denied tools: {}", active.resolved.deny_tools.join(", ")),
110 };
111 let message = format!(
112 "[POLICY DENIED] Tool '{tool_name}' is blocked by context policy pack '{policy_name}'.\n{detail}\n\
113 Adjust .lean-ctx/policy.toml or switch policy to proceed."
114 );
115 PolicyCheckResult {
116 blocked: true,
117 policy_name: Some(policy_name),
118 message: Some(message),
119 }
120}
121
122pub fn into_call_tool_result(check: &PolicyCheckResult) -> Option<CallToolResult> {
124 check.blocked.then(|| {
125 CallToolResult::error(vec![ContentBlock::text(
126 check
127 .message
128 .as_deref()
129 .unwrap_or("Blocked by context policy"),
130 )])
131 })
132}
133
134#[must_use]
139pub fn redact_result(text: &str) -> (String, usize) {
140 match runtime::active() {
141 Some(active) if !active.redaction.is_empty() => {
142 lean_ctx::core::redaction::redact_with_patterns(text, &active.redaction)
143 }
144 _ => (text.to_string(), 0),
145 }
146}
147
148pub fn protect_result(tool: &str, text: &str) -> Result<String, &'static str> {
152 let Some(active) = runtime::active() else {
153 return Ok(text.to_owned());
154 };
155 let outcome = lean_ctx::core::policy::content::evaluate_text(text, &active);
156 audit_filter(tool, &outcome.audit, outcome.blocked);
157 if outcome.blocked {
158 return Err("[POLICY BLOCKED] Content withheld by the active context policy.");
159 }
160 let mut text = outcome.text;
161 for warning in outcome.warnings {
162 let rendered = format!("[FILTER] {warning}");
163 if !text.lines().any(|line| line == rendered) {
164 text.push_str("\n\n");
165 text.push_str(&rendered);
166 }
167 }
168 Ok(text)
169}
170
171pub fn redact_model_text(text: &str) -> String {
177 let (policy_redacted, _) = redact_result(text);
178 let redacted = lean_ctx::core::redaction::redact_text_if_enabled(&policy_redacted);
179 lean_ctx::core::context_admission::admit_instance_source(
181 &redacted,
182 "protocol:model-text",
183 false,
184 )
185 .unwrap_or_else(|error| format!("[lean-ctx gateway: {error}]"))
186}
187
188pub fn audit_egress(tool: &str, reason: &str, agent_id: &str) {
193 let policy =
194 runtime::active().map_or_else(|| "policy".to_string(), |a| a.resolved.name.clone());
195 lean_ctx::core::events::emit_policy_violation(
196 &policy,
197 tool,
198 &format!("egress blocked: {reason}"),
199 );
200 lean_ctx::core::audit_trail::record(lean_ctx::core::audit_trail::AuditEntryData {
201 agent_id: agent_id.to_string(),
202 tool: tool.to_string(),
203 action: None,
204 input_hash: String::new(),
205 output_tokens: 0,
206 role: policy,
207 event_type: lean_ctx::core::audit_trail::AuditEventType::ToolDenied,
208 });
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use lean_ctx::core::policy::ResolvedPolicy;
215 use std::collections::BTreeMap;
216
217 fn active(allow: Option<Vec<&str>>, deny: Vec<&str>) -> ActivePolicy {
218 ActivePolicy::from_resolved(ResolvedPolicy {
219 name: "acme".into(),
220 version: "1.0.0".into(),
221 description: "t".into(),
222 chain: vec![],
223 default_read_mode: None,
224 allow_tools: allow.map(|a| a.into_iter().map(String::from).collect()),
225 deny_tools: deny.into_iter().map(String::from).collect(),
226 max_context_tokens: None,
227 audit_retention_days: None,
228 redaction: BTreeMap::new(),
229 filters: lean_ctx::core::policy::FilterRules::default(),
230 egress: lean_ctx::core::policy::EgressRules::default(),
231 routing: lean_ctx::core::policy::RoutingPolicyRules::default(),
232 budgets: lean_ctx::core::policy::BudgetRules::default(),
233 })
234 }
235
236 #[test]
237 fn no_pack_allows_everything() {
238 let r = evaluate(None, "ctx_shell");
239 assert!(!r.blocked);
240 }
241
242 #[test]
243 fn deny_tool_is_blocked_with_message() {
244 let p = active(None, vec!["ctx_url_read"]);
245 let r = evaluate(Some(&p), "ctx_url_read");
246 assert!(r.blocked);
247 assert_eq!(r.policy_name.as_deref(), Some("acme"));
248 assert!(r.message.unwrap().contains("[POLICY DENIED]"));
249 }
250
251 #[test]
252 fn allowlist_blocks_unlisted_tool() {
253 let p = active(Some(vec!["ctx_read"]), vec![]);
254 assert!(!evaluate(Some(&p), "ctx_read").blocked);
255 assert!(evaluate(Some(&p), "ctx_shell").blocked);
256 }
257
258 #[test]
259 fn exempt_tools_never_blocked_even_under_allowlist() {
260 let p = active(Some(vec!["ctx_read"]), vec![]);
263 for t in ["ctx", "ctx_session", "ctx_policy"] {
264 assert!(!evaluate(Some(&p), t).blocked, "{t} must be exempt");
265 }
266 }
267
268 #[test]
269 fn protected_clients_cannot_use_operator_recovery_exemptions() {
270 let p = active(Some(vec!["ctx_read"]), vec![]);
271 for tool in EXEMPT_TOOLS {
272 assert!(evaluate_with_exemptions(Some(&p), tool, false).blocked);
273 }
274 assert!(!evaluate_with_exemptions(Some(&p), "ctx_read", false).blocked);
275 }
276
277 #[test]
278 fn into_result_renders_denial() {
279 let p = active(None, vec!["ctx_shell"]);
280 let r = evaluate(Some(&p), "ctx_shell");
281 assert_eq!(
283 into_call_tool_result(&r).and_then(|denied| denied.is_error),
284 Some(true)
285 );
286 let allowed = evaluate(None, "ctx_shell");
287 assert!(into_call_tool_result(&allowed).is_none());
288 }
289
290 #[test]
295 fn global_active_drives_allow_and_redaction() {
296 let mut redaction = BTreeMap::new();
297 redaction.insert("employee_id".to_string(), r"EMP-\d{4}".to_string());
298 {
299 let _policy = runtime::TestPolicyOverride::set(Some(ResolvedPolicy {
300 name: "itest".into(),
301 version: "1.0.0".into(),
302 description: "t".into(),
303 chain: vec![],
304 default_read_mode: Some("map".into()),
305 allow_tools: None,
306 deny_tools: vec!["ctx_url_read".into()],
307 max_context_tokens: Some(5_000),
308 audit_retention_days: None,
309 redaction,
310 filters: lean_ctx::core::policy::FilterRules::default(),
311 egress: lean_ctx::core::policy::EgressRules::default(),
312 routing: lean_ctx::core::policy::RoutingPolicyRules::default(),
313 budgets: lean_ctx::core::policy::BudgetRules::default(),
314 }));
315
316 assert!(!check_tool_access("ctx_read").blocked);
317 assert!(!check_tool_access("ctx_session").blocked, "exempt tool");
318 let (out, hits) = redact_result("contact EMP-1234 today");
319 assert_eq!(hits, 1);
320 assert!(out.contains("[REDACTED:employee_id]"));
321 assert!(out.starts_with("contact "));
322 assert!(out.ends_with(" today"));
323 }
324 assert!(
325 !check_tool_access("ctx_url_read").blocked,
326 "no pack → allow"
327 );
328 assert_eq!(redact_result("contact EMP-1234 today").1, 0);
329 }
330}