Skip to main content

lean_ctx/core/policy/
mod.rs

1//! Context Policy Packs v1 — Policies-as-Code (GL #489).
2//!
3//! A policy pack is a declarative, versioned governance preset: which tools an
4//! agent may call, the default read mode, redaction patterns for sensitive
5//! data, an audit-retention expectation and a context-budget cap. Packs are
6//! plain TOML, support single inheritance via `extends`, and resolve into one
7//! [`ResolvedPolicy`] a team can review like code.
8//!
9//! v1 ships the **format, validation, resolution, curated built-ins and the
10//! `lean-ctx policy` CLI** (see `cli::policy_cmd`). Runtime enforcement wires
11//! in afterward (deliberately decoupled so this module stays free of hot-path
12//! churn — see the contract `docs/contracts/context-policy-packs-v1.md`).
13//!
14//! Inheritance semantics are security-first and predictable:
15//! - scalars (`default_read_mode`, `max_context_tokens`,
16//!   `audit_retention_days`) — the child **overrides** when set;
17//! - `deny_tools` and `[redaction]` — **accumulate** down the chain
18//!   (restrictions inherited from a parent can never be silently dropped;
19//!   a child may only tighten or re-point a named redaction pattern);
20//! - `allow_tools` — the child **overrides** when set (an allowlist is a
21//!   deliberate posture choice, not an accumulating set).
22
23pub mod builtin;
24pub mod coverage;
25pub mod floor;
26pub mod org;
27pub mod runtime;
28
29use std::collections::{BTreeMap, BTreeSet};
30use std::path::Path;
31
32use serde::{Deserialize, Serialize};
33
34/// Maximum `extends` chain depth (defense against runaway chains; built-ins
35/// use at most 2).
36const MAX_EXTENDS_DEPTH: usize = 8;
37
38/// Read modes a pack may pin as `default_read_mode` — the documented
39/// `ctx_read` mode vocabulary (range reads like `lines:N-M` are call-site
40/// specific and make no sense as a policy default).
41pub const KNOWN_READ_MODES: &[&str] = &[
42    "auto",
43    "full",
44    "map",
45    "signatures",
46    "diff",
47    "task",
48    "reference",
49    "aggressive",
50    "entropy",
51];
52
53// ── Wire format ──────────────────────────────────────────────────────────────
54
55/// One policy pack as written in TOML. Unknown keys are rejected so a typo
56/// (`alow_tools`) fails validation instead of silently weakening a policy.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct PolicyPack {
60    /// Stable identifier: lowercase, digits and hyphens (`finance-eu`).
61    pub name: String,
62    /// Semantic version of the pack itself (`1.0.0`).
63    pub version: String,
64    /// One-line human description.
65    pub description: String,
66    /// Optional parent pack (built-in name) this pack inherits from.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub extends: Option<String>,
69    /// Context-governance expectations.
70    #[serde(default)]
71    pub context: ContextRules,
72    /// Named redaction patterns: name → regex (matched against content before
73    /// it enters the model context).
74    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
75    pub redaction: BTreeMap<String, String>,
76    /// Inbound content filters (PII / classification / prompt-injection) — the
77    /// input side of the Great Filter (GL #675).
78    #[serde(default, skip_serializing_if = "FilterRules::is_empty")]
79    pub filters: FilterRules,
80    /// Egress/output DLP on agent writes & actions (GL #676).
81    #[serde(default, skip_serializing_if = "EgressRules::is_empty")]
82    pub egress: EgressRules,
83}
84
85/// The `[context]` section of a pack. All fields optional — only what a pack
86/// states is constrained; everything else stays at engine defaults.
87#[derive(Debug, Clone, Default, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct ContextRules {
90    /// Default `ctx_read` mode the policy expects (see [`KNOWN_READ_MODES`]).
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub default_read_mode: Option<String>,
93    /// Allowlist of tool names; when set, only these may be called.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub allow_tools: Option<Vec<String>>,
96    /// Denylist of tool names; always additive down the `extends` chain.
97    #[serde(default, skip_serializing_if = "Vec::is_empty")]
98    pub deny_tools: Vec<String>,
99    /// Upper bound on tokens a single context assembly may spend.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub max_context_tokens: Option<u32>,
102    /// Audit-retention expectation in days (governance intent; the hosted
103    /// plane enforces its own plan window — see org-audit-log-v1).
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub audit_retention_days: Option<u32>,
106}
107
108/// The `[filters]` section — inbound content detectors (GL #675). Each action
109/// is one of `off` / `warn` / `redact` / `block` (absent ⇒ `off`). Compiled
110/// into a [`crate::core::input_filters::FilterConfig`] at load time and run on
111/// tool output before it reaches the agent.
112#[derive(Debug, Clone, Default, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct FilterRules {
115    /// PII detection (Swiss AHV, IBAN, payment cards, email).
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub pii: Option<String>,
118    /// Data-classification marking gate (confidential/secret banners).
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub classification: Option<String>,
121    /// Prompt-injection detection (OWASP LLM01) on inbound content.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub injection: Option<String>,
124    /// Classification labels that gate; overrides the built-in default set.
125    /// Accumulates down the `extends` chain (a child may add, never drop).
126    #[serde(default, skip_serializing_if = "Vec::is_empty")]
127    pub blocked_labels: Vec<String>,
128}
129
130impl FilterRules {
131    /// True when no filter is configured (all actions absent, no labels).
132    #[must_use]
133    pub fn is_empty(&self) -> bool {
134        self.pii.is_none()
135            && self.classification.is_none()
136            && self.injection.is_none()
137            && self.blocked_labels.is_empty()
138    }
139}
140
141/// The `[egress]` section — output/DLP enforcement on agent writes & actions
142/// (GL #676). Gates `ctx_edit` writes and `ctx_shell` actions before they
143/// execute. Compiled into a [`crate::core::egress::EgressConfig`] at load time.
144#[derive(Debug, Clone, Default, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct EgressRules {
147    /// Regexes that block a write/action when matched (e.g. a prod-DB DSN).
148    /// Accumulates down the `extends` chain.
149    #[serde(default, skip_serializing_if = "Vec::is_empty")]
150    pub forbidden_patterns: Vec<String>,
151    /// Block writes/actions carrying detected secrets or PII.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub block_secrets: Option<bool>,
154    /// Rate limit: max agent write/action tool calls per 60 s.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub max_writes_per_min: Option<u32>,
157}
158
159impl EgressRules {
160    /// True when no egress rule is configured.
161    #[must_use]
162    pub fn is_empty(&self) -> bool {
163        self.forbidden_patterns.is_empty()
164            && self.block_secrets.is_none()
165            && self.max_writes_per_min.is_none()
166    }
167}
168
169// ── Resolved view ────────────────────────────────────────────────────────────
170
171/// A pack with its full `extends` chain folded in — what enforcement and
172/// `policy show` consume.
173#[derive(Debug, Clone, Serialize)]
174pub struct ResolvedPolicy {
175    pub name: String,
176    pub version: String,
177    pub description: String,
178    /// Inheritance chain, base-most first (`["baseline", "strict-redaction"]`
179    /// for a pack extending `strict-redaction`). Empty for root packs.
180    pub chain: Vec<String>,
181    pub default_read_mode: Option<String>,
182    pub allow_tools: Option<Vec<String>>,
183    pub deny_tools: Vec<String>,
184    pub max_context_tokens: Option<u32>,
185    pub audit_retention_days: Option<u32>,
186    pub redaction: BTreeMap<String, String>,
187    /// Folded inbound-filter actions + label set (GL #675).
188    #[serde(default, skip_serializing_if = "FilterRules::is_empty")]
189    pub filters: FilterRules,
190    /// Folded egress/output DLP rules (GL #676).
191    #[serde(default, skip_serializing_if = "EgressRules::is_empty")]
192    pub egress: EgressRules,
193}
194
195// ── Errors ───────────────────────────────────────────────────────────────────
196
197/// Why a pack failed to parse, validate or resolve. Rendered verbatim by the
198/// CLI, so every variant names the offending field and value.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub enum PolicyError {
201    Toml(String),
202    InvalidName(String),
203    InvalidVersion(String),
204    EmptyDescription,
205    UnknownReadMode(String),
206    BadRegex { pattern_name: String, error: String },
207    ZeroMaxTokens,
208    AllowDenyOverlap(Vec<String>),
209    UnknownParent(String),
210    ExtendsCycle(Vec<String>),
211    ExtendsTooDeep(usize),
212    UnknownFilterAction { field: String, value: String },
213}
214
215impl std::fmt::Display for PolicyError {
216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        match self {
218            PolicyError::Toml(e) => write!(f, "not valid pack TOML: {e}"),
219            PolicyError::InvalidName(n) => write!(
220                f,
221                "invalid pack name '{n}' (use lowercase letters, digits and hyphens)"
222            ),
223            PolicyError::InvalidVersion(v) => {
224                write!(f, "invalid version '{v}' (expected MAJOR.MINOR.PATCH)")
225            }
226            PolicyError::EmptyDescription => write!(f, "description must not be empty"),
227            PolicyError::UnknownReadMode(m) => write!(
228                f,
229                "unknown default_read_mode '{m}' (one of: {})",
230                KNOWN_READ_MODES.join(", ")
231            ),
232            PolicyError::BadRegex {
233                pattern_name,
234                error,
235            } => write!(
236                f,
237                "redaction pattern '{pattern_name}' is not a valid regex: {error}"
238            ),
239            PolicyError::ZeroMaxTokens => write!(f, "max_context_tokens must be greater than 0"),
240            PolicyError::AllowDenyOverlap(tools) => write!(
241                f,
242                "tools listed in both allow_tools and deny_tools: {}",
243                tools.join(", ")
244            ),
245            PolicyError::UnknownParent(p) => write!(
246                f,
247                "extends '{p}' does not name a known pack (built-ins: {})",
248                builtin::names().join(", ")
249            ),
250            PolicyError::ExtendsCycle(chain) => {
251                write!(f, "extends cycle: {}", chain.join(" -> "))
252            }
253            PolicyError::ExtendsTooDeep(d) => write!(
254                f,
255                "extends chain deeper than {MAX_EXTENDS_DEPTH} (found {d}) — flatten the hierarchy"
256            ),
257            PolicyError::UnknownFilterAction { field, value } => write!(
258                f,
259                "filters.{field} '{value}' is not a valid action (one of: off, warn, redact, block)"
260            ),
261        }
262    }
263}
264
265impl std::error::Error for PolicyError {}
266
267// ── Parse + validate ─────────────────────────────────────────────────────────
268
269/// Parse one pack from TOML text (no I/O) and validate it standalone.
270/// `extends` is checked against the built-ins during [`resolve`].
271pub fn parse(toml_text: &str) -> Result<PolicyPack, PolicyError> {
272    let pack: PolicyPack =
273        toml::from_str(toml_text).map_err(|e| PolicyError::Toml(e.to_string()))?;
274    validate(&pack)?;
275    Ok(pack)
276}
277
278/// Parse a pack from a file path. Read errors surface as [`PolicyError::Toml`]
279/// with the OS message — the CLI shows them verbatim.
280pub fn parse_file(path: &Path) -> Result<PolicyPack, PolicyError> {
281    let text = std::fs::read_to_string(path)
282        .map_err(|e| PolicyError::Toml(format!("{}: {e}", path.display())))?;
283    parse(&text)
284}
285
286/// Field-level validation of a single (unresolved) pack.
287pub fn validate(pack: &PolicyPack) -> Result<(), PolicyError> {
288    if pack.name.is_empty()
289        || !pack
290            .name
291            .bytes()
292            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
293        || pack.name.starts_with('-')
294        || pack.name.ends_with('-')
295    {
296        return Err(PolicyError::InvalidName(pack.name.clone()));
297    }
298    if !valid_semver(&pack.version) {
299        return Err(PolicyError::InvalidVersion(pack.version.clone()));
300    }
301    if pack.description.trim().is_empty() {
302        return Err(PolicyError::EmptyDescription);
303    }
304    if let Some(mode) = pack.context.default_read_mode.as_deref()
305        && !KNOWN_READ_MODES.contains(&mode)
306    {
307        return Err(PolicyError::UnknownReadMode(mode.to_string()));
308    }
309    if let Some(max) = pack.context.max_context_tokens
310        && max == 0
311    {
312        return Err(PolicyError::ZeroMaxTokens);
313    }
314    if let Some(allow) = &pack.context.allow_tools {
315        let deny: BTreeSet<&str> = pack.context.deny_tools.iter().map(String::as_str).collect();
316        let overlap: Vec<String> = allow
317            .iter()
318            .filter(|t| deny.contains(t.as_str()))
319            .cloned()
320            .collect();
321        if !overlap.is_empty() {
322            return Err(PolicyError::AllowDenyOverlap(overlap));
323        }
324    }
325    for (name, pattern) in &pack.redaction {
326        if let Err(e) = regex::Regex::new(pattern) {
327            return Err(PolicyError::BadRegex {
328                pattern_name: name.clone(),
329                error: e.to_string(),
330            });
331        }
332    }
333    validate_filter_action("pii", pack.filters.pii.as_deref())?;
334    validate_filter_action("classification", pack.filters.classification.as_deref())?;
335    validate_filter_action("injection", pack.filters.injection.as_deref())?;
336    for pattern in &pack.egress.forbidden_patterns {
337        if let Err(e) = regex::Regex::new(pattern) {
338            return Err(PolicyError::BadRegex {
339                pattern_name: format!("egress.forbidden_patterns: {pattern}"),
340                error: e.to_string(),
341            });
342        }
343    }
344    Ok(())
345}
346
347/// Reject a `[filters]` action that is not a known token.
348fn validate_filter_action(field: &str, value: Option<&str>) -> Result<(), PolicyError> {
349    if let Some(v) = value
350        && crate::core::input_filters::FilterAction::parse(v).is_none()
351    {
352        return Err(PolicyError::UnknownFilterAction {
353            field: field.to_string(),
354            value: v.to_string(),
355        });
356    }
357    Ok(())
358}
359
360/// `MAJOR.MINOR.PATCH`, digits only — packs don't need pre-release tags.
361fn valid_semver(v: &str) -> bool {
362    let parts: Vec<&str> = v.split('.').collect();
363    parts.len() == 3
364        && parts
365            .iter()
366            .all(|p| !p.is_empty() && p.len() <= 6 && p.bytes().all(|b| b.is_ascii_digit()))
367}
368
369// ── Resolve (extends) ────────────────────────────────────────────────────────
370
371/// Fold a pack's `extends` chain (against the built-ins) into one
372/// [`ResolvedPolicy`]. See the module docs for the inheritance semantics.
373pub fn resolve(pack: &PolicyPack) -> Result<ResolvedPolicy, PolicyError> {
374    // Walk to the root, collecting the chain (child first).
375    let mut lineage: Vec<PolicyPack> = vec![pack.clone()];
376    let mut seen: Vec<String> = vec![pack.name.clone()];
377    let mut next_parent = pack.extends.clone();
378    while let Some(parent_name) = next_parent.take() {
379        if seen.contains(&parent_name) {
380            seen.push(parent_name);
381            return Err(PolicyError::ExtendsCycle(seen));
382        }
383        if lineage.len() >= MAX_EXTENDS_DEPTH {
384            return Err(PolicyError::ExtendsTooDeep(lineage.len() + 1));
385        }
386        let parent =
387            builtin::get(&parent_name).ok_or(PolicyError::UnknownParent(parent_name.clone()))?;
388        seen.push(parent_name);
389        next_parent.clone_from(&parent.extends);
390        lineage.push(parent);
391    }
392
393    // Fold base-most first so children override scalars and accumulate
394    // restrictions on top.
395    let mut resolved = ResolvedPolicy {
396        name: pack.name.clone(),
397        version: pack.version.clone(),
398        description: pack.description.clone(),
399        chain: seen.iter().skip(1).rev().cloned().collect(),
400        default_read_mode: None,
401        allow_tools: None,
402        deny_tools: Vec::new(),
403        max_context_tokens: None,
404        audit_retention_days: None,
405        redaction: BTreeMap::new(),
406        filters: FilterRules::default(),
407        egress: EgressRules::default(),
408    };
409    for layer in lineage.iter().rev() {
410        if let Some(mode) = &layer.context.default_read_mode {
411            resolved.default_read_mode = Some(mode.clone());
412        }
413        if let Some(allow) = &layer.context.allow_tools {
414            resolved.allow_tools = Some(allow.clone());
415        }
416        for tool in &layer.context.deny_tools {
417            if !resolved.deny_tools.contains(tool) {
418                resolved.deny_tools.push(tool.clone());
419            }
420        }
421        if let Some(max) = layer.context.max_context_tokens {
422            resolved.max_context_tokens = Some(max);
423        }
424        if let Some(days) = layer.context.audit_retention_days {
425            resolved.audit_retention_days = Some(days);
426        }
427        for (name, pattern) in &layer.redaction {
428            resolved.redaction.insert(name.clone(), pattern.clone());
429        }
430        // Filter actions override (child wins); labels accumulate.
431        if let Some(v) = &layer.filters.pii {
432            resolved.filters.pii = Some(v.clone());
433        }
434        if let Some(v) = &layer.filters.classification {
435            resolved.filters.classification = Some(v.clone());
436        }
437        if let Some(v) = &layer.filters.injection {
438            resolved.filters.injection = Some(v.clone());
439        }
440        for label in &layer.filters.blocked_labels {
441            if !resolved.filters.blocked_labels.contains(label) {
442                resolved.filters.blocked_labels.push(label.clone());
443            }
444        }
445        // Egress: forbidden patterns accumulate; scalars override (child wins).
446        for pattern in &layer.egress.forbidden_patterns {
447            if !resolved.egress.forbidden_patterns.contains(pattern) {
448                resolved.egress.forbidden_patterns.push(pattern.clone());
449            }
450        }
451        if let Some(v) = layer.egress.block_secrets {
452            resolved.egress.block_secrets = Some(v);
453        }
454        if let Some(v) = layer.egress.max_writes_per_min {
455            resolved.egress.max_writes_per_min = Some(v);
456        }
457    }
458
459    // A resolved allowlist must not collide with accumulated denies.
460    if let Some(allow) = &resolved.allow_tools {
461        let overlap: Vec<String> = allow
462            .iter()
463            .filter(|t| resolved.deny_tools.contains(*t))
464            .cloned()
465            .collect();
466        if !overlap.is_empty() {
467            return Err(PolicyError::AllowDenyOverlap(overlap));
468        }
469    }
470    Ok(resolved)
471}
472
473/// Parse + validate + resolve in one step — the common CLI path.
474pub fn load(toml_text: &str) -> Result<ResolvedPolicy, PolicyError> {
475    resolve(&parse(toml_text)?)
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    fn minimal(name: &str, extends: Option<&str>) -> PolicyPack {
483        PolicyPack {
484            name: name.to_string(),
485            version: "1.0.0".to_string(),
486            description: "test pack".to_string(),
487            extends: extends.map(str::to_string),
488            context: ContextRules::default(),
489            redaction: BTreeMap::new(),
490            filters: FilterRules::default(),
491            egress: EgressRules::default(),
492        }
493    }
494
495    #[test]
496    fn parses_a_full_pack() {
497        let pack = parse(
498            r#"
499name = "acme-internal"
500version = "2.1.0"
501description = "ACME internal baseline"
502extends = "strict-redaction"
503
504[context]
505default_read_mode = "map"
506deny_tools = ["ctx_url_read"]
507max_context_tokens = 12000
508audit_retention_days = 365
509
510[redaction]
511employee_id = 'EMP-\d{6}'
512"#,
513        )
514        .expect("parses");
515        assert_eq!(pack.name, "acme-internal");
516        assert_eq!(pack.extends.as_deref(), Some("strict-redaction"));
517        assert_eq!(pack.context.deny_tools, vec!["ctx_url_read"]);
518        assert!(pack.redaction.contains_key("employee_id"));
519    }
520
521    #[test]
522    fn unknown_keys_are_rejected() {
523        let err = parse(
524            r#"
525name = "typo"
526version = "1.0.0"
527description = "x"
528
529[context]
530alow_tools = ["ctx_read"]
531"#,
532        )
533        .unwrap_err();
534        assert!(matches!(err, PolicyError::Toml(_)), "{err}");
535    }
536
537    #[test]
538    fn validation_catches_each_field() {
539        let mut p = minimal("Bad Name", None);
540        assert!(matches!(validate(&p), Err(PolicyError::InvalidName(_))));
541
542        p = minimal("ok", None);
543        p.version = "1.0".into();
544        assert!(matches!(validate(&p), Err(PolicyError::InvalidVersion(_))));
545
546        p = minimal("ok", None);
547        p.description = "  ".into();
548        assert!(matches!(validate(&p), Err(PolicyError::EmptyDescription)));
549
550        p = minimal("ok", None);
551        p.context.default_read_mode = Some("lines:1-5".into());
552        assert!(matches!(validate(&p), Err(PolicyError::UnknownReadMode(_))));
553
554        p = minimal("ok", None);
555        p.context.max_context_tokens = Some(0);
556        assert!(matches!(validate(&p), Err(PolicyError::ZeroMaxTokens)));
557
558        p = minimal("ok", None);
559        p.redaction.insert("broken".into(), "(unclosed".into());
560        assert!(matches!(validate(&p), Err(PolicyError::BadRegex { .. })));
561
562        p = minimal("ok", None);
563        p.context.allow_tools = Some(vec!["ctx_read".into()]);
564        p.context.deny_tools = vec!["ctx_read".into()];
565        assert!(matches!(
566            validate(&p),
567            Err(PolicyError::AllowDenyOverlap(_))
568        ));
569    }
570
571    #[test]
572    fn resolve_overrides_scalars_and_accumulates_denies() {
573        let mut child = minimal("child", Some("finance-eu"));
574        child.context.default_read_mode = Some("signatures".into());
575        child.context.deny_tools = vec!["ctx_shell".into()];
576        let r = resolve(&child).expect("resolves");
577
578        // Scalar overridden by the child.
579        assert_eq!(r.default_read_mode.as_deref(), Some("signatures"));
580        // finance-eu's denies survive; the child's add on top.
581        assert!(r.deny_tools.contains(&"ctx_url_read".to_string()));
582        assert!(r.deny_tools.contains(&"ctx_shell".to_string()));
583        // Redaction accumulated from the whole chain (baseline + strict + finance).
584        assert!(r.redaction.contains_key("iban"));
585        assert!(r.redaction.contains_key("private_key"));
586        // Chain is base-most first and excludes the pack itself.
587        assert_eq!(r.chain, vec!["baseline", "strict-redaction", "finance-eu"]);
588    }
589
590    #[test]
591    fn resolve_rejects_unknown_parent_and_cycle() {
592        let p = minimal("orphan", Some("no-such-pack"));
593        assert!(matches!(resolve(&p), Err(PolicyError::UnknownParent(_))));
594
595        // Self-reference is the minimal cycle reachable without registering
596        // custom packs (built-ins are acyclic by construction + test below).
597        let p = minimal("loop", Some("loop"));
598        assert!(matches!(resolve(&p), Err(PolicyError::ExtendsCycle(_))));
599    }
600
601    #[test]
602    fn child_redaction_overrides_same_named_parent_pattern() {
603        let mut child = minimal("child", Some("baseline"));
604        child
605            .redaction
606            .insert("private_key".into(), "MY-OWN-KEY-\\d+".into());
607        let r = resolve(&child).expect("resolves");
608        assert_eq!(r.redaction.get("private_key").unwrap(), "MY-OWN-KEY-\\d+");
609    }
610}