Enforce actor-level MCP policy wiring and Claude tool gates
This commit is contained in:
@@ -5,6 +5,8 @@ import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { SchemaDrivenExecutionEngine } from "../src/agents/orchestration.js";
|
||||
import type { ActorExecutionResult } from "../src/agents/pipeline.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { createDefaultMcpRegistry, createMcpHandlerShell } from "../src/mcp.js";
|
||||
import { SecurityViolationError } from "../src/security/index.js";
|
||||
|
||||
function createManifest(): unknown {
|
||||
@@ -252,6 +254,163 @@ test("runs DAG pipeline with state-dependent routing and retry behavior", async
|
||||
assert.deepEqual(engine.planChildPersonas({ parentPersonaId: "task", depth: 1 }), ["coder"]);
|
||||
});
|
||||
|
||||
test("injects mcp registry/config helpers and enforces Claude tool gate in actor executor", async () => {
|
||||
const workspaceRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-workspace-"));
|
||||
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-session-state-"));
|
||||
const projectContextPath = resolve(stateRoot, "project-context.json");
|
||||
const mcpConfigPath = resolve(workspaceRoot, "mcp.config.json");
|
||||
|
||||
await writeFile(
|
||||
mcpConfigPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
servers: {
|
||||
"task-master-tools": {
|
||||
handler: "claude-task-master",
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["task-master-mcp.js"],
|
||||
enabled_tools: ["read_file", "write_file", "search"],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const config = loadConfig({
|
||||
...process.env,
|
||||
MCP_CONFIG_PATH: mcpConfigPath,
|
||||
});
|
||||
|
||||
const customRegistry = createDefaultMcpRegistry();
|
||||
customRegistry.register(
|
||||
createMcpHandlerShell({
|
||||
id: "custom-task-mcp-handler",
|
||||
description: "custom task handler",
|
||||
matches: () => false,
|
||||
}),
|
||||
);
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: "1" as const,
|
||||
topologies: ["sequential"],
|
||||
personas: [
|
||||
{
|
||||
id: "task",
|
||||
displayName: "Task",
|
||||
systemPromptTemplate: "Task executor",
|
||||
toolClearance: {
|
||||
allowlist: ["read_file", "write_file"],
|
||||
banlist: ["rm"],
|
||||
},
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
topologyConstraints: {
|
||||
maxDepth: 2,
|
||||
maxRetries: 0,
|
||||
},
|
||||
pipeline: {
|
||||
entryNodeId: "task-node",
|
||||
nodes: [
|
||||
{
|
||||
id: "task-node",
|
||||
actorId: "task_actor",
|
||||
personaId: "task",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
};
|
||||
|
||||
const engine = new SchemaDrivenExecutionEngine({
|
||||
manifest,
|
||||
config,
|
||||
mcpRegistry: customRegistry,
|
||||
settings: {
|
||||
workspaceRoot,
|
||||
stateRoot,
|
||||
projectContextPath,
|
||||
maxChildren: 1,
|
||||
maxDepth: 2,
|
||||
maxRetries: 0,
|
||||
},
|
||||
actorExecutors: {
|
||||
task_actor: async (input) => {
|
||||
assert.equal(input.mcp.registry, customRegistry);
|
||||
|
||||
const codexConfig = input.mcp.resolveConfig({
|
||||
providerHint: "codex",
|
||||
});
|
||||
const codexServer = (codexConfig.codexConfig?.mcp_servers as Record<string, Record<string, unknown>> | undefined)?.[
|
||||
"task-master-tools"
|
||||
];
|
||||
assert.ok(codexServer);
|
||||
assert.deepEqual(codexServer.enabled_tools, ["read_file", "write_file"]);
|
||||
assert.deepEqual(codexServer.disabled_tools, ["rm"]);
|
||||
|
||||
const claudeConfig = input.mcp.resolveConfig({
|
||||
providerHint: "claude",
|
||||
});
|
||||
assert.ok(claudeConfig.claudeMcpServers?.["task-master-tools"]);
|
||||
|
||||
const canUseTool = input.mcp.createClaudeCanUseTool();
|
||||
const allow = await canUseTool(
|
||||
"mcp__claude-task-master__read_file",
|
||||
{},
|
||||
{
|
||||
signal: new AbortController().signal,
|
||||
toolUseID: "allow-1",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(allow, {
|
||||
behavior: "allow",
|
||||
toolUseID: "allow-1",
|
||||
});
|
||||
|
||||
const denyBlocked = await canUseTool(
|
||||
"mcp__claude-task-master__rm",
|
||||
{},
|
||||
{
|
||||
signal: new AbortController().signal,
|
||||
toolUseID: "deny-1",
|
||||
},
|
||||
);
|
||||
assert.equal(denyBlocked.behavior, "deny");
|
||||
|
||||
const denyMissingAllowlist = await canUseTool(
|
||||
"mcp__claude-task-master__search",
|
||||
{},
|
||||
{
|
||||
signal: new AbortController().signal,
|
||||
toolUseID: "deny-2",
|
||||
},
|
||||
);
|
||||
assert.equal(denyMissingAllowlist.behavior, "deny");
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
payload: {
|
||||
ok: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await engine.runSession({
|
||||
sessionId: "session-mcp-gate-1",
|
||||
initialPayload: {
|
||||
task: "verify mcp gate",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, "success");
|
||||
});
|
||||
|
||||
test("runs parallel topology blocks concurrently and routes via domain-event edges", async () => {
|
||||
const workspaceRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-workspace-"));
|
||||
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-session-state-"));
|
||||
@@ -916,3 +1075,105 @@ test("can map security violations to validation_fail for retry-unrolled remediat
|
||||
["secure-node:validation_fail:1", "secure-node:success:2"],
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime event side-channel logs session and node lifecycle without changing pipeline behavior", async () => {
|
||||
const workspaceRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-runtime-event-workspace-"));
|
||||
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-runtime-event-state-"));
|
||||
const projectContextPath = resolve(stateRoot, "project-context.json");
|
||||
const runtimeEventLogRelativePath = ".ai_ops/events/test-runtime-events.ndjson";
|
||||
const runtimeEventLogPath = resolve(workspaceRoot, runtimeEventLogRelativePath);
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: "1",
|
||||
topologies: ["sequential"],
|
||||
personas: [
|
||||
{
|
||||
id: "runner",
|
||||
displayName: "Runner",
|
||||
systemPromptTemplate: "Runner",
|
||||
toolClearance: {
|
||||
allowlist: ["read_file"],
|
||||
banlist: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
topologyConstraints: {
|
||||
maxDepth: 2,
|
||||
maxRetries: 0,
|
||||
},
|
||||
pipeline: {
|
||||
entryNodeId: "node-1",
|
||||
nodes: [
|
||||
{
|
||||
id: "node-1",
|
||||
actorId: "runner_actor",
|
||||
personaId: "runner",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const config = loadConfig({
|
||||
AGENT_RUNTIME_EVENT_LOG_PATH: runtimeEventLogRelativePath,
|
||||
});
|
||||
|
||||
const engine = new SchemaDrivenExecutionEngine({
|
||||
manifest,
|
||||
config,
|
||||
settings: {
|
||||
workspaceRoot,
|
||||
stateRoot,
|
||||
projectContextPath,
|
||||
maxDepth: 2,
|
||||
maxRetries: 0,
|
||||
maxChildren: 1,
|
||||
runtimeContext: {},
|
||||
},
|
||||
actorExecutors: {
|
||||
runner_actor: async () => ({
|
||||
status: "success",
|
||||
payload: {
|
||||
complete: true,
|
||||
usage: {
|
||||
input_tokens: 120,
|
||||
output_tokens: 80,
|
||||
tool_calls: 2,
|
||||
duration_ms: 450,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await engine.runSession({
|
||||
sessionId: "session-runtime-events",
|
||||
initialPayload: {
|
||||
task: "Emit runtime events",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, "success");
|
||||
|
||||
const lines = (await readFile(runtimeEventLogPath, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0);
|
||||
assert.ok(lines.length >= 4);
|
||||
|
||||
const events = lines.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
const eventTypes = new Set(events.map((event) => String(event.type)));
|
||||
assert.ok(eventTypes.has("session.started"));
|
||||
assert.ok(eventTypes.has("node.attempt.completed"));
|
||||
assert.ok(eventTypes.has("domain.validation_passed"));
|
||||
assert.ok(eventTypes.has("session.completed"));
|
||||
|
||||
const nodeAttemptEvent = events.find((event) => event.type === "node.attempt.completed");
|
||||
assert.ok(nodeAttemptEvent);
|
||||
const usage = nodeAttemptEvent.usage as Record<string, unknown>;
|
||||
assert.equal(usage.tokenInput, 120);
|
||||
assert.equal(usage.tokenOutput, 80);
|
||||
assert.equal(usage.toolCalls, 2);
|
||||
assert.equal(usage.durationMs, 450);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user