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 +11 -1
- package/dist/index.js +112 -32
- package/index.ts +129 -45
- 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
|
@@ -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
|
-
|
|
279
|
+
const result = {
|
|
252
280
|
requireApproval: {
|
|
253
281
|
title: 'SafeClaw Governance Check',
|
|
254
282
|
description: r.reason || 'This action requires confirmation',
|
|
255
|
-
severity: riskLevel
|
|
283
|
+
severity: approvalSeverityForRisk(riskLevel),
|
|
256
284
|
timeoutMs: 30_000,
|
|
257
|
-
timeoutBehavior: riskLevel
|
|
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
|
-
// (
|
|
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.
|
|
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
|
-
// (#
|
|
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:
|
|
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.
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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.
|
|
378
|
-
|
|
379
|
-
|
|
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.
|
|
403
|
-
userId: ctx.
|
|
404
|
-
channel:
|
|
405
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
304
|
+
severity: approvalSeverityForRisk(riskLevel),
|
|
274
305
|
timeoutMs: 30_000,
|
|
275
|
-
timeoutBehavior: riskLevel
|
|
306
|
+
timeoutBehavior: approvalTimeoutBehaviorForRisk(riskLevel),
|
|
307
|
+
allowedDecisions: approvalAllowedDecisionsForRisk(riskLevel),
|
|
276
308
|
},
|
|
277
|
-
}
|
|
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
|
|
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
|
-
// (
|
|
307
|
-
|
|
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.
|
|
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
|
-
// (#
|
|
340
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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.
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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.
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
432
|
-
userId: ctx.
|
|
433
|
-
channel:
|
|
434
|
-
|
|
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));
|
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 {
|