Add AST-based security middleware and enforcement wiring
This commit is contained in:
@@ -9,6 +9,8 @@ test("loads defaults and freezes config", () => {
|
||||
assert.equal(config.orchestration.maxDepth, 4);
|
||||
assert.equal(config.provisioning.portRange.basePort, 36000);
|
||||
assert.equal(config.discovery.fileRelativePath, ".agent-context/resources.json");
|
||||
assert.equal(config.security.violationHandling, "hard_abort");
|
||||
assert.equal(config.security.commandTimeoutMs, 120000);
|
||||
assert.equal(Object.isFrozen(config), true);
|
||||
assert.equal(Object.isFrozen(config.orchestration), true);
|
||||
});
|
||||
@@ -20,3 +22,9 @@ test("validates boolean env values", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("validates security violation mode", () => {
|
||||
assert.throws(
|
||||
() => loadConfig({ AGENT_SECURITY_VIOLATION_MODE: "retry_forever" }),
|
||||
/invalid_union|Invalid input/i,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -63,3 +63,34 @@ test("mcp registry rejects unknown explicit handlers", () => {
|
||||
/Unknown MCP handler/,
|
||||
);
|
||||
});
|
||||
|
||||
test("mcp registry enforces tool clearance on resolved codex tool lists", () => {
|
||||
const registry = createDefaultMcpRegistry();
|
||||
|
||||
const resolved = registry.resolveServerWithHandler({
|
||||
serverName: "sandbox-tools",
|
||||
server: {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
enabled_tools: ["read_file", "write_file", "search"],
|
||||
disabled_tools: ["legacy_tool"],
|
||||
},
|
||||
context: {},
|
||||
fullConfig: {
|
||||
servers: {},
|
||||
},
|
||||
toolClearance: {
|
||||
allowlist: ["read_file", "search"],
|
||||
banlist: ["search", "write_file"],
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(resolved.codex);
|
||||
assert.deepEqual(resolved.codex.enabled_tools, ["read_file"]);
|
||||
assert.deepEqual(resolved.codex.disabled_tools, [
|
||||
"legacy_tool",
|
||||
"search",
|
||||
"write_file",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { SchemaDrivenExecutionEngine } from "../src/agents/orchestration.js";
|
||||
import type { ActorExecutionResult } from "../src/agents/pipeline.js";
|
||||
import { SecurityViolationError } from "../src/security/index.js";
|
||||
|
||||
function createManifest(): unknown {
|
||||
return {
|
||||
@@ -191,6 +192,7 @@ test("runs DAG pipeline with state-dependent routing and retry behavior", async
|
||||
coder: async (input): Promise<ActorExecutionResult> => {
|
||||
assert.match(input.prompt, /AIOPS-123/);
|
||||
assert.deepEqual(input.toolClearance.allowlist, ["read_file", "write_file"]);
|
||||
assert.ok(input.security);
|
||||
coderAttempts += 1;
|
||||
if (coderAttempts === 1) {
|
||||
return {
|
||||
@@ -759,3 +761,158 @@ test("propagates abort signal into actor execution and stops the run", async ()
|
||||
await assert.rejects(() => runPromise, /(AbortError|manual-abort|aborted)/i);
|
||||
assert.equal(observedAbort, true);
|
||||
});
|
||||
|
||||
test("hard-aborts pipeline on security violations by default", async () => {
|
||||
const workspaceRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-workspace-"));
|
||||
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-session-state-"));
|
||||
const projectContextPath = resolve(stateRoot, "project-context.json");
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: "1",
|
||||
topologies: ["retry-unrolled", "sequential"],
|
||||
personas: [
|
||||
{
|
||||
id: "coder",
|
||||
displayName: "Coder",
|
||||
systemPromptTemplate: "Coder",
|
||||
toolClearance: {
|
||||
allowlist: ["git"],
|
||||
banlist: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
topologyConstraints: {
|
||||
maxDepth: 3,
|
||||
maxRetries: 2,
|
||||
},
|
||||
pipeline: {
|
||||
entryNodeId: "secure-node",
|
||||
nodes: [
|
||||
{
|
||||
id: "secure-node",
|
||||
actorId: "secure_actor",
|
||||
personaId: "coder",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const engine = new SchemaDrivenExecutionEngine({
|
||||
manifest,
|
||||
settings: {
|
||||
workspaceRoot,
|
||||
stateRoot,
|
||||
projectContextPath,
|
||||
maxDepth: 3,
|
||||
maxRetries: 2,
|
||||
maxChildren: 2,
|
||||
runtimeContext: {},
|
||||
},
|
||||
actorExecutors: {
|
||||
secure_actor: async () => {
|
||||
throw new SecurityViolationError("blocked by policy", {
|
||||
code: "TOOL_NOT_ALLOWED",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
engine.runSession({
|
||||
sessionId: "session-security-hard-abort",
|
||||
initialPayload: {
|
||||
task: "Security hard abort",
|
||||
},
|
||||
}),
|
||||
/blocked by policy/,
|
||||
);
|
||||
});
|
||||
|
||||
test("can map security violations to validation_fail for retry-unrolled remediation", async () => {
|
||||
const workspaceRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-workspace-"));
|
||||
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-session-state-"));
|
||||
const projectContextPath = resolve(stateRoot, "project-context.json");
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: "1",
|
||||
topologies: ["retry-unrolled", "sequential"],
|
||||
personas: [
|
||||
{
|
||||
id: "coder",
|
||||
displayName: "Coder",
|
||||
systemPromptTemplate: "Coder",
|
||||
toolClearance: {
|
||||
allowlist: ["git"],
|
||||
banlist: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
topologyConstraints: {
|
||||
maxDepth: 3,
|
||||
maxRetries: 2,
|
||||
},
|
||||
pipeline: {
|
||||
entryNodeId: "secure-node",
|
||||
nodes: [
|
||||
{
|
||||
id: "secure-node",
|
||||
actorId: "secure_actor",
|
||||
personaId: "coder",
|
||||
constraints: {
|
||||
maxRetries: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
} as const;
|
||||
|
||||
let attempts = 0;
|
||||
const engine = new SchemaDrivenExecutionEngine({
|
||||
manifest,
|
||||
settings: {
|
||||
workspaceRoot,
|
||||
stateRoot,
|
||||
projectContextPath,
|
||||
maxDepth: 3,
|
||||
maxRetries: 2,
|
||||
maxChildren: 2,
|
||||
securityViolationHandling: "validation_fail",
|
||||
runtimeContext: {},
|
||||
},
|
||||
actorExecutors: {
|
||||
secure_actor: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new SecurityViolationError("first attempt blocked", {
|
||||
code: "PATH_TRAVERSAL_BLOCKED",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
payload: {
|
||||
fixed: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await engine.runSession({
|
||||
sessionId: "session-security-validation-retry",
|
||||
initialPayload: {
|
||||
task: "Security retry path",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, "success");
|
||||
assert.deepEqual(
|
||||
result.records.map((record) => `${record.nodeId}:${record.status}:${String(record.attempt)}`),
|
||||
["secure-node:validation_fail:1", "secure-node:success:2"],
|
||||
);
|
||||
});
|
||||
|
||||
125
tests/security-middleware.test.ts
Normal file
125
tests/security-middleware.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
SecurityRulesEngine,
|
||||
SecureCommandExecutor,
|
||||
SecurityViolationError,
|
||||
parseShellScript,
|
||||
} from "../src/security/index.js";
|
||||
|
||||
test("shell parser extracts Command and Word nodes across chained expressions", () => {
|
||||
const parsed = parseShellScript("FOO=bar git status && npm test | cat > logs/output.txt");
|
||||
|
||||
assert.equal(parsed.commandCount, 3);
|
||||
assert.deepEqual(
|
||||
parsed.commands.map((command) => command.binary),
|
||||
["git", "npm", "cat"],
|
||||
);
|
||||
|
||||
const gitCommand = parsed.commands[0];
|
||||
assert.ok(gitCommand);
|
||||
assert.equal(gitCommand.assignments[0]?.key, "FOO");
|
||||
assert.deepEqual(gitCommand.args, ["status"]);
|
||||
|
||||
const catCommand = parsed.commands[2];
|
||||
assert.ok(catCommand);
|
||||
assert.deepEqual(catCommand.redirects, ["logs/output.txt"]);
|
||||
});
|
||||
|
||||
test("rules engine enforces binary allowlist, tool policy, and path boundaries", async () => {
|
||||
const worktreeRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-security-worktree-"));
|
||||
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-security-state-"));
|
||||
const projectContextPath = resolve(stateRoot, "project-context.json");
|
||||
|
||||
const rules = new SecurityRulesEngine({
|
||||
allowedBinaries: ["git", "npm", "cat"],
|
||||
worktreeRoot,
|
||||
protectedPaths: [stateRoot, projectContextPath],
|
||||
requireCwdWithinWorktree: true,
|
||||
rejectRelativePathTraversal: true,
|
||||
enforcePathBoundaryOnArguments: true,
|
||||
allowedEnvAssignments: [],
|
||||
blockedEnvAssignments: [],
|
||||
});
|
||||
|
||||
const allowed = rules.validateShellCommand({
|
||||
command: "git status && npm test | cat > logs/output.txt",
|
||||
cwd: worktreeRoot,
|
||||
toolClearance: {
|
||||
allowlist: ["git", "npm", "cat"],
|
||||
banlist: [],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(allowed.parsed.commandCount, 3);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
rules.validateShellCommand({
|
||||
command: "cat ../secrets.txt",
|
||||
cwd: worktreeRoot,
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof SecurityViolationError &&
|
||||
error.code === "PATH_TRAVERSAL_BLOCKED",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
rules.validateShellCommand({
|
||||
command: "git status",
|
||||
cwd: stateRoot,
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof SecurityViolationError &&
|
||||
error.code === "CWD_OUTSIDE_WORKTREE",
|
||||
);
|
||||
});
|
||||
|
||||
test("secure executor runs with explicit env policy", async () => {
|
||||
const worktreeRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-security-exec-"));
|
||||
|
||||
const rules = new SecurityRulesEngine({
|
||||
allowedBinaries: ["echo"],
|
||||
worktreeRoot,
|
||||
protectedPaths: [],
|
||||
requireCwdWithinWorktree: true,
|
||||
rejectRelativePathTraversal: true,
|
||||
enforcePathBoundaryOnArguments: true,
|
||||
allowedEnvAssignments: [],
|
||||
blockedEnvAssignments: [],
|
||||
});
|
||||
|
||||
const executor = new SecureCommandExecutor({
|
||||
rulesEngine: rules,
|
||||
timeoutMs: 2000,
|
||||
envPolicy: {
|
||||
inherit: ["PATH", "HOME"],
|
||||
scrub: ["SECRET_VALUE"],
|
||||
inject: {
|
||||
SAFE_VALUE: "ok",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let streamedStdout = "";
|
||||
const result = await executor.execute({
|
||||
command: "echo \"$SAFE_VALUE|$SECRET_VALUE\"",
|
||||
cwd: worktreeRoot,
|
||||
baseEnv: {
|
||||
PATH: process.env.PATH,
|
||||
HOME: process.env.HOME,
|
||||
SECRET_VALUE: "hidden",
|
||||
},
|
||||
onStdoutChunk: (chunk) => {
|
||||
streamedStdout += chunk;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.stdout, "ok|\n");
|
||||
assert.equal(streamedStdout, result.stdout);
|
||||
});
|
||||
Reference in New Issue
Block a user