Files
gbrain/scripts/check-jsonb-params.mjs
814258dda6 v0.42.53.0 fix(sync,db): #2339 op_checkpoints jsonb double-encode + bug-class sweep + CI guard (#2375)
* fix(sync): op_checkpoints pin write double-encodes jsonb — every sync aborts (#2339)

recordCompleted bound JSON.stringify(array) to a $3::jsonb param via postgres.js
.unsafe(), double-encoding it into a jsonb string scalar that violates the v119
op_checkpoints_completed_keys_array CHECK — aborting every multi-source sync on
real Postgres at the first checkpoint write. PGLite parses the string silently,
which is why unit tests stayed green and it shipped. Cast through $3::text::jsonb
so the text->jsonb cast parses a genuine array. Adds a DATABASE_URL-gated parity
test + a dedicated Postgres CI job so the guard can never silently skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(db): sweep positional jsonb double-encode sites + AST CI guard (#2324)

Every executeRaw/.unsafe site that bound JSON.stringify(x) to a bare positional
jsonb cast double-encodes on real Postgres (same class as #2339). Sweep them all
to the text::jsonb form across query-cache, sources-ops, llm-base,
calibration-profile, impact-capture, subagent, receipt-write, traversal-cache,
symbol-resolver, and the agent/sources commands. Adds scripts/check-jsonb-params.mjs
(AST-lite scanner for the positional form the legacy template grep misses, incl.
generic-typed calls), wired into check-jsonb-pattern.sh, with a self-test. PGLite's
native db.query is not scanned — it parses text to jsonb natively, so the bug can't
occur there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(search,eval): alias-hop injected results carry page_id (contradiction-probe crash)

applyAliasHop injected synthetic SearchResults without page_id (the `as
SearchResult` cast hid the missing field), so listActiveTakesForPages bound
undefined/NaN into ANY($1::int[]) and crashed the whole contradiction probe on
real Postgres. Stamp page_id=page.id at the injection site and add a finite-id
filter in generateIntraPagePairs as a defensive backstop (mirrors hybrid.ts:63).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(engines): positional jsonb binding rule (text::jsonb vs the double-encode trap)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* v0.42.53.0 fix(sync,db): #2339 op_checkpoints jsonb double-encode + bug-class sweep + CI guard

Bumps VERSION + package.json to 0.42.53.0, adds the CHANGELOG entry, and
regenerates llms-full.txt. Ships the #2339 sync-abort hotfix, the repo-wide
positional jsonb double-encode sweep, the alias-hop contradiction-probe crash
fix, and the new positional-form CI guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: post-ship sync — jsonb invariant now covers the positional form + new guard

CLAUDE.md JSONB invariant + KEY_FILES (sql-query, check-jsonb-pattern, op-checkpoint)
now describe the #2339 positional double-encode class, the $N::text::jsonb fix, and
the new check-jsonb-params.mjs guard. Regenerates llms-full.txt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 06:05:16 -07:00

129 lines
5.9 KiB
JavaScript

#!/usr/bin/env node
/**
* CI guard for the POSITIONAL jsonb double-encode footgun (#2339 / #2324 class).
*
* The legacy scripts/check-jsonb-pattern.sh only catches the template-tag form
* (`${JSON.stringify(x)}::jsonb`). It MISSES the positional-param form:
*
* engine.executeRaw(`... $3::jsonb ...`, [a, b, JSON.stringify(x)])
*
* Under postgres.js `.unsafe(sql, params)` a JS STRING bound to a `$N::jsonb`
* param double-encodes — the text→jsonb cast wraps the already-JSON string into a
* jsonb *string scalar*. PGLite parses it silently, so the bug is invisible in
* unit tests and only bites on real Postgres (it aborted every sync in #2339).
*
* This scanner flags any executeRaw / executeRawDirect / .unsafe(...) call whose
* balanced argument span contains BOTH a positional `$N::jsonb` cast
* (NOT `$N::text::jsonb`, NOT `$N::text[]`) AND a `JSON.stringify(` — the exact
* double-encode shape. It is heuristic by design (whole-span correlation); the
* real backstop is the DATABASE_URL-gated e2e parity test. Keep both.
*
* Allowed forms (NOT flagged):
* - `$N::text::jsonb` + JSON.stringify (the fix: binds as text, cast parses it)
* - `$N::text[]` (the unnest path — arrays bind fine)
* - executeRawJsonb(...) (passes raw objects, not strings)
* - sql.json(x) (postgres.js native jsonb serializer)
* - a `jsonb-guard-ok` comment anywhere in the call span (explicit opt-out)
*
* Exit 0 = clean, 1 = violations found. Runs under node or bun.
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
// Default scan roots; overridable via argv so the guard's own test can point it
// at a fixture dir (e.g. `node check-jsonb-params.mjs /tmp/fixtures`).
const ROOTS = process.argv.slice(2).length > 0 ? process.argv.slice(2) : ['src', 'scripts'];
// executeRawDirect must precede executeRaw in the alternation so the longer name
// wins; executeRawJsonb is deliberately excluded (it passes objects). The
// optional `<...>` handles generic type args, e.g. `executeRaw<{ id: string }>(`.
//
// Only the postgres.js raw path is scanned (executeRaw/executeRawDirect/.unsafe).
// PGLite's native `this.db.query(...)` is intentionally NOT matched: its driver
// parses a text→jsonb cast natively, so the double-encode that bites postgres.js
// `.unsafe()` does not occur there (the `pglite-masks` invariant). The engine
// parity test pins that the resulting jsonb_typeof agrees across both engines.
const CALL_RE = /\b(executeRawDirect|executeRaw|unsafe)\s*(?:<[^>;]*>)?\s*\(/g;
/** Walk from the '(' at openIdx and return [start,end) of the balanced span,
* respecting strings, template literals, and comments. */
function findSpan(src, openIdx) {
let depth = 0;
let mode = 'code'; // code | line | block | sq | dq | tpl
for (let i = openIdx; i < src.length; i++) {
const c = src[i];
const n = src[i + 1];
if (mode === 'line') { if (c === '\n') mode = 'code'; continue; }
if (mode === 'block') { if (c === '*' && n === '/') { mode = 'code'; i++; } continue; }
if (mode === 'sq') { if (c === '\\') { i++; continue; } if (c === "'") mode = 'code'; continue; }
if (mode === 'dq') { if (c === '\\') { i++; continue; } if (c === '"') mode = 'code'; continue; }
if (mode === 'tpl') { if (c === '\\') { i++; continue; } if (c === '`') mode = 'code'; continue; }
// mode === 'code'
if (c === '/' && n === '/') { mode = 'line'; i++; continue; }
if (c === '/' && n === '*') { mode = 'block'; i++; continue; }
if (c === "'") { mode = 'sq'; continue; }
if (c === '"') { mode = 'dq'; continue; }
if (c === '`') { mode = 'tpl'; continue; }
if (c === '(') depth++;
else if (c === ')') { depth--; if (depth === 0) return [openIdx + 1, i]; }
}
return [openIdx + 1, src.length];
}
/** Blank out comments so a commented-out example doesn't trip the JSON.stringify probe. */
function stripComments(s) {
return s.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
}
const violations = [];
function scanFile(file) {
const src = readFileSync(file, 'utf8');
CALL_RE.lastIndex = 0;
let m;
while ((m = CALL_RE.exec(src))) {
const method = m[1];
const openIdx = m.index + m[0].length - 1; // index of the '('
const [s, e] = findSpan(src, openIdx);
const span = src.slice(s, e);
if (/jsonb-guard-ok/.test(span)) continue;
if (!/JSON\.stringify\s*\(/.test(stripComments(span))) continue;
// A positional `$N::jsonb` that is NOT `$N::text::jsonb`.
const jsonbRe = /\$\d+\s*::\s*jsonb\b/g;
let j;
let badText = '';
while ((j = jsonbRe.exec(span))) {
const pre = span.slice(Math.max(0, j.index - 12), j.index);
if (/::\s*text\s*$/.test(pre)) continue; // $N::text::jsonb is the fix — allowed
badText = j[0].replace(/\s+/g, '');
break;
}
if (!badText) continue;
const line = src.slice(0, s).split('\n').length;
violations.push(
`${file}:${line} ${method}(...) binds JSON.stringify into ${badText} — use $N::text::jsonb or pass a raw object (executeRawJsonb / sql.json)`,
);
}
}
function walk(dir) {
let ents;
try { ents = readdirSync(dir); } catch { return; }
for (const ent of ents) {
if (ent === 'node_modules') continue;
const p = join(dir, ent);
const st = statSync(p);
if (st.isDirectory()) walk(p);
else if (p.endsWith('.ts') && !p.endsWith('.test.ts')) scanFile(p);
}
}
for (const root of ROOTS) walk(root);
if (violations.length) {
console.error('JSONB positional double-encode violations (#2339 class):\n');
for (const v of violations) console.error(' ' + v);
console.error(`\n${violations.length} violation(s). Fix: bind through $N::text::jsonb (keeping JSON.stringify), or pass a raw object via executeRawJsonb / sql.json. See docs/ENGINES.md.`);
process.exit(1);
}
console.log('check-jsonb-params: clean (no positional $N::jsonb + JSON.stringify double-encodes)');