feat: add CASL authorization and AI configuration

This commit is contained in:
2026-07-11 14:25:34 +08:00
parent 8f0991a51f
commit 1e1c476bc3
59 changed files with 7733 additions and 120 deletions

View File

@@ -0,0 +1,678 @@
import { NotFoundException } from '@nestjs/common';
import { CaslAbilityFactory } from '../authorization/casl-ability.factory';
import { AuthorizationService } from '../authorization/authorization.service';
import { AgentToolRegistry } from './agent-tool.registry';
import { AgentToolExecutor } from './agent-tool.executor';
import { AgentToolContextFactory, AgentToolContext } from './agent-tool.types';
import type { ToolDef, ToolInputResult, ToolDescriptor } from './agent-tool.types';
import type { AuthenticatedUser } from '../authorization';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const abilityFactory = new CaslAbilityFactory();
/** Create context via the factory (the ONLY valid path). */
function makeCtx(user: Partial<AuthenticatedUser> & { id: number; username: string }): AgentToolContext {
const u: AuthenticatedUser = {
id: user.id,
username: user.username,
permissions: user.permissions ?? [],
isSuperAdmin: user.isSuperAdmin ?? false,
roles: user.roles ?? [],
};
return AgentToolContextFactory.fromAuthenticatedUser(u);
}
const superAdminCtx = makeCtx({ id: 1, username: 'admin', isSuperAdmin: true });
const studentViewerCtx = makeCtx({
id: 2,
username: 'teacher_zhang',
permissions: ['student:view'],
});
const noPermCtx = makeCtx({ id: 3, username: 'guest', permissions: [] });
/** Create a simple mock tool. */
function makeTool(overrides: Partial<ToolDef> = {}): ToolDef {
return {
name: 'echo',
description: 'echoes input',
requiredPermission: 'student:view',
inputSchema: { type: 'object', properties: { text: { type: 'string' } }, additionalProperties: false },
validate(input: Record<string, unknown>): ToolInputResult<Record<string, unknown>> {
const forbidden = new Set(['userId', 'isSuperAdmin', 'permissions', 'roles', 'ability']);
for (const key of Object.keys(input)) {
if (forbidden.has(key)) return { ok: false, error: `禁止字段: ${key}` };
}
return { ok: true, value: input };
},
async execute(input: Record<string, unknown>): Promise<unknown> {
return { echoed: input };
},
...overrides,
};
}
function makeExecutor(opLogMock?: { log: jest.Mock }): {
executor: AgentToolExecutor;
registry: AgentToolRegistry;
opLog: { log: jest.Mock };
} {
const authz = new AuthorizationService(abilityFactory);
const registry = new AgentToolRegistry();
const opLog = opLogMock ?? { log: jest.fn().mockResolvedValue(undefined) };
const executor = new AgentToolExecutor(registry, abilityFactory, authz, opLog as never);
return { executor, registry, opLog };
}
// ---------------------------------------------------------------------------
// Fix 2: AgentToolContext — immutability & forgery resistance
// ---------------------------------------------------------------------------
describe('AgentToolContext — immutability & forgery resistance', () => {
it('context is fully frozen (cannot add/remove/modify properties)', () => {
const ctx = AgentToolContextFactory.fromAuthenticatedUser({
id: 1, username: 'admin', permissions: ['student:view'], isSuperAdmin: false, roles: [],
});
// Frozen object throws in strict mode on mutation
expect(() => {
(ctx as Record<string, unknown>).isSuperAdmin = true;
}).toThrow();
expect(() => {
(ctx as Record<string, unknown>).userId = 999;
}).toThrow();
expect(() => {
(ctx as Record<string, unknown>).newField = 'injected';
}).toThrow();
});
it('permissions array is frozen (cannot push/splice)', () => {
const ctx = AgentToolContextFactory.fromAuthenticatedUser({
id: 1, username: 'admin', permissions: ['student:view'], isSuperAdmin: false, roles: [],
});
expect(() => {
(ctx.permissions as string[]).push('student:delete');
}).toThrow();
});
it('mutating original AuthenticatedUser does NOT affect context', () => {
const user: AuthenticatedUser = {
id: 1, username: 'admin', permissions: ['student:view'], isSuperAdmin: false, roles: [],
};
const ctx = AgentToolContextFactory.fromAuthenticatedUser(user);
user.permissions.push('superadmin:hack');
user.isSuperAdmin = true;
expect(ctx.permissions).toEqual(['student:view']);
expect(ctx.isSuperAdmin).toBe(false);
});
it('hand-crafted plain-object context is rejected by executor.execute', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
const fakeCtx = {
userId: 1,
username: 'hacker',
permissions: Object.freeze(['student:view', 'student:delete']),
isSuperAdmin: true,
} as AgentToolContext;
const result = await executor.execute('echo', { text: 'hi' }, fakeCtx);
expect(result.status).toBe('denied');
expect(result.error).toBe('权限不足');
expect(result.result).toBeUndefined();
});
it('hand-crafted plain-object context is rejected by executor.listAvailable', () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
const fakeCtx = {
userId: 1,
username: 'hacker',
permissions: Object.freeze(['student:delete']),
isSuperAdmin: true,
} as AgentToolContext;
expect(() => executor.listAvailable(fakeCtx)).toThrow('DENIED');
});
it('Object.create(prototype) without factory is rejected', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
// Even if you get the prototype right, it's not in the WeakSet
const fakeCtx2 = Object.create(AgentToolContext.prototype) as AgentToolContext;
Object.defineProperties(fakeCtx2, {
userId: { value: 1 },
username: { value: 'hacker' },
permissions: { value: Object.freeze(['student:view', 'student:delete']) },
isSuperAdmin: { value: true },
});
Object.freeze(fakeCtx2);
const result = await executor.execute('echo', { text: 'hi' }, fakeCtx2);
expect(result.status).toBe('denied');
});
it('context does NOT expose ability field', () => {
const ctx = superAdminCtx;
expect((ctx as Record<string, unknown>).ability).toBeUndefined();
});
it('passing superAdmin-like permissions on non-superAdmin user does NOT grant superAdmin', () => {
const ctx = makeCtx({ id: 5, username: 'fake', permissions: ['superadmin:all'], isSuperAdmin: false });
expect(ctx.isSuperAdmin).toBe(false);
const ability = abilityFactory.createForUser({
permissions: ctx.permissions,
isSuperAdmin: ctx.isSuperAdmin,
});
expect(ability.can('manage', 'all')).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Fix 1: listAvailable via Executor (not Registry)
// ---------------------------------------------------------------------------
describe('listAvailable via Executor', () => {
it('returns ToolDescriptors with name, description, inputSchema — but NOT execute/validate', () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
const tools: ToolDescriptor[] = executor.listAvailable(studentViewerCtx);
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe('student_search');
expect(tools[0].description).toBeTruthy();
expect(tools[0].inputSchema).toBeDefined();
expect((tools[0] as Record<string, unknown>).execute).toBeUndefined();
expect((tools[0] as Record<string, unknown>).validate).toBeUndefined();
expect((tools[0] as Record<string, unknown>).requiredPermission).toBeUndefined();
});
it('super admin sees all tools', () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 't1', requiredPermission: 'student:view' }));
registry.register(makeTool({ name: 't2', requiredPermission: 'bill:export' }));
const tools = executor.listAvailable(superAdminCtx);
expect(tools).toHaveLength(2);
});
it('hides tool when principal lacks required permission', () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
const tools = executor.listAvailable(noPermCtx);
expect(tools).toHaveLength(0);
});
it('filters by exact permission code', () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
registry.register(makeTool({ name: 'bill_export', requiredPermission: 'bill:export-excel' }));
const tools = executor.listAvailable(studentViewerCtx);
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe('student_search');
});
it('descriptors include inputSchema when present', () => {
const { executor, registry } = makeExecutor();
registry.register(
makeTool({
name: 'get_student',
requiredPermission: 'student:view',
inputSchema: { type: 'object', properties: { studentId: { type: 'integer' } }, required: ['studentId'], additionalProperties: false },
}),
);
const tools = executor.listAvailable(studentViewerCtx);
expect(tools[0].inputSchema).toBeDefined();
expect(tools[0].inputSchema!.required).toContain('studentId');
});
});
// ---------------------------------------------------------------------------
// rawInput type guards
// ---------------------------------------------------------------------------
describe('rawInput type guards', () => {
it('rejects null input', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
const result = await executor.execute('echo', null, studentViewerCtx);
expect(result.status).toBe('failed');
expect(result.error).toBe('输入参数无效');
});
it('rejects array input', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
const result = await executor.execute('echo', [1, 2, 3], studentViewerCtx);
expect(result.status).toBe('failed');
expect(result.error).toBe('输入参数无效');
});
it('rejects string input', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
const result = await executor.execute('echo', 'just a string', studentViewerCtx);
expect(result.status).toBe('failed');
expect(result.error).toBe('输入参数无效');
});
it('rejects number input', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
const result = await executor.execute('echo', 42, studentViewerCtx);
expect(result.status).toBe('failed');
expect(result.error).toBe('输入参数无效');
});
it('validator that throws is caught and returns safe error', async () => {
const { executor, registry } = makeExecutor();
registry.register(
makeTool({
name: 'crash_validate',
requiredPermission: 'student:view',
validate(): never {
throw new Error('INTERNAL: validator crashed with raw SQL');
},
}),
);
const result = await executor.execute('crash_validate', { x: 1 }, studentViewerCtx);
expect(result.status).toBe('failed');
expect(result.error).toBe('输入参数无效');
expect(result.error).not.toContain('SQL');
expect(result.error).not.toContain('INTERNAL');
});
});
// ---------------------------------------------------------------------------
// Error & audit sanitization
// ---------------------------------------------------------------------------
describe('Error & audit sanitization', () => {
it('tool throw with phone/SQL in message does NOT leak to result', async () => {
const { executor, registry } = makeExecutor();
registry.register(
makeTool({
name: 'leaky',
requiredPermission: 'student:view',
async execute(): Promise<unknown> {
throw new Error('phone=13800138000, idNumber=320106199001011234, SQL: SELECT * FROM students WHERE id=1');
},
}),
);
const result = await executor.execute('leaky', {}, studentViewerCtx);
expect(result.status).toBe('failed');
expect(result.error).toBe('工具执行失败');
expect(result.error).not.toContain('13800138000');
expect(result.error).not.toContain('320106');
expect(result.error).not.toContain('SQL');
expect(result.error).not.toContain('SELECT');
});
it('malicious tool name is sanitized in audit action', async () => {
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
const { executor } = makeExecutor(opLog);
const result = await executor.execute(
'evil\n<script>alert(1)</script>!@#$%^&*()very_long_name_exceeding_64_chars_padding_padding_padding_padding_END',
{},
studentViewerCtx,
);
expect(result.toolName).not.toContain('\n');
expect(result.toolName).not.toContain('<script>');
expect(result.toolName).not.toContain('!');
expect(result.toolName.length).toBeLessThanOrEqual(64);
expect(opLog.log).toHaveBeenCalled();
const call = opLog.log.mock.calls[0][0];
expect(call.action).not.toContain('\n');
expect(call.action).not.toContain('<script>');
expect(call.action).not.toContain('!');
expect(call.action).toContain('denied');
});
it('audit detail never contains exception messages', async () => {
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
const { executor, registry } = makeExecutor(opLog);
registry.register(
makeTool({
name: 'crash',
requiredPermission: 'student:view',
async execute(): Promise<unknown> {
throw new Error('DB error: table students at 10.0.0.1:5432');
},
}),
);
await executor.execute('crash', {}, studentViewerCtx);
const call = opLog.log.mock.calls[0][0];
expect(call.detail).toBe('执行失败');
expect(call.detail).not.toContain('DB error');
expect(call.detail).not.toContain('10.0.0.1');
expect(call.detail).not.toContain('5432');
});
it('unknown tool returns generic denied, not raw tool name detail', async () => {
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
const { executor } = makeExecutor(opLog);
const result = await executor.execute('hack_tool_with_pii_13800138000', {}, studentViewerCtx);
expect(result.error).toBe('未知工具');
expect(result.error).not.toContain('13800138000');
const call = opLog.log.mock.calls[0][0];
expect(call.detail).toBe('拒绝访问');
expect(call.detail).not.toContain('13800138000');
});
});
// ---------------------------------------------------------------------------
// NotFoundException → not_found
// ---------------------------------------------------------------------------
describe('NotFoundException → not_found', () => {
it('NotFound from tool returns not_found with safe message', async () => {
const { executor, registry } = makeExecutor();
registry.register(
makeTool({
name: 'find_student',
requiredPermission: 'student:view',
async execute(): Promise<unknown> {
throw new NotFoundException('原始内部消息: student 999 not in scope');
},
}),
);
const result = await executor.execute('find_student', {}, studentViewerCtx);
expect(result.status).toBe('not_found');
expect(result.error).toBe('记录不存在或无权访问');
expect(result.error).not.toContain('999');
expect(result.error).not.toContain('原始内部消息');
});
it('generic Error from tool returns failed with safe message', async () => {
const { executor, registry } = makeExecutor();
registry.register(
makeTool({
name: 'crash',
requiredPermission: 'student:view',
async execute(): Promise<unknown> {
throw new Error('random runtime error');
},
}),
);
const result = await executor.execute('crash', {}, studentViewerCtx);
expect(result.status).toBe('failed');
expect(result.error).toBe('工具执行失败');
});
});
// ---------------------------------------------------------------------------
// execute — core behavior
// ---------------------------------------------------------------------------
describe('execute — unknown tool', () => {
it('returns denied for unknown tool name', async () => {
const { executor } = makeExecutor();
const result = await executor.execute('nonexistent', {}, superAdminCtx);
expect(result.status).toBe('denied');
expect(result.toolName).toBe('nonexistent');
expect(result.error).toBe('未知工具');
});
});
describe('execute — double-check authorization', () => {
it('denies even if tool is registered but principal lacks permission', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
const result = await executor.execute('student_search', { q: 'test' }, noPermCtx);
expect(result.status).toBe('denied');
expect(result.error).toBe('权限不足');
});
it('allows execution when principal has required permission', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
const result = await executor.execute('student_search', { q: 'test' }, studentViewerCtx);
expect(result.status).toBe('success');
});
});
describe('execute — forged input rejection', () => {
it('rejects userId in input', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
const result = await executor.execute(
'student_search',
{ userId: 999, q: 'test' },
studentViewerCtx,
);
expect(result.status).toBe('failed');
expect(result.error).toBe('输入参数无效');
});
it('rejects isSuperAdmin in input', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
const result = await executor.execute(
'student_search',
{ isSuperAdmin: true, q: 'test' },
studentViewerCtx,
);
expect(result.status).toBe('failed');
expect(result.error).toBe('输入参数无效');
});
it('rejects permissions in input', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
const result = await executor.execute(
'student_search',
{ permissions: ['student:delete'], q: 'test' },
studentViewerCtx,
);
expect(result.status).toBe('failed');
expect(result.error).toBe('输入参数无效');
});
});
describe('execute — success', () => {
it('returns result on success', async () => {
const { executor, registry } = makeExecutor();
registry.register(
makeTool({
name: 'echo',
requiredPermission: 'student:view',
async execute(input: Record<string, unknown>): Promise<unknown> {
return { message: input.text };
},
}),
);
const result = await executor.execute('echo', { text: 'hello' }, studentViewerCtx);
expect(result.status).toBe('success');
expect(result.result).toEqual({ message: 'hello' });
});
});
describe('execute — tool error handling', () => {
it('returns failed status with safe message on tool throw', async () => {
const { executor, registry } = makeExecutor();
registry.register(
makeTool({
name: 'crashy',
requiredPermission: 'student:view',
async execute(): Promise<unknown> {
throw new Error('数据库连接失败: connection refused at 10.0.0.1:5432');
},
}),
);
const result = await executor.execute('crashy', {}, studentViewerCtx);
expect(result.status).toBe('failed');
expect(result.error).toBe('工具执行失败');
expect(result.error).not.toContain('数据库连接失败');
expect(result.error).not.toContain('10.0.0.1');
});
});
// ---------------------------------------------------------------------------
// Fix 3: Audit — awaited, best-effort
// ---------------------------------------------------------------------------
describe('audit logging — awaited best-effort', () => {
it('logs success with userId/username from context', async () => {
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
const { executor, registry } = makeExecutor(opLog);
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
await executor.execute('echo', { text: 'hi' }, studentViewerCtx);
expect(opLog.log).toHaveBeenCalledTimes(1);
const call = opLog.log.mock.calls[0][0];
expect(call.userId).toBe(2);
expect(call.username).toBe('teacher_zhang');
expect(call.module).toBe('AI Agent Tool');
expect(call.action).toContain('echo');
expect(call.action).toContain('success');
});
it('logs denied with status', async () => {
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
const { executor, registry } = makeExecutor(opLog);
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
await executor.execute('student_search', {}, noPermCtx);
const call = opLog.log.mock.calls[0][0];
expect(call.action).toContain('denied');
expect(call.detail).toBe('拒绝访问');
});
it('logs failed on validation error', async () => {
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
const { executor, registry } = makeExecutor(opLog);
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
await executor.execute('student_search', { isSuperAdmin: true }, studentViewerCtx);
const call = opLog.log.mock.calls[0][0];
expect(call.action).toContain('failed');
});
it('audit detail NEVER contains phone/ID/sensitive fields', async () => {
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
const { executor, registry } = makeExecutor(opLog);
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
await executor.execute('echo', { phone: '13800138000', name: 'test' }, studentViewerCtx);
const call = opLog.log.mock.calls[0][0];
const detail = call.detail as string;
expect(detail).not.toContain('13800138000');
expect(detail).not.toContain('phone');
expect(detail).not.toContain('idNumber');
expect(detail).not.toContain('password');
});
it('audit write failure does not break successful tool call', async () => {
const opLog = { log: jest.fn().mockRejectedValue(new Error('DB write error')) };
const { executor, registry } = makeExecutor(opLog);
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
const result = await executor.execute('echo', { text: 'hi' }, studentViewerCtx);
expect(result.status).toBe('success');
expect(result.result).toEqual({ echoed: { text: 'hi' } });
});
it('execute awaits audit before returning (delayed audit does not drop)', async () => {
let auditResolved = false;
const opLog = {
log: jest.fn().mockImplementation(() => {
return new Promise<void>((resolve) => {
setTimeout(() => {
auditResolved = true;
resolve();
}, 50);
});
}),
};
const { executor, registry } = makeExecutor(opLog);
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
// At call time, audit hasn't resolved
expect(auditResolved).toBe(false);
const result = await executor.execute('echo', { text: 'hi' }, studentViewerCtx);
// After execute returns, audit IS resolved (awaited)
expect(auditResolved).toBe(true);
expect(result.status).toBe('success');
});
it('audit rejection still resolves execute with correct result', async () => {
const opLog = {
log: jest.fn().mockImplementation(() => {
return new Promise<void>((_, reject) => {
setTimeout(() => reject(new Error('audit write failed')), 10);
});
}),
};
const { executor, registry } = makeExecutor(opLog);
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
const result = await executor.execute('echo', { text: 'hi' }, studentViewerCtx);
expect(result.status).toBe('success');
expect(result.result).toEqual({ echoed: { text: 'hi' } });
});
});
// ---------------------------------------------------------------------------
// Super admin
// ---------------------------------------------------------------------------
describe('super admin', () => {
it('super admin can execute any tool regardless of permission', async () => {
const { executor, registry } = makeExecutor();
registry.register(
makeTool({
name: 'admin_only',
requiredPermission: 'nuclear:launch',
}),
);
const result = await executor.execute('admin_only', {}, superAdminCtx);
expect(result.status).toBe('success');
});
it('listAvailable returns all tools for super admin', () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 't1', requiredPermission: 'ghost:action' }));
registry.register(makeTool({ name: 't2', requiredPermission: 'custom:code' }));
const tools = executor.listAvailable(superAdminCtx);
expect(tools).toHaveLength(2);
});
});

