297 lines
8.9 KiB
TypeScript
297 lines
8.9 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 { 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<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,
|
|
);
|
|
}
|
|
|
|
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<string, unknown>);
|
|
} 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<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, 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 '执行失败';
|
|
}
|
|
}
|
|
}
|