mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 01:33:53 +00:00
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:
committed by
GitHub
parent
176fee5d07
commit
a7faec8ca1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ openclaw approvals get --node <id|name|ip>
|
||||
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 <id|name|ip> --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 <id|name|ip> --stdin`.
|
||||
For nodes that expose an OpenClaw approvals file, use the same body with `openclaw approvals set --node <id|name|ip> --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
|
||||
|
||||
@@ -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 <id|name|ip>` 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).
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<ExecApprovalsNodeG
|
||||
export const validateExecApprovalsNodeSetParams = lazyCompile<ExecApprovalsNodeSetParams>(
|
||||
ExecApprovalsNodeSetParamsSchema,
|
||||
);
|
||||
export const validateExecApprovalsNodeSnapshot = lazyCompile<ExecApprovalsNodeSnapshot>(
|
||||
ExecApprovalsNodeSnapshotSchema,
|
||||
);
|
||||
export const validateLogsTailParams = lazyCompile<LogsTailParams>(LogsTailParamsSchema);
|
||||
export const validateTerminalOpenParams = lazyCompile<TerminalOpenParams>(TerminalOpenParamsSchema);
|
||||
export const validateTerminalInputParams =
|
||||
@@ -1646,6 +1651,7 @@ export type {
|
||||
CronRunsParams,
|
||||
CronRunLogEntry,
|
||||
ExecApprovalsGetParams,
|
||||
ExecApprovalsNodeSnapshot,
|
||||
ExecApprovalsSetParams,
|
||||
ExecApprovalsSnapshot,
|
||||
ExecApprovalGetParams,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<TParams extends { nodeId: str
|
||||
command: "system.execApprovals.get" | "system.execApprovals.set";
|
||||
commandParams: (parsedParams: TParams) => Record<string, unknown>;
|
||||
readPayload: (response: { payload?: unknown; payloadJSON?: string | null }) => unknown;
|
||||
validatePayload?: (payload: unknown) => boolean;
|
||||
}): Promise<void> {
|
||||
const rawParams = params.rawParams;
|
||||
if (!assertValidParams(rawParams, params.validate, params.method, params.respond)) {
|
||||
@@ -145,7 +147,16 @@ async function respondWithExecApprovalsNodePayload<TParams extends { nodeId: str
|
||||
if (!respondUnavailableOnNodeInvokeError(params.respond, res)) {
|
||||
return;
|
||||
}
|
||||
params.respond(true, params.readPayload(res), undefined);
|
||||
const payload = params.readPayload(res);
|
||||
if (params.validatePayload && !params.validatePayload(payload)) {
|
||||
params.respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "node returned invalid exec approvals payload"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
params.respond(true, payload, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -198,6 +209,7 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
|
||||
// Node invocations can return structured payloads or JSON strings
|
||||
// depending on the transport; normalize before echoing the RPC response.
|
||||
readPayload: (res) => (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),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
33
ui/src/lib/nodes/exec-approvals.test.ts
Normal file
33
ui/src/lib/nodes/exec-approvals.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -88,13 +88,34 @@ export type ExecApprovalsFile = {
|
||||
agents?: Record<string, ExecApprovalsAgent>;
|
||||
};
|
||||
|
||||
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<string, boolean>;
|
||||
}
|
||||
| { 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<string | number>,
|
||||
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<string | number>,
|
||||
) {
|
||||
if (isNativeExecApprovalsSnapshot(state.execApprovalsSnapshot)) {
|
||||
state.lastError = "Host-native node approvals are read-only here.";
|
||||
return;
|
||||
}
|
||||
const base = cloneConfigObject(
|
||||
state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {},
|
||||
);
|
||||
|
||||
@@ -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<string, unknown> | 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) {
|
||||
</div>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${state.disabled || !state.dirty || !targetReady}
|
||||
?disabled=${state.disabled || !state.dirty || !targetReady || Boolean(state.nativePolicy)}
|
||||
@click=${state.onSave}
|
||||
>
|
||||
${state.saving ? "Saving…" : "Save"}
|
||||
@@ -217,16 +227,59 @@ export function renderExecApprovals(state: ExecApprovalsState) {
|
||||
${state.loading ? t("common.loading") : t("common.loadApprovals")}
|
||||
</button>
|
||||
</div>`
|
||||
: 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)}
|
||||
`}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="list" style="margin-top: 16px;">
|
||||
<div class="list-item">
|
||||
<div class="list-main">
|
||||
<div class="list-title">Host-native policy</div>
|
||||
<div class="list-sub">Read-only here. Edit from the companion app or CLI.</div>
|
||||
</div>
|
||||
<div class="list-meta"><span class="badge">Native</span></div>
|
||||
</div>
|
||||
<div class="list-item">
|
||||
<div class="list-main">
|
||||
<div class="list-title">Default action</div>
|
||||
<div class="list-sub">${defaultAction}</div>
|
||||
</div>
|
||||
<div class="list-meta">${rules.length} ${rules.length === 1 ? "rule" : "rules"}</div>
|
||||
</div>
|
||||
${rules.map(
|
||||
(rule) => html`
|
||||
<div class="list-item">
|
||||
<div class="list-main">
|
||||
<div class="list-title">${rule.pattern}</div>
|
||||
<div class="list-sub">
|
||||
${rule.action} · ${rule.shells?.join(", ") || "all shells"} ·
|
||||
${rule.enabled === false ? "off" : "on"}
|
||||
</div>
|
||||
${rule.description
|
||||
? html`<div class="list-sub">${clampText(rule.description, 120)}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderExecApprovalsTarget(state: ExecApprovalsState) {
|
||||
const hasNodes = state.targetNodes.length > 0;
|
||||
const nodeValue = state.targetNodeId ?? "";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user