Skip to content

Commit d92d577

Browse files
authored
fix(node): report Mac exec policy defaults (#103945)
1 parent 4cb9cc6 commit d92d577

14 files changed

Lines changed: 460 additions & 14 deletions

File tree

apps/macos/Sources/OpenClaw/ExecApprovals.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ struct ExecApprovalsResolved: Sendable {
298298
var file: ExecApprovalsFile
299299
}
300300

301-
struct ExecApprovalsResolvedDefaults: Sendable {
301+
struct ExecApprovalsResolvedDefaults: Codable, Sendable {
302302
var security: ExecSecurity
303303
var ask: ExecAsk
304304
var askFallback: ExecSecurity

apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -699,13 +699,17 @@ enum ExecApprovalsStore {
699699
return self.resolveFromFile(file, agentId: agentId)
700700
}
701701

702-
private static func resolveFromFile(_ file: ExecApprovalsFile, agentId: String?) -> ExecApprovalsResolved {
702+
static func resolveDefaults(from file: ExecApprovalsFile) -> ExecApprovalsResolvedDefaults {
703703
let defaults = file.defaults ?? ExecApprovalsDefaults()
704-
let resolvedDefaults = ExecApprovalsResolvedDefaults(
704+
return ExecApprovalsResolvedDefaults(
705705
security: defaults.security ?? self.defaultSecurity,
706706
ask: defaults.ask ?? self.defaultAsk,
707707
askFallback: defaults.askFallback ?? self.defaultAskFallback,
708708
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
709+
}
710+
711+
private static func resolveFromFile(_ file: ExecApprovalsFile, agentId: String?) -> ExecApprovalsResolved {
712+
let resolvedDefaults = self.resolveDefaults(from: file)
709713
let key = self.agentKey(agentId)
710714
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
711715
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()

apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import OpenClawKit
66
actor MacNodeRuntime {
77
private static let maxGatewayPayloadBytes = 25 * 1024 * 1024
88
private static let maxScreenSnapshotRawBytesBeforeBase64 = (maxGatewayPayloadBytes / 4) * 3
9+
private struct ExecApprovalsNodeSnapshot: Encodable {
10+
let path: String
11+
let exists: Bool
12+
let hash: String
13+
let file: ExecApprovalsFile
14+
let resolvedDefaults: ExecApprovalsResolvedDefaults
15+
}
16+
917
private let cameraCapture = CameraCaptureService()
1018
private let makeMainActorServices: @Sendable () async -> any MacNodeRuntimeMainActorServices
1119
private let browserProxyRequest: @Sendable (String?) async throws -> String
@@ -1130,6 +1138,13 @@ extension MacNodeRuntime {
11301138
}
11311139

11321140
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
1141+
struct GetParams: Decodable {
1142+
var includeResolvedDefaults: Bool?
1143+
}
1144+
1145+
let params = try req.paramsJSON.map { json in
1146+
try Self.decodeParams(GetParams.self, from: json)
1147+
} ?? GetParams(includeResolvedDefaults: nil)
11331148
let snapshot: ExecApprovalsSnapshot
11341149
switch ExecApprovalsStore.ensureSnapshotResult() {
11351150
case let .success(current):
@@ -1140,11 +1155,21 @@ extension MacNodeRuntime {
11401155
code: .unavailable,
11411156
message: "UNAVAILABLE: exec approvals store unavailable; retry")
11421157
}
1143-
let redacted = ExecApprovalsSnapshot(
1158+
guard params.includeResolvedDefaults == true else {
1159+
let redacted = ExecApprovalsSnapshot(
1160+
path: snapshot.path,
1161+
exists: snapshot.exists,
1162+
hash: snapshot.hash,
1163+
file: ExecApprovalsStore.redactForSnapshot(snapshot.file))
1164+
let payload = try Self.encodePayload(redacted)
1165+
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
1166+
}
1167+
let redacted = ExecApprovalsNodeSnapshot(
11441168
path: snapshot.path,
11451169
exists: snapshot.exists,
11461170
hash: snapshot.hash,
1147-
file: ExecApprovalsStore.redactForSnapshot(snapshot.file))
1171+
file: ExecApprovalsStore.redactForSnapshot(snapshot.file),
1172+
resolvedDefaults: ExecApprovalsStore.resolveDefaults(from: snapshot.file))
11481173
let payload = try Self.encodePayload(redacted)
11491174
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
11501175
}

apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,69 @@ struct MacNodeRuntimeTests {
298298
#expect(await probe.capturedCommands().isEmpty)
299299
}
300300

301+
@Test func `exec approvals snapshot reports resolved host defaults`() async throws {
302+
let root = URL(
303+
fileURLWithPath: "/tmp/oc-appr-\(UUID().uuidString.prefix(8))",
304+
isDirectory: true)
305+
let home = root.appendingPathComponent("home", isDirectory: true)
306+
let stateDir = root.appendingPathComponent("state", isDirectory: true)
307+
defer { try? FileManager().removeItem(at: root) }
308+
309+
try await TestIsolation.withEnvValues([
310+
"OPENCLAW_HOME": home.path,
311+
"OPENCLAW_STATE_DIR": stateDir.path,
312+
]) {
313+
let runtime = MacNodeRuntime()
314+
let legacyResponse = await runtime.handleInvoke(
315+
BridgeInvokeRequest(
316+
id: "req-approvals-get-legacy",
317+
command: OpenClawSystemCommand.execApprovalsGet.rawValue))
318+
#expect(legacyResponse.ok)
319+
let legacyPayloadJSON = try #require(legacyResponse.payloadJSON)
320+
let legacyPayload = try #require(
321+
JSONSerialization.jsonObject(with: Data(legacyPayloadJSON.utf8)) as? [String: Any])
322+
#expect(legacyPayload["resolvedDefaults"] == nil)
323+
324+
let response = await runtime.handleInvoke(
325+
BridgeInvokeRequest(
326+
id: "req-approvals-get-resolved",
327+
command: OpenClawSystemCommand.execApprovalsGet.rawValue,
328+
paramsJSON: #"{"includeResolvedDefaults":true}"#))
329+
330+
#expect(response.ok)
331+
let payloadJSON = try #require(response.payloadJSON)
332+
struct Snapshot: Decodable {
333+
let resolvedDefaults: ExecApprovalsResolvedDefaults
334+
}
335+
let snapshot = try JSONDecoder().decode(Snapshot.self, from: Data(payloadJSON.utf8))
336+
#expect(snapshot.resolvedDefaults.security == .full)
337+
#expect(snapshot.resolvedDefaults.ask == .off)
338+
#expect(snapshot.resolvedDefaults.askFallback == .deny)
339+
#expect(snapshot.resolvedDefaults.autoAllowSkills == false)
340+
341+
try ExecApprovalsStore.updateDefaults { defaults in
342+
defaults.security = .deny
343+
defaults.ask = .onMiss
344+
defaults.askFallback = .deny
345+
defaults.autoAllowSkills = false
346+
}.get()
347+
let persistedResponse = await runtime.handleInvoke(
348+
BridgeInvokeRequest(
349+
id: "req-approvals-get-persisted",
350+
command: OpenClawSystemCommand.execApprovalsGet.rawValue,
351+
paramsJSON: #"{"includeResolvedDefaults":true}"#))
352+
#expect(persistedResponse.ok)
353+
let persistedPayloadJSON = try #require(persistedResponse.payloadJSON)
354+
let persistedSnapshot = try JSONDecoder().decode(
355+
Snapshot.self,
356+
from: Data(persistedPayloadJSON.utf8))
357+
#expect(persistedSnapshot.resolvedDefaults.security == .deny)
358+
#expect(persistedSnapshot.resolvedDefaults.ask == .onMiss)
359+
#expect(persistedSnapshot.resolvedDefaults.askFallback == .deny)
360+
#expect(persistedSnapshot.resolvedDefaults.autoAllowSkills == false)
361+
}
362+
}
363+
301364
@Test func `system run denied event preserves gateway run id`() async throws {
302365
let stateDir = FileManager().temporaryDirectory
303366
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8400,6 +8400,7 @@ public struct ExecApprovalsNodeSnapshot: Codable, Sendable {
84008400
public let exists: Bool?
84018401
public let hash: String?
84028402
public let file: [String: AnyCodable]?
8403+
public let resolveddefaults: [String: AnyCodable]?
84038404
public let enabled: Bool?
84048405
public let basehash: String?
84058406
public let defaultaction: AnyCodable?
@@ -8412,6 +8413,7 @@ public struct ExecApprovalsNodeSnapshot: Codable, Sendable {
84128413
exists: Bool? = nil,
84138414
hash: String? = nil,
84148415
file: [String: AnyCodable]? = nil,
8416+
resolveddefaults: [String: AnyCodable]? = nil,
84158417
enabled: Bool? = nil,
84168418
basehash: String? = nil,
84178419
defaultaction: AnyCodable? = nil,
@@ -8423,6 +8425,7 @@ public struct ExecApprovalsNodeSnapshot: Codable, Sendable {
84238425
self.exists = exists
84248426
self.hash = hash
84258427
self.file = file
8428+
self.resolveddefaults = resolveddefaults
84268429
self.enabled = enabled
84278430
self.basehash = basehash
84288431
self.defaultaction = defaultaction
@@ -8436,6 +8439,7 @@ public struct ExecApprovalsNodeSnapshot: Codable, Sendable {
84368439
case exists
84378440
case hash
84388441
case file
8442+
case resolveddefaults = "resolvedDefaults"
84398443
case enabled
84408444
case basehash = "baseHash"
84418445
case defaultaction = "defaultAction"

docs/cli/approvals.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ openclaw approvals get --gateway
4848

4949
`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

51+
For file-backed nodes, the merged view requires a host-resolved policy snapshot. Older nodes show the effective policy as unavailable instead of assuming the Gateway's requested policy also applies on the host.
52+
5153
Precedence:
5254

5355
- The host approvals file is the enforceable source of truth.

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,31 @@ describe("exec approvals protocol validators", () => {
7575
).toBe(true);
7676
});
7777

78+
it("accepts file-backed node snapshots with host-resolved defaults", () => {
79+
expect(
80+
validateExecApprovalsNodeSnapshot({
81+
path: "/tmp/exec-approvals.json",
82+
exists: true,
83+
hash: "sha256:file",
84+
file: { version: 1 },
85+
}),
86+
).toBe(true);
87+
expect(
88+
validateExecApprovalsNodeSnapshot({
89+
path: "/tmp/exec-approvals.json",
90+
exists: true,
91+
hash: "sha256:file",
92+
file: { version: 1 },
93+
resolvedDefaults: {
94+
security: "deny",
95+
ask: "on-miss",
96+
askFallback: "deny",
97+
autoAllowSkills: false,
98+
},
99+
}),
100+
).toBe(true);
101+
});
102+
78103
it("rejects ambiguous or unsafe host-native approval payloads", () => {
79104
for (const snapshot of [
80105
{},
@@ -88,13 +113,47 @@ describe("exec approvals protocol validators", () => {
88113
file: { version: 1 },
89114
defaultAction: "deny",
90115
},
116+
{
117+
path: "/tmp/exec-approvals.json",
118+
exists: true,
119+
hash: "sha256:file",
120+
file: { version: 1 },
121+
resolvedDefaults: {
122+
security: "full",
123+
ask: "sometimes",
124+
askFallback: "deny",
125+
autoAllowSkills: false,
126+
},
127+
},
91128
{
92129
enabled: true,
93130
hash: "sha256:current",
94131
defaultAction: "deny",
95132
rules: [],
96133
message: "mixed state",
97134
},
135+
{
136+
enabled: true,
137+
hash: "sha256:current",
138+
defaultAction: "deny",
139+
rules: [],
140+
resolvedDefaults: {
141+
security: "deny",
142+
ask: "on-miss",
143+
askFallback: "deny",
144+
autoAllowSkills: false,
145+
},
146+
},
147+
{
148+
enabled: false,
149+
message: "No exec policy configured",
150+
resolvedDefaults: {
151+
security: "deny",
152+
ask: "on-miss",
153+
askFallback: "deny",
154+
autoAllowSkills: false,
155+
},
156+
},
98157
{ enabled: false, rules: [] },
99158
]) {
100159
expect(validateExecApprovalsNodeSnapshot(snapshot)).toBe(false);

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,28 @@ const ExecApprovalsPolicyFields = {
3030
autoAllowSkills: Type.Optional(Type.Boolean()),
3131
};
3232

33+
const ExecSecuritySchema = Type.Union([
34+
Type.Literal("deny"),
35+
Type.Literal("allowlist"),
36+
Type.Literal("full"),
37+
]);
38+
const ExecAskSchema = Type.Union([
39+
Type.Literal("off"),
40+
Type.Literal("on-miss"),
41+
Type.Literal("always"),
42+
]);
43+
44+
/** Host-resolved default policy after applying persisted defaults and runtime fallbacks. */
45+
const ExecApprovalsResolvedDefaultsSchema = Type.Object(
46+
{
47+
security: ExecSecuritySchema,
48+
ask: ExecAskSchema,
49+
askFallback: ExecSecuritySchema,
50+
autoAllowSkills: Type.Boolean(),
51+
},
52+
{ additionalProperties: false },
53+
);
54+
3355
/** Default exec approval policy shared by all agents unless overridden. */
3456
export const ExecApprovalsDefaultsSchema = Type.Object(ExecApprovalsPolicyFields, {
3557
additionalProperties: false,
@@ -109,6 +131,7 @@ export const ExecApprovalsNodeSnapshotSchema = Type.Object(
109131
exists: Type.Optional(Type.Boolean()),
110132
hash: Type.Optional(Type.String()),
111133
file: Type.Optional(ExecApprovalsFileSchema),
134+
resolvedDefaults: Type.Optional(ExecApprovalsResolvedDefaultsSchema),
112135
enabled: Type.Optional(Type.Boolean()),
113136
baseHash: Type.Optional(NonEmptyString),
114137
defaultAction: Type.Optional(NativeExecApprovalActionSchema),
@@ -140,6 +163,7 @@ export const ExecApprovalsNodeSnapshotSchema = Type.Object(
140163
{ required: ["path"] },
141164
{ required: ["exists"] },
142165
{ required: ["file"] },
166+
{ required: ["resolvedDefaults"] },
143167
{ required: ["message"] },
144168
],
145169
},
@@ -153,6 +177,7 @@ export const ExecApprovalsNodeSnapshotSchema = Type.Object(
153177
{ required: ["exists"] },
154178
{ required: ["hash"] },
155179
{ required: ["file"] },
180+
{ required: ["resolvedDefaults"] },
156181
{ required: ["baseHash"] },
157182
{ required: ["defaultAction"] },
158183
{ required: ["rules"] },

0 commit comments

Comments
 (0)