feat: add CASL authorization and AI configuration
This commit is contained in:
678
apps/server/src/agent-tools/agent-tool.executor.spec.ts
Normal file
678
apps/server/src/agent-tools/agent-tool.executor.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
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 '执行失败';
|
||||
}
|
||||
}
|
||||
}
|
||||
52
apps/server/src/agent-tools/agent-tool.registry.ts
Normal file
52
apps/server/src/agent-tools/agent-tool.registry.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
180
apps/server/src/agent-tools/agent-tool.types.ts
Normal file
180
apps/server/src/agent-tools/agent-tool.types.ts
Normal 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;
|
||||
}
|
||||
43
apps/server/src/agent-tools/agent-tools.module.ts
Normal file
43
apps/server/src/agent-tools/agent-tools.module.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
4
apps/server/src/agent-tools/index.ts
Normal file
4
apps/server/src/agent-tools/index.ts
Normal 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';
|
||||
180
apps/server/src/agent-tools/tools/get-student-basic.tool.spec.ts
Normal file
180
apps/server/src/agent-tools/tools/get-student-basic.tool.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
82
apps/server/src/agent-tools/tools/get-student-basic.tool.ts
Normal file
82
apps/server/src/agent-tools/tools/get-student-basic.tool.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
181
apps/server/src/agent-tools/tools/search-students.tool.spec.ts
Normal file
181
apps/server/src/agent-tools/tools/search-students.tool.spec.ts
Normal 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' }, {});
|
||||
});
|
||||
});
|
||||
});
|
||||
122
apps/server/src/agent-tools/tools/search-students.tool.ts
Normal file
122
apps/server/src/agent-tools/tools/search-students.tool.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user