openclaw-safeclaw-plugin 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +214 -41
- package/SKILL.md +32 -31
- package/cli.tsx +8 -1
- package/dist/cli.js +8 -1
- package/dist/index.d.ts +2 -16
- package/dist/index.js +228 -58
- package/dist/tui/Status.js +2 -1
- package/dist/tui/config.d.ts +2 -0
- package/dist/tui/config.js +24 -1
- package/index.ts +243 -83
- package/openclaw.plugin.json +29 -4
- package/package.json +11 -2
- package/policies/safeclaw.yaml +14 -0
- package/tui/Status.tsx +9 -1
- package/tui/config.ts +27 -1
package/dist/index.js
CHANGED
|
@@ -15,13 +15,21 @@ const { version: PLUGIN_VERSION } = require('./package.json');
|
|
|
15
15
|
const CONFIG_RELOAD_INTERVAL_MS = 60_000; // Reload config every 60 seconds
|
|
16
16
|
let config = loadConfig();
|
|
17
17
|
let configLoadedAt = Date.now();
|
|
18
|
+
// OpenClaw plugin config — merged on top of file config when available
|
|
19
|
+
let _ocPluginConfig = {};
|
|
20
|
+
// Logger — defaults to console, replaced by api.logger when available
|
|
21
|
+
let log = console;
|
|
18
22
|
function getConfig() {
|
|
19
23
|
const now = Date.now();
|
|
20
24
|
if (now - configLoadedAt >= CONFIG_RELOAD_INTERVAL_MS) {
|
|
21
25
|
config = loadConfig();
|
|
22
26
|
configLoadedAt = now;
|
|
23
27
|
}
|
|
24
|
-
|
|
28
|
+
// OpenClaw config takes priority over file config
|
|
29
|
+
return {
|
|
30
|
+
...config,
|
|
31
|
+
...Object.fromEntries(Object.entries(_ocPluginConfig).filter(([_, v]) => v != null)),
|
|
32
|
+
};
|
|
25
33
|
}
|
|
26
34
|
// --- HTTP Client ---
|
|
27
35
|
async function post(path, body) {
|
|
@@ -47,10 +55,10 @@ async function post(path, body) {
|
|
|
47
55
|
const rawDetail = errBody.detail ?? `HTTP ${res.status}`;
|
|
48
56
|
const detail = typeof rawDetail === 'string' ? rawDetail : JSON.stringify(rawDetail);
|
|
49
57
|
const hint = errBody.hint ? ` (${errBody.hint})` : '';
|
|
50
|
-
|
|
58
|
+
log.warn(`[SafeClaw] ${path}: ${detail}${hint}`);
|
|
51
59
|
}
|
|
52
60
|
catch {
|
|
53
|
-
|
|
61
|
+
log.warn(`[SafeClaw] HTTP ${res.status} from ${path}`);
|
|
54
62
|
}
|
|
55
63
|
return null; // Caller checks failMode
|
|
56
64
|
}
|
|
@@ -58,22 +66,43 @@ async function post(path, body) {
|
|
|
58
66
|
}
|
|
59
67
|
catch (e) {
|
|
60
68
|
if (e instanceof DOMException && e.name === 'TimeoutError') {
|
|
61
|
-
|
|
69
|
+
log.warn(`[SafeClaw] Timeout after ${cfg.timeoutMs}ms on ${path} (${cfg.serviceUrl})`);
|
|
62
70
|
}
|
|
63
71
|
else if (e instanceof TypeError && (e.message.includes('fetch') || e.message.includes('ECONNREFUSED'))) {
|
|
64
|
-
|
|
72
|
+
log.warn(`[SafeClaw] Connection refused: ${cfg.serviceUrl}${path} — is the service running?`);
|
|
65
73
|
}
|
|
66
74
|
else {
|
|
67
|
-
|
|
75
|
+
log.warn(`[SafeClaw] Service unavailable: ${cfg.serviceUrl}${path}`);
|
|
68
76
|
}
|
|
69
77
|
return null; // Caller checks failMode
|
|
70
78
|
}
|
|
71
79
|
}
|
|
80
|
+
async function get(path) {
|
|
81
|
+
const cfg = getConfig();
|
|
82
|
+
if (!cfg.enabled)
|
|
83
|
+
return null;
|
|
84
|
+
const headers = {};
|
|
85
|
+
if (cfg.apiKey)
|
|
86
|
+
headers['Authorization'] = `Bearer ${cfg.apiKey}`;
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch(`${cfg.serviceUrl}${path}`, {
|
|
89
|
+
signal: AbortSignal.timeout(cfg.timeoutMs),
|
|
90
|
+
headers,
|
|
91
|
+
});
|
|
92
|
+
if (!res.ok)
|
|
93
|
+
return null;
|
|
94
|
+
return await res.json();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// --- Plugin Definition ---
|
|
72
101
|
let handshakeCompleted = false;
|
|
73
102
|
async function performHandshake() {
|
|
74
103
|
const cfg = getConfig();
|
|
75
104
|
if (!cfg.apiKey) {
|
|
76
|
-
|
|
105
|
+
log.warn('[SafeClaw] No API key configured — skipping handshake');
|
|
77
106
|
return false;
|
|
78
107
|
}
|
|
79
108
|
const r = await post('/handshake', {
|
|
@@ -81,37 +110,37 @@ async function performHandshake() {
|
|
|
81
110
|
configHash: configHash(cfg),
|
|
82
111
|
});
|
|
83
112
|
if (r === null) {
|
|
84
|
-
|
|
113
|
+
log.warn('[SafeClaw] Handshake failed — API key may be invalid or service unreachable');
|
|
85
114
|
return false;
|
|
86
115
|
}
|
|
87
|
-
|
|
116
|
+
log.info(`[SafeClaw] Handshake OK — org=${r.orgId}, scope=${r.scope}, engine=${r.engineReady ? 'ready' : 'not ready'}`);
|
|
88
117
|
handshakeCompleted = true;
|
|
89
118
|
return true;
|
|
90
119
|
}
|
|
91
120
|
async function checkConnection() {
|
|
92
121
|
const cfg = getConfig();
|
|
93
122
|
const label = `[SafeClaw]`;
|
|
94
|
-
|
|
95
|
-
|
|
123
|
+
log.info(`${label} Connecting to ${cfg.serviceUrl} ...`);
|
|
124
|
+
log.info(`${label} Mode: enforcement=${cfg.enforcement}, failMode=${cfg.failMode}`);
|
|
96
125
|
try {
|
|
97
126
|
const res = await fetch(`${cfg.serviceUrl}/health`, {
|
|
98
127
|
signal: AbortSignal.timeout(cfg.timeoutMs * 2),
|
|
99
128
|
});
|
|
100
129
|
if (res.ok) {
|
|
101
130
|
const data = await res.json();
|
|
102
|
-
|
|
131
|
+
log.info(`${label} Connected — service ${data.status ?? 'ok'}`);
|
|
103
132
|
}
|
|
104
133
|
else {
|
|
105
|
-
|
|
134
|
+
log.warn(`${label} Service responded with HTTP ${res.status}`);
|
|
106
135
|
}
|
|
107
136
|
}
|
|
108
137
|
catch {
|
|
109
|
-
|
|
138
|
+
log.warn(`${label} Cannot reach service at ${cfg.serviceUrl}`);
|
|
110
139
|
if (cfg.failMode === 'closed') {
|
|
111
|
-
|
|
140
|
+
log.warn(`${label} fail-mode=closed — tool calls will be BLOCKED until service is reachable`);
|
|
112
141
|
}
|
|
113
142
|
else {
|
|
114
|
-
|
|
143
|
+
log.warn(`${label} fail-mode=open — tool calls will be ALLOWED despite no connection`);
|
|
115
144
|
}
|
|
116
145
|
}
|
|
117
146
|
}
|
|
@@ -124,6 +153,8 @@ export default {
|
|
|
124
153
|
console.log('[SafeClaw] Plugin disabled');
|
|
125
154
|
return;
|
|
126
155
|
}
|
|
156
|
+
log = api.logger ?? console;
|
|
157
|
+
_ocPluginConfig = api.pluginConfig ?? {};
|
|
127
158
|
// Generate a unique instance ID for this plugin run (fallback when agentId is not configured)
|
|
128
159
|
const instanceId = getConfig().agentId || `instance-${crypto.randomUUID()}`;
|
|
129
160
|
// Heartbeat watchdog — send config hash to service every 30s
|
|
@@ -139,20 +170,8 @@ export default {
|
|
|
139
170
|
// Heartbeat failure is non-fatal
|
|
140
171
|
}
|
|
141
172
|
};
|
|
142
|
-
//
|
|
173
|
+
// Clean shutdown: send shutdown heartbeat and clear interval (#194)
|
|
143
174
|
let heartbeatInterval;
|
|
144
|
-
checkConnection()
|
|
145
|
-
.then(() => performHandshake())
|
|
146
|
-
.then((ok) => {
|
|
147
|
-
if (!ok && getConfig().failMode === 'closed') {
|
|
148
|
-
console.warn('[SafeClaw] Handshake failed with fail-mode=closed — tool calls will be BLOCKED');
|
|
149
|
-
}
|
|
150
|
-
heartbeatInterval = setInterval(sendHeartbeat, 30000);
|
|
151
|
-
return sendHeartbeat();
|
|
152
|
-
})
|
|
153
|
-
.catch(() => { });
|
|
154
|
-
// Clean shutdown: send shutdown heartbeat and clear interval
|
|
155
|
-
// Use async shutdown for SIGINT/SIGTERM where async is supported (#55)
|
|
156
175
|
const shutdown = async () => {
|
|
157
176
|
if (heartbeatInterval)
|
|
158
177
|
clearInterval(heartbeatInterval);
|
|
@@ -167,29 +186,56 @@ export default {
|
|
|
167
186
|
// Best-effort shutdown notification
|
|
168
187
|
}
|
|
169
188
|
};
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
189
|
+
// Start heartbeat after connection check + handshake completes (#84)
|
|
190
|
+
const startupPromise = checkConnection()
|
|
191
|
+
.then(() => performHandshake())
|
|
192
|
+
.then((ok) => {
|
|
193
|
+
if (!ok && getConfig().failMode === 'closed') {
|
|
194
|
+
log.warn('[SafeClaw] Handshake failed with fail-mode=closed — tool calls will be BLOCKED');
|
|
195
|
+
}
|
|
196
|
+
heartbeatInterval = setInterval(sendHeartbeat, 30000);
|
|
197
|
+
return sendHeartbeat();
|
|
198
|
+
})
|
|
199
|
+
.catch(() => { });
|
|
200
|
+
// Register as an OpenClaw service so the gateway manages our lifecycle.
|
|
201
|
+
// The service's stop() method sends the shutdown heartbeat — no process.exit()
|
|
202
|
+
// needed, which avoids killing the entire gateway process (#194).
|
|
203
|
+
if (api.registerService) {
|
|
204
|
+
api.registerService({
|
|
205
|
+
id: 'safeclaw-governance',
|
|
206
|
+
start() { },
|
|
207
|
+
async stop() {
|
|
208
|
+
await startupPromise; // Ensure startup finished before tearing down
|
|
209
|
+
await shutdown();
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
// Fallback for older OpenClaw versions without registerService:
|
|
215
|
+
// No process.exit() — just clean up on beforeExit
|
|
216
|
+
process.on('beforeExit', () => { shutdown().catch(() => { }); });
|
|
217
|
+
}
|
|
218
|
+
// THE GATE — constraint checking on every tool call (#195: use correct OpenClaw field names)
|
|
173
219
|
api.on('before_tool_call', async (event, ctx) => {
|
|
174
220
|
const cfg = getConfig();
|
|
175
221
|
if (!handshakeCompleted && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
|
|
176
222
|
return { block: true, blockReason: 'SafeClaw handshake not completed (fail-closed)' };
|
|
177
223
|
}
|
|
178
224
|
const r = await post('/evaluate/tool-call', {
|
|
179
|
-
sessionId: ctx.sessionId ?? event.sessionId,
|
|
180
|
-
userId: ctx.
|
|
181
|
-
toolName: event.toolName ??
|
|
225
|
+
sessionId: ctx.sessionId ?? event.sessionId ?? '',
|
|
226
|
+
userId: ctx.agentId ?? '',
|
|
227
|
+
toolName: event.toolName ?? '',
|
|
182
228
|
params: event.params ?? {},
|
|
183
|
-
|
|
229
|
+
runId: ctx.runId ?? '',
|
|
184
230
|
});
|
|
185
231
|
if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
|
|
186
232
|
return { block: true, blockReason: `SafeClaw service unavailable at ${cfg.serviceUrl} (fail-closed)` };
|
|
187
233
|
}
|
|
188
234
|
else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
|
|
189
|
-
|
|
235
|
+
log.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, warn-only)`);
|
|
190
236
|
}
|
|
191
237
|
else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
|
|
192
|
-
|
|
238
|
+
log.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, audit-only)`);
|
|
193
239
|
}
|
|
194
240
|
if (r?.block) {
|
|
195
241
|
const blockReason = r.reason || 'Blocked by SafeClaw (no reason provided)';
|
|
@@ -197,71 +243,195 @@ export default {
|
|
|
197
243
|
return { block: true, blockReason };
|
|
198
244
|
}
|
|
199
245
|
if (cfg.enforcement === 'warn-only') {
|
|
200
|
-
|
|
246
|
+
log.warn(`[SafeClaw] Warning: ${blockReason}`);
|
|
201
247
|
}
|
|
202
248
|
// audit-only: logged server-side, no action here
|
|
203
249
|
}
|
|
204
250
|
}, { priority: 100 });
|
|
205
251
|
// Context injection — prepend governance context to agent system prompt
|
|
206
|
-
|
|
252
|
+
// (#195: before_agent_start is deprecated; use before_prompt_build + prependSystemContext)
|
|
253
|
+
api.on('before_prompt_build', async (event, ctx) => {
|
|
207
254
|
const r = await post('/context/build', {
|
|
208
|
-
sessionId: ctx.sessionId ?? event.sessionId,
|
|
209
|
-
userId: ctx.
|
|
255
|
+
sessionId: ctx.sessionId ?? event.sessionId ?? '',
|
|
256
|
+
userId: ctx.agentId ?? '',
|
|
210
257
|
});
|
|
211
258
|
if (r?.prependContext) {
|
|
212
|
-
return {
|
|
259
|
+
return { prependSystemContext: r.prependContext };
|
|
213
260
|
}
|
|
214
261
|
}, { priority: 100 });
|
|
215
262
|
// Message governance — check outbound messages
|
|
263
|
+
// (#195: use ctx.conversationId/sessionId, ctx.accountId; return only { cancel: true })
|
|
216
264
|
api.on('message_sending', async (event, ctx) => {
|
|
217
265
|
const cfg = getConfig();
|
|
218
266
|
const r = await post('/evaluate/message', {
|
|
219
|
-
sessionId: ctx.sessionId ?? event.sessionId,
|
|
220
|
-
userId: ctx.
|
|
267
|
+
sessionId: ctx.conversationId ?? ctx.sessionId ?? event.sessionId ?? '',
|
|
268
|
+
userId: ctx.accountId ?? '',
|
|
221
269
|
to: event.to,
|
|
222
270
|
content: event.content,
|
|
271
|
+
channelId: ctx.channelId ?? '',
|
|
223
272
|
});
|
|
224
273
|
if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
|
|
225
|
-
|
|
274
|
+
log.warn('[SafeClaw] Blocking message: service unavailable (fail-closed mode)');
|
|
275
|
+
return { cancel: true };
|
|
226
276
|
}
|
|
227
277
|
else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
|
|
228
|
-
|
|
278
|
+
log.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
|
|
229
279
|
}
|
|
230
280
|
else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
|
|
231
|
-
|
|
281
|
+
log.info('[SafeClaw] audit-only: service unreachable, allowing message (fail-closed)');
|
|
232
282
|
}
|
|
233
283
|
if (r?.block) {
|
|
234
284
|
const blockReason = r.reason || 'Blocked by SafeClaw (no reason provided)';
|
|
235
285
|
if (cfg.enforcement === 'enforce') {
|
|
236
|
-
|
|
286
|
+
log.warn(`[SafeClaw] Blocking message: ${blockReason}`);
|
|
287
|
+
return { cancel: true };
|
|
237
288
|
}
|
|
238
289
|
if (cfg.enforcement === 'warn-only') {
|
|
239
|
-
|
|
290
|
+
log.warn(`[SafeClaw] Warning: ${blockReason}`);
|
|
240
291
|
}
|
|
241
292
|
// audit-only: logged server-side, no action here
|
|
242
293
|
}
|
|
243
294
|
}, { priority: 100 });
|
|
244
295
|
// Async logging — fire-and-forget, no return value needed
|
|
296
|
+
// (#195: use event.prompt for input, event.lastAssistant for output; add provider/model)
|
|
245
297
|
api.on('llm_input', (event, ctx) => {
|
|
246
298
|
post('/log/llm-input', {
|
|
247
|
-
sessionId:
|
|
248
|
-
content: event.
|
|
299
|
+
sessionId: event.sessionId ?? ctx.sessionId ?? '',
|
|
300
|
+
content: event.prompt ?? '',
|
|
301
|
+
provider: event.provider ?? '',
|
|
302
|
+
model: event.model ?? '',
|
|
249
303
|
}).catch(() => { });
|
|
250
304
|
});
|
|
251
305
|
api.on('llm_output', (event, ctx) => {
|
|
252
306
|
post('/log/llm-output', {
|
|
253
|
-
sessionId:
|
|
254
|
-
content: event.
|
|
307
|
+
sessionId: event.sessionId ?? ctx.sessionId ?? '',
|
|
308
|
+
content: event.lastAssistant ?? '',
|
|
309
|
+
provider: event.provider ?? '',
|
|
310
|
+
model: event.model ?? '',
|
|
311
|
+
usage: event.usage ?? {},
|
|
255
312
|
}).catch(() => { });
|
|
256
313
|
});
|
|
314
|
+
// (#195: use event.toolName, !event.error for success, add durationMs and error)
|
|
257
315
|
api.on('after_tool_call', (event, ctx) => {
|
|
258
316
|
post('/record/tool-result', {
|
|
259
|
-
sessionId: ctx.sessionId ?? event.sessionId,
|
|
260
|
-
toolName: event.toolName ??
|
|
317
|
+
sessionId: ctx.sessionId ?? event.sessionId ?? '',
|
|
318
|
+
toolName: event.toolName ?? '',
|
|
261
319
|
params: event.params ?? {},
|
|
262
320
|
result: event.result ?? '',
|
|
263
|
-
success: event.
|
|
264
|
-
|
|
321
|
+
success: !event.error,
|
|
322
|
+
error: event.error ? String(event.error) : '',
|
|
323
|
+
durationMs: event.durationMs ?? 0,
|
|
324
|
+
}).catch((e) => log.warn('[SafeClaw] Failed to record tool result:', e));
|
|
265
325
|
});
|
|
326
|
+
// Subagent governance — block delegation bypass attempts (#188)
|
|
327
|
+
api.on('subagent_spawning', async (event, ctx) => {
|
|
328
|
+
const cfg = getConfig();
|
|
329
|
+
const r = await post('/evaluate/subagent-spawn', {
|
|
330
|
+
sessionId: ctx.sessionId ?? event.sessionId,
|
|
331
|
+
userId: ctx.agentId,
|
|
332
|
+
parentAgentId: event.parentAgentId,
|
|
333
|
+
childConfig: event.childConfig ?? {},
|
|
334
|
+
reason: event.reason ?? '',
|
|
335
|
+
});
|
|
336
|
+
if (r?.block && cfg.enforcement === 'enforce') {
|
|
337
|
+
throw new Error(r.reason || 'Blocked by SafeClaw: delegation bypass detected');
|
|
338
|
+
}
|
|
339
|
+
if (r?.block && cfg.enforcement === 'warn-only') {
|
|
340
|
+
log.warn(`[SafeClaw] Subagent spawn warning: ${r.reason}`);
|
|
341
|
+
}
|
|
342
|
+
}, { priority: 100 });
|
|
343
|
+
// Subagent ended — record child agent lifecycle (#188)
|
|
344
|
+
api.on('subagent_ended', (event, ctx) => {
|
|
345
|
+
post('/record/subagent-ended', {
|
|
346
|
+
sessionId: ctx.sessionId ?? event.sessionId,
|
|
347
|
+
parentAgentId: event.parentAgentId,
|
|
348
|
+
childAgentId: event.childAgentId,
|
|
349
|
+
}).catch(() => { });
|
|
350
|
+
});
|
|
351
|
+
// Session lifecycle — notify service of session start (#189)
|
|
352
|
+
api.on('session_start', (event, ctx) => {
|
|
353
|
+
post('/session/start', {
|
|
354
|
+
sessionId: ctx.sessionId ?? event.sessionId,
|
|
355
|
+
userId: ctx.agentId,
|
|
356
|
+
agentId: instanceId,
|
|
357
|
+
metadata: event.metadata ?? {},
|
|
358
|
+
}).catch(() => { });
|
|
359
|
+
});
|
|
360
|
+
// Session lifecycle — notify service of session end (#189)
|
|
361
|
+
api.on('session_end', (event, ctx) => {
|
|
362
|
+
post('/session/end', {
|
|
363
|
+
sessionId: ctx.sessionId ?? event.sessionId,
|
|
364
|
+
userId: ctx.agentId,
|
|
365
|
+
agentId: instanceId,
|
|
366
|
+
}).catch(() => { });
|
|
367
|
+
});
|
|
368
|
+
// Inbound message governance — evaluate received messages (#190)
|
|
369
|
+
api.on('message_received', (event, ctx) => {
|
|
370
|
+
post('/evaluate/inbound-message', {
|
|
371
|
+
sessionId: ctx.sessionId ?? event.sessionId,
|
|
372
|
+
userId: ctx.agentId,
|
|
373
|
+
channel: event.channel ?? ctx.channelId ?? '',
|
|
374
|
+
sender: event.sender ?? '',
|
|
375
|
+
content: event.content ?? '',
|
|
376
|
+
metadata: event.metadata ?? {},
|
|
377
|
+
}).catch(() => { });
|
|
378
|
+
});
|
|
379
|
+
// Agent tools — let agents introspect governance state (#197)
|
|
380
|
+
if (api.registerTool) {
|
|
381
|
+
api.registerTool({
|
|
382
|
+
name: 'safeclaw_status',
|
|
383
|
+
description: 'Check SafeClaw governance service status, enforcement mode, and active constraints',
|
|
384
|
+
parameters: {},
|
|
385
|
+
async execute(_params, _ctx) {
|
|
386
|
+
const health = await get('/health');
|
|
387
|
+
const cfg = getConfig();
|
|
388
|
+
return {
|
|
389
|
+
status: health?.status ?? 'unreachable',
|
|
390
|
+
enforcement: cfg.enforcement,
|
|
391
|
+
failMode: cfg.failMode,
|
|
392
|
+
serviceUrl: cfg.serviceUrl,
|
|
393
|
+
handshakeCompleted,
|
|
394
|
+
};
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
api.registerTool({
|
|
398
|
+
name: 'safeclaw_check_action',
|
|
399
|
+
description: 'Check if a specific tool call would be allowed by SafeClaw governance (dry run, no side effects)',
|
|
400
|
+
parameters: {
|
|
401
|
+
type: 'object',
|
|
402
|
+
properties: {
|
|
403
|
+
toolName: { type: 'string', description: 'Tool name to check' },
|
|
404
|
+
params: { type: 'object', description: 'Tool parameters to validate' },
|
|
405
|
+
},
|
|
406
|
+
required: ['toolName'],
|
|
407
|
+
},
|
|
408
|
+
async execute(params, ctx) {
|
|
409
|
+
const r = await post('/evaluate/tool-call', {
|
|
410
|
+
sessionId: ctx.sessionId ?? '',
|
|
411
|
+
userId: ctx.agentId ?? '',
|
|
412
|
+
toolName: params.toolName,
|
|
413
|
+
params: params.params ?? {},
|
|
414
|
+
dryRun: true,
|
|
415
|
+
});
|
|
416
|
+
return r ?? { error: 'Service unreachable' };
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
// CLI extension — `safeclaw status` command (#197)
|
|
421
|
+
if (api.registerCli) {
|
|
422
|
+
api.registerCli(({ program }) => {
|
|
423
|
+
const cmd = program.command('safeclaw').description('SafeClaw governance controls');
|
|
424
|
+
cmd.command('status')
|
|
425
|
+
.description('Show SafeClaw service status and enforcement mode')
|
|
426
|
+
.action(async () => {
|
|
427
|
+
const cfg = getConfig();
|
|
428
|
+
const health = await get('/health');
|
|
429
|
+
console.log(`SafeClaw: ${health?.status ?? 'unreachable'}`);
|
|
430
|
+
console.log(` Enforcement: ${cfg.enforcement}`);
|
|
431
|
+
console.log(` Fail mode: ${cfg.failMode}`);
|
|
432
|
+
console.log(` Service: ${cfg.serviceUrl}`);
|
|
433
|
+
});
|
|
434
|
+
}, { commands: ['safeclaw'] });
|
|
435
|
+
}
|
|
266
436
|
},
|
|
267
437
|
};
|
package/dist/tui/Status.js
CHANGED
|
@@ -6,6 +6,7 @@ import { existsSync, readFileSync } from 'fs';
|
|
|
6
6
|
import { join, dirname } from 'path';
|
|
7
7
|
import { homedir } from 'os';
|
|
8
8
|
import { fileURLToPath } from 'url';
|
|
9
|
+
import { isNemoClawSandbox, getSandboxName } from './config.js';
|
|
9
10
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
11
|
const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
|
|
11
12
|
export default function Status({ config }) {
|
|
@@ -169,5 +170,5 @@ export default function Status({ config }) {
|
|
|
169
170
|
return _jsxs(Text, { color: "green", children: [dot, " "] });
|
|
170
171
|
return _jsxs(Text, { color: "red", children: [dot, " "] });
|
|
171
172
|
};
|
|
172
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "Status" }) }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Service ' }), _jsxs(Text, { color: serviceDotColor, children: [dot, " "] }), _jsx(Text, { children: serviceText })] }), connected && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Evaluate ' }), statusDot(evaluateStatus), _jsx(Text, { children: evaluateStatus === 'ok' ? 'Responding' : evaluateStatus === 'checking' ? 'Checking...' : 'Not responding' })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Handshake ' }), statusDot(handshakeStatus), _jsx(Text, { children: handshakeStatus === 'ok' ? handshakeDetail : handshakeStatus === 'checking' ? 'Checking...' : handshakeDetail })] })] })), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' OpenClaw ' }), _jsxs(Text, { color: openclawDotColor, children: [dot, " "] }), _jsx(Text, { children: openclawText }), restartMsg && _jsx(Text, { dimColor: true, children: ` (${restartMsg})` })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Plugin ' }), statusDot(pluginInstalled), _jsx(Text, { children: pluginInstalled ? 'Installed' : 'Not installed' })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Config ' }), statusDot(pluginEnabled), _jsx(Text, { children: pluginEnabled ? 'Enabled' : 'Not enabled' })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' API Key ' }), statusDot(!!config.apiKey), _jsx(Text, { children: apiKeyMasked })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Enforcement ' }), _jsx(Text, { children: config.enforcement })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Fail Mode ' }), _jsx(Text, { children: config.failMode })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Enabled ' }), _jsx(Text, { color: config.enabled ? 'green' : 'red', children: config.enabled ? 'ON' : 'OFF' })] }), health?.version && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ' Service v' }), _jsx(Text, { children: health.version })] })), lastCheck && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [' Last check: ', lastCheck.toLocaleTimeString()] }) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ' Press ' }), _jsx(Text, { bold: true, children: "r" }), _jsx(Text, { dimColor: true, children: ' to restart OpenClaw daemon' })] })] }));
|
|
173
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "Status" }) }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Service ' }), _jsxs(Text, { color: serviceDotColor, children: [dot, " "] }), _jsx(Text, { children: serviceText })] }), connected && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Evaluate ' }), statusDot(evaluateStatus), _jsx(Text, { children: evaluateStatus === 'ok' ? 'Responding' : evaluateStatus === 'checking' ? 'Checking...' : 'Not responding' })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Handshake ' }), statusDot(handshakeStatus), _jsx(Text, { children: handshakeStatus === 'ok' ? handshakeDetail : handshakeStatus === 'checking' ? 'Checking...' : handshakeDetail })] })] })), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' OpenClaw ' }), _jsxs(Text, { color: openclawDotColor, children: [dot, " "] }), _jsx(Text, { children: openclawText }), restartMsg && _jsx(Text, { dimColor: true, children: ` (${restartMsg})` })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Plugin ' }), statusDot(pluginInstalled), _jsx(Text, { children: pluginInstalled ? 'Installed' : 'Not installed' })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Config ' }), statusDot(pluginEnabled), _jsx(Text, { children: pluginEnabled ? 'Enabled' : 'Not enabled' })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' API Key ' }), statusDot(!!config.apiKey), _jsx(Text, { children: apiKeyMasked })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Enforcement ' }), _jsx(Text, { children: config.enforcement })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Fail Mode ' }), _jsx(Text, { children: config.failMode })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Enabled ' }), _jsx(Text, { color: config.enabled ? 'green' : 'red', children: config.enabled ? 'ON' : 'OFF' })] }), isNemoClawSandbox() && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' NemoClaw ' }), _jsxs(Text, { color: "green", children: [dot, " "] }), _jsxs(Text, { children: ["Sandbox: ", getSandboxName()] })] })), health?.version && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ' Service v' }), _jsx(Text, { children: health.version })] })), lastCheck && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [' Last check: ', lastCheck.toLocaleTimeString()] }) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ' Press ' }), _jsx(Text, { bold: true, children: "r" }), _jsx(Text, { dimColor: true, children: ' to restart OpenClaw daemon' })] })] }));
|
|
173
174
|
}
|
package/dist/tui/config.d.ts
CHANGED
|
@@ -32,3 +32,5 @@ export declare function saveConfig(config: SafeClawConfig): void;
|
|
|
32
32
|
* Used to detect whether the on-disk config has drifted from the in-memory state.
|
|
33
33
|
*/
|
|
34
34
|
export declare function configHash(config: SafeClawConfig): string;
|
|
35
|
+
export declare function isNemoClawSandbox(): boolean;
|
|
36
|
+
export declare function getSandboxName(): string | null;
|
package/dist/tui/config.js
CHANGED
|
@@ -75,6 +75,13 @@ export function loadConfig() {
|
|
|
75
75
|
defaults.agentId = process.env.SAFECLAW_AGENT_ID;
|
|
76
76
|
if (process.env.SAFECLAW_AGENT_TOKEN)
|
|
77
77
|
defaults.agentToken = process.env.SAFECLAW_AGENT_TOKEN;
|
|
78
|
+
// NemoClaw sandbox detection
|
|
79
|
+
if (process.env.OPENSHELL_SANDBOX) {
|
|
80
|
+
// Inside NemoClaw sandbox — localhost won't work, use container-to-host bridge
|
|
81
|
+
if (!process.env.SAFECLAW_URL && defaults.serviceUrl === 'http://localhost:8420/api/v1') {
|
|
82
|
+
defaults.serviceUrl = 'http://host.containers.internal:8420/api/v1';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
78
85
|
defaults.serviceUrl = defaults.serviceUrl.replace(/\/+$/, '');
|
|
79
86
|
const validModes = ['enforce', 'warn-only', 'audit-only', 'disabled'];
|
|
80
87
|
if (!validModes.includes(defaults.enforcement)) {
|
|
@@ -145,7 +152,16 @@ export function saveConfig(config) {
|
|
|
145
152
|
}
|
|
146
153
|
// Ensure parent directory exists
|
|
147
154
|
mkdirSync(dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
|
|
148
|
-
|
|
155
|
+
try {
|
|
156
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(existing, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
if (e.code === 'EROFS' || e.code === 'EACCES') {
|
|
160
|
+
// Sandbox filesystem is read-only — silently skip
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
throw e;
|
|
164
|
+
}
|
|
149
165
|
}
|
|
150
166
|
/**
|
|
151
167
|
* SHA-256 hash of the four TUI-managed config fields.
|
|
@@ -160,3 +176,10 @@ export function configHash(config) {
|
|
|
160
176
|
});
|
|
161
177
|
return crypto.createHash('sha256').update(payload).digest('hex');
|
|
162
178
|
}
|
|
179
|
+
// --- NemoClaw sandbox helpers ---
|
|
180
|
+
export function isNemoClawSandbox() {
|
|
181
|
+
return !!process.env.OPENSHELL_SANDBOX;
|
|
182
|
+
}
|
|
183
|
+
export function getSandboxName() {
|
|
184
|
+
return process.env.OPENSHELL_SANDBOX || null;
|
|
185
|
+
}
|