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/index.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * to the SafeClaw service and acts on the responses.
8
8
  */
9
9
 
10
+ import type { OpenClawPluginApi, OpenClawPluginEvent, OpenClawPluginContext } from 'openclaw/plugin-sdk/core';
10
11
  import { loadConfig, configHash } from './tui/config.js';
11
12
  import crypto from 'crypto';
12
13
  import { createRequire } from 'module';
@@ -21,13 +22,23 @@ const CONFIG_RELOAD_INTERVAL_MS = 60_000; // Reload config every 60 seconds
21
22
  let config = loadConfig();
22
23
  let configLoadedAt = Date.now();
23
24
 
25
+ // OpenClaw plugin config — merged on top of file config when available
26
+ let _ocPluginConfig: Record<string, unknown> = {};
27
+
28
+ // Logger — defaults to console, replaced by api.logger when available
29
+ let log: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void } = console;
30
+
24
31
  function getConfig(): typeof config {
25
32
  const now = Date.now();
26
33
  if (now - configLoadedAt >= CONFIG_RELOAD_INTERVAL_MS) {
27
34
  config = loadConfig();
28
35
  configLoadedAt = now;
29
36
  }
30
- return config;
37
+ // OpenClaw config takes priority over file config
38
+ return {
39
+ ...config,
40
+ ...Object.fromEntries(Object.entries(_ocPluginConfig).filter(([_, v]) => v != null)),
41
+ } as typeof config;
31
42
  }
32
43
 
33
44
  // --- HTTP Client ---
@@ -57,53 +68,50 @@ async function post(path: string, body: Record<string, unknown>): Promise<Record
57
68
  const rawDetail = errBody.detail ?? `HTTP ${res.status}`;
58
69
  const detail = typeof rawDetail === 'string' ? rawDetail : JSON.stringify(rawDetail);
59
70
  const hint = errBody.hint ? ` (${errBody.hint})` : '';
60
- console.warn(`[SafeClaw] ${path}: ${detail}${hint}`);
71
+ log.warn(`[SafeClaw] ${path}: ${detail}${hint}`);
61
72
  } catch {
62
- console.warn(`[SafeClaw] HTTP ${res.status} from ${path}`);
73
+ log.warn(`[SafeClaw] HTTP ${res.status} from ${path}`);
63
74
  }
64
75
  return null; // Caller checks failMode
65
76
  }
66
77
  return await res.json() as Record<string, unknown>;
67
78
  } catch (e) {
68
79
  if (e instanceof DOMException && e.name === 'TimeoutError') {
69
- console.warn(`[SafeClaw] Timeout after ${cfg.timeoutMs}ms on ${path} (${cfg.serviceUrl})`);
80
+ log.warn(`[SafeClaw] Timeout after ${cfg.timeoutMs}ms on ${path} (${cfg.serviceUrl})`);
70
81
  } else if (e instanceof TypeError && (e.message.includes('fetch') || e.message.includes('ECONNREFUSED'))) {
71
- console.warn(`[SafeClaw] Connection refused: ${cfg.serviceUrl}${path} — is the service running?`);
82
+ log.warn(`[SafeClaw] Connection refused: ${cfg.serviceUrl}${path} — is the service running?`);
72
83
  } else {
73
- console.warn(`[SafeClaw] Service unavailable: ${cfg.serviceUrl}${path}`);
84
+ log.warn(`[SafeClaw] Service unavailable: ${cfg.serviceUrl}${path}`);
74
85
  }
75
86
  return null; // Caller checks failMode
76
87
  }
77
88
  }
78
89
 
