Skip to content

fix: the detector and the neutralizer agreed on nothing invisible (#812, #813, #814) - #876

Merged
raeq merged 2 commits into
mainfrom
fix/invisible-channel
Sep 1, 2026
Merged

fix: the detector and the neutralizer agreed on nothing invisible (#812, #813, #814)#876
raeq merged 2 commits into
mainfrom
fix/invisible-channel

Conversation

@raeq

@raeq raeq commented Sep 1, 2026

Copy link
Copy Markdown
Owner

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 main before the change

input strip_format canonicalize llm_guardrail has_anomalies
pay + U+1D173 + pal unchanged unchanged unchanged False
pay + U+1BCA0 + pal unchanged unchanged unchanged False
Hello + U+E000×4 kept (correct) stripped kept False
Hello + tag payload stripped stripped stripped True

The 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 Cf code points marked Default_Ignorable_Code_Point=Yes: Duployan U+1BCA0U+1BCA3, musical U+1D173U+1D17A. Not zero-width by name, not Tags, not fillers, not bidi — so no predicate covered them.

pay split by an invisible is the worked example in the anomaly guide, and it is caught with U+200B. It was not caught with U+1D173.

Scoped to the twelve, not the category: of the 41 Cf that survive strip_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

canonicalize deleted it, the detector said nothing. Added as a Carrier with 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 has strip_format keep the block.

#814 — the profile could not, even in principle

ProfileSpec had twelve fields and none was PUA. Adds PipelineSteps::STRIP_PUA and the spec field; seven of eight profiles set it. code_context does not — #413's rule applied to the profiles, not an omission, since it is the one profile whose job is preserving its input.

>>> get_pipeline("llm_guardrail")("Summarize." + chr(0xF0000))
'summarize.'                      # was 'summarize.\U000f0000'
>>> get_pipeline("code_context")("Menu " + chr(0xE000))
'Menu '                     # preserved, by design

One definition

The twelve live in invisibles.rs and 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_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.

What caught it independently

docs/security/watermarks.md had already measured this exact class, and its "removed by nothing, reported by nothing" row held exactly these 12. test_watermark_page.py failed the moment the fix landed, with neither: 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 enumerates Cf and asks which survivors are Default_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_area keeps its name because the preset half is still open, and the two profile step-order locks gained strip_pua.

Full gate green: cargo test 723, pytest 6,290, doc tests 41 pages, clippy both feature sets, perf_lint.sh, cargo doc, mkdocs --strict. No bindings/ file changed and no src/api signature moved.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 1, 2026 16:16
@raeq
raeq enabled auto-merge (squash) September 1, 2026 16:16
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

📄 Docs preview: https://cecd1337.disarm-docs.pages.dev

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_Ignorable Cf coverage (the 12 Duployan + musical controls) to the stripping and detection paths via a shared predicate.
  • Introduces strip_pua as a pipeline step and wires it into most policy profiles (while keeping code_context preserving 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.

Comment thread src/pipeline.rs
Comment thread src/pipeline.rs Outdated
raeq added a commit that referenced this pull request Sep 1, 2026
#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>
raeq added 2 commits September 1, 2026 18:43
…, #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>
@raeq
raeq force-pushed the fix/invisible-channel branch from 337203b to f86f25c Compare September 1, 2026 16:44
@raeq
raeq merged commit 5250547 into main Sep 1, 2026
22 checks passed
@raeq
raeq deleted the fix/invisible-channel branch September 1, 2026 16:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants