Skip to content

Commit a7faec8

Browse files
steipetevincentkoc
andauthored
fix(gateway): support native Windows exec approvals (#101669)
* fix(gateway): support native Windows exec approvals Co-authored-by: Vincent Koc <[email protected]> * chore: defer changelog entry to release * test: use tracked approvals temp directories --------- Co-authored-by: Vincent Koc <[email protected]>
1 parent 176fee5 commit a7faec8

16 files changed

Lines changed: 975 additions & 52 deletions

File tree

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7863,24 +7863,78 @@ public struct ExecApprovalsNodeGetParams: Codable, Sendable {
78637863
}
78647864
}
78657865

7866+
public struct ExecApprovalsNodeSnapshot: Codable, Sendable {
7867+
public let path: String?
7868+
public let exists: Bool?
7869+
public let hash: String?
7870+
public let file: [String: AnyCodable]?
7871+
public let enabled: Bool?
7872+
public let basehash: String?
7873+
public let defaultaction: AnyCodable?
7874+
public let rules: [[String: AnyCodable]]?
7875+
public let constraints: [String: AnyCodable]?
7876+
public let message: String?
7877+
7878+
public init(
7879+
path: String?,
7880+
exists: Bool?,
7881+
hash: String?,
7882+
file: [String: AnyCodable]?,
7883+
enabled: Bool?,
7884+
basehash: String?,
7885+
defaultaction: AnyCodable?,
7886+
rules: [[String: AnyCodable]]?,
7887+
constraints: [String: AnyCodable]?,
7888+
message: String?)
7889+
{
7890+
self.path = path
7891+
self.exists = exists
7892+
self.hash = hash
7893+
self.file = file
7894+
self.enabled = enabled
7895+
self.basehash = basehash
7896+
self.defaultaction = defaultaction
7897+
self.rules = rules
7898+
self.constraints = constraints
7899+
self.message = message
7900+
}
7901+
7902+
private enum CodingKeys: String, CodingKey {
7903+
case path
7904+
case exists
7905+
case hash
7906+
case file
7907+
case enabled
7908+
case basehash = "baseHash"
7909+
case defaultaction = "defaultAction"
7910+
case rules
7911+
case constraints
7912+
case message
7913+
}
7914+
}
7915+
78667916
public struct ExecApprovalsNodeSetParams: Codable, Sendable {
78677917
public let nodeid: String
7868-
public let file: [String: AnyCodable]
7918+
public let file: [String: AnyCodable]?
7919+
public let native: [String: AnyCodable]?
78697920
public let basehash: String?
78707921

78717922
public init(
78727923
nodeid: String,
7873-
file: [String: AnyCodable],
7924+
file: [String: AnyCodable]?,
7925+
native: [String: AnyCodable]?,
78747926
basehash: String?)
78757927
{
78767928
self.nodeid = nodeid
78777929
self.file = file
7930+
self.native = native
78787931
self.basehash = basehash
78797932
}
78807933

78817934
private enum CodingKeys: String, CodingKey {
78827935
case nodeid = "nodeId"
78837936
case file
7937+
case native
78847938
case basehash = "baseHash"
78857939
}
78867940
}

docs/cli/approvals.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ openclaw approvals get --node <id|name|ip>
4646
openclaw approvals get --gateway
4747
```
4848

49-
`get` shows the effective exec policy for the target: the requested `tools.exec` policy, the host approvals-file policy, and the merged effective result.
49+
`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.
5050

5151
Precedence:
5252

@@ -68,6 +68,19 @@ openclaw approvals set --gateway --file ./exec-approvals.json
6868

6969
`set` accepts JSON5, not only strict JSON. Use either `--file` or `--stdin`, not both.
7070

71+
Host-native Windows nodes use their own policy shape:
72+
73+
```bash
74+
openclaw approvals set --node <id|name|ip> --stdin <<'EOF'
75+
{
76+
defaultAction: "deny",
77+
rules: [{ pattern: "hostname", action: "allow" }]
78+
}
79+
EOF
80+
```
81+
82+
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.
83+
7184
## "Never prompt" / YOLO example
7285

