158 lines
4.4 KiB
TypeScript
158 lines
4.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);
|
|
});
|