1use once_cell::sync::Lazy;
9use regex::Regex;
10use serde_json::{json, Value};
11use std::collections::BTreeSet;
12use std::env;
13use std::fmt;
14
15static PRIVATE_KEY_RE: Lazy<std::result::Result<Regex, regex::Error>> = Lazy::new(|| {
16 Regex::new(r"(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----")
17});
18static JWT_RE: Lazy<std::result::Result<Regex, regex::Error>> =
19 Lazy::new(|| Regex::new(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"));
20static BEARER_RE: Lazy<std::result::Result<Regex, regex::Error>> =
21 Lazy::new(|| Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{16,}"));
22static OAUTH_RE: Lazy<std::result::Result<Regex, regex::Error>> = Lazy::new(|| {
23 Regex::new(
24 r"\b(?:ya29\.[A-Za-z0-9_-]+|gh[opsu]_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})\b",
25 )
26});
27static CLOUD_CREDENTIAL_RE: Lazy<std::result::Result<Regex, regex::Error>> =
28 Lazy::new(|| Regex::new(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b|\bAIza[0-9A-Za-z_-]{35}\b"));
29static DATABASE_URL_RE: Lazy<std::result::Result<Regex, regex::Error>> = Lazy::new(|| {
30 Regex::new(r#"(?i)\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis)://[^\s'"]+"#)
31});
32static COOKIE_RE: Lazy<std::result::Result<Regex, regex::Error>> =
33 Lazy::new(|| Regex::new(r"(?i)\b(?:cookie|set-cookie)\s*[:=]\s*[^\n]+"));
34static ENV_SECRET_RE: Lazy<std::result::Result<Regex, regex::Error>> = Lazy::new(|| {
35 Regex::new(
36 r#"(?mi)^(\s*[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD|CREDENTIAL|AUTH)[A-Z0-9_]*\s*=\s*)['"]?[^\s'"]+"#,
37 )
38});
39static API_KEY_RE: Lazy<std::result::Result<Regex, regex::Error>> = Lazy::new(|| {
40 Regex::new(
41 r#"(?i)\b([A-Z0-9_]*(?:api[_-]?key|apikey|token|secret|password|passwd|pwd|client_secret|access_token|refresh_token)[A-Z0-9_]*\b\s*[:=]\s*)['"]?[^\s'",;]{8,}"#,
42 )
43});
44static EMAIL_RE: Lazy<std::result::Result<Regex, regex::Error>> =
45 Lazy::new(|| Regex::new(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b"));
46static PHONE_RE: Lazy<std::result::Result<Regex, regex::Error>> =
47 Lazy::new(|| Regex::new(r"\b\+?\d[\d .()/-]{7,}\d\b"));
48
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct OperationalContextPolicy {
52 pub redact_emails: bool,
54 pub redact_phone_numbers: bool,
56 pub allow_sensitive_raw: bool,
58 pub allow_sensitive_command_persistence: bool,
60 pub custom_secret_patterns: Vec<String>,
62}
63
64impl OperationalContextPolicy {
65 pub fn from_env() -> Self {
73 let mut policy = Self::default();
74 if let Some(value) = env_bool("ENGRAM_OC_REDACT_EMAILS") {
75 policy.redact_emails = value;
76 }
77 if let Some(value) = env_bool("ENGRAM_OC_REDACT_PHONE_NUMBERS") {
78 policy.redact_phone_numbers = value;
79 }
80 if let Some(value) = env_bool("ENGRAM_OC_ALLOW_SENSITIVE_RAW") {
81 policy.allow_sensitive_raw = value;
82 }
83 if let Some(value) = env_bool("ENGRAM_OC_ALLOW_SENSITIVE_COMMAND_PERSISTENCE") {
84 policy.allow_sensitive_command_persistence = value;
85 }
86 policy
87 }
88
89 pub fn from_params(params: &Value) -> Self {
94 let mut policy = Self::from_env();
95 policy.apply_value(params);
96 for key in [
97 "operational_context_policy",
98 "context_policy",
99 "redaction_policy",
100 ] {
101 if let Some(value) = params.get(key) {
102 policy.apply_value(value);
103 }
104 }
105 policy
106 }
107
108 pub fn redact_text(&self, input: &str) -> std::result::Result<RedactedText, PolicyError> {
110 let mut text = input.to_string();
111 let mut classes = BTreeSet::new();
112
113 apply_lazy_regex(
114 &mut text,
115 &PRIVATE_KEY_RE,
116 "[REDACTED:private_key]",
117 "private_key",
118 &mut classes,
119 )?;
120 apply_lazy_regex(&mut text, &JWT_RE, "[REDACTED:jwt]", "jwt", &mut classes)?;
121 apply_lazy_regex(
122 &mut text,
123 &BEARER_RE,
124 "Bearer [REDACTED:bearer_token]",
125 "bearer_token",
126 &mut classes,
127 )?;
128 apply_lazy_regex(
129 &mut text,
130 &OAUTH_RE,
131 "[REDACTED:oauth_token]",
132 "oauth_token",
133 &mut classes,
134 )?;
135 apply_lazy_regex(
136 &mut text,
137 &CLOUD_CREDENTIAL_RE,
138 "[REDACTED:cloud_credential]",
139 "cloud_credential",
140 &mut classes,
141 )?;
142 apply_lazy_regex(
143 &mut text,
144 &DATABASE_URL_RE,
145 "[REDACTED:database_url]",
146 "database_url",
147 &mut classes,
148 )?;
149 apply_lazy_regex(
150 &mut text,
151 &COOKIE_RE,
152 "[REDACTED:cookie]",
153 "cookie",
154 &mut classes,
155 )?;
156 apply_lazy_regex(
157 &mut text,
158 &ENV_SECRET_RE,
159 "$1[REDACTED:env_secret]",
160 "env_secret",
161 &mut classes,
162 )?;
163 apply_lazy_regex(
164 &mut text,
165 &API_KEY_RE,
166 "$1[REDACTED:api_key]",
167 "api_key",
168 &mut classes,
169 )?;
170
171 if self.redact_emails {
172 apply_lazy_regex(
173 &mut text,
174 &EMAIL_RE,
175 "[REDACTED:email]",
176 "email",
177 &mut classes,
178 )?;
179 }
180 if self.redact_phone_numbers {
181 apply_lazy_regex(
182 &mut text,
183 &PHONE_RE,
184 "[REDACTED:phone]",
185 "phone",
186 &mut classes,
187 )?;
188 }
189
190 for pattern in &self.custom_secret_patterns {
191 let re = Regex::new(pattern).map_err(|err| {
192 PolicyError::RedactionFailed(format!("invalid custom redaction pattern: {err}"))
193 })?;
194 if re.is_match(&text) {
195 text = re
196 .replace_all(&text, "[REDACTED:custom_secret]")
197 .into_owned();
198 classes.insert("custom_secret".to_string());
199 }
200 }
201
202 let redacted = !classes.is_empty();
203 Ok(RedactedText {
204 text,
205 redacted,
206 classes: classes.into_iter().collect(),
207 })
208 }
209
210 pub fn analyze_command(&self, command: Option<&str>) -> SensitiveCommandAnalysis {
212 let Some(command) = command.map(str::trim).filter(|s| !s.is_empty()) else {
213 return SensitiveCommandAnalysis::default();
214 };
215
216 let lower = command.to_ascii_lowercase();
217 let normalized = lower.split_whitespace().collect::<Vec<_>>().join(" ");
218 let mut analysis = SensitiveCommandAnalysis::default();
219
220 if touches_env_file(&normalized) {
221 analysis.add_reason("env_file_access");
222 }
223 if normalized == "printenv"
224 || normalized.starts_with("printenv ")
225 || normalized == "env"
226 || normalized.starts_with("env |")
227 || normalized.starts_with("env >")
228 {
229 analysis.add_reason("environment_dump");
230 }
231 if normalized.contains("aws sts") {
232 analysis.add_reason("aws_sts");
233 }
234 if normalized.contains("aws secretsmanager")
235 || normalized.contains("aws ssm get-parameter")
236 || normalized.contains("aws configure")
237 {
238 analysis.add_reason("cloud_secret_command");
239 }
240 if normalized.contains("gh auth token")
241 || normalized.contains("gh auth status --show-token")
242 {
243 analysis.add_reason("github_auth_token");
244 }
245 if normalized.contains("kubectl get secrets")
246 || normalized.contains("kubectl get secret")
247 || normalized.contains("kubectl describe secret")
248 {
249 analysis.add_reason("kubernetes_secret_command");
250 }
251 if (normalized.contains("prod") || normalized.contains("production"))
252 && (normalized.contains(" log")
253 || normalized.contains(" logs")
254 || normalized.contains("dump")
255 || normalized.contains("journalctl")
256 || normalized.contains("kubectl logs"))
257 {
258 analysis.add_reason("production_log_dump");
259 }
260 if (normalized.contains("ci") || normalized.contains("github actions"))
261 && (normalized.contains(" log")
262 || normalized.contains("logs")
263 || normalized.contains("artifact"))
264 {
265 analysis.add_reason("ci_log_artifact");
266 }
267
268 analysis
269 }
270
271 pub fn force_ephemeral(&self, analysis: &SensitiveCommandAnalysis) -> bool {
273 analysis.is_sensitive && !self.allow_sensitive_command_persistence
274 }
275
276 pub fn allow_raw_for(&self, analysis: &SensitiveCommandAnalysis) -> bool {
278 !analysis.is_sensitive || self.allow_sensitive_raw
279 }
280
281 fn apply_value(&mut self, value: &Value) {
282 if let Some(v) = value.get("redact_emails").and_then(parse_bool_value) {
283 self.redact_emails = v;
284 }
285 if let Some(v) = value
286 .get("redact_phone_numbers")
287 .or_else(|| value.get("redact_phones"))
288 .and_then(parse_bool_value)
289 {
290 self.redact_phone_numbers = v;
291 }
292 if let Some(v) = value.get("allow_sensitive_raw").and_then(parse_bool_value) {
293 self.allow_sensitive_raw = v;
294 }
295 if let Some(v) = value
296 .get("allow_sensitive_command_persistence")
297 .or_else(|| value.get("allow_sensitive_persistence"))
298 .and_then(parse_bool_value)
299 {
300 self.allow_sensitive_command_persistence = v;
301 }
302 if let Some(patterns) = value
303 .get("custom_secret_patterns")
304 .and_then(Value::as_array)
305 {
306 self.custom_secret_patterns = patterns
307 .iter()
308 .filter_map(|v| v.as_str().map(str::to_string))
309 .collect();
310 }
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct RedactedText {
317 pub text: String,
318 pub redacted: bool,
319 pub classes: Vec<String>,
320}
321
322#[derive(Debug, Clone, Default, PartialEq, Eq)]
324pub struct RedactionReport {
325 classes: BTreeSet<String>,
326 fields: BTreeSet<String>,
327}
328
329impl RedactionReport {
330 pub fn new() -> Self {
331 Self::default()
332 }
333
334 pub fn record(&mut self, field: &str, redacted: &RedactedText) {
335 if redacted.redacted {
336 self.fields.insert(field.to_string());
337 for class in &redacted.classes {
338 self.classes.insert(class.clone());
339 }
340 }
341 }
342
343 pub fn has_redactions(&self) -> bool {
344 !self.classes.is_empty()
345 }
346
347 pub fn to_value(
348 &self,
349 policy: &OperationalContextPolicy,
350 sensitive: &SensitiveCommandAnalysis,
351 raw_persistence: &str,
352 ) -> Value {
353 json!({
354 "status": if self.has_redactions() { "redacted" } else { "clean" },
355 "classes": self.classes.iter().cloned().collect::<Vec<_>>(),
356 "fields": self.fields.iter().cloned().collect::<Vec<_>>(),
357 "sensitive_command": sensitive.is_sensitive,
358 "sensitive_reasons": sensitive.reasons.clone(),
359 "forced_ephemeral": policy.force_ephemeral(sensitive),
360 "raw_persistence": raw_persistence,
361 "overrides": {
362 "allow_sensitive_raw": policy.allow_sensitive_raw,
363 "allow_sensitive_command_persistence": policy.allow_sensitive_command_persistence,
364 },
365 "policy": {
366 "redact_emails": policy.redact_emails,
367 "redact_phone_numbers": policy.redact_phone_numbers,
368 "custom_secret_patterns_count": policy.custom_secret_patterns.len(),
369 }
370 })
371 }
372}
373
374#[derive(Debug, Clone, Default, PartialEq, Eq)]
376pub struct SensitiveCommandAnalysis {
377 pub is_sensitive: bool,
378 pub reasons: Vec<String>,
379}
380
381impl SensitiveCommandAnalysis {
382 pub fn add_reason(&mut self, reason: &str) {
383 self.is_sensitive = true;
384 if !self.reasons.iter().any(|r| r == reason) {
385 self.reasons.push(reason.to_string());
386 }
387 }
388
389 pub fn merge(&mut self, other: SensitiveCommandAnalysis) {
390 if other.is_sensitive {
391 self.is_sensitive = true;
392 }
393 for reason in other.reasons {
394 if !self.reasons.iter().any(|r| r == &reason) {
395 self.reasons.push(reason);
396 }
397 }
398 }
399}
400
401#[derive(Debug, Clone, PartialEq, Eq)]
403pub enum PolicyError {
404 RedactionFailed(String),
405}
406
407impl fmt::Display for PolicyError {
408 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409 match self {
410 PolicyError::RedactionFailed(message) => write!(f, "{message}"),
411 }
412 }
413}
414
415impl std::error::Error for PolicyError {}
416
417pub fn redact_field(
418 policy: &OperationalContextPolicy,
419 report: &mut RedactionReport,
420 field: &str,
421 value: &str,
422) -> std::result::Result<String, PolicyError> {
423 let redacted = policy.redact_text(value)?;
424 report.record(field, &redacted);
425 Ok(redacted.text)
426}
427
428pub fn redact_optional_field(
429 policy: &OperationalContextPolicy,
430 report: &mut RedactionReport,
431 field: &str,
432 value: &Option<String>,
433) -> std::result::Result<Option<String>, PolicyError> {
434 value
435 .as_deref()
436 .map(|s| redact_field(policy, report, field, s))
437 .transpose()
438}
439
440pub fn redact_string_list(
441 policy: &OperationalContextPolicy,
442 report: &mut RedactionReport,
443 field: &str,
444 values: &[String],
445) -> std::result::Result<Vec<String>, PolicyError> {
446 values
447 .iter()
448 .map(|value| redact_field(policy, report, field, value))
449 .collect()
450}
451
452pub fn failed_closed_metadata(reason: impl Into<String>) -> Value {
453 json!({
454 "status": "failed_closed",
455 "classes": [],
456 "fields": [],
457 "sensitive_command": false,
458 "sensitive_reasons": [],
459 "forced_ephemeral": true,
460 "raw_persistence": "blocked",
461 "error": reason.into(),
462 })
463}
464
465pub fn unknown_redaction_metadata() -> Value {
466 json!({
467 "status": "unknown",
468 "classes": [],
469 "fields": [],
470 "sensitive_command": false,
471 "sensitive_reasons": [],
472 "forced_ephemeral": false,
473 "raw_persistence": "unknown",
474 })
475}
476
477pub fn command_hint_from_params(params: &Value) -> Option<String> {
478 ["command", "cmd", "command_line"]
479 .iter()
480 .find_map(|key| params.get(*key).and_then(Value::as_str))
481 .map(str::to_string)
482}
483
484pub fn command_hint_from_tool_use(
485 params: &Value,
486 tool_name: &str,
487 tool_input: &Value,
488) -> Option<String> {
489 if let Some(command) = command_hint_from_params(params) {
490 return Some(command);
491 }
492
493 if let Some(command) = tool_input
494 .get("command")
495 .or_else(|| tool_input.get("cmd"))
496 .and_then(Value::as_str)
497 {
498 let args = tool_input
499 .get("args")
500 .and_then(Value::as_array)
501 .map(|arr| {
502 arr.iter()
503 .filter_map(Value::as_str)
504 .collect::<Vec<_>>()
505 .join(" ")
506 })
507 .filter(|s| !s.is_empty());
508 return Some(match args {
509 Some(args) => format!("{command} {args}"),
510 None => command.to_string(),
511 });
512 }
513
514 if command_like_tool(tool_name) {
515 if let Some(input) = tool_input.as_str() {
516 return Some(input.to_string());
517 }
518 return Some(tool_input.to_string());
519 }
520
521 None
522}
523
524pub fn command_hint_from_archive(params: &Value, tool_name: &str) -> Option<String> {
525 command_hint_from_params(params).or_else(|| {
526 if command_like_tool(tool_name) {
527 Some(tool_name.to_string())
528 } else {
529 None
530 }
531 })
532}
533
534fn apply_lazy_regex(
535 text: &mut String,
536 regex: &Lazy<std::result::Result<Regex, regex::Error>>,
537 replacement: &str,
538 class: &str,
539 classes: &mut BTreeSet<String>,
540) -> std::result::Result<(), PolicyError> {
541 let re = match &**regex {
542 Ok(re) => re,
543 Err(err) => {
544 return Err(PolicyError::RedactionFailed(format!(
545 "built-in redaction pattern failed to compile: {err}"
546 )));
547 }
548 };
549 if re.is_match(text) {
550 *text = re.replace_all(text.as_str(), replacement).into_owned();
551 classes.insert(class.to_string());
552 }
553 Ok(())
554}
555
556fn env_bool(key: &str) -> Option<bool> {
557 env::var(key).ok().as_deref().and_then(parse_bool_str)
558}
559
560fn parse_bool_value(value: &Value) -> Option<bool> {
561 value
562 .as_bool()
563 .or_else(|| value.as_str().and_then(parse_bool_str))
564}
565
566fn parse_bool_str(value: &str) -> Option<bool> {
567 match value.trim().to_ascii_lowercase().as_str() {
568 "1" | "true" | "yes" | "on" => Some(true),
569 "0" | "false" | "no" | "off" => Some(false),
570 _ => None,
571 }
572}
573
574fn touches_env_file(command: &str) -> bool {
575 let reads_file = [
576 "cat ", "less ", "more ", "tail ", "head ", "grep ", "rg ", "sed ", "awk ",
577 ]
578 .iter()
579 .any(|prefix| command.starts_with(prefix) || command.contains(&format!("| {prefix}")));
580 reads_file
581 && (command.contains(".env")
582 || command.contains("dotenv")
583 || command.contains("secrets.env"))
584}
585
586fn command_like_tool(tool_name: &str) -> bool {
587 let lower = tool_name.to_ascii_lowercase();
588 [
589 "shell",
590 "bash",
591 "zsh",
592 "terminal",
593 "exec_command",
594 "command",
595 ]
596 .iter()
597 .any(|needle| lower.contains(needle))
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603
604 #[test]
605 fn redacts_core_secret_classes() {
606 let policy = OperationalContextPolicy::default();
607 let input = concat!(
608 "OPENAI_API_KEY=sk-testkeyvalue1234567890\n",
609 "Authorization: Bearer abcdefghijklmnopqrstuvwxyz\n",
610 "jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c\n",
611 "postgres://user:secret@db.example/app\n",
612 "Cookie: sid=secret-cookie-value\n",
613 "AKIAIOSFODNN7EXAMPLE\n",
614 "-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----"
615 );
616
617 let redacted = policy.redact_text(input).expect("redact");
618
619 assert!(redacted.redacted);
620 assert!(!redacted.text.contains("sk-testkeyvalue"));
621 assert!(!redacted.text.contains("abcdefghijklmnopqrstuvwxyz"));
622 assert!(!redacted.text.contains("postgres://user:secret"));
623 assert!(!redacted.text.contains("secret-cookie-value"));
624 assert!(!redacted.text.contains("AKIAIOSFODNN7EXAMPLE"));
625 assert!(!redacted.text.contains("BEGIN PRIVATE KEY"));
626 assert!(redacted.classes.iter().any(|c| c == "env_secret"));
627 assert!(redacted.classes.iter().any(|c| c == "bearer_token"));
628 assert!(redacted.classes.iter().any(|c| c == "jwt"));
629 assert!(redacted.classes.iter().any(|c| c == "database_url"));
630 assert!(redacted.classes.iter().any(|c| c == "cookie"));
631 assert!(redacted.classes.iter().any(|c| c == "cloud_credential"));
632 assert!(redacted.classes.iter().any(|c| c == "private_key"));
633 }
634
635 #[test]
636 fn redacts_email_and_phone_only_when_configured() {
637 let input = "Contact alice@example.com or +1 (415) 555-2671";
638
639 let default_redaction = OperationalContextPolicy::default()
640 .redact_text(input)
641 .expect("default redact");
642 assert_eq!(default_redaction.text, input);
643
644 let policy = OperationalContextPolicy {
645 redact_emails: true,
646 redact_phone_numbers: true,
647 ..Default::default()
648 };
649 let redacted = policy.redact_text(input).expect("configured redact");
650 assert!(!redacted.text.contains("alice@example.com"));
651 assert!(!redacted.text.contains("415"));
652 assert!(redacted.classes.iter().any(|c| c == "email"));
653 assert!(redacted.classes.iter().any(|c| c == "phone"));
654 }
655
656 #[test]
657 fn detects_sensitive_command_examples() {
658 let policy = OperationalContextPolicy::default();
659 for command in [
660 "cat .env",
661 "printenv",
662 "aws sts get-caller-identity",
663 "gh auth token",
664 "kubectl get secrets -n prod",
665 "kubectl logs deployment/api -n production > prod.log",
666 "cat ci-logs.txt",
667 ] {
668 let analysis = policy.analyze_command(Some(command));
669 assert!(analysis.is_sensitive, "expected sensitive: {command}");
670 }
671 }
672
673 #[test]
674 fn sensitive_commands_force_ephemeral_and_no_raw_by_default() {
675 let policy = OperationalContextPolicy::default();
676 let analysis = policy.analyze_command(Some("gh auth token"));
677 assert!(policy.force_ephemeral(&analysis));
678 assert!(!policy.allow_raw_for(&analysis));
679
680 let override_policy = OperationalContextPolicy {
681 allow_sensitive_raw: true,
682 allow_sensitive_command_persistence: true,
683 ..Default::default()
684 };
685 assert!(!override_policy.force_ephemeral(&analysis));
686 assert!(override_policy.allow_raw_for(&analysis));
687 }
688
689 #[test]
690 fn invalid_custom_pattern_fails_closed() {
691 let policy = OperationalContextPolicy {
692 custom_secret_patterns: vec!["(".to_string()],
693 ..Default::default()
694 };
695
696 let err = policy.redact_text("safe input").expect_err("must fail");
697 assert!(err.to_string().contains("invalid custom redaction pattern"));
698 let metadata = failed_closed_metadata(err.to_string());
699 assert_eq!(metadata["status"], "failed_closed");
700 assert_eq!(metadata["raw_persistence"], "blocked");
701 }
702
703 #[test]
704 fn params_override_policy() {
705 let policy = OperationalContextPolicy::from_params(&json!({
706 "operational_context_policy": {
707 "redact_emails": true,
708 "redact_phone_numbers": true,
709 "allow_sensitive_raw": true,
710 "allow_sensitive_command_persistence": true,
711 "custom_secret_patterns": ["BEGIN-CUSTOM-[A-Z]+"]
712 }
713 }));
714
715 assert!(policy.redact_emails);
716 assert!(policy.redact_phone_numbers);
717 assert!(policy.allow_sensitive_raw);
718 assert!(policy.allow_sensitive_command_persistence);
719 assert_eq!(policy.custom_secret_patterns.len(), 1);
720 }
721}