fix: the detector and the neutralizer agreed on nothing invisible (#812, #813, #814) - #876
Conversation
|
📄 Docs preview: https://cecd1337.disarm-docs.pages.dev |
There was a problem hiding this comment.
🟡 Changes recommended
There are a couple of small but concrete issues in src/pipeline.rs (misleading flag comment and a missing buffer reserve in the new step) that should be corrected before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Unifies the definition of “invisible” characters across stripping (neutralization), anomaly detection, and LLM pipelines so that characters removed by canonicalization/stripping are also detectable (and vice versa), closing the gaps described in #812/#813/#814.
Changes:
- Adds
Default_IgnorableCfcoverage (the 12 Duployan + musical controls) to the stripping and detection paths via a shared predicate. - Introduces
strip_puaas a pipeline step and wires it into most policy profiles (while keepingcode_contextpreserving PUA by design). - Expands anomaly detection to treat PUA runs as an “invisible” carrier class and updates docs/CHANGELOG and tests accordingly.
File summaries
| File | Description |
|---|---|
| tests/test_policy_profiles.py | Updates expected named-profile step lists to include strip_pua. |
| tests/test_llm_presets.py | Locks step order for llm_guardrail/rag_ingest to include strip_pua. |
| tests/test_invisible_channel.py | New regression tests asserting stripper/detector agreement on the invisible channel. |
| tests/test_cve_vectors.py | Updates CVE-vector expectations around profiles vs presets for PUA handling. |
| src/whitespace.rs | Extends zero-width stripping to include the new default-ignorable format predicate. |
| src/presets.rs | Ensures preset fast-path classification accounts for the new invisible class member. |
| src/pipeline.rs | Adds STRIP_PUA step bitflag, ordering, profile wiring, and execution logic. |
| src/invisibles.rs | Adds is_default_ignorable_format and uses it in invisible-class stripping. |
| src/anomalies.rs | Uses the shared predicate for in-word invisibles and adds PUA run detection. |
| docs/user-guide/llm-pipelines.md | Documents the guardrail/profile PUA behavior change and code_context carve-out. |
| docs/user-guide/anomaly-detection.md | Updates the “invisible” kind definition and false-positive guards. |
| docs/security/watermarks.md | Updates the watermark measurement counts now that the “neither” class is closed. |
| CHANGELOG.md | Announces the unified invisible definition and the widened strip_zero_width_chars surface. |
Review details
- Files reviewed: 13/13 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
#876 review, two notes. The `STRIP_PUA` arm cleared `out` and extended without reserving. `filter`'s `size_hint` lower bound is 0, so `extend` cannot pre-size the buffer — which is the reason `strip_zero_width_chars_into` reserves, and this is the same shape. The flag's doc comment said it is set "by a caller through `Pipeline::new`". `Pipeline::new` has no such parameter and `ProfileSpec::build` is the only thing that sets it. The comment is now what is true, and says why it is that way: a thirteenth positional argument on `Pipeline::new` is a breaking change owed to six bindings, while the named profiles are curated recommendations that can take the preset policy without a signature change. A comment claiming a setter that does not exist is what prompted the note, so the claim is asserted rather than left as prose: a hand-built `TextPipeline` must keep PUA and must not list a `strip_pua` step. Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
…, #813, #814) Three surfaces, one channel. **#813.** Twelve `Cf` code points that Unicode marks `Default_Ignorable_Code_Point=Yes` — Duployan `U+1BCA0`–`U+1BCA3` and musical `U+1D173`–`U+1D17A` — were removed by nothing and reported by nothing. `pay` + `U+1D173` + `pal` survived `strip_format`, `canonicalize`, `canonicalize_strict`, `strip_obfuscation` and `llm_guardrail` untouched and reported clean. That is the worked example from `docs/user-guide/anomaly-detection.md`, which is caught with `U+200B`. They are invisible by *property* rather than by name, which is how they escaped every predicate: not zero-width by name, not Tags, not fillers, not bidi. The other 29 `Cf` code points that survive `strip_format` render and carry meaning — Arabic and Kaithi number signs, Egyptian hieroglyph layout controls — and are still kept. The fix is the twelve, not the category. **#812.** A run of Private Use Area code points was removed by `canonicalize` and reported by nothing, so a guardrail screening with `has_anomalies` passed exactly what the comparison presets had already decided was not text. Added as a `Carrier` with a run threshold of four rather than as a neighbour rule: one PUA code point beside a letter is an icon-font glyph, which is the same reason #413 has `strip_format` keep the block at all. **#814.** `ProfileSpec` had twelve fields and none was PUA, so `llm_guardrail` — the profile `docs/user-guide/llm-pipelines.md` sends a guardrail author to — could not strip the Private Use Area even in principle. Adds `PipelineSteps::STRIP_PUA` and the spec field. Seven of the eight profiles set it; `code_context` does not, which is #413's rule applied to the profiles rather than an omission — it is the one profile whose job is to preserve its input. The twelve are defined once, in `invisibles.rs`, and the strip path, the preset fast-path mask and the detector all read that definition. #700 found the detector's own eight-character list drifted from the strip functions; restating the class in a second place is how that happens. One consequence is public: `strip_zero_width_chars` widens by those twelve on every surface. It is the function whose description is "strip zero-width and invisible characters", and a code point that renders as nothing belongs to that set by that description. `docs/security/watermarks.md` had already measured this class before any of it was fixed: its "removed by nothing, reported by nothing" row held exactly these 12, and `test_watermark_page.py` failed the moment the fix landed. That row is now empty. Two existing tests asserted the defect and were updated rather than deleted: `test_ml_normalize_leaves_private_use_area` keeps its name because the preset half is still open, and the profile step-order locks gained `strip_pua`. `tests/test_invisible_channel.py` is the regression cover — 42 cases, every one of which fails on 0.14.1. Its closing gate is anchored to the Unicode property rather than to the list of twelve, so it asks the question the issue asked instead of restating the answer. Refs #762 Closes #812 Closes #813 Closes #814 Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
#876 review, two notes. The `STRIP_PUA` arm cleared `out` and extended without reserving. `filter`'s `size_hint` lower bound is 0, so `extend` cannot pre-size the buffer — which is the reason `strip_zero_width_chars_into` reserves, and this is the same shape. The flag's doc comment said it is set "by a caller through `Pipeline::new`". `Pipeline::new` has no such parameter and `ProfileSpec::build` is the only thing that sets it. The comment is now what is true, and says why it is that way: a thirteenth positional argument on `Pipeline::new` is a breaking change owed to six bindings, while the named profiles are curated recommendations that can take the preset policy without a signature change. A comment claiming a setter that does not exist is what prompted the note, so the claim is asserted rather than left as prose: a hand-built `TextPipeline` must keep PUA and must not list a `strip_pua` step. Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
337203b to
f86f25c
Compare
Closes #812, closes #813, closes #814. Refs #762.
Three issues, one channel: the neutralizer and the detector held independent definitions of "invisible", and they disagreed in both directions.
What was measured on
mainbefore the changestrip_formatcanonicalizellm_guardrailhas_anomaliespay+U+1D173+palpay+U+1BCA0+palHello+U+E000×4Hello+ tag payloadThe tag row is included because #812's tag half was already fixed by #700 — the issue was written against 0.14.1. Re-measuring first is what kept this PR to the part that is still broken.
#813 — invisible by property, not by name
Twelve
Cfcode points markedDefault_Ignorable_Code_Point=Yes: DuployanU+1BCA0–U+1BCA3, musicalU+1D173–U+1D17A. Not zero-width by name, not Tags, not fillers, not bidi — so no predicate covered them.paysplit by an invisible is the worked example in the anomaly guide, and it is caught withU+200B. It was not caught withU+1D173.Scoped to the twelve, not the category: of the 41
Cfthat survivestrip_format, the other 29 render and carry meaning (Arabic and Kaithi number signs, Egyptian hieroglyph layout controls) and are still kept. There is a test for that direction too.#812 — the PUA run
canonicalizedeleted it, the detector said nothing. Added as aCarrierwith a run threshold of 4, deliberately not a neighbour rule: a single PUA code point beside a letter is an icon-font glyph, which is the same reason #413 hasstrip_formatkeep the block.#814 — the profile could not, even in principle
ProfileSpechad twelve fields and none was PUA. AddsPipelineSteps::STRIP_PUAand the spec field; seven of eight profiles set it.code_contextdoes not — #413's rule applied to the profiles, not an omission, since it is the one profile whose job is preserving its input.One definition
The twelve live in
invisibles.rsand the strip path, the preset fast-path mask and the detector all read that function. #700 found the detector's own eight-character list had drifted from the strip functions; restating a class in a second place is how that happens.One consequence is public and stated in the CHANGELOG:
strip_zero_width_charswidens by those twelve on every surface. It is the function whose description is "strip zero-width and invisible characters", and a code point that renders as nothing belongs to that set by that description.What caught it independently
docs/security/watermarks.mdhad already measured this exact class, and its "removed by nothing, reported by nothing" row held exactly these 12.test_watermark_page.pyfailed the moment the fix landed, withneither: 12 -> 0. The page is updated; that row is now empty.Tests
tests/test_invisible_channel.py, 42 cases, every one failing on 0.14.1. Its closing gate enumeratesCfand asks which survivors areDefault_Ignorable— anchored to the property rather than to the list of twelve, so it asks the issue's question instead of restating the answer.Two existing tests asserted the defect and were updated rather than deleted:
test_ml_normalize_leaves_private_use_areakeeps its name because the preset half is still open, and the two profile step-order locks gainedstrip_pua.Full gate green:
cargo test723,pytest6,290, doc tests 41 pages, clippy both feature sets,perf_lint.sh,cargo doc,mkdocs --strict. Nobindings/file changed and nosrc/apisignature moved.🤖 Generated with Claude Code