Title
internal/redact: rule engine, allowlist, config integration
Summary
Implement the redaction engine mechanics: ordered rule application with
value-group masking, allowlist exemption, user extra patterns, bounded
block masking, and the Redactor construction from config — per DESIGN.md
§8. The built-in ruleset content ships separately (17).
Context
Invariant I4 makes this engine the chokepoint between raw source content and
everything user-visible (report, LLM). It must be RE2-safe (no backtracking
DoS from adversarial history lines), deterministic, and cheap enough to run
twice (aggregation stage + LLM prompt defense-in-depth).
Scope
internal/redact/redact.go, rule.go, tests. Rules content minimal
here (two placeholder rules for tests); real corpus in 17.
Detailed Requirements
-
Types:
type Rule struct {
ID string // stable, kebab-case (public API for allowlist docs)
Pattern *regexp.Regexp // RE2
Group int // 0 = mask whole match; N = mask only group N
Block bool // multiline block rule (see 4)
}
type Redactor struct { /* rules, allowlist, counters */ }
-
New(rules []Rule, allowlist []*regexp.Regexp, extra []*regexp.Regexp) (*Redactor, error) — validates: unique ids, Group within pattern's group
count. FromConfig(cfg config.Redaction, builtin []Rule) compiles
extra_patterns (ids extra-1…, Group 0) and allowlist; when
cfg.Mode == "off" returns a no-op Redactor whose Enabled() == false.
-
Redact(s string) (string, int):
- Apply rules in slice order. For each match: if any allowlist regex
matches the entire matched text (group-0 span), skip masking that
match.
- Replacement: group-targeted splice
[REDACTED:<id>] replacing only the
group span (regexp FindAllStringSubmatchIndex + manual rebuild —
ReplaceAllString cannot target groups).
- Count total maskings; deterministic single pass per rule (no re-scan of
already-masked output for the same rule; later rules do scan the
partially-masked string — order matters and is fixed by the rule
slice).
-
Block rules (Block: true, e.g. private-key blocks): pattern matches the
opening line; masking extends from the opening match through the matching
-----END …----- line or at most 100 lines, whichever first
(DESIGN §8), replaced by one [REDACTED:<id>].
-
RedactEvent(e model.Event) (model.Event, int) helper — applies
Redact to Title, Body, Project, and every Meta value; returns the
modified copy and the total masking count across all fields (used by 18;
LLM (24) uses plain Redact on the final prompt string).
-
Performance guard: engine must process a 1 MiB adversarial string
(aaaa… + near-miss token prefixes) in < 100ms with the 17 ruleset —
benchmark included (BenchmarkRedactWorstCase), asserted loosely in a
test (< 1s) to catch accidental catastrophic patterns.
-
No logging of matched content anywhere (the engine must never leak what
it masked — code-review AC).
Acceptance Criteria
Validation
go test -race -cover ./internal/redact/ + go test -bench RedactWorstCase -benchtime 1x output in PR.
Dependencies
01, 05 (config types), 03 (Event for the helper).
Non-goals
The real ruleset + corpus (17), entropy detection (v2, ADR-004), masking in
parsers (layering: parsers stay raw).
Design References
docs/DESIGN.md §8, §1.2 (I4)
docs/decisions/ADR-004-redaction-default-on.md
Source of truth: docs/issues/16-redaction-engine.md (PR #1, branch docs/v1-design). If this issue and the repo docs disagree, the docs win. Execution order and dependencies: docs/ISSUE_PLAN.md (this is issue 16 of 33).
Title
internal/redact: rule engine, allowlist, config integration
Summary
Implement the redaction engine mechanics: ordered rule application with
value-group masking, allowlist exemption, user extra patterns, bounded
block masking, and the
Redactorconstruction from config — per DESIGN.md§8. The built-in ruleset content ships separately (17).
Context
Invariant I4 makes this engine the chokepoint between raw source content and
everything user-visible (report, LLM). It must be RE2-safe (no backtracking
DoS from adversarial history lines), deterministic, and cheap enough to run
twice (aggregation stage + LLM prompt defense-in-depth).
Scope
internal/redact/redact.go,rule.go, tests. Rules content minimalhere (two placeholder rules for tests); real corpus in 17.
Detailed Requirements
Types:
New(rules []Rule, allowlist []*regexp.Regexp, extra []*regexp.Regexp) (*Redactor, error)— validates: unique ids, Group within pattern's groupcount.
FromConfig(cfg config.Redaction, builtin []Rule)compilesextra_patterns(idsextra-1…, Group 0) andallowlist; whencfg.Mode == "off"returns a no-op Redactor whoseEnabled() == false.Redact(s string) (string, int):matches the entire matched text (group-0 span), skip masking that
match.
[REDACTED:<id>]replacing only thegroup span (regexp
FindAllStringSubmatchIndex+ manual rebuild —ReplaceAllStringcannot target groups).already-masked output for the same rule; later rules do scan the
partially-masked string — order matters and is fixed by the rule
slice).
Block rules (
Block: true, e.g. private-key blocks): pattern matches theopening line; masking extends from the opening match through the matching
-----END …-----line or at most 100 lines, whichever first(DESIGN §8), replaced by one
[REDACTED:<id>].RedactEvent(e model.Event) (model.Event, int)helper — appliesRedactto Title, Body, Project, and every Meta value; returns themodified copy and the total masking count across all fields (used by 18;
LLM (24) uses plain
Redacton the final prompt string).Performance guard: engine must process a 1 MiB adversarial string
(
aaaa…+ near-miss token prefixes) in < 100ms with the 17 ruleset —benchmark included (
BenchmarkRedactWorstCase), asserted loosely in atest (< 1s) to catch accidental catastrophic patterns.
No logging of matched content anywhere (the engine must never leak what
it masked — code-review AC).
Acceptance Criteria
prefix/suffix (table test with env-var style placeholder rule).
masking intact; allowlist never adds content.
block without END masks exactly 100 lines then stops.
extra_patternsmasked withextra-Nids;mode="off"returns inputverbatim with count 0 and
Enabled()==false.Redact(Redact(x)) == Redact(x)over the test corpus(replacement tokens must not re-match any rule — asserted).
resolution: earlier-rule match wins, later rules see the masked text
(asserted with crafted overlap).
Validation
go test -race -cover ./internal/redact/+go test -bench RedactWorstCase -benchtime 1xoutput in PR.Dependencies
01, 05 (config types), 03 (Event for the helper).
Non-goals
The real ruleset + corpus (17), entropy detection (v2, ADR-004), masking in
parsers (layering: parsers stay raw).
Design References
docs/DESIGN.md§8, §1.2 (I4)docs/decisions/ADR-004-redaction-default-on.mdSource of truth:
docs/issues/16-redaction-engine.md(PR #1, branchdocs/v1-design). If this issue and the repo docs disagree, the docs win. Execution order and dependencies:docs/ISSUE_PLAN.md(this is issue 16 of 33).