From c955f1eaaf21c0898718b291f02a456a20a9da4a Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 20:07:22 +1000 Subject: [PATCH] feat(mcp): space-scope the read tool for bound callers Co-Authored-By: Claude Opus 4.8 --- lib/ai/agent/tools/read.js | 14 +++++++++- tests/ai/agent/tools/read_scope.test.js | 35 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/ai/agent/tools/read_scope.test.js diff --git a/lib/ai/agent/tools/read.js b/lib/ai/agent/tools/read.js index c9764fc..ca41ff5 100644 --- a/lib/ai/agent/tools/read.js +++ b/lib/ai/agent/tools/read.js @@ -13,11 +13,23 @@ export const readTool = { }, required: ['kind', 'id'] }, - async handler({ kind, id }, _ctx) { + async handler({ kind, id }, ctx = {}) { const table = TABLE[kind]; if (!table) return { error: `unknown kind "${kind}"` }; const { rows: [row] } = await pool.query(`SELECT * FROM ${table} WHERE id=$1`, [id]); if (!row) return { error: `${kind} ${id} not found` }; + // Space scoping. When a Space is bound (external scoped agents — and Dross, + // which operates within one Space), an entity that carries space_id must + // match it. Kinds without a space_id column (conversations) can't be proven + // in-scope, so they're denied to spaceScoped callers (external agents) only. + // Owner/Dross with no bound Space (ctx.space_id == null) → unrestricted. + if (ctx.space_id != null) { + if (row.space_id !== undefined) { + if (row.space_id !== ctx.space_id) return { error: `${kind} ${id} not found` }; + } else if (ctx.spaceScoped) { + return { error: `${kind} ${id} not found` }; + } + } return row; } }; diff --git a/tests/ai/agent/tools/read_scope.test.js b/tests/ai/agent/tools/read_scope.test.js new file mode 100644 index 0000000..62fb86d --- /dev/null +++ b/tests/ai/agent/tools/read_scope.test.js @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { pool } from '../../../../lib/db/pool.js'; +import { resetDb } from '../../../helpers/db.js'; +import { migrateUp } from '../../../../lib/db/migrate.js'; +import { readTool } from '../../../../lib/ai/agent/tools/read.js'; + +let spaceA, spaceB, pageInA, convoId; +beforeAll(async () => { + await resetDb(); await migrateUp(); + ({ rows: [{ id: spaceA }] } = await pool.query(`INSERT INTO spaces(slug,name) VALUES('a','A') RETURNING id`)); + ({ rows: [{ id: spaceB }] } = await pool.query(`INSERT INTO spaces(slug,name) VALUES('b','B') RETURNING id`)); + ({ rows: [{ id: pageInA }] } = await pool.query( + `INSERT INTO pages(space_id,slug,title,body_md) VALUES($1,'p','P','body') RETURNING id`, [spaceA])); + ({ rows: [{ id: convoId }] } = await pool.query( + `INSERT INTO conversations(title) VALUES('c') RETURNING id`)); +}); + +describe('read tool space scoping', () => { + it('reads an in-space page', async () => { + const out = await readTool.handler({ kind: 'page', id: pageInA }, { space_id: spaceA }); + expect(out.title).toBe('P'); + }); + it('blocks a cross-space page (scoped caller)', async () => { + const out = await readTool.handler({ kind: 'page', id: pageInA }, { space_id: spaceB }); + expect(out.error).toMatch(/not found/i); + }); + it('blocks unprovable kinds (conversation) for spaceScoped callers', async () => { + const out = await readTool.handler({ kind: 'conversation', id: convoId }, { space_id: spaceA, spaceScoped: true }); + expect(out.error).toMatch(/not found/i); + }); + it('owner (no space bound) reads anything', async () => { + const out = await readTool.handler({ kind: 'page', id: pageInA }, {}); + expect(out.title).toBe('P'); + }); +});