Enforce actor-level MCP policy wiring and Claude tool gates
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user