View 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 '执行失败';
}
}
}

View File

@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { CaslAction } from '../authorization/casl.constants';
import type { AppAbility } from '../authorization';
import type { ToolDef } from './agent-tool.types';
/**
* Internal tool registry — NOT exported from the module.
*
* Holds all registered Agent Tools. Lookups are delegated from
* {@link AgentToolExecutor}, which handles authorization, context
* validation, and audit logging.
*
* SDK consumers MUST NOT access this directly — use
* {@link AgentToolExecutor.listAvailable} and
* {@link AgentToolExecutor.execute} instead.
*/
@Injectable()
export class AgentToolRegistry {
private readonly tools: ToolDef[] = [];
/** Register a tool (called once at module init). */
register(tool: ToolDef): void {
const idx = this.tools.findIndex((t) => t.name === tool.name);
if (idx >= 0) {
this.tools[idx] = tool;
} else {
this.tools.push(tool);
}
}
/**
* Return tools whose required permission the given ability satisfies.
* The ability is built by the caller (Executor) — this is a pure
* filter, not an authorization decision.
*/
listAvailableInternal(ability: AppAbility): ToolDef[] {
return this.tools.filter((tool) => {
// Super admin ability has manage all — passes everything
if (ability.can(CaslAction.Manage, 'all')) return true;
// Exact permission-code check via CASL Access
return ability.can(CaslAction.Access, `PermissionCode:${tool.requiredPermission}`);
});
}
/**
* Look up an internal {@link ToolDef} by name.
* Returns `undefined` if not found.
*/
getForExecution(name: string): ToolDef | undefined {
return this.tools.find((t) => t.name === name);
}
}

