fix(exec): align node exec approvals (#1425)
Thanks @czekaj. Co-authored-by: Lucas Czekaj <lukasz@czekaj.us>
This commit is contained in:
committed by
Peter Steinberger
parent
d83ea7f2da
commit
4b3e9c0f33
@@ -7,11 +7,13 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
analyzeArgvCommand,
|
||||
analyzeShellCommand,
|
||||
evaluateExecAllowlist,
|
||||
isSafeBinUsage,
|
||||
matchAllowlist,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
normalizeSafeBins,
|
||||
requiresExecApproval,
|
||||
resolveCommandResolution,
|
||||
resolveExecApprovals,
|
||||
resolveExecApprovalsFromFile,
|
||||
@@ -183,6 +185,85 @@ describe("exec approvals safe bins", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals allowlist evaluation", () => {
|
||||
it("satisfies allowlist on exact match", () => {
|
||||
const analysis = {
|
||||
ok: true,
|
||||
segments: [
|
||||
{
|
||||
raw: "tool",
|
||||
argv: ["tool"],
|
||||
resolution: {
|
||||
rawExecutable: "tool",
|
||||
resolvedPath: "/usr/bin/tool",
|
||||
executableName: "tool",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const allowlist: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/tool" }];
|
||||
const result = evaluateExecAllowlist({
|
||||
analysis,
|
||||
allowlist,
|
||||
safeBins: new Set(),
|
||||
cwd: "/tmp",
|
||||
});
|
||||
expect(result.allowlistSatisfied).toBe(true);
|
||||
expect(result.allowlistMatches.map((entry) => entry.pattern)).toEqual(["/usr/bin/tool"]);
|
||||
});
|
||||
|
||||
it("satisfies allowlist via safe bins", () => {
|
||||
const analysis = {
|
||||
ok: true,
|
||||
segments: [
|
||||
{
|
||||
raw: "jq .foo",
|
||||
argv: ["jq", ".foo"],
|
||||
resolution: {
|
||||
rawExecutable: "jq",
|
||||
resolvedPath: "/usr/bin/jq",
|
||||
executableName: "jq",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = evaluateExecAllowlist({
|
||||
analysis,
|
||||
allowlist: [],
|
||||
safeBins: normalizeSafeBins(["jq"]),
|
||||
cwd: "/tmp",
|
||||
});
|
||||
expect(result.allowlistSatisfied).toBe(true);
|
||||
expect(result.allowlistMatches).toEqual([]);
|
||||
});
|
||||
|
||||
it("satisfies allowlist via auto-allow skills", () => {
|
||||
const analysis = {
|
||||
ok: true,
|
||||
segments: [
|
||||
{
|
||||
raw: "skill-bin",
|
||||
argv: ["skill-bin", "--help"],
|
||||
resolution: {
|
||||
rawExecutable: "skill-bin",
|
||||
resolvedPath: "/opt/skills/skill-bin",
|
||||
executableName: "skill-bin",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = evaluateExecAllowlist({
|
||||
analysis,
|
||||
allowlist: [],
|
||||
safeBins: new Set(),
|
||||
skillBins: new Set(["skill-bin"]),
|
||||
autoAllowSkills: true,
|
||||
cwd: "/tmp",
|
||||
});
|
||||
expect(result.allowlistSatisfied).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals policy helpers", () => {
|
||||
it("minSecurity returns the more restrictive value", () => {
|
||||
expect(minSecurity("deny", "full")).toBe("deny");
|
||||
@@ -193,6 +274,49 @@ describe("exec approvals policy helpers", () => {
|
||||
expect(maxAsk("off", "always")).toBe("always");
|
||||
expect(maxAsk("on-miss", "off")).toBe("on-miss");
|
||||
});
|
||||
|
||||
it("requiresExecApproval respects ask mode and allowlist satisfaction", () => {
|
||||
expect(
|
||||
requiresExecApproval({
|
||||
ask: "always",
|
||||
security: "allowlist",
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
requiresExecApproval({
|
||||
ask: "off",
|
||||
security: "allowlist",
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
requiresExecApproval({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
analysisOk: true,
|
||||
allowlistSatisfied: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
requiresExecApproval({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
analysisOk: false,
|
||||
allowlistSatisfied: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
requiresExecApproval({
|
||||
ask: "on-miss",
|
||||
security: "full",
|
||||
analysisOk: false,
|
||||
allowlistSatisfied: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals wildcard agent", () => {
|
||||
@@ -229,6 +353,82 @@ describe("exec approvals wildcard agent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals node host allowlist check", () => {
|
||||
// These tests verify the allowlist satisfaction logic used by the node host path
|
||||
// The node host checks: matchAllowlist() || isSafeBinUsage() for each command segment
|
||||
// Using hardcoded resolution objects for cross-platform compatibility
|
||||
|
||||
it("satisfies allowlist when command matches exact path pattern", () => {
|
||||
const resolution = {
|
||||
rawExecutable: "python3",
|
||||
resolvedPath: "/usr/bin/python3",
|
||||
executableName: "python3",
|
||||
};
|
||||
const entries: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/python3" }];
|
||||
const match = matchAllowlist(entries, resolution);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match?.pattern).toBe("/usr/bin/python3");
|
||||
});
|
||||
|
||||
it("satisfies allowlist when command matches ** wildcard pattern", () => {
|
||||
// Simulates symlink resolution: /opt/homebrew/bin/python3 -> /opt/homebrew/opt/python@3.14/bin/python3.14
|
||||
const resolution = {
|
||||
rawExecutable: "python3",
|
||||
resolvedPath: "/opt/homebrew/opt/python@3.14/bin/python3.14",
|
||||
executableName: "python3.14",
|
||||
};
|
||||
// Pattern with ** matches across multiple directories
|
||||
const entries: ExecAllowlistEntry[] = [{ pattern: "/opt/**/python*" }];
|
||||
const match = matchAllowlist(entries, resolution);
|
||||
expect(match?.pattern).toBe("/opt/**/python*");
|
||||
});
|
||||
|
||||
it("does not satisfy allowlist when command is not in allowlist", () => {
|
||||
const resolution = {
|
||||
rawExecutable: "unknown-tool",
|
||||
resolvedPath: "/usr/local/bin/unknown-tool",
|
||||
executableName: "unknown-tool",
|
||||
};
|
||||
// Allowlist has different commands
|
||||
const entries: ExecAllowlistEntry[] = [
|
||||
{ pattern: "/usr/bin/python3" },
|
||||
{ pattern: "/opt/**/node" },
|
||||
];
|
||||
const match = matchAllowlist(entries, resolution);
|
||||
expect(match).toBeNull();
|
||||
|
||||
// Also not a safe bin
|
||||
const safe = isSafeBinUsage({
|
||||
argv: ["unknown-tool", "--help"],
|
||||
resolution,
|
||||
safeBins: normalizeSafeBins(["jq", "curl"]),
|
||||
cwd: "/tmp",
|
||||
});
|
||||
expect(safe).toBe(false);
|
||||
});
|
||||
|
||||
it("satisfies via safeBins even when not in allowlist", () => {
|
||||
const resolution = {
|
||||
rawExecutable: "jq",
|
||||
resolvedPath: "/usr/bin/jq",
|
||||
executableName: "jq",
|
||||
};
|
||||
// Not in allowlist
|
||||
const entries: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/python3" }];
|
||||
const match = matchAllowlist(entries, resolution);
|
||||
expect(match).toBeNull();
|
||||
|
||||
// But is a safe bin with non-file args
|
||||
const safe = isSafeBinUsage({
|
||||
argv: ["jq", ".foo"],
|
||||
resolution,
|
||||
safeBins: normalizeSafeBins(["jq"]),
|
||||
cwd: "/tmp",
|
||||
});
|
||||
expect(safe).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals default agent migration", () => {
|
||||
it("migrates legacy default agent entries to main", () => {
|
||||
const file = {
|
||||
|
||||
@@ -727,6 +727,56 @@ export function isSafeBinUsage(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
export type ExecAllowlistEvaluation = {
|
||||
allowlistSatisfied: boolean;
|
||||
allowlistMatches: ExecAllowlistEntry[];
|
||||
};
|
||||
|
||||
export function evaluateExecAllowlist(params: {
|
||||
analysis: ExecCommandAnalysis;
|
||||
allowlist: ExecAllowlistEntry[];
|
||||
safeBins: Set<string>;
|
||||
cwd?: string;
|
||||
skillBins?: Set<string>;
|
||||
autoAllowSkills?: boolean;
|
||||
}): ExecAllowlistEvaluation {
|
||||
const allowlistMatches: ExecAllowlistEntry[] = [];
|
||||
if (!params.analysis.ok || params.analysis.segments.length === 0) {
|
||||
return { allowlistSatisfied: false, allowlistMatches };
|
||||
}
|
||||
const allowSkills = params.autoAllowSkills === true && (params.skillBins?.size ?? 0) > 0;
|
||||
const allowlistSatisfied = params.analysis.segments.every((segment) => {
|
||||
const match = matchAllowlist(params.allowlist, segment.resolution);
|
||||
if (match) allowlistMatches.push(match);
|
||||
const safe = isSafeBinUsage({
|
||||
argv: segment.argv,
|
||||
resolution: segment.resolution,
|
||||
safeBins: params.safeBins,
|
||||
cwd: params.cwd,
|
||||
});
|
||||
const skillAllow =
|
||||
allowSkills && segment.resolution?.executableName
|
||||
? params.skillBins?.has(segment.resolution.executableName)
|
||||
: false;
|
||||
return Boolean(match || safe || skillAllow);
|
||||
});
|
||||
return { allowlistSatisfied, allowlistMatches };
|
||||
}
|
||||
|
||||
export function requiresExecApproval(params: {
|
||||
ask: ExecAsk;
|
||||
security: ExecSecurity;
|
||||
analysisOk: boolean;
|
||||
allowlistSatisfied: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
params.ask === "always" ||
|
||||
(params.ask === "on-miss" &&
|
||||
params.security === "allowlist" &&
|
||||
(!params.analysisOk || !params.allowlistSatisfied))
|
||||
);
|
||||
}
|
||||
|
||||
export function recordAllowlistUse(
|
||||
approvals: ExecApprovalsFile,
|
||||
agentId: string | undefined,
|
||||
|
||||
Reference in New Issue
Block a user