Skip to content

Commit 3686bde

Browse files
committed
feat: add exec approvals tooling and service status
1 parent 9c06689 commit 3686bde

39 files changed

Lines changed: 1472 additions & 35 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
Docs: https://docs.clawd.bot
44

5+
## 2026.1.18-5
6+
7+
### Changes
8+
- Exec approvals: add `clawdbot approvals` CLI for viewing and updating gateway/node allowlists.
9+
- CLI: add `clawdbot service` gateway/node management and a `clawdbot node status` alias.
10+
- Status: show gateway + node service summaries in `clawdbot status` and `status --all`.
11+
- Control UI: add gateway/node target selector for exec approvals.
12+
- Docs: add approvals/service references and refresh node/control UI docs.
13+
514
## 2026.1.18-4
615

716
### Changes

apps/macos/Sources/Clawdbot/ExecApprovals.swift

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import CryptoKit
12
import Foundation
23
import OSLog
34
import Security
@@ -121,6 +122,13 @@ struct ExecApprovalsFile: Codable {
121122
var agents: [String: ExecApprovalsAgent]?
122123
}
123124

125+
struct ExecApprovalsSnapshot: Codable {
126+
var path: String
127+
var exists: Bool
128+
var hash: String
129+
var file: ExecApprovalsFile
130+
}
131+
124132
struct ExecApprovalsResolved {
125133
let url: URL
126134
let socketPath: String
@@ -153,6 +161,58 @@ enum ExecApprovalsStore {
153161
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
154162
}
155163

164+
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
165+
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
166+
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
167+
return ExecApprovalsFile(
168+
version: 1,
169+
socket: ExecApprovalsSocketConfig(
170+
path: socketPath.isEmpty ? nil : socketPath,
171+
token: token.isEmpty ? nil : token),
172+
defaults: file.defaults,
173+
agents: file.agents)
174+
}
175+
176+
static func readSnapshot() -> ExecApprovalsSnapshot {
177+
let url = self.fileURL()
178+
guard FileManager.default.fileExists(atPath: url.path) else {
179+
return ExecApprovalsSnapshot(
180+
path: url.path,
181+
exists: false,
182+
hash: self.hashRaw(nil),
183+
file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]))
184+
}
185+
let raw = try? String(contentsOf: url, encoding: .utf8)
186+
let data = raw.flatMap { $0.data(using: .utf8) }
187+
let decoded: ExecApprovalsFile = {
188+
if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 {
189+
return file
190+
}
191+
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
192+
}()
193+
return ExecApprovalsSnapshot(
194+
path: url.path,
195+
exists: true,
196+
hash: self.hashRaw(raw),
197+
file: decoded)
198+
}
199+
200+
static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
201+
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
202+
if socketPath.isEmpty {
203+
return ExecApprovalsFile(
204+
version: file.version,
205+
socket: nil,
206+
defaults: file.defaults,
207+
agents: file.agents)
208+
}
209+
return ExecApprovalsFile(
210+
version: file.version,
211+
socket: ExecApprovalsSocketConfig(path: socketPath, token: nil),
212+
defaults: file.defaults,
213+
agents: file.agents)
214+
}
215+
156216
static func loadFile() -> ExecApprovalsFile {
157217
let url = self.fileURL()
158218
guard FileManager.default.fileExists(atPath: url.path) else {
@@ -372,6 +432,12 @@ enum ExecApprovalsStore {
372432
return UUID().uuidString
373433
}
374434

435+
private static func hashRaw(_ raw: String?) -> String {
436+
let data = Data((raw ?? "").utf8)
437+
let digest = SHA256.hash(data: data)
438+
return digest.map { String(format: "%02x", $0) }.joined()
439+
}
440+
375441
private static func expandPath(_ raw: String) -> String {
376442
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
377443
if trimmed == "~" {

apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ final class MacNodeModeCoordinator {
158158
ClawdbotSystemCommand.notify.rawValue,
159159
ClawdbotSystemCommand.which.rawValue,
160160
ClawdbotSystemCommand.run.rawValue,
161+
ClawdbotSystemCommand.execApprovalsGet.rawValue,
162+
ClawdbotSystemCommand.execApprovalsSet.rawValue,
161163
]
162164

163165
let capsSet = Set(caps)

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ actor MacNodeRuntime {
6464
return try await self.handleSystemWhich(req)
6565
case ClawdbotSystemCommand.notify.rawValue:
6666
return try await self.handleSystemNotify(req)
67+
case ClawdbotSystemCommand.execApprovalsGet.rawValue:
68+
return try await self.handleSystemExecApprovalsGet(req)
69+
case ClawdbotSystemCommand.execApprovalsSet.rawValue:
70+
return try await self.handleSystemExecApprovalsSet(req)
6771
default:
6872
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
6973
}
@@ -676,6 +680,72 @@ actor MacNodeRuntime {
676680
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
677681
}
678682

683+
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
684+
_ = ExecApprovalsStore.ensureFile()
685+
let snapshot = ExecApprovalsStore.readSnapshot()
686+
let redacted = ExecApprovalsSnapshot(
687+
path: snapshot.path,
688+
exists: snapshot.exists,
689+
hash: snapshot.hash,
690+
file: ExecApprovalsStore.redactForSnapshot(snapshot.file))
691+
let payload = try Self.encodePayload(redacted)
692+
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
693+
}
694+
695+
private func handleSystemExecApprovalsSet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
696+
struct SetParams: Decodable {
697+
var file: ExecApprovalsFile
698+
var baseHash: String?
699+
}
700+
701+
let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON)
702+
let current = ExecApprovalsStore.ensureFile()
703+
let snapshot = ExecApprovalsStore.readSnapshot()
704+
if snapshot.exists {
705+
if snapshot.hash.isEmpty {
706+
return Self.errorResponse(
707+
req,
708+
code: .invalidRequest,
709+
message: "INVALID_REQUEST: exec approvals base hash unavailable; reload and retry")
710+
}
711+
let baseHash = params.baseHash?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
712+
if baseHash.isEmpty {
713+
return Self.errorResponse(
714+
req,
715+
code: .invalidRequest,
716+
message: "INVALID_REQUEST: exec approvals base hash required; reload and retry")
717+
}
718+
if baseHash != snapshot.hash {
719+
return Self.errorResponse(
720+
req,
721+
code: .invalidRequest,
722+
message: "INVALID_REQUEST: exec approvals changed; reload and retry")
723+
}
724+
}
725+
726+
var normalized = ExecApprovalsStore.normalizeIncoming(params.file)
727+
let socketPath = normalized.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines)
728+
let token = normalized.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines)
729+
let resolvedPath = (socketPath?.isEmpty == false)
730+
? socketPath!
731+
: current.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ??
732+
ExecApprovalsStore.socketPath()
733+
let resolvedToken = (token?.isEmpty == false)
734+
? token!
735+
: current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
736+
normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken)
737+
738+
ExecApprovalsStore.saveFile(normalized)
739+
let nextSnapshot = ExecApprovalsStore.readSnapshot()
740+
let redacted = ExecApprovalsSnapshot(
741+
path: nextSnapshot.path,
742+
exists: nextSnapshot.exists,
743+
hash: nextSnapshot.hash,
744+
file: ExecApprovalsStore.redactForSnapshot(nextSnapshot.file))
745+
let payload = try Self.encodePayload(redacted)
746+
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
747+
}
748+
679749
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
680750
guard let sender = self.eventSender else { return }
681751
guard let data = try? JSONEncoder().encode(payload),

apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ public enum ClawdbotSystemCommand: String, Codable, Sendable {
44
case run = "system.run"
55
case which = "system.which"
66
case notify = "system.notify"
7+
case execApprovalsGet = "system.execApprovals.get"
8+
case execApprovalsSet = "system.execApprovals.set"
79
}
810

911
public enum ClawdbotNotificationPriority: String, Codable, Sendable {

docs/cli/approvals.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
summary: "CLI reference for `clawdbot approvals` (exec approvals for gateway or node hosts)"
3+
read_when:
4+
- You want to edit exec approvals from the CLI
5+
- You need to manage allowlists on gateway or node hosts
6+
---
7+
8+
# `clawdbot approvals`
9+
10+
Manage exec approvals for the **gateway host** or a **node host**.
11+
By default, commands target the gateway. Use `--node` to edit a node’s approvals.
12+
13+
Related:
14+
- Exec approvals: [Exec approvals](/tools/exec-approvals)
15+
- Nodes: [Nodes](/nodes)
16+
17+
## Common commands
18+
19+
```bash
20+
clawdbot approvals get
21+
clawdbot approvals get --node <id|name|ip>
22+
```
23+
24+
## Replace approvals from a file
25+
26+
```bash
27+
clawdbot approvals set --file ./exec-approvals.json
28+
clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
29+
```
30+
31+
## Allowlist helpers
32+
33+
```bash
34+
clawdbot approvals allowlist add "~/Projects/**/bin/rg"
35+
clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"
36+
37+
clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
38+
```
39+
40+
## Notes
41+
42+
- `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix).
43+
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
44+
- Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`.

docs/cli/daemon.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ read_when:
99

1010
Manage the Gateway daemon (background service).
1111

12+
Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains
13+
as a legacy alias for compatibility.
14+
1215
Related:
1316
- Gateway CLI: [Gateway](/cli/gateway)
1417
- macOS platform notes: [macOS](/platforms/macos)

docs/cli/index.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ This page describes the current CLI behavior. If commands change, update this do
2929
- [`sessions`](/cli/sessions)
3030
- [`gateway`](/cli/gateway)
3131
- [`daemon`](/cli/daemon)
32+
- [`service`](/cli/service)
3233
- [`logs`](/cli/logs)
3334
- [`models`](/cli/models)
3435
- [`memory`](/cli/memory)
3536
- [`nodes`](/cli/nodes)
3637
- [`node`](/cli/node)
38+
- [`approvals`](/cli/approvals)
3739
- [`sandbox`](/cli/sandbox)
3840
- [`tui`](/cli/tui)
3941
- [`browser`](/cli/browser)
@@ -143,6 +145,21 @@ clawdbot [--dev] [--profile <name>] <command>
143145
start
144146
stop
145147
restart
148+
service
149+
gateway
150+
status
151+
install
152+
uninstall
153+
start
154+
stop
155+
restart
156+
node
157+
status
158+
install
159+
uninstall
160+
start
161+
stop
162+
restart
146163
logs
147164
models
148165
list
@@ -180,6 +197,10 @@ clawdbot [--dev] [--profile <name>] <command>
180197
start
181198
stop
182199
restart
200+
approvals
201+
get
202+
set
203+
allowlist add|remove
183204
browser
184205
status
185206
start
@@ -520,6 +541,9 @@ Options:
520541
- `--verbose`
521542
- `--debug` (alias for `--verbose`)
522543

544+
Notes:
545+
- Overview includes Gateway + Node service status when available.
546+
523547
### Usage tracking
524548
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
525549

docs/cli/node.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ Install a headless node host as a user service.
4343

4444
```bash
4545
clawdbot node daemon install --host <gateway-host> --port 18790
46+
# or
47+
clawdbot service node install --host <gateway-host> --port 18790
4648
```
4749

4850
Options:
@@ -58,6 +60,8 @@ Options:
5860
Manage the service:
5961

6062
```bash
63+
clawdbot node status
64+
clawdbot service node status
6165
clawdbot node daemon status
6266
clawdbot node daemon start
6367
clawdbot node daemon stop
@@ -83,3 +87,4 @@ The node host stores its node id + token in `~/.clawdbot/node.json`.
8387

8488
- `~/.clawdbot/exec-approvals.json`
8589
- [Exec approvals](/tools/exec-approvals)
90+
- `clawdbot approvals --node <id|name|ip>` (edit from the Gateway)

docs/cli/service.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
summary: "CLI reference for `clawdbot service` (manage gateway + node services)"
3+
read_when:
4+
- You want to manage Gateway or node services cross-platform
5+
- You want a single surface for start/stop/install/uninstall
6+
---
7+
8+
# `clawdbot service`
9+
10+
Manage the **Gateway** service and **node host** services.
11+
12+
Related:
13+
- Gateway daemon (legacy alias): [Daemon](/cli/daemon)
14+
- Node host: [Node](/cli/node)
15+
16+
## Gateway service
17+
18+
```bash
19+
clawdbot service gateway status
20+
clawdbot service gateway install --port 18789
21+
clawdbot service gateway start
22+
clawdbot service gateway stop
23+
clawdbot service gateway restart
24+
clawdbot service gateway uninstall
25+
```
26+
27+
Notes:
28+
- `service gateway status` supports `--json` and `--deep` for system checks.
29+
- `service gateway install` supports `--runtime node|bun` and `--token`.
30+
31+
## Node host service
32+
33+
```bash
34+
clawdbot service node status
35+
clawdbot service node install --host <gateway-host> --port 18790
36+
clawdbot service node start
37+
clawdbot service node stop
38+
clawdbot service node restart
39+
clawdbot service node uninstall
40+
```
41+
42+
Notes:
43+
- `service node install` supports `--runtime node|bun`, `--node-id`, `--display-name`,
44+
and TLS options (`--tls`, `--tls-fingerprint`).
45+
46+
## Aliases
47+
48+
- `clawdbot daemon …``clawdbot service gateway …`
49+
- `clawdbot node daemon …``clawdbot service node …`
50+
- `clawdbot node status``clawdbot service node status`

0 commit comments

Comments
 (0)