View File

@@ -0,0 +1,180 @@
import type { AuthenticatedUser } from '../authorization';
// ---------------------------------------------------------------------------
// AgentToolContext — trusted server-side principal (NO ability)
// ---------------------------------------------------------------------------
// Module-private brand and trusted set for runtime forgery resistance
const trustedContexts = new WeakSet<AgentToolContext>();
const CONTEXT_BRAND = Symbol('AgentToolContext');
/**
* Execution context for Agent Tool invocations — branded to prevent
* forgery. Only {@link AgentToolContextFactory} can create trusted
* instances; {@link AgentToolExecutor} enforces this at runtime via
* {@link AgentToolContextFactory.assertTrusted}.
*
* All fields come from the trusted server-side authentication layer.
* The CASL ability is deliberately OMITTED — callers cannot inject a
* pre-forged ability.
*/
export class AgentToolContext {
/** The authenticated user's numeric ID. */
readonly userId!: number;
/** The authenticated user's login name (for audit). */
readonly username!: string;
/**
* Flat list of `resource:action` permission codes.
* Frozen at creation — downstream code cannot mutate it.
*/
readonly permissions!: readonly string[];
/** Whether the user has a super-admin role. */
readonly isSuperAdmin!: boolean;
/** @internal Module-private brand — set only by the Factory. */
private readonly _brand = CONTEXT_BRAND;
private constructor() {
// Construction is only via AgentToolContextFactory
}
}
/**
* Creates a trusted {@link AgentToolContext} from the authenticated
* user record populated by the JWT strategy.
*
* This is the ONLY way to create an AgentToolContext — never construct
* it by hand. The returned context is frozen and registered in an
* internal WeakSet; {@link assertTrusted} rejects any context not
* created through this factory.
*/
export class AgentToolContextFactory {
/**
* Build a frozen, branded context from an authenticated user.
*
* @param user — the user record placed on the request by JWT auth.
*/
static fromAuthenticatedUser(user: AuthenticatedUser): AgentToolContext {
const ctx = Object.create(AgentToolContext.prototype) as AgentToolContext;
Object.defineProperties(ctx, {
userId: { value: user.id, enumerable: true, writable: false, configurable: false },
username: { value: user.username, enumerable: true, writable: false, configurable: false },
permissions: {
value: Object.freeze([...user.permissions]),
enumerable: true,
writable: false,
configurable: false,
},
isSuperAdmin: { value: user.isSuperAdmin, enumerable: true, writable: false, configurable: false },
_brand: { value: CONTEXT_BRAND, enumerable: false, writable: false, configurable: false },
});
Object.freeze(ctx);
trustedContexts.add(ctx);
return ctx;
}
/**
* Runtime check: reject forged/plain-object contexts.
*
* Called at the entry of {@link AgentToolExecutor.execute} and
* {@link AgentToolExecutor.listAvailable}. Throws if the argument
* was not created by {@link fromAuthenticatedUser}.
*/
static assertTrusted(context: unknown): asserts context is AgentToolContext {
if (
!(context instanceof AgentToolContext) ||
!trustedContexts.has(context)
) {
throw new Error('DENIED: untrusted execution context');
}
}
}
// ---------------------------------------------------------------------------
// ToolDescriptor — public, non-executable tool surface
// ---------------------------------------------------------------------------
/**
* A read-only descriptor of an agent tool returned to SDK consumers.
*
* Does NOT expose `execute`, `validate`, or `requiredPermission` —
* callers must go through {@link AgentToolExecutor} for double-check
* authorization, input validation, and audit logging.
*/
export interface ToolDescriptor {
/** Unique tool name exposed to the LLM (e.g. "search_students"). */
readonly name: string;
/** Human-readable description for the model. */
readonly description: string;
/**
* Optional provider-neutral JSON Schema-like input description.
* Never exposes execution internals or permission details.
*/
readonly inputSchema?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// ToolDef — internal tool definition (NOT for SDK consumers)
// ---------------------------------------------------------------------------
/**
* Result of input validation — either success with parsed input,
* or an error message.
*/
export type ToolInputResult<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: string };
/**
* A single Agent Tool definition — internal use only.
*
* SDK consumers MUST receive a {@link ToolDescriptor}, never a `ToolDef`.
* Tool execution always goes through {@link AgentToolExecutor}.
*
* @typeParam TInput — the parsed & validated input shape the `execute`
* function receives.
*/
export interface ToolDef<TInput = unknown> {
/** Unique tool name exposed to the LLM (e.g. "search_students"). */
readonly name: string;
/** Human-readable description for the model. */
readonly description: string;
/**
* The exact `resource:action` permission code required to use this tool.
* Checked via {@link AuthorizationService.canPermission}.
*/
readonly requiredPermission: string;
/**
* Optional provider-neutral JSON Schema-like input description.
*/
readonly inputSchema?: Record<string, unknown>;
/**
* Validate and parse raw input from the model.
* Reject unknown/sensitive fields (userId, permissions, isSuperAdmin, …).
*/
validate(input: Record<string, unknown>): ToolInputResult<TInput>;
/**
* Execute the tool with parsed input and the trusted context.
* MUST NOT trust `context` to come from input.
*/
execute(input: TInput, context: AgentToolContext): Promise<unknown>;
}
// ---------------------------------------------------------------------------
// Tool execution status (for audit)
// ---------------------------------------------------------------------------
export type ToolStatus = 'success' | 'denied' | 'failed' | 'not_found';
/**
* Result returned by {@link AgentToolExecutor.execute}.
*/
export interface ToolExecutionResult {
readonly status: ToolStatus;
readonly toolName: string;
/** Set on success; `undefined` on denied / failed / not_found. */
readonly result?: unknown;
/** Set on denied / failed / not_found; `undefined` on success.
* Always a safe, human-readable message — never raw exception text. */
readonly error?: string;
}

