feat: add CASL authorization and AI configuration
This commit is contained in:
251
apps/server/src/agent-tools/agent-tool.executor.ts
Normal file
251
apps/server/src/agent-tools/agent-tool.executor.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
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 '执行失败';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user