fix(ci): mirror isLocalOnlyPath in the security-tier gate and rebaseline four merged-growth file caps

Two base-reds on release/v3.8.51 (#12581), both drained at the source.

1) check:openapi-security-tiers reported six CORRECTLY annotated routes as
   unprotected and demanded the removal of their x-loopback-only annotation —
   pushing the fix in the unsafe direction. The gate re-reads routeGuard.ts as
   text (it cannot import the module: routeGuard pulls the server runtime and
   the gate runs on plain node), but it only read the FIRST half of
   isLocalOnlyPath():

     LOCAL_ONLY_API_PREFIXES.some(...) || LOCAL_ONLY_API_PATTERNS.some(...)

   so every route gated by a regex (/api/providers/volcengine-plan/connect/*)
   or by an imported constant (VNC_ROUTE_PREFIX, which the text parse turned
   into the literal string "VNC_ROUTE_PREFIX") looked open. Proven with
   isLocalOnlyPath() at runtime: all six return true; the control
   /api/providers/{id}/refresh stays false.

   New scripts/check/routeGuardConstants.mjs reads BOTH arrays, resolves
   imported identifiers by following the import, and THROWS on an unresolvable
   token instead of silently degrading it into a literal. Its array scanner is
   hand-rolled because regex literals carry the brackets and commas a
   \[([^\]]+)\] capture plus a naive comma split break on ([^/] and {1,3}).
   The reverse pass (missing-annotation warnings) now uses the same predicate.

2) check:file-size: four frozen files grew past their cap through merged PRs —
   chat.ts +10 (#12427/#12503 video-transcript redaction, derived from the
   post-guardrail payload at the single dispatch point) and stream.ts /
   accountFallback.ts / codex.ts +17 total (#12179 hot-path regex hoisting,
   bounded caches, quadratic-buffering fix). All cohesive at existing
   chokepoints; rebaselined with the rationale recorded in the baseline file.

Refs #12581
This commit is contained in:
diegosouzapw
2026-09-03 09:25:02 -03:00
parent e1cf542378
commit c622709e46
4 changed files with 316 additions and 34 deletions

View File

@@ -409,7 +409,7 @@
"open-sse/executors/antigravity.ts": 1665,
"open-sse/executors/base.ts": 1751,
"open-sse/executors/chatgpt-web.ts": 5056,
"open-sse/executors/codex.ts": 1499,
"open-sse/executors/codex.ts": 1503,
"open-sse/executors/cursor.ts": 1759,
"open-sse/executors/muse-spark-web.ts": 1405,
"open-sse/handlers/chatCore.ts": 5976,
@@ -417,13 +417,13 @@
"open-sse/handlers/search.ts": 1789,
"open-sse/mcp-server/schemas/tools.ts": 1621,
"open-sse/mcp-server/server.ts": 1572,
"open-sse/services/accountFallback.ts": 2422,
"open-sse/services/accountFallback.ts": 2429,
"open-sse/services/adobeFireflyBrowserLogin.ts": 1401,
"open-sse/services/combo.ts": 4023,
"open-sse/translator/response/openai-responses.ts": 1466,
"open-sse/utils/cursorAgentProtobuf.ts": 1547,
"open-sse/utils/proxyFetch.ts": 1271,
"open-sse/utils/stream.ts": 3072,
"open-sse/utils/stream.ts": 3078,
"open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts": 4398,
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1322,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1344,
@@ -451,7 +451,7 @@
"src/shared/components/RequestLoggerV2.tsx": 1718,
"src/shared/constants/providers/apikey/gateways.ts": 1439,
"src/shared/services/cliRuntime.ts": 1296,
"src/sse/handlers/chat.ts": 2424,
"src/sse/handlers/chat.ts": 2434,
"src/sse/services/auth.ts": 3427,
"tests/unit/account-fallback-service.test.ts": 2453,
"tests/unit/provider-validation-specialty.test.ts": 4656
@@ -629,5 +629,6 @@
"_rebaseline_2026_06_30_v3842_release_chatgptweb_compression": "v3.8.42 cycle-close file-size reconciliation (DRIFT measured OK on each PR's base, stacked above frozen at the merge tip; fast-path PR->release/** does not run check:file-size). (1) open-sse/executors/chatgpt-web.ts 2870->3206 (+336 = #5531 portable SHA3-512 sentinel-PoW wiring with the native-vs-fallback digest path + #5536 GPT-5.5 Pro handoff branch; the pure Keccak-f[1600] fallback itself already lives in the separate leaf open-sse/utils/sha3-512.ts — the executor growth is the cohesive call-site/handoff logic, not extractable without hiding the sentinel chokepoint). (2) tests/unit/chatgpt-web.test.ts 2855->3159 (+304 = #5536 GPT-5.5 Pro handoff coverage; pair-file with its executor). (3) open-sse/services/compression/strategySelector.ts 997->1022 (+25 = #5527 T02 honest default-on pipeline inflation guard wiring at the existing finalizeStackedResult choke). All cohesive at existing chokepoints; covered by tests/unit/chatgpt-web-sha3-boringssl-5531.test.ts, chatgpt-web.test.ts (GPT-5.5 Pro), compression-pipeline-inflation-guard.test.ts.",
"open-sse/executors/chatgpt-web.ts": "3241",
"_rebaseline_2026_08_30_11771_vercel_gateway_passthrough": "PR #11771 adds passthroughModels: true (1 line) to the Vercel AI Gateway registry entry — no split available, single-line provider-config addition.",
"_relax_velocity_2026_08_30": "127 frozen line caps and cap/testCap raised by 20% (velocity phase; see quality-baseline.json _policy)."
"_relax_velocity_2026_08_30": "127 frozen line caps and cap/testCap raised by 20% (velocity phase; see quality-baseline.json _policy).",
"_rebaseline_2026_09_03_basereds_v3851_merged_growth": "Base-red drain 2026-09-03 (release/v3.8.51): four frozen files grew past their cap through already-merged PRs, all cohesive additions at the existing chokepoint with no extraction that would not hide the choke. (1) src/sse/handlers/chat.ts 2424->2434 (+10 = #12427 video-transcript log/memory redaction and its #12503 re-anchor follow-up: the redaction entry must be derived from the POST-guardrail payload at the single dispatch point, so the threading cannot move out of the handler). (2) open-sse/utils/stream.ts 3072->3078, (3) open-sse/services/accountFallback.ts 2422->2429 and (4) open-sse/executors/codex.ts 1499->1503 (+17 total = #12179 perf: hot-path regexes hoisted to module scope, bounded token caches and the quadratic-buffering fix — module-level constants and guard clauses inside the existing hot loops). Covered by the suites those PRs shipped; measured with npm run check:file-size on the pure tip."
}

View File

@@ -9,35 +9,36 @@
import fs from "node:fs";
import path from "node:path";
import * as yaml from "js-yaml";
import { isLocalOnlyDocPath, readRegexArray, readStringArray } from "./routeGuardConstants.mjs";
const ROOT = process.cwd();
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
const ROUTE_GUARD_PATH = path.join(ROOT, "src", "server", "authz", "routeGuard.ts");
function parseStringArray(match) {
if (!match) return [];
// Strip line comments before splitting — array entries in routeGuard.ts often
// carry inline `// T-XX:` annotations that would otherwise pollute the parsed tokens.
return match[1]
.replace(/\/\/[^\n]*/g, "")
.split(",")
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
.filter(Boolean);
}
const guardSrc = fs.readFileSync(ROUTE_GUARD_PATH, "utf-8");
const LOCAL_ONLY_PREFIXES = parseStringArray(
guardSrc.match(/export const LOCAL_ONLY_API_PREFIXES.*?=\s*\[([^\]]+)\]/s)
);
const ALWAYS_PROTECTED_PATHS = parseStringArray(
guardSrc.match(/export const ALWAYS_PROTECTED_API_PATHS.*?=\s*\[([^\]]+)\]/s)
);
// Both halves of isLocalOnlyPath(): the flat prefixes AND the regex patterns.
// Reading only the prefixes reported regex-gated and imported-constant routes
// as unprotected (see routeGuardConstants.mjs).
let LOCAL_ONLY_PREFIXES;
let LOCAL_ONLY_PATTERNS;
let ALWAYS_PROTECTED_PATHS;
try {
LOCAL_ONLY_PREFIXES = readStringArray(guardSrc, "LOCAL_ONLY_API_PREFIXES", { root: ROOT });
LOCAL_ONLY_PATTERNS = readRegexArray(guardSrc, "LOCAL_ONLY_API_PATTERNS");
ALWAYS_PROTECTED_PATHS = readStringArray(guardSrc, "ALWAYS_PROTECTED_API_PATHS", { root: ROOT });
} catch (err) {
console.error(`[openapi-security-tiers] FAIL — ${err.message}`);
process.exit(1);
}
if (LOCAL_ONLY_PREFIXES.length === 0 || ALWAYS_PROTECTED_PATHS.length === 0) {
console.error("[openapi-security-tiers] FAIL — could not parse routeGuard.ts constants");
process.exit(1);
}
const localOnlyGuards = { prefixes: LOCAL_ONLY_PREFIXES, patterns: LOCAL_ONLY_PATTERNS };
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const paths = raw.paths || {};
@@ -49,14 +50,11 @@ for (const [pathStr, methods] of Object.entries(paths)) {
if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue;
if (spec["x-loopback-only"] === true) {
const matchesPrefix = LOCAL_ONLY_PREFIXES.some((prefix) => {
const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
return pathStr === norm || pathStr.startsWith(norm + "/");
});
if (!matchesPrefix) {
if (!isLocalOnlyDocPath(pathStr, localOnlyGuards)) {
errors.push(
`${method.toUpperCase()} ${pathStr}: has x-loopback-only but is NOT covered by ` +
`LOCAL_ONLY_API_PREFIXES [${LOCAL_ONLY_PREFIXES.join(", ")}]`
`LOCAL_ONLY_API_PREFIXES [${LOCAL_ONLY_PREFIXES.join(", ")}] ` +
`nor by LOCAL_ONLY_API_PATTERNS [${LOCAL_ONLY_PATTERNS.join(", ")}]`
);
}
}
@@ -87,16 +85,12 @@ for (const [pathStr, methods] of Object.entries(paths)) {
const reverseWarnings = [];
for (const [pathStr, methods] of Object.entries(paths)) {
if (!methods || typeof methods !== "object") continue;
const fallsUnderLocalOnly = LOCAL_ONLY_PREFIXES.some((prefix) => {
const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
return pathStr === norm || pathStr.startsWith(norm + "/");
});
if (!fallsUnderLocalOnly) continue;
if (!isLocalOnlyDocPath(pathStr, localOnlyGuards)) continue;
for (const [method, spec] of Object.entries(methods)) {
if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue;
if (spec["x-loopback-only"] !== true) {
reverseWarnings.push(
`${method.toUpperCase()} ${pathStr}: falls under LOCAL_ONLY_API_PREFIXES ` +
`${method.toUpperCase()} ${pathStr}: is LOCAL_ONLY per routeGuard ` +
`but is missing x-loopback-only: true annotation`
);
}

View File

@@ -0,0 +1,187 @@
// scripts/check/routeGuardConstants.mjs
// Shared reader for the compile-time constants in src/server/authz/routeGuard.ts.
//
// The security-tier gate cannot import the module itself (routeGuard pulls the
// server runtime — runtimeSettings → localDb → ioredis — and the gate runs on
// plain `node`), so it re-reads the constants from source. That text parse has
// to mirror `isLocalOnlyPath()` exactly, which means BOTH halves of the
// predicate:
//
// LOCAL_ONLY_API_PREFIXES.some(...) || LOCAL_ONLY_API_PATTERNS.some(...)
//
// Reading only the prefix array made every route that is gated by a regex
// (e.g. /api/providers/volcengine-plan/connect/*) or by an imported constant
// (VNC_ROUTE_PREFIX) look unprotected, and the gate then demanded the removal
// of a CORRECT `x-loopback-only` annotation — a false positive that pushes the
// fix in the unsafe direction. Unresolvable tokens now throw instead of
// silently degrading into a literal.
import fs from "node:fs";
import path from "node:path";
/**
* Strip `//` line comments without eating regex literals: a `//` preceded by a
* backslash (`\/\/`) or a colon (`https://`) is content, not a comment.
*/
export function stripLineComments(text) {
return text
.split("\n")
.map((line) => {
for (let i = 0; i < line.length - 1; i++) {
if (line[i] !== "/" || line[i + 1] !== "/") continue;
const prev = i > 0 ? line[i - 1] : "";
if (prev === "\\" || prev === ":") continue;
return line.slice(0, i);
}
return line;
})
.join("\n");
}
/**
* Entries of the array literal assigned to `constName`.
*
* Hand-rolled instead of a `\[([^\]]+)\]` capture because the pattern arrays
* hold regex literals whose character classes (`[^/]`) contain the very
* brackets and commas a naive capture/split would break on.
*/
export function parseArrayTokens(src, constName) {
const decl = src.search(new RegExp(`export const ${constName}\\b`));
if (decl < 0) return [];
const open = src.indexOf("[", decl);
if (open < 0) return [];
let depth = 0;
let end = -1;
for (let i = open; i < src.length; i++) {
const c = src[i];
if (c === "[") depth++;
else if (c === "]") {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
if (end < 0) return [];
const body = stripLineComments(src.slice(open + 1, end));
const tokens = [];
let current = "";
let nesting = 0;
for (const c of body) {
if (c === "," && nesting === 0) {
tokens.push(current);
current = "";
continue;
}
if (c === "[" || c === "(" || c === "{") nesting++;
else if (c === "]" || c === ")" || c === "}") nesting--;
current += c;
}
tokens.push(current);
return tokens.map((t) => t.trim()).filter(Boolean);
}
/** True for a token that is already a quoted string literal. */
function isStringLiteral(token) {
return /^["'`]/.test(token);
}
function unquote(token) {
return token.replace(/^["'`]|["'`]$/g, "");
}
/**
* Resolve a bare identifier used inside a routeGuard array (e.g. VNC_ROUTE_PREFIX)
* to its string value, following a local `const` or a named import. Returns null
* when it cannot be resolved — callers must treat that as fatal.
*/
export function resolveIdentifier(name, { guardSrc, root, readFile = readFileIfExists } = {}) {
const localDef = guardSrc.match(
new RegExp(`(?:export\\s+)?const\\s+${name}\\b[^=]*=\\s*["'\`]([^"'\`]+)["'\`]`)
);
if (localDef) return localDef[1];
const imported = guardSrc.match(
new RegExp(`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*["']([^"']+)["']`, "s")
);
if (!imported) return null;
for (const candidate of moduleCandidates(imported[1], root)) {
const src = readFile(candidate);
if (!src) continue;
const def = src.match(
new RegExp(`export\\s+const\\s+${name}\\b[^=]*=\\s*["'\`]([^"'\`]+)["'\`]`)
);
if (def) return def[1];
}
return null;
}
/** Candidate on-disk paths for an import specifier (`@/x` → src/x, plus relatives). */
export function moduleCandidates(spec, root) {
const base = spec.startsWith("@/")
? path.join(root, "src", spec.slice(2))
: spec.startsWith(".")
? path.join(root, "src", "server", "authz", spec)
: null;
if (!base) return [];
return [`${base}.ts`, `${base}.tsx`, path.join(base, "index.ts")];
}
function readFileIfExists(p) {
try {
return fs.readFileSync(p, "utf-8");
} catch {
return null;
}
}
/** String entries of a prefix array, with identifiers resolved. Throws if any cannot be. */
export function readStringArray(src, constName, opts = {}) {
const tokens = parseArrayTokens(src, constName);
return tokens.map((token) => {
if (isStringLiteral(token)) return unquote(token);
const resolved = resolveIdentifier(token, { guardSrc: src, ...opts });
if (resolved === null) {
throw new Error(
`[routeGuardConstants] could not resolve \`${token}\` used in ${constName}. ` +
`Add a resolvable \`export const ${token} = "…"\` or inline the literal — ` +
`leaving it unresolved would make the gate report protected routes as open.`
);
}
return resolved;
});
}
/** RegExp entries of a pattern array (regex literals only). */
export function readRegexArray(src, constName) {
return parseArrayTokens(src, constName)
.filter((token) => token.startsWith("/"))
.map((token) => {
const body = token.match(/^\/(.*)\/([a-z]*)$/s);
if (!body)
throw new Error(`[routeGuardConstants] unparsable regex in ${constName}: ${token}`);
return new RegExp(body[1], body[2]);
});
}
/**
* OpenAPI templates use `{param}`; the runtime sees a concrete segment. Swap the
* placeholders for a segment without slashes so `[^/]+`-style patterns match.
*/
export function concreteFromTemplate(pathStr) {
return pathStr.replace(/\{[^}]*\}/g, "_param_");
}
/** Mirror of routeGuard.isLocalOnlyPath() for a documented (templated) path. */
export function isLocalOnlyDocPath(pathStr, { prefixes = [], patterns = [] } = {}) {
const concrete = concreteFromTemplate(pathStr);
const underPrefix = prefixes.some((prefix) => {
const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
return pathStr === norm || pathStr.startsWith(`${norm}/`);
});
return underPrefix || patterns.some((re) => re.test(concrete) || re.test(pathStr));
}

View File

@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { test } from "node:test";
import {
concreteFromTemplate,
isLocalOnlyDocPath,
parseArrayTokens,
readRegexArray,
readStringArray,
resolveIdentifier,
stripLineComments,
} from "../../../scripts/check/routeGuardConstants.mjs";
const ROOT = path.resolve(import.meta.dirname, "..", "..", "..");
const GUARD_SRC = fs.readFileSync(
path.join(ROOT, "src", "server", "authz", "routeGuard.ts"),
"utf-8"
);
test("parseArrayTokens survives regex literals whose char classes hold ] and ,", () => {
const src = [
"export const PATTERNS: ReadonlyArray<RegExp> = [",
" /^\\/api\\/providers\\/[^/]+\\/login\\/?$/, // inline note",
" /^\\/api\\/x\\/[a-z]{1,3}$/,",
"];",
].join("\n");
assert.deepEqual(parseArrayTokens(src, "PATTERNS"), [
"/^\\/api\\/providers\\/[^/]+\\/login\\/?$/",
"/^\\/api\\/x\\/[a-z]{1,3}$/",
]);
});
test("stripLineComments keeps escaped slashes and URLs, drops real comments", () => {
assert.equal(stripLineComments("/a\\/\\/b/, // note"), "/a\\/\\/b/, ");
assert.equal(stripLineComments('"https://x", // note'), '"https://x", ');
assert.equal(stripLineComments("// whole line"), "");
});
test("resolveIdentifier follows a named import to its exported literal", () => {
const guardSrc = 'import { VNC_ROUTE_PREFIX } from "@/lib/vncSession/manifest";';
const readFile = (p: string) =>
p.endsWith(path.join("src", "lib", "vncSession", "manifest.ts"))
? 'export const VNC_ROUTE_PREFIX = "/api/vnc-session";'
: null;
assert.equal(
resolveIdentifier("VNC_ROUTE_PREFIX", { guardSrc, root: ROOT, readFile }),
"/api/vnc-session"
);
assert.equal(resolveIdentifier("MISSING", { guardSrc, root: ROOT, readFile }), null);
});
test("readStringArray throws instead of degrading an unresolvable token into a literal", () => {
const src = 'export const P: ReadonlyArray<string> = ["/api/a", SOME_CONST];';
assert.throws(
() => readStringArray(src, "P", { root: ROOT, readFile: () => null }),
/SOME_CONST/
);
});
test("concreteFromTemplate replaces OpenAPI placeholders with a slash-free segment", () => {
assert.equal(concreteFromTemplate("/api/x/{id}/y"), "/api/x/_param_/y");
});
test("isLocalOnlyDocPath honours prefixes AND patterns, and stays closed otherwise", () => {
const guards = {
prefixes: ["/api/mcp/", "/api/vnc-session"],
patterns: [/^\/api\/providers\/[^/]+\/login\/?$/],
};
assert.equal(isLocalOnlyDocPath("/api/vnc-session/{params}", guards), true);
assert.equal(isLocalOnlyDocPath("/api/mcp/tools", guards), true);
assert.equal(isLocalOnlyDocPath("/api/providers/{provider}/login", guards), true);
assert.equal(isLocalOnlyDocPath("/api/providers/{provider}/refresh", guards), false);
assert.equal(isLocalOnlyDocPath("/v1/chat/completions", guards), false);
});
// Regression guard for the real file: reading only LOCAL_ONLY_API_PREFIXES made
// the gate report the VNC (imported constant) and volcengine-plan (regex) routes
// as unprotected, and demand the removal of a correct x-loopback-only annotation.
test("the real routeGuard constants cover the imported-constant and regex-gated routes", () => {
const guards = {
prefixes: readStringArray(GUARD_SRC, "LOCAL_ONLY_API_PREFIXES", { root: ROOT }),
patterns: readRegexArray(GUARD_SRC, "LOCAL_ONLY_API_PATTERNS"),
};
assert.ok(guards.prefixes.includes("/api/vnc-session"), "VNC_ROUTE_PREFIX must resolve");
assert.ok(guards.patterns.length > 0, "pattern list must be parsed");
for (const documented of [
"/api/vnc-session",
"/api/vnc-session/{params}",
"/api/providers/volcengine-plan/connect/{sessionId}/status",
"/api/providers/volcengine-plan/connect/{sessionId}/resend",
]) {
assert.equal(isLocalOnlyDocPath(documented, guards), true, `${documented} must be LOCAL_ONLY`);
}
// Control: a deliberately remote-reachable provider route stays open.
assert.equal(isLocalOnlyDocPath("/api/providers/{id}/refresh", guards), false);
});