Ctrl K
ComunidadGuías…

src/core/contracts/tools.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 工具治理的安全核心纯函数单测(零依赖:node:test + node:assert,tsx 直跑)。
// 覆盖:reach 白名单语义、工具调用签名(sha256= 唯一方案)、审批匹配用的参数规范化哈希。
// 这些函数是「谁够得到哪些工具 / 签名防重放篡改 / 审批认不认同一个调用」的判定底座,回归即安全事故,故钉死行为。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { compileOpenApiTools } from './openapi-tools';
import { TOOL_DEFINITION_SCHEMA_VERSION } from './tool-definition';
import { argsHash, buildToolRuntime, composeToolRuntimes, LocalSlidingWindowRateLimiter, scopeAllowed, signToolCall, type ToolRuntime } from './tools';
import type { ToolProvider } from './types';

test('scopeAllowed: 精确匹配命中', () => {
  assert.equal(scopeAllowed('goods.read', ['goods.read']), true);
  assert.equal(scopeAllowed('goods.read', ['order.read']), false);
  assert.equal(scopeAllowed('goods.read', []), false);
});

test('scopeAllowed: 前缀通配 a.* 含点边界,不跨段误命中', () => {
  assert.equal(scopeAllowed('goods.read', ['goods.*']), true);
  assert.equal(scopeAllowed('goods.write', ['goods.*']), true);
  // 'goods.*' → 前缀 'goods.'(带点):裸 'goods' 与 'goodsly' 都不应命中
  assert.equal(scopeAllowed('goods', ['goods.*']), false);
  assert.equal(scopeAllowed('goodsly', ['goods.*']), false);
});

test('scopeAllowed: 全局 * 放行一切(仍受逐工具 risk/subject 闸约束,此处只测 reach)', () => {
  assert.equal(scopeAllowed('anything.at.all', ['*']), true);
  assert.equal(scopeAllowed('goods.read', ['order.*', '*']), true);
});

test('argsHash: 顶层键序无关(键序不同不算换动作)', () => {
  assert.equal(argsHash({ a: 1, b: 2 }), argsHash({ b: 2, a: 1 }));
});

test('argsHash: 嵌套对象键序也无关', () => {
  assert.equal(argsHash({ x: { a: 1, b: 2 }, y: 3 }), argsHash({ y: 3, x: { b: 2, a: 1 } }));
});

test('argsHash: 数组顺序敏感(顺序是语义,不可乱)', () => {
  assert.notEqual(argsHash({ ids: [1, 2] }), argsHash({ ids: [2, 1] }));
});

test('argsHash: 值变则哈希变;undefined 与空对象同哈希(?? {} 兜底)', () => {
  assert.notEqual(argsHash({ price: 10 }), argsHash({ price: 11 }));
  assert.equal(argsHash(undefined as unknown as Record<string, unknown>), argsHash({}));
});

test('argsHash: 遵循 JSON 传输语义,undefined 不生成非法规范化片段', () => {
  assert.equal(argsHash({}), argsHash({ optional: undefined }));
  assert.equal(argsHash({ values: [null] }), argsHash({ values: [undefined] }));
  assert.notEqual(argsHash({ optional: null }), argsHash({}));
});

test('LocalSlidingWindowRateLimiter: 定期清除不再活跃的桶', () => {
  let now = 0;
  const limiter = new LocalSlidingWindowRateLimiter(() => now);

  assert.equal(limiter.consume('old-tool', 1), false);
  assert.equal(limiter.consume('old-tool', 1), true);
  assert.equal(limiter.bucketCount(), 1);

  now = 60_001;
  assert.equal(limiter.consume('active-tool', 1), false);
  assert.equal(limiter.bucketCount(), 1);
});

function fakeRuntime(name: string, output: string, retrievalMode = false): ToolRuntime {
  const definition = { type: 'function' as const, function: { name, description: name, parameters: { type: 'object' } } };
  return {
    llmTools: [definition], maxCalls: 3, progressive: false, retrievalMode,
    catalog: [{ name, summary: name, scope: `${name}.read`, risk: 'low', confirm_required: false }],
    async lookup(names) { return names.includes(name) ? [definition] : []; },
    ...(retrievalMode ? { async retrieve() { return [definition]; } } : {}),
    async invoke(called) { return { ok: true, status: 200, text: `${output}:${called}` }; },
    executionUncertainty() { return null; },
  };
}

test('composeToolRuntimes: 聚合多工具源清单并把调用路由回所属来源', async () => {
  const runtime = composeToolRuntimes([fakeRuntime('order_list', 'orders'), fakeRuntime('shipment_track', 'shipping')], 9);
  assert.equal(runtime.llmTools.length, 2);
  assert.equal(runtime.maxCalls, 9);
  assert.equal((await runtime.invoke('shipment_track', {})).text, 'shipping:shipment_track');
  assert.deepEqual((await runtime.lookup(['order_list', 'shipment_track'])).map((x) => x.function.name), ['order_list', 'shipment_track']);
});

test('composeToolRuntimes: 同名工具跨源冲突时 fail-closed', () => {
  assert.throws(() => composeToolRuntimes([fakeRuntime('duplicate', 'a'), fakeRuntime('duplicate', 'b')], 5), /工具名冲突 duplicate/);
});

test('buildToolRuntime: 只读工具发现不等待挂起的 best-effort 审计', async () => {
  const spec = JSON.stringify({
    openapi: '3.0.0',
    info: { title: 'meta audit', version: '1' },
    paths: {
      '/items': {
        get: {
          operationId: 'item_list',
          summary: '查询项目',
          'x-agent-capability': { version: 1, enabled: true, scope: 'item.read' },
        },
      },
    },
  });
  const [definition] = compileOpenApiTools(spec).tools;
  assert.ok(definition);
  const runtime = buildToolRuntime({
    provider: { name: 'meta', enabled: true } as ToolProvider,
    allowedTools: [definition],
    maxCalls: 5,
    onBehalfOf: '',
    jobId: 'job-meta',
    clientAppId: '',
    truncateBytes: 8192,
    audit: async () => await new Promise<void>(() => undefined),
    retrieveNames: async () => ['item_list'],
    retrievalMode: true,
  });
  const bounded = async <T>(promise: Promise<T>): Promise<T> => await Promise.race([
    promise,
    new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error('meta audit blocked result')), 100)),
  ]);

  assert.deepEqual((await bounded(runtime.lookup(['item_list']))).map((item) => item.function.name), ['item_list']);
  const retrieved = await bounded(runtime.retrieve!('items'));
  assert.ok(retrieved);
  assert.deepEqual(retrieved.map((item) => item.function.name), ['item_list']);
});

const SECRET = 'test-secret-蚂蚁';
const TS = 1_700_000_000;

function acc(scope: string, over: Record<string, unknown> = {}): Record<string, unknown> {
  return { 'x-agent-capability': { version: 1, enabled: true, scope, ...over } };
}

test('signToolCall: 确定性 + body 改则签名改 + 带 sha256= 前缀', () => {
  const a = signToolCall(SECRET, TS, 'POST', '/api/x?q=1', '{"n":1}', 't1:u9', 'job-1');
  assert.equal(a, signToolCall(SECRET, TS, 'POST', '/api/x?q=1', '{"n":1}', 't1:u9', 'job-1'));
  assert.notEqual(a, signToolCall(SECRET, TS, 'POST', '/api/x?q=1', '{"n":2}', 't1:u9', 'job-1'));
  assert.match(a, /^sha256=[0-9a-f]{64}$/); // 算法名前缀 + HMAC-SHA256 hex
});

test('signToolCall: 换主体或换任务都改签名(杜绝重放篡 On-Behalf-Of/Job-Id)', () => {
  const base = signToolCall(SECRET, TS, 'POST', '/api/x', '{}', 't1:u9', 'job-1');
  assert.notEqual(base, signToolCall(SECRET, TS, 'POST', '/api/x', '{}', 't1:u8', 'job-1'), '换主体应改签名');
  assert.notEqual(base, signToolCall(SECRET, TS, 'POST', '/api/x', '{}', 't1:u9', 'job-2'), '换任务应改签名');
});

test('signToolCall: spec 拉取形态(空主体空任务)确定性,且与带主体的调用不同源', () => {
  const spec = signToolCall(SECRET, TS, 'GET', '/api/x', ''); // 主体/任务缺省为空串
  assert.equal(spec, signToolCall(SECRET, TS, 'GET', '/api/x', '', '', ''));
  assert.notEqual(spec, signToolCall(SECRET, TS, 'GET', '/api/x', '', 't1:u9', 'job-1'));
});

