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

@@ -34,6 +34,7 @@ Docs: https://docs.clawd.bot
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr. - Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
- Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj. - Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj.
- Exec: avoid defaulting to elevated mode when elevated is not allowed. - Exec: avoid defaulting to elevated mode when elevated is not allowed.
- Exec approvals: align node/gateway allowlist prechecks and approval gating; avoid null optional params in approval requests. (#1425) Thanks @czekaj.
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai. - UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
## 2026.1.21 ## 2026.1.21

View File

@@ -68,4 +68,70 @@ describe("exec approvals", () => {
const runId = (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId; const runId = (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId;
expect(runId).toBe(approvalId); expect(runId).toBe(approvalId);
}); });
it("skips approval when node allowlist is satisfied", async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-bin-"));
const binDir = path.join(tempDir, "bin");
await fs.mkdir(binDir, { recursive: true });
const exeName = process.platform === "win32" ? "tool.cmd" : "tool";
const exePath = path.join(binDir, exeName);
await fs.writeFile(exePath, "");
if (process.platform !== "win32") {
await fs.chmod(exePath, 0o755);
}
const prevPath = process.env.PATH;
const prevPathExt = process.env.PATHEXT;
process.env.PATH = binDir;
if (process.platform === "win32") {
process.env.PATHEXT = ".CMD";
}
try {
const approvalsFile = {
version: 1,
defaults: { security: "allowlist", ask: "on-miss", askFallback: "deny" },
agents: {
main: {
allowlist: [{ pattern: exePath }],
},
},
};
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
calls.push(method);
if (method === "exec.approvals.node.get") {
return { file: approvalsFile };
}
if (method === "node.invoke") {
return { payload: { success: true, stdout: "ok" } };
}
if (method === "exec.approval.request") {
return { decision: "allow-once" };
}
return { ok: true };
});
const { createExecTool } = await import("./bash-tools.exec.js");
const tool = createExecTool({
host: "node",
ask: "on-miss",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call2", { command: `${exeName} --help` });
expect(result.details.status).toBe("completed");
expect(calls).toContain("exec.approvals.node.get");
expect(calls).toContain("node.invoke");
expect(calls).not.toContain("exec.approval.request");
} finally {
process.env.PATH = prevPath;
if (prevPathExt === undefined) {
delete process.env.PATHEXT;
} else {
process.env.PATHEXT = prevPathExt;
}
}
});
}); });

View File