View File

@@ -0,0 +1,43 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { StudentsModule } from '../students/students.module';
import { AgentToolRegistry } from './agent-tool.registry';
import { AgentToolExecutor } from './agent-tool.executor';
import { SearchStudentsTool } from './tools/search-students.tool';
import { GetStudentBasicTool } from './tools/get-student-basic.tool';
/**
* Agent Tools feature module.
*
* Provides a provider-neutral tool executor for LLM agent frameworks.
* SDK consumers interact ONLY with {@link AgentToolExecutor}.
*
* `AgentToolRegistry` is an internal provider — it is NOT exported from
* this module. All tool listing and execution goes through the executor,
* which enforces double-check authorization, audit logging, and context
* trust validation.
*
* Imports `StudentsModule` for student data access and relies on the
* globally available `AuthorizationModule` and `OperationLogsModule`.
*/
@Module({
imports: [StudentsModule],
providers: [
AgentToolRegistry,
AgentToolExecutor,
SearchStudentsTool,
GetStudentBasicTool,
],
exports: [AgentToolExecutor],
})
export class AgentToolsModule implements OnModuleInit {
constructor(
private readonly registry: AgentToolRegistry,
private readonly searchTool: SearchStudentsTool,
private readonly getTool: GetStudentBasicTool,
) {}
onModuleInit(): void {
this.registry.register(this.searchTool);
this.registry.register(this.getTool);
}
}

