openclaw-safeclaw-plugin 1.4.1 → 2.0.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 CHANGED
@@ -130,7 +130,7 @@ The plugin registers 11 hooks on OpenClaw events. Each hook communicates with th
130
130
  |------|----------|-------------|
131
131
  | `before_tool_call` | 100 | The main gate. Evaluates every tool call against SHACL shapes, policies, preferences, and dependencies. Returns `{ block: true }` if the action violates constraints. |
132
132
  | `message_sending` | 100 | Checks outbound messages for sensitive data leaks, contact rule violations, and content policy. Returns `{ cancel: true }` to block. |
133
- | `subagent_spawning` | 100 | Evaluates child agent spawn requests. Detects delegation bypass attempts where a blocked parent tries to spawn an unrestricted child. |
133
+ | `subagent_spawning` | 100 | Forwards child-spawn requests (parent/child session keys, child agent id) to the service. **Note:** under OpenClaw v2026.6.8 this hook exposes only session keys — no parent agent id — so spawn-time delegation-bypass *enforcement* is deferred to the session-keyed hierarchy work (#321); today this hook is observational. Child tool calls are still gated individually by `before_tool_call`. |
134
134
 
135
135
  ### Context hooks (modify agent behavior)
136
136
 
@@ -150,6 +150,16 @@ The plugin registers 11 hooks on OpenClaw events. Each hook communicates with th
150
150
  | `session_end` | Notifies the service when a session ends. |
151
151
  | `message_received` | Evaluates inbound messages for governance (sender, channel, content). |
152
152
 
153
+ > **Required for LLM audit logging.** As of OpenClaw v2026.6.8, raw-conversation
154
+ > hooks (`llm_input`, `llm_output`, and the `before_agent_*` family) only fire for
155
+ > non-bundled plugins when conversation access is explicitly granted. Without it,
156
+ > SafeClaw's LLM I/O audit trail is silently empty. Enable it in your OpenClaw
157
+ > config:
158
+ >
159
+ > ```json
160
+ > { "plugins": { "entries": { "safeclaw": { "hooks": { "allowConversationAccess": true } } } } }
161
+ > ```
162
+
153
163
  ## Agent Tools
154
164
 
155
165
  The plugin registers two tools that agents can call to introspect governance state.
package/dist/index.js CHANGED
@@ -97,6 +97,23 @@ async function get(path) {
97
97
  return null;
98
98
  }
99
99
  }
100
+ function approvalSeverityForRisk(riskLevel) {
101
+ if (riskLevel === 'CriticalRisk' || riskLevel === 'HighRisk')
102
+ return 'critical';
103
+ if (riskLevel === 'MediumRisk')
104
+ return 'warning';
105
+ return 'info';
106
+ }
107
+ function approvalTimeoutBehaviorForRisk(riskLevel) {
108
+ return riskLevel === 'CriticalRisk' || riskLevel === 'HighRisk' ? 'deny' : 'allow';
109
+ }
110
+ // For high-risk actions, forbid durable "allow-always" trust — the user may only
111
+ // approve this one occurrence or deny (#314). Lower-risk actions keep all options.
112
+ function approvalAllowedDecisionsForRisk(riskLevel) {
113
+ return riskLevel === 'CriticalRisk' || riskLevel === 'HighRisk'
114
+ ? ['allow-once', 'deny']
115
+ : ['allow-once', 'allow-always', 'deny'];
116
+ }
100
117
  // --- Plugin Definition ---
101
118
  let handshakeCompleted = false;
102
119
  let lastHandshakeConfigHash = '';
@@ -230,11 +247,19 @@ export default {
230
247
  return { block: true, blockReason: 'SafeClaw handshake not completed (fail-closed)' };
231
248
  }
232
249
  const r = await post('/evaluate/tool-call', {
233
- sessionId: ctx.sessionId ?? event.sessionId ?? '',
250
+ sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
234
251
  userId: ctx.agentId ?? '',
235
252
  toolName: event.toolName ?? '',
236
253
  params: event.params ?? {},
237
254
  runId: ctx.runId ?? '',
255
+ // v2026.6.8 discriminators so the service can classify code-mode exec,
256
+ // file-touching envelopes, etc. (#316)
257
+ toolKind: event.toolKind ?? '',
258
+ toolInputKind: event.toolInputKind ?? '',
259
+ derivedPaths: event.derivedPaths ?? [],
260
+ // Trigger origin (#324): cron-driven runs carry ctx.jobId.
261
+ triggeredBy: ctx.jobId ? 'cron' : 'interactive',
262
+ jobId: ctx.jobId ?? '',
238
263
  });
239
264
  if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
240
265
  return { block: true, blockReason: `SafeClaw service unavailable at ${cfg.serviceUrl} (fail-closed)` };
