openclaw-safeclaw-plugin 1.4.0 → 1.4.1

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/dist/index.js CHANGED
@@ -99,6 +99,7 @@ async function get(path) {
99
99
  }
100
100
  // --- Plugin Definition ---
101
101
  let handshakeCompleted = false;
102
+ let lastHandshakeConfigHash = '';
102
103
  async function performHandshake() {
103
104
  const cfg = getConfig();
104
105
  if (!cfg.apiKey) {
@@ -115,6 +116,7 @@ async function performHandshake() {
115
116
  }
116
117
  log.info(`[SafeClaw] Handshake OK — org=${r.orgId}, scope=${r.scope}, engine=${r.engineReady ? 'ready' : 'not ready'}`);
117
118
  handshakeCompleted = true;
119
+ lastHandshakeConfigHash = configHash(getConfig());
118
120
  return true;
119
121
  }
120
122
  async function checkConnection() {
@@ -160,9 +162,15 @@ export default {
160
162
  // Heartbeat watchdog — send config hash to service every 30s
161
163
  const sendHeartbeat = async () => {
162
164
  try {
165
+ const currentHash = configHash(getConfig());
166
+ if (handshakeCompleted && currentHash !== lastHandshakeConfigHash) {
167
+ log.info('[SafeClaw] Config changed — re-authenticating');
168
+ handshakeCompleted = false;
169
+ performHandshake().catch(() => { });
170
+ }
163
171
  await post('/heartbeat', {
164
172
  agentId: instanceId,
165
- configHash: configHash(getConfig()),
173
+ configHash: currentHash,
166
174
  status: 'alive',
167
175
  });
168
176
  }
@@ -237,6 +245,19 @@ export default {
237
245
  else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
238
246
  log.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, audit-only)`);
239
247
  }
248
+ // If service says confirmation required, use OpenClaw's native approval flow
249
+ if (r?.confirmationRequired) {
250
+ const riskLevel = r.riskLevel || '';
251
+ return {
252
+ requireApproval: {
253
+ title: 'SafeClaw Governance Check',
254
+ description: r.reason || 'This action requires confirmation',
255
+ severity: riskLevel === 'HighRisk' ? 'critical' : riskLevel === 'MediumRisk' ? 'warning' : 'info',
256
+ timeoutMs: 30_000,
257
+ timeoutBehavior: riskLevel === 'HighRisk' ? 'deny' : 'allow',
258
+ },
259
+ };
260
+ }
240
261
  if (r?.block) {
241
262
  const blockReason = r.reason || 'Blocked by SafeClaw (no reason provided)';
242
263
  if (cfg.enforcement === 'enforce') {
@@ -300,7 +321,7 @@ export default {
300
321
  content: event.prompt ?? '',
301
322
  provider: event.provider ?? '',
302
323
  model: event.model ?? '',
303
- }).catch(() => { });
324
+ }).catch((e) => log.warn('[SafeClaw] Failed to log LLM input:', e));
304
325
  });
305
326
  api.on('llm_output', (event, ctx) => {
306
327
  post('/log/llm-output', {
@@ -309,7 +330,7 @@ export default {
309
330
  provider: event.provider ?? '',
310
331
  model: event.model ?? '',
311
332
  usage: event.usage ?? {},
312
- }).catch(() => { });
333
+ }).catch((e) => log.warn('[SafeClaw] Failed to log LLM output:', e));
313
334
  });
314
335
  // (#195: use event.toolName, !event.error for success, add durationMs and error)
315
336
  api.on('after_tool_call', (event, ctx) => {
@@ -327,15 +348,15 @@ export default {
327
348
  api.on('subagent_spawning', async (event, ctx) => {
328
349
  const cfg = getConfig();
329
350
  const r = await post('/evaluate/subagent-spawn', {
330
- sessionId: ctx.sessionId ?? event.sessionId,
331
- userId: ctx.agentId,
351
+ sessionId: ctx.sessionId ?? event.sessionId ?? '',
352
+ userId: ctx.agentId ?? '',
332
353
  parentAgentId: event.parentAgentId,
333
354
  childConfig: event.childConfig ?? {},
334
355
  reason: event.reason ?? '',
335
356
  });
336
357
  // Fail-closed handling (matches before_tool_call / message_sending pattern)
337
358
  if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
338
- throw new Error('SafeClaw service unavailable (fail-closed mode)');
359
+ return { status: 'error', error: 'SafeClaw service unavailable (fail-closed mode)' };
339
360
  }
340
361
  else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
341
362
  log.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
@@ -344,7 +365,7 @@ export default {
344
365
  log.warn('[SafeClaw] Service unavailable (fail-closed mode, audit-only)');
345
366
  }
346
367
  if (r?.block && cfg.enforcement === 'enforce') {
347
- throw new Error(r.reason || 'Blocked by SafeClaw: delegation bypass detected');
368
+ return { status: 'error', error: r.reason || 'Blocked by SafeClaw: delegation bypass detected' };
348
369
  }
349
370
  if (r?.block && cfg.enforcement === 'warn-only') {
350
371
  log.warn(`[SafeClaw] Subagent spawn warning: ${r.reason}`);
@@ -353,38 +374,38 @@ export default {
353
374
  // Subagent ended — record child agent lifecycle (#188)
354
375
  api.on('subagent_ended', (event, ctx) => {
355
376
  post('/record/subagent-ended', {
356
- sessionId: ctx.sessionId ?? event.sessionId,
377
+ sessionId: ctx.sessionId ?? event.sessionId ?? '',
357
378
  parentAgentId: event.parentAgentId,
358
379
  childAgentId: event.childAgentId,
359
- }).catch(() => { });
380
+ }).catch((e) => log.warn('[SafeClaw] Failed to record subagent ended:', e));
360
381
  });
361
382
  // Session lifecycle — notify service of session start (#189)
362
383
  api.on('session_start', (event, ctx) => {
363
384
  post('/session/start', {
364
- sessionId: ctx.sessionId ?? event.sessionId,
365
- userId: ctx.agentId,
385
+ sessionId: ctx.sessionId ?? event.sessionId ?? '',
386
+ userId: ctx.agentId ?? '',
366
387
  agentId: instanceId,
367
388
  metadata: event.metadata ?? {},
368
- }).catch(() => { });
389
+ }).catch((e) => log.warn('[SafeClaw] Failed to record session start:', e));
369
390
  });
370
391
  // Session lifecycle — notify service of session end (#189)
371
392
  api.on('session_end', (event, ctx) => {
372
393
  post('/session/end', {
373
- sessionId: ctx.sessionId ?? event.sessionId,
374
- userId: ctx.agentId,
394
+ sessionId: ctx.sessionId ?? event.sessionId ?? '',
395
+ userId: ctx.agentId ?? '',
375
396
  agentId: instanceId,
376
- }).catch(() => { });
397
+ }).catch((e) => log.warn('[SafeClaw] Failed to record session end:', e));
377
398
  });
378
399
  // Inbound message governance — evaluate received messages (#190)
379
400
  api.on('message_received', (event, ctx) => {
380
401
  post('/evaluate/inbound-message', {
381
- sessionId: ctx.sessionId ?? event.sessionId,
382
- userId: ctx.agentId,
402
+ sessionId: ctx.sessionId ?? event.sessionId ?? '',
403
+ userId: ctx.agentId ?? '',
383
404
  channel: event.channel ?? ctx.channelId ?? '',
384
405
  sender: event.sender ?? '',
385
406
  content: event.content ?? '',
386
407
  metadata: event.metadata ?? {},
387
- }).catch(() => { });
408
+ }).catch((e) => log.warn('[SafeClaw] Failed to evaluate inbound message:', e));
388
409
  });
389
410
  // Agent tools — let agents introspect governance state (#197)
390
411
  if (api.registerTool) {
@@ -5,7 +5,7 @@
5
5
  * Reads ~/.safeclaw/config.json, applies env-var overrides,
6
6
  * and exposes helpers for saving and hashing config state.
7
7
  */
8
- import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
8
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
9
9
  import { join, dirname } from 'path';
10
10
  import { homedir } from 'os';
11
11
  import crypto from 'crypto';
@@ -167,6 +167,10 @@ export function saveConfig(config) {
167
167
  mkdirSync(dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
168
168
  try {
169
169
  writeFileSync(CONFIG_PATH, JSON.stringify(existing, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
170
+ try {
171
+ chmodSync(CONFIG_PATH, 0o600);
172
+ }
173
+ catch { /* best-effort */ }
170
174
  }
171
175
  catch (e) {
172
176
  const code = e.code;
@@ -189,6 +193,7 @@ export function configHash(config) {
189
193
  enforcement: config.enforcement,
190
194
  failMode: config.failMode,
191
195
  serviceUrl: config.serviceUrl,
196
+ apiKeyFingerprint: config.apiKey ? config.apiKey.slice(0, 4) + config.apiKey.slice(-4) : '',
192
197
  });
193
198
  return crypto.createHash('sha256').update(payload).digest('hex');
194
199
  }
package/index.ts CHANGED
@@ -7,7 +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
+ import type { OpenClawPluginApi, OpenClawPluginEvent, OpenClawPluginContext, BeforeToolCallResult } from 'openclaw/plugin-sdk/core';
11
11
  import { loadConfig, configHash } from './tui/config.js';
12
12
  import crypto from 'crypto';
13
13
  import { createRequire } from 'module';
@@ -107,6 +107,7 @@ async function get(path: string): Promise<Record<string, unknown> | null> {
107
107
  // --- Plugin Definition ---
108
108
 
109
109
  let handshakeCompleted = false;
110
+ let lastHandshakeConfigHash = '';
110
111
 
111
112
  async function performHandshake(): Promise<boolean> {
112
113
  const cfg = getConfig();
@@ -127,6 +128,7 @@ async function performHandshake(): Promise<boolean> {
127
128
 
128
129
  log.info(`[SafeClaw] Handshake OK — org=${r.orgId}, scope=${r.scope}, engine=${r.engineReady ? 'ready' : 'not ready'}`);
129
130
  handshakeCompleted = true;
131
+ lastHandshakeConfigHash = configHash(getConfig());
130
132
  return true;
131
133
  }
132
134
 
@@ -176,9 +178,15 @@ export default {
176
178
  // Heartbeat watchdog — send config hash to service every 30s
177
179
  const sendHeartbeat = async () => {
178
180
  try {
181
+ const currentHash = configHash(getConfig());
182
+ if (handshakeCompleted && currentHash !== lastHandshakeConfigHash) {
183
+ log.info('[SafeClaw] Config changed — re-authenticating');
184
+ handshakeCompleted = false;
185
+ performHandshake().catch(() => {});
186
+ }
179
187
  await post('/heartbeat', {
180
188
  agentId: instanceId,
181
- configHash: configHash(getConfig()),
189
+ configHash: currentHash,
182
190
  status: 'alive',
183
191
  });
184
192
  } catch {
@@ -254,6 +262,21 @@ export default {
254
262
  } else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
255
263
  log.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, audit-only)`);
256
264
  }
265
+
266
+ // If service says confirmation required, use OpenClaw's native approval flow
267
+ if (r?.confirmationRequired) {
268
+ const riskLevel = (r.riskLevel as string) || '';
269
+ return {
270
+ requireApproval: {
271
+ title: 'SafeClaw Governance Check',
272
+ description: (r.reason as string) || 'This action requires confirmation',
273
+ severity: riskLevel === 'HighRisk' ? 'critical' : riskLevel === 'MediumRisk' ? 'warning' : 'info',
274
+ timeoutMs: 30_000,
275
+ timeoutBehavior: riskLevel === 'HighRisk' ? 'deny' : 'allow',
276
+ },
277
+ } satisfies BeforeToolCallResult;
278
+ }
279
+
257
280
  if (r?.block) {
258
281
  const blockReason = (r.reason as string) || 'Blocked by SafeClaw (no reason provided)';
259
282
  if (cfg.enforcement === 'enforce') {
@@ -320,7 +343,7 @@ export default {
320
343
  content: event.prompt ?? '',
321
344
  provider: event.provider ?? '',
322
345
  model: event.model ?? '',
323
- }).catch(() => {});
346
+ }).catch((e) => log.warn('[SafeClaw] Failed to log LLM input:', e));
324
347
  });
