fix(exec): align node exec approvals (#1425)

Thanks @czekaj.

Co-authored-by: Lucas Czekaj <lukasz@czekaj.us>
This commit is contained in:
Lucas Czekaj
2026-01-21 22:02:17 -08:00
committed by Peter Steinberger
parent d83ea7f2da
commit 4b3e9c0f33
6 changed files with 395 additions and 52 deletions

View File

@@ -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 = {

View 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,