@@ -8,16 +8,17 @@ import {
type ExecAsk, type ExecAsk,
type ExecHost, type ExecHost,
type ExecSecurity, type ExecSecurity,
type ExecAllowlistEntry, type ExecApprovalsFile,
addAllowlistEntry, addAllowlistEntry,
analyzeShellCommand, analyzeShellCommand,
isSafeBinUsage, evaluateExecAllowlist,
matchAllowlist,
maxAsk, maxAsk,
minSecurity, minSecurity,
requiresExecApproval,
resolveSafeBins, resolveSafeBins,
recordAllowlistUse, recordAllowlistUse,
resolveExecApprovals, resolveExecApprovals,
resolveExecApprovalsFromFile,
} from "../infra/exec-approvals.js"; } from "../infra/exec-approvals.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { buildNodeShellCommand } from "../infra/node-shell.js"; import { buildNodeShellCommand } from "../infra/node-shell.js";
@@ -870,7 +871,43 @@ export function createExecTool(
if (nodeEnv) { if (nodeEnv) {
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true }); applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
} }
const requiresAsk = hostAsk === "always" || hostAsk === "on-miss"; const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env });
let allowlistSatisfied = false;
if (hostAsk === "on-miss" && hostSecurity === "allowlist") {
try {
const approvalsSnapshot = (await callGatewayTool(
"exec.approvals.node.get",
{ timeoutMs: 10_000 },
{ nodeId },
)) as { file?: unknown } | null;
const approvalsFile =
approvalsSnapshot && typeof approvalsSnapshot === "object"
? approvalsSnapshot.file
: undefined;
if (approvalsFile && typeof approvalsFile === "object") {
const resolved = resolveExecApprovalsFromFile({
file: approvalsFile as ExecApprovalsFile,
agentId,
overrides: { security: "allowlist" },
});
// Allowlist-only precheck; safe bins are node-local and may diverge.
allowlistSatisfied = evaluateExecAllowlist({
analysis,
allowlist: resolved.allowlist,
safeBins: new Set(),
cwd: workdir,
}).allowlistSatisfied;
}
} catch {
// Fall back to requiring approval if node approvals cannot be fetched.
}
}
const requiresAsk = requiresExecApproval({
ask: hostAsk,
security: hostSecurity,
analysisOk: analysis.ok,
allowlistSatisfied,
});
const commandText = params.command; const commandText = params.command;
const invokeTimeoutMs = Math.max( const invokeTimeoutMs = Math.max(
10_000, 10_000,
@@ -921,8 +958,8 @@ export function createExecTool(
security: hostSecurity, security: hostSecurity,
ask: hostAsk, ask: hostAsk,
agentId, agentId,
resolvedPath: null, resolvedPath: undefined,
sessionKey: defaults?.sessionKey ?? null, sessionKey: defaults?.sessionKey,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
}, },
)) as { decision?: string } | null; )) as { decision?: string } | null;
@@ -1059,33 +1096,28 @@ export function createExecTool(
throw new Error("exec denied: host=gateway security=deny"); throw new Error("exec denied: host=gateway security=deny");
} }
const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env }); const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env });
const allowlistMatches: ExecAllowlistEntry[] = []; const allowlistEval = evaluateExecAllowlist({
let allowlistSatisfied = false; analysis,
if (hostSecurity === "allowlist" && analysis.ok && analysis.segments.length > 0) { allowlist: approvals.allowlist,
allowlistSatisfied = analysis.segments.every((segment) => { safeBins,
const match = matchAllowlist(approvals.allowlist, segment.resolution); cwd: workdir,
if (match) allowlistMatches.push(match); });
const safe = isSafeBinUsage({ const allowlistMatches = allowlistEval.allowlistMatches;
argv: segment.argv, const allowlistSatisfied =
resolution: segment.resolution, hostSecurity === "allowlist" && analysis.ok ? allowlistEval.allowlistSatisfied : false;
safeBins, const requiresAsk = requiresExecApproval({
cwd: workdir, ask: hostAsk,
}); security: hostSecurity,
return Boolean(match || safe); analysisOk: analysis.ok,
}); allowlistSatisfied,
} });
const requiresAsk =
hostAsk === "always" ||
(hostAsk === "on-miss" &&
hostSecurity === "allowlist" &&
(!analysis.ok || !allowlistSatisfied));
if (requiresAsk) { if (requiresAsk) {
const approvalId = crypto.randomUUID(); const approvalId = crypto.randomUUID();
const approvalSlug = createApprovalSlug(approvalId); const approvalSlug = createApprovalSlug(approvalId);
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
const contextKey = `exec:${approvalId}`; const contextKey = `exec:${approvalId}`;
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath ?? null; const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath;
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000)); const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
const commandText = params.command; const commandText = params.command;
const effectiveTimeout = const effectiveTimeout =
@@ -1107,7 +1139,7 @@ export function createExecTool(
ask: hostAsk, ask: hostAsk,
agentId, agentId,
resolvedPath, resolvedPath,
sessionKey: defaults?.sessionKey ?? null, sessionKey: defaults?.sessionKey,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
}, },
)) as { decision?: string } | null; )) as { decision?: string } | null;

View File

