openclaw-safeclaw-plugin 1.0.0 → 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,29 +8,47 @@
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
@@ -48,11 +66,11 @@ async function post(path: string, body: Record<string, unknown>): Promise<Record
48
66
  return await res.json() as Record<string, unknown>;
49
67
  } catch (e) {
50
68
  if (e instanceof DOMException && e.name === 'TimeoutError') {
51
- 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})`);
52
70
  } else if (e instanceof TypeError && (e.message.includes('fetch') || e.message.includes('ECONNREFUSED'))) {
53
- 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?`);
54
72
  } else {
55
- console.warn(`[SafeClaw] Service unavailable: ${config.serviceUrl}${path}`);
73
+ console.warn(`[SafeClaw] Service unavailable: ${cfg.serviceUrl}${path}`);
56
74
  }
57
75
  return null; // Caller checks failMode
58
76
  }
@@ -83,14 +101,15 @@ interface PluginApi {
83
101
  let handshakeCompleted = false;
84
102
 
85
103
  async function performHandshake(): Promise<boolean> {
86
- if (!config.apiKey) {
104
+ const cfg = getConfig();
105
+ if (!cfg.apiKey) {
87
106
  console.warn('[SafeClaw] No API key configured — skipping handshake');
88
107
  return false;
89
108
  }
90
109
 
91
110
  const r = await post('/handshake', {
92
- pluginVersion: '0.1.3',
93
- configHash: configHash(config),
111
+ pluginVersion: PLUGIN_VERSION,
112
+ configHash: configHash(cfg),
94
113
  });
95
114
 
96
115
  if (r === null) {
@@ -104,13 +123,14 @@ async function performHandshake(): Promise<boolean> {
104
123
  }
105
124
 
106
125
  async function checkConnection(): Promise<void> {
126
+ const cfg = getConfig();
107
127
  const label = `[SafeClaw]`;
108
- console.log(`${label} Connecting to ${config.serviceUrl} ...`);
109
- 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}`);
110
130
 
111
131
  try {
112
- const res = await fetch(`${config.serviceUrl}/health`, {
113
- signal: AbortSignal.timeout(config.timeoutMs * 2),
132
+ const res = await fetch(`${cfg.serviceUrl}/health`, {
133
+ signal: AbortSignal.timeout(cfg.timeoutMs * 2),
114
134
  });
115
135
  if (res.ok) {
116
136
  const data = await res.json() as Record<string, unknown>;
@@ -119,8 +139,8 @@ async function checkConnection(): Promise<void> {
119
139
  console.warn(`${label} ✗ Service responded with HTTP ${res.status}`);
120
140
  }
121
141
  } catch {
122
- console.warn(`${label} ✗ Cannot reach service at ${config.serviceUrl}`);
123
- if (config.failMode === 'closed') {
142
+ console.warn(`${label} ✗ Cannot reach service at ${cfg.serviceUrl}`);
143
+ if (cfg.failMode === 'closed') {
124
144
  console.warn(`${label} fail-mode=closed → tool calls will be BLOCKED until service is reachable`);
125
145
  } else {
126
146
  console.warn(`${label} fail-mode=open → tool calls will be ALLOWED despite no connection`);
@@ -131,20 +151,23 @@ async function checkConnection(): Promise<void> {
131
151
  export default {
132
152
  id: 'safeclaw',
133
153
  name: 'SafeClaw Neurosymbolic Governance',
134
- version: '0.1.3',
154
+ version: PLUGIN_VERSION,
135
155
 
136
156
  register(api: PluginApi) {
137
- if (!config.enabled) {
157
+ if (!getConfig().enabled) {
138
158
  console.log('[SafeClaw] Plugin disabled');
139
159
  return;
140
160
  }
141
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
+
142
165
  // Heartbeat watchdog — send config hash to service every 30s
143
166
  const sendHeartbeat = async () => {
144
167
  try {
145
168
  await post('/heartbeat', {
146
- agentId: config.agentId || 'default',
147
- configHash: configHash(config),
169
+ agentId: instanceId,
170
+ configHash: configHash(getConfig()),
148
171
  status: 'alive',
149
172
  });
150
173
  } catch {
@@ -152,34 +175,40 @@ export default {
152
175
  }
153
176
  };
154
177
 
155
- // Start heartbeat after connection check + handshake
178
+ // Start heartbeat only after connection check + handshake completes (#84)
179
+ let heartbeatInterval: ReturnType<typeof setInterval> | undefined;
156
180
  checkConnection()
157
181
  .then(() => performHandshake())
158
182
  .then((ok) => {
159
- if (!ok && config.failMode === 'closed') {
160
- 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');
161
185
  }
186
+ heartbeatInterval = setInterval(sendHeartbeat, 30000);
162
187
  return sendHeartbeat();
163
188
  })
164
189
  .catch(() => {});
165
- const heartbeatInterval = setInterval(sendHeartbeat, 30000);
166
190
 
167
191
  // Clean shutdown: send shutdown heartbeat and clear interval
168
- const shutdown = () => {
169
- clearInterval(heartbeatInterval);
170
- post('/heartbeat', {
171
- agentId: config.agentId || 'default',
172
- configHash: configHash(config),
173
- status: 'shutdown',
174
- }).catch(() => {});
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()),
199
+ status: 'shutdown',
200
+ });
201
+ } catch {
202
+ // Best-effort shutdown notification
203
+ }
175
204
  };
176
- process.on('exit', shutdown);
177
- process.on('SIGINT', () => { shutdown(); process.exit(0); });
178
- 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); });
179
207
 
180
208
  // THE GATE — constraint checking on every tool call
181
209
  api.on('before_tool_call', async (event: PluginEvent, ctx: PluginContext) => {
182
- if (!handshakeCompleted && config.failMode === 'closed' && config.enforcement === 'enforce') {
210
+ const cfg = getConfig();
211
+ if (!handshakeCompleted && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
183
212
  return { block: true, blockReason: 'SafeClaw handshake not completed (fail-closed)' };
184
213
  }
185
214
 
@@ -191,19 +220,20 @@ export default {
191
220
  sessionHistory: event.sessionHistory ?? [],
192
221
  });
193
222
 
194
- if (r === null && config.failMode === 'closed' && config.enforcement === 'enforce') {
195
- return { block: true, blockReason: `SafeClaw service unavailable at ${config.serviceUrl} (fail-closed)` };
196
- } else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
197
- console.warn(`[SafeClaw] Service unavailable at ${config.serviceUrl} (fail-closed mode, warn-only)`);
198
- } else if (r === null && config.failMode === 'closed' && config.enforcement === 'audit-only') {
199
- 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)`);
200
229
  }
201
230
  if (r?.block) {
202
- if (config.enforcement === 'enforce') {
203
- 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 };
204
234
  }
205
- if (config.enforcement === 'warn-only') {
206
- console.warn(`[SafeClaw] Warning: ${r.reason}`);
235
+ if (cfg.enforcement === 'warn-only') {
236
+ console.warn(`[SafeClaw] Warning: ${blockReason}`);
207
237
  }
208
238
  // audit-only: logged server-side, no action here
209
239
  }
@@ -223,6 +253,7 @@ export default {
223
253
 
224
254
  // Message governance — check outbound messages
225
255
  api.on('message_sending', async (event: PluginEvent, ctx: PluginContext) => {
256
+ const cfg = getConfig();
226
257
  const r = await post('/evaluate/message', {
227
258
  sessionId: ctx.sessionId ?? event.sessionId,
228
259
  userId: ctx.userId ?? event.userId,
@@ -230,17 +261,20 @@ export default {
230
261
  content: event.content,
231
262
  });
232
263
 
233
- if (r === null && config.failMode === 'closed' && config.enforcement === 'enforce') {
234
- return { cancel: true };
235
- } 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') {
236
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)');
237
270
  }
238
271
  if (r?.block) {
239
- if (config.enforcement === 'enforce') {
240
- 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 };
241
275
  }
242
- if (config.enforcement === 'warn-only') {
243
- console.warn(`[SafeClaw] Warning: ${r.reason}`);
276
+ if (cfg.enforcement === 'warn-only') {
277
+ console.warn(`[SafeClaw] Warning: ${blockReason}`);
244
278
  }
245
279
  // audit-only: logged server-side, no action here
246
280
  }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "openclaw-safeclaw-plugin",
3
- "version": "1.0.0",
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>