Files
ai_ops/tests/security-middleware.test.ts

196 lines
5.4 KiB
TypeScript

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 commands across chained expressions", async () => {
const parsed = await 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 protectedFilePath = resolve(worktreeRoot, ".ai_ops", "project-context.json");
const rules = new SecurityRulesEngine({
allowedBinaries: ["git", "npm", "cat"],
worktreeRoot,
protectedPaths: [stateRoot, projectContextPath, protectedFilePath],
requireCwdWithinWorktree: true,
rejectRelativePathTraversal: true,
enforcePathBoundaryOnArguments: true,
allowedEnvAssignments: [],
blockedEnvAssignments: [],
});
const allowed = await 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);
await assert.rejects(
() =>
rules.validateShellCommand({
command: "cat ../secrets.txt",
cwd: worktreeRoot,
}),
(error: unknown) =>
error instanceof SecurityViolationError && error.code === "PATH_TRAVERSAL_BLOCKED",
);
await assert.rejects(
() =>
rules.validateShellCommand({
command: "git status",
cwd: stateRoot,
}),
(error: unknown) =>
error instanceof SecurityViolationError && error.code === "CWD_OUTSIDE_WORKTREE",
);
await assert.rejects(
() =>
rules.validateShellCommand({
command: "echo $(unauthorized_bin)",
cwd: worktreeRoot,
}),
(error: unknown) =>
error instanceof SecurityViolationError && error.code === "SHELL_SUBSHELL_BLOCKED",
);
await assert.rejects(
() =>
rules.validateShellCommand({
command: "git status && unauthorized_bin",
cwd: worktreeRoot,
}),
(error: unknown) =>
error instanceof SecurityViolationError && error.code === "BINARY_NOT_ALLOWED",
);
await assert.rejects(
() =>
rules.validateShellCommand({
command: `cat > ${protectedFilePath}`,
cwd: worktreeRoot,
}),
(error: unknown) =>
error instanceof SecurityViolationError &&
error.code === "PATH_INSIDE_PROTECTED_PATH",
);
});
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);
});
test("rules engine carries session context in tool audit events", () => {
const events: Array<Record<string, unknown>> = [];
const rules = new SecurityRulesEngine(
{
allowedBinaries: ["git"],
worktreeRoot: "/tmp",
protectedPaths: [],
requireCwdWithinWorktree: true,
rejectRelativePathTraversal: true,
enforcePathBoundaryOnArguments: true,
allowedEnvAssignments: [],
blockedEnvAssignments: [],
},
(event) => {
events.push(event as unknown as Record<string, unknown>);
},
);
rules.assertToolInvocationAllowed({
tool: "git",
toolClearance: {
allowlist: ["git"],
banlist: [],
},
context: {
sessionId: "session-ctx",
nodeId: "node-ctx",
attempt: 2,
},
});
const allowedEvent = events.find((event) => event.type === "tool.invocation_allowed");
assert.ok(allowedEvent);
assert.equal(allowedEvent.sessionId, "session-ctx");
assert.equal(allowedEvent.nodeId, "node-ctx");
assert.equal(allowedEvent.attempt, 2);
});