79
- // --- Plugin Definition ---
80
-
81
- interface PluginEvent {
82
- sessionId?: string;
83
- userId?: string;
84
- [key: string]: unknown;
85
- }
86
-
87
- interface PluginContext {
88
- sessionId?: string;
89
- userId?: string;
90
- [key: string]: unknown;
90
+ async function get(path: string): Promise<Record<string, unknown> | null> {
91
+ const cfg = getConfig();
92
+ if (!cfg.enabled) return null;
93
+ const headers: Record<string, string> = {};
94
+ if (cfg.apiKey) headers['Authorization'] = `Bearer ${cfg.apiKey}`;
95
+ try {
96
+ const res = await fetch(`${cfg.serviceUrl}${path}`, {
97
+ signal: AbortSignal.timeout(cfg.timeoutMs),
98
+ headers,
99
+ });
100
+ if (!res.ok) return null;
101
+ return await res.json() as Record<string, unknown>;
102
+ } catch {
103
+ return null;
104
+ }
91
105
  }
92
106
 
93
- interface PluginApi {
94
- on(
95
- event: string,
96
- handler: (event: PluginEvent, ctx: PluginContext) => Promise<Record<string, unknown> | void> | void,
97
- options?: { priority?: number },
98
- ): void;
99
- }
107
+ // --- Plugin Definition ---
100
108
 
101
109
  let handshakeCompleted = false;
102
110
 
103
111
  async function performHandshake(): Promise<boolean> {
104
112
  const cfg = getConfig();
105
113
  if (!cfg.apiKey) {
106
- console.warn('[SafeClaw] No API key configured — skipping handshake');
114
+ log.warn('[SafeClaw] No API key configured — skipping handshake');
107
115
  return false;
108
116
  }
109
117
 
@@ -113,11 +121,11 @@ async function performHandshake(): Promise<boolean> {
113
121
  });
114
122
 
115
123
  if (r === null) {
116
- console.warn('[SafeClaw] Handshake failed — API key may be invalid or service unreachable');
124
+ log.warn('[SafeClaw] Handshake failed — API key may be invalid or service unreachable');
117
125
  return false;
118
126
  }
119
127
 
120
- console.log(`[SafeClaw] Handshake OK — org=${r.orgId}, scope=${r.scope}, engine=${r.engineReady ? 'ready' : 'not ready'}`);
128
+ log.info(`[SafeClaw] Handshake OK — org=${r.orgId}, scope=${r.scope}, engine=${r.engineReady ? 'ready' : 'not ready'}`);
121
129
  handshakeCompleted = true;
122
130
  return true;
123
131
  }
