diff --git a/src/lib/shadow-call.ts b/src/lib/shadow-call.ts index 829ea69119..7eec991ee3 100644 --- a/src/lib/shadow-call.ts +++ b/src/lib/shadow-call.ts @@ -29,6 +29,21 @@ export function isShadowSourceModel(modelId: string, configured?: unknown): bool return shadowSourceModels(configured).some(prefix => modelId.startsWith(prefix)); } +/** + * The configured source prefix this model matched, or undefined. + * + * Callers that RECORD the intercepted model must record this rather than the caller's raw + * `modelId`. Matching is by prefix, so `gpt-5.6-luna` plus arbitrary trailing text still + * intercepts — and the raw string is caller-controlled, reaches `usage.jsonl` and `/api/logs`, + * and only passes a pattern-based redactor on the way. A credential family that redactor does + * not recognize survives verbatim. Returning the operator-configured prefix keeps the log + * field inside a set the operator chose, so no caller string is ever persisted. + */ +export function shadowSourceModelPrefix(modelId: string, configured?: unknown): string | undefined { + if (modelId.includes("/")) return undefined; + return shadowSourceModels(configured).find(prefix => modelId.startsWith(prefix)); +} + /** * Decide whether a matching source model should use the opt-in intercept. * diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 121fa93fe3..8344db309f 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -315,7 +315,7 @@ export function sidecarOutcomeRecorder( -import { isShadowSourceModel, shouldInterceptShadowCall } from "../../lib/shadow-call"; +import { isShadowSourceModel, shadowSourceModelPrefix, shouldInterceptShadowCall } from "../../lib/shadow-call"; export { DEFAULT_SHADOW_SOURCE_MODELS, isShadowSourceModel, shadowSourceModels } from "../../lib/shadow-call"; @@ -1921,7 +1921,14 @@ async function handleResponsesInner( if (parsed._rawBody && typeof parsed._rawBody === "object") { (parsed._rawBody as Record).reasoning = { effort: "low" }; } - logCtx.shadowCallRewrittenFrom = sanitizeLogMetadataString(_sciOriginal); + // Record the operator-configured prefix that matched, NOT the caller's raw model string. + // Matching is by prefix, so a caller can append arbitrary text and still intercept; that + // raw value would then land in usage.jsonl and /api/logs behind a pattern-based redactor + // that does not recognize every credential family. The prefix is a value the operator + // configured, so no caller-controlled string is persisted. + logCtx.shadowCallRewrittenFrom = sanitizeLogMetadataString( + shadowSourceModelPrefix(_sciOriginal, _sci.sourceModels), + ); // Helpers must not resume/append into the parent thread's Cursor conversation. parsed._cursorIsolateConversation = true; } diff --git a/tests/responses-shadow-intercept.test.ts b/tests/responses-shadow-intercept.test.ts index 18ee9eb059..ce1b720b1e 100644 --- a/tests/responses-shadow-intercept.test.ts +++ b/tests/responses-shadow-intercept.test.ts @@ -152,6 +152,41 @@ describe("shadow call intercept request path (issue #311)", () => { expect(logCtx.shadowCallRewrittenFrom).toBe("gpt-5.6-luna"); }); + // The intercept matches by PREFIX, so a caller can append anything and still be intercepted. + // The recorded marker is persisted to usage.jsonl and served from /api/logs, and the runtime + // redactor is pattern-based: a credential family it does not recognize would survive verbatim. + // Recording the operator-configured prefix instead of the caller's raw string removes the + // class, rather than adding one more pattern to a deny-list. + test("the recorded marker is the configured prefix, never the caller's raw model string", async () => { + const logCtx: RequestLogContext = { model: "", provider: "" }; + globalThis.fetch = (async () => new Response(JSON.stringify({ + choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }), { status: 200, headers: { "content-type": "application/json" } })) as typeof fetch; + + // A Google-shaped key: the runtime redactor has no rule for this family, and the newline + // is stripped before redaction runs, so the old code persisted this string intact. + const smuggled = "gpt-5.6-luna\nAIzaSyA1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6"; + await post(interceptConfig(), smuggled, "turn", logCtx); + + expect(logCtx.shadowCallRewrittenFrom).toBe("gpt-5.6-luna"); + expect(logCtx.shadowCallRewrittenFrom ?? "").not.toContain("AIza"); + }); + + test("a configured non-default prefix is recorded as itself", async () => { + const logCtx: RequestLogContext = { model: "", provider: "" }; + globalThis.fetch = (async () => new Response(JSON.stringify({ + choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }), { status: 200, headers: { "content-type": "application/json" } })) as typeof fetch; + + const config = interceptConfig(); + config.shadowCallIntercept = { enabled: true, model: "grok-4.5", sourceModels: ["gpt-5.4-mini"] }; + await post(config, "gpt-5.4-mini-2024-07-18", "turn", logCtx); + + expect(logCtx.shadowCallRewrittenFrom).toBe("gpt-5.4-mini"); + }); + test("leaves gpt-5.6-terra requests unrewritten", async () => { let sawFetch = false; globalThis.fetch = (async () => {