View File

@@ -0,0 +1,4 @@
export { AgentToolsModule } from './agent-tools.module';
export { AgentToolExecutor } from './agent-tool.executor';
export { AgentToolContextFactory, AgentToolContext } from './agent-tool.types';
export type { ToolDescriptor, ToolExecutionResult, ToolStatus } from './agent-tool.types';

View File

@@ -0,0 +1,180 @@
import { NotFoundException } from '@nestjs/common';
import { GetStudentBasicTool } from './get-student-basic.tool';
import { CaslAbilityFactory } from '../../authorization/casl-ability.factory';
import { StudentAccessScopeFactory } from '../../students/student-access-scope.factory';
import { AgentToolContextFactory } from '../agent-tool.types';
import type { AgentToolContext } from '../agent-tool.types';
import type { AuthenticatedUser } from '../../authorization';
const abilityFactory = new CaslAbilityFactory();
const scopeFactory = new StudentAccessScopeFactory(abilityFactory);
function makeCtx(overrides: Partial<AuthenticatedUser> & { id: number; username: string }): AgentToolContext {
const user: AuthenticatedUser = {
id: overrides.id,
username: overrides.username,
permissions: overrides.permissions ?? [],
isSuperAdmin: overrides.isSuperAdmin ?? false,
roles: overrides.roles ?? [],
};
return AgentToolContextFactory.fromAuthenticatedUser(user);
}
const studentViewerCtx = makeCtx({ id: 2, username: 'teacher', permissions: ['student:view'] });
const superAdminCtx = makeCtx({ id: 1, username: 'admin', isSuperAdmin: true });
const classEditorCtx = makeCtx({
id: 4,
username: 'class_editor',
permissions: ['student:view', 'class:edit'],
});
function makeTool(svcOverride?: { agentGetStudentBasic: jest.Mock }): GetStudentBasicTool {
const svc = svcOverride ?? { agentGetStudentBasic: jest.fn().mockResolvedValue(null) };
return new GetStudentBasicTool(svc as never, scopeFactory);
}
const basicOutput = {
id: 1,
name: '张三',
studentNo: 'S001',
gender: '男',
status: 'active',
organizationId: 10,
organizationName: '杭州校区',
classIds: [5],
};
describe('GetStudentBasicTool', () => {
it('has name "get_student_basic"', () => {
const tool = makeTool();
expect(tool.name).toBe('get_student_basic');
});
it('requires permission "student:view"', () => {
const tool = makeTool();
expect(tool.requiredPermission).toBe('student:view');
});
// -----------------------------------------------------------------------
// Validation
// -----------------------------------------------------------------------
describe('validate', () => {
it('accepts valid studentId', () => {
const tool = makeTool();
const result = tool.validate({ studentId: 1 });
expect(result.ok).toBe(true);
if (result.ok) expect(result.value.studentId).toBe(1);
});
it('rejects missing studentId', () => {
const tool = makeTool();
const result = tool.validate({});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('studentId');
});
it('rejects non-integer studentId', () => {
const tool = makeTool();
const result = tool.validate({ studentId: 'abc' });
expect(result.ok).toBe(false);
});
it('rejects extra unknown fields', () => {
const tool = makeTool();
const result = tool.validate({ studentId: 1, extraField: 'hack' });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('extraField');
});
it('rejects userId', () => {
const tool = makeTool();
const result = tool.validate({ studentId: 1, userId: 999 });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('userId');
});
});
// -----------------------------------------------------------------------
// P2-2: Scope construction
// -----------------------------------------------------------------------
describe('P2-2: scope', () => {
it('super admin uses manageAll scope', async () => {
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(basicOutput) };
const tool = makeTool(mockSvc);
await tool.execute({ studentId: 1 }, superAdminCtx);
expect(mockSvc.agentGetStudentBasic).toHaveBeenCalledWith(
{ type: 'manageAll' },
1,
);
});
it('non-admin uses teacher scope', async () => {
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(basicOutput) };
const tool = makeTool(mockSvc);
await tool.execute({ studentId: 1 }, studentViewerCtx);
expect(mockSvc.agentGetStudentBasic).toHaveBeenCalledWith(
{ type: 'teacher', userId: 2 },
1,
);
});
it('class:edit uses manageAll scope', async () => {
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(basicOutput) };
const tool = makeTool(mockSvc);
await tool.execute({ studentId: 1 }, classEditorCtx);
expect(mockSvc.agentGetStudentBasic).toHaveBeenCalledWith(
{ type: 'manageAll' },
1,
);
});
});
// -----------------------------------------------------------------------
// P2-1: NotFoundException for null result
// -----------------------------------------------------------------------
describe('P2-1: NotFoundException', () => {
it('null from service throws NotFoundException (not returned as success)', async () => {
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(null) };
const tool = makeTool(mockSvc);
await expect(tool.execute({ studentId: 999 }, studentViewerCtx)).rejects.toThrow(
NotFoundException,
);
});
it('service NotFound message is "记录不存在或无权访问"', async () => {
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(null) };
const tool = makeTool(mockSvc);
await expect(tool.execute({ studentId: 999 }, studentViewerCtx)).rejects.toThrow(
'记录不存在或无权访问',
);
});
});
// -----------------------------------------------------------------------
// Execute — happy path
// -----------------------------------------------------------------------
describe('execute', () => {
it('returns formatted student data', async () => {
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(basicOutput) };
const tool = makeTool(mockSvc);
const result = await tool.execute({ studentId: 1 }, superAdminCtx);
expect(result).toEqual(basicOutput);
const keys = Object.keys(result as Record<string, unknown>);
expect(keys).not.toContain('phone');
expect(keys).not.toContain('idNumber');
});
});
});