325
348
 
326
349
  api.on('llm_output', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
@@ -330,7 +353,7 @@ export default {
330
353
  provider: event.provider ?? '',
331
354
  model: event.model ?? '',
332
355
  usage: event.usage ?? {},
333
- }).catch(() => {});
356
+ }).catch((e) => log.warn('[SafeClaw] Failed to log LLM output:', e));
334
357
  });
335
358
 
336
359
  // (#195: use event.toolName, !event.error for success, add durationMs and error)
@@ -350,8 +373,8 @@ export default {
350
373
  api.on('subagent_spawning', async (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
351
374
  const cfg = getConfig();
352
375
  const r = await post('/evaluate/subagent-spawn', {
353
- sessionId: ctx.sessionId ?? event.sessionId,
354
- userId: ctx.agentId,
376
+ sessionId: ctx.sessionId ?? event.sessionId ?? '',
377
+ userId: ctx.agentId ?? '',
355
378
  parentAgentId: event.parentAgentId,
356
379
  childConfig: event.childConfig ?? {},
357
380
  reason: event.reason ?? '',
@@ -359,7 +382,7 @@ export default {
359
382
 
360
383
  // Fail-closed handling (matches before_tool_call / message_sending pattern)
361
384
  if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
362
- throw new Error('SafeClaw service unavailable (fail-closed mode)');
385
+ return { status: 'error', error: 'SafeClaw service unavailable (fail-closed mode)' };
363
386
  } else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
364
387
  log.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
365
388
  } else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
@@ -367,7 +390,7 @@ export default {
367
390
  }
368
391
 
369
392
  if (r?.block && cfg.enforcement === 'enforce') {
370
- throw new Error((r.reason as string) || 'Blocked by SafeClaw: delegation bypass detected');
393
+ return { status: 'error', error: (r.reason as string) || 'Blocked by SafeClaw: delegation bypass detected' };
371
394
  }
372
395
  if (r?.block && cfg.enforcement === 'warn-only') {
373
396
  log.warn(`[SafeClaw] Subagent spawn warning: ${r.reason}`);
@@ -377,41 +400,41 @@ export default {
377
400
  // Subagent ended — record child agent lifecycle (#188)
378
401
  api.on('subagent_ended', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
379
402
  post('/record/subagent-ended', {
380
- sessionId: ctx.sessionId ?? event.sessionId,
403
+ sessionId: ctx.sessionId ?? event.sessionId ?? '',
381
404
  parentAgentId: event.parentAgentId,
382
405
  childAgentId: event.childAgentId,
383
- }).catch(() => {});
406
+ }).catch((e) => log.warn('[SafeClaw] Failed to record subagent ended:', e));
384
407
  });
385
408
 
386
409
  // Session lifecycle — notify service of session start (#189)
387
410
  api.on('session_start', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
388
411
  post('/session/start', {
389
- sessionId: ctx.sessionId ?? event.sessionId,
390
- userId: ctx.agentId,
412
+ sessionId: ctx.sessionId ?? event.sessionId ?? '',
413
+ userId: ctx.agentId ?? '',
391
414
  agentId: instanceId,
392
415
  metadata: event.metadata ?? {},
393
- }).catch(() => {});
416
+ }).catch((e) => log.warn('[SafeClaw] Failed to record session start:', e));
394
417
  });
