Files
gbrain/test/takes-mcp-allowlist.serial.test.ts
T
f7f8512b14 v0.42.28.0 fix(engine): batch inserts use jsonb_to_recordset, not text[] array literals (#1861) (#1927)
* fix(engine): batch inserts use jsonb_to_recordset, not text[] array literals (#1861)

addLinksBatch/addTimelineEntriesBatch/addTakesBatch passed free text through
unnest(${arr}::text[]); postgres.js serialized it to a Postgres text[] literal
that array_in rejected ("malformed array literal") on calendar/Zoom context,
aborting the whole `extract links --stale` sweep. Bind the batch as one JSONB
doc via jsonb_to_recordset(($1::jsonb)->'rows') through the audited
executeRawJsonb contract instead. Shared row builders (src/core/batch-rows.ts)
keep both engines byte-identical; NUL is stripped only from free-text body
fields (context/summary/detail/claim), while identity/security fields
(slugs/source_ids/holder/kind/dates) still reject NUL. addTakesBatch is now
batchRetry-wrapped ('addTakesBatch' audit site) and its BrainEngine signature
takes BatchOpts. Scalar addLink context is NUL-stripped too.

Regression tests on both engines: PGLite always-on poison/NUL/parity suite +
DATABASE_URL-gated Postgres lane (the engine that actually crashed).

* test: make "no Anthropic key" tests hermetic via withoutAnthropicKey

hasAnthropicKey() reads both ANTHROPIC_API_KEY and ~/.gbrain config; tests that
only deleted the env var fired a real LLM call on configured machines (warning
flipped NO_ANTHROPIC_API_KEY -> LLM_OUTPUT_NOT_JSON). New test/helpers/no-anthropic-key.ts
neutralizes both sources (env + GBRAIN_HOME temp dir) for the duration of the call.
Refactors the five no-key tests in think-pipeline + takes-mcp-allowlist to use it,
including two that previously passed only by luck of the live LLM output.

* chore: docs + version bump (v0.42.28.0)

KEY_FILES.md/RETRIEVAL.md describe the jsonb_to_recordset batch path; TODOS.md
files the #1861 follow-ups (element-isolation, remaining ::text[] sites, shared
SQL-string hoist, batch-insert edge-case tests). CHANGELOG + VERSION + package.json
to 0.42.28.0.

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

* docs: sync TESTING.md batch-insert references for v0.42.28.0

The #1861 fix migrated links/timeline/takes batch inserts from
unnest(::text[]) to jsonb_to_recordset. Update the stale "postgres-js
unnest() binding" note and add the two new poison-regression test files
(test/links-timeline-jsonb-poison.test.ts PGLite half,
test/e2e/jsonb-batch-poison-postgres.test.ts Postgres lane) to the inventory.

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

* fix(sql-query): reject top-level array jsonb params in executeRawJsonb (#1861 P2a)

The "no top-level array" rule was only a comment. A bare JS array bound to a
$N::jsonb position can serialize as a Postgres array literal (not jsonb) through
postgres.js, silently re-entering the "malformed array literal" class #1861 just
escaped. executeRawJsonb now throws a clear error steering callers to the
{ rows: [...] } object wrapper. Verified breaks zero call sites (all pass objects
or null). Codex adversarial P2a; batch-size enforcement (P2b) filed as a TODO.

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-07 08:05:34 -07:00

241 lines
11 KiB
TypeScript

/**
* v0.28: integration test that proves the per-token takes-holder allow-list
* filters server-side through the dispatch layer (Codex P0 #3 fix
* verification). PGLite-only; no DATABASE_URL required.
*
* Threads:
* 1. Auth wires `permissions.takes_holders` from `access_tokens` → AuthResult
* 2. HTTP transport passes `auth.takesHoldersAllowList` to dispatchToolCall
* 3. dispatch.ts threads it into OperationContext.takesHoldersAllowList
* 4. takes_list / takes_search ops pass it to engine.listTakes / .searchTakes
* 5. engine SQL applies `AND holder = ANY($allowList)`
*
* This test exercises step 3-5 directly through dispatchToolCall.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { withoutAnthropicKey } from './helpers/no-anthropic-key.ts';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { dispatchToolCall } from '../src/mcp/dispatch.ts';
import { TAKES_FENCE_BEGIN, TAKES_FENCE_END } from '../src/core/takes-fence.ts';
let engine: PGLiteEngine;
let alicePageId: number;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
const alice = await engine.putPage('people/alice-example', {
title: 'Alice', type: 'person', compiled_truth: '## Takes\n',
});
alicePageId = alice.id;
// Seed three takes by three holders. Public fact, garry's bet, brain's hunch.
await engine.addTakesBatch([
{ page_id: alicePageId, row_num: 1, claim: 'CEO of Acme', kind: 'fact', holder: 'world', weight: 1.0 },
{ page_id: alicePageId, row_num: 2, claim: 'Strong technical founder', kind: 'take', holder: 'garry', weight: 0.85 },
{ page_id: alicePageId, row_num: 3, claim: 'Seemed burned out in last OH', kind: 'hunch', holder: 'brain', weight: 0.4 },
]);
});
afterAll(async () => {
await engine.disconnect();
});
function parseResult(result: { content: Array<{ text: string }>; isError?: boolean }): unknown {
expect(result.isError).toBeFalsy();
return JSON.parse(result.content[0].text);
}
describe('per-token takes-holder allow-list — takes_list', () => {
test('default (no allow-list, local CLI) returns all holders', async () => {
const result = await dispatchToolCall(engine, 'takes_list', { page_slug: 'people/alice-example' }, {
remote: false, // Local CLI: no allow-list applied.
});
const takes = parseResult(result) as Array<{ holder: string; claim: string }>;
const holders = takes.map(t => t.holder).sort();
expect(holders).toEqual(['brain', 'garry', 'world']);
});
test('allow-list ["world"] (default-deny token) returns ONLY world holders', async () => {
const result = await dispatchToolCall(engine, 'takes_list', { page_slug: 'people/alice-example' }, {
remote: true,
takesHoldersAllowList: ['world'],
});
const takes = parseResult(result) as Array<{ holder: string; claim: string }>;
expect(takes).toHaveLength(1);
expect(takes[0].holder).toBe('world');
expect(takes[0].claim).toBe('CEO of Acme');
});
test('allow-list ["world", "garry"] returns world + garry, hides brain hunches', async () => {
const result = await dispatchToolCall(engine, 'takes_list', { page_slug: 'people/alice-example' }, {
remote: true,
takesHoldersAllowList: ['world', 'garry'],
});
const takes = parseResult(result) as Array<{ holder: string }>;
const holders = takes.map(t => t.holder).sort();
expect(holders).toEqual(['garry', 'world']);
});
test('allow-list with no overlap returns empty (no fallback to default)', async () => {
const result = await dispatchToolCall(engine, 'takes_list', { page_slug: 'people/alice-example' }, {
remote: true,
takesHoldersAllowList: ['nonexistent-holder'],
});
const takes = parseResult(result) as unknown[];
expect(takes).toHaveLength(0);
});
});
describe('per-token takes-holder allow-list — takes_search', () => {
test('allow-list ["world"] filters search hits to public claims only', async () => {
const result = await dispatchToolCall(engine, 'takes_search', { query: 'founder' }, {
remote: true,
takesHoldersAllowList: ['world'],
});
const hits = parseResult(result) as Array<{ holder: string; claim: string }>;
expect(hits.every(h => h.holder === 'world')).toBe(true);
});
test('no allow-list (local) sees all holders in search', async () => {
const result = await dispatchToolCall(engine, 'takes_search', { query: 'founder' }, {
remote: false,
});
const hits = parseResult(result) as Array<{ holder: string }>;
// 'Strong technical founder' (garry) should match
expect(hits.some(h => h.holder === 'garry')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Page-body channel: get_page / get_versions must respect the same allow-list.
// Take rows are stored in TWO places per the extract-takes contract: the
// `takes` table (filtered by the SQL `holder = ANY($allowList)` clause) and
// inline in `pages.compiled_truth` between TAKES_FENCE markers as a markdown
// table. Without a strip on the page-CRUD path, a `world`-only token reading
// `get_page <slug>` recovers every non-`world` claim verbatim from the body.
// ---------------------------------------------------------------------------
describe('per-token takes-holder allow-list — get_page body channel', () => {
const SLUG = 'people/bob-example';
const FENCE_BODY =
'## Takes\n\n' +
`${TAKES_FENCE_BEGIN}\n` +
'\n| # | claim | kind | who | weight | since | source |\n' +
'|---|---|---|---|---|---|---|\n' +
'| 1 | CEO of Widget | fact | world | 1.0 | 2017-01 | Crustdata |\n' +
'| 2 | Strong technical founder | take | garry | 0.85 | 2026-04-29 | OH |\n' +
'| 3 | Seemed burned out in last OH | hunch | brain | 0.4 | 2026-05-01 | private |\n\n' +
`${TAKES_FENCE_END}\n` +
'\nFooter content stays.\n';
beforeAll(async () => {
await engine.putPage(SLUG, { title: 'Bob', type: 'person', compiled_truth: FENCE_BODY });
});
test('remote token with allow-list strips fence from compiled_truth', async () => {
const result = await dispatchToolCall(engine, 'get_page', { slug: SLUG }, {
remote: true,
takesHoldersAllowList: ['world'],
});
const page = parseResult(result) as { compiled_truth: string };
expect(page.compiled_truth).not.toContain(TAKES_FENCE_BEGIN);
expect(page.compiled_truth).not.toContain(TAKES_FENCE_END);
expect(page.compiled_truth).not.toContain('Strong technical founder');
expect(page.compiled_truth).not.toContain('Seemed burned out');
expect(page.compiled_truth).not.toContain('| garry |');
expect(page.compiled_truth).not.toContain('| brain |');
// Surrounding body kept intact.
expect(page.compiled_truth).toContain('Footer content stays.');
});
test('local CLI (no allow-list) preserves the fence — backwards compatibility', async () => {
const result = await dispatchToolCall(engine, 'get_page', { slug: SLUG }, {
remote: false,
});
const page = parseResult(result) as { compiled_truth: string };
expect(page.compiled_truth).toContain(TAKES_FENCE_BEGIN);
expect(page.compiled_truth).toContain('Seemed burned out');
});
test('fuzzy resolution path also strips for remote token', async () => {
const result = await dispatchToolCall(engine, 'get_page', { slug: 'people/bob-example', fuzzy: true }, {
remote: true,
takesHoldersAllowList: ['world', 'garry'],
});
const page = parseResult(result) as { compiled_truth: string };
// Allow-list does not yet re-render filtered rows; whole fence is stripped.
// Pinned so future re-rendering work is an additive change, not a silent
// semantic flip.
expect(page.compiled_truth).not.toContain(TAKES_FENCE_BEGIN);
expect(page.compiled_truth).not.toContain('Strong technical founder');
});
});
describe('per-token takes-holder allow-list — get_versions body channel', () => {
const SLUG = 'people/carol-example';
const FENCE_BODY =
`${TAKES_FENCE_BEGIN}\n| # | claim | kind | who |\n|---|---|---|---|\n| 1 | private hunch | hunch | brain |\n${TAKES_FENCE_END}\n`;
beforeAll(async () => {
await engine.putPage(SLUG, { title: 'Carol', type: 'person', compiled_truth: FENCE_BODY });
await engine.createVersion(SLUG); // snapshot now has the fence
});
test('remote token with allow-list strips fence from every snapshot', async () => {
const result = await dispatchToolCall(engine, 'get_versions', { slug: SLUG }, {
remote: true,
takesHoldersAllowList: ['world'],
});
const versions = parseResult(result) as Array<{ compiled_truth: string }>;
expect(versions.length).toBeGreaterThan(0);
for (const v of versions) {
expect(v.compiled_truth).not.toContain(TAKES_FENCE_BEGIN);
expect(v.compiled_truth).not.toContain('private hunch');
}
});
test('local CLI sees historical takes in snapshots', async () => {
const result = await dispatchToolCall(engine, 'get_versions', { slug: SLUG }, {
remote: false,
});
const versions = parseResult(result) as Array<{ compiled_truth: string }>;
expect(versions.some(v => v.compiled_truth.includes('private hunch'))).toBe(true);
});
});
describe('think op — read-only on remote callers (Lane D landed)', () => {
test('remote save/take is forced read-only via remote_persisted_blocked flag', async () => {
// Hermetic no-key: neutralize BOTH env var AND ~/.gbrain config key, else a
// configured machine fires a real LLM call and the warning flips to
// LLM_OUTPUT_NOT_JSON. runThink then returns gather-only + NO_ANTHROPIC_API_KEY.
const result = await withoutAnthropicKey(() => dispatchToolCall(engine, 'think', { question: 'q', save: true, take: true }, {
remote: true,
takesHoldersAllowList: ['world', 'garry', 'brain'],
}));
const env = parseResult(result) as {
remote_persisted_blocked: boolean;
saved_slug: string | null;
warnings: string[];
};
// Codex P1 #7: remote save/take is silently disabled.
expect(env.remote_persisted_blocked).toBe(true);
expect(env.saved_slug).toBeNull();
// Without API key, gather succeeds but synthesis is skipped.
expect(env.warnings).toContain('NO_ANTHROPIC_API_KEY');
});
test('local-CLI think runs full pipeline (gather-only without API key)', async () => {
const result = await withoutAnthropicKey(() => dispatchToolCall(engine, 'think', { question: 'q', save: true }, {
remote: false,
}));
const env = parseResult(result) as {
warnings: string[];
remote_persisted_blocked: boolean;
};
expect(env.remote_persisted_blocked).toBe(false);
// Without API key, returns gather-only + warning. With key, would actually synthesize.
expect(env.warnings).toContain('NO_ANTHROPIC_API_KEY');
});
});