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 {
|
||||
|
||||
105
src/config.ts
105
src/config.ts
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
14
src/security/audit-log.ts
Normal 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
18
src/security/errors.ts
Normal 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
176
src/security/executor.ts
Normal 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
33
src/security/index.ts
Normal 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";
|
||||
421
src/security/rules-engine.ts
Normal file
421
src/security/rules-engine.ts
Normal 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
95
src/security/schemas.ts
Normal 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);
|
||||
}
|
||||
180
src/security/shell-parser.ts
Normal file
180
src/security/shell-parser.ts
Normal 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
3
src/types/bash-parser.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare module "bash-parser" {
|
||||
export default function parseBash(script: string): unknown;
|
||||
}
|
||||
Reference in New Issue
Block a user