openclaw-safeclaw-plugin 1.5.0 → 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 +11 -1
- package/dist/index.js +98 -30
- package/index.ts +115 -43
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- package/types/openclaw-sdk.d.ts +272 -60
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 |
|
|
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
|
@@ -107,6 +107,13 @@ function approvalSeverityForRisk(riskLevel) {
|
|
|
107
107
|
function approvalTimeoutBehaviorForRisk(riskLevel) {
|
|
108
108
|
return riskLevel === 'CriticalRisk' || riskLevel === 'HighRisk' ? 'deny' : 'allow';
|
|
109
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
|
+
}
|
|
110
117
|
// --- Plugin Definition ---
|
|
111
118
|
let handshakeCompleted = false;
|
|
112
119
|
let lastHandshakeConfigHash = '';
|
|
@@ -240,11 +247,19 @@ export default {
|
|
|
240
247
|
return { block: true, blockReason: 'SafeClaw handshake not completed (fail-closed)' };
|
|
241
248
|
}
|
|
242
249
|
const r = await post('/evaluate/tool-call', {
|
|
243
|
-
sessionId: ctx.sessionId ?? event.sessionId ?? '',
|
|
250
|
+
sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
|
|
244
251
|
userId: ctx.agentId ?? '',
|
|
245
252
|
toolName: event.toolName ?? '',
|
|
246
253
|
params: event.params ?? {},
|
|
247
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 ?? '',
|
|
248
263
|
});
|
|
249
264
|
if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
|
|
250
265
|
return { block: true, blockReason: `SafeClaw service unavailable at ${cfg.serviceUrl} (fail-closed)` };
|
|
@@ -255,18 +270,26 @@ export default {
|
|
|
255
270
|
else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
|
|
256
271
|
log.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, audit-only)`);
|
|
257
272
|
}
|
|
258
|
-
// 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.
|
|
259
277
|
if (r?.confirmationRequired) {
|
|
260
278
|
const riskLevel = r.riskLevel || '';
|
|
261
|
-
|
|
279
|
+
const result = {
|
|
262
280
|
requireApproval: {
|
|
263
281
|
title: 'SafeClaw Governance Check',
|
|
264
282
|
description: r.reason || 'This action requires confirmation',
|
|
265
283
|
severity: approvalSeverityForRisk(riskLevel),
|
|
266
284
|
timeoutMs: 30_000,
|
|
267
285
|
timeoutBehavior: approvalTimeoutBehaviorForRisk(riskLevel),
|
|
286
|
+
allowedDecisions: approvalAllowedDecisionsForRisk(riskLevel),
|
|
268
287
|
},
|
|
269
288
|
};
|
|
289
|
+
if (r.params && typeof r.params === 'object') {
|
|
290
|
+
result.params = r.params;
|
|
291
|
+
}
|
|
292
|
+
return result;
|
|
270
293
|
}
|
|
271
294
|
if (r?.block) {
|
|
272
295
|
const blockReason = r.reason || 'Blocked by SafeClaw (no reason provided)';
|
|
@@ -278,24 +301,31 @@ export default {
|
|
|
278
301
|
}
|
|
279
302
|
// audit-only: logged server-side, no action here
|
|
280
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
|
+
}
|
|
281
310
|
}, { priority: 100 });
|
|
282
311
|
// Context injection — prepend governance context to agent system prompt
|
|
283
312
|
// (#195: before_agent_start is deprecated; use before_prompt_build + prependSystemContext)
|
|
284
313
|
api.on('before_prompt_build', async (event, ctx) => {
|
|
285
314
|
const r = await post('/context/build', {
|
|
286
|
-
sessionId: ctx.sessionId ?? event.sessionId ?? '',
|
|
315
|
+
sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
|
|
287
316
|
userId: ctx.agentId ?? '',
|
|
288
317
|
});
|
|
289
318
|
if (r?.prependContext) {
|
|
290
319
|
return { prependSystemContext: r.prependContext };
|
|
291
320
|
}
|
|
292
321
|
}, { priority: 100 });
|
|
293
|
-
// Message governance — check outbound messages
|
|
294
|
-
// (
|
|
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 }.)
|
|
295
325
|
api.on('message_sending', async (event, ctx) => {
|
|
296
326
|
const cfg = getConfig();
|
|
297
327
|
const r = await post('/evaluate/message', {
|
|
298
|
-
sessionId: ctx.
|
|
328
|
+
sessionId: ctx.sessionKey ?? ctx.conversationId ?? '',
|
|
299
329
|
userId: ctx.accountId ?? '',
|
|
300
330
|
to: event.to,
|
|
301
331
|
content: event.content,
|
|
@@ -323,29 +353,42 @@ export default {
|
|
|
323
353
|
// audit-only: logged server-side, no action here
|
|
324
354
|
}
|
|
325
355
|
}, { priority: 100 });
|
|
326
|
-
// Async logging — fire-and-forget, no return value needed
|
|
327
|
-
// (#
|
|
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.)
|
|
328
361
|
api.on('llm_input', (event, ctx) => {
|
|
329
362
|
post('/log/llm-input', {
|
|
330
|
-
sessionId: event.sessionId ?? ctx.sessionId ?? '',
|
|
363
|
+
sessionId: ctx.sessionKey ?? event.sessionId ?? ctx.sessionId ?? '',
|
|
331
364
|
content: event.prompt ?? '',
|
|
332
365
|
provider: event.provider ?? '',
|
|
333
366
|
model: event.model ?? '',
|
|
367
|
+
runId: event.runId ?? ctx.runId ?? '',
|
|
334
368
|
}).catch((e) => log.warn('[SafeClaw] Failed to log LLM input:', e));
|
|
335
369
|
});
|
|
336
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
|
+
: '';
|
|
337
379
|
post('/log/llm-output', {
|
|
338
|
-
sessionId: event.sessionId ?? ctx.sessionId ?? '',
|
|
339
|
-
content:
|
|
380
|
+
sessionId: ctx.sessionKey ?? event.sessionId ?? ctx.sessionId ?? '',
|
|
381
|
+
content: assistantText,
|
|
340
382
|
provider: event.provider ?? '',
|
|
341
383
|
model: event.model ?? '',
|
|
342
384
|
usage: event.usage ?? {},
|
|
385
|
+
runId: event.runId ?? ctx.runId ?? '',
|
|
343
386
|
}).catch((e) => log.warn('[SafeClaw] Failed to log LLM output:', e));
|
|
344
387
|
});
|
|
345
388
|
// (#195: use event.toolName, !event.error for success, add durationMs and error)
|
|
346
389
|
api.on('after_tool_call', (event, ctx) => {
|
|
347
390
|
post('/record/tool-result', {
|
|
348
|
-
sessionId: ctx.sessionId ?? event.sessionId ?? '',
|
|
391
|
+
sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
|
|
349
392
|
userId: ctx.agentId ?? '',
|
|
350
393
|
toolName: event.toolName ?? '',
|
|
351
394
|
params: event.params ?? {},
|
|
@@ -356,15 +399,27 @@ export default {
|
|
|
356
399
|
runId: ctx.runId ?? event.runId ?? '',
|
|
357
400
|
}).catch((e) => log.warn('[SafeClaw] Failed to record tool result:', e));
|
|
358
401
|
});
|
|
359
|
-
// 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)
|
|
360
411
|
api.on('subagent_spawning', async (event, ctx) => {
|
|
361
412
|
const cfg = getConfig();
|
|
362
413
|
const r = await post('/evaluate/subagent-spawn', {
|
|
363
|
-
sessionId: ctx.
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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 ?? '',
|
|
368
423
|
});
|
|
369
424
|
// Fail-closed handling (matches before_tool_call / message_sending pattern)
|
|
370
425
|
if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
|
|
@@ -383,18 +438,26 @@ export default {
|
|
|
383
438
|
log.warn(`[SafeClaw] Subagent spawn warning: ${r.reason}`);
|
|
384
439
|
}
|
|
385
440
|
}, { priority: 100 });
|
|
386
|
-
// 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)
|
|
387
445
|
api.on('subagent_ended', (event, ctx) => {
|
|
388
446
|
post('/record/subagent-ended', {
|
|
389
|
-
sessionId: ctx.
|
|
390
|
-
|
|
391
|
-
|
|
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 ?? '',
|
|
392
455
|
}).catch((e) => log.warn('[SafeClaw] Failed to record subagent ended:', e));
|
|
393
456
|
});
|
|
394
457
|
// Session lifecycle — notify service of session start (#189)
|
|
395
458
|
api.on('session_start', (event, ctx) => {
|
|
396
459
|
post('/session/start', {
|
|
397
|
-
sessionId: ctx.sessionId ?? event.sessionId ?? '',
|
|
460
|
+
sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
|
|
398
461
|
userId: ctx.agentId ?? '',
|
|
399
462
|
agentId: instanceId,
|
|
400
463
|
metadata: event.metadata ?? {},
|
|
@@ -403,18 +466,23 @@ export default {
|
|
|
403
466
|
// Session lifecycle — notify service of session end (#189)
|
|
404
467
|
api.on('session_end', (event, ctx) => {
|
|
405
468
|
post('/session/end', {
|
|
406
|
-
sessionId: ctx.sessionId ?? event.sessionId ?? '',
|
|
469
|
+
sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
|
|
407
470
|
userId: ctx.agentId ?? '',
|
|
408
471
|
agentId: instanceId,
|
|
409
472
|
}).catch((e) => log.warn('[SafeClaw] Failed to record session end:', e));
|
|
410
473
|
});
|
|
411
|
-
// 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).
|
|
412
479
|
api.on('message_received', (event, ctx) => {
|
|
413
480
|
post('/evaluate/inbound-message', {
|
|
414
|
-
sessionId: ctx.
|
|
415
|
-
userId: ctx.
|
|
416
|
-
channel:
|
|
417
|
-
|
|
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 ?? '',
|
|
418
486
|
content: event.content ?? '',
|
|
419
487
|
metadata: event.metadata ?? {},
|
|
420
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,
|
|
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';
|
|
@@ -114,6 +114,16 @@ function approvalTimeoutBehaviorForRisk(riskLevel: string): 'allow' | 'deny' {
|
|
|
114
114
|
return riskLevel === 'CriticalRisk' || riskLevel === 'HighRisk' ? 'deny' : 'allow';
|
|
115
115
|
}
|
|
116
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
|
+
|
|
117
127
|
// --- Plugin Definition ---
|
|
118
128
|
|
|
119
129
|
let handshakeCompleted = false;
|
|
@@ -251,18 +261,26 @@ export default {
|
|
|
251
261
|
}
|
|
252
262
|
|
|
253
263
|
// THE GATE — constraint checking on every tool call (#195: use correct OpenClaw field names)
|
|
254
|
-
api.on('before_tool_call', async (event
|
|
264
|
+
api.on('before_tool_call', async (event, ctx) => {
|
|
255
265
|
const cfg = getConfig();
|
|
256
266
|
if (!handshakeCompleted && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
|
|
257
267
|
return { block: true, blockReason: 'SafeClaw handshake not completed (fail-closed)' };
|
|
258
268
|
}
|
|
259
269
|
|
|
260
270
|
const r = await post('/evaluate/tool-call', {
|
|
261
|
-
sessionId: ctx.sessionId ?? event.sessionId ?? '',
|
|
271
|
+
sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
|
|
262
272
|
userId: ctx.agentId ?? '',
|
|
263
273
|
toolName: event.toolName ?? '',
|
|
264
274
|
params: event.params ?? {},
|
|
265
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 ?? '',
|
|
266
284
|
});
|
|
267
285
|
|
|
268
286
|
if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
|
|
@@ -273,18 +291,26 @@ export default {
|
|
|
273
291
|
log.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, audit-only)`);
|
|
274
292
|
}
|
|
275
293
|
|
|
276
|
-
// 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.
|
|
277
298
|
if (r?.confirmationRequired) {
|
|
278
299
|
const riskLevel = (r.riskLevel as string) || '';
|
|
279
|
-
|
|
300
|
+
const result: BeforeToolCallResult = {
|
|
280
301
|
requireApproval: {
|
|
281
302
|
title: 'SafeClaw Governance Check',
|
|
282
303
|
description: (r.reason as string) || 'This action requires confirmation',
|
|
283
304
|
severity: approvalSeverityForRisk(riskLevel),
|
|
284
305
|
timeoutMs: 30_000,
|
|
285
306
|
timeoutBehavior: approvalTimeoutBehaviorForRisk(riskLevel),
|
|
307
|
+
allowedDecisions: approvalAllowedDecisionsForRisk(riskLevel),
|
|
286
308
|
},
|
|
287
|
-
}
|
|
309
|
+
};
|
|
310
|
+
if (r.params && typeof r.params === 'object') {
|
|
311
|
+
result.params = r.params as Record<string, unknown>;
|
|
312
|
+
}
|
|
313
|
+
return result satisfies BeforeToolCallResult;
|
|
288
314
|
}
|
|
289
315
|
|
|
290
316
|
if (r?.block) {
|
|
@@ -297,13 +323,20 @@ export default {
|
|
|
297
323
|
}
|
|
298
324
|
// audit-only: logged server-side, no action here
|
|
299
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
|
+
}
|
|
300
333
|
}, { priority: 100 });
|
|
301
334
|
|
|
302
335
|
// Context injection — prepend governance context to agent system prompt
|
|
303
336
|
// (#195: before_agent_start is deprecated; use before_prompt_build + prependSystemContext)
|
|
304
|
-
api.on('before_prompt_build', async (event
|
|
337
|
+
api.on('before_prompt_build', async (event, ctx) => {
|
|
305
338
|
const r = await post('/context/build', {
|
|
306
|
-
sessionId: ctx.sessionId ?? event.sessionId ?? '',
|
|
339
|
+
sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
|
|
307
340
|
userId: ctx.agentId ?? '',
|
|
308
341
|
});
|
|
309
342
|
|
|
@@ -312,12 +345,13 @@ export default {
|
|
|
312
345
|
}
|
|
313
346
|
}, { priority: 100 });
|
|
314
347
|
|
|
315
|
-
// Message governance — check outbound messages
|
|
316
|
-
// (
|
|
317
|
-
|
|
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) => {
|
|
318
352
|
const cfg = getConfig();
|
|
319
353
|
const r = await post('/evaluate/message', {
|
|
320
|
-
sessionId: ctx.
|
|
354
|
+
sessionId: ctx.sessionKey ?? ctx.conversationId ?? '',
|
|
321
355
|
userId: ctx.accountId ?? '',
|
|
322
356
|
to: event.to,
|
|
323
357
|
content: event.content,
|
|
@@ -345,31 +379,44 @@ export default {
|
|
|
345
379
|
}
|
|
346
380
|
}, { priority: 100 });
|
|
347
381
|
|
|
348
|
-
// Async logging — fire-and-forget, no return value needed
|
|
349
|
-
// (#
|
|
350
|
-
|
|
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) => {
|
|
351
388
|
post('/log/llm-input', {
|
|
352
|
-
sessionId: event.sessionId ?? ctx.sessionId ?? '',
|
|
389
|
+
sessionId: ctx.sessionKey ?? event.sessionId ?? ctx.sessionId ?? '',
|
|
353
390
|
content: event.prompt ?? '',
|
|
354
391
|
provider: event.provider ?? '',
|
|
355
392
|
model: event.model ?? '',
|
|
393
|
+
runId: event.runId ?? ctx.runId ?? '',
|
|
356
394
|
}).catch((e) => log.warn('[SafeClaw] Failed to log LLM input:', e));
|
|
357
395
|
});
|
|
358
396
|
|
|
359
|
-
api.on('llm_output', (event
|
|
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
|
+
: '';
|
|
360
406
|
post('/log/llm-output', {
|
|
361
|
-
sessionId: event.sessionId ?? ctx.sessionId ?? '',
|
|
362
|
-
content:
|
|
407
|
+
sessionId: ctx.sessionKey ?? event.sessionId ?? ctx.sessionId ?? '',
|
|
408
|
+
content: assistantText,
|
|
363
409
|
provider: event.provider ?? '',
|
|
364
410
|
model: event.model ?? '',
|
|
365
411
|
usage: event.usage ?? {},
|
|
412
|
+
runId: event.runId ?? ctx.runId ?? '',
|
|
366
413
|
}).catch((e) => log.warn('[SafeClaw] Failed to log LLM output:', e));
|
|
367
414
|
});
|
|
368
415
|
|
|
369
416
|
// (#195: use event.toolName, !event.error for success, add durationMs and error)
|
|
370
|
-
api.on('after_tool_call', (event
|
|
417
|
+
api.on('after_tool_call', (event, ctx) => {
|
|
371
418
|
post('/record/tool-result', {
|
|
372
|
-
sessionId: ctx.sessionId ?? event.sessionId ?? '',
|
|
419
|
+
sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
|
|
373
420
|
userId: ctx.agentId ?? '',
|
|
374
421
|
toolName: event.toolName ?? '',
|
|
375
422
|
params: event.params ?? {},
|
|
@@ -381,15 +428,27 @@ export default {
|
|
|
381
428
|
}).catch((e) => log.warn('[SafeClaw] Failed to record tool result:', e));
|
|
382
429
|
});
|
|
383
430
|
|
|
384
|
-
// Subagent governance — block delegation bypass attempts (#188)
|
|
385
|
-
|
|
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) => {
|
|
386
441
|
const cfg = getConfig();
|
|
387
442
|
const r = await post('/evaluate/subagent-spawn', {
|
|
388
|
-
sessionId: ctx.
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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 ?? '',
|
|
393
452
|
});
|
|
394
453
|
|
|
395
454
|
// Fail-closed handling (matches before_tool_call / message_sending pattern)
|
|
@@ -409,19 +468,27 @@ export default {
|
|
|
409
468
|
}
|
|
410
469
|
}, { priority: 100 });
|
|
411
470
|
|
|
412
|
-
// Subagent ended — record child agent lifecycle (#188)
|
|
413
|
-
|
|
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) => {
|
|
414
476
|
post('/record/subagent-ended', {
|
|
415
|
-
sessionId: ctx.
|
|
416
|
-
|
|
417
|
-
|
|
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 ?? '',
|
|
418
485
|
}).catch((e) => log.warn('[SafeClaw] Failed to record subagent ended:', e));
|
|
419
486
|
});
|
|
420
487
|
|
|
421
488
|
// Session lifecycle — notify service of session start (#189)
|
|
422
|
-
api.on('session_start', (event
|
|
489
|
+
api.on('session_start', (event, ctx) => {
|
|
423
490
|
post('/session/start', {
|
|
424
|
-
sessionId: ctx.sessionId ?? event.sessionId ?? '',
|
|
491
|
+
sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
|
|
425
492
|
userId: ctx.agentId ?? '',
|
|
426
493
|
agentId: instanceId,
|
|
427
494
|
metadata: event.metadata ?? {},
|
|
@@ -429,21 +496,26 @@ export default {
|
|
|
429
496
|
});
|
|
430
497
|
|
|
431
498
|
// Session lifecycle — notify service of session end (#189)
|
|
432
|
-
api.on('session_end', (event
|
|
499
|
+
api.on('session_end', (event, ctx) => {
|
|
433
500
|
post('/session/end', {
|
|
434
|
-
sessionId: ctx.sessionId ?? event.sessionId ?? '',
|
|
501
|
+
sessionId: ctx.sessionKey ?? ctx.sessionId ?? event.sessionId ?? '',
|
|
435
502
|
userId: ctx.agentId ?? '',
|
|
436
503
|
agentId: instanceId,
|
|
437
504
|
}).catch((e) => log.warn('[SafeClaw] Failed to record session end:', e));
|
|
438
505
|
});
|
|
439
506
|
|
|
440
|
-
// Inbound message governance — evaluate received messages (#190)
|
|
441
|
-
|
|
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) => {
|
|
442
513
|
post('/evaluate/inbound-message', {
|
|
443
|
-
sessionId: ctx.
|
|
444
|
-
userId: ctx.
|
|
445
|
-
channel:
|
|
446
|
-
|
|
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 ?? '',
|
|
447
519
|
content: event.content ?? '',
|
|
448
520
|
metadata: event.metadata ?? {},
|
|
449
521
|
}).catch((e) => log.warn('[SafeClaw] Failed to evaluate inbound message:', e));
|
package/openclaw.plugin.json
CHANGED
|
@@ -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": "
|
|
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": "
|
|
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.
|
|
61
|
+
"openclaw": ">=2026.6.8"
|
|
62
62
|
},
|
|
63
63
|
"peerDependenciesMeta": {
|
|
64
64
|
"openclaw": {
|
package/types/openclaw-sdk.d.ts
CHANGED
|
@@ -1,104 +1,316 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Ambient type declarations for OpenClaw Plugin SDK.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
on
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
172
|
+
export interface SessionLifecycleEvent {
|
|
55
173
|
sessionId?: string;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 {
|