|
|
|
|
@@ -16,8 +16,11 @@ import {
|
|
|
|
|
} from "./lifecycle-observer.js";
|
|
|
|
|
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 type {
|
|
|
|
|
CodexConfigObject,
|
|
|
|
|
LoadedMcpConfig,
|
|
|
|
|
McpLoadContext,
|
|
|
|
|
} from "../mcp/types.js";
|
|
|
|
|
import { PersonaRegistry } from "./persona-registry.js";
|
|
|
|
|
import { type ProjectContextPatch, type FileSystemProjectContextStore } from "./project-context.js";
|
|
|
|
|
import {
|
|
|
|
|
@@ -74,19 +77,35 @@ export type ActorToolPermissionHandler = (
|
|
|
|
|
) => Promise<ActorToolPermissionResult>;
|
|
|
|
|
|
|
|
|
|
export type ActorExecutionMcpContext = {
|
|
|
|
|
registry: McpRegistry;
|
|
|
|
|
allowedTools: string[];
|
|
|
|
|
resolvedConfig: LoadedMcpConfig;
|
|
|
|
|
resolveConfig: (context?: McpLoadContext) => LoadedMcpConfig;
|
|
|
|
|
filterToolsForProvider: (tools: string[]) => string[];
|
|
|
|
|
createToolPermissionHandler: () => ActorToolPermissionHandler;
|
|
|
|
|
createClaudeCanUseTool: () => ActorToolPermissionHandler;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type ResolvedExecutionSecurityConstraints = {
|
|
|
|
|
dropUid: boolean;
|
|
|
|
|
dropGid: boolean;
|
|
|
|
|
worktreePath: string;
|
|
|
|
|
violationMode: SecurityViolationHandling;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type ResolvedExecutionContext = {
|
|
|
|
|
phase: string;
|
|
|
|
|
modelConstraint: string;
|
|
|
|
|
allowedTools: string[];
|
|
|
|
|
security: ResolvedExecutionSecurityConstraints;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type ActorExecutionInput = {
|
|
|
|
|
sessionId: string;
|
|
|
|
|
node: PipelineNode;
|
|
|
|
|
prompt: string;
|
|
|
|
|
context: NodeExecutionContext;
|
|
|
|
|
signal: AbortSignal;
|
|
|
|
|
toolClearance: ToolClearancePolicy;
|
|
|
|
|
executionContext: ResolvedExecutionContext;
|
|
|
|
|
mcp: ActorExecutionMcpContext;
|
|
|
|
|
security?: ActorExecutionSecurityContext;
|
|
|
|
|
};
|
|
|
|
|
@@ -114,12 +133,13 @@ export type PipelineAggregateStatus = "success" | "failure";
|
|
|
|
|
export type PipelineExecutorOptions = {
|
|
|
|
|
workspaceRoot: string;
|
|
|
|
|
runtimeContext: Record<string, string | number | boolean>;
|
|
|
|
|
defaultModelConstraint?: string;
|
|
|
|
|
resolvedExecutionSecurityConstraints: ResolvedExecutionSecurityConstraints;
|
|
|
|
|
maxDepth: number;
|
|
|
|
|
maxRetries: number;
|
|
|
|
|
manager: AgentManager;
|
|
|
|
|
managerSessionId: string;
|
|
|
|
|
projectContextStore: FileSystemProjectContextStore;
|
|
|
|
|
mcpRegistry: McpRegistry;
|
|
|
|
|
failurePolicy?: FailurePolicy;
|
|
|
|
|
lifecycleObserver?: PipelineLifecycleObserver;
|
|
|
|
|
hardFailureThreshold?: number;
|
|
|
|
|
@@ -301,6 +321,99 @@ function dedupeStrings(values: readonly string[]): string[] {
|
|
|
|
|
return deduped;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cloneMcpConfig(config: LoadedMcpConfig): LoadedMcpConfig {
|
|
|
|
|
return typeof structuredClone === "function"
|
|
|
|
|
? structuredClone(config)
|
|
|
|
|
: (JSON.parse(JSON.stringify(config)) as LoadedMcpConfig);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readStringArray(value: unknown): string[] | undefined {
|
|
|
|
|
if (!Array.isArray(value)) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const output: string[] = [];
|
|
|
|
|
for (const item of value) {
|
|
|
|
|
if (typeof item !== "string") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const normalized = item.trim();
|
|
|
|
|
if (!normalized) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
output.push(normalized);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return output;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toAllowedToolPolicy(allowedTools: readonly string[]): ToolClearancePolicy {
|
|
|
|
|
return {
|
|
|
|
|
allowlist: [...allowedTools],
|
|
|
|
|
banlist: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyAllowedToolsToLoadedMcpConfig(
|
|
|
|
|
input: LoadedMcpConfig,
|
|
|
|
|
allowedTools: readonly string[],
|
|
|
|
|
): LoadedMcpConfig {
|
|
|
|
|
if (allowedTools.length === 0) {
|
|
|
|
|
const codexServers = input.codexConfig?.mcp_servers;
|
|
|
|
|
if (!codexServers) {
|
|
|
|
|
return cloneMcpConfig(input);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sanitizedServers: Record<string, CodexConfigObject> = {};
|
|
|
|
|
for (const [serverName, rawServer] of Object.entries(codexServers)) {
|
|
|
|
|
if (typeof rawServer !== "object" || rawServer === null || Array.isArray(rawServer)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
sanitizedServers[serverName] = {
|
|
|
|
|
...rawServer,
|
|
|
|
|
enabled_tools: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...cloneMcpConfig(input),
|
|
|
|
|
codexConfig: {
|
|
|
|
|
...(input.codexConfig ?? {}),
|
|
|
|
|
mcp_servers: sanitizedServers,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const allowset = new Set(allowedTools);
|
|
|
|
|
const codexServers = input.codexConfig?.mcp_servers;
|
|
|
|
|
if (!codexServers) {
|
|
|
|
|
return cloneMcpConfig(input);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sanitizedServers: Record<string, CodexConfigObject> = {};
|
|
|
|
|
for (const [serverName, rawServer] of Object.entries(codexServers)) {
|
|
|
|
|
if (typeof rawServer !== "object" || rawServer === null || Array.isArray(rawServer)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const enabledFromConfig = readStringArray((rawServer as Record<string, unknown>).enabled_tools);
|
|
|
|
|
const enabledTools = (enabledFromConfig ?? allowedTools).filter((tool) => allowset.has(tool));
|
|
|
|
|
sanitizedServers[serverName] = {
|
|
|
|
|
...rawServer,
|
|
|
|
|
enabled_tools: enabledTools,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...cloneMcpConfig(input),
|
|
|
|
|
codexConfig: {
|
|
|
|
|
...(input.codexConfig ?? {}),
|
|
|
|
|
mcp_servers: sanitizedServers,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toToolNameCandidates(toolName: string): string[] {
|
|
|
|
|
const trimmed = toolName.trim();
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
@@ -857,14 +970,20 @@ export class PipelineExecutor {
|
|
|
|
|
try {
|
|
|
|
|
throwIfAborted(input.signal);
|
|
|
|
|
const toolClearance = this.personaRegistry.getToolClearance(input.node.personaId);
|
|
|
|
|
const executionContext = this.resolveExecutionContext({
|
|
|
|
|
node: input.node,
|
|
|
|
|
toolClearance,
|
|
|
|
|
prompt: input.prompt,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return await input.executor({
|
|
|
|
|
sessionId: input.sessionId,
|
|
|
|
|
node: input.node,
|
|
|
|
|
prompt: input.prompt,
|
|
|
|
|
context: input.context,
|
|
|
|
|
signal: input.signal,
|
|
|
|
|
toolClearance,
|
|
|
|
|
mcp: this.buildActorMcpContext(toolClearance),
|
|
|
|
|
executionContext,
|
|
|
|
|
mcp: this.buildActorMcpContext(executionContext, input.prompt),
|
|
|
|
|
security: this.securityContext,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
@@ -901,34 +1020,142 @@ export class PipelineExecutor {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildActorMcpContext(toolClearance: ToolClearancePolicy): ActorExecutionMcpContext {
|
|
|
|
|
private resolveExecutionContext(input: {
|
|
|
|
|
node: PipelineNode;
|
|
|
|
|
toolClearance: ToolClearancePolicy;
|
|
|
|
|
prompt: string;
|
|
|
|
|
}): ResolvedExecutionContext {
|
|
|
|
|
const normalizedToolClearance = parseToolClearancePolicy(input.toolClearance);
|
|
|
|
|
const toolUniverse = this.resolveAvailableToolsForAttempt(normalizedToolClearance, input.prompt);
|
|
|
|
|
const allowedTools = this.resolveAllowedToolsForAttempt({
|
|
|
|
|
toolClearance: normalizedToolClearance,
|
|
|
|
|
toolUniverse,
|
|
|
|
|
});
|
|
|
|
|
const modelConstraint =
|
|
|
|
|
this.personaRegistry.getModelConstraint(input.node.personaId) ??
|
|
|
|
|
this.options.defaultModelConstraint ??
|
|
|
|
|
"provider-default";
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
phase: input.node.id,
|
|
|
|
|
modelConstraint,
|
|
|
|
|
allowedTools,
|
|
|
|
|
security: {
|
|
|
|
|
...this.options.resolvedExecutionSecurityConstraints,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private resolveAllowedToolsForAttempt(input: {
|
|
|
|
|
toolClearance: ToolClearancePolicy;
|
|
|
|
|
toolUniverse: string[];
|
|
|
|
|
}): string[] {
|
|
|
|
|
const normalized = parseToolClearancePolicy(input.toolClearance);
|
|
|
|
|
const banlist = new Set(normalized.banlist);
|
|
|
|
|
|
|
|
|
|
if (normalized.allowlist.length > 0) {
|
|
|
|
|
return dedupeStrings(normalized.allowlist.filter((tool) => !banlist.has(tool)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (input.toolUniverse.length > 0) {
|
|
|
|
|
return dedupeStrings(input.toolUniverse.filter((tool) => !banlist.has(tool)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private resolveAvailableToolsForAttempt(toolClearance: ToolClearancePolicy, prompt: string): string[] {
|
|
|
|
|
if (!this.options.resolveMcpConfig) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resolved = this.options.resolveMcpConfig({
|
|
|
|
|
providerHint: "codex",
|
|
|
|
|
prompt,
|
|
|
|
|
toolClearance,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const rawServers = resolved.codexConfig?.mcp_servers;
|
|
|
|
|
if (!rawServers) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tools: string[] = [];
|
|
|
|
|
for (const rawServer of Object.values(rawServers)) {
|
|
|
|
|
if (typeof rawServer !== "object" || rawServer === null || Array.isArray(rawServer)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const enabled = readStringArray((rawServer as Record<string, unknown>).enabled_tools) ?? [];
|
|
|
|
|
tools.push(...enabled);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return dedupeStrings(tools);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildActorMcpContext(
|
|
|
|
|
executionContext: ResolvedExecutionContext,
|
|
|
|
|
prompt: string,
|
|
|
|
|
): ActorExecutionMcpContext {
|
|
|
|
|
const toolPolicy = toAllowedToolPolicy(executionContext.allowedTools);
|
|
|
|
|
const filterToolsForProvider = (tools: string[]): string[] => {
|
|
|
|
|
const deduped = dedupeStrings(tools);
|
|
|
|
|
const allowset = new Set(executionContext.allowedTools);
|
|
|
|
|
return deduped.filter((tool) => allowset.has(tool));
|
|
|
|
|
};
|
|
|
|
|
const baseResolvedConfig = this.options.resolveMcpConfig
|
|
|
|
|
? this.options.resolveMcpConfig({
|
|
|
|
|
providerHint: "both",
|
|
|
|
|
prompt,
|
|
|
|
|
toolClearance: toolPolicy,
|
|
|
|
|
})
|
|
|
|
|
: {};
|
|
|
|
|
const resolvedConfig = applyAllowedToolsToLoadedMcpConfig(
|
|
|
|
|
baseResolvedConfig,
|
|
|
|
|
executionContext.allowedTools,
|
|
|
|
|
);
|
|
|
|
|
const resolveConfig = (context: McpLoadContext = {}): LoadedMcpConfig => {
|
|
|
|
|
if (!this.options.resolveMcpConfig) {
|
|
|
|
|
return {};
|
|
|
|
|
if (context.providerHint === "codex") {
|
|
|
|
|
return {
|
|
|
|
|
...(resolvedConfig.codexConfig ? { codexConfig: cloneMcpConfig(resolvedConfig).codexConfig } : {}),
|
|
|
|
|
...(resolvedConfig.sourcePath ? { sourcePath: resolvedConfig.sourcePath } : {}),
|
|
|
|
|
...(resolvedConfig.resolvedHandlers
|
|
|
|
|
? { resolvedHandlers: { ...resolvedConfig.resolvedHandlers } }
|
|
|
|
|
: {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.options.resolveMcpConfig({
|
|
|
|
|
...context,
|
|
|
|
|
toolClearance,
|
|
|
|
|
});
|
|
|
|
|
if (context.providerHint === "claude") {
|
|
|
|
|
return {
|
|
|
|
|
...(resolvedConfig.claudeMcpServers
|
|
|
|
|
? { claudeMcpServers: cloneMcpConfig(resolvedConfig).claudeMcpServers }
|
|
|
|
|
: {}),
|
|
|
|
|
...(resolvedConfig.sourcePath ? { sourcePath: resolvedConfig.sourcePath } : {}),
|
|
|
|
|
...(resolvedConfig.resolvedHandlers
|
|
|
|
|
? { resolvedHandlers: { ...resolvedConfig.resolvedHandlers } }
|
|
|
|
|
: {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return cloneMcpConfig(resolvedConfig);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const createToolPermissionHandler = (): ActorToolPermissionHandler =>
|
|
|
|
|
this.createToolPermissionHandler(toolClearance);
|
|
|
|
|
this.createToolPermissionHandler(executionContext.allowedTools);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
registry: this.options.mcpRegistry,
|
|
|
|
|
allowedTools: [...executionContext.allowedTools],
|
|
|
|
|
resolvedConfig: cloneMcpConfig(resolvedConfig),
|
|
|
|
|
resolveConfig,
|
|
|
|
|
filterToolsForProvider,
|
|
|
|
|
createToolPermissionHandler,
|
|
|
|
|
createClaudeCanUseTool: createToolPermissionHandler,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createToolPermissionHandler(toolClearance: ToolClearancePolicy): ActorToolPermissionHandler {
|
|
|
|
|
const normalizedToolClearance = parseToolClearancePolicy(toolClearance);
|
|
|
|
|
const allowlist = new Set(normalizedToolClearance.allowlist);
|
|
|
|
|
const banlist = new Set(normalizedToolClearance.banlist);
|
|
|
|
|
private createToolPermissionHandler(allowedTools: readonly string[]): ActorToolPermissionHandler {
|
|
|
|
|
const allowset = new Set(allowedTools);
|
|
|
|
|
const rulesEngine = this.securityContext?.rulesEngine;
|
|
|
|
|
const toolPolicy = toAllowedToolPolicy(allowedTools);
|
|
|
|
|
|
|
|
|
|
return async (toolName, _input, options) => {
|
|
|
|
|
const toolUseID = options.toolUseID;
|
|
|
|
|
@@ -942,63 +1169,23 @@ export class PipelineExecutor {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const allowMatch = candidates.find((candidate) => allowset.has(candidate));
|
|
|
|
|
if (!allowMatch) {
|
|
|
|
|
rulesEngine?.assertToolInvocationAllowed({
|
|
|
|
|
tool: candidates[0] ?? toolName,
|
|
|
|
|
toolClearance: toolPolicy,
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
behavior: "deny",
|
|
|
|
|
message: `Tool "${toolName}" is blocked by actor tool policy.`,
|
|
|
|
|
message: `Tool "${toolName}" is not in the resolved execution allowlist.`,
|
|
|
|
|
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,
|
|
|
|
|
tool: allowMatch,
|
|
|
|
|
toolClearance: toolPolicy,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|