test('compileOpenApiTools 风险安全默认下限:未标 risk 的写操作兜底 medium,GET/只读兜底 low,显式声明照走', () => {
  const P = { parameters: [{ name: 'id', in: 'query', schema: { type: 'string' } }] }; // 写接口须有参数才暴露
  const spec = JSON.stringify({
    paths: {
      '/a': { get: { ...acc('s.a'), summary: '查 a' } },                                   // GET 零参合法
      '/b': { post: { ...acc('s.b'), summary: '写 b(漏标 risk)', ...P } },
      '/c': { post: { ...acc('s.c', { execution: { readonly: true } }), summary: '查 c', ...P } },
      '/d': { post: { ...acc('s.d', { risk: { level: 'low' } }), summary: '写 d 显式 low', ...P } },
      '/e': { delete: { ...acc('s.e', { risk: { level: 'high' } }), summary: '删 e', ...P } },
    },
  });
  const tools = compileOpenApiTools(spec).tools;
  const tool = (scope: string) => { const x = tools.find((t) => t.scope === scope); assert.ok(x, `工具 ${scope} 应被暴露`); return x; };
  assert.equal(tool('s.a').risk, 'low', 'GET 兜底 low');
  assert.equal(tool('s.a').readonly, true);
  assert.equal(tool('s.b').risk, 'medium', '未标 risk 的写操作兜底 medium(留痕、不静默 low)');
  assert.equal(tool('s.b').readonly, false);
  assert.equal(tool('s.c').risk, 'low', '显式 execution.readonly 的 POST 查询兜底 low');
  assert.equal(tool('s.d').risk, 'low', '作者显式 low 照走(声明优先于下限)');
  assert.equal(tool('s.e').risk, 'high', '显式 high 照走');
});

test('compileOpenApiTools: ACC 单工具超时原值保留,超界声明不静默截断', () => {
  const makeSpec = (timeoutMs: unknown) => JSON.stringify({
    paths: {
      '/report': {
        get: {
          operationId: 'report_export',
          summary: '导出经营报表',
          ...acc('report.export', { execution: { timeout_ms: timeoutMs } }),
        },
      },
    },
  });

  const valid = compileOpenApiTools(makeSpec(120000));
  assert.equal(valid.tools[0]?.timeoutMs, 120000);
  assert.equal(valid.diagnostics.some((d) => d.severity === 'error'), false);

  const invalid = compileOpenApiTools(makeSpec(600001));
  assert.equal(invalid.tools.length, 0);
  assert.ok(invalid.diagnostics.some((d) => d.code === 'invalid_timeout'));
});

test('compileOpenApiTools: timeout_ms 保持契约严格类型,字符串数字给出可操作诊断', () => {
  const yaml = `
openapi: 3.0.0
info:
  title: Demo
  version: "1"
paths:
  /report:
    get:
      operationId: report_export
      summary: 导出经营报表
      x-agent-capability:
        version: 1
        enabled: true
        scope: report.export
        execution:
          timeout_ms: "5000"
`;

  const result = compileOpenApiTools(yaml);

  assert.equal(result.tools.length, 0);
  const diagnostic = result.diagnostics.find((d) => d.code === 'invalid_timeout_type');
  assert.equal(diagnostic?.severity, 'error');
  assert.match(diagnostic?.message ?? '', /string "5000"/);
  assert.match(diagnostic?.suggestion ?? '', /timeout_ms: 5000(不要加引号)/);
});

test('compileOpenApiTools: 业务参数走 OpenAPI schema,未知扩展保留但不参与治理', () => {
  const spec = JSON.stringify({
    paths: {
      '/order/create': {
        post: {
          operationId: 'order_create',
          summary: '创建订单',
          ...acc('order.create', {
            guidance: { context: ['tenant-boundary', 'requires-inventory-check'] },
            outcome: { result: '返回订单号和创建状态', side_effect: 'write' },
          }),
          'x-business-policy': { approval_scene: 'order_over_limit' },
          'x-business-owner': 'trade-team',
          requestBody: {
            content: {
              'application/json': {
                schema: {
                  type: 'object',
                  required: ['buyer_id', 'items'],
                  properties: {
                    buyer_id: { type: 'string', description: '购买人 ID' },
                    items: {
                      type: 'array',
                      items: {
                        type: 'object',
                        required: ['sku_id', 'qty'],
                        properties: {
                          sku_id: { type: 'string' },
                          qty: { type: 'integer', minimum: 1 },
                          attrs: { type: 'object', additionalProperties: true },
                        },
                      },
                    },
                    remark: { type: 'string', maxLength: 200 },
                  },
                },
              },
            },
          },
        },
      },
    },
  });

  const [tool] = compileOpenApiTools(spec).tools;
  assert.equal(tool?.name, 'order_create');
  assert.deepEqual(tool.context, ['tenant-boundary', 'requires-inventory-check']);
  assert.deepEqual(tool.outcome, { result: '返回订单号和创建状态', sideEffect: 'write' });
  assert.deepEqual(tool.extensions, {
    'x-business-policy': { approval_scene: 'order_over_limit' },
    'x-business-owner': 'trade-team',
  });
  assert.deepEqual((tool.inputSchema.properties as Record<string, unknown>)['items'], {
    type: 'array',
    items: {
      type: 'object',
      required: ['sku_id', 'qty'],
      properties: {
        sku_id: { type: 'string' },
        qty: { type: 'integer', minimum: 1 },
        attrs: { type: 'object', additionalProperties: true },
      },
    },
  });
});

