openclaw-safeclaw-plugin 0.9.2 → 1.1.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/index.ts CHANGED
@@ -8,35 +8,54 @@
8
8
  */
9
9
 
10
10
  import { loadConfig, configHash } from './tui/config.js';
11
+ import crypto from 'crypto';
12
+ import { createRequire } from 'module';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const { version: PLUGIN_VERSION } = require('./package.json') as { version: string };
11
16
 
12
17
  // --- Configuration ---
13
18
 
14
- const config = loadConfig();
19
+ const CONFIG_RELOAD_INTERVAL_MS = 60_000; // Reload config every 60 seconds
20
+
21
+ let config = loadConfig();
22
+ let configLoadedAt = Date.now();
23
+
24
+ function getConfig(): typeof config {
25
+ const now = Date.now();
26
+ if (now - configLoadedAt >= CONFIG_RELOAD_INTERVAL_MS) {
27
+ config = loadConfig();
28
+ configLoadedAt = now;
29
+ }
30
+ return config;
31
+ }
15
32
 
16
33
  // --- HTTP Client ---
17
34
 
18
35
  async function post(path: string, body: Record<string, unknown>): Promise<Record<string, unknown> | null> {
19
- if (!config.enabled) return null;
36
+ const cfg = getConfig();
37
+ if (!cfg.enabled) return null;
20
38
 
21
39
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
22
- if (config.apiKey) {
23
- headers['Authorization'] = `Bearer ${config.apiKey}`;
40
+ if (cfg.apiKey) {
41
+ headers['Authorization'] = `Bearer ${cfg.apiKey}`;
24
42
  }
25
43
 
26
- const agentFields = config.agentId ? { agentId: config.agentId, agentToken: config.agentToken } : {};
44
+ const agentFields = cfg.agentId ? { agentId: cfg.agentId, agentToken: cfg.agentToken } : {};
27
45
 
28
46
  try {
29
- const res = await fetch(`${config.serviceUrl}${path}`, {
47
+ const res = await fetch(`${cfg.serviceUrl}${path}`, {
30
48
  method: 'POST',
31
49
  headers,
32
50
  body: JSON.stringify({ ...body, ...agentFields }),
33
- signal: AbortSignal.timeout(config.timeoutMs),
51
+ signal: AbortSignal.timeout(cfg.timeoutMs),
34
52
  });
35
53
  if (!res.ok) {
36
54
  // Try to parse structured error body from service
37
55
  try {
38
56
  const errBody = await res.json() as Record<string, unknown>;
39
- const detail = errBody.detail ?? `HTTP ${res.status}`;
57
+ const rawDetail = errBody.detail ?? `HTTP ${res.status}`;
58
+ const detail = typeof rawDetail === 'string' ? rawDetail : JSON.stringify(rawDetail);
40
59
  const hint = errBody.hint ? ` (${errBody.hint})` : '';
41
60
  console.warn(`[SafeClaw] ${path}: ${detail}${hint}`);
42
61
  } catch {
@@ -47,11 +66,11 @@ async function post(path: string, body: Record<string, unknown>): Promise<Record
47
66
  return await res.json() as Record<string, unknown>;
48
67
  } catch (e) {
49
68
  if (e instanceof DOMException && e.name === 'TimeoutError') {
50
- console.warn(`[SafeClaw] Timeout after ${config.timeoutMs}ms on ${path} (${config.serviceUrl})`);
69
+ console.warn(`[SafeClaw] Timeout after ${cfg.timeoutMs}ms on ${path} (${cfg.serviceUrl})`);
51
70
  } else if (e instanceof TypeError && (e.message.includes('fetch') || e.message.includes('ECONNREFUSED'))) {
52
- console.warn(`[SafeClaw] Connection refused: ${config.serviceUrl}${path} — is the service running?`);
71
+ console.warn(`[SafeClaw] Connection refused: ${cfg.serviceUrl}${path} — is the service running?`);
53
72
  } else {
54
- console.warn(`[SafeClaw] Service unavailable: ${config.serviceUrl}${path}`);
73
+ console.warn(`[SafeClaw] Service unavailable: ${cfg.serviceUrl}${path}`);
55
74
  }
56
75
  return null; // Caller checks failMode
57
76
  }
@@ -82,14 +101,15 @@ interface PluginApi {
82
101
  let handshakeCompleted = false;
83
102
 
84
103
  async function performHandshake(): Promise<boolean> {
85
- if (!config.apiKey) {
104
+ const cfg = getConfig();
105
+ if (!cfg.apiKey) {
86
106
  console.warn('[SafeClaw] No API key configured — skipping handshake');
87
107
  return false;
88
108
  }
89
109
 
90
110
  const r = await post('/handshake', {
91
- pluginVersion: '0.1.3',
92
- configHash: configHash(config),
111
+ pluginVersion: PLUGIN_VERSION,
112
+ configHash: configHash(cfg),
93
113
  });
94
114
 
95
115
  if (r === null) {
@@ -103,13 +123,14 @@ async function performHandshake(): Promise<boolean> {
103
123
  }
104
124
 
105
125
  async function checkConnection(): Promise<void> {
126
+ const cfg = getConfig();
106
127
  const label = `[SafeClaw]`;
107
- console.log(`${label} Connecting to ${config.serviceUrl} ...`);
108
- console.log(`${label} Mode: enforcement=${config.enforcement}, failMode=${config.failMode}`);
128
+ console.log(`${label} Connecting to ${cfg.serviceUrl} ...`);
129
+ console.log(`${label} Mode: enforcement=${cfg.enforcement}, failMode=${cfg.failMode}`);
109
130
 
110
131
  try {
111
- const res = await fetch(`${config.serviceUrl}/health`, {
112
- signal: AbortSignal.timeout(config.timeoutMs * 2),
132
+ const res = await fetch(`${cfg.serviceUrl}/health`, {
133
+ signal: AbortSignal.timeout(cfg.timeoutMs * 2),
113
134
  });
114
135
  if (res.ok) {
115
136
  const data = await res.json() as Record<string, unknown>;
@@ -118,8 +139,8 @@ async function checkConnection(): Promise<void> {
118
139
  console.warn(`${label} ✗ Service responded with HTTP ${res.status}`);
119
140
  }
120
141
  } catch {
121
- console.warn(`${label} ✗ Cannot reach service at ${config.serviceUrl}`);
122
- if (config.failMode === 'closed') {
142
+ console.warn(`${label} ✗ Cannot reach service at ${cfg.serviceUrl}`);
143
+ if (cfg.failMode === 'closed') {
123
144
  console.warn(`${label} fail-mode=closed → tool calls will be BLOCKED until service is reachable`);
124
145
  } else {
125
146
  console.warn(`${label} fail-mode=open → tool calls will be ALLOWED despite no connection`);
@@ -130,64 +151,64 @@ async function checkConnection(): Promise<void> {
130
151
  export default {
131
152
  id: 'safeclaw',
132
153
  name: 'SafeClaw Neurosymbolic Governance',
133
- version: '0.1.3',
154
+ version: PLUGIN_VERSION,
134
155
 
135
156
  register(api: PluginApi) {
136
- if (!config.enabled) {
157
+ if (!getConfig().enabled) {
137
158
  console.log('[SafeClaw] Plugin disabled');
138
159
  return;
139
160
  }
140
161
 
162
+ // Generate a unique instance ID for this plugin run (fallback when agentId is not configured)
163
+ const instanceId = getConfig().agentId || `instance-${crypto.randomUUID()}`;
164
+
141
165
  // Heartbeat watchdog — send config hash to service every 30s
142
166
  const sendHeartbeat = async () => {
143
167
  try {
144
- await fetch(`${config.serviceUrl}/heartbeat`, {
145
- method: 'POST',
146
- headers: { 'Content-Type': 'application/json' },
147
- body: JSON.stringify({
148
- agentId: config.agentId || 'default',
149
- configHash: configHash(config),
150
- status: 'alive',
151
- }),
152
- signal: AbortSignal.timeout(config.timeoutMs),
168
+ await post('/heartbeat', {
169
+ agentId: instanceId,
170
+ configHash: configHash(getConfig()),
171
+ status: 'alive',
153
172
  });
154
173
  } catch {
155
174
  // Heartbeat failure is non-fatal
156
175
  }
157
176
  };
158
177
 
159
- // Start heartbeat after connection check + handshake
178
+ // Start heartbeat only after connection check + handshake completes (#84)
179
+ let heartbeatInterval: ReturnType<typeof setInterval> | undefined;
160
180
  checkConnection()
161
181
  .then(() => performHandshake())
162
182
  .then((ok) => {
163
- if (!ok && config.failMode === 'closed') {
164
- console.warn('[SafeClaw] Handshake failed with fail-mode=closed — tool calls will be BLOCKED');
183
+ if (!ok && getConfig().failMode === 'closed') {
184
+ console.warn('[SafeClaw] Handshake failed with fail-mode=closed — tool calls will be BLOCKED');
165
185
  }
186
+ heartbeatInterval = setInterval(sendHeartbeat, 30000);
166
187
  return sendHeartbeat();
167
188
  })
168
189
  .catch(() => {});
169
- const heartbeatInterval = setInterval(sendHeartbeat, 30000);
170
190
 
171
191
  // Clean shutdown: send shutdown heartbeat and clear interval
172
- const shutdown = () => {
173
- clearInterval(heartbeatInterval);
174
- fetch(`${config.serviceUrl}/heartbeat`, {
175
- method: 'POST',
176
- headers: { 'Content-Type': 'application/json' },
177
- body: JSON.stringify({
178
- agentId: config.agentId || 'default',
179
- configHash: configHash(config),
192
+ // Use async shutdown for SIGINT/SIGTERM where async is supported (#55)
193
+ const shutdown = async () => {
194
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
195
+ try {
196
+ await post('/heartbeat', {
197
+ agentId: instanceId,
198
+ configHash: configHash(getConfig()),
180
199
  status: 'shutdown',
181
- }),
182
- }).catch(() => {});
200
+ });
201
+ } catch {
202
+ // Best-effort shutdown notification
203
+ }
183
204
  };
184
- process.on('exit', shutdown);
185
- process.on('SIGINT', () => { shutdown(); process.exit(0); });
186
- process.on('SIGTERM', () => { shutdown(); process.exit(0); });
205
+ process.on('SIGINT', async () => { await shutdown(); process.exit(0); });
206
+ process.on('SIGTERM', async () => { await shutdown(); process.exit(0); });
187
207
 
188
208
  // THE GATE — constraint checking on every tool call
189
209
  api.on('before_tool_call', async (event: PluginEvent, ctx: PluginContext) => {
190
- if (!handshakeCompleted && config.failMode === 'closed' && config.enforcement === 'enforce') {
210
+ const cfg = getConfig();
211
+ if (!handshakeCompleted && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
191
212
  return { block: true, blockReason: 'SafeClaw handshake not completed (fail-closed)' };
192
213
  }
193
214
 
@@ -199,19 +220,20 @@ export default {
199
220
  sessionHistory: event.sessionHistory ?? [],
200
221
  });
201
222
 
202
- if (r === null && config.failMode === 'closed' && config.enforcement === 'enforce') {
203
- return { block: true, blockReason: `SafeClaw service unavailable at ${config.serviceUrl} (fail-closed)` };
204
- } else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
205
- console.warn(`[SafeClaw] Service unavailable at ${config.serviceUrl} (fail-closed mode, warn-only)`);
206
- } else if (r === null && config.failMode === 'closed' && config.enforcement === 'audit-only') {
207
- console.warn(`[SafeClaw] Service unavailable at ${config.serviceUrl} (fail-closed mode, audit-only)`);
223
+ if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
224
+ return { block: true, blockReason: `SafeClaw service unavailable at ${cfg.serviceUrl} (fail-closed)` };
225
+ } else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
226
+ console.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, warn-only)`);
227
+ } else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
228
+ console.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, audit-only)`);
208
229
  }
209
230
  if (r?.block) {
210
- if (config.enforcement === 'enforce') {
211
- return { block: true, blockReason: r.reason as string };
231
+ const blockReason = (r.reason as string) || 'Blocked by SafeClaw (no reason provided)';
232
+ if (cfg.enforcement === 'enforce') {
233
+ return { block: true, blockReason };
212
234
  }
213
- if (config.enforcement === 'warn-only') {
214
- console.warn(`[SafeClaw] Warning: ${r.reason}`);
235
+ if (cfg.enforcement === 'warn-only') {
236
+ console.warn(`[SafeClaw] Warning: ${blockReason}`);
215
237
  }
216
238
  // audit-only: logged server-side, no action here
217
239
  }
@@ -231,6 +253,7 @@ export default {
231
253
 
232
254
  // Message governance — check outbound messages
233
255
  api.on('message_sending', async (event: PluginEvent, ctx: PluginContext) => {
256
+ const cfg = getConfig();
234
257
  const r = await post('/evaluate/message', {
235
258
  sessionId: ctx.sessionId ?? event.sessionId,
236
259
  userId: ctx.userId ?? event.userId,
@@ -238,17 +261,20 @@ export default {
238
261
  content: event.content,
239
262
  });
240
263
 
241
- if (r === null && config.failMode === 'closed' && config.enforcement === 'enforce') {
242
- return { cancel: true };
243
- } else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
264
+ if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
265
+ return { cancel: true, cancelReason: 'SafeClaw service unavailable (fail-closed mode)' };
266
+ } else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
244
267
  console.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
268
+ } else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
269
+ console.info('[SafeClaw] audit-only: service unreachable, allowing message (fail-closed)');
245
270
  }
246
271
  if (r?.block) {
247
- if (config.enforcement === 'enforce') {
248
- return { cancel: true };
272
+ const blockReason = (r.reason as string) || 'Blocked by SafeClaw (no reason provided)';
273
+ if (cfg.enforcement === 'enforce') {
274
+ return { cancel: true, cancelReason: blockReason };
249
275
  }
250
- if (config.enforcement === 'warn-only') {
251
- console.warn(`[SafeClaw] Warning: ${r.reason}`);
276
+ if (cfg.enforcement === 'warn-only') {
277
+ console.warn(`[SafeClaw] Warning: ${blockReason}`);
252
278
  }
253
279
  // audit-only: logged server-side, no action here
254
280
  }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "openclaw-safeclaw-plugin",
3
- "version": "0.9.2",
3
+ "version": "1.1.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",
7
7
  "type": "module",
8
8
  "bin": {
9
- "safeclaw": "dist/cli.js"
9
+ "safeclaw-plugin": "dist/cli.js"
10
10
  },
11
11
  "scripts": {
12
12
  "build": "tsc",
package/tui/About.tsx CHANGED
@@ -5,11 +5,28 @@ export default function About() {
5
5
  return (
6
6
  <Box flexDirection="column" paddingX={1}>
7
7
  <Box marginBottom={1}>
8
- <Text bold>About</Text>
8
+ <Text bold>About SafeClaw</Text>
9
9
  </Box>
10
- <Text> SafeClaw Neurosymbolic Governance</Text>
11
- <Text dimColor> Validates AI agent actions against OWL</Text>
12
- <Text dimColor> ontologies and SHACL constraints.</Text>
10
+ <Text> Neurosymbolic governance for AI agents.</Text>
11
+ <Text dimColor> Validates tool calls, messages, and actions against</Text>
12
+ <Text dimColor> OWL ontologies and SHACL constraints before execution.</Text>
13
+
14
+ <Box marginTop={1} marginBottom={1}>
15
+ <Text bold> Features</Text>
16
+ </Box>
17
+ <Text dimColor> - 9-step constraint pipeline (SHACL, policies, preferences, dependencies)</Text>
18
+ <Text dimColor> - Role-based access control with per-user preferences</Text>
19
+ <Text dimColor> - Multi-agent governance with delegation detection</Text>
20
+ <Text dimColor> - Append-only audit trail with compliance reporting</Text>
21
+ <Text dimColor> - Passive LLM security reviewer and classification observer</Text>
22
+ <Text dimColor> - Natural language policy compilation</Text>
23
+
24
+ <Box marginTop={1} marginBottom={1}>
25
+ <Text bold> CLI commands</Text>
26
+ </Box>
27
+ <Text dimColor> safeclaw-plugin This plugin CLI (connect, status, config, tui)</Text>
28
+ <Text dimColor> safeclaw Service CLI (serve, audit, policy, pref, status)</Text>
29
+
13
30
  <Box marginTop={1}>
14
31
  <Text> Web: </Text>
15
32
  <Text color="cyan">https://safeclaw.eu</Text>
package/tui/App.tsx CHANGED
@@ -55,7 +55,7 @@ export default function App() {
55
55
  {`${i + 1}:${t}`}
56
56
  </Text>
57
57
  ))}
58
- <Text dimColor> tab/1-3 to switch</Text>
58
+ <Text dimColor> tab/1-3 switch q quit</Text>
59
59
  </Box>
60
60
 
61
61
  {/* Content */}
package/tui/Settings.tsx CHANGED
@@ -22,6 +22,7 @@ const SETTINGS: SettingItem[] = [
22
22
  { key: 'enforcement', label: 'Enforcement', type: 'cycle', values: ENFORCEMENT_MODES },
23
23
  { key: 'failMode', label: 'Fail Mode', type: 'cycle', values: FAIL_MODES },
24
24
  { key: 'serviceUrl', label: 'Service URL', type: 'text' },
25
+ { key: 'apiKey', label: 'API Key', type: 'text' },
25
26
  ];
26
27
 
27
28
  export default function Settings({ config, onConfigChange }: SettingsProps) {
@@ -38,7 +39,17 @@ export default function Settings({ config, onConfigChange }: SettingsProps) {
38
39
  useInput((input, key) => {
39
40
  if (editing) {
40
41
  if (key.return) {
41
- updateConfig({ serviceUrl: editBuffer });
42
+ const editingKey = SETTINGS[selected].key;
43
+ if (editingKey === 'apiKey' && editBuffer && !editBuffer.startsWith('sc_')) {
44
+ // Reject invalid API keys — must start with sc_
45
+ return;
46
+ }
47
+ try {
48
+ updateConfig({ [editingKey]: editBuffer } as Partial<SafeClawConfig>);
49
+ } catch {
50
+ // saveConfig may throw on invalid URL — stay in edit mode
51
+ return;
52
+ }
42
53
  setEditing(false);
43
54
  } else if (key.escape) {
44
55
  setEditing(false);
@@ -67,7 +78,7 @@ export default function Settings({ config, onConfigChange }: SettingsProps) {
67
78
  updateConfig({ [currentKey]: next });
68
79
  } else if (setting.type === 'text' && key.return) {
69
80
  setEditing(true);
70
- setEditBuffer(config.serviceUrl);
81
+ setEditBuffer(String(config[setting.key as keyof SafeClawConfig] ?? ''));
71
82
  }
72
83
  }
73
84
  });
@@ -85,8 +96,12 @@ export default function Settings({ config, onConfigChange }: SettingsProps) {
85
96
 
86
97
  if (setting.key === 'enabled') {
87
98
  value = config.enabled ? 'ON' : 'OFF';
88
- } else if (setting.key === 'serviceUrl' && editing && isSelected) {
89
- value = editBuffer + '█';
99
+ } else if (setting.type === 'text' && editing && isSelected) {
100
+ value = (setting.key === 'apiKey' ? '*'.repeat(editBuffer.length) : editBuffer) + '█';
101
+ } else if (setting.key === 'apiKey') {
102
+ value = config.apiKey
103
+ ? `${config.apiKey.slice(0, 6)}..${config.apiKey.slice(-4)}`
104
+ : '(not set)';
90
105
  } else {
91
106
  value = String(config[setting.key as keyof SafeClawConfig]);
92
107
  }
@@ -117,13 +132,22 @@ export default function Settings({ config, onConfigChange }: SettingsProps) {
117
132
  <Box marginTop={1}>
118
133
  <Text dimColor>
119
134
  {editing
120
- ? ' type to edit · enter to save · esc to cancel'
121
- : ' ↑↓ navigate · ←→ change · enter to edit URL · q quit'}
135
+ ? SETTINGS[selected].key === 'apiKey' && editBuffer && !editBuffer.startsWith('sc_')
136
+ ? ' API key must start with sc_ · esc to cancel'
137
+ : ' type to edit · enter to save · esc to cancel'
138
+ : ' ↑↓ navigate · ←→/enter cycle/toggle · enter to edit text fields · q quit'}
122
139
  </Text>
123
140
  </Box>
124
141
 
142
+ <Box marginTop={1}>
143
+ <Text dimColor>{' Enforcement: enforce=block violations, warn-only=log only, audit-only=record only, disabled=off'}</Text>
144
+ </Box>
125
145
  <Box>
126
- <Text dimColor>{' Saves to ~/.safeclaw/config.json'}</Text>
146
+ <Text dimColor>{' Fail Mode: open=allow if service unreachable, closed=block if service unreachable'}</Text>
147
+ </Box>
148
+
149
+ <Box marginTop={1}>
150
+ <Text dimColor>{' All changes save to ~/.safeclaw/config.json immediately.'}</Text>
127
151
  </Box>
128
152
  </Box>
129
153
  );
package/tui/Status.tsx CHANGED
@@ -1,8 +1,15 @@
1
1
  import React, { useState, useEffect } from 'react';
2
2
  import { Text, Box, useInput } from 'ink';
3
3
  import { exec } from 'child_process';
4
+ import { existsSync, readFileSync } from 'fs';
5
+ import { join, dirname } from 'path';
6
+ import { homedir } from 'os';
7
+ import { fileURLToPath } from 'url';
4
8
  import { type SafeClawConfig } from './config.js';
5
9
 
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version as string;
12
+
6
13
  interface StatusProps {
7
14
  config: SafeClawConfig;
8
15
  }
@@ -13,6 +20,7 @@ interface HealthData {
13
20
  engine_ready?: boolean;
14
21
  }
15
22
 
23
+ type CheckStatus = 'checking' | 'ok' | 'fail';
16
24
  type OpenClawStatus = 'checking' | 'running' | 'not running' | 'error';
17
25
 
18
26
  export default function Status({ config }: StatusProps) {
@@ -21,6 +29,9 @@ export default function Status({ config }: StatusProps) {
21
29
  const [lastCheck, setLastCheck] = useState<Date | null>(null);
22
30
  const [openclawStatus, setOpenclawStatus] = useState<OpenClawStatus>('checking');
23
31
  const [restartMsg, setRestartMsg] = useState<string | null>(null);
32
+ const [evaluateStatus, setEvaluateStatus] = useState<CheckStatus>('checking');
33
+ const [handshakeStatus, setHandshakeStatus] = useState<CheckStatus>('checking');
34
+ const [handshakeDetail, setHandshakeDetail] = useState('');
24
35
 
25
36
  const checkHealth = async () => {
26
37
  try {
@@ -31,17 +42,73 @@ export default function Status({ config }: StatusProps) {
31
42
  const data = await res.json() as HealthData;
32
43
  setHealth(data);
33
44
  setError(null);
45
+ // If healthy, run deeper checks
46
+ checkEvaluate();
47
+ if (config.apiKey) checkHandshake();
48
+ else {
49
+ setHandshakeStatus('fail');
50
+ setHandshakeDetail('No API key');
51
+ }
34
52
  } else {
35
53
  setHealth(null);
36
54
  setError(`HTTP ${res.status}`);
55
+ setEvaluateStatus('fail');
56
+ setHandshakeStatus('fail');
37
57
  }
38
58
  } catch {
39
59
  setHealth(null);
40
60
  setError('Cannot connect');
61
+ setEvaluateStatus('fail');
62
+ setHandshakeStatus('fail');
41
63
  }
42
64
  setLastCheck(new Date());
43
65
  };
44
66
 
67
+ const checkEvaluate = async () => {
68
+ try {
69
+ const res = await fetch(`${config.serviceUrl}/evaluate/tool-call`, {
70
+ method: 'POST',
71
+ headers: {
72
+ 'Content-Type': 'application/json',
73
+ ...(config.apiKey ? { 'Authorization': `Bearer ${config.apiKey}` } : {}),
74
+ },
75
+ body: JSON.stringify({
76
+ sessionId: 'tui-check', userId: 'tui-check',
77
+ toolName: 'echo', params: { message: 'tui-check' },
78
+ }),
79
+ signal: AbortSignal.timeout(config.timeoutMs),
80
+ });
81
+ setEvaluateStatus(res.ok || res.status === 422 || res.status === 401 || res.status === 403 ? 'ok' : 'fail');
82
+ } catch {
83
+ setEvaluateStatus('fail');
84
+ }
85
+ };
86
+
87
+ const checkHandshake = async () => {
88
+ try {
89
+ const res = await fetch(`${config.serviceUrl}/handshake`, {
90
+ method: 'POST',
91
+ headers: {
92
+ 'Content-Type': 'application/json',
93
+ 'Authorization': `Bearer ${config.apiKey}`,
94
+ },
95
+ body: JSON.stringify({ pluginVersion: PKG_VERSION, configHash: '' }),
96
+ signal: AbortSignal.timeout(config.timeoutMs),
97
+ });
98
+ if (res.ok) {
99
+ const data = await res.json() as Record<string, unknown>;
100
+ setHandshakeStatus('ok');
101
+ setHandshakeDetail(`org=${data.orgId ?? '?'}`);
102
+ } else {
103
+ setHandshakeStatus('fail');
104
+ setHandshakeDetail(`HTTP ${res.status}`);
105
+ }
106
+ } catch {
107
+ setHandshakeStatus('fail');
108
+ setHandshakeDetail('timeout');
109
+ }
110
+ };
111
+
45
112
  const checkOpenClaw = () => {
46
113
  exec('openclaw daemon status', { timeout: 10000 }, (err, stdout) => {
47
114
  if (err) {
@@ -82,6 +149,7 @@ export default function Status({ config }: StatusProps) {
82
149
  }
83
150
  });
84
151
 
152
+ // Derived display values
85
153
  const connected = health !== null;
86
154
  const dot = '●';
87
155
  const serviceDotColor = connected ? 'green' : 'red';
@@ -94,6 +162,31 @@ export default function Status({ config }: StatusProps) {
94
162
  : openclawStatus === 'running' ? 'Running'
95
163
  : 'Not running';
96
164
 
165
+ // Plugin file checks
166
+ const extensionDir = join(homedir(), '.openclaw', 'extensions', 'safeclaw');
167
+ const pluginInstalled = existsSync(join(extensionDir, 'openclaw.plugin.json'))
168
+ && existsSync(join(extensionDir, 'index.js'));
169
+
170
+ // Plugin enabled in OpenClaw config
171
+ let pluginEnabled = false;
172
+ const ocConfigPath = join(homedir(), '.openclaw', 'openclaw.json');
173
+ if (existsSync(ocConfigPath)) {
174
+ try {
175
+ const ocConfig = JSON.parse(readFileSync(ocConfigPath, 'utf-8'));
176
+ pluginEnabled = !!ocConfig?.plugins?.entries?.safeclaw?.enabled;
177
+ } catch { /* ignore */ }
178
+ }
179
+
180
+ const apiKeyMasked = config.apiKey
181
+ ? `${config.apiKey.slice(0, 6)}..${config.apiKey.slice(-4)}`
182
+ : '(not set)';
183
+
184
+ const statusDot = (status: CheckStatus | boolean) => {
185
+ if (status === 'checking') return <Text color="yellow">{dot} </Text>;
186
+ if (status === 'ok' || status === true) return <Text color="green">{dot} </Text>;
187
+ return <Text color="red">{dot} </Text>;
188
+ };
189
+
97
190
  return (
98
191
  <Box flexDirection="column" paddingX={1}>
99
192
  <Box marginBottom={1}>
@@ -101,18 +194,51 @@ export default function Status({ config }: StatusProps) {
101
194
  </Box>
102
195
 
103
196
  <Box>
104
- <Text dimColor>{' Service '}</Text>
197
+ <Text dimColor>{' Service '}</Text>
105
198
  <Text color={serviceDotColor}>{dot} </Text>
106
199
  <Text>{serviceText}</Text>
107
200
  </Box>
108
201
 
202
+ {connected && (
203
+ <>
204
+ <Box>
205
+ <Text dimColor>{' Evaluate '}</Text>
206
+ {statusDot(evaluateStatus)}
207
+ <Text>{evaluateStatus === 'ok' ? 'Responding' : evaluateStatus === 'checking' ? 'Checking...' : 'Not responding'}</Text>
208
+ </Box>
209
+ <Box>
210
+ <Text dimColor>{' Handshake '}</Text>
211
+ {statusDot(handshakeStatus)}
212
+ <Text>{handshakeStatus === 'ok' ? handshakeDetail : handshakeStatus === 'checking' ? 'Checking...' : handshakeDetail}</Text>
213
+ </Box>
214
+ </>
215
+ )}
216
+
109
217
  <Box>
110
- <Text dimColor>{' OpenClaw '}</Text>
218
+ <Text dimColor>{' OpenClaw '}</Text>
111
219
  <Text color={openclawDotColor}>{dot} </Text>
112
220
  <Text>{openclawText}</Text>
113
221
  {restartMsg && <Text dimColor>{` (${restartMsg})`}</Text>}
114
222
  </Box>
115
223
 
224
+ <Box>
225
+ <Text dimColor>{' Plugin '}</Text>
226
+ {statusDot(pluginInstalled)}
227
+ <Text>{pluginInstalled ? 'Installed' : 'Not installed'}</Text>
228
+ </Box>
229
+
230
+ <Box>
231
+ <Text dimColor>{' Config '}</Text>
232
+ {statusDot(pluginEnabled)}
233
+ <Text>{pluginEnabled ? 'Enabled' : 'Not enabled'}</Text>
234
+ </Box>
235
+
236
+ <Box>
237
+ <Text dimColor>{' API Key '}</Text>
238
+ {statusDot(!!config.apiKey)}
239
+ <Text>{apiKeyMasked}</Text>
240
+ </Box>
241
+
116
242
  <Box>
117
243
  <Text dimColor>{' Enforcement '}</Text>
118
244
  <Text>{config.enforcement}</Text>