@@ -125,8 +133,8 @@ async function performHandshake(): Promise<boolean> {
125
133
  async function checkConnection(): Promise<void> {
126
134
  const cfg = getConfig();
127
135
  const label = `[SafeClaw]`;
128
- console.log(`${label} Connecting to ${cfg.serviceUrl} ...`);
129
- console.log(`${label} Mode: enforcement=${cfg.enforcement}, failMode=${cfg.failMode}`);
136
+ log.info(`${label} Connecting to ${cfg.serviceUrl} ...`);
137
+ log.info(`${label} Mode: enforcement=${cfg.enforcement}, failMode=${cfg.failMode}`);
130
138
 
131
139
  try {
132
140
  const res = await fetch(`${cfg.serviceUrl}/health`, {
@@ -134,16 +142,16 @@ async function checkConnection(): Promise<void> {
134
142
  });
135
143
  if (res.ok) {
136
144
  const data = await res.json() as Record<string, unknown>;
137
- console.log(`${label} Connected — service ${data.status ?? 'ok'}`);
145
+ log.info(`${label} Connected — service ${data.status ?? 'ok'}`);
138
146
  } else {
139
- console.warn(`${label} Service responded with HTTP ${res.status}`);
147
+ log.warn(`${label} Service responded with HTTP ${res.status}`);
140
148
  }
141
149
  } catch {
142
- console.warn(`${label} Cannot reach service at ${cfg.serviceUrl}`);
150
+ log.warn(`${label} Cannot reach service at ${cfg.serviceUrl}`);
143
151
  if (cfg.failMode === 'closed') {
144
- console.warn(`${label} fail-mode=closed tool calls will be BLOCKED until service is reachable`);
152
+ log.warn(`${label} fail-mode=closed tool calls will be BLOCKED until service is reachable`);
145
153
  } else {
146
- console.warn(`${label} fail-mode=open tool calls will be ALLOWED despite no connection`);
154
+ log.warn(`${label} fail-mode=open tool calls will be ALLOWED despite no connection`);
147
155
  }
148
156
  }
149
157
  }
@@ -153,12 +161,15 @@ export default {
153
161
  name: 'SafeClaw Neurosymbolic Governance',
154
162
  version: PLUGIN_VERSION,
155
163
 
156
- register(api: PluginApi) {
164
+ register(api: OpenClawPluginApi) {
157
165
  if (!getConfig().enabled) {
158
166
  console.log('[SafeClaw] Plugin disabled');
159
167
  return;
160
168
  }
161
169
 
170
+ log = api.logger ?? console;
171
+ _ocPluginConfig = api.pluginConfig ?? {};
172
+
162
173
  // Generate a unique instance ID for this plugin run (fallback when agentId is not configured)
163
174
  const instanceId = getConfig().agentId || `instance-${crypto.randomUUID()}`;
164
175
 
@@ -175,21 +186,9 @@ export default {
175
186
  }
176
187
  };
177
188
 
178
- // Start heartbeat only after connection check + handshake completes (#84)
189
+ // Clean shutdown: send shutdown heartbeat and clear interval (#194)
179
190
  let heartbeatInterval: ReturnType<typeof setInterval> | undefined;
180
- checkConnection()
181
- .then(() => performHandshake())
182
- .then((ok) => {
183
- if (!ok && getConfig().failMode === 'closed') {
184
- console.warn('[SafeClaw] Handshake failed with fail-mode=closed — tool calls will be BLOCKED');
185
- }
186
- heartbeatInterval = setInterval(sendHeartbeat, 30000);
187
- return sendHeartbeat();
188
- })
189
- .catch(() => {});
190
191
 
191
- // Clean shutdown: send shutdown heartbeat and clear interval
192
- // Use async shutdown for SIGINT/SIGTERM where async is supported (#55)
193
192
  const shutdown = async () => {
194
193
  if (heartbeatInterval) clearInterval(heartbeatInterval);
195
194
  try {
@@ -202,30 +201,58 @@ export default {
202
201
  // Best-effort shutdown notification
203
202
  }
204
203
  };
205
- process.on('SIGINT', async () => { await shutdown(); process.exit(0); });
206
- process.on('SIGTERM', async () => { await shutdown(); process.exit(0); });
207
204
 
208
- // THE GATE constraint checking on every tool call
209
- api.on('before_tool_call', async (event: PluginEvent, ctx: PluginContext) => {
205
+ // Start heartbeat after connection check + handshake completes (#84)
206
+ const startupPromise = checkConnection()
207
+ .then(() => performHandshake())
208
+ .then((ok) => {
209
+ if (!ok && getConfig().failMode === 'closed') {
210
+ log.warn('[SafeClaw] Handshake failed with fail-mode=closed — tool calls will be BLOCKED');
211
+ }
212
+ heartbeatInterval = setInterval(sendHeartbeat, 30000);
213
+ return sendHeartbeat();
214
+ })
215
+ .catch(() => {});
216
+
217
+ // Register as an OpenClaw service so the gateway manages our lifecycle.
218
+ // The service's stop() method sends the shutdown heartbeat — no process.exit()
219
+ // needed, which avoids killing the entire gateway process (#194).
220
+ if (api.registerService) {
221
+ api.registerService({
222
+ id: 'safeclaw-governance',
223
+ start() { /* startup handled above via startupPromise */ },
224
+ async stop() {
225
+ await startupPromise; // Ensure startup finished before tearing down
226
+ await shutdown();
227
+ },
228
+ });
229
+ } else {
230
+ // Fallback for older OpenClaw versions without registerService:
231
+ // No process.exit() — just clean up on beforeExit
232
+ process.on('beforeExit', () => { shutdown().catch(() => {}); });
233
+ }
234
+
235
+ // THE GATE — constraint checking on every tool call (#195: use correct OpenClaw field names)
236
+ api.on('before_tool_call', async (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
210
237
  const cfg = getConfig();
211
238
  if (!handshakeCompleted && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
212
239
  return { block: true, blockReason: 'SafeClaw handshake not completed (fail-closed)' };
213
240
  }
214
241
 
215
242
  const r = await post('/evaluate/tool-call', {
216
- sessionId: ctx.sessionId ?? event.sessionId,
217
- userId: ctx.userId ?? event.userId,
218
- toolName: event.toolName ?? event.tool_name,
243
+ sessionId: ctx.sessionId ?? event.sessionId ?? '',
244
+ userId: ctx.agentId ?? '',
245
+ toolName: event.toolName ?? '',
219
246
  params: event.params ?? {},
220
- sessionHistory: event.sessionHistory ?? [],
247
+ runId: ctx.runId ?? '',
221
248
  });
222
249
 
223
250
  if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
224
251
  return { block: true, blockReason: `SafeClaw service unavailable at ${cfg.serviceUrl} (fail-closed)` };
225
252
  } else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
226
- console.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, warn-only)`);
253
+ log.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, warn-only)`);
227
254
  } else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
228
- console.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, audit-only)`);
255
+ log.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, audit-only)`);
229
256
  }
230
257
  if (r?.block) {
231
258
  const blockReason = (r.reason as string) || 'Blocked by SafeClaw (no reason provided)';
@@ -233,76 +260,209 @@ export default {
233
260
  return { block: true, blockReason };
234
261
  }
235
262
  if (cfg.enforcement === 'warn-only') {
236
- console.warn(`[SafeClaw] Warning: ${blockReason}`);
263
+ log.warn(`[SafeClaw] Warning: ${blockReason}`);
237
264
  }
238
265
  // audit-only: logged server-side, no action here
239
266
  }
240
267
  }, { priority: 100 });