test('compileOpenApiTools: approval.when 进入 ToolDefinition 而不是扩展袋', () => {
  const spec = JSON.stringify({
    paths: {
      '/refund': {
        post: {
          operationId: 'refund_create',
          summary: '创建退款单',
          ...acc('refund.write', {
            risk: { level: 'medium' },
            approval: { when: [{ param: 'amount', op: '>', value: 1000, label: '退款金额超过 1000' }] },
          }),
          requestBody: { content: { 'application/json': { schema: {
            type: 'object',
            properties: {
              order_id: { type: 'string', description: '订单号' },
              amount: { type: 'number', description: '金额' },
            },
            required: ['order_id', 'amount'],
          } } } },
        },
      },
    },
  });

  const { tools, diagnostics } = compileOpenApiTools(spec);

  assert.equal(tools.length, 1);
  assert.deepEqual(tools[0]?.confirmWhen, [{ param: 'amount', op: '>', value: 1000, label: '退款金额超过 1000' }]);
  assert.equal(tools[0]?.extensions['x-agent-capability'], undefined);
  assert.equal(diagnostics.some((d) => d.severity === 'error'), false);
});

test('compileOpenApiTools: approval.when 格式错误时阻断工具,避免误以为条件审批生效', () => {
  const spec = JSON.stringify({
    paths: {
      '/refund': {
        post: {
          operationId: 'refund_create',
          summary: '创建退款单',
          ...acc('refund.write', {
            approval: { when: [{ param: 'amount', op: 'roughly', value: 1000 }] },
          }),
          requestBody: { content: { 'application/json': { schema: {
            type: 'object',
            properties: { amount: { type: 'number', description: '金额' } },
            required: ['amount'],
          } } } },
        },
      },
    },
  });

  const { tools, diagnostics } = compileOpenApiTools(spec);

  assert.equal(tools.length, 0);
  assert.ok(diagnostics.some((d) => d.severity === 'error' && d.code === 'invalid_confirm_when_op'));
});

test('compileOpenApiTools: 输出 ToolDefinition schema/source、diagnostics,并保留 path/header 参数位置', () => {
  const spec = JSON.stringify({
    paths: {
      '/stores/{store_id}/staff': {
        get: {
          ...acc('tenant.staff.read'),
          parameters: [
            { name: 'store_id', in: 'path', required: true, schema: { type: 'string' }, description: '门店 ID' },
            { name: 'x-tenant-id', in: 'header', schema: { type: 'string' } },
            { name: 'keyword', in: 'query', schema: { type: 'string' } },
          ],
        },
      },
    },
  });

  const { tools, diagnostics } = compileOpenApiTools(spec);
  const [tool] = tools;
  assert.equal(tool?.schemaVersion, TOOL_DEFINITION_SCHEMA_VERSION);
  assert.equal(tool?.source, 'openapi');
  assert.equal(tool?.paramIn['store_id'], 'path');
  assert.equal(tool?.paramIn['x-tenant-id'], 'header');
  assert.equal(tool?.paramIn['keyword'], 'query');
  assert.deepEqual(tool?.inputSchema['required'], ['store_id']);
  assert.ok(diagnostics.some((d) => d.severity === 'warning' && d.code === 'missing_operation_id'));
  assert.ok(diagnostics.some((d) => d.severity === 'warning' && d.code === 'param_missing_description' && d.message.includes('x-tenant-id')));
});

