diff --git a/crates/aisix-core/src/models/guardrail.rs b/crates/aisix-core/src/models/guardrail.rs index 8a680b16..c15218e5 100644 --- a/crates/aisix-core/src/models/guardrail.rs +++ b/crates/aisix-core/src/models/guardrail.rs @@ -445,12 +445,26 @@ pub struct PiiCustomPattern { pub name: String, /// Regular expression AISIX compiles when building the guardrail chain. /// An invalid pattern makes AISIX log and skip the guardrail. + /// + /// When the expression declares at least one capture group, a `mask` + /// action rewrites only the first capture group of each match and keeps + /// the rest of the match unchanged. Use this to replace a value while + /// preserving its surrounding key or label, for example + /// `"version"\s*:\s*"([^"]*)"`. Without capture groups, the whole match + /// is rewritten. #[schemars(length(min = 1))] pub regex: String, /// Per-pattern action override. Falls back to the guardrail's /// `default_action` when omitted. #[serde(default, skip_serializing_if = "Option::is_none")] pub action: Option, + /// Literal text that replaces the masked span, such as `***`. When + /// omitted, the span is rewritten to `[_REDACTED]`. An empty + /// string removes the span. Only valid when the pattern's effective + /// action is `mask`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(max = 256))] + pub replacement: Option, } /// Config block for `kind: "pii"`. Built-in sensitive-data detection and @@ -1464,7 +1478,8 @@ mod tests { "kind": "pii", "default_action": "block", "custom_patterns": [ - { "name": "employee_id", "regex": "\\bEMP-\\d{6}\\b", "action": "mask" } + { "name": "employee_id", "regex": "\\bEMP-\\d{6}\\b", "action": "mask" }, + { "name": "eda_version", "regex": "version: (\\S+)", "action": "mask", "replacement": "***" } ], "max_buffer_bytes": 1024, "on_buffer_exceeded": "fail_open" @@ -1473,15 +1488,23 @@ mod tests { match g.config { GuardrailKind::Pii(ref c) => { assert!(c.detectors.is_empty()); - assert_eq!(c.custom_patterns.len(), 1); + assert_eq!(c.custom_patterns.len(), 2); assert_eq!(c.custom_patterns[0].name, "employee_id"); assert_eq!(c.custom_patterns[0].action.as_deref(), Some("mask")); + assert_eq!(c.custom_patterns[0].replacement, None); + assert_eq!(c.custom_patterns[1].replacement.as_deref(), Some("***")); assert_eq!(c.default_action, "block"); assert_eq!(c.max_buffer_bytes, 1024); assert_eq!(c.on_buffer_exceeded, "fail_open"); } _ => panic!("expected Pii variant"), } + // A row without `replacement` serialises without the key + // (skip_serializing_if), so older documents round-trip untouched. + let ser = serde_json::to_value(&g).unwrap(); + let pats = ser["custom_patterns"].as_array().unwrap(); + assert!(pats[0].get("replacement").is_none()); + assert_eq!(pats[1]["replacement"], "***"); } #[test] diff --git a/crates/aisix-guardrails/src/build.rs b/crates/aisix-guardrails/src/build.rs index fd4a424c..a3abc36d 100644 --- a/crates/aisix-guardrails/src/build.rs +++ b/crates/aisix-guardrails/src/build.rs @@ -249,12 +249,22 @@ fn build_one_inner( value: s.to_owned(), })?, }; - let rule = PiiRule::new(p.name.clone(), &p.regex, action, None).map_err(|e| { - BuildError::InvalidRegex { + // `replacement` only means something to a mask rewrite; on a + // block-action pattern it would be accepted-but-unread config + // (the #962 class), so the row is rejected instead. cp-api + // validates the same combination at write time; this covers + // the declarative-file source and defends the invariant. + if p.replacement.is_some() && action == PiiAction::Block { + return Err(BuildError::ReplacementOnBlock { + name: p.name.clone(), + }); + } + let rule = PiiRule::new(p.name.clone(), &p.regex, action, None) + .map_err(|e| BuildError::InvalidRegex { pattern: p.regex.clone(), source: e, - } - })?; + })? + .with_replacement(p.replacement.clone()); rules.push(rule); } let on_exceeded_fail_open = cfg.on_buffer_exceeded == "fail_open"; @@ -459,6 +469,14 @@ enum BuildError { /// skipped + warned rather than silently running a weaker policy. #[error("invalid {field} value {value:?}")] InvalidValue { field: &'static str, value: String }, + /// A `custom_patterns[].replacement` on a pattern whose effective + /// action is `block` — the replacement would never be read (never + /// half-honor a knob, #963). Carries the pattern NAME only, never + /// the replacement text or a matched value. + #[error( + "custom_patterns[].replacement requires action=mask (pattern {name:?} resolves to block)" + )] + ReplacementOnBlock { name: String }, /// A guardrail kind whose runtime dispatch was compiled out via /// feature flags (e.g. a pruned build that excluded `--features bedrock` /// or `--features azure-content-safety`). The chain treats the row as @@ -1459,6 +1477,67 @@ mod tests { ); } + /// AISIX-Cloud#1334: a capture-group custom pattern with a + /// `replacement` builds and rewrites only group 1 through the chain. + #[test] + fn pii_custom_pattern_replacement_and_group_build_from_row() { + let table: ResourceTable = ResourceTable::default(); + table.insert(entry( + "eda", + "g-1", + parse( + r#"{ + "name": "eda", + "kind": "pii", + "custom_patterns": [{ + "name": "eda_version", + "regex": "version\\s*:\\s*(\\d+(?:\\.\\d+)+)", + "action": "mask", + "replacement": "***" + }] + }"#, + ), + )); + let chain = build_chain_from_snapshot(&table, None); + assert_eq!(chain.len(), 1); + let r = chain.redact_input_text("tool version: 12.1 done").unwrap(); + assert_eq!(r.text, "tool version: *** done"); + } + + /// A `replacement` on a pattern whose effective action is `block` + /// (explicit or via `default_action`) rejects the row — the knob + /// would otherwise be accepted but never read (#963). + #[test] + fn pii_replacement_on_block_action_rejects_row() { + for row in [ + // Explicit per-pattern block. + r#"{ + "name": "bad-explicit", + "kind": "pii", + "custom_patterns": [{ + "name": "p", "regex": "x(y)", "action": "block", "replacement": "*" + }] + }"#, + // Inherited block via default_action. + r#"{ + "name": "bad-inherited", + "kind": "pii", + "default_action": "block", + "custom_patterns": [{ + "name": "p", "regex": "x(y)", "replacement": "*" + }] + }"#, + ] { + let table: ResourceTable = ResourceTable::default(); + table.insert(entry("bad", "g-1", parse(row))); + let chain = build_chain_from_snapshot(&table, None); + assert!( + chain.is_empty(), + "replacement+block row must be skipped, not half-honored", + ); + } + } + /// A stub remote guardrail that always fails open (returns `Bypass`), /// standing in for a Bedrock/Azure guardrail whose upstream is down. struct AlwaysBypass; diff --git a/crates/aisix-guardrails/src/pii.rs b/crates/aisix-guardrails/src/pii.rs index 1240506e..06138b7e 100644 --- a/crates/aisix-guardrails/src/pii.rs +++ b/crates/aisix-guardrails/src/pii.rs @@ -10,15 +10,27 @@ //! Each rule carries an action: //! - `Block`: the request/response is rejected (422 content-filter) — //! same enforcement path as the keyword blocklist. -//! - `Mask`: each matched span is rewritten to `[_REDACTED]` -//! and processing continues. Callers apply the rewrite through -//! [`crate::Guardrail::redact_input_text`] / +//! - `Mask`: each matched span is rewritten to the rule's replacement +//! text (`[_REDACTED]` by default, or an operator-configured +//! `replacement`) and processing continues. Callers apply the rewrite +//! through [`crate::Guardrail::redact_input_text`] / //! [`crate::Guardrail::redact_output_text`]. //! +//! Capture-group scoping (AISIX-Cloud#1334): a rule whose regex declares +//! at least one capture group replaces (and checksum-validates) ONLY +//! group 1 of each match; the rest of the match is kept verbatim. This is +//! how a rule expresses "replace the value, keep the key" for shapes like +//! `"version": "12.1"` — the `regex` crate has no lookaround, so context +//! can only be consumed by the match and preserved via the group split. +//! A regex without capture groups keeps the original whole-match +//! semantics. +//! //! The detector NAME is the only thing that ever leaves this module — //! block reasons, telemetry counts, and mask tokens all carry the name, //! never the matched value (#153 anti-leak rule, and the #932 acceptance -//! criterion that redacted values must not appear in gateway logs). +//! criterion that redacted values must not appear in gateway logs). An +//! operator-configured `replacement` is config, not matched content, so +//! it may appear in rewritten payloads by design. use std::borrow::Cow; use std::collections::BTreeMap; @@ -32,7 +44,8 @@ use crate::{Guardrail, GuardrailVerdict, Redaction, StreamOutputPolicy}; /// What to do when a detector matches. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PiiAction { - /// Rewrite the matched span to `[_REDACTED]` and continue. + /// Rewrite the matched span to the rule's replacement text + /// (`[_REDACTED]` by default) and continue. Mask, /// Reject the request/response (422 content-filter). Block, @@ -63,8 +76,14 @@ pub struct PiiRule { regex: Regex, action: PiiAction, validate: Option, - /// Pre-computed `[_REDACTED]` token. + /// The literal text a masked span is rewritten to: the pre-computed + /// `[_REDACTED]` token, or the operator-configured + /// `replacement` override (see [`PiiRule::with_replacement`]). mask_token: String, + /// `true` when the regex declares at least one capture group — the + /// rule then replaces and validates ONLY group 1 of each match, + /// keeping the rest of the match verbatim (module docs). + group_scoped: bool, } impl PiiRule { @@ -76,37 +95,84 @@ impl PiiRule { ) -> Result { let name = name.into(); let mask_token = mask_token(&name); + let regex = Regex::new(pattern)?; + // captures_len() counts group 0 (the whole match), so > 1 means + // the pattern declares explicit groups. `(?:…)` doesn't count. + let group_scoped = regex.captures_len() > 1; Ok(Self { - regex: Regex::new(pattern)?, + regex, name, action, validate, mask_token, + group_scoped, }) } + /// Override the default `[_REDACTED]` mask token with an + /// operator-configured literal replacement (AISIX-Cloud#1334). + /// `None` keeps the default; an empty string deletes the span. + /// The text is used verbatim — no `$n` group expansion. + pub fn with_replacement(mut self, replacement: Option) -> Self { + if let Some(r) = replacement { + self.mask_token = r; + } + self + } + /// `true` when `candidate` is a real detection (regex already matched; /// this applies the optional checksum validator). fn accepts(&self, candidate: &str) -> bool { self.validate.is_none_or(|v| v(candidate)) } + /// The span of `caps` this rule replaces and validates: group 1 for a + /// group-scoped rule, the whole match otherwise. `None` when group 1 + /// did not participate in this match (an alternation branch without + /// it) — the match is then not a detection and stays untouched. + fn target_span<'t>(&self, caps: ®ex::Captures<'t>) -> Option> { + caps.get(if self.group_scoped { 1 } else { 0 }) + } + /// First validated match in `text`, or `None`. fn detects(&self, text: &str) -> bool { - self.regex.find_iter(text).any(|m| self.accepts(m.as_str())) + if self.group_scoped { + // Group-scoped rules validate group 1, so the (slower) + // captures iterator is required; the built-in detectors have + // no groups and stay on the find_iter fast path below. + self.regex.captures_iter(text).any(|caps| { + self.target_span(&caps) + .is_some_and(|g| self.accepts(g.as_str())) + }) + } else { + self.regex.find_iter(text).any(|m| self.accepts(m.as_str())) + } } - /// Rewrite every validated match in `text` to the rule's mask token. - /// Returns the match count (0 = `text` returned unchanged). + /// Rewrite every validated match in `text` to the rule's mask token — + /// only group 1 of the match for a group-scoped rule. Returns the + /// match count (0 = `text` returned unchanged). fn mask_all<'t>(&self, text: &'t str) -> (Cow<'t, str>, u32) { let mut count = 0u32; let out = self.regex.replace_all(text, |caps: ®ex::Captures<'_>| { - let m = &caps[0]; - if self.accepts(m) { - count += 1; - self.mask_token.clone() - } else { - m.to_string() + let whole = caps.get(0).expect("group 0 always participates"); + match self.target_span(caps) { + Some(target) if self.accepts(target.as_str()) => { + count += 1; + if self.group_scoped { + // Keep the match's bytes outside group 1 verbatim. + let m = whole.as_str(); + let mut out = + String::with_capacity(m.len() - target.len() + self.mask_token.len()); + out.push_str(&m[..target.start() - whole.start()]); + out.push_str(&self.mask_token); + out.push_str(&m[target.end() - whole.start()..]); + out + } else { + self.mask_token.clone() + } + } + _ => whole.as_str().to_string(), } }); (out, count) @@ -589,13 +655,169 @@ mod tests { assert_eq!(r.text, "ssn [US_SSN_REDACTED] from [IP_ADDRESS_REDACTED]"); } + // ---- capture-group scoping + replacement (AISIX-Cloud#1334) ---- + + #[test] + fn group_scoped_rule_replaces_group_1_only() { + let rule = PiiRule::new( + "eda_version", + r"version\s*:\s*(\d+(?:\.\d+)+)", + PiiAction::Mask, + None, + ) + .unwrap() + .with_replacement(Some("***".into())); + let g = guardrail(vec![rule]); + let r = g.redact_input_text("tool version: 12.1 loaded").unwrap(); + assert_eq!(r.text, "tool version: *** loaded"); + assert_eq!(r.counts.get("eda_version"), Some(&1)); + } + + #[test] + fn group_scoped_rule_keeps_json_parseable() { + let rule = PiiRule::new( + "eda_version", + r#""version"\s*:\s*"([^"]*)""#, + PiiAction::Mask, + None, + ) + .unwrap() + .with_replacement(Some("***".into())); + let g = guardrail(vec![rule]); + let doc = r#"{"tool":"eda","version": "12.1","cells":42}"#; + let r = g.redact_input_text(doc).unwrap(); + assert_eq!(r.text, r#"{"tool":"eda","version": "***","cells":42}"#); + // The acceptance criterion is structural, not textual: the + // rewritten document must still parse. + let v: serde_json::Value = serde_json::from_str(&r.text).unwrap(); + assert_eq!(v["version"], "***"); + assert_eq!(v["cells"], 42); + } + + #[test] + fn group_scoped_rule_skips_hard_negatives_byte_for_byte() { + let rule = PiiRule::new( + "eda_version", + r"(?:version|版本)\s*[::]\s*(\d+(?:\.\d+)+)", + PiiAction::Mask, + None, + ) + .unwrap() + .with_replacement(Some("***".into())); + let g = guardrail(vec![rule]); + // Dot-separated numbers everywhere, none anchored by the keyword: + // zero hits, and None means the caller keeps the original bytes. + let log = "Elapsed: 12.345s Memory: 4.2 GB node 0.13um top.v:12:1 at 10.2.255.1"; + assert!(g.redact_input_text(log).is_none()); + // Mixed hit + negatives: only the anchored value is rewritten, + // every other byte survives verbatim (exact-string assertion). + let mixed = "Elapsed: 12.345s 版本:12.1 at 10.2.255.1"; + let r = g.redact_input_text(mixed).unwrap(); + assert_eq!(r.text, "Elapsed: 12.345s 版本:*** at 10.2.255.1"); + } + + #[test] + fn group_scoped_validator_checks_group_not_whole_match() { + // A prefixed card-number rule: the whole match includes "card " + // which can never pass Luhn. The validator must run on group 1 — + // otherwise a prefixed pattern silently disables its checksum. + let rule = PiiRule::new( + "prefixed_card", + r"card (\d{13,19})", + PiiAction::Mask, + Some(luhn_checksum), + ) + .unwrap() + .with_replacement(Some("####".into())); + let g = guardrail(vec![rule]); + // 4111111111111111 passes Luhn → masked, prefix kept. + let r = g.redact_input_text("card 4111111111111111 ok").unwrap(); + assert_eq!(r.text, "card #### ok"); + // Same shape, broken check digit → untouched. + assert!(g.redact_input_text("card 4111111111111112 ok").is_none()); + } + + #[test] + fn group_scoped_block_rule_validates_group_too() { + let rule = PiiRule::new( + "prefixed_card", + r"card (\d{13,19})", + PiiAction::Block, + Some(luhn_checksum), + ) + .unwrap(); + let g = guardrail(vec![rule]); + assert!(g.first_block_match("card 4111111111111111").is_some()); + assert!(g.first_block_match("card 4111111111111112").is_none()); + } + + #[test] + fn group_not_participating_leaves_match_untouched() { + // Alternation where only one branch carries group 1: the other + // branch has no sensitive segment to replace, so it stays as-is. + let rule = PiiRule::new("opt_group", r"ver=(\d+)|verless", PiiAction::Mask, None) + .unwrap() + .with_replacement(Some("***".into())); + let g = guardrail(vec![rule]); + let r = g.redact_input_text("ver=42 and verless mode").unwrap(); + assert_eq!(r.text, "ver=*** and verless mode"); + assert_eq!(r.counts.get("opt_group"), Some(&1)); + assert!(g.redact_input_text("verless only").is_none()); + } + + #[test] + fn replacement_defaults_to_name_token_and_supports_empty() { + // No replacement → the [_REDACTED] default, group-scoped. + let rule = PiiRule::new("ver", r"version: (\S+)", PiiAction::Mask, None).unwrap(); + let g = guardrail(vec![rule]); + let r = g.redact_input_text("version: 12.1").unwrap(); + assert_eq!(r.text, "version: [VER_REDACTED]"); + // Empty replacement deletes the span. + let rule = PiiRule::new("ver", r"version: (\S+)", PiiAction::Mask, None) + .unwrap() + .with_replacement(Some(String::new())); + let g = guardrail(vec![rule]); + let r = g.redact_input_text("version: 12.1 end").unwrap(); + assert_eq!(r.text, "version: end"); + } + + #[test] + fn replacement_without_groups_replaces_whole_match() { + // Back-compat: no capture group + custom replacement still swaps + // the entire match. + let rule = PiiRule::new("ver", r"\bv\d+\.\d+\b", PiiAction::Mask, None) + .unwrap() + .with_replacement(Some("vX.Y".into())); + let g = guardrail(vec![rule]); + let r = g.redact_input_text("running v12.1 now").unwrap(); + assert_eq!(r.text, "running vX.Y now"); + } + + #[test] + fn replacement_dollar_is_literal_not_group_expansion() { + let rule = PiiRule::new("ver", r"version: (\S+)", PiiAction::Mask, None) + .unwrap() + .with_replacement(Some("$1".into())); + let g = guardrail(vec![rule]); + let r = g.redact_input_text("version: 12.1").unwrap(); + assert_eq!(r.text, "version: $1"); + } + #[test] fn every_builtin_detector_compiles() { for (id, _, _) in BUILTIN_DETECTORS { - assert!( - builtin_rule(id, PiiAction::Mask).is_some(), - "builtin {id} must compile", + let rule = builtin_rule(id, PiiAction::Mask) + .unwrap_or_else(|| panic!("builtin {id} must compile")); + // group_scoped is auto-detected from the pattern, so an + // accidental capturing `(...)` in a builtin would silently + // narrow its replacement to group 1. Builtins must stay + // whole-match: use `(?:…)` for grouping. + assert_eq!( + rule.regex.captures_len(), + 1, + "builtin {id} must not declare capture groups", ); + assert!(!rule.group_scoped); } assert!(builtin_rule("no_such_detector", PiiAction::Mask).is_none()); } diff --git a/schemas/resources/guardrail.schema.json b/schemas/resources/guardrail.schema.json index 5bcd79d5..2ef69363 100644 --- a/schemas/resources/guardrail.schema.json +++ b/schemas/resources/guardrail.schema.json @@ -175,9 +175,14 @@ "type": "string" }, "regex": { - "description": "Regular expression AISIX compiles when building the guardrail chain. An invalid pattern makes AISIX log and skip the guardrail.", + "description": "Regular expression AISIX compiles when building the guardrail chain. An invalid pattern makes AISIX log and skip the guardrail.\n\nWhen the expression declares at least one capture group, a `mask` action rewrites only the first capture group of each match and keeps the rest of the match unchanged. Use this to replace a value while preserving its surrounding key or label, for example `\"version\"\\s*:\\s*\"([^\"]*)\"`. Without capture groups, the whole match is rewritten.", "minLength": 1, "type": "string" + }, + "replacement": { + "description": "Literal text that replaces the masked span, such as `***`. When omitted, the span is rewritten to `[_REDACTED]`. An empty string removes the span. Only valid when the pattern's effective action is `mask`.", + "maxLength": 256, + "type": "string" } }, "required": [ diff --git a/tests/e2e/src/cases/guardrail-pii-capture-group-e2e.test.ts b/tests/e2e/src/cases/guardrail-pii-capture-group-e2e.test.ts new file mode 100644 index 00000000..b0e0430d --- /dev/null +++ b/tests/e2e/src/cases/guardrail-pii-capture-group-e2e.test.ts @@ -0,0 +1,196 @@ +import { createHash } from "node:crypto"; +import OpenAI from "openai"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + EtcdClient, + ProxyClient, + SeedClient, + spawnApp, + startOpenAiUpstream, + waitConfigPropagation, + type OpenAiUpstream, + type SpawnedApp, +} from "../harness/index.js"; + +// E2E: capture-group custom patterns + configurable `replacement` +// (AISIX-Cloud#1334) — the "replace the value, keep the key" semantics. +// +// Rules under test (seeded as kind=pii custom_patterns): +// - plain form `(?:version|版本)\s*[::]\s*(\d+(?:\.\d+)+)` → group 1 +// becomes `***`, the keyword and separator stay verbatim; anchoring on +// the keyword is what keeps dot-separated negatives (durations, sizes, +// IPs, file:line) untouched. +// - JSON form `"version"\s*:\s*"([^"]*)"` → only the value inside the +// quotes is rewritten, so the document still parses. +// +// Acceptance (issue #1334): both forms rewrite on request AND response, +// Chinese and English labels covered, JSON stays parseable (asserted by +// parsing, not string comparison), hard negatives hit zero, non-matched +// content survives byte-for-byte (exact-equality assertions), and the +// whole flow stays 200 — mask never blocks. + +const CALLER = "sk-pii-capture-e2e"; +const hash = (s: string) => createHash("sha256").update(s).digest("hex"); + +// Hard negatives from the issue: dot-separated numbers that are NOT +// version values (duration, size, process node, file:line, IPv4). +const NEGATIVES = + "Elapsed: 12.345s Memory: 4.2 GB node 0.13um top.v:12:1 ip 10.2.255.1"; + +// Mixed prompt: an English hit, the negatives, and a Chinese hit. +const PROMPT = `EDA version: 12.1 ${NEGATIVES} 工具版本:2022.4 结束`; +const PROMPT_MASKED = `EDA version: *** ${NEGATIVES} 工具版本:*** 结束`; + +// The model reply embeds the JSON key-value form. +const REPLY_JSON = `{"tool":"vcs","version": "12.1","cells":42}`; +const REPLY = `config ${REPLY_JSON} ok ${NEGATIVES}`; + +describe("pii capture-group + replacement e2e", () => { + let app: SpawnedApp | undefined; + let upstream: OpenAiUpstream | undefined; + let etcdReachable = false; + + beforeAll(async () => { + const etcd = new EtcdClient(); + etcdReachable = await etcd.ping(); + if (!etcdReachable) return; + + upstream = await startOpenAiUpstream({ + nonStreamBody: { + id: "cmpl-capture", + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: "gpt-4o-mini", + choices: [ + { + index: 0, + message: { role: "assistant", content: REPLY }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 5, completion_tokens: 8, total_tokens: 13 }, + }, + }); + + app = await spawnApp(); + const seed = new SeedClient(etcd, app.etcdPrefix); + + const pk = await seed.createProviderKey({ + display_name: "pii-capture-pk", + secret: "sk-mock", + api_base: `${upstream.baseUrl}/v1`, + }); + await seed.createModel({ + display_name: "pii-capture-e2e", + provider: "openai", + model_name: "gpt-4o-mini", + provider_key_id: pk.id, + }); + await seed.createGuardrail({ + name: "pii-capture-guard", + enabled: true, + hook_point: "both", + kind: "pii", + custom_patterns: [ + { + name: "eda_version", + regex: "(?:version|版本)\\s*[::]\\s*(\\d+(?:\\.\\d+)+)", + action: "mask", + replacement: "***", + }, + { + name: "eda_version_json", + regex: '"version"\\s*:\\s*"([^"]*)"', + action: "mask", + replacement: "***", + }, + ], + }); + + // Caller key LAST: once it authenticates, every resource seeded + // before it (guardrail included) is in the snapshot (AGENTS.md gate + // rule — the gate must not exercise the behavior under test). + await seed.createApiKey({ + key_hash: hash(CALLER), + allowed_models: ["pii-capture-e2e"], + }); + await waitConfigPropagation(async () => { + const r = await new ProxyClient(app!.proxyUrl, CALLER).listModels(); + return r.status === 200; + }); + }); + + afterAll(async () => { + await app?.exit(); + await upstream?.close(); + }); + + const client = () => + new OpenAI({ + apiKey: CALLER, + baseURL: `${app!.proxyUrl}/v1`, + maxRetries: 0, + }); + + const chat = (content: string) => + client().chat.completions.create({ + model: "pii-capture-e2e", + messages: [{ role: "user", content }], + }); + + test("request: value replaced in place, key + negatives byte-identical, zh+en", async (ctx) => { + if (!etcdReachable || !app || !upstream) { + ctx.skip(); + return; + } + + const res = await chat(PROMPT); + // Mask never blocks: the call succeeded (a 422 would have thrown). + expect(res.choices[0]?.message?.content ?? "").not.toBe(""); + + // The upstream saw the ENTIRE prompt with only the two anchored + // values rewritten — exact equality is the byte-for-byte assertion + // for everything outside the hits (negatives included). + const sent = JSON.parse(upstream.receivedRequests.at(-1)!.body) as { + messages: Array<{ content: string }>; + }; + expect(sent.messages[0].content).toBe(PROMPT_MASKED); + }); + + test("request: a no-hit prompt passes through byte-identical", async (ctx) => { + if (!etcdReachable || !app || !upstream) { + ctx.skip(); + return; + } + await chat(NEGATIVES); + const sent = JSON.parse(upstream.receivedRequests.at(-1)!.body) as { + messages: Array<{ content: string }>; + }; + expect(sent.messages[0].content).toBe(NEGATIVES); + }); + + test("response: JSON form masked in place and still parseable", async (ctx) => { + if (!etcdReachable || !app) { + ctx.skip(); + return; + } + const res = await chat("show me the config"); + const reply = res.choices[0]?.message?.content ?? ""; + // Only the value inside the quotes changed; prefix/suffix and the + // negatives after the JSON survive byte-for-byte. + expect(reply).toBe( + `config {"tool":"vcs","version": "***","cells":42} ok ${NEGATIVES}`, + ); + // Structural acceptance: parse the embedded JSON, don't just diff it. + const embedded = reply.slice(reply.indexOf("{"), reply.lastIndexOf("}") + 1); + const parsed = JSON.parse(embedded) as { + tool: string; + version: string; + cells: number; + }; + expect(parsed.version).toBe("***"); + expect(parsed.tool).toBe("vcs"); + expect(parsed.cells).toBe(42); + expect(reply).not.toContain("12.1"); + }); +});