diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 377aaa033..28c359441 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -3384,6 +3384,9 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise { - return ctx.engine.resolveSlugs(p.partial as string); + // #3242: was fully UNSCOPED — the one read that leaked every source's + // slugs to any caller (the reporter's "resolve_slugs sees them but + // get_page doesn't" matrix). Route through the same visibility set as + // get_page/search: grant > federated set > scalar source. + return ctx.engine.resolveSlugs(p.partial as string, federatedSearchScope(ctx)); }, scope: 'read', }; diff --git a/src/mcp/dispatch.ts b/src/mcp/dispatch.ts index a37e23c92..18552fc80 100644 --- a/src/mcp/dispatch.ts +++ b/src/mcp/dispatch.ts @@ -54,6 +54,13 @@ export interface DispatchOpts { * resolves it from the per-token allow-list (eE3). */ sourceId?: string; + /** + * #3242: federated read set for callers with NO explicit source scope + * (stdio without GBRAIN_SOURCE; legacy HTTP tokens without an operator-set + * `permissions.source_id` grant). Transport-computed, never derived from + * caller params. See OperationContext.localFederatedSourceIds. + */ + localFederatedSourceIds?: string[]; /** * v0.31 (eD3): hook called by the dispatcher AFTER op.handler succeeds * to compute `_meta.brain_hot_memory` for the response. Wrapped in its @@ -216,6 +223,7 @@ export function buildOperationContext( // CLI / HTTP / stdio transports SHOULD pass an explicit sourceId via opts; // this fallback covers code paths that historically passed undefined. sourceId: opts.sourceId ?? 'default', + ...(opts.localFederatedSourceIds ? { localFederatedSourceIds: opts.localFederatedSourceIds } : {}), auth: opts.auth, }; } diff --git a/src/mcp/http-transport.ts b/src/mcp/http-transport.ts index c3ff28c27..b1ee57a5a 100644 --- a/src/mcp/http-transport.ts +++ b/src/mcp/http-transport.ts @@ -84,6 +84,14 @@ interface AuthResult { * Bounded to the stored grant — never widened to "all". */ auth?: AuthInfo; + /** + * #3242: true when the token row carries an operator-set + * `permissions.source_id` (string OR array — even a malformed one, which + * fails closed to 'default' without widening). false = the historical + * no-grant 'default' floor; ONLY that case gets the federated read set + * (config.federated sources) threaded as localFederatedSourceIds. + */ + hasSourceGrant?: boolean; } /* Legacy token source-scope parsing lives in core/legacy-token-scope.ts and is @@ -229,6 +237,9 @@ export async function startHttpTransport(opts: HttpTransportOptions) { // source unless the token carries an explicit grant (#1336 above). sourceId, auth, + // #3242: distinguish "operator granted a scope" from "historical + // no-grant floor" — only the latter widens to federated sources. + hasSourceGrant: perms?.source_id != null, }; } catch { return { ok: false }; @@ -379,10 +390,21 @@ export async function startHttpTransport(opts: HttpTransportOptions) { // takes_search / query (when it returns takes) can server-side filter. // v0.34.1 (#861): thread source-isolation scope. Legacy access_tokens // path defaults to 'default' per AuthResult.sourceId above. + // #3242: a token with NO operator-set source grant reads across the + // federated set (config.federated sources), not just the scalar + // 'default' floor. Granted tokens (hasSourceGrant) never widen. + let localFederated: string[] | undefined; + if (auth.hasSourceGrant === false && auth.sourceId) { + try { + const { localFederatedSourceIds } = await import('../core/source-resolver.ts'); + localFederated = await localFederatedSourceIds(engine, auth.sourceId, 'seed_default'); + } catch { /* scalar scope stands */ } + } const result = await dispatchToolCall(engine, toolName, args, { remote: true, takesHoldersAllowList: auth.takesHoldersAllowList, sourceId: auth.sourceId, + ...(localFederated ? { localFederatedSourceIds: localFederated } : {}), // #1336: thread the token's federated_read grant so read ops scope // to the operator-granted sources via sourceScopeOpts. auth: auth.auth, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 6f98b01dc..425624e98 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -35,6 +35,20 @@ export async function startMcpServer(engine: BrainEngine) { // shape and cast through `any` (the SDK accepts it via the ServerResult union). server.setRequestHandler(CallToolRequestSchema, async (request: any): Promise => { const { name, arguments: params } = request.params; + // #3242: when the operator didn't pin a source via GBRAIN_SOURCE, stdio + // reads span every `config.federated = true` source (same visibility set + // as unqualified local CLI reads). GBRAIN_SOURCE set = explicit scope, + // no widening. Best-effort: a resolver failure keeps the scalar scope. + // ponytail: one tiny SELECT per tool call; cache it if it ever shows up. + let localFederated: string[] | undefined; + try { + const { localFederatedSourceIds } = await import('../core/source-resolver.ts'); + localFederated = await localFederatedSourceIds( + engine, + process.env.GBRAIN_SOURCE || 'default', + process.env.GBRAIN_SOURCE ? 'env' : 'seed_default', + ); + } catch { /* scalar scope stands */ } // v0.28: stdio MCP has no per-token auth (local pipe). Default the // takes-holder allow-list to ['world'] so agent-facing callers don't // see private hunches via takes_list / takes_search / query. Operators @@ -51,6 +65,7 @@ export async function startMcpServer(engine: BrainEngine) { // Operators who want a different source on stdio MCP should set // GBRAIN_SOURCE in the env or use --source via `gbrain call`. sourceId: process.env.GBRAIN_SOURCE || 'default', + ...(localFederated ? { localFederatedSourceIds: localFederated } : {}), // v0.31 (eD3): _meta.brain_hot_memory injection so Claude Desktop / // Code see the brain's relevant hot memory automatically alongside // every tool-call response. Best-effort; absorbs errors. diff --git a/test/local-federated-search-scope.test.ts b/test/local-federated-search-scope.test.ts index fc811a4f3..2a7dd1b64 100644 --- a/test/local-federated-search-scope.test.ts +++ b/test/local-federated-search-scope.test.ts @@ -11,9 +11,17 @@ * Fix: the CLI context builder computes `ctx.localFederatedSourceIds` * (resolved source + every other federated source) whenever the source * resolved via a NON-explicit tier; `federatedSearchScope` widens the scalar - * scope to that set for the `search` / `query` ops — trusted-local only - * (`ctx.remote === false`), never for remote callers, never when a per-call - * `source_id` or an explicit --source/env/dotfile was given. + * scope to that set — never when a per-call `source_id`, a grant array, or an + * explicit --source/env/dotfile was given. + * + * #3242 extends the same visibility set to `get_page` / `list_pages` / + * `resolve_slugs` (pages ingested into a `federated: true` source were + * invisible to normal reads while the unscoped resolve_slugs leaked them), + * and to transports whose caller carries NO explicit source scope (stdio + * without GBRAIN_SOURCE; legacy HTTP tokens without a `permissions.source_id` + * grant) — those transports now populate `localFederatedSourceIds` themselves, + * so the widening gate is field-presence (transport-decided, never + * param-controlled), not `ctx.remote`. */ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { PGLiteEngine } from '../src/core/pglite-engine.ts'; @@ -105,11 +113,19 @@ describe('federatedSearchScope — trust + explicitness matrix', () => { expect(federatedSearchScope(ctx)).toEqual({ sourceIds: ['default', 'wiki'] }); }); - test('remote caller NEVER widens (fail-closed), even if the field is set', () => { - const ctx = ctxOf({ remote: true, localFederatedSourceIds: ['default', 'wiki'] }); + test('remote caller WITHOUT the field never widens (fail-closed)', () => { + const ctx = ctxOf({ remote: true }); expect(federatedSearchScope(ctx)).toEqual({ sourceId: 'default' }); }); + test('#3242: remote caller widens when its transport populated the field (no-grant floor)', () => { + // The field is set only by server-side transports (stdio without + // GBRAIN_SOURCE / legacy HTTP token without a source grant) — never from + // caller params — so presence of the field IS the trust decision. + const ctx = ctxOf({ remote: true, localFederatedSourceIds: ['default', 'wiki'] }); + expect(federatedSearchScope(ctx)).toEqual({ sourceIds: ['default', 'wiki'] }); + }); + test('per-call source_id wins over the federated set', () => { const ctx = ctxOf({ localFederatedSourceIds: ['default', 'wiki'] }); expect(federatedSearchScope(ctx, 'wiki')).toEqual({ sourceId: 'wiki' }); @@ -152,3 +168,50 @@ describe('search op — unqualified local search spans federated sources', () => expect(slugs).toEqual(['notes/home']); }); }); + +// #3242 — pages in a federated source must be visible to the normal read ops, +// not just search/query; and resolve_slugs must be SCOPED (pre-fix it was the +// one read that leaked every source's slugs). +describe('#3242 — get_page / list_pages / resolve_slugs share the federated visibility set', () => { + const getPage = operations.find((o) => o.name === 'get_page')!; + const listPages = operations.find((o) => o.name === 'list_pages')!; + const resolveSlugsOp = operations.find((o) => o.name === 'resolve_slugs')!; + + function federatedCtx(overrides: Partial = {}): OperationContext { + return ctxOf({ localFederatedSourceIds: ['default', 'wiki'], ...overrides }); + } + + test('get_page: federated-source page readable on an unqualified ctx (pre-fix: page_not_found)', async () => { + const page = (await getPage.handler(federatedCtx(), { slug: 'wiki/topic' })) as { slug: string }; + expect(page.slug).toBe('wiki/topic'); + }); + + test('get_page: non-federated source stays invisible', async () => { + await expect(getPage.handler(federatedCtx(), { slug: 'private/topic' })).rejects.toThrow(/not found/i); + }); + + test('get_page: scalar ctx (explicit source, no field) keeps single-source behavior', async () => { + await expect(getPage.handler(ctxOf(), { slug: 'wiki/topic' })).rejects.toThrow(/not found/i); + }); + + test('list_pages: federated-source pages listed on an unqualified ctx (pre-fix: missing)', async () => { + const rows = (await listPages.handler(federatedCtx(), {})) as Array<{ slug: string }>; + const slugs = rows.map((r) => r.slug); + expect(slugs).toContain('notes/home'); + expect(slugs).toContain('wiki/topic'); + expect(slugs).not.toContain('private/topic'); + expect(slugs).not.toContain('old/topic'); + }); + + test('resolve_slugs: scoped to the visibility set (pre-fix: leaked every source)', async () => { + const federated = (await resolveSlugsOp.handler(federatedCtx(), { partial: 'topic' })) as string[]; + expect(federated).toContain('wiki/topic'); + expect(federated).not.toContain('private/topic'); + + // A remote scalar caller (no field, no grant) must no longer see foreign slugs. + const scalar = (await resolveSlugsOp.handler(ctxOf({ remote: true }), { partial: 'topic' })) as string[]; + expect(scalar).not.toContain('wiki/topic'); + expect(scalar).not.toContain('private/topic'); + expect(scalar).not.toContain('old/topic'); + }); +});