mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
501 lines
13 KiB
JavaScript
501 lines
13 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { readFileSync } = require('node:fs');
|
|
const { join } = require('node:path');
|
|
const { test } = require('node:test');
|
|
const {
|
|
ProjectPolicyEngine,
|
|
} = require('@qinglong/runtime-core/project-policy');
|
|
const {
|
|
InvalidToolDefinitionError,
|
|
InvalidToolJsonValueError,
|
|
TOOL_INVOCATION_SCHEMA,
|
|
ToolDefinitionRegistry,
|
|
ToolPolicySnapshotConflictError,
|
|
ToolPolicyUnavailableError,
|
|
UnsupportedToolError,
|
|
normalizeToolDefinition,
|
|
prepareToolInvocation,
|
|
} = require('../dist/tool-execution/tool-registry/toolRegistry');
|
|
|
|
function definition(overrides = {}) {
|
|
const value = {
|
|
name: 'run.compare',
|
|
version: '1.0.0',
|
|
description: 'Compare one bounded Run projection',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
runId: { type: 'string', minLength: 1, maxLength: 64 },
|
|
tags: {
|
|
type: 'array',
|
|
items: { type: 'string', maxLength: 16 },
|
|
maxItems: 4,
|
|
uniqueItems: true,
|
|
},
|
|
},
|
|
required: ['runId'],
|
|
additionalProperties: false,
|
|
},
|
|
outputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
summary: { type: 'string', maxLength: 1024 },
|
|
},
|
|
required: ['summary'],
|
|
additionalProperties: false,
|
|
},
|
|
effect: 'read',
|
|
risk: 'low',
|
|
requiredPermissions: ['run.read'],
|
|
timeoutSeconds: 15,
|
|
};
|
|
return {
|
|
...value,
|
|
...overrides,
|
|
inputSchema: overrides.inputSchema ?? value.inputSchema,
|
|
outputSchema: Object.hasOwn(overrides, 'outputSchema')
|
|
? overrides.outputSchema
|
|
: value.outputSchema,
|
|
};
|
|
}
|
|
|
|
function principal(overrides = {}) {
|
|
return {
|
|
subject: { type: 'user', id: 'usr-1' },
|
|
authenticationId: 'auth-1',
|
|
authenticatedAtMs: 900,
|
|
expiresAtMs: 2_000,
|
|
assurance: 'multi_factor',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function request(overrides = {}) {
|
|
return {
|
|
projectId: 'default',
|
|
principal: principal(),
|
|
nowMs: 1_000,
|
|
tool: { name: 'run.compare', version: '1.0.0' },
|
|
input: { tags: ['failed', 'recent'], runId: 'run-1' },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function policyDecision(effect = 'allow', fence = {}) {
|
|
return {
|
|
effect,
|
|
reasons:
|
|
effect === 'allow'
|
|
? ['role_grant']
|
|
: effect === 'deny'
|
|
? ['permission_missing']
|
|
: ['agent_action_requires_approval'],
|
|
fence:
|
|
fence === null
|
|
? null
|
|
: {
|
|
projectVersion: 3,
|
|
bindingVersion: 7,
|
|
...fence,
|
|
},
|
|
};
|
|
}
|
|
|
|
function authorizer(resolve = () => policyDecision()) {
|
|
const calls = [];
|
|
return {
|
|
calls,
|
|
async authorize(currentPrincipal, projectId, permission) {
|
|
calls.push({ currentPrincipal, projectId, permission });
|
|
return resolve(permission);
|
|
},
|
|
};
|
|
}
|
|
|
|
test('publishes one immutable registry without runtime registration', () => {
|
|
const registry = new ToolDefinitionRegistry([
|
|
definition({ version: '2.0.0' }),
|
|
definition(),
|
|
]);
|
|
assert.equal(Object.isFrozen(registry), true);
|
|
assert.equal('register' in registry, false);
|
|
assert.deepEqual(
|
|
registry.list().map(({ name, version }) => ({ name, version })),
|
|
[
|
|
{ name: 'run.compare', version: '1.0.0' },
|
|
{ name: 'run.compare', version: '2.0.0' },
|
|
],
|
|
);
|
|
assert.equal(Object.isFrozen(registry.list()[0].inputSchema), true);
|
|
assert.throws(
|
|
() => registry.resolve('run.compare', '3.0.0'),
|
|
UnsupportedToolError,
|
|
);
|
|
});
|
|
|
|
test('publishes the same contract through root and tool-registry subpath', () => {
|
|
const root = require('../dist');
|
|
const subpath = require('@qinglong/runtime-core/tool-registry');
|
|
assert.equal(root.ToolDefinitionRegistry, ToolDefinitionRegistry);
|
|
assert.equal(subpath.prepareToolInvocation, prepareToolInvocation);
|
|
});
|
|
|
|
test('normalizes a bounded exact JSON Schema subset', () => {
|
|
const normalized = normalizeToolDefinition(definition());
|
|
assert.deepEqual(normalized.requiredPermissions, ['run.read']);
|
|
assert.deepEqual(
|
|
normalizeToolDefinition(
|
|
definition({ requiredPermissions: ['package.manage'] }),
|
|
).requiredPermissions,
|
|
['package.manage'],
|
|
);
|
|
assert.deepEqual(Object.keys(normalized.inputSchema.properties), [
|
|
'runId',
|
|
'tags',
|
|
]);
|
|
assert.deepEqual(normalized.inputSchema.required, ['runId']);
|
|
|
|
const invalid = [
|
|
definition({ extra: true }),
|
|
definition({ name: 'RunCompare' }),
|
|
definition({ version: 'v1.0.0' }),
|
|
definition({ requiredPermissions: ['tool.call:run.get'] }),
|
|
definition({ requiredPermissions: ['run.read', 'run.read'] }),
|
|
definition({
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {},
|
|
required: [],
|
|
additionalProperties: true,
|
|
},
|
|
}),
|
|
definition({
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
value: { type: 'string' },
|
|
},
|
|
required: [],
|
|
additionalProperties: false,
|
|
},
|
|
}),
|
|
definition({
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {},
|
|
required: [],
|
|
additionalProperties: false,
|
|
oneOf: [],
|
|
},
|
|
}),
|
|
];
|
|
for (const value of invalid) {
|
|
assert.throws(
|
|
() => normalizeToolDefinition(value),
|
|
InvalidToolDefinitionError,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('enforces schema depth, node and property budgets', () => {
|
|
let schema = { type: 'string', maxLength: 8 };
|
|
for (let index = 0; index < 9; index += 1) {
|
|
schema = { type: 'array', items: schema, maxItems: 1 };
|
|
}
|
|
assert.throws(
|
|
() =>
|
|
normalizeToolDefinition(
|
|
definition({
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: { value: schema },
|
|
required: ['value'],
|
|
additionalProperties: false,
|
|
},
|
|
}),
|
|
),
|
|
/depth exceeded/,
|
|
);
|
|
|
|
const properties = Object.fromEntries(
|
|
Array.from({ length: 65 }, (_, index) => [
|
|
`field${index}`,
|
|
{ type: 'boolean' },
|
|
]),
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
normalizeToolDefinition(
|
|
definition({
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties,
|
|
required: [],
|
|
additionalProperties: false,
|
|
},
|
|
}),
|
|
),
|
|
/property budget exceeded/,
|
|
);
|
|
});
|
|
|
|
test('canonicalizes input and output while rejecting drift and bounds', () => {
|
|
const registry = new ToolDefinitionRegistry([definition()]);
|
|
const input = registry.normalizeInput('run.compare', '1.0.0', {
|
|
tags: ['failed', 'recent'],
|
|
runId: 'run-1',
|
|
});
|
|
assert.deepEqual(input, {
|
|
runId: 'run-1',
|
|
tags: ['failed', 'recent'],
|
|
});
|
|
assert.equal(Object.isFrozen(input), true);
|
|
assert.deepEqual(
|
|
registry.normalizeOutput('run.compare', '1.0.0', {
|
|
summary: 'changed\nwith context',
|
|
}),
|
|
{ summary: 'changed\nwith context' },
|
|
);
|
|
|
|
for (const invalid of [
|
|
{},
|
|
{ runId: 'run-1', extra: true },
|
|
{ runId: 'run-1', tags: ['same', 'same'] },
|
|
{ runId: 'x'.repeat(65) },
|
|
]) {
|
|
assert.throws(
|
|
() => registry.normalizeInput('run.compare', '1.0.0', invalid),
|
|
InvalidToolJsonValueError,
|
|
);
|
|
}
|
|
assert.throws(
|
|
() =>
|
|
registry.normalizeOutput('run.compare', '1.0.0', {
|
|
summary: 1,
|
|
}),
|
|
InvalidToolJsonValueError,
|
|
);
|
|
|
|
const getterInput = { runId: 'run-1' };
|
|
Object.defineProperty(getterInput, 'tags', {
|
|
enumerable: true,
|
|
get() {
|
|
throw new Error('must not execute');
|
|
},
|
|
});
|
|
assert.throws(
|
|
() => registry.normalizeInput('run.compare', '1.0.0', getterInput),
|
|
/JSON data properties/,
|
|
);
|
|
const sparse = [];
|
|
sparse.length = 1;
|
|
assert.throws(
|
|
() =>
|
|
registry.normalizeInput('run.compare', '1.0.0', {
|
|
runId: 'run-1',
|
|
tags: sparse,
|
|
}),
|
|
/dense JSON array/,
|
|
);
|
|
});
|
|
|
|
test('requires null output when a Tool has no output schema', () => {
|
|
const registry = new ToolDefinitionRegistry([
|
|
definition({ outputSchema: undefined }),
|
|
]);
|
|
assert.equal(registry.normalizeOutput('run.compare', '1.0.0', null), null);
|
|
assert.throws(
|
|
() => registry.normalizeOutput('run.compare', '1.0.0', {}),
|
|
/output must be null/,
|
|
);
|
|
});
|
|
|
|
test('prepares one digest-bound invocation from a single policy fence', async () => {
|
|
const registry = new ToolDefinitionRegistry([definition()]);
|
|
const policy = authorizer();
|
|
const plan = await prepareToolInvocation(registry, request(), policy);
|
|
assert.equal(plan.status, 'ready');
|
|
assert.equal(plan.schema, TOOL_INVOCATION_SCHEMA);
|
|
assert.equal(plan.permission, 'tool.call:run.compare');
|
|
assert.deepEqual(plan.requiredPermissions, ['run.read']);
|
|
assert.deepEqual(plan.fence, {
|
|
projectVersion: 3,
|
|
bindingVersion: 7,
|
|
});
|
|
assert.match(plan.inputDigest, /^[0-9a-f]{64}$/);
|
|
assert.match(plan.actionDigest, /^[0-9a-f]{64}$/);
|
|
assert.equal('execute' in plan, false);
|
|
assert.deepEqual(
|
|
policy.calls.map(({ permission }) => permission),
|
|
['tool.call:run.compare', 'run.read'],
|
|
);
|
|
|
|
const replay = await prepareToolInvocation(
|
|
registry,
|
|
request({ input: { runId: 'run-1', tags: ['failed', 'recent'] } }),
|
|
authorizer(),
|
|
);
|
|
assert.equal(replay.actionDigest, plan.actionDigest);
|
|
});
|
|
|
|
test('uses the real Project Policy port and requires approval for an Agent Tool call', async () => {
|
|
const registry = new ToolDefinitionRegistry([definition()]);
|
|
const policy = new ProjectPolicyEngine({
|
|
async resolve(projectId, subject) {
|
|
return {
|
|
project: {
|
|
id: projectId,
|
|
name: 'Default',
|
|
slug: 'default',
|
|
status: 'active',
|
|
version: 3,
|
|
createdAtMs: 1,
|
|
updatedAtMs: 2,
|
|
},
|
|
binding: {
|
|
projectId,
|
|
subject,
|
|
version: 7,
|
|
state: 'active',
|
|
role: 'operator',
|
|
mutationId: 'bind-1',
|
|
changedBy: { type: 'user', id: 'owner-1' },
|
|
createdAtMs: 2,
|
|
},
|
|
};
|
|
},
|
|
async append() {
|
|
throw new Error('not used');
|
|
},
|
|
});
|
|
const plan = await prepareToolInvocation(
|
|
registry,
|
|
request({
|
|
principal: principal({
|
|
subject: { type: 'agent', id: 'agent-1' },
|
|
assurance: 'service',
|
|
}),
|
|
}),
|
|
policy,
|
|
);
|
|
assert.equal(plan.status, 'approval_required');
|
|
assert.equal(plan.permission, 'tool.call:run.compare');
|
|
assert.equal('execute' in plan, false);
|
|
});
|
|
|
|
test('short-circuits denial before parsing untrusted Tool input', async () => {
|
|
const registry = new ToolDefinitionRegistry([definition()]);
|
|
const policy = authorizer(() => policyDecision('deny', null));
|
|
const plan = await prepareToolInvocation(
|
|
registry,
|
|
request({ input: { invalid: true } }),
|
|
policy,
|
|
);
|
|
assert.deepEqual(plan, {
|
|
status: 'denied',
|
|
tool: { name: 'run.compare', version: '1.0.0' },
|
|
permission: 'tool.call:run.compare',
|
|
});
|
|
assert.equal(policy.calls.length, 1);
|
|
});
|
|
|
|
test('fails closed on unavailable, malformed or mixed policy snapshots', async () => {
|
|
const registry = new ToolDefinitionRegistry([definition()]);
|
|
await assert.rejects(
|
|
prepareToolInvocation(
|
|
registry,
|
|
request(),
|
|
authorizer(() => {
|
|
throw new Error('storage internals');
|
|
}),
|
|
),
|
|
ToolPolicyUnavailableError,
|
|
);
|
|
await assert.rejects(
|
|
prepareToolInvocation(
|
|
registry,
|
|
request(),
|
|
authorizer((permission) =>
|
|
policyDecision('allow', {
|
|
projectVersion: permission === 'run.read' ? 4 : 3,
|
|
}),
|
|
),
|
|
),
|
|
ToolPolicySnapshotConflictError,
|
|
);
|
|
await assert.rejects(
|
|
prepareToolInvocation(
|
|
registry,
|
|
request(),
|
|
authorizer(() => ({
|
|
effect: 'allow',
|
|
reasons: ['driver stack'],
|
|
fence: null,
|
|
})),
|
|
),
|
|
ToolPolicyUnavailableError,
|
|
);
|
|
});
|
|
|
|
test('rejects expired principals and extensible invocation envelopes', async () => {
|
|
const registry = new ToolDefinitionRegistry([definition()]);
|
|
await assert.rejects(
|
|
prepareToolInvocation(
|
|
registry,
|
|
request({
|
|
principal: principal({ expiresAtMs: 1_000 }),
|
|
}),
|
|
authorizer(),
|
|
),
|
|
/principal lifetime is inactive/,
|
|
);
|
|
await assert.rejects(
|
|
prepareToolInvocation(
|
|
registry,
|
|
{ ...request(), extra: true },
|
|
authorizer(),
|
|
),
|
|
/request shape is invalid/,
|
|
);
|
|
});
|
|
|
|
test('enforces whole-envelope byte budgets after schema validation', () => {
|
|
const registry = new ToolDefinitionRegistry([
|
|
definition({
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
payload: { type: 'string', maxLength: 70_000 },
|
|
},
|
|
required: ['payload'],
|
|
additionalProperties: false,
|
|
},
|
|
}),
|
|
]);
|
|
assert.throws(
|
|
() =>
|
|
registry.normalizeInput('run.compare', '1.0.0', {
|
|
payload: 'x'.repeat(66_000),
|
|
}),
|
|
/byte budget exceeded/,
|
|
);
|
|
});
|
|
|
|
test('keeps registry and invocation planning free of execution and ambient authority', () => {
|
|
const source = readFileSync(
|
|
join(__dirname, '../src/tool-execution/tool-registry/toolRegistry.ts'),
|
|
'utf8',
|
|
);
|
|
for (const authority of [
|
|
"from 'node:child_process'",
|
|
"from 'node:fs'",
|
|
"from 'node:http'",
|
|
"from 'node:https'",
|
|
'setInterval(',
|
|
'setTimeout(',
|
|
'dynamic import',
|
|
]) {
|
|
assert.equal(source.includes(authority), false, authority);
|
|
}
|
|
});
|