241
268
 
242
269
  // Context injection — prepend governance context to agent system prompt
243
- api.on('before_agent_start', async (event: PluginEvent, ctx: PluginContext) => {
270
+ // (#195: before_agent_start is deprecated; use before_prompt_build + prependSystemContext)
271
+ api.on('before_prompt_build', async (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
244
272
  const r = await post('/context/build', {
245
- sessionId: ctx.sessionId ?? event.sessionId,
246
- userId: ctx.userId ?? event.userId,
273
+ sessionId: ctx.sessionId ?? event.sessionId ?? '',
274
+ userId: ctx.agentId ?? '',
247
275
  });
248
276
 
249
277
  if (r?.prependContext) {
250
- return { prependContext: r.prependContext as string };
278
+ return { prependSystemContext: r.prependContext as string };
251
279
  }
252
280
  }, { priority: 100 });
253
281
 
254
282
  // Message governance — check outbound messages
255
- api.on('message_sending', async (event: PluginEvent, ctx: PluginContext) => {
283
+ // (#195: use ctx.conversationId/sessionId, ctx.accountId; return only { cancel: true })
284
+ api.on('message_sending', async (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
256
285
  const cfg = getConfig();
257
286
  const r = await post('/evaluate/message', {
258
- sessionId: ctx.sessionId ?? event.sessionId,
259
- userId: ctx.userId ?? event.userId,
287
+ sessionId: ctx.conversationId ?? ctx.sessionId ?? event.sessionId ?? '',
288
+ userId: ctx.accountId ?? '',
260
289
  to: event.to,
261
290
  content: event.content,
291
+ channelId: ctx.channelId ?? '',
262
292
  });
263
293
 
264
294
  if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
265
- return { cancel: true, cancelReason: 'SafeClaw service unavailable (fail-closed mode)' };
295
+ log.warn('[SafeClaw] Blocking message: service unavailable (fail-closed mode)');
296
+ return { cancel: true };
266
297
  } else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
267
- console.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
298
+ log.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
268
299
  } else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
269
- console.info('[SafeClaw] audit-only: service unreachable, allowing message (fail-closed)');
300
+ log.info('[SafeClaw] audit-only: service unreachable, allowing message (fail-closed)');
270
301
  }
271
302
  if (r?.block) {
272
303
  const blockReason = (r.reason as string) || 'Blocked by SafeClaw (no reason provided)';
273
304
  if (cfg.enforcement === 'enforce') {
274
- return { cancel: true, cancelReason: blockReason };
305
+ log.warn(`[SafeClaw] Blocking message: ${blockReason}`);
306
+ return { cancel: true };
275
307
  }
276
308
  if (cfg.enforcement === 'warn-only') {
277
- console.warn(`[SafeClaw] Warning: ${blockReason}`);
309
+ log.warn(`[SafeClaw] Warning: ${blockReason}`);
278
310
  }
279
311
  // audit-only: logged server-side, no action here
280
312
  }
281
313
  }, { priority: 100 });
282
314
 
283
315
  // Async logging — fire-and-forget, no return value needed
284
- api.on('llm_input', (event: PluginEvent, ctx: PluginContext) => {
316
+ // (#195: use event.prompt for input, event.lastAssistant for output; add provider/model)
317
+ api.on('llm_input', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
285
318
  post('/log/llm-input', {
286
- sessionId: ctx.sessionId ?? event.sessionId,
287
- content: event.content,
319
+ sessionId: event.sessionId ?? ctx.sessionId ?? '',
320
+ content: event.prompt ?? '',
321
+ provider: event.provider ?? '',
322
+ model: event.model ?? '',
288
323
  }).catch(() => {});
289
324
  });
290
325
 
291
- api.on('llm_output', (event: PluginEvent, ctx: PluginContext) => {
326
+ api.on('llm_output', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
292
327
  post('/log/llm-output', {
293
- sessionId: ctx.sessionId ?? event.sessionId,
294
- content: event.content,
328
+ sessionId: event.sessionId ?? ctx.sessionId ?? '',
329
+ content: event.lastAssistant ?? '',
330
+ provider: event.provider ?? '',
331
+ model: event.model ?? '',
332
+ usage: event.usage ?? {},
295
333
  }).catch(() => {});
296
334
  });
297
335
 
298
- api.on('after_tool_call', (event: PluginEvent, ctx: PluginContext) => {
336
+ // (#195: use event.toolName, !event.error for success, add durationMs and error)
337
+ api.on('after_tool_call', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
299
338
  post('/record/tool-result', {
300
- sessionId: ctx.sessionId ?? event.sessionId,
301
- toolName: event.toolName ?? event.tool_name,
339
+ sessionId: ctx.sessionId ?? event.sessionId ?? '',
340
+ toolName: event.toolName ?? '',
302
341
  params: event.params ?? {},
303
342
  result: event.result ?? '',
304
- success: event.success ?? false,
305
- }).catch((e) => console.warn('[SafeClaw] Failed to record tool result:', e));
343
+ success: !event.error,
344
+ error: event.error ? String(event.error) : '',
345
+ durationMs: event.durationMs ?? 0,
346
+ }).catch((e) => log.warn('[SafeClaw] Failed to record tool result:', e));
347
+ });
348
+
349
+ // Subagent governance — block delegation bypass attempts (#188)
350
+ api.on('subagent_spawning', async (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
351
+ const cfg = getConfig();
352
+ const r = await post('/evaluate/subagent-spawn', {
353
+ sessionId: ctx.sessionId ?? event.sessionId,
354
+ userId: ctx.agentId,
355
+ parentAgentId: event.parentAgentId,
356
+ childConfig: event.childConfig ?? {},
357
+ reason: event.reason ?? '',
358
+ });
359
+
360
+ if (r?.block && cfg.enforcement === 'enforce') {
361
+ throw new Error((r.reason as string) || 'Blocked by SafeClaw: delegation bypass detected');
362
+ }
363
+ if (r?.block && cfg.enforcement === 'warn-only') {
364
+ log.warn(`[SafeClaw] Subagent spawn warning: ${r.reason}`);
365
+ }
366
+ }, { priority: 100 });
367
+
368
+ // Subagent ended — record child agent lifecycle (#188)
369
+ api.on('subagent_ended', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
370
+ post('/record/subagent-ended', {
371
+ sessionId: ctx.sessionId ?? event.sessionId,
372
+ parentAgentId: event.parentAgentId,
373
+ childAgentId: event.childAgentId,
374
+ }).catch(() => {});
306
375
  });
376
+
377
+ // Session lifecycle — notify service of session start (#189)
378
+ api.on('session_start', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
379
+ post('/session/start', {
380
+ sessionId: ctx.sessionId ?? event.sessionId,
381
+ userId: ctx.agentId,
382
+ agentId: instanceId,
383
+ metadata: event.metadata ?? {},
384
+ }).catch(() => {});
385
+ });
386
+
387
+ // Session lifecycle — notify service of session end (#189)
388
+ api.on('session_end', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
389
+ post('/session/end', {
390
+ sessionId: ctx.sessionId ?? event.sessionId,
391
+ userId: ctx.agentId,
392
+ agentId: instanceId,
393
+ }).catch(() => {});
394
+ });
395
+
396
+ // Inbound message governance — evaluate received messages (#190)
397
+ api.on('message_received', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
398
+ post('/evaluate/inbound-message', {
399
+ sessionId: ctx.sessionId ?? event.sessionId,
400
+ userId: ctx.agentId,
401
+ channel: event.channel ?? (ctx as any).channelId ?? '',
402
+ sender: event.sender ?? '',
403
+ content: event.content ?? '',
404
+ metadata: event.metadata ?? {},
405
+ }).catch(() => {});
406
+ });
407
+
408
+ // Agent tools — let agents introspect governance state (#197)
409
+ if (api.registerTool) {
410
+ api.registerTool({
411
+ name: 'safeclaw_status',
412
+ description: 'Check SafeClaw governance service status, enforcement mode, and active constraints',
413
+ parameters: {},
414
+ async execute(_params: Record<string, unknown>, _ctx: Record<string, unknown>) {
415
+ const health = await get('/health');
416
+ const cfg = getConfig();
417
+ return {
418
+ status: health?.status ?? 'unreachable',
419
+ enforcement: cfg.enforcement,
420
+ failMode: cfg.failMode,
421
+ serviceUrl: cfg.serviceUrl,
422
+ handshakeCompleted,
423
+ };
424
+ },
425
+ });
426
+
427
+ api.registerTool({
428
+ name: 'safeclaw_check_action',
429
+ description: 'Check if a specific tool call would be allowed by SafeClaw governance (dry run, no side effects)',
430
+ parameters: {
431
+ type: 'object',
432
+ properties: {
433
+ toolName: { type: 'string', description: 'Tool name to check' },
434
+ params: { type: 'object', description: 'Tool parameters to validate' },
435
+ },
436
+ required: ['toolName'],
437
+ },
438
+ async execute(params: Record<string, unknown>, ctx: Record<string, unknown>) {
439
+ const r = await post('/evaluate/tool-call', {
440
+ sessionId: (ctx as any).sessionId ?? '',
441
+ userId: (ctx as any).agentId ?? '',
442
+ toolName: params.toolName,
443
+ params: (params.params as Record<string, unknown>) ?? {},
444
+ dryRun: true,
445
+ });
446
+ return r ?? { error: 'Service unreachable' };
447
+ },
448
+ });
449
+ }
450
+
451
+ // CLI extension — `safeclaw status` command (#197)
452
+ if (api.registerCli) {
453
+ api.registerCli(({ program }: { program: any }) => {
454
+ const cmd = program.command('safeclaw').description('SafeClaw governance controls');
455
+ cmd.command('status')
456
+ .description('Show SafeClaw service status and enforcement mode')
457
+ .action(async () => {
458
+ const cfg = getConfig();
459
+ const health = await get('/health');
460
+ console.log(`SafeClaw: ${health?.status ?? 'unreachable'}`);
461
+ console.log(` Enforcement: ${cfg.enforcement}`);
462
+ console.log(` Fail mode: ${cfg.failMode}`);
463
+ console.log(` Service: ${cfg.serviceUrl}`);
464
+ });
465
+ }, { commands: ['safeclaw'] });
466
+ }
307
467
  },