@@ -245,18 +270,26 @@ export default {
245
270
  else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
246
271
  log.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, audit-only)`);
247
272
  }
248
- // If service says confirmation required, use OpenClaw's native approval flow
273
+ // If service says confirmation required, use OpenClaw's native approval flow.
274
+ // Carry any param rewrite (#316) on the same result — OpenClaw applies it as
275
+ // the approval's overrideParams once the user approves, so the post-approval
276
+ // execution still runs the governed (sanitized) params.
249
277
  if (r?.confirmationRequired) {
250
278
  const riskLevel = r.riskLevel || '';
251
- return {
279
+ const result = {
252
280
  requireApproval: {
253
281
  title: 'SafeClaw Governance Check',
254
282
  description: r.reason || 'This action requires confirmation',
255
- severity: riskLevel === 'HighRisk' ? 'critical' : riskLevel === 'MediumRisk' ? 'warning' : 'info',
283
+ severity: approvalSeverityForRisk(riskLevel),
256
284
  timeoutMs: 30_000,
257
- timeoutBehavior: riskLevel === 'HighRisk' ? 'deny' : 'allow',
285
+ timeoutBehavior: approvalTimeoutBehaviorForRisk(riskLevel),
286
+ allowedDecisions: approvalAllowedDecisionsForRisk(riskLevel),
258
287
  },
259
288
  };
289
+ if (r.params && typeof r.params === 'object') {
290
+ result.params = r.params;
291
+ }
292
+ return result;
260
293
  }
261
294
  if (r?.block) {
262
295
  const blockReason = r.reason || 'Blocked by SafeClaw (no reason provided)';
@@ -268,24 +301,31 @@ export default {
268
301
  }
269
302
  // audit-only: logged server-side, no action here
270
303
  }
304
+ // Param rewrite (#316): the service only returns `params` for an allowed
305
+ // call when it sanitized the input (e.g. stripped control characters), so
306
+ // the tool executes the governed params rather than the raw ones.
307
+ if (r?.params && typeof r.params === 'object') {
308
+ return { params: r.params };
309
+ }
271
310
  }, { priority: 100 });
272
311
  // Context injection — prepend governance context to agent system prompt
273
312
  // (#195: before_agent_start is deprecated; use before_prompt_build + prependSystemContext)
274
313
  api.on('before_prompt_build', async (event, ctx) => {
275
314
  const r = await post('/context/build', {
276
- sessionId: ctx.sessionId ?? event.sessionId ?? '',
315
+ sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
277
316
  userId: ctx.agentId ?? '',
278
317
  });
279
318
  if (r?.prependContext) {
280
319
  return { prependSystemContext: r.prependContext };
281
320
  }
282
321
  }, { priority: 100 });
283
- // Message governance — check outbound messages
284
- // (#195: use ctx.conversationId/sessionId, ctx.accountId; return only { cancel: true })
322
+ // Message governance — check outbound messages.
323
+ // (v2026.6.8 message context exposes sessionKey/accountId/channelId there is
324
+ // no sessionId; the event has no session field. Return only { cancel: true }.)
285
325
  api.on('message_sending', async (event, ctx) => {
286
326
  const cfg = getConfig();
287
327
  const r = await post('/evaluate/message', {
288
- sessionId: ctx.conversationId ?? ctx.sessionId ?? event.sessionId ?? '',
328
+ sessionId: ctx.sessionKey ?? ctx.conversationId ?? '',
289
329
  userId: ctx.accountId ?? '',
290
330
  to: event.to,
291
331
  content: event.content,
@@ -313,46 +353,73 @@ export default {
313
353
  // audit-only: logged server-side, no action here
314
354
  }
315
355
  }, { priority: 100 });
316
- // Async logging — fire-and-forget, no return value needed
317
- // (#195: use event.prompt for input, event.lastAssistant for output; add provider/model)
356
+ // Async logging — fire-and-forget, no return value needed.
357
+ // (#315: v2026.6.8 raw-conversation hooks require
358
+ // `plugins.entries.safeclaw.hooks.allowConversationAccess: true` to fire at
359
+ // all — see README. Output text is `assistantTexts: string[]`, not the
360
+ // untyped `lastAssistant`. `runId` is forwarded for audit correlation.)
318
361
  api.on('llm_input', (event, ctx) => {
319
362
  post('/log/llm-input', {
320
- sessionId: event.sessionId ?? ctx.sessionId ?? '',
363
+ sessionId: ctx.sessionKey ?? event.sessionId ?? ctx.sessionId ?? '',
321
364
  content: event.prompt ?? '',
322
365
  provider: event.provider ?? '',
323
366
  model: event.model ?? '',
367
+ runId: event.runId ?? ctx.runId ?? '',
324
368
  }).catch((e) => log.warn('[SafeClaw] Failed to log LLM input:', e));
325
369
  });
326
370
  api.on('llm_output', (event, ctx) => {
371
+ // Join assistant segments with a blank line, matching how OpenClaw v2026.6.8
372
+ // itself joins `assistantTexts`, so message boundaries are preserved in the
373
+ // audit content (joining with '' would fuse "first"+"second" into one blob).
374
+ const assistantText = Array.isArray(event.assistantTexts)
375
+ ? event.assistantTexts.join('\n\n')
376
+ : typeof event.lastAssistant === 'string'
377
+ ? event.lastAssistant
378
+ : '';
327
379
  post('/log/llm-output', {
328
- sessionId: event.sessionId ?? ctx.sessionId ?? '',
329
- content: event.lastAssistant ?? '',
380
+ sessionId: ctx.sessionKey ?? event.sessionId ?? ctx.sessionId ?? '',
381
+ content: assistantText,
330
382
  provider: event.provider ?? '',
331
383
  model: event.model ?? '',
332
384
  usage: event.usage ?? {},
385
+ runId: event.runId ?? ctx.runId ?? '',
333
386
  }).catch((e) => log.warn('[SafeClaw] Failed to log LLM output:', e));
334
387
  });
335
388
  // (#195: use event.toolName, !event.error for success, add durationMs and error)
336
389
  api.on('after_tool_call', (event, ctx) => {
337
390
  post('/record/tool-result', {
338
- sessionId: ctx.sessionId ?? event.sessionId ?? '',
391
+ sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
392
+ userId: ctx.agentId ?? '',
339
393
  toolName: event.toolName ?? '',
340
394
  params: event.params ?? {},
341
395
  result: event.result ?? '',
342
396
  success: !event.error,
343
397
  error: event.error ? String(event.error) : '',
344
398
  durationMs: event.durationMs ?? 0,
399
+ runId: ctx.runId ?? event.runId ?? '',
345
400
  }).catch((e) => log.warn('[SafeClaw] Failed to record tool result:', e));
346
401
  });
347
- // Subagent governance — block delegation bypass attempts (#188)
402
+ // Subagent governance — block delegation bypass attempts (#188).
403
+ // NOTE: `subagent_spawning` is a deprecated OpenClaw compatibility hook
404
+ // (removal scheduled after 2026-08-16). In v2026.6.8 the context exposes only
405
+ // session keys (`requesterSessionKey`/`childSessionKey`/`runId`) — there is NO
406
+ // parent agentId at this hook. The event carries the *child's* `agentId`. So
407
+ // we forward the parent/child session keys and the child agent id; the service
408
+ // cannot do the killed-PARENT lookup from a session key alone, so spawn-time
409
+ // delegation enforcement is reworked onto a session-keyed hierarchy in #321.
410
+ // `requester` is an object (channel/account/to/threadId), not a string. (#312)
348
411
  api.on('subagent_spawning', async (event, ctx) => {
349
412
  const cfg = getConfig();
350
413
  const r = await post('/evaluate/subagent-spawn', {
351
- sessionId: ctx.sessionId ?? event.sessionId ?? '',
352
- userId: ctx.agentId ?? '',
353
- parentAgentId: event.parentAgentId,
354
- childConfig: event.childConfig ?? {},
355
- reason: event.reason ?? '',
414
+ sessionId: ctx.requesterSessionKey ?? ctx.childSessionKey ?? '',
415
+ parentSessionKey: ctx.requesterSessionKey ?? '',
416
+ childSessionKey: ctx.childSessionKey ?? event.childSessionKey ?? '',
417
+ childAgentId: event.agentId ?? '',
418
+ mode: event.mode ?? '',
419
+ threadRequested: event.threadRequested ?? false,
420
+ requester: event.requester ?? {},
421
+ runId: ctx.runId ?? '',
422
+ reason: event.label ?? '',
356
423
  });
357
424
  // Fail-closed handling (matches before_tool_call / message_sending pattern)
358
425
  if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
@@ -371,18 +438,26 @@ export default {
371
438
  log.warn(`[SafeClaw] Subagent spawn warning: ${r.reason}`);
372
439
  }
373
440
  }, { priority: 100 });
374
- // Subagent ended — record child agent lifecycle (#188)
441
+ // Subagent ended — record child agent lifecycle (#188).
442
+ // v2026.6.8 reports `targetSessionKey`/`targetKind`/`outcome`/`error`/`runId`,
443
+ // not parent/child agent IDs. The ended child is `targetSessionKey`; the
444
+ // parent is the requester session key from the subagent context. (#312)
375
445
  api.on('subagent_ended', (event, ctx) => {
376
446
  post('/record/subagent-ended', {
377
- sessionId: ctx.sessionId ?? event.sessionId ?? '',
378
- parentAgentId: event.parentAgentId,
379
- childAgentId: event.childAgentId,
447
+ sessionId: ctx.requesterSessionKey ?? event.targetSessionKey ?? '',
448
+ parentSessionKey: ctx.requesterSessionKey ?? '',
449
+ childSessionKey: event.targetSessionKey ?? '',
450
+ targetKind: event.targetKind ?? '',
451
+ outcome: event.outcome ?? '',
452
+ success: event.outcome ? event.outcome === 'ok' : !event.error,
453
+ error: event.error ?? '',
454
+ runId: event.runId ?? ctx.runId ?? '',
380
455
  }).catch((e) => log.warn('[SafeClaw] Failed to record subagent ended:', e));
381
456
  });
382
457
  // Session lifecycle — notify service of session start (#189)
383
458
  api.on('session_start', (event, ctx) => {
384
459
  post('/session/start', {
385
- sessionId: ctx.sessionId ?? event.sessionId ?? '',
460
+ sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
386
461
  userId: ctx.agentId ?? '',
387
462
  agentId: instanceId,
388
463
  metadata: event.metadata ?? {},
@@ -391,18 +466,23 @@ export default {
391
466
  // Session lifecycle — notify service of session end (#189)
392
467
  api.on('session_end', (event, ctx) => {
393
468
  post('/session/end', {
394
- sessionId: ctx.sessionId ?? event.sessionId ?? '',
469
+ sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
395
470
  userId: ctx.agentId ?? '',
396
471
  agentId: instanceId,
397
472
  }).catch((e) => log.warn('[SafeClaw] Failed to record session end:', e));
398
473
  });
399
- // Inbound message governance — evaluate received messages (#190)
474
+ // Inbound message governance — evaluate received messages (#190).
475
+ // v2026.6.8 message context: sender is `event.from` (+ stable `event.senderId`),
476
+ // the session is `ctx.sessionKey`, and the channel id is on the context. The
477
+ // message context has no agentId (use accountId) and no provider field, so the
478
+ // service maps channel trust from `channelId`/`conversationId` (#313, feeds #320).
400
479
  api.on('message_received', (event, ctx) => {
401
480
  post('/evaluate/inbound-message', {
402
- sessionId: ctx.sessionId ?? event.sessionId ?? '',
403
- userId: ctx.agentId ?? '',
404
- channel: event.channel ?? ctx.channelId ?? '',
405
- sender: event.sender ?? '',
481
+ sessionId: ctx.sessionKey ?? event.sessionKey ?? '',
482
+ userId: ctx.accountId ?? '',
483
+ channel: ctx.channelId ?? '',
484
+ conversationId: ctx.conversationId ?? '',
485
+ sender: event.senderId ?? ctx.senderId ?? event.from ?? '',
406
486
  content: event.content ?? '',
407
487
  metadata: event.metadata ?? {},
408
488
  }).catch((e) => log.warn('[SafeClaw] Failed to evaluate inbound message:', e));
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, BeforeToolCallResult } from 'openclaw/plugin-sdk/core';
10
+ import type { OpenClawPluginApi, 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';
@@ -104,6 +104,26 @@ async function get(path: string): Promise<Record<string, unknown> | null> {
104
104
  }
105
105
  }
106
106
 
107
+ function approvalSeverityForRisk(riskLevel: string): 'info' | 'warning' | 'critical' {
108
+ if (riskLevel === 'CriticalRisk' || riskLevel === 'HighRisk') return 'critical';
109
+ if (riskLevel === 'MediumRisk') return 'warning';
110
+ return 'info';
111
+ }
112
+
113
+ function approvalTimeoutBehaviorForRisk(riskLevel: string): 'allow' | 'deny' {
114
+ return riskLevel === 'CriticalRisk' || riskLevel === 'HighRisk' ? 'deny' : 'allow';
115
+ }
116
+
117
+ // For high-risk actions, forbid durable "allow-always" trust — the user may only
118
+ // approve this one occurrence or deny (#314). Lower-risk actions keep all options.
119
+ function approvalAllowedDecisionsForRisk(
120
+ riskLevel: string,
121
+ ): Array<'allow-once' | 'allow-always' | 'deny'> {
122
+ return riskLevel === 'CriticalRisk' || riskLevel === 'HighRisk'
123
+ ? ['allow-once', 'deny']
124
+ : ['allow-once', 'allow-always', 'deny'];
125
+ }
126
+
107
127
  // --- Plugin Definition ---
108
128
 
109
129
  let handshakeCompleted = false;
@@ -241,18 +261,26 @@ export default {
241
261
  }
242
262
 
243
263
  // THE GATE — constraint checking on every tool call (#195: use correct OpenClaw field names)
244
- api.on('before_tool_call', async (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
264
+ api.on('before_tool_call', async (event, ctx) => {
245
265
  const cfg = getConfig();
246
266
  if (!handshakeCompleted && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
247
267
  return { block: true, blockReason: 'SafeClaw handshake not completed (fail-closed)' };
248
268
  }
249
269
 
250
270
  const r = await post('/evaluate/tool-call', {
251
- sessionId: ctx.sessionId ?? event.sessionId ?? '',
271
+ sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
252
272
  userId: ctx.agentId ?? '',
253
273
  toolName: event.toolName ?? '',
254
274
  params: event.params ?? {},
255
275
  runId: ctx.runId ?? '',
276
+ // v2026.6.8 discriminators so the service can classify code-mode exec,
277
+ // file-touching envelopes, etc. (#316)
278
+ toolKind: event.toolKind ?? '',
279
+ toolInputKind: event.toolInputKind ?? '',
280
+ derivedPaths: event.derivedPaths ?? [],
281
+ // Trigger origin (#324): cron-driven runs carry ctx.jobId.
282
+ triggeredBy: ctx.jobId ? 'cron' : 'interactive',
283
+ jobId: ctx.jobId ?? '',
256
284
  });
257
285
 
258
286
  if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
@@ -263,18 +291,26 @@ export default {
263
291
  log.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, audit-only)`);
