From ed1b70b55eb94b521c00b78a8a3c30659a460ef6 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 2 Oct 2026 05:31:53 -0700 Subject: [PATCH] fix(combos): enforce admission scope before decision provider work --- scripts/test-layout/layout.json | 1 + src/combos/jev.ts | 16 +++- src/server/responses/core-combo.ts | 4 + structure/providers/jev-decision.md | 4 + tests/fixtures/test-layout-expected.json | 1 + tests/server/jev-decision-scope.test.ts | 100 +++++++++++++++++++++++ 6 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 tests/server/jev-decision-scope.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 646700d05c6..a9c3069e828 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,6 +2,7 @@ "version": 1, "root": "tests", "explicit": { + "jev-decision-scope.test.ts": "server", "claude-intercept-on-demand.test.ts": "claude-integration", "main-account-hard-lock-thresholds.test.ts": "codex-integration", "main-account-external-usage.test.ts": "codex-integration", diff --git a/src/combos/jev.ts b/src/combos/jev.ts index b114f5b67ae..d04e9cd0cd6 100644 --- a/src/combos/jev.ts +++ b/src/combos/jev.ts @@ -127,6 +127,8 @@ export interface ResolveJevDecisionOptions { * any other id names a configured `jev-decision` row (for example a self-hosted Ollama `tev1`). */ decisionProvider?: string; + /** Request-local authorization of the concrete decision destination, before credential access. */ + isDestinationAllowed?: (providerName: string, modelId: string) => boolean; /** Decision deadline; values outside 1000..120000 ms keep the four-second default. */ timeoutMs?: number; signal?: AbortSignal; @@ -674,14 +676,20 @@ function selfHostedApiKey(name: string, apiKey: string | undefined): string | un * custom destination. Any other id must be an enabled `jev-decision` row whose baseUrl is a * `/systemone` endpoint and which names its own model; only its own key may accompany it, so no * TypeSafe credential can reach a self-hosted service. `undefined` means no usable decision - * service (reported through the existing `missing_key` gate). + * service (reported through the existing `missing_key` gate); `null` means the request's + * destination scope refused it before any credential access. */ -function jevDecisionEndpoint(config: OcxConfig, decisionProvider: string): JevDecisionEndpoint | undefined { +function jevDecisionEndpoint( + config: OcxConfig, + decisionProvider: string, + isDestinationAllowed?: ResolveJevDecisionOptions["isDestinationAllowed"], +): JevDecisionEndpoint | undefined | null { const configured = Object.hasOwn(config.providers, decisionProvider) ? config.providers[decisionProvider] : undefined; if (configured?.disabled === true) return undefined; if (decisionProvider === JEV_PROVIDER_ID) { + if (isDestinationAllowed?.(JEV_PROVIDER_ID, JEV_MODEL) === false) return null; const configuredOwnsJev = configured && providerMatchesRegistryTransport(JEV_PROVIDER_ID, configured); const apiKey = ( @@ -704,6 +712,7 @@ function jevDecisionEndpoint(config: OcxConfig, decisionProvider: string): JevDe // `jev-latest` is TypeSafe's model name; a self-hosted host must name its own. const model = configured.defaultModel?.trim() || configured.models?.[0]?.trim(); if (!model) return undefined; + if (isDestinationAllowed?.(decisionProvider, model) === false) return null; const apiKey = selfHostedApiKey(decisionProvider, configured.apiKey); if (apiKey === null) return undefined; return { @@ -733,7 +742,8 @@ export async function resolveJevDecision(options: ResolveJevDecisionOptions): Pr if (options.candidates.length === 0) return failed("no_choices"); if (!candidatesFitRequestBounds(options.candidates)) return failed("invalid"); - const endpoint = jevDecisionEndpoint(options.config, options.decisionProvider ?? JEV_PROVIDER_ID); + const endpoint = jevDecisionEndpoint(options.config, options.decisionProvider ?? JEV_PROVIDER_ID, options.isDestinationAllowed); + if (endpoint === null) return failed("invalid"); if (!endpoint) return failed("missing_key"); let requestBody: string; diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 7f4305bb51f..00d841d2cc4 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -3,6 +3,7 @@ import { isDeclaredReasoningEffort, resolveEffortAtOrBelow, } from "../../reasoning-effort"; +import { resolveAdmissionModelScope, routeAllowedByScope } from "../admission-model-scope"; import { recordAttemptRequestedEffort } from "../request-log"; import { CODEX_TEXT_GUARDED_BUDGET_POLICY, @@ -572,6 +573,9 @@ export async function executeComboResponses( candidates: choices.map(choice => choice.candidate), fallback, config, + isDestinationAllowed: (providerName, modelId) => routeAllowedByScope( + resolveAdmissionModelScope(config, options.admission), { providerName, modelId }, + ), ...(combo.decisionProvider ? { decisionProvider: combo.decisionProvider } : {}), ...(combo.decisionModel ? { diff --git a/structure/providers/jev-decision.md b/structure/providers/jev-decision.md index 5ffe13b1ec2..1913a988cfc 100644 --- a/structure/providers/jev-decision.md +++ b/structure/providers/jev-decision.md @@ -58,6 +58,10 @@ the JEV credential are excluded. It owns the joint target/effort choice map, str validation, canonical `jev-latest` destination, default four-second deadline, no-redirect policy, bounded response, and caller-cancellation propagation. Missing credentials or safe state, transport failures, and invalid answers fail open to the first eligible target; no response can escape the configured choice map. +The direct TypeSafe and System One decision destinations are checked against the parent API key's +resolved provider/model scope before reading decision credentials or extracting state. A denied +optional decision uses the existing fail-open inference target without sending a decision request; +management probes and unrestricted admissions retain their existing behavior. Telemetry never retains extracted state or credentials. The optional `targets[].modelProfile` note is validated at the Combo management input diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f25fa0b2e99..195430ca8a8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { + "jev-decision-scope.test.ts": "server", "claude-intercept-on-demand.test.ts": "claude-integration", "main-account-hard-lock-thresholds.test.ts": "codex-integration", "main-account-external-usage.test.ts": "codex-integration", diff --git a/tests/server/jev-decision-scope.test.ts b/tests/server/jev-decision-scope.test.ts new file mode 100644 index 00000000000..12234fd3529 --- /dev/null +++ b/tests/server/jev-decision-scope.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { clearComboSelectionState, clearComboTargetCooldowns, resolveJevDecision } from "../../src/combos"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { executeComboResponses } from "../../src/server/responses/core-combo"; +import { admissionModelScopeOf, routeAllowedByScope } from "../../src/server/admission-model-scope"; +import type { DataPlaneAdmission } from "../../src/server/auth-cors"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +const admission: DataPlaneAdmission = { kind: "configured", keyId: "fixture-key", source: "bearer" }; +afterEach(() => { clearComboSelectionState(); clearComboTargetCooldowns(); }); + +function fixture(scope: { allowedProviders?: string[]; allowedModels?: string[] }, decisionProvider = "decider") { + let keyReads = 0; + let sends = 0; + const decision: OcxProviderConfig = { + adapter: "jev-decision", baseUrl: "https://decider.example/v1/systemone", defaultModel: "tev1:4b", liveModels: false, + get apiKey() { keyReads++; return "fixture-decision-key"; }, + fetch: (async () => { + sends++; + return Response.json({ answers: { route: { choice: "a/m2:low", confidence: 0.9 } } }); + }) as typeof fetch, + }; + const config: OcxConfig = { + port: 0, defaultProvider: "a", + apiKeys: [{ id: "fixture-key", key: "fixture-admission-key", name: "fixture", createdAt: "2026-01-01T00:00:00.000Z", ...scope }], + providers: { + a: { adapter: "openai-chat", baseUrl: "https://inference.example/v1", apiKey: "fixture-inference-key", liveModels: false, + models: ["m1", "m2"], modelReasoningEfforts: { m1: ["low"], m2: ["low"] } }, + [decisionProvider]: decision, + }, + combos: { auto: { alias: "auto", strategy: "jev", decisionProvider, targets: [{ provider: "a", model: "m1" }, { provider: "a", model: "m2" }] } }, + }; + return { config, decision, keyReads: () => keyReads, sends: () => sends }; +} + +async function run(config: OcxConfig) { + const body = { model: "auto", input: "Choose an appropriate model for this task.", stream: false }; + const budget = createTranslatorBudget(); + const children: string[] = []; + try { + const response = await executeComboResponses(new Request("http://127.0.0.1/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }), body, "auto", config, { model: "", provider: "" }, { translatorBudget: budget, admission }, { + async handleResponses(request, _config, _log, options) { + expect(options?.admission).toEqual(admission); + const child = await request.json() as { model: string }; + children.push(child.model); + return Response.json({ id: "resp_fixture", object: "response", status: "completed", output: [] }); + }, + async handleComboResponses() { throw new Error("unexpected nested combo"); }, + }); + expect(response.status).toBe(200); + await response.text(); + return children; + } finally { budget.dispose(); } +} + +describe("JEV decision destination scope", () => { + test("a denied provider performs no credential work or send and keeps the inference fallback", async () => { + const f = fixture({ allowedProviders: ["a"], allowedModels: ["a/m1"] }); + expect(await run(f.config)).toEqual(["a/m1"]); + expect(f.keyReads()).toBe(0); + expect(f.sends()).toBe(0); + }); + test("model-only restrictions also prevent a decision send", async () => { + const f = fixture({ allowedModels: ["a/m1"] }); + expect(await run(f.config)).toEqual(["a/m1"]); + expect(f.keyReads()).toBe(0); + expect(f.sends()).toBe(0); + }); + test("an allowed normalized provider/model still decides the inference target", async () => { + const f = fixture({ allowedProviders: [" A ", " DECIDER "], allowedModels: ["a/m1", "a/m2", " DECIDER/TEV1:4B "] }); + expect(await run(f.config)).toEqual(["a/m2"]); + expect(f.keyReads()).toBeGreaterThan(0); + expect(f.sends()).toBe(1); + }); + for (const decisionProvider of [undefined, "jev", "decider"]) { + test(`${decisionProvider ?? "default jev"}: checks the concrete destination before credentials`, async () => { + const name = decisionProvider ?? "jev"; + const f = fixture({}, name); + if (name === "jev") f.decision.baseUrl = "https://api.typesafe.ai/v1/systemone"; + const seen: string[] = []; + const result = await resolveJevDecision({ + config: f.config, decisionProvider, + body: { input: "hello" }, + candidates: [{ key: "a/m1", provider: "a", model: "m1", reasoningEfforts: ["low"] }, + { key: "a/m2", provider: "a", model: "m2", reasoningEfforts: ["low"] }], + fallback: { targetKey: "a/m1", effort: "low" }, + isDestinationAllowed(providerName, modelId) { seen.push(`${providerName}/${modelId}`); return false; }, + }); + expect(seen).toEqual([name === "jev" ? "jev/jev-latest" : "decider/tev1:4b"]); + expect(result).toMatchObject({ targetKey: "a/m1", gate: "invalid" }); + expect(f.keyReads()).toBe(0); + expect(f.sends()).toBe(0); + }); + } + test("bare model allowlists preserve their existing meaning", () => { + expect(routeAllowedByScope(admissionModelScopeOf({ allowedModels: ["TEV1:4B"] }), { providerName: "decider", modelId: "tev1:4b" })).toBe(true); + }); +});