mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
Three fixes in the same leak class (a remote caller reading or writing outside its granted sources), for multi-source / multi-tenant brains: 1. dispatchToolCall now refuses remote calls that arrive without a resolved sourceId (missing_source_scope error envelope) instead of silently falling back to the shared 'default' source. Every shipped transport already passes sourceId explicitly (serve-http from the OAuth client row, http-transport from the legacy token grant, stdio from GBRAIN_SOURCE); reaching the fallback remotely always meant a programmatic caller skipped scope resolution — the bug class behind #1924 / #1371. Trusted local callers (remote === false) keep the historical fallback. Direct-dispatch tests updated to carry an explicit sourceId, matching the real transport contract. 2. log_ingest threads ctx.sourceId (same pattern as get_chunks / get_page), so ingest events are attributed to the caller's source instead of piling into 'default'. Engines already accept entry.source_id (v0.31.2). 3. get_ingest_log is source-scoped for remote callers via the linkReadScopeOpts collapse rule (scalar grant → [scalar]; federated grant → granted array); it previously returned the whole brain's ingest log to any read-scoped remote client, and ingest summaries can carry another source's private context. Trusted local callers keep the whole-brain view. Tests: dispatch guard (refuse remote-without-source, keep local fallback, guard ordering after op lookup) and end-to-end ingest-log attribution + scoping over the real dispatch path, on PGLite.
142 lines
6.1 KiB
TypeScript
142 lines
6.1 KiB
TypeScript
/**
|
|
* v0.28 e2e: per-token takes_holders allow-list, end-to-end through the
|
|
* access_tokens.permissions JSONB column. Closes Codex P0 #3 verification.
|
|
*
|
|
* The HTTP transport's validateToken reads permissions.takes_holders from
|
|
* the access_tokens row and threads it into the dispatch context. This
|
|
* test exercises that path against real Postgres without booting the
|
|
* full Bun.serve transport (the auth probe is the load-bearing piece).
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
import { setupDB, teardownDB, hasDatabase, getEngine } from './helpers.ts';
|
|
import { dispatchToolCall } from '../../src/mcp/dispatch.ts';
|
|
|
|
const RUN = hasDatabase();
|
|
const d = RUN ? describe : describe.skip;
|
|
|
|
function hashToken(token: string): string {
|
|
return createHash('sha256').update(token).digest('hex');
|
|
}
|
|
|
|
function generateToken(): string {
|
|
return 'gbrain_' + randomBytes(32).toString('hex');
|
|
}
|
|
|
|
let alicePageId: number;
|
|
|
|
beforeAll(async () => {
|
|
if (!RUN) return;
|
|
const engine = await setupDB();
|
|
const alice = await engine.putPage('people/alice-example', {
|
|
title: 'Alice', type: 'person', compiled_truth: '## Takes\n',
|
|
});
|
|
alicePageId = alice.id;
|
|
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: 'Burned out signal', kind: 'hunch', holder: 'brain', weight: 0.4 },
|
|
]);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (!RUN) return;
|
|
await teardownDB();
|
|
});
|
|
|
|
d('access_tokens.permissions.takes_holders end-to-end', () => {
|
|
test('newly-created token defaults to {takes_holders: ["world"]} via migration v32 backfill', async () => {
|
|
const engine = getEngine();
|
|
const token = generateToken();
|
|
const hash = hashToken(token);
|
|
await engine.executeRaw(
|
|
`INSERT INTO access_tokens (name, token_hash) VALUES ($1, $2)`,
|
|
[`tok-default-${Date.now()}`, hash],
|
|
);
|
|
const rows = await engine.executeRaw<{ permissions: { takes_holders?: unknown } }>(
|
|
`SELECT permissions FROM access_tokens WHERE token_hash = $1`,
|
|
[hash],
|
|
);
|
|
expect(rows[0]?.permissions).toEqual({ takes_holders: ['world'] });
|
|
});
|
|
|
|
test('explicit ["world","garry"] permission filters dispatch responses correctly', async () => {
|
|
const engine = getEngine();
|
|
const token = generateToken();
|
|
const hash = hashToken(token);
|
|
await engine.executeRaw(
|
|
`INSERT INTO access_tokens (name, token_hash, permissions) VALUES ($1, $2, $3::jsonb)`,
|
|
// Pass the object directly — JSON.stringify + ::jsonb cast double-encodes
|
|
// (per CLAUDE.md memory: postgres-js JSONB double-encode trap).
|
|
[`tok-wg-${Date.now()}`, hash, { takes_holders: ['world', 'garry'] }],
|
|
);
|
|
// Read back permissions to simulate validateToken's path
|
|
const rows = await engine.executeRaw<{ permissions: { takes_holders?: string[] } }>(
|
|
`SELECT permissions FROM access_tokens WHERE token_hash = $1`,
|
|
[hash],
|
|
);
|
|
const allowList = rows[0]?.permissions?.takes_holders ?? ['world'];
|
|
expect(allowList).toEqual(['world', 'garry']);
|
|
|
|
// Now dispatch with that allow-list, verify SQL filter applies
|
|
const result = await dispatchToolCall(engine, 'takes_list', { page_slug: 'people/alice-example' }, {
|
|
remote: true, sourceId: 'default', takesHoldersAllowList: allowList,
|
|
});
|
|
expect(result.isError).toBeFalsy();
|
|
const takes = JSON.parse(result.content[0].text) as Array<{ holder: string }>;
|
|
const holders = new Set(takes.map(t => t.holder));
|
|
expect(holders.has('world')).toBe(true);
|
|
expect(holders.has('garry')).toBe(true);
|
|
expect(holders.has('brain')).toBe(false); // brain hunch is hidden
|
|
});
|
|
|
|
test('default ["world"] hides garry hunches even from search', async () => {
|
|
const engine = getEngine();
|
|
const token = generateToken();
|
|
const hash = hashToken(token);
|
|
await engine.executeRaw(
|
|
`INSERT INTO access_tokens (name, token_hash, permissions) VALUES ($1, $2, $3)`,
|
|
[`tok-w-${Date.now()}`, hash, { takes_holders: ['world'] }],
|
|
);
|
|
const result = await dispatchToolCall(engine, 'takes_search', { query: 'founder' }, {
|
|
remote: true, sourceId: 'default', takesHoldersAllowList: ['world'],
|
|
});
|
|
const hits = JSON.parse(result.content[0].text) as Array<{ holder: string }>;
|
|
expect(hits.every(h => h.holder === 'world')).toBe(true);
|
|
});
|
|
|
|
test('NULL permissions row defaults to ["world"] (back-compat for pre-v32 tokens edited manually)', async () => {
|
|
const engine = getEngine();
|
|
const token = generateToken();
|
|
const hash = hashToken(token);
|
|
// Simulate a manually-tampered token where permissions was set to NULL after creation
|
|
await engine.executeRaw(
|
|
`INSERT INTO access_tokens (name, token_hash, permissions) VALUES ($1, $2, $3)`,
|
|
[`tok-null-${Date.now()}`, hash, {}],
|
|
);
|
|
const rows = await engine.executeRaw<{ permissions: { takes_holders?: string[] } }>(
|
|
`SELECT permissions FROM access_tokens WHERE token_hash = $1`,
|
|
[hash],
|
|
);
|
|
// perm was {} so takes_holders is undefined; HTTP transport defaults to ['world']
|
|
const allowList = Array.isArray(rows[0]?.permissions?.takes_holders) ? rows[0].permissions!.takes_holders! : ['world'];
|
|
expect(allowList).toEqual(['world']);
|
|
});
|
|
|
|
test('revoked token is excluded from active token query', async () => {
|
|
const engine = getEngine();
|
|
const token = generateToken();
|
|
const hash = hashToken(token);
|
|
await engine.executeRaw(
|
|
`INSERT INTO access_tokens (name, token_hash, revoked_at) VALUES ($1, $2, now())`,
|
|
[`tok-revoked-${Date.now()}`, hash],
|
|
);
|
|
// The HTTP transport's validateToken filters WHERE revoked_at IS NULL — confirm the row is invisible there.
|
|
const rows = await engine.executeRaw<{ id: string }>(
|
|
`SELECT id FROM access_tokens WHERE token_hash = $1 AND revoked_at IS NULL`,
|
|
[hash],
|
|
);
|
|
expect(rows).toHaveLength(0);
|
|
});
|
|
});
|