[Feature] Move response jailbreak detection into a response-stage signal - #3335
Conversation
✅ Deploy Preview for vllm-semantic-router ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
the inital idea for #3272 is that i think the design of halu and response jailbreak is not aligned with the signal decision architecture. We should move them from plugin to signal, same as other signals which can be composed as input of projection or decisions. Just make sure we are aligning with the target |
|
@Xunzhuo Agreed, and this PR is not the move yet — execution still sits in the plugins. It only separates a response that was cleared from one that was never scanned, which both detectors currently report as the same thing. Once they are signals, unavailable should land on One question before I cut the move. Happy to fold this into that PR instead. |
|
i dont have clear design but have some ideas, for the response direction signal, like when we detected a response jailbreak or haluciation sigal, we can have some decisions like:
and we can combine the request direction signal with response direction signal, this signal will only be activated at the response stage since we have not response signal when it is at request stage |
|
with the response direction signals working with request direction signals, we can express stronger routing recipes. like we can also detect the system stats as reponse direction signals as well as the structure validations for model structure output etc |
|
Thank you, that makes sense. I'll rework this PR in that direction. |
385b160 to
e6f306f
Compare
e6f306f to
9b99819
Compare
31e7d36 to
e954579
Compare
e954579 to
61b00a0
Compare
|
@Xunzhuo Pushed. Detection is out of the plugin — the declared rules drive it, so the observation exists whether or not I didn't wire a decision to resolve from it. Checking the issue again, the split it asks for is the plugin consuming evidence, so that felt like it belongs with the enforcement work — happy to add it now if you'd rather. halu follows, and the block / replace / forward outcomes come with moving enforcement onto the decision :) |
Xunzhuo
left a comment
There was a problem hiding this comment.
Thanks for pushing the stage split. I found three blockers on this head:
- response-stage decisions are skipped during request evaluation, but never evaluated after the response signal is published; the plugin still acts only on the already selected request decision, so request+response composition cannot drive a response action;
- the new signal is absent from the CLI schema, Dashboard signal/condition types, and DSL compiler/decompiler, so supported config surfaces cannot preserve it;
- this behavior-visible config/routing change has no buffered/streaming E2E coverage.
Please complete the response-stage decision seam and the required cross-surface/E2E contract before this lands.
…bservation Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com>
…er Replay store graph dashboard/backend replaces the router module in, and since vllm-project#3445 on main `go mod tidy` there wants the checksums of the modules the Router Replay store pulls in (lib/pq, the Milvus and Qdrant clients, cockroachdb/errors and their transitive go.mod files). The dashboard job is skipped on main pushes, so it surfaced on this PR's quality and dashboard gates instead. Generated with `go mod tidy`; the diff is exactly the one the gate printed. Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com>
…mers
NeedsJailbreakMappingForRouting and the classifier.jailbreak runtime task
keyed only on a decision rule naming a jailbreak signal. A response-direction
rule is consumed by the selected decision's response_jailbreak plugin and is
never named by a decision rule, so once the E2E profile stopped composing one
into a decision nothing loaded the label mapping: the http_classify
prompt_guard refused to build ("label mapping is required for http_classify")
and the router failed at startup. The plugin-only compatibility path had the
same gap on main, where IsJailbreakEnabled stayed false without the mapping
and the plugin silently never ran.
UsesJailbreakClassifierInReachableRouting counts all three consumers over the
request-reachable profiles (a decision reading a request-direction rule, a
declared response-direction rule, an enabled response_jailbreak plugin), and
the mapping loader, the model download gate and the runtime task use it.
Tests: TestNeedsJailbreakMappingForResponseStageConsumers (flat config,
including the disabled-plugin and prompt_guard-off negatives),
TestNeedsJailbreakMappingForResponseStageConsumersFollowsRecipeReachability
(unreachable recipes do not load it; scoped configs agree) and
TestInitializeRuntimeInitializesJailbreakClassifierForResponseStageConsumers
(the initializer runs once for either consumer, the request-stage ones stay
skipped). All three fail on the previous gating.
Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com>
Xunzhuo
left a comment
There was a problem hiding this comment.
Two response-safety blockers remain on this head:
scanJailbreakChunksdrops a chunk error once any other chunk succeeds, and the response path then publishesresolved=true. A clean result can therefore bypasson_error: blockeven though part of the response was never inspected. Please preserve positive matches, but return an unresolved/error outcome when any chunk needed for a clean verdict fails, with a partial-failure regression.- Stage validation only walks direct decision leaves. A projection can still consume a response-direction jailbreak rule, and request-time evaluation turns its absent result into the configured miss value before a response exists. Please reject response-stage inputs anywhere in the decision dependency graph and cover the projection-indirection case.
…bservation Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com>
CheckForJailbreakRiskWithThreshold and CheckForJailbreakWithThreshold dropped a chunk's failure as soon as any other chunk was scored, so a response whose failed chunk was never inspected came back clean, the response path published the observation as resolved, and the plugin let it through even under prompt_guard.on_error: block. A match in a scored chunk still counts, as the request path keeps a match past an unresolved chunk; without one, a partial scan now returns an error, which the response path publishes as unresolved so the on_error policy applies. The security classification API and the plugin-owned detection path share the helpers and change the same way. Tests: TestCheckForJailbreakErrorsWhenACleanVerdictNeedsAFailedChunk (risk and argmax wrappers) and TestResponseJailbreakPartialScanFailureIsNotClean, whose stub fails every attempt for the chunk carrying a marker (the connector retries a 5xx) and scores the rest: clean chunks leave the rule unresolved and on_error: block returns 403, while a match in a scored chunk is still a detection. Both fail on the previous wrappers. Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com>
|
@Xunzhuo Both Fixed :) a partly scanned response is unresolved (match kept, on_error: block fails closed) and a response-direction rule behind a projection is rejected at load, each with a regression. The Memory Integration lane failed before the tests started, on an HF 429 while downloading the embedding model; could you re-run it? |
validateDecisionsReadRequestStageSignals walked only the decision's own rule leaves. A projection score could still take a response-direction jailbreak rule as an input, and at request time, before the model has answered, the absent result became the input's configured miss value, so the decision was shaped by a signal that could not exist yet. decisionReadsResponseSignal now follows a projection leaf through its mapping to the score and its inputs, recursing through projection inputs (a confidence-valued one through the output it names), and the error names the projection output the rule was read through. Tests: TestValidateRejectsProjectionReadingResponseDirectionRule covers a direct score input, a score of scores and a confidence-valued output, and accepts the same graph over a request-direction rule; it fails on the leaf-only walk. Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com>
4a98bb7 to
09b5f62
Compare
Xunzhuo
left a comment
There was a problem hiding this comment.
Thanks—the partial-scan path now remains unresolved unless a scored chunk genuinely matches, and the projection walk covers direct and nested score/output references, so both blockers are resolved. The exact head only needs the Memory Integration lane rerun after its external Hugging Face 429 before approval.
Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com>
Xunzhuo
left a comment
There was a problem hiding this comment.
Thanks for the update. One fail-closed gap remains. Response evaluation scans once at the lowest rule threshold; if a lower-threshold rule matches, CheckForJailbreakRiskWithThreshold suppresses a partial-scan error, then every rule is published as resolved. With thresholds 0.4 and 0.9, a 0.5-scored chunk plus a failed chunk incorrectly marks the 0.9 rule clean. Please preserve partial-scan failure per rule and add the multi-threshold regression.
The response stage scanned the answer once at the lowest declared threshold, and a match at that threshold dropped the chunk failure. With rules at 0.4 and 0.9, a 0.5 score next to a failed chunk marked the 0.9 rule clean. The scan now reports the score and the chunk failure apart from each other, and each rule resolves that one scan on its own line. A rule the score matches stays a match, and a rule the score does not reach is unresolved while a chunk is missing. TestResponseJailbreakPartialScanIsResolvedPerRuleThreshold covers the two rule case and fails on the previous path. Related vllm-project#3272 Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com>
…bservation Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com>
|
Fixed :) |
Xunzhuo
left a comment
There was a problem hiding this comment.
lets add halu into the signal layer
|
Okay, proceeding in #3531! |
…nal (vllm-project#3335) * [Feature] Move response jailbreak detection into a response-stage signal Signal types now declare the stage they can be observed at. Every existing type stays request-stage, so "type:name" and the {type, name} condition shape are unchanged, and system stats or structured-output validation would be response-stage types under the same rule. response_jailbreak is the first of them. Rules are declared under routing.signals.response_jailbreak and evaluated from those rules before any plugin runs, so the observation exists whether or not an enforcement plugin is enabled. It is published to SignalConfidences and SignalErrors where every other signal publishes, with detected, not-detected and unavailable all distinguishable. The plugin no longer classifies when rules are declared; it consumes the observation and applies its action, with its own threshold reported as ignored rather than silently dropped. A configuration that has not declared rules keeps the old path unchanged. A decision reading a response-stage signal is not evaluated while the request is still being routed, since that signal does not exist yet. This is kept separate from on_unknown, which means "evaluated and could not resolve"; treating a stage that has not happened as unknown would let on_unknown: match route a request on a detector that never ran. Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com> * [Feature] Put the response stage on the jailbreak rule and evaluate response-stage decisions Review follow-up for the response-stage signal split. The stage is now a field on the existing jailbreak signal: a rule with direction: response scores the model's output, the default request scores the prompt. There is no new signal type, so the jailbreak:<name> key, the {type, name} condition, Router Replay and the matched-signal headers carry the response observation unchanged, and the CLI schema, Dashboard and DSL compiler/decompiler preserve it as one more rule field. A response-direction rule rejects method: contrastive, the pattern lists and include_history. Response-stage decisions are evaluated once the response exists. The request-stage evaluation still skips them; after the response-direction rules are scored, the decisions that read them are resolved from the request-stage matches plus the response observation, beside the request-time selection rather than replacing it. The response_jailbreak plugin enforces on whichever decision matched, so a request signal composed with the response observation drives the response action. The decision engine is untouched: the stage filter lives in the classifier's decision evaluation, which is the only place the engine is built. Tests: the seam is pinned at the filter level with the http_classify stub (request decision without a plugin, response-stage decision blocks with 403), a guardrail backend returning 500 is recorded under the rule's key with no score and fails closed under on_error: block while the default policy delivers with the failure still on record, and the DSL round-trips direction in both directions. The response-jailbreak E2E profile gains a composed response-stage decision case and a streamed-response pass-through case, and is selected on PRs touching the response filter. Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com> * [Feature] Send the compose probe the keyword signal matches and split the direction round-trip test The compose-block E2E case sent `vsr-response-compose-probe`, but the profile's keyword signal is `__response_compose_probe__` and keyword rules match literally, so the request fell through to default_decision and the response-stage decision was never in play. Same defect 17566cb fixed for the window cases; this rebase picks that fix up too. `TestJailbreakDirectionRoundTrip` tripped the cyclop gate at 15 against a limit of 12. It is now three tests over one compile helper: the compiled directions, the text round trip, and the AST decompile. Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com> * [Feature] Keep the dashboard jailbreak field out of the oversized signal functions Rebasing onto main picks up the TypeScript structure gate, which ratchets functions that already exceed 100 lines: they may not grow. The direction field grew five of them - getSignalFieldSchema, buildSignalFormFields, and ConfigPageSignalsSection with its view, editor and save handlers - so the agent lint went red. Move the jailbreak-specific pieces those functions carry into module-level helpers: the jailbreak field schema and form fields become constants the switch and the field list reference, and the section component reads its jailbreak details, form defaults, edit state and saved entry from small functions. Every ratcheted function is now shorter than its baseline, and the field order, labels and behaviour are unchanged. Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com> Claude-Session: https://claude.ai/code/session_01AE8dtxTSjVKP3D3dBG7sgp * [Feature] Let the selected decision's plugin consume the response signal The response observation was fed back through the decision engine after the model answered, and a second decision could be selected from it. That is not the lifecycle vllm-project#3272 asks for: the request-time selection has executed, and the response-stage observation belongs to the plugin of the decision that was selected. Drop EvaluateResponseStageDecision, the staged decision filter and the response-stage decision fields; the response_jailbreak plugin enforces on ctx.VSRSelectedDecision only. A decision rule that names a response-direction rule is rejected at load. Decisions are selected before the model has answered, so the rule could only ever read as unknown there, and skipping such decisions at request time left them unable to match at all. The rules were read from the root config, which only describes the default recipe, while classification is recipe-scoped. Read them from the recipe the request resolved to, through its classifier, so a rule declared on one entrypoint's recipe scores only that entrypoint's responses. TestResponseJailbreakSignalReadsTheSelectedRecipeRules is the named-entrypoint regression: the guarded recipe declares the rule and the default recipe does not; a response through vllm-sr/guarded is scored and blocked, one through the default entrypoint is not scored. It fails when the rules come from the root config. The compose-block E2E case and its decisions go with the second selection; the streaming pass-through case now runs through the block decision. Docs, the reference config and the fragment say the plugin consumes the observation. Related vllm-project#3272 Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com> * [Feature] Record the response-stage observation in Router Replay and the debug header The replay record is written while the request is routed, before the model has answered, so the response-direction rule's match, score and failure never reached it, and the record's response_jailbreak fields were only ever set at creation. Append the observation as one outcome per response-direction rule instead: the verdict (detected, not_detected, unavailable), the score it thresholded or the failure code, and the action the selected decision's plugin applied. Outcomes are the append-only post-route channel every store implements, so no store schema changes. Recorded before a block returns, so a blocked response leaves the same evidence as a delivered one. x-vsr-matched-jailbreak was written in the response headers phase, from the request-stage matches, before the body existed. Rewrite it in the body phase with the response-direction matches after the request ones, under the same debug gate as the request-stage signal headers. The warning E2E case asserts it. A decision whose response_jailbreak plugin runs with no response-direction rule declared is reported once at load as the compatibility path, replacing the per-request debug line. Related vllm-project#3272 Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com> * [CI/Build] Tidy dashboard/backend go.sum for the router module's Router Replay store graph dashboard/backend replaces the router module in, and since vllm-project#3445 on main `go mod tidy` there wants the checksums of the modules the Router Replay store pulls in (lib/pq, the Milvus and Qdrant clients, cockroachdb/errors and their transitive go.mod files). The dashboard job is skipped on main pushes, so it surfaced on this PR's quality and dashboard gates instead. Generated with `go mod tidy`; the diff is exactly the one the gate printed. Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com> * [Bug] Provision the jailbreak classifier for its response-stage consumers NeedsJailbreakMappingForRouting and the classifier.jailbreak runtime task keyed only on a decision rule naming a jailbreak signal. A response-direction rule is consumed by the selected decision's response_jailbreak plugin and is never named by a decision rule, so once the E2E profile stopped composing one into a decision nothing loaded the label mapping: the http_classify prompt_guard refused to build ("label mapping is required for http_classify") and the router failed at startup. The plugin-only compatibility path had the same gap on main, where IsJailbreakEnabled stayed false without the mapping and the plugin silently never ran. UsesJailbreakClassifierInReachableRouting counts all three consumers over the request-reachable profiles (a decision reading a request-direction rule, a declared response-direction rule, an enabled response_jailbreak plugin), and the mapping loader, the model download gate and the runtime task use it. Tests: TestNeedsJailbreakMappingForResponseStageConsumers (flat config, including the disabled-plugin and prompt_guard-off negatives), TestNeedsJailbreakMappingForResponseStageConsumersFollowsRecipeReachability (unreachable recipes do not load it; scoped configs agree) and TestInitializeRuntimeInitializesJailbreakClassifierForResponseStageConsumers (the initializer runs once for either consumer, the request-stage ones stay skipped). All three fail on the previous gating. Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com> * [Bug] Leave a partly scanned response unresolved instead of clean CheckForJailbreakRiskWithThreshold and CheckForJailbreakWithThreshold dropped a chunk's failure as soon as any other chunk was scored, so a response whose failed chunk was never inspected came back clean, the response path published the observation as resolved, and the plugin let it through even under prompt_guard.on_error: block. A match in a scored chunk still counts, as the request path keeps a match past an unresolved chunk; without one, a partial scan now returns an error, which the response path publishes as unresolved so the on_error policy applies. The security classification API and the plugin-owned detection path share the helpers and change the same way. Tests: TestCheckForJailbreakErrorsWhenACleanVerdictNeedsAFailedChunk (risk and argmax wrappers) and TestResponseJailbreakPartialScanFailureIsNotClean, whose stub fails every attempt for the chunk carrying a marker (the connector retries a 5xx) and scores the rest: clean chunks leave the rule unresolved and on_error: block returns 403, while a match in a scored chunk is still a detection. Both fail on the previous wrappers. Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com> * [Bug] Reject a response-direction rule read through a projection validateDecisionsReadRequestStageSignals walked only the decision's own rule leaves. A projection score could still take a response-direction jailbreak rule as an input, and at request time, before the model has answered, the absent result became the input's configured miss value, so the decision was shaped by a signal that could not exist yet. decisionReadsResponseSignal now follows a projection leaf through its mapping to the score and its inputs, recursing through projection inputs (a confidence-valued one through the output it names), and the error names the projection output the rule was read through. Tests: TestValidateRejectsProjectionReadingResponseDirectionRule covers a direct score input, a score of scores and a confidence-valued output, and accepts the same graph over a request-direction rule; it fails on the leaf-only walk. Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com> * [Bug] Resolve a partly scanned response per jailbreak rule threshold The response stage scanned the answer once at the lowest declared threshold, and a match at that threshold dropped the chunk failure. With rules at 0.4 and 0.9, a 0.5 score next to a failed chunk marked the 0.9 rule clean. The scan now reports the score and the chunk failure apart from each other, and each rule resolves that one scan on its own line. A rule the score matches stays a match, and a rule the score does not reach is unresolved while a chunk is missing. TestResponseJailbreakPartialScanIsResolvedPerRuleThreshold covers the two rule case and fails on the previous path. Related vllm-project#3272 Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com> --------- Signed-off-by: subin9 <101092510+subin9@users.noreply.github.com> Signed-off-by: alanxtl <m134679102365478@163.com>
Related #3272
Purpose
jailbreakrule declaresdirection: responseto score the model's output. Samejailbreak:<name>key and reporting; CLI schema, Dashboard and DSL carrydirectionas one more rule field.method: contrastive, the pattern lists andinclude_historyare rejected on it.SignalErrorswith its own code.response_jailbreakplugin of the decision selected for the request consumes the observation and applies its action. Nothing is selected again after the model answers; a decision that reads a response-direction rule, directly in its rules or through a projection, is rejected at load. A response is clean only when every chunk of it was scored: a chunk the backend failed on leaves the rule unresolved unless another chunk matched, andprompt_guard.on_error: blockfails closed on it.prompt_guardfor the recipe (label mapping, model download, runtime task) even when no decision rule reads a jailbreak signal. Before, the mapping only loaded for a decision rule, so anhttp_classifyguard refused to build and the plugin-only path never ran.x-vsr-debug,x-vsr-matched-jailbreakcarries the response matches after the request ones.dashboard/backend/go.sumis tidied:mainhas needed it since [Bug] Enforce Fusion quorum over usable panel responses #3445 and the Dashboard job is skipped onmainpushes, so it surfaced on this PR's quality and dashboard gates.Test Plan
cd src/semantic-router && LD_LIBRARY_PATH=<rust libs> go test ./pkg/config/ ./pkg/extproc/ ./pkg/classification/ ./pkg/dsl/ ./pkg/routerreplay/...make e2e-test E2E_PROFILE=response-jailbreakTest Result
TestResponseJailbreakSignalReadsTheSelectedRecipeRulesis the named-entrypoint regression; it fails when rules come from the root config.TestResponseJailbreakSignalDrivesTheSelectedDecisionPlugin,TestResponseJailbreakBackendFailureIsNotHidden,TestResponseJailbreakSignalRecordsReplayOutcome,TestResponseJailbreakMatchedHeaderIncludesResponseRules,TestValidateRejectsDecisionReadingResponseDirectionRule, and the threeTestJailbreakDirection*DSL round-trips.TestResponseJailbreakPartialScanFailureIsNotCleanandTestCheckForJailbreakErrorsWhenACleanVerdictNeedsAFailedChunk(a failed chunk beside clean ones is unresolved and blocks underon_error: block; a match in a scored chunk still counts),TestValidateRejectsProjectionReadingResponseDirectionRule(score, score of scores, confidence-valued output),TestNeedsJailbreakMappingForResponseStageConsumerswith its recipe-reachability variant, andTestInitializeRuntimeInitializesJailbreakClassifierForResponseStageConsumers; each fails on the previous code.response-jailbreak: window block and warning through the signal-driven path (the warning case also checksx-vsr-matched-jailbreak), plus the streaming pass-through contract.Not covered here