Add AST-based security middleware and enforcement wiring
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user