308
468
  };
@@ -2,7 +2,12 @@
2
2
  "id": "safeclaw",
3
3
  "name": "SafeClaw Neurosymbolic Governance",
4
4
  "description": "Validates AI agent actions against OWL ontologies and SHACL constraints before execution",
5
- "version": "0.5.1",
5
+ "version": "1.1.0",
6
+ "author": "Tendly EU",
7
+ "license": "MIT",
8
+ "homepage": "https://safeclaw.eu",
9
+ "repository": "https://github.com/tendlyeu/SafeClaw",
10
+ "category": "security",
6
11
  "configSchema": {
7
12
  "type": "object",
8
13
  "additionalProperties": false,
@@ -10,7 +15,7 @@
10
15
  "serviceUrl": {
11
16
  "type": "string",
12
17
  "description": "SafeClaw service URL",
13
- "default": "https://api.safeclaw.eu/api/v1"
18
+ "default": "http://localhost:8420/api/v1"
14
19
  },
15
20
  "apiKey": {
16
21
  "type": "string",
@@ -19,12 +24,32 @@
19
24
  "enforcement": {
20
25
  "type": "string",
21
26
  "enum": ["enforce", "warn-only", "audit-only", "disabled"],
22
- "default": "enforce"
27
+ "default": "enforce",
28
+ "description": "Enforcement mode: enforce blocks violations, warn-only logs, audit-only records silently"
23
29
  },
24
30
  "failMode": {
25
31
  "type": "string",
26
32
  "enum": ["open", "closed"],
27
- "default": "open"
33
+ "default": "open",
34
+ "description": "Behavior when service is unreachable: open allows actions, closed blocks them"
35
+ },
36
+ "agentId": {
37
+ "type": "string",
38
+ "description": "Agent identifier for multi-agent governance"
39
+ },
40
+ "agentToken": {
41
+ "type": "string",
42
+ "description": "Agent authentication token"
43
+ },
44
+ "timeoutMs": {
45
+ "type": "number",
46
+ "default": 5000,
47
+ "description": "HTTP timeout for service calls in milliseconds"
48
+ },
49
+ "enabled": {
50
+ "type": "boolean",
51
+ "default": true,
52
+ "description": "Master enable/disable switch"
28
53
  }
29
54
  }
30
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-safeclaw-plugin",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "SafeClaw Neurosymbolic Governance plugin for OpenClaw — validates AI agent actions against OWL ontologies and SHACL constraints",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -25,7 +25,8 @@
25
25
  "cli.tsx",
26
26
  "tui/",
27
27
  "SKILL.md",
28
- "README.md"
28
+ "README.md",
29
+ "policies/"
29
30
  ],
