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:
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
35
tests/ai/agent/tools/read_scope.test.js
Normal file
35
tests/ai/agent/tools/read_scope.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user