View File

@@ -0,0 +1,82 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { StudentsService } from '../../students/students.service';
import { StudentAccessScopeFactory } from '../../students/student-access-scope.factory';
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
interface GetStudentBasicInput {
studentId: number;
}
/** Forbidden input keys — if the model sends these, validation fails. */
const FORBIDDEN_INPUT_KEYS = new Set([
'userId',
'isSuperAdmin',
'permissions',
'roles',
'ability',
'user',
'password',
'token',
]);
@Injectable()
export class GetStudentBasicTool implements ToolDef<GetStudentBasicInput> {
readonly inputSchema = {
type: 'object',
properties: {
studentId: {
type: 'integer',
description: '学生ID',
minimum: 1,
},
},
required: ['studentId'],
additionalProperties: false,
};
readonly name = 'get_student_basic';
readonly description = '获取单个学生基本信息。仅返回基础公开字段。';
readonly requiredPermission = 'student:view';
constructor(
private readonly studentsService: StudentsService,
private readonly scopeFactory: StudentAccessScopeFactory,
) {}
validate(input: Record<string, unknown>): ToolInputResult<GetStudentBasicInput> {
for (const key of Object.keys(input)) {
if (FORBIDDEN_INPUT_KEYS.has(key)) {
return { ok: false, error: `不允许的输入字段: ${key}` };
}
}
if (input.studentId === undefined) {
return { ok: false, error: '缺少必填字段: studentId' };
}
const studentId = Number(input.studentId);
if (!Number.isInteger(studentId) || studentId <= 0) {
return { ok: false, error: 'studentId 必须是正整数' };
}
// Reject unexpected keys
const allowedKeys = new Set(['studentId']);
for (const key of Object.keys(input)) {
if (!allowedKeys.has(key)) {
return { ok: false, error: `不允许的输入字段: ${key}` };
}
}
return { ok: true, value: { studentId } };
}
async execute(input: GetStudentBasicInput, context: AgentToolContext): Promise<unknown> {
const scope = this.scopeFactory.buildScope(context);
const result = await this.studentsService.agentGetStudentBasic(scope, input.studentId);
if (result === null) {
throw new NotFoundException('记录不存在或无权访问');
}
return result;
}
}

