From a7faec8ca1d0ce5d20ab7475b5cc7b42e8c23764 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Jul 2026 14:50:59 +0100 Subject: [PATCH] fix(gateway): support native Windows exec approvals (#101669) * fix(gateway): support native Windows exec approvals Co-authored-by: Vincent Koc * chore: defer changelog entry to release * test: use tracked approvals temp directories --------- Co-authored-by: Vincent Koc --- .../OpenClawProtocol/GatewayModels.swift | 58 +++- docs/cli/approvals.md | 19 +- docs/tools/exec-approvals.md | 5 + .../src/exec-approvals-validators.test.ts | 73 +++++ packages/gateway-protocol/src/index.ts | 6 + .../src/schema/exec-approvals.ts | 118 +++++++- .../src/schema/protocol-schemas.ts | 2 + packages/gateway-protocol/src/schema/types.ts | 1 + src/cli/exec-approvals-cli.test.ts | 147 +++++++++- src/cli/exec-approvals-cli.ts | 277 ++++++++++++++++-- .../server-methods/exec-approvals.test.ts | 106 ++++++- src/gateway/server-methods/exec-approvals.ts | 28 +- ui/src/lib/nodes/exec-approvals.test.ts | 33 +++ ui/src/lib/nodes/index.ts | 51 +++- ui/src/pages/nodes/view-exec-approvals.ts | 73 ++++- ui/src/pages/nodes/view.devices.test.ts | 32 ++ 16 files changed, 976 insertions(+), 53 deletions(-) create mode 100644 ui/src/lib/nodes/exec-approvals.test.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 7082d13eb2b9..fb2bc2f563e9 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -7863,24 +7863,78 @@ public struct ExecApprovalsNodeGetParams: Codable, Sendable { } } +public struct ExecApprovalsNodeSnapshot: Codable, Sendable { + public let path: String? + public let exists: Bool? + public let hash: String? + public let file: [String: AnyCodable]? + public let enabled: Bool? + public let basehash: String? + public let defaultaction: AnyCodable? + public let rules: [[String: AnyCodable]]? + public let constraints: [String: AnyCodable]? + public let message: String? + + public init( + path: String?, + exists: Bool?, + hash: String?, + file: [String: AnyCodable]?, + enabled: Bool?, + basehash: String?, + defaultaction: AnyCodable?, + rules: [[String: AnyCodable]]?, + constraints: [String: AnyCodable]?, + message: String?) + { + self.path = path + self.exists = exists + self.hash = hash + self.file = file + self.enabled = enabled + self.basehash = basehash + self.defaultaction = defaultaction + self.rules = rules + self.constraints = constraints + self.message = message + } + + private enum CodingKeys: String, CodingKey { + case path + case exists + case hash + case file + case enabled + case basehash = "baseHash" + case defaultaction = "defaultAction" + case rules + case constraints + case message + } +} + public struct ExecApprovalsNodeSetParams: Codable, Sendable { public let nodeid: String - public let file: [String: AnyCodable] + public let file: [String: AnyCodable]? + public let native: [String: AnyCodable]? public let basehash: String? public init( nodeid: String, - file: [String: AnyCodable], + file: [String: AnyCodable]?, + native: [String: AnyCodable]?, basehash: String?) { self.nodeid = nodeid self.file = file + self.native = native self.basehash = basehash } private enum CodingKeys: String, CodingKey { case nodeid = "nodeId" case file + case native case basehash = "baseHash" } } diff --git a/docs/cli/approvals.md b/docs/cli/approvals.md index 5b4e34615a1c..dba266d14ef5 100644 --- a/docs/cli/approvals.md +++ b/docs/cli/approvals.md @@ -46,7 +46,7 @@ openclaw approvals get --node openclaw approvals get --gateway ``` -`get` shows the effective exec policy for the target: the requested `tools.exec` policy, the host approvals-file policy, and the merged effective result. +`get` shows the effective exec policy for the target: the requested `tools.exec` policy, the host approvals-file policy, and the merged effective result. Nodes with a host-native policy, such as the Windows companion, show that policy directly instead of applying OpenClaw approvals-file policy math. Precedence: @@ -68,6 +68,19 @@ openclaw approvals set --gateway --file ./exec-approvals.json `set` accepts JSON5, not only strict JSON. Use either `--file` or `--stdin`, not both. +Host-native Windows nodes use their own policy shape: + +```bash +openclaw approvals set --node --stdin <<'EOF' +{ + defaultAction: "deny", + rules: [{ pattern: "hostname", action: "allow" }] +} +EOF +``` + +The CLI reads the node's current hash first and sends it with the update, so concurrent local edits are rejected instead of overwritten. `rules` is required because this operation replaces the node's complete rule list; `defaultAction` is optional. A node that reports its native policy as disabled cannot be configured remotely; enable or configure the policy on that host first. Host-native policies do not support the `allowlist add|remove` helpers. + ## "Never prompt" / YOLO example Set the host approvals defaults to `full` + `off` for a host that should never stop on exec approvals: @@ -85,7 +98,7 @@ openclaw approvals set --stdin <<'EOF' EOF ``` -Node variant: same body with `openclaw approvals set --node --stdin`. +For nodes that expose an OpenClaw approvals file, use the same body with `openclaw approvals set --node --stdin`. Host-native nodes require their owner-specific shape shown above. This changes the **host approvals file** only. To keep the requested OpenClaw policy aligned, also set: @@ -129,7 +142,7 @@ No target flag means the local approvals file on disk. ## Notes -- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host). +- The node host must advertise `system.execApprovals.get/set` (macOS app, headless node host, or Windows companion). - Approvals files are stored per host in the OpenClaw state dir: `$OPENCLAW_STATE_DIR/exec-approvals.json`, or `~/.openclaw/exec-approvals.json` when the variable is unset. ## Related diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index fb116ba70060..9a5faefb1a9b 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -435,6 +435,11 @@ Nodes must advertise `system.execApprovals.get/set` (macOS app or headless node host). If a node does not advertise exec approvals yet, edit its local approvals file directly. +Some node hosts, including the Windows companion, own a different approval +policy format. Control UI shows these host-native policies read-only. Use the +companion app or `openclaw approvals set --node ` with the native +policy shape to edit them; see [Approvals CLI](/cli/approvals). + CLI: `openclaw approvals` supports gateway or node editing - see [Approvals CLI](/cli/approvals). diff --git a/packages/gateway-protocol/src/exec-approvals-validators.test.ts b/packages/gateway-protocol/src/exec-approvals-validators.test.ts index 9a7dac68510e..30423b7732d4 100644 --- a/packages/gateway-protocol/src/exec-approvals-validators.test.ts +++ b/packages/gateway-protocol/src/exec-approvals-validators.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { validateExecApprovalRequestParams, + validateExecApprovalsNodeSnapshot, validateExecApprovalsNodeSetParams, validateExecApprovalsSetParams, } from "./index.js"; @@ -44,6 +45,78 @@ describe("exec approvals protocol validators", () => { ).toBe(true); }); + it("accepts the shipped Windows node approval contract", () => { + expect( + validateExecApprovalsNodeSnapshot({ + enabled: true, + hash: "sha256:current", + baseHash: "sha256:current", + defaultAction: "deny", + constraints: { + baseHashRequired: true, + defaultAllowAllowed: false, + broadAllowRulesAllowed: false, + dangerousAllowRulesAllowed: false, + }, + rules: [{ pattern: "hostname", action: "allow", enabled: true }], + }), + ).toBe(true); + expect( + validateExecApprovalsNodeSnapshot({ enabled: false, message: "No exec policy configured" }), + ).toBe(true); + expect( + validateExecApprovalsNodeSetParams({ + nodeId: "windows-node", + native: { defaultAction: "deny", rules: [] }, + baseHash: "sha256:current", + }), + ).toBe(true); + }); + + it("rejects ambiguous or unsafe host-native approval payloads", () => { + for (const snapshot of [ + {}, + { hash: "sha256:current" }, + { enabled: true, hash: "sha256:current", defaultAction: "deny" }, + { enabled: true, hash: "sha256:current", defaultAction: "full", rules: [] }, + { + path: "/tmp/exec-approvals.json", + exists: true, + hash: "sha256:file", + file: { version: 1 }, + defaultAction: "deny", + }, + { + enabled: true, + hash: "sha256:current", + defaultAction: "deny", + rules: [], + message: "mixed state", + }, + { enabled: false, rules: [] }, + ]) { + expect(validateExecApprovalsNodeSnapshot(snapshot)).toBe(false); + } + + for (const params of [ + { nodeId: "windows-node", native: {}, baseHash: "sha256:current" }, + { + nodeId: "windows-node", + native: { defaultAction: "deny" }, + baseHash: "sha256:current", + }, + { nodeId: "windows-node", native: { defaultAction: "full" }, baseHash: "sha256:current" }, + { nodeId: "windows-node", native: { rules: [] } }, + { + nodeId: "windows-node", + native: { rules: [{ pattern: "", action: "allow" }] }, + baseHash: "sha256:current", + }, + ]) { + expect(validateExecApprovalsNodeSetParams(params)).toBe(false); + } + }); + it("rejects unknown allowlist metadata", () => { expect( validateExecApprovalsSetParams({ diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index de6a46c38deb..3a835bbacbdc 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -227,6 +227,8 @@ import { ExecApprovalsGetParamsSchema, type ExecApprovalsNodeGetParams, ExecApprovalsNodeGetParamsSchema, + type ExecApprovalsNodeSnapshot, + ExecApprovalsNodeSnapshotSchema, type ExecApprovalsNodeSetParams, ExecApprovalsNodeSetParamsSchema, type ExecApprovalsSetParams, @@ -1053,6 +1055,9 @@ export const validateExecApprovalsNodeGetParams = lazyCompile( ExecApprovalsNodeSetParamsSchema, ); +export const validateExecApprovalsNodeSnapshot = lazyCompile( + ExecApprovalsNodeSnapshotSchema, +); export const validateLogsTailParams = lazyCompile(LogsTailParamsSchema); export const validateTerminalOpenParams = lazyCompile(TerminalOpenParamsSchema); export const validateTerminalInputParams = @@ -1646,6 +1651,7 @@ export type { CronRunsParams, CronRunLogEntry, ExecApprovalsGetParams, + ExecApprovalsNodeSnapshot, ExecApprovalsSetParams, ExecApprovalsSnapshot, ExecApprovalGetParams, diff --git a/packages/gateway-protocol/src/schema/exec-approvals.ts b/packages/gateway-protocol/src/schema/exec-approvals.ts index a7086a9fc678..1217080bb100 100644 --- a/packages/gateway-protocol/src/schema/exec-approvals.ts +++ b/packages/gateway-protocol/src/schema/exec-approvals.ts @@ -63,7 +63,7 @@ export const ExecApprovalsFileSchema = Type.Object( { additionalProperties: false }, ); -/** Read snapshot with path/hash metadata for optimistic writes. */ +/** File-backed read snapshot with path/hash metadata for optimistic writes. */ export const ExecApprovalsSnapshotSchema = Type.Object( { path: NonEmptyString, @@ -74,6 +74,96 @@ export const ExecApprovalsSnapshotSchema = Type.Object( { additionalProperties: false }, ); +const NativeExecApprovalActionSchema = Type.Union([ + Type.Literal("allow"), + Type.Literal("deny"), + Type.Literal("prompt"), +]); + +/** One rule owned and enforced by a host-native exec policy implementation. */ +const NativeExecApprovalRuleSchema = Type.Object( + { + pattern: NonEmptyString, + action: NativeExecApprovalActionSchema, + shells: Type.Optional(Type.Array(NonEmptyString)), + description: Type.Optional(Type.String()), + enabled: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +); + +const NativeExecApprovalConstraintsSchema = Type.Object( + { + baseHashRequired: Type.Optional(Type.Boolean()), + defaultAllowAllowed: Type.Optional(Type.Boolean()), + broadAllowRulesAllowed: Type.Optional(Type.Boolean()), + dangerousAllowRulesAllowed: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +); + +/** Node read snapshot supporting file-backed and host-native approval owners. */ +export const ExecApprovalsNodeSnapshotSchema = Type.Object( + { + path: Type.Optional(Type.String()), + exists: Type.Optional(Type.Boolean()), + hash: Type.Optional(Type.String()), + file: Type.Optional(ExecApprovalsFileSchema), + enabled: Type.Optional(Type.Boolean()), + baseHash: Type.Optional(NonEmptyString), + defaultAction: Type.Optional(NativeExecApprovalActionSchema), + rules: Type.Optional(Type.Array(NativeExecApprovalRuleSchema)), + constraints: Type.Optional(NativeExecApprovalConstraintsSchema), + message: Type.Optional(Type.String()), + }, + { + additionalProperties: false, + oneOf: [ + { + required: ["path", "exists", "hash", "file"], + not: { + anyOf: [ + { required: ["enabled"] }, + { required: ["baseHash"] }, + { required: ["defaultAction"] }, + { required: ["rules"] }, + { required: ["constraints"] }, + { required: ["message"] }, + ], + }, + }, + { + properties: { enabled: { const: true }, hash: { minLength: 1 } }, + required: ["enabled", "hash", "defaultAction", "rules"], + not: { + anyOf: [ + { required: ["path"] }, + { required: ["exists"] }, + { required: ["file"] }, + { required: ["message"] }, + ], + }, + }, + { + properties: { enabled: { const: false } }, + required: ["enabled"], + not: { + anyOf: [ + { required: ["path"] }, + { required: ["exists"] }, + { required: ["hash"] }, + { required: ["file"] }, + { required: ["baseHash"] }, + { required: ["defaultAction"] }, + { required: ["rules"] }, + { required: ["constraints"] }, + ], + }, + }, + ], + }, +); + /** Empty request payload for reading local exec approval policy. */ export const ExecApprovalsGetParamsSchema = Type.Object({}, { additionalProperties: false }); @@ -94,14 +184,34 @@ export const ExecApprovalsNodeGetParamsSchema = Type.Object( { additionalProperties: false }, ); -/** Node-scoped exec approval policy write request with optional base hash guard. */ +/** Writable host-native policy fields; the node remains the validation authority. */ +const NativeExecApprovalPolicySchema = Type.Object( + { + defaultAction: Type.Optional(NativeExecApprovalActionSchema), + // Windows treats set as full replacement; omission would silently clear the rule list. + rules: Type.Array(NativeExecApprovalRuleSchema), + }, + { additionalProperties: false }, +); + +/** Node-scoped write for exactly one file-backed or host-native approval owner. */ export const ExecApprovalsNodeSetParamsSchema = Type.Object( { nodeId: NonEmptyString, - file: ExecApprovalsFileSchema, + file: Type.Optional(ExecApprovalsFileSchema), + native: Type.Optional(NativeExecApprovalPolicySchema), baseHash: Type.Optional(NonEmptyString), }, - { additionalProperties: false }, + { + additionalProperties: false, + oneOf: [ + { required: ["file"], not: { required: ["native"] } }, + { + required: ["native", "baseHash"], + not: { required: ["file"] }, + }, + ], + }, ); /** Lookup request for one pending exec approval by id. */ diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index 449c146cccee..5a271bb673a9 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -201,6 +201,7 @@ import { import { ExecApprovalsGetParamsSchema, ExecApprovalsNodeGetParamsSchema, + ExecApprovalsNodeSnapshotSchema, ExecApprovalsNodeSetParamsSchema, ExecApprovalsSetParamsSchema, ExecApprovalsSnapshotSchema, @@ -668,6 +669,7 @@ export const ProtocolSchemas = { ExecApprovalsGetParams: ExecApprovalsGetParamsSchema, ExecApprovalsSetParams: ExecApprovalsSetParamsSchema, ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema, + ExecApprovalsNodeSnapshot: ExecApprovalsNodeSnapshotSchema, ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema, ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema, ExecApprovalGetParams: ExecApprovalGetParamsSchema, diff --git a/packages/gateway-protocol/src/schema/types.ts b/packages/gateway-protocol/src/schema/types.ts index 151753aa066c..8ca4e32a5f3a 100644 --- a/packages/gateway-protocol/src/schema/types.ts +++ b/packages/gateway-protocol/src/schema/types.ts @@ -310,6 +310,7 @@ export type LogsTailResult = SchemaType<"LogsTailResult">; export type ExecApprovalsGetParams = SchemaType<"ExecApprovalsGetParams">; export type ExecApprovalsSetParams = SchemaType<"ExecApprovalsSetParams">; export type ExecApprovalsNodeGetParams = SchemaType<"ExecApprovalsNodeGetParams">; +export type ExecApprovalsNodeSnapshot = SchemaType<"ExecApprovalsNodeSnapshot">; export type ExecApprovalsNodeSetParams = SchemaType<"ExecApprovalsNodeSetParams">; export type ExecApprovalsSnapshot = SchemaType<"ExecApprovalsSnapshot">; export type ExecApprovalGetParams = SchemaType<"ExecApprovalGetParams">; diff --git a/src/cli/exec-approvals-cli.test.ts b/src/cli/exec-approvals-cli.test.ts index 629300518efb..37718d60780a 100644 --- a/src/cli/exec-approvals-cli.test.ts +++ b/src/cli/exec-approvals-cli.test.ts @@ -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) => { diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index 27af93ca4213..dc1a2cf713ed 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -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; + } + | { 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 { + 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 { - 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 { + 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; }> { - 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) { diff --git a/src/gateway/server-methods/exec-approvals.test.ts b/src/gateway/server-methods/exec-approvals.test.ts index 967a6d9d259f..f3940dc3f850 100644 --- a/src/gateway/server-methods/exec-approvals.test.ts +++ b/src/gateway/server-methods/exec-approvals.test.ts @@ -143,7 +143,13 @@ describe("exec approvals gateway methods", () => { it("relays approved exec-approval commands", async () => { const command = "system.execApprovals.get"; - const invoke = vi.fn().mockResolvedValue({ ok: true, payload: { exists: true } }); + const payload = { + path: "/tmp/exec-approvals.json", + exists: true, + hash: "sha256:file", + file: { version: 1 }, + }; + const invoke = vi.fn().mockResolvedValue({ ok: true, payload }); const respond = vi.fn(); await execApprovalsHandlers["exec.approvals.node.get"]({ @@ -174,7 +180,103 @@ describe("exec approvals gateway methods", () => { }); expect(invoke).toHaveBeenCalledWith({ nodeId: "node-1", command, params: {} }); - expect(respond).toHaveBeenCalledWith(true, { exists: true }, undefined); + expect(respond).toHaveBeenCalledWith(true, payload, undefined); + }); + + it("relays host-native approval writes without file-shape translation", async () => { + const command = "system.execApprovals.set"; + const invoke = vi.fn().mockResolvedValue({ + ok: true, + payload: { updated: true, hash: "sha256:next" }, + }); + const respond = vi.fn(); + const params = { + nodeId: "windows-node", + native: { + defaultAction: "deny" as const, + rules: [{ pattern: "hostname", action: "allow" as const }], + }, + baseHash: "sha256:current", + }; + + await execApprovalsHandlers["exec.approvals.node.set"]({ + req: { + type: "req", + id: "req-native-set", + method: "exec.approvals.node.set", + params, + }, + params, + client: null, + isWebchatConnect: () => false, + respond, + context: { + getRuntimeConfig: () => ({}), + nodeRegistry: { + get: () => ({ + nodeId: "windows-node", + connId: "conn-1", + platform: "windows", + deviceFamily: "Windows", + declaredCommands: [command], + commands: [command], + }), + invoke, + }, + } as never, + }); + + expect(invoke).toHaveBeenCalledWith({ + nodeId: "windows-node", + command, + params: { + defaultAction: "deny", + rules: [{ pattern: "hostname", action: "allow" }], + baseHash: "sha256:current", + }, + }); + expect(respond).toHaveBeenCalledWith(true, { updated: true, hash: "sha256:next" }, undefined); + }); + + it("rejects malformed node approval snapshots at the gateway boundary", async () => { + const command = "system.execApprovals.get"; + const respond = vi.fn(); + + await execApprovalsHandlers["exec.approvals.node.get"]({ + req: { + type: "req", + id: "req-invalid-native-get", + method: "exec.approvals.node.get", + params: { nodeId: "windows-node" }, + }, + params: { nodeId: "windows-node" }, + client: null, + isWebchatConnect: () => false, + respond, + context: { + getRuntimeConfig: () => ({}), + nodeRegistry: { + get: () => ({ + nodeId: "windows-node", + connId: "conn-1", + platform: "windows", + deviceFamily: "Windows", + declaredCommands: [command], + commands: [command], + }), + invoke: vi.fn().mockResolvedValue({ + ok: true, + payload: { enabled: true, hash: "sha256:current", rules: [] }, + }), + }, + } as never, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ message: "node returned invalid exec approvals payload" }), + ); }); it("preserves unavailable details for unknown nodes", async () => { diff --git a/src/gateway/server-methods/exec-approvals.ts b/src/gateway/server-methods/exec-approvals.ts index f31585cc757c..8983ef2077a7 100644 --- a/src/gateway/server-methods/exec-approvals.ts +++ b/src/gateway/server-methods/exec-approvals.ts @@ -5,6 +5,7 @@ import { errorShape, validateExecApprovalsGetParams, validateExecApprovalsNodeGetParams, + validateExecApprovalsNodeSnapshot, validateExecApprovalsNodeSetParams, validateExecApprovalsSetParams, } from "../../../packages/gateway-protocol/src/index.js"; @@ -102,6 +103,7 @@ async function respondWithExecApprovalsNodePayload Record; readPayload: (response: { payload?: unknown; payloadJSON?: string | null }) => unknown; + validatePayload?: (payload: unknown) => boolean; }): Promise { const rawParams = params.rawParams; if (!assertValidParams(rawParams, params.validate, params.method, params.respond)) { @@ -145,7 +147,16 @@ async function respondWithExecApprovalsNodePayload (res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload), + validatePayload: validateExecApprovalsNodeSnapshot, }); }, "exec.approvals.node.set": async ({ params, respond, context }) => { @@ -208,13 +220,13 @@ export const execApprovalsHandlers: GatewayRequestHandlers = { context, respond, command: "system.execApprovals.set", - commandParams: (parsedParams) => ({ - file: parsedParams.file, - baseHash: parsedParams.baseHash, - }), - // node.set returns JSON on the command channel; keep the gateway response - // shape aligned with local exec.approvals.set. - readPayload: (res) => safeParseJson(res.payloadJSON ?? null), + // Host-native nodes own a different policy model. Preserve that model at + // the node boundary instead of pretending it is an OpenClaw approvals file. + commandParams: (parsedParams) => + "native" in parsedParams + ? { ...parsedParams.native, baseHash: parsedParams.baseHash } + : { file: parsedParams.file, baseHash: parsedParams.baseHash }, + readPayload: (res) => (res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload), }); }, }; diff --git a/ui/src/lib/nodes/exec-approvals.test.ts b/ui/src/lib/nodes/exec-approvals.test.ts new file mode 100644 index 000000000000..49f0d754f8c8 --- /dev/null +++ b/ui/src/lib/nodes/exec-approvals.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createInitialNodesState, + loadExecApprovals, + saveExecApprovals, + updateExecApprovalsFormValue, +} from "./index.ts"; + +describe("host-native exec approvals state", () => { + it("keeps native snapshots read-only", async () => { + const request = vi.fn().mockResolvedValue({ + enabled: true, + hash: "sha256:current", + defaultAction: "deny", + rules: [{ pattern: "hostname", action: "allow" }], + }); + const state = createInitialNodesState({ client: { request }, connected: true }); + const target = { kind: "node" as const, nodeId: "windows-node" }; + + await loadExecApprovals(state, target); + + expect(state.execApprovalsForm).toBeNull(); + expect(state.execApprovalsDirty).toBe(false); + updateExecApprovalsFormValue(state, ["defaults", "security"], "full"); + expect(state.execApprovalsDirty).toBe(false); + expect(state.lastError).toContain("read-only"); + + await saveExecApprovals(state, target); + + expect(request).toHaveBeenCalledTimes(1); + expect(state.lastError).toContain("read-only"); + }); +}); diff --git a/ui/src/lib/nodes/index.ts b/ui/src/lib/nodes/index.ts index 6e1b5244f8b3..ba757b029b8c 100644 --- a/ui/src/lib/nodes/index.ts +++ b/ui/src/lib/nodes/index.ts @@ -88,13 +88,34 @@ export type ExecApprovalsFile = { agents?: Record; }; -export type ExecApprovalsSnapshot = { +export type FileExecApprovalsSnapshot = { path: string; exists: boolean; hash: string; file: ExecApprovalsFile; }; +export type NativeExecApprovalRule = { + pattern: string; + action: "allow" | "deny" | "prompt"; + shells?: string[]; + description?: string; + enabled?: boolean; +}; + +export type NativeExecApprovalsSnapshot = + | { + enabled: true; + hash: string; + baseHash?: string; + defaultAction: "allow" | "deny" | "prompt"; + rules: NativeExecApprovalRule[]; + constraints?: Record; + } + | { enabled: false; message?: string }; + +export type ExecApprovalsSnapshot = FileExecApprovalsSnapshot | NativeExecApprovalsSnapshot; + export type ExecApprovalsTarget = { kind: "gateway" } | { kind: "node"; nodeId: string }; export type NodesState = { @@ -372,9 +393,20 @@ export async function loadExecApprovals( function applyExecApprovalsSnapshot(state: ExecApprovalsState, snapshot: ExecApprovalsSnapshot) { state.execApprovalsSnapshot = snapshot; - if (!state.execApprovalsDirty) { - state.execApprovalsForm = cloneConfigObject(snapshot.file ?? {}); + if (isNativeExecApprovalsSnapshot(snapshot)) { + state.execApprovalsForm = null; + state.execApprovalsDirty = false; + return; } + if (!state.execApprovalsDirty) { + state.execApprovalsForm = cloneConfigObject(snapshot.file); + } +} + +export function isNativeExecApprovalsSnapshot( + snapshot: ExecApprovalsSnapshot | null | undefined, +): snapshot is NativeExecApprovalsSnapshot { + return Boolean(snapshot && "enabled" in snapshot); } export async function saveExecApprovals( @@ -389,6 +421,11 @@ export async function saveExecApprovals( state.lastError = null; state.chatError = null; try { + if (isNativeExecApprovalsSnapshot(state.execApprovalsSnapshot)) { + state.lastError = + "Host-native node approvals are read-only here; use the companion app or approvals set --node."; + return; + } const baseHash = state.execApprovalsSnapshot?.hash; if (!baseHash) { state.lastError = "Exec approvals hash missing; reload and retry."; @@ -422,6 +459,10 @@ export function updateExecApprovalsFormValue( path: Array, value: unknown, ) { + if (isNativeExecApprovalsSnapshot(state.execApprovalsSnapshot)) { + state.lastError = "Host-native node approvals are read-only here."; + return; + } const base = cloneConfigObject( state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {}, ); @@ -434,6 +475,10 @@ export function removeExecApprovalsFormValue( state: ExecApprovalsState, path: Array, ) { + if (isNativeExecApprovalsSnapshot(state.execApprovalsSnapshot)) { + state.lastError = "Host-native node approvals are read-only here."; + return; + } const base = cloneConfigObject( state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {}, ); diff --git a/ui/src/pages/nodes/view-exec-approvals.ts b/ui/src/pages/nodes/view-exec-approvals.ts index 3e117bc20ac6..877f02990be6 100644 --- a/ui/src/pages/nodes/view-exec-approvals.ts +++ b/ui/src/pages/nodes/view-exec-approvals.ts @@ -2,7 +2,12 @@ import { html, nothing } from "lit"; import { t } from "../../i18n/index.ts"; import { clampText, formatRelativeTimestamp } from "../../lib/format.ts"; -import type { ExecApprovalsAllowlistEntry, ExecApprovalsFile } from "../../lib/nodes/index.ts"; +import { + isNativeExecApprovalsSnapshot, + type ExecApprovalsAllowlistEntry, + type ExecApprovalsFile, + type NativeExecApprovalsSnapshot, +} from "../../lib/nodes/index.ts"; import { resolveConfigAgents as resolveSharedConfigAgents, resolveNodeTargets, @@ -35,6 +40,7 @@ type ExecApprovalsState = { loading: boolean; saving: boolean; form: ExecApprovalsFile | null; + nativePolicy: NativeExecApprovalsSnapshot | null; defaults: ExecApprovalsResolvedDefaults; selectedScope: string; selectedAgent: Record | null; @@ -145,8 +151,11 @@ function resolveExecApprovalsScope( } export function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState { - const form = props.execApprovalsForm ?? props.execApprovalsSnapshot?.file ?? null; - const ready = Boolean(form); + const snapshot = props.execApprovalsSnapshot; + const nativePolicy = isNativeExecApprovalsSnapshot(snapshot) ? snapshot : null; + const fileSnapshot = snapshot && !isNativeExecApprovalsSnapshot(snapshot) ? snapshot : null; + const form = nativePolicy ? null : (props.execApprovalsForm ?? fileSnapshot?.file ?? null); + const ready = Boolean(form || nativePolicy); const defaults = resolveExecApprovalsDefaults(form); const agents = resolveExecApprovalsAgents(props.configForm, form); const targetNodes = resolveExecApprovalsNodes(props.nodes); @@ -171,6 +180,7 @@ export function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState loading: props.execApprovalsLoading, saving: props.execApprovalsSaving, form, + nativePolicy, defaults, selectedScope, selectedAgent, @@ -202,7 +212,7 @@ export function renderExecApprovals(state: ExecApprovalsState) { ` - : html` - ${renderExecApprovalsTabs(state)} ${renderExecApprovalsPolicy(state)} - ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE - ? nothing - : renderExecApprovalsAllowlist(state)} - `} + : state.nativePolicy + ? renderNativeExecApprovals(state.nativePolicy) + : html` + ${renderExecApprovalsTabs(state)} ${renderExecApprovalsPolicy(state)} + ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE + ? nothing + : renderExecApprovalsAllowlist(state)} + `} `; } +function renderNativeExecApprovals(snapshot: NativeExecApprovalsSnapshot) { + const rules = snapshot.enabled && Array.isArray(snapshot.rules) ? snapshot.rules : []; + const defaultAction = snapshot.enabled + ? snapshot.defaultAction + : (snapshot.message ?? "unavailable"); + return html` +
+
+
+
Host-native policy
+
Read-only here. Edit from the companion app or CLI.
+
+
Native
+
+
+
+
Default action
+
${defaultAction}
+
+
${rules.length} ${rules.length === 1 ? "rule" : "rules"}
+
+ ${rules.map( + (rule) => html` +
+
+
${rule.pattern}
+
+ ${rule.action} · ${rule.shells?.join(", ") || "all shells"} · + ${rule.enabled === false ? "off" : "on"} +
+ ${rule.description + ? html`
${clampText(rule.description, 120)}
` + : nothing} +
+
+ `, + )} +
+ `; +} + function renderExecApprovalsTarget(state: ExecApprovalsState) { const hasNodes = state.targetNodes.length > 0; const nodeValue = state.targetNodeId ?? ""; diff --git a/ui/src/pages/nodes/view.devices.test.ts b/ui/src/pages/nodes/view.devices.test.ts index 61ada4881b5e..d25d52ff587b 100644 --- a/ui/src/pages/nodes/view.devices.test.ts +++ b/ui/src/pages/nodes/view.devices.test.ts @@ -192,3 +192,35 @@ describe("nodes devices pending rendering", () => { expect(details[1]).toBe("requested: roles: node, operator \u00b7 scopes: operator.read"); }); }); + +describe("nodes exec approvals rendering", () => { + it("renders host-native Windows policies as read-only", () => { + const container = renderNodesContainer({ + nodes: [ + { + id: "windows-node", + label: "Windows node", + commands: ["system.execApprovals.get", "system.execApprovals.set"], + }, + ], + execApprovalsTarget: "node", + execApprovalsTargetNodeId: "windows-node", + execApprovalsSnapshot: { + enabled: true, + hash: "sha256:current", + defaultAction: "deny", + rules: [{ pattern: "hostname", action: "allow" }], + }, + }); + const card = Array.from(container.querySelectorAll(".card")).find( + (candidate) => + candidate.querySelector(".card-title")?.textContent?.trim() === "Exec approvals", + ); + + expect(card?.textContent).toContain("Host-native policy"); + expect(card?.textContent).toContain("Read-only here"); + expect(card?.textContent).toContain("hostname"); + expect(card?.textContent).toContain("deny"); + expect(card?.querySelector("button")?.hasAttribute("disabled")).toBe(true); + }); +});