fix(gateway): support native Windows exec approvals (#101669)

* fix(gateway): support native Windows exec approvals

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>

* chore: defer changelog entry to release

* test: use tracked approvals temp directories

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Peter Steinberger
2026-07-07 14:50:59 +01:00
committed by GitHub
parent 176fee5d07
commit a7faec8ca1
16 changed files with 976 additions and 53 deletions
+146 -1
View File
@@ -1,7 +1,10 @@
// Exec approvals CLI tests cover approval command registration and output handling.
import fs from "node:fs";
import path from "node:path";
import { Readable } from "node:stream";
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import * as execApprovals from "../infra/exec-approvals.js";
import type { ExecApprovalsFile } from "../infra/exec-approvals.js";
import { registerExecApprovalsCli, testing } from "./exec-approvals-cli.js";
@@ -64,6 +67,7 @@ const mocks = vi.hoisted(() => {
});
const { callGatewayFromCli, defaultRuntime, readBestEffortConfig, runtimeErrors } = mocks;
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const localSnapshot = {
path: "/tmp/local-exec-approvals.json",
@@ -369,6 +373,147 @@ describe("exec approvals CLI", () => {
);
});
it("shows host-native node approvals without approvals-file policy math", async () => {
callGatewayFromCli.mockImplementation(async (method: string) => {
if (method === "config.get") {
return { config: { tools: { exec: { security: "full", ask: "off" } } } };
}
if (method === "exec.approvals.node.get") {
return {
enabled: true,
hash: "sha256:current",
baseHash: "sha256:current",
defaultAction: "deny",
rules: [{ pattern: "hostname", action: "allow" }],
} as never;
}
return {} as never;
});
await runApprovalsCommand(["approvals", "get", "--node", "windows", "--json"]);
expect(writtenJson().defaultAction).toBe("deny");
expect(effectivePolicy()).toEqual({
note: "This node enforces a host-native exec policy; OpenClaw approvals-file policy math does not apply.",
scopes: [],
});
expect(callGatewayFromCli.mock.calls.map((call) => call[0])).toEqual([
"exec.approvals.node.get",
]);
expect(runtimeErrors).toHaveLength(0);
});
it("writes host-native node approvals with the current hash", async () => {
const dir = tempDirs.make("openclaw-native-approvals-");
const policyPath = path.join(dir, "policy.json");
fs.writeFileSync(
policyPath,
JSON.stringify({
defaultAction: "deny",
rules: [{ pattern: "hostname", action: "allow" }],
}),
);
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
if (method === "exec.approvals.node.get") {
return {
enabled: true,
hash: "sha256:current",
defaultAction: "deny",
rules: [],
} as never;
}
return { method, params };
},
);
await runApprovalsCommand([
"approvals",
"set",
"--node",
"windows",
"--file",
policyPath,
"--json",
]);
expect(callGatewayFromCli.mock.calls[1]?.[0]).toBe("exec.approvals.node.set");
expect(callGatewayFromCli.mock.calls[1]?.[2]).toEqual({
nodeId: "node-1",
native: {
defaultAction: "deny",
rules: [{ pattern: "hostname", action: "allow" }],
},
baseHash: "sha256:current",
});
expect(callGatewayFromCli.mock.calls[2]?.[0]).toBe("exec.approvals.node.get");
expect(runtimeErrors).toHaveLength(0);
});
it("rejects unknown host-native policy fields instead of dropping them", async () => {
const dir = tempDirs.make("openclaw-native-approvals-");
const policyPath = path.join(dir, "policy.json");
fs.writeFileSync(
policyPath,
JSON.stringify({ rules: [{ pattern: "hostname", action: "allow", shell: "powershell" }] }),
);
callGatewayFromCli.mockResolvedValue({
enabled: true,
hash: "sha256:current",
defaultAction: "deny",
rules: [],
} as never);
await expect(
runApprovalsCommand(["approvals", "set", "--node", "windows", "--file", policyPath]),
).rejects.toThrow("__exit__:1");
expect(callGatewayFromCli).toHaveBeenCalledTimes(1);
expect(runtimeErrors[0]).toContain("Unknown host-native exec approval rule 1 field: shell");
});
it("rejects remote configuration when a host-native policy is disabled", async () => {
callGatewayFromCli.mockResolvedValue({
enabled: false,
message: "No exec policy configured",
} as never);
await expect(
runApprovalsCommand([
"approvals",
"set",
"--node",
"windows",
"--file",
"/does/not/exist.json",
]),
).rejects.toThrow("__exit__:1");
expect(callGatewayFromCli).toHaveBeenCalledTimes(1);
expect(runtimeErrors[0]).toContain("disabled on this node and cannot be configured remotely");
});
it("rejects allowlist helpers for host-native nodes", async () => {
callGatewayFromCli.mockImplementation(async (method: string) => {
if (method === "exec.approvals.node.get") {
return {
enabled: true,
hash: "sha256:current",
defaultAction: "deny",
rules: [],
} as never;
}
return {} as never;
});
await expect(
runApprovalsCommand(["approvals", "allowlist", "add", "--node", "windows", "hostname"]),
).rejects.toThrow("__exit__:1");
expect(callGatewayFromCli).toHaveBeenCalledTimes(1);
expect(runtimeErrors[0]).toContain("do not support allowlist mutations");
});
it("keeps gateway approvals output when config.get fails", async () => {
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
+257 -20
View File
@@ -27,13 +27,37 @@ import { nodesCallOpts, resolveNodeId } from "./nodes-cli/rpc.js";
import type { NodesRpcOpts } from "./nodes-cli/types.js";
import { applyParentDefaultHelpAction } from "./program/parent-default-help.js";
type ExecApprovalsSnapshot = {
type FileExecApprovalsSnapshot = {
path: string;
exists: boolean;
hash: string;
file: ExecApprovalsFile;
};
type NativeExecApprovalAction = "allow" | "deny" | "prompt";
type NativeExecApprovalRule = {
pattern: string;
action: NativeExecApprovalAction;
shells?: string[];
description?: string;
enabled?: boolean;
};
type NativeExecApprovalPolicy = {
defaultAction?: NativeExecApprovalAction;
rules: NativeExecApprovalRule[];
};
type NativeExecApprovalsSnapshot =
| {
enabled: true;
hash: string;
baseHash?: string;
defaultAction: NativeExecApprovalAction;
rules: NativeExecApprovalRule[];
constraints?: Record<string, boolean>;
}
| { enabled: false; message?: string };
type ExecApprovalsSnapshot = FileExecApprovalsSnapshot | NativeExecApprovalsSnapshot;
type ConfigSnapshotLike = {
config?: OpenClawConfig;
};
@@ -105,6 +129,106 @@ function loadSnapshotLocal(): ExecApprovalsSnapshot {
};
}
function isFileApprovalsSnapshot(
snapshot: ExecApprovalsSnapshot,
): snapshot is FileExecApprovalsSnapshot {
return "file" in snapshot;
}
function isNativeApprovalsSnapshot(
snapshot: ExecApprovalsSnapshot,
): snapshot is NativeExecApprovalsSnapshot {
return "enabled" in snapshot;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseNativeAction(value: unknown, label: string): NativeExecApprovalAction {
if (value === "allow" || value === "deny" || value === "prompt") {
return value;
}
return exitWithError(`${label} must be allow, deny, or prompt.`);
}
function normalizeNativePolicyInput(value: unknown): NativeExecApprovalPolicy {
if (!isRecord(value)) {
exitWithError("Host-native exec approvals JSON must be an object.");
}
const unknownKeys = Object.keys(value).filter(
(key) => key !== "defaultAction" && key !== "rules",
);
if (unknownKeys.length > 0) {
exitWithError(`Unknown host-native exec approvals field: ${unknownKeys[0]}.`);
}
const defaultAction =
value.defaultAction === undefined
? undefined
: parseNativeAction(value.defaultAction, "defaultAction");
if (!Array.isArray(value.rules)) {
exitWithError("Host-native exec approvals rules must be an array.");
}
const rules = value.rules?.map((entry, index) => {
if (!isRecord(entry)) {
exitWithError(`Host-native exec approval rule ${index + 1} must be an object.`);
}
const unknownRuleKeys = Object.keys(entry).filter(
(key) =>
key !== "pattern" &&
key !== "action" &&
key !== "shells" &&
key !== "description" &&
key !== "enabled",
);
if (unknownRuleKeys.length > 0) {
exitWithError(
`Unknown host-native exec approval rule ${index + 1} field: ${unknownRuleKeys[0]}.`,
);
}
const pattern = normalizeOptionalString(entry.pattern);
if (!pattern) {
exitWithError(`Host-native exec approval rule ${index + 1} requires pattern.`);
}
const action = parseNativeAction(
entry.action,
`Host-native exec approval rule ${index + 1} action`,
);
let shells: string[] | undefined;
if (entry.shells !== undefined) {
if (!Array.isArray(entry.shells)) {
exitWithError(`Host-native exec approval rule ${index + 1} shells must be an array.`);
}
shells = entry.shells.map((shell) => {
const normalized = typeof shell === "string" ? shell.trim() : "";
if (!normalized) {
exitWithError(
`Host-native exec approval rule ${index + 1} shells must be non-empty strings.`,
);
}
return normalized;
});
}
if (entry.description !== undefined && typeof entry.description !== "string") {
exitWithError(`Host-native exec approval rule ${index + 1} description must be a string.`);
}
if (entry.enabled !== undefined && typeof entry.enabled !== "boolean") {
exitWithError(`Host-native exec approval rule ${index + 1} enabled must be a boolean.`);
}
return {
pattern,
action,
...(shells ? { shells } : {}),
...(entry.description !== undefined ? { description: entry.description } : {}),
...(entry.enabled !== undefined ? { enabled: entry.enabled } : {}),
};
});
return {
...(defaultAction ? { defaultAction } : {}),
rules,
};
}
function saveSnapshotLocal(file: ExecApprovalsFile): ExecApprovalsSnapshot {
saveExecApprovals(file);
return loadSnapshotLocal();
@@ -138,11 +262,12 @@ function requireTrimmedNonEmpty(value: string, message: string): string {
}
async function loadWritableSnapshotTarget(opts: ExecApprovalsCliOpts): Promise<{
snapshot: ExecApprovalsSnapshot;
snapshot: FileExecApprovalsSnapshot | NativeExecApprovalsSnapshot;
nodeId: string | null;
source: ApprovalsTargetSource;
targetLabel: string;
baseHash: string;
kind: "file" | "native";
}> {
// Writes carry the base hash so gateway/node updates can reject stale snapshots.
const { snapshot, nodeId, source } = await loadSnapshotTarget(opts);
@@ -150,25 +275,44 @@ async function loadWritableSnapshotTarget(opts: ExecApprovalsCliOpts): Promise<{
defaultRuntime.log(theme.muted("Writing local approvals."));
}
const targetLabel = source === "local" ? "local" : nodeId ? `node:${nodeId}` : "gateway";
const baseHash = snapshot.hash;
if (isNativeApprovalsSnapshot(snapshot) && !snapshot.enabled) {
exitWithError(
"Host-native exec approvals are disabled on this node and cannot be configured remotely.",
);
}
const baseHash = "hash" in snapshot ? snapshot.hash : undefined;
if (!baseHash) {
exitWithError("Exec approvals hash missing; reload and retry.");
}
return { snapshot, nodeId, source, targetLabel, baseHash };
const kind = isNativeApprovalsSnapshot(snapshot) ? "native" : "file";
return { snapshot, nodeId, source, targetLabel, baseHash, kind };
}
async function saveSnapshotTargeted(params: {
type SaveSnapshotTargetedParams = {
opts: ExecApprovalsCliOpts;
source: ApprovalsTargetSource;
nodeId: string | null;
file: ExecApprovalsFile;
baseHash: string;
targetLabel: string;
}): Promise<void> {
const next =
params.source === "local"
? saveSnapshotLocal(params.file)
: await saveSnapshot(params.opts, params.nodeId, params.file, params.baseHash);
} & ({ file: ExecApprovalsFile } | { native: NativeExecApprovalPolicy });
async function saveSnapshotTargeted(params: SaveSnapshotTargetedParams): Promise<void> {
let next: ExecApprovalsSnapshot;
if ("native" in params) {
if (params.source !== "node" || !params.nodeId) {
exitWithError("Host-native exec approvals can only target a node.");
}
await callGatewayFromCli("exec.approvals.node.set", params.opts, {
nodeId: params.nodeId,
native: params.native,
baseHash: params.baseHash,
});
next = await loadSnapshot(params.opts, params.nodeId);
} else if (params.source === "local") {
next = saveSnapshotLocal(params.file);
} else {
next = await saveSnapshot(params.opts, params.nodeId, params.file, params.baseHash);
}
if (params.opts.json) {
defaultRuntime.writeJson(next, 0);
return;
@@ -212,13 +356,22 @@ async function loadConfigForApprovalsTarget(params: {
function buildEffectivePolicyReport(params: {
configLoad: ConfigLoadResult;
source: ApprovalsTargetSource;
approvals: ExecApprovalsFile;
approvals?: ExecApprovalsFile;
hostPath: string;
nativePolicy: boolean;
}): EffectivePolicyReport {
const cfg = params.configLoad.config;
const timeoutNote = params.configLoad.timedOut
? "Config fetch timed out. Re-run with a higher --timeout to inspect Effective Policy."
: null;
if (!params.approvals) {
return {
scopes: [],
note: params.nativePolicy
? "This node enforces a host-native exec policy; OpenClaw approvals-file policy math does not apply."
: "Approvals file unavailable.",
};
}
if (params.source === "node") {
if (!cfg) {
return {
@@ -291,6 +444,10 @@ function renderEffectivePolicy(params: { report: EffectivePolicyReport }) {
}
function renderApprovalsSnapshot(snapshot: ExecApprovalsSnapshot, targetLabel: string) {
if (isNativeApprovalsSnapshot(snapshot)) {
renderNativeApprovalsSnapshot(snapshot, targetLabel);
return;
}
const rich = isRich();
const heading = (text: string) => (rich ? theme.heading(text) : text);
const muted = (text: string) => (rich ? theme.muted(text) : text);
@@ -373,6 +530,59 @@ function renderApprovalsSnapshot(snapshot: ExecApprovalsSnapshot, targetLabel: s
);
}
function renderNativeApprovalsSnapshot(snapshot: NativeExecApprovalsSnapshot, targetLabel: string) {
const rich = isRich();
const heading = (text: string) => (rich ? theme.heading(text) : text);
const muted = (text: string) => (rich ? theme.muted(text) : text);
const rules = snapshot.enabled ? snapshot.rules : [];
const summaryRows = [
{ Field: "Target", Value: targetLabel },
{ Field: "Kind", Value: "host-native" },
{ Field: "Enabled", Value: snapshot.enabled ? "yes" : "no" },
{ Field: "Hash", Value: snapshot.enabled ? snapshot.hash : "unavailable" },
{
Field: "Default",
Value: snapshot.enabled ? snapshot.defaultAction : (snapshot.message ?? "unavailable"),
},
{ Field: "Rules", Value: String(rules.length) },
];
defaultRuntime.log(heading("Approvals"));
defaultRuntime.log(
renderTable({
width: getTerminalTableWidth(),
columns: [
{ key: "Field", header: "Field", minWidth: 8 },
{ key: "Value", header: "Value", minWidth: 24, flex: true },
],
rows: summaryRows,
}).trimEnd(),
);
if (rules.length === 0) {
defaultRuntime.log("");
defaultRuntime.log(muted("No host-native rules."));
return;
}
defaultRuntime.log("");
defaultRuntime.log(heading("Rules"));
defaultRuntime.log(
renderTable({
width: getTerminalTableWidth(),
columns: [
{ key: "Pattern", header: "Pattern", minWidth: 20, flex: true },
{ key: "Action", header: "Action", minWidth: 8 },
{ key: "Shells", header: "Shells", minWidth: 10, flex: true },
{ key: "Enabled", header: "Enabled", minWidth: 7 },
],
rows: rules.map((rule) => ({
Pattern: rule.pattern,
Action: rule.action,
Shells: rule.shells?.join(", ") || "all",
Enabled: rule.enabled === false ? "no" : "yes",
})),
}).trimEnd(),
);
}
async function saveSnapshot(
opts: ExecApprovalsCliOpts,
nodeId: string | null,
@@ -423,9 +633,14 @@ async function loadWritableAllowlistAgent(opts: ExecApprovalsCliOpts): Promise<{
agent: ExecApprovalsAgent;
allowlistEntries: NonNullable<ExecApprovalsAgent["allowlist"]>;
}> {
const { snapshot, nodeId, source, targetLabel, baseHash } =
const { snapshot, nodeId, source, targetLabel, baseHash, kind } =
await loadWritableSnapshotTarget(opts);
const file = snapshot.file ?? { version: 1 };
if (kind === "native" || !isFileApprovalsSnapshot(snapshot)) {
exitWithError(
"Host-native node approvals do not support allowlist mutations; use approvals set --node with host-native JSON.",
);
}
const file = snapshot.file;
file.version = 1;
const agentKey = resolveAgentKey(opts.agent);
@@ -507,12 +722,17 @@ export function registerExecApprovalsCli(program: Command) {
.action(async (opts: ExecApprovalsCliOpts) => {
try {
const { snapshot, nodeId, source } = await loadSnapshotTarget(opts);
const configLoad = await loadConfigForApprovalsTarget({ opts, source });
const nativePolicy = isNativeApprovalsSnapshot(snapshot);
const configLoad = nativePolicy
? { config: null, timedOut: false }
: await loadConfigForApprovalsTarget({ opts, source });
const fileSnapshot = isFileApprovalsSnapshot(snapshot) ? snapshot : null;
const effectivePolicy = buildEffectivePolicyReport({
configLoad,
source,
approvals: snapshot.file,
hostPath: snapshot.path,
approvals: fileSnapshot?.file,
hostPath: fileSnapshot?.path ?? "",
nativePolicy,
});
if (opts.json) {
defaultRuntime.writeJson({ ...snapshot, effectivePolicy }, 0);
@@ -549,14 +769,31 @@ export function registerExecApprovalsCli(program: Command) {
if (opts.file && opts.stdin) {
exitWithError("Use either --file or --stdin (not both).");
}
const { source, nodeId, targetLabel, baseHash } = await loadWritableSnapshotTarget(opts);
const { source, nodeId, targetLabel, baseHash, kind } =
await loadWritableSnapshotTarget(opts);
const raw = opts.stdin ? await readStdin() : await fs.readFile(String(opts.file), "utf8");
let file: ExecApprovalsFile;
let input: unknown;
try {
file = JSON5.parse(raw);
input = JSON5.parse(raw);
} catch (err) {
exitWithError(`Failed to parse approvals JSON: ${String(err)}`);
}
if (kind === "native") {
const native = normalizeNativePolicyInput(input);
await saveSnapshotTargeted({
opts,
source,
nodeId,
native,
baseHash,
targetLabel,
});
return;
}
if (!isRecord(input)) {
exitWithError("Exec approvals JSON must be an object.");
}
const file = input as ExecApprovalsFile;
file.version = 1;
await saveSnapshotTargeted({ opts, source, nodeId, file, baseHash, targetLabel });
} catch (err) {