Repository navigation
Reject spoofed audio markers and reserved tokens in transcripts - #116
Merged
Merged
Conversation
Addresses review findings #3 (P0, marker spoofable from user text) and #16 (P2, transcript tokenized with special tokens parsed) — the same defect in opposite directions. <|audio|> is a registered special token, so text a caller types is tokenized into the *real* marker. vLLM pairs markers with audio items positionally and stops once every item is matched, appending the rest of the prompt verbatim, so an extra marker is not an error: the leftmost one wins the transcript and vLLM's own _validate_mm_placeholders still sees matching counts and passes. A caller could therefore move the transcript to a position of their choosing, and in a multi-clip request shift every transcript onto the wrong clip, with no error raised. The reverse case — markers with no audio payload — is the vector behind vLLM's own CVE-2026-44222 (GHSA-hpv8-x276-m59f), where models indexing a grid from a spoofed placeholder hit an unhandled IndexError. Inbound, encode(..., add_special_tokens=False) only suppresses *added* BOS and EOS; special-token strings already present in the text are still parsed into genuine ids. Since the switch sets requires_raw_input_tokens and detects adapters on raw input_ids, a control token arriving via the transcript would steer adapter selection from audio content. Two guards: - apply() requires the marker count to equal the audio item count, for both str and token-id prompts. Enforced there rather than in _call_hf_processor because that runs with only the cache-*missing* items, so on a processor cache hit its item count is smaller than the request's and the comparison would be wrong. apply() is the only entry point that always sees the whole request. Its parameters differ across the vLLM versions this package supports, so both shapes are read defensively; an unrecognised signature raises rather than skipping the check, since a security control that silently no-ops is worse than none. - _transcribe refuses a transcript that tokenized into the audio marker or any adapter control token. Rejected rather than neutralized with split_special_tokens=True so the condition is visible instead of silently rewriting model output: such a transcript means either an attack or a badly misbehaving ASR backend, and both are worth surfacing. Consequence worth knowing: a text-only prompt containing <|audio|> is now refused. That is the CVE vector, but it also rejects legitimate prose that mentions the marker — the accepted cost of count-based rather than provenance-based validation. Stripping the marker from user content at chat template render time is the only fix for that, and it waits on finding #15 making the template injection robust and tested. Tests: 13 new cases covering injection from text, from token ids, with no audio payload at all, and from the transcript (both the marker and an adapter control token), plus negative controls so that rejecting everything would not pass. Verified as genuine guards by reverting each fix in turn: 3 fail without the apply() check, 2 without the transcript guard. The tokenizer fakes in the test module gained convert_tokens_to_ids, which every real tokenizer has and the new guard needs. The apply() signature handling was verified against an installed vLLM 0.21 only; 0.19/0.20 behaviour needs the GPU run to confirm. Signed-off-by: aviv ron <rona@il.ibm.com>
GitHub did not schedule a workflow run for the pull_request opened event on 3fcbecf, so no CI or DCO check suite was created. This empty commit fires a synchronize event to schedule them. Signed-off-by: aviv ron <rona@il.ibm.com>
Collaborator
Author
|
/gpu-test |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
❌ GPU tests failed —
|
❌ GPU tests failed —
|
aviv1ron1
marked this pull request as ready for review
August 9, 2026 18:01
aviv1ron1
requested review from
antonpibm,
freunda and
yairallouche
as code owners
August 9, 2026 18:01
yairallouche
pushed a commit
that referenced
this pull request
Sep 8, 2026
aviv1ron1
added a commit
that referenced
this pull request
Sep 9, 2026
Absorbs feature/moe-audio-support so it does not need a PR of its own: that
branch carried no functional code, only documentation plus the tests that back
it. Taken from staging/feature/moe-audio-support @ 8bf0bb3.
What came across:
* docs/AUDIO.md -- the audio cascade over a pure sparse MoE base. Hand-merged,
not overwritten: this file had already diverged here (63+/25- from the
TurboCTC edits, which touch the same sections). Verified afterwards that both
sides survive -- the TurboCTC default, the transformers>=5.16 requirement and
the 120s chunker window on one side, the granitemoe material on the other.
* tests/composer/test_audio_marker_output_row.py -- the marker/reserved-row copy
is now parametrized over MLP topology as well as embedding tying, taking it
from 2 cases to 4 (untied/tied x dense/sparse-MoE). The fixup only ever
touches embedding rows, so both topologies must behave identically; the sparse
arm is what would notice if a shared-MLP-shaped assumption crept into the
model construction it runs against.
* tests/composer/test_granitemoe_audio_compose.py -- new, 9 tests over 4
classes: audio does not resurrect the shared MLP, does not widen the adapter
surface and leaves no zero-width parameter; the control LUT goes stale on the
marker and refresh fixes it idempotently and passes the validator; the marker
output row; and survival across save/load.
Deliberately NOT taken: a 5-line comment block in
src/granite_switch/composer/tokenizer_setup.py noting that the <|unused_N|>
convention also holds on granitemoe bases. True, but prose, and keeping src/
out of this commit makes the "no functional change" claim checkable rather than
asserted -- `git show --stat` shows no src/ path at all.
Verified: the two test files give 32 passed here; ruff check and format clean
over 211 files.
One thing a reviewer should not misread. The composer tier on this base reports
pre-existing failures that predate this commit and are unrelated to it:
TestGraniteMoeSR fails 6 of 8 even in isolation, in 0.37s, with
"ValueError: not enough values to unpack (expected 5, got 3)". Public main is 15
commits behind staging and is missing #119, which changed composer return
signatures and updated test_granitemoe_compose_e2e.py to match; the two are out
of step here. It has gone unnoticed because the public repo's automatic CI runs
only tests/unit/, so tests/composer/ is unchecked on every PR. #119 is expected
to reach public main shortly, which resolves it. The test file imported above is
based on #116, before that refactor, so it matches the signatures this base has.
Signed-off-by: aviv ron <rona@il.ibm.com>
yairallouche
pushed a commit
that referenced
this pull request
Sep 30, 2026
#130) * Default ASR to Granite Speech 5.0 TurboCTC, and raise transformers to 5.16 Re-applies the TurboCTC work onto public main by hand, rather than by cherry-picking the six commits it was developed as (09c52c2..9f1e5e8 on the staging branch). Public main has since absorbed #95, #104, #116 and #121, so several of the files had moved underneath the original patches. What changes, in five parts: * DEFAULT_ASR_MODEL_ID becomes ibm-granite/granite-speech-5.0-470m-turboctc, a 470M English conformer CTC encoder, replacing distil-whisper/distil-small.en. Being CTC it has no generate(), so decode kwargs (language/task) are dropped rather than forwarded, and transcripts are lowercase and unpunctuated. * ASR defaults retuned for a CTC backend: asr_self_chunks False and asr_chunk_length_s 120.0, since the encoder has no internal chunking and the HF pipeline's own CTC chunking mis-trims every seam (it rescales stride by inputs_to_logits_ratio, which this checkpoint does not publish). asr_device defaults to cuda and asr_dtype resolves to bfloat16 there. * transformers pinned >=5.5.1,<5.17.0 in the core package and >=5.16.0 on the audio extra, which is where granite_speech5_ctc landed. Only the audio path needs 5.16, so the requirement sits on the extra. * ATTENTION_LAYER_TYPES accepts both "attention" and "full_attention". transformers 5.16 renamed the value and rewrites it inside PreTrainedConfig.__init__, so comparing against the bare string silently dropped the attention LoRA target groups and left adapters with MLP targets only. * IsHybrid is no longer declared. It requires mamba-state hooks this model neither implements nor needs -- the composer normalizes every layer to attention, so a composed checkpoint has zero mamba layers. vLLM's escape hatch for exactly this shape compares the literal "attention", so the 5.16 rename flipped is_hybrid true and engine init began failing for the mamba dtype hook. Two places where main had moved and the original patch could not be taken as-is. Both are in compose_granite_switch.py's argparse help, and both would have been regressions if cherry-picked: * main established that --enable-audio is the only flag that switches audio on ("Requires --enable-audio; ignored without it"), replacing the older "Implies --enable-audio". The re-applied text keeps main's rule and carries only the TurboCTC facts across. * docs/AUDIO.md was hand-merged rather than overwritten, so main's confirmation from the Granite authors that <|unused_N|> ids are reserved survives alongside the new TurboCTC sections. uv.lock regenerated rather than patched: transformers 5.8.1 -> 5.16.1. Known limitation, carried over and unresolved: vLLM <=0.25 keys a layer-type table on the old "attention" spelling in granitemoehybrid.py, so in a venv built from this branch, serving a *stock* Granite 4.0 hybrid checkpoint raises KeyError: 'full_attention'. Composed Granite Switch checkpoints are unaffected -- they declare GraniteSwitchForCausalLM, which builds its own layers and never consults that table. vLLM fixed it in 0.26.0. Verified: ruff check and format clean; tests/unit/test_asr.py, test_config.py and test_config_edge_cases.py pass 90/90 (2 skipped for lack of a local vLLM). Signed-off-by: aviv ron <rona@il.ibm.com> * fix(tests): stop _patched_pipeline leaking its mock onto transformers.pipeline Closes #121. _patched_pipeline patched transformers.pipelines.pipeline before transformers.pipeline. mock.patch.__enter__ records the current value so it can restore it, and transformers is a lazy module: resolving transformers.pipeline when it is not yet cached on the top-level module goes through transformers.pipelines. With the submodule patched first, the second patch read back the mock, recorded it as "the original", and faithfully restored it on exit. The mock then stayed installed for the rest of the process, and load() resolves `from transformers import pipeline` at call time, so every later real transcription picked it up. One of the mocks in this file raises the "does not recognize this architecture" ValueError, which _unsupported_architecture_error converts into ImportError: transformers 5.16.0 cannot load the ASR model 'ibm-granite/granite-speech-5.0-470m-turboctc': its architecture requires transformers>=5.16 -- naming the installed version as too old for itself, from a GPU test that had nothing to do with the unit test that leaked. That false trail is the reason this is worth more than a one-line diff of explanation. Swapping the two patches is the whole fix: transformers.pipeline is now read while transformers.pipelines is still real, so both record and restore the real function. Why it went unnoticed: the leak only occurs when transformers.pipeline is not already cached on the top-level module, which depends on what else ran first. tests/unit/test_asr.py alone restores correctly; the full tests/unit/ directory leaks. CI always runs the full suite, so CI always leaked. TestPatchedPipelineRestores guards both attributes, and forces the lazy-resolve precondition with transformers.__dict__.pop("pipeline", None) rather than relying on collection order. Verified to fail on the old ordering (assert <Mock> is not <Mock>) and pass on the new one, so it is a real guard rather than a tautology. Verified on transformers 5.16.1 and 5.8.1: full tests/unit/ is 287 passed / 1448 skipped on both, and a probe asserting transformers.pipeline is the real function after the session passes on both. ruff check and format clean. Note this fixes the misleading diagnosis, not the GPU-tier failures it was masking: the leaked vLLM engine that starves later GPU tests is a separate teardown defect (#123) and is deliberately left for its own PR. Signed-off-by: aviv ron <rona@il.ibm.com> * fix(vllm): alias full_attention so vLLM <=0.25 can load a 5.16-written config Closes #122. transformers 5.16 renamed the layer type "attention" to "full_attention" and rewrites the value inside PreTrainedConfig.__init__, so it is now the only spelling a written config can carry -- writing the old one back does not help. vLLM <=0.25 keys its Granite-hybrid layer table on the old name: # vllm/model_executor/models/granitemoehybrid.py:319 ALL_DECODER_LAYER_TYPES = {"attention": ..., "mamba": ...} layer_class = ALL_DECODER_LAYER_TYPES[config.layer_types[layer_idx]] KeyError: 'full_attention' register() now aliases the new name onto the same layer class. Reproduced on both 0.19.1 and 0.20.2; upstream fixed it in 0.26.0 by adding the key themselves, and setdefault makes this a no-op there, so the block can be deleted on a version bump without touching anything else. Two things this repairs, which is why it is not only a test fix: * The vLLM equivalence tier -- 14 failures plus 19 nested. Those tests build their upstream reference with transformers, save_pretrained it, and hand the directory to vllm.LLM; the saved config now says full_attention and its architectures field routes to vLLM's stale class, so the *control group* never starts. GraniteSwitchForCausalLM was never affected: it builds layers through a closure and does not consult that table. * Serving a stock Granite 4.0 hybrid checkpoint. In a venv built from this branch -- transformers>=5.16 comes from our own audio extra, vLLM is pinned <=0.25 -- `vllm serve ibm-granite/granite-4.0-micro` crashed. Composed Granite Switch checkpoints did not; the published 4.2-30b carries layer_types: ["full_attention", ...] and serves correctly. Why it lives in register() and not a test fixture: the lookup runs inside vLLM's *spawned* engine-core process, so nothing patched in the parent survives. vLLM loads its plugins in that process during init (v1/engine/core.py calls load_general_plugins()), reading the vllm.general_plugins entry point we already declare. It is the only hook we own that executes there. Aliasing adds a name, not behaviour: both keys resolve to the identical class object, so the decoder layer built is the one that was built before the rename. The broad except is deliberate -- if a future vLLM moves the module or the dict, skipping silently costs us this KeyError again, whereas raising would take down every engine start. A larger alternative was considered and rejected as too big for this change: retargeting the whole equivalence tier off granitemoehybrid onto the families we actually ship. That gap is real -- granite (4.1/4.2/30b) has no equivalence coverage in any tier and granitemoe has HF-only -- but it is separate work. tests/vllm/test_plugin_registration.py asserts both keys are present and resolve to the same object, and that register() stays re-entrant as its docstring promises. It runs in-process rather than through the usual subprocess wrapper because register() creates no engine and so opens no CUDA context; it skips cleanly where vLLM is absent. The assertions hold on 0.26+ too, so the test does not need editing when the alias becomes redundant. Verified: ruff check and format clean over 210 files; tests/unit/ is 286 passed / 1448 skipped on transformers 5.16.1, unchanged. The alias itself only demonstrates on GPU -- the proof is the 14 equivalence failures going green. Not addressed here: #123, the leaked vLLM engine that starves later GPU tests (8 failures + 8 errors), which is its own PR; and #127's tokenizer-fetch flake. Also untouched is the second stale comparison, is_hybrid in vllm/config/model.py, still broken in 0.28.0 -- we sidestep it by not declaring IsHybrid. Signed-off-by: aviv ron <rona@il.ibm.com> * docs+tests(audio): fold in the MoE-audio documentation and its coverage Absorbs feature/moe-audio-support so it does not need a PR of its own: that branch carried no functional code, only documentation plus the tests that back it. Taken from staging/feature/moe-audio-support @ 8bf0bb3. What came across: * docs/AUDIO.md -- the audio cascade over a pure sparse MoE base. Hand-merged, not overwritten: this file had already diverged here (63+/25- from the TurboCTC edits, which touch the same sections). Verified afterwards that both sides survive -- the TurboCTC default, the transformers>=5.16 requirement and the 120s chunker window on one side, the granitemoe material on the other. * tests/composer/test_audio_marker_output_row.py -- the marker/reserved-row copy is now parametrized over MLP topology as well as embedding tying, taking it from 2 cases to 4 (untied/tied x dense/sparse-MoE). The fixup only ever touches embedding rows, so both topologies must behave identically; the sparse arm is what would notice if a shared-MLP-shaped assumption crept into the model construction it runs against. * tests/composer/test_granitemoe_audio_compose.py -- new, 9 tests over 4 classes: audio does not resurrect the shared MLP, does not widen the adapter surface and leaves no zero-width parameter; the control LUT goes stale on the marker and refresh fixes it idempotently and passes the validator; the marker output row; and survival across save/load. Deliberately NOT taken: a 5-line comment block in src/granite_switch/composer/tokenizer_setup.py noting that the <|unused_N|> convention also holds on granitemoe bases. True, but prose, and keeping src/ out of this commit makes the "no functional change" claim checkable rather than asserted -- `git show --stat` shows no src/ path at all. Verified: the two test files give 32 passed here; ruff check and format clean over 211 files. One thing a reviewer should not misread. The composer tier on this base reports pre-existing failures that predate this commit and are unrelated to it: TestGraniteMoeSR fails 6 of 8 even in isolation, in 0.37s, with "ValueError: not enough values to unpack (expected 5, got 3)". Public main is 15 commits behind staging and is missing #119, which changed composer return signatures and updated test_granitemoe_compose_e2e.py to match; the two are out of step here. It has gone unnoticed because the public repo's automatic CI runs only tests/unit/, so tests/composer/ is unchecked on every PR. #119 is expected to reach public main shortly, which resolves it. The test file imported above is based on #116, before that refactor, so it matches the signatures this base has. Signed-off-by: aviv ron <rona@il.ibm.com> * test(audio): drop the SingleSwitch arm from the MoE-audio compose fixture #131 removed SingleSwitch. test_granitemoe_audio_compose.py arrived from feature/moe-audio-support, which branched before that, so it still parametrized its fixture over ["single", "multi"] and asserted the switch class was SingleSwitch on the single arm -- 9 errors after the merge, every one a [single] case, while all 23 [multi] cases passed. Now SWITCH_TYPES = ["multi"] and the assertion is unconditional, which is what every other switch-parametrized file already does post-#131: test_control_lut_refresh.py:34, tests/hf/test_multi_switch.py:49, tests/vllm/test_multi_switch.py:52, tests/shared/gap_equivalence.py:15. So this converges on the established shape rather than inventing one. Nothing about the audio path changed -- the coverage that was single/multi parametrized is engine-independent (the marker's embedding and output rows, the control-LUT refresh, save/load survival), so dropping the removed engine loses no assertion. 23 passed. The wider audio tier is 128 passed / 53 skipped with one error that is this machine having no GPU ("Torch not compiled with CUDA enabled"), not a merge regression. Signed-off-by: aviv ron <rona@il.ibm.com> * Revert the transformers 5.16 adaptation; leave it to the version-bump PR This branch should carry only the net TurboCTC change. The 5.16 work done here belongs with the version bump instead (issue #84, origin/issue-84-support-0.28), so that branch can be merged in cleanly rather than conflicting with a parallel half-done version of the same adaptation. Reverted to main exactly: pyproject.toml both transformers pins (core ceiling raise, and audio extra >=5.16) uv.lock back to main's resolution vllm/__init__.py the full_attention alias vllm/granite_switch_model.py IsHybrid declared again tests/unit/test_config_edge_cases.py literal layer_types assertions tests/shared/granite4_equivalence.py layer_types back to "attention" tests/vllm/test_plugin_registration.py deleted (it only tested the alias) Two files mixed both concerns and were edited rather than reverted: * config.py loses ATTENTION_LAYER_TYPES and goes back to comparing the bare string. Its TurboCTC content (the asr_* defaults and docstrings) stays. * docs/AUDIO.md claimed the audio extra *requires* transformers>=5.16, which is no longer true. Reworded rather than deleted, because the requirement is real even while unpinned: the default model needs >=5.16, that is not pinned yet, #84 tracks it, and until then the cascade runs only with an --asr-model the installed transformers supports. Kept deliberately: * The _unsupported_architecture_error guard in asr.py. It is TurboCTC's own error handling, not part of the bump, and it is what makes this branch coherent unpinned -- an install below 5.16 gets an actionable ImportError naming the fix instead of transformers' generic "unrecognized architecture". * The _patched_pipeline mock-leak fix. Pre-existing bug on main, but it is what stops the new TurboCTC GPU tests reporting a false "transformers 5.16 too old" error, which would make the next GPU run unreadable. * The MoE-audio documentation and tests. No 5.16 dependency; they pass on these pins. Done as a forward revert rather than a history rewrite: no force-push, PR #130 keeps its review history, and `git diff main..HEAD` is what determines the merge result either way. Two things the version-bump PR still needs, which this revert removes from here and which its current state does not cover: * ATTENTION_LAYER_TYPES accepting both spellings. Required on any transformers >=5.16 regardless of vLLM version -- comparing against the bare string silently drops the attention LoRA target groups, leaving adapters with MLP targets only. * Dropping IsHybrid. vLLM's is_hybrid escape hatch still compares the literal "attention" in 0.28.0, so moving to 0.28 does not fix it; a composed checkpoint still fails engine init asking for a mamba dtype hook. Only the full_attention alias is genuinely obsolete under 0.28 -- upstream added that key in 0.26.0 -- so that one is gone for good. Note the bump PR currently pins transformers <5.16, which still excludes the version the default ASR model needs. As it stands, merging this branch after it would leave TurboCTC unable to load. Verified: ruff check and format clean over 200 files; tests/unit/ is 235 passed / 1448 skipped on the restored pins. Signed-off-by: aviv ron <rona@il.ibm.com> --------- Signed-off-by: aviv ron <rona@il.ibm.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses review findings #3 (P0, marker spoofable from user text) and #16 (P2, transcript tokenized with special tokens parsed) — the same defect in opposite directions.
<|audio|> is a registered special token, so text a caller types is tokenized into the real marker. vLLM pairs markers with audio items positionally and stops once every item is matched, appending the rest of the prompt verbatim, so an extra marker is not an error: the leftmost one wins the transcript and vLLM's own _validate_mm_placeholders still sees matching counts and passes. A caller could therefore move the transcript to a position of their choosing, and in a multi-clip request shift every transcript onto the wrong clip, with no error raised. The reverse case — markers with no audio payload — is the vector behind vLLM's own CVE-2026-44222 (GHSA-hpv8-x276-m59f), where models indexing a grid from a spoofed placeholder hit an unhandled IndexError.
Inbound, encode(..., add_special_tokens=False) only suppresses added BOS and EOS; special-token strings already present in the text are still parsed into genuine ids. Since the switch sets requires_raw_input_tokens and detects adapters on raw input_ids, a control token arriving via the transcript would steer adapter selection from audio content.
Two guards:
apply() requires the marker count to equal the audio item count, for both str and token-id prompts. Enforced there rather than in _call_hf_processor because that runs with only the cache-missing items, so on a processor cache hit its item count is smaller than the request's and the comparison would be wrong. apply() is the only entry point that always sees the whole request. Its parameters differ across the vLLM versions this package supports, so both shapes are read defensively; an unrecognised signature raises rather than skipping the check, since a security control that silently no-ops is worse than none.
_transcribe refuses a transcript that tokenized into the audio marker or any adapter control token. Rejected rather than neutralized with split_special_tokens=True so the condition is visible instead of silently rewriting model output: such a transcript means either an attack or a badly misbehaving ASR backend, and both are worth surfacing.
Consequence worth knowing: a text-only prompt containing <|audio|> is now refused. That is the CVE vector, but it also rejects legitimate prose that mentions the marker — the accepted cost of count-based rather than provenance-based validation. Stripping the marker from user content at chat template render time is the only fix for that, and it waits on finding #15 making the template injection robust and tested.
Tests: 13 new cases covering injection from text, from token ids, with no audio payload at all, and from the transcript (both the marker and an adapter control token), plus negative controls so that rejecting everything would not pass. Verified as genuine guards by reverting each fix in turn: 3 fail without the apply() check, 2 without the transcript guard. The tokenizer fakes in the test module gained convert_tokens_to_ids, which every real tokenizer has and the new guard needs.
The apply() signature handling was verified against an installed vLLM 0.21 only; 0.19/0.20 behaviour needs the GPU run to confirm.