@@ -7,11 +7,13 @@ import { describe, expect, it, vi } from "vitest";
import { import {
analyzeArgvCommand, analyzeArgvCommand,
analyzeShellCommand, analyzeShellCommand,
evaluateExecAllowlist,
isSafeBinUsage, isSafeBinUsage,
matchAllowlist, matchAllowlist,
maxAsk, maxAsk,
minSecurity, minSecurity,
normalizeSafeBins, normalizeSafeBins,
requiresExecApproval,
resolveCommandResolution, resolveCommandResolution,
resolveExecApprovals, resolveExecApprovals,
resolveExecApprovalsFromFile, 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", () => { describe("exec approvals policy helpers", () => {
it("minSecurity returns the more restrictive value", () => { it("minSecurity returns the more restrictive value", () => {
expect(minSecurity("deny", "full")).toBe("deny"); expect(minSecurity("deny", "full")).toBe("deny");
@@ -193,6 +274,49 @@ describe("exec approvals policy helpers", () => {
expect(maxAsk("off", "always")).toBe("always"); expect(maxAsk("off", "always")).toBe("always");
expect(maxAsk("on-miss", "off")).toBe("on-miss"); 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", () => { 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", () => { describe("exec approvals default agent migration", () => {
it("migrates legacy default agent entries to main", () => { it("migrates legacy default agent entries to main", () => {
const file = { const file = {

View File

@@ -727,6 +727,56 @@ export function isSafeBinUsage(params: {
return true; 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( export function recordAllowlistUse(
approvals: ExecApprovalsFile, approvals: ExecApprovalsFile,
agentId: string | undefined, agentId: string | undefined,

View File

@@ -4,12 +4,11 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { import {
type ExecAllowlistEntry,
addAllowlistEntry, addAllowlistEntry,
analyzeArgvCommand, analyzeArgvCommand,
analyzeShellCommand, analyzeShellCommand,
isSafeBinUsage, evaluateExecAllowlist,
matchAllowlist, requiresExecApproval,
normalizeExecApprovals, normalizeExecApprovals,
recordAllowlistUse, recordAllowlistUse,
resolveExecApprovals, resolveExecApprovals,
@@ -592,26 +591,18 @@ async function handleInvoke(
const cfg = loadConfig(); const cfg = loadConfig();
const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined; const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined;
const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins); const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins);
const allowlistMatches: ExecAllowlistEntry[] = [];
const bins = autoAllowSkills ? await skillBins.current() : new Set<string>(); const bins = autoAllowSkills ? await skillBins.current() : new Set<string>();
const allowlistEval = evaluateExecAllowlist({
analysis,
allowlist: approvals.allowlist,
safeBins,
cwd: params.cwd ?? undefined,
skillBins: bins,
autoAllowSkills,
});
const allowlistMatches = allowlistEval.allowlistMatches;
const allowlistSatisfied = const allowlistSatisfied =
security === "allowlist" && analysis.ok && analysis.segments.length > 0 security === "allowlist" && analysis.ok ? allowlistEval.allowlistSatisfied : false;
? analysis.segments.every((segment) => {
const match = matchAllowlist(approvals.allowlist, segment.resolution);
if (match) allowlistMatches.push(match);
const safe = isSafeBinUsage({
argv: segment.argv,
resolution: segment.resolution,
safeBins,
cwd: params.cwd ?? undefined,
});
const skillAllow =
autoAllowSkills && segment.resolution?.executableName
? bins.has(segment.resolution.executableName)
: false;
return Boolean(match || safe || skillAllow);
})
: false;
const useMacAppExec = process.platform === "darwin"; const useMacAppExec = process.platform === "darwin";
if (useMacAppExec) { if (useMacAppExec) {
@@ -715,9 +706,12 @@ async function handleInvoke(
return; return;
} }
const requiresAsk = const requiresAsk = requiresExecApproval({
ask === "always" || ask,
(ask === "on-miss" && security === "allowlist" && (!analysis.ok || !allowlistSatisfied)); security,
analysisOk: analysis.ok,
allowlistSatisfied,
});
const approvalDecision = const approvalDecision =
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always" params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"