7386
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'
8598
EOF
8699
```
87100

88-
Node variant: same body with `openclaw approvals set --node <id|name|ip> --stdin`.
101+
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.
89102

90103
This changes the **host approvals file** only. To keep the requested OpenClaw policy aligned, also set:
91104

@@ -129,7 +142,7 @@ No target flag means the local approvals file on disk.
129142

130143
## Notes
131144

132-
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
145+
- The node host must advertise `system.execApprovals.get/set` (macOS app, headless node host, or Windows companion).
133146
- 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.
134147

135148
## Related

docs/tools/exec-approvals.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,11 @@ Nodes must advertise `system.execApprovals.get/set` (macOS app or headless
435435
node host). If a node does not advertise exec approvals yet, edit its
436436
local approvals file directly.
437437

438+
Some node hosts, including the Windows companion, own a different approval
439+
policy format. Control UI shows these host-native policies read-only. Use the
440+
companion app or `openclaw approvals set --node <id|name|ip>` with the native
441+
policy shape to edit them; see [Approvals CLI](/cli/approvals).
442+
438443
CLI: `openclaw approvals` supports gateway or node editing - see
439444
[Approvals CLI](/cli/approvals).
440445

packages/gateway-protocol/src/exec-approvals-validators.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { describe, expect, it } from "vitest";
33
import {
44
validateExecApprovalRequestParams,
5+
validateExecApprovalsNodeSnapshot,
56
validateExecApprovalsNodeSetParams,
67
validateExecApprovalsSetParams,
78
} from "./index.js";
@@ -44,6 +45,78 @@ describe("exec approvals protocol validators", () => {
4445
).toBe(true);
4546
});
4647

48+
it("accepts the shipped Windows node approval contract", () => {
49+
expect(
50+
validateExecApprovalsNodeSnapshot({
51+
enabled: true,
52+
hash: "sha256:current",
53+
baseHash: "sha256:current",
54+
defaultAction: "deny",
55+
constraints: {
56+
baseHashRequired: true,
57+
defaultAllowAllowed: false,
58+
broadAllowRulesAllowed: false,
59+
dangerousAllowRulesAllowed: false,
60+
},
61+
rules: [{ pattern: "hostname", action: "allow", enabled: true }],
62+
}),
63+
).toBe(true);
64+
expect(
65+
validateExecApprovalsNodeSnapshot({ enabled: false, message: "No exec policy configured" }),
66+
).toBe(true);
67+
expect(
68+
validateExecApprovalsNodeSetParams({
69+
nodeId: "windows-node",
70+
native: { defaultAction: "deny", rules: [] },
71+
baseHash: "sha256:current",
72+
}),
73+
).toBe(true);
74+
});
75+
76+
it("rejects ambiguous or unsafe host-native approval payloads", () => {
77+
for (const snapshot of [
78+
{},
79+
{ hash: "sha256:current" },
80+
{ enabled: true, hash: "sha256:current", defaultAction: "deny" },
81+
{ enabled: true, hash: "sha256:current", defaultAction: "full", rules: [] },
82+
{
83+
path: "/tmp/exec-approvals.json",
84+
exists: true,
85+
hash: "sha256:file",
86+
file: { version: 1 },
87+
defaultAction: "deny",
88+
},
89+
{
90+
enabled: true,
91+
hash: "sha256:current",
92+
defaultAction: "deny",
93+
rules: [],
94+
message: "mixed state",
95+
},
96+
{ enabled: false, rules: [] },
97+
]) {
98+
expect(validateExecApprovalsNodeSnapshot(snapshot)).toBe(false);
99+
}
100+
101+
for (const params of [
102+
{ nodeId: "windows-node", native: {}, baseHash: "sha256:current" },
103+
{
104+
nodeId: "windows-node",
105+
native: { defaultAction: "deny" },
106+
baseHash: "sha256:current",
107+
},
108+
{ nodeId: "windows-node", native: { defaultAction: "full" }, baseHash: "sha256:current" },
109+
{ nodeId: "windows-node", native: { rules: [] } },
110+
{
111+
nodeId: "windows-node",
112+
native: { rules: [{ pattern: "", action: "allow" }] },
113+
baseHash: "sha256:current",
114+
},
115+
]) {
116+
expect(validateExecApprovalsNodeSetParams(params)).toBe(false);
117+
}
118+
});
119+
47120
it("rejects unknown allowlist metadata", () => {
48121
expect(
49122
validateExecApprovalsSetParams({

packages/gateway-protocol/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,8 @@ import {
227227
ExecApprovalsGetParamsSchema,
228228
type ExecApprovalsNodeGetParams,
229229
ExecApprovalsNodeGetParamsSchema,
230+
type ExecApprovalsNodeSnapshot,
231+
ExecApprovalsNodeSnapshotSchema,
230232
type ExecApprovalsNodeSetParams,
231233
ExecApprovalsNodeSetParamsSchema,
232234
type ExecApprovalsSetParams,
@@ -1053,6 +1055,9 @@ export const validateExecApprovalsNodeGetParams = lazyCompile<ExecApprovalsNodeG
10531055
export const validateExecApprovalsNodeSetParams = lazyCompile<ExecApprovalsNodeSetParams>(
10541056
ExecApprovalsNodeSetParamsSchema,
10551057
);
1058+
export const validateExecApprovalsNodeSnapshot = lazyCompile<ExecApprovalsNodeSnapshot>(
1059+
ExecApprovalsNodeSnapshotSchema,
1060+
);
10561061
export const validateLogsTailParams = lazyCompile<LogsTailParams>(LogsTailParamsSchema);
10571062
export const validateTerminalOpenParams = lazyCompile<TerminalOpenParams>(TerminalOpenParamsSchema);
10581063
export const validateTerminalInputParams =
@@ -1646,6 +1651,7 @@ export type {
16461651
CronRunsParams,
16471652
CronRunLogEntry,
16481653
ExecApprovalsGetParams,
1654+
ExecApprovalsNodeSnapshot,
16491655
ExecApprovalsSetParams,
16501656
ExecApprovalsSnapshot,
16511657
ExecApprovalGetParams,

packages/gateway-protocol/src/schema/exec-approvals.ts

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export const ExecApprovalsFileSchema = Type.Object(
6363
{ additionalProperties: false },
6464
);
6565

66-
/** Read snapshot with path/hash metadata for optimistic writes. */
66+
/** File-backed read snapshot with path/hash metadata for optimistic writes. */
6767
export const ExecApprovalsSnapshotSchema = Type.Object(
6868
{
6969
path: NonEmptyString,
@@ -74,6 +74,96 @@ export const ExecApprovalsSnapshotSchema = Type.Object(
7474
{ additionalProperties: false },
7575
);
7676

77+
const NativeExecApprovalActionSchema = Type.Union([
78+
Type.Literal("allow"),
79+
Type.Literal("deny"),
80+
Type.Literal("prompt"),
81+
]);
82+
83+
/** One rule owned and enforced by a host-native exec policy implementation. */
84+
const NativeExecApprovalRuleSchema = Type.Object(
85+
{
86+
pattern: NonEmptyString,
87+
action: NativeExecApprovalActionSchema,
88+
shells: Type.Optional(Type.Array(NonEmptyString)),
89+
description: Type.Optional(Type.String()),
90+
enabled: Type.Optional(Type.Boolean()),
91+
},
92+
{ additionalProperties: false },
93+
);
94+
95+
const NativeExecApprovalConstraintsSchema = Type.Object(
96+
{
97+
baseHashRequired: Type.Optional(Type.Boolean()),
98+
defaultAllowAllowed: Type.Optional(Type.Boolean()),
99+
broadAllowRulesAllowed: Type.Optional(Type.Boolean()),
100+
dangerousAllowRulesAllowed: Type.Optional(Type.Boolean()),
101+
},
102+
{ additionalProperties: false },
103+
);
104+
105+
/** Node read snapshot supporting file-backed and host-native approval owners. */
106+
export const ExecApprovalsNodeSnapshotSchema = Type.Object(
107+
{
108+
path: Type.Optional(Type.String()),
109+
exists: Type.Optional(Type.Boolean()),
110+
hash: Type.Optional(Type.String()),
111+
file: Type.Optional(ExecApprovalsFileSchema),
112+
enabled: Type.Optional(Type.Boolean()),
113+
baseHash: Type.Optional(NonEmptyString),
114+
defaultAction: Type.Optional(NativeExecApprovalActionSchema),
115+
rules: Type.Optional(Type.Array(NativeExecApprovalRuleSchema)),
116+
constraints: Type.Optional(NativeExecApprovalConstraintsSchema),
117+
message: Type.Optional(Type.String()),
118+
},
119+
{
120+
additionalProperties: false,
121+
oneOf: [
122+
{
123+
required: ["path", "exists", "hash", "file"],
124+
not: {
125+
anyOf: [
126+
{ required: ["enabled"] },
127+
{ required: ["baseHash"] },
128+
{ required: ["defaultAction"] },
129+
{ required: ["rules"] },
130+
{ required: ["constraints"] },
131+
{ required: ["message"] },
132+
],
133+
},
134+
},
135+
{
136+
properties: { enabled: { const: true }, hash: { minLength: 1 } },
137+
required: ["enabled", "hash", "defaultAction", "rules"],
138+
not: {
139+
anyOf: [
140+
{ required: ["path"] },
141+
{ required: ["exists"] },
142+
{ required: ["file"] },
143+
{ required: ["message"] },
144+
],
145+
},
146+
},
147+
{
148+
properties: { enabled: { const: false } },
149+
required: ["enabled"],
150+
not: {
151+
anyOf: [
152+
{ required: ["path"] },
153+
{ required: ["exists"] },
154+
{ required: ["hash"] },
155+
{ required: ["file"] },
156+
{ required: ["baseHash"] },
157+
{ required: ["defaultAction"] },
158+
{ required: ["rules"] },
159+
{ required: ["constraints"] },
160+
],
161+
},
162+
},
163+
],
164+
},
165+
);
166+
77167
/** Empty request payload for reading local exec approval policy. */
78168
export const ExecApprovalsGetParamsSchema = Type.Object({}, { additionalProperties: false });
79169

@@ -94,14 +184,34 @@ export const ExecApprovalsNodeGetParamsSchema = Type.Object(
94184
{ additionalProperties: false },
95185
);
96186

97-
/** Node-scoped exec approval policy write request with optional base hash guard. */
187+
/** Writable host-native policy fields; the node remains the validation authority. */
188+
const NativeExecApprovalPolicySchema = Type.Object(
189+
{
190+
defaultAction: Type.Optional(NativeExecApprovalActionSchema),
191+
// Windows treats set as full replacement; omission would silently clear the rule list.
192+
rules: Type.Array(NativeExecApprovalRuleSchema),
193+
},
194+
{ additionalProperties: false },
195+
);
196+
197+
/** Node-scoped write for exactly one file-backed or host-native approval owner. */
98198
export const ExecApprovalsNodeSetParamsSchema = Type.Object(
99199
{
100200
nodeId: NonEmptyString,
101-
file: ExecApprovalsFileSchema,
201+
file: Type.Optional(ExecApprovalsFileSchema),
202+
native: Type.Optional(NativeExecApprovalPolicySchema),
102203
baseHash: Type.Optional(NonEmptyString),
103204
},
104-
{ additionalProperties: false },
205+
{
206+
additionalProperties: false,
207+
oneOf: [
208+
{ required: ["file"], not: { required: ["native"] } },
209+
{
210+
required: ["native", "baseHash"],
211+
not: { required: ["file"] },
212+
},
213+
],
214+
},
105215
);
106216

107217
/** Lookup request for one pending exec approval by id. */

0 commit comments

Comments
 (0)