395
418
 
396
419
  // Session lifecycle — notify service of session end (#189)
397
420
  api.on('session_end', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
398
421
  post('/session/end', {
399
- sessionId: ctx.sessionId ?? event.sessionId,
400
- userId: ctx.agentId,
422
+ sessionId: ctx.sessionId ?? event.sessionId ?? '',
423
+ userId: ctx.agentId ?? '',
401
424
  agentId: instanceId,
402
- }).catch(() => {});
425
+ }).catch((e) => log.warn('[SafeClaw] Failed to record session end:', e));
403
426
  });
404
427
 
405
428
  // Inbound message governance — evaluate received messages (#190)
406
429
  api.on('message_received', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
407
430
  post('/evaluate/inbound-message', {
408
- sessionId: ctx.sessionId ?? event.sessionId,
409
- userId: ctx.agentId,
431
+ sessionId: ctx.sessionId ?? event.sessionId ?? '',
432
+ userId: ctx.agentId ?? '',
410
433
  channel: event.channel ?? (ctx as any).channelId ?? '',
411
434
  sender: event.sender ?? '',
412
435
  content: event.content ?? '',
413
436
  metadata: event.metadata ?? {},
414
- }).catch(() => {});
437
+ }).catch((e) => log.warn('[SafeClaw] Failed to evaluate inbound message:', e));
415
438
  });
