import { describe, it, expect, vi } from 'vitest'; import type { RbacService } from '../../src/services/rbac.service.js'; import { RunAuthorityResolver, restrictedContextNotice, unscopedAuthority, } from '../../src/services/run-authority.js'; /** * An agent carries a project's prompts AND its MCP tools. Before this, the only * check on a chat turn was `run:agents:` — so anyone who could talk to an * agent inherited tool access to every project that agent had joined. * * The rule these pin: knowledge travels with the agent, action is bounded by * whoever invoked it. */ function rbac(allowed: string[]): RbacService { return { canAccess: vi.fn(async (_u: string, action: string, resource: string, name?: string) => action === 'run' && resource === 'projects' && name !== undefined && allowed.includes(name), ), } as unknown as RbacService; } const SRE = { id: 'p-sre', name: 'sre' }; const PAY = { id: 'p-pay', name: 'payments' }; describe('caller authority', () => { it('grants tools only for projects the caller can reach', async () => { const r = new RunAuthorityResolver(rbac(['sre'])); const a = await r.resolve({ kind: 'caller', userId: 'u1', projects: [SRE, PAY] }); expect([...a.toolProjects]).toEqual(['p-sre']); expect(a.restrictedProjects).toEqual(['payments']); }); it('still lists the unreachable project as restricted, not absent', async () => { // The distinction matters: the prompts DO load, so the model has to be told // it is holding context it cannot act on. const r = new RunAuthorityResolver(rbac([])); const a = await r.resolve({ kind: 'caller', userId: 'u1', projects: [SRE, PAY] }); expect(a.toolProjects.size).toBe(0); expect(a.restrictedProjects).toEqual(['sre', 'payments']); }); it('gives full tool access when the caller can reach everything', async () => { const r = new RunAuthorityResolver(rbac(['sre', 'payments'])); const a = await r.resolve({ kind: 'caller', userId: 'u1', projects: [SRE, PAY] }); expect(a.toolProjects.size).toBe(2); expect(a.restrictedProjects).toEqual([]); }); it('asks RBAC the same question the project-chat route asks', async () => { // Reusing `run:projects:` rather than inventing a parallel permission // is the point — one grant means the same thing on both paths. const svc = rbac(['sre']); await new RunAuthorityResolver(svc).resolve({ kind: 'caller', userId: 'u1', projects: [SRE] }); expect(svc.canAccess).toHaveBeenCalledWith('u1', 'run', 'projects', 'sre'); }); it('fails closed with no user', async () => { const r = new RunAuthorityResolver(rbac(['sre'])); const a = await r.resolve({ kind: 'caller', projects: [SRE] }); expect(a.toolProjects.size).toBe(0); }); it('fails closed when RBAC is not wired', async () => { // Older harnesses construct ChatService without it. Knowledge and no tools // is the safe degradation; the reverse would be a silent grant. const a = await new RunAuthorityResolver().resolve({ kind: 'caller', userId: 'u1', projects: [SRE] }); expect(a.toolProjects.size).toBe(0); }); }); describe('McpToken project ceiling', () => { it('refuses projects outside the token binding even when RBAC would allow', async () => { // `project` travels in the request BODY, so the URL-based token scope check // in main.ts never sees it. Without this a token for sre could drive an // agent into payments' tools. const r = new RunAuthorityResolver(rbac(['sre', 'payments'])); const a = await r.resolve({ kind: 'caller', userId: 'u1', projects: [SRE, PAY], mcpTokenProjectName: 'sre', }); expect([...a.toolProjects]).toEqual(['p-sre']); expect(a.restrictedProjects).toEqual(['payments']); }); it('does not widen a token beyond what its owner may reach', async () => { const r = new RunAuthorityResolver(rbac([])); const a = await r.resolve({ kind: 'caller', userId: 'u1', projects: [SRE], mcpTokenProjectName: 'sre', }); expect(a.toolProjects.size).toBe(0); }); }); describe('agent authority', () => { it('takes every membership without consulting any user', async () => { const svc = rbac([]); const a = await new RunAuthorityResolver(svc).resolve({ kind: 'agent', projects: [SRE, PAY] }); expect(a.toolProjects.size).toBe(2); expect(a.restrictedProjects).toEqual([]); expect(svc.canAccess).not.toHaveBeenCalled(); }); }); describe('service-account authority', () => { it('refuses rather than running on a forgeable identity', async () => { // A ServiceAccount today is an unauthenticated `x-service-account` header. // Honouring it would grant tool access on a client-supplied string, so this // fails loudly until there is a real credential behind it. await expect( new RunAuthorityResolver(rbac(['sre'])).resolve({ kind: 'service-account', projects: [SRE] }), ).rejects.toThrow(/not implemented|authenticated identity/); }); }); describe('the boundary notice', () => { it('names the projects and says the tools are absent', () => { const n = restrictedContextNotice(['payments', 'sre']); expect(n).toContain('payments, sre'); expect(n).toMatch(/NO tools/); expect(n).toMatch(/not reproduce its contents verbatim/i); }); it('unscoped authority grants nothing', () => { expect(unscopedAuthority('u1').toolProjects.size).toBe(0); }); });