test('compileOpenApiTools: Cookie、未知和缺失参数位置均 fail-closed,不静默映射为 query', () => {
  const operation = (scope: string, parameter: Record<string, unknown>) => ({
    operationId: scope.replace('.', '_'),
    summary: '读取示例',
    ...acc(scope),
    parameters: [{ name: 'session_id', description: '会话标识', schema: { type: 'string' }, ...parameter }],
  });
  const spec = JSON.stringify({
    paths: {
      '/cookie': { get: operation('demo.cookie', { in: 'cookie', required: true }) },
      '/unknown': { get: operation('demo.unknown', { in: 'formData' }) },
      '/missing': { get: operation('demo.missing', {}) },
    },
  });

  const result = compileOpenApiTools(spec);

  assert.equal(result.tools.length, 0);
  const diagnostics = result.diagnostics.filter((d) => d.code === 'unsupported_param_location');
  assert.deepEqual(diagnostics.map((d) => d.path), ['GET /cookie', 'GET /unknown', 'GET /missing']);
  assert.ok(diagnostics.every((d) => d.severity === 'error'));
  assert.match(diagnostics[0]?.message ?? '', /"cookie"/);
  assert.match(diagnostics[1]?.message ?? '', /"formData"/);
  assert.match(diagnostics[2]?.message ?? '', /未声明/);
  assert.match(diagnostics[0]?.suggestion ?? '', /签名头和业务侧授权/);
});

test('compileOpenApiTools: 编译阻断统一输出 error diagnostics', () => {
  const invalid = compileOpenApiTools('{bad-json');
  assert.equal(invalid.tools.length, 0);
  assert.equal(invalid.diagnostics[0]?.severity, 'error');
  assert.equal(invalid.diagnostics[0]?.code, 'invalid_spec');
  assert.match(invalid.diagnostics[0]?.message ?? '', /不是合法 JSON 或 YAML/);

  const spec = JSON.stringify({
    paths: {
      '/legacy': {
        post: {
          deprecated: true,
          ...acc('legacy.write'),
          parameters: [{ name: 'id', in: 'query', schema: { type: 'string' } }],
        },
      },
      '/no-scope': {
        get: {
          ...acc(''),
          summary: '缺 scope',
        },
      },
      '/blind-write': {
        post: {
          ...acc('blind.write'),
          summary: '无 schema 写接口',
        },
      },
    },
  });

  const result = compileOpenApiTools(spec);
  assert.equal(result.tools.length, 0);
  assert.ok(result.diagnostics.some((d) => d.severity === 'error' && d.code === 'deprecated' && d.path === 'POST /legacy'));
  assert.ok(result.diagnostics.some((d) => d.severity === 'error' && d.code === 'missing_scope' && d.path === 'GET /no-scope'));
  assert.ok(result.diagnostics.some((d) => d.severity === 'error' && d.code === 'write_without_schema' && d.path === 'POST /blind-write'));
});

test('compileOpenApiTools: YAML 与 JSON 进入同一编译链', () => {
  const yaml = `
openapi: 3.0.0
info:
  title: Demo
  version: "1"
paths:
  /orders:
    get:
      operationId: order_list
      summary: 查询订单
      x-agent-capability:
        version: 1
        enabled: true
        scope: order.read
`;
  const result = compileOpenApiTools(yaml);
  assert.equal(result.tools.length, 1);
  assert.equal(result.tools[0]?.name, 'order_list');
  assert.equal(result.tools[0]?.scope, 'order.read');
});

test('compileOpenApiTools: 候选工具必须通过 ToolDefinition 契约校验才进入清单', () => {
  const spec = JSON.stringify({
    paths: {
      '/stores/{store_id}/staff': {
        get: {
          operationId: 'tenant.staff.list',
          summary: '查询员工',
          ...acc('tenant.staff.read'),
          parameters: [
            { name: 'keyword', in: 'query', schema: { type: 'string' }, description: '搜索词' },
          ],
        },
      },
    },
  });

  const result = compileOpenApiTools(spec);

  assert.equal(result.tools.length, 0);
  assert.ok(result.diagnostics.some((d) => d.severity === 'error' && d.code === 'invalid_name' && d.path === 'GET /stores/{store_id}/staff'));
  assert.ok(result.diagnostics.some((d) => d.severity === 'error' && d.code === 'path_param_missing' && d.message.includes('store_id')));
});