30
31
  "keywords": [
31
32
  "openclaw",
@@ -55,6 +56,14 @@
55
56
  "@types/react": "^19.2.14",
56
57
  "typescript": "^5.4.0"
57
58
  },
59
+ "peerDependencies": {
60
+ "openclaw": ">=2026.1.0"
61
+ },
62
+ "peerDependenciesMeta": {
63
+ "openclaw": {
64
+ "optional": true
65
+ }
66
+ },
58
67
  "engines": {
59
68
  "node": ">=18.0.0"
60
69
  },
@@ -0,0 +1,14 @@
1
+ # SafeClaw governance service egress policy
2
+ # Compatible with NemoClaw 0.1.x policy format
3
+ # Apply with: nemoclaw policy-add safeclaw
4
+ rules:
5
+ - name: safeclaw-remote
6
+ host: "api.safeclaw.eu"
7
+ port: 443
8
+ protocol: https
9
+ allow: true
10
+ - name: safeclaw-host-local
11
+ host: "host.containers.internal"
12
+ port: 8420
13
+ protocol: http
14
+ allow: true
package/tui/Status.tsx CHANGED
@@ -5,7 +5,7 @@ import { existsSync, readFileSync } from 'fs';
5
5
  import { join, dirname } from 'path';
6
6
  import { homedir } from 'os';
7
7
  import { fileURLToPath } from 'url';
8
- import { type SafeClawConfig } from './config.js';
8
+ import { type SafeClawConfig, isNemoClawSandbox, getSandboxName } from './config.js';
9
9
 
10
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
11
  const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version as string;
@@ -256,6 +256,14 @@ export default function Status({ config }: StatusProps) {
256
256
  </Text>
257
257
  </Box>
258
258
 
259
+ {isNemoClawSandbox() && (
260
+ <Box>
261
+ <Text dimColor>{' NemoClaw '}</Text>
262
+ <Text color="green">{dot} </Text>
263
+ <Text>Sandbox: {getSandboxName()}</Text>
264
+ </Box>
265
+ )}
266
+
259
267
  {health?.version && (
260
268
  <Box marginTop={1}>
261
269
  <Text dimColor>{' Service v'}</Text>