Skip to main content

lean_ctx/server/
policy_guard.rs

1//! Context-policy-pack enforcement for the MCP server pipeline (GL #673).
2//!
3//! Consults the resolved active policy ([`crate::core::policy::runtime`]) to
4//! allow/deny tool calls, in addition to the [`super::role_guard`]. This is the
5//! runtime half of Context Policy Packs v1 (GL #489), whose engine module ships
6//! the format/validation/CLI and defers enforcement to here.
7//!
8//! - **Opt-in:** with no active pack, every tool is allowed (current behavior).
9//! - **Local-Free:** only the agent pipeline is constrained, never a human's
10//!   own local reads.
11//! - **No self-lockout:** the `EXEMPT_TOOLS` meta tools can never be
12//!   policy-denied, so an operator can always switch roles/policies back out.
13
14use rmcp::model::{CallToolResult, Content};
15
16use crate::core::policy::runtime::{self, ActivePolicy};
17
18/// Tools that can never be policy-denied (mirror role_guard's session/meta
19/// exemption), so a pack can't lock the operator out of fixing the policy.
20const 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
28/// Check whether `tool_name` is allowed by the active policy pack, recording an
29/// audit entry on denial (same APIs as [`super::role_guard`]).
30pub 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
53/// Pure decision (no side effects) — the audit-free core, unit-tested directly.
54fn 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/// Apply the active policy's redaction patterns to outbound tool result text.
104/// Returns the (possibly redacted) text and the number of redactions applied.
105/// No-op (`hits == 0`, original text) when no pack is active or it has no
106/// `[redaction]` block.
107#[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
117/// Audit a content-filter decision (GL #675). **Privacy-preserving**: records
118/// only the detector classes and counts (e.g. `pii:iban×2`) — never the matched
119/// values. A `blocked` decision additionally surfaces a policy-violation event;
120/// redactions are recorded as `SecretDetected` for the compliance ledger.
121pub 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
154/// Audit a blocked egress (write/action) DLP decision (GL #676).
155/// **Privacy-preserving**: records the rule/class label (`forbidden-pattern:…`,
156/// `secret`, `pii:…`, `rate-limit`) — never the matched content.
157pub 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        // An allowlist of only ctx_read must still let the operator reach the
220        // policy/session meta-tools to recover.
221        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    /// End-to-end through the global runtime cache: the public allow path and
237    /// `redact_result` must reflect the active pack, and clearing it restores
238    /// the unrestricted default. (Deny audit side-effects are covered by the
239    /// pure `evaluate` tests, so this stays disk-free.)
240    #[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}