diff --git a/assistant/src/__tests__/approval-routes-http.test.ts b/assistant/src/__tests__/approval-routes-http.test.ts new file mode 100644 index 00000000000..dcdfbaa7a4a --- /dev/null +++ b/assistant/src/__tests__/approval-routes-http.test.ts @@ -0,0 +1,704 @@ +/** + * HTTP-layer integration tests for the standalone approval endpoints. + * + * Tests POST /v1/confirm, POST /v1/secret, and POST /v1/trust-rules + * through RuntimeHttpServer with pending-interactions tracking. + */ +import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test'; +import { mkdtempSync, rmSync, realpathSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ServerMessage } from '../daemon/ipc-protocol.js'; +import type { Session } from '../daemon/session.js'; + +const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'approval-routes-http-test-'))); + +mock.module('../util/platform.js', () => ({ + getRootDir: () => testDir, + getDataDir: () => testDir, + isMacOS: () => process.platform === 'darwin', + isLinux: () => process.platform === 'linux', + isWindows: () => process.platform === 'win32', + getSocketPath: () => join(testDir, 'test.sock'), + getPidPath: () => join(testDir, 'test.pid'), + getDbPath: () => join(testDir, 'test.db'), + getLogPath: () => join(testDir, 'test.log'), + ensureDataDir: () => {}, +})); + +mock.module('../util/logger.js', () => ({ + getLogger: () => new Proxy({} as Record, { + get: () => () => {}, + }), +})); + +mock.module('../config/loader.js', () => ({ + getConfig: () => ({ + model: 'test', + provider: 'test', + apiKeys: {}, + memory: { enabled: false }, + rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 }, + secretDetection: { enabled: false }, + sandbox: { enabled: false }, + }), +})); + +// Mock the trust store so addRule doesn't touch disk or require full config +mock.module('../permissions/trust-store.js', () => ({ + addRule: () => ({ id: 'test-rule', tool: 'test', pattern: '*', scope: 'everywhere', decision: 'allow', priority: 100 }), + getRules: () => [], +})); + +import { initializeDb, getDb, resetDb } from '../memory/db.js'; +import { RuntimeHttpServer } from '../runtime/http-server.js'; +import { AssistantEventHub } from '../runtime/assistant-event-hub.js'; +import * as pendingInteractions from '../runtime/pending-interactions.js'; + +initializeDb(); + +// --------------------------------------------------------------------------- +// Session helpers +// --------------------------------------------------------------------------- + +function makeIdleSession(opts?: { + onConfirmation?: (requestId: string, decision: string) => void; + onSecret?: (requestId: string, value?: string, delivery?: string) => void; +}): Session { + let processing = false; + return { + isProcessing: () => processing, + persistUserMessage: (_content: string, _attachments: unknown[], requestId?: string) => { + processing = true; + return requestId ?? 'msg-1'; + }, + memoryPolicy: { scopeId: 'default', includeDefaultFallback: false, strictSideEffects: false }, + setChannelCapabilities: () => {}, + setAssistantId: () => {}, + setGuardianContext: () => {}, + setCommandIntent: () => {}, + updateClient: () => {}, + enqueueMessage: () => ({ queued: false, requestId: 'noop' }), + runAgentLoop: async (_content: string, _messageId: string, onEvent: (msg: ServerMessage) => void) => { + onEvent({ type: 'assistant_text_delta', text: 'Hello!' }); + onEvent({ type: 'message_complete', sessionId: 'test-session' }); + processing = false; + }, + handleConfirmationResponse: (requestId: string, decision: string) => { + opts?.onConfirmation?.(requestId, decision); + }, + handleSecretResponse: (requestId: string, value?: string, delivery?: string) => { + opts?.onSecret?.(requestId, value, delivery); + }, + } as unknown as Session; +} + +/** + * Session whose agent loop emits a confirmation_request, so the hub + * publisher registers a pending interaction automatically. + */ +function makeConfirmationEmittingSession(opts?: { + onConfirmation?: (requestId: string, decision: string) => void; + confirmRequestId?: string; + toolName?: string; +}): Session { + let processing = false; + const reqId = opts?.confirmRequestId ?? 'confirm-req-1'; + const tool = opts?.toolName ?? 'shell_command'; + return { + isProcessing: () => processing, + persistUserMessage: (_content: string, _attachments: unknown[], requestId?: string) => { + processing = true; + return requestId ?? 'msg-1'; + }, + memoryPolicy: { scopeId: 'default', includeDefaultFallback: false, strictSideEffects: false }, + setChannelCapabilities: () => {}, + setAssistantId: () => {}, + setGuardianContext: () => {}, + setCommandIntent: () => {}, + updateClient: () => {}, + enqueueMessage: () => ({ queued: false, requestId: 'noop' }), + runAgentLoop: async (_content: string, _messageId: string, onEvent: (msg: ServerMessage) => void) => { + // Emit confirmation_request — this triggers the hub publisher to register + // the pending interaction + onEvent({ + type: 'confirmation_request', + requestId: reqId, + toolName: tool, + input: { command: 'ls' }, + riskLevel: 'medium', + allowlistOptions: [ + { label: 'Allow ls', description: 'Allow ls command', pattern: 'ls' }, + ], + scopeOptions: [ + { label: 'This session', scope: 'session' }, + ], + persistentDecisionsAllowed: true, + }); + // Hang to simulate waiting for decision + await new Promise(() => {}); + }, + handleConfirmationResponse: (requestId: string, decision: string) => { + opts?.onConfirmation?.(requestId, decision); + }, + handleSecretResponse: () => {}, + } as unknown as Session; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const TEST_TOKEN = 'test-bearer-token-approvals'; +const AUTH_HEADERS = { Authorization: `Bearer ${TEST_TOKEN}` }; + +describe('standalone approval endpoints — HTTP layer', () => { + let server: RuntimeHttpServer; + let port: number; + let eventHub: AssistantEventHub; + + beforeEach(() => { + const db = getDb(); + db.run('DELETE FROM messages'); + db.run('DELETE FROM conversations'); + db.run('DELETE FROM conversation_keys'); + pendingInteractions.clear(); + eventHub = new AssistantEventHub(); + }); + + afterAll(() => { + resetDb(); + try { rmSync(testDir, { recursive: true, force: true }); } catch { /* best effort */ } + }); + + async function startServer(sessionFactory: () => Session): Promise { + port = 20000 + Math.floor(Math.random() * 1000); + server = new RuntimeHttpServer({ + port, + bearerToken: TEST_TOKEN, + sendMessageDeps: { + getOrCreateSession: async () => sessionFactory(), + assistantEventHub: eventHub, + resolveAttachments: () => [], + }, + }); + await server.start(); + } + + async function stopServer(): Promise { + await server?.stop(); + } + + function url(path: string): string { + return `http://127.0.0.1:${port}/v1/${path}`; + } + + // ── POST /v1/confirm ───────────────────────────────────────────────── + + describe('POST /v1/confirm', () => { + test('resolves a pending confirmation by requestId', async () => { + let confirmedRequestId: string | undefined; + let confirmedDecision: string | undefined; + + const session = makeIdleSession({ + onConfirmation: (reqId, dec) => { + confirmedRequestId = reqId; + confirmedDecision = dec; + }, + }); + + await startServer(() => session); + + // Manually register a pending interaction + pendingInteractions.register('req-abc', { + session, + conversationId: 'conv-1', + kind: 'confirmation', + confirmationDetails: { + toolName: 'shell_command', + input: { command: 'ls' }, + riskLevel: 'medium', + allowlistOptions: [], + scopeOptions: [], + }, + }); + + const res = await fetch(url('confirm'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'req-abc', decision: 'allow' }), + }); + const body = await res.json() as { accepted: boolean }; + + expect(res.status).toBe(200); + expect(body.accepted).toBe(true); + expect(confirmedRequestId).toBe('req-abc'); + expect(confirmedDecision).toBe('allow'); + + // Interaction should be removed after resolution + expect(pendingInteractions.get('req-abc')).toBeUndefined(); + + await stopServer(); + }); + + test('returns 404 for unknown requestId', async () => { + await startServer(() => makeIdleSession()); + + const res = await fetch(url('confirm'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'nonexistent', decision: 'allow' }), + }); + + expect(res.status).toBe(404); + + await stopServer(); + }); + + test('returns 404 for already-resolved requestId', async () => { + const session = makeIdleSession(); + await startServer(() => session); + + pendingInteractions.register('req-once', { + session, + conversationId: 'conv-1', + kind: 'confirmation', + }); + + // First resolution succeeds + const res1 = await fetch(url('confirm'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'req-once', decision: 'allow' }), + }); + expect(res1.status).toBe(200); + + // Second resolution fails (already consumed) + const res2 = await fetch(url('confirm'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'req-once', decision: 'deny' }), + }); + expect(res2.status).toBe(404); + + await stopServer(); + }); + + test('returns 400 for missing requestId', async () => { + await startServer(() => makeIdleSession()); + + const res = await fetch(url('confirm'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ decision: 'allow' }), + }); + + expect(res.status).toBe(400); + + await stopServer(); + }); + + test('returns 400 for invalid decision', async () => { + await startServer(() => makeIdleSession()); + + const res = await fetch(url('confirm'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'req-1', decision: 'maybe' }), + }); + + expect(res.status).toBe(400); + + await stopServer(); + }); + }); + + // ── POST /v1/secret ────────────────────────────────────────────────── + + describe('POST /v1/secret', () => { + test('resolves a pending secret request by requestId', async () => { + let secretRequestId: string | undefined; + let secretValue: string | undefined; + let secretDelivery: string | undefined; + + const session = makeIdleSession({ + onSecret: (reqId, val, del) => { + secretRequestId = reqId; + secretValue = val; + secretDelivery = del; + }, + }); + + await startServer(() => session); + + pendingInteractions.register('secret-req-1', { + session, + conversationId: 'conv-1', + kind: 'secret', + }); + + const res = await fetch(url('secret'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'secret-req-1', value: 'my-secret-key', delivery: 'store' }), + }); + const body = await res.json() as { accepted: boolean }; + + expect(res.status).toBe(200); + expect(body.accepted).toBe(true); + expect(secretRequestId).toBe('secret-req-1'); + expect(secretValue).toBe('my-secret-key'); + expect(secretDelivery).toBe('store'); + + // Interaction should be removed after resolution + expect(pendingInteractions.get('secret-req-1')).toBeUndefined(); + + await stopServer(); + }); + + test('returns 404 for unknown requestId', async () => { + await startServer(() => makeIdleSession()); + + const res = await fetch(url('secret'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'nonexistent', value: 'test' }), + }); + + expect(res.status).toBe(404); + + await stopServer(); + }); + + test('returns 400 for missing requestId', async () => { + await startServer(() => makeIdleSession()); + + const res = await fetch(url('secret'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ value: 'test' }), + }); + + expect(res.status).toBe(400); + + await stopServer(); + }); + + test('returns 400 for invalid delivery', async () => { + await startServer(() => makeIdleSession()); + + const res = await fetch(url('secret'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'req-1', value: 'test', delivery: 'invalid' }), + }); + + expect(res.status).toBe(400); + + await stopServer(); + }); + }); + + // ── POST /v1/trust-rules ───────────────────────────────────────────── + + describe('POST /v1/trust-rules', () => { + test('returns 404 for unknown requestId', async () => { + await startServer(() => makeIdleSession()); + + const res = await fetch(url('trust-rules'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'nonexistent', pattern: 'ls', scope: 'session', decision: 'allow' }), + }); + + expect(res.status).toBe(404); + + await stopServer(); + }); + + test('returns 400 for missing requestId', async () => { + await startServer(() => makeIdleSession()); + + const res = await fetch(url('trust-rules'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ pattern: 'ls', scope: 'session', decision: 'allow' }), + }); + + expect(res.status).toBe(400); + + await stopServer(); + }); + + test('returns 400 for missing pattern', async () => { + await startServer(() => makeIdleSession()); + + const res = await fetch(url('trust-rules'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'req-1', scope: 'session', decision: 'allow' }), + }); + + expect(res.status).toBe(400); + + await stopServer(); + }); + + test('returns 400 for missing scope', async () => { + await startServer(() => makeIdleSession()); + + const res = await fetch(url('trust-rules'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'req-1', pattern: 'ls', decision: 'allow' }), + }); + + expect(res.status).toBe(400); + + await stopServer(); + }); + + test('returns 400 for invalid decision', async () => { + await startServer(() => makeIdleSession()); + + const res = await fetch(url('trust-rules'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'req-1', pattern: 'ls', scope: 'session', decision: 'maybe' }), + }); + + expect(res.status).toBe(400); + + await stopServer(); + }); + + test('returns 409 when no confirmation details available', async () => { + const session = makeIdleSession(); + await startServer(() => session); + + // Register without confirmationDetails + pendingInteractions.register('req-no-details', { + session, + conversationId: 'conv-1', + kind: 'secret', + }); + + const res = await fetch(url('trust-rules'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'req-no-details', pattern: 'ls', scope: 'session', decision: 'allow' }), + }); + + expect(res.status).toBe(409); + + await stopServer(); + }); + + test('returns 403 when persistent decisions are not allowed', async () => { + const session = makeIdleSession(); + await startServer(() => session); + + pendingInteractions.register('req-no-persist', { + session, + conversationId: 'conv-1', + kind: 'confirmation', + confirmationDetails: { + toolName: 'shell_command', + input: { command: 'rm -rf' }, + riskLevel: 'high', + allowlistOptions: [{ label: 'Allow', description: 'test', pattern: 'rm' }], + scopeOptions: [{ label: 'Session', scope: 'session' }], + persistentDecisionsAllowed: false, + }, + }); + + const res = await fetch(url('trust-rules'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'req-no-persist', pattern: 'rm', scope: 'session', decision: 'allow' }), + }); + + expect(res.status).toBe(403); + + await stopServer(); + }); + + test('returns 403 when pattern does not match allowlist', async () => { + const session = makeIdleSession(); + await startServer(() => session); + + pendingInteractions.register('req-bad-pattern', { + session, + conversationId: 'conv-1', + kind: 'confirmation', + confirmationDetails: { + toolName: 'shell_command', + input: { command: 'ls' }, + riskLevel: 'medium', + allowlistOptions: [{ label: 'Allow ls', description: 'test', pattern: 'ls' }], + scopeOptions: [{ label: 'Session', scope: 'session' }], + }, + }); + + const res = await fetch(url('trust-rules'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'req-bad-pattern', pattern: 'rm', scope: 'session', decision: 'allow' }), + }); + + expect(res.status).toBe(403); + const body = await res.json() as { error: string }; + expect(body.error).toContain('pattern'); + + await stopServer(); + }); + + test('returns 403 when scope does not match scope options', async () => { + const session = makeIdleSession(); + await startServer(() => session); + + pendingInteractions.register('req-bad-scope', { + session, + conversationId: 'conv-1', + kind: 'confirmation', + confirmationDetails: { + toolName: 'shell_command', + input: { command: 'ls' }, + riskLevel: 'medium', + allowlistOptions: [{ label: 'Allow ls', description: 'test', pattern: 'ls' }], + scopeOptions: [{ label: 'Session', scope: 'session' }], + }, + }); + + const res = await fetch(url('trust-rules'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'req-bad-scope', pattern: 'ls', scope: 'global', decision: 'allow' }), + }); + + expect(res.status).toBe(403); + const body = await res.json() as { error: string }; + expect(body.error).toContain('scope'); + + await stopServer(); + }); + + test('does not remove the pending interaction after adding trust rule', async () => { + const session = makeIdleSession(); + await startServer(() => session); + + pendingInteractions.register('req-keep', { + session, + conversationId: 'conv-1', + kind: 'confirmation', + confirmationDetails: { + toolName: 'shell_command', + input: { command: 'ls' }, + riskLevel: 'medium', + allowlistOptions: [{ label: 'Allow ls', description: 'test', pattern: 'ls' }], + scopeOptions: [{ label: 'Session', scope: 'session' }], + }, + }); + + const res = await fetch(url('trust-rules'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'req-keep', pattern: 'ls', scope: 'session', decision: 'allow' }), + }); + + expect(res.status).toBe(200); + + // Interaction should still be present (not consumed) + expect(pendingInteractions.get('req-keep')).toBeDefined(); + + await stopServer(); + }); + }); + + // ── Hub publisher integration ──────────────────────────────────────── + + describe('hub publisher registers pending interactions', () => { + test('confirmation_request events register pending interactions', async () => { + const confirmReceived: Array<{ requestId: string; decision: string }> = []; + + const session = makeConfirmationEmittingSession({ + confirmRequestId: 'auto-req-1', + toolName: 'shell_command', + onConfirmation: (reqId, dec) => { + confirmReceived.push({ requestId: reqId, decision: dec }); + }, + }); + + await startServer(() => session); + + // Send a message that triggers a confirmation_request + const res = await fetch(url('messages'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ conversationKey: 'conv-auto', content: 'Run ls', sourceChannel: 'macos' }), + }); + expect(res.status).toBe(202); + + // Wait for the agent loop to emit the confirmation_request + await new Promise((r) => setTimeout(r, 100)); + + // The pending interaction should have been auto-registered + const interaction = pendingInteractions.get('auto-req-1'); + expect(interaction).toBeDefined(); + expect(interaction!.kind).toBe('confirmation'); + expect(interaction!.confirmationDetails?.toolName).toBe('shell_command'); + + // Now resolve it via the confirm endpoint + const confirmRes = await fetch(url('confirm'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS }, + body: JSON.stringify({ requestId: 'auto-req-1', decision: 'allow' }), + }); + expect(confirmRes.status).toBe(200); + + expect(confirmReceived).toHaveLength(1); + expect(confirmReceived[0].requestId).toBe('auto-req-1'); + expect(confirmReceived[0].decision).toBe('allow'); + + await stopServer(); + }); + }); + + // ── getByConversation ──────────────────────────────────────────────── + + describe('getByConversation', () => { + test('returns all pending interactions for a conversation', async () => { + const session = makeIdleSession(); + await startServer(() => session); + + pendingInteractions.register('req-a', { + session, + conversationId: 'conv-x', + kind: 'confirmation', + }); + pendingInteractions.register('req-b', { + session, + conversationId: 'conv-x', + kind: 'secret', + }); + pendingInteractions.register('req-c', { + session, + conversationId: 'conv-y', + kind: 'confirmation', + }); + + const results = pendingInteractions.getByConversation('conv-x'); + expect(results).toHaveLength(2); + expect(results.map((r) => r.requestId).sort()).toEqual(['req-a', 'req-b']); + + const resultsY = pendingInteractions.getByConversation('conv-y'); + expect(resultsY).toHaveLength(1); + expect(resultsY[0].requestId).toBe('req-c'); + + const resultsZ = pendingInteractions.getByConversation('conv-z'); + expect(resultsZ).toHaveLength(0); + + await stopServer(); + }); + }); +}); diff --git a/assistant/src/runtime/http-server.ts b/assistant/src/runtime/http-server.ts index ff8a8a4562d..34a2fe61319 100644 --- a/assistant/src/runtime/http-server.ts +++ b/assistant/src/runtime/http-server.ts @@ -35,6 +35,11 @@ import { handleRunSecret, handleAddTrustRule, } from './routes/run-routes.js'; +import { + handleConfirm, + handleSecret, + handleTrustRule, +} from './routes/approval-routes.js'; import { handleDeleteConversation, handleChannelInbound, @@ -566,6 +571,11 @@ export class RuntimeHttpServer { }); } + // Standalone approval endpoints — keyed by requestId, orthogonal to message sending + if (endpoint === 'confirm' && req.method === 'POST') return await handleConfirm(req); + if (endpoint === 'secret' && req.method === 'POST') return await handleSecret(req); + if (endpoint === 'trust-rules' && req.method === 'POST') return await handleTrustRule(req); + if (endpoint === 'attachments' && req.method === 'POST') return await handleUploadAttachment(req); if (endpoint === 'attachments' && req.method === 'DELETE') return await handleDeleteAttachment(req); diff --git a/assistant/src/runtime/pending-interactions.ts b/assistant/src/runtime/pending-interactions.ts new file mode 100644 index 00000000000..2bbff554da3 --- /dev/null +++ b/assistant/src/runtime/pending-interactions.ts @@ -0,0 +1,73 @@ +/** + * In-memory tracker that maps requestId to session info for pending + * confirmation and secret interactions. + * + * When the agent loop emits a confirmation_request or secret_request, + * the onEvent callback registers the interaction here. Standalone HTTP + * endpoints (/v1/confirm, /v1/secret, /v1/trust-rules) look up the + * session from this tracker to resolve the interaction. + */ + +import type { Session } from '../daemon/session.js'; + +export interface ConfirmationDetails { + toolName: string; + input: Record; + riskLevel: string; + executionTarget?: 'sandbox' | 'host'; + allowlistOptions: Array<{ label: string; description: string; pattern: string }>; + scopeOptions: Array<{ label: string; scope: string }>; + persistentDecisionsAllowed?: boolean; +} + +export interface PendingInteraction { + session: Session; + conversationId: string; + kind: 'confirmation' | 'secret'; + confirmationDetails?: ConfirmationDetails; +} + +const pending = new Map(); + +export function register(requestId: string, interaction: PendingInteraction): void { + pending.set(requestId, interaction); +} + +/** + * Remove and return the pending interaction for the given requestId. + * Returns undefined if no interaction is registered. + */ +export function resolve(requestId: string): PendingInteraction | undefined { + const interaction = pending.get(requestId); + if (interaction) { + pending.delete(requestId); + } + return interaction; +} + +/** + * Return the pending interaction without removing it. + * Used by trust-rule endpoint which doesn't resolve the confirmation itself. + */ +export function get(requestId: string): PendingInteraction | undefined { + return pending.get(requestId); +} + +/** + * Return all pending interactions for a given conversation. + * Needed by channel approval migration (PR 3). + */ +export function getByConversation(conversationId: string): Array<{ requestId: string } & PendingInteraction> { + const results: Array<{ requestId: string } & PendingInteraction> = []; + for (const [requestId, interaction] of pending) { + if (interaction.conversationId === conversationId) { + results.push({ requestId, ...interaction }); + } + } + return results; +} + +/** Clear all pending interactions. Useful for testing. */ +export function clear(): void { + pending.clear(); +} diff --git a/assistant/src/runtime/routes/approval-routes.ts b/assistant/src/runtime/routes/approval-routes.ts new file mode 100644 index 00000000000..65354dfc3f8 --- /dev/null +++ b/assistant/src/runtime/routes/approval-routes.ts @@ -0,0 +1,179 @@ +/** + * Route handlers for standalone approval endpoints. + * + * These endpoints resolve pending confirmations, secrets, and trust rules + * by requestId — orthogonal to message sending. + */ +import * as pendingInteractions from '../pending-interactions.js'; +import { addRule } from '../../permissions/trust-store.js'; +import { getTool } from '../../tools/registry.js'; +import { getLogger } from '../../util/logger.js'; + +const log = getLogger('approval-routes'); + +/** + * POST /v1/confirm — resolve a pending confirmation by requestId. + */ +export async function handleConfirm(req: Request): Promise { + const body = await req.json() as { + requestId?: string; + decision?: string; + }; + + const { requestId, decision } = body; + + if (!requestId || typeof requestId !== 'string') { + return Response.json({ error: 'requestId is required' }, { status: 400 }); + } + + if (decision !== 'allow' && decision !== 'deny') { + return Response.json( + { error: 'decision must be "allow" or "deny"' }, + { status: 400 }, + ); + } + + const interaction = pendingInteractions.resolve(requestId); + if (!interaction) { + return Response.json( + { error: 'No pending interaction found for this requestId' }, + { status: 404 }, + ); + } + + interaction.session.handleConfirmationResponse(requestId, decision); + return Response.json({ accepted: true }); +} + +/** + * POST /v1/secret — resolve a pending secret request by requestId. + */ +export async function handleSecret(req: Request): Promise { + const body = await req.json() as { + requestId?: string; + value?: string; + delivery?: string; + }; + + const { requestId, value, delivery } = body; + + if (!requestId || typeof requestId !== 'string') { + return Response.json({ error: 'requestId is required' }, { status: 400 }); + } + + if (delivery !== undefined && delivery !== 'store' && delivery !== 'transient_send') { + return Response.json( + { error: 'delivery must be "store" or "transient_send"' }, + { status: 400 }, + ); + } + + const interaction = pendingInteractions.resolve(requestId); + if (!interaction) { + return Response.json( + { error: 'No pending interaction found for this requestId' }, + { status: 404 }, + ); + } + + interaction.session.handleSecretResponse( + requestId, + value, + delivery as 'store' | 'transient_send' | undefined, + ); + return Response.json({ accepted: true }); +} + +/** + * POST /v1/trust-rules — add a trust rule for a pending confirmation. + * + * Does NOT resolve the confirmation itself (the client still needs to + * POST /v1/confirm to approve/deny). Validates the pattern and scope + * against the server-provided allowlist options from the original + * confirmation_request. + */ +export async function handleTrustRule(req: Request): Promise { + const body = await req.json() as { + requestId?: string; + pattern?: string; + scope?: string; + decision?: string; + }; + + const { requestId, pattern, scope, decision } = body; + + if (!requestId || typeof requestId !== 'string') { + return Response.json({ error: 'requestId is required' }, { status: 400 }); + } + + if (!pattern || typeof pattern !== 'string') { + return Response.json({ error: 'pattern is required' }, { status: 400 }); + } + + if (!scope || typeof scope !== 'string') { + return Response.json({ error: 'scope is required' }, { status: 400 }); + } + + if (decision !== 'allow' && decision !== 'deny') { + return Response.json({ error: 'decision must be "allow" or "deny"' }, { status: 400 }); + } + + // Look up without removing — trust rule doesn't resolve the confirmation + const interaction = pendingInteractions.get(requestId); + if (!interaction) { + return Response.json( + { error: 'No pending interaction found for this requestId' }, + { status: 404 }, + ); + } + + if (!interaction.confirmationDetails) { + return Response.json( + { error: 'No confirmation details available for this request' }, + { status: 409 }, + ); + } + + const confirmation = interaction.confirmationDetails; + + if (confirmation.persistentDecisionsAllowed === false) { + return Response.json( + { error: 'Persistent trust rules are not allowed for this tool invocation' }, + { status: 403 }, + ); + } + + // Validate pattern against server-provided allowlist options + const validPatterns = (confirmation.allowlistOptions ?? []).map((o) => o.pattern); + if (!validPatterns.includes(pattern)) { + return Response.json( + { error: 'pattern does not match any server-provided allowlist option' }, + { status: 403 }, + ); + } + + // Validate scope against server-provided scope options + const validScopes = (confirmation.scopeOptions ?? []).map((o) => o.scope); + if (!validScopes.includes(scope)) { + return Response.json( + { error: 'scope does not match any server-provided scope option' }, + { status: 403 }, + ); + } + + try { + const tool = getTool(confirmation.toolName); + const executionTarget = tool?.origin === 'skill' ? confirmation.executionTarget : undefined; + addRule(confirmation.toolName, pattern, scope, decision, undefined, { + executionTarget, + }); + log.info( + { tool: confirmation.toolName, pattern, scope, decision, requestId }, + 'Trust rule added via HTTP (bound to pending confirmation)', + ); + return Response.json({ accepted: true }); + } catch (err) { + log.error({ err }, 'Failed to add trust rule'); + return Response.json({ error: 'Failed to add trust rule' }, { status: 500 }); + } +} diff --git a/assistant/src/runtime/routes/conversation-routes.ts b/assistant/src/runtime/routes/conversation-routes.ts index c437313608a..65481d7ce16 100644 --- a/assistant/src/runtime/routes/conversation-routes.ts +++ b/assistant/src/runtime/routes/conversation-routes.ts @@ -22,6 +22,7 @@ import type { } from '../http-types.js'; import type { ServerMessage } from '../../daemon/ipc-protocol.js'; import { buildAssistantEvent } from '../assistant-event.js'; +import * as pendingInteractions from '../pending-interactions.js'; import { getLogger } from '../../util/logger.js'; const log = getLogger('conversation-routes'); @@ -143,13 +144,42 @@ export function handleListMessages( /** * Build an `onEvent` callback that publishes every outbound event to the * assistant event hub, maintaining ordered delivery through a serial chain. + * + * Also registers pending interactions when confirmation_request or + * secret_request events flow through, so standalone approval endpoints + * can look up the session by requestId. */ function makeHubPublisher( deps: SendMessageDeps, conversationId: string, + session: import('../../daemon/session.js').Session, ): (msg: ServerMessage) => void { let hubChain: Promise = Promise.resolve(); return (msg: ServerMessage) => { + // Register pending interactions for approval events + if (msg.type === 'confirmation_request') { + pendingInteractions.register(msg.requestId, { + session, + conversationId, + kind: 'confirmation', + confirmationDetails: { + toolName: msg.toolName, + input: msg.input, + riskLevel: msg.riskLevel, + executionTarget: msg.executionTarget, + allowlistOptions: msg.allowlistOptions, + scopeOptions: msg.scopeOptions, + persistentDecisionsAllowed: msg.persistentDecisionsAllowed, + }, + }); + } else if (msg.type === 'secret_request') { + pendingInteractions.register(msg.requestId, { + session, + conversationId, + kind: 'secret', + }); + } + const msgRecord = msg as unknown as Record; const msgSessionId = 'sessionId' in msg && typeof msgRecord.sessionId === 'string' @@ -243,7 +273,7 @@ export async function handleSendMessage( if (deps.sendMessageDeps) { const smDeps = deps.sendMessageDeps; const session = await smDeps.getOrCreateSession(mapping.conversationId); - const onEvent = makeHubPublisher(smDeps, mapping.conversationId); + const onEvent = makeHubPublisher(smDeps, mapping.conversationId, session); const attachments = hasAttachments ? smDeps.resolveAttachments(attachmentIds)