Enforce actor-level MCP policy wiring and Claude tool gates

This commit is contained in:
2026-02-23 17:28:55 -05:00
parent 20e944f7d4
commit 3ca9bd3db8
5 changed files with 828 additions and 111 deletions

View File

@@ -1,6 +1,6 @@
import { resolve } from "node:path";
import { getConfig, loadConfig, type AppConfig } from "../config.js";
import { createDefaultMcpRegistry, McpRegistry } from "../mcp.js";
import { createDefaultMcpRegistry, loadMcpConfigFromEnv, McpRegistry } from "../mcp.js";
import { parseAgentManifest, type AgentManifest } from "./manifest.js";
import { AgentManager } from "./manager.js";
import {
@@ -19,9 +19,17 @@ import { FileSystemStateContextManager, type StoredSessionState } from "./state-
import type { JsonObject } from "./types.js";
import {
SecureCommandExecutor,
type SecurityAuditEvent,
type SecurityAuditSink,
SecurityRulesEngine,
createFileSecurityAuditSink,
} from "../security/index.js";
import {
RuntimeEventPublisher,
createDiscordWebhookRuntimeEventSink,
createFileRuntimeEventSink,
type RuntimeEventSeverity,
} from "../telemetry/index.js";
export type OrchestrationSettings = {
workspaceRoot: string;
@@ -76,13 +84,106 @@ function getChildrenByParent(manifest: AgentManifest): Map<string, AgentManifest
return map;
}
function mapSecurityAuditSeverity(event: SecurityAuditEvent): RuntimeEventSeverity {
if (event.type === "shell.command_blocked" || event.type === "tool.invocation_blocked") {
return "critical";
}
return "info";
}
function toSecurityAuditMessage(event: SecurityAuditEvent): string {
if (event.type === "shell.command_blocked") {
return event.reason;
}
if (event.type === "tool.invocation_blocked") {
return event.reason;
}
if (event.type === "shell.command_allowed") {
return "Shell command passed security validation.";
}
if (event.type === "shell.command_profiled") {
return "Shell command parsed for security validation.";
}
return `Tool "${event.tool}" passed policy checks.`;
}
function toSecurityAuditMetadata(event: SecurityAuditEvent): JsonObject {
if (event.type === "shell.command_profiled") {
return {
command: event.command,
cwd: event.cwd,
commandCount: event.parsed.commandCount,
};
}
if (event.type === "shell.command_allowed") {
return {
command: event.command,
cwd: event.cwd,
commandCount: event.commandCount,
};
}
if (event.type === "shell.command_blocked") {
return {
command: event.command,
cwd: event.cwd,
reason: event.reason,
code: event.code,
};
}
if (event.type === "tool.invocation_allowed") {
return {
tool: event.tool,
};
}
return {
tool: event.tool,
reason: event.reason,
code: event.code,
};
}
function createRuntimeEventPublisher(input: {
config: Readonly<AppConfig>;
settings: OrchestrationSettings;
}): RuntimeEventPublisher {
const sinks = [
createFileRuntimeEventSink(
resolve(input.settings.workspaceRoot, input.config.runtimeEvents.logPath),
),
];
if (input.config.runtimeEvents.discordWebhookUrl) {
sinks.push(
createDiscordWebhookRuntimeEventSink({
webhookUrl: input.config.runtimeEvents.discordWebhookUrl,
minSeverity: input.config.runtimeEvents.discordMinSeverity,
alwaysNotifyTypes: input.config.runtimeEvents.discordAlwaysNotifyTypes,
}),
);
}
return new RuntimeEventPublisher({
sinks,
});
}
function createActorSecurityContext(input: {
config: Readonly<AppConfig>;
settings: OrchestrationSettings;
runtimeEventPublisher: RuntimeEventPublisher;
}): ActorExecutionSecurityContext {
const auditSink = createFileSecurityAuditSink(
const fileAuditSink = createFileSecurityAuditSink(
resolve(input.settings.workspaceRoot, input.config.security.auditLogPath),
);
const auditSink: SecurityAuditSink = (event): void => {
fileAuditSink(event);
void input.runtimeEventPublisher.publish({
type: `security.${event.type}`,
severity: mapSecurityAuditSeverity(event),
message: toSecurityAuditMessage(event),
metadata: toSecurityAuditMetadata(event),
});
};
const rulesEngine = new SecurityRulesEngine(
{
allowedBinaries: input.config.security.shellAllowedBinaries,
@@ -126,10 +227,12 @@ export class SchemaDrivenExecutionEngine {
private readonly stateManager: FileSystemStateContextManager;
private readonly projectContextStore: FileSystemProjectContextStore;
private readonly actorExecutors: ReadonlyMap<string, ActorExecutor>;
private readonly config: Readonly<AppConfig>;
private readonly settings: OrchestrationSettings;
private readonly childrenByParent: Map<string, AgentManifest["relationships"]>;
private readonly manager: AgentManager;
private readonly mcpRegistry: McpRegistry;
private readonly runtimeEventPublisher: RuntimeEventPublisher;
private readonly securityContext: ActorExecutionSecurityContext;
constructor(input: {
@@ -147,6 +250,7 @@ export class SchemaDrivenExecutionEngine {
this.manifest = parseAgentManifest(input.manifest);
const config = input.config ?? getConfig();
this.config = config;
this.settings = {
workspaceRoot: resolve(input.settings?.workspaceRoot ?? process.cwd()),
stateRoot: resolve(input.settings?.stateRoot ?? config.orchestration.stateRoot),
@@ -179,9 +283,14 @@ export class SchemaDrivenExecutionEngine {
maxRecursiveDepth: config.agentManager.maxRecursiveDepth,
});
this.mcpRegistry = input.mcpRegistry ?? createDefaultMcpRegistry();
this.runtimeEventPublisher = createRuntimeEventPublisher({
config,
settings: this.settings,
});
this.securityContext = createActorSecurityContext({
config,
settings: this.settings,
runtimeEventPublisher: this.runtimeEventPublisher,
});
for (const persona of this.manifest.personas) {
@@ -261,8 +370,21 @@ export class SchemaDrivenExecutionEngine {
managerSessionId,
projectContextStore: this.projectContextStore,
mcpRegistry: this.mcpRegistry,
resolveMcpConfig: ({ providerHint, prompt, toolClearance }) =>
loadMcpConfigFromEnv(
{
providerHint,
prompt,
},
{
config: this.config,
registry: this.mcpRegistry,
toolClearance,
},
),
securityViolationHandling: this.settings.securityViolationHandling,
securityContext: this.securityContext,
runtimeEventPublisher: this.runtimeEventPublisher,
},
);
try {

View File

@@ -17,6 +17,7 @@ import {
import type { AgentManifest, PipelineEdge, PipelineNode, RouteCondition } from "./manifest.js";
import type { AgentManager, RecursiveChildIntent } from "./manager.js";
import type { McpRegistry } from "../mcp/handlers.js";
import type { LoadedMcpConfig, McpLoadContext } from "../mcp/types.js";
import { PersonaRegistry } from "./persona-registry.js";
import { type ProjectContextPatch, type FileSystemProjectContextStore } from "./project-context.js";
import {
@@ -27,11 +28,14 @@ import {
import type { JsonObject } from "./types.js";
import {
SecureCommandExecutor,
parseToolClearancePolicy,
SecurityRulesEngine,
SecurityViolationError,
type ExecutionEnvPolicy,
type SecurityViolationHandling,
type ToolClearancePolicy,
} from "../security/index.js";
import { type RuntimeEventPublisher } from "../telemetry/index.js";
export type ActorResultStatus = "success" | "validation_fail" | "failure";
export type ActorFailureKind = "soft" | "hard";
@@ -47,16 +51,43 @@ export type ActorExecutionResult = {
failureCode?: string;
};
export type ActorToolPermissionResult =
| {
behavior: "allow";
toolUseID?: string;
}
| {
behavior: "deny";
message: string;
interrupt?: boolean;
toolUseID?: string;
};
export type ActorToolPermissionHandler = (
toolName: string,
input: Record<string, unknown>,
options: {
signal: AbortSignal;
toolUseID?: string;
agentID?: string;
},
) => Promise<ActorToolPermissionResult>;
export type ActorExecutionMcpContext = {
registry: McpRegistry;
resolveConfig: (context?: McpLoadContext) => LoadedMcpConfig;
createToolPermissionHandler: () => ActorToolPermissionHandler;
createClaudeCanUseTool: () => ActorToolPermissionHandler;
};
export type ActorExecutionInput = {
sessionId: string;
node: PipelineNode;
prompt: string;
context: NodeExecutionContext;
signal: AbortSignal;
toolClearance: {
allowlist: string[];
banlist: string[];
};
toolClearance: ToolClearancePolicy;
mcp: ActorExecutionMcpContext;
security?: ActorExecutionSecurityContext;
};
@@ -92,8 +123,10 @@ export type PipelineExecutorOptions = {
failurePolicy?: FailurePolicy;
lifecycleObserver?: PipelineLifecycleObserver;
hardFailureThreshold?: number;
resolveMcpConfig?: (input: McpLoadContext & { toolClearance: ToolClearancePolicy }) => LoadedMcpConfig;
securityViolationHandling?: SecurityViolationHandling;
securityContext?: ActorExecutionSecurityContext;
runtimeEventPublisher?: RuntimeEventPublisher;
};
export type ActorExecutionSecurityContext = {
@@ -245,6 +278,63 @@ function toAbortError(signal: AbortSignal): Error {
return error;
}
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function dedupeStrings(values: readonly string[]): string[] {
const deduped: string[] = [];
const seen = new Set<string>();
for (const value of values) {
const normalized = value.trim();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
deduped.push(normalized);
}
return deduped;
}
function toToolNameCandidates(toolName: string): string[] {
const trimmed = toolName.trim();
if (!trimmed) {
return [];
}
const candidates = [trimmed];
const maybeSuffix = (separator: string): void => {
const index = trimmed.lastIndexOf(separator);
if (index === -1 || index + separator.length >= trimmed.length) {
return;
}
candidates.push(trimmed.slice(index + separator.length));
};
const doubleUnderscoreParts = trimmed.split("__").filter((entry) => entry.length > 0);
if (doubleUnderscoreParts.length >= 2) {
const last = doubleUnderscoreParts[doubleUnderscoreParts.length - 1];
if (last) {
candidates.push(last);
}
if (doubleUnderscoreParts.length >= 3) {
candidates.push(doubleUnderscoreParts.slice(2).join("__"));
}
}
maybeSuffix(":");
maybeSuffix(".");
maybeSuffix("/");
maybeSuffix("\\");
return dedupeStrings(candidates);
}
function defaultEventPayloadForStatus(status: ActorResultStatus): DomainEventPayload {
if (status === "success") {
return {
@@ -320,6 +410,7 @@ export class PipelineExecutor {
stateManager: this.stateManager,
projectContextStore: this.options.projectContextStore,
domainEventBus: this.domainEventBus,
runtimeEventPublisher: this.options.runtimeEventPublisher,
});
for (const node of manifest.pipeline.nodes) {
@@ -342,136 +433,171 @@ export class PipelineExecutor {
initialState?: Partial<StoredSessionState>;
signal?: AbortSignal;
}): Promise<PipelineRunSummary> {
const projectContext = await this.options.projectContextStore.readState();
await this.stateManager.initializeSession(input.sessionId, {
...(input.initialState ?? {}),
flags: {
...projectContext.globalFlags,
...(input.initialState?.flags ?? {}),
},
await this.options.runtimeEventPublisher?.publish({
type: "session.started",
severity: "info",
sessionId: input.sessionId,
message: "Pipeline session started.",
metadata: {
project_context: {
globalFlags: { ...projectContext.globalFlags },
artifactPointers: { ...projectContext.artifactPointers },
taskQueue: projectContext.taskQueue.map((task) => ({
id: task.id,
title: task.title,
status: task.status,
...(task.assignee ? { assignee: task.assignee } : {}),
...(task.metadata ? { metadata: task.metadata } : {}),
})),
},
...((input.initialState?.metadata ?? {}) as JsonObject),
entryNodeId: this.manifest.pipeline.entryNodeId,
},
});
await this.stateManager.writeHandoff(input.sessionId, {
nodeId: this.manifest.pipeline.entryNodeId,
payload: input.initialPayload,
});
try {
const projectContext = await this.options.projectContextStore.readState();
const records: PipelineExecutionRecord[] = [];
const events: DomainEvent[] = [];
const ready = new Map<string, number>([[this.manifest.pipeline.entryNodeId, 0]]);
const completedNodes = new Set<string>();
await this.stateManager.initializeSession(input.sessionId, {
...(input.initialState ?? {}),
flags: {
...projectContext.globalFlags,
...(input.initialState?.flags ?? {}),
},
metadata: {
project_context: {
globalFlags: { ...projectContext.globalFlags },
artifactPointers: { ...projectContext.artifactPointers },
taskQueue: projectContext.taskQueue.map((task) => ({
id: task.id,
title: task.title,
status: task.status,
...(task.assignee ? { assignee: task.assignee } : {}),
...(task.metadata ? { metadata: task.metadata } : {}),
})),
},
...((input.initialState?.metadata ?? {}) as JsonObject),
},
});
const maxExecutions = this.manifest.pipeline.nodes.length * (this.options.maxRetries + 4);
let executionCount = 0;
let sequentialHardFailures = 0;
await this.stateManager.writeHandoff(input.sessionId, {
nodeId: this.manifest.pipeline.entryNodeId,
payload: input.initialPayload,
});
while (ready.size > 0) {
throwIfAborted(input.signal);
const frontier: QueueItem[] = [...ready.entries()].map(([nodeId, depth]) => ({ nodeId, depth }));
ready.clear();
const records: PipelineExecutionRecord[] = [];
const events: DomainEvent[] = [];
const ready = new Map<string, number>([[this.manifest.pipeline.entryNodeId, 0]]);
const completedNodes = new Set<string>();
for (const group of this.buildExecutionGroups(frontier)) {
const groupResults = group.concurrent
? await Promise.all(
group.items.map((queueItem) => this.executeNode({ ...queueItem, sessionId: input.sessionId, signal: input.signal })),
)
: await this.executeSequentialGroup(input.sessionId, group.items, input.signal);
const maxExecutions = this.manifest.pipeline.nodes.length * (this.options.maxRetries + 4);
let executionCount = 0;
let sequentialHardFailures = 0;
for (const nodeResult of groupResults) {
records.push(...nodeResult.records);
events.push(...nodeResult.events);
while (ready.size > 0) {
throwIfAborted(input.signal);
const frontier: QueueItem[] = [...ready.entries()].map(([nodeId, depth]) => ({ nodeId, depth }));
ready.clear();
executionCount += nodeResult.records.length;
if (executionCount > maxExecutions) {
throw new Error("Pipeline execution exceeded the configured safe execution bound.");
}
for (const wasHardFailure of nodeResult.hardFailureAttempts) {
if (wasHardFailure) {
sequentialHardFailures += 1;
} else {
sequentialHardFailures = 0;
}
if (
this.failurePolicy.shouldAbortAfterSequentialHardFailures(
sequentialHardFailures,
this.hardFailureThreshold,
for (const group of this.buildExecutionGroups(frontier)) {
const groupResults = group.concurrent
? await Promise.all(
group.items.map((queueItem) => this.executeNode({ ...queueItem, sessionId: input.sessionId, signal: input.signal })),
)
) {
throw new Error(
`Hard failure threshold reached (>=${String(this.hardFailureThreshold)} sequential API/network/403 failures). Pipeline aborted.`,
);
}
}
: await this.executeSequentialGroup(input.sessionId, group.items, input.signal);
completedNodes.add(nodeResult.queueItem.nodeId);
for (const nodeResult of groupResults) {
records.push(...nodeResult.records);
events.push(...nodeResult.events);
const state = await this.stateManager.readState(input.sessionId);
const candidateEdges = this.edgesBySource.get(nodeResult.queueItem.nodeId) ?? [];
const eventTypes = new Set(nodeResult.finalEventTypes);
for (const edge of candidateEdges) {
if (!shouldEdgeRun(edge, nodeResult.finalResult.status, eventTypes)) {
continue;
executionCount += nodeResult.records.length;
if (executionCount > maxExecutions) {
throw new Error("Pipeline execution exceeded the configured safe execution bound.");
}
if (!(await edgeConditionsSatisfied(edge, state, this.options.workspaceRoot))) {
continue;
for (const wasHardFailure of nodeResult.hardFailureAttempts) {
if (wasHardFailure) {
sequentialHardFailures += 1;
} else {
sequentialHardFailures = 0;
}
if (
this.failurePolicy.shouldAbortAfterSequentialHardFailures(
sequentialHardFailures,
this.hardFailureThreshold,
)
) {
throw new Error(
`Hard failure threshold reached (>=${String(this.hardFailureThreshold)} sequential API/network/403 failures). Pipeline aborted.`,
);
}
}
if (completedNodes.has(edge.to)) {
continue;
}
completedNodes.add(nodeResult.queueItem.nodeId);
await this.stateManager.writeHandoff(input.sessionId, {
nodeId: edge.to,
fromNodeId: nodeResult.queueItem.nodeId,
payload: nodeResult.finalPayload,
});
const state = await this.stateManager.readState(input.sessionId);
const candidateEdges = this.edgesBySource.get(nodeResult.queueItem.nodeId) ?? [];
const eventTypes = new Set(nodeResult.finalEventTypes);
const nextDepth = nodeResult.queueItem.depth + 1;
const existingDepth = ready.get(edge.to);
if (existingDepth === undefined || nextDepth < existingDepth) {
ready.set(edge.to, nextDepth);
for (const edge of candidateEdges) {
if (!shouldEdgeRun(edge, nodeResult.finalResult.status, eventTypes)) {
continue;
}
if (!(await edgeConditionsSatisfied(edge, state, this.options.workspaceRoot))) {
continue;
}
if (completedNodes.has(edge.to)) {
continue;
}
await this.stateManager.writeHandoff(input.sessionId, {
nodeId: edge.to,
fromNodeId: nodeResult.queueItem.nodeId,
payload: nodeResult.finalPayload,
});
const nextDepth = nodeResult.queueItem.depth + 1;
const existingDepth = ready.get(edge.to);
if (existingDepth === undefined || nextDepth < existingDepth) {
ready.set(edge.to, nextDepth);
}
}
}
}
}
}
const finalState = await this.stateManager.readState(input.sessionId);
const status = this.computeAggregateStatus(records);
if (status === "success") {
await this.options.projectContextStore.patchState({
artifactPointers: {
[`sessions/${input.sessionId}/final_state`]: this.stateManager.getSessionStatePath(input.sessionId),
const finalState = await this.stateManager.readState(input.sessionId);
const status = this.computeAggregateStatus(records);
if (status === "success") {
await this.options.projectContextStore.patchState({
artifactPointers: {
[`sessions/${input.sessionId}/final_state`]: this.stateManager.getSessionStatePath(input.sessionId),
},
});
}
await this.options.runtimeEventPublisher?.publish({
type: "session.completed",
severity: status === "success" ? "info" : "critical",
sessionId: input.sessionId,
message: `Pipeline session completed with status "${status}".`,
metadata: {
status,
recordCount: records.length,
eventCount: events.length,
},
});
}
return {
sessionId: input.sessionId,
status,
records,
events,
finalState,
};
return {
sessionId: input.sessionId,
status,
records,
events,
finalState,
};
} catch (error) {
await this.options.runtimeEventPublisher?.publish({
type: "session.failed",
severity: "critical",
sessionId: input.sessionId,
message: `Pipeline session failed: ${toErrorMessage(error)}`,
metadata: {
errorMessage: toErrorMessage(error),
},
});
throw error;
}
}
private computeAggregateStatus(records: PipelineExecutionRecord[]): PipelineAggregateStatus {
@@ -730,13 +856,15 @@ export class PipelineExecutor {
}): Promise<ActorExecutionResult> {
try {
throwIfAborted(input.signal);
const toolClearance = this.personaRegistry.getToolClearance(input.node.personaId);
return await input.executor({
sessionId: input.sessionId,
node: input.node,
prompt: input.prompt,
context: input.context,
signal: input.signal,
toolClearance: this.personaRegistry.getToolClearance(input.node.personaId),
toolClearance,
mcp: this.buildActorMcpContext(toolClearance),
security: this.securityContext,
});
} catch (error) {
@@ -773,6 +901,113 @@ export class PipelineExecutor {
}
}
private buildActorMcpContext(toolClearance: ToolClearancePolicy): ActorExecutionMcpContext {
const resolveConfig = (context: McpLoadContext = {}): LoadedMcpConfig => {
if (!this.options.resolveMcpConfig) {
return {};
}
return this.options.resolveMcpConfig({
...context,
toolClearance,
});
};
const createToolPermissionHandler = (): ActorToolPermissionHandler =>
this.createToolPermissionHandler(toolClearance);
return {
registry: this.options.mcpRegistry,
resolveConfig,
createToolPermissionHandler,
createClaudeCanUseTool: createToolPermissionHandler,
};
}
private createToolPermissionHandler(toolClearance: ToolClearancePolicy): ActorToolPermissionHandler {
const normalizedToolClearance = parseToolClearancePolicy(toolClearance);
const allowlist = new Set(normalizedToolClearance.allowlist);
const banlist = new Set(normalizedToolClearance.banlist);
const rulesEngine = this.securityContext?.rulesEngine;
return async (toolName, _input, options) => {
const toolUseID = options.toolUseID;
if (options.signal.aborted) {
return {
behavior: "deny",
message: "Tool execution denied because the request signal is aborted.",
interrupt: true,
...(toolUseID ? { toolUseID } : {}),
};
}
const candidates = toToolNameCandidates(toolName);
const banMatch = candidates.find((candidate) => banlist.has(candidate));
if (banMatch) {
if (rulesEngine) {
try {
rulesEngine.assertToolInvocationAllowed({
tool: banMatch,
toolClearance: normalizedToolClearance,
});
} catch {
// Security audit event already emitted by rules engine.
}
}
return {
behavior: "deny",
message: `Tool "${toolName}" is blocked by actor tool policy.`,
interrupt: true,
...(toolUseID ? { toolUseID } : {}),
};
}
if (allowlist.size > 0) {
const allowMatch = candidates.find((candidate) => allowlist.has(candidate));
if (!allowMatch) {
if (rulesEngine) {
try {
rulesEngine.assertToolInvocationAllowed({
tool: toolName,
toolClearance: normalizedToolClearance,
});
} catch {
// Security audit event already emitted by rules engine.
}
}
return {
behavior: "deny",
message: `Tool "${toolName}" is not in the actor tool allowlist.`,
interrupt: true,
...(toolUseID ? { toolUseID } : {}),
};
}
rulesEngine?.assertToolInvocationAllowed({
tool: allowMatch,
toolClearance: normalizedToolClearance,
});
return {
behavior: "allow",
...(toolUseID ? { toolUseID } : {}),
};
}
rulesEngine?.assertToolInvocationAllowed({
tool: candidates[0] ?? toolName,
toolClearance: normalizedToolClearance,
});
return {
behavior: "allow",
...(toolUseID ? { toolUseID } : {}),
};
};
}
private createAttemptDomainEvents(input: {
sessionId: string;
nodeId: string;