import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify from 'fastify'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { MindDB, CronStore } from '@waggle/core'; import { approvalRoutes } from '../../src/local/routes/approval.js'; import { securityMiddleware } from '../../src/local/security-middleware.js'; import { ApprovalGrantStore, isGrantableTool, resolveGrantRiskLevel, } from '../../src/local/approval-grants.js'; describe('approval routes — held actions (L2 union)', () => { let tmpDir: string; let db: MindDB; let store: CronStore; let server: ReturnType; let pendingApprovals: Map; timestamp: number; riskLevel?: 'low' | 'medium' | 'high' | 'critical'; resolve: (v: boolean) => void }>; let execSpy: ReturnType; let buildToolsSpy: ReturnType; let grantSpy: ReturnType; let literalDefaultIsViewer: boolean; beforeEach(async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-appr-')); db = new MindDB(path.join(tmpDir, 'test.mind')); store = new CronStore(db); pendingApprovals = new Map(); execSpy = vi.fn(async () => 'email sent'); buildToolsSpy = vi.fn(() => [{ name: 'send_email', description: '', parameters: {}, execute: execSpy }]); grantSpy = vi.fn(); literalDefaultIsViewer = false; server = Fastify({ logger: false }); server.decorate('cronStore', store); server.decorate('localConfig', { dataDir: tmpDir }); server.decorate('workspaceManager', { get: (id: string) => { if (id === 'viewer-workspace') { return { id, name: 'Viewer WS', teamId: 'team-1', teamRole: 'viewer' }; } if (id === 'w1') { return { id, name: 'Member WS', teamId: 'team-1', teamRole: 'member' }; } if (id === 'default' && literalDefaultIsViewer) { return { id, name: 'Literal Default WS', teamId: 'team-1', teamRole: 'viewer' }; } return undefined; }, getDefault: () => 'w1', list: () => [ { id: 'w1', name: 'Member WS', teamId: 'team-1', teamRole: 'member' }, { id: 'viewer-workspace', name: 'Viewer WS', teamId: 'team-1', teamRole: 'viewer' }, ], }); server.decorate('agentState', { cronStore: store, pendingApprovals, approvalGrantStore: { grant: grantSpy }, buildToolsForWorkspace: buildToolsSpy, }); await server.register(securityMiddleware); await server.register(approvalRoutes); }); afterEach(async () => { await server.close(); db.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); function hold(id = 'pa-1', workspaceId: string | null = 'w1') { return store.savePendingAction({ id, workspaceId, source: 'loop:1', toolName: 'send_email', argsJson: JSON.stringify({ to: 'x@y.z' }), summary: 'Send follow-up', riskLevel: 'medium', approvalClass: 'elevated', }); } it('GET /pending returns held actions with source + risk + summary', async () => { hold(); const res = await server.inject({ method: 'GET', url: '/api/approval/pending' }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.count).toBe(1); expect(body.pending[0]).toMatchObject({ requestId: 'pa-1', toolName: 'send_email', source: 'held', riskLevel: 'medium', summary: 'Send follow-up' }); expect(body.pending[0].input).toEqual({ to: 'x@y.z' }); }); it('GET /pending unions live + held entries', async () => { pendingApprovals.set('live-1', { toolName: 'bash', input: { command: 'ls' }, timestamp: 123, resolve: vi.fn() }); hold(); const res = await server.inject({ method: 'GET', url: '/api/approval/pending' }); const sources = res.json().pending.map((p: { source: string }) => p.source).sort(); expect(sources).toEqual(['held', 'live']); }); it('POST approve on a held id executes the tool and flips to executed', async () => { hold(); const res = await server.inject({ method: 'POST', url: '/api/approval/pa-1', payload: { approved: true } }); expect(res.statusCode).toBe(200); expect(res.json()).toMatchObject({ ok: true, approved: true, status: 'executed' }); expect(buildToolsSpy).toHaveBeenCalledWith( path.join(tmpDir, 'workspaces', 'w1', 'files'), undefined, 'w1', ); expect(execSpy).toHaveBeenCalledWith({ to: 'x@y.z' }); expect(store.getPendingAction('pa-1')!.status).toBe('executed'); }); it('POST deny on a held id marks it denied (tool not run)', async () => { hold(); const res = await server.inject({ method: 'POST', url: '/api/approval/pa-1', payload: { approved: false } }); expect(res.json()).toMatchObject({ ok: true, approved: false, status: 'denied' }); expect(execSpy).not.toHaveBeenCalled(); expect(store.getPendingAction('pa-1')!.status).toBe('denied'); }); it.each([ { approved: true, sourceWorkspaceId: 'w1' }, { approved: false, sourceWorkspaceId: 'w1' }, ])('viewer cannot decide a held action even with member sourceWorkspaceId ($approved)', async (payload) => { hold('viewer-held', 'viewer-workspace'); const res = await server.inject({ method: 'POST', url: '/api/approval/viewer-held', payload, }); expect(res.statusCode).toBe(403); expect(res.json()).toMatchObject({ code: 'VIEWER_READ_ONLY' }); expect(execSpy).not.toHaveBeenCalled(); expect(store.getPendingAction('viewer-held')!.status).toBe('held'); }); it('wildcard held action blocks when its executor target is the literal default viewer workspace', async () => { literalDefaultIsViewer = true; hold('default-held', '*'); const res = await server.inject({ method: 'POST', url: '/api/approval/default-held', payload: { approved: true }, }); expect(res.statusCode).toBe(403); expect(res.json()).toMatchObject({ code: 'VIEWER_READ_ONLY' }); expect(execSpy).not.toHaveBeenCalled(); expect(store.getPendingAction('default-held')!.status).toBe('held'); }); it('personal held action executes in the personal root without targeting managed default', async () => { literalDefaultIsViewer = true; hold('personal-held', null); const res = await server.inject({ method: 'POST', url: '/api/approval/personal-held', payload: { approved: true }, }); expect(res.statusCode).toBe(200); expect(res.json()).toMatchObject({ ok: true, status: 'executed' }); expect(buildToolsSpy).toHaveBeenCalledWith(os.homedir(), undefined, undefined); expect(execSpy).toHaveBeenCalledOnce(); }); it('wildcard held action does not inherit unrelated viewer workspaces outside its executor target', async () => { hold('wildcard-held', '*'); const res = await server.inject({ method: 'POST', url: '/api/approval/wildcard-held', payload: { approved: true }, }); expect(res.statusCode).toBe(200); expect(res.json()).toMatchObject({ ok: true, status: 'executed' }); expect(buildToolsSpy).toHaveBeenCalledWith( path.join(tmpDir, 'workspaces', 'default', 'files'), undefined, 'default', ); expect(execSpy).toHaveBeenCalledOnce(); expect(store.getPendingAction('wildcard-held')!.status).toBe('executed'); }); it('POST on an unknown id is 404', async () => { const res = await server.inject({ method: 'POST', url: '/api/approval/nope', payload: { approved: true } }); expect(res.statusCode).toBe(404); }); it('POST on a live id resolves the live promise (interactive path unchanged)', async () => { const resolve = vi.fn(); pendingApprovals.set('live-1', { toolName: 'write_file', input: {}, timestamp: 1, resolve }); const res = await server.inject({ method: 'POST', url: '/api/approval/live-1', payload: { approved: true } }); expect(res.statusCode).toBe(200); expect(resolve).toHaveBeenCalledWith(true); expect(pendingApprovals.has('live-1')).toBe(false); const second = await server.inject({ method: 'POST', url: '/api/approval/live-1', payload: { approved: true } }); expect(second.statusCode).toBe(404); expect(resolve).toHaveBeenCalledTimes(1); }); it('downgrades always approval for host execution to one explicit call', async () => { const resolve = vi.fn(); pendingApprovals.set('live-bash', { toolName: 'bash', input: { command: 'echo approved once' }, timestamp: 1, resolve, }); const res = await server.inject({ method: 'POST', url: '/api/approval/live-bash', payload: { approved: true, always: true, sourceWorkspaceId: 'w1' }, }); expect(res.statusCode).toBe(200); expect(res.json()).toMatchObject({ approved: true, always: false }); expect(resolve).toHaveBeenCalledWith(true); expect(grantSpy).not.toHaveBeenCalled(); }); it.each([ { id: 'delete-skill', toolName: 'delete_skill', input: { name: 'critical-skill' } }, { id: 'opaque-high', toolName: 'opaque_plugin_action', input: {}, riskLevel: 'high' as const }, { id: 'install-capability', toolName: 'install_capability', input: { name: 'marketplace-skill', source: 'marketplace' }, }, { id: 'connector-send', toolName: 'connector_outlook_send_email', input: { to: 'user@example.test', subject: 'Hello' }, riskLevel: resolveGrantRiskLevel( 'connector_outlook_send_email', { to: 'user@example.test', subject: 'Hello' }, 'medium', ), }, ])('downgrades always approval for critical request $toolName', async ({ id, toolName, input, riskLevel, }) => { const resolve = vi.fn(); pendingApprovals.set(id, { toolName, input, timestamp: 1, riskLevel, resolve, }); const res = await server.inject({ method: 'POST', url: `/api/approval/${id}`, payload: { approved: true, always: true, sourceWorkspaceId: 'w1' }, }); expect(res.statusCode).toBe(200); expect(res.json()).toMatchObject({ approved: true, always: false }); expect(resolve).toHaveBeenCalledWith(true); expect(grantSpy).not.toHaveBeenCalled(); }); it('persists always approval for a grantable scoped tool', async () => { const resolve = vi.fn(); const input = { path: 'notes.txt' }; pendingApprovals.set('live-write-file', { toolName: 'write_file', input, timestamp: 1, resolve, }); const res = await server.inject({ method: 'POST', url: '/api/approval/live-write-file', payload: { approved: true, always: true, sourceWorkspaceId: 'w1' }, }); expect(res.statusCode).toBe(200); expect(res.json()).toMatchObject({ approved: true, always: true }); expect(resolve).toHaveBeenCalledWith(true); expect(grantSpy).toHaveBeenCalledWith( 'write_file', input, 'w1', { trustedRiskLevel: undefined }, ); }); it('POST approve is idempotent — re-approving an executed held id is 409 (already decided)', async () => { hold(); await server.inject({ method: 'POST', url: '/api/approval/pa-1', payload: { approved: true } }); const second = await server.inject({ method: 'POST', url: '/api/approval/pa-1', payload: { approved: true } }); expect(second.statusCode).toBe(409); expect(second.json()).toEqual({ error: 'already_decided', status: 'executed' }); expect(execSpy).toHaveBeenCalledTimes(1); }); }); describe('critical approval grants', () => { it('ignores legacy critical grants, rejects new ones, and preserves scoped file grants', () => { const grantDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-host-grants-')); try { fs.writeFileSync( path.join(grantDir, 'approval-grants.json'), JSON.stringify({ version: 1, grants: [{ id: 'legacy-bash', toolName: 'bash', targetKey: '*', sourceWorkspaceId: 'w1', description: 'legacy shell grant', grantedAt: new Date().toISOString(), expiresAt: null, }, { id: 'legacy-delete-skill', toolName: 'delete_skill', targetKey: '*', sourceWorkspaceId: 'w1', description: 'legacy destructive grant', grantedAt: new Date().toISOString(), expiresAt: null, }, { id: 'legacy-install-capability', toolName: 'install_capability', targetKey: '*', sourceWorkspaceId: 'w1', description: 'legacy install grant', grantedAt: new Date().toISOString(), expiresAt: null, }, { id: 'legacy-connector-send', toolName: 'connector_outlook_send_email', targetKey: '*', sourceWorkspaceId: 'w1', description: 'legacy connector send grant', grantedAt: new Date().toISOString(), expiresAt: null, }], }), 'utf-8', ); const grants = new ApprovalGrantStore(grantDir); for (const toolName of [ 'bash', 'run_code', 'cli_execute', 'install_capability', 'connector_outlook_send_email', ]) { expect(grants.has(toolName, {}, 'w1')).toBe(false); } expect(grants.list()).toEqual([]); for (const toolName of ['bash', 'run_code', 'cli_execute']) { expect(() => grants.grant(toolName, {}, 'w1')).toThrow(/cannot be persisted/i); } const criticalGrants: Array<{ toolName: string; args: Record; options?: { trustedRiskLevel?: 'low' | 'medium' | 'high' | 'critical' }; }> = [ { toolName: 'delete_skill', args: { name: 'critical-skill' } }, { toolName: 'git_push', args: { force: true, branch: 'main' } }, { toolName: 'install_capability', args: { name: 'marketplace-skill', source: 'marketplace' }, }, { toolName: 'connector_outlook_send_email', args: { to: 'user@example.test', subject: 'Hello' }, options: { trustedRiskLevel: 'medium' }, }, { toolName: 'opaque_plugin_action', args: {}, options: { trustedRiskLevel: 'high' } }, ]; for (const { toolName, args, options } of criticalGrants) { expect(() => grants.grant(toolName, args, 'w1', options)).toThrow(/cannot be persisted/i); } grants.grant('write_file', { path: 'notes.txt' }, 'w1'); expect(grants.has('write_file', { path: 'notes.txt' }, 'w1')).toBe(true); expect(resolveGrantRiskLevel( 'connector_outlook_send_email', { to: 'user@example.test' }, 'medium', )).toBe('high'); expect(isGrantableTool( 'connector_outlook_send_email', { to: 'user@example.test' }, 'medium', )).toBe(false); } finally { fs.rmSync(grantDir, { recursive: true, force: true }); } }); });