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_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_source(&redacted, "protocol:model-text", false)
180 .unwrap_or_else(|error| format!("[lean-ctx gateway: {error}]"))
181}
182
183pub fn audit_egress(tool: &str, reason: &str, agent_id: &str) {
188 let policy =
189 runtime::active().map_or_else(|| "policy".to_string(), |a| a.resolved.name.clone());
190 lean_ctx::core::events::emit_policy_violation(
191 &policy,
192 tool,
193 &format!("egress blocked: {reason}"),
194 );
195 lean_ctx::core::audit_trail::record(lean_ctx::core::audit_trail::AuditEntryData {
196 agent_id: agent_id.to_string(),
197 tool: tool.to_string(),
198 action: None,
199 input_hash: String::new(),
200 output_tokens: 0,
201 role: policy,
202 event_type: lean_ctx::core::audit_trail::AuditEventType::ToolDenied,
203 });
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use lean_ctx::core::policy::ResolvedPolicy;
210 use std::collections::BTreeMap;
211
212 fn active(allow: Option<Vec<&str>>, deny: Vec<&str>) -> ActivePolicy {
213 ActivePolicy::from_resolved(ResolvedPolicy {
214 name: "acme".into(),
215 version: "1.0.0".into(),
216 description: "t".into(),
217 chain: vec![],
218 default_read_mode: None,
219 allow_tools: allow.map(|a| a.into_iter().map(String::from).collect()),
220 deny_tools: deny.into_iter().map(String::from).collect(),
221 max_context_tokens: None,
222 audit_retention_days: None,
223 redaction: BTreeMap::new(),
224 filters: lean_ctx::core::policy::FilterRules::default(),
225 egress: lean_ctx::core::policy::EgressRules::default(),
226 routing: lean_ctx::core::policy::RoutingPolicyRules::default(),
227 budgets: lean_ctx::core::policy::BudgetRules::default(),
228 })
229 }
230
231 #[test]
232 fn no_pack_allows_everything() {
233 let r = evaluate(None, "ctx_shell");
234 assert!(!r.blocked);
235 }
236
237 #[test]
238 fn deny_tool_is_blocked_with_message() {
239 let p = active(None, vec!["ctx_url_read"]);
240 let r = evaluate(Some(&p), "ctx_url_read");
241 assert!(r.blocked);
242 assert_eq!(r.policy_name.as_deref(), Some("acme"));
243 assert!(r.message.unwrap().contains("[POLICY DENIED]"));
244 }
245
246 #[test]
247 fn allowlist_blocks_unlisted_tool() {
248 let p = active(Some(vec!["ctx_read"]), vec![]);
249 assert!(!evaluate(Some(&p), "ctx_read").blocked);
250 assert!(evaluate(Some(&p), "ctx_shell").blocked);
251 }
252
253 #[test]
254 fn exempt_tools_never_blocked_even_under_allowlist() {
255 let p = active(Some(vec!["ctx_read"]), vec![]);
258 for t in ["ctx", "ctx_session", "ctx_policy"] {
259 assert!(!evaluate(Some(&p), t).blocked, "{t} must be exempt");
260 }
261 }
262
263 #[test]
264 fn protected_clients_cannot_use_operator_recovery_exemptions() {
265 let p = active(Some(vec!["ctx_read"]), vec![]);
266 for tool in EXEMPT_TOOLS {
267 assert!(evaluate_with_exemptions(Some(&p), tool, false).blocked);
268 }
269 assert!(!evaluate_with_exemptions(Some(&p), "ctx_read", false).blocked);
270 }
271
272 #[test]
273 fn into_result_renders_denial() {
274 let p = active(None, vec!["ctx_shell"]);
275 let r = evaluate(Some(&p), "ctx_shell");
276 assert_eq!(
278 into_call_tool_result(&r).and_then(|denied| denied.is_error),
279 Some(true)
280 );
281 let allowed = evaluate(None, "ctx_shell");
282 assert!(into_call_tool_result(&allowed).is_none());
283 }
284
285 #[test]
290 fn global_active_drives_allow_and_redaction() {
291 let mut redaction = BTreeMap::new();
292 redaction.insert("employee_id".to_string(), r"EMP-\d{4}".to_string());
293 {
294 let _policy = runtime::TestPolicyOverride::set(Some(ResolvedPolicy {
295 name: "itest".into(),
296 version: "1.0.0".into(),
297 description: "t".into(),
298 chain: vec![],
299 default_read_mode: Some("map".into()),
300 allow_tools: None,
301 deny_tools: vec!["ctx_url_read".into()],
302 max_context_tokens: Some(5_000),
303 audit_retention_days: None,
304 redaction,
305 filters: lean_ctx::core::policy::FilterRules::default(),
306 egress: lean_ctx::core::policy::EgressRules::default(),
307 routing: lean_ctx::core::policy::RoutingPolicyRules::default(),
308 budgets: lean_ctx::core::policy::BudgetRules::default(),
309 }));
310
311 assert!(!check_tool_access("ctx_read").blocked);
312 assert!(!check_tool_access("ctx_session").blocked, "exempt tool");
313 let (out, hits) = redact_result("contact EMP-1234 today");
314 assert_eq!(hits, 1);
315 assert!(out.contains("[REDACTED:employee_id]"));
316 assert!(out.starts_with("contact "));
317 assert!(out.ends_with(" today"));
318 }
319 assert!(
320 !check_tool_access("ctx_url_read").blocked,
321 "no pack → allow"
322 );
323 assert_eq!(redact_result("contact EMP-1234 today").1, 0);
324 }
325}