View File

@@ -0,0 +1,181 @@
import { SearchStudentsTool } from './search-students.tool';
import { CaslAbilityFactory } from '../../authorization/casl-ability.factory';
import { StudentAccessScopeFactory } from '../../students/student-access-scope.factory';
import { AgentToolContextFactory } from '../agent-tool.types';
import type { AgentToolContext } from '../agent-tool.types';
import type { AuthenticatedUser } from '../../authorization';
const abilityFactory = new CaslAbilityFactory();
const scopeFactory = new StudentAccessScopeFactory(abilityFactory);
function makeCtx(
overrides: Partial<AuthenticatedUser> & { id: number; username: string },
): AgentToolContext {
const user: AuthenticatedUser = {
id: overrides.id,
username: overrides.username,
permissions: overrides.permissions ?? [],
isSuperAdmin: overrides.isSuperAdmin ?? false,
roles: overrides.roles ?? [],
};
return AgentToolContextFactory.fromAuthenticatedUser(user);
}
const studentViewerCtx = makeCtx({ id: 2, username: 'teacher', permissions: ['student:view'] });
const noPermCtx = makeCtx({ id: 3, username: 'guest', permissions: [] });
const superAdminCtx = makeCtx({ id: 1, username: 'admin', isSuperAdmin: true });
const classEditorCtx = makeCtx({
id: 4,
username: 'class_editor',
permissions: ['student:view', 'class:edit'],
});
function makeTool(svcOverride?: { agentSearchStudents: jest.Mock }): SearchStudentsTool {
const svc = svcOverride ?? { agentSearchStudents: jest.fn().mockResolvedValue([]) };
return new SearchStudentsTool(svc as never, scopeFactory);
}
describe('SearchStudentsTool', () => {
// -----------------------------------------------------------------------
// Tool metadata
// -----------------------------------------------------------------------
it('has name "search_students"', () => {
const tool = makeTool();
expect(tool.name).toBe('search_students');
});
it('requires permission "student:view"', () => {
const tool = makeTool();
expect(tool.requiredPermission).toBe('student:view');
});
// -----------------------------------------------------------------------
// Input validation
// -----------------------------------------------------------------------
describe('validate', () => {
it('accepts valid input with keyword', () => {
const tool = makeTool();
const result = tool.validate({ keyword: '张三' });
expect(result.ok).toBe(true);
if (result.ok) expect(result.value.keyword).toBe('张三');
});
it('accepts valid input with classId', () => {
const tool = makeTool();
const result = tool.validate({ classId: 5 });
expect(result.ok).toBe(true);
if (result.ok) expect(result.value.classId).toBe(5);
});
it('accepts valid input with organizationId', () => {
const tool = makeTool();
const result = tool.validate({ organizationId: 10 });
expect(result.ok).toBe(true);
if (result.ok) expect(result.value.organizationId).toBe(10);
});
it('accepts valid input with limit', () => {
const tool = makeTool();
const result = tool.validate({ limit: 30 });
expect(result.ok).toBe(true);
if (result.ok) expect(result.value.limit).toBe(30);
});
it('rejects userId', () => {
const tool = makeTool();
const result = tool.validate({ userId: 999 });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('userId');
});
it('rejects isSuperAdmin', () => {
const tool = makeTool();
const result = tool.validate({ isSuperAdmin: true });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('isSuperAdmin');
});
it('rejects permissions', () => {
const tool = makeTool();
const result = tool.validate({ permissions: ['student:delete'] });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('permissions');
});
it('rejects roles', () => {
const tool = makeTool();
const result = tool.validate({ roles: ['admin'] });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('roles');
});
it('rejects ability', () => {
const tool = makeTool();
const result = tool.validate({ ability: {} });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('ability');
});
it('rejects unknown fields to match additionalProperties false', () => {
const tool = makeTool();
const result = tool.validate({ debug: true });
expect(result.ok).toBe(false);
});
it('rejects limit above the advertised maximum', () => {
const tool = makeTool();
const result = tool.validate({ limit: 51 });
expect(result.ok).toBe(false);
});
it('rejects non-integer classId', () => {
const tool = makeTool();
const result = tool.validate({ classId: 'abc' });
expect(result.ok).toBe(false);
});
it('rejects non-integer organizationId', () => {
const tool = makeTool();
const result = tool.validate({ organizationId: 1.5 });
expect(result.ok).toBe(false);
});
});
// -----------------------------------------------------------------------
// P2-2: Scope construction via StudentAccessScopeFactory
// -----------------------------------------------------------------------
describe('P2-2: scope construction', () => {
it('super admin uses manageAll scope', async () => {
const mockSvc = { agentSearchStudents: jest.fn().mockResolvedValue([]) };
const tool = makeTool(mockSvc);
await tool.execute({}, superAdminCtx);
expect(mockSvc.agentSearchStudents).toHaveBeenCalledWith({ type: 'manageAll' }, {});
});
it('non-admin uses teacher scope with userId', async () => {
const mockSvc = { agentSearchStudents: jest.fn().mockResolvedValue([]) };
const tool = makeTool(mockSvc);
await tool.execute({ keyword: 'test' }, studentViewerCtx);
expect(mockSvc.agentSearchStudents).toHaveBeenCalledWith(
{ type: 'teacher', userId: 2 },
{ keyword: 'test' },
);
});
it('class:edit permission grants manageAll scope (not teacher)', async () => {
const mockSvc = { agentSearchStudents: jest.fn().mockResolvedValue([]) };
const tool = makeTool(mockSvc);
await tool.execute({}, classEditorCtx);
expect(mockSvc.agentSearchStudents).toHaveBeenCalledWith({ type: 'manageAll' }, {});
});
});
});

