import { Injectable, NotFoundException } from '@nestjs/common'; import { CaslAbilityFactory } from '../authorization/casl-ability.factory'; import { AuthorizationService } from '../authorization'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { AGENT_SKILLS } from './agent-skill.catalog'; import { AgentToolRegistry } from './agent-tool.registry'; import { AgentToolContextFactory } from './agent-tool.types'; import type { AgentSkillDescriptor, AgentToolContext, ToolDescriptor, ToolExecutionResult, ToolStatus, } from './agent-tool.types'; /** Safe tool name: alphanumeric + underscore, max 64 chars. */ const TOOL_NAME_RE = /^[a-zA-Z0-9_]+$/; const TOOL_NAME_MAX_LEN = 64; /** Safe user-facing messages that never leak internals. */ const SAFE_MESSAGES = { unknownTool: '未知工具', permissionDenied: '权限不足', invalidInput: '输入参数无效', executionFailed: '工具执行失败', notFound: '记录不存在或无权访问', } as const; /** * Executes Agent Tools with double-check authorization, input validation, * context trust validation, and audit logging. * * ## Security guarantees * * 1. Context trust is validated at runtime via * {@link AgentToolContextFactory.assertTrusted} — forged/plain-object * contexts are rejected. * 2. The ability is constructed fresh from the principal in the context * — callers cannot pre-forge it. * 3. Permission is checked AGAIN at execute time (not just at list time). * 4. Unknown tools are rejected with a generic message, and the tool name * is sanitized in audit logs. * 5. `rawInput` is `unknown` — null, arrays, and strings are caught before * validation. * 6. All tool & validator exceptions are caught and mapped to safe messages. * 7. Audit logs never include raw input, stack traces, or internal error text. * 8. Audit log is awaited best-effort — failure does NOT fail the tool call. */ @Injectable() export class AgentToolExecutor { constructor( private readonly registry: AgentToolRegistry, private readonly abilityFactory: CaslAbilityFactory, private readonly authz: AuthorizationService, private readonly opLog: OperationLogsService, ) {} /** * List tools available to the given context. * * Returns read-only {@link ToolDescriptor}s — never exposes * `execute`, `validate`, or `requiredPermission`. * * This is the ONLY public entry point for tool discovery. * SDK consumers MUST use this instead of direct Registry access. * * @param context — trusted context from * {@link AgentToolContextFactory.fromAuthenticatedUser}. */ listAvailable(context: AgentToolContext, skillKey?: string | null): ToolDescriptor[] { AgentToolContextFactory.assertTrusted(context); const ability = this.abilityFactory.createForUser({ permissions: context.permissions, isSuperAdmin: context.isSuperAdmin, }); return this.registry .listAvailableInternal(ability) .filter((tool) => !skillKey || tool.skillKey === skillKey) .map(({ name, skillKey: toolSkillKey, description, inputSchema }) => ({ name, skillKey: toolSkillKey, description, ...(inputSchema ? { inputSchema } : {}), })); } listSkills(context: AgentToolContext): AgentSkillDescriptor[] { const tools = this.listAvailable(context); return AGENT_SKILLS.map((skill) => ({ ...skill, tools: tools .filter((tool) => tool.skillKey === skill.key) .map(({ name, description }) => ({ name, description })), })).filter((skill) => skill.tools.length > 0); } /** * Execute a tool by name. * * @param name — tool name (e.g. "search_students"). Must pass sanitization. * @param rawInput — raw input from the model (may be any JSON value). * @param context — trusted context from * {@link AgentToolContextFactory.fromAuthenticatedUser}. */ async execute( name: string, rawInput: unknown, context: AgentToolContext, allowedSkillKey?: string | null, ): Promise { // 0. Context trust validation — must be first try { AgentToolContextFactory.assertTrusted(context); } catch { return { status: 'denied', toolName: '_denied', error: SAFE_MESSAGES.permissionDenied }; } // 1. Sanitize tool name — model-controlled input const safeName = this.sanitizeName(name); const tool = this.registry.getForExecution(name); if (!tool) { return this.auditAndReturn( safeName, 'denied', undefined, SAFE_MESSAGES.unknownTool, context, ); } if (allowedSkillKey && tool.skillKey !== allowedSkillKey) { return this.auditAndReturn( safeName, 'denied', undefined, SAFE_MESSAGES.permissionDenied, context, tool.skillKey, ); } // 2. Build ability from principal fields — never trust a pre-built one const ability = this.abilityFactory.createForUser({ permissions: context.permissions, isSuperAdmin: context.isSuperAdmin, }); // 3. Double-check authorization at execute time if (!this.authz.canPermission(ability, tool.requiredPermission)) { return this.auditAndReturn( safeName, 'denied', undefined, SAFE_MESSAGES.permissionDenied, context, tool.skillKey, ); } // 4. Guard: rawInput must be a plain object if (rawInput === null || Array.isArray(rawInput) || typeof rawInput !== 'object') { return this.auditAndReturn( safeName, 'failed', undefined, SAFE_MESSAGES.invalidInput, context, tool.skillKey, ); } // 5. Validate and parse input — validator exceptions are caught let parsed: { ok: true; value: unknown } | { ok: false }; try { parsed = tool.validate(rawInput as Record); } catch { return this.auditAndReturn( safeName, 'failed', undefined, SAFE_MESSAGES.invalidInput, context, tool.skillKey, ); } if (!parsed.ok) { return this.auditAndReturn( safeName, 'failed', undefined, SAFE_MESSAGES.invalidInput, context, tool.skillKey, ); } // 6. Execute try { const result = await tool.execute(parsed.value, context); return this.auditAndReturn( safeName, 'success', result, undefined, context, tool.skillKey, ); } catch (err: unknown) { // NotFoundException → not_found with safe message if (err instanceof NotFoundException) { return this.auditAndReturn( safeName, 'not_found', undefined, SAFE_MESSAGES.notFound, context, tool.skillKey, ); } // All other errors → generic failed message return this.auditAndReturn( safeName, 'failed', undefined, SAFE_MESSAGES.executionFailed, context, tool.skillKey, ); } } /** * Sanitize a tool name from model input. * * Only allows `[a-zA-Z0-9_]`, max {@link TOOL_NAME_MAX_LEN} chars. * Returns the sanitized name or a safe fallback. */ private sanitizeName(name: string): string { if (typeof name !== 'string') return '_invalid'; const trimmed = name.slice(0, TOOL_NAME_MAX_LEN); if (TOOL_NAME_RE.test(trimmed)) return trimmed; // Replace unsafe chars with underscore return trimmed.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, TOOL_NAME_MAX_LEN); } /** * Build result + best-effort awaited audit log. * Audit write failure is caught and never propagated — it must not * turn a successful data read into a failure. */ private async auditAndReturn( toolName: string, status: ToolStatus, result: unknown, error: string | undefined, context: AgentToolContext, skillKey?: string, ): Promise { // Await audit (best-effort — failure is silently swallowed) try { await this.opLog.log({ userId: context.userId, username: context.username, module: 'AI Agent Tool', action: `${toolName} [${status}]`, detail: this.buildAuditDetail(status), status, }); } catch { // Swallow — audit failure must not break the tool call } return { status, toolName, skillKey, result, error }; } /** * Build a safe audit detail string. * NEVER includes raw input, exception messages, phone numbers, or other PII. * Only writes safe category labels. */ private buildAuditDetail(status: ToolStatus): string { switch (status) { case 'success': return '执行成功'; case 'denied': return '拒绝访问'; case 'not_found': return '记录不存在或无权访问'; default: return '执行失败'; } } }