416
439
 
417
440
  // Agent tools — let agents introspect governance state (#197)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-safeclaw-plugin",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
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",
package/tui/config.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * and exposes helpers for saving and hashing config state.
7
7
  */
8
8
 
9
- import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
9
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
10
10
  import { join, dirname } from 'path';
11
11
  import { homedir } from 'os';
12
12
  import crypto from 'crypto';
@@ -181,6 +181,7 @@ export function saveConfig(config: SafeClawConfig): void {
181
181
 
182
182
  try {
183
183
  writeFileSync(CONFIG_PATH, JSON.stringify(existing, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
184
+ try { chmodSync(CONFIG_PATH, 0o600); } catch { /* best-effort */ }
184
185
  } catch (e) {
185
186
  const code = (e as NodeJS.ErrnoException).code;
186
187
  if (code === 'EROFS') {
@@ -203,6 +204,7 @@ export function configHash(config: SafeClawConfig): string {
203
204
  enforcement: config.enforcement,
204
205
  failMode: config.failMode,
205
206
  serviceUrl: config.serviceUrl,
207
+ apiKeyFingerprint: config.apiKey ? config.apiKey.slice(0, 4) + config.apiKey.slice(-4) : '',
206
208
  });
207
209
  return crypto.createHash('sha256').update(payload).digest('hex');
208
210
  }
@@ -1,11 +1,17 @@
1
1
  /**
2
2
  * Ambient type declarations for OpenClaw Plugin SDK.
3
- * These match the types exported by openclaw/plugin-sdk as of v2026.3.
3
+ * These match the types exported by openclaw/plugin-sdk as of v2026.4.
4
4
  * When installed inside OpenClaw, the real SDK types take precedence.
5
5
  */
6
6
 
7
7
  declare module 'openclaw/plugin-sdk/core' {
8
8
  export interface OpenClawPluginApi {
9
+ /**
10
+ * Register a hook handler for OpenClaw lifecycle events.
11
+ *
12
+ * @deprecated The `before_agent_start` hook is deprecated in v2026.4.
13
+ * Use `before_model_resolve` + `before_prompt_build` instead.
14
+ */
9
15
  on(
10
16
  hookName: string,
11
17
  handler: (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => Promise<Record<string, unknown> | void> | void,
@@ -33,6 +39,9 @@ declare module 'openclaw/plugin-sdk/core' {
33
39
  model?: string;
34
40
  lastAssistant?: string;
35
41
  usage?: Record<string, unknown>;
42
+ runId?: string;
43
+ toolCallId?: string;
44
+ childAgentId?: string;
36
45
  parentAgentId?: string;
37
46
  childConfig?: Record<string, unknown>;
38
47
  reason?: string;
@@ -66,6 +75,32 @@ declare module 'openclaw/plugin-sdk/core' {
66
75
  execute: (params: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<unknown>;
67
76
  }
68
77
 
78
+ export interface PluginApprovalRequest {
79
+ title: string;
80
+ description: string;
81
+ severity?: 'info' | 'warning' | 'critical';
82
+ timeoutMs?: number;
83
+ timeoutBehavior?: 'allow' | 'deny';
84
+ pluginId?: string;
85
+ onResolution?: (decision: PluginApprovalResolution) => Promise<void> | void;
86
+ }
87
+
88
+ export interface PluginApprovalResolution {
89
+ approved: boolean;
90
+ resolvedBy?: string;
91
+ resolvedAt?: string;
92
+ }
93
+
94
+ /**
95
+ * Hook result types for before_tool_call.
96
+ * Return one of: nothing (allow), { block, blockReason }, or { requireApproval }.
97
+ */
98
+ export interface BeforeToolCallResult {
99
+ block?: boolean;
100
+ blockReason?: string;
101
+ requireApproval?: PluginApprovalRequest;
102
+ }
103
+
69
104
  export interface OpenClawPluginLogger {
70
105
  info: (...args: unknown[]) => void;
71
106
  warn: (...args: unknown[]) => void;