Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions src/core/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { dedupResults } from './search/dedup.ts';
import { captureEvalCandidate, isEvalCaptureEnabled, isEvalScrubEnabled } from './eval-capture.ts';
import type { HybridSearchMeta } from './types.ts';
import { extractPageLinks, isAutoLinkEnabled, isAutoTimelineEnabled, parseTimelineEntries, makeResolver, type UnresolvedFrontmatterRef } from './link-extraction.ts';
import { stripTakesFence } from './takes-fence.ts';
import * as db from './db.ts';

// --- Types ---
Expand Down Expand Up @@ -350,7 +351,19 @@ const get_page: Operation = {
}

const tags = await ctx.engine.getTags(page.slug);
return { ...page, tags, ...(resolved_slug ? { resolved_slug } : {}) };
// Privacy boundary for the per-token takes-holder allow-list (v0.28.6).
// takes_list / takes_search / think.gather filter rows by holder at the
// SQL layer, but takes are also rendered as a markdown table inside the
// page body between TAKES_FENCE markers — `extract-takes.ts` ("markdown
// is canonical, the takes table is a derived index"). A read-only token
// restricted to e.g. `world` could call `get_page <slug>` and recover
// every non-`world` claim verbatim from the body. Strip the fence here
// when the caller carries an allow-list (i.e. the remote MCP path).
// Local CLI callers leave takesHoldersAllowList unset and see the fence.
const visibleBody = ctx.takesHoldersAllowList
? { ...page, compiled_truth: stripTakesFence(page.compiled_truth) }
: page;
return { ...visibleBody, tags, ...(resolved_slug ? { resolved_slug } : {}) };
},
scope: 'read',
cliHints: { name: 'get', positional: ['slug'] },
Expand Down Expand Up @@ -1251,7 +1264,13 @@ const get_versions: Operation = {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
return ctx.engine.getVersions(p.slug as string);
const versions = await ctx.engine.getVersions(p.slug as string);
// Same takes-allow-list privacy boundary as get_page. Snapshots persist
// historical compiled_truth verbatim, including the takes fence, so
// a remote token bypassing get_page via /history would re-introduce
// the same leak across every prior version.
if (!ctx.takesHoldersAllowList) return versions;
return versions.map(v => ({ ...v, compiled_truth: stripTakesFence(v.compiled_truth) }));
},
scope: 'read',
cliHints: { name: 'history', positional: ['slug'] },
Expand Down
98 changes: 98 additions & 0 deletions test/takes-mcp-allowlist.serial.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
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;
Expand Down Expand Up @@ -105,6 +106,103 @@ describe('per-token takes-holder allow-list — takes_search', () => {
});
});

// ---------------------------------------------------------------------------
// 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 () => {
// Without ANTHROPIC_API_KEY, runThink returns gather-only result with NO_ANTHROPIC_API_KEY warning.
Expand Down
Loading