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_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    lean_ctx::core::context_admission::admit_source(&redacted, "protocol:model-text", false)
180        .unwrap_or_else(|error| format!("[lean-ctx gateway: {error}]"))
181}
182
183/// Audit a blocked egress (write/action) DLP decision (GL #676).
184/// **Privacy-preserving**: records the rule/class label (`forbidden-pattern:…`,
185/// `secret`, `pii:…`, `rate-limit`) — never the matched content — for the
186/// agent `agent_id` that attempted it.
187pub 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        // An allowlist of only ctx_read must still let the operator reach the
256        // policy/session meta-tools to recover.
257        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        // A denial is a tool error, not a successful result.
277        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    /// End-to-end through the global runtime cache: the public allow path and
286    /// `redact_result` must reflect the active pack, and clearing it restores
287    /// the unrestricted default. (Deny audit side-effects are covered by the
288    /// pure `evaluate` tests, so this stays disk-free.)
289    #[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}