View File

@@ -0,0 +1,122 @@
import { Injectable } from '@nestjs/common';
import { StudentsService } from '../../students/students.service';
import { StudentAccessScopeFactory } from '../../students/student-access-scope.factory';
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
/** Whitelisted input shape for search_students. */
interface SearchStudentsInput {
keyword?: string;
classId?: number;
organizationId?: number;
limit?: number;
}
/** Forbidden input keys — if the model sends these, validation fails. */
const FORBIDDEN_INPUT_KEYS = new Set([
'userId',
'isSuperAdmin',
'permissions',
'roles',
'ability',
'user',
'password',
'token',
]);
@Injectable()
export class SearchStudentsTool implements ToolDef<SearchStudentsInput> {
readonly name = 'search_students';
readonly inputSchema = {
type: 'object',
properties: {
keyword: {
type: 'string',
description: '搜索关键词(姓名/学号)',
maxLength: 100,
},
classId: {
type: 'integer',
description: '班级ID',
minimum: 1,
},
organizationId: {
type: 'integer',
description: '校区ID',
minimum: 1,
},
limit: {
type: 'integer',
description: '返回条数上限',
minimum: 1,
maximum: 50,
},
},
additionalProperties: false,
};
readonly description = '搜索学生,支持关键词、班级、校区筛选。仅返回基础公开字段。';
readonly requiredPermission = 'student:view';
constructor(
private readonly studentsService: StudentsService,
private readonly scopeFactory: StudentAccessScopeFactory,
) {}
validate(input: Record<string, unknown>): ToolInputResult<SearchStudentsInput> {
// Reject forbidden keys
for (const key of Object.keys(input)) {
if (FORBIDDEN_INPUT_KEYS.has(key)) {
return {
ok: false,
error: `不允许的输入字段: ${key}`,
};
}
}
const allowedKeys = new Set(['keyword', 'classId', 'organizationId', 'limit']);
for (const key of Object.keys(input)) {
if (!allowedKeys.has(key)) {
return { ok: false, error: `不允许的输入字段: ${key}` };
}
}
const result: SearchStudentsInput = {};
if (input.keyword !== undefined) {
if (typeof input.keyword !== 'string' || input.keyword.length > 100) {
return { ok: false, error: 'keyword 必须是字符串且长度不超过100' };
}
result.keyword = input.keyword;
}
if (input.classId !== undefined) {
const id = Number(input.classId);
if (!Number.isInteger(id) || id <= 0) {
return { ok: false, error: 'classId 必须是正整数' };
}
result.classId = id;
}
if (input.organizationId !== undefined) {
const id = Number(input.organizationId);
if (!Number.isInteger(id) || id <= 0) {
return { ok: false, error: 'organizationId 必须是正整数' };
}
result.organizationId = id;
}
if (input.limit !== undefined) {
const limit = Number(input.limit);
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
return { ok: false, error: 'limit 必须是 1 到 50 的整数' };
}
result.limit = limit;
}
return { ok: true, value: result };
}
async execute(input: SearchStudentsInput, context: AgentToolContext): Promise<unknown> {
const scope = this.scopeFactory.buildScope(context);
return this.studentsService.agentSearchStudents(scope, input);
}
}