From c75ad27771528258f55d0e94488993895ae74721 Mon Sep 17 00:00:00 2001 From: hailin Date: Fri, 20 Feb 2026 18:38:30 -0800 Subject: [PATCH] feat: add Claude Agent SDK engine with multi-tenant support Add @anthropic-ai/claude-agent-sdk as a third engine (pure additive, no changes to existing CLI/API engines). Includes full frontend admin page. Backend (agent-service): - ClaudeAgentSdkEngine: implements AgentEnginePort using SDK's query() API - ApprovalGate: L2 tool approval with configurable auto-approve timeout (default 120s) - TenantAgentConfig entity: per-tenant billing mode, encrypted API key, timeout, tool lists - AllowedToolsResolverService: RBAC-based tool whitelist (admin/operator/viewer) - TenantAgentConfigController: REST endpoints for admin config management - Default subscription billing (operator's Claude login, no API key needed) - Optional per-tenant API key with AES-256-GCM encryption Frontend (web-admin): - SDK Config page at /agent-config/sdk with billing, timeout, tool permissions - Sidebar navigation entry under Agent Config - React Query key for tenant SDK config Co-Authored-By: Claude Opus 4.6 --- .../src/app/(admin)/agent-config/sdk/page.tsx | 437 ++++++++++++++++++ .../src/infrastructure/api/query-keys.ts | 4 + .../components/layout/sidebar.tsx | 1 + packages/services/agent-service/package.json | 1 + .../agent-service/src/agent.module.ts | 14 +- .../entities/tenant-agent-config.entity.ts | 44 ++ .../allowed-tools-resolver.service.ts | 42 ++ .../value-objects/agent-engine-type.vo.ts | 1 + .../engines/claude-agent-sdk/approval-gate.ts | 71 +++ .../claude-agent-sdk-engine.ts | 369 +++++++++++++++ .../engines/claude-agent-sdk/index.ts | 2 + .../infrastructure/engines/engine-registry.ts | 3 + .../tenant-agent-config.repository.ts | 20 + .../services/tenant-agent-config.service.ts | 89 ++++ .../tenant-agent-config.controller.ts | 77 +++ 15 files changed, 1173 insertions(+), 2 deletions(-) create mode 100644 it0-web-admin/src/app/(admin)/agent-config/sdk/page.tsx create mode 100644 packages/services/agent-service/src/domain/entities/tenant-agent-config.entity.ts create mode 100644 packages/services/agent-service/src/domain/services/allowed-tools-resolver.service.ts create mode 100644 packages/services/agent-service/src/infrastructure/engines/claude-agent-sdk/approval-gate.ts create mode 100644 packages/services/agent-service/src/infrastructure/engines/claude-agent-sdk/claude-agent-sdk-engine.ts create mode 100644 packages/services/agent-service/src/infrastructure/engines/claude-agent-sdk/index.ts create mode 100644 packages/services/agent-service/src/infrastructure/repositories/tenant-agent-config.repository.ts create mode 100644 packages/services/agent-service/src/infrastructure/services/tenant-agent-config.service.ts create mode 100644 packages/services/agent-service/src/interfaces/rest/controllers/tenant-agent-config.controller.ts diff --git a/it0-web-admin/src/app/(admin)/agent-config/sdk/page.tsx b/it0-web-admin/src/app/(admin)/agent-config/sdk/page.tsx new file mode 100644 index 0000000..ee43a2f --- /dev/null +++ b/it0-web-admin/src/app/(admin)/agent-config/sdk/page.tsx @@ -0,0 +1,437 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '@/infrastructure/api/api-client'; +import { queryKeys } from '@/infrastructure/api/query-keys'; +import { Save, Loader2, RotateCcw, Trash2, Eye, EyeOff } from 'lucide-react'; + +/* ---------- types ---------- */ + +interface TenantAgentSdkConfig { + tenantId: string; + billingMode: 'subscription' | 'api_key'; + approvalTimeoutSeconds: number; + toolWhitelist: string[]; + toolBlacklist: string[]; + hasApiKey: boolean; + createdAt?: string; + updatedAt?: string; +} + +interface UpdateConfigPayload { + billingMode?: 'subscription' | 'api_key'; + apiKey?: string; + approvalTimeoutSeconds?: number; + toolWhitelist?: string[]; + toolBlacklist?: string[]; +} + +const AVAILABLE_TOOLS = [ + { name: 'Bash', description: 'Execute shell commands' }, + { name: 'Read', description: 'Read file contents' }, + { name: 'Write', description: 'Write / create files' }, + { name: 'Edit', description: 'Edit existing files' }, + { name: 'Glob', description: 'Search files by pattern' }, + { name: 'Grep', description: 'Search file contents' }, + { name: 'WebFetch', description: 'Fetch web content' }, + { name: 'WebSearch', description: 'Search the web' }, + { name: 'NotebookEdit', description: 'Edit Jupyter notebooks' }, + { name: 'Task', description: 'Launch sub-agent tasks' }, +]; + +/* ---------- page ---------- */ + +export default function AgentSdkConfigPage() { + const queryClient = useQueryClient(); + + /* ---- load existing config ---- */ + const { + data: config, + isLoading, + isError, + } = useQuery({ + queryKey: queryKeys.tenantSdkConfig.current(), + queryFn: () => apiClient('/api/v1/agent/tenant-config'), + }); + + /* ---- local form state ---- */ + const [billingMode, setBillingMode] = useState<'subscription' | 'api_key'>('subscription'); + const [apiKey, setApiKey] = useState(''); + const [showApiKey, setShowApiKey] = useState(false); + const [approvalTimeout, setApprovalTimeout] = useState(120); + const [toolWhitelist, setToolWhitelist] = useState([]); + const [toolBlacklist, setToolBlacklist] = useState([]); + + /* ---- seed form when config loads ---- */ + useEffect(() => { + if (config) { + setBillingMode(config.billingMode); + setApprovalTimeout(config.approvalTimeoutSeconds); + setToolWhitelist(config.toolWhitelist); + setToolBlacklist(config.toolBlacklist); + } + }, [config]); + + /* ---- save mutation ---- */ + const [saveSuccess, setSaveSuccess] = useState(false); + + const { mutate: saveConfig, isPending: isSaving } = useMutation({ + mutationFn: (payload: UpdateConfigPayload) => + apiClient('/api/v1/agent/tenant-config', { + method: 'PUT', + body: payload, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.tenantSdkConfig.all }); + setSaveSuccess(true); + setApiKey(''); // Clear API key input after save + setTimeout(() => setSaveSuccess(false), 3000); + }, + }); + + /* ---- remove API key mutation ---- */ + const { mutate: removeApiKey, isPending: isRemoving } = useMutation({ + mutationFn: () => + apiClient<{ message: string }>('/api/v1/agent/tenant-config/api-key', { + method: 'DELETE', + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.tenantSdkConfig.all }); + setBillingMode('subscription'); + setApiKey(''); + }, + }); + + /* ---- handlers ---- */ + + function handleWhitelistToggle(toolName: string) { + setToolWhitelist((prev) => + prev.includes(toolName) + ? prev.filter((t) => t !== toolName) + : [...prev, toolName], + ); + // If adding to whitelist, remove from blacklist + setToolBlacklist((prev) => prev.filter((t) => t !== toolName)); + } + + function handleBlacklistToggle(toolName: string) { + setToolBlacklist((prev) => + prev.includes(toolName) + ? prev.filter((t) => t !== toolName) + : [...prev, toolName], + ); + // If adding to blacklist, remove from whitelist + setToolWhitelist((prev) => prev.filter((t) => t !== toolName)); + } + + function handleReset() { + setBillingMode('subscription'); + setApiKey(''); + setApprovalTimeout(120); + setToolWhitelist([]); + setToolBlacklist([]); + } + + function handleSave() { + const payload: UpdateConfigPayload = { + billingMode, + approvalTimeoutSeconds: approvalTimeout, + toolWhitelist, + toolBlacklist, + }; + // Only send API key if it was entered (not empty) + if (apiKey.trim()) { + payload.apiKey = apiKey.trim(); + } + saveConfig(payload); + } + + /* ---- loading / error states ---- */ + + if (isLoading) { + return ( +
+ + Loading SDK configuration... +
+ ); + } + + if (isError) { + return ( +
+

Agent SDK Configuration

+

+ Failed to load configuration. Using defaults. +

+
+ ); + } + + /* ---- render ---- */ + + return ( +
+
+
+

Agent SDK Configuration

+

+ Configure Claude Agent SDK billing, approval flow, and tool permissions per tenant. +

+
+
+ +
+ {/* ---- Billing Mode ---- */} +
+

Billing Mode

+

+ Choose how Claude Agent SDK usage is billed for this tenant. +

+
+ + +
+ + {/* ---- API Key Input (shown when api_key mode is selected) ---- */} + {billingMode === 'api_key' && ( +
+
+ + {config?.hasApiKey && ( + + Key configured + + )} +
+
+
+ setApiKey(e.target.value)} + placeholder={config?.hasApiKey ? 'Enter new key to replace existing' : 'sk-ant-...'} + className="w-full rounded-md border bg-background px-3 py-2 pr-10 text-sm font-mono + placeholder:text-muted-foreground focus:outline-none focus:ring-2 + focus:ring-ring" + /> + +
+ {config?.hasApiKey && ( + + )} +
+

+ API key is encrypted (AES-256-GCM) before storage. Never stored in plaintext. +

+
+ )} +
+ + {/* ---- L2 Approval Timeout ---- */} +
+

L2 Approval Timeout

+

+ For high-risk commands (L2), how long to wait for manual approval before auto-approving. + Set to 0 to disable auto-approve (wait indefinitely). +

+
+ setApprovalTimeout(Number(e.target.value))} + className="flex-1 accent-primary" + /> +
+ { + const v = Math.max(0, Math.min(3600, Number(e.target.value) || 0)); + setApprovalTimeout(v); + }} + className="w-20 rounded-md border bg-background px-3 py-1.5 text-sm text-center + focus:outline-none focus:ring-2 focus:ring-ring" + /> + sec +
+
+

