Files
gongxue-base/apps/server/src/agent-tools/agent-tool.executor.ts

252 lines
7.8 KiB
TypeScript

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 { AgentToolRegistry } from './agent-tool.registry';
import { AgentToolContextFactory } from './agent-tool.types';
import type { AgentToolContext, ToolExecutionResult, ToolStatus, ToolDescriptor } 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): ToolDescriptor[] {
AgentToolContextFactory.assertTrusted(context);
const ability = this.abilityFactory.createForUser({
permissions: context.permissions,
isSuperAdmin: context.isSuperAdmin,
});
return this.registry
.listAvailableInternal(ability)
.map(({ name, description, inputSchema }) => ({
name,
description,
...(inputSchema ? { inputSchema } : {}),
}));
}
/**
* 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,
): Promise<ToolExecutionResult> {
// 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,
);
}
// 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,
);
}
// 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,
);
}
// 5. Validate and parse input — validator exceptions are caught
let parsed: { ok: true; value: unknown } | { ok: false };
try {
parsed = tool.validate(rawInput as Record<string, unknown>);
} catch {
return this.auditAndReturn(
safeName,
'failed',
undefined,
SAFE_MESSAGES.invalidInput,
context,
);
}
if (!parsed.ok) {
return this.auditAndReturn(
safeName,
'failed',
undefined,
SAFE_MESSAGES.invalidInput,
context,
);
}
// 6. Execute
try {
const result = await tool.execute(parsed.value, context);
return this.auditAndReturn(safeName, 'success', result, undefined, context);
} catch (err: unknown) {
// NotFoundException → not_found with safe message
if (err instanceof NotFoundException) {
return this.auditAndReturn(
safeName,
'not_found',
undefined,
SAFE_MESSAGES.notFound,
context,
);
}
// All other errors → generic failed message
return this.auditAndReturn(
safeName,
'failed',
undefined,
SAFE_MESSAGES.executionFailed,
context,
);
}
}
/**
* 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,
): Promise<ToolExecutionResult> {
// 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, 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 '执行失败';
}
}
}