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 {

View File

@@ -1,5 +1,6 @@
import type { AgentManagerLimits } from "./agents/manager.js";
import type { BuiltInProvisioningConfig } from "./agents/provisioning.js";
import { parseSecurityViolationHandling, type SecurityViolationHandling } from "./security/index.js";
export type ProviderRuntimeConfig = {
codexApiKey?: string;
@@ -27,6 +28,17 @@ export type DiscoveryRuntimeConfig = {
fileRelativePath: string;
};
export type SecurityRuntimeConfig = {
violationHandling: SecurityViolationHandling;
shellAllowedBinaries: string[];
commandTimeoutMs: number;
auditLogPath: string;
inheritedEnvVars: string[];
scrubbedEnvVars: string[];
dropUid?: number;
dropGid?: number;
};
export type AppConfig = {
provider: ProviderRuntimeConfig;
mcp: McpRuntimeConfig;
@@ -34,6 +46,7 @@ export type AppConfig = {
orchestration: OrchestrationRuntimeConfig;
provisioning: BuiltInProvisioningConfig;
discovery: DiscoveryRuntimeConfig;
security: SecurityRuntimeConfig;
};
const DEFAULT_AGENT_MANAGER: AgentManagerLimits = {
@@ -68,6 +81,15 @@ const DEFAULT_DISCOVERY: DiscoveryRuntimeConfig = {
fileRelativePath: ".agent-context/resources.json",
};
const DEFAULT_SECURITY: SecurityRuntimeConfig = {
violationHandling: "hard_abort",
shellAllowedBinaries: ["git", "npm", "node", "cat", "ls", "pwd", "echo", "bash", "sh"],
commandTimeoutMs: 120_000,
auditLogPath: ".ai_ops/security/command-audit.ndjson",
inheritedEnvVars: ["PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL"],
scrubbedEnvVars: [],
};
function readOptionalString(
env: NodeJS.ProcessEnv,
key: string,
@@ -87,6 +109,31 @@ function readStringWithFallback(
return readOptionalString(env, key) ?? fallback;
}
function readCsvStringArrayWithFallback(
env: NodeJS.ProcessEnv,
key: string,
fallback: readonly string[],
options: {
allowEmpty?: boolean;
} = {},
): string[] {
const raw = env[key]?.trim();
if (!raw) {
return [...fallback];
}
const parsed = raw
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (parsed.length === 0 && options.allowEmpty !== true) {
throw new Error(`Environment variable ${key} must include at least one comma-delimited value.`);
}
return parsed;
}
function readIntegerWithBounds(
env: NodeJS.ProcessEnv,
key: string,
@@ -108,6 +155,26 @@ function readIntegerWithBounds(
return parsed;
}
function readOptionalIntegerWithBounds(
env: NodeJS.ProcessEnv,
key: string,
bounds: {
min: number;
},
): number | undefined {
const raw = env[key]?.trim();
if (!raw) {
return undefined;
}
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < bounds.min) {
throw new Error(`Environment variable ${key} must be an integer >= ${String(bounds.min)}.`);
}
return parsed;
}
function readBooleanWithFallback(
env: NodeJS.ProcessEnv,
key: string,
@@ -142,6 +209,12 @@ function deepFreeze<T>(value: T): Readonly<T> {
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Readonly<AppConfig> {
const rawViolationHandling = readStringWithFallback(
env,
"AGENT_SECURITY_VIOLATION_MODE",
DEFAULT_SECURITY.violationHandling,
);
const config: AppConfig = {
provider: {
codexApiKey: readOptionalString(env, "CODEX_API_KEY"),
@@ -257,6 +330,38 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Readonly<AppCo
DEFAULT_DISCOVERY.fileRelativePath,
),
},
security: {
violationHandling: parseSecurityViolationHandling(rawViolationHandling),
shellAllowedBinaries: readCsvStringArrayWithFallback(
env,
"AGENT_SECURITY_ALLOWED_BINARIES",
DEFAULT_SECURITY.shellAllowedBinaries,
),
commandTimeoutMs: readIntegerWithBounds(
env,
"AGENT_SECURITY_COMMAND_TIMEOUT_MS",
DEFAULT_SECURITY.commandTimeoutMs,
{ min: 1 },
),
auditLogPath: readStringWithFallback(
env,
"AGENT_SECURITY_AUDIT_LOG_PATH",
DEFAULT_SECURITY.auditLogPath,
),
inheritedEnvVars: readCsvStringArrayWithFallback(
env,
"AGENT_SECURITY_ENV_INHERIT",
DEFAULT_SECURITY.inheritedEnvVars,
),
scrubbedEnvVars: readCsvStringArrayWithFallback(
env,
"AGENT_SECURITY_ENV_SCRUB",
DEFAULT_SECURITY.scrubbedEnvVars,
{ allowEmpty: true },
),
dropUid: readOptionalIntegerWithBounds(env, "AGENT_SECURITY_DROP_UID", { min: 0 }),
dropGid: readOptionalIntegerWithBounds(env, "AGENT_SECURITY_DROP_GID", { min: 0 }),
},
};
return deepFreeze(config);

View File

@@ -20,6 +20,7 @@ import type {
McpLoadContext,
SharedMcpConfigFile,
} from "./mcp/types.js";
import type { ToolClearancePolicy } from "./security/schemas.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -62,6 +63,7 @@ export function loadMcpConfigFromEnv(
options?: {
config?: Readonly<AppConfig>;
registry?: McpRegistry;
toolClearance?: ToolClearancePolicy;
},
): LoadedMcpConfig {
const runtimeConfig = options?.config ?? getConfig();
@@ -82,6 +84,7 @@ export function loadMcpConfigFromEnv(
server,
context,
fullConfig: config,
toolClearance: options?.toolClearance,
});
resolvedHandlers[serverName] = resolved.handlerId;

View File

@@ -10,6 +10,10 @@ import type {
SharedMcpConfigFile,
SharedMcpServer,
} from "./types.js";
import {
parseToolClearancePolicy,
type ToolClearancePolicy,
} from "../security/schemas.js";
export type McpHandlerUtils = {
inferTransport: typeof inferTransport;
@@ -114,6 +118,77 @@ function readBooleanConfigValue(
return typeof value === "boolean" ? value : undefined;
}
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 dedupe(values: string[]): string[] {
const output: string[] = [];
const seen = new Set<string>();
for (const value of values) {
if (seen.has(value)) {
continue;
}
seen.add(value);
output.push(value);
}
return output;
}
function applyToolClearanceToResult(
result: McpHandlerResult,
toolClearance: ToolClearancePolicy,
): McpHandlerResult {
if (!result.codex) {
return result;
}
const codexConfig = result.codex;
const existingEnabled = readStringArray(codexConfig.enabled_tools);
const existingDisabled = readStringArray(codexConfig.disabled_tools) ?? [];
const banlist = new Set(toolClearance.banlist);
let enabledTools = existingEnabled;
if (toolClearance.allowlist.length > 0) {
enabledTools = (enabledTools ?? toolClearance.allowlist).filter((tool) =>
toolClearance.allowlist.includes(tool),
);
}
if (enabledTools) {
enabledTools = enabledTools.filter((tool) => !banlist.has(tool));
}
const disabledTools = dedupe([...existingDisabled, ...toolClearance.banlist]);
return {
...result,
codex: {
...codexConfig,
...(enabledTools ? { enabled_tools: enabledTools } : {}),
...(disabledTools.length > 0 ? { disabled_tools: disabledTools } : {}),
},
};
}
function applyEnabledByDefault(input: McpHandlerBusinessLogicInput): McpHandlerResult {
if (input.server.enabled !== undefined) {
return input.baseResult;
@@ -187,8 +262,9 @@ export class McpRegistry {
server: SharedMcpServer;
context: McpLoadContext;
fullConfig: SharedMcpConfigFile;
toolClearance?: ToolClearancePolicy;
}): McpHandlerResult & { handlerId: string } {
const { serverName, server, context, fullConfig } = input;
const { serverName, server, context, fullConfig, toolClearance } = input;
const handler = this.resolveHandler(serverName, server);
const handlerConfig = {
...(fullConfig.handlerSettings?.[handler.id] ?? {}),
@@ -203,9 +279,12 @@ export class McpRegistry {
fullConfig,
utils,
});
const securedResult = toolClearance
? applyToolClearanceToResult(result, parseToolClearancePolicy(toolClearance))
: result;
return {
...result,
...securedResult,
handlerId: handler.id,
};
}

14
src/security/audit-log.ts Normal file
View File

@@ -0,0 +1,14 @@
import { mkdir, appendFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import type { SecurityAuditEvent, SecurityAuditSink } from "./rules-engine.js";
export function createFileSecurityAuditSink(filePath: string): SecurityAuditSink {
const resolvedPath = resolve(filePath);
return (event: SecurityAuditEvent): void => {
void (async () => {
await mkdir(dirname(resolvedPath), { recursive: true });
await appendFile(resolvedPath, `${JSON.stringify(event)}\n`, "utf8");
})();
};
}

18
src/security/errors.ts Normal file
View File

@@ -0,0 +1,18 @@
export class SecurityViolationError extends Error {
readonly code: string;
readonly details?: Record<string, unknown>;
constructor(
message: string,
input: {
code?: string;
details?: Record<string, unknown>;
cause?: unknown;
} = {},
) {
super(message, input.cause !== undefined ? { cause: input.cause } : undefined);
this.name = "SecurityViolationError";
this.code = input.code ?? "SECURITY_VIOLATION";
this.details = input.details;
}
}

176
src/security/executor.ts Normal file
View File

@@ -0,0 +1,176 @@
import { spawn } from "node:child_process";
import type { ParsedShellScript } from "./shell-parser.js";
import {
parseExecutionEnvPolicy,
type ExecutionEnvPolicy,
type ToolClearancePolicy,
} from "./schemas.js";
import { SecurityRulesEngine } from "./rules-engine.js";
export type SecureCommandExecutionResult = {
exitCode: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
timedOut: boolean;
durationMs: number;
parsed: ParsedShellScript;
};
export type SecureCommandExecutorOptions = {
rulesEngine: SecurityRulesEngine;
envPolicy?: ExecutionEnvPolicy;
timeoutMs?: number;
shellPath?: string;
uid?: number;
gid?: number;
};
export class SecureCommandExecutor {
private readonly rulesEngine: SecurityRulesEngine;
private readonly envPolicy: ExecutionEnvPolicy;
private readonly timeoutMs: number;
private readonly shellPath: string;
private readonly uid?: number;
private readonly gid?: number;
constructor(options: SecureCommandExecutorOptions) {
this.rulesEngine = options.rulesEngine;
this.envPolicy = parseExecutionEnvPolicy(options.envPolicy ?? {});
this.timeoutMs = options.timeoutMs ?? 120_000;
this.shellPath = options.shellPath ?? "/bin/bash";
this.uid = options.uid;
this.gid = options.gid;
}
async execute(input: {
command: string;
cwd: string;
baseEnv?: Record<string, string | undefined>;
injectedEnv?: Record<string, string>;
toolClearance?: ToolClearancePolicy;
signal?: AbortSignal;
onStdoutChunk?: (chunk: string) => void;
onStderrChunk?: (chunk: string) => void;
}): Promise<SecureCommandExecutionResult> {
const validated = this.rulesEngine.validateShellCommand({
command: input.command,
cwd: input.cwd,
toolClearance: input.toolClearance,
});
const startedAt = Date.now();
const env = this.buildExecutionEnv(input.baseEnv, input.injectedEnv);
return new Promise<SecureCommandExecutionResult>((resolvePromise, rejectPromise) => {
let stdout = "";
let stderr = "";
let settled = false;
let timedOut = false;
const settleResolve = (result: SecureCommandExecutionResult): void => {
if (settled) {
return;
}
settled = true;
resolvePromise(result);
};
const settleReject = (error: unknown): void => {
if (settled) {
return;
}
settled = true;
rejectPromise(error);
};
const child = spawn(this.shellPath, ["-c", input.command], {
cwd: validated.cwd,
env,
...(this.uid !== undefined ? { uid: this.uid } : {}),
...(this.gid !== undefined ? { gid: this.gid } : {}),
...(input.signal ? { signal: input.signal } : {}),
stdio: ["ignore", "pipe", "pipe"],
});
const timeoutHandle =
this.timeoutMs > 0
? setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, this.timeoutMs)
: undefined;
child.stdout.on("data", (chunk) => {
const text = String(chunk);
stdout += text;
input.onStdoutChunk?.(text);
});
child.stderr.on("data", (chunk) => {
const text = String(chunk);
stderr += text;
input.onStderrChunk?.(text);
});
child.on("error", (error) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
settleReject(error);
});
child.on("close", (exitCode, signal) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
if (timedOut) {
settleReject(
new Error(
`Command timed out after ${String(this.timeoutMs)}ms: ${input.command}`,
),
);
return;
}
settleResolve({
exitCode,
signal,
stdout,
stderr,
timedOut,
durationMs: Date.now() - startedAt,
parsed: validated.parsed,
});
});
});
}
private buildExecutionEnv(
baseEnv: Record<string, string | undefined> | undefined,
injectedEnv: Record<string, string> | undefined,
): Record<string, string> {
const out: Record<string, string> = {};
const source = baseEnv ?? process.env;
for (const key of this.envPolicy.inherit) {
const value = source[key];
if (typeof value === "string") {
out[key] = value;
}
}
for (const key of this.envPolicy.scrub) {
delete out[key];
}
Object.assign(out, this.envPolicy.inject);
if (injectedEnv) {
Object.assign(out, injectedEnv);
}
return out;
}
}

33
src/security/index.ts Normal file
View File

@@ -0,0 +1,33 @@
export { SecurityViolationError } from "./errors.js";
export {
parseExecutionEnvPolicy,
parseSecurityViolationHandling,
parseShellValidationPolicy,
parseToolClearancePolicy,
securityViolationHandlingSchema,
shellValidationPolicySchema,
toolClearancePolicySchema,
executionEnvPolicySchema,
type ExecutionEnvPolicy,
type SecurityViolationHandling,
type ShellValidationPolicy,
type ToolClearancePolicy,
} from "./schemas.js";
export {
parseShellScript,
type ParsedShellAssignment,
type ParsedShellCommand,
type ParsedShellScript,
} from "./shell-parser.js";
export {
SecurityRulesEngine,
type SecurityAuditEvent,
type SecurityAuditSink,
type ValidatedShellCommand,
} from "./rules-engine.js";
export {
SecureCommandExecutor,
type SecureCommandExecutionResult,
type SecureCommandExecutorOptions,
} from "./executor.js";
export { createFileSecurityAuditSink } from "./audit-log.js";

View File

@@ -0,0 +1,421 @@
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
import { SecurityViolationError } from "./errors.js";
import {
parseShellScript,
type ParsedShellCommand,
type ParsedShellScript,
} from "./shell-parser.js";
import {
parseShellValidationPolicy,
parseToolClearancePolicy,
type ShellValidationPolicy,
type ToolClearancePolicy,
} from "./schemas.js";
export type SecurityAuditEvent =
| {
type: "shell.command_profiled";
timestamp: string;
command: string;
cwd: string;
parsed: ParsedShellScript;
}
| {
type: "shell.command_allowed";
timestamp: string;
command: string;
cwd: string;
commandCount: number;
}
| {
type: "shell.command_blocked";
timestamp: string;
command: string;
cwd: string;
reason: string;
code: string;
details?: Record<string, unknown>;
}
| {
type: "tool.invocation_allowed";
timestamp: string;
tool: string;
}
| {
type: "tool.invocation_blocked";
timestamp: string;
tool: string;
reason: string;
code: string;
};
export type SecurityAuditSink = (event: SecurityAuditEvent) => void;
export type ValidatedShellCommand = {
cwd: string;
parsed: ParsedShellScript;
};
function normalizeToken(value: string): string {
return value.trim();
}
function hasPathTraversalSegment(token: string): boolean {
const normalized = token.replaceAll("\\", "/");
if (normalized === ".." || normalized.startsWith("../") || normalized.endsWith("/..")) {
return true;
}
return normalized.includes("/../");
}
function isPathLikeToken(token: string): boolean {
if (!token || token === ".") {
return false;
}
if (token.startsWith("/") || token.startsWith("./") || token.startsWith("../")) {
return true;
}
return token.includes("/") || token.includes("\\");
}
function isWithinPath(root: string, candidate: string): boolean {
const rel = relative(root, candidate);
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
}
function isPathBlocked(candidatePath: string, protectedPath: string): boolean {
const rel = relative(protectedPath, candidatePath);
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
}
function toToolSet(values: readonly string[]): Set<string> {
const out = new Set<string>();
for (const value of values) {
out.add(value);
}
return out;
}
function toNow(): string {
return new Date().toISOString();
}
export class SecurityRulesEngine {
private readonly policy: ShellValidationPolicy;
private readonly allowedBinaries: Set<string>;
private readonly allowedEnvAssignments: Set<string>;
private readonly blockedEnvAssignments: Set<string>;
private readonly worktreeRoot: string;
private readonly protectedPaths: string[];
constructor(
policy: ShellValidationPolicy,
private readonly auditSink?: SecurityAuditSink,
) {
this.policy = parseShellValidationPolicy(policy);
this.allowedBinaries = toToolSet(this.policy.allowedBinaries);
this.allowedEnvAssignments = toToolSet(this.policy.allowedEnvAssignments);
this.blockedEnvAssignments = toToolSet(this.policy.blockedEnvAssignments);
this.worktreeRoot = resolve(this.policy.worktreeRoot);
this.protectedPaths = this.policy.protectedPaths.map((path) => resolve(path));
}
getPolicy(): ShellValidationPolicy {
return {
...this.policy,
allowedBinaries: [...this.policy.allowedBinaries],
protectedPaths: [...this.policy.protectedPaths],
allowedEnvAssignments: [...this.policy.allowedEnvAssignments],
blockedEnvAssignments: [...this.policy.blockedEnvAssignments],
};
}
validateShellCommand(input: {
command: string;
cwd: string;
toolClearance?: ToolClearancePolicy;
}): ValidatedShellCommand {
const resolvedCwd = resolve(input.cwd);
try {
this.assertCwdBoundary(resolvedCwd);
const parsed = parseShellScript(input.command);
const toolClearance = input.toolClearance
? parseToolClearancePolicy(input.toolClearance)
: undefined;
this.emit({
type: "shell.command_profiled",
timestamp: toNow(),
command: input.command,
cwd: resolvedCwd,
parsed,
});
for (const command of parsed.commands) {
this.assertBinaryAllowed(command, toolClearance);
this.assertAssignmentsAllowed(command);
this.assertArgumentPaths(command, resolvedCwd);
}
this.emit({
type: "shell.command_allowed",
timestamp: toNow(),
command: input.command,
cwd: resolvedCwd,
commandCount: parsed.commandCount,
});
return {
cwd: resolvedCwd,
parsed,
};
} catch (error) {
if (error instanceof SecurityViolationError) {
this.emit({
type: "shell.command_blocked",
timestamp: toNow(),
command: input.command,
cwd: resolvedCwd,
reason: error.message,
code: error.code,
details: error.details,
});
throw error;
}
throw new SecurityViolationError("Unexpected error while validating shell command.", {
code: "SECURITY_VALIDATION_FAILED",
cause: error,
});
}
}
assertToolInvocationAllowed(input: {
tool: string;
toolClearance: ToolClearancePolicy;
}): void {
const policy = parseToolClearancePolicy(input.toolClearance);
if (policy.banlist.includes(input.tool)) {
this.emit({
type: "tool.invocation_blocked",
timestamp: toNow(),
tool: input.tool,
reason: `Tool "${input.tool}" is explicitly banned by policy.`,
code: "TOOL_BANNED",
});
throw new SecurityViolationError(
`Tool "${input.tool}" is explicitly banned by policy.`,
{
code: "TOOL_BANNED",
details: {
tool: input.tool,
},
},
);
}
if (policy.allowlist.length > 0 && !policy.allowlist.includes(input.tool)) {
this.emit({
type: "tool.invocation_blocked",
timestamp: toNow(),
tool: input.tool,
reason: `Tool "${input.tool}" is not present in allowlist.`,
code: "TOOL_NOT_ALLOWED",
});
throw new SecurityViolationError(
`Tool "${input.tool}" is not present in allowlist.`,
{
code: "TOOL_NOT_ALLOWED",
details: {
tool: input.tool,
},
},
);
}
this.emit({
type: "tool.invocation_allowed",
timestamp: toNow(),
tool: input.tool,
});
}
filterAllowedTools(tools: string[], toolClearance: ToolClearancePolicy): string[] {
const policy = parseToolClearancePolicy(toolClearance);
const allowedByAllowlist =
policy.allowlist.length === 0
? tools
: tools.filter((tool) => policy.allowlist.includes(tool));
return allowedByAllowlist.filter((tool) => !policy.banlist.includes(tool));
}
private assertCwdBoundary(cwd: string): void {
if (this.policy.requireCwdWithinWorktree && !isWithinPath(this.worktreeRoot, cwd)) {
throw new SecurityViolationError(
`cwd "${cwd}" is outside configured worktree root "${this.worktreeRoot}".`,
{
code: "CWD_OUTSIDE_WORKTREE",
details: {
cwd,
worktreeRoot: this.worktreeRoot,
},
},
);
}
for (const blockedPath of this.protectedPaths) {
if (!isPathBlocked(cwd, blockedPath)) {
continue;
}
throw new SecurityViolationError(
`cwd "${cwd}" is inside protected path "${blockedPath}".`,
{
code: "CWD_INSIDE_PROTECTED_PATH",
details: {
cwd,
protectedPath: blockedPath,
},
},
);
}
}
private assertBinaryAllowed(
command: ParsedShellCommand,
toolClearance?: ToolClearancePolicy,
): void {
const binaryToken = normalizeToken(command.binary);
const binaryName = basename(binaryToken);
if (!this.allowedBinaries.has(binaryToken) && !this.allowedBinaries.has(binaryName)) {
throw new SecurityViolationError(
`Binary "${command.binary}" is not in the shell allowlist.`,
{
code: "BINARY_NOT_ALLOWED",
details: {
binary: command.binary,
},
},
);
}
if (!toolClearance) {
return;
}
this.assertToolInvocationAllowed({
tool: binaryName,
toolClearance,
});
}
private assertAssignmentsAllowed(command: ParsedShellCommand): void {
for (const assignment of command.assignments) {
if (this.blockedEnvAssignments.has(assignment.key)) {
throw new SecurityViolationError(
`Environment assignment "${assignment.key}" is blocked by policy.`,
{
code: "ENV_ASSIGNMENT_BLOCKED",
details: {
key: assignment.key,
},
},
);
}
if (
this.allowedEnvAssignments.size > 0 &&
!this.allowedEnvAssignments.has(assignment.key)
) {
throw new SecurityViolationError(
`Environment assignment "${assignment.key}" is not in the assignment allowlist.`,
{
code: "ENV_ASSIGNMENT_NOT_ALLOWED",
details: {
key: assignment.key,
},
},
);
}
}
}
private assertArgumentPaths(command: ParsedShellCommand, cwd: string): void {
if (!this.policy.enforcePathBoundaryOnArguments) {
return;
}
for (const token of [...command.args, ...command.redirects]) {
if (!isPathLikeToken(token)) {
continue;
}
if (this.policy.rejectRelativePathTraversal && hasPathTraversalSegment(token)) {
throw new SecurityViolationError(`Path traversal token "${token}" is blocked.`, {
code: "PATH_TRAVERSAL_BLOCKED",
details: {
token,
binary: command.binary,
},
});
}
if (token.startsWith("~")) {
throw new SecurityViolationError(
`Home-expansion path token "${token}" is blocked.`,
{
code: "HOME_EXPANSION_BLOCKED",
details: {
token,
binary: command.binary,
},
},
);
}
const resolvedPath = isAbsolute(token) ? resolve(token) : resolve(cwd, token);
if (!isWithinPath(this.worktreeRoot, resolvedPath)) {
throw new SecurityViolationError(
`Path token "${token}" resolves outside worktree root.`,
{
code: "PATH_OUTSIDE_WORKTREE",
details: {
token,
resolvedPath,
worktreeRoot: this.worktreeRoot,
},
},
);
}
for (const protectedPath of this.protectedPaths) {
if (!isPathBlocked(resolvedPath, protectedPath)) {
continue;
}
throw new SecurityViolationError(
`Path token "${token}" resolves inside protected path.`,
{
code: "PATH_INSIDE_PROTECTED_PATH",
details: {
token,
resolvedPath,
protectedPath,
},
},
);
}
}
}
private emit(event: SecurityAuditEvent): void {
this.auditSink?.(event);
}
}

95
src/security/schemas.ts Normal file
View File

@@ -0,0 +1,95 @@
import { z } from "zod";
function dedupe(values: readonly string[]): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const value of values) {
if (seen.has(value)) {
continue;
}
seen.add(value);
out.push(value);
}
return out;
}
const stringTokenSchema = z
.string()
.trim()
.min(1)
.refine((value) => !value.includes("\0"), "String token cannot include null bytes.");
export const toolClearancePolicySchema = z
.object({
allowlist: z.array(stringTokenSchema).default([]),
banlist: z.array(stringTokenSchema).default([]),
})
.strict();
export type ToolClearancePolicy = z.infer<typeof toolClearancePolicySchema>;
export function parseToolClearancePolicy(input: unknown): ToolClearancePolicy {
const parsed = toolClearancePolicySchema.parse(input);
return {
allowlist: dedupe(parsed.allowlist),
banlist: dedupe(parsed.banlist),
};
}
export const shellValidationPolicySchema = z
.object({
allowedBinaries: z.array(stringTokenSchema).min(1),
worktreeRoot: stringTokenSchema,
protectedPaths: z.array(stringTokenSchema).default([]),
requireCwdWithinWorktree: z.boolean().default(true),
rejectRelativePathTraversal: z.boolean().default(true),
enforcePathBoundaryOnArguments: z.boolean().default(true),
allowedEnvAssignments: z.array(stringTokenSchema).default([]),
blockedEnvAssignments: z.array(stringTokenSchema).default([]),
})
.strict();
export type ShellValidationPolicy = z.infer<typeof shellValidationPolicySchema>;
export function parseShellValidationPolicy(input: unknown): ShellValidationPolicy {
const parsed = shellValidationPolicySchema.parse(input);
return {
...parsed,
allowedBinaries: dedupe(parsed.allowedBinaries),
protectedPaths: dedupe(parsed.protectedPaths),
allowedEnvAssignments: dedupe(parsed.allowedEnvAssignments),
blockedEnvAssignments: dedupe(parsed.blockedEnvAssignments),
};
}
export const executionEnvPolicySchema = z
.object({
inherit: z.array(stringTokenSchema).default(["PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL"]),
scrub: z.array(stringTokenSchema).default([]),
inject: z.record(z.string(), z.string()).default({}),
})
.strict();
export type ExecutionEnvPolicy = z.infer<typeof executionEnvPolicySchema>;
export function parseExecutionEnvPolicy(input: unknown): ExecutionEnvPolicy {
const parsed = executionEnvPolicySchema.parse(input);
return {
inherit: dedupe(parsed.inherit),
scrub: dedupe(parsed.scrub),
inject: { ...parsed.inject },
};
}
export type SecurityViolationHandling = "hard_abort" | "validation_fail";
export const securityViolationHandlingSchema = z.union([
z.literal("hard_abort"),
z.literal("validation_fail"),
]);
export function parseSecurityViolationHandling(input: unknown): SecurityViolationHandling {
return securityViolationHandlingSchema.parse(input);
}

View File

@@ -0,0 +1,180 @@
import parseBash from "bash-parser";
import { SecurityViolationError } from "./errors.js";
type UnknownRecord = Record<string, unknown>;
export type ParsedShellAssignment = {
raw: string;
key: string;
value: string;
};
export type ParsedShellCommand = {
binary: string;
args: string[];
flags: string[];
assignments: ParsedShellAssignment[];
redirects: string[];
words: string[];
};
export type ParsedShellScript = {
commandCount: number;
commands: ParsedShellCommand[];
};
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function readTextNode(node: unknown): string | undefined {
if (!isRecord(node)) {
return undefined;
}
const text = readString(node.text);
if (!text) {
return undefined;
}
return text;
}
function parseAssignment(raw: string): ParsedShellAssignment | undefined {
const separatorIndex = raw.indexOf("=");
if (separatorIndex <= 0) {
return undefined;
}
const key = raw.slice(0, separatorIndex);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
return undefined;
}
return {
raw,
key,
value: raw.slice(separatorIndex + 1),
};
}
function toParsedCommand(node: UnknownRecord): ParsedShellCommand | undefined {
if (node.type !== "Command") {
return undefined;
}
const binary = readTextNode(node.name)?.trim();
if (!binary) {
return undefined;
}
const args: string[] = [];
const assignments: ParsedShellAssignment[] = [];
const redirects: string[] = [];
const prefix = Array.isArray(node.prefix) ? node.prefix : [];
for (const entry of prefix) {
if (!isRecord(entry) || entry.type !== "AssignmentWord") {
continue;
}
const raw = readString(entry.text);
if (!raw) {
continue;
}
const assignment = parseAssignment(raw);
if (assignment) {
assignments.push(assignment);
}
}
const suffix = Array.isArray(node.suffix) ? node.suffix : [];
for (const entry of suffix) {
if (!isRecord(entry)) {
continue;
}
if (entry.type === "Word") {
const text = readString(entry.text);
if (text) {
args.push(text);
}
continue;
}
if (entry.type !== "Redirect") {
continue;
}
const fileWord = readTextNode(entry.file);
if (fileWord) {
redirects.push(fileWord);
}
}
return {
binary,
args,
flags: args.filter((arg) => arg.startsWith("-")),
assignments,
redirects,
words: [binary, ...args, ...redirects],
};
}
export function parseShellScript(script: string): ParsedShellScript {
let ast: unknown;
try {
ast = parseBash(script);
} catch (error) {
throw new SecurityViolationError("Shell command failed AST parsing.", {
code: "SHELL_PARSE_FAILED",
cause: error,
details: {
script,
},
});
}
const commands: ParsedShellCommand[] = [];
const seen = new WeakSet<object>();
const visit = (node: unknown): void => {
if (!isRecord(node)) {
return;
}
if (seen.has(node)) {
return;
}
seen.add(node);
const parsedCommand = toParsedCommand(node);
if (parsedCommand) {
commands.push(parsedCommand);
}
for (const value of Object.values(node)) {
if (Array.isArray(value)) {
for (const item of value) {
visit(item);
}
continue;
}
visit(value);
}
};
visit(ast);
return {
commandCount: commands.length,
commands,
};
}

3
src/types/bash-parser.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
declare module "bash-parser" {
export default function parseBash(script: string): unknown;
}