From ef6ae36a250ea2dc9d73c76764e10c165c25420e Mon Sep 17 00:00:00 2001 From: jasonli0226 Date: Mon, 20 Jul 2026 01:04:02 +0800 Subject: [PATCH 01/10] feat(approvals): add ApprovalRequest/UserToolApproval models and repositories Persistence layer for tool-call approvals: the ApprovalRequest (one row per non-memory gate hit) and UserToolApproval (standing always-scope allow/deny) models, the TOOL_APPROVAL notification type, and the MCP annotation/approval-override columns. Adds repositories for both tables and registers them in DbModule. --- .../migration.sql | 52 +++++++++++ packages/api/prisma/schema.prisma | 48 ++++++++++ .../__tests__/approval-repositories.test.ts | 82 +++++++++++++++++ .../api/src/db/approval-request.repository.ts | 92 +++++++++++++++++++ packages/api/src/db/db.module.ts | 4 + .../src/db/user-tool-approval.repository.ts | 79 ++++++++++++++++ 6 files changed, 357 insertions(+) create mode 100644 packages/api/prisma/migrations/20260707002033_tool_approval/migration.sql create mode 100644 packages/api/src/db/__tests__/approval-repositories.test.ts create mode 100644 packages/api/src/db/approval-request.repository.ts create mode 100644 packages/api/src/db/user-tool-approval.repository.ts diff --git a/packages/api/prisma/migrations/20260707002033_tool_approval/migration.sql b/packages/api/prisma/migrations/20260707002033_tool_approval/migration.sql new file mode 100644 index 0000000..252ea5d --- /dev/null +++ b/packages/api/prisma/migrations/20260707002033_tool_approval/migration.sql @@ -0,0 +1,52 @@ +-- Add TOOL_APPROVAL to NotificationType enum +ALTER TYPE "NotificationType" ADD VALUE 'TOOL_APPROVAL'; + +-- Create ApprovalStatus enum +CREATE TYPE "ApprovalStatus" AS ENUM ('PENDING', 'APPROVED', 'DENIED', 'EXPIRED', 'CANCELLED'); + +-- Create ApprovalRequest table +CREATE TABLE "ApprovalRequest" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "agentRunId" TEXT NOT NULL, + "sessionId" TEXT, + "toolName" TEXT NOT NULL, + "paramsSummary" JSONB NOT NULL, + "status" "ApprovalStatus" NOT NULL DEFAULT 'PENDING', + "scope" TEXT, + "resolutionReason" TEXT, + "memoryStore" TEXT, + "memoryKey" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "resolvedAt" TIMESTAMP(3), + + CONSTRAINT "ApprovalRequest_pkey" PRIMARY KEY ("id") +); + +-- Create indexes for ApprovalRequest +CREATE INDEX "ApprovalRequest_userId_status_idx" ON "ApprovalRequest"("userId", "status"); +CREATE INDEX "ApprovalRequest_agentRunId_idx" ON "ApprovalRequest"("agentRunId"); + +-- Create UserToolApproval table +CREATE TABLE "UserToolApproval" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "toolName" TEXT NOT NULL, + "resourceKey" TEXT NOT NULL DEFAULT '', + "decision" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserToolApproval_pkey" PRIMARY KEY ("id") +); + +-- Create unique constraint for UserToolApproval +CREATE UNIQUE INDEX "UserToolApproval_userId_toolName_resourceKey_key" ON "UserToolApproval"("userId", "toolName", "resourceKey"); + +-- Create index for UserToolApproval +CREATE INDEX "UserToolApproval_userId_idx" ON "UserToolApproval"("userId"); + +-- Add annotations column to McpTool +ALTER TABLE "McpTool" ADD COLUMN "annotations" JSONB; + +-- Add approvalOverrides column to McpConnection +ALTER TABLE "McpConnection" ADD COLUMN "approvalOverrides" JSONB; diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index 413d1d6..e7de03b 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -542,6 +542,7 @@ enum NotificationType { GROUP_INVITE_RESPONSE PRIMARY_AGENT_ASSIGNED MCP_SERVER_ATTENTION + TOOL_APPROVAL } model Notification { @@ -558,6 +559,51 @@ model Notification { @@index([recipientId, isRead]) } +enum ApprovalStatus { + PENDING + APPROVED + DENIED + EXPIRED + CANCELLED +} + +/// One row per approval gate hit that was not silently served from memory-allow. +/// Rows for system outcomes (standing_deny, no_channel, subagent, restart_sweep) +/// are created pre-resolved with resolutionReason set. Append-mostly; only +/// status/scope/resolutionReason/memory fields and resolvedAt flip, exactly once. +model ApprovalRequest { + id String @id @default(cuid()) + userId String + agentRunId String + sessionId String? + toolName String + paramsSummary Json // redacted/truncated display copy — never the raw args + status ApprovalStatus @default(PENDING) + scope String? // once | session | always — user's tier, or the deny tier that fired for standing/session-deny rows + resolutionReason String? // standing_deny | session_deny | timeout | no_channel | subagent | restart_sweep | run_aborted + memoryStore String? // 'session' | 'persistent' — set when resolution wrote or applied a memory entry + memoryKey String? // `${toolName}:${resourceKey}` — server-trusted /revoke target; never client-supplied + createdAt DateTime @default(now()) + resolvedAt DateTime? + + @@index([userId, status]) + @@index([agentRunId]) +} + +/// Standing (always-scope) allow/deny decisions. Only run/resource-scoped +/// tools get rows; call-scoped tools never persist (chat-click ceiling). +model UserToolApproval { + id String @id @default(cuid()) + userId String + toolName String + resourceKey String @default("") // '' = run-scope sentinel (NOT NULL: Postgres treats NULLs as distinct in uniques). Run-scoped tools always write '', resource-scoped always non-empty. + decision String // 'allowed' | 'denied' + createdAt DateTime @default(now()) + + @@unique([userId, toolName, resourceKey]) + @@index([userId]) +} + // ============================================================================ // System Settings (single-row, JSON + Zod) // ============================================================================ @@ -633,6 +679,7 @@ model McpTool { name String description String inputSchema Json + annotations Json? // MCP spec tool annotations (readOnlyHint etc.) captured at discovery — untrusted hints scanFlagged Boolean @default(false) // description failed prompt-injection scan scanReason String? createdAt DateTime @default(now()) @@ -652,6 +699,7 @@ model McpConnection { lastError String? lastDiscoveredAt DateTime? tiers Json? // { recommended: string[], optional: string[], off: string[] } — null until set + approvalOverrides Json? // { [toolName]: 'ask' | 'allow' } — sparse overlay; null until any tool is toggled. Only gates tools that are already bound; tier off ⇒ inert. createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/packages/api/src/db/__tests__/approval-repositories.test.ts b/packages/api/src/db/__tests__/approval-repositories.test.ts new file mode 100644 index 0000000..f5d7f5c --- /dev/null +++ b/packages/api/src/db/__tests__/approval-repositories.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ApprovalRequestRepository } from '../approval-request.repository.js'; +import { UserToolApprovalRepository } from '../user-tool-approval.repository.js'; + +const prismaMock = () => + ({ + approvalRequest: { + create: vi.fn(), + findUnique: vi.fn(), + findMany: vi.fn(), + updateMany: vi.fn(), + }, + userToolApproval: { + upsert: vi.fn(), + findMany: vi.fn(), + deleteMany: vi.fn(), + }, + }) as never; + +describe('ApprovalRequestRepository', () => { + it('transitionStatus guards on the from-status and reports success', async () => { + const prisma = prismaMock(); + (prisma as any).approvalRequest.updateMany.mockResolvedValue({ count: 1 }); + const repo = new ApprovalRequestRepository(prisma); + + const ok = await repo.transitionStatus('apr1', 'PENDING', { + status: 'APPROVED', + scope: 'once', + }); + + expect(ok).toBe(true); + expect((prisma as any).approvalRequest.updateMany).toHaveBeenCalledWith({ + where: { id: 'apr1', status: 'PENDING' }, + data: expect.objectContaining({ + status: 'APPROVED', + scope: 'once', + resolvedAt: expect.any(Date), + }), + }); + }); + + it('transitionStatus returns false when the row already left the from-status', async () => { + const prisma = prismaMock(); + (prisma as any).approvalRequest.updateMany.mockResolvedValue({ count: 0 }); + const repo = new ApprovalRequestRepository(prisma); + expect(await repo.transitionStatus('apr1', 'PENDING', { status: 'DENIED' })).toBe(false); + }); + + it('expireAllPending stamps reason and returns count', async () => { + const prisma = prismaMock(); + (prisma as any).approvalRequest.updateMany.mockResolvedValue({ count: 3 }); + const repo = new ApprovalRequestRepository(prisma); + expect(await repo.expireAllPending('restart_sweep')).toBe(3); + expect((prisma as any).approvalRequest.updateMany).toHaveBeenCalledWith({ + where: { status: 'PENDING' }, + data: expect.objectContaining({ status: 'EXPIRED', resolutionReason: 'restart_sweep' }), + }); + }); +}); + +describe('UserToolApprovalRepository', () => { + it('deleteById is recipient-guarded', async () => { + const prisma = prismaMock(); + (prisma as any).userToolApproval.deleteMany.mockResolvedValue({ count: 0 }); + const repo = new UserToolApprovalRepository(prisma); + expect(await repo.deleteById('row1', 'other-user')).toBe(false); + expect((prisma as any).userToolApproval.deleteMany).toHaveBeenCalledWith({ + where: { id: 'row1', userId: 'other-user' }, + }); + }); + + it('findForKeys queries both specificity levels in one call', async () => { + const prisma = prismaMock(); + (prisma as any).userToolApproval.findMany.mockResolvedValue([]); + const repo = new UserToolApprovalRepository(prisma); + await repo.findForKeys('u1', 'wiki_delete', ['page-a', '']); + expect((prisma as any).userToolApproval.findMany).toHaveBeenCalledWith({ + where: { userId: 'u1', toolName: 'wiki_delete', resourceKey: { in: ['page-a', ''] } }, + }); + }); +}); diff --git a/packages/api/src/db/approval-request.repository.ts b/packages/api/src/db/approval-request.repository.ts new file mode 100644 index 0000000..d4e1b11 --- /dev/null +++ b/packages/api/src/db/approval-request.repository.ts @@ -0,0 +1,92 @@ +import { Injectable } from '@nestjs/common'; + +import type { ApprovalRequest, ApprovalStatus, Prisma } from '../generated/prisma/client.js'; +import { PrismaService } from '../prisma/prisma.service.js'; + +interface CreateInput { + readonly userId: string; + readonly agentRunId: string; + readonly sessionId?: string | null; + readonly toolName: string; + readonly paramsSummary: Prisma.InputJsonValue; + readonly status?: ApprovalStatus; + readonly scope?: string; + readonly resolutionReason?: string; + readonly memoryStore?: string; + readonly memoryKey?: string; + readonly resolvedAt?: Date; +} + +/** + * Repository for ApprovalRequest data access. + * Provides atomic status transitions and expiration utilities. + */ +@Injectable() +export class ApprovalRequestRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(input: CreateInput): Promise { + return this.prisma.approvalRequest.create({ + data: { + userId: input.userId, + agentRunId: input.agentRunId, + sessionId: input.sessionId ?? null, + toolName: input.toolName, + paramsSummary: input.paramsSummary, + status: input.status ?? 'PENDING', + scope: input.scope, + resolutionReason: input.resolutionReason, + memoryStore: input.memoryStore, + memoryKey: input.memoryKey, + resolvedAt: input.resolvedAt, + }, + }); + } + + async findById(id: string): Promise { + return this.prisma.approvalRequest.findUnique({ + where: { id }, + }); + } + + async listPendingForUser(userId: string): Promise { + return this.prisma.approvalRequest.findMany({ + where: { userId, status: 'PENDING' }, + }); + } + + /** + * Atomically transition a request from one status to another. + * The `from` status acts as a guard — returns true only if the row + * matched the id AND was in the `from` status. + */ + async transitionStatus( + id: string, + from: ApprovalStatus, + patch: { + status: ApprovalStatus; + scope?: string; + resolutionReason?: string; + memoryStore?: string; + memoryKey?: string; + }, + ): Promise { + const result = await this.prisma.approvalRequest.updateMany({ + where: { id, status: from }, + data: { ...patch, resolvedAt: new Date() }, + }); + return result.count === 1; + } + + /** + * Expire all pending requests in a startup sweep. + * Returns the count of rows updated. + */ + async expireAllPending(reason: string): Promise { + const result = await this.prisma.approvalRequest.updateMany({ + where: { status: 'PENDING' }, + data: { status: 'EXPIRED', resolutionReason: reason, resolvedAt: new Date() }, + }); + return result.count; + } +} diff --git a/packages/api/src/db/db.module.ts b/packages/api/src/db/db.module.ts index 8ddb454..4356fcd 100644 --- a/packages/api/src/db/db.module.ts +++ b/packages/api/src/db/db.module.ts @@ -17,6 +17,8 @@ import { SystemSettingsRepository } from './system-settings.repository.js'; import { GroupRepository } from './group.repository.js'; import { GroupInviteRepository } from './group-invite.repository.js'; import { NotificationRepository } from './notification.repository.js'; +import { ApprovalRequestRepository } from './approval-request.repository.js'; +import { UserToolApprovalRepository } from './user-tool-approval.repository.js'; import { WikiPageRepository } from './wiki-page.repository.js'; import { WikiLinkRepository } from './wiki-link.repository.js'; import { WikiShareRepository } from './wiki-share.repository.js'; @@ -42,6 +44,8 @@ const repositories = [ GroupRepository, GroupInviteRepository, NotificationRepository, + ApprovalRequestRepository, + UserToolApprovalRepository, WikiPageRepository, WikiLinkRepository, WikiShareRepository, diff --git a/packages/api/src/db/user-tool-approval.repository.ts b/packages/api/src/db/user-tool-approval.repository.ts new file mode 100644 index 0000000..055985c --- /dev/null +++ b/packages/api/src/db/user-tool-approval.repository.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; + +import type { UserToolApproval } from '../generated/prisma/client.js'; +import { PrismaService } from '../prisma/prisma.service.js'; + +interface UpsertInput { + readonly userId: string; + readonly toolName: string; + readonly resourceKey: string; + readonly decision: 'allowed' | 'denied'; +} + +/** + * Repository for UserToolApproval data access. + * Manages per-user standing decisions for tool and resource combinations. + */ +@Injectable() +export class UserToolApprovalRepository { + constructor(private readonly prisma: PrismaService) {} + + /** + * Create or update a user's tool approval decision. + * Uses a compound unique key (userId, toolName, resourceKey). + */ + async upsert(input: UpsertInput): Promise { + const { userId, toolName, resourceKey, decision } = input; + return this.prisma.userToolApproval.upsert({ + where: { userId_toolName_resourceKey: { userId, toolName, resourceKey } }, + create: { userId, toolName, resourceKey, decision }, + update: { decision }, + }); + } + + /** + * Find all approvals for a user across multiple resource keys + * in a single query. Queries both specificity levels (specific + run-scope sentinel ''). + */ + async findForKeys( + userId: string, + toolName: string, + resourceKeys: readonly string[], + ): Promise { + return this.prisma.userToolApproval.findMany({ + where: { userId, toolName, resourceKey: { in: [...resourceKeys] } }, + }); + } + + /** + * List all standing approvals for a user. + */ + async listForUser(userId: string): Promise { + return this.prisma.userToolApproval.findMany({ + where: { userId }, + }); + } + + /** + * Delete a specific approval by id, guarded by userId. + * Returns true if a row was deleted, false if the id/userId mismatch + * (prevents cross-user deletion). + */ + async deleteById(id: string, userId: string): Promise { + const result = await this.prisma.userToolApproval.deleteMany({ + where: { id, userId }, + }); + return result.count === 1; + } + + /** + * Delete all approvals matching a specific resource key. + * Returns true if at least one row was deleted. + */ + async deleteByKey(userId: string, toolName: string, resourceKey: string): Promise { + const result = await this.prisma.userToolApproval.deleteMany({ + where: { userId, toolName, resourceKey }, + }); + return result.count > 0; + } +} From ed798f9b687701e5ae8bf38e8648ac5ea44fca10 Mon Sep 17 00:00:00 2001 From: jasonli0226 Date: Mon, 20 Jul 2026 01:04:15 +0800 Subject: [PATCH 02/10] feat(approvals): rendezvous gate, domain types, and frame delivery Core of the tool-approval system: - ToolApprovalService rendezvous gate with resolve/revoke/timeout/abort and a restart sweep; precedence lookup over standing, session, and per-run memory. - Domain types (offered-scope/downgrade rules), params redaction, and metrics. - REST controllers for resolve/revoke/pending and standing approvals. - Pub/sub approval-frame delivery keyed off cache.constants. - Tool interface gains requiresApproval/approvalScope/resourceKey fields; adds the run-activity module the gate reads. --- .../__tests__/approval-delivery.test.ts | 107 ++++ .../__tests__/approvals-controller.test.ts | 109 ++++ .../__tests__/params-summary.test.ts | 64 +++ .../__tests__/tool-approval-lookup.test.ts | 138 +++++ .../tool-approval-rendezvous.test.ts | 282 +++++++++ .../approvals/approval-delivery.service.ts | 140 +++++ .../api/src/approvals/approval-metrics.ts | 12 + packages/api/src/approvals/approval.types.ts | 67 +++ .../api/src/approvals/approvals.controller.ts | 85 +++ .../api/src/approvals/approvals.module.ts | 36 ++ packages/api/src/approvals/params-summary.ts | 26 + .../src/approvals/tool-approval.service.ts | 533 ++++++++++++++++++ .../approvals/tool-approvals.controller.ts | 52 ++ packages/api/src/cache/cache.constants.ts | 1 + .../api/src/engine/run-activity.module.ts | 20 + packages/api/src/engine/tool.ts | 21 + 16 files changed, 1693 insertions(+) create mode 100644 packages/api/src/approvals/__tests__/approval-delivery.test.ts create mode 100644 packages/api/src/approvals/__tests__/approvals-controller.test.ts create mode 100644 packages/api/src/approvals/__tests__/params-summary.test.ts create mode 100644 packages/api/src/approvals/__tests__/tool-approval-lookup.test.ts create mode 100644 packages/api/src/approvals/__tests__/tool-approval-rendezvous.test.ts create mode 100644 packages/api/src/approvals/approval-delivery.service.ts create mode 100644 packages/api/src/approvals/approval-metrics.ts create mode 100644 packages/api/src/approvals/approval.types.ts create mode 100644 packages/api/src/approvals/approvals.controller.ts create mode 100644 packages/api/src/approvals/approvals.module.ts create mode 100644 packages/api/src/approvals/params-summary.ts create mode 100644 packages/api/src/approvals/tool-approval.service.ts create mode 100644 packages/api/src/approvals/tool-approvals.controller.ts create mode 100644 packages/api/src/engine/run-activity.module.ts diff --git a/packages/api/src/approvals/__tests__/approval-delivery.test.ts b/packages/api/src/approvals/__tests__/approval-delivery.test.ts new file mode 100644 index 0000000..27c46cc --- /dev/null +++ b/packages/api/src/approvals/__tests__/approval-delivery.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ApprovalDeliveryService } from '../approval-delivery.service.js'; + +const deps = () => ({ + pubsub: { publish: vi.fn(async () => undefined) }, + fanout: { create: vi.fn(async () => ({})) }, +}); + +const promptInput = { + approvalId: 'apr1', + userId: 'u1', + sessionId: 's1', + toolName: 'wiki_delete', + paramsSummary: { pageId: 'p1' }, + offeredScopes: ['allow_once', 'deny_once'] as const, + reason: 'no_memory', + expiresAt: new Date('2026-07-06T12:00:00Z'), +}; + +describe('ApprovalDeliveryService', () => { + it('deliverPrompt publishes a prompt frame and a TOOL_APPROVAL bell notification', async () => { + const d = deps(); + const svc = new ApprovalDeliveryService(d.pubsub as never, d.fanout as never); + await svc.deliverPrompt(promptInput); + expect(d.pubsub.publish).toHaveBeenCalledWith( + expect.stringContaining('approval:frame'), + expect.objectContaining({ kind: 'prompt', approvalId: 'apr1', sessionId: 's1' }), + ); + expect(d.fanout.create).toHaveBeenCalledWith( + expect.objectContaining({ + recipientId: 'u1', + type: 'TOOL_APPROVAL', + payload: expect.objectContaining({ + approvalId: 'apr1', + offeredScopes: promptInput.offeredScopes, + }), + }), + ); + }); + + it('deliverPrompt with null sessionId throws (gate maps to no_channel)', async () => { + const d = deps(); + const svc = new ApprovalDeliveryService(d.pubsub as never, d.fanout as never); + await expect(svc.deliverPrompt({ ...promptInput, sessionId: null })).rejects.toThrow(); + expect(d.pubsub.publish).not.toHaveBeenCalled(); + expect(d.fanout.create).not.toHaveBeenCalled(); + }); + + it('deliverNotice publishes a notice frame and a TOOL_APPROVAL bell notification', async () => { + const d = deps(); + const svc = new ApprovalDeliveryService(d.pubsub as never, d.fanout as never); + await svc.deliverNotice({ + approvalId: 'apr2', + userId: 'u1', + sessionId: 's1', + toolName: 'wiki_delete', + paramsSummary: { pageId: 'p1' }, + reason: 'standing_deny', + }); + expect(d.pubsub.publish).toHaveBeenCalledWith( + expect.stringContaining('approval:frame'), + expect.objectContaining({ kind: 'notice', approvalId: 'apr2', reason: 'standing_deny' }), + ); + expect(d.fanout.create).toHaveBeenCalledWith( + expect.objectContaining({ + recipientId: 'u1', + type: 'TOOL_APPROVAL', + payload: expect.objectContaining({ approvalId: 'apr2' }), + }), + ); + }); + + it('deliverNotice with null sessionId still publishes (only deliverPrompt fails closed on no_channel)', async () => { + const d = deps(); + const svc = new ApprovalDeliveryService(d.pubsub as never, d.fanout as never); + await svc.deliverNotice({ + approvalId: 'apr3', + userId: 'u1', + sessionId: null, + toolName: 'wiki_delete', + paramsSummary: {}, + reason: 'session_deny', + }); + expect(d.pubsub.publish).toHaveBeenCalledWith( + expect.stringContaining('approval:frame'), + expect.objectContaining({ kind: 'notice', sessionId: null }), + ); + }); + + it('deliverResolved publishes without creating a notification', async () => { + const d = deps(); + const svc = new ApprovalDeliveryService(d.pubsub as never, d.fanout as never); + await svc.deliverResolved({ + approvalId: 'apr1', + userId: 'u1', + sessionId: 's1', + status: 'APPROVED', + scope: 'once', + }); + expect(d.pubsub.publish).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ kind: 'resolved', status: 'APPROVED' }), + ); + expect(d.fanout.create).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/approvals/__tests__/approvals-controller.test.ts b/packages/api/src/approvals/__tests__/approvals-controller.test.ts new file mode 100644 index 0000000..a5d32e4 --- /dev/null +++ b/packages/api/src/approvals/__tests__/approvals-controller.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ApprovalsController } from '../approvals.controller.js'; +import { ToolApprovalsController } from '../tool-approvals.controller.js'; + +const req = { user: { sub: 'u1' } } as never; + +describe('ApprovalsController', () => { + it('resolve delegates with the JWT user and 404s on failure', async () => { + const svc = { + resolve: vi.fn(async () => ({ ok: false, downgraded: false })), + listPending: vi.fn(), + revoke: vi.fn(), + }; + const ctrl = new ApprovalsController(svc as never); + await expect(ctrl.resolve('apr1', { choice: 'allow_once' }, req)).rejects.toThrow(/not found/i); + expect(svc.resolve).toHaveBeenCalledWith('apr1', 'u1', 'allow_once'); + }); + + it('resolve returns downgraded flag on success', async () => { + const svc = { + resolve: vi.fn(async () => ({ ok: true, downgraded: true })), + listPending: vi.fn(), + revoke: vi.fn(), + }; + const ctrl = new ApprovalsController(svc as never); + expect(await ctrl.resolve('apr1', { choice: 'deny_always' }, req)).toEqual({ + success: true, + data: { downgraded: true }, + }); + }); + + it('rejects an unknown choice with 400', async () => { + const svc = { resolve: vi.fn(), listPending: vi.fn(), revoke: vi.fn() }; + const ctrl = new ApprovalsController(svc as never); + await expect(ctrl.resolve('apr1', { choice: 'yolo' } as never, req)).rejects.toThrow( + /invalid/i, + ); + expect(svc.resolve).not.toHaveBeenCalled(); + }); + + it('listPending delegates with the JWT user', async () => { + const rows = [{ id: 'apr1' }]; + const svc = { + resolve: vi.fn(), + listPending: vi.fn(async () => rows), + revoke: vi.fn(), + }; + const ctrl = new ApprovalsController(svc as never); + expect(await ctrl.listPending(req)).toEqual({ success: true, data: rows }); + expect(svc.listPending).toHaveBeenCalledWith('u1'); + }); + + it('revoke delegates with the JWT user and 404s on failure', async () => { + const svc = { + resolve: vi.fn(), + listPending: vi.fn(), + revoke: vi.fn(async () => false), + }; + const ctrl = new ApprovalsController(svc as never); + await expect(ctrl.revoke('apr1', req)).rejects.toThrow(/not found/i); + expect(svc.revoke).toHaveBeenCalledWith('apr1', 'u1'); + }); + + it('revoke succeeds when the service reports removal', async () => { + const svc = { + resolve: vi.fn(), + listPending: vi.fn(), + revoke: vi.fn(async () => true), + }; + const ctrl = new ApprovalsController(svc as never); + expect(await ctrl.revoke('apr1', req)).toEqual({ success: true }); + }); +}); + +describe('ToolApprovalsController', () => { + it('list delegates with the JWT user', async () => { + const rows = [{ id: 'row1' }]; + const repo = { listForUser: vi.fn(async () => rows), deleteById: vi.fn() }; + const audit = { create: vi.fn() }; + const ctrl = new ToolApprovalsController(repo as never, audit as never); + expect(await ctrl.list(req)).toEqual({ success: true, data: rows }); + expect(repo.listForUser).toHaveBeenCalledWith('u1'); + }); + + it('delete is identity-guarded through the repository', async () => { + const repo = { listForUser: vi.fn(), deleteById: vi.fn(async () => false) }; + const audit = { create: vi.fn() }; + const ctrl = new ToolApprovalsController(repo as never, audit as never); + await expect(ctrl.remove('row1', req)).rejects.toThrow(/not found/i); + expect(repo.deleteById).toHaveBeenCalledWith('row1', 'u1'); + expect(audit.create).not.toHaveBeenCalled(); + }); + + it('delete audits approval.revoke with source=settings on success', async () => { + const repo = { listForUser: vi.fn(), deleteById: vi.fn(async () => true) }; + const audit = { create: vi.fn(async () => undefined) }; + const ctrl = new ToolApprovalsController(repo as never, audit as never); + expect(await ctrl.remove('row1', req)).toEqual({ success: true }); + expect(audit.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'u1', + action: 'approval.revoke', + resourceId: 'row1', + details: expect.objectContaining({ source: 'settings' }), + }), + ); + }); +}); diff --git a/packages/api/src/approvals/__tests__/params-summary.test.ts b/packages/api/src/approvals/__tests__/params-summary.test.ts new file mode 100644 index 0000000..76ee88d --- /dev/null +++ b/packages/api/src/approvals/__tests__/params-summary.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { downgradeChoice, offeredScopes } from '../approval.types.js'; +import { redactParamsSummary } from '../params-summary.js'; + +describe('offeredScopes', () => { + it('caps call-scoped tools at session in both directions', () => { + expect(offeredScopes('call', false)).toEqual([ + 'allow_once', + 'allow_session', + 'deny_once', + 'deny_session', + ]); + }); + it('offers only once-tier when the extractor failed', () => { + expect(offeredScopes('resource', true)).toEqual(['allow_once', 'deny_once']); + }); +}); + +describe('downgradeChoice (symmetric, spec §6)', () => { + const callOffered = offeredScopes('call', false); + it('downgrades forged allow_always to allow_session', () => { + expect(downgradeChoice('allow_always', callOffered)).toEqual({ + choice: 'allow_session', + downgraded: true, + }); + }); + it('downgrades forged deny_always to deny_session', () => { + expect(downgradeChoice('deny_always', callOffered)).toEqual({ + choice: 'deny_session', + downgraded: true, + }); + }); + it('passes in-scope choices through untouched', () => { + expect(downgradeChoice('deny_session', callOffered)).toEqual({ + choice: 'deny_session', + downgraded: false, + }); + }); +}); + +describe('redactParamsSummary', () => { + it('masks values under secret-shaped keys', () => { + const out = redactParamsSummary({ apiKey: 'sk-12345', body: 'hello' }); + expect(out['apiKey']).toBe('***'); + expect(out['body']).toBe('hello'); + }); + it('masks secret-shaped values regardless of key', () => { + const out = redactParamsSummary({ note: 'header Authorization: Bearer abc.def.ghi' }); + expect(out['note']).toContain('***'); + expect(out['note']).not.toContain('abc.def.ghi'); + }); + it('truncates long values to 200 chars with ellipsis', () => { + const out = redactParamsSummary({ text: 'x'.repeat(500) }); + expect(out['text']).toHaveLength(201); // 200 + '…' + expect(out['text']?.endsWith('…')).toBe(true); + }); + it('stringifies non-string values', () => { + expect(redactParamsSummary({ count: 3, nested: { a: 1 } })).toEqual({ + count: '3', + nested: '{"a":1}', + }); + }); +}); diff --git a/packages/api/src/approvals/__tests__/tool-approval-lookup.test.ts b/packages/api/src/approvals/__tests__/tool-approval-lookup.test.ts new file mode 100644 index 0000000..b700fd5 --- /dev/null +++ b/packages/api/src/approvals/__tests__/tool-approval-lookup.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; + +import { ToolApprovalService } from '../tool-approval.service.js'; + +// Minimal fakes: only what lookup touches. +function makeService(persistentRows: { resourceKey: string; decision: string }[]) { + const userToolApprovalRepo = { + findForKeys: vi.fn().mockResolvedValue( + persistentRows.map((r, i) => ({ + id: `row${i}`, + userId: 'u1', + toolName: 'wiki_delete', + ...r, + })), + ), + }; + const svc = new ToolApprovalService( + userToolApprovalRepo as never, + // Task 5 adds more deps; keep constructor order stable: see implementation. + ...([] as never[]), + ); + return { svc, userToolApprovalRepo }; +} + +const base = { + userId: 'u1', + sessionId: 's1', + toolName: 'wiki_delete', + resourceKey: 'page-a', + extractorFailed: false, +}; + +describe('precedence truth table (spec §5)', () => { + it('no entries anywhere → prompt(no_memory)', async () => { + const { svc } = makeService([]); + expect(await svc.lookup(base)).toEqual({ kind: 'prompt', reason: 'no_memory' }); + }); + + it('resource-level persistent deny beats run-level allow', async () => { + const { svc } = makeService([ + { resourceKey: 'page-a', decision: 'denied' }, + { resourceKey: '', decision: 'allowed' }, + ]); + const r = await svc.lookup(base); + expect(r.kind).toBe('deny'); + if (r.kind === 'deny') expect(r.hit).toMatchObject({ store: 'persistent', scope: 'always' }); + }); + + it('resource-level allow beats run-level deny (more-specific wins)', async () => { + const { svc } = makeService([ + { resourceKey: 'page-a', decision: 'allowed' }, + { resourceKey: '', decision: 'denied' }, + ]); + expect((await svc.lookup(base)).kind).toBe('allow'); + }); + + it('equal specificity: session deny beats persistent allow (deny wins across stores)', async () => { + const { svc } = makeService([{ resourceKey: 'page-a', decision: 'allowed' }]); + svc.writeSessionMemory('s1', 'wiki_delete', 'page-a', 'deny'); + const r = await svc.lookup(base); + expect(r.kind).toBe('deny'); + if (r.kind === 'deny') expect(r.hit).toMatchObject({ store: 'session', scope: 'session' }); + }); + + it('session allow at resource level short-circuits without persistent rows', async () => { + const { svc } = makeService([]); + svc.writeSessionMemory('s1', 'wiki_delete', 'page-a', 'allow'); + expect((await svc.lookup(base)).kind).toBe('allow'); + }); + + it('run-level session deny fires when no resource-level entries exist', async () => { + const { svc } = makeService([]); + svc.writeSessionMemory('s1', 'wiki_delete', '', 'deny'); + expect((await svc.lookup(base)).kind).toBe('deny'); + }); + + it('null sessionId consults only persistent rows', async () => { + const { svc } = makeService([{ resourceKey: '', decision: 'allowed' }]); + expect((await svc.lookup({ ...base, sessionId: null })).kind).toBe('allow'); + }); + + it('extractorFailed → prompt(extractor_error), memory not consulted at resource level', async () => { + const { svc } = makeService([]); + const r = await svc.lookup({ ...base, resourceKey: '', extractorFailed: true }); + expect(r).toEqual({ kind: 'prompt', reason: 'extractor_error' }); + }); + + it('clearSessionMemory evaporates session entries', async () => { + const { svc } = makeService([]); + svc.writeSessionMemory('s1', 'wiki_delete', 'page-a', 'deny'); + svc.clearSessionMemory('s1'); + expect((await svc.lookup(base)).kind).toBe('prompt'); + }); +}); + +describe('session TTL (spec §4)', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('sliding renewal: read at ~23h renews; entry still hits at >24h-from-write but <24h-from-touch', async () => { + vi.useFakeTimers(); + const { svc } = makeService([]); + + // Write entry at t=0 + const now = new Date('2026-01-01T00:00:00Z').getTime(); + vi.setSystemTime(now); + svc.writeSessionMemory('s1', 'wiki_delete', 'page-a', 'allow'); + + // Advance ~23h and read (should hit and refresh touchedAt) + vi.advanceTimersByTime(23 * 60 * 60 * 1000); + const r1 = await svc.lookup(base); + expect(r1.kind).toBe('allow'); + + // Advance another ~23h (total >24h from write, but <24h from the refresh read) + vi.advanceTimersByTime(23 * 60 * 60 * 1000); + const r2 = await svc.lookup(base); + // Entry should still hit because the touch in the previous read refreshed the TTL + expect(r2.kind).toBe('allow'); + }); + + it('eviction: read after >24h with no intervening touch → entry expired, returns prompt', async () => { + vi.useFakeTimers(); + const { svc } = makeService([]); + + // Write entry at t=0 + const now = new Date('2026-01-01T00:00:00Z').getTime(); + vi.setSystemTime(now); + svc.writeSessionMemory('s1', 'wiki_delete', 'page-a', 'deny'); + + // Advance >24h without reading + vi.advanceTimersByTime(24 * 60 * 60 * 1000 + 1000); + + // Lookup should return prompt (entry expired and evicted) + const r = await svc.lookup(base); + expect(r).toEqual({ kind: 'prompt', reason: 'no_memory' }); + }); +}); diff --git a/packages/api/src/approvals/__tests__/tool-approval-rendezvous.test.ts b/packages/api/src/approvals/__tests__/tool-approval-rendezvous.test.ts new file mode 100644 index 0000000..64b5c8a --- /dev/null +++ b/packages/api/src/approvals/__tests__/tool-approval-rendezvous.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; + +import { ToolApprovalService } from '../tool-approval.service.js'; + +function makeService(overrides: { deliverPromptFails?: boolean } = {}) { + const rows = new Map>(); + let nextId = 0; + const approvalRepo = { + create: vi.fn(async (input: Record) => { + const id = `apr${++nextId}`; + rows.set(id, { id, status: 'PENDING', ...input }); + return rows.get(id); + }), + findById: vi.fn(async (id: string) => rows.get(id) ?? null), + listPendingForUser: vi.fn(async () => []), + transitionStatus: vi.fn(async (id: string, from: string, patch: Record) => { + const row = rows.get(id); + if (!row || row['status'] !== from) return false; + Object.assign(row, patch, { resolvedAt: new Date() }); + return true; + }), + expireAllPending: vi.fn(async () => 0), + }; + const userToolApprovalRepo = { + findForKeys: vi.fn(async () => []), + upsert: vi.fn(async (i: Record) => ({ id: 'uta1', ...i })), + deleteByKey: vi.fn(async () => true), + }; + const auditRepo = { create: vi.fn(async () => ({})) }; + const delivery = { + deliverPrompt: overrides.deliverPromptFails + ? vi.fn(async () => { + throw new Error('no adapter'); + }) + : vi.fn(async () => undefined), + deliverNotice: vi.fn(async () => undefined), + deliverResolved: vi.fn(async () => undefined), + }; + const runActivity = { recordToolStart: vi.fn(), recordToolEnd: vi.fn() }; + const svc = new ToolApprovalService( + userToolApprovalRepo as never, + approvalRepo as never, + auditRepo as never, + delivery as never, + runActivity as never, + ); + return { svc, approvalRepo, userToolApprovalRepo, auditRepo, delivery, rows }; +} + +const gateInput = { + userId: 'u1', + agentRunId: 'run1', + sessionId: 's1', + isSubAgent: false, + toolName: 'wiki_delete', + scopeKind: 'resource' as const, + resourceKey: 'page-a', + extractorFailed: false, + params: { pageId: 'page-a' }, +}; + +describe('gate — rendezvous', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('allow_once resolves the wait, writes no memory', async () => { + const { svc, approvalRepo, userToolApprovalRepo, delivery } = makeService(); + const gatePromise = svc.gate(gateInput); + await vi.waitFor(() => expect(delivery.deliverPrompt).toHaveBeenCalled()); + const approvalId = delivery.deliverPrompt.mock.calls[0]?.[0]?.approvalId as string; + + const res = await svc.resolve(approvalId, 'u1', 'allow_once'); + expect(res).toEqual({ ok: true, downgraded: false }); + expect(await gatePromise).toEqual({ allowed: true }); + expect(userToolApprovalRepo.upsert).not.toHaveBeenCalled(); + expect(approvalRepo.transitionStatus).toHaveBeenCalledWith( + approvalId, + 'PENDING', + expect.objectContaining({ status: 'APPROVED', scope: 'once' }), + ); + }); + + it('deny_always writes persistent deny with memoryStore/memoryKey stamped', async () => { + const { svc, delivery, userToolApprovalRepo, rows } = makeService(); + const gatePromise = svc.gate(gateInput); + await vi.waitFor(() => expect(delivery.deliverPrompt).toHaveBeenCalled()); + const approvalId = delivery.deliverPrompt.mock.calls[0]?.[0]?.approvalId as string; + + await svc.resolve(approvalId, 'u1', 'deny_always'); + const result = await gatePromise; + expect(result.allowed).toBe(false); + expect(userToolApprovalRepo.upsert).toHaveBeenCalledWith({ + userId: 'u1', + toolName: 'wiki_delete', + resourceKey: 'page-a', + decision: 'denied', + }); + expect(rows.get(approvalId)).toMatchObject({ + memoryStore: 'persistent', + memoryKey: 'wiki_delete:page-a', + scope: 'always', + }); + }); + + it('timeout expires the row, blocks, writes no memory', async () => { + const { svc, delivery, userToolApprovalRepo, rows } = makeService(); + const gatePromise = svc.gate(gateInput); + await vi.waitFor(() => expect(delivery.deliverPrompt).toHaveBeenCalled()); + await vi.advanceTimersByTimeAsync(300_001); + const result = await gatePromise; + expect(result.allowed).toBe(false); + if (!result.allowed) expect(result.blockedMessage).toContain('Silence is not consent'); + expect(userToolApprovalRepo.upsert).not.toHaveBeenCalled(); + const row = [...rows.values()][0]; + expect(row).toMatchObject({ status: 'EXPIRED', resolutionReason: 'timeout' }); + }); + + it('abort signal cancels the pending request', async () => { + const { svc, delivery, rows } = makeService(); + const controller = new AbortController(); + const gatePromise = svc.gate({ ...gateInput, abortSignal: controller.signal }); + await vi.waitFor(() => expect(delivery.deliverPrompt).toHaveBeenCalled()); + controller.abort(); + const result = await gatePromise; + expect(result.allowed).toBe(false); + expect([...rows.values()][0]).toMatchObject({ + status: 'CANCELLED', + resolutionReason: 'run_aborted', + }); + }); + + it('pre-aborted signal short-circuits gate without waiting for timeout', async () => { + const { svc, delivery, userToolApprovalRepo, rows } = makeService(); + const controller = new AbortController(); + controller.abort(); // Abort BEFORE calling gate() + const gatePromise = svc.gate({ ...gateInput, abortSignal: controller.signal }); + const result = await gatePromise; + expect(result.allowed).toBe(false); + expect([...rows.values()][0]).toMatchObject({ + status: 'CANCELLED', + resolutionReason: 'run_aborted', + }); + // Verify no memory was written (no upsert call) + expect(userToolApprovalRepo.upsert).not.toHaveBeenCalled(); + // Verify it settled immediately (didn't wait for timeout) + expect(delivery.deliverPrompt).toHaveBeenCalled(); + }); + + it('resolve after timeout loses the race (atomic transition)', async () => { + const { svc, delivery } = makeService(); + const gatePromise = svc.gate(gateInput); + await vi.waitFor(() => expect(delivery.deliverPrompt).toHaveBeenCalled()); + const approvalId = delivery.deliverPrompt.mock.calls[0]?.[0]?.approvalId as string; + await vi.advanceTimersByTimeAsync(300_001); + await gatePromise; + expect((await svc.resolve(approvalId, 'u1', 'allow_once')).ok).toBe(false); + }); + + it('foreign user cannot resolve', async () => { + const { svc, delivery } = makeService(); + const gatePromise = svc.gate(gateInput); + await vi.waitFor(() => expect(delivery.deliverPrompt).toHaveBeenCalled()); + const approvalId = delivery.deliverPrompt.mock.calls[0]?.[0]?.approvalId as string; + expect((await svc.resolve(approvalId, 'attacker', 'allow_once')).ok).toBe(false); + // still resolvable by the owner afterwards + expect((await svc.resolve(approvalId, 'u1', 'deny_once')).ok).toBe(true); + await gatePromise; + }); + + it('sub-agent gate auto-denies without prompting and writes no memory', async () => { + const { svc, delivery, rows, userToolApprovalRepo } = makeService(); + const result = await svc.gate({ ...gateInput, isSubAgent: true }); + expect(result.allowed).toBe(false); + if (!result.allowed) expect(result.blockedMessage).toContain('sub-agents cannot request'); + expect(delivery.deliverPrompt).not.toHaveBeenCalled(); + expect(userToolApprovalRepo.upsert).not.toHaveBeenCalled(); + expect([...rows.values()][0]).toMatchObject({ + status: 'DENIED', + resolutionReason: 'subagent', + }); + }); + + it('sub-agent honors existing session allow (memory flows via shared sessionId)', async () => { + const { svc } = makeService(); + svc.writeSessionMemory('s1', 'wiki_delete', 'page-a', 'allow'); + expect(await svc.gate({ ...gateInput, isSubAgent: true })).toEqual({ allowed: true }); + }); + + it('standing persistent deny → immediate pre-resolved DENIED row + notice, no prompt', async () => { + const made = makeService(); + made.userToolApprovalRepo.findForKeys.mockResolvedValue([ + { + id: 'r1', + userId: 'u1', + toolName: 'wiki_delete', + resourceKey: 'page-a', + decision: 'denied', + }, + ]); + const result = await made.svc.gate(gateInput); + expect(result.allowed).toBe(false); + if (!result.allowed) expect(result.blockedMessage).toContain('standing denial'); + expect(made.delivery.deliverNotice).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'standing_deny' }), + ); + expect(made.delivery.deliverPrompt).not.toHaveBeenCalled(); + expect([...made.rows.values()][0]).toMatchObject({ + status: 'DENIED', + resolutionReason: 'standing_deny', + scope: 'always', + memoryStore: 'persistent', + memoryKey: 'wiki_delete:page-a', + }); + }); + + it('delivery failure → no_channel auto-deny, no memory', async () => { + const { svc, rows } = makeService({ deliverPromptFails: true }); + const result = await svc.gate(gateInput); + expect(result.allowed).toBe(false); + expect([...rows.values()][0]).toMatchObject({ + status: 'DENIED', + resolutionReason: 'no_channel', + }); + }); + + it('null sessionId (cron) → no_channel auto-deny without attempting delivery', async () => { + const { svc, delivery } = makeService(); + const result = await svc.gate({ ...gateInput, sessionId: null }); + expect(result.allowed).toBe(false); + expect(delivery.deliverPrompt).not.toHaveBeenCalled(); + }); + + it('revoke removes exactly the entry the row references (server-trusted, client key ignored)', async () => { + const { svc, delivery, userToolApprovalRepo } = makeService(); + const gatePromise = svc.gate(gateInput); + await vi.waitFor(() => expect(delivery.deliverPrompt).toHaveBeenCalled()); + const approvalId = delivery.deliverPrompt.mock.calls[0]?.[0]?.approvalId as string; + await svc.resolve(approvalId, 'u1', 'deny_always'); + await gatePromise; + + expect(await svc.revoke(approvalId, 'u1')).toBe(true); + expect(userToolApprovalRepo.deleteByKey).toHaveBeenCalledWith('u1', 'wiki_delete', 'page-a'); + expect(await svc.revoke(approvalId, 'attacker')).toBe(false); + }); + + it('session-scoped revoke removes session entry; subsequent lookup returns prompt', async () => { + const { svc, delivery } = makeService(); + const gatePromise = svc.gate(gateInput); + await vi.waitFor(() => expect(delivery.deliverPrompt).toHaveBeenCalled()); + const approvalId = delivery.deliverPrompt.mock.calls[0]?.[0]?.approvalId as string; + + // Resolve with deny_session to write a session entry + await svc.resolve(approvalId, 'u1', 'deny_session'); + await gatePromise; + + // Verify the session memory holds the deny + let result = await svc.lookup({ + userId: 'u1', + sessionId: 's1', + toolName: 'wiki_delete', + resourceKey: 'page-a', + extractorFailed: false, + }); + expect(result.kind).toBe('deny'); + if (result.kind === 'deny') { + expect(result.hit).toMatchObject({ store: 'session', scope: 'session' }); + } + + // Revoke the session entry + expect(await svc.revoke(approvalId, 'u1')).toBe(true); + + // Verify the session entry is gone; lookup now returns prompt + result = await svc.lookup({ + userId: 'u1', + sessionId: 's1', + toolName: 'wiki_delete', + resourceKey: 'page-a', + extractorFailed: false, + }); + expect(result.kind).toBe('prompt'); + }); +}); diff --git a/packages/api/src/approvals/approval-delivery.service.ts b/packages/api/src/approvals/approval-delivery.service.ts new file mode 100644 index 0000000..72318c5 --- /dev/null +++ b/packages/api/src/approvals/approval-delivery.service.ts @@ -0,0 +1,140 @@ +import { Injectable } from '@nestjs/common'; + +import { RedisPubSubService } from '../cache/redis-pubsub.service.js'; +import { PUBSUB_CHANNELS } from '../cache/cache.constants.js'; +import { NotificationFanoutService } from '../notifications/notifications.fanout.js'; +import type { ApprovalChoice } from './approval.types.js'; +import type { ApprovalDeliveryPort } from './tool-approval.service.js'; + +/** + * Pub/sub payload shape for approval frames. Consumed by `ChannelManagerService` + * (routes to the session's adapter) and, in later tasks, by Telegram (Task 12) + * and the web dashboard UI (Task 13). + */ +export interface ApprovalFramePayload { + readonly kind: 'prompt' | 'notice' | 'resolved'; + readonly sessionId: string | null; // routing handle — ChannelManager resolves adapter + recipient + readonly userId: string; + readonly approvalId: string; + readonly toolName?: string; + readonly paramsSummary?: Record; + readonly offeredScopes?: readonly string[]; + readonly reason?: string; + readonly expiresAt?: string; // ISO + readonly status?: string; + readonly scope?: string; +} + +/** + * Real `ApprovalDeliveryPort` implementation: publishes a frame on the + * `approvalFrame` pub/sub channel (picked up by `ChannelManagerService` and + * routed to the requesting session's channel adapter) and, for prompts and + * notices, raises a `TOOL_APPROVAL` bell notification via the fanout. + * + * `deliverPrompt` throws when `sessionId` is `null` — `ToolApprovalService.gate` + * treats any throw from `deliverPrompt` as the `no_channel` signal and fails + * closed (auto-deny). Resolved frames intentionally do NOT raise a bell + * notification — the user already has the approval card open from the + * prompt/notice and a resolved-frame bell would just be noise. + */ +@Injectable() +export class ApprovalDeliveryService implements ApprovalDeliveryPort { + constructor( + private readonly pubsub: RedisPubSubService, + private readonly fanout: NotificationFanoutService, + ) {} + + async deliverPrompt(req: { + approvalId: string; + userId: string; + sessionId: string | null; + toolName: string; + paramsSummary: Record; + offeredScopes: readonly ApprovalChoice[]; + reason: string; + expiresAt: Date; + }): Promise { + if (req.sessionId === null) { + // No delivery channel for this run — the caller (ToolApprovalService.gate) + // treats this throw as the no_channel signal and fails closed. + throw new Error('ApprovalDeliveryService.deliverPrompt: sessionId is null (no channel)'); + } + + const payload: ApprovalFramePayload = { + kind: 'prompt', + sessionId: req.sessionId, + userId: req.userId, + approvalId: req.approvalId, + toolName: req.toolName, + paramsSummary: req.paramsSummary, + offeredScopes: req.offeredScopes, + reason: req.reason, + expiresAt: req.expiresAt.toISOString(), + }; + await this.pubsub.publish(PUBSUB_CHANNELS.approvalFrame, payload); + await this.fanout.create({ + recipientId: req.userId, + type: 'TOOL_APPROVAL', + payload: { + approvalId: req.approvalId, + toolName: req.toolName, + paramsSummary: req.paramsSummary, + offeredScopes: req.offeredScopes, + reason: req.reason, + expiresAt: payload.expiresAt, + }, + }); + } + + async deliverNotice(req: { + approvalId: string; + userId: string; + sessionId: string | null; + toolName: string; + paramsSummary: Record; + reason: 'standing_deny' | 'session_deny'; + }): Promise { + const payload: ApprovalFramePayload = { + kind: 'notice', + sessionId: req.sessionId, + userId: req.userId, + approvalId: req.approvalId, + toolName: req.toolName, + paramsSummary: req.paramsSummary, + reason: req.reason, + }; + await this.pubsub.publish(PUBSUB_CHANNELS.approvalFrame, payload); + await this.fanout.create({ + recipientId: req.userId, + type: 'TOOL_APPROVAL', + payload: { + approvalId: req.approvalId, + toolName: req.toolName, + paramsSummary: req.paramsSummary, + reason: req.reason, + }, + }); + } + + async deliverResolved(req: { + approvalId: string; + userId: string; + sessionId: string | null; + toolName?: string; + status: string; + scope?: string; + }): Promise { + const payload: ApprovalFramePayload = { + kind: 'resolved', + sessionId: req.sessionId, + userId: req.userId, + approvalId: req.approvalId, + status: req.status, + ...(req.toolName !== undefined ? { toolName: req.toolName } : {}), + ...(req.scope !== undefined ? { scope: req.scope } : {}), + }; + await this.pubsub.publish(PUBSUB_CHANNELS.approvalFrame, payload); + // Intentionally no fanout.create — resolved frames don't raise a bell + // notification (spec Step 1: only prompt/notice do). + } +} diff --git a/packages/api/src/approvals/approval-metrics.ts b/packages/api/src/approvals/approval-metrics.ts new file mode 100644 index 0000000..508d232 --- /dev/null +++ b/packages/api/src/approvals/approval-metrics.ts @@ -0,0 +1,12 @@ +import { Counter } from 'prom-client'; + +export const toolApprovalsTotal = new Counter({ + name: 'clawix_tool_approvals_total', + help: 'Tool-approval outcomes', + labelNames: ['outcome'] as const, +}); + +export const approvalExtractorErrorsTotal = new Counter({ + name: 'clawix_approval_extractor_errors_total', + help: 'resource-scoped tools whose resourceKeyFromParams threw or returned empty', +}); diff --git a/packages/api/src/approvals/approval.types.ts b/packages/api/src/approvals/approval.types.ts new file mode 100644 index 0000000..37d8df5 --- /dev/null +++ b/packages/api/src/approvals/approval.types.ts @@ -0,0 +1,67 @@ +export type ApprovalScopeKind = 'run' | 'resource' | 'call'; + +export type ApprovalChoice = + | 'allow_once' + | 'allow_session' + | 'allow_always' + | 'deny_once' + | 'deny_session' + | 'deny_always'; + +export type MemoryDecision = 'allow' | 'deny'; + +/** `${toolName}:${resourceKey}` — resourceKey '' for run/call scopes. */ +export function memoryKey(toolName: string, resourceKey: string): string { + return `${toolName}:${resourceKey}`; +} + +/** Scopes offerable for a prompt, computed server-side (spec §6/§7). */ +export function offeredScopes( + scopeKind: ApprovalScopeKind, + extractorFailed: boolean, +): readonly ApprovalChoice[] { + if (extractorFailed) return ['allow_once', 'deny_once']; + if (scopeKind === 'call') return ['allow_once', 'allow_session', 'deny_once', 'deny_session']; + return [ + 'allow_once', + 'allow_session', + 'allow_always', + 'deny_once', + 'deny_session', + 'deny_always', + ]; +} + +/** Server-side symmetric downgrade of an out-of-scope choice (spec §6). */ +export function downgradeChoice( + choice: ApprovalChoice, + offered: readonly ApprovalChoice[], +): { choice: ApprovalChoice; downgraded: boolean } { + if (offered.includes(choice)) return { choice, downgraded: false }; + const ladder: Record = { + allow_always: ['allow_session', 'allow_once'], + allow_session: ['allow_once'], + allow_once: [], + deny_always: ['deny_session', 'deny_once'], + deny_session: ['deny_once'], + deny_once: [], + }; + for (const next of ladder[choice]) { + if (offered.includes(next)) return { choice: next, downgraded: true }; + } + // Fall back to the weakest same-direction choice; offered always contains both *_once. + const fallback: ApprovalChoice = choice.startsWith('allow') ? 'allow_once' : 'deny_once'; + return { choice: fallback, downgraded: choice !== fallback }; +} + +export interface LookupHit { + readonly decision: MemoryDecision; + readonly store: 'session' | 'persistent'; + readonly key: string; + readonly scope: 'session' | 'always'; +} + +export type LookupResult = + | { readonly kind: 'allow'; readonly hit: LookupHit } + | { readonly kind: 'deny'; readonly hit: LookupHit } + | { readonly kind: 'prompt'; readonly reason: 'no_memory' | 'extractor_error' }; diff --git a/packages/api/src/approvals/approvals.controller.ts b/packages/api/src/approvals/approvals.controller.ts new file mode 100644 index 0000000..908f9aa --- /dev/null +++ b/packages/api/src/approvals/approvals.controller.ts @@ -0,0 +1,85 @@ +import { + BadRequestException, + Body, + Controller, + Get, + NotFoundException, + Param, + Post, + Req, +} from '@nestjs/common'; +import { z } from 'zod'; + +import type { JwtPayload } from '../auth/auth.types.js'; +import type { ApprovalRequest } from '../generated/prisma/client.js'; +import { ToolApprovalService } from './tool-approval.service.js'; + +interface AuthenticatedRequest { + readonly user: JwtPayload; +} + +/** + * The six `ApprovalChoice` values (spec §4) — kept in lockstep with the + * domain union in `approval.types.ts`. Unknown values are rejected with 400. + */ +const approvalChoiceSchema = z.enum([ + 'allow_once', + 'allow_session', + 'allow_always', + 'deny_once', + 'deny_session', + 'deny_always', +]); + +const resolveBodySchema = z.object({ + choice: approvalChoiceSchema, +}); + +/** + * REST surface for the human-in-the-loop tool approval flow: list a user's + * pending prompts (for reconnect/refresh) and resolve/revoke by id. Ownership + * is enforced inside `ToolApprovalService` (rendezvous keyed by userId) — + * mismatches surface as 404, never 403, to avoid an existence leak. + */ +@Controller('api/v1/chat/approvals') +export class ApprovalsController { + constructor(private readonly toolApprovals: ToolApprovalService) {} + + @Get('pending') + async listPending( + @Req() req: AuthenticatedRequest, + ): Promise<{ success: true; data: ApprovalRequest[] }> { + const data = await this.toolApprovals.listPending(req.user.sub); + return { success: true, data }; + } + + @Post(':id/resolve') + async resolve( + @Param('id') id: string, + @Body() body: unknown, + @Req() req: AuthenticatedRequest, + ): Promise<{ success: true; data: { downgraded: boolean } }> { + const parsed = resolveBodySchema.safeParse(body); + if (!parsed.success) { + throw new BadRequestException('Invalid approval choice'); + } + + const result = await this.toolApprovals.resolve(id, req.user.sub, parsed.data.choice); + if (!result.ok) { + throw new NotFoundException('Approval request not found'); + } + return { success: true, data: { downgraded: result.downgraded } }; + } + + @Post(':id/revoke') + async revoke( + @Param('id') id: string, + @Req() req: AuthenticatedRequest, + ): Promise<{ success: true }> { + const removed = await this.toolApprovals.revoke(id, req.user.sub); + if (!removed) { + throw new NotFoundException('Approval request not found'); + } + return { success: true }; + } +} diff --git a/packages/api/src/approvals/approvals.module.ts b/packages/api/src/approvals/approvals.module.ts new file mode 100644 index 0000000..63d1882 --- /dev/null +++ b/packages/api/src/approvals/approvals.module.ts @@ -0,0 +1,36 @@ +import { Module } from '@nestjs/common'; + +import { DbModule } from '../db/db.module.js'; +import { RunActivityModule } from '../engine/run-activity.module.js'; +import { NotificationsModule } from '../notifications/notifications.module.js'; +import { ApprovalDeliveryService } from './approval-delivery.service.js'; +import { ApprovalsController } from './approvals.controller.js'; +import { ToolApprovalsController } from './tool-approvals.controller.js'; +import { ToolApprovalService, APPROVAL_DELIVERY } from './tool-approval.service.js'; + +/** + * `RunActivityRegistry` is imported from `RunActivityModule` (a tiny module + * that owns and exports the single registry instance — see + * `run-activity.module.ts`) rather than provided here, so the + * `awaiting_approval:` activity marker recorded during a rendezvous + * wait is visible to the SAME registry instance the engine watchdog + * snapshots for its death-log. Providing a second instance here would make + * that marker invisible to the watchdog — see Task 9 carry-forward from the + * Task 5 review. + * + * We depend on `RunActivityModule` directly rather than the full + * `EngineModule` because `EngineModule` itself imports `ApprovalsModule` + * (`AgentRunnerService` needs `ToolApprovalService` — Task 10). Importing + * `EngineModule` here would form a cycle; `RunActivityModule` is a leaf + * module with no dependency on either side, so it does not. + */ +@Module({ + imports: [DbModule, RunActivityModule, NotificationsModule], + controllers: [ApprovalsController, ToolApprovalsController], + providers: [ + ToolApprovalService, + { provide: APPROVAL_DELIVERY, useClass: ApprovalDeliveryService }, + ], + exports: [ToolApprovalService], +}) +export class ApprovalsModule {} diff --git a/packages/api/src/approvals/params-summary.ts b/packages/api/src/approvals/params-summary.ts new file mode 100644 index 0000000..cfc6238 --- /dev/null +++ b/packages/api/src/approvals/params-summary.ts @@ -0,0 +1,26 @@ +const SECRET_KEY_RE = /key|token|secret|password|credential|authorization/i; +const SECRET_VALUE_RE = + /(Bearer\s+[\w.~+/-]+=*|sk-[\w-]{8,}|ghp_[\w]{20,}|AKIA[0-9A-Z]{16}|eyJ[\w-]+\.[\w-]+\.[\w-]+)/g; +const MAX_VALUE_CHARS = 200; + +/** + * Build the display-only summary of tool params (spec §7). Best-effort regex + * masking, NOT leak-proof — the reviewing user is the trust boundary. The + * tool always executes the raw args; this copy goes to chat/Telegram/bell/DB. + */ +export function redactParamsSummary( + params: Readonly>, +): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(params)) { + let text = typeof value === 'string' ? value : JSON.stringify(value ?? null); + if (SECRET_KEY_RE.test(key)) { + text = '***'; + } else { + text = text.replace(SECRET_VALUE_RE, '***'); + } + if (text.length > MAX_VALUE_CHARS) text = `${text.slice(0, MAX_VALUE_CHARS)}…`; + result[key] = text; + } + return result; +} diff --git a/packages/api/src/approvals/tool-approval.service.ts b/packages/api/src/approvals/tool-approval.service.ts new file mode 100644 index 0000000..f3de347 --- /dev/null +++ b/packages/api/src/approvals/tool-approval.service.ts @@ -0,0 +1,533 @@ +import { Inject, Injectable, type OnModuleInit } from '@nestjs/common'; + +import { createLogger } from '@clawix/shared'; + +import { UserToolApprovalRepository } from '../db/user-tool-approval.repository.js'; +import { ApprovalRequestRepository } from '../db/approval-request.repository.js'; +import { AuditLogRepository } from '../db/audit-log.repository.js'; +import { RunActivityRegistry } from '../engine/run-activity.registry.js'; +import type { ApprovalRequest, Prisma } from '../generated/prisma/client.js'; +import { + memoryKey, + offeredScopes, + downgradeChoice, + type ApprovalChoice, + type ApprovalScopeKind, + type LookupResult, + type MemoryDecision, +} from './approval.types.js'; +import { redactParamsSummary } from './params-summary.js'; +import { toolApprovalsTotal } from './approval-metrics.js'; + +const logger = createLogger('approvals:tool-approval'); + +const SESSION_ENTRY_TTL_MS = 24 * 60 * 60 * 1000; // spec §4: 24h sliding +const TIMEOUT_MS = Number(process.env['TOOL_APPROVAL_TIMEOUT_MS'] ?? 300_000); + +const BLOCKED_BASE = (tool: string): string => + `BLOCKED: the user denied ${tool}. Do not retry, rephrase, or attempt the same outcome another way.`; +const BLOCKED_STANDING = (tool: string): string => + `${BLOCKED_BASE(tool)} The user has a standing denial for this action; they can lift it from the approval card or Settings.`; +const BLOCKED_SUBAGENT = (tool: string): string => + `BLOCKED: ${tool} requires user approval, which sub-agents cannot request. Ask for approval from the primary conversation or have the parent perform this step.`; +const BLOCKED_TIMEOUT = (tool: string): string => + `BLOCKED: the approval request for ${tool} timed out without a user response. Silence is not consent. Do not retry, rephrase, or attempt the same outcome another way.`; +const BLOCKED_NO_CHANNEL = (tool: string): string => + `BLOCKED: ${tool} requires user approval and no delivery channel is available for this session. Do not retry.`; + +/** + * Delivery side channel for approval prompts/notices/resolutions — bell + + * channel frames. Throws from `deliverPrompt` are treated as no_channel + * (fail-closed). Real implementation lands in Task 9. + */ +export interface ApprovalDeliveryPort { + /** Push a prompt/notice frame + bell notification. Throws → treated as no_channel. */ + deliverPrompt(req: { + approvalId: string; + userId: string; + sessionId: string | null; + toolName: string; + paramsSummary: Record; + offeredScopes: readonly ApprovalChoice[]; + reason: string; + expiresAt: Date; + }): Promise; + deliverNotice(req: { + approvalId: string; + userId: string; + sessionId: string | null; + toolName: string; + paramsSummary: Record; + reason: 'standing_deny' | 'session_deny'; + }): Promise; + deliverResolved(req: { + approvalId: string; + userId: string; + sessionId: string | null; + toolName?: string; + status: string; + scope?: string; + }): Promise; +} +export const APPROVAL_DELIVERY = Symbol('APPROVAL_DELIVERY'); + +export interface GateInput { + readonly userId: string; + readonly agentRunId: string; + readonly sessionId: string | null; + readonly isSubAgent: boolean; + readonly toolName: string; + readonly scopeKind: ApprovalScopeKind; + readonly resourceKey: string; // '' unless resource-scoped + readonly extractorFailed: boolean; + readonly params: Readonly>; + readonly abortSignal?: AbortSignal; +} +export type GateResult = + | { readonly allowed: true } + | { readonly allowed: false; readonly blockedMessage: string }; + +interface SessionEntry { + readonly decision: MemoryDecision; + touchedAt: number; +} + +interface PendingEntry { + readonly resolve: (outcome: { choice: ApprovalChoice } | { system: string }) => void; + readonly input: GateInput; + readonly offered: readonly ApprovalChoice[]; +} + +@Injectable() +export class ToolApprovalService implements OnModuleInit { + /** sessionId → (memoryKey → entry). In-process by design (spec §4). */ + private readonly sessionMemory = new Map>(); + + /** approvalId → in-flight rendezvous waiting on a human resolution. */ + private readonly pending = new Map(); + + constructor( + private readonly userToolApprovals: UserToolApprovalRepository, + private readonly approvalRequests: ApprovalRequestRepository, + private readonly auditLogs: AuditLogRepository, + @Inject(APPROVAL_DELIVERY) private readonly delivery: ApprovalDeliveryPort, + private readonly runActivity: RunActivityRegistry, + ) {} + + async onModuleInit(): Promise { + const swept = await this.approvalRequests.expireAllPending('restart_sweep'); + if (swept > 0) { + logger.warn({ swept }, 'Expired stale PENDING approvals from before restart'); + await this.auditLogs.create({ + userId: 'system', + action: 'approval.autodeny', + resource: 'approval_request', + resourceId: 'sweep', + details: { reason: 'restart_sweep', count: swept }, + }); + } + } + + writeSessionMemory( + sessionId: string, + toolName: string, + resourceKey: string, + decision: MemoryDecision, + ): void { + const bucket = this.sessionMemory.get(sessionId) ?? new Map(); + bucket.set(memoryKey(toolName, resourceKey), { decision, touchedAt: Date.now() }); + this.sessionMemory.set(sessionId, bucket); + } + + removeSessionEntry(sessionId: string, key: string): boolean { + return this.sessionMemory.get(sessionId)?.delete(key) ?? false; + } + + clearSessionMemory(sessionId: string): void { + this.sessionMemory.delete(sessionId); + } + + private readSession(sessionId: string | null, key: string): MemoryDecision | undefined { + if (!sessionId) return undefined; + const entry = this.sessionMemory.get(sessionId)?.get(key); + if (!entry) return undefined; + if (Date.now() - entry.touchedAt > SESSION_ENTRY_TTL_MS) { + this.sessionMemory.get(sessionId)?.delete(key); + return undefined; + } + // Immutable update: create new entry with refreshed touchedAt + this.sessionMemory.get(sessionId)?.set(key, { ...entry, touchedAt: Date.now() }); + return entry.decision; + } + + /** + * Precedence (spec §5): (a) more-specific key beats less-specific, + * regardless of decision or store; (b) at equal specificity, deny beats + * allow, regardless of store. Fail-closed. + */ + async lookup(input: { + userId: string; + sessionId: string | null; + toolName: string; + resourceKey: string; + extractorFailed: boolean; + }): Promise { + const { userId, sessionId, toolName, resourceKey, extractorFailed } = input; + if (extractorFailed) return { kind: 'prompt', reason: 'extractor_error' }; + + const specificities = resourceKey !== '' ? [resourceKey, ''] : ['']; + const persistentRows = await this.userToolApprovals.findForKeys( + userId, + toolName, + specificities, + ); + + for (const level of specificities) { + const key = memoryKey(toolName, level); + const session = this.readSession(sessionId, key); + const persistent = persistentRows.find((r) => r.resourceKey === level); + + const denyStore = + session === 'deny' ? 'session' : persistent?.decision === 'denied' ? 'persistent' : null; + if (denyStore) { + return { + kind: 'deny', + hit: { + decision: 'deny', + store: denyStore, + key, + scope: denyStore === 'session' ? 'session' : 'always', + }, + }; + } + + const allowStore = + session === 'allow' ? 'session' : persistent?.decision === 'allowed' ? 'persistent' : null; + if (allowStore) { + return { + kind: 'allow', + hit: { + decision: 'allow', + store: allowStore, + key, + scope: allowStore === 'session' ? 'session' : 'always', + }, + }; + } + } + + return { kind: 'prompt', reason: 'no_memory' }; + } + + async gate(input: GateInput): Promise { + const key = memoryKey(input.toolName, input.resourceKey); + const lookup = await this.lookup(input); + + if (lookup.kind === 'allow') return { allowed: true }; + + if (lookup.kind === 'deny') { + // Pre-resolved row + notice card; NO rendezvous (spec Q3/§6). + const reason = lookup.hit.store === 'persistent' ? 'standing_deny' : 'session_deny'; + const row = await this.approvalRequests.create({ + userId: input.userId, + agentRunId: input.agentRunId, + sessionId: input.sessionId, + toolName: input.toolName, + paramsSummary: redactParamsSummary(input.params), + status: 'DENIED', + scope: lookup.hit.scope, + resolutionReason: reason, + memoryStore: lookup.hit.store, + memoryKey: lookup.hit.key, + resolvedAt: new Date(), + }); + toolApprovalsTotal.inc({ outcome: 'autodeny_standing' }); + await this.audit(input.userId, 'approval.autodeny', row.id, { + reason, + tool: input.toolName, + }); + await this.safeDeliver(() => + this.delivery.deliverNotice({ + approvalId: row.id, + userId: input.userId, + sessionId: input.sessionId, + toolName: input.toolName, + paramsSummary: redactParamsSummary(input.params), + reason, + }), + ); + return { allowed: false, blockedMessage: BLOCKED_STANDING(input.toolName) }; + } + + // prompt path + if (input.isSubAgent) { + return this.autoDeny( + input, + 'subagent', + BLOCKED_SUBAGENT(input.toolName), + 'autodeny_subagent', + ); + } + if (!input.sessionId) { + return this.autoDeny( + input, + 'no_channel', + BLOCKED_NO_CHANNEL(input.toolName), + 'autodeny_no_channel', + ); + } + + const offered = offeredScopes(input.scopeKind, input.extractorFailed); + const summary = redactParamsSummary(input.params); + const expiresAt = new Date(Date.now() + TIMEOUT_MS); + const row = await this.approvalRequests.create({ + userId: input.userId, + agentRunId: input.agentRunId, + sessionId: input.sessionId, + toolName: input.toolName, + paramsSummary: summary, + }); + await this.audit(input.userId, 'approval.request', row.id, { + tool: input.toolName, + reason: lookup.reason, + }); + + try { + await this.delivery.deliverPrompt({ + approvalId: row.id, + userId: input.userId, + sessionId: input.sessionId, + toolName: input.toolName, + paramsSummary: summary, + offeredScopes: offered, + reason: lookup.reason, + expiresAt, + }); + } catch (err: unknown) { + logger.warn({ err, approvalId: row.id }, 'Prompt delivery failed — fail-closed'); + await this.approvalRequests.transitionStatus(row.id, 'PENDING', { + status: 'DENIED', + resolutionReason: 'no_channel', + }); + toolApprovalsTotal.inc({ outcome: 'autodeny_no_channel' }); + await this.audit(input.userId, 'approval.autodeny', row.id, { reason: 'no_channel' }); + return { allowed: false, blockedMessage: BLOCKED_NO_CHANNEL(input.toolName) }; + } + + // Rendezvous: keep hang-tracing honest during the wait (spec §6 step 4). + this.runActivity.recordToolStart(input.agentRunId, `awaiting_approval:${input.toolName}`); + try { + const outcome = await this.waitForResolution(row.id, input, offered); + return await this.settle(row.id, input, key, outcome); + } finally { + this.runActivity.recordToolEnd(input.agentRunId); + } + } + + private waitForResolution( + approvalId: string, + input: GateInput, + offered: readonly ApprovalChoice[], + ): Promise<{ choice: ApprovalChoice } | { system: 'timeout' | 'run_aborted' }> { + return new Promise((resolvePromise) => { + let done = false; + + // Short-circuit: if signal already aborted, settle immediately + if (input.abortSignal?.aborted) { + done = true; + this.pending.delete(approvalId); + resolvePromise({ system: 'run_aborted' }); + return; + } + + const finish = ( + outcome: { choice: ApprovalChoice } | { system: 'timeout' | 'run_aborted' }, + ) => { + if (done) return; + done = true; + clearTimeout(timer); + input.abortSignal?.removeEventListener('abort', onAbort); + this.pending.delete(approvalId); + resolvePromise(outcome); + }; + + const timer = setTimeout(() => finish({ system: 'timeout' }), TIMEOUT_MS); + timer.unref?.(); + const onAbort = () => finish({ system: 'run_aborted' }); + input.abortSignal?.addEventListener('abort', onAbort, { once: true }); + this.pending.set(approvalId, { + resolve: finish as PendingEntry['resolve'], + input, + offered, + }); + }); + } + + private async settle( + approvalId: string, + input: GateInput, + key: string, + outcome: { choice: ApprovalChoice } | { system: string }, + ): Promise { + if ('system' in outcome) { + const isTimeout = outcome.system === 'timeout'; + await this.approvalRequests.transitionStatus(approvalId, 'PENDING', { + status: isTimeout ? 'EXPIRED' : 'CANCELLED', + resolutionReason: outcome.system, + }); + toolApprovalsTotal.inc({ outcome: isTimeout ? 'expired' : 'cancelled' }); + await this.audit(input.userId, 'approval.autodeny', approvalId, { reason: outcome.system }); + await this.safeDeliver(() => + this.delivery.deliverResolved({ + approvalId, + userId: input.userId, + sessionId: input.sessionId, + toolName: input.toolName, + status: isTimeout ? 'EXPIRED' : 'CANCELLED', + }), + ); + return { + allowed: false, + blockedMessage: isTimeout ? BLOCKED_TIMEOUT(input.toolName) : BLOCKED_BASE(input.toolName), + }; + } + + const choice = outcome.choice; + const isAllow = choice.startsWith('allow'); + const scope = choice.endsWith('once') + ? 'once' + : choice.endsWith('session') + ? 'session' + : 'always'; + let memoryStore: string | undefined; + let memKey: string | undefined; + + if (scope === 'session' && input.sessionId) { + this.writeSessionMemory( + input.sessionId, + input.toolName, + input.resourceKey, + isAllow ? 'allow' : 'deny', + ); + memoryStore = 'session'; + memKey = key; + } else if (scope === 'always') { + await this.userToolApprovals.upsert({ + userId: input.userId, + toolName: input.toolName, + resourceKey: input.resourceKey, + decision: isAllow ? 'allowed' : 'denied', + }); + memoryStore = 'persistent'; + memKey = key; + } + + await this.approvalRequests.transitionStatus(approvalId, 'PENDING', { + status: isAllow ? 'APPROVED' : 'DENIED', + scope, + ...(memoryStore ? { memoryStore, memoryKey: memKey } : {}), + }); + toolApprovalsTotal.inc({ outcome: isAllow ? 'approved' : 'denied' }); + await this.safeDeliver(() => + this.delivery.deliverResolved({ + approvalId, + userId: input.userId, + sessionId: input.sessionId, + toolName: input.toolName, + status: isAllow ? 'APPROVED' : 'DENIED', + scope, + }), + ); + return isAllow + ? { allowed: true } + : { allowed: false, blockedMessage: BLOCKED_BASE(input.toolName) }; + } + + async resolve( + approvalId: string, + userId: string, + requestedChoice: ApprovalChoice, + ): Promise<{ ok: boolean; downgraded: boolean }> { + const entry = this.pending.get(approvalId); + if (!entry || entry.input.userId !== userId) return { ok: false, downgraded: false }; + const { choice, downgraded } = downgradeChoice(requestedChoice, entry.offered); + await this.audit(userId, 'approval.resolve', approvalId, { + choice, + requestedChoice, + downgraded, + }); + entry.resolve({ choice }); + return { ok: true, downgraded }; + } + + async revoke(approvalId: string, userId: string): Promise { + const row = await this.approvalRequests.findById(approvalId); + if (!row || row.userId !== userId || !row.memoryStore || !row.memoryKey) return false; + // memoryKey format `${toolName}:${resourceKey}` — resourceKey may contain ':' + const sep = row.memoryKey.indexOf(':'); + const toolName = row.memoryKey.slice(0, sep); + const resourceKey = row.memoryKey.slice(sep + 1); + const removed = + row.memoryStore === 'persistent' + ? await this.userToolApprovals.deleteByKey(userId, toolName, resourceKey) + : row.sessionId + ? this.removeSessionEntry(row.sessionId, row.memoryKey) + : false; + if (removed) { + await this.audit(userId, 'approval.revoke', approvalId, { memoryKey: row.memoryKey }); + } + return removed; + } + + async listPending(userId: string): Promise { + return this.approvalRequests.listPendingForUser(userId); + } + + private async autoDeny( + input: GateInput, + reason: string, + blockedMessage: string, + metric: string, + ): Promise { + const row = await this.approvalRequests.create({ + userId: input.userId, + agentRunId: input.agentRunId, + sessionId: input.sessionId, + toolName: input.toolName, + paramsSummary: redactParamsSummary(input.params), + status: 'DENIED', + resolutionReason: reason, + resolvedAt: new Date(), + }); + toolApprovalsTotal.inc({ outcome: metric }); + await this.audit(input.userId, 'approval.autodeny', row.id, { reason, tool: input.toolName }); + return { allowed: false, blockedMessage }; + } + + private async audit( + userId: string, + action: string, + resourceId: string, + details: Record, + ): Promise { + try { + await this.auditLogs.create({ + userId, + action, + resource: 'approval_request', + resourceId, + details: details as Prisma.InputJsonValue, + }); + } catch (err: unknown) { + logger.error({ err, action }, 'Audit write failed'); + } + } + + private async safeDeliver(fn: () => Promise): Promise { + try { + await fn(); + } catch (err: unknown) { + logger.warn({ err }, 'Approval delivery side-channel failed (non-fatal)'); + } + } +} diff --git a/packages/api/src/approvals/tool-approvals.controller.ts b/packages/api/src/approvals/tool-approvals.controller.ts new file mode 100644 index 0000000..e441eb9 --- /dev/null +++ b/packages/api/src/approvals/tool-approvals.controller.ts @@ -0,0 +1,52 @@ +import { Controller, Delete, Get, NotFoundException, Param, Req } from '@nestjs/common'; + +import type { JwtPayload } from '../auth/auth.types.js'; +import type { UserToolApproval } from '../generated/prisma/client.js'; +import { AuditLogRepository } from '../db/audit-log.repository.js'; +import { UserToolApprovalRepository } from '../db/user-tool-approval.repository.js'; + +interface AuthenticatedRequest { + readonly user: JwtPayload; +} + +/** + * Settings-page surface for a user's standing (`allow_always` / + * `deny_always`) tool approvals: list and revoke by row id. Ownership is + * enforced inside `UserToolApprovalRepository.deleteById` (a userId-guarded + * `deleteMany`) — a mismatch returns 404, never 403, to avoid an existence + * leak. + */ +@Controller('api/v1/tool-approvals') +export class ToolApprovalsController { + constructor( + private readonly userToolApprovals: UserToolApprovalRepository, + private readonly auditLogs: AuditLogRepository, + ) {} + + @Get() + async list( + @Req() req: AuthenticatedRequest, + ): Promise<{ success: true; data: UserToolApproval[] }> { + const data = await this.userToolApprovals.listForUser(req.user.sub); + return { success: true, data }; + } + + @Delete(':id') + async remove( + @Param('id') id: string, + @Req() req: AuthenticatedRequest, + ): Promise<{ success: true }> { + const removed = await this.userToolApprovals.deleteById(id, req.user.sub); + if (!removed) { + throw new NotFoundException('Tool approval not found'); + } + await this.auditLogs.create({ + userId: req.user.sub, + action: 'approval.revoke', + resource: 'user_tool_approval', + resourceId: id, + details: { source: 'settings' }, + }); + return { success: true }; + } +} diff --git a/packages/api/src/cache/cache.constants.ts b/packages/api/src/cache/cache.constants.ts index 9404066..5867abc 100644 --- a/packages/api/src/cache/cache.constants.ts +++ b/packages/api/src/cache/cache.constants.ts @@ -29,6 +29,7 @@ export const PUBSUB_CHANNELS = { agentResultReady: `${REDIS_KEY_PREFIX}agent:result-ready`, channelResponseReady: `${REDIS_KEY_PREFIX}channel:response-ready`, cronResultReady: `${REDIS_KEY_PREFIX}cron:result-ready`, + approvalFrame: `${REDIS_KEY_PREFIX}approval:frame`, } as const; /** Connection timeout in milliseconds. */ diff --git a/packages/api/src/engine/run-activity.module.ts b/packages/api/src/engine/run-activity.module.ts new file mode 100644 index 0000000..29981c4 --- /dev/null +++ b/packages/api/src/engine/run-activity.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; + +import { RunActivityRegistry } from './run-activity.registry.js'; + +/** + * Owns the single `RunActivityRegistry` instance shared by `EngineModule` + * (agent-runner / task-executor watchdog) and `ApprovalsModule` + * (`awaiting_approval:` activity marker during a rendezvous wait). + * + * Split out from `EngineModule` so `ApprovalsModule` can depend on the + * registry without importing the whole of `EngineModule` — which itself + * needs to import `ApprovalsModule` for `ToolApprovalService`. Importing + * `EngineModule` from both sides would form a cycle; importing this + * single-provider module from both sides does not. + */ +@Module({ + providers: [RunActivityRegistry], + exports: [RunActivityRegistry], +}) +export class RunActivityModule {} diff --git a/packages/api/src/engine/tool.ts b/packages/api/src/engine/tool.ts index 9193e7b..7177b83 100644 --- a/packages/api/src/engine/tool.ts +++ b/packages/api/src/engine/tool.ts @@ -1,5 +1,7 @@ import type { ToolDefinition } from '@clawix/shared'; +import type { ApprovalScopeKind } from '../approvals/approval.types.js'; + /** Result returned by a tool execution. */ export interface ToolResult { readonly output: string; @@ -46,6 +48,25 @@ export interface Tool { */ readonly rawParams?: boolean; + /** When true, ToolRegistry gates execute() behind ToolApprovalService (spec §3). */ + readonly requiresApproval?: boolean; + + /** What an "Always" decision covers. Default 'call' — the conservative floor (spec §2). */ + readonly approvalScope?: ApprovalScopeKind; + + /** Required when approvalScope === 'resource'. Derives the memory key from validated params. */ + readonly resourceKeyFromParams?: (params: Record) => string; + + /** + * Optional per-call refinement of `requiresApproval`. When present on a tool + * with `requiresApproval: true`, the registry evaluates it against the + * validated params; a `false` return means this specific call is exempt from + * approval (e.g. a read-only action of a multi-action tool). Absent predicate + * = every call requires approval. If the predicate throws, the call is + * treated as requiring approval (fail closed). + */ + readonly requiresApprovalForParams?: (params: Record) => boolean; + execute(params: Record, ctx?: ToolExecuteContext): Promise; } From 5ab58b68e8e784d9fd279fcdb03e3379f9d088f5 Mon Sep 17 00:00:00 2001 From: jasonli0226 Date: Mon, 20 Jul 2026 01:04:31 +0800 Subject: [PATCH 03/10] feat(shell_net): network-capable shell tool with policy caps and runner image Adds an ephemeral, network-capable shell tool gated by policy.allowShellNet: - shell_net tool and a dedicated ShellNetConcurrencyLimiter, with per-policy memory/timeout/cpu/concurrency caps (maxShellNet* columns). - shell tool sensitive-command classification so risky invocations can be gated. - shell-net-runner image (curl/wget/git/ssh + writable HOME). - Unit, concurrency, classification, and live integration coverage. --- infra/docker/shell-net-runner/Dockerfile | 26 ++ .../migration.sql | 6 + packages/api/prisma/schema.prisma | 5 + .../shell-net-concurrency-limiter.test.ts | 24 ++ .../engine/__tests__/shell-net-tool.test.ts | 103 ++++++++ .../__tests__/shell-net.integration.test.ts | 119 +++++++++ .../shell-sensitive-classification.test.ts | 121 +++++++++ .../tools/shell-net-concurrency-limiter.ts | 30 +++ packages/api/src/engine/tools/shell-net.ts | 243 ++++++++++++++++++ packages/api/src/engine/tools/shell.ts | 154 +++++++++++ 10 files changed, 831 insertions(+) create mode 100644 infra/docker/shell-net-runner/Dockerfile create mode 100644 packages/api/prisma/migrations/20260715150027_add_shell_net_policy/migration.sql create mode 100644 packages/api/src/engine/__tests__/shell-net-concurrency-limiter.test.ts create mode 100644 packages/api/src/engine/__tests__/shell-net-tool.test.ts create mode 100644 packages/api/src/engine/__tests__/shell-net.integration.test.ts create mode 100644 packages/api/src/engine/__tests__/shell-sensitive-classification.test.ts create mode 100644 packages/api/src/engine/tools/shell-net-concurrency-limiter.ts create mode 100644 packages/api/src/engine/tools/shell-net.ts diff --git a/infra/docker/shell-net-runner/Dockerfile b/infra/docker/shell-net-runner/Dockerfile new file mode 100644 index 0000000..5e7f3bb --- /dev/null +++ b/infra/docker/shell-net-runner/Dockerfile @@ -0,0 +1,26 @@ +# shell_net runner — the agent image plus outbound tooling. +# Mirrors infra/docker/agent/Dockerfile except for the added network binaries +# and the git-hang fixes (writable HOME + GIT_TERMINAL_PROMPT=0). +# Build with the shared egress-firewall.sh in context: +# docker build -f infra/docker/shell-net-runner/Dockerfile infra/docker +FROM python:3.12-slim + +# iptables + gosu back the egress-firewall entrypoint (see egress-firewall.sh). +RUN apt-get update && apt-get install -y --no-install-recommends \ + git jq curl wget ca-certificates openssh-client iptables gosu \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /workspace /skills/builtin /skills/custom /app /home/agent \ + && chown -R 1000:1000 /workspace /skills /home/agent + +ENV GIT_TERMINAL_PROMPT=0 HOME=/home/agent + +# Egress firewall runs as root at boot, programs iptables, then drops to uid +# 1000 via gosu. Do NOT set `USER 1000:1000` — the entrypoint drops privilege +# itself; the runner passes --user 0:0 at start and --user 1000:1000 at exec. +COPY egress-firewall.sh /usr/local/bin/egress-firewall.sh +RUN chmod +x /usr/local/bin/egress-firewall.sh + +WORKDIR /workspace +ENTRYPOINT ["/usr/local/bin/egress-firewall.sh"] +CMD ["sleep", "infinity"] diff --git a/packages/api/prisma/migrations/20260715150027_add_shell_net_policy/migration.sql b/packages/api/prisma/migrations/20260715150027_add_shell_net_policy/migration.sql new file mode 100644 index 0000000..c69ceb3 --- /dev/null +++ b/packages/api/prisma/migrations/20260715150027_add_shell_net_policy/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Policy" ADD COLUMN "allowShellNet" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "maxConcurrentShellNetRuns" INTEGER NOT NULL DEFAULT 2, +ADD COLUMN "maxShellNetCpuCores" INTEGER NOT NULL DEFAULT 1, +ADD COLUMN "maxShellNetMemoryMb" INTEGER NOT NULL DEFAULT 512, +ADD COLUMN "maxShellNetTimeoutSecs" INTEGER NOT NULL DEFAULT 60; diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index e7de03b..e6c70fe 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -43,6 +43,11 @@ model Policy { maxPythonTimeoutSecs Int @default(60) maxPythonCpuCores Int @default(1) maxConcurrentPythonRuns Int @default(2) + allowShellNet Boolean @default(false) + maxShellNetMemoryMb Int @default(512) + maxShellNetTimeoutSecs Int @default(60) + maxShellNetCpuCores Int @default(1) + maxConcurrentShellNetRuns Int @default(2) maxSubAgentRunMs Int @default(300000) // wall-clock cap per spawned sub-agent run (ms) allowMcp Boolean @default(false) isActive Boolean @default(true) diff --git a/packages/api/src/engine/__tests__/shell-net-concurrency-limiter.test.ts b/packages/api/src/engine/__tests__/shell-net-concurrency-limiter.test.ts new file mode 100644 index 0000000..922d5ac --- /dev/null +++ b/packages/api/src/engine/__tests__/shell-net-concurrency-limiter.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { ShellNetConcurrencyLimiter } from '../tools/shell-net-concurrency-limiter.js'; + +describe('ShellNetConcurrencyLimiter', () => { + it('allows up to cap concurrent runs per user', () => { + const l = new ShellNetConcurrencyLimiter(); + l.acquire('u1', 2); + l.acquire('u1', 2); + expect(() => l.acquire('u1', 2)).toThrow(/max concurrent shell_net/i); + }); + + it('release frees a slot', () => { + const l = new ShellNetConcurrencyLimiter(); + l.acquire('u1', 1); + l.release('u1'); + expect(() => l.acquire('u1', 1)).not.toThrow(); + }); + + it('counts users independently', () => { + const l = new ShellNetConcurrencyLimiter(); + l.acquire('u1', 1); + expect(() => l.acquire('u2', 1)).not.toThrow(); + }); +}); diff --git a/packages/api/src/engine/__tests__/shell-net-tool.test.ts b/packages/api/src/engine/__tests__/shell-net-tool.test.ts new file mode 100644 index 0000000..b7f089d --- /dev/null +++ b/packages/api/src/engine/__tests__/shell-net-tool.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createShellNetTool, ShellNetDeps } from '../tools/shell-net.js'; + +const policy = { + allowShellNet: true, + maxShellNetMemoryMb: 2048, + maxShellNetTimeoutSecs: 300, + maxShellNetCpuCores: 2, + maxConcurrentShellNetRuns: 3, +}; + +function makeDeps(overrides: Partial = {}): ShellNetDeps { + return { + userId: 'u1', + workspaceHostPath: '/tmp/ws-s1', + policy, + runner: { + start: vi.fn(async () => 'c-eph-1'), + exec: vi.fn(async () => ({ exitCode: 0, stdout: 'ok', stderr: '' })), + stop: vi.fn(async () => undefined), + }, + limiter: { acquire: vi.fn(), release: vi.fn() }, + ...overrides, + } as unknown as ShellNetDeps; +} + +const sig = () => ({ abortSignal: new AbortController().signal }); + +describe('shell_net tool', () => { + it('is whole-tool run-scoped approval-gated', () => { + const tool = createShellNetTool(makeDeps()); + expect(tool.name).toBe('shell_net'); + expect(tool.requiresApproval).toBe(true); + expect(tool.approvalScope).toBe('run'); + }); + + it('starts an ephemeral container on the egress network and stops it', async () => { + const deps = makeDeps(); + const tool = createShellNetTool(deps); + await tool.execute({ command: 'curl https://example.com' }, sig()); + expect(deps.runner.start).toHaveBeenCalledOnce(); + const startArgs = (deps.runner.start as ReturnType).mock.calls[0]; + expect(startArgs[2]).toMatchObject({ + network: 'clawix-python-net-egress', + egressFirewall: true, + }); + expect(deps.runner.stop).toHaveBeenCalledWith('c-eph-1'); + }); + + it('runs the command as uid 1000 via the exec user option', async () => { + const deps = makeDeps(); + await createShellNetTool(deps).execute({ command: 'curl x' }, sig()); + const execCall = (deps.runner.exec as ReturnType).mock.calls[0]; + expect(execCall[2]).toMatchObject({ user: '1000:1000' }); + }); + + it('blocks deny-pattern commands BEFORE starting a container', async () => { + const deps = makeDeps(); + const tool = createShellNetTool(deps); + const res = await tool.execute({ command: 'rm -rf /' }, sig()); + expect(res.isError).toBe(true); + expect(deps.runner.start).not.toHaveBeenCalled(); + expect(deps.limiter.acquire).not.toHaveBeenCalled(); + }); + + it('errors when command is empty', async () => { + const deps = makeDeps(); + const res = await createShellNetTool(deps).execute({ command: ' ' }, sig()); + expect(res.isError).toBe(true); + expect(deps.runner.start).not.toHaveBeenCalled(); + }); + + it('errors when allowShellNet is false', async () => { + const deps = makeDeps({ policy: { ...policy, allowShellNet: false } }); + const res = await createShellNetTool(deps).execute({ command: 'curl x' }, sig()); + expect(res.isError).toBe(true); + expect(deps.runner.start).not.toHaveBeenCalled(); + }); + + it('stops the container even when exec throws', async () => { + const deps = makeDeps({ + runner: { + start: vi.fn(async () => 'c-eph-2'), + exec: vi.fn(async () => { + throw new Error('boom'); + }), + stop: vi.fn(async () => undefined), + }, + }); + await createShellNetTool(deps).execute({ command: 'curl x' }, sig()); + expect(deps.runner.stop).toHaveBeenCalledWith('c-eph-2'); + expect(deps.limiter.release).toHaveBeenCalledWith('u1'); + }); + + it('passes the capped timeout to exec in milliseconds', async () => { + const execMock = vi.fn(async () => ({ exitCode: 0, stdout: 'ok', stderr: '' })); + const deps = makeDeps({ + runner: { start: vi.fn(async () => 'c'), exec: execMock, stop: vi.fn(async () => undefined) }, + }); + await createShellNetTool(deps).execute({ command: 'curl x', timeoutSecs: 30 }, sig()); + expect(execMock.mock.calls[0][2]).toMatchObject({ timeout: 30_000 }); + }); +}); diff --git a/packages/api/src/engine/__tests__/shell-net.integration.test.ts b/packages/api/src/engine/__tests__/shell-net.integration.test.ts new file mode 100644 index 0000000..efa6111 --- /dev/null +++ b/packages/api/src/engine/__tests__/shell-net.integration.test.ts @@ -0,0 +1,119 @@ +import { execFile as execFileCb } from 'child_process'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; +import { promisify } from 'util'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { ContainerRunner } from '../container-runner.js'; +import { createShellNetTool } from '../tools/shell-net.js'; +import { ShellNetConcurrencyLimiter } from '../tools/shell-net-concurrency-limiter.js'; +import type { Tool } from '../tool.js'; + +const execFile = promisify(execFileCb); + +const RUNNER_IMAGE = 'clawix-shell-net-runner:latest'; +const NETWORK_NAME = 'clawix-python-net-egress'; + +// ------------------------------------------------------------------ // +// Docker / prerequisite availability checks // +// ------------------------------------------------------------------ // + +async function isDockerAvailable(): Promise { + try { + await execFile('docker', ['info'], { timeout: 5_000 }); + return true; + } catch { + return false; + } +} + +async function isImagePresent(image: string): Promise { + try { + const { stdout } = await execFile('docker', ['images', '-q', image], { timeout: 5_000 }); + return stdout.trim().length > 0; + } catch { + return false; + } +} + +async function isNetworkPresent(network: string): Promise { + try { + const { stdout } = await execFile( + 'docker', + ['network', 'ls', '-q', '--filter', `name=${network}`], + { + timeout: 5_000, + }, + ); + return stdout.trim().length > 0; + } catch { + return false; + } +} + +const dockerAvailable = await isDockerAvailable(); +const isCI = process.env['CI'] === 'true'; +const imagePresent = dockerAvailable ? await isImagePresent(RUNNER_IMAGE) : false; +const networkPresent = dockerAvailable ? await isNetworkPresent(NETWORK_NAME) : false; + +// This is a LIVE ACCEPTANCE PROOF, not a mocked unit test: it must actually run +// the real shell_net tool code path against a real ephemeral container on the +// real egress network. If any prerequisite is missing, skip loudly rather than +// silently — a skip here means the proof did NOT execute. +const canRun = dockerAvailable && !isCI && imagePresent && networkPresent; + +if (!canRun) { + console.warn( + `[shell-net.integration] SKIPPING live proof — dockerAvailable=${dockerAvailable} isCI=${isCI} ` + + `imagePresent(${RUNNER_IMAGE})=${imagePresent} networkPresent(${NETWORK_NAME})=${networkPresent}. ` + + 'This is NOT an acceptance proof if skipped.', + ); +} + +describe.skipIf(!canRun)('shell_net live acceptance proof (real Docker + real network)', () => { + let runner: ContainerRunner; + let limiter: ShellNetConcurrencyLimiter; + let tool: Tool; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'clawix-shell-net-integration-')); + + runner = new ContainerRunner(); + limiter = new ShellNetConcurrencyLimiter(); + + tool = createShellNetTool({ + userId: 'shell-net-proof-user', + workspaceHostPath: tmpDir, + policy: { + allowShellNet: true, + maxShellNetMemoryMb: 8192, + maxShellNetTimeoutSecs: 600, + maxShellNetCpuCores: 4, + maxConcurrentShellNetRuns: 5, + }, + runner, + limiter, + }); + }, 60_000); + + afterAll(async () => { + if (tmpDir) { + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + }); + + it('executes an agent-authored curl through the real ephemeral container and reaches example.com', async () => { + const result = await tool.execute( + { command: 'curl -s -o /dev/null -w "%{http_code}" https://example.com' }, + { abortSignal: new AbortController().signal }, + ); + + console.log('[shell-net.integration] tool result:', JSON.stringify(result)); + + expect(result.isError).toBe(false); + expect(result.output).toContain('200'); + }, 120_000); +}); diff --git a/packages/api/src/engine/__tests__/shell-sensitive-classification.test.ts b/packages/api/src/engine/__tests__/shell-sensitive-classification.test.ts new file mode 100644 index 0000000..f4ddd43 --- /dev/null +++ b/packages/api/src/engine/__tests__/shell-sensitive-classification.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; + +import { classifyShellCommand } from '../tools/shell.js'; + +describe('classifyShellCommand — exempt commands', () => { + it.each([ + 'ls -la', + 'grep -rn foo src/', + 'cat package.json', + 'pnpm run build', + 'pnpm test', + 'git status', + 'git commit -m "wip"', + 'echo hello > out.txt', + 'mkdir -p src/lib', + 'rm -f build/artifact.js', + 'sed -n "1,20p" README.md', + ])('does not gate %j', (command) => { + expect(classifyShellCommand(command)).toEqual([]); + }); +}); + +describe('classifyShellCommand — net-egress', () => { + it.each([ + 'curl https://example.com', + 'wget https://example.com/file.tar.gz', + 'nc -l 4444', + 'ssh user@host', + 'scp file user@host:/tmp/', + 'rsync -a ./ user@host:/srv/', + 'git clone https://github.com/foo/bar', + 'git push origin main', + 'npx some-cli', + ])('gates %j', (command) => { + expect(classifyShellCommand(command)).toEqual(['net-egress']); + }); +}); + +describe('classifyShellCommand — pkg-install', () => { + it.each([ + 'pip install requests', + 'pip3 install requests', + 'uv pip install requests', + 'npm install lodash', + 'pnpm add lodash', + 'yarn add lodash', + 'apt-get install -y jq', + 'apk add curl-dev', + 'gem install bundler', + 'cargo install ripgrep', + 'poetry add httpx', + ])('gates %j', (command) => { + expect(classifyShellCommand(command)).toContain('pkg-install'); + }); +}); + +describe('classifyShellCommand — fs-outside-workspace', () => { + it.each([ + 'echo pwned > /etc/passwd', + 'echo x >> /root/.bashrc', + 'cp secret.txt /etc/', + 'mv /workspace/a /var/lib/b', + 'rm -f /usr/bin/node', + 'tee /etc/hosts', + 'chmod +x /usr/local/bin/thing', + 'echo x > ../../etc/thing', + "sed -i 's/a/b/' /etc/hosts", + ])('gates %j', (command) => { + expect(classifyShellCommand(command)).toContain('fs-outside-workspace'); + }); + + it('does not gate sed reading a file outside the workspace', () => { + expect(classifyShellCommand("sed -n '1,20p' /etc/hosts")).toEqual([]); + }); + + it.each(['echo x > /workspace/out.txt', 'echo x > /tmp/scratch', 'cp a.txt /workspace/b.txt'])( + 'does not gate writes inside owned roots: %j', + (command) => { + expect(classifyShellCommand(command)).toEqual([]); + }, + ); +}); + +describe('classifyShellCommand — decomposition', () => { + it('catches a sensitive segment hidden behind a benign one', () => { + expect(classifyShellCommand('ls -la; curl https://evil.test')).toEqual(['net-egress']); + }); + + it('catches a sensitive command inside a $() subshell', () => { + expect(classifyShellCommand('echo $(curl https://evil.test)')).toEqual(['net-egress']); + }); + + it('catches a sensitive command inside a backtick subshell', () => { + expect(classifyShellCommand('echo `wget https://evil.test`')).toEqual(['net-egress']); + }); + + it('catches an escaping write in a compound segment', () => { + expect(classifyShellCommand('cd /workspace && echo x > /etc/cron.d/job')).toEqual([ + 'fs-outside-workspace', + ]); + }); +}); + +describe('classifyShellCommand — key stability', () => { + it('returns categories sorted and de-duplicated', () => { + expect(classifyShellCommand('curl https://a.test && curl https://b.test')).toEqual([ + 'net-egress', + ]); + }); + + it('produces one stable key regardless of category order in the command', () => { + const a = classifyShellCommand('pip install requests && curl https://a.test'); + const b = classifyShellCommand('curl https://a.test && pip install requests'); + expect(a).toEqual(b); + expect(a.join('+')).toBe('net-egress+pkg-install'); + }); + + it('is case-insensitive', () => { + expect(classifyShellCommand('CURL https://example.com')).toEqual(['net-egress']); + }); +}); diff --git a/packages/api/src/engine/tools/shell-net-concurrency-limiter.ts b/packages/api/src/engine/tools/shell-net-concurrency-limiter.ts new file mode 100644 index 0000000..e910168 --- /dev/null +++ b/packages/api/src/engine/tools/shell-net-concurrency-limiter.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; + +/** + * Per-user in-memory concurrency counter for shell_net runs. Dedicated instance + * so shell_net concurrency is counted apart from python_run_net. + */ +@Injectable() +export class ShellNetConcurrencyLimiter { + private readonly counts = new Map(); + + acquire(userId: string, cap: number): void { + const cur = this.counts.get(userId) ?? 0; + if (cur >= cap) { + throw new Error( + `Error: max concurrent shell_net runs (${cap}) reached. Wait for an in-flight run to finish.`, + ); + } + this.counts.set(userId, cur + 1); + } + + release(userId: string): void { + const cur = this.counts.get(userId); + if (cur === undefined) return; + if (cur <= 1) { + this.counts.delete(userId); + } else { + this.counts.set(userId, cur - 1); + } + } +} diff --git a/packages/api/src/engine/tools/shell-net.ts b/packages/api/src/engine/tools/shell-net.ts new file mode 100644 index 0000000..3be41f7 --- /dev/null +++ b/packages/api/src/engine/tools/shell-net.ts @@ -0,0 +1,243 @@ +/** + * shell_net tool factory — network-enabled ephemeral shell execution. + * + * Mirrors python_run_net: each call spawns a fresh ephemeral container attached + * to the constrained egress network, docker-execs the command, and stops the + * container in finally. No warm pool. + * + * ⚠ Egress is UNFILTERED (shares clawix-python-net-egress with python_run_net, + * which has no RFC1918/metadata SSRF block). See + * docs/plans/2026-07-15-shell-net-and-egress-status.md §3. + */ +import { randomUUID } from 'node:crypto'; + +import { createLogger } from '@clawix/shared'; +import type { AgentDefinition } from '@clawix/shared'; + +import type { IContainerRunner } from '../container-runner.js'; +import type { Tool, ToolExecuteContext, ToolResult } from '../tool.js'; +import { checkDenyPatterns } from './shell.js'; + +const logger = createLogger('engine:tools:shell_net'); + +const NETWORK_NAME = process.env['PYTHON_NET_NETWORK_NAME'] ?? 'clawix-python-net-egress'; +const RUNNER_IMAGE = process.env['SHELL_NET_RUNNER_IMAGE'] ?? 'clawix-shell-net-runner:latest'; +/** Operator allowlist re-permitting internal host[:port] targets past the egress block. */ +const ALLOWLIST = process.env['SHELL_NET_INTERNAL_ALLOWLIST'] ?? ''; +const DEFAULT_WORKDIR = '/workspace'; +const MAX_COMMAND_BYTES = 8192; + +export interface ShellNetPolicy { + readonly allowShellNet: boolean; + readonly maxShellNetMemoryMb: number; + readonly maxShellNetTimeoutSecs: number; + readonly maxShellNetCpuCores: number; + readonly maxConcurrentShellNetRuns: number; +} + +export interface ShellNetDeps { + readonly userId: string; + readonly workspaceHostPath: string; + readonly policy: ShellNetPolicy; + readonly runner: Pick; + readonly limiter: { + acquire: (userId: string, cap: number) => void; + release: (userId: string) => void; + }; +} + +export interface ShellNetToolResult extends ToolResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +} + +export function createShellNetTool(deps: ShellNetDeps): Tool { + return { + name: 'shell_net', + description: + 'Execute a shell command in a fresh ephemeral container WITH outbound network access. ' + + 'USE THIS FOR: curl/wget HTTP calls, git clone/fetch, ssh/scp — anything needing the network. ' + + "DON'T USE THIS WHEN: no network is needed — prefer the plain `shell` tool. " + + 'Only available when allowShellNet is enabled in the active policy. Slower than `shell` (fresh container per call).', + parameters: { + type: 'object', + properties: { + command: { type: 'string', description: 'The shell command to execute.' }, + workdir: { + type: 'string', + description: 'Working directory inside the container (default: /workspace).', + }, + timeoutSecs: { + type: 'integer', + minimum: 1, + description: 'Execution timeout in seconds (capped by policy).', + }, + }, + required: ['command'], + }, + requiresApproval: true, + approvalScope: 'run', + + async execute( + rawInput: Record, + ctx?: ToolExecuteContext, + ): Promise { + const callId = randomUUID(); + const startedAt = Date.now(); + + // ── Validate ─────────────────────────────────────────────────── + const command = typeof rawInput['command'] === 'string' ? rawInput['command'] : ''; + if (command.trim().length === 0) { + return makeErrorResult('Error: command is required.'); + } + if (Buffer.byteLength(command, 'utf8') > MAX_COMMAND_BYTES) { + return makeErrorResult(`Error: command exceeds ${MAX_COMMAND_BYTES} bytes.`); + } + + // ── Policy ───────────────────────────────────────────────────── + if (!deps.policy.allowShellNet) { + return makeErrorResult('Error: shell_net is not enabled in the active policy.'); + } + + // ── Deny-pattern hard stop — BEFORE any container work ───────── + const denyReason = checkDenyPatterns(command); + if (denyReason !== undefined) { + logger.warn({ callId, command, denyReason }, 'shell_net command blocked by deny patterns'); + return makeErrorResult(denyReason); + } + + const workdir = + typeof rawInput['workdir'] === 'string' ? rawInput['workdir'] : DEFAULT_WORKDIR; + const timeoutSec = Math.min( + typeof rawInput['timeoutSecs'] === 'number' + ? rawInput['timeoutSecs'] + : deps.policy.maxShellNetTimeoutSecs, + deps.policy.maxShellNetTimeoutSecs, + ); + + // ── Concurrency gate ─────────────────────────────────────────── + try { + deps.limiter.acquire(deps.userId, deps.policy.maxConcurrentShellNetRuns); + } catch (err) { + return makeErrorResult(err); + } + + let containerId: string | undefined; + try { + const syntheticAgentDef: AgentDefinition = { + id: `shell-net-runner-${callId}`, + name: `Shell Net Runner (${callId})`, + description: null, + systemPrompt: '', + role: 'worker', + provider: 'none', + model: 'none', + apiBaseUrl: null, + skillIds: [], + maxTokensPerRun: 0, + containerConfig: { + image: RUNNER_IMAGE, + cpuLimit: String(deps.policy.maxShellNetCpuCores), + memoryLimit: `${deps.policy.maxShellNetMemoryMb}m`, + timeoutSeconds: deps.policy.maxShellNetTimeoutSecs, + readOnlyRootfs: false, + allowedMounts: [], + idleTimeoutSeconds: 0, + }, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + containerId = await deps.runner.start(syntheticAgentDef, [], { + disableAutoStop: true, + workspaceHostPath: deps.workspaceHostPath, + network: NETWORK_NAME, + egressFirewall: true, + egressAllowlist: ALLOWLIST, + }); + + try { + // The container boots as root for the firewall entrypoint; agent work + // must run as uid 1000 (the entrypoint's gosu-dropped user). + const execRes = await deps.runner.exec(containerId, ['sh', '-c', command], { + signal: ctx?.abortSignal, + timeout: timeoutSec * 1000, + workdir, + user: '1000:1000', + }); + + const stderr = mapExitCodeToStderr(execRes, timeoutSec, deps.policy.maxShellNetMemoryMb); + const isError = execRes.exitCode !== 0; + + logger.info( + { + tool: 'shell_net', + callId, + userId: deps.userId, + exitCode: execRes.exitCode, + durationMs: Date.now() - startedAt, + stdoutBytes: execRes.stdout.length, + stderrBytes: stderr.length, + }, + 'shell_net completed', + ); + + return makeResult({ + stdout: execRes.stdout, + stderr, + exitCode: execRes.exitCode, + isError, + }); + } finally { + if (containerId !== undefined) { + await deps.runner.stop(containerId).catch((err: unknown) => { + logger.warn({ containerId, err }, 'shell_net: failed to stop ephemeral container'); + }); + } + } + } catch (err) { + return makeErrorResult(err); + } finally { + deps.limiter.release(deps.userId); + } + }, + }; +} + +// ------------------------------------------------------------------ // +// Helpers // +// ------------------------------------------------------------------ // + +function mapExitCodeToStderr( + res: { exitCode: number; stdout: string; stderr: string }, + timeoutSec: number, + memMb: number, +): string { + if (res.exitCode === 124) return `Error: execution timed out after ${timeoutSec}s.`; + if (res.exitCode === 137) + return `Error: process killed (out of memory). Memory limit was ${memMb} MB.`; + if (res.exitCode === -1) return 'Error: cancelled.'; + return res.stderr; +} + +function makeResult(r: { + stdout: string; + stderr: string; + exitCode: number; + isError: boolean; +}): ShellNetToolResult { + return { + output: (r.isError ? r.stderr : r.stdout).trim(), + isError: r.isError, + stdout: r.stdout, + stderr: r.stderr, + exitCode: r.exitCode, + }; +} + +function makeErrorResult(err: unknown): ShellNetToolResult { + const message = err instanceof Error ? err.message : String(err); + return { output: message, isError: true, stdout: '', stderr: message, exitCode: 0 }; +} diff --git a/packages/api/src/engine/tools/shell.ts b/packages/api/src/engine/tools/shell.ts index 827c531..45cc30f 100644 --- a/packages/api/src/engine/tools/shell.ts +++ b/packages/api/src/engine/tools/shell.ts @@ -131,6 +131,148 @@ export function checkDenyPatterns(command: string): string | undefined { return undefined; } +// ------------------------------------------------------------------ // +// Sensitive-command classification (approval gating) // +// ------------------------------------------------------------------ // + +/** + * Kinds of shell command that require human approval. + * + * The *category* — not the raw command string — is the approval resource key. + * Command strings are unbounded free text, so keying approval memory on them + * would make persistent decisions both useless (never a second hit) and + * unauditable. Keying on the category means an "Always" grant reads as a + * capability the user understands: "this agent may install packages". + */ +export type ShellSensitiveCategory = 'net-egress' | 'pkg-install' | 'fs-outside-workspace'; + +interface SensitivePattern { + readonly regex: RegExp; + readonly category: ShellSensitiveCategory; +} + +const SENSITIVE_PATTERNS: readonly SensitivePattern[] = [ + // Outbound network + { + regex: /\b(curl|wget|nc|netcat|ncat|telnet|ftp|sftp|scp|rsync|ssh)\b/, + category: 'net-egress', + }, + { regex: /\bgit\s+(clone|push|pull|fetch|remote|ls-remote)\b/, category: 'net-egress' }, + { regex: /\b(npx|pnpx)\b/, category: 'net-egress' }, + // Package installation + { regex: /\b(pip|pip3)\s+install\b/, category: 'pkg-install' }, + { regex: /\buv\s+(pip\s+install|add)\b/, category: 'pkg-install' }, + { regex: /\b(npm|pnpm|yarn)\s+(install|add|i)\b/, category: 'pkg-install' }, + { regex: /\bapt(-get)?\s+install\b/, category: 'pkg-install' }, + { regex: /\bapk\s+add\b/, category: 'pkg-install' }, + { regex: /\b(gem|cargo|go)\s+install\b/, category: 'pkg-install' }, + { regex: /\bpoetry\s+add\b/, category: 'pkg-install' }, +]; + +/** + * Absolute path roots a command may write to without approval. Everything the + * agent legitimately owns lives under these. + */ +const WRITABLE_ROOTS: readonly string[] = ['/workspace', '/tmp']; + +/** Commands whose non-flag arguments name paths they mutate. */ +const FS_MUTATING_COMMAND = /^(cp|mv|rm|mkdir|rmdir|touch|chmod|chown|ln|truncate|tee|dd)\b/; + +function unquote(token: string): string { + const first = token[0]; + if ((first === '"' || first === "'") && token.endsWith(first) && token.length > 1) { + return token.slice(1, -1); + } + return token; +} + +/** + * Whether a write target escapes the roots the agent owns. + * + * Absolute paths are checked against {@link WRITABLE_ROOTS}. Relative paths + * resolve against `workdir` (which defaults into /workspace), so they are only + * treated as escaping when they traverse upward with `..`. + */ +function isOutsideWorkspace(path: string): boolean { + if (!path.startsWith('/')) { + return path.split('/').includes('..'); + } + return !WRITABLE_ROOTS.some((root) => path === root || path.startsWith(`${root}/`)); +} + +/** + * Extract the paths a single command segment writes to: redirect targets, plus + * the non-flag arguments of filesystem-mutating commands. + */ +function writeTargets(segment: string): readonly string[] { + const targets: string[] = []; + + // Redirects: `> path`, `>> path`, `2> path`. `>&1` style fd-dups are skipped. + const redirectRegex = /(?:^|\s)\d?>>?\s*(?!&)("[^"]*"|'[^']*'|\S+)/g; + let match: RegExpExecArray | null; + while ((match = redirectRegex.exec(segment)) !== null) { + const target = match[1]; + if (target !== undefined) { + targets.push(unquote(target)); + } + } + + const trimmed = segment.trim(); + // `sed` only writes when editing in place; otherwise it is a reader. + const mutates = FS_MUTATING_COMMAND.test(trimmed) || /^sed\b.*\s-i\b/.test(trimmed); + if (mutates) { + for (const token of trimmed.split(/\s+/).slice(1)) { + if (!token.startsWith('-')) { + targets.push(unquote(token)); + } + } + } + + return targets; +} + +/** + * Classify a command into the approval categories it triggers. + * + * This is a heuristic defence-in-depth layer that decides *when to ask a + * human*, not a security boundary — the container is the boundary, and the + * deny-pattern list is the hard stop. A sufficiently obfuscated command + * (base64-decoded payloads, variable indirection) will not be classified; + * that is accepted, because an unclassified command is still confined to the + * sandbox and still subject to {@link checkDenyPatterns}. + * + * @param command - The raw shell command string. + * @returns Sorted, de-duplicated categories. Empty means no approval needed. + */ +export function classifyShellCommand(command: string): readonly ShellSensitiveCategory[] { + const found = new Set(); + + // Word-boundary patterns match anywhere in the command, so subshell and + // compound contents are covered without decomposing first. + const lower = command.toLowerCase(); + for (const { regex, category } of SENSITIVE_PATTERNS) { + if (regex.test(lower)) { + found.add(category); + } + } + + // Write targets need segmentation — a path only means something relative to + // the command it is an argument of. + const segments = [ + ...splitCompoundCommand(command), + ...extractSubshells(command).flatMap((inner) => splitCompoundCommand(inner)), + ]; + for (const segment of segments) { + for (const target of writeTargets(segment)) { + if (isOutsideWorkspace(target)) { + found.add('fs-outside-workspace'); + } + } + } + + return [...found].sort(); +} + // ------------------------------------------------------------------ // // Shell tool factory // // ------------------------------------------------------------------ // @@ -169,6 +311,18 @@ export function createShellTool(containerId: string, containerRunner: IContainer required: ['command'], }, + // Only commands that reach outside the sandbox are gated — network egress, + // package installs, and writes escaping the roots the agent owns. Ordinary + // in-container work (ls, grep, build, test) is exempt via + // requiresApprovalForParams, which keeps prompt volume survivable and keeps + // sub-agents (hardcoded auto-deny, never promptable) usable for everything + // that isn't sensitive. The deny-pattern list remains the hard stop and + // still runs inside execute() after any approval. + requiresApproval: true, + requiresApprovalForParams: (p) => classifyShellCommand(String(p['command'] ?? '')).length > 0, + approvalScope: 'resource', + resourceKeyFromParams: (p) => classifyShellCommand(String(p['command'] ?? '')).join('+'), + async execute(params: Record, ctx?: ToolExecuteContext): Promise { const command = params['command'] as string; const workdir = typeof params['workdir'] === 'string' ? params['workdir'] : DEFAULT_WORKDIR; From 6b14ee2205564e4d9bec13bf077da6a80b8a4e13 Mon Sep 17 00:00:00 2001 From: jasonli0226 Date: Mon, 20 Jul 2026 01:04:45 +0800 Subject: [PATCH 04/10] feat(egress): per-container egress firewall for network runners Boots the network-capable runners behind an L3 egress filter: - egress-firewall.sh blocks private/metadata/gateway ranges (SSRF) and drops to an unprivileged user via gosu at container start; python-runner self-firewalls on boot with a dual-mode entrypoint. - container-runner gains an egressFirewall start flag and exec-user option; the no-network path is unchanged. ExecOptions/egress types live in shared. - python_run_net now runs behind the firewall; compose files, .env.example, and the image build scripts updated for the new build context. Integration test proves private/metadata/gateway blocked while public + DNS + allowlist work. --- .env.example | 7 +- docker-compose.dev.yml | 5 + docker-compose.prod.yml | 9 +- infra/docker/egress-firewall.sh | 90 ++++++++++ infra/docker/python-runner/Dockerfile | 19 +- .../engine/__tests__/container-runner.test.ts | 62 ++++++- .../egress-filter.integration.test.ts | 164 ++++++++++++++++++ .../__tests__/python-run-net-tool.test.ts | 18 +- packages/api/src/engine/container-runner.ts | 59 +++++-- .../src/engine/tools/python/python-run-net.ts | 37 ++-- packages/shared/src/types/container.ts | 6 + scripts/install.mjs | 4 +- scripts/setup-dev.sh | 2 +- scripts/update.mjs | 4 +- 14 files changed, 447 insertions(+), 39 deletions(-) create mode 100755 infra/docker/egress-firewall.sh create mode 100644 packages/api/src/engine/__tests__/egress-filter.integration.test.ts diff --git a/.env.example b/.env.example index d955702..fa89e5b 100644 --- a/.env.example +++ b/.env.example @@ -136,9 +136,14 @@ PYTHON_RUNNER_IMAGE=clawix-python-runner:latest PYTHON_POOL_IDLE_TIMEOUT_SEC=300 PYTHON_POOL_MAX_SIZE=20 PYTHON_NET_NETWORK_NAME=clawix-python-net-egress -# Comma-separated host[:port] entries permitted to bypass the RFC1918 block. +# Comma-separated host[:port] entries re-permitted past the per-container egress +# firewall (enforced by egress-firewall.sh baked into the runner image). IPv4 +# only — resolved via getent ahostsv4; all other IPv6 egress is blocked. # Example: PYTHON_INTERNAL_ALLOWLIST=admin.internal,grafana.internal:3000 PYTHON_INTERNAL_ALLOWLIST= +# Same, for shell_net's ephemeral containers on the egress network (IPv4, exact). +# Example: SHELL_NET_INTERNAL_ALLOWLIST=admin.internal,grafana.internal:3000 +SHELL_NET_INTERNAL_ALLOWLIST= # Which Plan-tier allowlist file the clawix-pypi-proxy mounts (prod compose only). # Values: standard | extended | unrestricted. Defaults to extended. PYTHON_ALLOWLIST_TIER=extended diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5675088..1bde551 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -268,3 +268,8 @@ networks: name: clawix-python-net-egress driver: bridge internal: false + # RFC1918/metadata egress is blocked PER-CONTAINER by the egress-firewall + # entrypoint baked into the runner images (routed private ranges + gateway + + # 169.254.169.254 dropped; public egress + DNS preserved). Used by + # python_run_net and shell_net ephemeral runners. + # See docs/specs/2026-07-16-egress-filter-design.md. diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f0618bc..6bff254 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -256,7 +256,8 @@ networks: name: clawix-python-net-egress driver: bridge # External default: this network reaches the public internet by default. - # RFC1918 blocking is enforced by the deploy environment's firewall rules - # or a sidecar egress proxy. Used by python_run_net ephemeral runners and - # also carries proxy traffic from those runners to clawix-pypi-proxy. - # See docs/specs/2026-05-08-python-run-tool-design.md §Security. + # RFC1918/metadata egress is blocked PER-CONTAINER by the egress-firewall + # entrypoint baked into the runner images (routed private ranges + gateway + + # 169.254.169.254 dropped; public egress + DNS preserved). Used by + # python_run_net and shell_net ephemeral runners, and carries proxy traffic + # to clawix-pypi-proxy. See docs/specs/2026-07-16-egress-filter-design.md. diff --git a/infra/docker/egress-firewall.sh b/infra/docker/egress-firewall.sh new file mode 100755 index 0000000..1f45b4b --- /dev/null +++ b/infra/docker/egress-firewall.sh @@ -0,0 +1,90 @@ +#!/bin/sh +# Egress firewall for ephemeral containers on clawix-python-net-egress. +# Runs as PID 1 (ENTRYPOINT). Two boot modes, keyed off the effective uid, +# because ONE image (clawix-python-runner:latest) serves both: +# +# * Booted as root (egress path: --user 0:0 + --cap-add NET_ADMIN, set by the +# python_run_net / shell_net tools): program the L3/L4 blocks below, then +# drop to uid 1000 via gosu and exec "$@". +# * Booted as uid 1000 (the SAME image's other caller — the no-network +# python_run warm pool: --user 1000:1000, NO NET_ADMIN): apply NO firewall +# (uid 1000 cannot, and that path never had one) and exec "$@" unchanged. +# This keeps the no-net path byte-identical at runtime despite the shared +# image. Without this branch the first iptables call fails under set -eu as +# uid 1000, the container dies, and python_run breaks. +# +# Fail-closed: on the root path any rule failure aborts (set -eu) so the +# container never runs agent code with an unfiltered network. +# +# NOTE: 127.0.0.0/8 is deliberately NOT dropped. The container netns has its own +# loopback, isolated from the host's; the only lo service is Docker's embedded +# DNS (127.0.0.11), and blocking it would break name resolution for zero gain +# (127.0.0.1: reaches only the agent's own processes). This is a conscious +# deviation from ssrf-protection.ts's isBlockedIpv4, which blocks loopback at the +# app layer where "loopback" means the host's. +set -eu + +# 0. No-network path (uid 1000, no caps). The shared image is also the +# python_run warm-pool base, which boots unprivileged and never had a +# firewall. Keep it that way; iptables here would only fail (no NET_ADMIN). +if [ "$(id -u)" != 0 ]; then + [ "$#" -eq 0 ] && set -- sleep infinity + exec "$@" +fi + +# ---- Egress path (root + NET_ADMIN): program the firewall, then drop to 1000. ---- + +# 1. Loopback (keeps Docker embedded DNS 127.0.0.11:53 reachable). +iptables -A OUTPUT -o lo -j ACCEPT + +# 2. Return traffic. (v4 OUTPUT policy stays ACCEPT so this is belt-and-braces +# on v4 — kept for symmetry with the v6 DROP-policy side; harmless.) +iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT + +# 3. Operator allowlist — EGRESS_ALLOWLIST="host[:port],host[:port]". +# Resolved to IPv4 at boot; ACCEPT inserted before the blocks (step 4). +if [ -n "${EGRESS_ALLOWLIST:-}" ]; then + OLD_IFS=$IFS; IFS=',' + for entry in $EGRESS_ALLOWLIST; do + IFS=$OLD_IFS + host=${entry%%:*} + port="" + case "$entry" in *:*) port=${entry##*:} ;; esac + [ -n "$host" ] || continue + # Resolve host to IPv4 addresses (getent works for names and literal IPs). + for ip in $(getent ahostsv4 "$host" 2>/dev/null | awk '{print $1}' | sort -u); do + if [ -n "$port" ]; then + iptables -A OUTPUT -d "$ip" -p tcp --dport "$port" -j ACCEPT + iptables -A OUTPUT -d "$ip" -p udp --dport "$port" -j ACCEPT + else + iptables -A OUTPUT -d "$ip" -j ACCEPT + fi + done + IFS=',' + done + IFS=$OLD_IFS +fi + +# 4. Block private / metadata / CGNAT / this-host destinations. +for net in 169.254.0.0/16 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 0.0.0.0/8; do + iptables -A OUTPUT -d "$net" -j DROP +done + +# 5. Public internet — default OUTPUT policy stays ACCEPT. + +# 6. IPv6 — only when the kernel has a v6 stack. /proc/net/if_inet6 exists iff +# IPv6 is enabled; if it's absent there is no v6 egress to leak, so skipping +# is safe and avoids a spurious fail-closed abort on v4-only hosts where +# ip6tables can't init its table. If v6 IS present and ip6tables fails, +# set -eu aborts (correct: never run with an unfiltered v6 path). +if [ -e /proc/net/if_inet6 ]; then + ip6tables -A OUTPUT -o lo -j ACCEPT + ip6tables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT + ip6tables -P OUTPUT DROP +fi + +# 7. Drop root, run the workload as uid 1000. No args → idle. +if [ "$#" -eq 0 ]; then + set -- sleep infinity +fi +exec gosu 1000:1000 "$@" diff --git a/infra/docker/python-runner/Dockerfile b/infra/docker/python-runner/Dockerfile index 68e2607..84b2b79 100644 --- a/infra/docker/python-runner/Dockerfile +++ b/infra/docker/python-runner/Dockerfile @@ -1,12 +1,17 @@ FROM python:3.12-slim +# iptables + gosu back the egress-firewall entrypoint: iptables programs the L3 +# blocks, gosu drops root -> uid 1000 once the rules are in place. RUN apt-get update && apt-get install -y --no-install-recommends \ - git jq \ + git jq iptables gosu \ && rm -rf /var/lib/apt/lists/* # Configure pip to use the proxy. (At pre-bake time we install from upstream # via the proxy too; at runtime, agent installs go through the same path.) -COPY pip.conf /etc/pip.conf +# Path is relative to the `infra/docker` build context (not this Dockerfile's +# dir), so it is `python-runner/pip.conf`. Build with: +# docker build -f infra/docker/python-runner/Dockerfile infra/docker +COPY python-runner/pip.conf /etc/pip.conf # Pre-bake the common set with pinned majors for reproducibility. RUN pip install --no-cache-dir --index-url https://pypi.org/simple/ \ @@ -19,6 +24,14 @@ RUN pip install --no-cache-dir --index-url https://pypi.org/simple/ \ RUN mkdir -p /workspace && chown -R 1000:1000 /workspace -USER 1000:1000 +# Egress firewall runs as root at boot, then drops to uid 1000. Do NOT set +# `USER 1000` — the entrypoint needs root to program iptables on the egress +# path (python_run_net), and no-ops straight to `exec "$@"` (still uid 1000) on +# the no-network warm-pool path (python_run), so this shared image stays correct +# for BOTH callers. +COPY egress-firewall.sh /usr/local/bin/egress-firewall.sh +RUN chmod +x /usr/local/bin/egress-firewall.sh + WORKDIR /workspace +ENTRYPOINT ["/usr/local/bin/egress-firewall.sh"] CMD ["sleep", "infinity"] diff --git a/packages/api/src/engine/__tests__/container-runner.test.ts b/packages/api/src/engine/__tests__/container-runner.test.ts index fa109db..bef0cc7 100644 --- a/packages/api/src/engine/__tests__/container-runner.test.ts +++ b/packages/api/src/engine/__tests__/container-runner.test.ts @@ -42,7 +42,8 @@ vi.mock('../mount-security.js', () => ({ })); // Dynamic import after mocks are in place -const { ContainerRunner } = await import('../container-runner.js'); +const { ContainerRunner, buildDockerRunArgs, buildDockerExecArgs } = + await import('../container-runner.js'); // ------------------------------------------------------------------ // // Helpers // @@ -438,3 +439,62 @@ describe('exec with AbortSignal', () => { expect(result.stderr).toMatch(/exec aborted|aborted/i); }); }); + +// ------------------------------------------------------------------ // +// buildDockerRunArgs — egress firewall // +// ------------------------------------------------------------------ // + +describe('buildDockerRunArgs egress firewall', () => { + it('adds NET_ADMIN + root user + allowlist env when egressFirewall is set', () => { + const args = buildDockerRunArgs({ + agentDef: makeAgentDef(), + containerName: 'c', + validatedMounts: [], + network: 'clawix-python-net-egress', + egressFirewall: true, + egressAllowlist: 'a.test:3001', + }); + expect(args).toContain('--cap-add'); + expect(args).toContain('NET_ADMIN'); + // starts as root, NOT 1000:1000 + const i = args.indexOf('--user'); + expect(args[i + 1]).toBe('0:0'); + expect(args).not.toContain('1000:1000'); + // allowlist env passed through + expect(args).toContain('EGRESS_ALLOWLIST=a.test:3001'); + }); + + it('is byte-identical to today when egressFirewall is absent', () => { + const args = buildDockerRunArgs({ + agentDef: makeAgentDef(), + containerName: 'c', + validatedMounts: [], + }); + expect(args).toContain('1000:1000'); + expect(args).not.toContain('NET_ADMIN'); + expect(args.join(' ')).not.toContain('EGRESS_ALLOWLIST'); + // Snapshot the full arg vector so positional / ordering drift is caught, + // not just membership — this is the "byte-identical no-net path" guarantee. + expect(args).toMatchSnapshot(); + }); +}); + +// ------------------------------------------------------------------ // +// buildDockerExecArgs — user option // +// ------------------------------------------------------------------ // + +describe('buildDockerExecArgs', () => { + it('adds --user when user option is set', () => { + const args = buildDockerExecArgs('cid', ['sh', '-c', 'echo hi'], { user: '1000:1000' }); + const i = args.indexOf('--user'); + expect(i).toBeGreaterThan(-1); + expect(args[i + 1]).toBe('1000:1000'); + expect(args).toEqual(expect.arrayContaining(['exec', 'cid', 'sh', '-c', 'echo hi'])); + }); + + it('omits --user when not set (unchanged behavior)', () => { + const args = buildDockerExecArgs('cid', ['ls'], {}); + expect(args).not.toContain('--user'); + expect(args).toEqual(['exec', 'cid', 'ls']); + }); +}); diff --git a/packages/api/src/engine/__tests__/egress-filter.integration.test.ts b/packages/api/src/engine/__tests__/egress-filter.integration.test.ts new file mode 100644 index 0000000..5d09bd2 --- /dev/null +++ b/packages/api/src/engine/__tests__/egress-filter.integration.test.ts @@ -0,0 +1,164 @@ +/** + * Live acceptance proof for the per-container egress firewall. + * + * Runs the REAL ContainerRunner against BOTH firewalled runner images on the + * real clawix-python-net-egress network and asserts, by exec: + * - public egress + DNS survive (https://example.com → HTTP 200), + * - cloud metadata (169.254.169.254) is blocked, + * - the Docker gateway host-port (the §3 escape) is blocked, + * - EGRESS_ALLOWLIST re-permits a specific host:port. + * + * Probe uses python3 (present in BOTH images — python-runner has no curl) and + * classifies the outcome by exit code so we can tell "firewall permitted" from + * "service unreachable": + * exit 0 → reached (HTTP reply OR connection refused/reset = SYN allowed out) + * exit 28 → timed out (the DROP rule ate the packet = firewall blocked) + * exit 1 → other error + * + * Gated: skips (loudly) when Docker / the images / the network are absent or in + * CI, mirroring container-runner.integration.test.ts. + */ +import { execFile as execFileCb } from 'child_process'; +import { promisify } from 'util'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { ContainerRunner } from '../container-runner.js'; + +const execFile = promisify(execFileCb); +const NET = 'clawix-python-net-egress'; +const IMAGES = ['clawix-python-runner:latest', 'clawix-shell-net-runner:latest']; + +// Classifies reachability by exit code (see file header). +const PROBE = [ + 'import sys, socket, urllib.request, urllib.error', + 'try:', + ' urllib.request.urlopen(sys.argv[1], timeout=5)', + " print('HTTP-OK'); sys.exit(0)", + 'except urllib.error.URLError as e:', + ' r = e.reason', + " if isinstance(r, socket.timeout) or 'timed out' in str(r).lower():", + " print('TIMEOUT'); sys.exit(28)", + " print('REACHED', r); sys.exit(0)", + 'except Exception as e:', + " print('OTHER', e); sys.exit(1)", +].join('\n'); + +async function dockerOk(): Promise { + try { + await execFile('docker', ['info'], { timeout: 5000 }); + return true; + } catch { + return false; + } +} + +async function present(): Promise { + try { + for (const img of IMAGES) { + const { stdout } = await execFile('docker', ['images', '-q', img]); + if (!stdout.trim()) return false; + } + await execFile('docker', ['network', 'inspect', NET]); + return true; + } catch { + return false; + } +} + +async function gateway(): Promise { + const { stdout } = await execFile('docker', [ + 'network', + 'inspect', + NET, + '--format', + '{{(index .IPAM.Config 0).Gateway}}', + ]); + return stdout.trim(); +} + +const canRun = (await dockerOk()) && (await present()) && process.env['CI'] !== 'true'; +if (!canRun) { + console.warn('[egress-filter.integration] SKIPPED — docker/images/network missing or CI'); +} + +function defFor(image: string) { + return { + id: 'egress-test', + name: 'egress-test', + description: null, + systemPrompt: '', + role: 'worker', + provider: 'none', + model: 'none', + apiBaseUrl: null, + skillIds: [], + maxTokensPerRun: 0, + containerConfig: { + image, + cpuLimit: '1', + memoryLimit: '512m', + timeoutSeconds: 120, + readOnlyRootfs: false, + allowedMounts: [], + idleTimeoutSeconds: 0, + }, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + } as never; +} + +describe.skipIf(!canRun)('egress firewall (real Docker)', () => { + const runner = new ContainerRunner(); + let started: string[] = []; + + afterEach(async () => { + for (const id of started) await runner.stop(id).catch(() => {}); + started = []; + }, 60000); // stop() has a 10s docker-stop grace per container — above vitest's 10s hook default + + /** Returns the probe's exit code (0 reached, 28 timed-out/blocked, 1 other). */ + async function probe(id: string, url: string): Promise { + const r = await runner.exec(id, ['python3', '-c', PROBE, url], { + user: '1000:1000', + timeout: 20000, + }); + return r.exitCode; + } + + for (const image of IMAGES) { + it(`${image}: blocks private+metadata, allows public, keeps DNS`, async () => { + const id = await runner.start(defFor(image), [], { + disableAutoStop: true, + network: NET, + egressFirewall: true, + }); + started.push(id); + const gw = await gateway(); + + // public by hostname → also proves DNS survives the firewall + expect(await probe(id, 'https://example.com')).toBe(0); + // cloud metadata → blocked (DROP → timeout) + expect(await probe(id, 'http://169.254.169.254/')).toBe(28); + // §3 host-port escape via the Docker gateway → blocked + expect(await probe(id, `http://${gw}:3001`)).toBe(28); + }, 120000); + } + + it('allowlist re-permits a specific host:port', async () => { + const gw = await gateway(); + const id = await runner.start(defFor('clawix-python-runner:latest'), [], { + disableAutoStop: true, + network: NET, + egressFirewall: true, + egressAllowlist: `${gw}:3001`, + }); + started.push(id); + // With gateway:3001 allowlisted the SYN is permitted; whether :3001 answers + // or refuses, the outcome is "reached" (exit 0), never a firewall timeout + // (28). Asserting "not 28" isolates the firewall decision from whether a + // service happens to be listening. + expect(await probe(id, `http://${gw}:3001`)).not.toBe(28); + }, 120000); +}); diff --git a/packages/api/src/engine/__tests__/python-run-net-tool.test.ts b/packages/api/src/engine/__tests__/python-run-net-tool.test.ts index 1d3f2b3..4951b45 100644 --- a/packages/api/src/engine/__tests__/python-run-net-tool.test.ts +++ b/packages/api/src/engine/__tests__/python-run-net-tool.test.ts @@ -59,7 +59,23 @@ describe('python_run_net tool', () => { await tool.execute({ code: 'print(1)' }, { abortSignal: new AbortController().signal }); const startArgs = (deps.runner.start as ReturnType).mock.calls[0]; // start(agentDef, mounts, options) — third arg must include network: 'clawix-python-net-egress' - expect(startArgs[2]).toMatchObject({ network: 'clawix-python-net-egress' }); + expect(startArgs[2]).toMatchObject({ + network: 'clawix-python-net-egress', + egressFirewall: true, + }); + }); + + it('runs every agent exec as uid 1000 (root-booted container drops to 1000)', async () => { + const deps = makeDeps(); + await createPythonRunNetTool(deps).execute( + { code: 'print(1)' }, + { abortSignal: new AbortController().signal }, + ); + const execMock = deps.runner.exec as ReturnType; + expect(execMock.mock.calls.length).toBeGreaterThan(0); + for (const call of execMock.mock.calls) { + expect(call[2]).toMatchObject({ user: '1000:1000' }); + } }); it('passes timeout to runner.exec in milliseconds', async () => { diff --git a/packages/api/src/engine/container-runner.ts b/packages/api/src/engine/container-runner.ts index 8897147..5710484 100644 --- a/packages/api/src/engine/container-runner.ts +++ b/packages/api/src/engine/container-runner.ts @@ -75,6 +75,15 @@ export interface StartOptions { * needs to reach a sidecar service such as the PyPI proxy. */ readonly network?: string; + /** + * Attach a boot-time egress firewall: start the container as root with + * NET_ADMIN so the baked `egress-firewall.sh` entrypoint can program iptables, + * then it drops to uid 1000. Egress path only (`python_run_net`, `shell_net`); + * every existing caller leaves this false and gets today's args verbatim. + */ + readonly egressFirewall?: boolean; + /** Passed as EGRESS_ALLOWLIST into the firewall container (comma-separated host[:port]). */ + readonly egressAllowlist?: string; } // ------------------------------------------------------------------ // @@ -150,6 +159,8 @@ export class ContainerRunner implements IContainerRunner { workspaceHostPath: options.workspaceHostPath, skillMounts: options.skillMounts, network: options.network, + egressFirewall: options.egressFirewall, + egressAllowlist: options.egressAllowlist, }); logger.info( @@ -190,23 +201,13 @@ export class ContainerRunner implements IContainerRunner { command: readonly string[], options: ExecOptions = {}, ): Promise { - const { stdin, workdir, timeout: rawTimeout, signal } = options; + const { stdin, workdir, timeout: rawTimeout, signal, user } = options; const timeoutMs = Math.min(rawTimeout ?? DEFAULT_EXEC_TIMEOUT_MS, MAX_EXEC_TIMEOUT_MS); - const args: string[] = ['exec']; - - if (workdir !== undefined) { - args.push('-w', workdir); - } - - if (stdin !== undefined) { - args.push('-i'); - } - - args.push(containerId, ...command); + const args = buildDockerExecArgs(containerId, command, { workdir, stdin, user }); - logger.debug({ containerId, command, workdir }, 'Executing command in container'); + logger.debug({ containerId, command, workdir, user }, 'Executing command in container'); if (stdin !== undefined) { return this.execWithStdin(args, stdin, signal); @@ -397,6 +398,10 @@ interface DockerRunArgsParams { readonly skillMounts?: StartOptions['skillMounts']; /** Docker network to attach the container to. Defaults to 'none'. */ readonly network?: string; + /** Start as root + NET_ADMIN for the egress-firewall entrypoint (see StartOptions). */ + readonly egressFirewall?: boolean; + /** Value for the container's EGRESS_ALLOWLIST env when egressFirewall is set. */ + readonly egressAllowlist?: string; } /** @@ -412,8 +417,11 @@ export function buildDockerRunArgs(params: DockerRunArgsParams): string[] { '-d', '--name', containerName, + // Egress-firewall containers must boot as root so the entrypoint can program + // iptables; it drops to uid 1000 via gosu before running any workload. Every + // other caller starts unprivileged as today. '--user', - '1000:1000', + params.egressFirewall ? '0:0' : '1000:1000', '--network', params.network ?? 'none', '--cpus', @@ -432,6 +440,11 @@ export function buildDockerRunArgs(params: DockerRunArgsParams): string[] { `TZ=${process.env['TZ'] ?? 'UTC'}`, ]; + if (params.egressFirewall) { + args.push('--cap-add', 'NET_ADMIN'); + args.push('-e', `EGRESS_ALLOWLIST=${params.egressAllowlist ?? ''}`); + } + if (containerConfig.readOnlyRootfs) { args.push('--read-only'); args.push('--tmpfs', '/tmp:rw,noexec,nosuid,size=64m'); @@ -463,6 +476,24 @@ export function buildDockerRunArgs(params: DockerRunArgsParams): string[] { return args; } +/** + * Build the argument list for `docker exec`. Pure function, exported for + * testability. `--user` is emitted first (before -w/-i) when set so the + * exec runs as that uid:gid; omitting it preserves today's behavior exactly. + */ +export function buildDockerExecArgs( + containerId: string, + command: readonly string[], + options: { workdir?: string; stdin?: string; user?: string }, +): string[] { + const args: string[] = ['exec']; + if (options.user !== undefined) args.push('--user', options.user); + if (options.workdir !== undefined) args.push('-w', options.workdir); + if (options.stdin !== undefined) args.push('-i'); + args.push(containerId, ...command); + return args; +} + // ------------------------------------------------------------------ // // cleanupOrphanContainers // // ------------------------------------------------------------------ // diff --git a/packages/api/src/engine/tools/python/python-run-net.ts b/packages/api/src/engine/tools/python/python-run-net.ts index 0397162..d2a3363 100644 --- a/packages/api/src/engine/tools/python/python-run-net.ts +++ b/packages/api/src/engine/tools/python/python-run-net.ts @@ -43,6 +43,10 @@ const logger = createLogger('engine:tools:python_run_net'); const NETWORK_NAME = process.env['PYTHON_NET_NETWORK_NAME'] ?? 'clawix-python-net-egress'; const RUNNER_IMAGE = process.env['PYTHON_RUNNER_IMAGE'] ?? 'clawix-python-runner:latest'; +/** Operator allowlist re-permitting internal host[:port] targets past the egress block. */ +const ALLOWLIST = process.env['PYTHON_INTERNAL_ALLOWLIST'] ?? ''; +/** uid:gid agent execs run as (the container boots root for the firewall, drops to 1000). */ +const EXEC_USER = '1000:1000'; // ------------------------------------------------------------------ // // Deps interface // @@ -60,6 +64,8 @@ export interface PythonRunNetDeps { disableAutoStop?: boolean; workspaceHostPath?: string; network?: string; + egressFirewall?: boolean; + egressAllowlist?: string; }, ) => Promise; exec: ( @@ -70,6 +76,7 @@ export interface PythonRunNetDeps { timeout?: number; workdir?: string; stdin?: string; + user?: string; }, ) => Promise<{ exitCode: number; stdout: string; stderr: string }>; stop: (id: string) => Promise; @@ -134,6 +141,8 @@ export function createPythonRunNetTool(deps: PythonRunNetDeps): Tool { }, }, }, + requiresApproval: true, + approvalScope: 'run', async execute( rawInput: Record, @@ -194,12 +203,19 @@ export function createPythonRunNetTool(deps: PythonRunNetDeps): Tool { disableAutoStop: true, workspaceHostPath: deps.workspaceHostPath, network: NETWORK_NAME, + egressFirewall: true, + egressAllowlist: ALLOWLIST, }); try { + // The container boots as root for the firewall entrypoint, then drops + // to uid 1000; every agent exec below must run as uid 1000 so files it + // writes to /workspace keep uid-1000 ownership (root writes would break + // subsequent uid-1000 access). const markerPath = `/tmp/python_run_net_marker_${callId}`; await deps.runner.exec(containerId, ['touch', markerPath], { signal: ctx?.abortSignal, + user: EXEC_USER, }); // Install extra packages if requested @@ -208,7 +224,7 @@ export function createPythonRunNetTool(deps: PythonRunNetDeps): Tool { const installRes = await deps.runner.exec( containerId!, ['pip', 'install', '--quiet', '--no-color', ...input.packages!], - { signal: ctx?.abortSignal, timeout: 120 * 1000 }, + { signal: ctx?.abortSignal, timeout: 120 * 1000, user: EXEC_USER }, ); if (installRes.exitCode !== 0) { throw new PythonToolError( @@ -239,7 +255,7 @@ export function createPythonRunNetTool(deps: PythonRunNetDeps): Tool { `if [ -e "$0" ]; then readlink -f "$0"; else echo NOTFOUND; fi`, input.script!, ], - { signal: ctx?.abortSignal, timeout: 5_000 }, + { signal: ctx?.abortSignal, timeout: 5_000, user: EXEC_USER }, ); const resolved = checkRes.stdout.trim(); if (resolved === 'NOTFOUND' || !resolved.startsWith('/workspace/')) { @@ -260,6 +276,7 @@ export function createPythonRunNetTool(deps: PythonRunNetDeps): Tool { signal: ctx?.abortSignal, timeout: timeoutSec * 1000, workdir: '/workspace', + user: EXEC_USER, }); // Collect workspace files modified during this run @@ -267,16 +284,11 @@ export function createPythonRunNetTool(deps: PythonRunNetDeps): Tool { ? [] : parseFindOutput( ( - await deps.runner.exec(containerId, [ - 'find', - '/workspace', - '-newer', - markerPath, - '-type', - 'f', - '-printf', - '%P\n', - ]) + await deps.runner.exec( + containerId, + ['find', '/workspace', '-newer', markerPath, '-type', 'f', '-printf', '%P\n'], + { user: EXEC_USER }, + ) ).stdout, ); @@ -350,6 +362,7 @@ async function writeInlineScript( await deps.runner.exec(containerId, ['sh', '-c', `cat > ${path}`], { signal, stdin: code, + user: EXEC_USER, }); return path; } diff --git a/packages/shared/src/types/container.ts b/packages/shared/src/types/container.ts index 25023bc..6691781 100644 --- a/packages/shared/src/types/container.ts +++ b/packages/shared/src/types/container.ts @@ -37,6 +37,12 @@ export interface ExecOptions { readonly stdin?: string; readonly workdir?: string; readonly timeout?: number; + /** + * Run the exec as this user (e.g. '1000:1000'). Omitted → the container's + * default user. Egress-firewall containers boot as root and drop to uid 1000 + * via gosu, so agent execs must pass '1000:1000' to stay unprivileged. + */ + readonly user?: string; /** * Optional signal to abort the in-flight `docker exec`. When fired, * the child receives SIGTERM and exec() resolves with `{ exitCode: -1, ... }` diff --git a/scripts/install.mjs b/scripts/install.mjs index 360744c..1029543 100755 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -608,7 +608,9 @@ async function main() { ok('clawix-agent:latest built'); step('Building python-runner Docker image'); - runVisible('docker build -t clawix-python-runner:latest infra/docker/python-runner'); + runVisible( + 'docker build -t clawix-python-runner:latest -f infra/docker/python-runner/Dockerfile infra/docker', + ); ok('clawix-python-runner:latest built'); step(`Starting stack (${deployMode})`); diff --git a/scripts/setup-dev.sh b/scripts/setup-dev.sh index dde87dd..250f9ce 100755 --- a/scripts/setup-dev.sh +++ b/scripts/setup-dev.sh @@ -38,7 +38,7 @@ pnpm --filter @clawix/shared run build if command -v docker >/dev/null 2>&1; then echo "Starting local infrastructure (Postgres, Redis, pgAdmin)..." docker build -t clawix-agent:latest -f infra/docker/agent/Dockerfile . - docker build -t clawix-python-runner:latest infra/docker/python-runner + docker build -t clawix-python-runner:latest -f infra/docker/python-runner/Dockerfile infra/docker docker compose -f docker-compose.dev.yml up -d echo "Waiting for services to be healthy..." diff --git a/scripts/update.mjs b/scripts/update.mjs index 50f91b4..8a6e6bd 100755 --- a/scripts/update.mjs +++ b/scripts/update.mjs @@ -115,7 +115,9 @@ async function main() { ok('clawix-agent:latest built'); step('Building python-runner Docker image'); - runVisible('docker build -t clawix-python-runner:latest infra/docker/python-runner'); + runVisible( + 'docker build -t clawix-python-runner:latest -f infra/docker/python-runner/Dockerfile infra/docker', + ); ok('clawix-python-runner:latest built'); } From c7b2ca6d129372535bd2e2a748a80afad7d2ec4e Mon Sep 17 00:00:00 2001 From: jasonli0226 Date: Mon, 20 Jul 2026 01:05:01 +0800 Subject: [PATCH 05/10] feat(approvals): engine guard at the ToolRegistry chokepoint Enforces approvals where every tool call funnels through: - ToolRegistry gates execute() behind ToolApprovalService with registration-time scope validation; fails closed when an approval-requiring tool runs without an approval context. Per-action predicates keep read-only actions exempt (cron gates only mutating actions). - Privileged built-ins (cron, wiki share/unshare/delete, browser) annotated with approval scopes; MCP tools derive ask-by-default from captured annotations plus per-tool overrides. - Approval context wired into agent runs. Full integration coverage. --- .../container-runner.test.ts.snap | 34 ++ .../__tests__/agent-runner.service.test.ts | 82 +++- .../builtin-approval-annotations.test.ts | 369 ++++++++++++++++++ .../tool-approval.integration.test.ts | 360 +++++++++++++++++ .../__tests__/tool-registry-approval.test.ts | 223 +++++++++++ .../api/src/engine/agent-runner.service.ts | 31 ++ packages/api/src/engine/engine.module.ts | 16 +- packages/api/src/engine/tool-registry.ts | 106 +++++ .../engine/tools/browser/tools/browser-cdp.ts | 2 + packages/api/src/engine/tools/cron.ts | 14 + .../tools/mcp/__tests__/mcp-approval.test.ts | 22 ++ .../api/src/engine/tools/mcp/mcp-approval.ts | 21 + .../engine/tools/mcp/mcp-tool.factory.spec.ts | 63 +++ .../src/engine/tools/mcp/mcp-tool.factory.ts | 20 +- .../src/engine/tools/wiki/wiki-delete.tool.ts | 3 + .../src/engine/tools/wiki/wiki-share.tool.ts | 4 + .../engine/tools/wiki/wiki-unshare.tool.ts | 3 + 17 files changed, 1359 insertions(+), 14 deletions(-) create mode 100644 packages/api/src/engine/__tests__/__snapshots__/container-runner.test.ts.snap create mode 100644 packages/api/src/engine/__tests__/builtin-approval-annotations.test.ts create mode 100644 packages/api/src/engine/__tests__/tool-approval.integration.test.ts create mode 100644 packages/api/src/engine/__tests__/tool-registry-approval.test.ts create mode 100644 packages/api/src/engine/tools/mcp/__tests__/mcp-approval.test.ts create mode 100644 packages/api/src/engine/tools/mcp/mcp-approval.ts diff --git a/packages/api/src/engine/__tests__/__snapshots__/container-runner.test.ts.snap b/packages/api/src/engine/__tests__/__snapshots__/container-runner.test.ts.snap new file mode 100644 index 0000000..6c73222 --- /dev/null +++ b/packages/api/src/engine/__tests__/__snapshots__/container-runner.test.ts.snap @@ -0,0 +1,34 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`buildDockerRunArgs egress firewall > is byte-identical to today when egressFirewall is absent 1`] = ` +[ + "run", + "-d", + "--name", + "c", + "--user", + "1000:1000", + "--network", + "none", + "--cpus", + "0.5", + "--memory", + "512m", + "--pids-limit", + "256", + "--ulimit", + "nofile=1024:1024", + "--security-opt", + "no-new-privileges", + "--label", + "clawix.timeout=30", + "-e", + "TZ=UTC", + "--read-only", + "--tmpfs", + "/tmp:rw,noexec,nosuid,size=64m", + "clawix-agent:latest", + "sleep", + "infinity", +] +`; diff --git a/packages/api/src/engine/__tests__/agent-runner.service.test.ts b/packages/api/src/engine/__tests__/agent-runner.service.test.ts index ae4eb8d..3cff7e1 100644 --- a/packages/api/src/engine/__tests__/agent-runner.service.test.ts +++ b/packages/api/src/engine/__tests__/agent-runner.service.test.ts @@ -89,6 +89,7 @@ vi.mock('fs', async (importOriginal) => { // ------------------------------------------------------------------ // import { AgentRunnerService } from '../agent-runner.service.js'; +import { ToolRegistry } from '../tool-registry.js'; import type { RunOptions } from '../agent-runner.types.js'; import type { SessionManagerService } from '../session-manager.service.js'; import type { ContainerRunner } from '../container-runner.js'; @@ -445,6 +446,14 @@ function buildMocks() { getAccessToken: vi.fn().mockResolvedValue('tok'), }; + const mockToolApprovalService: { + gate: ReturnType; + clearSessionMemory: ReturnType; + } = { + gate: vi.fn().mockResolvedValue({ allowed: true }), + clearSessionMemory: vi.fn(), + }; + return { mockSessionManager, mockContainerRunner, @@ -470,15 +479,19 @@ function buildMocks() { mockNotificationRepo, mockMcpClientService, mockMcpTokenManager, + mockToolApprovalService, }; } /** - * Construct an AgentRunnerService with all 41 constructor deps in the EXACT + * Construct an AgentRunnerService with all 44 constructor deps in the EXACT * order declared in agent-runner.service.ts. Centralized so the three test * suites can't drift — and so newly-added deps land at their real positions - * (positions 28-37 were previously omitted-as-undefined; 38-41 are the MCP - * deps that MUST be at the tail, not masquerading as python deps). + * (positions 28-38 were previously omitted-as-undefined; 39-42 are the MCP + * deps that MUST be at the tail, not masquerading as python deps; 43 is the + * run-activity registry; 44 is the tool approval service, Task 10). Position + * 31 is the shell_net concurrency limiter (Task 6), inserted next to + * pythonLimiter to mirror the real constructor. */ function buildService(mocks: ReturnType): AgentRunnerService { return new AgentRunnerService( @@ -518,26 +531,30 @@ function buildService(mocks: ReturnType): AgentRunnerService { read: vi.fn().mockReturnValue(2), warm: vi.fn().mockResolvedValue(undefined) } as any, // 27: agentRunRegistry mocks.mockAgentRunRegistry as unknown as AgentRunRegistry, - // 28-31: python* (gated off in tests; unused → empty stubs) + // 28-32: python*/shellNet* (gated off in tests; unused → empty stubs) + {} as any, {} as any, {} as any, + // 31: shellNetLimiter (Task 6, next to pythonLimiter) {} as any, {} as any, - // 32-35: wiki* (registerWikiTools is real, but takes objects it doesn't call here) + // 33-36: wiki* (registerWikiTools is real, but takes objects it doesn't call here) {} as any, {} as any, {} as any, {} as any, - // 36-37: auditLogRepo, sessionSearchService + // 37-38: auditLogRepo, sessionSearchService {} as any, {} as any, - // 38-41: MCP deps (the tail — must be here, not at python positions) + // 39-42: MCP deps (the tail — must be here, not at python positions) mocks.mockMcpServerRepo as any, mocks.mockNotificationRepo as any, mocks.mockMcpClientService as any, mocks.mockMcpTokenManager as any, - // 42: run-activity registry (per-run logger + activity tracking) + // 43: run-activity registry (per-run logger + activity tracking) mocks.mockRunActivity as unknown as import('../run-activity.registry.js').RunActivityRegistry, + // 44: tool approval service (approval gate bound into the ToolRegistry's approval context) + mocks.mockToolApprovalService as unknown as import('../../approvals/tool-approval.service.js').ToolApprovalService, ); } @@ -836,6 +853,54 @@ describe('AgentRunnerService', () => { expect(vi.mocked(createSpawnTool)).not.toHaveBeenCalled(); }); + // ---------------------------------------------------------------- // + // Test 14b: Wires the approval context onto the ToolRegistry // + // ---------------------------------------------------------------- // + + it('sets the approval context on the ToolRegistry for a primary run', async () => { + const spy = vi.spyOn(ToolRegistry.prototype, 'setApprovalContext'); + + await service.run(defaultOptions); + + expect(spy).toHaveBeenCalledTimes(1); + const ctx = spy.mock.calls[0]![0]; + expect(ctx).toMatchObject({ + userId: 'user-1', + agentRunId: 'run-1', + sessionId: 'sess-1', + isSubAgent: false, + }); + expect(typeof ctx.gate).toBe('function'); + + // The bound gate must delegate to ToolApprovalService.gate unmodified. + const gateInput = { + userId: 'user-1', + agentRunId: 'run-1', + sessionId: 'sess-1', + isSubAgent: false, + toolName: 'shell', + scopeKind: 'call' as const, + resourceKey: '', + extractorFailed: false, + params: {}, + }; + await ctx.gate(gateInput); + expect(mocks.mockToolApprovalService.gate).toHaveBeenCalledWith(gateInput); + + spy.mockRestore(); + }); + + it('sets isSubAgent true in the approval context for a sub-agent run', async () => { + const spy = vi.spyOn(ToolRegistry.prototype, 'setApprovalContext'); + + await service.run({ ...defaultOptions, isSubAgent: true }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]![0]).toMatchObject({ isSubAgent: true }); + + spy.mockRestore(); + }); + // ---------------------------------------------------------------- // // Test 15: Reuses existing AgentRun when agentRunId is provided // // ---------------------------------------------------------------- // @@ -1050,6 +1115,7 @@ describe('AgentRunnerService', () => { 'user-1', // userId undefined, // budgetTracker — none in this test (no tokenBudget passed) 300000, // subAgentTimeoutMs from policy.maxSubAgentRunMs + ['openai', 'anthropic'], // allowedProviders from policy, for anonymous default-worker resolution ); }); diff --git a/packages/api/src/engine/__tests__/builtin-approval-annotations.test.ts b/packages/api/src/engine/__tests__/builtin-approval-annotations.test.ts new file mode 100644 index 0000000..9a6b386 --- /dev/null +++ b/packages/api/src/engine/__tests__/builtin-approval-annotations.test.ts @@ -0,0 +1,369 @@ +vi.mock('@clawix/shared', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +import { describe, expect, it, vi } from 'vitest'; + +import { createPythonRunNetTool } from '../tools/python/python-run-net.js'; +import { createBrowserCdpTool } from '../tools/browser/tools/browser-cdp.js'; +import { createShellNetTool } from '../tools/shell-net.js'; +import { createWikiDeleteTool } from '../tools/wiki/wiki-delete.tool.js'; +import { createWikiShareTool } from '../tools/wiki/wiki-share.tool.js'; +import { createWikiUnshareTool } from '../tools/wiki/wiki-unshare.tool.js'; +import { createCronTool } from '../tools/cron.js'; +import { createShellTool } from '../tools/shell.js'; +import type { IContainerRunner } from '../container-runner.js'; +import type { WikiPageRepository } from '../../db/wiki-page.repository.js'; +import type { WikiLinkRepository } from '../../db/wiki-link.repository.js'; +import type { AuditLogRepository } from '../../db/audit-log.repository.js'; +import type { WikiShareRepository } from '../../db/wiki-share.repository.js'; +import type { UserRepository } from '../../db/user.repository.js'; +import type { PrismaService } from '../../prisma/prisma.service.js'; +import type { CronGuardService } from '../cron-guard.service.js'; +import type { ChannelRepository } from '../../db/channel.repository.js'; +import type { TaskRepository } from '../../db/task.repository.js'; +import type { TaskRunRepository } from '../../db/task-run.repository.js'; +import type { TaskRunMessageRepository } from '../../db/task-run-message.repository.js'; +import type { CronPolicy } from '../tools/cron.js'; +import type { BrowserSessionManager } from '../tools/browser/browser-session-manager.js'; + +// ------------------------------------------------------------------ // +// Mock Builders // +// ------------------------------------------------------------------ // + +function makeMockPythonRunNetDeps() { + return { + userId: 'user-1', + workspaceHostPath: '/workspace', + policy: { + allowPythonNet: true, + maxPythonTimeoutSecs: 300, + maxPythonMemoryMb: 512, + maxPythonCpuCores: 2, + maxConcurrentPythonRuns: 5, + }, + runner: { + start: vi.fn(), + exec: vi.fn(), + stop: vi.fn(), + }, + proxyHealth: { isHealthy: () => true }, + limiter: { + acquire: vi.fn(), + release: vi.fn(), + }, + installMutex: { + runExclusive: vi.fn(), + }, + }; +} + +function makeMockWikiRepos() { + const pages = { + findById: vi.fn().mockResolvedValue({ + id: 'page-1', + slug: 'test-page', + title: 'Test Page', + ownerId: 'user-1', + }), + deleteByOwner: vi.fn().mockResolvedValue(true), + } as unknown as WikiPageRepository; + + const links = { + deleteAllForPage: vi.fn().mockResolvedValue(undefined), + } as unknown as WikiLinkRepository; + + const audit = { + create: vi.fn().mockResolvedValue({}), + } as unknown as AuditLogRepository; + + const shares = { + setOrgShare: vi.fn().mockResolvedValue({ + id: 'share-1', + pageId: 'page-1', + targetType: 'ORG', + }), + setGroupShare: vi.fn().mockResolvedValue({ + id: 'share-1', + pageId: 'page-1', + targetType: 'GROUP', + groupId: 'group-1', + }), + revokeShareById: vi.fn().mockResolvedValue(true), + } as unknown as WikiShareRepository; + + const users = { + findById: vi.fn().mockResolvedValue({ id: 'user-1', role: 'admin' }), + } as unknown as UserRepository; + + const prisma = { + groupMember: { + findFirst: vi.fn().mockResolvedValue({ userId: 'user-1', groupId: 'group-1' }), + }, + wikiShare: { + findUnique: vi.fn().mockResolvedValue({ + id: 'share-1', + pageId: 'page-1', + targetType: 'ORG', + }), + }, + } as unknown as PrismaService; + + return { pages, links, audit, shares, users, prisma }; +} + +function makeMockCronDeps() { + const cronGuard = { + canCreate: vi.fn().mockResolvedValue({ allowed: true }), + } as unknown as CronGuardService; + + const taskRepo = { + findByUser: vi.fn().mockResolvedValue([]), + findById: vi.fn().mockResolvedValue({ + id: 'task-1', + name: 'Test Task', + createdByUserId: 'user-1', + }), + create: vi.fn().mockResolvedValue({ id: 'task-1', name: 'Test Task' }), + delete: vi.fn().mockResolvedValue(undefined), + updateNextRunAt: vi.fn().mockResolvedValue(undefined), + } as unknown as TaskRepository; + + const channelRepo = { + findByType: vi.fn().mockResolvedValue([]), + } as unknown as ChannelRepository; + + const taskRunRepo = { + findByTaskIdWithLimit: vi.fn().mockResolvedValue([]), + findById: vi.fn().mockResolvedValue({ + id: 'run-1', + taskId: 'task-1', + status: 'completed', + startedAt: new Date(), + }), + } as unknown as TaskRunRepository; + + const taskRunMessageRepo = { + findByTaskRunId: vi.fn().mockResolvedValue([]), + } as unknown as TaskRunMessageRepository; + + const policy: CronPolicy = { + cronEnabled: true, + maxScheduledTasks: 10, + minCronIntervalSecs: 60, + maxTokensPerCronRun: null, + }; + + return { + cronGuard, + taskRepo, + channelRepo, + taskRunRepo, + taskRunMessageRepo, + policy, + }; +} + +function makeMockBrowserSessionManager() { + return { + getPlaywrightContext: vi.fn().mockReturnValue({ + pages: () => [ + { + context: () => ({ + newCDPSession: vi.fn(), + }), + }, + ], + }), + } as unknown as BrowserSessionManager; +} + +// ------------------------------------------------------------------ // +// Tests // +// ------------------------------------------------------------------ // + +describe('built-in approval annotations (spec §2 table)', () => { + it('python_run_net is run-scoped with no extractor', () => { + const deps = makeMockPythonRunNetDeps(); + const tool = createPythonRunNetTool(deps); + + expect(tool.requiresApproval).toBe(true); + expect(tool.approvalScope).toBe('run'); + expect(tool.resourceKeyFromParams).toBeUndefined(); + }); + + it('browser_cdp is run-scoped with no extractor', () => { + const manager = makeMockBrowserSessionManager(); + const getRunContext = () => ({ + runId: 'run-1', + policy: { allowBrowserCdp: true }, + }); + const tool = createBrowserCdpTool(manager, getRunContext); + + expect(tool.requiresApproval).toBe(true); + expect(tool.approvalScope).toBe('run'); + expect(tool.resourceKeyFromParams).toBeUndefined(); + }); + + it('shell_net is run-scoped approval-gated', () => { + const tool = createShellNetTool({ + userId: 'u1', + workspaceHostPath: '/tmp/ws', + policy: { + allowShellNet: true, + maxShellNetMemoryMb: 512, + maxShellNetTimeoutSecs: 60, + maxShellNetCpuCores: 1, + maxConcurrentShellNetRuns: 2, + }, + runner: { + start: async () => 'c', + exec: async () => ({ exitCode: 0, stdout: '', stderr: '' }), + stop: async () => undefined, + }, + limiter: { acquire: () => undefined, release: () => undefined }, + }); + expect(tool.requiresApproval).toBe(true); + expect(tool.approvalScope).toBe('run'); + }); + + it('wiki_delete is resource-scoped with pageId extractor', () => { + const { pages, links, audit } = makeMockWikiRepos(); + const tool = createWikiDeleteTool(pages, links, audit, 'user-1'); + + expect(tool.requiresApproval).toBe(true); + expect(tool.approvalScope).toBe('resource'); + expect(tool.resourceKeyFromParams).toBeDefined(); + + // Test extractor with pageId + expect(tool.resourceKeyFromParams?.({ pageId: 'page-1' })).toBe('page-1'); + expect(tool.resourceKeyFromParams?.({ pageId: 'page-2' })).toBe('page-2'); + expect(tool.resourceKeyFromParams?.({} as never)).toBe(''); + }); + + it('wiki_share is resource-scoped with pageId:targetType:groupId extractor', () => { + const { pages, shares, audit, users, prisma } = makeMockWikiRepos(); + const tool = createWikiShareTool(pages, shares, audit, users, prisma, 'user-1'); + + expect(tool.requiresApproval).toBe(true); + expect(tool.approvalScope).toBe('resource'); + expect(tool.resourceKeyFromParams).toBeDefined(); + + // Test extractor with pageId, targetType, groupId + expect( + tool.resourceKeyFromParams?.({ + pageId: 'page-1', + targetType: 'group', + groupId: 'group-1', + }), + ).toBe('page-1:group:group-1'); + + // Test with org share (no groupId) + expect( + tool.resourceKeyFromParams?.({ + pageId: 'page-1', + targetType: 'org', + }), + ).toBe('page-1:org:'); + + // Test with missing params + expect( + tool.resourceKeyFromParams?.({ + pageId: 'page-1', + targetType: 'group', + }), + ).toBe('page-1:group:'); + }); + + it('wiki_unshare is resource-scoped with shareId extractor', () => { + const { prisma, pages, shares, audit } = makeMockWikiRepos(); + const tool = createWikiUnshareTool(prisma, pages, shares, audit, 'user-1'); + + expect(tool.requiresApproval).toBe(true); + expect(tool.approvalScope).toBe('resource'); + expect(tool.resourceKeyFromParams).toBeDefined(); + + // Test extractor with shareId + expect(tool.resourceKeyFromParams?.({ shareId: 'share-1' })).toBe('share-1'); + expect(tool.resourceKeyFromParams?.({ shareId: 'share-2' })).toBe('share-2'); + expect(tool.resourceKeyFromParams?.({} as never)).toBe(''); + }); + + it('cron is resource-scoped, gates only mutating actions, with action-composite extractor', () => { + const { cronGuard, taskRepo, channelRepo, taskRunRepo, taskRunMessageRepo, policy } = + makeMockCronDeps(); + const tool = createCronTool( + cronGuard, + taskRepo, + channelRepo, + 'user-1', + 'agent-1', + policy, + false, + null, + taskRunRepo, + taskRunMessageRepo, + 'UTC', + ); + + expect(tool.requiresApproval).toBe(true); + expect(tool.approvalScope).toBe('resource'); + expect(tool.resourceKeyFromParams).toBeDefined(); + expect(tool.requiresApprovalForParams).toBeDefined(); + + // Mutating actions (real cron enum: create, remove) are gated. + expect(tool.requiresApprovalForParams?.({ action: 'create' })).toBe(true); + expect(tool.requiresApprovalForParams?.({ action: 'remove' })).toBe(true); + + // Read actions (real cron enum: list, runs, runDetail) are exempt. + expect(tool.requiresApprovalForParams?.({ action: 'list' })).toBe(false); + expect(tool.requiresApprovalForParams?.({ action: 'runs' })).toBe(false); + expect(tool.requiresApprovalForParams?.({ action: 'runDetail' })).toBe(false); + + // Action-composite resource key: mutating with jobId → `${action}:${jobId}`. + expect(tool.resourceKeyFromParams?.({ action: 'remove', jobId: 'job-1' })).toBe('remove:job-1'); + // Mutating without jobId (e.g. create, before a job exists) → bare action. + expect(tool.resourceKeyFromParams?.({ action: 'create' })).toBe('create'); + }); + + it('shell is resource-scoped, gates only sensitive commands, with category extractor', () => { + const tool = createShellTool('container-1', { + start: vi.fn(), + exec: vi.fn(), + stop: vi.fn(), + } as unknown as IContainerRunner); + + expect(tool.requiresApproval).toBe(true); + expect(tool.approvalScope).toBe('resource'); + expect(tool.resourceKeyFromParams).toBeDefined(); + expect(tool.requiresApprovalForParams).toBeDefined(); + + // Ordinary in-container work is exempt — the sandbox is the boundary. + expect(tool.requiresApprovalForParams?.({ command: 'ls -la' })).toBe(false); + expect(tool.requiresApprovalForParams?.({ command: 'pnpm test' })).toBe(false); + + // Commands reaching outside the sandbox are gated. + expect(tool.requiresApprovalForParams?.({ command: 'curl https://evil.test' })).toBe(true); + expect(tool.requiresApprovalForParams?.({ command: 'pip install requests' })).toBe(true); + expect(tool.requiresApprovalForParams?.({ command: 'echo x > /etc/passwd' })).toBe(true); + + // Resource key is the category set, not the raw command: two different + // curl calls share one key, so an "Always" grant is a capability grant. + expect(tool.resourceKeyFromParams?.({ command: 'curl https://a.test' })).toBe('net-egress'); + expect(tool.resourceKeyFromParams?.({ command: 'curl https://b.test' })).toBe('net-egress'); + expect(tool.resourceKeyFromParams?.({ command: 'pip install requests' })).toBe('pkg-install'); + expect( + tool.resourceKeyFromParams?.({ command: 'pip install requests && curl https://a.test' }), + ).toBe('net-egress+pkg-install'); + + // A missing/absent command must not throw — the registry treats a throwing + // extractor as an error, and a throwing predicate as requiring approval. + expect(tool.requiresApprovalForParams?.({})).toBe(false); + expect(tool.resourceKeyFromParams?.({})).toBe(''); + }); +}); diff --git a/packages/api/src/engine/__tests__/tool-approval.integration.test.ts b/packages/api/src/engine/__tests__/tool-approval.integration.test.ts new file mode 100644 index 0000000..53cf8db --- /dev/null +++ b/packages/api/src/engine/__tests__/tool-approval.integration.test.ts @@ -0,0 +1,360 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { LLMProvider, LLMResponse, ChatMessage } from '@clawix/shared'; + +import { ReasoningLoop } from '../reasoning-loop.js'; +import { ToolRegistry } from '../tool-registry.js'; +import { RunActivityRegistry } from '../run-activity.registry.js'; +import type { Tool, ToolResult } from '../tool.js'; +import { + ToolApprovalService, + type ApprovalDeliveryPort, +} from '../../approvals/tool-approval.service.js'; +import type { ApprovalRequestRepository } from '../../db/approval-request.repository.js'; +import type { UserToolApprovalRepository } from '../../db/user-tool-approval.repository.js'; +import type { AuditLogRepository } from '../../db/audit-log.repository.js'; +import type { ApprovalChoice } from '../../approvals/approval.types.js'; + +/** + * End-to-end proof that the approval gate composes correctly with a real + * ReasoningLoop + ToolRegistry + ToolApprovalService: a sensitive tool call + * really is intercepted before `execute()`, the rendezvous really unblocks + * on `resolve()`, a standing deny really short-circuits without waiting, a + * sub-agent really gets the hardcoded auto-deny, and session-scoped memory + * really persists across two calls within one run. + * + * Only the `ApprovalDeliveryPort` seam and the DB repositories are mocked + * (in-memory) — same harness shape as `agent-loop-hang-tracing.integration.test.ts`. + * Real timers throughout (no `vi.useFakeTimers()`): the rendezvous resolves + * via a same-tick `setTimeout(0)` scheduled from the mocked `deliverPrompt`, + * which fires only after `gate()` has registered the pending entry. + */ + +/* ------------------------------------------------------------------ */ +/* In-memory fakes for the approval repositories */ +/* ------------------------------------------------------------------ */ + +interface FakeApprovalRow { + id: string; + userId: string; + agentRunId: string; + sessionId: string | null; + toolName: string; + paramsSummary: Record; + status: string; + scope?: string; + resolutionReason?: string; + memoryStore?: string; + memoryKey?: string; + resolvedAt?: Date; +} + +function makeApprovalRequestRepo() { + const rows: FakeApprovalRow[] = []; + let nextId = 0; + return { + rows, + create: vi.fn(async (input: Omit & { status?: string }) => { + const row: FakeApprovalRow = { id: `req-${++nextId}`, status: 'PENDING', ...input }; + rows.push(row); + return row; + }), + findById: vi.fn(async (id: string) => rows.find((r) => r.id === id) ?? null), + listPendingForUser: vi.fn(async (userId: string) => + rows.filter((r) => r.userId === userId && r.status === 'PENDING'), + ), + transitionStatus: vi.fn( + async ( + id: string, + from: string, + patch: { + status: string; + scope?: string; + resolutionReason?: string; + memoryStore?: string; + memoryKey?: string; + }, + ) => { + const row = rows.find((r) => r.id === id); + if (!row || row.status !== from) return false; + Object.assign(row, patch, { resolvedAt: new Date() }); + return true; + }, + ), + expireAllPending: vi.fn(async () => 0), + }; +} + +interface FakeStandingRow { + id: string; + userId: string; + toolName: string; + resourceKey: string; + decision: 'allowed' | 'denied'; +} + +function makeUserToolApprovalRepo(seed: readonly FakeStandingRow[] = []) { + const rows: FakeStandingRow[] = [...seed]; + return { + rows, + upsert: vi.fn( + async (input: { + userId: string; + toolName: string; + resourceKey: string; + decision: 'allowed' | 'denied'; + }) => { + const row: FakeStandingRow = { id: `uta-${rows.length}`, ...input }; + rows.push(row); + return row; + }, + ), + findForKeys: vi.fn(async (userId: string, toolName: string, resourceKeys: readonly string[]) => + rows.filter( + (r) => + r.userId === userId && r.toolName === toolName && resourceKeys.includes(r.resourceKey), + ), + ), + listForUser: vi.fn(async (userId: string) => rows.filter((r) => r.userId === userId)), + deleteByKey: vi.fn(async () => true), + }; +} + +function makeAuditLogRepo() { + return { create: vi.fn(async () => ({})) }; +} + +/* ------------------------------------------------------------------ */ +/* Scripted provider + response builders */ +/* ------------------------------------------------------------------ */ + +function toolCallResponse(toolName: string, id: string): LLMResponse { + return { + content: null, + toolCalls: [{ id, name: toolName, arguments: {} }], + thinkingBlocks: null, + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + finishReason: 'tool_use', + }; +} + +function finalResponse(content: string): LLMResponse { + return { + content, + toolCalls: [], + thinkingBlocks: null, + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + finishReason: 'stop', + }; +} + +/** Provider that replays a fixed script of responses, one per `chat()` call. */ +function scriptedProvider(responses: readonly LLMResponse[]): LLMProvider { + let call = 0; + return { + name: 'scripted', + chat: vi.fn(async () => { + const response = responses[Math.min(call, responses.length - 1)]!; + call += 1; + return response; + }), + }; +} + +/* ------------------------------------------------------------------ */ +/* Harness */ +/* ------------------------------------------------------------------ */ + +const USER_ID = 'user-1'; +const AGENT_RUN_ID = 'run-1'; +const SENSITIVE_TOOL = 'sensitive_tool'; + +interface HarnessOptions { + readonly isSubAgent?: boolean; + readonly sessionId?: string | null; + readonly seedStandingDeny?: boolean; + /** When set, the mocked `deliverPrompt` schedules `resolve(choice)` shortly after gate() registers the pending entry. */ + readonly autoResolveChoice?: ApprovalChoice; +} + +function buildHarness(opts: HarnessOptions = {}) { + const executed = { count: 0 }; + const tool: Tool = { + name: SENSITIVE_TOOL, + description: 'A tool that requires human approval before it runs.', + parameters: { type: 'object', properties: {}, required: [] }, + requiresApproval: true, + async execute(): Promise { + executed.count += 1; + return { output: 'tool executed', isError: false }; + }, + }; + + const requestRepo = makeApprovalRequestRepo(); + const userApprovalRepo = makeUserToolApprovalRepo( + opts.seedStandingDeny + ? [ + { + id: 'uta-seed', + userId: USER_ID, + toolName: SENSITIVE_TOOL, + resourceKey: '', + decision: 'denied', + }, + ] + : [], + ); + const auditLogs = makeAuditLogRepo(); + + // eslint-disable-next-line prefer-const -- assigned after construction, read only inside the deliverPrompt closure + let approvalService: ToolApprovalService; + const delivery = { + deliverPrompt: vi.fn(async (req: { approvalId: string; userId: string }) => { + if (opts.autoResolveChoice) { + // Fires as a macrotask, strictly after gate() has finished the + // synchronous continuation that registers the pending rendezvous + // entry — so `resolve()` always finds it. Real timers throughout. + setTimeout(() => { + void approvalService.resolve(req.approvalId, req.userId, opts.autoResolveChoice!); + }, 0); + } + }), + deliverNotice: vi.fn(async () => undefined), + deliverResolved: vi.fn(async () => undefined), + }; + + const runActivity = new RunActivityRegistry(); + approvalService = new ToolApprovalService( + userApprovalRepo as unknown as UserToolApprovalRepository, + requestRepo as unknown as ApprovalRequestRepository, + auditLogs as unknown as AuditLogRepository, + delivery as unknown as ApprovalDeliveryPort, + runActivity, + ); + + const toolRegistry = new ToolRegistry(); + toolRegistry.register(tool); + toolRegistry.setApprovalContext({ + userId: USER_ID, + agentRunId: AGENT_RUN_ID, + sessionId: opts.sessionId === undefined ? 'sess-1' : opts.sessionId, + isSubAgent: opts.isSubAgent ?? false, + gate: (input) => approvalService.gate(input), + }); + + return { toolRegistry, approvalService, delivery, requestRepo, userApprovalRepo, executed }; +} + +async function runLoop( + toolRegistry: ToolRegistry, + responses: readonly LLMResponse[], +): Promise<{ content: string | null; iterations: number; messages: readonly ChatMessage[] }> { + const provider = scriptedProvider(responses); + const loop = new ReasoningLoop(provider, toolRegistry, { compress: vi.fn() } as never, { + provider: 'mock', + model: 'test', + }); + const result = await loop.run([{ role: 'user', content: 'please do the sensitive thing' }]); + return { content: result.content, iterations: result.iterations, messages: result.messages }; +} + +/* ------------------------------------------------------------------ */ +/* Tests */ +/* ------------------------------------------------------------------ */ + +describe('tool-call approval — end-to-end (ReasoningLoop + ToolRegistry + ToolApprovalService)', () => { + it('1. allow_once: PENDING row created, prompt delivered, tool executes on resolve, loop completes', async () => { + const h = buildHarness({ autoResolveChoice: 'allow_once' }); + + const { content } = await runLoop(h.toolRegistry, [ + toolCallResponse(SENSITIVE_TOOL, 'tc-1'), + finalResponse('all set'), + ]); + + expect(h.delivery.deliverPrompt).toHaveBeenCalledTimes(1); + expect(h.requestRepo.rows).toHaveLength(1); + expect(h.requestRepo.rows[0]).toMatchObject({ status: 'APPROVED', scope: 'once' }); + expect(h.executed.count).toBe(1); + expect(content).toBe('all set'); + }); + + it('2. deny_once: tool NOT executed, BLOCKED tool-result fed to model, loop continues to final response', async () => { + const h = buildHarness({ autoResolveChoice: 'deny_once' }); + + const { content, messages, iterations } = await runLoop(h.toolRegistry, [ + toolCallResponse(SENSITIVE_TOOL, 'tc-1'), + finalResponse('understood, not proceeding'), + ]); + + expect(h.delivery.deliverPrompt).toHaveBeenCalledTimes(1); + expect(h.executed.count).toBe(0); + const toolMessage = messages.find((m) => m.role === 'tool'); + expect(toolMessage?.content).toContain('BLOCKED'); + expect(toolMessage?.content).toContain('denied'); + expect(iterations).toBe(2); + expect(content).toBe('understood, not proceeding'); + }); + + it('3. standing persistent deny: immediate BLOCKED, notice delivered (not prompt), no rendezvous delay', async () => { + const h = buildHarness({ seedStandingDeny: true }); + + const start = Date.now(); + const { messages } = await runLoop(h.toolRegistry, [ + toolCallResponse(SENSITIVE_TOOL, 'tc-1'), + finalResponse('acknowledged'), + ]); + const elapsedMs = Date.now() - start; + + expect(h.executed.count).toBe(0); + expect(h.delivery.deliverPrompt).not.toHaveBeenCalled(); + expect(h.delivery.deliverNotice).toHaveBeenCalledTimes(1); + expect(h.delivery.deliverNotice).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'standing_deny' }), + ); + expect(h.requestRepo.rows).toHaveLength(1); + expect(h.requestRepo.rows[0]).toMatchObject({ + status: 'DENIED', + resolutionReason: 'standing_deny', + memoryStore: 'persistent', + }); + const toolMessage = messages.find((m) => m.role === 'tool'); + expect(toolMessage?.content).toContain('standing denial'); + // No rendezvous wait was ever entered — this resolves near-instantly with real timers. + expect(elapsedMs).toBeLessThan(1000); + }); + + it('4. sub-agent context: real behavior is a hardcoded auto-deny with no delivery at all', async () => { + const h = buildHarness({ isSubAgent: true }); + + const { messages } = await runLoop(h.toolRegistry, [ + toolCallResponse(SENSITIVE_TOOL, 'tc-1'), + finalResponse('cannot proceed without approval'), + ]); + + expect(h.executed.count).toBe(0); + expect(h.delivery.deliverPrompt).not.toHaveBeenCalled(); + expect(h.delivery.deliverNotice).not.toHaveBeenCalled(); + expect(h.delivery.deliverResolved).not.toHaveBeenCalled(); + expect(h.requestRepo.rows).toHaveLength(1); + expect(h.requestRepo.rows[0]).toMatchObject({ status: 'DENIED', resolutionReason: 'subagent' }); + const toolMessage = messages.find((m) => m.role === 'tool'); + expect(toolMessage?.content).toContain('sub-agents cannot request'); + }); + + it('5. session-allow: a second call to the same key executes silently, no new ApprovalRequest row', async () => { + const h = buildHarness({ autoResolveChoice: 'allow_session' }); + + const { content, iterations } = await runLoop(h.toolRegistry, [ + toolCallResponse(SENSITIVE_TOOL, 'tc-1'), + toolCallResponse(SENSITIVE_TOOL, 'tc-2'), + finalResponse('both done'), + ]); + + expect(h.executed.count).toBe(2); + // Only the first call ever prompted; the second hit session memory. + expect(h.delivery.deliverPrompt).toHaveBeenCalledTimes(1); + expect(h.requestRepo.rows).toHaveLength(1); + expect(h.requestRepo.rows[0]).toMatchObject({ status: 'APPROVED', scope: 'session' }); + expect(iterations).toBe(3); + expect(content).toBe('both done'); + }); +}); diff --git a/packages/api/src/engine/__tests__/tool-registry-approval.test.ts b/packages/api/src/engine/__tests__/tool-registry-approval.test.ts new file mode 100644 index 0000000..6f54a99 --- /dev/null +++ b/packages/api/src/engine/__tests__/tool-registry-approval.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ToolRegistry } from '../tool-registry.js'; +import type { Tool } from '../tool.js'; + +const okTool = (over: Partial = {}): Tool => ({ + name: 'wiki_delete', + description: 'd', + parameters: { type: 'object', properties: { pageId: { type: 'string' } }, required: ['pageId'] }, + requiresApproval: true, + approvalScope: 'resource', + resourceKeyFromParams: (p) => String(p['pageId'] ?? ''), + execute: vi.fn(async () => ({ output: 'deleted', isError: false })), + ...over, +}); + +const ctx = (gate: ReturnType) => ({ + userId: 'u1', + agentRunId: 'run1', + sessionId: 's1', + isSubAgent: false, + gate, +}); + +describe('registration-time validation', () => { + it("throws for approvalScope 'resource' without an extractor", () => { + const registry = new ToolRegistry(); + expect(() => registry.register(okTool({ resourceKeyFromParams: undefined }))).toThrow( + /resource.*without resourceKeyFromParams/, + ); + }); +}); + +describe('guard at runAndPostProcess', () => { + it('executes when the gate allows, passing validated params', async () => { + const gate = vi.fn(async () => ({ allowed: true })); + const registry = new ToolRegistry(); + registry.setApprovalContext(ctx(gate)); + const tool = okTool(); + registry.register(tool); + + const result = await registry.execute('wiki_delete', { pageId: 'page-a' }); + expect(result.isError).toBe(false); + expect(gate).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: 'wiki_delete', + scopeKind: 'resource', + resourceKey: 'page-a', + extractorFailed: false, + }), + ); + expect(tool.execute).toHaveBeenCalled(); + }); + + it('returns the blocked message as an error result when denied, without executing', async () => { + const gate = vi.fn(async () => ({ allowed: false, blockedMessage: 'BLOCKED: no' })); + const registry = new ToolRegistry(); + registry.setApprovalContext(ctx(gate)); + const tool = okTool(); + registry.register(tool); + + const result = await registry.execute('wiki_delete', { pageId: 'page-a' }); + expect(result.isError).toBe(true); + expect(result.output).toContain('BLOCKED: no'); + expect(tool.execute).not.toHaveBeenCalled(); + }); + + it('skips the gate entirely for tools without requiresApproval', async () => { + const gate = vi.fn(); + const registry = new ToolRegistry(); + registry.setApprovalContext(ctx(gate)); + registry.register( + okTool({ + requiresApproval: undefined, + approvalScope: undefined, + resourceKeyFromParams: undefined, + }), + ); + await registry.execute('wiki_delete', { pageId: 'page-a' }); + expect(gate).not.toHaveBeenCalled(); + }); + + it('blocks approval-requiring tool when no approval context was set (fail-closed)', async () => { + const registry = new ToolRegistry(); + const tool = okTool(); + registry.register(tool); + const result = await registry.execute('wiki_delete', { pageId: 'page-a' }); + expect(result.isError).toBe(true); + expect(result.output).toContain('BLOCKED'); + expect(result.output).toContain('approval context is not initialized'); + expect(tool.execute).not.toHaveBeenCalled(); + }); + + it('executes normally for tools without requiresApproval when no approval context is set', async () => { + const registry = new ToolRegistry(); + const tool = okTool({ + requiresApproval: undefined, + approvalScope: undefined, + resourceKeyFromParams: undefined, + }); + registry.register(tool); + const result = await registry.execute('wiki_delete', { pageId: 'page-a' }); + expect(result.isError).toBe(false); + expect(tool.execute).toHaveBeenCalled(); + }); + + it('extractor throwing → extractorFailed=true, resourceKey empty', async () => { + const gate = vi.fn(async () => ({ allowed: true })); + const registry = new ToolRegistry(); + registry.setApprovalContext(ctx(gate)); + registry.register( + okTool({ + resourceKeyFromParams: () => { + throw new Error('boom'); + }, + }), + ); + await registry.execute('wiki_delete', { pageId: 'page-a' }); + expect(gate).toHaveBeenCalledWith( + expect.objectContaining({ extractorFailed: true, resourceKey: '' }), + ); + }); + + it('gates rawParams (MCP) tools with their raw params', async () => { + const gate = vi.fn(async () => ({ allowed: true })); + const registry = new ToolRegistry(); + registry.setApprovalContext(ctx(gate)); + registry.register( + okTool({ + name: 'mcp__srv__send', + rawParams: true, + approvalScope: 'call', + resourceKeyFromParams: undefined, + parameters: { type: 'object' }, + }), + ); + await registry.execute('mcp__srv__send', { anything: true }); + expect(gate).toHaveBeenCalledWith( + expect.objectContaining({ toolName: 'mcp__srv__send', scopeKind: 'call', resourceKey: '' }), + ); + }); + + it('forwards abortSignal into the gate input', async () => { + const gate = vi.fn(async () => ({ allowed: true })); + const registry = new ToolRegistry(); + registry.setApprovalContext(ctx(gate)); + registry.register(okTool()); + const controller = new AbortController(); + await registry.execute('wiki_delete', { pageId: 'p' }, { abortSignal: controller.signal }); + expect(gate).toHaveBeenCalledWith(expect.objectContaining({ abortSignal: controller.signal })); + }); +}); + +describe('requiresApprovalForParams (per-call predicate)', () => { + it('predicate returns false → gate not called, tool executes (approval context set)', async () => { + const gate = vi.fn(async () => ({ allowed: true })); + const registry = new ToolRegistry(); + registry.setApprovalContext(ctx(gate)); + const tool = okTool({ requiresApprovalForParams: () => false }); + registry.register(tool); + + const result = await registry.execute('wiki_delete', { pageId: 'page-a' }); + expect(result.isError).toBe(false); + expect(gate).not.toHaveBeenCalled(); + expect(tool.execute).toHaveBeenCalled(); + }); + + it('predicate returns false → tool executes even with NO approval context set (exemption beats fail-closed)', async () => { + const registry = new ToolRegistry(); + const tool = okTool({ requiresApprovalForParams: () => false }); + registry.register(tool); + + const result = await registry.execute('wiki_delete', { pageId: 'page-a' }); + expect(result.isError).toBe(false); + expect(tool.execute).toHaveBeenCalled(); + }); + + it('predicate returns true → gate called as normal', async () => { + const gate = vi.fn(async () => ({ allowed: true })); + const registry = new ToolRegistry(); + registry.setApprovalContext(ctx(gate)); + const tool = okTool({ requiresApprovalForParams: () => true }); + registry.register(tool); + + const result = await registry.execute('wiki_delete', { pageId: 'page-a' }); + expect(result.isError).toBe(false); + expect(gate).toHaveBeenCalled(); + expect(tool.execute).toHaveBeenCalled(); + }); + + it('predicate throws → treated as requiring approval: gate called with context', async () => { + const gate = vi.fn(async () => ({ allowed: true })); + const registry = new ToolRegistry(); + registry.setApprovalContext(ctx(gate)); + const tool = okTool({ + requiresApprovalForParams: () => { + throw new Error('predicate boom'); + }, + }); + registry.register(tool); + + const result = await registry.execute('wiki_delete', { pageId: 'page-a' }); + expect(result.isError).toBe(false); + expect(gate).toHaveBeenCalled(); + expect(tool.execute).toHaveBeenCalled(); + }); + + it('predicate throws → treated as requiring approval: BLOCKED_NO_CONTEXT without context', async () => { + const registry = new ToolRegistry(); + const tool = okTool({ + requiresApprovalForParams: () => { + throw new Error('predicate boom'); + }, + }); + registry.register(tool); + + const result = await registry.execute('wiki_delete', { pageId: 'page-a' }); + expect(result.isError).toBe(true); + expect(result.output).toContain('BLOCKED'); + expect(result.output).toContain('approval context is not initialized'); + expect(tool.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/engine/agent-runner.service.ts b/packages/api/src/engine/agent-runner.service.ts index 9743ce2..c285f6f 100644 --- a/packages/api/src/engine/agent-runner.service.ts +++ b/packages/api/src/engine/agent-runner.service.ts @@ -100,6 +100,8 @@ import { PythonConcurrencyLimiter } from './tools/python/concurrency-limiter.js' import { InstallMutex } from './tools/python/install-mutex.js'; import { createPythonRunTool } from './tools/python/python-run.js'; import { createPythonRunNetTool } from './tools/python/python-run-net.js'; +import { createShellNetTool } from './tools/shell-net.js'; +import { ShellNetConcurrencyLimiter } from './tools/shell-net-concurrency-limiter.js'; import { WikiPageRepository } from '../db/wiki-page.repository.js'; import { WikiLinkRepository } from '../db/wiki-link.repository.js'; import { WikiShareRepository } from '../db/wiki-share.repository.js'; @@ -116,6 +118,7 @@ import { registerSessionTools } from './tools/session/register.js'; import { SessionSearchService } from './session-recall/session-search.service.js'; import { mcpBindingsSchema, type McpBindings } from '@clawix/shared'; import { bindingsFromTiers } from './tools/mcp/bindings-from-tiers.js'; +import { ToolApprovalService } from '../approvals/tool-approval.service.js'; const logger = createLogger('engine:agent-runner'); @@ -175,6 +178,7 @@ export class AgentRunnerService { private readonly pythonPool: PythonContainerPoolService, private readonly pythonProxyHealth: PythonProxyHealthService, private readonly pythonLimiter: PythonConcurrencyLimiter, + private readonly shellNetLimiter: ShellNetConcurrencyLimiter, private readonly pythonInstallMutex: InstallMutex, private readonly wikiPageRepo: WikiPageRepository, private readonly wikiLinkRepo: WikiLinkRepository, @@ -187,6 +191,7 @@ export class AgentRunnerService { private readonly mcpClientService: McpClientService, private readonly mcpTokenManager: McpTokenManager, private readonly runActivity: RunActivityRegistry, + private readonly toolApprovalService: ToolApprovalService, ) {} /** Lazy accessor to break circular dependency with TaskExecutorService. */ @@ -482,6 +487,13 @@ export class AgentRunnerService { // Step 13: Create ToolRegistry, register builtin tools + web tools + memory/wiki tools + spawn tool const registry = new ToolRegistry(); + registry.setApprovalContext({ + userId, + agentRunId: agentRun.id, + sessionId: session?.id ?? null, + isSubAgent: Boolean(isSubAgent), + gate: (input) => this.toolApprovalService.gate(input), + }); registerBuiltinTools(registry, containerId, this.containerRunner); registerWebTools(registry, this.searchProviderRegistry); @@ -517,6 +529,7 @@ export class AgentRunnerService { userId, budgetTracker, policy.maxSubAgentRunMs, + policy.allowedProviders, ), ); } @@ -616,6 +629,24 @@ export class AgentRunnerService { ); } + if (policy.allowShellNet && workspacePaths !== undefined) { + registry.register( + createShellNetTool({ + userId, + workspaceHostPath: workspacePaths.hostPath, + policy: { + allowShellNet: policy.allowShellNet, + maxShellNetMemoryMb: policy.maxShellNetMemoryMb, + maxShellNetTimeoutSecs: policy.maxShellNetTimeoutSecs, + maxShellNetCpuCores: policy.maxShellNetCpuCores, + maxConcurrentShellNetRuns: policy.maxConcurrentShellNetRuns, + }, + runner: this.containerRunner, + limiter: this.shellNetLimiter, + }), + ); + } + // Step 13d: Wire MCP tools (gated by policy.allowMcp + per-agent bindings). // Registration is zero-network: wrappers come from the cached McpTool // catalog + the caller's McpConnection; connections open lazily on the diff --git a/packages/api/src/engine/engine.module.ts b/packages/api/src/engine/engine.module.ts index 9112848..db230ab 100644 --- a/packages/api/src/engine/engine.module.ts +++ b/packages/api/src/engine/engine.module.ts @@ -8,6 +8,7 @@ import { DbModule } from '../db/index.js'; import { McpModule } from '../mcp/mcp.module.js'; import { SystemSettingsModule } from '../system-settings/system-settings.module.js'; import { ProviderConfigModule } from '../provider-config/provider-config.module.js'; +import { ApprovalsModule } from '../approvals/approvals.module.js'; import { AgentRunnerService } from './agent-runner.service.js'; import { CronGuardService } from './cron-guard.service.js'; import { CronSchedulerService } from './cron-scheduler.service.js'; @@ -28,7 +29,7 @@ import { WorkspaceSeederService } from './workspace-seeder.service.js'; import { StaleRunReaperService } from './stale-run-reaper.service.js'; import { CompressorService } from './compressor.js'; import { AgentRunRegistry } from './agent-run-registry.service.js'; -import { RunActivityRegistry } from './run-activity.registry.js'; +import { RunActivityModule } from './run-activity.module.js'; import { SearchProviderRegistry } from './tools/web/search-provider.js'; import { BraveSearchProvider } from './tools/web/providers/brave.js'; import { DuckDuckGoProvider } from './tools/web/providers/duckduckgo.js'; @@ -45,12 +46,20 @@ import { import { BrowserQuotaCache } from './tools/browser/browser-quota-cache.service.js'; import { AgentRunSourceAdapter } from './tools/browser/agent-run-source.adapter.js'; import { PythonConcurrencyLimiter } from './tools/python/concurrency-limiter.js'; +import { ShellNetConcurrencyLimiter } from './tools/shell-net-concurrency-limiter.js'; import { InstallMutex } from './tools/python/install-mutex.js'; import { WikiBootstrapService } from './wiki/wiki-bootstrap.service.js'; import { SessionSearchService } from './session-recall/session-search.service.js'; @Module({ - imports: [DbModule, McpModule, SystemSettingsModule, ProviderConfigModule], + imports: [ + DbModule, + McpModule, + SystemSettingsModule, + ProviderConfigModule, + RunActivityModule, + ApprovalsModule, + ], providers: [ AgentRunnerService, ContextBuilderService, @@ -70,6 +79,7 @@ import { SessionSearchService } from './session-recall/session-search.service.js PythonProxyHealthService, PythonContainerPoolService, PythonConcurrencyLimiter, + ShellNetConcurrencyLimiter, InstallMutex, MemoryConsolidationService, TaskExecutorService, @@ -79,7 +89,6 @@ import { SessionSearchService } from './session-recall/session-search.service.js StaleRunReaperService, CompressorService, AgentRunRegistry, - RunActivityRegistry, { provide: SkillLoaderService, useFactory: () => { @@ -144,6 +153,7 @@ import { SessionSearchService } from './session-recall/session-search.service.js CronGuardService, SkillLoaderService, AgentRunRegistry, + RunActivityModule, PythonProxyHealthService, PythonContainerPoolService, WikiBootstrapService, diff --git a/packages/api/src/engine/tool-registry.ts b/packages/api/src/engine/tool-registry.ts index 0bb17a1..1100d52 100644 --- a/packages/api/src/engine/tool-registry.ts +++ b/packages/api/src/engine/tool-registry.ts @@ -1,6 +1,8 @@ import { createLogger } from '@clawix/shared'; import type { ToolDefinition } from '@clawix/shared'; +import type { GateInput, GateResult } from '../approvals/tool-approval.service.js'; +import { approvalExtractorErrorsTotal } from '../approvals/approval-metrics.js'; import { toToolDefinition, type ParamSchema, @@ -11,6 +13,27 @@ import { const logger = createLogger('engine:tool-registry'); +/** + * Message when a tool requiring approval executes on a registry that never + * received an approval context — fail-closed guard. + */ +export const BLOCKED_NO_CONTEXT = (tool: string): string => + `BLOCKED: ${tool} requires user approval, but the approval context is not initialized. This indicates a system configuration error, not a user decision. The tool call must not be retried or worked around.`; + +/** + * Per-run identity + bound gate function, set once via + * `ToolRegistry.setApprovalContext()` before the run begins. Closure-captured + * per run instance — ToolRegistry is a plain per-run class, not a Nest + * injectable, so this is not DI-managed. + */ +export interface ToolApprovalContext { + readonly userId: string; + readonly agentRunId: string; + readonly sessionId: string | null; + readonly isSubAgent: boolean; + readonly gate: (input: GateInput) => Promise; +} + /* ------------------------------------------------------------------ */ /* Module-level helpers: validation */ /* ------------------------------------------------------------------ */ @@ -191,6 +214,7 @@ function stripUnknownKeys( export class ToolRegistry { private readonly tools = new Map(); private readonly maxOutputChars: number; + private approvalContext: ToolApprovalContext | null = null; constructor(maxOutputChars = 16_000) { this.maxOutputChars = maxOutputChars; @@ -198,6 +222,11 @@ export class ToolRegistry { /** Register a tool. Overwrites any existing tool with the same name. */ register(tool: Tool): void { + if (tool.approvalScope === 'resource' && !tool.resourceKeyFromParams) { + throw new Error( + `Tool ${tool.name} declares approvalScope 'resource' without resourceKeyFromParams`, + ); + } if (this.tools.has(tool.name)) { logger.warn({ tool: tool.name }, 'Overwriting existing tool registration'); } @@ -205,6 +234,16 @@ export class ToolRegistry { this.tools.set(tool.name, tool); } + /** + * Set the per-run approval identity + bound gate function. Must be called + * before execute() for tools with `requiresApproval`; if never called, such + * tools fail closed (execute() returns BLOCKED_NO_CONTEXT) — a missing + * wiring is never an approval bypass. + */ + setApprovalContext(ctx: ToolApprovalContext): void { + this.approvalContext = ctx; + } + /** Check whether a tool is registered. */ has(name: string): boolean { return this.tools.has(name); @@ -283,6 +322,73 @@ export class ToolRegistry { params: Record, ctx?: ToolExecuteContext, ): Promise { + // Resolve whether THIS call requires approval. `requiresApprovalForParams` + // lets a multi-action tool exempt specific actions (e.g. reads) from an + // otherwise `requiresApproval: true` tool. Absent predicate = gated as + // declared; predicate throwing is treated as requiring approval (fail + // closed) rather than silently exempting the call. + let needsApproval = tool.requiresApproval === true; + if (needsApproval && tool.requiresApprovalForParams) { + try { + // Strict `!== false`: only an explicit false exempts. A non-boolean + // return (reachable only via a runtime type bypass the signature can't + // model) gates rather than exempts — so the lint rule's "redundant + // boolean compare" assumption doesn't hold here; the compare is the + // fail-closed defense. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare + needsApproval = tool.requiresApprovalForParams(params) !== false; + } catch (predicateErr: unknown) { + const message = predicateErr instanceof Error ? predicateErr.message : String(predicateErr); + logger.error( + { tool: tool.name, error: message }, + 'requiresApprovalForParams threw on validated params', + ); + needsApproval = true; + } + } + + // Fail-closed: block if this call requires approval but no approval context is wired + if (needsApproval && !this.approvalContext) { + return { output: BLOCKED_NO_CONTEXT(tool.name), isError: true }; + } + + if (needsApproval && this.approvalContext) { + const scopeKind = tool.approvalScope ?? 'call'; + let resourceKey = ''; + let extractorFailed = false; + if (scopeKind === 'resource') { + try { + resourceKey = tool.resourceKeyFromParams?.(params) ?? ''; + if (resourceKey === '') extractorFailed = true; + } catch { + extractorFailed = true; + resourceKey = ''; + } + if (extractorFailed) { + // Bug, not a user condition (spec §2 failure class 2): loud + alertable. + approvalExtractorErrorsTotal.inc(); + logger.error({ tool: tool.name }, 'resourceKeyFromParams failed on validated params'); + } + } + const gateResult = await this.approvalContext.gate({ + userId: this.approvalContext.userId, + agentRunId: this.approvalContext.agentRunId, + sessionId: this.approvalContext.sessionId, + isSubAgent: this.approvalContext.isSubAgent, + toolName: tool.name, + scopeKind, + resourceKey, + extractorFailed, + params, + abortSignal: ctx?.abortSignal, + }); + if (!gateResult.allowed) { + return { + output: `${gateResult.blockedMessage}\n\n[Analyze the error above and try a different approach.]`, + isError: true, + }; + } + } try { const result = await tool.execute(params, ctx); const output = this.truncate(result.output); diff --git a/packages/api/src/engine/tools/browser/tools/browser-cdp.ts b/packages/api/src/engine/tools/browser/tools/browser-cdp.ts index e530791..00652cb 100644 --- a/packages/api/src/engine/tools/browser/tools/browser-cdp.ts +++ b/packages/api/src/engine/tools/browser/tools/browser-cdp.ts @@ -41,6 +41,8 @@ export function createBrowserCdpTool( }, required: ['method'], }, + requiresApproval: true, + approvalScope: 'run', async execute(params: Record): Promise { const ctx = getRunContext(); diff --git a/packages/api/src/engine/tools/cron.ts b/packages/api/src/engine/tools/cron.ts index c454507..9f78cbd 100644 --- a/packages/api/src/engine/tools/cron.ts +++ b/packages/api/src/engine/tools/cron.ts @@ -128,6 +128,20 @@ export function createCronTool( }, required: ['action'], }, + // Only the mutating actions (create/remove) require approval — list/runs/ + // runDetail are reads and are exempt via requiresApprovalForParams. The + // resource key is action-composite (`${action}:${jobId}` or just + // `${action}` when there's no jobId yet, e.g. create) so an approval + // granted for one action/job can never leak into approving a different + // action or a different job (spec §2 amendment). + requiresApproval: true, + requiresApprovalForParams: (p) => ['create', 'remove'].includes(String(p['action'])), + approvalScope: 'resource', + resourceKeyFromParams: (p) => { + const action = String(p['action'] ?? ''); + const jobId = String(p['jobId'] ?? ''); + return jobId ? `${action}:${jobId}` : action; + }, async execute(params: Record): Promise { const action = params['action'] as string; diff --git a/packages/api/src/engine/tools/mcp/__tests__/mcp-approval.test.ts b/packages/api/src/engine/tools/mcp/__tests__/mcp-approval.test.ts new file mode 100644 index 0000000..f3ec985 --- /dev/null +++ b/packages/api/src/engine/tools/mcp/__tests__/mcp-approval.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { mcpRequiresApproval } from '../mcp-approval.js'; + +describe('mcpRequiresApproval', () => { + it('explicit allow override wins', () => { + expect( + mcpRequiresApproval('send_email', { readOnlyHint: false }, { send_email: 'allow' }), + ).toBe(false); + }); + it('explicit ask override wins even over readOnlyHint', () => { + expect(mcpRequiresApproval('search', { readOnlyHint: true }, { search: 'ask' })).toBe(true); + }); + it('no override: readOnlyHint true → allow', () => { + expect(mcpRequiresApproval('search', { readOnlyHint: true }, null)).toBe(false); + }); + it('no override: missing/false annotations → ask (fail-closed; servers can lie or omit)', () => { + expect(mcpRequiresApproval('send_email', undefined, null)).toBe(true); + expect(mcpRequiresApproval('send_email', { readOnlyHint: false }, null)).toBe(true); + expect(mcpRequiresApproval('send_email', 'garbage', null)).toBe(true); + }); +}); diff --git a/packages/api/src/engine/tools/mcp/mcp-approval.ts b/packages/api/src/engine/tools/mcp/mcp-approval.ts new file mode 100644 index 0000000..671f7ef --- /dev/null +++ b/packages/api/src/engine/tools/mcp/mcp-approval.ts @@ -0,0 +1,21 @@ +import type { McpApprovalOverrides } from '@clawix/shared'; + +/** + * Spec §3: overrides win; absent an entry, only an explicit readOnlyHint=true + * annotation loosens to allow. Annotations are untrusted hints — they can only + * loosen toward allow for read-only claims, never force-escalate. + */ +export function mcpRequiresApproval( + toolName: string, + annotations: unknown, + overrides: McpApprovalOverrides | null, +): boolean { + const override = overrides?.[toolName]; + if (override === 'allow') return false; + if (override === 'ask') return true; + const readOnly = + typeof annotations === 'object' && + annotations !== null && + (annotations as Record)['readOnlyHint'] === true; + return !readOnly; +} diff --git a/packages/api/src/engine/tools/mcp/mcp-tool.factory.spec.ts b/packages/api/src/engine/tools/mcp/mcp-tool.factory.spec.ts index bfa0040..e93eeb9 100644 --- a/packages/api/src/engine/tools/mcp/mcp-tool.factory.spec.ts +++ b/packages/api/src/engine/tools/mcp/mcp-tool.factory.spec.ts @@ -29,6 +29,7 @@ function makeServer(over: Partial = {}): McpServerForRun { lastDiscoveredAt: new Date(), createdAt: new Date(), updatedAt: new Date(), + approvalOverrides: null, tools: [ { id: 't1', @@ -36,6 +37,9 @@ function makeServer(over: Partial = {}): McpServerForRun { name: 'search', description: 'Search repos', inputSchema: { type: 'object', properties: { q: { type: 'string' } } }, + // Declared read-only so it loosens to allow by default (spec §3); + // unrelated tests below exercise call mechanics, not approval gating. + annotations: { readOnlyHint: true }, scanFlagged: false, scanReason: null, createdAt: new Date(), @@ -46,6 +50,7 @@ function makeServer(over: Partial = {}): McpServerForRun { name: 'create_issue', description: 'Create an issue', inputSchema: { type: 'object' }, + annotations: null, scanFlagged: false, scanReason: null, createdAt: new Date(), @@ -216,4 +221,62 @@ describe('registerMcpTools', () => { } as never); expect(registry.has(`mcp__github__${longToolName}`)).toBe(false); }); + + describe('approval derivation (Task 8, spec §3: ask-by-default)', () => { + const BOTH: import('@clawix/shared').McpBindings = { + servers: [{ serverId: 'srv1', enabledTools: ['search', 'create_issue'] }], + }; + + it('sets approvalScope "call" on every registered MCP tool', async () => { + await registerMcpTools(registry, { + servers: [makeServer()], + bindings: BOTH, + ...deps, + } as never); + expect(registry.get('mcp__github__search')?.approvalScope).toBe('call'); + expect(registry.get('mcp__github__create_issue')?.approvalScope).toBe('call'); + }); + + it('readOnlyHint: true annotation, no override → requiresApproval false', async () => { + await registerMcpTools(registry, { + servers: [makeServer()], + bindings: BOTH, + ...deps, + } as never); + expect(registry.get('mcp__github__search')?.requiresApproval).toBe(false); + }); + + it('no annotation, no override → requiresApproval true (fail closed)', async () => { + await registerMcpTools(registry, { + servers: [makeServer()], + bindings: BOTH, + ...deps, + } as never); + expect(registry.get('mcp__github__create_issue')?.requiresApproval).toBe(true); + }); + + it('explicit "allow" override loosens a non-read-only tool', async () => { + const server = makeServer(); + server.connections[0]!.approvalOverrides = { create_issue: 'allow' }; + await registerMcpTools(registry, { servers: [server], bindings: BOTH, ...deps } as never); + expect(registry.get('mcp__github__create_issue')?.requiresApproval).toBe(false); + }); + + it('explicit "ask" override wins even over readOnlyHint', async () => { + const server = makeServer(); + server.connections[0]!.approvalOverrides = { search: 'ask' }; + await registerMcpTools(registry, { servers: [server], bindings: BOTH, ...deps } as never); + expect(registry.get('mcp__github__search')?.requiresApproval).toBe(true); + }); + + it('malformed approvalOverrides JSON on the connection → treated as null (fail closed, no throw)', async () => { + const server = makeServer(); + server.connections[0]!.approvalOverrides = 'not-an-object' as never; + await expect( + registerMcpTools(registry, { servers: [server], bindings: BOTH, ...deps } as never), + ).resolves.toBeUndefined(); + expect(registry.get('mcp__github__create_issue')?.requiresApproval).toBe(true); + expect(registry.get('mcp__github__search')?.requiresApproval).toBe(false); + }); + }); }); diff --git a/packages/api/src/engine/tools/mcp/mcp-tool.factory.ts b/packages/api/src/engine/tools/mcp/mcp-tool.factory.ts index 90a33bc..198ecbe 100644 --- a/packages/api/src/engine/tools/mcp/mcp-tool.factory.ts +++ b/packages/api/src/engine/tools/mcp/mcp-tool.factory.ts @@ -7,8 +7,8 @@ * caller has no active connection → silent skip. TOFU: only tools present in * BOTH the explicit binding allowlist AND the cached catalog register. */ -import { createLogger } from '@clawix/shared'; -import type { McpBindings } from '@clawix/shared'; +import { createLogger, setMcpApprovalOverridesSchema } from '@clawix/shared'; +import type { McpApprovalOverrides, McpBindings } from '@clawix/shared'; import type { McpServerForRun } from '../../../db/mcp-server.repository.js'; import type { AuditLogRepository } from '../../../db/audit-log.repository.js'; @@ -20,6 +20,7 @@ import type { ToolRegistry } from '../../tool-registry.js'; import { scanContextContent } from '../../prompt-injection-scanner.js'; import { mcpToolCallDurationSeconds, mcpToolCallsTotal } from './mcp-metrics.js'; import type { McpRunConnections } from './mcp-run-connections.js'; +import { mcpRequiresApproval } from './mcp-approval.js'; const logger = createLogger('engine:tools:mcp:factory'); @@ -64,6 +65,14 @@ export async function registerMcpTools( continue; } + // Per-connection sparse overlay (Task 8, spec §3): parsed once per + // connection, not per tool. Malformed/absent JSON → null (fail closed to + // the annotation-derived default inside mcpRequiresApproval). + const approvalOverrides: McpApprovalOverrides | null = setMcpApprovalOverridesSchema + .nullable() + .catch(null) + .parse(connection.approvalOverrides); + const allowed = new Set(binding.enabledTools); for (const tool of connection.tools) { if (!allowed.has(tool.name)) continue; // TOFU: explicit allowlist only @@ -73,7 +82,7 @@ export async function registerMcpTools( logger.warn({ toolName }, 'Skipping MCP tool: combined name exceeds 64 chars'); continue; } - registry.register(createMcpTool(opts, server, connection, tool, toolName)); + registry.register(createMcpTool(opts, server, connection, tool, toolName, approvalOverrides)); } } } @@ -84,6 +93,7 @@ function createMcpTool( connection: McpConnection, tool: McpToolRow, toolName: string, + approvalOverrides: McpApprovalOverrides | null, ): Tool { const description = tool.scanFlagged ? `[flagged: this tool's description failed the prompt-injection scan (${tool.scanReason ?? 'unknown'})]` @@ -96,6 +106,10 @@ function createMcpTool( // The MCP server is the source of truth for its own schema — pass params // through verbatim instead of running the registry's strict cast/validate/strip. rawParams: true, + // Ask-by-default (spec §3): overrides win; absent an entry, only an + // explicit readOnlyHint=true annotation loosens to allow. + requiresApproval: mcpRequiresApproval(tool.name, tool.annotations, approvalOverrides), + approvalScope: 'call', async execute(params, ctx): Promise { const started = Date.now(); let output: string; diff --git a/packages/api/src/engine/tools/wiki/wiki-delete.tool.ts b/packages/api/src/engine/tools/wiki/wiki-delete.tool.ts index edfce75..1639207 100644 --- a/packages/api/src/engine/tools/wiki/wiki-delete.tool.ts +++ b/packages/api/src/engine/tools/wiki/wiki-delete.tool.ts @@ -27,6 +27,9 @@ export function createWikiDeleteTool( properties: { pageId: { type: 'string' } }, required: ['pageId'], }, + requiresApproval: true, + approvalScope: 'resource', + resourceKeyFromParams: (params) => String(params['pageId'] ?? ''), async execute(params): Promise { const pageId = String(params['pageId'] ?? ''); if (!pageId) return { output: 'pageId required', isError: true }; diff --git a/packages/api/src/engine/tools/wiki/wiki-share.tool.ts b/packages/api/src/engine/tools/wiki/wiki-share.tool.ts index 2c71970..404b7a9 100644 --- a/packages/api/src/engine/tools/wiki/wiki-share.tool.ts +++ b/packages/api/src/engine/tools/wiki/wiki-share.tool.ts @@ -36,6 +36,10 @@ export function createWikiShareTool( }, required: ['pageId', 'targetType'], }, + requiresApproval: true, + approvalScope: 'resource', + resourceKeyFromParams: (params) => + `${String(params['pageId'] ?? '')}:${String(params['targetType'] ?? '')}:${String(params['groupId'] ?? '')}`, async execute(params): Promise { const pageId = String(params['pageId'] ?? ''); const targetType = params['targetType'] as 'group' | 'org'; diff --git a/packages/api/src/engine/tools/wiki/wiki-unshare.tool.ts b/packages/api/src/engine/tools/wiki/wiki-unshare.tool.ts index f7a7aa4..b2a5332 100644 --- a/packages/api/src/engine/tools/wiki/wiki-unshare.tool.ts +++ b/packages/api/src/engine/tools/wiki/wiki-unshare.tool.ts @@ -26,6 +26,9 @@ export function createWikiUnshareTool( properties: { shareId: { type: 'string' } }, required: ['shareId'], }, + requiresApproval: true, + approvalScope: 'resource', + resourceKeyFromParams: (params) => String(params['shareId'] ?? ''), async execute(params): Promise { const shareId = String(params['shareId'] ?? ''); From 096528a27ee1a790bd66dc19b0486cdc9b2f08f2 Mon Sep 17 00:00:00 2001 From: jasonli0226 Date: Mon, 20 Jul 2026 01:05:15 +0800 Subject: [PATCH 06/10] feat(approvals): telegram and web channel delivery of approval prompts Routes approval frames to the surface the user is on: - Telegram inline-keyboard prompts with callback resolution; the tracked prompt is edited when tapped, and updates are processed concurrently (@grammyjs/runner) so approval taps arrive promptly. - Web WS approval frames over the channel protocol. - Channel-manager routing plus module wiring. --- packages/api/package.json | 1 + .../__tests__/channel-manager.service.test.ts | 223 ++++++++ .../__tests__/telegram-approval.test.ts | 537 ++++++++++++++++++ .../__tests__/telegram.adapter.test.ts | 7 + .../src/channels/channel-manager.service.ts | 130 +++++ packages/api/src/channels/channels.module.ts | 16 +- .../src/channels/telegram/telegram.adapter.ts | 402 ++++++++++++- .../web/__tests__/web.adapter.test.ts | 85 +++ packages/api/src/channels/web/web.adapter.ts | 19 + packages/api/src/channels/web/web.protocol.ts | 8 + pnpm-lock.yaml | 14 + 11 files changed, 1433 insertions(+), 9 deletions(-) create mode 100644 packages/api/src/channels/__tests__/telegram-approval.test.ts diff --git a/packages/api/package.json b/packages/api/package.json index 329500f..ba67bb2 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -27,6 +27,7 @@ "@fastify/helmet": "^13.0.2", "@fastify/multipart": "^10.0.0", "@google/genai": "^1.50.1", + "@grammyjs/runner": "^2.0.3", "@modelcontextprotocol/sdk": "^1.29.0", "@mozilla/readability": "^0.6.0", "@nestjs/common": "^11.0.0", diff --git a/packages/api/src/channels/__tests__/channel-manager.service.test.ts b/packages/api/src/channels/__tests__/channel-manager.service.test.ts index 01d7a6b..7223e2d 100644 --- a/packages/api/src/channels/__tests__/channel-manager.service.test.ts +++ b/packages/api/src/channels/__tests__/channel-manager.service.test.ts @@ -771,6 +771,229 @@ describe('ChannelManagerService', () => { }); }); + describe('approval frame delivery', () => { + it('subscribes to approvalFrame on module init', async () => { + mockChannelRepo.findActive.mockResolvedValue([]); + + const manager = createManager(); + await manager.onModuleInit(); + + expect(mockPubsub.subscribe).toHaveBeenCalledWith( + PUBSUB_CHANNELS.approvalFrame, + expect.any(Function), + ); + }); + + it('routes a prompt frame to the session adapter as approval.request', async () => { + const dbChannel = { + id: 'ch-telegram', + type: 'telegram', + name: 'Bot', + config: {}, + isActive: true, + }; + mockChannelRepo.findActive.mockResolvedValue([dbChannel]); + + const channel = mockChannel({ id: 'ch-telegram' }); + mockRegistry.create.mockReturnValue(channel); + + mockSessionRepo.findById.mockResolvedValue({ + id: 'sess-1', + userId: 'user-1', + channelId: 'ch-telegram', + }); + mockUserRepo.findById.mockResolvedValue({ + id: 'user-1', + telegramId: '12345', + }); + + const manager = createManager(); + await manager.onModuleInit(); + + // approvalFrame is the third subscription (index 2) + const approvalCb = mockPubsub.subscribe.mock.calls[2]![1] as (msg: { + payload: Record; + }) => Promise; + + const payload = { + kind: 'prompt', + sessionId: 'sess-1', + userId: 'user-1', + approvalId: 'apr-1', + toolName: 'wiki_delete', + paramsSummary: { pageId: 'p1' }, + offeredScopes: ['allow_once', 'deny_once'], + reason: 'no_memory', + expiresAt: '2026-07-06T12:00:00.000Z', + }; + + await approvalCb({ payload }); + + expect(channel.sendMessage).toHaveBeenCalledWith({ + recipientId: '12345', + text: 'Approval needed: agent wants to run wiki_delete. Open the dashboard to approve or deny.', + metadata: { event: 'approval.request', approval: payload }, + }); + }); + + it('routes a notice frame as approval.notice', async () => { + const dbChannel = { + id: 'ch-telegram', + type: 'telegram', + name: 'Bot', + config: {}, + isActive: true, + }; + mockChannelRepo.findActive.mockResolvedValue([dbChannel]); + + const channel = mockChannel({ id: 'ch-telegram' }); + mockRegistry.create.mockReturnValue(channel); + + mockSessionRepo.findById.mockResolvedValue({ + id: 'sess-1', + userId: 'user-1', + channelId: 'ch-telegram', + }); + mockUserRepo.findById.mockResolvedValue({ + id: 'user-1', + telegramId: '12345', + }); + + const manager = createManager(); + await manager.onModuleInit(); + + const approvalCb = mockPubsub.subscribe.mock.calls[2]![1] as (msg: { + payload: Record; + }) => Promise; + + const payload = { + kind: 'notice', + sessionId: 'sess-1', + userId: 'user-1', + approvalId: 'apr-2', + toolName: 'wiki_delete', + reason: 'standing_deny', + }; + + await approvalCb({ payload }); + + expect(channel.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + // Notice fallback reports an automatic block, not an approve/deny CTA. + text: 'Blocked wiki_delete: a standing "always deny" rule denied it automatically. Manage standing decisions in the dashboard.', + metadata: { event: 'approval.notice', approval: payload }, + }), + ); + }); + + it('routes a resolved frame as approval.resolved', async () => { + const dbChannel = { + id: 'ch-telegram', + type: 'telegram', + name: 'Bot', + config: {}, + isActive: true, + }; + mockChannelRepo.findActive.mockResolvedValue([dbChannel]); + + const channel = mockChannel({ id: 'ch-telegram' }); + mockRegistry.create.mockReturnValue(channel); + + mockSessionRepo.findById.mockResolvedValue({ + id: 'sess-1', + userId: 'user-1', + channelId: 'ch-telegram', + }); + mockUserRepo.findById.mockResolvedValue({ + id: 'user-1', + telegramId: '12345', + }); + + const manager = createManager(); + await manager.onModuleInit(); + + const approvalCb = mockPubsub.subscribe.mock.calls[2]![1] as (msg: { + payload: Record; + }) => Promise; + + const payload = { + kind: 'resolved', + sessionId: 'sess-1', + userId: 'user-1', + approvalId: 'apr-1', + toolName: 'wiki_delete', + status: 'APPROVED', + scope: 'once', + }; + + await approvalCb({ payload }); + + expect(channel.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + // Resolved fallback reports the outcome + scope, not an approve/deny CTA. + text: 'Approval for wiki_delete was approved (once).', + metadata: { event: 'approval.resolved', approval: payload }, + }), + ); + }); + + it('does not crash when sessionId has no active adapter', async () => { + mockChannelRepo.findActive.mockResolvedValue([]); + mockSessionRepo.findById.mockResolvedValue({ + id: 'sess-1', + userId: 'user-1', + channelId: 'ch-gone', + }); + + const manager = createManager(); + await manager.onModuleInit(); + + const approvalCb = mockPubsub.subscribe.mock.calls[2]![1] as (msg: { + payload: Record; + }) => Promise; + + await expect( + approvalCb({ + payload: { + kind: 'prompt', + sessionId: 'sess-1', + userId: 'user-1', + approvalId: 'apr-1', + toolName: 'wiki_delete', + }, + }), + ).resolves.not.toThrow(); + + expect(mockUserRepo.findById).not.toHaveBeenCalled(); + }); + + it('skips delivery when the frame has no sessionId', async () => { + mockChannelRepo.findActive.mockResolvedValue([]); + + const manager = createManager(); + await manager.onModuleInit(); + + const approvalCb = mockPubsub.subscribe.mock.calls[2]![1] as (msg: { + payload: Record; + }) => Promise; + + await expect( + approvalCb({ + payload: { + kind: 'notice', + sessionId: null, + userId: 'user-1', + approvalId: 'apr-1', + toolName: 'wiki_delete', + reason: 'session_deny', + }, + }), + ).resolves.not.toThrow(); + + expect(mockSessionRepo.findById).not.toHaveBeenCalled(); + }); + }); + it('stops all active channels', async () => { const dbChannel = { id: 'ch-1', diff --git a/packages/api/src/channels/__tests__/telegram-approval.test.ts b/packages/api/src/channels/__tests__/telegram-approval.test.ts new file mode 100644 index 0000000..950b2ac --- /dev/null +++ b/packages/api/src/channels/__tests__/telegram-approval.test.ts @@ -0,0 +1,537 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createTelegramAdapter, updateConstraint } from '../telegram/telegram.adapter.js'; +import type { ChannelAdapterConfig } from '@clawix/shared'; +import type { Context } from 'grammy'; + +const sendMessageMock = vi.fn().mockResolvedValue({ message_id: 100 }); +const editMessageTextMock = vi.fn().mockResolvedValue({}); +const answerCallbackQueryMock = vi.fn().mockResolvedValue(true); + +// Captures the handler passed to `bot.on(event, handler)` so tests can invoke +// it directly, mirroring the mock pattern in `telegram.adapter.test.ts`. +let capturedHandlers: Record Promise> = {}; + +vi.mock('@grammyjs/runner', () => ({ + run: vi.fn(() => ({ isRunning: () => true, stop: vi.fn().mockResolvedValue(undefined) })), + sequentialize: vi.fn(() => vi.fn()), +})); + +vi.mock('grammy', () => { + return { + Bot: vi.fn().mockImplementation(() => ({ + on: vi.fn((event: string, handler: (ctx: unknown) => Promise) => { + capturedHandlers[event] = handler; + }), + command: vi.fn(), + use: vi.fn(), + catch: vi.fn(), + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + api: { + sendMessage: sendMessageMock, + sendChatAction: vi.fn().mockResolvedValue({}), + editMessageText: editMessageTextMock, + answerCallbackQuery: answerCallbackQueryMock, + setWebhook: vi.fn().mockResolvedValue({}), + }, + })), + }; +}); + +interface CallbackCtxOptions { + readonly data: string; + readonly fromId: number; + readonly message?: { readonly message_id: number; readonly chat: { readonly id: number } }; +} + +function makeCallbackCtx(opts: CallbackCtxOptions): unknown { + return { + callbackQuery: { + id: 'cbq-id', + data: opts.data, + from: { id: opts.fromId, is_bot: false, first_name: 'Test' }, + chat_instance: 'ci', + message: opts.message, + }, + }; +} + +function getCallbackHandler(): (ctx: unknown) => Promise { + const handler = capturedHandlers['callback_query:data']; + if (!handler) { + throw new Error('callback_query:data handler was not registered'); + } + return handler; +} + +describe('telegram adapter — tool-call approval', () => { + const config: ChannelAdapterConfig = { + id: 'channel-1', + type: 'telegram', + name: 'Test Bot', + config: { bot_token: 'test-token-123' }, + }; + + const findByTelegramIdMock = vi.fn(); + const resolveMock = vi.fn(); + const revokeMock = vi.fn(); + const deps = { + userRepo: { findByTelegramId: findByTelegramIdMock }, + toolApprovals: { resolve: resolveMock, revoke: revokeMock }, + }; + + beforeEach(() => { + capturedHandlers = {}; + sendMessageMock.mockClear(); + sendMessageMock.mockResolvedValue({ message_id: 100 }); + editMessageTextMock.mockClear(); + editMessageTextMock.mockResolvedValue({}); + answerCallbackQueryMock.mockClear(); + findByTelegramIdMock.mockReset(); + resolveMock.mockReset(); + revokeMock.mockReset(); + }); + + // The prompt is raised from inside the message handler, which holds its + // chat's sequentialize lane for as long as the approval is pending. If a tap + // shared that lane it would queue behind the very handler it has to release, + // and the approval could only ever resolve by timing out. + describe('updateConstraint — approval taps must not share the message lane', () => { + it('returns no constraint for a callback query, even with a chat', () => { + const ctx = { + callbackQuery: { id: 'cbq-id', data: 'apr:apr1:allow_once' }, + chat: { id: 42 }, + } as unknown as Context; + + expect(updateConstraint(ctx)).toBeUndefined(); + }); + + it('constrains messages to a per-chat lane', () => { + const ctx = { chat: { id: 42 } } as unknown as Context; + + expect(updateConstraint(ctx)).toBe('42'); + }); + + it('gives different chats different lanes so one run cannot block another', () => { + const a = { chat: { id: 1 } } as unknown as Context; + const b = { chat: { id: 2 } } as unknown as Context; + + expect(updateConstraint(a)).not.toBe(updateConstraint(b)); + }); + }); + + describe('approval.request — inline keyboard prompt', () => { + it('sends one button per offered scope with the correct label and callback_data', async () => { + const adapter = createTelegramAdapter(config, deps); + + await adapter.sendMessage({ + recipientId: 'chat-1', + text: 'fallback text', + metadata: { + event: 'approval.request', + approval: { + kind: 'prompt', + sessionId: 's1', + userId: 'u1', + approvalId: 'apr1', + toolName: 'shell', + paramsSummary: { cmd: 'ls' }, + offeredScopes: ['allow_once', 'allow_session', 'deny_once', 'deny_session'], + reason: 'no_memory', + expiresAt: new Date().toISOString(), + }, + }, + }); + + expect(sendMessageMock).toHaveBeenCalledTimes(1); + const [, , options] = sendMessageMock.mock.calls[0] as [ + string, + string, + { + reply_markup: { inline_keyboard: { text: string; callback_data: string }[][] }; + }, + ]; + const flat = options.reply_markup.inline_keyboard + .flat() + .sort((a, b) => a.callback_data.localeCompare(b.callback_data)); + + expect(flat).toEqual( + [ + { text: 'Deny (session)', callback_data: 'apr:apr1:deny_session' }, + { text: 'Deny once', callback_data: 'apr:apr1:deny_once' }, + { text: 'Allow (session)', callback_data: 'apr:apr1:allow_session' }, + { text: 'Allow once', callback_data: 'apr:apr1:allow_once' }, + ].sort((a, b) => a.callback_data.localeCompare(b.callback_data)), + ); + }); + + it('never renders a fixed six buttons — only what offeredScopes carries', async () => { + const adapter = createTelegramAdapter(config, deps); + + await adapter.sendMessage({ + recipientId: 'chat-1', + text: 'fallback', + metadata: { + event: 'approval.request', + approval: { + kind: 'prompt', + sessionId: 's1', + userId: 'u1', + approvalId: 'apr-extractor-failed', + toolName: 'shell', + offeredScopes: ['allow_once', 'deny_once'], + reason: 'extractor_error', + expiresAt: new Date().toISOString(), + }, + }, + }); + + const [, , options] = sendMessageMock.mock.calls[0] as [ + string, + string, + { reply_markup: { inline_keyboard: unknown[][] } }, + ]; + expect(options.reply_markup.inline_keyboard.flat()).toHaveLength(2); + }); + }); + + describe('approval.notice — single revoke button', () => { + it('renders reason text plus a single "Revoke standing deny" button', async () => { + const adapter = createTelegramAdapter(config, deps); + + await adapter.sendMessage({ + recipientId: 'chat-1', + text: 'fallback', + metadata: { + event: 'approval.notice', + approval: { + kind: 'notice', + sessionId: 's1', + userId: 'u1', + approvalId: 'apr2', + toolName: 'shell', + paramsSummary: { cmd: 'rm -rf /' }, + reason: 'standing_deny', + }, + }, + }); + + const [, , options] = sendMessageMock.mock.calls[0] as [ + string, + string, + { + reply_markup: { inline_keyboard: { text: string; callback_data: string }[][] }; + }, + ]; + expect(options.reply_markup.inline_keyboard).toEqual([ + [{ text: 'Revoke standing deny', callback_data: 'aprrevoke:apr2' }], + ]); + }); + }); + + describe('callback_query — resolving via inline keyboard', () => { + it('resolves for the mapped user, answers the callback, and edits the outcome', async () => { + const adapter = createTelegramAdapter(config, deps); + void adapter; + + findByTelegramIdMock.mockResolvedValueOnce({ id: 'u1', telegramId: '555' }); + resolveMock.mockResolvedValueOnce({ ok: true, downgraded: false }); + + await getCallbackHandler()( + makeCallbackCtx({ + data: 'apr:apr1:allow_once', + fromId: 555, + message: { message_id: 100, chat: { id: 555 } }, + }), + ); + + expect(findByTelegramIdMock).toHaveBeenCalledWith('555'); + expect(resolveMock).toHaveBeenCalledWith('apr1', 'u1', 'allow_once'); + expect(answerCallbackQueryMock).toHaveBeenCalledWith('cbq-id', expect.any(Object)); + expect(editMessageTextMock).toHaveBeenCalledWith( + 555, + 100, + expect.stringContaining('Approved'), + ); + }); + + it('edits the tracked prompt when Telegram omits the tapped message (>48h old)', async () => { + const adapter = createTelegramAdapter(config, deps); + + // Seed the prompt-message map by sending the original inline-keyboard + // prompt (chatId = recipientId '555', messageId = 100 from the mock). + await adapter.sendMessage({ + recipientId: '555', + text: 'fallback', + metadata: { + event: 'approval.request', + approval: { + kind: 'prompt', + sessionId: 's1', + userId: 'u1', + approvalId: 'apr1', + toolName: 'shell', + offeredScopes: ['allow_once', 'deny_once'], + }, + }, + }); + editMessageTextMock.mockClear(); + + findByTelegramIdMock.mockResolvedValueOnce({ id: 'u1', telegramId: '555' }); + resolveMock.mockResolvedValueOnce({ ok: true, downgraded: false }); + + // Telegram omits `message` for callbacks on messages older than 48h. + await getCallbackHandler()( + makeCallbackCtx({ data: 'apr:apr1:allow_once', fromId: 555, message: undefined }), + ); + + expect(resolveMock).toHaveBeenCalledWith('apr1', 'u1', 'allow_once'); + // The prompt is still edited via the tracked {chatId, messageId} — its + // buttons are replaced, not left live. + expect(editMessageTextMock).toHaveBeenCalledWith( + '555', + 100, + expect.stringContaining('Approved'), + ); + }); + + it('rejects a callback from an unmapped Telegram user and never calls resolve', async () => { + const adapter = createTelegramAdapter(config, deps); + void adapter; + + findByTelegramIdMock.mockResolvedValueOnce(null); + + await getCallbackHandler()( + makeCallbackCtx({ + data: 'apr:apr1:allow_once', + fromId: 999, + message: { message_id: 100, chat: { id: 999 } }, + }), + ); + + expect(resolveMock).not.toHaveBeenCalled(); + expect(answerCallbackQueryMock).toHaveBeenCalledWith( + 'cbq-id', + expect.objectContaining({ show_alert: true }), + ); + expect(editMessageTextMock).not.toHaveBeenCalled(); + }); + + it('never trusts a userId embedded in callback_data — always resolves via from.id', async () => { + const adapter = createTelegramAdapter(config, deps); + void adapter; + + // Even a well-formed apr:: payload carries no userId at all; + // the only identity signal is `from.id`, resolved server-side. + findByTelegramIdMock.mockResolvedValueOnce({ id: 'real-user', telegramId: '555' }); + resolveMock.mockResolvedValueOnce({ ok: true, downgraded: false }); + + await getCallbackHandler()( + makeCallbackCtx({ + data: 'apr:apr1:allow_once', + fromId: 555, + message: { message_id: 100, chat: { id: 555 } }, + }), + ); + + expect(resolveMock).toHaveBeenCalledWith('apr1', 'real-user', 'allow_once'); + }); + + it('answers with an alert and does not resolve when the approval is no longer available', async () => { + const adapter = createTelegramAdapter(config, deps); + void adapter; + + findByTelegramIdMock.mockResolvedValueOnce({ id: 'u1', telegramId: '555' }); + resolveMock.mockResolvedValueOnce({ ok: false, downgraded: false }); + + await getCallbackHandler()( + makeCallbackCtx({ + data: 'apr:apr1:allow_once', + fromId: 555, + message: { message_id: 100, chat: { id: 555 } }, + }), + ); + + expect(answerCallbackQueryMock).toHaveBeenCalledWith( + 'cbq-id', + expect.objectContaining({ show_alert: true }), + ); + expect(editMessageTextMock).not.toHaveBeenCalled(); + }); + + it('revokes a standing deny via the aprrevoke callback for the mapped user', async () => { + const adapter = createTelegramAdapter(config, deps); + void adapter; + + findByTelegramIdMock.mockResolvedValueOnce({ id: 'u1', telegramId: '555' }); + revokeMock.mockResolvedValueOnce(true); + + await getCallbackHandler()( + makeCallbackCtx({ + data: 'aprrevoke:apr2', + fromId: 555, + message: { message_id: 200, chat: { id: 555 } }, + }), + ); + + expect(revokeMock).toHaveBeenCalledWith('apr2', 'u1'); + expect(editMessageTextMock).toHaveBeenCalledWith( + 555, + 200, + expect.stringContaining('Revoked'), + ); + }); + + it('answers with an error and calls neither resolve nor revoke when deps are not wired', async () => { + const adapter = createTelegramAdapter(config); // no deps + void adapter; + + await getCallbackHandler()( + makeCallbackCtx({ + data: 'apr:apr1:allow_once', + fromId: 555, + message: { message_id: 100, chat: { id: 555 } }, + }), + ); + + expect(findByTelegramIdMock).not.toHaveBeenCalled(); + expect(resolveMock).not.toHaveBeenCalled(); + expect(answerCallbackQueryMock).toHaveBeenCalledWith( + 'cbq-id', + expect.objectContaining({ show_alert: true }), + ); + }); + }); + + describe('approval.resolved — editing the original prompt message', () => { + it('edits the tracked prompt message when a mapping exists', async () => { + const adapter = createTelegramAdapter(config, deps); + sendMessageMock.mockResolvedValueOnce({ message_id: 777 }); + + await adapter.sendMessage({ + recipientId: 'chat-9', + text: 'fallback', + metadata: { + event: 'approval.request', + approval: { + kind: 'prompt', + sessionId: 's1', + userId: 'u1', + approvalId: 'apr9', + toolName: 'shell', + offeredScopes: ['allow_once', 'deny_once'], + reason: 'no_memory', + expiresAt: new Date().toISOString(), + }, + }, + }); + + await adapter.sendMessage({ + recipientId: 'chat-9', + text: 'fallback resolved', + metadata: { + event: 'approval.resolved', + approval: { + kind: 'resolved', + sessionId: 's1', + userId: 'u1', + approvalId: 'apr9', + status: 'APPROVED', + scope: 'once', + }, + }, + }); + + expect(editMessageTextMock).toHaveBeenCalledWith( + 'chat-9', + 777, + expect.stringContaining('Approved'), + ); + }); + + it('is a no-op when there is no tracked mapping for the approvalId', async () => { + const adapter = createTelegramAdapter(config, deps); + + const result = await adapter.sendMessage({ + recipientId: 'chat-9', + text: 'fallback resolved', + metadata: { + event: 'approval.resolved', + approval: { + kind: 'resolved', + sessionId: 's1', + userId: 'u1', + approvalId: 'unknown-approval', + status: 'APPROVED', + }, + }, + }); + + expect(editMessageTextMock).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('caps the tracked prompt-message map at 500 entries (FIFO eviction)', async () => { + const adapter = createTelegramAdapter(config, deps); + let n = 0; + sendMessageMock.mockImplementation(async () => ({ message_id: ++n })); + + for (let i = 0; i < 501; i++) { + await adapter.sendMessage({ + recipientId: 'chat-x', + text: 'fallback', + metadata: { + event: 'approval.request', + approval: { + kind: 'prompt', + sessionId: 's', + userId: 'u', + approvalId: `apr-${i}`, + toolName: 'shell', + offeredScopes: ['allow_once', 'deny_once'], + reason: 'no_memory', + expiresAt: new Date().toISOString(), + }, + }, + }); + } + + editMessageTextMock.mockClear(); + + // apr-0 (the oldest) should have been evicted by the 501st insert. + await adapter.sendMessage({ + recipientId: 'chat-x', + text: 'resolved', + metadata: { + event: 'approval.resolved', + approval: { + kind: 'resolved', + sessionId: 's', + userId: 'u', + approvalId: 'apr-0', + status: 'APPROVED', + }, + }, + }); + expect(editMessageTextMock).not.toHaveBeenCalled(); + + // apr-500 (the most recent) should still be tracked. + await adapter.sendMessage({ + recipientId: 'chat-x', + text: 'resolved', + metadata: { + event: 'approval.resolved', + approval: { + kind: 'resolved', + sessionId: 's', + userId: 'u', + approvalId: 'apr-500', + status: 'APPROVED', + }, + }, + }); + expect(editMessageTextMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/api/src/channels/__tests__/telegram.adapter.test.ts b/packages/api/src/channels/__tests__/telegram.adapter.test.ts index 661d27f..a243d9f 100644 --- a/packages/api/src/channels/__tests__/telegram.adapter.test.ts +++ b/packages/api/src/channels/__tests__/telegram.adapter.test.ts @@ -7,11 +7,18 @@ const sendMessageMock = vi.fn().mockResolvedValue({ message_id: 100 }); const sendChatActionMock = vi.fn().mockResolvedValue({}); const editMessageTextMock = vi.fn().mockResolvedValue({}); +vi.mock('@grammyjs/runner', () => ({ + run: vi.fn(() => ({ isRunning: () => true, stop: vi.fn().mockResolvedValue(undefined) })), + sequentialize: vi.fn(() => vi.fn()), +})); + vi.mock('grammy', () => { return { Bot: vi.fn().mockImplementation(() => ({ on: vi.fn(), command: vi.fn(), + use: vi.fn(), + catch: vi.fn(), start: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined), api: { diff --git a/packages/api/src/channels/channel-manager.service.ts b/packages/api/src/channels/channel-manager.service.ts index 5487eb8..84e23cb 100644 --- a/packages/api/src/channels/channel-manager.service.ts +++ b/packages/api/src/channels/channel-manager.service.ts @@ -10,6 +10,7 @@ import { RedisPubSubService } from '../cache/redis-pubsub.service.js'; import { SessionRepository } from '../db/session.repository.js'; import { UserRepository } from '../db/user.repository.js'; import { PUBSUB_CHANNELS } from '../cache/cache.constants.js'; +import type { ApprovalFramePayload } from '../approvals/approval-delivery.service.js'; const logger = createLogger('channels:manager'); @@ -84,6 +85,7 @@ export class ChannelManagerService implements OnModuleInit, OnModuleDestroy { await this.startAll(); await this.subscribeToResponseDelivery(); await this.subscribeToCronResults(); + await this.subscribeToApprovalFrame(); } async onModuleDestroy(): Promise { @@ -317,6 +319,134 @@ export class ChannelManagerService implements OnModuleInit, OnModuleDestroy { } } + /** + * Subscribe to approvalFrame pub/sub events (Task 9 delivery). Routes each + * prompt/notice/resolved frame to the requesting session's channel adapter, + * reusing the same session→adapter→recipient crawl as + * `deliverResponseToChannel`. + */ + private async subscribeToApprovalFrame(): Promise { + await this.pubsub.subscribe( + PUBSUB_CHANNELS.approvalFrame, + async (msg) => { + const payload = msg.payload; + if (!payload?.approvalId || !payload?.kind) return; + + await this.deliverApprovalFrame(payload); + }, + ); + } + + /** + * Look up the session's channel and user, then deliver the approval frame + * via the appropriate channel adapter. A missing session, channel, adapter, + * or recipient is logged and skipped — never thrown — so a malformed or + * unroutable frame can't crash the subscription callback. + */ + private async deliverApprovalFrame(payload: ApprovalFramePayload): Promise { + try { + if (!payload.sessionId) { + logger.debug( + { approvalId: payload.approvalId, kind: payload.kind }, + 'Approval frame has no sessionId; skipping channel delivery', + ); + return; + } + + const session = await this.sessionRepo.findById(payload.sessionId); + + if (!session.channelId) { + logger.debug( + { sessionId: payload.sessionId }, + 'Session has no channelId; skipping approval frame delivery', + ); + return; + } + + const adapter = this.findByChannelId(session.channelId); + if (!adapter) { + logger.warn( + { sessionId: payload.sessionId, channelId: session.channelId }, + 'No active adapter for approval frame delivery', + ); + return; + } + + const user = await this.userRepo.findById(session.userId); + + const recipientId = this.resolveRecipientId(adapter.type, user); + if (!recipientId) { + logger.warn( + { channelType: adapter.type, userId: user.id }, + 'Could not resolve recipient ID for approval frame', + ); + return; + } + + // Spec §7 event names: kind 'prompt' travels as event 'approval.request'; + // 'notice' and 'resolved' as approval.notice / approval.resolved. + const event = payload.kind === 'prompt' ? 'approval.request' : `approval.${payload.kind}`; + await adapter.sendMessage({ + recipientId, + text: this.approvalFallbackText(payload), // plain-text fallback for adapters without rich rendering + metadata: { event, approval: payload }, + }); + + logger.info( + { + approvalId: payload.approvalId, + kind: payload.kind, + sessionId: payload.sessionId, + channelId: session.channelId, + recipientId, + }, + 'Delivered approval frame to channel', + ); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.error( + { approvalId: payload.approvalId, kind: payload.kind, error: message }, + 'Failed to deliver approval frame', + ); + } + } + + /** + * Plain-text fallback for adapters without rich approval-card rendering + * (used verbatim by WhatsApp if ever routed; Telegram/web override + * rendering in their own adapters via `metadata.event`/`metadata.approval`). + * + * Kind-specific so a settled or auto-denied frame doesn't wrongly tell the + * user to "approve or deny" something already decided: `prompt` is the only + * actionable frame; `notice` reports an automatic block; `resolved` reports + * the outcome. + */ + private approvalFallbackText(payload: ApprovalFramePayload): string { + const toolName = payload.toolName ?? 'this tool'; + if (payload.kind === 'notice') { + const rule = + payload.reason === 'standing_deny' + ? 'a standing "always deny" rule' + : payload.reason === 'session_deny' + ? 'a "deny this session" rule' + : 'an automatic rule'; + return `Blocked ${toolName}: ${rule} denied it automatically. Manage standing decisions in the dashboard.`; + } + if (payload.kind === 'resolved') { + const outcome = + payload.status === 'APPROVED' + ? 'approved' + : payload.status === 'EXPIRED' + ? 'expired (auto-denied — no response in time)' + : payload.status === 'CANCELLED' + ? 'cancelled' + : 'denied'; + const scope = payload.scope ? ` (${payload.scope})` : ''; + return `Approval for ${toolName} was ${outcome}${scope}.`; + } + return `Approval needed: agent wants to run ${toolName}. Open the dashboard to approve or deny.`; + } + // ---------------------------------------------------------------- // // Private helpers // // ---------------------------------------------------------------- // diff --git a/packages/api/src/channels/channels.module.ts b/packages/api/src/channels/channels.module.ts index 66ec89c..8db06bb 100644 --- a/packages/api/src/channels/channels.module.ts +++ b/packages/api/src/channels/channels.module.ts @@ -4,6 +4,8 @@ import { JwtModule } from '@nestjs/jwt'; import { DbModule } from '../db/db.module.js'; import { EngineModule } from '../engine/engine.module.js'; import { CommandModule } from '../commands/command.module.js'; +import { ApprovalsModule } from '../approvals/approvals.module.js'; +import { ToolApprovalService } from '../approvals/tool-approval.service.js'; import { ChannelRegistry } from './channel.registry.js'; import { MessageRouterService } from './message-router.service.js'; import { ChannelManagerService } from './channel-manager.service.js'; @@ -18,7 +20,7 @@ import { createWebAdapter } from './web/web.adapter.js'; import { WebChatGateway } from './web/web.gateway.js'; @Module({ - imports: [DbModule, EngineModule, JwtModule.register({}), CommandModule], + imports: [DbModule, EngineModule, JwtModule.register({}), CommandModule, ApprovalsModule], controllers: [ChannelsController], providers: [ ChannelRegistry, @@ -34,9 +36,16 @@ import { WebChatGateway } from './web/web.gateway.js'; sessionRepo: SessionRepository, userRepo: UserRepository, gateway: WebChatGateway, + toolApprovals: ToolApprovalService, ) => { - // Register channel adapter factories - registry.register('telegram', (config) => createTelegramAdapter(config)); + // Register channel adapter factories. `createTelegramAdapter` gets a + // second `deps` argument threading `ToolApprovalService` + `UserRepository` + // through this factory closure — the same mechanism already used to give + // `ChannelManagerService` itself a `UserRepository` below, and the web + // adapter's `gateway.setAdapter(adapter)` wiring above. + registry.register('telegram', (config) => + createTelegramAdapter(config, { userRepo, toolApprovals }), + ); registry.register('whatsapp', (config) => createWhatsAppAdapter(config)); registry.register('web', (config) => { const adapter = createWebAdapter(config); @@ -61,6 +70,7 @@ import { WebChatGateway } from './web/web.gateway.js'; SessionRepository, UserRepository, WebChatGateway, + ToolApprovalService, ], }, ], diff --git a/packages/api/src/channels/telegram/telegram.adapter.ts b/packages/api/src/channels/telegram/telegram.adapter.ts index a00c078..e601427 100644 --- a/packages/api/src/channels/telegram/telegram.adapter.ts +++ b/packages/api/src/channels/telegram/telegram.adapter.ts @@ -1,5 +1,7 @@ import { Bot } from 'grammy'; +import type { Context } from 'grammy'; import type { Message } from 'grammy/types'; +import { run, sequentialize, type RunnerHandle } from '@grammyjs/runner'; import { createLogger } from '@clawix/shared'; import type { ChannelAdapter, @@ -15,9 +17,187 @@ import { TELEGRAM_MAX_MESSAGE_LENGTH, splitMessage, } from '../utils/message-chunker.js'; +import type { ApprovalFramePayload } from '../../approvals/approval-delivery.service.js'; +import type { ApprovalChoice } from '../../approvals/approval.types.js'; +import type { ToolApprovalService } from '../../approvals/tool-approval.service.js'; +import type { UserRepository } from '../../db/user.repository.js'; const logger = createLogger('channels:telegram'); +/** + * Ordering constraint for the runner's `sequentialize` middleware. + * + * Messages in the same chat process in arrival order (one lane per chat), while + * different chats run concurrently — so one user's long agent run no longer + * blocks everybody else's messages. + * + * Callback queries deliberately get NO constraint, so they bypass the lane + * entirely. This is load-bearing, not an optimisation: an approval prompt is + * raised from *inside* the message handler (`ToolApprovalService.gate()` parks + * the reasoning loop until a human answers), so that handler still occupies its + * chat's lane while the prompt is live. Putting the tap in the same lane would + * queue the only update that can release the handler behind the handler itself + * — a deadlock that resolves only when the approval times out. + */ +export function updateConstraint(ctx: Context): string | undefined { + if (ctx.callbackQuery) return undefined; + const chatId = ctx.chat?.id; + return chatId === undefined ? undefined : String(chatId); +} + +// -------------------------------------------------------------------- // +// Tool-call approval: inline-keyboard prompts + callback_query // +// -------------------------------------------------------------------- // + +/** + * Minimal surface the adapter needs from the approvals stack, narrowed via + * `Pick` so this file only depends on the two methods it actually calls + * (Interface Segregation) rather than the full service/repository shape. + * `ChannelsModule` threads real instances into the adapter factory closure — + * the same mechanism it already uses to give `ChannelManagerService` a + * `UserRepository` (see channels.module.ts). Optional because the adapter's + * own tests construct it with a single argument; a callback received with no + * deps wired answers with an error instead of throwing. + */ +export interface TelegramApprovalDeps { + readonly userRepo: Pick; + readonly toolApprovals: Pick; +} + +/** Cap on the in-adapter prompt→message tracking map (FIFO eviction). */ +const MAX_TRACKED_PROMPT_MESSAGES = 500; + +interface TrackedPromptMessage { + readonly chatId: string; + readonly messageId: number; +} + +interface ApprovalButton { + readonly text: string; + readonly callback_data: string; +} + +/** Button labels for the six `ApprovalChoice` values (spec §4 / Task 3). */ +const CHOICE_LABELS: Record = { + allow_once: 'Allow once', + allow_session: 'Allow (session)', + allow_always: 'Always allow', + deny_once: 'Deny once', + deny_session: 'Deny (session)', + deny_always: 'Always deny', +}; + +function isApprovalChoice(value: string): value is ApprovalChoice { + return Object.prototype.hasOwnProperty.call(CHOICE_LABELS, value); +} + +function scopeTierOf(choice: ApprovalChoice): 'once' | 'session' | 'always' { + if (choice.endsWith('once')) return 'once'; + if (choice.endsWith('session')) return 'session'; + return 'always'; +} + +function toApprovalButton(choice: ApprovalChoice, approvalId: string): ApprovalButton { + return { text: CHOICE_LABELS[choice], callback_data: `apr:${approvalId}:${choice}` }; +} + +/** + * Honest-buttons rule: render exactly one button per `offeredScopes` entry + * present in the frame — never a fixed six. Allow/deny buttons for the same + * scope tier (once/session/always) share a row when both are offered. + */ +function buildApprovalKeyboard( + offeredScopes: readonly string[] | undefined, + approvalId: string, +): ApprovalButton[][] { + const choices = (offeredScopes ?? []).filter(isApprovalChoice); + const used = new Set(); + const rows: ApprovalButton[][] = []; + + for (const choice of choices) { + if (used.has(choice)) continue; + used.add(choice); + const tier = scopeTierOf(choice); + const partner = choices.find((c) => !used.has(c) && scopeTierOf(c) === tier); + const row: ApprovalButton[] = [toApprovalButton(choice, approvalId)]; + if (partner) { + used.add(partner); + row.push(toApprovalButton(partner, approvalId)); + } + rows.push(row); + } + + return rows; +} + +function formatParamsSummaryLines(paramsSummary?: Record): string { + const entries = Object.entries(paramsSummary ?? {}); + if (entries.length === 0) return ''; + return '\n' + entries.map(([key, value]) => `• ${key}: ${value}`).join('\n'); +} + +function formatPromptText(payload: ApprovalFramePayload): string { + const toolName = payload.toolName ?? 'a tool'; + return `\u{1F510} Approval needed: run ${toolName}?${formatParamsSummaryLines(payload.paramsSummary)}`; +} + +function formatNoticeText(payload: ApprovalFramePayload): string { + const toolName = payload.toolName ?? 'a tool'; + const reasonText = + payload.reason === 'standing_deny' + ? 'You have a standing deny for this action.' + : 'You have a session deny for this action.'; + return `\u{1F6AB} Blocked: ${toolName}\n${reasonText}${formatParamsSummaryLines(payload.paramsSummary)}`; +} + +function formatResolvedText(payload: ApprovalFramePayload): string { + const scopeSuffix = payload.scope ? ` (${payload.scope})` : ''; + switch (payload.status) { + case 'APPROVED': + return `✅ Approved${scopeSuffix}`; + case 'DENIED': + return `❌ Denied${scopeSuffix}`; + case 'EXPIRED': + return '⏳ Expired — no response received.'; + case 'CANCELLED': + return '⏹ Cancelled.'; + default: + return `Resolved: ${payload.status ?? 'unknown'}`; + } +} + +function formatChoiceOutcomeText(choice: ApprovalChoice, downgraded: boolean): string { + const isAllow = choice.startsWith('allow'); + const icon = isAllow ? '✅' : '❌'; + const verb = isAllow ? 'Approved' : 'Denied'; + const suffix = downgraded ? ' (adjusted to an available scope)' : ''; + return `${icon} ${verb} (${scopeTierOf(choice)})${suffix}`; +} + +type ParsedApprovalCallback = + | { readonly kind: 'resolve'; readonly approvalId: string; readonly choice: ApprovalChoice } + | { readonly kind: 'revoke'; readonly approvalId: string }; + +const CALLBACK_REVOKE_PREFIX = 'aprrevoke:'; +const CALLBACK_RESOLVE_PREFIX = 'apr:'; + +/** Parse `apr::` / `aprrevoke:` callback_data. */ +function parseApprovalCallbackData(data: string): ParsedApprovalCallback | null { + if (data.startsWith(CALLBACK_REVOKE_PREFIX)) { + const approvalId = data.slice(CALLBACK_REVOKE_PREFIX.length); + return approvalId.length > 0 ? { kind: 'revoke', approvalId } : null; + } + if (data.startsWith(CALLBACK_RESOLVE_PREFIX)) { + const rest = data.slice(CALLBACK_RESOLVE_PREFIX.length); + const sep = rest.lastIndexOf(':'); + if (sep <= 0) return null; + const approvalId = rest.slice(0, sep); + const choiceRaw = rest.slice(sep + 1); + return isApprovalChoice(choiceRaw) ? { kind: 'resolve', approvalId, choice: choiceRaw } : null; + } + return null; +} + /** * Outbound reply-threading mode (mirrors Hermes `reply_to_mode`): * "off" — never thread; replies are sent as standalone messages. @@ -79,8 +259,15 @@ const extractReplyContext = (message: Message): InboundMessage['replyCtx'] => { /** * Create a Telegram channel adapter using grammy. * Supports polling (default) and webhook modes. + * + * `deps` threads the approvals stack into the adapter — optional so existing + * single-argument construction (this adapter's own unit tests, and any + * caller that doesn't need approval callbacks) keeps working unchanged. */ -export function createTelegramAdapter(config: ChannelAdapterConfig): ChannelAdapter { +export function createTelegramAdapter( + config: ChannelAdapterConfig, + deps?: TelegramApprovalDeps, +): ChannelAdapter { const botToken = config.config['bot_token'] as string | undefined; if (!botToken) { @@ -93,6 +280,68 @@ export function createTelegramAdapter(config: ChannelAdapterConfig): ChannelAdap const replyToMode = resolveReplyToMode(config.config['reply_to_mode']); const bot = new Bot(botToken); let messageHandler: MessageHandler | null = null; + let runnerHandle: RunnerHandle | null = null; + + // Concurrency ordering. Must be installed before any handler below. + bot.use(sequentialize(updateConstraint)); + + // Without an error handler grammY's default rethrows, which tears down the + // whole runner — one throwing handler would take polling down for every user. + bot.catch((err) => { + logger.error( + { updateId: err.ctx.update.update_id, error: err.message }, + 'Unhandled error in Telegram middleware', + ); + }); + + // approvalId -> {chatId, messageId} of the inline-keyboard prompt sent for + // it, so a later `approval.resolved` frame (which may arrive because the + // approval was resolved through a *different* channel, e.g. the web + // dashboard or bell) knows which Telegram message to edit in place. FIFO + // capped so a long-running bot can't leak memory on abandoned prompts. + const promptMessages = new Map(); + + function rememberPromptMessage(approvalId: string, chatId: string, messageId: number): void { + if (promptMessages.size >= MAX_TRACKED_PROMPT_MESSAGES && !promptMessages.has(approvalId)) { + const oldestKey = promptMessages.keys().next().value; + if (oldestKey !== undefined) { + promptMessages.delete(oldestKey); + } + } + promptMessages.set(approvalId, { chatId, messageId }); + } + + /** + * Resolve which message to edit when an approval settles via callback. + * Prefer the tapped message (`ctx.callbackQuery.message`), but Telegram omits + * it for messages older than 48h — fall back to the tracked prompt so the + * inline keyboard is still replaced instead of left live. Returns undefined + * only when neither is available. + */ + function editTargetFor( + live: { chat: { id: number }; message_id: number } | undefined, + approvalId: string, + ): { chatId: number | string; messageId: number } | undefined { + if (live) return { chatId: live.chat.id, messageId: live.message_id }; + const tracked = promptMessages.get(approvalId); + return tracked ? { chatId: tracked.chatId, messageId: tracked.messageId } : undefined; + } + + async function safeEditMessageText( + chatId: number | string, + messageId: number, + text: string, + ): Promise { + try { + await bot.api.editMessageText(chatId, messageId, text); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + if (msg.includes('message is not modified')) { + return; + } + logger.warn({ chatId, messageId, error: msg }, 'Failed to edit approval message'); + } + } // Handle /start command bot.command('start', async (ctx) => { @@ -132,6 +381,96 @@ export function createTelegramAdapter(config: ChannelAdapterConfig): ChannelAdap } }); + // Handle inline-keyboard taps on approval prompts/notices. + // + // Security boundary: the Telegram `from.id` on the callback is resolved to + // a Clawix user via the SAME telegramId lookup the text handler's user + // resolution relies on (`UserRepository.findByTelegramId`, via + // `MessageRouterService.lookupUser` for text messages). A foreign or + // unmapped Telegram user gets an error answer and `resolve`/`revoke` are + // never called — the userId passed to the approval service always comes + // from this lookup, never from the callback_data payload. + bot.on('callback_query:data', async (ctx) => { + const callbackQueryId = ctx.callbackQuery.id; + const data = ctx.callbackQuery.data; + const parsed = parseApprovalCallbackData(data); + + if (!parsed) { + logger.warn({ data }, 'Unrecognised approval callback_data'); + await bot.api.answerCallbackQuery(callbackQueryId, { + text: 'Unrecognised action.', + show_alert: true, + }); + return; + } + + if (!deps) { + logger.warn( + { approvalId: parsed.approvalId }, + 'Approval callback received but no approvals deps are wired', + ); + await bot.api.answerCallbackQuery(callbackQueryId, { + text: 'Approvals are not available right now.', + show_alert: true, + }); + return; + } + + const fromId = ctx.callbackQuery.from.id; + const user = await deps.userRepo.findByTelegramId(String(fromId)); + if (!user) { + logger.warn( + { fromId, approvalId: parsed.approvalId }, + 'Approval callback from unmapped Telegram user — refusing', + ); + await bot.api.answerCallbackQuery(callbackQueryId, { + text: 'You are not authorized for this action.', + show_alert: true, + }); + return; + } + + const target = ctx.callbackQuery.message; + + if (parsed.kind === 'resolve') { + const result = await deps.toolApprovals.resolve(parsed.approvalId, user.id, parsed.choice); + if (!result.ok) { + await bot.api.answerCallbackQuery(callbackQueryId, { + text: 'This approval is no longer available.', + show_alert: true, + }); + return; + } + await bot.api.answerCallbackQuery(callbackQueryId, { text: 'Recorded.' }); + // Edit the prompt (falling back to the tracked message when Telegram + // omits the tapped one), THEN drop the map entry — deleting before the + // edit would strip the only fallback for a >48h-old prompt and leave its + // buttons live. Any later `approval.resolved` frame then no-ops. + const editTarget = editTargetFor(target, parsed.approvalId); + if (editTarget) { + await safeEditMessageText( + editTarget.chatId, + editTarget.messageId, + formatChoiceOutcomeText(parsed.choice, result.downgraded), + ); + } + promptMessages.delete(parsed.approvalId); + return; + } + + const removed = await deps.toolApprovals.revoke(parsed.approvalId, user.id); + await bot.api.answerCallbackQuery(callbackQueryId, { + text: removed ? 'Standing deny revoked.' : 'Nothing to revoke.', + }); + if (removed) { + const editTarget = editTargetFor(target, parsed.approvalId); + if (editTarget) { + await safeEditMessageText(editTarget.chatId, editTarget.messageId, '\u{1F513} Revoked.'); + } + promptMessages.delete(parsed.approvalId); + } + }); + const adapter: ChannelAdapter = { id: config.id, type: 'telegram', @@ -156,21 +495,72 @@ export function createTelegramAdapter(config: ChannelAdapterConfig): ChannelAdap logger.warn('Webhook mode: ensure POST /api/telegram/webhook route is registered'); } else { logger.info('Starting Telegram bot in polling mode'); - bot.start({ - onStart: () => { - logger.info('Telegram bot polling started'); - }, - }); + // `run()` (grammY runner) instead of `bot.start()`: the built-in poller + // handles updates strictly sequentially, so a handler that parks — an + // agent run awaiting a tool approval — stalls the whole update loop and + // the approval tap can never arrive. The runner processes updates + // concurrently; `sequentialize` above restores per-chat ordering. + // + // `allowed_updates: []` means "all types except chat_member" — the same + // default `bot.start()` sent. Passed explicitly because the runner omits + // the field otherwise, and Telegram then reuses whatever was last set on + // the token (possibly a narrower list from another deployment). + runnerHandle = run(bot, { runner: { fetch: { allowed_updates: [] } } }); + logger.info('Telegram bot polling started'); } }, async disconnect(): Promise { logger.info('Stopping Telegram bot'); + if (runnerHandle) { + // Resolves once in-flight middleware finishes. + if (runnerHandle.isRunning()) { + await runnerHandle.stop(); + } + runnerHandle = null; + return; + } await bot.stop(); }, async sendMessage(message: OutboundMessage): Promise { const chatId = message.recipientId; + const event = message.metadata?.event; + + // Tool-approval prompt/notice/resolved frames (Task 9 delivery) render + // as an inline-keyboard card instead of the generic chunked-text path + // below — `metadata.approval` carries the structured `ApprovalFramePayload`. + if (event === 'approval.request' || event === 'approval.notice') { + const payload = message.metadata?.['approval'] as ApprovalFramePayload | undefined; + if (!payload) return undefined; + + const isPrompt = event === 'approval.request'; + const text = isPrompt ? formatPromptText(payload) : formatNoticeText(payload); + const keyboard: ApprovalButton[][] = isPrompt + ? buildApprovalKeyboard(payload.offeredScopes, payload.approvalId) + : [[{ text: 'Revoke standing deny', callback_data: `aprrevoke:${payload.approvalId}` }]]; + + const sent = await bot.api.sendMessage(chatId, text, { + reply_markup: { inline_keyboard: keyboard }, + }); + if (isPrompt) { + rememberPromptMessage(payload.approvalId, chatId, sent.message_id); + } + return String(sent.message_id); + } + + if (event === 'approval.resolved') { + const payload = message.metadata?.['approval'] as ApprovalFramePayload | undefined; + if (!payload) return undefined; + + const tracked = promptMessages.get(payload.approvalId); + if (!tracked) return undefined; // no known message to edit — nothing to do + + promptMessages.delete(payload.approvalId); + await safeEditMessageText(tracked.chatId, tracked.messageId, formatResolvedText(payload)); + return String(tracked.messageId); + } + const chunks = splitMessage(message.text, SAFE_SPLIT_LENGTH); if (chunks.length === 0) { diff --git a/packages/api/src/channels/web/__tests__/web.adapter.test.ts b/packages/api/src/channels/web/__tests__/web.adapter.test.ts index 7fd77c9..dd9af6f 100644 --- a/packages/api/src/channels/web/__tests__/web.adapter.test.ts +++ b/packages/api/src/channels/web/__tests__/web.adapter.test.ts @@ -187,6 +187,91 @@ describe('createWebAdapter', () => { }), ).resolves.toBe('m1'); }); + + describe('approval.* frames', () => { + it('emits an approval.request frame (not message.create) for a prompt', async () => { + const socket = makeMockSocket(); + adapter.addConnection('user-1', socket as never); + + const approval = { + kind: 'prompt', + approvalId: 'apr-1', + sessionId: 'sess-1', + userId: 'user-1', + toolName: 'wiki_delete', + }; + + await adapter.sendMessage({ + recipientId: 'user-1', + text: 'Approval needed: agent wants to run wiki_delete. Open the dashboard to approve or deny.', + metadata: { event: 'approval.request', approval }, + }); + + expect(socket.send).toHaveBeenCalledOnce(); + const payload = JSON.parse(socket.send.mock.calls[0]![0] as string) as { + type: string; + payload: unknown; + }; + expect(payload.type).toBe('approval.request'); + expect(payload.payload).toEqual(approval); + }); + + it('emits an approval.notice frame', async () => { + const socket = makeMockSocket(); + adapter.addConnection('user-1', socket as never); + + const approval = { + kind: 'notice', + approvalId: 'apr-2', + sessionId: 'sess-1', + userId: 'user-1', + toolName: 'wiki_delete', + reason: 'standing_deny', + }; + + await adapter.sendMessage({ + recipientId: 'user-1', + text: 'irrelevant fallback text', + metadata: { event: 'approval.notice', approval }, + }); + + expect(socket.send).toHaveBeenCalledOnce(); + const payload = JSON.parse(socket.send.mock.calls[0]![0] as string) as { + type: string; + payload: unknown; + }; + expect(payload.type).toBe('approval.notice'); + expect(payload.payload).toEqual(approval); + }); + + it('emits an approval.resolved frame', async () => { + const socket = makeMockSocket(); + adapter.addConnection('user-1', socket as never); + + const approval = { + kind: 'resolved', + approvalId: 'apr-1', + sessionId: 'sess-1', + userId: 'user-1', + status: 'APPROVED', + scope: 'once', + }; + + await adapter.sendMessage({ + recipientId: 'user-1', + text: 'irrelevant fallback text', + metadata: { event: 'approval.resolved', approval }, + }); + + expect(socket.send).toHaveBeenCalledOnce(); + const payload = JSON.parse(socket.send.mock.calls[0]![0] as string) as { + type: string; + payload: unknown; + }; + expect(payload.type).toBe('approval.resolved'); + expect(payload.payload).toEqual(approval); + }); + }); }); describe('sendError()', () => { diff --git a/packages/api/src/channels/web/web.adapter.ts b/packages/api/src/channels/web/web.adapter.ts index 8d61c3a..049f339 100644 --- a/packages/api/src/channels/web/web.adapter.ts +++ b/packages/api/src/channels/web/web.adapter.ts @@ -104,6 +104,25 @@ export function createWebAdapter(config: ChannelAdapterConfig): WebAdapterExtend 'Sending message to user', ); + // Tool-approval prompt/notice/resolved frames carry their own structured + // payload (see ChannelManagerService.deliverApprovalFrame) and render as + // a dedicated approval.* frame instead of a chat message.create — the + // `text` fallback exists only for adapters without rich rendering. + if ( + event === 'approval.request' || + event === 'approval.notice' || + event === 'approval.resolved' + ) { + sendToUser( + message.recipientId, + serializeServerMessage({ + type: event, + payload: message.metadata?.['approval'], + }), + ); + return messageId === '' ? undefined : messageId; + } + const payload = serializeServerMessage({ type: 'message.create', payload: { diff --git a/packages/api/src/channels/web/web.protocol.ts b/packages/api/src/channels/web/web.protocol.ts index 11e3f7d..9fcbfbf 100644 --- a/packages/api/src/channels/web/web.protocol.ts +++ b/packages/api/src/channels/web/web.protocol.ts @@ -57,6 +57,14 @@ export type ServerMessage = | { readonly type: 'error'; readonly payload: { readonly code: string; readonly message: string }; + } + | { + // Tool-approval prompt/notice/resolved frames (spec §7). `payload` is + // the `ApprovalFramePayload` published by `ApprovalDeliveryService` and + // routed verbatim by `ChannelManagerService` — kept as `unknown` here + // to avoid a channels→approvals module coupling in the wire protocol. + readonly type: 'approval.request' | 'approval.notice' | 'approval.resolved'; + readonly payload: unknown; }; export function serializeServerMessage(msg: ServerMessage): string { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3be4f0f..cee92ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,9 @@ importers: '@google/genai': specifier: ^1.50.1 version: 1.50.1(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)) + '@grammyjs/runner': + specifier: ^2.0.3 + version: 2.0.3(grammy@1.41.1) '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0(zod@3.25.76) @@ -915,6 +918,12 @@ packages: '@modelcontextprotocol/sdk': optional: true + '@grammyjs/runner@2.0.3': + resolution: {integrity: sha512-nckmTs1dPWfVQteK9cxqxzE+0m1VRvluLWB8UgFzsjg62w3qthPJt0TYtJBEdG7OedvfQq4vnFAyE6iaMkR42A==} + engines: {node: '>=12.20.0 || >=14.13.1'} + peerDependencies: + grammy: ^1.13.1 + '@grammyjs/types@3.25.0': resolution: {integrity: sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg==} @@ -7269,6 +7278,11 @@ snapshots: - supports-color - utf-8-validate + '@grammyjs/runner@2.0.3(grammy@1.41.1)': + dependencies: + abort-controller: 3.0.0 + grammy: 1.41.1 + '@grammyjs/types@3.25.0': {} '@hapi/boom@9.1.4': From 18c2aac4a84ffb62b17a931d3b07642a1cc7b393 Mon Sep 17 00:00:00 2001 From: jasonli0226 Date: Mon, 20 Jul 2026 01:05:28 +0800 Subject: [PATCH 07/10] feat(approvals): web dashboard approval surfaces Delivers approvals across the dashboard UI: - In-chat approval card with honest offered-scope buttons, wired through use-chat frame handling and the conversations page. - Dashboard-wide pending-approval banner (counts only still-pending) and inline resolve from the notification bell. - Standing-approvals management page. - Approval frame parsing/labels helpers, reduced-motion support, and the notifications API client. --- .../__tests__/approval-card.test.tsx | 135 ++++++++++++ .../conversations/approval-card.tsx | 192 ++++++++++++++++++ .../app/(dashboard)/conversations/page.tsx | 15 ++ .../app/(dashboard)/conversations/use-chat.ts | 62 +++++- packages/web/src/app/(dashboard)/layout.tsx | 2 + .../app/(dashboard)/tool-approvals/page.tsx | 151 ++++++++++++++ .../__tests__/notification-bell.test.tsx | 114 +++++++++++ .../pending-approval-banner.test.tsx | 72 +++++++ .../src/components/dashboard/app-sidebar.tsx | 14 ++ .../dashboard/notification-bell.tsx | 121 +++++++++++ .../dashboard/pending-approval-banner.tsx | 73 +++++++ .../dashboard/unread-chat-provider.tsx | 67 +++++- .../src/lib/__tests__/approval-frames.test.ts | 172 ++++++++++++++++ packages/web/src/lib/api/notifications.ts | 12 +- packages/web/src/lib/approval-frames.ts | 144 +++++++++++++ packages/web/src/lib/approval-labels.ts | 30 +++ .../web/src/lib/prefers-reduced-motion.ts | 10 + packages/web/src/lib/tool-approvals.ts | 26 +++ 18 files changed, 1402 insertions(+), 10 deletions(-) create mode 100644 packages/web/src/app/(dashboard)/conversations/__tests__/approval-card.test.tsx create mode 100644 packages/web/src/app/(dashboard)/conversations/approval-card.tsx create mode 100644 packages/web/src/app/(dashboard)/tool-approvals/page.tsx create mode 100644 packages/web/src/components/dashboard/__tests__/notification-bell.test.tsx create mode 100644 packages/web/src/components/dashboard/__tests__/pending-approval-banner.test.tsx create mode 100644 packages/web/src/components/dashboard/pending-approval-banner.tsx create mode 100644 packages/web/src/lib/__tests__/approval-frames.test.ts create mode 100644 packages/web/src/lib/approval-frames.ts create mode 100644 packages/web/src/lib/approval-labels.ts create mode 100644 packages/web/src/lib/prefers-reduced-motion.ts create mode 100644 packages/web/src/lib/tool-approvals.ts diff --git a/packages/web/src/app/(dashboard)/conversations/__tests__/approval-card.test.tsx b/packages/web/src/app/(dashboard)/conversations/__tests__/approval-card.test.tsx new file mode 100644 index 0000000..ae685d2 --- /dev/null +++ b/packages/web/src/app/(dashboard)/conversations/__tests__/approval-card.test.tsx @@ -0,0 +1,135 @@ +import { createElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ApprovalCard } from '../approval-card'; +import type { ApprovalFrame } from '@/lib/approval-frames'; + +const authFetchMock = vi.fn(); + +vi.mock('@/lib/auth', () => ({ + authFetch: (...args: unknown[]) => authFetchMock(...args), +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const basePrompt: ApprovalFrame = { + approvalId: 'apr1', + kind: 'prompt', + toolName: 'shell', + paramsSummary: { command: 'rm -rf /tmp/x' }, + offeredScopes: ['allow_once', 'deny_once'], + status: 'pending', +}; + +describe('ApprovalCard — honest-buttons rule', () => { + it('renders exactly one button per offeredScopes entry, never a hardcoded six', () => { + render(createElement(ApprovalCard, { frame: basePrompt, onResolved: vi.fn() })); + expect(screen.getByRole('button', { name: 'Allow once' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Deny' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Always allow' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Always deny' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Allow this session' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Deny this session' })).not.toBeInTheDocument(); + }); + + it('renders all six buttons when all six scopes are offered', () => { + const frame: ApprovalFrame = { + ...basePrompt, + offeredScopes: [ + 'allow_once', + 'allow_session', + 'allow_always', + 'deny_once', + 'deny_session', + 'deny_always', + ], + }; + render(createElement(ApprovalCard, { frame, onResolved: vi.fn() })); + for (const label of [ + 'Allow once', + 'Allow this session', + 'Always allow', + 'Deny', + 'Deny this session', + 'Always deny', + ]) { + expect(screen.getByRole('button', { name: label })).toBeInTheDocument(); + } + }); + + it('renders the params summary as key/value pairs', () => { + render(createElement(ApprovalCard, { frame: basePrompt, onResolved: vi.fn() })); + expect(screen.getByText('command')).toBeInTheDocument(); + expect(screen.getByText('rm -rf /tmp/x')).toBeInTheDocument(); + }); +}); + +describe('ApprovalCard — resolve action', () => { + it('POSTs the chosen scope and reports a synthesized resolved frame', async () => { + authFetchMock.mockResolvedValue({ success: true, data: { downgraded: false } }); + const onResolved = vi.fn(); + render(createElement(ApprovalCard, { frame: basePrompt, onResolved })); + + fireEvent.click(screen.getByRole('button', { name: 'Allow once' })); + + await waitFor(() => { + expect(onResolved).toHaveBeenCalledWith({ + kind: 'resolved', + approvalId: 'apr1', + status: 'APPROVED', + scope: 'once', + }); + }); + expect(authFetchMock).toHaveBeenCalledWith( + '/api/v1/chat/approvals/apr1/resolve', + expect.objectContaining({ method: 'POST', body: JSON.stringify({ choice: 'allow_once' }) }), + ); + }); +}); + +describe('ApprovalCard — notice kind', () => { + it('shows the reason line and a single revoke button, no offer buttons', () => { + const notice: ApprovalFrame = { + approvalId: 'apr2', + kind: 'notice', + toolName: 'wiki_delete', + reason: 'standing_deny', + status: 'pending', + }; + render(createElement(ApprovalCard, { frame: notice, onResolved: vi.fn() })); + expect(screen.getByText(/standing "always deny" rule/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Revoke standing deny' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Allow once' })).not.toBeInTheDocument(); + }); +}); + +describe('ApprovalCard — resolved denial', () => { + it('keeps a scoped denial visible with an Undo action', () => { + const denied: ApprovalFrame = { + approvalId: 'apr3', + kind: 'prompt', + toolName: 'shell', + status: 'denied', + scope: 'session', + }; + render(createElement(ApprovalCard, { frame: denied, onResolved: vi.fn() })); + expect(screen.getByText('Denied')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument(); + }); + + it('does not offer Undo for a one-off (once-scoped) denial — nothing to revoke', () => { + const denied: ApprovalFrame = { + approvalId: 'apr4', + kind: 'prompt', + toolName: 'shell', + status: 'denied', + scope: 'once', + }; + render(createElement(ApprovalCard, { frame: denied, onResolved: vi.fn() })); + expect(screen.getByText('Denied')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Undo' })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/app/(dashboard)/conversations/approval-card.tsx b/packages/web/src/app/(dashboard)/conversations/approval-card.tsx new file mode 100644 index 0000000..3975d16 --- /dev/null +++ b/packages/web/src/app/(dashboard)/conversations/approval-card.tsx @@ -0,0 +1,192 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { authFetch } from '@/lib/auth'; +import { CHOICE_LABELS, type ApprovalChoice } from '@/lib/approval-labels'; +import { cn } from '@/lib/utils'; +import type { ApprovalFrame, IncomingApprovalFrame } from '@/lib/approval-frames'; +import { prefersReducedMotion } from '@/lib/prefers-reduced-motion'; + +/** Seconds remaining until `expiresAt`, ticking every second; `null` when absent. */ +function useCountdown(expiresAt: string | undefined): number | null { + const [secondsLeft, setSecondsLeft] = useState(null); + + useEffect(() => { + if (!expiresAt) { + setSecondsLeft(null); + return; + } + const target = new Date(expiresAt).getTime(); + const tick = () => { + setSecondsLeft(Math.max(0, Math.round((target - Date.now()) / 1000))); + }; + tick(); + const interval = setInterval(tick, 1000); + return () => { + clearInterval(interval); + }; + }, [expiresAt]); + + return secondsLeft; +} + +function formatCountdown(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return m > 0 ? `${m}m ${s}s` : `${s}s`; +} + +const REASON_TEXT: Record = { + standing_deny: 'Blocked automatically by a standing "always deny" rule for this tool.', + session_deny: 'Blocked automatically by a "deny this session" rule for this tool.', +}; + +const STATUS_BADGE: Record = { + denied: { label: 'Denied', variant: 'destructive' }, + expired: { label: 'Expired', variant: 'outline' }, + cancelled: { label: 'Cancelled', variant: 'outline' }, +}; + +interface ApprovalCardProps { + frame: ApprovalFrame; + /** Called with a synthetic `resolved` frame after a successful resolve/revoke + * call — the caller applies it through `applyApprovalFrame` so this + * component never mutates approval state directly. */ + onResolved: (frame: IncomingApprovalFrame) => void; +} + +/** + * One tool-approval card. Renders exactly one button per entry in + * `frame.offeredScopes` — never a hardcoded six (honest-buttons rule) — plus + * a countdown for pending prompts and an Undo/Revoke action for resolved + * denials and standing-deny notices. + */ +export function ApprovalCard({ frame, onResolved }: ApprovalCardProps) { + const [busy, setBusy] = useState(false); + const secondsLeft = useCountdown( + frame.kind === 'prompt' && frame.status === 'pending' ? frame.expiresAt : undefined, + ); + + async function handleChoice(choice: ApprovalChoice) { + if (busy) return; + setBusy(true); + try { + const res = await authFetch<{ success: true; data: { downgraded: boolean } }>( + `/api/v1/chat/approvals/${frame.approvalId}/resolve`, + { method: 'POST', body: JSON.stringify({ choice }) }, + ); + onResolved({ + kind: 'resolved', + approvalId: frame.approvalId, + status: choice.startsWith('allow') ? 'APPROVED' : 'DENIED', + scope: choice.endsWith('once') ? 'once' : choice.endsWith('session') ? 'session' : 'always', + }); + if (res.data.downgraded) { + toast.info('Your choice exceeded what was offered — downgraded to a narrower scope.'); + } + } catch (err) { + toast.error('Failed to resolve approval', { + description: err instanceof Error ? err.message : 'Please try again.', + }); + } finally { + setBusy(false); + } + } + + async function handleRevoke() { + if (busy) return; + setBusy(true); + try { + await authFetch<{ success: true }>(`/api/v1/chat/approvals/${frame.approvalId}/revoke`, { + method: 'POST', + }); + onResolved({ kind: 'resolved', approvalId: frame.approvalId, status: 'REVOKED' }); + } catch (err) { + toast.error('Failed to revoke', { + description: err instanceof Error ? err.message : 'Please try again.', + }); + } finally { + setBusy(false); + } + } + + const paramsEntries = frame.paramsSummary ? Object.entries(frame.paramsSummary) : []; + const urgent = secondsLeft !== null && secondsLeft <= 10; + const pulse = urgent && !prefersReducedMotion(); + const resolvedBadge = frame.status !== 'pending' ? STATUS_BADGE[frame.status] : undefined; + // Only a scoped (session/always) denial stamps a memoryStore/memoryKey the + // server can revoke — a one-off `deny_once` leaves nothing to undo. + const canUndo = frame.status === 'denied' && frame.scope !== undefined && frame.scope !== 'once'; + + return ( + + + + + Tool approval: {frame.toolName ?? 'unknown'} + + {resolvedBadge && {resolvedBadge.label}} + + + + {frame.kind === 'notice' && frame.reason && ( +

{REASON_TEXT[frame.reason] ?? frame.reason}

+ )} + + {paramsEntries.length > 0 && ( +
+ {paramsEntries.map(([key, value]) => ( +
+
{key}
+
{value}
+
+ ))} +
+ )} + + {frame.kind === 'prompt' && frame.status === 'pending' && secondsLeft !== null && ( +

+ Expires in {formatCountdown(secondsLeft)} +

+ )} + + {frame.kind === 'prompt' && frame.status === 'pending' && ( +
+ {(frame.offeredScopes ?? []).map((choice) => ( + + ))} +
+ )} + + {frame.kind === 'notice' && ( +
+ +
+ )} + + {canUndo && ( +
+ +
+ )} +
+
+ ); +} diff --git a/packages/web/src/app/(dashboard)/conversations/page.tsx b/packages/web/src/app/(dashboard)/conversations/page.tsx index dfd83ba..88bb80b 100644 --- a/packages/web/src/app/(dashboard)/conversations/page.tsx +++ b/packages/web/src/app/(dashboard)/conversations/page.tsx @@ -8,6 +8,7 @@ import { useChat } from './use-chat'; import { ChatThread } from './chat-thread'; import { ChatInput, EmptyState } from './chat-input'; import { SessionSidebar } from './session-sidebar'; +import { ApprovalCard } from './approval-card'; const SIDEBAR_STORAGE_KEY = 'conversations-sidebar-open'; @@ -57,6 +58,8 @@ export default function ConversationsPage() { loadMoreSessions, refreshSessions, toolProgressMode, + pendingApprovals, + applyResolvedApproval, } = useChat(); // Reset banner dismissal whenever a fresh error string arrives so it re-displays. @@ -157,6 +160,18 @@ export default function ConversationsPage() { {isArchived && (Archived)} + {pendingApprovals.length > 0 && ( +
+ {pendingApprovals.map((frame) => ( + + ))} +
+ )} + {error && !errorDismissed && (
} | { type: 'pong'; payload: Record } | { type: 'session.reset'; payload: { sessionId: string } } - | { type: 'error'; payload: { code: string; message: string } }; + | { type: 'error'; payload: { code: string; message: string } } + // Tool-approval prompt/notice/resolved frames (spec §7, Task 9). `payload` + // is the `ApprovalFramePayload` published by `ApprovalDeliveryService` and + // routed verbatim by `ChannelManagerService` — its own `kind` field (not + // this outer `type`) drives reducer semantics; see `applyApprovalFrame`. + | { + type: 'approval.request' | 'approval.notice' | 'approval.resolved'; + payload: IncomingApprovalFrame; + }; /* ------------------------------------------------------------------ */ /* Hook */ @@ -134,6 +154,7 @@ export function useChat() { const [webChannelId, setWebChannelId] = useState(null); const [channelResolved, setChannelResolved] = useState(false); const [toolProgressMode, setToolProgressMode] = useState('all'); + const [pendingApprovals, setPendingApprovals] = useState([]); /* ---- refs ---- */ const wsRef = useRef(null); @@ -434,6 +455,14 @@ export function useChat() { case 'pong': pongReceivedRef.current = true; break; + + case 'approval.request': + case 'approval.notice': + case 'approval.resolved': + // `parsed.payload.kind` (not the outer frame `type`) drives the + // reducer — it already mirrors 'prompt' | 'notice' | 'resolved'. + setPendingApprovals((prev) => applyApprovalFrame(prev, parsed.payload)); + break; } }; @@ -627,6 +656,35 @@ export function useChat() { void fetchSessionsRef.current?.(); }, []); + /* ---- approval cards: apply a resolved frame from a card's own action ---- */ + // `ApprovalCard` resolves/revokes over REST directly, then reports the + // outcome here so the same pure reducer used for WS frames stays the single + // source of truth for card state (the WS `approval.resolved` frame that + // follows is idempotent against this). + const applyResolvedApproval = useCallback((frame: IncomingApprovalFrame) => { + setPendingApprovals((prev) => applyApprovalFrame(prev, frame)); + }, []); + + /* ---- seed pending approvals on mount (reload recovery) ---- */ + useEffect(() => { + void authFetch('/api/v1/chat/approvals/pending') + .then((res) => { + const rows = Array.isArray(res.data) ? res.data : []; + // Merge, don't replace: the mount-seed GET and the WS connection are + // independent async paths. A live `approval.request`/`approval.resolved` + // frame may land before this response — a wholesale replace would drop + // that live update. Only append rows we don't already track. + setPendingApprovals((prev) => { + const known = new Set(prev.map((f) => f.approvalId)); + const seeded = rows.map(pendingRowToFrame).filter((f) => !known.has(f.approvalId)); + return seeded.length ? [...prev, ...seeded] : prev; + }); + }) + .catch(() => { + /* no approvals to recover, or not yet authenticated — safe to skip */ + }); + }, []); + /* ---- resolve web channel ID ---- */ useEffect(() => { void authFetch<{ @@ -761,5 +819,7 @@ export function useChat() { loadMoreSessions, refreshSessions: fetchSessions, toolProgressMode, + pendingApprovals, + applyResolvedApproval, }; } diff --git a/packages/web/src/app/(dashboard)/layout.tsx b/packages/web/src/app/(dashboard)/layout.tsx index f23538f..f335ddf 100644 --- a/packages/web/src/app/(dashboard)/layout.tsx +++ b/packages/web/src/app/(dashboard)/layout.tsx @@ -7,6 +7,7 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s import { AppSidebar } from '@/components/dashboard/app-sidebar'; import { NotificationBell } from '@/components/dashboard/notification-bell'; import { UnreadChatProvider } from '@/components/dashboard/unread-chat-provider'; +import { PendingApprovalBanner } from '@/components/dashboard/pending-approval-banner'; import { Toaster } from '@/components/ui/sonner'; import { EASING, DURATION } from '@/lib/anime'; @@ -54,6 +55,7 @@ export default function DashboardLayout({
+
diff --git a/packages/web/src/app/(dashboard)/tool-approvals/page.tsx b/packages/web/src/app/(dashboard)/tool-approvals/page.tsx new file mode 100644 index 0000000..eaa379a --- /dev/null +++ b/packages/web/src/app/(dashboard)/tool-approvals/page.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { Loader2, ShieldCheck } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { CHOICE_LABELS } from '@/lib/approval-labels'; +import { + listStandingApprovals, + revokeStandingApproval, + type UserToolApproval, +} from '@/lib/tool-approvals'; + +export default function ToolApprovalsPage() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [revoking, setRevoking] = useState>(new Set()); + + const load = useCallback(async () => { + setLoading(true); + try { + setRows(await listStandingApprovals()); + setError(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load standing approvals'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + async function revoke(id: string) { + setRevoking((s) => new Set(s).add(id)); + // Optimistic removal — restore the row on failure. + const prev = rows; + setRows((r) => r.filter((row) => row.id !== id)); + try { + await revokeStandingApproval(id); + toast.success('Standing decision revoked'); + } catch (err) { + setRows(prev); + toast.error(err instanceof Error ? err.message : 'Failed to revoke'); + } finally { + setRevoking((s) => { + const next = new Set(s); + next.delete(id); + return next; + }); + } + } + + return ( +
+
+
+
+

Tool Approvals

+ + standing + +
+

+ Your permanent “always allow” and “always deny” decisions for + sensitive tools. Revoke one to be asked again next time an agent uses it. +

+
+
+ + {error && ( + + {error} + + )} + + {loading ? ( +
+ Loading… +
+ ) : rows.length === 0 && !error ? ( + + + +

No standing tool decisions.

+
+
+ ) : ( + + + + + + Tool + Resource + Decision + Since + Action + + + + {rows.map((row) => ( + + {row.toolName} + + {row.resourceKey || '(any)'} + + + + {row.decision === 'denied' + ? CHOICE_LABELS.deny_always + : CHOICE_LABELS.allow_always} + + + + {new Date(row.createdAt).toLocaleDateString()} + + + + + + ))} + +
+
+
+ )} +
+ ); +} diff --git a/packages/web/src/components/dashboard/__tests__/notification-bell.test.tsx b/packages/web/src/components/dashboard/__tests__/notification-bell.test.tsx new file mode 100644 index 0000000..622563f --- /dev/null +++ b/packages/web/src/components/dashboard/__tests__/notification-bell.test.tsx @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { NotificationBell } from '../notification-bell'; +import { notificationsApi, type Notification } from '@/lib/api/notifications'; +import { authFetch } from '@/lib/auth'; + +vi.mock('@/lib/auth', () => ({ authFetch: vi.fn() })); +vi.mock('@/lib/api/groups', () => ({ groupsApi: {} })); +// The WS stream is exercised elsewhere; stub it so the bell renders from the +// REST list only (no real socket in jsdom). +vi.mock('../use-notifications-stream', () => ({ useNotificationsStream: () => undefined })); +vi.mock('@/lib/api/notifications', () => ({ + notificationsApi: { + list: vi.fn(), + markRead: vi.fn(async () => undefined), + markAllRead: vi.fn(async () => undefined), + }, +})); + +const listMock = vi.mocked(notificationsApi.list); +const markReadMock = vi.mocked(notificationsApi.markRead); +const authFetchMock = vi.mocked(authFetch); + +// Radix Popover needs these in jsdom. +beforeEach(() => { + window.matchMedia ??= ((q: string) => + ({ + matches: false, + media: q, + addEventListener: () => {}, + removeEventListener: () => {}, + }) as unknown as MediaQueryList) as typeof window.matchMedia; + Element.prototype.hasPointerCapture ??= () => false; + Element.prototype.setPointerCapture ??= () => {}; + Element.prototype.releasePointerCapture ??= () => {}; + Element.prototype.scrollIntoView ??= () => {}; +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function promptNotification(over: Partial = {}): Notification { + return { + id: 'n1', + type: 'TOOL_APPROVAL', + isRead: false, + createdAt: new Date('2026-07-14T00:00:00Z').toISOString(), + payload: { + approvalId: 'apr1', + toolName: 'wiki_delete', + offeredScopes: ['allow_once', 'deny_once'], + ...over, + }, + } as Notification; +} + +async function openBell(notification: Notification) { + listMock.mockResolvedValue({ items: [notification], unreadCount: 1 }); + render(); + await waitFor(() => expect(listMock).toHaveBeenCalled()); + fireEvent.click(screen.getByRole('button', { name: /notifications/i })); +} + +describe('NotificationBell — TOOL_APPROVAL branch', () => { + it('renders one button per offered scope, never a hardcoded six', async () => { + await openBell(promptNotification()); + + expect(await screen.findByRole('button', { name: 'Allow once' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Deny' })).toBeInTheDocument(); + for (const absent of [ + 'Always allow', + 'Always deny', + 'Allow this session', + 'Deny this session', + ]) { + expect(screen.queryByRole('button', { name: absent })).not.toBeInTheDocument(); + } + }); + + it('resolves via the chat approvals endpoint with the chosen scope, then marks read', async () => { + authFetchMock.mockResolvedValue({ success: true, data: { downgraded: false } } as never); + await openBell(promptNotification()); + + fireEvent.click(await screen.findByRole('button', { name: 'Allow once' })); + + await waitFor(() => + expect(authFetchMock).toHaveBeenCalledWith('/api/v1/chat/approvals/apr1/resolve', { + method: 'POST', + body: JSON.stringify({ choice: 'allow_once' }), + }), + ); + await waitFor(() => expect(markReadMock).toHaveBeenCalledWith('n1')); + }); + + it('renders a single Revoke button for a notice (no offered scopes) and calls revoke', async () => { + authFetchMock.mockResolvedValue({ success: true } as never); + await openBell(promptNotification({ offeredScopes: undefined, reason: 'standing_deny' })); + + const revoke = await screen.findByRole('button', { name: 'Revoke standing deny' }); + expect(screen.queryByRole('button', { name: 'Allow once' })).not.toBeInTheDocument(); + + fireEvent.click(revoke); + + await waitFor(() => + expect(authFetchMock).toHaveBeenCalledWith('/api/v1/chat/approvals/apr1/revoke', { + method: 'POST', + }), + ); + await waitFor(() => expect(markReadMock).toHaveBeenCalledWith('n1')); + }); +}); diff --git a/packages/web/src/components/dashboard/__tests__/pending-approval-banner.test.tsx b/packages/web/src/components/dashboard/__tests__/pending-approval-banner.test.tsx new file mode 100644 index 0000000..db7e467 --- /dev/null +++ b/packages/web/src/components/dashboard/__tests__/pending-approval-banner.test.tsx @@ -0,0 +1,72 @@ +import { createElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; + +import { PendingApprovalBanner } from '../pending-approval-banner'; +import type { ApprovalFrame } from '@/lib/approval-frames'; + +const pathnameMock = vi.fn<() => string>(); +const useUnreadChatMock = vi.fn<() => { pendingApprovals: ApprovalFrame[] }>(); + +vi.mock('next/navigation', () => ({ + usePathname: () => pathnameMock(), +})); + +vi.mock('../unread-chat-provider', () => ({ + useUnreadChat: () => useUnreadChatMock(), +})); + +// jsdom has no matchMedia; prefersReducedMotion() calls it on the render path. +window.matchMedia ??= ((query: string) => + ({ + matches: false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + }) as unknown as MediaQueryList) as typeof window.matchMedia; + +function setup(pendingApprovals: ApprovalFrame[], pathname = '/agents') { + pathnameMock.mockReturnValue(pathname); + useUnreadChatMock.mockReturnValue({ pendingApprovals }); + render(createElement(PendingApprovalBanner)); +} + +const pendingFrame: ApprovalFrame = { + approvalId: 'apr1', + kind: 'prompt', + toolName: 'shell', + status: 'pending', +}; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('PendingApprovalBanner', () => { + it('renders when at least one approval is still pending and off /conversations', () => { + setup([pendingFrame]); + expect(screen.getByText(/awaiting your approval/i)).toBeInTheDocument(); + }); + + it('does NOT render on /conversations even with a pending approval', () => { + setup([pendingFrame], '/conversations'); + expect(screen.queryByText(/awaiting your approval/i)).not.toBeInTheDocument(); + }); + + it('does NOT render when the only entries are resolved (denied/expired/cancelled)', () => { + // The shared reducer keeps denied/expired/cancelled entries in the list for + // the /conversations ApprovalCard's Undo affordance — the banner must NOT + // treat them as pending, or it sticks after the user resolves from the bell. + const denied: ApprovalFrame = { ...pendingFrame, approvalId: 'apr2', status: 'denied' }; + const expired: ApprovalFrame = { ...pendingFrame, approvalId: 'apr3', status: 'expired' }; + setup([denied, expired]); + expect(screen.queryByText(/awaiting your approval/i)).not.toBeInTheDocument(); + }); + + it('renders when a pending entry coexists with resolved ones', () => { + const denied: ApprovalFrame = { ...pendingFrame, approvalId: 'apr2', status: 'denied' }; + setup([denied, pendingFrame]); + expect(screen.getByText(/awaiting your approval/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/dashboard/app-sidebar.tsx b/packages/web/src/components/dashboard/app-sidebar.tsx index 8982759..bf35dc8 100644 --- a/packages/web/src/components/dashboard/app-sidebar.tsx +++ b/packages/web/src/components/dashboard/app-sidebar.tsx @@ -22,6 +22,7 @@ import { Radio, ScrollText, Settings2, + ShieldCheck, Sun, User, Users, @@ -263,6 +264,19 @@ export function AppSidebar() { + + + + + Tool Approvals + + + diff --git a/packages/web/src/components/dashboard/notification-bell.tsx b/packages/web/src/components/dashboard/notification-bell.tsx index da2895a..e4f5c8a 100644 --- a/packages/web/src/components/dashboard/notification-bell.tsx +++ b/packages/web/src/components/dashboard/notification-bell.tsx @@ -7,10 +7,19 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { toast } from 'sonner'; +import { authFetch } from '@/lib/auth'; import { groupsApi } from '@/lib/api/groups'; import { notificationsApi, type Notification } from '@/lib/api/notifications'; +import { CHOICE_LABELS, isApprovalChoice, type ApprovalChoice } from '@/lib/approval-labels'; import { useNotificationsStream } from './use-notifications-stream'; +// Mirrors the (non-exported) map in `(dashboard)/conversations/approval-card.tsx` — +// only the standing-deny reasons a TOOL_APPROVAL notice can carry. +const REASON_TEXT: Record = { + standing_deny: 'Blocked automatically by a standing "always deny" rule for this tool.', + session_deny: 'Blocked automatically by a "deny this session" rule for this tool.', +}; + const POLL_INTERVAL_MS = 30_000; export function NotificationBell() { @@ -55,6 +64,14 @@ export function NotificationBell() { return; } + if (n.type === 'TOOL_APPROVAL') { + // No toast here — the amber dashboard banner (pendingApprovals in + // unread-chat-provider.tsx, fed by the chat WS's approval.* frames) + // already surfaces live prompts. A second popup on top of the bell + // badge bump above would just be redundant noise. + return; + } + if (n.type === 'GROUP_INVITE_RESPONSE') { const who = n.payload.responderName ?? n.payload.responderEmail ?? 'A member'; const groupName = n.payload.groupName ?? 'your group'; @@ -141,6 +158,47 @@ export function NotificationBell() { } }; + const handleApprovalChoice = async (n: Notification, choice: ApprovalChoice) => { + const approvalId = n.payload.approvalId; + if (!approvalId) return; + setBusyId(n.id); + try { + const res = await authFetch<{ success: true; data: { downgraded: boolean } }>( + `/api/v1/chat/approvals/${encodeURIComponent(approvalId)}/resolve`, + { method: 'POST', body: JSON.stringify({ choice }) }, + ); + await notificationsApi.markRead(n.id); + dismiss(n.id); + if (res.data.downgraded) { + toast.info('Your choice exceeded what was offered — downgraded to a narrower scope.'); + } + await refresh(); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to resolve approval'); + } finally { + setBusyId(null); + } + }; + + const handleApprovalRevoke = async (n: Notification) => { + const approvalId = n.payload.approvalId; + if (!approvalId) return; + setBusyId(n.id); + try { + await authFetch<{ success: true }>( + `/api/v1/chat/approvals/${encodeURIComponent(approvalId)}/revoke`, + { method: 'POST' }, + ); + await notificationsApi.markRead(n.id); + dismiss(n.id); + await refresh(); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to revoke'); + } finally { + setBusyId(null); + } + }; + const handleMarkAll = async () => { setLoading(true); try { @@ -204,6 +262,8 @@ export function NotificationBell() { busy={busyId === n.id} onAccept={() => handleAccept(n)} onReject={() => handleReject(n)} + onApprovalChoice={(choice) => handleApprovalChoice(n, choice)} + onApprovalRevoke={() => handleApprovalRevoke(n)} /> )) )} @@ -218,12 +278,73 @@ function NotificationRow({ busy, onAccept, onReject, + onApprovalChoice, + onApprovalRevoke, }: { notification: Notification; busy: boolean; onAccept: () => void; onReject: () => void; + onApprovalChoice: (choice: ApprovalChoice) => void; + onApprovalRevoke: () => void; }) { + if (notification.type === 'TOOL_APPROVAL') { + const { toolName, paramsSummary, offeredScopes, reason } = notification.payload; + // Honest-buttons rule: only the choices the server actually offered — + // never a hardcoded six. + const choices = (offeredScopes ?? []).filter(isApprovalChoice); + const paramsEntries = paramsSummary ? Object.entries(paramsSummary) : []; + return ( +
+
+ Tool approval:{' '} + {toolName ?? 'unknown'} +
+ {paramsEntries.length > 0 && ( +
+ {paramsEntries.map(([key, value]) => ( +
+
{key}
+
{value}
+
+ ))} +
+ )} + {choices.length === 0 && reason && ( +
{REASON_TEXT[reason] ?? reason}
+ )} +
+ {new Date(notification.createdAt).toLocaleString()} +
+ {choices.length > 0 ? ( +
+ {choices.map((choice) => ( + + ))} +
+ ) : ( +
+ +
+ )} +
+ ); + } + if (notification.type === 'GROUP_INVITE') { const groupName = notification.payload.groupName ?? 'a group'; return ( diff --git a/packages/web/src/components/dashboard/pending-approval-banner.tsx b/packages/web/src/components/dashboard/pending-approval-banner.tsx new file mode 100644 index 0000000..619b162 --- /dev/null +++ b/packages/web/src/components/dashboard/pending-approval-banner.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { ShieldAlert } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { prefersReducedMotion } from '@/lib/prefers-reduced-motion'; +import { useUnreadChat } from './unread-chat-provider'; + +/** + * Dashboard-wide "an agent needs you" strip (spec §7 / Task 14). Renders only + * when there's at least one approval prompt pending AND the user isn't + * already on `/conversations` (where the real `ApprovalCard`s are visible) — + * mirrors the same on-page suppression `unread-chat-provider.tsx` uses for + * its own unread badge. + * + * `pendingApprovals` comes from `useUnreadChat()`, which parses the chat WS's + * `approval.request` / `approval.resolved` frames — this component owns no + * WS subscription of its own. + */ +export function PendingApprovalBanner() { + const pathname = usePathname(); + const { pendingApprovals } = useUnreadChat(); + const [now, setNow] = useState(() => Date.now()); + + // Only entries still awaiting a decision drive the banner. The shared + // reducer deliberately KEEPS denied/expired/cancelled entries in the list + // (the `/conversations` ApprovalCard shows their outcome + an Undo action), + // so a raw `length > 0` would keep the banner stuck — e.g. at "(0s left)" + // with a past `expiresAt` — after the user denies a prompt from the bell. + const pending = pendingApprovals.filter((f) => f.status === 'pending'); + const hasPending = pending.length > 0; + + useEffect(() => { + if (!hasPending) return; + const tick = () => setNow(Date.now()); + tick(); + const interval = setInterval(tick, 1000); + return () => clearInterval(interval); + }, [hasPending]); + + if (!hasPending) return null; + if (pathname.startsWith('/conversations')) return null; + + // Surface the soonest-expiring prompt's countdown — that's the one most in + // danger of auto-denial, so it's the most useful number to show. + const soonestExpiry = pending.reduce((min, frame) => { + if (!frame.expiresAt) return min; + const t = new Date(frame.expiresAt).getTime(); + return min === undefined || t < min ? t : min; + }, undefined); + const remaining = + soonestExpiry !== undefined ? Math.max(0, Math.round((soonestExpiry - now) / 1000)) : null; + + const pulse = !prefersReducedMotion(); + + return ( + + + + An agent is awaiting your approval{remaining !== null ? ` (${remaining}s left)` : ''} → + + + ); +} diff --git a/packages/web/src/components/dashboard/unread-chat-provider.tsx b/packages/web/src/components/dashboard/unread-chat-provider.tsx index 37522b2..cc92c1e 100644 --- a/packages/web/src/components/dashboard/unread-chat-provider.tsx +++ b/packages/web/src/components/dashboard/unread-chat-provider.tsx @@ -3,7 +3,14 @@ import { createContext, useContext, useEffect, useRef, useState } from 'react'; import { usePathname } from 'next/navigation'; -import { getAccessToken } from '@/lib/auth'; +import { authFetch, getAccessToken } from '@/lib/auth'; +import { + applyApprovalFrame, + pendingRowToFrame, + type ApprovalFrame, + type IncomingApprovalFrame, + type PendingApprovalRow, +} from '@/lib/approval-frames'; /** * Tracks incoming chat `message.create` frames that arrive while the user @@ -24,11 +31,18 @@ import { getAccessToken } from '@/lib/auth'; interface UnreadChatContextValue { readonly count: number; readonly clear: () => void; + /** Approval prompts awaiting the user's choice (spec §7) — fed by the same + * chat WS connection's `approval.request` / `approval.resolved` frames. + * Drives `pending-approval-banner.tsx`. `approval.notice` frames (standing- + * deny auto-resolutions) are intentionally NOT added here — there's + * nothing pending for the user to act on. */ + readonly pendingApprovals: readonly ApprovalFrame[]; } const UnreadChatContext = createContext({ count: 0, clear: () => undefined, + pendingApprovals: [], }); export function useUnreadChat(): UnreadChatContextValue { @@ -40,12 +54,13 @@ const RECONNECT_MAX_MS = 30_000; interface IncomingFrame { readonly type: string; - readonly payload?: { readonly messageId?: string }; + readonly payload?: unknown; } export function UnreadChatProvider({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const [count, setCount] = useState(0); + const [pendingApprovals, setPendingApprovals] = useState([]); const seenIds = useRef>(new Set()); const onChatPage = pathname.startsWith('/conversations'); @@ -69,6 +84,28 @@ export function UnreadChatProvider({ children }: { children: React.ReactNode }) let reconnectTimer: ReturnType | null = null; let backoff = RECONNECT_INITIAL_MS; + // Reconcile pendingApprovals against the server's authoritative pending + // set. Covers both a fresh page load and resuming this socket after it + // was closed while the user was on /conversations — during that window + // we couldn't see any approval.resolved frames, so a stale entry here + // would show the banner for an approval that's already been decided. + void authFetch<{ data: PendingApprovalRow[] }>('/api/v1/chat/approvals/pending') + .then((res) => { + if (stopped) return; + const rows = Array.isArray(res.data) ? res.data : []; + setPendingApprovals((prev) => { + const serverIds = new Set(rows.map((r) => r.id)); + const kept = prev.filter((f) => serverIds.has(f.approvalId)); + const keptIds = new Set(kept.map((f) => f.approvalId)); + const added = rows.filter((r) => !keptIds.has(r.id)).map(pendingRowToFrame); + return added.length ? [...kept, ...added] : kept; + }); + }) + .catch(() => { + // Not yet authenticated, or a transient network error — live WS + // frames (and the next reconnect's reconciliation) keep this in sync. + }); + const connect = async (): Promise => { if (stopped) return; const token = await getAccessToken(); @@ -85,12 +122,25 @@ export function UnreadChatProvider({ children }: { children: React.ReactNode }) ws.onmessage = (e) => { try { const msg = JSON.parse(e.data as string) as IncomingFrame; - if (msg.type !== 'message.create') return; - const id = msg.payload?.messageId; - if (!id) return; - if (seenIds.current.has(id)) return; - seenIds.current.add(id); - setCount((c) => c + 1); + + if (msg.type === 'message.create') { + const id = (msg.payload as { messageId?: string } | undefined)?.messageId; + if (!id) return; + if (seenIds.current.has(id)) return; + seenIds.current.add(id); + setCount((c) => c + 1); + return; + } + + // Tool-approval prompt/resolved frames (spec §7, Task 9) — same + // wire shape and reducer `use-chat.ts` uses for the in-chat + // approval cards. `approval.notice` (standing-deny auto-resolution) + // is intentionally NOT handled here: nothing is pending on it. + if (msg.type === 'approval.request' || msg.type === 'approval.resolved') { + setPendingApprovals((prev) => + applyApprovalFrame(prev, msg.payload as IncomingApprovalFrame), + ); + } } catch { // Malformed frames are ignored — use-chat handles real parse. } @@ -132,6 +182,7 @@ export function UnreadChatProvider({ children }: { children: React.ReactNode }) setCount(0); seenIds.current.clear(); }, + pendingApprovals, }} > {children} diff --git a/packages/web/src/lib/__tests__/approval-frames.test.ts b/packages/web/src/lib/__tests__/approval-frames.test.ts new file mode 100644 index 0000000..a5916f2 --- /dev/null +++ b/packages/web/src/lib/__tests__/approval-frames.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; +import { + applyApprovalFrame, + pendingRowToFrame, + type ApprovalFrame, + type IncomingApprovalFrame, +} from '../approval-frames'; + +const prompt: IncomingApprovalFrame = { + kind: 'prompt', + approvalId: 'apr1', + toolName: 'shell', + paramsSummary: { command: 'ls -la' }, + offeredScopes: [ + 'allow_once', + 'allow_session', + 'allow_always', + 'deny_once', + 'deny_session', + 'deny_always', + ], + expiresAt: '2026-01-01T00:05:00.000Z', +}; + +describe('applyApprovalFrame', () => { + it('appends a new prompt card', () => { + const result = applyApprovalFrame([], prompt); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + approvalId: 'apr1', + kind: 'prompt', + toolName: 'shell', + status: 'pending', + offeredScopes: prompt.offeredScopes, + }); + }); + + it('appends a new notice card', () => { + const notice: IncomingApprovalFrame = { + kind: 'notice', + approvalId: 'apr2', + toolName: 'wiki_delete', + paramsSummary: { id: 'w1' }, + reason: 'standing_deny', + }; + const result = applyApprovalFrame([], notice); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + approvalId: 'apr2', + kind: 'notice', + reason: 'standing_deny', + status: 'pending', + }); + }); + + it('replaces the existing card in place on a duplicate approvalId (prompt/notice)', () => { + const first = applyApprovalFrame([], prompt); + const redelivered: IncomingApprovalFrame = { + ...prompt, + paramsSummary: { command: 'ls -la /tmp' }, + }; + const result = applyApprovalFrame(first, redelivered); + expect(result).toHaveLength(1); + expect(result[0]?.paramsSummary).toEqual({ command: 'ls -la /tmp' }); + }); + + it('filters unknown offeredScopes values (defensive against a malformed frame)', () => { + const malformed: IncomingApprovalFrame = { + ...prompt, + approvalId: 'apr9', + offeredScopes: ['allow_once', 'bogus_scope'], + }; + const result = applyApprovalFrame([], malformed); + expect(result[0]?.offeredScopes).toEqual(['allow_once']); + }); + + it('resolved APPROVED removes the card', () => { + const withPrompt = applyApprovalFrame([], prompt); + const resolved: IncomingApprovalFrame = { + kind: 'resolved', + approvalId: 'apr1', + status: 'APPROVED', + scope: 'once', + }; + const result = applyApprovalFrame(withPrompt, resolved); + expect(result).toEqual([]); + }); + + it('resolved DENIED keeps the card and flips status + scope', () => { + const withPrompt = applyApprovalFrame([], prompt); + const resolved: IncomingApprovalFrame = { + kind: 'resolved', + approvalId: 'apr1', + status: 'DENIED', + scope: 'session', + }; + const result = applyApprovalFrame(withPrompt, resolved); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ approvalId: 'apr1', status: 'denied', scope: 'session' }); + }); + + it('resolved EXPIRED and CANCELLED also keep the card', () => { + const withPrompt = applyApprovalFrame([], prompt); + const expired = applyApprovalFrame(withPrompt, { + kind: 'resolved', + approvalId: 'apr1', + status: 'EXPIRED', + }); + expect(expired[0]?.status).toBe('expired'); + + const cancelled = applyApprovalFrame(withPrompt, { + kind: 'resolved', + approvalId: 'apr1', + status: 'CANCELLED', + }); + expect(cancelled[0]?.status).toBe('cancelled'); + }); + + it('resolved REVOKED (client-synthesized, after a successful /revoke call) removes the card', () => { + const denied: ApprovalFrame[] = [ + { approvalId: 'apr1', kind: 'prompt', status: 'denied', scope: 'session' }, + ]; + const result = applyApprovalFrame(denied, { + kind: 'resolved', + approvalId: 'apr1', + status: 'REVOKED', + }); + expect(result).toEqual([]); + }); + + it('resolved for an unknown approvalId is a no-op', () => { + const withPrompt = applyApprovalFrame([], prompt); + const result = applyApprovalFrame(withPrompt, { + kind: 'resolved', + approvalId: 'does-not-exist', + status: 'APPROVED', + }); + expect(result).toBe(withPrompt); + }); + + it('resolved for an unknown approvalId on an empty list is a no-op', () => { + const result = applyApprovalFrame([], { + kind: 'resolved', + approvalId: 'does-not-exist', + status: 'DENIED', + }); + expect(result).toEqual([]); + }); +}); + +describe('pendingRowToFrame', () => { + it('maps a raw ApprovalRequest row to a pending prompt frame with the safe-minimum offered scopes', () => { + const frame = pendingRowToFrame({ + id: 'apr5', + toolName: 'shell', + paramsSummary: { command: 'echo hi' }, + }); + expect(frame).toEqual({ + approvalId: 'apr5', + kind: 'prompt', + toolName: 'shell', + paramsSummary: { command: 'echo hi' }, + offeredScopes: ['allow_once', 'deny_once'], + status: 'pending', + }); + }); + + it('drops a non-string-record paramsSummary rather than rendering garbage', () => { + const frame = pendingRowToFrame({ id: 'apr6', toolName: 'shell', paramsSummary: { n: 42 } }); + expect(frame.paramsSummary).toBeUndefined(); + }); +}); diff --git a/packages/web/src/lib/api/notifications.ts b/packages/web/src/lib/api/notifications.ts index f703984..a6b0997 100644 --- a/packages/web/src/lib/api/notifications.ts +++ b/packages/web/src/lib/api/notifications.ts @@ -5,7 +5,8 @@ export type NotificationType = | 'MEMORY_REVOKED' | 'GROUP_INVITE' | 'GROUP_INVITE_RESPONSE' - | 'PRIMARY_AGENT_ASSIGNED'; + | 'PRIMARY_AGENT_ASSIGNED' + | 'TOOL_APPROVAL'; export interface NotificationPayload { inviteId?: string; @@ -22,6 +23,15 @@ export interface NotificationPayload { responderId?: string; responderName?: string | null; responderEmail?: string | null; + // TOOL_APPROVAL (raised by ApprovalDeliveryService.deliverPrompt/deliverNotice — + // packages/api/src/approvals/approval-delivery.service.ts. `offeredScopes` and + // `expiresAt` are only present on prompt deliveries, not standing-deny notices. + approvalId?: string; + toolName?: string; + paramsSummary?: Record; + offeredScopes?: string[]; + reason?: string; + expiresAt?: string; } export interface Notification { diff --git a/packages/web/src/lib/approval-frames.ts b/packages/web/src/lib/approval-frames.ts new file mode 100644 index 0000000..a1d54d1 --- /dev/null +++ b/packages/web/src/lib/approval-frames.ts @@ -0,0 +1,144 @@ +import { isApprovalChoice, type ApprovalChoice } from '@/lib/approval-labels'; + +/** + * Wire shape for the chat WebSocket's `approval.request` | `approval.notice` + * | `approval.resolved` frames — mirrors `ApprovalFramePayload` from + * `packages/api/src/approvals/approval-delivery.service.ts`. `kind` (not the + * outer WS `type` string) drives reducer semantics: `'prompt'` and + * `'notice'` upsert a card, `'resolved'` mutates one. + */ +export interface IncomingApprovalFrame { + readonly kind: 'prompt' | 'notice' | 'resolved'; + readonly approvalId: string; + readonly toolName?: string; + readonly paramsSummary?: Record; + readonly offeredScopes?: readonly string[]; + readonly reason?: string; + readonly expiresAt?: string; // ISO + readonly status?: string; // resolved only: APPROVED | DENIED | EXPIRED | CANCELLED | REVOKED (client-synthesized) + readonly scope?: string; // resolved only: once | session | always +} + +export type ApprovalFrameStatus = 'pending' | 'approved' | 'denied' | 'expired' | 'cancelled'; + +/** Client-side approval card state — one entry per `approvalId`. */ +export interface ApprovalFrame { + readonly approvalId: string; + readonly kind: 'prompt' | 'notice'; + readonly toolName?: string; + readonly paramsSummary?: Record; + readonly offeredScopes?: readonly ApprovalChoice[]; + readonly reason?: string; + readonly expiresAt?: string; + readonly status: ApprovalFrameStatus; + readonly scope?: string; +} + +/** Raw shape returned by `GET /api/v1/chat/approvals/pending` (the Prisma + * `ApprovalRequest` row, Task 11) — only the fields this module reads. */ +export interface PendingApprovalRow { + readonly id: string; + readonly toolName: string; + readonly paramsSummary: unknown; +} + +function toStatus(raw: string | undefined): ApprovalFrameStatus { + switch (raw) { + case 'APPROVED': + return 'approved'; + case 'EXPIRED': + return 'expired'; + case 'CANCELLED': + return 'cancelled'; + case 'DENIED': + return 'denied'; + default: + // Unexpected/missing status on a resolved frame — treat conservatively + // as a denial rather than silently re-arming the offer buttons. + return 'denied'; + } +} + +/** A resolved frame's status that means "this card is done, drop it." */ +function isRemovalStatus(raw: string | undefined): boolean { + return raw === 'APPROVED' || raw === 'REVOKED'; +} + +function toOfferedScopes( + raw: readonly string[] | undefined, +): readonly ApprovalChoice[] | undefined { + if (!raw) return undefined; + return raw.filter(isApprovalChoice); +} + +/** + * Pure reducer merging one incoming WS frame into the current approval-card + * list (spec §7 / Task 13): + * - `prompt` | `notice`: appends a new card, or replaces the existing one in + * place if `approvalId` already exists (duplicate delivery, e.g. reconnect + * replay). + * - `resolved`: mutates the matching card. `APPROVED` (and a client-side + * `REVOKED`, used after a successful `/revoke` call) removes the card; + * any other status (`DENIED`, `EXPIRED`, `CANCELLED`) keeps it, flipping + * `status` so the card can show the outcome (and, for a scoped deny, an + * Undo action). An unknown `approvalId` is a no-op — nothing to resolve. + */ +export function applyApprovalFrame( + list: ApprovalFrame[], + frame: IncomingApprovalFrame, +): ApprovalFrame[] { + if (frame.kind === 'resolved') { + const idx = list.findIndex((f) => f.approvalId === frame.approvalId); + if (idx === -1) return list; + if (isRemovalStatus(frame.status)) { + return list.filter((f) => f.approvalId !== frame.approvalId); + } + const status = toStatus(frame.status); + return list.map((f, i) => + i === idx + ? { ...f, status, ...(frame.scope !== undefined ? { scope: frame.scope } : {}) } + : f, + ); + } + + const next: ApprovalFrame = { + approvalId: frame.approvalId, + kind: frame.kind, + toolName: frame.toolName, + paramsSummary: frame.paramsSummary, + offeredScopes: toOfferedScopes(frame.offeredScopes), + reason: frame.reason, + expiresAt: frame.expiresAt, + status: 'pending', + }; + const idx = list.findIndex((f) => f.approvalId === frame.approvalId); + if (idx === -1) return [...list, next]; + return list.map((f, i) => (i === idx ? next : f)); +} + +function isRecordOfStrings(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + return Object.values(value).every((v) => typeof v === 'string'); +} + +/** + * Converts a raw `GET /approvals/pending` row (reload recovery) into an + * `ApprovalFrame`. That endpoint returns the bare `ApprovalRequest` row, + * which — unlike the live WS prompt frame — carries no `offeredScopes` or + * `expiresAt` (those are computed only at prompt-delivery time, Task 9/11). + * We default to `['allow_once', 'deny_once']`: per `offeredScopes()` in + * `packages/api/src/approvals/approval.types.ts`, those two choices are + * always in the offered set regardless of scope kind, so this is never a + * false affordance — just a conservative one until a fresh frame (if any) + * supplies the richer set. + */ +export function pendingRowToFrame(row: PendingApprovalRow): ApprovalFrame { + return { + approvalId: row.id, + kind: 'prompt', + toolName: row.toolName, + paramsSummary: isRecordOfStrings(row.paramsSummary) ? row.paramsSummary : undefined, + offeredScopes: ['allow_once', 'deny_once'], + status: 'pending', + }; +} diff --git a/packages/web/src/lib/approval-labels.ts b/packages/web/src/lib/approval-labels.ts new file mode 100644 index 0000000..9236dfe --- /dev/null +++ b/packages/web/src/lib/approval-labels.ts @@ -0,0 +1,30 @@ +/** + * Canonical labels for the six `ApprovalChoice` values used by the tool-call + * approval flow. Mirrors `ApprovalChoice` in + * `packages/api/src/approvals/approval.types.ts` (spec §4) — the web package + * can't import server-side types directly, so this union is a hand-kept + * mirror. Exported from this shared module so the chat approval card and the + * settings-page standing-approvals list both consume the same map instead of + * duplicating labels. + */ +export type ApprovalChoice = + | 'allow_once' + | 'allow_session' + | 'allow_always' + | 'deny_once' + | 'deny_session' + | 'deny_always'; + +export const CHOICE_LABELS: Record = { + allow_once: 'Allow once', + allow_session: 'Allow this session', + allow_always: 'Always allow', + deny_once: 'Deny', + deny_session: 'Deny this session', + deny_always: 'Always deny', +}; + +/** Narrows an arbitrary string (e.g. from a WS frame) to a known `ApprovalChoice`. */ +export function isApprovalChoice(value: string): value is ApprovalChoice { + return Object.prototype.hasOwnProperty.call(CHOICE_LABELS, value); +} diff --git a/packages/web/src/lib/prefers-reduced-motion.ts b/packages/web/src/lib/prefers-reduced-motion.ts new file mode 100644 index 0000000..3d4b8b1 --- /dev/null +++ b/packages/web/src/lib/prefers-reduced-motion.ts @@ -0,0 +1,10 @@ +/** + * Shared `prefers-reduced-motion` media-query check. Extracted from + * `approval-card.tsx` (Task 13) so the tool-approval bell/banner (Task 14) + * doesn't copy-paste the same guard — both gate their pulse/attention + * animations on this. + */ +export function prefersReducedMotion(): boolean { + if (typeof window === 'undefined') return false; + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} diff --git a/packages/web/src/lib/tool-approvals.ts b/packages/web/src/lib/tool-approvals.ts new file mode 100644 index 0000000..e5ea789 --- /dev/null +++ b/packages/web/src/lib/tool-approvals.ts @@ -0,0 +1,26 @@ +import { authFetch } from '@/lib/auth'; + +/** A user's standing (`allow_always` / `deny_always`) tool decision — the + * Prisma `UserToolApproval` row (Task 1/11). `resourceKey === ''` is the + * run-scope sentinel ("any resource"); a non-empty key scopes to one target. */ +export interface UserToolApproval { + id: string; + toolName: string; + resourceKey: string; + decision: 'allowed' | 'denied'; + createdAt: string; +} + +interface ListResponse { + success: boolean; + data: UserToolApproval[]; +} + +export async function listStandingApprovals(): Promise { + const res = await authFetch('/api/v1/tool-approvals'); + return Array.isArray(res.data) ? res.data : []; +} + +export async function revokeStandingApproval(id: string): Promise { + await authFetch(`/api/v1/tool-approvals/${id}`, { method: 'DELETE' }); +} From b1ad65b43d9700e122ce711d3aba231d4af0b5e3 Mon Sep 17 00:00:00 2001 From: jasonli0226 Date: Mon, 20 Jul 2026 01:05:40 +0800 Subject: [PATCH 08/10] feat(approvals): clear session approvals on reset; gate seed reset - Session reset revokes pending approvals and clears per-session approval memory; ApprovalsModule wired into the app module. Reset-command and e2e coverage. - Make the destructive seed reset opt-in via SEED_RESET so a normal seed no longer wipes existing data. --- packages/api/prisma/seed.ts | 379 ++++++++++-------- packages/api/src/__tests__/app.e2e.test.ts | 26 ++ packages/api/src/app.module.ts | 2 + .../commands/__tests__/reset.command.test.ts | 25 +- packages/api/src/commands/command.module.ts | 3 +- packages/api/src/commands/reset.command.ts | 5 + 6 files changed, 277 insertions(+), 163 deletions(-) diff --git a/packages/api/prisma/seed.ts b/packages/api/prisma/seed.ts index 973a484..f98e201 100644 --- a/packages/api/prisma/seed.ts +++ b/packages/api/prisma/seed.ts @@ -2,6 +2,14 @@ * Prisma seed script — populates the database with development data. * * Run: pnpm exec prisma db seed + * Upsert-only: reconciles system settings, policies, users, and provider + * configs. Safe to re-run — this is how you pick up new Policy columns after + * a migration. + * + * Run: SEED_RESET=true pnpm exec prisma db seed + * Additionally wipes ALL channels, sessions, agent definitions, groups, and + * audit logs (not just seeded rows) and recreates the dev fixtures. Fresh + * databases only — never against a deployment you care about. */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -52,15 +60,31 @@ async function main(): Promise { } const passwordHash = await bcrypt.hash(defaultPassword, 12); - // --- Clean previous seed data (allows safe re-seeding) --- - // Delete in reverse dependency order; ON DELETE CASCADE handles children. - console.log(' Cleaning previous seed data...'); - await prisma.auditLog.deleteMany({}); - await prisma.group.deleteMany({}); - await prisma.session.deleteMany({}); - await prisma.userAgent.deleteMany({}); - await prisma.channel.deleteMany({}); - await prisma.agentDefinition.deleteMany({}); + // --- Clean previous seed data (opt-in via SEED_RESET) --- + // The deletes below are NOT limited to seeded rows — they wipe every channel, + // session, agent definition, group, and the entire audit trail, including + // whatever a live deployment created. That is fine on a fresh dev database and + // destructive anywhere else, so a reset is opt-in rather than the default. + // + // Without it this script runs in upsert-only mode: system settings, policies, + // users, and provider configs are reconciled, and the sections that `create` + // rows unconditionally (agents, channels, group, audit entry) are skipped so + // re-running cannot duplicate them. That covers the usual reason to re-run — + // picking up new Policy columns after a migration — without collateral damage. + const reset = process.env['SEED_RESET'] === 'true'; + + if (reset) { + // Delete in reverse dependency order; ON DELETE CASCADE handles children. + console.log(' SEED_RESET=true — cleaning previous seed data...'); + await prisma.auditLog.deleteMany({}); + await prisma.group.deleteMany({}); + await prisma.session.deleteMany({}); + await prisma.userAgent.deleteMany({}); + await prisma.channel.deleteMany({}); + await prisma.agentDefinition.deleteMany({}); + } else { + console.log(' Upsert-only mode (set SEED_RESET=true to wipe and fully re-seed).'); + } // --- System Settings (singleton — org identity + config) --- const system = await prisma.systemSettings.upsert({ @@ -135,6 +159,11 @@ async function main(): Promise { maxPythonTimeoutSecs: 60, maxPythonCpuCores: 1, maxConcurrentPythonRuns: 2, + allowShellNet: false, + maxShellNetMemoryMb: 512, + maxShellNetTimeoutSecs: 60, + maxShellNetCpuCores: 1, + maxConcurrentShellNetRuns: 2, maxSubAgentRunMs: 300000, // 5 min }, create: { @@ -154,6 +183,11 @@ async function main(): Promise { maxPythonTimeoutSecs: 60, maxPythonCpuCores: 1, maxConcurrentPythonRuns: 2, + allowShellNet: false, + maxShellNetMemoryMb: 512, + maxShellNetTimeoutSecs: 60, + maxShellNetCpuCores: 1, + maxConcurrentShellNetRuns: 2, maxSubAgentRunMs: 300000, // 5 min allowMcp: false, }, @@ -170,6 +204,11 @@ async function main(): Promise { maxPythonTimeoutSecs: 300, maxPythonCpuCores: 2, maxConcurrentPythonRuns: 3, + allowShellNet: false, + maxShellNetMemoryMb: 2048, + maxShellNetTimeoutSecs: 300, + maxShellNetCpuCores: 2, + maxConcurrentShellNetRuns: 3, maxSubAgentRunMs: 480000, // 8 min }, create: { @@ -189,6 +228,11 @@ async function main(): Promise { maxPythonTimeoutSecs: 300, maxPythonCpuCores: 2, maxConcurrentPythonRuns: 3, + allowShellNet: false, + maxShellNetMemoryMb: 2048, + maxShellNetTimeoutSecs: 300, + maxShellNetCpuCores: 2, + maxConcurrentShellNetRuns: 3, maxSubAgentRunMs: 480000, // 8 min allowMcp: true, }, @@ -205,6 +249,11 @@ async function main(): Promise { maxPythonTimeoutSecs: 600, maxPythonCpuCores: 4, maxConcurrentPythonRuns: 5, + allowShellNet: true, + maxShellNetMemoryMb: 8192, + maxShellNetTimeoutSecs: 600, + maxShellNetCpuCores: 4, + maxConcurrentShellNetRuns: 5, maxSubAgentRunMs: 540000, // 9 min (kept under the 10-min stale-run reaper) }, create: { @@ -224,6 +273,11 @@ async function main(): Promise { maxPythonTimeoutSecs: 600, maxPythonCpuCores: 4, maxConcurrentPythonRuns: 5, + allowShellNet: true, + maxShellNetMemoryMb: 8192, + maxShellNetTimeoutSecs: 600, + maxShellNetCpuCores: 4, + maxConcurrentShellNetRuns: 5, maxSubAgentRunMs: 540000, // 9 min (kept under the 10-min stale-run reaper) allowMcp: true, }, @@ -274,114 +328,118 @@ async function main(): Promise { }); console.log(` User: ${viewer.name} (${viewer.role}, ${standardPolicy.name})`); - // --- Agent Definitions --- - const primaryAgent = await prisma.agentDefinition.create({ - data: { - name: 'Primary Assistant', - description: 'Default primary agent for users', - systemPrompt: 'You are a helpful AI assistant.', - role: 'primary', - provider: defaultProvider, - model: defaultModel, - maxTokensPerRun: 100000, - containerConfig: { - image: 'clawix-agent:latest', - cpuLimit: '1', - memoryLimit: '512m', - timeoutSeconds: 300, - readOnlyRootfs: true, - allowedMounts: [], + // --- Agent Definitions + User Agents (reset-only) --- + // Plain `create`s with no natural unique key to upsert on — they rely on the + // wipe above, so they only run as part of a reset. + if (reset) { + const primaryAgent = await prisma.agentDefinition.create({ + data: { + name: 'Primary Assistant', + description: 'Default primary agent for users', + systemPrompt: 'You are a helpful AI assistant.', + role: 'primary', + provider: defaultProvider, + model: defaultModel, + maxTokensPerRun: 100000, + containerConfig: { + image: 'clawix-agent:latest', + cpuLimit: '1', + memoryLimit: '512m', + timeoutSeconds: 300, + readOnlyRootfs: true, + allowedMounts: [], + }, + isActive: true, }, - isActive: true, - }, - }); - console.log(` Agent: ${primaryAgent.name} (primary, ${defaultProvider}/${defaultModel})`); - - const coderAgent = await prisma.agentDefinition.create({ - data: { - name: 'coder', - description: 'Writes, reviews, and tests code — optimized for code generation', - systemPrompt: - 'You are a skilled software engineer. Write clean, complete, functional code. Never use placeholders or TODO comments. Always verify your output is complete. Use the tools available to read, write, and execute code in the workspace.', - role: 'worker', - provider: defaultProvider, - model: defaultModel, - maxTokensPerRun: 100000, - containerConfig: { - image: 'clawix-agent:latest', - cpuLimit: '1', - memoryLimit: '512m', - timeoutSeconds: 300, - readOnlyRootfs: false, - allowedMounts: [], + }); + console.log(` Agent: ${primaryAgent.name} (primary, ${defaultProvider}/${defaultModel})`); + + const coderAgent = await prisma.agentDefinition.create({ + data: { + name: 'coder', + description: 'Writes, reviews, and tests code — optimized for code generation', + systemPrompt: + 'You are a skilled software engineer. Write clean, complete, functional code. Never use placeholders or TODO comments. Always verify your output is complete. Use the tools available to read, write, and execute code in the workspace.', + role: 'worker', + provider: defaultProvider, + model: defaultModel, + maxTokensPerRun: 100000, + containerConfig: { + image: 'clawix-agent:latest', + cpuLimit: '1', + memoryLimit: '512m', + timeoutSeconds: 300, + readOnlyRootfs: false, + allowedMounts: [], + }, + isActive: true, }, - isActive: true, - }, - }); - console.log(` Agent: ${coderAgent.name} (worker, ${defaultProvider}/${defaultModel})`); - - const researcherAgent = await prisma.agentDefinition.create({ - data: { - name: 'researcher', - description: 'Searches the web and summarizes findings', - systemPrompt: - 'You are a research specialist. Search the web for information, analyze sources, and provide clear, well-organized summaries with citations.', - role: 'worker', - provider: defaultProvider, - model: defaultModel, - maxTokensPerRun: 50000, - containerConfig: { - image: 'clawix-agent:latest', - cpuLimit: '0.5', - memoryLimit: '256m', - timeoutSeconds: 120, - readOnlyRootfs: true, - allowedMounts: [], + }); + console.log(` Agent: ${coderAgent.name} (worker, ${defaultProvider}/${defaultModel})`); + + const researcherAgent = await prisma.agentDefinition.create({ + data: { + name: 'researcher', + description: 'Searches the web and summarizes findings', + systemPrompt: + 'You are a research specialist. Search the web for information, analyze sources, and provide clear, well-organized summaries with citations.', + role: 'worker', + provider: defaultProvider, + model: defaultModel, + maxTokensPerRun: 50000, + containerConfig: { + image: 'clawix-agent:latest', + cpuLimit: '0.5', + memoryLimit: '256m', + timeoutSeconds: 120, + readOnlyRootfs: true, + allowedMounts: [], + }, + isActive: true, }, - isActive: true, - }, - }); - console.log(` Agent: ${researcherAgent.name} (worker, ${defaultProvider}/${defaultModel})`); - - const defaultWorker = await prisma.agentDefinition.create({ - data: { - name: 'default-worker', - description: 'Default worker agent for anonymous sub-agent tasks', - systemPrompt: 'Complete the assigned task thoroughly and report the result.', - role: 'worker', - provider: defaultProvider, - model: defaultModel, - maxTokensPerRun: 50000, - containerConfig: { - image: 'clawix-agent:latest', - cpuLimit: '0.5', - memoryLimit: '256m', - timeoutSeconds: 300, - readOnlyRootfs: false, - allowedMounts: [], + }); + console.log(` Agent: ${researcherAgent.name} (worker, ${defaultProvider}/${defaultModel})`); + + const defaultWorker = await prisma.agentDefinition.create({ + data: { + name: 'default-worker', + description: 'Default worker agent for anonymous sub-agent tasks', + systemPrompt: 'Complete the assigned task thoroughly and report the result.', + role: 'worker', + provider: defaultProvider, + model: defaultModel, + maxTokensPerRun: 50000, + containerConfig: { + image: 'clawix-agent:latest', + cpuLimit: '0.5', + memoryLimit: '256m', + timeoutSeconds: 300, + readOnlyRootfs: false, + allowedMounts: [], + }, + isActive: true, }, - isActive: true, - }, - }); - console.log(` Agent: ${defaultWorker.name} (worker, ${defaultProvider}/${defaultModel})`); - - // --- User Agents (bind users to primary agent) --- - await prisma.userAgent.create({ - data: { - userId: admin.id, - agentDefinitionId: primaryAgent.id, - workspacePath: `users/${admin.id}/workspace`, - }, - }); + }); + console.log(` Agent: ${defaultWorker.name} (worker, ${defaultProvider}/${defaultModel})`); - await prisma.userAgent.create({ - data: { - userId: developer.id, - agentDefinitionId: primaryAgent.id, - workspacePath: `users/${developer.id}/workspace`, - }, - }); - console.log(' UserAgents: admin + developer bound to Primary Assistant'); + // --- User Agents (bind users to primary agent) --- + await prisma.userAgent.create({ + data: { + userId: admin.id, + agentDefinitionId: primaryAgent.id, + workspacePath: `users/${admin.id}/workspace`, + }, + }); + + await prisma.userAgent.create({ + data: { + userId: developer.id, + agentDefinitionId: primaryAgent.id, + workspacePath: `users/${developer.id}/workspace`, + }, + }); + console.log(' UserAgents: admin + developer bound to Primary Assistant'); + } // Custom skill directories are created lazily inside each user's workspace // (/skills/) on first agent run — see agent-runner.service.ts. @@ -424,58 +482,63 @@ async function main(): Promise { ); } - // --- Channel --- - const webChannel = await prisma.channel.create({ - data: { - type: 'web', - name: 'Web Dashboard', - config: { enableProgress: true, enableToolHints: true }, - isActive: true, - }, - }); - console.log(` Channel: ${webChannel.name}`); - - if (process.env['TELEGRAM_BOT_TOKEN']) { - const telegramChannel = await prisma.channel.create({ + // --- Channels, group, audit entry (reset-only) --- + // Same reasoning as the agent definitions above: unconditional `create`s that + // depend on the wipe, so re-running without a reset would duplicate them. + if (reset) { + // --- Channel --- + const webChannel = await prisma.channel.create({ data: { - type: 'telegram', - name: 'Telegram Bot', - config: encryptChannelConfig('telegram', { - bot_token: process.env['TELEGRAM_BOT_TOKEN'], - }) as Record, + type: 'web', + name: 'Web Dashboard', + config: { enableProgress: true, enableToolHints: true }, isActive: true, }, }); - console.log(` Channel: ${telegramChannel.name}`); - } + console.log(` Channel: ${webChannel.name}`); + + if (process.env['TELEGRAM_BOT_TOKEN']) { + const telegramChannel = await prisma.channel.create({ + data: { + type: 'telegram', + name: 'Telegram Bot', + config: encryptChannelConfig('telegram', { + bot_token: process.env['TELEGRAM_BOT_TOKEN'], + }) as Record, + isActive: true, + }, + }); + console.log(` Channel: ${telegramChannel.name}`); + } + + // --- Group (memory sharing) --- + const engineeringGroup = await prisma.group.create({ + data: { + name: 'Engineering', + description: 'Engineering team memory sharing group', + createdById: admin.id, + members: { + create: [ + { userId: admin.id, role: 'OWNER' }, + { userId: developer.id, role: 'MEMBER' }, + ], + }, + }, + }); + console.log(` Group: ${engineeringGroup.name} (2 members)`); - // --- Group (memory sharing) --- - const engineeringGroup = await prisma.group.create({ - data: { - name: 'Engineering', - description: 'Engineering team memory sharing group', - createdById: admin.id, - members: { - create: [ - { userId: admin.id, role: 'OWNER' }, - { userId: developer.id, role: 'MEMBER' }, - ], + // --- Audit Log entry --- + await prisma.auditLog.create({ + data: { + userId: admin.id, + action: 'org.seed', + resource: 'SystemSettings', + resourceId: system.id, + details: { source: 'seed-script', version: '1.0.0' }, }, - }, - }); - console.log(` Group: ${engineeringGroup.name} (2 members)`); - - // --- Audit Log entry --- - await prisma.auditLog.create({ - data: { - userId: admin.id, - action: 'org.seed', - resource: 'SystemSettings', - resourceId: system.id, - details: { source: 'seed-script', version: '1.0.0' }, - }, - }); - console.log(' AuditLog: seed event recorded'); + }); + console.log(' AuditLog: seed event recorded'); + } console.log('\nSeed complete.'); } diff --git a/packages/api/src/__tests__/app.e2e.test.ts b/packages/api/src/__tests__/app.e2e.test.ts index eabba45..4374910 100644 --- a/packages/api/src/__tests__/app.e2e.test.ts +++ b/packages/api/src/__tests__/app.e2e.test.ts @@ -12,6 +12,9 @@ import { PrismaService } from '../prisma/prisma.service.js'; import { RedisService } from '../cache/redis.service.js'; import { RedisPubSubService } from '../cache/redis-pubsub.service.js'; import { ContainerPoolService } from '../engine/container-pool.service.js'; +import { AgentRunnerService } from '../engine/agent-runner.service.js'; +import { RunActivityRegistry } from '../engine/run-activity.registry.js'; +import { ToolApprovalService } from '../approvals/tool-approval.service.js'; interface ErrorResponseBody { statusCode: number; @@ -78,6 +81,11 @@ describe('App E2E', () => { findMany: () => Promise.resolve([]), findUnique: () => Promise.resolve(null), }, + // ApprovalsModule (Task 9): ToolApprovalService.onModuleInit sweeps + // stale PENDING approvals from before restart. + approvalRequest: { + updateMany: () => Promise.resolve({ count: 0 }), + }, }) .overrideProvider(RedisService) .useValue({ @@ -198,4 +206,22 @@ describe('App E2E', () => { expect(body.code).toBe('INTERNAL_ERROR'); expect(body.message).toBe('Internal server error'); }); + + // Task 10: EngineModule now imports ApprovalsModule (AgentRunnerService + // needs ToolApprovalService) and ApprovalsModule needs RunActivityRegistry + // (Task 9 invariant: the SAME instance the engine watchdog snapshots, so + // the `awaiting_approval:` marker is visible to it). Resolving the + // would-be EngineModule<->ApprovalsModule cycle by extracting + // RunActivityModule must not silently give each side its own registry. + it('wires AgentRunnerService and ToolApprovalService to the same RunActivityRegistry instance', () => { + const agentRunner = app.get(AgentRunnerService); + const toolApproval = app.get(ToolApprovalService); + + const fromEngine = (agentRunner as unknown as { runActivity: RunActivityRegistry }).runActivity; + const fromApprovals = (toolApproval as unknown as { runActivity: RunActivityRegistry }) + .runActivity; + + expect(fromEngine).toBeInstanceOf(RunActivityRegistry); + expect(fromEngine).toBe(fromApprovals); + }); }); diff --git a/packages/api/src/app.module.ts b/packages/api/src/app.module.ts index c906e46..bda4979 100644 --- a/packages/api/src/app.module.ts +++ b/packages/api/src/app.module.ts @@ -5,6 +5,7 @@ import { ThrottlerModule, ThrottlerStorage } from '@nestjs/throttler'; import { AdminModule } from './admin/index.js'; import { DashboardModule } from './dashboard/index.js'; import { AgentsModule } from './agents/index.js'; +import { ApprovalsModule } from './approvals/approvals.module.js'; import { AuditModule } from './audit/index.js'; import { AuthModule } from './auth/index.js'; import { JwtAuthGuard } from './auth/jwt-auth.guard.js'; @@ -55,6 +56,7 @@ import { WorkspaceModule } from './workspace/index.js'; CacheModule, AuthModule, AgentsModule, + ApprovalsModule, TasksModule, SkillsModule, ChannelsModule, diff --git a/packages/api/src/commands/__tests__/reset.command.test.ts b/packages/api/src/commands/__tests__/reset.command.test.ts index 3c0cbff..b237988 100644 --- a/packages/api/src/commands/__tests__/reset.command.test.ts +++ b/packages/api/src/commands/__tests__/reset.command.test.ts @@ -22,6 +22,9 @@ describe('ResetCommand', () => { count: vi.fn().mockResolvedValue(10), }, }; + const mockToolApprovalService = { + clearSessionMemory: vi.fn(), + }; beforeEach(() => { vi.clearAllMocks(); @@ -30,27 +33,41 @@ describe('ResetCommand', () => { }); it('has the correct name and description', () => { - const cmd = new ResetCommand(mockSessionManager as never, mockPrisma as never); + const cmd = new ResetCommand( + mockSessionManager as never, + mockPrisma as never, + mockToolApprovalService as never, + ); expect(cmd.name).toBe('reset'); expect(cmd.description).toBeDefined(); }); - it('deactivates the session and returns confirmation', async () => { + it('deactivates the session, clears approval session memory, and returns confirmation', async () => { mockPrisma.sessionMessage.count.mockResolvedValue(10); - const cmd = new ResetCommand(mockSessionManager as never, mockPrisma as never); + const cmd = new ResetCommand( + mockSessionManager as never, + mockPrisma as never, + mockToolApprovalService as never, + ); const result = await cmd.execute(makeContext()); expect(mockSessionManager.deactivate).toHaveBeenCalledWith('session-1'); + expect(mockToolApprovalService.clearSessionMemory).toHaveBeenCalledWith('session-1'); expect(result.text).toContain('reset'); expect(result.text).toContain('fresh'); }); it('returns early message when session has no messages', async () => { mockPrisma.sessionMessage.count.mockResolvedValue(0); - const cmd = new ResetCommand(mockSessionManager as never, mockPrisma as never); + const cmd = new ResetCommand( + mockSessionManager as never, + mockPrisma as never, + mockToolApprovalService as never, + ); const result = await cmd.execute(makeContext()); expect(mockSessionManager.deactivate).not.toHaveBeenCalled(); + expect(mockToolApprovalService.clearSessionMemory).not.toHaveBeenCalled(); expect(result.text).toContain('No active conversation'); }); }); diff --git a/packages/api/src/commands/command.module.ts b/packages/api/src/commands/command.module.ts index d6c3a55..eb325c2 100644 --- a/packages/api/src/commands/command.module.ts +++ b/packages/api/src/commands/command.module.ts @@ -3,13 +3,14 @@ import { Module } from '@nestjs/common'; import { DbModule } from '../db/index.js'; import { EngineModule } from '../engine/engine.module.js'; import { PrismaModule } from '../prisma/index.js'; +import { ApprovalsModule } from '../approvals/approvals.module.js'; import { CommandService } from './command.service.js'; import { ResetCommand } from './reset.command.js'; import { CompactCommand } from './compact.command.js'; import { HelpCommand } from './help.command.js'; @Module({ - imports: [DbModule, EngineModule, PrismaModule], + imports: [DbModule, EngineModule, PrismaModule, ApprovalsModule], providers: [ ResetCommand, CompactCommand, diff --git a/packages/api/src/commands/reset.command.ts b/packages/api/src/commands/reset.command.ts index 6f624d5..7403eb9 100644 --- a/packages/api/src/commands/reset.command.ts +++ b/packages/api/src/commands/reset.command.ts @@ -6,6 +6,7 @@ import type { } from './session-command.js'; import { SessionManagerService } from '../engine/session-manager.service.js'; import { PrismaService } from '../prisma/prisma.service.js'; +import { ToolApprovalService } from '../approvals/tool-approval.service.js'; @Injectable() export class ResetCommand implements SessionCommand { @@ -15,6 +16,7 @@ export class ResetCommand implements SessionCommand { constructor( private readonly sessionManager: SessionManagerService, private readonly prisma: PrismaService, + private readonly toolApprovalService: ToolApprovalService, ) {} async execute(ctx: SessionCommandContext): Promise { @@ -27,6 +29,9 @@ export class ResetCommand implements SessionCommand { } await this.sessionManager.deactivate(ctx.sessionId); + // Session-scoped approval memory ("allow for this session") must not + // leak into the fresh conversation that follows a reset (spec §4). + this.toolApprovalService.clearSessionMemory(ctx.sessionId); return { text: 'Session reset. Your next message will start a fresh conversation.', event: 'session.reset', From 6dcd855665e6d6311dce30141ba61c9fc4240f8b Mon Sep 17 00:00:00 2001 From: jasonli0226 Date: Mon, 20 Jul 2026 01:05:51 +0800 Subject: [PATCH 09/10] feat(mcp): per-tool ask-before-use approval overrides in tiers Lets operators mark MCP tools as ask-before-use per connection: - mcp.service/controller expose and persist sparse approvalOverrides ({ [toolName]: 'ask' | 'allow' }); tier-utils and mcp-client capture tool annotations at discovery. Schema and validation updated in shared. - Tiers tab gains an ask-before-use column; web mcp client wired through. --- packages/api/src/db/mcp-server.repository.ts | 28 ++++- .../mcp/__tests__/mcp-client.service.test.ts | 19 ++- .../src/mcp/__tests__/mcp.controller.test.ts | 10 ++ .../api/src/mcp/__tests__/mcp.service.test.ts | 108 ++++++++++++++++++ packages/api/src/mcp/mcp-client.service.ts | 5 + packages/api/src/mcp/mcp.controller.ts | 22 ++++ packages/api/src/mcp/mcp.service.ts | 71 +++++++++++- packages/api/src/mcp/tier-utils.ts | 26 ++++- .../src/schemas/__tests__/mcp.schema.test.ts | 21 ++++ packages/shared/src/schemas/index.ts | 2 + packages/shared/src/schemas/mcp.schema.ts | 11 ++ .../mcp-servers/[id]/tiers-tab.tsx | 77 ++++++++++--- packages/web/src/lib/__tests__/mcp.test.ts | 16 ++- packages/web/src/lib/mcp.ts | 37 ++++++ 14 files changed, 429 insertions(+), 24 deletions(-) diff --git a/packages/api/src/db/mcp-server.repository.ts b/packages/api/src/db/mcp-server.repository.ts index 2a54985..0954653 100644 --- a/packages/api/src/db/mcp-server.repository.ts +++ b/packages/api/src/db/mcp-server.repository.ts @@ -35,6 +35,8 @@ export interface CatalogToolData { readonly name: string; readonly description: string; readonly inputSchema: Prisma.InputJsonValue; + /** MCP spec tool annotations captured at discovery — untrusted hints. */ + readonly annotations?: Prisma.NullableJsonNullValueInput | Prisma.InputJsonValue; readonly scanFlagged: boolean; readonly scanReason?: string | null; } @@ -229,15 +231,37 @@ export class McpServerRepository { } } - /** Persist a connection's curated tool tiers (normalized by the service). */ + /** + * Persist a connection's curated tool tiers (normalized by the service). + * Auto-sort additionally proposes per-tool approval overlays in the same + * LLM call — when supplied, `approvalOverrides` is written in the same + * update (a single query is inherently atomic, so both columns land or + * neither does). + */ async updateConnectionTiers( connectionId: string, tiers: Prisma.InputJsonValue, + approvalOverrides?: Prisma.InputJsonValue, + ): Promise { + try { + return await this.prisma.mcpConnection.update({ + where: { id: connectionId }, + data: { tiers, ...(approvalOverrides !== undefined ? { approvalOverrides } : {}) }, + }); + } catch (error) { + handlePrismaError(error, 'McpConnection'); + } + } + + /** Persist a human-curated per-tool approval overlay (sparse map, replaces wholesale). */ + async updateConnectionApprovalOverrides( + connectionId: string, + approvalOverrides: Prisma.InputJsonValue, ): Promise { try { return await this.prisma.mcpConnection.update({ where: { id: connectionId }, - data: { tiers }, + data: { approvalOverrides }, }); } catch (error) { handlePrismaError(error, 'McpConnection'); diff --git a/packages/api/src/mcp/__tests__/mcp-client.service.test.ts b/packages/api/src/mcp/__tests__/mcp-client.service.test.ts index cc13deb..8813717 100644 --- a/packages/api/src/mcp/__tests__/mcp-client.service.test.ts +++ b/packages/api/src/mcp/__tests__/mcp-client.service.test.ts @@ -101,11 +101,28 @@ describe('McpClientService', () => { expect(validateUrl).toHaveBeenCalledWith(PARAMS.url, { allowlistEnv: 'MCP_INTERNAL_ALLOWLIST', }); - expect(tools).toEqual([{ name: 't', description: 'd', inputSchema: { type: 'object' } }]); + expect(tools).toEqual([ + { name: 't', description: 'd', inputSchema: { type: 'object' }, annotations: null }, + ]); expect(mockClose).toHaveBeenCalled(); // discover closes its connection expect(mockAgentClose).toHaveBeenCalled(); // ...and frees the pinned dispatcher }); + it('passes through server-provided tool annotations verbatim', async () => { + mockListTools.mockResolvedValue({ + tools: [ + { + name: 't', + description: 'd', + inputSchema: { type: 'object' }, + annotations: { readOnlyHint: true }, + }, + ], + }); + const tools = await svc.discover(PARAMS); + expect(tools[0]?.annotations).toEqual({ readOnlyHint: true }); + }); + it('connect returns a client whose callTool maps content and isError', async () => { mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }], isError: false }); const conn = await svc.connect(PARAMS); diff --git a/packages/api/src/mcp/__tests__/mcp.controller.test.ts b/packages/api/src/mcp/__tests__/mcp.controller.test.ts index 6bdeb8e..9db76b2 100644 --- a/packages/api/src/mcp/__tests__/mcp.controller.test.ts +++ b/packages/api/src/mcp/__tests__/mcp.controller.test.ts @@ -6,6 +6,7 @@ function makeSvc() { return { startOAuth: vi.fn(), handleOAuthCallback: vi.fn(), + setApprovalOverrides: vi.fn(), }; } @@ -39,4 +40,13 @@ describe('McpController OAuth routes', () => { expect(r.statusCode).toBe(302); expect(r.url).toContain('oauth=error'); }); + + it('PUT connections/:id/approval-overrides threads through to the service', async () => { + svc.setApprovalOverrides.mockResolvedValue({ search: 'allow' }); + const r = await controller.setApprovalOverrides({ user: { sub: 'user-1' } } as never, 'conn1', { + search: 'allow', + }); + expect(svc.setApprovalOverrides).toHaveBeenCalledWith('user-1', 'conn1', { search: 'allow' }); + expect(r).toEqual({ search: 'allow' }); + }); }); diff --git a/packages/api/src/mcp/__tests__/mcp.service.test.ts b/packages/api/src/mcp/__tests__/mcp.service.test.ts index ded1ab0..272f5f4 100644 --- a/packages/api/src/mcp/__tests__/mcp.service.test.ts +++ b/packages/api/src/mcp/__tests__/mcp.service.test.ts @@ -80,6 +80,13 @@ function makeDeps() { status: 'active', tiers, })), + updateConnectionApprovalOverrides: vi.fn(async (id: string, approvalOverrides: unknown) => ({ + id, + mcpServerId: 'srv1', + userId: 'u1', + status: 'active', + approvalOverrides, + })), ensureConnection: vi.fn(async () => ({ id: 'c1', mcpServerId: 'srv1', userId: 'u1' })), upsertOAuthToken: vi.fn(async () => ({})), setConnectionStatus: vi.fn(async () => ({})), @@ -339,6 +346,30 @@ describe('McpService.connect (user, discovers own catalog)', () => { ]); }); + it('persists server-provided tool annotations verbatim at discovery', async () => { + deps.client.discover.mockResolvedValue([ + { + name: 'search', + description: 'Search repos', + inputSchema: { type: 'object' }, + annotations: { readOnlyHint: true }, + }, + ]); + await svc.connect('u1', 'srv1', { credential: 'Bearer t' }); + expect(deps.repo.createConnectionWithCatalog).toHaveBeenCalledWith(expect.anything(), [ + expect.objectContaining({ name: 'search', annotations: { readOnlyHint: true } }), + ]); + }); + + it('missing annotations persist as JsonNull, not undefined', async () => { + deps.client.discover.mockResolvedValue([ + { name: 'search', description: 'Search repos', inputSchema: { type: 'object' } }, + ]); + await svc.connect('u1', 'srv1', { credential: 'Bearer t' }); + const [, tools] = deps.repo.createConnectionWithCatalog.mock.calls[0] as [unknown, unknown[]]; + expect((tools[0] as { annotations: unknown }).annotations).not.toBeUndefined(); + }); + it('throws ForbiddenError when policy.allowMcp is false', async () => { deps.policies.findById.mockResolvedValue({ id: 'p1', allowMcp: false }); await expect(svc.connect('u1', 'srv1', {})).rejects.toThrow(ForbiddenError); @@ -503,6 +534,83 @@ describe('McpService.autoSortTiers', () => { expect(tiers.recommended).toEqual([]); expect(deps.repo.updateConnectionTiers).not.toHaveBeenCalled(); }); + + it('persists the LLM-proposed approval overlay alongside tiers, in the same call', async () => { + mockChat.mockResolvedValue({ + content: + '{"recommended":["search"],"optional":[],"off":["delete_repo"],' + + '"approval":{"search":"allow","delete_repo":"ask","ghost":"allow"}}', + usage: { inputTokens: 1, outputTokens: 1 }, + }); + await svc.autoSortTiers('u1', 'conn1'); + expect(deps.repo.updateConnectionTiers).toHaveBeenCalledWith( + 'conn1', + { recommended: ['search'], optional: [], off: ['delete_repo'] }, + { search: 'allow', delete_repo: 'ask' }, // unknown tool name "ghost" dropped + ); + }); + + it('an existing human-set override always wins over the LLM proposal (never silently flipped)', async () => { + deps.repo.findConnectionById.mockResolvedValue({ + id: 'conn1', + mcpServerId: 'srv1', + userId: 'u1', + status: 'active', + credentialEnc: null, + approvalOverrides: { search: 'ask' }, // human toggled this to ask + }); + mockChat.mockResolvedValue({ + content: + '{"recommended":["search"],"optional":[],"off":["delete_repo"],"approval":{"search":"allow"}}', + usage: { inputTokens: 1, outputTokens: 1 }, + }); + await svc.autoSortTiers('u1', 'conn1'); + expect(deps.repo.updateConnectionTiers).toHaveBeenCalledWith( + 'conn1', + { recommended: ['search'], optional: [], off: ['delete_repo'] }, + { search: 'ask' }, + ); + }); +}); + +describe('McpService.setApprovalOverrides', () => { + let deps: ReturnType; + let svc: McpService; + beforeEach(() => { + deps = makeDeps(); + svc = makeSvc(deps); + deps.repo.findConnectionById.mockResolvedValue({ + id: 'conn1', + mcpServerId: 'srv1', + userId: 'u1', + status: 'active', + credentialEnc: null, + }); + }); + + it('persists the overlay and audits mcp.approvals.set', async () => { + const result = await svc.setApprovalOverrides('u1', 'conn1', { send_email: 'allow' }); + expect(result).toEqual({ send_email: 'allow' }); + expect(deps.repo.updateConnectionApprovalOverrides).toHaveBeenCalledWith('conn1', { + send_email: 'allow', + }); + expect(deps.audit.create).toHaveBeenCalledWith( + expect.objectContaining({ action: 'mcp.approvals.set', resourceId: 'conn1' }), + ); + }); + + it("rejects another user's connection", async () => { + deps.repo.findConnectionById.mockResolvedValue({ + id: 'conn1', + userId: 'other', + mcpServerId: 'srv1', + status: 'active', + credentialEnc: null, + }); + await expect(svc.setApprovalOverrides('u1', 'conn1', { search: 'ask' })).rejects.toThrow( + ForbiddenError, + ); + }); }); const OAUTH_SERVER = { diff --git a/packages/api/src/mcp/mcp-client.service.ts b/packages/api/src/mcp/mcp-client.service.ts index 11f32ba..782646b 100644 --- a/packages/api/src/mcp/mcp-client.service.ts +++ b/packages/api/src/mcp/mcp-client.service.ts @@ -28,6 +28,8 @@ export interface DiscoveredTool { readonly name: string; readonly description: string; readonly inputSchema: Record; + /** MCP spec tool annotations (e.g. `readOnlyHint`), untrusted server hints. */ + readonly annotations: Record | null; } export interface McpCallResult { @@ -69,6 +71,9 @@ export class McpClientService { name: t.name, description: t.description ?? '', inputSchema: (t.inputSchema ?? { type: 'object' }) as Record, + // Pass through verbatim — untrusted server-provided hints, scanned + // display-side; consumed by mcpRequiresApproval as a loosen-only signal. + annotations: (t.annotations ?? null) as Record | null, })); } finally { try { diff --git a/packages/api/src/mcp/mcp.controller.ts b/packages/api/src/mcp/mcp.controller.ts index 52a7f4c..8c0bfbc 100644 --- a/packages/api/src/mcp/mcp.controller.ts +++ b/packages/api/src/mcp/mcp.controller.ts @@ -14,9 +14,11 @@ import { } from '@nestjs/common'; import { connectMcpSchema, + setMcpApprovalOverridesSchema, setMcpTiersSchema, updateMcpConnectionSchema, type ConnectMcpInput, + type McpApprovalOverrides, type SetMcpTiersInput, type UpdateMcpConnectionInput, } from '@clawix/shared'; @@ -143,4 +145,24 @@ export class McpController { async autoSortTiers(@Req() req: AuthenticatedRequest, @Param('id') id: string) { return this.svc.autoSortTiers(req.user.sub, id); } + + /** The caller's stored per-tool approval overlay, or an empty map. */ + @Get('connections/:id/approval-overrides') + async getApprovalOverrides(@Req() req: AuthenticatedRequest, @Param('id') id: string) { + return this.svc.getApprovalOverrides(req.user.sub, id); + } + + /** + * Persist a human-curated per-tool approval overlay ("Ask before use" toggle + * column) for the caller's connection. Sparse map — replaces the overlay + * wholesale, since the UI always PUTs the full current state. + */ + @Put('connections/:id/approval-overrides') + async setApprovalOverrides( + @Req() req: AuthenticatedRequest, + @Param('id') id: string, + @Body(new ZodValidationPipe(setMcpApprovalOverridesSchema)) body: McpApprovalOverrides, + ) { + return this.svc.setApprovalOverrides(req.user.sub, id, body); + } } diff --git a/packages/api/src/mcp/mcp.service.ts b/packages/api/src/mcp/mcp.service.ts index 1cfcb79..ed2d27b 100644 --- a/packages/api/src/mcp/mcp.service.ts +++ b/packages/api/src/mcp/mcp.service.ts @@ -16,6 +16,7 @@ import { ValidationError, createLogger, listProviders, + setMcpApprovalOverridesSchema, } from '@clawix/shared'; import { RedisService } from '../cache/redis.service.js'; import { createPkcePair, randomUrlToken } from './oauth-pkce.js'; @@ -23,6 +24,7 @@ import type { ChatMessage, ConnectMcpInput, ImportMcpServerInput, + McpApprovalOverrides, McpToolTiers, UpdateMcpConnectionInput, UpdateMcpServerInput, @@ -41,7 +43,7 @@ import { scanContextContent } from '../engine/prompt-injection-scanner.js'; import { validateUrl } from '../engine/tools/web/ssrf-protection.js'; import { McpClientService, type DiscoveredTool } from './mcp-client.service.js'; import { McpOAuthDiscoveryService } from './mcp-oauth-discovery.service.js'; -import { normalizeTiers, parseTiersJson } from './tier-utils.js'; +import { normalizeApprovals, normalizeTiers, parseTiersJson } from './tier-utils.js'; const logger = createLogger('mcp:service'); @@ -634,6 +636,19 @@ export class McpService { return (connection.tiers as McpToolTiers | null) ?? null; } + /** + * The caller's stored per-tool approval overlay (the "Ask before use" toggle + * column), or an empty map. Symmetric read for + * `setApprovalOverrides` — the Tiers-tab UI needs the current state to render + * each toggle. Malformed persisted JSON degrades to `{}` (never throws), so + * a corrupt column can't wedge the page. + */ + async getApprovalOverrides(userId: string, connectionId: string): Promise { + await this.assertMcpAllowed(userId); + const connection = await this.getOwnConnection(userId, connectionId); + return setMcpApprovalOverridesSchema.catch({}).parse(connection.approvalOverrides ?? {}) ?? {}; + } + /** * Persist a human-curated tier assignment for the caller's connection. * Normalized against the connection's catalog (precedence @@ -658,6 +673,35 @@ export class McpService { return tiers; } + /** + * Persist a human-curated per-tool approval overlay for the caller's + * connection (spec §3). Sparse map, keyed by tool name — replaces the + * overlay wholesale (the Tiers-tab toggle column always PUTs full state), + * unlike `setTiers` there's no catalog-normalization pass: an entry for a + * tool that's off-tier or no longer in the catalog is inert config, not an + * error. Owner-only, audited. + */ + async setApprovalOverrides( + userId: string, + connectionId: string, + overrides: McpApprovalOverrides, + ): Promise { + await this.assertMcpAllowed(userId); + const connection = await this.getOwnConnection(userId, connectionId); + await this.repo.updateConnectionApprovalOverrides( + connection.id, + overrides as unknown as Prisma.InputJsonValue, + ); + await this.audit.create({ + userId, + action: 'mcp.approvals.set', + resource: 'mcp_connection', + resourceId: connection.id, + details: { count: Object.keys(overrides).length }, + }); + return overrides; + } + /** * Ask the org's default provider to sort the connection's catalog into * recommended/optional/off (agent-agnostic — general usefulness). Validated @@ -682,10 +726,13 @@ export class McpService { { role: 'system', content: - 'You classify third-party tools by general usefulness for an AI agent. ' + + 'You classify third-party tools by general usefulness for an AI agent, and by ' + + 'whether calling them is safe to run without a standing human approval. ' + 'recommended = common, safe/read-oriented; off = destructive, admin, or rarely needed; ' + - 'optional = everything else. Reply with ONLY JSON: {"recommended":[],"optional":[],"off":[]} ' + - 'using exact tool names.', + 'optional = everything else. approval: "allow" for safe, read-only, non-destructive ' + + 'tools; "ask" for anything that writes, deletes, sends, or otherwise has side effects. ' + + 'Reply with ONLY JSON: {"recommended":[],"optional":[],"off":[],' + + '"approval":{"":"ask"|"allow"}} using exact tool names.', }, { role: 'user', @@ -693,10 +740,21 @@ export class McpService { }, ]; const res = await provider.chat(messages, { model, settings: { temperature: 0 } }); - const tiers = normalizeTiers(parseTiersJson(res.content), catalogNames); + const parsed = parseTiersJson(res.content); + const tiers = normalizeTiers(parsed, catalogNames); + // Existing human-set overrides always win — auto-sort only fills gaps + // for tools that have never been explicitly toggled. A background + // reclassification must never silently flip a standing decision. + const existingOverrides = setMcpApprovalOverridesSchema + .nullable() + .catch(null) + .parse(connection.approvalOverrides); + const proposedApprovals = normalizeApprovals(parsed, catalogNames); + const approvals: McpApprovalOverrides = { ...proposedApprovals, ...existingOverrides }; await this.repo.updateConnectionTiers( connection.id, tiers as unknown as Prisma.InputJsonValue, + approvals as unknown as Prisma.InputJsonValue, ); await this.audit.create({ userId, @@ -743,6 +801,9 @@ function scanTool(t: DiscoveredTool) { name: t.name, description: t.description, inputSchema: t.inputSchema as never, + // Pass through verbatim (untrusted server hint; scanned display-side + // already via the description scan above). + annotations: (t.annotations ?? Prisma.JsonNull) as never, scanFlagged: scan.blocked, scanReason: scan.blocked ? scan.findings.join(', ') : null, }; diff --git a/packages/api/src/mcp/tier-utils.ts b/packages/api/src/mcp/tier-utils.ts index c31b050..3d2172e 100644 --- a/packages/api/src/mcp/tier-utils.ts +++ b/packages/api/src/mcp/tier-utils.ts @@ -1,9 +1,11 @@ -import type { McpToolTiers } from '@clawix/shared'; +import type { McpApprovalOverrides, McpToolTiers } from '@clawix/shared'; interface RawTiers { recommended?: unknown; optional?: unknown; off?: unknown; + /** Auto-sort's proposed per-tool approval classification (spec §3). */ + approval?: unknown; } /** Tolerantly parse a model's JSON tier output (handles fences / surrounding prose). */ @@ -59,3 +61,25 @@ export function normalizeTiers( } return { recommended, optional, off }; } + +/** + * Validate the auto-sort LLM's proposed per-tool approval classification + * against the real catalog: unknown tool names dropped, non-enum values + * dropped. Unlike `normalizeTiers`, omitted tools are simply absent (sparse + * overlay, not rebalanced wholesale — spec §3). + */ +export function normalizeApprovals( + raw: RawTiers | null, + catalogNames: readonly string[], +): McpApprovalOverrides { + const valid = new Set(catalogNames); + const src = raw?.approval; + if (!src || typeof src !== 'object') return {}; + const out: Record = {}; + for (const [name, value] of Object.entries(src as Record)) { + if (valid.has(name) && (value === 'ask' || value === 'allow')) { + out[name] = value; + } + } + return out; +} diff --git a/packages/shared/src/schemas/__tests__/mcp.schema.test.ts b/packages/shared/src/schemas/__tests__/mcp.schema.test.ts index 39b2511..4327b93 100644 --- a/packages/shared/src/schemas/__tests__/mcp.schema.test.ts +++ b/packages/shared/src/schemas/__tests__/mcp.schema.test.ts @@ -6,6 +6,7 @@ import { updateMcpConnectionSchema, mcpBindingsSchema, setMcpTiersSchema, + setMcpApprovalOverridesSchema, } from '../mcp.schema.js'; import { updateAgentDefinitionSchema } from '../agent.schema.js'; @@ -118,6 +119,26 @@ describe('setMcpTiersSchema', () => { }); }); +describe('setMcpApprovalOverridesSchema', () => { + it('accepts a sparse map of tool name -> ask|allow', () => { + const r = setMcpApprovalOverridesSchema.safeParse({ send_email: 'ask', search: 'allow' }); + expect(r.success).toBe(true); + }); + + it('accepts an empty object (clearing all overrides)', () => { + expect(setMcpApprovalOverridesSchema.safeParse({}).success).toBe(true); + }); + + it('rejects values outside the ask|allow enum', () => { + expect(setMcpApprovalOverridesSchema.safeParse({ search: 'deny' }).success).toBe(false); + }); + + it('rejects a non-object payload', () => { + expect(setMcpApprovalOverridesSchema.safeParse('garbage').success).toBe(false); + expect(setMcpApprovalOverridesSchema.safeParse(null).success).toBe(false); + }); +}); + describe('mcpBindingsSchema', () => { it('parses bindings out of a toolConfig-shaped object', () => { const r = mcpBindingsSchema.parse({ diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 7fe331f..32724e7 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -163,6 +163,7 @@ export { updateMcpConnectionSchema, mcpBindingsSchema, setMcpTiersSchema, + setMcpApprovalOverridesSchema, type ImportMcpServerInput, type UpdateMcpServerInput, type ConnectMcpInput, @@ -170,4 +171,5 @@ export { type McpBindings, type McpToolTiers, type SetMcpTiersInput, + type McpApprovalOverrides, } from './mcp.schema.js'; diff --git a/packages/shared/src/schemas/mcp.schema.ts b/packages/shared/src/schemas/mcp.schema.ts index 95ac0e1..f12ee5f 100644 --- a/packages/shared/src/schemas/mcp.schema.ts +++ b/packages/shared/src/schemas/mcp.schema.ts @@ -101,6 +101,17 @@ export interface McpToolTiers { readonly off: string[]; } +/** + * Sparse per-tool approval overlay for an MCP connection, keyed by tool name. + * Unlike `McpToolTiers`, this is NOT rebalanced wholesale — an entry is only + * present for tools a human explicitly toggled; absent entries fall back to + * the annotation-derived default (see `mcpRequiresApproval`). + */ +export type McpApprovalOverrides = Readonly>; + +/** PUT /mcp/connections/:id/approval-overrides body. */ +export const setMcpApprovalOverridesSchema = z.record(z.string(), z.enum(['ask', 'allow'])); + export type ImportMcpServerInput = z.infer; export type UpdateMcpServerInput = z.infer; export type ConnectMcpInput = z.infer; diff --git a/packages/web/src/app/(dashboard)/mcp-servers/[id]/tiers-tab.tsx b/packages/web/src/app/(dashboard)/mcp-servers/[id]/tiers-tab.tsx index 465b138..801b57e 100644 --- a/packages/web/src/app/(dashboard)/mcp-servers/[id]/tiers-tab.tsx +++ b/packages/web/src/app/(dashboard)/mcp-servers/[id]/tiers-tab.tsx @@ -12,17 +12,27 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { autoSortConnectionTiers, + getConnectionApprovalOverrides, getConnectionTiers, listMcpTools, + mcpApprovalDefault, + setConnectionApprovalOverrides, setConnectionTiers, + type McpApprovalOverride, + type McpApprovalOverrides, type McpToolDto, type McpToolTiers, } from '@/lib/mcp'; type Tier = 'recommended' | 'optional' | 'off'; +const ASK_TOOLTIP = + 'Chat approvals for MCP tools last at most a session — permanent allow lives here.'; + /** Build the serverId-keyed tier assignment from a tiers object. */ function assignmentFromTiers( tools: readonly McpToolDto[], @@ -51,6 +61,9 @@ export function TiersTab({ }) { const [tools, setTools] = useState([]); const [assignment, setAssignment] = useState>({}); + // Explicit per-tool approval overrides only; a tool absent here shows its + // derived default (`mcpApprovalDefault`). Persisted independently of tiers. + const [overrides, setOverrides] = useState({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [sorting, setSorting] = useState(false); @@ -62,12 +75,14 @@ export function TiersTab({ } void (async () => { try { - const [t, tiers] = await Promise.all([ + const [t, tiers, ov] = await Promise.all([ listMcpTools(serverId), getConnectionTiers(connectionId), + getConnectionApprovalOverrides(connectionId), ]); setTools(t); setAssignment(assignmentFromTiers(t, tiers)); + setOverrides(ov); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to load tiers'); } finally { @@ -76,6 +91,23 @@ export function TiersTab({ })(); }, [serverId, connectionId]); + /** Persist the "Ask before use" toggle for one tool, PUTing the full map. */ + async function toggleApproval(toolName: string, ask: boolean) { + if (!connectionId) return; + const next: McpApprovalOverrides = { + ...overrides, + [toolName]: (ask ? 'ask' : 'allow') satisfies McpApprovalOverride, + }; + const prev = overrides; + setOverrides(next); // optimistic + try { + await setConnectionApprovalOverrides(connectionId, next); + } catch (err) { + setOverrides(prev); // roll back + toast.error(err instanceof Error ? err.message : 'Failed to save approval setting'); + } + } + if (!connectionId) return (

Connect to this server to tier its tools.

@@ -141,19 +173,36 @@ export function TiersTab({ {tool.name} {tool.scanFlagged && flagged} - +
+ + + + + {ASK_TOOLTIP} + + +
))} {tools.length === 0 && ( diff --git a/packages/web/src/lib/__tests__/mcp.test.ts b/packages/web/src/lib/__tests__/mcp.test.ts index dc8d7e0..13c5e8e 100644 --- a/packages/web/src/lib/__tests__/mcp.test.ts +++ b/packages/web/src/lib/__tests__/mcp.test.ts @@ -1,5 +1,19 @@ import { describe, it, expect } from 'vitest'; -import { connectionBadge, type McpServerWithConnection } from '../mcp'; +import { connectionBadge, mcpApprovalDefault, type McpServerWithConnection } from '../mcp'; + +describe('mcpApprovalDefault — mirrors server mcpRequiresApproval (ask-by-default)', () => { + it('only a truthful readOnlyHint=true loosens to allow', () => { + expect(mcpApprovalDefault({ readOnlyHint: true })).toBe('allow'); + }); + + it('missing / false / malformed annotations default to ask (servers can lie or omit)', () => { + expect(mcpApprovalDefault(null)).toBe('ask'); + expect(mcpApprovalDefault(undefined)).toBe('ask'); + expect(mcpApprovalDefault({})).toBe('ask'); + expect(mcpApprovalDefault({ readOnlyHint: false })).toBe('ask'); + expect(mcpApprovalDefault({ readOnlyHint: 'true' })).toBe('ask'); + }); +}); function server(over: Partial = {}): McpServerWithConnection { return { diff --git a/packages/web/src/lib/mcp.ts b/packages/web/src/lib/mcp.ts index 3d5ab9a..fbaabdf 100644 --- a/packages/web/src/lib/mcp.ts +++ b/packages/web/src/lib/mcp.ts @@ -51,8 +51,16 @@ export interface McpToolDto { scanFlagged: boolean; scanReason: string | null; createdAt: string; + /** MCP tool annotations captured at discovery (Task 8); `readOnlyHint` drives + * the default "Ask before use" state when no explicit override exists. */ + annotations?: Record | null; } +/** Per-tool approval overlay value (spec §3): `ask` gates every call, `allow` + * never prompts. Absent = derived default (ask unless `readOnlyHint`). */ +export type McpApprovalOverride = 'ask' | 'allow'; +export type McpApprovalOverrides = Record; + export interface McpCallRow { id: string; userId: string; @@ -229,6 +237,35 @@ export function setConnectionTiers( }); } +export function getConnectionApprovalOverrides( + connectionId: string, +): Promise { + return authFetch(`/mcp/connections/${connectionId}/approval-overrides`); +} + +export function setConnectionApprovalOverrides( + connectionId: string, + overrides: McpApprovalOverrides, +): Promise { + return authFetch(`/mcp/connections/${connectionId}/approval-overrides`, { + method: 'PUT', + body: JSON.stringify(overrides), + }); +} + +/** + * Default "Ask before use" state for a tool with no explicit override, mirroring + * `mcpRequiresApproval` on the server (`packages/api/src/engine/tools/mcp/mcp-approval.ts`): + * only a truthful `readOnlyHint === true` annotation loosens to `allow`; anything + * else (missing/false/malformed) defaults to `ask`. Annotations are untrusted + * server hints — they may only loosen, never force-escalate. + */ +export function mcpApprovalDefault( + annotations: Record | null | undefined, +): McpApprovalOverride { + return annotations?.['readOnlyHint'] === true ? 'allow' : 'ask'; +} + export function autoSortConnectionTiers(connectionId: string): Promise { return authFetch(`/mcp/connections/${connectionId}/auto-sort-tiers`, { method: 'POST', From fc3abc375ed69e4d08913dabfafa704bc3b5689d Mon Sep 17 00:00:00 2001 From: jasonli0226 Date: Mon, 20 Jul 2026 01:06:02 +0800 Subject: [PATCH 10/10] feat(agents): enforce provider policy on agent definitions and spawns Gate agent providers against the owner's Policy.allowedProviders: - agents.service rejects creating/updating an agent that names an out-of-policy provider, and gates sub-agent spawns against the spawning user's policy. - agent-definition.repository resolves a policy-permitted default worker (falling back to the first allowed, configured provider) so anonymous spawns keep working without forcing the org default into every policy. - spawn tool honours the policy path. Unit coverage across all three. --- .../agents/__tests__/agents.service.test.ts | 120 ++++++++++++++++- packages/api/src/agents/agents.service.ts | 27 ++++ .../agent-definition.repository.test.ts | 77 +++++++++++ .../api/src/db/agent-definition.repository.ts | 124 +++++++++++++++--- .../src/engine/__tests__/spawn-tool.test.ts | 41 ++++-- packages/api/src/engine/tools/spawn.ts | 9 +- 6 files changed, 368 insertions(+), 30 deletions(-) diff --git a/packages/api/src/agents/__tests__/agents.service.test.ts b/packages/api/src/agents/__tests__/agents.service.test.ts index bb48adb..4550580 100644 --- a/packages/api/src/agents/__tests__/agents.service.test.ts +++ b/packages/api/src/agents/__tests__/agents.service.test.ts @@ -44,6 +44,7 @@ function makeService(opts: { findByAgentDefinitionId?: ReturnType; agentCount?: number; maxAgents?: number; + allowedProviders?: string[]; agentDefUpdate?: ReturnType; }) { const agentDefRepo = { @@ -64,14 +65,22 @@ function makeService(opts: { const userAgentRepo = { existsForUser: vi.fn().mockResolvedValue(opts.existsForUser ?? false), + findByUserId: vi.fn().mockResolvedValue({ workspacePath: 'users/u/workspace' }), + create: vi + .fn() + .mockImplementation(async (data: Record) => ({ id: 'ua-1', ...data })), } as unknown as UserAgentRepository; const userRepo = { - findById: vi.fn().mockResolvedValue({ id: ownerId, policyId: 'policy-1' }), + findById: vi.fn().mockImplementation(async (id: string) => ({ id, policyId: 'policy-1' })), } as unknown as UserRepository; const policyRepo = { - findById: vi.fn().mockResolvedValue({ id: 'policy-1', maxAgents: opts.maxAgents ?? 5 }), + findById: vi.fn().mockResolvedValue({ + id: 'policy-1', + maxAgents: opts.maxAgents ?? 5, + allowedProviders: opts.allowedProviders ?? ['anthropic', 'openai'], + }), } as unknown as PolicyRepository; const prisma = {} as unknown as PrismaService; @@ -201,6 +210,39 @@ describe('AgentsService.updateAgent', () => { service.updateAgent(agentId, { name: 'hacked' }, otherUserId, 'user'), ).rejects.toBeInstanceOf(ForbiddenException); }); + + it('rejects updating provider to one not in the owner’s policy', async () => { + const { service } = makeService({ allowedProviders: ['anthropic', 'openai'] }); + await expect( + service.updateAgent(agentId, { provider: 'gemini' }, ownerId, 'user'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('allows updating provider to one in the owner’s policy', async () => { + const agentDefUpdate = vi.fn().mockResolvedValue(makeAgent()); + const { service } = makeService({ allowedProviders: ['anthropic', 'openai'], agentDefUpdate }); + await expect( + service.updateAgent(agentId, { provider: 'openai' }, ownerId, 'user'), + ).resolves.toBeDefined(); + expect(agentDefUpdate).toHaveBeenCalled(); + }); + + it('does not check the provider when the update omits it', async () => { + const agentDefUpdate = vi.fn().mockResolvedValue(makeAgent()); + const { service, policyRepo } = makeService({ agentDefUpdate }); + await service.updateAgent(agentId, { name: 'renamed' }, ownerId, 'user'); + expect(policyRepo.findById).not.toHaveBeenCalled(); + expect(agentDefUpdate).toHaveBeenCalled(); + }); + + it('exempts admins from the provider check on update', async () => { + const agentDefUpdate = vi.fn().mockResolvedValue(makeAgent()); + const { service } = makeService({ allowedProviders: ['anthropic'], agentDefUpdate }); + await expect( + service.updateAgent(agentId, { provider: 'gemini' }, adminId, 'admin'), + ).resolves.toBeDefined(); + expect(agentDefUpdate).toHaveBeenCalled(); + }); }); describe('AgentsService.listAgentRuns', () => { @@ -240,3 +282,77 @@ describe('AgentsService.listAgentRuns', () => { ); }); }); + +describe('AgentsService.createAgent — provider policy enforcement', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const baseInput = { + name: 'My Worker', + description: '', + systemPrompt: 'sys', + provider: 'gemini', + model: 'gemini-2.5-pro', + role: 'worker' as const, + maxTokensPerRun: 50000, + }; + + it('rejects a provider not in the creator’s policy allowedProviders', async () => { + const { service } = makeService({ allowedProviders: ['anthropic', 'openai'] }); + + await expect(service.createAgent(baseInput, ownerId, 'user')).rejects.toThrow( + BadRequestException, + ); + }); + + it('allows a provider that is in the creator’s policy allowedProviders', async () => { + const { service, agentDefRepo } = makeService({ allowedProviders: ['anthropic', 'openai'] }); + + await expect( + service.createAgent({ ...baseInput, provider: 'openai' }, ownerId, 'user'), + ).resolves.toBeDefined(); + expect(agentDefRepo.create).toHaveBeenCalled(); + }); + + it('exempts admins (official/template agents) from the provider check', async () => { + const { service, agentDefRepo } = makeService({ allowedProviders: ['anthropic'] }); + + await expect( + service.createAgent({ ...baseInput, provider: 'gemini' }, adminId, 'admin'), + ).resolves.toBeDefined(); + expect(agentDefRepo.create).toHaveBeenCalled(); + }); +}); + +describe('AgentsService.createSubAgent — provider policy enforcement', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const baseSubInput = { + userId: otherUserId, + name: 'Sub Worker', + systemPrompt: 'sys', + provider: 'gemini', + model: 'gemini-2.5-pro', + maxTokensPerRun: 50000, + }; + + it('rejects a provider not in the target user’s policy allowedProviders', async () => { + const { service } = makeService({ allowedProviders: ['anthropic', 'openai'] }); + + await expect(service.createSubAgent(baseSubInput, ownerId)).rejects.toThrow( + BadRequestException, + ); + }); + + it('allows a provider that is in the target user’s policy allowedProviders', async () => { + const { service, agentDefRepo } = makeService({ allowedProviders: ['anthropic', 'openai'] }); + + await expect( + service.createSubAgent({ ...baseSubInput, provider: 'anthropic' }, ownerId), + ).resolves.toBeDefined(); + expect(agentDefRepo.create).toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/agents/agents.service.ts b/packages/api/src/agents/agents.service.ts index 5bd99a5..02651ec 100644 --- a/packages/api/src/agents/agents.service.ts +++ b/packages/api/src/agents/agents.service.ts @@ -66,6 +66,7 @@ export class AgentsService { // official/template agents) are exempt. if (createdById && userRole !== 'admin') { await this.enforceAgentLimit(createdById); + await this.assertProviderAllowed(createdById, input.provider); } return this.agentDefRepo.create({ ...input, createdById, isOfficial }); } @@ -85,6 +86,23 @@ export class AgentsService { } } + /** + * Throw `BadRequestException` if the given provider is not permitted by the + * user's policy (`Policy.allowedProviders`). This is the definition-time gate + * that stops a user persisting an agent naming an out-of-policy provider; the + * run-time provider check in `AgentRunnerService` remains the backstop. + */ + private async assertProviderAllowed(userId: string, provider: string): Promise { + const user = await this.userRepo.findById(userId); + const policy = await this.policyRepo.findById(user.policyId); + if (!policy.allowedProviders.includes(provider)) { + throw new BadRequestException( + `Provider '${provider}' is not allowed by your policy. ` + + `Allowed providers: ${policy.allowedProviders.join(', ') || '(none)'}.`, + ); + } + } + async updateAgent( id: string, input: UpdateAgentDefinitionInput & { readonly isActive?: boolean }, @@ -96,6 +114,11 @@ export class AgentsService { if (existing.isOfficial || existing.createdById !== userId) { throw new ForbiddenException('You can only edit your own custom agent definitions'); } + // `provider` is updatable via the partial update schema — gate a change to + // it against the owner's policy, same as at creation time. + if (input.provider !== undefined && userId) { + await this.assertProviderAllowed(userId, input.provider); + } } return this.agentDefRepo.update(id, input); } @@ -139,6 +162,10 @@ export class AgentsService { }, createdById?: string, ) { + // The sub-agent runs as `input.userId`, so gate its provider against that + // user's policy (not the caller's). + await this.assertProviderAllowed(input.userId, input.provider); + // Find user's primary agent to get workspace path const primaryUserAgent = await this.userAgentRepo.findByUserId(input.userId); const workspacePath = primaryUserAgent?.workspacePath ?? `users/${input.userId}/workspace`; diff --git a/packages/api/src/db/__tests__/agent-definition.repository.test.ts b/packages/api/src/db/__tests__/agent-definition.repository.test.ts index 6861f29..225c51a 100644 --- a/packages/api/src/db/__tests__/agent-definition.repository.test.ts +++ b/packages/api/src/db/__tests__/agent-definition.repository.test.ts @@ -268,4 +268,81 @@ describe('AgentDefinitionRepository', () => { ); }); }); + + describe('findOrCreateDefaultWorkerForPolicy', () => { + // Org default provider is anthropic; a provider is "configured" iff the + // routed providerConfig lookup returns an enabled row. + function routeProviderConfig(enabled: string[]) { + mockPrisma.providerConfig.findFirst.mockImplementation( + async (args: { where: Record }) => { + const where = args.where; + if (where['isDefault']) { + return { id: 'pc-default', provider: 'anthropic', isEnabled: true, isDefault: true }; + } + if (typeof where['provider'] === 'string' && enabled.includes(where['provider'])) { + return { id: `pc-${where['provider']}`, provider: where['provider'], isEnabled: true }; + } + return null; + }, + ); + } + + it('uses the org default worker when the org default provider is allowed', async () => { + routeProviderConfig(['anthropic', 'openai']); + mockPrisma.agentDefinition.findFirst.mockResolvedValue(null); + mockPrisma.agentDefinition.create.mockImplementation( + async (args: { data: Record }) => ({ id: 'created', ...args.data }), + ); + + const result = await repo.findOrCreateDefaultWorkerForPolicy(['anthropic', 'openai']); + + expect(result.provider).toBe('anthropic'); + expect(mockPrisma.agentDefinition.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ name: 'default-worker', provider: 'anthropic' }), + }); + }); + + it('auto-substitutes an allowed provider when the org default is out-of-policy', async () => { + routeProviderConfig(['anthropic', 'openai']); + mockPrisma.agentDefinition.findFirst.mockResolvedValue(null); + mockPrisma.agentDefinition.create.mockImplementation( + async (args: { data: Record }) => ({ id: 'created', ...args.data }), + ); + + const result = await repo.findOrCreateDefaultWorkerForPolicy(['openai']); + + expect(result.provider).toBe('openai'); + expect(mockPrisma.agentDefinition.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ name: 'default-worker-openai', provider: 'openai' }), + }); + }); + + it('reuses an existing per-provider default worker row when present', async () => { + routeProviderConfig(['anthropic', 'openai']); + const existing = { + ...mockAgent, + id: 'dw-openai', + name: 'default-worker-openai', + role: 'worker', + provider: 'openai', + model: 'gpt-4o', + }; + mockPrisma.agentDefinition.findFirst.mockResolvedValue(existing); + + const result = await repo.findOrCreateDefaultWorkerForPolicy(['openai']); + + expect(result).toEqual(existing); + expect(mockPrisma.agentDefinition.create).not.toHaveBeenCalled(); + }); + + it('throws an actionable error when none of the allowed providers is configured', async () => { + routeProviderConfig(['anthropic', 'openai']); // gemini not enabled + mockPrisma.agentDefinition.findFirst.mockResolvedValue(null); + + await expect(repo.findOrCreateDefaultWorkerForPolicy(['gemini'])).rejects.toThrow( + /allowed providers/i, + ); + expect(mockPrisma.agentDefinition.create).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/api/src/db/agent-definition.repository.ts b/packages/api/src/db/agent-definition.repository.ts index 40abe9c..18e5283 100644 --- a/packages/api/src/db/agent-definition.repository.ts +++ b/packages/api/src/db/agent-definition.repository.ts @@ -91,26 +91,116 @@ export class AgentDefinitionRepository { } return this.prisma.agentDefinition.create({ - data: { - name: 'default-worker', - description: 'Default worker agent for anonymous sub-agent tasks', - systemPrompt: 'Complete the assigned task thoroughly and report the result.', - role: 'worker', - provider, - model, - maxTokensPerRun: 50000, - containerConfig: { - image: process.env['AGENT_CONTAINER_IMAGE'] ?? 'clawix-agent:latest', - cpuLimit: '0.5', - memoryLimit: '256m', - timeoutSeconds: 300, - readOnlyRootfs: false, - allowedMounts: [], - }, - }, + data: this.buildDefaultWorkerData('default-worker', provider, model), + }); + } + + /** + * Find or create a default worker whose provider is permitted by the spawning + * user's policy (`Policy.allowedProviders`). + * + * When the org default provider is allowed (or no restriction is supplied), + * this is exactly {@link findOrCreateDefaultWorker}. When the org default is + * out-of-policy, the first allowed provider that is both configured (enabled + * `ProviderConfig`) and resolvable in the provider registry is substituted, + * backed by a per-provider `default-worker-` row — so anonymous + * sub-agent spawns keep working without forcing the org default into every + * policy, and without ever binding an out-of-policy provider. + */ + async findOrCreateDefaultWorkerForPolicy(allowedProviders?: string[]): Promise { + const defaultProviderConfig = await this.prisma.providerConfig.findFirst({ + where: { isDefault: true, isEnabled: true }, + }); + if (!defaultProviderConfig) { + throw new Error( + 'No default provider configured. Set isDefault=true on a ProviderConfig row, ' + + 'or configure DEFAULT_PROVIDER and re-run bootstrap.', + ); + } + const orgDefault = defaultProviderConfig.provider; + + // No restriction context, or the org default is permitted → standard path. + if ( + !allowedProviders || + allowedProviders.length === 0 || + allowedProviders.includes(orgDefault) + ) { + return this.findOrCreateDefaultWorker(); + } + + // Restricted: the org default is out-of-policy. Substitute the first allowed + // provider that is both configured and resolvable in the registry. + for (const provider of allowedProviders) { + const config = await this.prisma.providerConfig.findFirst({ + where: { provider, isEnabled: true }, + }); + if (!config) { + continue; + } + const model = this.tryResolveModelForProvider(provider); + if (!model) { + continue; + } + return this.findOrCreateNamedDefaultWorker(`default-worker-${provider}`, provider, model); + } + + throw new Error( + `None of your policy's allowed providers (${allowedProviders.join(', ')}) is configured ` + + 'for anonymous sub-agent spawns. Ask an admin to enable one, or spawn a named worker agent.', + ); + } + + /** Find or create a per-provider default worker row by exact name. */ + private async findOrCreateNamedDefaultWorker( + name: string, + provider: string, + model: string, + ): Promise { + const existing = await this.prisma.agentDefinition.findFirst({ + where: { name, role: 'worker' }, + }); + if (existing) { + return existing; + } + return this.prisma.agentDefinition.create({ + data: this.buildDefaultWorkerData(name, provider, model), }); } + /** Shared create payload for anonymous default-worker definitions. */ + private buildDefaultWorkerData( + name: string, + provider: string, + model: string, + ): Prisma.AgentDefinitionCreateInput { + return { + name, + description: 'Default worker agent for anonymous sub-agent tasks', + systemPrompt: 'Complete the assigned task thoroughly and report the result.', + role: 'worker', + provider, + model, + maxTokensPerRun: 50000, + containerConfig: { + image: process.env['AGENT_CONTAINER_IMAGE'] ?? 'clawix-agent:latest', + cpuLimit: '0.5', + memoryLimit: '256m', + timeoutSeconds: 300, + readOnlyRootfs: false, + allowedMounts: [], + }, + }; + } + + /** Resolve a provider's default model from the registry; null if unavailable. */ + private tryResolveModelForProvider(provider: string): string | null { + try { + return getProviderSpec(provider).defaultModel || null; + } catch { + return null; + } + } + private async resolveDefaultWorkerProviderModel(): Promise<{ provider: string; model: string; diff --git a/packages/api/src/engine/__tests__/spawn-tool.test.ts b/packages/api/src/engine/__tests__/spawn-tool.test.ts index 0b44132..7d70314 100644 --- a/packages/api/src/engine/__tests__/spawn-tool.test.ts +++ b/packages/api/src/engine/__tests__/spawn-tool.test.ts @@ -8,12 +8,12 @@ import type { AgentRunRepository } from '../../db/agent-run.repository.js'; // Minimal stub shapes — only the methods used by the spawn tool. function makeAgentDefRepo(overrides?: { findByName?: ReturnType; - findOrCreateDefaultWorker?: ReturnType; -}): Pick { + findOrCreateDefaultWorkerForPolicy?: ReturnType; +}): Pick { return { findByName: overrides?.findByName ?? vi.fn().mockResolvedValue(null), - findOrCreateDefaultWorker: - overrides?.findOrCreateDefaultWorker ?? + findOrCreateDefaultWorkerForPolicy: + overrides?.findOrCreateDefaultWorkerForPolicy ?? vi.fn().mockResolvedValue({ id: 'def-default', name: 'default-worker', @@ -141,11 +141,10 @@ describe('spawn tool — named spawn', () => { }); describe('spawn tool — anonymous spawn', () => { - it('uses default-worker when agent_name is omitted', async () => { + it('uses the policy-aware default worker when agent_name is omitted', async () => { const defaultWorker = { id: 'def-default', name: 'default-worker', role: 'worker' }; - const agentDefRepo = makeAgentDefRepo({ - findOrCreateDefaultWorker: vi.fn().mockResolvedValue(defaultWorker), - }); + const findOrCreateDefaultWorkerForPolicy = vi.fn().mockResolvedValue(defaultWorker); + const agentDefRepo = makeAgentDefRepo({ findOrCreateDefaultWorkerForPolicy }); const agentRunRepo = makeAgentRunRepo(defaultRun); const tool = createSpawnTool( @@ -160,12 +159,36 @@ describe('spawn tool — anonymous spawn', () => { const result = await tool.execute({ prompt: 'Do something generic' }); expect(result.isError).toBe(false); - expect(agentDefRepo.findOrCreateDefaultWorker).toHaveBeenCalledOnce(); + expect(findOrCreateDefaultWorkerForPolicy).toHaveBeenCalledOnce(); expect(agentRunRepo.create).toHaveBeenCalledWith( expect.objectContaining({ agentDefinitionId: 'def-default' }), ); expect(result.output).toContain('default-worker'); }); + + it('passes the caller’s policy allowedProviders to the default-worker resolver', async () => { + const findOrCreateDefaultWorkerForPolicy = vi + .fn() + .mockResolvedValue({ id: 'def-default', name: 'default-worker', role: 'worker' }); + const agentDefRepo = makeAgentDefRepo({ findOrCreateDefaultWorkerForPolicy }); + const agentRunRepo = makeAgentRunRepo(defaultRun); + + const tool = createSpawnTool( + agentDefRepo as AgentDefinitionRepository, + agentRunRepo as AgentRunRepository, + null, + 'session-abc', + 'parent-run-0', + 'user-1', + undefined, + undefined, + ['openai', 'gemini'], + ); + + await tool.execute({ prompt: 'Do something generic' }); + + expect(findOrCreateDefaultWorkerForPolicy).toHaveBeenCalledWith(['openai', 'gemini']); + }); }); describe('spawn tool — task executor integration', () => { diff --git a/packages/api/src/engine/tools/spawn.ts b/packages/api/src/engine/tools/spawn.ts index 20c3010..03c5545 100644 --- a/packages/api/src/engine/tools/spawn.ts +++ b/packages/api/src/engine/tools/spawn.ts @@ -46,6 +46,8 @@ interface TaskSubmitter { * @param userId - The ID of the user initiating the spawn. * @param budgetTracker - Optional shared budget tracker inherited by the sub-agent. * @param subAgentTimeoutMs - Wall-clock cap (ms) for the spawned run, from the user's policy. + * @param allowedProviders - The caller's policy `allowedProviders`; used to resolve an + * in-policy provider for anonymous (default-worker) spawns. */ export function createSpawnTool( agentDefRepo: AgentDefinitionRepository, @@ -56,6 +58,7 @@ export function createSpawnTool( userId: string, budgetTracker?: BudgetTracker, subAgentTimeoutMs?: number, + allowedProviders?: string[], ): Tool { return { name: 'spawn', @@ -113,8 +116,10 @@ export function createSpawnTool( agentDefId = agentDef.id; displayName = agentName; } else { - // Anonymous spawn: use the default worker - const defaultWorker = await agentDefRepo.findOrCreateDefaultWorker(); + // Anonymous spawn: use a default worker whose provider is permitted by + // the caller's policy (auto-substituted if the org default is out-of-policy). + const defaultWorker = + await agentDefRepo.findOrCreateDefaultWorkerForPolicy(allowedProviders); agentDefId = defaultWorker.id; displayName = 'default-worker'; }