HAI-SDLC Hardening Review (Delta) — disarm¶
- Date: 2026-06-18
- Range:
fd7a54f..f24b8e3(delta since the prior review of 2026-06-17) - Scope: Rust core + all bindings, delta-scoped to changed files + blast radius
- Budget profile: Standard
- Mode: Analysis only — no code changed. This file is the deliverable.
Resolution status (2026-06-18 follow-up)¶
A post-0.11 hardening pass actioned this report. Status of each finding:
Already fixed by the 0.11 merges (stale here): - D-1 (
canonicalize/canonicalize_strictnot raw-idempotent) → #434 (confusables iterated to a fixed point). Verified idempotent. - D-5 (sort_keyfold-before-transliterate) → #419. Verified idempotent.Fixed in the follow-up pass: - D-2 —
is_presentation_basenow rejects bases a later strip removes (control / zero-width / blank-render); added astrip_formatidempotency proptest + deterministic test. - D-6 — the emoji-flag tail is validated against the RGI subdivision allowlist (England/Scotland/Wales); any other payload is stripped. Fully closes the channel (not just the partial cap). - D-3 (sub-finding) / M-P5 —reserveadded tostrip_control_chars_into/strip_zero_width_chars_into; ASCII fast path + guard test added tostrip_bidi_into. - D-4 —Text.canonicalizeand_presets.strip_formatpipeline docstrings regenerated. - D-8 — the "collapse is the terminal whitespace step" contract is documented oncollapse_whitespace. - D-1 proptest generator —U+0327/U+0308added to the adversarialSPECIALset. This surfaced a new latent bug:sort_keywas non-idempotent when transliteration emits uppercase (Old Persian𐏈→Auramazda); fixed with a secondfold_caseafter transliterate (the #419 pattern), with a regression test.Deferred — would require a re-architecture or is not a clear win: - D-3 (main) / H-P4 — routing all presets through the
*_intoping-pong is a structural rewrite of the idempotency-critical preset layer (thecanonicalizefixed-point loop + NFC sandwiches don't ping-pong cleanly). Warrants its own PR with allocation benchmarks. The cheap sub-parts (reserves, fast paths) are done. - D-7 — folded into the D-3 ping-pong; same deferral.See the base report below for the carried-over polish/perf items, similarly actioned.
What changed since the last review¶
15 commits. The substantive code delta is concentrated in the sanitization core:
src/invisibles.rs(NEW, #413/#428) — strips the "ASCII-smuggling" classes (Unicode Tags, variation selectors, CGJ, noncharacters, PUA), preserving well-formed emoji subdivision-flag sequences and (rendering policy) presentation selectors.src/presets.rs—security_cleanis now a 10-stage pipeline: NFKC → strip_bidi → strip_invisible_classes → strip_control → strip_zero_width → collapse_whitespace → strip_zalgo(2) → NFC → confusables→latin → NFC. The confusables fold is NFC-sandwiched (#416/#418) and path-separator neutralization was removed (#431).src/whitespace.rs(#433/#437) — the fusedcollapse_whitespace(_, true, true)was split into three functions (strip_control_chars/strip_zero_width_chars/collapse_whitespace); line controls now fold to a space (a\rb→a b) instead of being deleted.src/pipeline.rs,src/api/{presets,text}.rs, the Node TS6 migration, and matching Ruby/Python binding updates surfacing the new ops.- confusables tables — Greek iota U+03B9 re-pointed to the i-class (#438).
How this run was conducted¶
Delta-aware Pass 0, then four analysts (Correctness, Security, Performance, Polish) scoped to the diff, then a verification pass that re-checked every High/Critical against source. The verification pass mattered this time: the Correctness and Security analysts reached opposite conclusions on the headline idempotency guarantee, and independent checking of the confusables tables sided with Correctness (details under D-1).
Verdict¶
The new code is, in isolation, well-built — invisibles.rs has exactly-correct range predicates, complete class coverage, ASCII fast paths, and clean cross-binding parity; the path-separator removal is correct and honestly documented; the whitespace split closes an invisible-join vector. But the central f(f(x)) == f(x) guarantee this delta claims to establish is still broken in three independent, verified ways, and the test suite gives false assurance on exactly that property (one proptest passes vacuously, one preset has no idempotency proptest, one is deliberately omitted). There is also a verified partial bypass of the new anti-smuggling control, and the documented preset-allocation debt (prior H-P4) regressed.
Nothing here is an unconditional Critical, but D-1 is the most important finding of either review and should gate any claim that idempotency is "done."
Findings (all independently verified against source)¶
D-1 · security_clean / normalize_user_input are NOT raw-idempotent · High (Correctness analyst rated Critical) [VERIFIED — table entries confirmed]¶
- Location:
src/presets.rssecurity_clean(10-stage pipeline),normalize_user_input - Trigger:
security_clean("\u{0441}\u{0327}")(Cyrillicс+ combining cedilla) → pass 1 ="ç", pass 2 ="c". - Mechanism (verified): the single confusables pass folds
с(U+0441)→cwhile the combining cedilla floats free; the terminal NFC then composesc+U+0327 →ç(U+00E7), which is itself a confusables key. The next call foldsç→c. I confirmed all three preconditions directly in the source:confusables_to_latin.tsvcontains0441 → cand00E7 → c, and Unicode NFC composesc+U+0327 to U+00E7. The trigger family is the ~8 precomposed letters that are simultaneously confusables keys and decompose to base+dropped-diacritic:Ç ç Ǿ ί ϊ ό ї إ(verified00C7→C,01FE→O,03AF→i,03CA→i,03CC→o,0457→i,0625→lall present). - Why the #416 NFC-sandwich doesn't cover it: the sandwich feeds the fold a consistent composed form, but the failing case is one where the fold itself un-masks a cross-script base into Latin
c; the post-fold NFC then synthesizes a precomposed confusable the fold already passed. This is exactly the "moves the asymmetry one pass downstream" risk flagged when this fix was scoped last session — the robust fix is to fold on NFD (so composed and decomposed fold identically) or iterate fold+NFC to convergence (bounded — the trigger set is finite and each pass strictly shrinks). - Why the proptest misses it (false assurance):
security_clean_idempotentasserts raw equality (good) but its generator's combining-mark set is only U+0301/U+0300/U+0489 — none compose a Latin confusable base into a precomposed table key. The property passes vacuously. Add U+0327 (and one representative of each trigger mark) plus a confusable base to the generator. - Why it matters: violates the idempotency invariant that THREAT_MODEL.md and the docstrings assert as a security property — a canonicalize-on-write vs canonicalize-on-compare denylist can derive two different keys for the same input. Pre-existing; #416 narrowed but did not close it.
- Note on the analyst disagreement: the Security analyst concluded idempotency was "closed" — but it only reasoned about zalgo marks, never the confusable-base + combining-mark path. Verification (the table-entry check above) confirms Correctness was right.
- Confidence: High. Reproduce:
security_clean("\u{0441}\u{0327}").
D-2 · display_clean is NOT idempotent — VS carve-out tests the base before later strips remove it · High [VERIFIED]¶
- Location:
src/presets.rsdisplay_clean;src/invisibles.rs:101-103(is_presentation_base),:175-182(VS keep) - Trigger:
display_clean("\u{2800}\u{FE0F}x")→ pass 1 ="\u{FE0F}x", pass 2 ="x". Also U+115F/1160/3164/FFA0 (Hangul fillers), NUL, ZWSP in front of the selector. - Mechanism (verified):
display_cleanrunsstrip_invisible_classes(step 1b) before the whitespace/control strips.is_presentation_base(ch)is literally!ch.is_whitespace(), and U+2800 is categorySo(not whitespace), so the VS16 after it is kept. Thencollapse_whitespace(step 2) folds U+2800 because it's in theis_blank_renderset (whitespace.rs:93) and trims it as leading space — leaving the VS16 leading, which the next call strips. The function's own comment claims "No NFC pass … idempotent," reasoning about base+mark but missing this carve-out/strip ordering. - Why it matters: another broken
f(f(x))==f(x), anddisplay_cleanhas no idempotency proptest at all — wholly uncaught. Lower than D-1 only because it's the rendering preset, not the comparison path. - Fix direction: decide the presentation-VS fate after the control/zero-width/blank-render strips (run the invisibles VS handling last), or make
is_presentation_basereject the blank-render/control set. Add adisplay_cleanidempotency proptest. - Confidence: High. Reproduce:
display_clean("\u{2800}\u{FE0F}x").
D-3 · Preset per-stage allocation (prior H-P4) regressed — now 7–10 Strings/call · High [VERIFIED]¶
- Location:
src/presets.rs(all presets); contrastsrc/pipeline.rsping-pong - What: presets still chain the returning forms instead of the
*_intotwo-buffer ping-pong the engine uses, and the delta made it worse: the #433 whitespace split turned one fused call into three allocating calls, and #413 added thestrip_invisible_classesstage. Verified stage counts:security_clean6→10,catalog_key6→9,search_key6→8,sort_key6→7,normalize_user_input→9,strip_obfuscation→10. I confirmedsecurity_clean's 10 stages by direct read. - Sub-finding (High):
strip_control_chars(whitespace.rs) andstrip_bidi(presets.rs) start fromString::new()with noreserveand no ASCII fast path — unlike their siblings (strip_zero_width_chars,strip_zalgo,strip_invisible_classes,collapse_whitespaceall have one).strip_control_charsis now a mandatory stage in all six key paths. - Why it matters:
search_key/sort_key/catalog_keyare the documented per-call short-string hot paths; they now do 7–9 heap allocs + full copies where the engine does ~2. On uncapped adversarial input,security_cleanis a 10× linear constant (not super-linear — every stage is O(n) — so DoS-adjacent, not algorithmic). - Fix direction: route presets through the existing
*_intoping-pong (add an_intoform tostrip_invisible_classes); addreserve(text.len())tostrip_control_chars_intoand anis_ascii()fast path tostrip_bidi_into(none of its targets are ASCII). One move reverses the regression and absorbs the split cost. - Confidence: High.
D-4 · Stale Python docstrings describe pre-#413 pipelines (doc-vs-code drift) · High [VERIFIED]¶
- Location:
python/disarm/_text.py:258(Text.security_clean),:280(Text.display_clean),python/disarm/_presets.py:144(display_clean) - What:
Text.security_cleandocstring still reads "NFKC → confusables → strip bidi/format → collapse_whitespace" — omitting strip-invisibles (#413), the anti-zalgo cap (#429), the NFC-sandwich (#416), and the step reordering (it now does ~10 steps).Text.display_cleanreads "Collapse whitespace, strip control and zero-width characters" — omits strip-bidi (which it does) and strip-invisibles._presets.display_clean's "Pipeline:" line omits the #413 strip — and becausedocs/api/pipelines.mdembeds that docstring and prints a correct hand-written step list below it, the rendered page contradicts itself. - Why it matters: the exact doc-vs-code drift class the run targets, on security-relevant presets; the sibling
collapse_whitespacedocstring was updated in this same delta, so the omission is conspicuous. - Fix direction: regenerate the three docstrings from the current pipeline.
- Confidence: High.
D-5 · sort_key still non-idempotent (transliterate-before-fold) — proptest deliberately omitted · Medium [VERIFIED]¶
- Location:
src/presets.rssort_key; proptest omission comment atsrc/presets.rs:1063 - Trigger:
sort_key("\u{1CB1}")(Georgian Mtavruli HE) → pass 1 ="\u{10F1}"(ჱ) → pass 2 ="he". - Mechanism (verified):
sort_keytransliterates beforefold_case. U+1CB1 is absent fromtranslit_default.tsv(preserved),fold_caselowercases it to U+10F1, which is in the table (10F1 → he, confirmed). So the second pass transliterates it. General case-before-transliterate ordering bug. - Status: this is the bug deferred last session (now tracked as #419). The delta deliberately omits the global
sort_key_idempotentproptest (comment confirmed atpresets.rs:1063) rather than fixing it — a known-broken invariant hidden from CI, against the project's broken-windows norm. - Fix direction: fold before transliterate (or iterate to convergence); then re-enable the omitted proptest. (This matches the Option-1 plan agreed last session — the fix just hasn't landed yet.)
- Confidence: High.
D-6 · Emoji-flag carve-out is a partial ASCII-smuggling bypass · Medium [VERIFIED]¶
- Location:
src/invisibles.rsconsume_flag_tail(reached bystrip_tags/strip_invisible_classes) - What:
consume_flag_tailpreserves any run of tag letters (U+E0061–E007A) terminated by CANCEL TAG (U+E007F) followingU+1F3F4, without checking it spells a real ISO-3166 subdivision (verified: the function just accumulates tag letters and accepts on the terminator). Tag letters decode to ASCII lowercasea–z— the standard smuggling alphabet. So a lowercase payload wrapped asU+1F3F4+ payload-as-tag-chars +U+E007Fsurvivesstrip_tags,security_clean, andnormalize_user_input. - Why it matters: the feature's docs/CHANGELOG promise to neutralize the "ASCII smuggling into LLMs" Tags channel; THREAT_MODEL.md doesn't carve out the flag exception. Bounded to lowercase (uppercase/digits/space break the tail), so it's a partial channel, not a full one — hence Medium.
- Fix direction: validate the tail against the known subdivision allowlist (the emoji engine already matches real flags elsewhere), or cap to the ≤6-letter region-subtag shape; otherwise document the carve-out as an explicit limitation.
- Confidence: High.
Medium / Low (carried-over and minor)¶
- D-7 (Medium, perf): the 3-way whitespace split triples passes over the tail in every key path (
strip_control+strip_zero_width+collapse_whitespacewhere there was one fused call). Folded into the D-3 ping-pong fix. (Verified mechanism; impact modest — tail buffers are usually short.) - D-8 (Low, correctness): the three whitespace functions are not order-commutative; presets always call them in the safe order (collapse last), but the public
Pipelinebuilder lets a caller sequenceCOLLAPSE_WSbefore a strip and get untrimmed output. Document the "collapse is the terminal whitespace step" contract. - Prior polish findings still open (unchanged in delta): H-D1 (
api::slugifydoc claims Python validateslang—api/text.rs), H-D2 (PythonslugifyRaises:lists unraised unknown-lang—_api.py:576), H-D3 (Nodedocs/node/api.mdstill saysgetPipeline"not yet surfaced" though exported — now asymmetric since Ruby did add itsget_pipelinesection), M-D3 (output-encoder/strip_log_injectionfamily Python-only, undocumented-as-absent in Ruby/Node). M-D1/M-D2 (error.rsstale#[allow(dead_code)]/ missing#[must_use]) are out of delta scope —error.rsunchanged.
Resolved / improved since the last review¶
- Path-separator neutralization removed (#431) — correct and honestly documented: THREAT_MODEL.md states disarm is an input-normalizer and path-traversal defence belongs at the sink; no preset name/docstring still implies path sanitization; a regression test (
test_presets_do_not_mangle_path_separators) pins it. Resolves the prior concern about presets implying a guarantee they didn't deliver. - New
invisibles.rsis clean — every range predicate (is_tag,is_variation_selector,is_noncharacterincl. the(cp & 0xFFFF) >= 0xFFFEplane math,is_pua,is_tag_letter) is exactly correct (exhaustively checked); ASCII fast path +with_capacitypresent;#[must_use]on the Layer-2 surface; new ops surfaced and tested across all four languages. - Greek-iota remap (#438) introduces no fold cycle (verified: Latin
03B9→iterminal; Cyrillic03B9→0456non-key). - Whitespace line-control fold (#433) closes an invisible-join vector (
a\rb→a b); fold and zero-width sets are disjoint. security_cleanidempotency (#416) — partially resolved: the NFC-sandwich closes the confusables-emits-decomposed-skeleton case, but D-1 remains.
Recommended priority order¶
- D-1 — the headline. Fix with NFD-fold or iterate-to-convergence, and extend the proptest generator to the cedilla/trigger-mark class so the property stops passing vacuously. Until then, the idempotency guarantee should not be advertised as complete.
- D-2 — reorder the presentation-VS decision after the strips; add a
display_cleanidempotency proptest. - D-5 — land the
sort_keyfold-before-transliterate fix (the agreed Option-1 follow-up) and re-enable the omitted proptest. - D-6 — validate the emoji-flag tail against the real subdivision allowlist (or document the carve-out).
- D-3 / D-7 — route presets through the
*_intoping-pong; addreserve/ASCII fast paths tostrip_control_chars/strip_bidi. - D-4 and the carried-over H-D1/H-D2/H-D3/M-D3 docs items — cheap, user-trust-facing.
Test-suite gap (cross-cutting)¶
The three idempotency defects (D-1, D-2, D-5) are all in the property the delta is about, yet: security_clean_idempotent passes vacuously (generator can't reach the trigger marks), display_clean has no idempotency proptest, and sort_key_idempotent is deliberately omitted. The proptests currently certify a guarantee that three short strings break ("\u{0441}\u{0327}", "\u{2800}\u{FE0F}x", "\u{1CB1}"). Strengthening the generators is as important as the fixes.
Generated by the HAI-SDLC hardening sequence — delta re-run, analysis only. No source files were modified. Findings cite path:line / table entries at f24b8e3. Every High/Critical was independently verified against source (confusables TSV entries, Unicode normalization behavior, and pipeline step order) during this run.