openclaw-safeclaw-plugin 1.0.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 +216 -41
- package/SKILL.md +32 -31
- package/cli.tsx +123 -26
- package/dist/cli.js +127 -26
- package/dist/index.d.ts +2 -16
- package/dist/index.js +288 -85
- package/dist/tui/About.js +1 -1
- package/dist/tui/App.js +1 -1
- package/dist/tui/Settings.js +25 -6
- package/dist/tui/Status.js +95 -2
- package/dist/tui/config.d.ts +6 -0
- package/dist/tui/config.js +66 -4
- package/index.ts +305 -111
- package/openclaw.plugin.json +29 -4
- package/package.json +12 -3
- package/policies/safeclaw.yaml +14 -0
- package/tui/About.tsx +21 -4
- package/tui/App.tsx +1 -1
- package/tui/Settings.tsx +31 -7
- package/tui/Status.tsx +137 -3
- package/tui/config.ts +69 -3
package/dist/index.js
CHANGED
|
@@ -7,23 +7,46 @@
|
|
|
7
7
|
* to the SafeClaw service and acts on the responses.
|
|
8
8
|
*/
|
|
9
9
|
import { loadConfig, configHash } from './tui/config.js';
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import { createRequire } from 'module';
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const { version: PLUGIN_VERSION } = require('./package.json');
|
|
10
14
|
// --- Configuration ---
|
|
11
|
-
const
|
|
15
|
+
const CONFIG_RELOAD_INTERVAL_MS = 60_000; // Reload config every 60 seconds
|
|
16
|
+
let config = loadConfig();
|
|
17
|
+
let configLoadedAt = Date.now();
|
|
18
|
+
// OpenClaw plugin config — merged on top of file config when available
|
|
19
|
+
let _ocPluginConfig = {};
|
|
20
|
+
// Logger — defaults to console, replaced by api.logger when available
|
|
21
|
+
let log = console;
|
|
22
|
+
function getConfig() {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
if (now - configLoadedAt >= CONFIG_RELOAD_INTERVAL_MS) {
|
|
25
|
+
config = loadConfig();
|
|
26
|
+
configLoadedAt = now;
|
|
27
|
+
}
|
|
28
|
+
// OpenClaw config takes priority over file config
|
|
29
|
+
return {
|
|
30
|
+
...config,
|
|
31
|
+
...Object.fromEntries(Object.entries(_ocPluginConfig).filter(([_, v]) => v != null)),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
12
34
|
// --- HTTP Client ---
|
|
13
35
|
async function post(path, body) {
|
|
14
|
-
|
|
36
|
+
const cfg = getConfig();
|
|
37
|
+
if (!cfg.enabled)
|
|
15
38
|
return null;
|
|
16
39
|
const headers = { 'Content-Type': 'application/json' };
|
|
17
|
-
if (
|
|
18
|
-
headers['Authorization'] = `Bearer ${
|
|
40
|
+
if (cfg.apiKey) {
|
|
41
|
+
headers['Authorization'] = `Bearer ${cfg.apiKey}`;
|
|
19
42
|
}
|
|
20
|
-
const agentFields =
|
|
43
|
+
const agentFields = cfg.agentId ? { agentId: cfg.agentId, agentToken: cfg.agentToken } : {};
|
|
21
44
|
try {
|
|
22
|
-
const res = await fetch(`${
|
|
45
|
+
const res = await fetch(`${cfg.serviceUrl}${path}`, {
|
|
23
46
|
method: 'POST',
|
|
24
47
|
headers,
|
|
25
48
|
body: JSON.stringify({ ...body, ...agentFields }),
|
|
26
|
-
signal: AbortSignal.timeout(
|
|
49
|
+
signal: AbortSignal.timeout(cfg.timeoutMs),
|
|
27
50
|
});
|
|
28
51
|
if (!res.ok) {
|
|
29
52
|
// Try to parse structured error body from service
|
|
@@ -32,10 +55,10 @@ async function post(path, body) {
|
|
|
32
55
|
const rawDetail = errBody.detail ?? `HTTP ${res.status}`;
|
|
33
56
|
const detail = typeof rawDetail === 'string' ? rawDetail : JSON.stringify(rawDetail);
|
|
34
57
|
const hint = errBody.hint ? ` (${errBody.hint})` : '';
|
|
35
|
-
|
|
58
|
+
log.warn(`[SafeClaw] ${path}: ${detail}${hint}`);
|
|
36
59
|
}
|
|
37
60
|
catch {
|
|
38
|
-
|
|
61
|
+
log.warn(`[SafeClaw] HTTP ${res.status} from ${path}`);
|
|
39
62
|
}
|
|
40
63
|
return null; // Caller checks failMode
|
|
41
64
|
}
|
|
@@ -43,76 +66,103 @@ async function post(path, body) {
|
|
|
43
66
|
}
|
|
44
67
|
catch (e) {
|
|
45
68
|
if (e instanceof DOMException && e.name === 'TimeoutError') {
|
|
46
|
-
|
|
69
|
+
log.warn(`[SafeClaw] Timeout after ${cfg.timeoutMs}ms on ${path} (${cfg.serviceUrl})`);
|
|
47
70
|
}
|
|
48
71
|
else if (e instanceof TypeError && (e.message.includes('fetch') || e.message.includes('ECONNREFUSED'))) {
|
|
49
|
-
|
|
72
|
+
log.warn(`[SafeClaw] Connection refused: ${cfg.serviceUrl}${path} — is the service running?`);
|
|
50
73
|
}
|
|
51
74
|
else {
|
|
52
|
-
|
|
75
|
+
log.warn(`[SafeClaw] Service unavailable: ${cfg.serviceUrl}${path}`);
|
|
53
76
|
}
|
|
54
77
|
return null; // Caller checks failMode
|
|
55
78
|
}
|
|
56
79
|
}
|
|
80
|
+
async function get(path) {
|
|
81
|
+
const cfg = getConfig();
|
|
82
|
+
if (!cfg.enabled)
|
|
83
|
+
return null;
|
|
84
|
+
const headers = {};
|
|
85
|
+
if (cfg.apiKey)
|
|
86
|
+
headers['Authorization'] = `Bearer ${cfg.apiKey}`;
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch(`${cfg.serviceUrl}${path}`, {
|
|
89
|
+
signal: AbortSignal.timeout(cfg.timeoutMs),
|
|
90
|
+
headers,
|
|
91
|
+
});
|
|
92
|
+
if (!res.ok)
|
|
93
|
+
return null;
|
|
94
|
+
return await res.json();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// --- Plugin Definition ---
|
|
57
101
|
let handshakeCompleted = false;
|
|
58
102
|
async function performHandshake() {
|
|
59
|
-
|
|
60
|
-
|
|
103
|
+
const cfg = getConfig();
|
|
104
|
+
if (!cfg.apiKey) {
|
|
105
|
+
log.warn('[SafeClaw] No API key configured — skipping handshake');
|
|
61
106
|
return false;
|
|
62
107
|
}
|
|
63
108
|
const r = await post('/handshake', {
|
|
64
|
-
pluginVersion:
|
|
65
|
-
configHash: configHash(
|
|
109
|
+
pluginVersion: PLUGIN_VERSION,
|
|
110
|
+
configHash: configHash(cfg),
|
|
66
111
|
});
|
|
67
112
|
if (r === null) {
|
|
68
|
-
|
|
113
|
+
log.warn('[SafeClaw] Handshake failed — API key may be invalid or service unreachable');
|
|
69
114
|
return false;
|
|
70
115
|
}
|
|
71
|
-
|
|
116
|
+
log.info(`[SafeClaw] Handshake OK — org=${r.orgId}, scope=${r.scope}, engine=${r.engineReady ? 'ready' : 'not ready'}`);
|
|
72
117
|
handshakeCompleted = true;
|
|
73
118
|
return true;
|
|
74
119
|
}
|
|
75
120
|
async function checkConnection() {
|
|
121
|
+
const cfg = getConfig();
|
|
76
122
|
const label = `[SafeClaw]`;
|
|
77
|
-
|
|
78
|
-
|
|
123
|
+
log.info(`${label} Connecting to ${cfg.serviceUrl} ...`);
|
|
124
|
+
log.info(`${label} Mode: enforcement=${cfg.enforcement}, failMode=${cfg.failMode}`);
|
|
79
125
|
try {
|
|
80
|
-
const res = await fetch(`${
|
|
81
|
-
signal: AbortSignal.timeout(
|
|
126
|
+
const res = await fetch(`${cfg.serviceUrl}/health`, {
|
|
127
|
+
signal: AbortSignal.timeout(cfg.timeoutMs * 2),
|
|
82
128
|
});
|
|
83
129
|
if (res.ok) {
|
|
84
130
|
const data = await res.json();
|
|
85
|
-
|
|
131
|
+
log.info(`${label} Connected — service ${data.status ?? 'ok'}`);
|
|
86
132
|
}
|
|
87
133
|
else {
|
|
88
|
-
|
|
134
|
+
log.warn(`${label} Service responded with HTTP ${res.status}`);
|
|
89
135
|
}
|
|
90
136
|
}
|
|
91
137
|
catch {
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
|
|
138
|
+
log.warn(`${label} Cannot reach service at ${cfg.serviceUrl}`);
|
|
139
|
+
if (cfg.failMode === 'closed') {
|
|
140
|
+
log.warn(`${label} fail-mode=closed — tool calls will be BLOCKED until service is reachable`);
|
|
95
141
|
}
|
|
96
142
|
else {
|
|
97
|
-
|
|
143
|
+
log.warn(`${label} fail-mode=open — tool calls will be ALLOWED despite no connection`);
|
|
98
144
|
}
|
|
99
145
|
}
|
|
100
146
|
}
|
|
101
147
|
export default {
|
|
102
148
|
id: 'safeclaw',
|
|
103
149
|
name: 'SafeClaw Neurosymbolic Governance',
|
|
104
|
-
version:
|
|
150
|
+
version: PLUGIN_VERSION,
|
|
105
151
|
register(api) {
|
|
106
|
-
if (!
|
|
152
|
+
if (!getConfig().enabled) {
|
|
107
153
|
console.log('[SafeClaw] Plugin disabled');
|
|
108
154
|
return;
|
|
109
155
|
}
|
|
156
|
+
log = api.logger ?? console;
|
|
157
|
+
_ocPluginConfig = api.pluginConfig ?? {};
|
|
158
|
+
// Generate a unique instance ID for this plugin run (fallback when agentId is not configured)
|
|
159
|
+
const instanceId = getConfig().agentId || `instance-${crypto.randomUUID()}`;
|
|
110
160
|
// Heartbeat watchdog — send config hash to service every 30s
|
|
111
161
|
const sendHeartbeat = async () => {
|
|
112
162
|
try {
|
|
113
163
|
await post('/heartbeat', {
|
|
114
|
-
agentId:
|
|
115
|
-
configHash: configHash(
|
|
164
|
+
agentId: instanceId,
|
|
165
|
+
configHash: configHash(getConfig()),
|
|
116
166
|
status: 'alive',
|
|
117
167
|
});
|
|
118
168
|
}
|
|
@@ -120,115 +170,268 @@ export default {
|
|
|
120
170
|
// Heartbeat failure is non-fatal
|
|
121
171
|
}
|
|
122
172
|
};
|
|
123
|
-
//
|
|
124
|
-
|
|
173
|
+
// Clean shutdown: send shutdown heartbeat and clear interval (#194)
|
|
174
|
+
let heartbeatInterval;
|
|
175
|
+
const shutdown = async () => {
|
|
176
|
+
if (heartbeatInterval)
|
|
177
|
+
clearInterval(heartbeatInterval);
|
|
178
|
+
try {
|
|
179
|
+
await post('/heartbeat', {
|
|
180
|
+
agentId: instanceId,
|
|
181
|
+
configHash: configHash(getConfig()),
|
|
182
|
+
status: 'shutdown',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Best-effort shutdown notification
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
// Start heartbeat after connection check + handshake completes (#84)
|
|
190
|
+
const startupPromise = checkConnection()
|
|
125
191
|
.then(() => performHandshake())
|
|
126
192
|
.then((ok) => {
|
|
127
|
-
if (!ok &&
|
|
128
|
-
|
|
193
|
+
if (!ok && getConfig().failMode === 'closed') {
|
|
194
|
+
log.warn('[SafeClaw] Handshake failed with fail-mode=closed — tool calls will be BLOCKED');
|
|
129
195
|
}
|
|
196
|
+
heartbeatInterval = setInterval(sendHeartbeat, 30000);
|
|
130
197
|
return sendHeartbeat();
|
|
131
198
|
})
|
|
132
199
|
.catch(() => { });
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
200
|
+
// Register as an OpenClaw service so the gateway manages our lifecycle.
|
|
201
|
+
// The service's stop() method sends the shutdown heartbeat — no process.exit()
|
|
202
|
+
// needed, which avoids killing the entire gateway process (#194).
|
|
203
|
+
if (api.registerService) {
|
|
204
|
+
api.registerService({
|
|
205
|
+
id: 'safeclaw-governance',
|
|
206
|
+
start() { },
|
|
207
|
+
async stop() {
|
|
208
|
+
await startupPromise; // Ensure startup finished before tearing down
|
|
209
|
+
await shutdown();
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
// Fallback for older OpenClaw versions without registerService:
|
|
215
|
+
// No process.exit() — just clean up on beforeExit
|
|
216
|
+
process.on('beforeExit', () => { shutdown().catch(() => { }); });
|
|
217
|
+
}
|
|
218
|
+
// THE GATE — constraint checking on every tool call (#195: use correct OpenClaw field names)
|
|
147
219
|
api.on('before_tool_call', async (event, ctx) => {
|
|
148
|
-
|
|
220
|
+
const cfg = getConfig();
|
|
221
|
+
if (!handshakeCompleted && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
|
|
149
222
|
return { block: true, blockReason: 'SafeClaw handshake not completed (fail-closed)' };
|
|
150
223
|
}
|
|
151
224
|
const r = await post('/evaluate/tool-call', {
|
|
152
|
-
sessionId: ctx.sessionId ?? event.sessionId,
|
|
153
|
-
userId: ctx.
|
|
154
|
-
toolName: event.toolName ??
|
|
225
|
+
sessionId: ctx.sessionId ?? event.sessionId ?? '',
|
|
226
|
+
userId: ctx.agentId ?? '',
|
|
227
|
+
toolName: event.toolName ?? '',
|
|
155
228
|
params: event.params ?? {},
|
|
156
|
-
|
|
229
|
+
runId: ctx.runId ?? '',
|
|
157
230
|
});
|
|
158
|
-
if (r === null &&
|
|
159
|
-
return { block: true, blockReason: `SafeClaw service unavailable at ${
|
|
231
|
+
if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
|
|
232
|
+
return { block: true, blockReason: `SafeClaw service unavailable at ${cfg.serviceUrl} (fail-closed)` };
|
|
160
233
|
}
|
|
161
|
-
else if (r === null &&
|
|
162
|
-
|
|
234
|
+
else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
|
|
235
|
+
log.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, warn-only)`);
|
|
163
236
|
}
|
|
164
|
-
else if (r === null &&
|
|
165
|
-
|
|
237
|
+
else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
|
|
238
|
+
log.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, audit-only)`);
|
|
166
239
|
}
|
|
167
240
|
if (r?.block) {
|
|
168
|
-
|
|
169
|
-
|
|
241
|
+
const blockReason = r.reason || 'Blocked by SafeClaw (no reason provided)';
|
|
242
|
+
if (cfg.enforcement === 'enforce') {
|
|
243
|
+
return { block: true, blockReason };
|
|
170
244
|
}
|
|
171
|
-
if (
|
|
172
|
-
|
|
245
|
+
if (cfg.enforcement === 'warn-only') {
|
|
246
|
+
log.warn(`[SafeClaw] Warning: ${blockReason}`);
|
|
173
247
|
}
|
|
174
248
|
// audit-only: logged server-side, no action here
|
|
175
249
|
}
|
|
176
250
|
}, { priority: 100 });
|
|
177
251
|
// Context injection — prepend governance context to agent system prompt
|
|
178
|
-
|
|
252
|
+
// (#195: before_agent_start is deprecated; use before_prompt_build + prependSystemContext)
|
|
253
|
+
api.on('before_prompt_build', async (event, ctx) => {
|
|
179
254
|
const r = await post('/context/build', {
|
|
180
|
-
sessionId: ctx.sessionId ?? event.sessionId,
|
|
181
|
-
userId: ctx.
|
|
255
|
+
sessionId: ctx.sessionId ?? event.sessionId ?? '',
|
|
256
|
+
userId: ctx.agentId ?? '',
|
|
182
257
|
});
|
|
183
258
|
if (r?.prependContext) {
|
|
184
|
-
return {
|
|
259
|
+
return { prependSystemContext: r.prependContext };
|
|
185
260
|
}
|
|
186
261
|
}, { priority: 100 });
|
|
187
262
|
// Message governance — check outbound messages
|
|
263
|
+
// (#195: use ctx.conversationId/sessionId, ctx.accountId; return only { cancel: true })
|
|
188
264
|
api.on('message_sending', async (event, ctx) => {
|
|
265
|
+
const cfg = getConfig();
|
|
189
266
|
const r = await post('/evaluate/message', {
|
|
190
|
-
sessionId: ctx.sessionId ?? event.sessionId,
|
|
191
|
-
userId: ctx.
|
|
267
|
+
sessionId: ctx.conversationId ?? ctx.sessionId ?? event.sessionId ?? '',
|
|
268
|
+
userId: ctx.accountId ?? '',
|
|
192
269
|
to: event.to,
|
|
193
270
|
content: event.content,
|
|
271
|
+
channelId: ctx.channelId ?? '',
|
|
194
272
|
});
|
|
195
|
-
if (r === null &&
|
|
273
|
+
if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
|
|
274
|
+
log.warn('[SafeClaw] Blocking message: service unavailable (fail-closed mode)');
|
|
196
275
|
return { cancel: true };
|
|
197
276
|
}
|
|
198
|
-
else if (r === null &&
|
|
199
|
-
|
|
277
|
+
else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
|
|
278
|
+
log.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
|
|
279
|
+
}
|
|
280
|
+
else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
|
|
281
|
+
log.info('[SafeClaw] audit-only: service unreachable, allowing message (fail-closed)');
|
|
200
282
|
}
|
|
201
283
|
if (r?.block) {
|
|
202
|
-
|
|
284
|
+
const blockReason = r.reason || 'Blocked by SafeClaw (no reason provided)';
|
|
285
|
+
if (cfg.enforcement === 'enforce') {
|
|
286
|
+
log.warn(`[SafeClaw] Blocking message: ${blockReason}`);
|
|
203
287
|
return { cancel: true };
|
|
204
288
|
}
|
|
205
|
-
if (
|
|
206
|
-
|
|
289
|
+
if (cfg.enforcement === 'warn-only') {
|
|
290
|
+
log.warn(`[SafeClaw] Warning: ${blockReason}`);
|
|
207
291
|
}
|
|
208
292
|
// audit-only: logged server-side, no action here
|
|
209
293
|
}
|
|
210
294
|
}, { priority: 100 });
|
|
211
295
|
// Async logging — fire-and-forget, no return value needed
|
|
296
|
+
// (#195: use event.prompt for input, event.lastAssistant for output; add provider/model)
|
|
212
297
|
api.on('llm_input', (event, ctx) => {
|
|
213
298
|
post('/log/llm-input', {
|
|
214
|
-
sessionId:
|
|
215
|
-
content: event.
|
|
299
|
+
sessionId: event.sessionId ?? ctx.sessionId ?? '',
|
|
300
|
+
content: event.prompt ?? '',
|
|
301
|
+
provider: event.provider ?? '',
|
|
302
|
+
model: event.model ?? '',
|
|
216
303
|
}).catch(() => { });
|
|
217
304
|
});
|
|
218
305
|
api.on('llm_output', (event, ctx) => {
|
|
219
306
|
post('/log/llm-output', {
|
|
220
|
-
sessionId:
|
|
221
|
-
content: event.
|
|
307
|
+
sessionId: event.sessionId ?? ctx.sessionId ?? '',
|
|
308
|
+
content: event.lastAssistant ?? '',
|
|
309
|
+
provider: event.provider ?? '',
|
|
310
|
+
model: event.model ?? '',
|
|
311
|
+
usage: event.usage ?? {},
|
|
222
312
|
}).catch(() => { });
|
|
223
313
|
});
|
|
314
|
+
// (#195: use event.toolName, !event.error for success, add durationMs and error)
|
|
224
315
|
api.on('after_tool_call', (event, ctx) => {
|
|
225
316
|
post('/record/tool-result', {
|
|
226
|
-
sessionId: ctx.sessionId ?? event.sessionId,
|
|
227
|
-
toolName: event.toolName ??
|
|
317
|
+
sessionId: ctx.sessionId ?? event.sessionId ?? '',
|
|
318
|
+
toolName: event.toolName ?? '',
|
|
228
319
|
params: event.params ?? {},
|
|
229
320
|
result: event.result ?? '',
|
|
230
|
-
success: event.
|
|
231
|
-
|
|
321
|
+
success: !event.error,
|
|
322
|
+
error: event.error ? String(event.error) : '',
|
|
323
|
+
durationMs: event.durationMs ?? 0,
|
|
324
|
+
}).catch((e) => log.warn('[SafeClaw] Failed to record tool result:', e));
|
|
325
|
+
});
|
|
326
|
+
// Subagent governance — block delegation bypass attempts (#188)
|
|
327
|
+
api.on('subagent_spawning', async (event, ctx) => {
|
|
328
|
+
const cfg = getConfig();
|
|
329
|
+
const r = await post('/evaluate/subagent-spawn', {
|
|
330
|
+
sessionId: ctx.sessionId ?? event.sessionId,
|
|
331
|
+
userId: ctx.agentId,
|
|
332
|
+
parentAgentId: event.parentAgentId,
|
|
333
|
+
childConfig: event.childConfig ?? {},
|
|
334
|
+
reason: event.reason ?? '',
|
|
335
|
+
});
|
|
336
|
+
if (r?.block && cfg.enforcement === 'enforce') {
|
|
337
|
+
throw new Error(r.reason || 'Blocked by SafeClaw: delegation bypass detected');
|
|
338
|
+
}
|
|
339
|
+
if (r?.block && cfg.enforcement === 'warn-only') {
|
|
340
|
+
log.warn(`[SafeClaw] Subagent spawn warning: ${r.reason}`);
|
|
341
|
+
}
|
|
342
|
+
}, { priority: 100 });
|
|
343
|
+
// Subagent ended — record child agent lifecycle (#188)
|
|
344
|
+
api.on('subagent_ended', (event, ctx) => {
|
|
345
|
+
post('/record/subagent-ended', {
|
|
346
|
+
sessionId: ctx.sessionId ?? event.sessionId,
|
|
347
|
+
parentAgentId: event.parentAgentId,
|
|
348
|
+
childAgentId: event.childAgentId,
|
|
349
|
+
}).catch(() => { });
|
|
232
350
|
});
|
|
351
|
+
// Session lifecycle — notify service of session start (#189)
|
|
352
|
+
api.on('session_start', (event, ctx) => {
|
|
353
|
+
post('/session/start', {
|
|
354
|
+
sessionId: ctx.sessionId ?? event.sessionId,
|
|
355
|
+
userId: ctx.agentId,
|
|
356
|
+
agentId: instanceId,
|
|
357
|
+
metadata: event.metadata ?? {},
|
|
358
|
+
}).catch(() => { });
|
|
359
|
+
});
|
|
360
|
+
// Session lifecycle — notify service of session end (#189)
|
|
361
|
+
api.on('session_end', (event, ctx) => {
|
|
362
|
+
post('/session/end', {
|
|
363
|
+
sessionId: ctx.sessionId ?? event.sessionId,
|
|
364
|
+
userId: ctx.agentId,
|
|
365
|
+
agentId: instanceId,
|
|
366
|
+
}).catch(() => { });
|
|
367
|
+
});
|
|
368
|
+
// Inbound message governance — evaluate received messages (#190)
|
|
369
|
+
api.on('message_received', (event, ctx) => {
|
|
370
|
+
post('/evaluate/inbound-message', {
|
|
371
|
+
sessionId: ctx.sessionId ?? event.sessionId,
|
|
372
|
+
userId: ctx.agentId,
|
|
373
|
+
channel: event.channel ?? ctx.channelId ?? '',
|
|
374
|
+
sender: event.sender ?? '',
|
|
375
|
+
content: event.content ?? '',
|
|
376
|
+
metadata: event.metadata ?? {},
|
|
377
|
+
}).catch(() => { });
|
|
378
|
+
});
|
|
379
|
+
// Agent tools — let agents introspect governance state (#197)
|
|
380
|
+
if (api.registerTool) {
|
|
381
|
+
api.registerTool({
|
|
382
|
+
name: 'safeclaw_status',
|
|
383
|
+
description: 'Check SafeClaw governance service status, enforcement mode, and active constraints',
|
|
384
|
+
parameters: {},
|
|
385
|
+
async execute(_params, _ctx) {
|
|
386
|
+
const health = await get('/health');
|
|
387
|
+
const cfg = getConfig();
|
|
388
|
+
return {
|
|
389
|
+
status: health?.status ?? 'unreachable',
|
|
390
|
+
enforcement: cfg.enforcement,
|
|
391
|
+
failMode: cfg.failMode,
|
|
392
|
+
serviceUrl: cfg.serviceUrl,
|
|
393
|
+
handshakeCompleted,
|
|
394
|
+
};
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
api.registerTool({
|
|
398
|
+
name: 'safeclaw_check_action',
|
|
399
|
+
description: 'Check if a specific tool call would be allowed by SafeClaw governance (dry run, no side effects)',
|
|
400
|
+
parameters: {
|
|
401
|
+
type: 'object',
|
|
402
|
+
properties: {
|
|
403
|
+
toolName: { type: 'string', description: 'Tool name to check' },
|
|
404
|
+
params: { type: 'object', description: 'Tool parameters to validate' },
|
|
405
|
+
},
|
|
406
|
+
required: ['toolName'],
|
|
407
|
+
},
|
|
408
|
+
async execute(params, ctx) {
|
|
409
|
+
const r = await post('/evaluate/tool-call', {
|
|
410
|
+
sessionId: ctx.sessionId ?? '',
|
|
411
|
+
userId: ctx.agentId ?? '',
|
|
412
|
+
toolName: params.toolName,
|
|
413
|
+
params: params.params ?? {},
|
|
414
|
+
dryRun: true,
|
|
415
|
+
});
|
|
416
|
+
return r ?? { error: 'Service unreachable' };
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
// CLI extension — `safeclaw status` command (#197)
|
|
421
|
+
if (api.registerCli) {
|
|
422
|
+
api.registerCli(({ program }) => {
|
|
423
|
+
const cmd = program.command('safeclaw').description('SafeClaw governance controls');
|
|
424
|
+
cmd.command('status')
|
|
425
|
+
.description('Show SafeClaw service status and enforcement mode')
|
|
426
|
+
.action(async () => {
|
|
427
|
+
const cfg = getConfig();
|
|
428
|
+
const health = await get('/health');
|
|
429
|
+
console.log(`SafeClaw: ${health?.status ?? 'unreachable'}`);
|
|
430
|
+
console.log(` Enforcement: ${cfg.enforcement}`);
|
|
431
|
+
console.log(` Fail mode: ${cfg.failMode}`);
|
|
432
|
+
console.log(` Service: ${cfg.serviceUrl}`);
|
|
433
|
+
});
|
|
434
|
+
}, { commands: ['safeclaw'] });
|
|
435
|
+
}
|
|
233
436
|
},
|
|
234
437
|
};
|
package/dist/tui/About.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Text, Box } from 'ink';
|
|
3
3
|
export default function About() {
|
|
4
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "About" }) }), _jsx(Text, { children: "
|
|
4
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "About SafeClaw" }) }), _jsx(Text, { children: " Neurosymbolic governance for AI agents." }), _jsx(Text, { dimColor: true, children: " Validates tool calls, messages, and actions against" }), _jsx(Text, { dimColor: true, children: " OWL ontologies and SHACL constraints before execution." }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { bold: true, children: " Features" }) }), _jsx(Text, { dimColor: true, children: " - 9-step constraint pipeline (SHACL, policies, preferences, dependencies)" }), _jsx(Text, { dimColor: true, children: " - Role-based access control with per-user preferences" }), _jsx(Text, { dimColor: true, children: " - Multi-agent governance with delegation detection" }), _jsx(Text, { dimColor: true, children: " - Append-only audit trail with compliance reporting" }), _jsx(Text, { dimColor: true, children: " - Passive LLM security reviewer and classification observer" }), _jsx(Text, { dimColor: true, children: " - Natural language policy compilation" }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { bold: true, children: " CLI commands" }) }), _jsx(Text, { dimColor: true, children: " safeclaw-plugin This plugin CLI (connect, status, config, tui)" }), _jsx(Text, { dimColor: true, children: " safeclaw Service CLI (serve, audit, policy, pref, status)" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { children: " Web: " }), _jsx(Text, { color: "cyan", children: "https://safeclaw.eu" })] }), _jsxs(Box, { children: [_jsx(Text, { children: " Docs: " }), _jsx(Text, { color: "cyan", children: "https://safeclaw.eu/docs" })] }), _jsxs(Box, { children: [_jsx(Text, { children: " Repo: " }), _jsx(Text, { color: "cyan", children: "https://github.com/tendlyeu/SafeClaw" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: " q to quit" }) })] }));
|
|
5
5
|
}
|
package/dist/tui/App.js
CHANGED
|
@@ -33,5 +33,5 @@ export default function App() {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
});
|
|
36
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "single", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "SafeClaw " }), _jsxs(Text, { dimColor: true, children: ["v", PKG_VERSION] })] }), _jsxs(Box, { paddingX: 1, gap: 2, children: [TABS.map((t, i) => (_jsx(Text, { bold: tab === t, color: tab === t ? 'cyan' : 'white', dimColor: tab !== t, children: `${i + 1}:${t}` }, t))), _jsx(Text, { dimColor: true, children: " tab/1-3
|
|
36
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "single", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "SafeClaw " }), _jsxs(Text, { dimColor: true, children: ["v", PKG_VERSION] })] }), _jsxs(Box, { paddingX: 1, gap: 2, children: [TABS.map((t, i) => (_jsx(Text, { bold: tab === t, color: tab === t ? 'cyan' : 'white', dimColor: tab !== t, children: `${i + 1}:${t}` }, t))), _jsx(Text, { dimColor: true, children: " tab/1-3 switch q quit" })] }), _jsxs(Box, { marginTop: 1, children: [tab === 'Status' && _jsx(Status, { config: config }), tab === 'Settings' && (_jsx(Settings, { config: config, onConfigChange: setConfig })), tab === 'About' && _jsx(About, {})] })] }));
|
|
37
37
|
}
|
package/dist/tui/Settings.js
CHANGED
|
@@ -9,6 +9,7 @@ const SETTINGS = [
|
|
|
9
9
|
{ key: 'enforcement', label: 'Enforcement', type: 'cycle', values: ENFORCEMENT_MODES },
|
|
10
10
|
{ key: 'failMode', label: 'Fail Mode', type: 'cycle', values: FAIL_MODES },
|
|
11
11
|
{ key: 'serviceUrl', label: 'Service URL', type: 'text' },
|
|
12
|
+
{ key: 'apiKey', label: 'API Key', type: 'text' },
|
|
12
13
|
];
|
|
13
14
|
export default function Settings({ config, onConfigChange }) {
|
|
14
15
|
const [selected, setSelected] = useState(0);
|
|
@@ -22,7 +23,18 @@ export default function Settings({ config, onConfigChange }) {
|
|
|
22
23
|
useInput((input, key) => {
|
|
23
24
|
if (editing) {
|
|
24
25
|
if (key.return) {
|
|
25
|
-
|
|
26
|
+
const editingKey = SETTINGS[selected].key;
|
|
27
|
+
if (editingKey === 'apiKey' && editBuffer && !editBuffer.startsWith('sc_')) {
|
|
28
|
+
// Reject invalid API keys — must start with sc_
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
updateConfig({ [editingKey]: editBuffer });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// saveConfig may throw on invalid URL — stay in edit mode
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
26
38
|
setEditing(false);
|
|
27
39
|
}
|
|
28
40
|
else if (key.escape) {
|
|
@@ -57,7 +69,7 @@ export default function Settings({ config, onConfigChange }) {
|
|
|
57
69
|
}
|
|
58
70
|
else if (setting.type === 'text' && key.return) {
|
|
59
71
|
setEditing(true);
|
|
60
|
-
setEditBuffer(config.
|
|
72
|
+
setEditBuffer(String(config[setting.key] ?? ''));
|
|
61
73
|
}
|
|
62
74
|
}
|
|
63
75
|
});
|
|
@@ -68,8 +80,13 @@ export default function Settings({ config, onConfigChange }) {
|
|
|
68
80
|
if (setting.key === 'enabled') {
|
|
69
81
|
value = config.enabled ? 'ON' : 'OFF';
|
|
70
82
|
}
|
|
71
|
-
else if (setting.
|
|
72
|
-
value = editBuffer + '█';
|
|
83
|
+
else if (setting.type === 'text' && editing && isSelected) {
|
|
84
|
+
value = (setting.key === 'apiKey' ? '*'.repeat(editBuffer.length) : editBuffer) + '█';
|
|
85
|
+
}
|
|
86
|
+
else if (setting.key === 'apiKey') {
|
|
87
|
+
value = config.apiKey
|
|
88
|
+
? `${config.apiKey.slice(0, 6)}..${config.apiKey.slice(-4)}`
|
|
89
|
+
: '(not set)';
|
|
73
90
|
}
|
|
74
91
|
else {
|
|
75
92
|
value = String(config[setting.key]);
|
|
@@ -79,6 +96,8 @@ export default function Settings({ config, onConfigChange }) {
|
|
|
79
96
|
? config.enabled ? 'green' : 'red'
|
|
80
97
|
: undefined, children: value }), showArrows && _jsx(Text, { dimColor: true, children: ' ▶' })] }, setting.key));
|
|
81
98
|
}), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: editing
|
|
82
|
-
?
|
|
83
|
-
|
|
99
|
+
? SETTINGS[selected].key === 'apiKey' && editBuffer && !editBuffer.startsWith('sc_')
|
|
100
|
+
? ' API key must start with sc_ · esc to cancel'
|
|
101
|
+
: ' type to edit · enter to save · esc to cancel'
|
|
102
|
+
: ' ↑↓ navigate · ←→/enter cycle/toggle · enter to edit text fields · q quit' }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: ' Enforcement: enforce=block violations, warn-only=log only, audit-only=record only, disabled=off' }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: ' Fail Mode: open=allow if service unreachable, closed=block if service unreachable' }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: ' All changes save to ~/.safeclaw/config.json immediately.' }) })] }));
|
|
84
103
|
}
|