feat(mcp): space-scope the read tool for bound callers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-04 20:07:22 +10:00
parent 99b1fa445a
commit c955f1eaaf
2 changed files with 48 additions and 1 deletions

View File

@@ -13,11 +13,23 @@ export const readTool = {
}, },
required: ['kind', 'id'] required: ['kind', 'id']
}, },
async handler({ kind, id }, _ctx) { async handler({ kind, id }, ctx = {}) {
const table = TABLE[kind]; const table = TABLE[kind];
if (!table) return { error: `unknown kind "${kind}"` }; if (!table) return { error: `unknown kind "${kind}"` };
const { rows: [row] } = await pool.query(`SELECT * FROM ${table} WHERE id=$1`, [id]); const { rows: [row] } = await pool.query(`SELECT * FROM ${table} WHERE id=$1`, [id]);
if (!row) return { error: `${kind} ${id} not found` }; 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; return row;
} }
}; };

View File

@@ -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');
});
});