264
292
  }
265
293
 
266
- // If service says confirmation required, use OpenClaw's native approval flow
294
+ // If service says confirmation required, use OpenClaw's native approval flow.
295
+ // Carry any param rewrite (#316) on the same result — OpenClaw applies it as
296
+ // the approval's overrideParams once the user approves, so the post-approval
297
+ // execution still runs the governed (sanitized) params.
267
298
  if (r?.confirmationRequired) {
268
299
  const riskLevel = (r.riskLevel as string) || '';
269
- return {
300
+ const result: BeforeToolCallResult = {
270
301
  requireApproval: {
271
302
  title: 'SafeClaw Governance Check',
272
303
  description: (r.reason as string) || 'This action requires confirmation',
273
- severity: riskLevel === 'HighRisk' ? 'critical' : riskLevel === 'MediumRisk' ? 'warning' : 'info',
304
+ severity: approvalSeverityForRisk(riskLevel),
274
305
  timeoutMs: 30_000,
275
- timeoutBehavior: riskLevel === 'HighRisk' ? 'deny' : 'allow',
306
+ timeoutBehavior: approvalTimeoutBehaviorForRisk(riskLevel),
307
+ allowedDecisions: approvalAllowedDecisionsForRisk(riskLevel),
276
308
  },
277
- } satisfies BeforeToolCallResult;
309
+ };
310
+ if (r.params && typeof r.params === 'object') {
311
+ result.params = r.params as Record<string, unknown>;
312
+ }
313
+ return result satisfies BeforeToolCallResult;
278
314
  }
