Skip to content

perf(classify): skip NFKC + zero-width fold on pure-ASCII commands (~1.8x)#71

Open
askalf wants to merge 1 commit into
masterfrom
perf/classify-ascii-fastpath
Open

perf(classify): skip NFKC + zero-width fold on pure-ASCII commands (~1.8x)#71
askalf wants to merge 1 commit into
masterfrom
perf/classify-ascii-fastpath

Conversation

@askalf

@askalf askalf commented Jul 16, 2026

Copy link
Copy Markdown
Owner

What

classify() folds fullwidth/homoglyph forms (NFKC) and strips zero-width / bidi formatting characters before matching, so an attacker can't disguise or split a keyword past the ruleset. That fold ran on every call:

  • cmd.normalize('NFKC') (up to 16KB)
  • a fresh 17-element Set allocated inline
  • Array.from(cmd, ...).join('') — a full code-point rebuild of the command

All three only ever change non-ASCII input: NFKC is the identity on ASCII, and every stripped character is >= U+00AD. This PR hoists the constants to module scope and guards the whole fold behind a cheap NON_ASCII_RE.test(cmd). Pure-ASCII commands — essentially all real traffic — skip it entirely; non-ASCII input is folded exactly as before.

Why it's safe

The result is byte-identical to always running the fold — the guard only elides work that is provably a no-op on ASCII.

  • 203/203 tests pass (200 existing + 3 new).
  • Detection corpus unchanged — same recall/precision, same documented misses.
  • New test/homoglyph-normalize.test.mjs adds the first unit coverage for the fold: fullwidth RM, ZWSP / soft-hyphen / ZWNJ / RLO keyword splits, and ASCII substring-safety (storm must not trip the rm rule). Evasion chars are built from numeric code points so the test file stays pure-ASCII on disk.

Perf

Same-process A/B (baseline = current master), realistic ASCII command mix, best-of-3 min:

classify()
baseline ~15.7 us/call
this PR ~8.7 us/call
~1.8x faster

classify() dominates decide(), which runs on every hook/daemon tool call, so this roughly halves the firewall's per-call CPU for normal commands. No new dependencies; still zero-runtime-dep.

classify() ran cmd.normalize('NFKC'), allocated a fresh Set, and rebuilt the
whole command via Array.from(...).join('') on every call - but all three only
affect non-ASCII input (NFKC is identity on ASCII; every stripped formatting
char is >= U+00AD). Gate the fold behind a cheap non-ASCII test and hoist the
constants to module scope. Pure-ASCII commands (the common case) now skip it
entirely; non-ASCII input is folded exactly as before, so verdicts are
byte-identical.

~1.8x faster classify() on a realistic ASCII command mix (~15.7us -> ~8.7us per
call, same-process A/B). classify() dominates decide(), which runs on every
hook/daemon tool call, so this roughly halves per-call CPU for normal commands.

Adds the first unit coverage for the homoglyph/zero-width fold (fullwidth RM,
ZWSP/soft-hyphen/ZWNJ/RLO keyword splits, ASCII substring-safety) - previously
only the red-team bench exercised it. Detection corpus unchanged (same recall
and precision); 203/203 tests pass.
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