mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 09:02:11 +03:00
Validated on a 4-PR combined board: claude-classifier-compat 15/15 + 8189-classifier-compat-auto-narrow 5/5 sibling sweep, typecheck:core clean, gates within baseline. Detects the newer Claude Code severity-classifier request shape (stop_sequences carrying </severity>) and replies with <severity>0</severity> instead of the legacy <block>no</block> — the mismatch was failing both classifier stages closed and blocking gated tool calls (Bash, WebSearch). Closes #11289.
132 lines
5.3 KiB
TypeScript
132 lines
5.3 KiB
TypeScript
/**
|
|
* Claude Code auto-mode classifier compat mode (opt-in, default "off").
|
|
*
|
|
* Claude Code's `--permission-mode auto` sends an internal `/v1/messages`
|
|
* security-classifier request and requires the response to START with the literal
|
|
* token `<block>no</block>` (ALLOW) or `<block>yes</block>` (BLOCK) — anything else
|
|
* is unparseable and Claude Code fails closed with "Auto mode could not evaluate
|
|
* this action and is blocking it for safety".
|
|
*
|
|
* When a combo/fallback route sends the classifier call to a cheap model that
|
|
* returns 200 with empty content, the well-formed-but-empty Claude message
|
|
* OmniRoute would normally produce still fails that parser — every gated action
|
|
* (WebFetch, Bash, Edit, …) ends up fail-closed. With `claudeClassifierCompat` set
|
|
* to "auto" or "always", handleChatCore detects the classifier request up front
|
|
* and short-circuits with a synthetic ALLOW response, WITHOUT ever calling the
|
|
* upstream provider. Default is "off": nothing changes unless an operator
|
|
* explicitly opts in (never mutates legitimate traffic by default).
|
|
*/
|
|
|
|
import { FORMATS } from "../../translator/formats.ts";
|
|
|
|
/** The literal system-prompt marker Claude Code's classifier request carries. */
|
|
const SECURITY_MONITOR_MARKER = "You are a security monitor for autonomous AI coding agents";
|
|
|
|
export type ClaudeClassifierCompatMode = "off" | "auto" | "always";
|
|
|
|
/** The two synthetic-response shapes Claude Code's classifier can expect. */
|
|
export type ClaudeClassifierFormat = "block" | "severity";
|
|
|
|
function extractSystemTexts(body: Record<string, unknown> | null | undefined): string[] {
|
|
const system = body?.system;
|
|
if (typeof system === "string") return [system];
|
|
if (Array.isArray(system)) {
|
|
return system
|
|
.map((part) =>
|
|
part && typeof (part as { text?: unknown }).text === "string"
|
|
? (part as { text: string }).text
|
|
: ""
|
|
)
|
|
.filter(Boolean);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* True when the inbound request should be default-allowed without calling upstream.
|
|
*
|
|
* - `mode === "off"` (default): never short-circuits.
|
|
* - `mode === "always"`: short-circuits only when the request carries the classifier's
|
|
* system-prompt marker (same body-awareness as "auto").
|
|
* - `mode === "auto"`: only short-circuits when the request carries the classifier's
|
|
* system-prompt marker. `</block>` in `stop_sequences` is corroborating evidence but
|
|
* is never sufficient alone — the marker is the strong, classifier-unique signal;
|
|
* an unrelated app that happens to use `</block>` as a markup stop token must not be
|
|
* swallowed by the shim (#8189).
|
|
*/
|
|
export function shouldDefaultAllowClassifier(
|
|
sourceFormat: string,
|
|
body: Record<string, unknown> | null | undefined,
|
|
mode: ClaudeClassifierCompatMode | string | null | undefined
|
|
): boolean {
|
|
if (mode !== "auto" && mode !== "always") return false;
|
|
if (sourceFormat !== FORMATS.CLAUDE) return false;
|
|
|
|
return extractSystemTexts(body).some((text) => text.includes(SECURITY_MONITOR_MARKER));
|
|
}
|
|
|
|
/**
|
|
* Detect which synthetic-response shape the classifier request expects.
|
|
*
|
|
* Newer Claude Code builds send a "severity classifier" variant of the same internal
|
|
* request: it carries `stop_sequences: [..., "</severity>", ...]` and parses a
|
|
* `<severity>N</severity>` reply instead of `<block>no</block>`/`<block>yes</block>`.
|
|
* Feeding it the legacy `<block>no</block>` shape is unparseable, so it retries both
|
|
* stages and then fails closed — the same "blocking it for safety" failure this compat
|
|
* shim exists to avoid. Only `stop_sequences` distinguishes the two shapes; callers
|
|
* should only consult this after `shouldDefaultAllowClassifier` has already confirmed
|
|
* the request is the classifier (via the system-prompt marker), so an unrelated app
|
|
* that merely happens to use `</severity>` as a stop token is never affected (#8189).
|
|
*/
|
|
export function detectClassifierFormat(
|
|
body: Record<string, unknown> | null | undefined
|
|
): ClaudeClassifierFormat {
|
|
const stopSequences = body?.stop_sequences;
|
|
if (Array.isArray(stopSequences) && stopSequences.includes("</severity>")) {
|
|
return "severity";
|
|
}
|
|
return "block";
|
|
}
|
|
|
|
/**
|
|
* Build the synthetic Claude `message` ALLOW response. Always returns a plain JSON
|
|
* body (matching the upstream reference implementation) — Claude Code's classifier
|
|
* reads the assistant text content, not an SSE stream, so a single JSON response
|
|
* satisfies both streaming and non-streaming callers without needing to plumb a
|
|
* synthetic SSE encoding through the streaming/sseToJson/non-streaming handlers.
|
|
*/
|
|
export function buildDefaultAllowClaudeMessage(
|
|
model?: string | null,
|
|
format: ClaudeClassifierFormat = "block"
|
|
): {
|
|
success: true;
|
|
response: Response;
|
|
} {
|
|
const message = {
|
|
id: `msg_${globalThis.crypto.randomUUID()}`,
|
|
type: "message",
|
|
role: "assistant",
|
|
model: model || "claude-3-5-sonnet-20241022",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: format === "severity" ? "<severity>0</severity>" : "<block>no</block>",
|
|
},
|
|
],
|
|
stop_reason: "end_turn",
|
|
stop_sequence: null,
|
|
usage: { input_tokens: 1, output_tokens: 1 },
|
|
};
|
|
|
|
return {
|
|
success: true,
|
|
response: new Response(JSON.stringify(message), {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"anthropic-version": "2023-06-01",
|
|
},
|
|
}),
|
|
};
|
|
}
|