mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
fix(sources): federated-source pages visible to get_page/list_pages/resolve_slugs and no-grant MCP callers (#3242) (#3301)
Pages ingested into a config.federated=true source were invisible to
normal reads: get_page/list_pages scoped to the scalar resolved source
('default'), while the fully UNSCOPED resolve_slugs leaked every
source's slugs — the reporter's exact observation matrix.
- federatedSearchScope now backs get_page, list_pages and resolve_slugs
(not just search/query), so the unqualified read surface shares one
visibility set: grant > federated set > scalar source. resolve_slugs
gains the missing sourceScopeOpts-family scoping (leak sealed).
- The widening gate is now field-presence instead of ctx.remote:
localFederatedSourceIds is populated only by server-side transports
(never from caller params), so trust stays fail-closed while the
stdio MCP transport (no GBRAIN_SOURCE) and the legacy HTTP token
path (no operator-set permissions.source_id grant) can opt their
unqualified callers into the operator-configured federated set.
Tokens WITH a grant, per-call source_id, and OAuth allowedSources
all still win and never widen.
- gbrain sync now attributes its ingest-log row to the synced source
instead of the shared 'default' bucket (attribution sub-bug).
No engine SQL changes: getPage/listPages/resolveSlugs already accept
sourceIds[] in both engines (#1393/#876).
Fixes #3242
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Garry Tan
Claude Fable 5
parent
cd18081f4a
commit
4b38724aa2
@@ -3384,6 +3384,9 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
|
||||
// Log ingest
|
||||
await engine.logIngest({
|
||||
// #3242 (attribution sub-bug): credit the sync to the source it wrote
|
||||
// to, not the shared 'default' bucket.
|
||||
...(opts.sourceId ? { source_id: opts.sourceId } : {}),
|
||||
source_type: 'git_sync',
|
||||
source_ref: `${repoPath} @ ${headCommit.slice(0, 8)}`,
|
||||
pages_updated: pagesAffected,
|
||||
|
||||
+41
-28
@@ -433,20 +433,25 @@ export interface OperationContext {
|
||||
*/
|
||||
sourceId: string;
|
||||
/**
|
||||
* #2561 — federated read scope for UNQUALIFIED local CLI reads.
|
||||
* #2561 / #3242 — federated read scope for UNQUALIFIED reads.
|
||||
*
|
||||
* Set ONLY by the local CLI's context builder (src/cli.ts makeContext), and
|
||||
* only when the source resolved via a non-explicit tier (local_path /
|
||||
* brain_default / sole_non_default / seed_default — NOT --source, NOT
|
||||
* GBRAIN_SOURCE, NOT a .gbrain-source dotfile). Contains the resolved
|
||||
* source first, then every other `config.federated = true` source, so an
|
||||
* unqualified `gbrain search "X"` spans federated sources as
|
||||
* docs/guides/multi-source-brains.md promises.
|
||||
* Set ONLY by trusted server-side context builders — never from caller
|
||||
* params — and only when the caller carries no explicit source scope:
|
||||
* - local CLI (src/cli.ts makeContext) when the source resolved via a
|
||||
* non-explicit tier (local_path / brain_default / sole_non_default /
|
||||
* seed_default — NOT --source, NOT GBRAIN_SOURCE, NOT a dotfile);
|
||||
* - stdio MCP (src/mcp/server.ts) when GBRAIN_SOURCE is unset;
|
||||
* - HTTP MCP (src/mcp/http-transport.ts) for legacy bearer tokens with
|
||||
* NO operator-set `permissions.source_id` grant (the historical
|
||||
* 'default' floor). Tokens WITH an explicit grant never widen.
|
||||
*
|
||||
* Consumed exclusively by `federatedSearchScope` and ONLY when
|
||||
* `ctx.remote === false` — a remote caller's scope stays governed by
|
||||
* `ctx.auth.allowedSources` / scalar `ctx.sourceId` (source-isolation
|
||||
* invariant, fail-closed).
|
||||
* Contains the resolved source first, then every other
|
||||
* `config.federated = true` source, so an unqualified read/search spans
|
||||
* federated sources as docs/guides/multi-source-brains.md promises.
|
||||
*
|
||||
* Consumed exclusively by `federatedSearchScope`. Fail-closed remains:
|
||||
* a grant (`ctx.auth.allowedSources`) or a per-call `source_id` always
|
||||
* wins, and a context without this field never widens.
|
||||
*/
|
||||
localFederatedSourceIds?: string[];
|
||||
}
|
||||
@@ -565,25 +570,26 @@ export function resolveRequestedScope(
|
||||
}
|
||||
|
||||
/**
|
||||
* #2561 — source scope for the search-shaped read ops (`search`, `query`).
|
||||
* #2561 / #3242 — source scope for the page-visibility read ops (`search`,
|
||||
* `query`, `get_page`, `list_pages`, `resolve_slugs`).
|
||||
*
|
||||
* Delegates to `resolveRequestedScope` (the single trust+grant resolver), then
|
||||
* widens an UNQUALIFIED trusted-local scalar scope to the CLI-computed
|
||||
* federated set (`ctx.localFederatedSourceIds`, resolved source first). This is
|
||||
* what makes `sources add --federated` mean something for local search: a
|
||||
* federated source participates in unqualified `gbrain search "X"` results.
|
||||
* widens an UNQUALIFIED scalar scope to the transport-computed federated set
|
||||
* (`ctx.localFederatedSourceIds`, resolved source first). This is what makes
|
||||
* `sources add --federated` mean something: a federated source participates in
|
||||
* unqualified reads (#3242 — pages ingested into a `federated: true` source
|
||||
* were invisible to get_page/search/list_pages while resolve_slugs leaked them).
|
||||
*
|
||||
* The expansion NEVER applies when:
|
||||
* - the caller is not strictly trusted-local (`ctx.remote !== false`) —
|
||||
* remote scope stays grant-governed (fail-closed source isolation);
|
||||
* - a per-call `source_id` was passed (explicit wins, including `__all__`);
|
||||
* - the resolver already produced a federated array (OAuth grant);
|
||||
* - the CLI resolved the source from an explicit signal (--source / env /
|
||||
* dotfile) — makeContext leaves `localFederatedSourceIds` unset then.
|
||||
* - the resolver already produced a federated array (OAuth grant governs);
|
||||
* - the transport didn't populate `localFederatedSourceIds` (see that
|
||||
* field's doc: it is only set for callers with NO explicit source scope,
|
||||
* and never from caller-controlled params — so trust stays fail-closed).
|
||||
*
|
||||
* Deliberately NOT inside `sourceScopeOpts`: code-intel ops collapse a
|
||||
* multi-element scope to an error (`resolveCodeIntelScope`), and non-search
|
||||
* reads (get_page, get_links, …) keep their long-standing scalar behavior.
|
||||
* multi-element scope to an error (`resolveCodeIntelScope`), and the remaining
|
||||
* scalar reads (get_links, get_chunks, …) keep their long-standing behavior.
|
||||
*/
|
||||
export function federatedSearchScope(
|
||||
ctx: OperationContext,
|
||||
@@ -591,7 +597,6 @@ export function federatedSearchScope(
|
||||
): { sourceId?: string; sourceIds?: string[] } {
|
||||
const scope = resolveRequestedScope(ctx, sourceIdParam);
|
||||
if (
|
||||
ctx.remote === false &&
|
||||
sourceIdParam === undefined &&
|
||||
scope.sourceId !== undefined &&
|
||||
scope.sourceIds === undefined &&
|
||||
@@ -748,7 +753,9 @@ const get_page: Operation = {
|
||||
// with a federated `allowedSources` grant (and no single ctx.sourceId) got
|
||||
// an UNSCOPED exact lookup — a cross-source read of any page by slug. getPage
|
||||
// now honors sourceIds[] (both engines), so the same scope closes both paths.
|
||||
const sourceOpts = sourceScopeOpts(ctx);
|
||||
// #3242: federatedSearchScope (not bare sourceScopeOpts) so an unqualified
|
||||
// read sees pages in `federated: true` sources, matching search/query.
|
||||
const sourceOpts = federatedSearchScope(ctx);
|
||||
const fuzzyScope = sourceOpts;
|
||||
|
||||
let page = await ctx.engine.getPage(slug, { includeDeleted, ...sourceOpts });
|
||||
@@ -1503,7 +1510,9 @@ const list_pages: Operation = {
|
||||
// enumerate src-B pages. Pre-fix, ctx.sourceId / ctx.auth?.allowedSources
|
||||
// were ignored at this op handler and the engine returned every source's
|
||||
// pages indiscriminately.
|
||||
const scope = sourceScopeOpts(ctx);
|
||||
// #3242: federatedSearchScope so unqualified listing spans federated
|
||||
// sources (same visibility set as search / get_page). Grants still win.
|
||||
const scope = federatedSearchScope(ctx);
|
||||
const pages = await ctx.engine.listPages({
|
||||
type: p.type as any,
|
||||
tag: p.tag as string,
|
||||
@@ -2737,7 +2746,11 @@ const resolve_slugs: Operation = {
|
||||
partial: { type: 'string', required: true },
|
||||
},
|
||||
handler: async (ctx, p) => {
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<any> => {
|
||||
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.
|
||||
|
||||
@@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user