1use super::runtime::ActivePolicy;
8use crate::core::input_filters::{self, FilterAction, FilterConfig, FilterOutcome};
9use crate::core::session::EngineSessionId;
10
11pub 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#[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 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 if !active.within_horizon() {
53 return withheld(EXPIRED);
54 }
55 outcome
56}
57
58const EXPIRED: &str = "expired-content-policy";
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ReleaseCheck {
64 Unchanged,
66 BuiltinSecretOnly,
70 Refused,
72}
73
74pub 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 return if active.within_horizon() {
87 ReleaseCheck::Unchanged
88 } else {
89 ReleaseCheck::Refused
90 };
91 }
92 let settled = evaluate_text(&masked, active);
96 if settled.blocked || settled.text != masked {
97 return ReleaseCheck::Refused;
98 }
99 ReleaseCheck::BuiltinSecretOnly
100}
101
102fn evaluate_pack_rules(text: &str, active: &ActivePolicy, filters: &FilterConfig) -> FilterOutcome {
105 if !active.content_valid {
106 return withheld("invalid-content-policy");
107 }
108 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
182pub 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
196pub(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 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 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"))]
385pub 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;