279
315
 
280
316
  if (r?.block) {
@@ -287,13 +323,20 @@ export default {
287
323
  }
288
324
  // audit-only: logged server-side, no action here
289
325
  }
326
+
327
+ // Param rewrite (#316): the service only returns `params` for an allowed
328
+ // call when it sanitized the input (e.g. stripped control characters), so
329
+ // the tool executes the governed params rather than the raw ones.
330
+ if (r?.params && typeof r.params === 'object') {
331
+ return { params: r.params as Record<string, unknown> };
332
+ }
290
333
  }, { priority: 100 });
291
334
 
292
335
  // Context injection — prepend governance context to agent system prompt
293
336
  // (#195: before_agent_start is deprecated; use before_prompt_build + prependSystemContext)
294
- api.on('before_prompt_build', async (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
337
+ api.on('before_prompt_build', async (event, ctx) => {
295
338
  const r = await post('/context/build', {
296
- sessionId: ctx.sessionId ?? event.sessionId ?? '',
339
+ sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
297
340
  userId: ctx.agentId ?? '',
298
341
  });
299
342
 
@@ -302,12 +345,13 @@ export default {
302
345
  }
303
346
  }, { priority: 100 });
304
347
 
305
- // Message governance — check outbound messages
306
- // (#195: use ctx.conversationId/sessionId, ctx.accountId; return only { cancel: true })
307
- api.on('message_sending', async (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
348
+ // Message governance — check outbound messages.
349
+ // (v2026.6.8 message context exposes sessionKey/accountId/channelId there is
350
+ // no sessionId; the event has no session field. Return only { cancel: true }.)
351
+ api.on('message_sending', async (event, ctx) => {
308
352
  const cfg = getConfig();
309
353
  const r = await post('/evaluate/message', {
310
- sessionId: ctx.conversationId ?? ctx.sessionId ?? event.sessionId ?? '',
354
+ sessionId: ctx.sessionKey ?? ctx.conversationId ?? '',
311
355
  userId: ctx.accountId ?? '',
312
356
  to: event.to,
313
357
  content: event.content,
@@ -335,49 +379,76 @@ export default {
335
379
  }
336
380
  }, { priority: 100 });
337
381
 
338
- // Async logging — fire-and-forget, no return value needed
339
- // (#195: use event.prompt for input, event.lastAssistant for output; add provider/model)
340
- api.on('llm_input', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
382
+ // Async logging — fire-and-forget, no return value needed.
383
+ // (#315: v2026.6.8 raw-conversation hooks require
384
+ // `plugins.entries.safeclaw.hooks.allowConversationAccess: true` to fire at
385
+ // all — see README. Output text is `assistantTexts: string[]`, not the
386
+ // untyped `lastAssistant`. `runId` is forwarded for audit correlation.)
387
+ api.on('llm_input', (event, ctx) => {
341
388
  post('/log/llm-input', {
342
- sessionId: event.sessionId ?? ctx.sessionId ?? '',
389
+ sessionId: ctx.sessionKey ?? event.sessionId ?? ctx.sessionId ?? '',
343
390
  content: event.prompt ?? '',
344
391
  provider: event.provider ?? '',
345
392
  model: event.model ?? '',
393
+ runId: event.runId ?? ctx.runId ?? '',
346
394
  }).catch((e) => log.warn('[SafeClaw] Failed to log LLM input:', e));
347
395
  });
348
396
 
349
- api.on('llm_output', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
397
+ api.on('llm_output', (event, ctx) => {
398
+ // Join assistant segments with a blank line, matching how OpenClaw v2026.6.8
399
+ // itself joins `assistantTexts`, so message boundaries are preserved in the
400
+ // audit content (joining with '' would fuse "first"+"second" into one blob).
401
+ const assistantText = Array.isArray(event.assistantTexts)
402
+ ? event.assistantTexts.join('\n\n')
403
+ : typeof event.lastAssistant === 'string'
404
+ ? event.lastAssistant
405
+ : '';
350
406
  post('/log/llm-output', {
351
- sessionId: event.sessionId ?? ctx.sessionId ?? '',
352
- content: event.lastAssistant ?? '',
407
+ sessionId: ctx.sessionKey ?? event.sessionId ?? ctx.sessionId ?? '',
408
+ content: assistantText,
353
409
  provider: event.provider ?? '',
354
410
  model: event.model ?? '',
355
411
  usage: event.usage ?? {},
412
+ runId: event.runId ?? ctx.runId ?? '',
356
413
  }).catch((e) => log.warn('[SafeClaw] Failed to log LLM output:', e));
357
414
  });
358
415
 
359
416
  // (#195: use event.toolName, !event.error for success, add durationMs and error)
360
- api.on('after_tool_call', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
417
+ api.on('after_tool_call', (event, ctx) => {
361
418
  post('/record/tool-result', {
362
- sessionId: ctx.sessionId ?? event.sessionId ?? '',
419
+ sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
420
+ userId: ctx.agentId ?? '',
363
421
  toolName: event.toolName ?? '',
364
422
  params: event.params ?? {},
365
423
  result: event.result ?? '',
366
424
  success: !event.error,
367
425
  error: event.error ? String(event.error) : '',
368
426
  durationMs: event.durationMs ?? 0,
427
+ runId: ctx.runId ?? event.runId ?? '',
369
428
  }).catch((e) => log.warn('[SafeClaw] Failed to record tool result:', e));
370
429
  });
371
430
 
372
- // Subagent governance — block delegation bypass attempts (#188)
373
- api.on('subagent_spawning', async (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
431
+ // Subagent governance — block delegation bypass attempts (#188).
432
+ // NOTE: `subagent_spawning` is a deprecated OpenClaw compatibility hook
433
+ // (removal scheduled after 2026-08-16). In v2026.6.8 the context exposes only
434
+ // session keys (`requesterSessionKey`/`childSessionKey`/`runId`) — there is NO
435
+ // parent agentId at this hook. The event carries the *child's* `agentId`. So
436
+ // we forward the parent/child session keys and the child agent id; the service
437
+ // cannot do the killed-PARENT lookup from a session key alone, so spawn-time
438
+ // delegation enforcement is reworked onto a session-keyed hierarchy in #321.
439
+ // `requester` is an object (channel/account/to/threadId), not a string. (#312)
440
+ api.on('subagent_spawning', async (event, ctx) => {
374
441
  const cfg = getConfig();
375
442
  const r = await post('/evaluate/subagent-spawn', {
376
- sessionId: ctx.sessionId ?? event.sessionId ?? '',
377
- userId: ctx.agentId ?? '',
378
- parentAgentId: event.parentAgentId,
379
- childConfig: event.childConfig ?? {},
380
- reason: event.reason ?? '',
443
+ sessionId: ctx.requesterSessionKey ?? ctx.childSessionKey ?? '',
444
+ parentSessionKey: ctx.requesterSessionKey ?? '',
445
+ childSessionKey: ctx.childSessionKey ?? event.childSessionKey ?? '',
446
+ childAgentId: event.agentId ?? '',
447
+ mode: event.mode ?? '',
448
+ threadRequested: event.threadRequested ?? false,
449
+ requester: event.requester ?? {},
450
+ runId: ctx.runId ?? '',
451
+ reason: event.label ?? '',
381
452
  });
382
453
 
383
454
  // Fail-closed handling (matches before_tool_call / message_sending pattern)
@@ -397,19 +468,27 @@ export default {
397
468
  }
398
469
  }, { priority: 100 });
399
470
 
400
- // Subagent ended — record child agent lifecycle (#188)
401
- api.on('subagent_ended', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
471
+ // Subagent ended — record child agent lifecycle (#188).
472
+ // v2026.6.8 reports `targetSessionKey`/`targetKind`/`outcome`/`error`/`runId`,
473
+ // not parent/child agent IDs. The ended child is `targetSessionKey`; the
474
+ // parent is the requester session key from the subagent context. (#312)
475
+ api.on('subagent_ended', (event, ctx) => {
402
476
  post('/record/subagent-ended', {
403
- sessionId: ctx.sessionId ?? event.sessionId ?? '',
404
- parentAgentId: event.parentAgentId,
405
- childAgentId: event.childAgentId,
477
+ sessionId: ctx.requesterSessionKey ?? event.targetSessionKey ?? '',
478
+ parentSessionKey: ctx.requesterSessionKey ?? '',
479
+ childSessionKey: event.targetSessionKey ?? '',
480
+ targetKind: event.targetKind ?? '',
481
+ outcome: event.outcome ?? '',
482
+ success: event.outcome ? event.outcome === 'ok' : !event.error,
483
+ error: event.error ?? '',
484
+ runId: event.runId ?? ctx.runId ?? '',
406
485
  }).catch((e) => log.warn('[SafeClaw] Failed to record subagent ended:', e));
407
486
  });
408
487
 
409
488
  // Session lifecycle — notify service of session start (#189)
410
- api.on('session_start', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
489
+ api.on('session_start', (event, ctx) => {
411
490
  post('/session/start', {
412
- sessionId: ctx.sessionId ?? event.sessionId ?? '',
491
+ sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
413
492
  userId: ctx.agentId ?? '',
414
493
  agentId: instanceId,
415
494
  metadata: event.metadata ?? {},
@@ -417,21 +496,26 @@ export default {
417
496
  });
418
497
 
419
498
  // Session lifecycle — notify service of session end (#189)
420
- api.on('session_end', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
499
+ api.on('session_end', (event, ctx) => {
421
500
  post('/session/end', {
422
- sessionId: ctx.sessionId ?? event.sessionId ?? '',
501
+ sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
423
502
  userId: ctx.agentId ?? '',
424
503
  agentId: instanceId,
425
504
  }).catch((e) => log.warn('[SafeClaw] Failed to record session end:', e));
426
505
  });
427
506
 
428
- // Inbound message governance — evaluate received messages (#190)
429
- api.on('message_received', (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => {
507
+ // Inbound message governance — evaluate received messages (#190).
508
+ // v2026.6.8 message context: sender is `event.from` (+ stable `event.senderId`),
509
+ // the session is `ctx.sessionKey`, and the channel id is on the context. The
510
+ // message context has no agentId (use accountId) and no provider field, so the
511
+ // service maps channel trust from `channelId`/`conversationId` (#313, feeds #320).
512
+ api.on('message_received', (event, ctx) => {
430
513
  post('/evaluate/inbound-message', {
431
- sessionId: ctx.sessionId ?? event.sessionId ?? '',
432
- userId: ctx.agentId ?? '',
433
- channel: event.channel ?? (ctx as any).channelId ?? '',
434
- sender: event.sender ?? '',
514
+ sessionId: ctx.sessionKey ?? event.sessionKey ?? '',
515
+ userId: ctx.accountId ?? '',
516
+ channel: ctx.channelId ?? '',
517
+ conversationId: ctx.conversationId ?? '',
518
+ sender: event.senderId ?? ctx.senderId ?? event.from ?? '',
435
519
  content: event.content ?? '',
436
520
  metadata: event.metadata ?? {},
437
521
  }).catch((e) => log.warn('[SafeClaw] Failed to evaluate inbound message:', e));
@@ -2,7 +2,7 @@
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": "1.3.0",
5
+ "version": "2.0.0",
6
6
  "author": "Tendly EU",
7
7
  "license": "MIT",
8
8
  "homepage": "https://safeclaw.eu",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-safeclaw-plugin",
3
- "version": "1.4.1",
3
+ "version": "2.0.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",
@@ -58,7 +58,7 @@
58
58
  "typescript": "^5.4.0"
59
59
  },
60
60
  "peerDependencies": {
61
- "openclaw": ">=2026.1.0"
61
+ "openclaw": ">=2026.6.8"
62
62
  },
63
63
  "peerDependenciesMeta": {
64
64
  "openclaw": {
@@ -1,104 +1,316 @@
1
1
  /**
2
- * Ambient type declarations for OpenClaw Plugin SDK.
3
- * These match the types exported by openclaw/plugin-sdk as of v2026.4.
4
- * When installed inside OpenClaw, the real SDK types take precedence.
2
+ * Ambient type declarations for the OpenClaw Plugin SDK.
3
+ * Transcribed from the published `openclaw@2026.6.8` source
4
+ * (`src/plugins/hook-types.ts`, `hook-message.types.ts`,
5
+ * `hook-before-tool-call-result.ts`). When installed inside OpenClaw the real
6
+ * SDK types take precedence.
7
+ *
8
+ * Design note (#317): each registered hook has BOTH a precise event payload and
9
+ * a precise *context* type — the contexts genuinely differ (agent runs expose
10
+ * `agentId`/`sessionId`; message hooks expose neither, only `sessionKey`;
11
+ * subagent hooks expose only `requesterSessionKey`/`childSessionKey`/`runId`).
12
+ * `on()` is typed via per-hook overloads keyed off `PluginHookEventMap` /
13
+ * `PluginHookContextMap`, so reading a field that does not exist on that hook's
14
+ * real payload/context (e.g. `ctx.agentId` on a subagent hook, `ctx.sessionId`
15
+ * on a message hook) fails `tsc` instead of silently reading `undefined`.
5
16
  */
6
17
 
7
18
  declare module 'openclaw/plugin-sdk/core' {
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
- */
15
- on(
16
- hookName: string,
17
- handler: (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => Promise<Record<string, unknown> | void> | void,
18
- options?: { priority?: number },
19
- ): void;
20
- registerService?(service: OpenClawPluginService): void;
21
- registerCli?(registrar: (ctx: { program: unknown; config: unknown }) => void, opts?: { commands: string[] }): void;
22
- registerTool?(tool: OpenClawPluginTool): void;
23
- registerCommand?(def: { name: string; description: string; execute: (ctx: unknown) => Promise<string | void> }): void;
24
- pluginConfig?: Record<string, unknown>;
25
- logger?: OpenClawPluginLogger;
19
+ // ---- Per-hook contexts (v2026.6.8) ----
20
+
21
+ /** PluginHookAgentContext agent-runtime hooks (tool calls, prompts, llm, session). */
22
+ export interface PluginHookAgentContext {
23
+ runId?: string;
24
+ jobId?: string;
25
+ agentId?: string;
26
+ /** Canonical conversation key — present on agent-runtime hooks. */
27
+ sessionKey?: string;
28
+ sessionId?: string;
29
+ workspaceDir?: string;
30
+ modelProviderId?: string;
31
+ modelId?: string;
32
+ messageProvider?: string;
33
+ /** Channel/plugin id for channel-originated runs, e.g. "discord". */
34
+ channel?: string;
35
+ chatId?: string;
36
+ senderId?: string;
37
+ trigger?: string;
38
+ channelId?: string;
26
39
  }
27
40
 
28
- export interface OpenClawPluginEvent {
41
+ /** PluginHookMessageContext inbound/outbound message hooks. No agentId / sessionId. */
42
+ export interface PluginHookMessageContext {
43
+ channelId: string;
44
+ accountId?: string;
45
+ conversationId?: string;
46
+ /** Canonical conversation key; the message-hook equivalent of sessionId. */
47
+ sessionKey?: string;
48
+ runId?: string;
49
+ messageId?: string;
50
+ senderId?: string;
51
+ replyToId?: string;
52
+ }
53
+
54
+ /** PluginHookSubagentContext — subagent_spawning / subagent_ended. Session keys only. */
55
+ export interface PluginHookSubagentContext {
56
+ runId?: string;
57
+ childSessionKey?: string;
58
+ /** The requesting (parent) session key. There is no parent agentId here. */
59
+ requesterSessionKey?: string;
60
+ }
61
+
62
+ /** Loose fallback context for hooks SafeClaw does not strongly type. */
63
+ export interface OpenClawPluginContext {
64
+ [key: string]: unknown;
65
+ }
66
+
67
+ // ---- Per-hook event payloads (v2026.6.8) ----
68
+
69
+ export interface BeforeToolCallEvent {
70
+ toolName?: string;
71
+ params?: Record<string, unknown>;
29
72
  sessionId?: string;
73
+ runId?: string;
74
+ toolCallId?: string;
75
+ /** e.g. "code_mode_exec" — distinguishes code-mode exec cells from shell exec. */
76
+ toolKind?: string;
77
+ /** Code-mode body language, e.g. "javascript" | "typescript". */
78
+ toolInputKind?: string;
79
+ /** Host-derived target paths for file-touching envelopes (e.g. apply_patch). */
80
+ derivedPaths?: readonly string[];
81
+ }
82
+
83
+ export interface AfterToolCallEvent {
30
84
  toolName?: string;
31
85
  params?: Record<string, unknown>;
32
- to?: string;
33
- content?: string;
34
86
  result?: unknown;
35
87
  error?: string;
36
88
  durationMs?: number;
89
+ sessionId?: string;
90
+ runId?: string;
91
+ }
92
+
93
+ export interface BeforePromptBuildEvent {
94
+ sessionId?: string;
95
+ }
96
+
97
+ /** v2026.6.8 PluginHookMessageSendingEvent — no sessionId on the event. */
98
+ export interface MessageSendingEvent {
99
+ to: string;
100
+ content: string;
101
+ replyToId?: string | number;
102
+ threadId?: string | number;
103
+ metadata?: Record<string, unknown>;
104
+ }
105
+
106
+ /** v2026.6.8 PluginHookMessageReceivedEvent — sender is `from`; key is `sessionKey`. */
107
+ export interface MessageReceivedEvent {
108
+ from: string;
109
+ content: string;
110
+ timestamp?: number;
111
+ threadId?: string | number;
112
+ messageId?: string;
113
+ senderId?: string;
114
+ replyToId?: string;
115
+ replyToSender?: string;
116
+ sessionKey?: string;
117
+ runId?: string;
118
+ metadata?: Record<string, unknown>;
119
+ }
120
+
121
+ export interface LlmInputEvent {
122
+ runId?: string;
123
+ sessionId?: string;
124
+ provider?: string;
125
+ model?: string;
126
+ systemPrompt?: string;
37
127
  prompt?: string;
128
+ }
129
+
130
+ /** v2026.6.8: reliable text is `assistantTexts: string[]`; `lastAssistant` is untyped. */
131
+ export interface LlmOutputEvent {
132
+ runId?: string;
133
+ sessionId?: string;
38
134
  provider?: string;
39
135
  model?: string;
40
- lastAssistant?: string;
136
+ prompt?: string;
137
+ assistantTexts?: string[];
138
+ lastAssistant?: unknown;
41
139
  usage?: Record<string, unknown>;
140
+ }
141
+
142
+ /**
143
+ * Deprecated compatibility hook (removal scheduled after 2026-08-16).
144
+ * v2026.6.8 PluginHookSubagentSpawnBase — `requester` is an object, not a string.
145
+ */
146
+ export interface SubagentSpawningEvent {
147
+ childSessionKey: string;
148
+ agentId: string;
149
+ label?: string;
150
+ mode: 'run' | 'session';
151
+ requester?: {
152
+ channel?: string;
153
+ accountId?: string;
154
+ to?: string;
155
+ threadId?: string | number;
156
+ };
157
+ threadRequested: boolean;
158
+ }
159
+
160
+ export interface SubagentEndedEvent {
161
+ targetSessionKey: string;
162
+ targetKind: 'subagent' | 'acp';
163
+ reason: string;
164
+ sendFarewell?: boolean;
165
+ accountId?: string;
42
166
  runId?: string;
43
- toolCallId?: string;
44
- childAgentId?: string;
45
- parentAgentId?: string;
46
- childConfig?: Record<string, unknown>;
47
- reason?: string;
48
- metadata?: Record<string, unknown>;
49
- channel?: string;
50
- sender?: string;
51
- [key: string]: unknown;
167
+ endedAt?: number;
168
+ outcome?: 'ok' | 'error' | 'timeout' | 'killed' | 'reset' | 'deleted';
169
+ error?: string;
52
170
  }
53
171
 
54
- export interface OpenClawPluginContext {
172
+ export interface SessionLifecycleEvent {
55
173
  sessionId?: string;
56
- agentId?: string;
57
- runId?: string;
58
- conversationId?: string;
59
- accountId?: string;
60
- channelId?: string;
61
- workspaceDir?: string;
174
+ sessionKey?: string;
175
+ metadata?: Record<string, unknown>;
176
+ }
177
+
178
+ /** Generic fallback payload for hooks SafeClaw does not strongly type. */
179
+ export interface OpenClawPluginEvent {
62
180
  [key: string]: unknown;
63
181
  }
64
182
 
65
- export interface OpenClawPluginService {
66
- id: string;
67
- start: () => void | Promise<void>;
68
- stop?: () => void | Promise<void>;
183
+ export interface PluginHookEventMap {
184
+ before_tool_call: BeforeToolCallEvent;
185
+ after_tool_call: AfterToolCallEvent;
186
+ before_prompt_build: BeforePromptBuildEvent;
187
+ message_sending: MessageSendingEvent;
188
+ message_received: MessageReceivedEvent;
189
+ llm_input: LlmInputEvent;
190
+ llm_output: LlmOutputEvent;
191
+ subagent_spawning: SubagentSpawningEvent;
192
+ subagent_ended: SubagentEndedEvent;
193
+ session_start: SessionLifecycleEvent;
194
+ session_end: SessionLifecycleEvent;
69
195
  }
70
196
 
71
- export interface OpenClawPluginTool {
72
- name: string;
73
- description: string;
74
- parameters: Record<string, unknown>;
75
- execute: (params: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<unknown>;
197
+ export interface PluginHookContextMap {
198
+ before_tool_call: PluginHookAgentContext;
199
+ after_tool_call: PluginHookAgentContext;
200
+ before_prompt_build: PluginHookAgentContext;
201
+ message_sending: PluginHookMessageContext;
202
+ message_received: PluginHookMessageContext;
203
+ llm_input: PluginHookAgentContext;
204
+ llm_output: PluginHookAgentContext;
205
+ subagent_spawning: PluginHookSubagentContext;
206
+ subagent_ended: PluginHookSubagentContext;
207
+ session_start: PluginHookAgentContext;
208
+ session_end: PluginHookAgentContext;
76
209
  }
77
210
 
211
+ // ---- Hook results ----
212
+
213
+ export type PluginApprovalDecision = 'allow-once' | 'allow-always' | 'deny';
214
+ /** v2026.6.8: typed resolution union (was a free-form string). */
215
+ export type PluginApprovalResolution = PluginApprovalDecision | 'timeout' | 'cancelled';
216
+
78
217
  export interface PluginApprovalRequest {
79
218
  title: string;
80
219
  description: string;
81
220
  severity?: 'info' | 'warning' | 'critical';
82
221
  timeoutMs?: number;
83
222
  timeoutBehavior?: 'allow' | 'deny';
223
+ /** v2026.6.8: restrict the offered decisions (e.g. forbid durable "allow-always"). */
224
+ allowedDecisions?: PluginApprovalDecision[];
84
225
  pluginId?: string;
85
226
  onResolution?: (decision: PluginApprovalResolution) => Promise<void> | void;
86
227
  }
87
228
 
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
229
  export interface BeforeToolCallResult {
99
230
  block?: boolean;
100
231
  blockReason?: string;
101
232
  requireApproval?: PluginApprovalRequest;
233
+ /** v2026.6.8: rewrite tool params before execution. */
234
+ params?: Record<string, unknown>;
235
+ }
236
+
237
+ export interface BeforePromptBuildResult {
238
+ prependSystemContext?: string;
239
+ }
240
+
241
+ export interface MessageSendingResult {
242
+ content?: string;
243
+ cancel?: boolean;
244
+ cancelReason?: string;
245
+ }
246
+
247
+ /** v2026.6.8 discriminated union — the error variant requires `error: string`. */
248
+ export type SubagentSpawningResult =
249
+ | {
250
+ status: 'ok';
251
+ threadBindingReady?: boolean;
252
+ deliveryOrigin?: {
253
+ channel?: string;
254
+ accountId?: string;
255
+ to?: string;
256
+ threadId?: string | number;
257
+ };
258
+ }
259
+ | { status: 'error'; error: string };
260
+
261
+ export interface PluginHookResultMap {
262
+ before_tool_call: BeforeToolCallResult;
263
+ after_tool_call: void;
264
+ before_prompt_build: BeforePromptBuildResult;
265
+ message_sending: MessageSendingResult;
266
+ message_received: void;
267
+ llm_input: void;
268
+ llm_output: void;
269
+ subagent_spawning: SubagentSpawningResult;
270
+ subagent_ended: void;
271
+ session_start: void;
272
+ session_end: void;
273
+ }
274
+
275
+ export interface OpenClawPluginApi {
276
+ /** Typed overload for the hooks SafeClaw registers (precise event + context). */
277
+ on<K extends keyof PluginHookEventMap>(
278
+ hookName: K,
279
+ handler: (
280
+ event: PluginHookEventMap[K],
281
+ ctx: PluginHookContextMap[K],
282
+ ) => PluginHookResultMap[K] | void | Promise<PluginHookResultMap[K] | void>,
283
+ options?: { priority?: number; timeoutMs?: number },
284
+ ): void;
285
+ /** Generic fallback for any other hook. */
286
+ on(
287
+ hookName: string,
288
+ handler: (
289
+ event: OpenClawPluginEvent,
290
+ ctx: OpenClawPluginContext,
291
+ ) => Promise<Record<string, unknown> | void> | void,
292
+ options?: { priority?: number; timeoutMs?: number },
293
+ ): void;
294
+
295
+ registerService?(service: OpenClawPluginService): void;
296
+ registerCli?(registrar: (ctx: { program: unknown; config: unknown }) => void, opts?: { commands: string[] }): void;
297
+ registerTool?(tool: OpenClawPluginTool): void;
298
+ registerCommand?(def: { name: string; description: string; execute: (ctx: unknown) => Promise<string | void> }): void;
299
+ pluginConfig?: Record<string, unknown>;
300
+ logger?: OpenClawPluginLogger;
301
+ }
302
+
303
+ export interface OpenClawPluginService {
304
+ id: string;
305
+ start: () => void | Promise<void>;
306
+ stop?: () => void | Promise<void>;
307
+ }
308
+
309
+ export interface OpenClawPluginTool {
310
+ name: string;
311
+ description: string;
312
+ parameters: Record<string, unknown>;
313
+ execute: (params: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<unknown>;
102
314
  }
103
315
 
104
316
  export interface OpenClawPluginLogger {