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