Add AST-based security middleware and enforcement wiring

This commit is contained in:
2026-02-23 14:21:22 -05:00
parent 9b4216dda9
commit ef2a25b5fb
28 changed files with 1936 additions and 37 deletions

View File

@@ -1,10 +1,11 @@
import { isRecord } from "./types.js";
import { isDomainEventType, type DomainEventType } from "./domain-events.js";
import {
parseToolClearancePolicy,
type ToolClearancePolicy as SecurityToolClearancePolicy,
} from "../security/schemas.js";
export type ToolClearancePolicy = {
allowlist: string[];
banlist: string[];
};
export type ToolClearancePolicy = SecurityToolClearancePolicy;
export type ManifestPersona = {
id: string;
@@ -139,14 +140,12 @@ function readStringArray(record: Record<string, unknown>, key: string): string[]
}
function parseToolClearance(value: unknown): ToolClearancePolicy {
if (!isRecord(value)) {
throw new Error("Manifest persona toolClearance must be an object.");
try {
return parseToolClearancePolicy(value);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`Manifest persona toolClearance is invalid: ${detail}`);
}
return {
allowlist: readStringArray(value, "allowlist"),
banlist: readStringArray(value, "banlist"),
};
}
function parsePersona(value: unknown): ManifestPersona {

View File

@@ -8,10 +8,20 @@ import {
type PersonaBehaviorEvent,
type PersonaBehaviorHandler,
} from "./persona-registry.js";
import { PipelineExecutor, type ActorExecutor, type PipelineRunSummary } from "./pipeline.js";
import {
PipelineExecutor,
type ActorExecutionSecurityContext,
type ActorExecutor,
type PipelineRunSummary,
} from "./pipeline.js";
import { FileSystemProjectContextStore } from "./project-context.js";
import { FileSystemStateContextManager, type StoredSessionState } from "./state-context.js";
import type { JsonObject } from "./types.js";
import {
SecureCommandExecutor,
SecurityRulesEngine,
createFileSecurityAuditSink,
} from "../security/index.js";
export type OrchestrationSettings = {
workspaceRoot: string;
@@ -20,6 +30,7 @@ export type OrchestrationSettings = {
maxDepth: number;
maxRetries: number;
maxChildren: number;
securityViolationHandling: "hard_abort" | "validation_fail";
runtimeContext: Record<string, string | number | boolean>;
};
@@ -37,6 +48,7 @@ export function loadOrchestrationSettingsFromEnv(
maxDepth: config.orchestration.maxDepth,
maxRetries: config.orchestration.maxRetries,
maxChildren: config.orchestration.maxChildren,
securityViolationHandling: config.security.violationHandling,
};
}
@@ -64,6 +76,50 @@ function getChildrenByParent(manifest: AgentManifest): Map<string, AgentManifest
return map;
}
function createActorSecurityContext(input: {
config: Readonly<AppConfig>;
settings: OrchestrationSettings;
}): ActorExecutionSecurityContext {
const auditSink = createFileSecurityAuditSink(
resolve(input.settings.workspaceRoot, input.config.security.auditLogPath),
);
const rulesEngine = new SecurityRulesEngine(
{
allowedBinaries: input.config.security.shellAllowedBinaries,
worktreeRoot: resolve(
input.settings.workspaceRoot,
input.config.provisioning.gitWorktree.rootDirectory,
),
protectedPaths: [input.settings.stateRoot, input.settings.projectContextPath],
requireCwdWithinWorktree: true,
rejectRelativePathTraversal: true,
enforcePathBoundaryOnArguments: true,
allowedEnvAssignments: [],
blockedEnvAssignments: ["AGENT_STATE_ROOT", "AGENT_PROJECT_CONTEXT_PATH"],
},
auditSink,
);
return {
rulesEngine,
createCommandExecutor: (overrides) =>
new SecureCommandExecutor({
rulesEngine,
timeoutMs: overrides?.timeoutMs ?? input.config.security.commandTimeoutMs,
envPolicy:
overrides?.envPolicy ??
{
inherit: [...input.config.security.inheritedEnvVars],
scrub: [...input.config.security.scrubbedEnvVars],
inject: {},
},
shellPath: overrides?.shellPath,
uid: overrides?.uid ?? input.config.security.dropUid,
gid: overrides?.gid ?? input.config.security.dropGid,
}),
};
}
export class SchemaDrivenExecutionEngine {
private readonly manifest: AgentManifest;
private readonly personaRegistry = new PersonaRegistry();
@@ -74,6 +130,7 @@ export class SchemaDrivenExecutionEngine {
private readonly childrenByParent: Map<string, AgentManifest["relationships"]>;
private readonly manager: AgentManager;
private readonly mcpRegistry: McpRegistry;
private readonly securityContext: ActorExecutionSecurityContext;
constructor(input: {
manifest: AgentManifest | unknown;
@@ -99,6 +156,8 @@ export class SchemaDrivenExecutionEngine {
maxDepth: input.settings?.maxDepth ?? config.orchestration.maxDepth,
maxRetries: input.settings?.maxRetries ?? config.orchestration.maxRetries,
maxChildren: input.settings?.maxChildren ?? config.orchestration.maxChildren,
securityViolationHandling:
input.settings?.securityViolationHandling ?? config.security.violationHandling,
runtimeContext: {
...(input.settings?.runtimeContext ?? {}),
},
@@ -120,6 +179,10 @@ export class SchemaDrivenExecutionEngine {
maxRecursiveDepth: config.agentManager.maxRecursiveDepth,
});
this.mcpRegistry = input.mcpRegistry ?? createDefaultMcpRegistry();
this.securityContext = createActorSecurityContext({
config,
settings: this.settings,
});
for (const persona of this.manifest.personas) {
this.personaRegistry.register({
@@ -198,6 +261,8 @@ export class SchemaDrivenExecutionEngine {
managerSessionId,
projectContextStore: this.projectContextStore,
mcpRegistry: this.mcpRegistry,
securityViolationHandling: this.settings.securityViolationHandling,
securityContext: this.securityContext,
},
);
try {

View File

@@ -1,5 +1,6 @@
import type { JsonObject } from "./types.js";
import type { ManifestPersona, ToolClearancePolicy } from "./manifest.js";
import { parseToolClearancePolicy } from "../security/schemas.js";
export type PersonaBehaviorEvent = "onTaskComplete" | "onValidationFail";
@@ -28,18 +29,6 @@ function renderTemplate(
});
}
function uniqueStrings(values: string[]): string[] {
const output: string[] = [];
const seen = new Set<string>();
for (const value of values) {
if (!seen.has(value)) {
output.push(value);
seen.add(value);
}
}
return output;
}
export class PersonaRegistry {
private readonly personas = new Map<string, PersonaRuntimeDefinition>();
@@ -54,12 +43,10 @@ export class PersonaRegistry {
throw new Error(`Persona \"${persona.id}\" is already registered.`);
}
const toolClearance = parseToolClearancePolicy(persona.toolClearance);
this.personas.set(persona.id, {
...persona,
toolClearance: {
allowlist: uniqueStrings(persona.toolClearance.allowlist),
banlist: uniqueStrings(persona.toolClearance.banlist),
},
toolClearance,
});
}
@@ -81,8 +68,6 @@ export class PersonaRegistry {
getToolClearance(personaId: string): ToolClearancePolicy {
const persona = this.getById(personaId);
// TODO(security): enforce allowlist/banlist in the tool execution boundary.
return {
allowlist: [...persona.toolClearance.allowlist],
banlist: [...persona.toolClearance.banlist],

View File

@@ -25,6 +25,13 @@ import {
type StoredSessionState,
} from "./state-context.js";
import type { JsonObject } from "./types.js";
import {
SecureCommandExecutor,
SecurityRulesEngine,
SecurityViolationError,
type ExecutionEnvPolicy,
type SecurityViolationHandling,
} from "../security/index.js";
export type ActorResultStatus = "success" | "validation_fail" | "failure";
export type ActorFailureKind = "soft" | "hard";
@@ -50,6 +57,7 @@ export type ActorExecutionInput = {
allowlist: string[];
banlist: string[];
};
security?: ActorExecutionSecurityContext;
};
export type ActorExecutor = (input: ActorExecutionInput) => Promise<ActorExecutionResult>;
@@ -84,6 +92,19 @@ export type PipelineExecutorOptions = {
failurePolicy?: FailurePolicy;
lifecycleObserver?: PipelineLifecycleObserver;
hardFailureThreshold?: number;
securityViolationHandling?: SecurityViolationHandling;
securityContext?: ActorExecutionSecurityContext;
};
export type ActorExecutionSecurityContext = {
rulesEngine: SecurityRulesEngine;
createCommandExecutor: (input?: {
timeoutMs?: number;
envPolicy?: ExecutionEnvPolicy;
shellPath?: string;
uid?: number;
gid?: number;
}) => SecureCommandExecutor;
};
type QueueItem = {
@@ -283,6 +304,8 @@ export class PipelineExecutor {
private readonly failurePolicy: FailurePolicy;
private readonly lifecycleObserver: PipelineLifecycleObserver;
private readonly hardFailureThreshold: number;
private readonly securityViolationHandling: SecurityViolationHandling;
private readonly securityContext?: ActorExecutionSecurityContext;
private managerRunCounter = 0;
constructor(
@@ -294,6 +317,8 @@ export class PipelineExecutor {
) {
this.failurePolicy = options.failurePolicy ?? new FailurePolicy();
this.hardFailureThreshold = options.hardFailureThreshold ?? 2;
this.securityViolationHandling = options.securityViolationHandling ?? "hard_abort";
this.securityContext = options.securityContext;
this.lifecycleObserver =
options.lifecycleObserver ??
new PersistenceLifecycleObserver({
@@ -719,12 +744,29 @@ export class PipelineExecutor {
context: input.context,
signal: input.signal,
toolClearance: this.personaRegistry.getToolClearance(input.node.personaId),
security: this.securityContext,
});
} catch (error) {
if (input.signal.aborted) {
throw toAbortError(input.signal);
}
if (error instanceof SecurityViolationError) {
if (this.securityViolationHandling === "hard_abort") {
throw error;
}
return {
status: "validation_fail",
payload: {
error: error.message,
security_violation: true,
},
failureCode: error.code,
failureKind: "soft",
};
}
const classified = this.failurePolicy.classifyFailureFromError(error);
return {