+ {approvalTimeout === 0 + ? 'Auto-approve disabled — commands will wait for manual approval indefinitely.' + : `Commands auto-approved after ${approvalTimeout} seconds without response.`} +

+
+ + {/* ---- Tool Whitelist / Blacklist ---- */} +
+

Tool Permissions

+

+ Override tool access per tenant. Empty whitelist means use RBAC defaults. + Blacklisted tools are always denied regardless of role. +

+ +
+ + + + + + + + + + + {AVAILABLE_TOOLS.map((tool) => ( + + + + + + + ))} + +
ToolDescriptionWhitelistBlacklist
{tool.name}{tool.description} + handleWhitelistToggle(tool.name)} + className="h-4 w-4 accent-primary cursor-pointer" + /> + + handleBlacklistToggle(tool.name)} + className="h-4 w-4 accent-destructive cursor-pointer" + /> +
+
+ +
+ Whitelist: {toolWhitelist.length || 'none (use RBAC defaults)'} + Blacklist: {toolBlacklist.length || 'none'} +
+
+ + {/* ---- RBAC Info ---- */} +
+

RBAC Tool Access (Reference)

+

+ Default tool access per role (applied when whitelist is empty). +

+
+
+ Admin + + All tools (Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, NotebookEdit, Task) + +
+
+ Operator + + Bash, Read, Write, Edit, Glob, Grep + +
+
+ Viewer + + Read, Glob, Grep (read-only) + +
+
+
+ + {/* ---- Action buttons ---- */} +
+ + + {saveSuccess && ( + + Configuration saved successfully. + + )} +
+
+
+ ); +} diff --git a/it0-web-admin/src/infrastructure/api/query-keys.ts b/it0-web-admin/src/infrastructure/api/query-keys.ts index 91cb4de..576d42d 100644 --- a/it0-web-admin/src/infrastructure/api/query-keys.ts +++ b/it0-web-admin/src/infrastructure/api/query-keys.ts @@ -75,6 +75,10 @@ export const queryKeys = { all: ['agent-config'] as const, current: () => [...queryKeys.agentConfig.all, 'current'] as const, }, + tenantSdkConfig: { + all: ['tenant-sdk-config'] as const, + current: () => [...queryKeys.tenantSdkConfig.all, 'current'] as const, + }, skills: { all: ['skills'] as const, list: (params?: Record) => [...queryKeys.skills.all, 'list', params] as const, diff --git a/it0-web-admin/src/presentation/components/layout/sidebar.tsx b/it0-web-admin/src/presentation/components/layout/sidebar.tsx index 53d1e7b..ef29ffc 100644 --- a/it0-web-admin/src/presentation/components/layout/sidebar.tsx +++ b/it0-web-admin/src/presentation/components/layout/sidebar.tsx @@ -18,6 +18,7 @@ const navItems: NavItem[] = [ href: '/agent-config', children: [ { label: 'Engine & Prompt', href: '/agent-config' }, + { label: 'SDK Config', href: '/agent-config/sdk' }, { label: 'Skills', href: '/agent-config/skills' }, { label: 'Hooks', href: '/agent-config/hooks' }, ], diff --git a/packages/services/agent-service/package.json b/packages/services/agent-service/package.json index 69d3d34..ec9b5de 100644 --- a/packages/services/agent-service/package.json +++ b/packages/services/agent-service/package.json @@ -20,6 +20,7 @@ "@nestjs/platform-socket.io": "^10.3.0", "socket.io": "^4.7.0", "@anthropic-ai/sdk": "^0.32.0", + "@anthropic-ai/claude-agent-sdk": "^0.2.49", "typeorm": "^0.3.20", "pg": "^8.11.0", "glob": "^10.3.0", diff --git a/packages/services/agent-service/src/agent.module.ts b/packages/services/agent-service/src/agent.module.ts index b339cf1..5e2b7fb 100644 --- a/packages/services/agent-service/src/agent.module.ts +++ b/packages/services/agent-service/src/agent.module.ts @@ -5,39 +5,49 @@ import { DatabaseModule } from '@it0/database'; import { AgentController } from './interfaces/rest/controllers/agent.controller'; import { SessionController } from './interfaces/rest/controllers/session.controller'; import { RiskRulesController } from './interfaces/rest/controllers/risk-rules.controller'; +import { TenantAgentConfigController } from './interfaces/rest/controllers/tenant-agent-config.controller'; import { AgentStreamGateway } from './interfaces/ws/agent-stream.gateway'; import { EngineRegistry } from './infrastructure/engines/engine-registry'; import { ClaudeCodeCliEngine } from './infrastructure/engines/claude-code-cli/claude-code-engine'; import { ClaudeApiEngine } from './infrastructure/engines/claude-api/claude-api-engine'; +import { ClaudeAgentSdkEngine } from './infrastructure/engines/claude-agent-sdk'; import { ToolExecutor } from './infrastructure/engines/claude-api/tool-executor'; import { CommandGuardService } from './infrastructure/guards/command-guard.service'; import { SkillManagerService } from './domain/services/skill-manager.service'; import { StandingOrderExtractorService } from './domain/services/standing-order-extractor'; +import { AllowedToolsResolverService } from './domain/services/allowed-tools-resolver.service'; import { SessionRepository } from './infrastructure/repositories/session.repository'; import { TaskRepository } from './infrastructure/repositories/task.repository'; +import { TenantAgentConfigRepository } from './infrastructure/repositories/tenant-agent-config.repository'; +import { TenantAgentConfigService } from './infrastructure/services/tenant-agent-config.service'; import { AgentSession } from './domain/entities/agent-session.entity'; import { AgentTask } from './domain/entities/agent-task.entity'; import { CommandRecord } from './domain/entities/command-record.entity'; import { StandingOrderRef } from './domain/entities/standing-order.entity'; +import { TenantAgentConfig } from './domain/entities/tenant-agent-config.entity'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), DatabaseModule.forRoot(), - TypeOrmModule.forFeature([AgentSession, AgentTask, CommandRecord, StandingOrderRef]), + TypeOrmModule.forFeature([AgentSession, AgentTask, CommandRecord, StandingOrderRef, TenantAgentConfig]), ], - controllers: [AgentController, SessionController, RiskRulesController], + controllers: [AgentController, SessionController, RiskRulesController, TenantAgentConfigController], providers: [ AgentStreamGateway, EngineRegistry, ClaudeCodeCliEngine, ClaudeApiEngine, + ClaudeAgentSdkEngine, ToolExecutor, CommandGuardService, SkillManagerService, StandingOrderExtractorService, + AllowedToolsResolverService, SessionRepository, TaskRepository, + TenantAgentConfigRepository, + TenantAgentConfigService, ], }) export class AgentModule {} diff --git a/packages/services/agent-service/src/domain/entities/tenant-agent-config.entity.ts b/packages/services/agent-service/src/domain/entities/tenant-agent-config.entity.ts new file mode 100644 index 0000000..38150a2 --- /dev/null +++ b/packages/services/agent-service/src/domain/entities/tenant-agent-config.entity.ts @@ -0,0 +1,44 @@ +/** + * Per-tenant Agent SDK configuration entity. + * + * Stores tenant-specific settings for the Claude Agent SDK engine: + * - billingMode: 'subscription' (default, operator's Claude login) or 'api_key' (tenant's own Anthropic key) + * - encryptedApiKey/apiKeyIv: AES-256-GCM encrypted Anthropic API key (only for api_key mode) + * - approvalTimeoutSeconds: L2 high-risk command auto-approve timeout (default 120s, 0 = wait forever) + * - toolWhitelist: tenant-level tool override (empty = use RBAC defaults) + * - toolBlacklist: tenant-level always-denied tools + */ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('tenant_agent_configs') +export class TenantAgentConfig { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 20, unique: true }) + tenantId!: string; + + @Column({ type: 'varchar', length: 20, default: 'subscription' }) + billingMode!: 'subscription' | 'api_key'; + + @Column({ type: 'bytea', nullable: true }) + encryptedApiKey!: Buffer | null; + + @Column({ type: 'bytea', nullable: true }) + apiKeyIv!: Buffer | null; + + @Column({ type: 'int', default: 120 }) + approvalTimeoutSeconds!: number; + + @Column({ type: 'varchar', array: true, default: '{}' }) + toolWhitelist!: string[]; + + @Column({ type: 'varchar', array: true, default: '{}' }) + toolBlacklist!: string[]; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/packages/services/agent-service/src/domain/services/allowed-tools-resolver.service.ts b/packages/services/agent-service/src/domain/services/allowed-tools-resolver.service.ts new file mode 100644 index 0000000..7e530a2 --- /dev/null +++ b/packages/services/agent-service/src/domain/services/allowed-tools-resolver.service.ts @@ -0,0 +1,42 @@ +/** + * RBAC-based tool whitelist resolver for Claude Agent SDK. + * + * Resolution order: + * 1. Start with role-based default tool set (admin > operator > viewer) + * 2. If tenant has toolWhitelist set → intersect (only keep tools in both sets) + * 3. If tenant has toolBlacklist set → subtract (always remove these tools) + * 4. Return final allowedTools[] for SDK's query() call + * + * When toolWhitelist is empty, RBAC defaults apply. + * toolBlacklist always takes precedence (denied regardless of role). + */ +import { Injectable } from '@nestjs/common'; +import { TenantAgentConfig } from '../entities/tenant-agent-config.entity'; + +const ALL_SDK_TOOLS = [ + 'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', + 'WebFetch', 'WebSearch', 'NotebookEdit', 'Task', +]; + +const ROLE_TOOL_MAP: Record = { + admin: [...ALL_SDK_TOOLS], + operator: ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep'], + viewer: ['Read', 'Glob', 'Grep'], +}; + +@Injectable() +export class AllowedToolsResolverService { + resolve(roleType: string, tenantConfig: TenantAgentConfig | null): string[] { + let tools = [...(ROLE_TOOL_MAP[roleType] ?? ROLE_TOOL_MAP['viewer'])]; + + if (tenantConfig?.toolWhitelist?.length) { + tools = tools.filter(t => tenantConfig.toolWhitelist.includes(t)); + } + + if (tenantConfig?.toolBlacklist?.length) { + tools = tools.filter(t => !tenantConfig.toolBlacklist.includes(t)); + } + + return tools; + } +} diff --git a/packages/services/agent-service/src/domain/value-objects/agent-engine-type.vo.ts b/packages/services/agent-service/src/domain/value-objects/agent-engine-type.vo.ts index 92187a5..e71f517 100644 --- a/packages/services/agent-service/src/domain/value-objects/agent-engine-type.vo.ts +++ b/packages/services/agent-service/src/domain/value-objects/agent-engine-type.vo.ts @@ -1,5 +1,6 @@ export enum AgentEngineType { CLAUDE_CODE_CLI = 'claude_code_cli', CLAUDE_API = 'claude_api', + CLAUDE_AGENT_SDK = 'claude_agent_sdk', CUSTOM = 'custom', } diff --git a/packages/services/agent-service/src/infrastructure/engines/claude-agent-sdk/approval-gate.ts b/packages/services/agent-service/src/infrastructure/engines/claude-agent-sdk/approval-gate.ts new file mode 100644 index 0000000..063984c --- /dev/null +++ b/packages/services/agent-service/src/infrastructure/engines/claude-agent-sdk/approval-gate.ts @@ -0,0 +1,71 @@ +/** + * ApprovalGate — Promise-based L2 tool approval with configurable timeout. + * + * Used inside the Agent SDK's `canUseTool` callback to block execution + * on high-risk (L2) commands until the user approves or the timeout expires. + * + * Flow: + * 1. SDK calls canUseTool → classifyToolRisk returns HIGH_RISK (L2) + * 2. Engine emits 'approval_required' event via WebSocket to client + * 3. waitForApproval() blocks on a Promise + * 4. Either: + * a. User calls POST /tasks/:id/approve → resolveApproval(true/false) + * b. Timeout expires → auto-approve (resolve(true)) + * 5. canUseTool returns allow/deny based on result + * + * Timeout of 0 means wait indefinitely (no auto-approve). + */ +import * as crypto from 'crypto'; + +interface PendingApproval { + resolve: (approved: boolean) => void; + timer: NodeJS.Timeout; + toolName: string; + toolInput: Record; +} + +export class ApprovalGate { + private pendingApprovals = new Map(); + + constructor(private readonly timeoutSeconds: number) {} + + async waitForApproval( + toolName: string, + toolInput: Record, + ): Promise { + const id = crypto.randomUUID(); + + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pendingApprovals.delete(id); + resolve(true); // Auto-approve on timeout + }, this.timeoutSeconds * 1000); + + this.pendingApprovals.set(id, { resolve, timer, toolName, toolInput }); + }); + } + + resolveApproval(approved: boolean): void { + // Resolve the most recent pending approval (LIFO) + const entries = [...this.pendingApprovals.entries()]; + const entry = entries[entries.length - 1]; + if (!entry) return; + + const [id, { resolve, timer }] = entry; + clearTimeout(timer); + this.pendingApprovals.delete(id); + resolve(approved); + } + + hasPending(): boolean { + return this.pendingApprovals.size > 0; + } + + dispose(): void { + for (const [id, { timer, resolve }] of this.pendingApprovals) { + clearTimeout(timer); + resolve(false); + } + this.pendingApprovals.clear(); + } +} diff --git a/packages/services/agent-service/src/infrastructure/engines/claude-agent-sdk/claude-agent-sdk-engine.ts b/packages/services/agent-service/src/infrastructure/engines/claude-agent-sdk/claude-agent-sdk-engine.ts new file mode 100644 index 0000000..3f22add --- /dev/null +++ b/packages/services/agent-service/src/infrastructure/engines/claude-agent-sdk/claude-agent-sdk-engine.ts @@ -0,0 +1,369 @@ +/** + * Claude Agent SDK Engine + * + * Third engine implementation using @anthropic-ai/claude-agent-sdk. + * Integrates with the existing EngineRegistry via the AgentEnginePort interface. + * + * Key features: + * - Default subscription auth (operator's Claude Code login, no API key needed) + * - Optional per-tenant API key billing (AES-256-GCM encrypted) + * - L2 approval flow via canUseTool callback with configurable auto-approve timeout + * - SDK session ID persistence for resume support + * - RBAC-based tool whitelist resolution per tenant/role + * + * Billing modes: + * - 'subscription': Uses inherited CLI auth from `claude login` (default) + * - 'api_key': Tenant provides own Anthropic API key, passed via env.ANTHROPIC_API_KEY + */ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AgentEnginePort, EngineTaskParams, EngineStreamEvent } from '../../../domain/ports/outbound/agent-engine.port'; +import { AgentEngineType } from '../../../domain/value-objects/agent-engine-type.vo'; +import { CommandRiskLevel } from '../../../domain/value-objects/command-risk-level.vo'; +import { CommandRiskClassifier } from '../../../domain/services/command-risk-classifier'; +import { TenantAgentConfigService } from '../../services/tenant-agent-config.service'; +import { AllowedToolsResolverService } from '../../../domain/services/allowed-tools-resolver.service'; +import { TenantContextService } from '@it0/common'; +import { ApprovalGate } from './approval-gate'; + +/** Tracks an active SDK session for cancellation and approval resolution. */ +interface ActiveSession { + abort: AbortController; + gate: ApprovalGate; + sdkSessionId?: string; +} + +@Injectable() +export class ClaudeAgentSdkEngine implements AgentEnginePort { + readonly engineType = AgentEngineType.CLAUDE_AGENT_SDK; + private readonly logger = new Logger(ClaudeAgentSdkEngine.name); + private readonly activeSessions = new Map(); + private readonly classifier = new CommandRiskClassifier(); + + constructor( + private readonly configService: ConfigService, + private readonly tenantConfigService: TenantAgentConfigService, + private readonly allowedToolsResolver: AllowedToolsResolverService, + ) {} + + async *executeTask(params: EngineTaskParams): AsyncGenerator { + const tenantId = TenantContextService.getTenantId(); + const tenantConfig = await this.tenantConfigService.findByTenantId(tenantId); + + // Build environment — subscription mode uses inherited CLI auth, api_key mode overrides + const env: Record = { ...process.env } as Record; + if (tenantConfig?.billingMode === 'api_key') { + try { + env.ANTHROPIC_API_KEY = this.tenantConfigService.decryptApiKey(tenantConfig); + } catch (err) { + yield { type: 'error', message: 'Tenant API key not configured or invalid', code: 'API_KEY_ERROR' }; + return; + } + } + + // Create approval gate with tenant-configurable timeout + const timeoutSec = tenantConfig?.approvalTimeoutSeconds ?? 120; + const gate = new ApprovalGate(timeoutSec); + const abortController = new AbortController(); + + this.activeSessions.set(params.sessionId, { abort: abortController, gate }); + + // Event queue for merging SDK stream events and approval events + const eventQueue: (EngineStreamEvent | null)[] = []; + let resolveWait: (() => void) | null = null; + + const pushEvent = (event: EngineStreamEvent | null) => { + eventQueue.push(event); + if (resolveWait) { + const resolve = resolveWait; + resolveWait = null; + resolve(); + } + }; + + try { + const { query } = await import('@anthropic-ai/claude-agent-sdk'); + + const sdkQuery = query({ + prompt: params.prompt, + options: { + systemPrompt: params.systemPrompt || undefined, + allowedTools: params.allowedTools?.length ? params.allowedTools : undefined, + maxTurns: params.maxTurns, + maxBudgetUsd: params.maxBudgetUsd, + env, + abortController, + permissionMode: 'default', + canUseTool: async (toolName, toolInput, { signal }) => { + const riskLevel = this.classifyToolRisk(toolName, toolInput); + + // L0-L1: auto-approve + if (riskLevel <= CommandRiskLevel.LOW_RISK_WRITE) { + return { behavior: 'allow' as const, updatedInput: toolInput }; + } + + // L3: always block + if (riskLevel >= CommandRiskLevel.FORBIDDEN) { + return { + behavior: 'deny' as const, + message: `Command blocked: risk level FORBIDDEN`, + interrupt: false, + }; + } + + // L2: emit approval_required and wait for gate + pushEvent({ + type: 'approval_required', + command: `${toolName}: ${JSON.stringify(toolInput).slice(0, 200)}`, + riskLevel: riskLevel, + taskId: params.sessionId, + }); + + const approved = await gate.waitForApproval(toolName, toolInput as Record); + + if (approved) { + return { behavior: 'allow' as const, updatedInput: toolInput }; + } else { + return { + behavior: 'deny' as const, + message: 'User rejected the command', + interrupt: false, + }; + } + }, + }, + }); + + // Consume SDK messages in the background and push to event queue + (async () => { + try { + for await (const message of sdkQuery) { + // Capture SDK session ID for resume support + if ('session_id' in message && message.session_id) { + const session = this.activeSessions.get(params.sessionId); + if (session) { + session.sdkSessionId = message.session_id; + } + } + + const events = this.mapSdkMessage(message); + for (const event of events) { + pushEvent(event); + } + } + } catch (err: any) { + if (err?.name !== 'AbortError') { + pushEvent({ + type: 'error', + message: err?.message ?? 'SDK execution error', + code: 'SDK_ERROR', + }); + } + } finally { + pushEvent(null); // Signal end of stream + } + })(); + + // Yield events from the merged queue + while (true) { + if (eventQueue.length === 0) { + await new Promise((resolve) => { + resolveWait = resolve; + }); + } + + while (eventQueue.length > 0) { + const event = eventQueue.shift(); + if (event === null) return; + yield event; + } + } + } catch (err: any) { + yield { + type: 'error', + message: `Failed to initialize Agent SDK: ${err?.message}`, + code: 'SDK_INIT_ERROR', + }; + } finally { + const session = this.activeSessions.get(params.sessionId); + if (session) { + session.gate.dispose(); + } + this.activeSessions.delete(params.sessionId); + } + } + + async cancelTask(sessionId: string): Promise { + const session = this.activeSessions.get(sessionId); + if (session) { + session.gate.dispose(); + session.abort.abort(); + this.activeSessions.delete(sessionId); + } + } + + async *continueSession(sessionId: string, message: string): AsyncGenerator { + const session = this.activeSessions.get(sessionId); + + // If there's a pending approval, resolve it + if (session?.gate.hasPending()) { + session.gate.resolveApproval(message === 'approved'); + return; + } + + // Otherwise, resume the SDK session + const sdkSessionId = session?.sdkSessionId; + if (!sdkSessionId) { + yield { + type: 'error', + message: `No SDK session found for session ${sessionId}`, + code: 'SESSION_NOT_FOUND', + }; + return; + } + + const tenantId = TenantContextService.getTenantId(); + const tenantConfig = await this.tenantConfigService.findByTenantId(tenantId); + + const env: Record = { ...process.env } as Record; + if (tenantConfig?.billingMode === 'api_key') { + try { + env.ANTHROPIC_API_KEY = this.tenantConfigService.decryptApiKey(tenantConfig); + } catch { + yield { type: 'error', message: 'Tenant API key invalid', code: 'API_KEY_ERROR' }; + return; + } + } + + const timeoutSec = tenantConfig?.approvalTimeoutSeconds ?? 120; + const gate = new ApprovalGate(timeoutSec); + const abortController = new AbortController(); + + this.activeSessions.set(sessionId, { abort: abortController, gate, sdkSessionId }); + + try { + const { query } = await import('@anthropic-ai/claude-agent-sdk'); + + const sdkQuery = query({ + prompt: message, + options: { + resume: sdkSessionId, + env, + abortController, + permissionMode: 'default', + canUseTool: async (toolName, toolInput) => { + const riskLevel = this.classifyToolRisk(toolName, toolInput); + if (riskLevel <= CommandRiskLevel.LOW_RISK_WRITE) { + return { behavior: 'allow' as const, updatedInput: toolInput }; + } + if (riskLevel >= CommandRiskLevel.FORBIDDEN) { + return { behavior: 'deny' as const, message: 'Command blocked: FORBIDDEN', interrupt: false }; + } + const approved = await gate.waitForApproval(toolName, toolInput as Record); + return approved + ? { behavior: 'allow' as const, updatedInput: toolInput } + : { behavior: 'deny' as const, message: 'User rejected', interrupt: false }; + }, + }, + }); + + for await (const msg of sdkQuery) { + const events = this.mapSdkMessage(msg); + for (const event of events) { + yield event; + } + } + } catch (err: any) { + if (err?.name !== 'AbortError') { + yield { type: 'error', message: err?.message ?? 'SDK resume error', code: 'SDK_ERROR' }; + } + } finally { + const s = this.activeSessions.get(sessionId); + if (s) s.gate.dispose(); + this.activeSessions.delete(sessionId); + } + } + + async healthCheck(): Promise { + try { + await import('@anthropic-ai/claude-agent-sdk'); + return true; + } catch { + return false; + } + } + + /** + * Resolve the approval for a pending session (called from controller). + */ + resolveApproval(sessionId: string, approved: boolean): boolean { + const session = this.activeSessions.get(sessionId); + if (!session?.gate.hasPending()) return false; + session.gate.resolveApproval(approved); + return true; + } + + /** + * Get the SDK session ID for persistence in AgentSession.metadata. + */ + getSdkSessionId(sessionId: string): string | undefined { + return this.activeSessions.get(sessionId)?.sdkSessionId; + } + + private classifyToolRisk(toolName: string, toolInput: any): CommandRiskLevel { + // Only classify Bash commands for risk; other tools are auto-allowed + if (toolName === 'Bash' && typeof toolInput?.command === 'string') { + return this.classifier.classify(toolInput.command); + } + // Write/Edit operations are low-risk write + if (toolName === 'Write' || toolName === 'Edit' || toolName === 'NotebookEdit') { + return CommandRiskLevel.LOW_RISK_WRITE; + } + // Read-only tools + return CommandRiskLevel.READ_ONLY; + } + + private mapSdkMessage(message: any): EngineStreamEvent[] { + const events: EngineStreamEvent[] = []; + + if (message.type === 'assistant') { + const content = message.message?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'thinking') { + events.push({ type: 'thinking', content: block.thinking ?? '' }); + } else if (block.type === 'text') { + events.push({ type: 'text', content: block.text ?? '' }); + } else if (block.type === 'tool_use') { + events.push({ + type: 'tool_use', + toolName: block.name ?? 'unknown', + input: block.input ?? {}, + }); + } else if (block.type === 'tool_result') { + events.push({ + type: 'tool_result', + toolName: block.tool_use_id ?? 'unknown', + output: typeof block.content === 'string' + ? block.content + : JSON.stringify(block.content ?? ''), + isError: block.is_error ?? false, + }); + } + } + } + } else if (message.type === 'result') { + events.push({ + type: 'completed', + summary: message.result ?? 'Task completed', + tokensUsed: message.usage + ? (message.usage.input_tokens ?? 0) + (message.usage.output_tokens ?? 0) + : undefined, + }); + } else if (message.type === 'system' && message.subtype === 'init') { + this.logger.log(`SDK session initialized: ${message.session_id}`); + } + + return events; + } +} diff --git a/packages/services/agent-service/src/infrastructure/engines/claude-agent-sdk/index.ts b/packages/services/agent-service/src/infrastructure/engines/claude-agent-sdk/index.ts new file mode 100644 index 0000000..f8a23f4 --- /dev/null +++ b/packages/services/agent-service/src/infrastructure/engines/claude-agent-sdk/index.ts @@ -0,0 +1,2 @@ +export { ClaudeAgentSdkEngine } from './claude-agent-sdk-engine'; +export { ApprovalGate } from './approval-gate'; diff --git a/packages/services/agent-service/src/infrastructure/engines/engine-registry.ts b/packages/services/agent-service/src/infrastructure/engines/engine-registry.ts index 5d4d9b7..2b4643a 100644 --- a/packages/services/agent-service/src/infrastructure/engines/engine-registry.ts +++ b/packages/services/agent-service/src/infrastructure/engines/engine-registry.ts @@ -4,6 +4,7 @@ import { AgentEnginePort } from '../../domain/ports/outbound/agent-engine.port'; import { AgentEngineType } from '../../domain/value-objects/agent-engine-type.vo'; import { ClaudeCodeCliEngine } from './claude-code-cli/claude-code-engine'; import { ClaudeApiEngine } from './claude-api/claude-api-engine'; +import { ClaudeAgentSdkEngine } from './claude-agent-sdk/claude-agent-sdk-engine'; @Injectable() export class EngineRegistry { @@ -12,10 +13,12 @@ export class EngineRegistry { constructor( @Optional() private readonly cliEngine: ClaudeCodeCliEngine, @Optional() private readonly apiEngine: ClaudeApiEngine, + @Optional() private readonly sdkEngine: ClaudeAgentSdkEngine, private readonly configService: ConfigService, ) { if (cliEngine) this.engines.set(AgentEngineType.CLAUDE_CODE_CLI, cliEngine); if (apiEngine) this.engines.set(AgentEngineType.CLAUDE_API, apiEngine); + if (sdkEngine) this.engines.set(AgentEngineType.CLAUDE_AGENT_SDK, sdkEngine); } getActiveEngine(): AgentEnginePort { diff --git a/packages/services/agent-service/src/infrastructure/repositories/tenant-agent-config.repository.ts b/packages/services/agent-service/src/infrastructure/repositories/tenant-agent-config.repository.ts new file mode 100644 index 0000000..e6e6ba0 --- /dev/null +++ b/packages/services/agent-service/src/infrastructure/repositories/tenant-agent-config.repository.ts @@ -0,0 +1,20 @@ +/** + * Repository for TenantAgentConfig. + * Extends TenantAwareRepository for schema-per-tenant isolation (SET search_path). + */ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { TenantAwareRepository } from '@it0/database'; +import { TenantAgentConfig } from '../../domain/entities/tenant-agent-config.entity'; + +@Injectable() +export class TenantAgentConfigRepository extends TenantAwareRepository { + constructor(dataSource: DataSource) { + super(dataSource, TenantAgentConfig); + } + + async findByTenantId(tenantId: string): Promise { + const repo = await this.getRepository(); + return repo.findOneBy({ tenantId } as any); + } +} diff --git a/packages/services/agent-service/src/infrastructure/services/tenant-agent-config.service.ts b/packages/services/agent-service/src/infrastructure/services/tenant-agent-config.service.ts new file mode 100644 index 0000000..e3cd041 --- /dev/null +++ b/packages/services/agent-service/src/infrastructure/services/tenant-agent-config.service.ts @@ -0,0 +1,89 @@ +/** + * Service for managing per-tenant Agent SDK configuration. + * + * Handles CRUD operations for TenantAgentConfig with: + * - API key encryption/decryption using CryptoUtil (AES-256-GCM, same pattern as CredentialVaultService) + * - Upsert semantics (create if not exists, update if exists) + * - API key removal with automatic revert to subscription billing + */ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CryptoUtil } from '@it0/common'; +import { TenantAgentConfigRepository } from '../repositories/tenant-agent-config.repository'; +import { TenantAgentConfig } from '../../domain/entities/tenant-agent-config.entity'; + +export interface UpdateTenantAgentConfigDto { + billingMode?: 'subscription' | 'api_key'; + apiKey?: string; + approvalTimeoutSeconds?: number; + toolWhitelist?: string[]; + toolBlacklist?: string[]; +} + +@Injectable() +export class TenantAgentConfigService { + private readonly logger = new Logger(TenantAgentConfigService.name); + + constructor( + private readonly repo: TenantAgentConfigRepository, + private readonly configService: ConfigService, + ) {} + + private get masterKey(): string { + return this.configService.get('VAULT_MASTER_KEY', 'dev-vault-key'); + } + + async findByTenantId(tenantId: string): Promise { + return this.repo.findByTenantId(tenantId); + } + + async upsert(tenantId: string, dto: UpdateTenantAgentConfigDto): Promise { + let config = await this.repo.findByTenantId(tenantId); + + if (!config) { + config = new TenantAgentConfig(); + config.tenantId = tenantId; + } + + if (dto.billingMode !== undefined) { + config.billingMode = dto.billingMode; + } + + if (dto.apiKey !== undefined) { + const { encrypted, iv } = CryptoUtil.encrypt(dto.apiKey, this.masterKey); + config.encryptedApiKey = encrypted; + config.apiKeyIv = iv; + } + + if (dto.approvalTimeoutSeconds !== undefined) { + config.approvalTimeoutSeconds = dto.approvalTimeoutSeconds; + } + + if (dto.toolWhitelist !== undefined) { + config.toolWhitelist = dto.toolWhitelist; + } + + if (dto.toolBlacklist !== undefined) { + config.toolBlacklist = dto.toolBlacklist; + } + + return this.repo.save(config); + } + + async removeApiKey(tenantId: string): Promise { + const config = await this.repo.findByTenantId(tenantId); + if (!config) return null; + + config.billingMode = 'subscription'; + config.encryptedApiKey = null; + config.apiKeyIv = null; + return this.repo.save(config); + } + + decryptApiKey(config: TenantAgentConfig): string { + if (!config.encryptedApiKey || !config.apiKeyIv) { + throw new Error('No API key configured for this tenant'); + } + return CryptoUtil.decrypt(config.encryptedApiKey, config.apiKeyIv, this.masterKey); + } +} diff --git a/packages/services/agent-service/src/interfaces/rest/controllers/tenant-agent-config.controller.ts b/packages/services/agent-service/src/interfaces/rest/controllers/tenant-agent-config.controller.ts new file mode 100644 index 0000000..fb20741 --- /dev/null +++ b/packages/services/agent-service/src/interfaces/rest/controllers/tenant-agent-config.controller.ts @@ -0,0 +1,77 @@ +/** + * Admin REST controller for per-tenant Agent SDK configuration. + * + * Endpoints (all JWT-protected): + * GET /api/v1/agent/tenant-config → Get current tenant's SDK config (returns defaults if none set) + * PUT /api/v1/agent/tenant-config → Create/update config (billingMode, apiKey, timeout, tools) + * DELETE /api/v1/agent/tenant-config/api-key → Remove API key, revert to subscription billing + * + * Note: API key is never returned in responses — only `hasApiKey: boolean` is exposed. + */ +import { Controller, Get, Put, Delete, Body, UseGuards, NotFoundException } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { TenantId } from '@it0/common'; +import { TenantAgentConfigService, UpdateTenantAgentConfigDto } from '../../../infrastructure/services/tenant-agent-config.service'; + +@Controller('api/v1/agent/tenant-config') +@UseGuards(AuthGuard('jwt')) +export class TenantAgentConfigController { + constructor( + private readonly tenantConfigService: TenantAgentConfigService, + ) {} + + @Get() + async getConfig(@TenantId() tenantId: string) { + const config = await this.tenantConfigService.findByTenantId(tenantId); + if (!config) { + return { + tenantId, + billingMode: 'subscription', + approvalTimeoutSeconds: 120, + toolWhitelist: [], + toolBlacklist: [], + hasApiKey: false, + }; + } + + return { + tenantId: config.tenantId, + billingMode: config.billingMode, + approvalTimeoutSeconds: config.approvalTimeoutSeconds, + toolWhitelist: config.toolWhitelist, + toolBlacklist: config.toolBlacklist, + hasApiKey: config.encryptedApiKey !== null, + createdAt: config.createdAt, + updatedAt: config.updatedAt, + }; + } + + @Put() + async upsertConfig( + @TenantId() tenantId: string, + @Body() dto: UpdateTenantAgentConfigDto, + ) { + const config = await this.tenantConfigService.upsert(tenantId, dto); + return { + tenantId: config.tenantId, + billingMode: config.billingMode, + approvalTimeoutSeconds: config.approvalTimeoutSeconds, + toolWhitelist: config.toolWhitelist, + toolBlacklist: config.toolBlacklist, + hasApiKey: config.encryptedApiKey !== null, + updatedAt: config.updatedAt, + }; + } + + @Delete('api-key') + async removeApiKey(@TenantId() tenantId: string) { + const config = await this.tenantConfigService.removeApiKey(tenantId); + if (!config) { + throw new NotFoundException('No agent config found for this tenant'); + } + return { + message: 'API key removed, reverted to subscription billing', + billingMode: config.billingMode, + }; + } +}