diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 0293b4bd04..94241e8a75 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -406,7 +406,12 @@ export function ensureStrictCatalogFields( if (typeof entry.supports_reasoning_summaries !== "boolean") entry.supports_reasoning_summaries = false; if (typeof entry.default_reasoning_summary !== "string") entry.default_reasoning_summary = "none"; if (typeof entry.support_verbosity !== "boolean") entry.support_verbosity = true; - if (typeof entry.default_verbosity !== "string") entry.default_verbosity = "low"; + // A row that has declared it does NOT support verbosity must not also ship a default for the + // control it just disowned: Codex seeds its picker from `default_verbosity`, so leaving the + // strict-fields fallback in place re-creates the dead toggle the explicit opt-out removed. + // Scoped to an explicit `false`, so rows that never declare a capability keep the default. + if (entry.support_verbosity === false) delete entry.default_verbosity; + else if (typeof entry.default_verbosity !== "string") entry.default_verbosity = "low"; if (typeof entry.apply_patch_tool_type !== "string") entry.apply_patch_tool_type = "freeform"; if (!entry.truncation_policy || typeof entry.truncation_policy !== "object" || Array.isArray(entry.truncation_policy)) { entry.truncation_policy = { mode: "tokens", limit: 10000 }; diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index 46b2aa91ba..d552b78065 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -68,6 +68,45 @@ function classifyIpv4(hostname: string): DestinationAssessment { return { kind: "public", detail: "public IP" }; } +/** + * Expand an IPv6 literal into its eight hextets, or null when it is not one this can parse. + * `firstIpv6Hextet` below only needs the leading group; prefix matching needs the whole address, + * and `::` compression plus the RFC 4291 trailing dotted-quad form both have to be handled. + */ +function ipv6Hextets(hostname: string): number[] | null { + let text = hostname; + const dotted = text.match(/(\d{1,3}(?:\.\d{1,3}){3})$/); + if (dotted?.index !== undefined) { + const octets = dotted[1].split(".").map(Number); + if (octets.some(octet => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null; + text = text.slice(0, dotted.index) + + ((octets[0]! << 8) | octets[1]!).toString(16) + + ":" + + ((octets[2]! << 8) | octets[3]!).toString(16); + } + const halves = text.split("::"); + if (halves.length > 2) return null; + const parseGroups = (part: string): number[] | null => { + if (!part) return []; + const out: number[] = []; + for (const piece of part.split(":")) { + if (!/^[0-9a-f]{1,4}$/i.test(piece)) return null; + out.push(Number.parseInt(piece, 16)); + } + return out; + }; + const head = parseGroups(halves[0] ?? ""); + const tail = halves.length === 2 ? parseGroups(halves[1] ?? "") : []; + if (!head || !tail) return null; + if (halves.length === 1) return head.length === 8 ? head : null; + const fill = 8 - head.length - tail.length; + if (fill < 1) return null; + return [...head, ...Array(fill).fill(0), ...tail]; +} + +/** RFC 6052 §2.1 well-known NAT64 prefix, 64:ff9b::/96, as its six leading hextets. */ +const NAT64_WELL_KNOWN_PREFIX = [0x64, 0xff9b, 0, 0, 0, 0] as const; + function firstIpv6Hextet(hostname: string): number | null { const head = hostname.split(":")[0]; if (!head) return 0; @@ -89,6 +128,20 @@ function classifyIpv6(hostname: string): DestinationAssessment { const ipv4 = `${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`; return classifyIpv4(ipv4); } + // NAT64 (RFC 6052): on an IPv6-only/DNS64 network every IPv4-only peer is synthesized into + // 64:ff9b::, whose leading hextet (0x64) is below the 2000::/3 global-unicast window and + // so fell through to "non-global address". That rejected ordinary public destinations for any + // user behind NAT64 — two tests already worked around it with `allowPrivateNetwork: true`. + // Classify the EMBEDDED IPv4 instead, exactly as the ::ffff: forms above do, so a wrapped + // 127.0.0.1 or 10/8 stays blocked rather than becoming an SSRF bypass. Only the well-known + // prefix is decoded; RFC 8215's 64:ff9b:1::/48 is reserved for local-use translation and keeps + // its non-global treatment. + const hextets = ipv6Hextets(hostname); + if (hextets && NAT64_WELL_KNOWN_PREFIX.every((group, index) => hextets[index] === group)) { + const hi = hextets[6]!; + const lo = hextets[7]!; + return classifyIpv4(`${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`); + } if (hostname === "::1") return { kind: "loopback", detail: "loopback address" }; if (hostname === "::") return { kind: "unspecified", detail: "unspecified address" }; const hextet = firstIpv6Hextet(hostname); diff --git a/tests/catalog-verbosity-default.test.ts b/tests/catalog-verbosity-default.test.ts new file mode 100644 index 0000000000..75a07cca66 --- /dev/null +++ b/tests/catalog-verbosity-default.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { buildCatalogEntries, gatherRoutedModels as gatherRoutedModelsDirect, upstreamNativeEntry } from "../src/codex/catalog"; +import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; +import { resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests } from "../src/codex/catalog"; +import { clearModelCache } from "../src/codex/model-cache"; + +const gatherRoutedModels: typeof gatherRoutedModelsDirect = (config, options) => + gatherRoutedModelsDirect(withStubbedProviderFetch(config), options); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache(); + resetOpenAiApiCatalogWarningStateForTests(); + resetCatalogRuntimeStateForTests(); +}); + +const originalFetch = globalThis.fetch; + +/** + * A serialized row that declares `support_verbosity: false` must not also carry a + * `default_verbosity`. Codex seeds its picker from `default_verbosity`, so leaving the + * strict-fields fallback in place re-creates the dead toggle that the explicit opt-out + * (#2578 architecture) removed. All field names here are the SERIALIZED Codex spellings — + * `supports_verbosity` does not exist in this format, which is exactly how an earlier + * assertion passed while every routed row advertised the control. + */ +describe("catalog — default_verbosity is dropped when verbosity is unsupported", () => { + test("GREEN: an opted-out routed row carries no verbosity default", async () => { + const models = await gatherRoutedModels({ + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", + liveModels: false, + models: ["grok-4.6"], + }, + }, + }); + const entries = buildCatalogEntries(null, [], models); + const xai = entries.find(e => e.slug === "xai/grok-4.6"); + expect(xai?.support_verbosity).toBe(false); + expect(xai?.default_verbosity).toBeUndefined(); + }); + + test("GREEN: a Kiro opted-out row carries no verbosity default either", async () => { + const models = await gatherRoutedModels({ + providers: { + kiro: { + adapter: "kiro", + baseUrl: "https://runtime.us-east-1.kiro.dev", + authMode: "oauth", + liveModels: false, + models: ["gpt-5.6-sol"], + }, + }, + }); + const entries = buildCatalogEntries(null, [], models); + const kiro = entries.find(e => e.slug === "kiro/gpt-5.6-sol"); + expect(kiro?.support_verbosity).toBe(false); + expect(kiro?.default_verbosity).toBeUndefined(); + }); + + test("CONTROL: rows that never declare a capability keep the permissive default", async () => { + const models = await gatherRoutedModels({ + providers: { + plain: { + adapter: "openai-responses", + baseUrl: "https://plain.example.test/v1", + authMode: "key", + liveModels: false, + models: ["plain-model"], + }, + }, + }); + const entries = buildCatalogEntries(null, [], models); + const plain = entries.find(e => e.slug === "plain/plain-model"); + expect(plain?.support_verbosity).toBe(true); + expect(plain?.default_verbosity).toBe("low"); + }); + + test("CONTROL: a native OpenAI row keeps verbosity and its default", () => { + const template = upstreamNativeEntry("gpt-5.6-sol"); + expect(template).not.toBeNull(); + const entries = buildCatalogEntries(template, ["gpt-5.6-sol"], []); + const native = entries.find(e => e.slug === "gpt-5.6-sol"); + expect(native?.support_verbosity).toBe(true); + expect(native?.default_verbosity).toBe("low"); + }); +}); diff --git a/tests/destination-policy-resolved.test.ts b/tests/destination-policy-resolved.test.ts index 98a4db6827..207f73ec8c 100644 --- a/tests/destination-policy-resolved.test.ts +++ b/tests/destination-policy-resolved.test.ts @@ -253,3 +253,46 @@ describe("resolvePublicAddresses — caller-specific diagnostics", () => { )).rejects.toThrow("benchmark address (198.19.7.9)"); }); }); + +describe("providerDestinationConfigError — NAT64 well-known prefix (RFC 6052)", () => { + // On an IPv6-only/DNS64 network every IPv4-only peer is synthesized into 64:ff9b::. + // 0x64 sits below the 2000::/3 global-unicast window, so the wrapper alone read as + // "non-global address" and rejected ordinary public destinations for anyone behind NAT64. + test("a wrapped public IPv4 is accepted", () => { + for (const host of ["64:ff9b::d25:c62c", "64:ff9b::0fe0:7748", "64:ff9b::13.37.198.44"]) { + expect(providerDestinationConfigError("p", provider(`https://[${host}]/v1`))).toBeNull(); + } + }); + + // The embedded address is what gets classified, so the decode cannot become an SSRF bypass. + test("a wrapped private, loopback, or link-local IPv4 stays blocked", () => { + const cases: [string, string][] = [ + ["64:ff9b::7f00:1", "loopback"], + ["64:ff9b::a00:1", "private-network"], + ["64:ff9b::c0a8:1", "private-network"], + ["64:ff9b::ac10:1", "private-network"], + // 169.254.169.254 is the cloud metadata IP, so the wrapped form lands on the stronger + // metadata blocklist rather than the generic link-local rule. + ["64:ff9b::a9fe:a9fe", "blocked metadata endpoint"], + ["64:ff9b::127.0.0.1", "loopback"], + ]; + for (const [host, detail] of cases) { + expect(providerDestinationConfigError("p", provider(`https://[${host}]/v1`))).toContain(detail); + } + }); + + // RFC 8215 reserves 64:ff9b:1::/48 for local-use translation, which is not the well-known + // prefix and keeps its non-global treatment. + test("the RFC 8215 local-use prefix is not decoded", () => { + expect(providerDestinationConfigError("p", provider("https://[64:ff9b:1::d25:c62c]/v1"))) + .toContain("non-global"); + }); + + test("unrelated IPv6 classification is unchanged", () => { + expect(providerDestinationConfigError("p", provider("https://[2606:4700::6812:1250]/v1"))).toBeNull(); + expect(providerDestinationConfigError("p", provider("https://[::1]/v1"))).toContain("loopback"); + expect(providerDestinationConfigError("p", provider("https://[fd00::1]/v1"))).toContain("private-network"); + expect(providerDestinationConfigError("p", provider("https://[fe80::1]/v1"))).toContain("link-local"); + expect(providerDestinationConfigError("p", provider("https://[2001:db8::1]/v1"))).toContain("documentation"); + }); +});