feat(hierarchy): M4-1b-ii hierarchy command family, grant evaluation, visibility (#1465)
ci/woodpecker/push/publish Pipeline was canceled

This commit was merged in pull request #1465.
This commit is contained in:
2026-08-29 16:54:39 +00:00
parent 5125fe21b0
commit 215faeda0a
20 changed files with 8407 additions and 132 deletions
+89 -24
View File
@@ -176,7 +176,18 @@ describe('MCP actor identity and tool scope enforcement', () => {
).toBe(false);
expect(
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
).toBe(true);
).toBe(false);
});
it('derives no scope elevation from any platform role (contract 2 §1.1 bypass retirement)', () => {
const memberScopes = deriveMcpToolScopesForUser({ role: 'member' });
for (const role of ['admin', 'platform-admin', 'super-admin', null, undefined]) {
const scopes = deriveMcpToolScopesForUser({ role });
expect([...scopes].sort()).toEqual([...memberScopes].sort());
expect(scopes.has(MCP_TOOL_SCOPES.brain_create_task)).toBe(false);
expect(scopes.has(MCP_TOOL_SCOPES.brain_update_task)).toBe(false);
expect(scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false);
}
});
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
@@ -311,14 +322,20 @@ describe('MCP actor identity and tool scope enforcement', () => {
]);
});
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
const { service } = makeService({
it('gives admin-role and platform-admin-role actors only owned content on brain reads (§1.1 retirement)', async () => {
// Contract 2 §1.1: users.role confers no content visibility. An actor whose
// role is 'admin', 'platform-admin', or 'super-admin' but who holds no
// ownership sees exactly what an unprivileged member with the same
// ownership would see — here, only the one project they own, and nothing
// tenant-wide or platform-wide.
const fixtures = {
projects: [
{ id: 'project-owned', ownerId: 'role-bearing-user', teamId: 'tenant-a', name: 'owned' },
{
id: 'project-tenant-a',
ownerId: 'other-user-a',
teamId: 'tenant-a',
name: 'same tenant',
name: 'same tenant, unowned',
},
{
id: 'project-tenant-b',
@@ -328,39 +345,50 @@ describe('MCP actor identity and tool scope enforcement', () => {
},
],
missions: [
{ id: 'mission-owned', projectId: 'project-owned' },
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
],
tasks: [
{ id: 'task-owned', projectId: 'project-owned', status: 'not-started' },
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
],
});
const { server, tools } = makeCapturingServer();
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
};
service.registerTools(server, actor);
const actors = [
makeAdminActor('role-bearing-user', 'tenant-a'),
makePlatformAdminActor('role-bearing-user'),
];
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']);
for (const actor of actors) {
const { service } = makeService(fixtures);
const { server, tools } = makeCapturingServer();
service.registerTools(server, actor);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']);
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']);
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']);
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-owned']);
}
});
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
const { service, brain } = makeService({
projects: [
{ id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
// §1.1 retirement: content visibility comes from ownership, not the
// tenant-admin role — the acting user owns the tenant-a project.
{ id: 'project-tenant-a', ownerId: 'tenant-admin-user', teamId: 'tenant-a' },
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
],
missions: [
@@ -373,7 +401,29 @@ describe('MCP actor identity and tool scope enforcement', () => {
],
});
const { server, tools } = makeCapturingServer();
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
// Platform role no longer derives task-write scopes (§1.1 retirement):
// a role-derived admin actor is scope-denied before any tenant logic.
const roleDerivedAdmin = makeAdminActor('tenant-admin-user', 'tenant-a');
service.registerTools(server, roleDerivedAdmin);
await expect(
getTool(tools, 'brain_create_task').handler({ title: 'role-derived write' }),
).rejects.toThrow('MCP tool scope denied');
expect(brain.tasks.create).not.toHaveBeenCalled();
// The tenant-scoping checks below sit behind the scope gate; exercise
// them with explicitly granted task-write scopes (how grant-mapped
// scopes will arrive), not with a platform role.
tools.clear();
const actor = createMcpActorContext({
userId: 'tenant-admin-user',
tenantId: 'tenant-a',
role: 'member',
scopes: [
...deriveMcpToolScopesForUser({ role: 'member' }),
MCP_TOOL_SCOPES.brain_create_task,
MCP_TOOL_SCOPES.brain_update_task,
],
});
service.registerTools(server, actor);
@@ -416,7 +466,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
);
});
it('keeps admin-only coordination tools on server-derived paths', async () => {
it('denies coordination tools to every role-derived actor and keeps the granted path server-derived', async () => {
const { service, coord } = makeService();
const { server, tools } = makeCapturingServer();
const member = makeMemberActor('authenticated-user');
@@ -433,10 +483,25 @@ describe('MCP actor identity and tool scope enforcement', () => {
const tenantAdminTool = getTool(tools, 'coord_list_tasks');
await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
// §1.1 retirement: platform-admin no longer derives coord scopes either.
tools.clear();
service.registerTools(server, platformAdmin);
const platformAdminTool = getTool(tools, 'coord_list_tasks');
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
await expect(platformAdminTool.handler({})).rejects.toThrow(
'MCP tool scope denied: coord:read',
);
// An explicitly granted coord:read scope reaches the server-derived
// path (caller-supplied projectPath is stripped by the schema).
tools.clear();
const grantedActor = createMcpActorContext({
userId: 'granted-user',
role: 'member',
scopes: [MCP_TOOL_SCOPES.coord_list_tasks],
});
service.registerTools(server, grantedActor);
const grantedTool = getTool(tools, 'coord_list_tasks');
await grantedTool.handler({ projectPath: '/tmp/victim' });
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
});
+19 -57
View File
@@ -63,20 +63,6 @@ interface SessionEntry {
actor: McpActorContext;
}
const GLOBAL_ADMIN_MCP_SCOPES = new Set<McpToolScope>(Object.values(MCP_TOOL_SCOPES));
const TENANT_ADMIN_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
MCP_TOOL_SCOPES.brain_list_tasks,
MCP_TOOL_SCOPES.brain_create_task,
MCP_TOOL_SCOPES.brain_update_task,
MCP_TOOL_SCOPES.brain_list_missions,
MCP_TOOL_SCOPES.brain_list_conversations,
MCP_TOOL_SCOPES.memory_search,
MCP_TOOL_SCOPES.memory_get_preferences,
MCP_TOOL_SCOPES.memory_save_preference,
MCP_TOOL_SCOPES.memory_save_insight,
]);
const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
@@ -89,15 +75,17 @@ const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.memory_save_insight,
]);
export function deriveMcpToolScopesForUser(input: {
/**
* Contract 2 §1.1: platform role confers NO MCP scope elevation — the
* former tenant-admin/global-admin scope sets keyed on users.role are
* retired. Every authenticated user receives the base member set; task
* writes and coordination scopes attach to explicit hierarchy grants when
* the MCP grant mapping lands, never to a platform role. The role
* parameter is kept for caller compatibility and deliberately ignored.
*/
export function deriveMcpToolScopesForUser(_input: {
role?: string | null;
}): ReadonlySet<McpToolScope> {
if (input.role === 'platform-admin' || input.role === 'super-admin') {
return new Set(GLOBAL_ADMIN_MCP_SCOPES);
}
if (input.role === 'admin') {
return new Set(TENANT_ADMIN_MCP_SCOPES);
}
return new Set(MEMBER_MCP_SCOPES);
}
@@ -168,41 +156,22 @@ type TaskLike = TenantScopedLike & {
userId?: string | null;
};
function isGlobalAdminActor(actor: McpActorContext): boolean {
return actor.role === 'platform-admin' || actor.role === 'super-admin';
}
function isTenantAdminActor(actor: McpActorContext): boolean {
return actor.role === 'admin';
}
function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean {
return (
record.tenantId === actor.tenantId ||
record.organizationId === actor.tenantId ||
record.teamId === actor.tenantId
);
}
/**
* Contract 2 §1.1: `users.role` confers NO content visibility — the former
* global-admin/tenant-admin filter short-circuits keyed on the platform role
* are retired along with the role-derived scope sets. Content reaches an MCP
* actor through ownership only; widened access arrives as explicit hierarchy
* grants when the MCP grant mapping lands.
*/
function filterProjectsForActor<T extends ProjectLike>(actor: McpActorContext, projects: T[]): T[] {
if (isGlobalAdminActor(actor)) return projects;
return projects.filter(
(project) =>
project.ownerId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, project)),
);
return projects.filter((project) => project.ownerId === actor.userId);
}
function filterMissionsByDirectActorScope<T extends MissionLike>(
actor: McpActorContext,
missions: T[],
): T[] {
if (isGlobalAdminActor(actor)) return missions;
return missions.filter(
(mission) =>
mission.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, mission)),
);
return missions.filter((mission) => mission.userId === actor.userId);
}
function scopesEqual(left: ReadonlySet<McpToolScope>, right: ReadonlySet<McpToolScope>): boolean {
@@ -293,7 +262,6 @@ export class McpService implements OnModuleDestroy {
}
private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined;
return project ? filterProjectsForActor(actor, [project]).length === 1 : false;
}
@@ -302,8 +270,6 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
missions: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return missions;
const projects = (await this.brain.projects.findAll()) as ProjectLike[];
const projectIds = new Set(
filterProjectsForActor(actor, projects).map((project) => project.id),
@@ -317,7 +283,6 @@ export class McpService implements OnModuleDestroy {
}
private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined;
if (!mission) return false;
return (await this.filterMissionsForActor(actor, [mission])).length === 1;
@@ -339,7 +304,7 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
refs: { projectId?: string | null; missionId?: string | null },
): Promise<void> {
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
if (!refs.projectId && !refs.missionId) {
throw new Error('MCP task scope denied');
}
await this.assertTaskReferencesAuthorized(actor, refs);
@@ -349,8 +314,6 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
tasks: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return tasks;
const [projects, missions] = await Promise.all([
this.brain.projects.findAll(),
this.brain.missions.findAll(),
@@ -367,7 +330,6 @@ export class McpService implements OnModuleDestroy {
return tasks.filter(
(task) =>
task.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, task)) ||
(typeof task.projectId === 'string' && projectIds.has(task.projectId)) ||
(typeof task.missionId === 'string' && missionIds.has(task.missionId)),
);