Skip to main content

engram/context/
policy.rs

1//! Redaction and sensitive-command policy for Operational Context persistence.
2//!
3//! Operational Context records are intentionally stricter than ordinary memory
4//! writes: every summary, event, and raw artifact must be redacted before
5//! durable storage, and sensitive commands default to ephemeral/no-raw behavior
6//! unless a caller explicitly opts out through policy parameters.
7
8use 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/// Runtime policy for Operational Context redaction.
50#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct OperationalContextPolicy {
52    /// Redact email addresses only when explicitly configured.
53    pub redact_emails: bool,
54    /// Redact phone numbers only when explicitly configured.
55    pub redact_phone_numbers: bool,
56    /// Permit raw artifact persistence for sensitive commands.
57    pub allow_sensitive_raw: bool,
58    /// Permit permanent persistence for records associated with sensitive commands.
59    pub allow_sensitive_command_persistence: bool,
60    /// Extra repo/session patterns that must be redacted.
61    pub custom_secret_patterns: Vec<String>,
62}
63
64impl OperationalContextPolicy {
65    /// Build policy from environment variables.
66    ///
67    /// Supported booleans:
68    /// - `ENGRAM_OC_REDACT_EMAILS`
69    /// - `ENGRAM_OC_REDACT_PHONE_NUMBERS`
70    /// - `ENGRAM_OC_ALLOW_SENSITIVE_RAW`
71    /// - `ENGRAM_OC_ALLOW_SENSITIVE_COMMAND_PERSISTENCE`
72    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    /// Build policy from env plus per-call/session parameters.
90    ///
91    /// Callers may pass overrides either top-level or in one of:
92    /// `operational_context_policy`, `context_policy`, or `redaction_policy`.
93    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    /// Redact a single text field. Errors must be treated as fail-closed by callers.
109    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    /// Analyze a command string for sensitive Operational Context handling.
211    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    /// Sensitive commands must be persisted only as ephemeral records unless overridden.
272    pub fn force_ephemeral(&self, analysis: &SensitiveCommandAnalysis) -> bool {
273        analysis.is_sensitive && !self.allow_sensitive_command_persistence
274    }
275
276    /// Sensitive commands must not persist raw artifacts unless overridden.
277    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/// Redacted output for one text field.
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct RedactedText {
317    pub text: String,
318    pub redacted: bool,
319    pub classes: Vec<String>,
320}
321
322/// Accumulates redaction decisions for one event/summary/artifact.
323#[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/// Sensitive command detection result.
375#[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/// Policy errors. Callers must fail closed and avoid raw fallback.
402#[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}