Skip to content

refactor(redact): lift + generalize the ADR-0018 scrubbers into a shared engine/redact/ module (JEF-486)#258

Merged
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-486-lift-generalize-the-redaction-primitives-out-of-notifyrs
Jul 23, 2026
Merged

refactor(redact): lift + generalize the ADR-0018 scrubbers into a shared engine/redact/ module (JEF-486)#258
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-486-lift-generalize-the-redaction-primitives-out-of-notifyrs

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

What

Lifts the breach notifier's redaction primitives out of notify.rs into a new shared engine/src/engine/redact/ module and generalizes them off BreachNotice, so the (future) read-only MCP server from ADR-0031 reuses the exact same scrubbers — the two sanctioned egress paths can't drift in what they consider safe to emit off-cluster.

Grounded in docs/adr/0031-read-only-mcp-server-tiered-redaction.md §3, which calls for sanitize / scrub_decision_names / scrub_cve_tokens to be "lifted and generalized from their current private home ... into a shared redaction module."

Generalized API (engine/src/engine/redact/)

  • sanitize(&str) -> String — strip prompt/wire STRUCTURE (fences, braces, backtick, CR/LF). Now the ONE home; previously the adjudication prompt kept its own copy in reason::adjudicate::guards, which now re-exports this so the prompt path is unchanged.
  • scrub_decision_names(text: &str, names: &[&str]) -> String — replace each supplied name (longest-first, trimmed, deduped) with [redacted]. SEMANTIC name stripping only — no CVE coupling, so the MCP raw tier can relax it independently.
  • scrub_cve_tokens(text: &str) -> String — case-insensitive CVE-… token scrub; independent so the MCP forensic tier can relax it.
  • redacted_attack_outcome(refs: impl IntoIterator<Item = &AttackRef>, cap) -> Vec<Value> — the counts-only ATT&CK-outcome reducer (distinct, ordered, sanitized, capped techniques — never the per-objective targets).

notify.rs re-points to these; the notifier-specific name-collection (entry + objective node-keys + bare suffixes) stays local as decision_names. Each new file is well under the 1,000-line cap.

Byte-identical proof (the load-bearing check)

Captured the exact serialized redacted payload from the pre-refactor code for a fixture exercising every scrubber (structure chars + a CVE token + a full node-key AND its bare secret name), across default / verbose / armed shapes, then pinned those exact strings in a new test redacted_payload_is_byte_identical_to_pre_refactor. It passes against the refactored code, proving the notifier's output is unchanged to the byte. All pre-existing ADR-0018 notifier tests stay green.

Tests

  • New engine::redact::tests — 12 unit tests on the generalized inputs the MCP server will pass (verdict text blob, CVE line, bare decision name) plus fence/CVE/name edge cases.
  • New byte-identical snapshot assertion in the notifier tests.

Checks

  • cargo fmt — clean
  • cargo clippy --all-targets -- -D warnings — clean
  • cargo test — 814 lib + all integration tests pass (incl. the file-size guard: no file >1000 lines)

Scope note / decision recorded

The ticket's "counts-only helper" was ambiguous (parenthesized as "redacted_payload / whatever it's called"). I interpreted it as the reusable redaction rule — the ATT&CK-outcome reducer that drops per-objective targets and keeps only distinct techniques + a count — and lifted that (redacted_attack_outcome), rather than moving the notifier's whole redacted_payload wire-shape builder (which stays in notify.rs, since it assembles the notifier's specific JSON, not a shared primitive). This keeps redact/ focused on shared redaction decisions and out of per-path wire shapes, matching ADR-0031's "one redaction implementation" goal without over-building.

Closes JEF-486

🤖 Generated with Claude Code

https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP

…red engine/redact/ module (JEF-486)

The breach notifier's redaction primitives were private to notify.rs and keyed
on BreachNotice. ADR-0031's read-only MCP server must reuse the exact same
scrubbers so the two sanctioned egress paths can't drift in what they consider
safe to emit off-cluster.

Lift `sanitize`, `scrub_decision_names`, `scrub_cve_tokens`, and the counts-only
ATT&CK-outcome reducer into a new `engine/src/engine/redact/` module directory,
generalized off BreachNotice to take primitive inputs (`&str`, `&[&str]`, an
AttackRef iterator). `sanitize` — previously the adjudication prompt's own copy
in reason::adjudicate::guards — now has ONE home in redact/, re-exported by
guards so the prompt path is unchanged. notify.rs re-points to the shared
primitives; the notifier-specific name-collection stays local (`decision_names`).

Behavior is byte-identical for the notifier: a new snapshot test pins the exact
serialized redacted payload (default / verbose / armed) captured from the
pre-refactor code. New redact unit tests cover the generalized inputs the MCP
server will pass (a verdict text blob, a CVE line, a bare decision name) plus the
fence/CVE/name edge cases. Every file stays under the 1,000-line cap.

Closes JEF-486

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
@thejefflarson
thejefflarson merged commit a4556a3 into main Jul 23, 2026
5 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-486-lift-generalize-the-redaction-primitives-out-of-notifyrs branch July 23, 2026 00:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant