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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-20 18:38:30 -08:00
parent 67d5a13c0c
commit c75ad27771
15 changed files with 1173 additions and 2 deletions

View File

@ -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<TenantAgentSdkConfig>('/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<string[]>([]);
const [toolBlacklist, setToolBlacklist] = useState<string[]>([]);
/* ---- 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<TenantAgentSdkConfig>('/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 (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Loading SDK configuration...</span>
</div>
);
}
if (isError) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Agent SDK Configuration</h1>
<p className="text-sm text-destructive mb-4">
Failed to load configuration. Using defaults.
</p>
</div>
);
}
/* ---- render ---- */
return (
<div className="max-w-3xl">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold">Agent SDK Configuration</h1>
<p className="text-sm text-muted-foreground mt-1">
Configure Claude Agent SDK billing, approval flow, and tool permissions per tenant.
</p>
</div>
</div>
<div className="space-y-8">
{/* ---- Billing Mode ---- */}
<section className="bg-card rounded-lg border p-5">
<h2 className="text-lg font-semibold mb-1">Billing Mode</h2>
<p className="text-sm text-muted-foreground mb-4">
Choose how Claude Agent SDK usage is billed for this tenant.
</p>
<div className="flex gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="billingMode"
value="subscription"
checked={billingMode === 'subscription'}
onChange={() => setBillingMode('subscription')}
className="h-4 w-4 accent-primary"
/>
<div>
<span className="text-sm font-medium">Subscription</span>
<p className="text-xs text-muted-foreground">
Use operator&apos;s Claude login (no API key needed)
</p>
</div>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="billingMode"
value="api_key"
checked={billingMode === 'api_key'}
onChange={() => setBillingMode('api_key')}
className="h-4 w-4 accent-primary"
/>
<div>
<span className="text-sm font-medium">API Key</span>
<p className="text-xs text-muted-foreground">
Tenant&apos;s own Anthropic API key (token-based billing)
</p>
</div>
</label>
</div>
{/* ---- API Key Input (shown when api_key mode is selected) ---- */}
{billingMode === 'api_key' && (
<div className="mt-4 p-4 rounded-md border bg-muted/30">
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium">Anthropic API Key</label>
{config?.hasApiKey && (
<span className="text-xs text-green-600 dark:text-green-400">
Key configured
</span>
)}
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showApiKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground
hover:text-foreground transition-colors"
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{config?.hasApiKey && (
<button
type="button"
onClick={() => removeApiKey()}
disabled={isRemoving}
className="inline-flex items-center gap-1 px-3 py-2 rounded-md border
text-sm text-destructive hover:bg-destructive/10
disabled:opacity-50 transition-colors"
>
<Trash2 className="h-4 w-4" />
Remove
</button>
)}
</div>
<p className="text-xs text-muted-foreground mt-2">
API key is encrypted (AES-256-GCM) before storage. Never stored in plaintext.
</p>
</div>
)}
</section>
{/* ---- L2 Approval Timeout ---- */}
<section className="bg-card rounded-lg border p-5">
<h2 className="text-lg font-semibold mb-1">L2 Approval Timeout</h2>
<p className="text-sm text-muted-foreground mb-4">
For high-risk commands (L2), how long to wait for manual approval before auto-approving.
Set to 0 to disable auto-approve (wait indefinitely).
</p>
<div className="flex items-center gap-4">
<input
type="range"
min={0}
max={600}
step={10}
value={approvalTimeout}
onChange={(e) => setApprovalTimeout(Number(e.target.value))}
className="flex-1 accent-primary"
/>
<div className="flex items-center gap-1">
<input
type="number"
min={0}
max={3600}
value={approvalTimeout}
onChange={(e) => {
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"
/>
<span className="text-sm text-muted-foreground">sec</span>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
{approvalTimeout === 0
? 'Auto-approve disabled — commands will wait for manual approval indefinitely.'
: `Commands auto-approved after ${approvalTimeout} seconds without response.`}
</p>
</section>
{/* ---- Tool Whitelist / Blacklist ---- */}
<section className="bg-card rounded-lg border p-5">
<h2 className="text-lg font-semibold mb-1">Tool Permissions</h2>
<p className="text-sm text-muted-foreground mb-4">
Override tool access per tenant. Empty whitelist means use RBAC defaults.
Blacklisted tools are always denied regardless of role.
</p>
<div className="overflow-hidden rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50">
<th className="text-left px-4 py-2 font-medium">Tool</th>
<th className="text-left px-4 py-2 font-medium">Description</th>
<th className="text-center px-4 py-2 font-medium w-24">Whitelist</th>
<th className="text-center px-4 py-2 font-medium w-24">Blacklist</th>
</tr>
</thead>
<tbody>
{AVAILABLE_TOOLS.map((tool) => (
<tr
key={tool.name}
className="border-t hover:bg-accent/30 transition-colors"
>
<td className="px-4 py-2 font-mono text-xs">{tool.name}</td>
<td className="px-4 py-2 text-muted-foreground text-xs">{tool.description}</td>
<td className="px-4 py-2 text-center">
<input
type="checkbox"
checked={toolWhitelist.includes(tool.name)}
onChange={() => handleWhitelistToggle(tool.name)}
className="h-4 w-4 accent-primary cursor-pointer"
/>
</td>
<td className="px-4 py-2 text-center">
<input
type="checkbox"
checked={toolBlacklist.includes(tool.name)}
onChange={() => handleBlacklistToggle(tool.name)}
className="h-4 w-4 accent-destructive cursor-pointer"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex gap-4 mt-3 text-xs text-muted-foreground">
<span>Whitelist: {toolWhitelist.length || 'none (use RBAC defaults)'}</span>
<span>Blacklist: {toolBlacklist.length || 'none'}</span>
</div>
</section>
{/* ---- RBAC Info ---- */}
<section className="bg-card rounded-lg border p-5">
<h2 className="text-lg font-semibold mb-1">RBAC Tool Access (Reference)</h2>
<p className="text-sm text-muted-foreground mb-4">
Default tool access per role (applied when whitelist is empty).
</p>
<div className="space-y-2 text-sm">
<div className="flex gap-2">
<span className="font-medium w-20">Admin</span>
<span className="text-muted-foreground font-mono text-xs">
All tools (Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, NotebookEdit, Task)
</span>
</div>
<div className="flex gap-2">
<span className="font-medium w-20">Operator</span>
<span className="text-muted-foreground font-mono text-xs">
Bash, Read, Write, Edit, Glob, Grep
</span>
</div>
<div className="flex gap-2">
<span className="font-medium w-20">Viewer</span>
<span className="text-muted-foreground font-mono text-xs">
Read, Glob, Grep (read-only)
</span>
</div>
</div>
</section>
{/* ---- Action buttons ---- */}
<div className="flex items-center gap-3 pb-4">
<button
type="button"
onClick={handleSave}
disabled={isSaving}
className="inline-flex items-center gap-2 px-4 py-2 rounded-md
bg-primary text-primary-foreground text-sm font-medium
hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
{isSaving ? 'Saving...' : 'Save Configuration'}
</button>
<button
type="button"
onClick={handleReset}
className="inline-flex items-center gap-2 px-4 py-2 rounded-md border
text-sm font-medium hover:bg-accent transition-colors"
>
<RotateCcw className="h-4 w-4" />
Reset to Defaults
</button>
{saveSuccess && (
<span className="text-sm text-green-600 dark:text-green-400">
Configuration saved successfully.
</span>
)}
</div>
</div>
</div>
);
}

View File

@ -75,6 +75,10 @@ export const queryKeys = {
all: ['agent-config'] as const, all: ['agent-config'] as const,
current: () => [...queryKeys.agentConfig.all, 'current'] as const, current: () => [...queryKeys.agentConfig.all, 'current'] as const,
}, },
tenantSdkConfig: {
all: ['tenant-sdk-config'] as const,
current: () => [...queryKeys.tenantSdkConfig.all, 'current'] as const,
},
skills: { skills: {
all: ['skills'] as const, all: ['skills'] as const,
list: (params?: Record<string, string>) => [...queryKeys.skills.all, 'list', params] as const, list: (params?: Record<string, string>) => [...queryKeys.skills.all, 'list', params] as const,

View File

@ -18,6 +18,7 @@ const navItems: NavItem[] = [
href: '/agent-config', href: '/agent-config',
children: [ children: [
{ label: 'Engine & Prompt', href: '/agent-config' }, { label: 'Engine & Prompt', href: '/agent-config' },
{ label: 'SDK Config', href: '/agent-config/sdk' },
{ label: 'Skills', href: '/agent-config/skills' }, { label: 'Skills', href: '/agent-config/skills' },
{ label: 'Hooks', href: '/agent-config/hooks' }, { label: 'Hooks', href: '/agent-config/hooks' },
], ],

View File

@ -20,6 +20,7 @@
"@nestjs/platform-socket.io": "^10.3.0", "@nestjs/platform-socket.io": "^10.3.0",
"socket.io": "^4.7.0", "socket.io": "^4.7.0",
"@anthropic-ai/sdk": "^0.32.0", "@anthropic-ai/sdk": "^0.32.0",
"@anthropic-ai/claude-agent-sdk": "^0.2.49",
"typeorm": "^0.3.20", "typeorm": "^0.3.20",
"pg": "^8.11.0", "pg": "^8.11.0",
"glob": "^10.3.0", "glob": "^10.3.0",

View File

@ -5,39 +5,49 @@ import { DatabaseModule } from '@it0/database';
import { AgentController } from './interfaces/rest/controllers/agent.controller'; import { AgentController } from './interfaces/rest/controllers/agent.controller';
import { SessionController } from './interfaces/rest/controllers/session.controller'; import { SessionController } from './interfaces/rest/controllers/session.controller';
import { RiskRulesController } from './interfaces/rest/controllers/risk-rules.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 { AgentStreamGateway } from './interfaces/ws/agent-stream.gateway';
import { EngineRegistry } from './infrastructure/engines/engine-registry'; import { EngineRegistry } from './infrastructure/engines/engine-registry';
import { ClaudeCodeCliEngine } from './infrastructure/engines/claude-code-cli/claude-code-engine'; import { ClaudeCodeCliEngine } from './infrastructure/engines/claude-code-cli/claude-code-engine';
import { ClaudeApiEngine } from './infrastructure/engines/claude-api/claude-api-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 { ToolExecutor } from './infrastructure/engines/claude-api/tool-executor';
import { CommandGuardService } from './infrastructure/guards/command-guard.service'; import { CommandGuardService } from './infrastructure/guards/command-guard.service';
import { SkillManagerService } from './domain/services/skill-manager.service'; import { SkillManagerService } from './domain/services/skill-manager.service';
import { StandingOrderExtractorService } from './domain/services/standing-order-extractor'; 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 { SessionRepository } from './infrastructure/repositories/session.repository';
import { TaskRepository } from './infrastructure/repositories/task.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 { AgentSession } from './domain/entities/agent-session.entity';
import { AgentTask } from './domain/entities/agent-task.entity'; import { AgentTask } from './domain/entities/agent-task.entity';
import { CommandRecord } from './domain/entities/command-record.entity'; import { CommandRecord } from './domain/entities/command-record.entity';
import { StandingOrderRef } from './domain/entities/standing-order.entity'; import { StandingOrderRef } from './domain/entities/standing-order.entity';
import { TenantAgentConfig } from './domain/entities/tenant-agent-config.entity';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
DatabaseModule.forRoot(), 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: [ providers: [
AgentStreamGateway, AgentStreamGateway,
EngineRegistry, EngineRegistry,
ClaudeCodeCliEngine, ClaudeCodeCliEngine,
ClaudeApiEngine, ClaudeApiEngine,
ClaudeAgentSdkEngine,
ToolExecutor, ToolExecutor,
CommandGuardService, CommandGuardService,
SkillManagerService, SkillManagerService,
StandingOrderExtractorService, StandingOrderExtractorService,
AllowedToolsResolverService,
SessionRepository, SessionRepository,
TaskRepository, TaskRepository,
TenantAgentConfigRepository,
TenantAgentConfigService,
], ],
}) })
export class AgentModule {} export class AgentModule {}

View File

@ -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;
}

View File

@ -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<string, string[]> = {
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;
}
}

View File

@ -1,5 +1,6 @@
export enum AgentEngineType { export enum AgentEngineType {
CLAUDE_CODE_CLI = 'claude_code_cli', CLAUDE_CODE_CLI = 'claude_code_cli',
CLAUDE_API = 'claude_api', CLAUDE_API = 'claude_api',
CLAUDE_AGENT_SDK = 'claude_agent_sdk',
CUSTOM = 'custom', CUSTOM = 'custom',
} }

View File

@ -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<string, unknown>;
}
export class ApprovalGate {
private pendingApprovals = new Map<string, PendingApproval>();
constructor(private readonly timeoutSeconds: number) {}
async waitForApproval(
toolName: string,
toolInput: Record<string, unknown>,
): Promise<boolean> {
const id = crypto.randomUUID();
return new Promise<boolean>((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();
}
}

View File

@ -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<string, ActiveSession>();
private readonly classifier = new CommandRiskClassifier();
constructor(
private readonly configService: ConfigService,
private readonly tenantConfigService: TenantAgentConfigService,
private readonly allowedToolsResolver: AllowedToolsResolverService,
) {}
async *executeTask(params: EngineTaskParams): AsyncGenerator<EngineStreamEvent> {
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<string, string> = { ...process.env } as Record<string, string>;
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<string, unknown>);
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<void>((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<void> {
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<EngineStreamEvent> {
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<string, string> = { ...process.env } as Record<string, string>;
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<string, unknown>);
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<boolean> {
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;
}
}

View File

@ -0,0 +1,2 @@
export { ClaudeAgentSdkEngine } from './claude-agent-sdk-engine';
export { ApprovalGate } from './approval-gate';

View File

@ -4,6 +4,7 @@ import { AgentEnginePort } from '../../domain/ports/outbound/agent-engine.port';
import { AgentEngineType } from '../../domain/value-objects/agent-engine-type.vo'; import { AgentEngineType } from '../../domain/value-objects/agent-engine-type.vo';
import { ClaudeCodeCliEngine } from './claude-code-cli/claude-code-engine'; import { ClaudeCodeCliEngine } from './claude-code-cli/claude-code-engine';
import { ClaudeApiEngine } from './claude-api/claude-api-engine'; import { ClaudeApiEngine } from './claude-api/claude-api-engine';
import { ClaudeAgentSdkEngine } from './claude-agent-sdk/claude-agent-sdk-engine';
@Injectable() @Injectable()
export class EngineRegistry { export class EngineRegistry {
@ -12,10 +13,12 @@ export class EngineRegistry {
constructor( constructor(
@Optional() private readonly cliEngine: ClaudeCodeCliEngine, @Optional() private readonly cliEngine: ClaudeCodeCliEngine,
@Optional() private readonly apiEngine: ClaudeApiEngine, @Optional() private readonly apiEngine: ClaudeApiEngine,
@Optional() private readonly sdkEngine: ClaudeAgentSdkEngine,
private readonly configService: ConfigService, private readonly configService: ConfigService,
) { ) {
if (cliEngine) this.engines.set(AgentEngineType.CLAUDE_CODE_CLI, cliEngine); if (cliEngine) this.engines.set(AgentEngineType.CLAUDE_CODE_CLI, cliEngine);
if (apiEngine) this.engines.set(AgentEngineType.CLAUDE_API, apiEngine); if (apiEngine) this.engines.set(AgentEngineType.CLAUDE_API, apiEngine);
if (sdkEngine) this.engines.set(AgentEngineType.CLAUDE_AGENT_SDK, sdkEngine);
} }
getActiveEngine(): AgentEnginePort { getActiveEngine(): AgentEnginePort {

View File

@ -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<TenantAgentConfig> {
constructor(dataSource: DataSource) {
super(dataSource, TenantAgentConfig);
}
async findByTenantId(tenantId: string): Promise<TenantAgentConfig | null> {
const repo = await this.getRepository();
return repo.findOneBy({ tenantId } as any);
}
}

View File

@ -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<string>('VAULT_MASTER_KEY', 'dev-vault-key');
}
async findByTenantId(tenantId: string): Promise<TenantAgentConfig | null> {
return this.repo.findByTenantId(tenantId);
}
async upsert(tenantId: string, dto: UpdateTenantAgentConfigDto): Promise<TenantAgentConfig> {
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<TenantAgentConfig | null> {
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);
}
}

View File

@ -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,
};
}
}