Skip to main content

lean_ctx/core/policy/
content.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Shared, local policy decision before content is released or persisted.
3//!
4//! Callers retain access/audit and format ownership. In particular, a signed
5//! snapshot must reject a rewrite instead of publishing a stale content digest.
6
7use super::runtime::ActivePolicy;
8use crate::core::input_filters::{self, FilterAction, FilterConfig, FilterOutcome};
9use crate::core::session::EngineSessionId;
10
11/// A protected output must be inspectable as one bounded unit. Larger inputs
12/// are withheld rather than partially scanned and returned as if safe.
13pub const MAX_PROTECTED_CONTENT_BYTES: usize = 8 * 1024 * 1024;
14pub(super) const MAX_REDACTION_RULES: usize = 128;
15pub(super) const MAX_RULE_LABEL_BYTES: usize = 64;
16pub(super) const MAX_RULE_PATTERN_BYTES: usize = 4096;
17
18fn withheld(reason: &str) -> FilterOutcome {
19    FilterOutcome {
20        blocked: true,
21        block_reason: Some(reason.into()),
22        audit: vec![(reason.into(), 1)],
23        ..FilterOutcome::default()
24    }
25}
26
27/// Evaluate blocking detectors on the original text, before any redaction can
28/// hide a blocking signal. Reuse the policy's precompiled patterns and return
29/// only sanitized text plus content-free rule/count metadata.
30#[must_use]
31pub fn evaluate_text(text: &str, active: &ActivePolicy) -> FilterOutcome {
32    evaluate_text_with_filters(text, active, &active.filters)
33}
34
35fn evaluate_text_with_filters(
36    text: &str,
37    active: &ActivePolicy,
38    filters: &FilterConfig,
39) -> FilterOutcome {
40    let mut outcome = evaluate_pack_rules(text, active, filters);
41    if outcome.blocked {
42        return outcome;
43    }
44    // Active protection cannot be disabled by the compression/raw escape hatch
45    // or by the user's optional local-secret-redaction setting.
46    outcome.text = crate::core::redaction::redact_text(&outcome.text);
47    if outcome.text.len() > MAX_PROTECTED_CONTENT_BYTES {
48        return withheld("content-size-limit");
49    }
50    // Filtering took time: nothing is handed back as permitted under a
51    // policy whose signed horizon passed meanwhile.
52    if !active.within_horizon() {
53        return withheld(EXPIRED);
54    }
55    outcome
56}
57
58/// Reason for content withheld because the policy's signed horizon passed.
59const EXPIRED: &str = "expired-content-policy";
60
61/// What the release point may do with one finished text under the pack.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ReleaseCheck {
64    /// The pack leaves the text exactly as it is.
65    Unchanged,
66    /// No rule of the pack blocks or rewrites the text; only the built-in
67    /// secret mask is outstanding, and the masked text is one the pack then
68    /// leaves exactly as it is.
69    BuiltinSecretOnly,
70    /// A rule of the pack blocks the text or would rewrite it.
71    Refused,
72}
73
74/// Tell a text that only needs the built-in secret mask from one a rule of the
75/// pack itself objects to. The first may be masked and delivered where the
76/// position allows a rewrite; the second is never rewritten at the release
77/// point (a finished result the pack would change is stale).
78pub fn release_check(text: &str, active: &ActivePolicy) -> ReleaseCheck {
79    let own = evaluate_pack_rules(text, active, &active.filters);
80    if own.blocked || own.text != text {
81        return ReleaseCheck::Refused;
82    }
83    let masked = crate::core::redaction::redact_text(text);
84    if masked == text {
85        // The pack's rules ran above; the release itself is decided now.
86        return if active.within_horizon() {
87            ReleaseCheck::Unchanged
88        } else {
89            ReleaseCheck::Refused
90        };
91    }
92    // Like with like: the masked text, evaluated in full, must be a fixed
93    // point — no rule of the pack fires on it and a second mask changes
94    // nothing. Otherwise the result cannot be released in a settled form.
95    let settled = evaluate_text(&masked, active);
96    if settled.blocked || settled.text != masked {
97        return ReleaseCheck::Refused;
98    }
99    ReleaseCheck::BuiltinSecretOnly
100}
101
102/// The pack's own rules: blocking detectors on the original text, then the
103/// pack's filters and `[redaction]` entries. Without the built-in secret mask.
104fn evaluate_pack_rules(text: &str, active: &ActivePolicy, filters: &FilterConfig) -> FilterOutcome {
105    if !active.content_valid {
106        return withheld("invalid-content-policy");
107    }
108    // Before any rule is consulted: a policy past its signed horizon decides
109    // nothing, however it was obtained and however long it was kept.
110    if !active.within_horizon() {
111        return withheld(EXPIRED);
112    }
113    if text.len() > MAX_PROTECTED_CONTENT_BYTES {
114        return withheld("content-size-limit");
115    }
116    if text.contains('\0') {
117        return withheld("uninspectable-content");
118    }
119    if active.redaction.len() + active.blocked_patterns.len()
120        > MAX_REDACTION_RULES
121            + active
122                .hive_rules
123                .min(crate::core::policy::hive_filters::HIVE_MAX_RULES)
124        || active
125            .redaction
126            .iter()
127            .chain(&active.blocked_patterns)
128            .any(|(label, pattern)| {
129                label.len() > MAX_RULE_LABEL_BYTES
130                    || pattern.as_str().len() > MAX_RULE_PATTERN_BYTES
131                    || pattern.is_match("")
132            })
133    {
134        return withheld("content-rule-limit");
135    }
136
137    for (label, pattern) in &active.blocked_patterns {
138        if pattern.is_match(text) {
139            return FilterOutcome {
140                blocked: true,
141                block_reason: Some("content-pattern-blocked".into()),
142                audit: vec![(format!("block:{label}"), 1)],
143                ..FilterOutcome::default()
144            };
145        }
146    }
147    let mut outcome = input_filters::apply(text, filters);
148    if outcome.blocked {
149        return outcome;
150    }
151    for (label, pattern) in &active.redaction {
152        let marker = format!("[REDACTED:{label}]");
153        let mut output = String::with_capacity(outcome.text.len());
154        let mut end = 0;
155        let mut hits = 0;
156        for matched in pattern.find_iter(&outcome.text) {
157            let prefix = &outcome.text[end..matched.start()];
158            if output.len() + prefix.len() + marker.len() > MAX_PROTECTED_CONTENT_BYTES {
159                return withheld("content-size-limit");
160            }
161            output.push_str(prefix);
162            output.push_str(&marker);
163            end = matched.end();
164            hits += 1;
165        }
166        let suffix = &outcome.text[end..];
167        if output.len() + suffix.len() > MAX_PROTECTED_CONTENT_BYTES {
168            return withheld("content-size-limit");
169        }
170        output.push_str(suffix);
171        outcome.text = output;
172        if hits > 0 {
173            outcome.audit.push((format!("redaction:{label}"), hits));
174        }
175    }
176    if !active.within_horizon() {
177        return withheld(EXPIRED);
178    }
179    outcome
180}
181
182/// Shared storage/transport boundary. Warnings remain caller-owned; never
183/// append them to canonical stored content or change a signed payload silently.
184pub fn protect_active(text: &str) -> Result<std::borrow::Cow<'_, str>, &'static str> {
185    let Some(active) = super::runtime::active() else {
186        return Ok(std::borrow::Cow::Borrowed(text));
187    };
188    let outcome = evaluate_text(text, &active);
189    if outcome.blocked {
190        Err("content withheld by policy")
191    } else {
192        Ok(std::borrow::Cow::Owned(outcome.text))
193    }
194}
195
196/// Protect a validated Engine identity with project rules while skipping only
197/// the project PII detector, which can mistake the legacy timestamp format for
198/// a payment card. Explicit block/redaction rules remain active.
199pub(crate) fn protect_active_engine_identity(
200    id: EngineSessionId<'_>,
201) -> Result<std::borrow::Cow<'_, str>, &'static str> {
202    let text = id.as_str();
203    let Some(active) = super::runtime::active() else {
204        return Ok(std::borrow::Cow::Borrowed(text));
205    };
206    protect_engine_identity_with_active(id, &active)
207}
208
209pub(crate) fn protect_engine_identity_with_active<'a>(
210    id: EngineSessionId<'a>,
211    active: &ActivePolicy,
212) -> Result<std::borrow::Cow<'a, str>, &'static str> {
213    let text = id.as_str();
214    let filters = FilterConfig::new(
215        FilterAction::Off,
216        active.filters.classification,
217        active.filters.injection,
218        &active.resolved.filters.blocked_labels,
219    );
220    let outcome = evaluate_text_with_filters(text, active, &filters);
221    if outcome.blocked {
222        Err("content withheld by policy")
223    } else {
224        Ok(std::borrow::Cow::Owned(outcome.text))
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn policy(extra: &str) -> ActivePolicy {
233        let pack = crate::core::policy::parse(&format!(
234            "name = \"content-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n{extra}"
235        ))
236        .expect("valid policy");
237        ActivePolicy::from_resolved(crate::core::policy::resolve(&pack).expect("resolved policy"))
238    }
239
240    #[test]
241    fn customer_numbers_are_redacted_with_content_free_audit() {
242        let active = policy("[redaction]\ncustomer = 'K-[0-9]{6}'");
243        let outcome = evaluate_text("Grüezi 客户 K-482193 / K-482194", &active);
244        assert!(!outcome.blocked);
245        assert_eq!(
246            outcome.text,
247            "Grüezi 客户 [REDACTED:customer] / [REDACTED:customer]"
248        );
249        assert_eq!(outcome.audit, vec![("redaction:customer".into(), 2)]);
250    }
251
252    #[test]
253    fn named_blocks_see_original_content_before_masking() {
254        let active = policy(
255            "[redaction]\nmask = 'K-[0-9]{6}'\n[filters.blocked_patterns]\ncustomer = 'K-482193'",
256        );
257        let blocked = evaluate_text("Contact K-482193", &active);
258        assert!(blocked.blocked);
259        assert!(blocked.text.is_empty(), "{:?}", blocked.text);
260        assert_eq!(blocked.audit, vec![("block:customer".into(), 1)]);
261        assert!(!format!("{blocked:?}").contains("K-482193"));
262        let allowed = evaluate_text("Contact K-482194", &active);
263        assert!(!allowed.blocked);
264        assert_eq!(allowed.text, "Contact [REDACTED:mask]");
265    }
266
267    #[test]
268    fn organization_block_cannot_be_replaced_by_same_named_local_pattern() {
269        let org = policy("[filters.blocked_patterns]\ncustomer = 'ORG-[0-9]+'").resolved;
270        let local = policy("[filters.blocked_patterns]\ncustomer = 'LOCAL-[0-9]+'").resolved;
271        let merged = ActivePolicy::from_resolved(crate::core::policy::floor::merge_floor(
272            &org,
273            Some(&local),
274        ));
275        assert!(evaluate_text("ORG-17", &merged).blocked);
276        assert!(evaluate_text("LOCAL-18", &merged).blocked);
277        assert!(!evaluate_text("ordinary context", &merged).blocked);
278        let invalid = policy("[filters.blocked_patterns]\ncustomer = 'x*'");
279        assert!(evaluate_text("ordinary context", &invalid).blocked);
280    }
281
282    #[test]
283    fn redaction_cannot_hide_a_classification_block() {
284        let active =
285            policy("[redaction]\nlabel = 'CONFIDENTIAL'\n[filters]\nclassification = 'block'");
286        let outcome = evaluate_text("CONFIDENTIAL\nK-482193", &active);
287        assert!(outcome.blocked);
288        assert!(outcome.text.is_empty(), "{:?}", outcome.text);
289        assert!(!format!("{:?}", outcome.audit).contains("K-482193"));
290    }
291
292    #[test]
293    fn pii_redaction_and_company_rules_both_apply() {
294        let active = policy("[redaction]\ncustomer = 'K-[0-9]{6}'\n[filters]\npii = 'redact'");
295        let outcome = evaluate_text("K-482193 contact alice@example.com", &active);
296        assert!(!outcome.blocked);
297        assert!(!outcome.text.contains("K-482193"));
298        assert!(!outcome.text.contains("alice@example.com"));
299    }
300
301    #[test]
302    fn binary_and_oversized_content_are_withheld_without_echo() {
303        let active = policy("");
304        for text in [
305            "customer\0K-482193".to_string(),
306            "x".repeat(MAX_PROTECTED_CONTENT_BYTES + 1),
307        ] {
308            let outcome = evaluate_text(&text, &active);
309            assert!(outcome.blocked);
310            assert!(outcome.text.is_empty(), "{:?}", outcome.text);
311        }
312    }
313
314    #[test]
315    fn empty_matching_rules_are_rejected_without_expansion() {
316        let outcome = evaluate_text("K-482193", &policy("[redaction]\nempty = 'x*'"));
317        assert!(outcome.blocked);
318        assert_eq!(outcome.block_reason.as_deref(), Some("content-rule-limit"));
319        assert!(outcome.text.is_empty(), "{:?}", outcome.text);
320    }
321
322    #[test]
323    fn updated_policy_rechecks_previously_released_content() {
324        let text = "K-482193";
325        assert_eq!(evaluate_text(text, &policy("")).text, text);
326        let stricter = policy("[redaction]\ncustomer = 'K-[0-9]{6}'");
327        assert_eq!(evaluate_text(text, &stricter).text, "[REDACTED:customer]");
328    }
329
330    #[test]
331    fn engine_identity_skips_only_generic_pii_and_still_honors_project_blocks() {
332        let id_text = "20261004-123406-123456p43210s0";
333        let id = crate::core::session::parse_engine_session_id(id_text).expect("legacy ID");
334        let pii_policy = policy("[filters]\npii = 'redact'");
335        // The standard Luhn-valid test number in groups of four, assembled at
336        // run time so the source carries no card-shaped literal.
337        let group = "1".repeat(4);
338        let card = format!("4{} {group} {group} {group}", "1".repeat(3));
339        let generic = evaluate_text(&format!("paid with {card}"), &pii_policy);
340        assert!(
341            !generic.blocked && !generic.text.contains(card.as_str()),
342            "free text stays under the PII detector"
343        );
344        // Changed expectation: this asserted that the id itself is masked in
345        // free text. Since the 3.11 detector rule (`pii::card_layout_plausible`:
346        // a separated digit run is a card only in an issuer layout) its
347        // `YYYYmmdd-HHMMSS` run is no card, and the id is delivered as written.
348        assert_eq!(evaluate_text(id_text, &pii_policy).text.as_str(), id_text);
349        assert_eq!(
350            protect_engine_identity_with_active(id, &pii_policy)
351                .expect("typed identity remains intact")
352                .as_ref(),
353            id_text
354        );
355
356        let blocked_policy =
357            policy("[filters]\npii = 'redact'\n[filters.blocked_patterns]\nlegacy_id = '123406'");
358        assert!(
359            protect_engine_identity_with_active(id, &blocked_policy).is_err(),
360            "project blocked_patterns must still reject typed IDs"
361        );
362    }
363
364    #[test]
365    fn invalid_or_excessive_rules_cannot_silently_disappear() {
366        for pattern in ["[".to_string(), "x".repeat(MAX_RULE_PATTERN_BYTES + 1)] {
367            let mut resolved = policy("").resolved;
368            resolved.redaction.insert("invalid".into(), pattern);
369            let active = ActivePolicy::from_resolved(resolved);
370            assert!(!active.tool_allowed("ctx_read"));
371            assert!(evaluate_text("K-482193", &active).blocked);
372        }
373        let mut resolved = policy("").resolved;
374        for n in 0..=MAX_REDACTION_RULES {
375            resolved
376                .redaction
377                .insert(format!("rule{n}"), "customer".into());
378        }
379        assert!(evaluate_text("customer", &ActivePolicy::from_resolved(resolved)).blocked);
380        assert!(evaluate_text("customer", &ActivePolicy::deny_all()).blocked);
381    }
382}
383
384#[cfg(any(test, feature = "test-support"))]
385/// A protected policy (`extra` added to a minimal pack) for this thread until
386/// the guard drops. Shared by the storage tests and the proxy's CCR tests.
387pub fn protected_for_test(extra: &str) -> super::runtime::TestPolicyOverride {
388    let pack = super::parse(&format!(
389        "name = \"storage-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n{extra}"
390    ))
391    .expect("test pack");
392    super::runtime::TestPolicyOverride::set(Some(super::resolve(&pack).expect("test pack")))
393}
394
395#[cfg(test)]
396#[path = "content_storage_tests.rs"]
397pub(crate) mod storage_tests;