Skip to main content

lean_ctx_server/server/
policy_guard.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Context-policy-pack enforcement for the MCP server pipeline (GL #673).
3//!
4//! Consults the resolved active policy ([`lean_ctx::core::policy::runtime`]) to
5//! allow/deny tool calls, in addition to the [`super::role_guard`]. This is the
6//! runtime half of Context Policy Packs v1 (GL #489), whose engine module ships
7//! the format/validation/CLI and defers enforcement to here.
8//!
9//! - **Opt-in:** with no active pack, every tool is allowed (current behavior).
10//! - **Local-Free:** only the agent pipeline is constrained, never a human's
11//!   own local reads.
12//! - **No self-lockout:** the `EXEMPT_TOOLS` meta tools can never be
13//!   policy-denied, so an operator can always switch roles/policies back out.
14
15use 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
24/// Tools that can never be policy-denied (mirror role_guard's session/meta
25/// exemption), so a pack can't lock the operator out of fixing the policy.
26const 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
34/// [`check_tool_access_as`] for this process's agent identity
35/// (`LEAN_CTX_AGENT_ID`, else `local`), for callers without an MCP session.
36pub 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
43/// Check whether `tool_name` is allowed by the active policy pack, recording an
44/// audit entry for `agent_id` on denial (same APIs as [`super::role_guard`]).
45pub 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/// Pure decision (no side effects) — the audit-free core, unit-tested directly.
73#[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    // Protected MCP clients are not the operator. Recovery remains available
84    // through the human's separate CLI, never by relaxing the model's boundary.
85    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
122/// The denial as a tool error the agent can read; `None` when allowed.
123pub 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/// Apply the active policy's redaction patterns to outbound tool result text.
135/// Returns the (possibly redacted) text and the number of redactions applied.
136/// No-op (`hits == 0`, original text) when no pack is active or it has no
137/// `[redaction]` block.
138#[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
148/// Shared content decision for mutable tool output. A denial never echoes the
149/// input; structured/signed callers use `content::evaluate_text` directly and
150/// reject rewrites before computing their final digest.
151pub 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
171/// Apply the policy-pack and built-in secret redactors to text sent to a model
172/// by protocol surfaces outside the MCP tool pipeline, then admit it through
173/// the context gateway like any other delivery: PII is masked, withheld
174/// content is replaced by a content-free reason, and the decision is recorded
175/// in the current call's receipt when one is open.
176pub 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    // The model's own text, assembled by the Instance.
180    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
188/// Audit a blocked egress (write/action) DLP decision (GL #676).
189/// **Privacy-preserving**: records the rule/class label (`forbidden-pattern:…`,
190/// `secret`, `pii:…`, `rate-limit`) — never the matched content — for the
191/// agent `agent_id` that attempted it.
192pub 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        // An allowlist of only ctx_read must still let the operator reach the
261        // policy/session meta-tools to recover.
262        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        // A denial is a tool error, not a successful result.
282        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    /// End-to-end through the global runtime cache: the public allow path and
291    /// `redact_result` must reflect the active pack, and clearing it restores
292    /// the unrestricted default. (Deny audit side-effects are covered by the
293    /// pure `evaluate` tests, so this stays disk-free.)
294    #[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}