openclaw-safeclaw-plugin 0.1.2 → 0.3.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
@@ -7,70 +7,10 @@
7
7
  * to the SafeClaw service and acts on the responses.
8
8
  */
9
9
 
10
- import { readFileSync, existsSync } from 'fs';
11
- import { join } from 'path';
12
- import { homedir } from 'os';
10
+ import { loadConfig, configHash } from './tui/config.js';
13
11
 
14
12
  // --- Configuration ---
15
13
 
16
- interface SafeClawPluginConfig {
17
- serviceUrl: string;
18
- apiKey: string;
19
- timeoutMs: number;
20
- enabled: boolean;
21
- enforcement: 'enforce' | 'warn-only' | 'audit-only' | 'disabled';
22
- failMode: 'open' | 'closed';
23
- agentId: string;
24
- agentToken: string;
25
- }
26
-
27
- function loadConfig(): SafeClawPluginConfig {
28
- const defaults: SafeClawPluginConfig = {
29
- serviceUrl: process.env.SAFECLAW_URL ?? 'https://api.safeclaw.eu/api/v1',
30
- apiKey: process.env.SAFECLAW_API_KEY ?? '',
31
- timeoutMs: parseInt(process.env.SAFECLAW_TIMEOUT_MS ?? '500', 10),
32
- enabled: process.env.SAFECLAW_ENABLED !== 'false',
33
- enforcement: (process.env.SAFECLAW_ENFORCEMENT as SafeClawPluginConfig['enforcement']) ?? 'enforce',
34
- failMode: (process.env.SAFECLAW_FAIL_MODE as SafeClawPluginConfig['failMode']) ?? 'closed',
35
- agentId: process.env.SAFECLAW_AGENT_ID ?? '',
36
- agentToken: process.env.SAFECLAW_AGENT_TOKEN ?? '',
37
- };
38
-
39
- // Try loading from config file
40
- const configPath = join(homedir(), '.safeclaw', 'config.json');
41
- if (existsSync(configPath)) {
42
- try {
43
- const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
44
- if (raw.enabled === false) defaults.enabled = false;
45
- if (raw.remote?.serviceUrl) defaults.serviceUrl = raw.remote.serviceUrl;
46
- if (raw.remote?.apiKey) defaults.apiKey = raw.remote.apiKey;
47
- if (raw.remote?.timeoutMs) defaults.timeoutMs = raw.remote.timeoutMs;
48
- if (raw.enforcement?.mode) defaults.enforcement = raw.enforcement.mode;
49
- if (raw.enforcement?.failMode) defaults.failMode = raw.enforcement.failMode;
50
- if (raw.agentId) defaults.agentId = raw.agentId;
51
- if (raw.agentToken) defaults.agentToken = raw.agentToken;
52
- } catch {
53
- // Config file unreadable — use defaults
54
- }
55
- }
56
-
57
- defaults.serviceUrl = defaults.serviceUrl.replace(/\/+$/, '');
58
-
59
- const validModes = ['enforce', 'warn-only', 'audit-only', 'disabled'] as const;
60
- if (!validModes.includes(defaults.enforcement as any)) {
61
- console.warn(`[SafeClaw] Invalid enforcement mode "${defaults.enforcement}", defaulting to "enforce"`);
62
- defaults.enforcement = 'enforce';
63
- }
64
-
65
- const validFailModes = ['open', 'closed'] as const;
66
- if (!validFailModes.includes(defaults.failMode as any)) {
67
- console.warn(`[SafeClaw] Invalid fail mode "${defaults.failMode}", defaulting to "closed"`);
68
- defaults.failMode = 'closed';
69
- }
70
-
71
- return defaults;
72
- }
73
-
74
14
  const config = loadConfig();
75
15
 
76
16
  // --- HTTP Client ---
@@ -175,8 +115,44 @@ export default {
175
115
  return;
176
116
  }
177
117
 
178
- // Fire-and-forget startup health check
179
- checkConnection().catch(() => {});
118
+ // Heartbeat watchdog send config hash to service every 30s
119
+ const sendHeartbeat = async () => {
120
+ try {
121
+ await fetch(`${config.serviceUrl}/heartbeat`, {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/json' },
124
+ body: JSON.stringify({
125
+ agentId: config.agentId || 'default',
126
+ configHash: configHash(config),
127
+ status: 'alive',
128
+ }),
129
+ signal: AbortSignal.timeout(config.timeoutMs),
130
+ });
131
+ } catch {
132
+ // Heartbeat failure is non-fatal
133
+ }
134
+ };
135
+
136
+ // Start heartbeat after connection check
137
+ checkConnection().then(() => sendHeartbeat()).catch(() => {});
138
+ const heartbeatInterval = setInterval(sendHeartbeat, 30000);
139
+
140
+ // Clean shutdown: send shutdown heartbeat and clear interval
141
+ const shutdown = () => {
142
+ clearInterval(heartbeatInterval);
143
+ fetch(`${config.serviceUrl}/heartbeat`, {
144
+ method: 'POST',
145
+ headers: { 'Content-Type': 'application/json' },
146
+ body: JSON.stringify({
147
+ agentId: config.agentId || 'default',
148
+ configHash: configHash(config),
149
+ status: 'shutdown',
150
+ }),
151
+ }).catch(() => {});
152
+ };
153
+ process.on('exit', shutdown);
154
+ process.on('SIGINT', () => { shutdown(); process.exit(0); });
155
+ process.on('SIGTERM', () => { shutdown(); process.exit(0); });
180
156
 
181
157
  // THE GATE — constraint checking on every tool call
182
158
  api.on('before_tool_call', async (event: PluginEvent, ctx: PluginContext) => {
@@ -192,6 +168,8 @@ export default {
192
168
  return { block: true, blockReason: `SafeClaw service unavailable at ${config.serviceUrl} (fail-closed)` };
193
169
  } else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
194
170
  console.warn(`[SafeClaw] Service unavailable at ${config.serviceUrl} (fail-closed mode, warn-only)`);
171
+ } else if (r === null && config.failMode === 'closed' && config.enforcement === 'audit-only') {
172
+ console.warn(`[SafeClaw] Service unavailable at ${config.serviceUrl} (fail-closed mode, audit-only)`);
195
173
  }
196
174
  if (r?.block) {
197
175
  if (config.enforcement === 'enforce') {
@@ -230,8 +208,14 @@ export default {
230
208
  } else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
231
209
  console.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
232
210
  }
233
- if (r?.block && config.enforcement === 'enforce') {
234
- return { cancel: true };
211
+ if (r?.block) {
212
+ if (config.enforcement === 'enforce') {
213
+ return { cancel: true };
214
+ }
215
+ if (config.enforcement === 'warn-only') {
216
+ console.warn(`[SafeClaw] Warning: ${r.reason}`);
217
+ }
218
+ // audit-only: logged server-side, no action here
235
219
  }
236
220
  }, { priority: 100 });
237
221
 
@@ -256,8 +240,8 @@ export default {
256
240
  toolName: event.toolName ?? event.tool_name,
257
241
  params: event.params ?? {},
258
242
  result: event.result ?? '',
259
- success: event.success ?? true,
260
- }).catch(() => {});
243
+ success: event.success ?? false,
244
+ }).catch((e) => console.warn('[SafeClaw] Failed to record tool result:', e));
261
245
  });
262
246
  },
263
247
  };
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "openclaw-safeclaw-plugin",
3
- "version": "0.1.2",
3
+ "version": "0.3.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
+ "bin": {
9
+ "safeclaw": "dist/cli.js"
10
+ },
8
11
  "scripts": {
9
12
  "build": "tsc",
10
13
  "typecheck": "tsc --noEmit",
@@ -13,6 +16,8 @@
13
16
  "files": [
14
17
  "dist/",
15
18
  "index.ts",
19
+ "cli.tsx",
20
+ "tui/",
16
21
  "SKILL.md",
17
22
  "README.md"
18
23
  ],
@@ -40,10 +45,17 @@
40
45
  },
41
46
  "author": "Tendly EU",
42
47
  "devDependencies": {
43
- "typescript": "^5.4.0",
44
- "@types/node": "^20.0.0"
48
+ "@types/node": "^20.0.0",
49
+ "@types/react": "^19.2.14",
50
+ "typescript": "^5.4.0"
45
51
  },
46
52
  "engines": {
47
53
  "node": ">=18.0.0"
54
+ },
55
+ "dependencies": {
56
+ "ink": "^6.8.0",
57
+ "ink-select-input": "^6.2.0",
58
+ "ink-text-input": "^6.0.0",
59
+ "react": "^19.2.4"
48
60
  }
49
61
  }
package/tui/About.tsx ADDED
@@ -0,0 +1,30 @@
1
+ import React from 'react';
2
+ import { Text, Box } from 'ink';
3
+
4
+ export default function About() {
5
+ return (
6
+ <Box flexDirection="column" paddingX={1}>
7
+ <Box marginBottom={1}>
8
+ <Text bold>About</Text>
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>
13
+ <Box marginTop={1}>
14
+ <Text> Web: </Text>
15
+ <Text color="cyan">https://safeclaw.eu</Text>
16
+ </Box>
17
+ <Box>
18
+ <Text> Docs: </Text>
19
+ <Text color="cyan">https://safeclaw.eu/docs</Text>
20
+ </Box>
21
+ <Box>
22
+ <Text> Repo: </Text>
23
+ <Text color="cyan">https://github.com/tendlyeu/SafeClaw</Text>
24
+ </Box>
25
+ <Box marginTop={1}>
26
+ <Text dimColor> q to quit</Text>
27
+ </Box>
28
+ </Box>
29
+ );
30
+ }
package/tui/App.tsx ADDED
@@ -0,0 +1,65 @@
1
+ import React, { useState } from 'react';
2
+ import { Text, Box, useInput, useApp } from 'ink';
3
+ import { loadConfig, type SafeClawConfig } from './config.js';
4
+ import Status from './Status.js';
5
+ import Settings from './Settings.js';
6
+ import About from './About.js';
7
+
8
+ const TABS = ['Status', 'Settings', 'About'] as const;
9
+ type Tab = typeof TABS[number];
10
+
11
+ export default function App() {
12
+ const { exit } = useApp();
13
+ const [tab, setTab] = useState<Tab>('Status');
14
+ const [config, setConfig] = useState<SafeClawConfig>(loadConfig());
15
+
16
+ useInput((input, key) => {
17
+ if (input === 'q' && tab !== 'Settings') {
18
+ exit();
19
+ return;
20
+ }
21
+ if (key.tab || (input === '1' || input === '2' || input === '3')) {
22
+ if (input === '1') setTab('Status');
23
+ else if (input === '2') setTab('Settings');
24
+ else if (input === '3') setTab('About');
25
+ else {
26
+ const idx = TABS.indexOf(tab);
27
+ setTab(TABS[(idx + 1) % TABS.length]);
28
+ }
29
+ }
30
+ });
31
+
32
+ return (
33
+ <Box flexDirection="column">
34
+ {/* Header */}
35
+ <Box borderStyle="single" borderColor="green" paddingX={1}>
36
+ <Text bold color="green">SafeClaw </Text>
37
+ <Text dimColor>v0.2.0</Text>
38
+ </Box>
39
+
40
+ {/* Tab bar */}
41
+ <Box paddingX={1} gap={2}>
42
+ {TABS.map((t, i) => (
43
+ <Text
44
+ key={t}
45
+ bold={tab === t}
46
+ color={tab === t ? 'cyan' : 'white'}
47
+ dimColor={tab !== t}
48
+ >
49
+ {`${i + 1}:${t}`}
50
+ </Text>
51
+ ))}
52
+ <Text dimColor> tab/1-3 to switch</Text>
53
+ </Box>
54
+
55
+ {/* Content */}
56
+ <Box marginTop={1}>
57
+ {tab === 'Status' && <Status config={config} />}
58
+ {tab === 'Settings' && (
59
+ <Settings config={config} onConfigChange={setConfig} />
60
+ )}
61
+ {tab === 'About' && <About />}
62
+ </Box>
63
+ </Box>
64
+ );
65
+ }
@@ -0,0 +1,130 @@
1
+ import React, { useState } from 'react';
2
+ import { Text, Box, useInput } from 'ink';
3
+ import { type SafeClawConfig, saveConfig } from './config.js';
4
+
5
+ interface SettingsProps {
6
+ config: SafeClawConfig;
7
+ onConfigChange: (config: SafeClawConfig) => void;
8
+ }
9
+
10
+ const ENFORCEMENT_MODES = ['enforce', 'warn-only', 'audit-only', 'disabled'] as const;
11
+ const FAIL_MODES = ['closed', 'open'] as const;
12
+
13
+ interface SettingItem {
14
+ key: string;
15
+ label: string;
16
+ type: 'toggle' | 'cycle' | 'text';
17
+ values?: readonly string[];
18
+ }
19
+
20
+ const SETTINGS: SettingItem[] = [
21
+ { key: 'enabled', label: 'Enabled', type: 'toggle' },
22
+ { key: 'enforcement', label: 'Enforcement', type: 'cycle', values: ENFORCEMENT_MODES },
23
+ { key: 'failMode', label: 'Fail Mode', type: 'cycle', values: FAIL_MODES },
24
+ { key: 'serviceUrl', label: 'Service URL', type: 'text' },
25
+ ];
26
+
27
+ export default function Settings({ config, onConfigChange }: SettingsProps) {
28
+ const [selected, setSelected] = useState(0);
29
+ const [editing, setEditing] = useState(false);
30
+ const [editBuffer, setEditBuffer] = useState('');
31
+
32
+ const updateConfig = (patch: Partial<SafeClawConfig>) => {
33
+ const updated = { ...config, ...patch };
34
+ saveConfig(updated);
35
+ onConfigChange(updated);
36
+ };
37
+
38
+ useInput((input, key) => {
39
+ if (editing) {
40
+ if (key.return) {
41
+ updateConfig({ serviceUrl: editBuffer });
42
+ setEditing(false);
43
+ } else if (key.escape) {
44
+ setEditing(false);
45
+ } else if (key.backspace || key.delete) {
46
+ setEditBuffer(prev => prev.slice(0, -1));
47
+ } else if (input && !key.ctrl && !key.meta) {
48
+ setEditBuffer(prev => prev + input);
49
+ }
50
+ return;
51
+ }
52
+
53
+ if (key.upArrow) {
54
+ setSelected(prev => Math.max(0, prev - 1));
55
+ } else if (key.downArrow) {
56
+ setSelected(prev => Math.min(SETTINGS.length - 1, prev + 1));
57
+ } else if (key.return || key.rightArrow || key.leftArrow) {
58
+ const setting = SETTINGS[selected];
59
+ if (setting.type === 'toggle') {
60
+ updateConfig({ enabled: !config.enabled });
61
+ } else if (setting.type === 'cycle' && setting.values) {
62
+ const currentKey = setting.key as 'enforcement' | 'failMode';
63
+ const current = config[currentKey];
64
+ const idx = setting.values.indexOf(current);
65
+ const dir = key.leftArrow ? -1 : 1;
66
+ const next = setting.values[(idx + dir + setting.values.length) % setting.values.length];
67
+ updateConfig({ [currentKey]: next });
68
+ } else if (setting.type === 'text' && key.return) {
69
+ setEditing(true);
70
+ setEditBuffer(config.serviceUrl);
71
+ }
72
+ }
73
+ });
74
+
75
+ return (
76
+ <Box flexDirection="column" paddingX={1}>
77
+ <Box marginBottom={1}>
78
+ <Text bold>Settings</Text>
79
+ </Box>
80
+
81
+ {SETTINGS.map((setting, i) => {
82
+ const isSelected = i === selected;
83
+ const prefix = isSelected ? '▸ ' : ' ';
84
+ let value: string;
85
+
86
+ if (setting.key === 'enabled') {
87
+ value = config.enabled ? 'ON' : 'OFF';
88
+ } else if (setting.key === 'serviceUrl' && editing && isSelected) {
89
+ value = editBuffer + '█';
90
+ } else {
91
+ value = String(config[setting.key as keyof SafeClawConfig]);
92
+ }
93
+
94
+ const showArrows = isSelected && setting.type === 'cycle';
95
+
96
+ return (
97
+ <Box key={setting.key}>
98
+ <Text color={isSelected ? 'cyan' : undefined} bold={isSelected}>
99
+ {prefix}
100
+ {setting.label.padEnd(16)}
101
+ </Text>
102
+ {showArrows && <Text dimColor>{'◀ '}</Text>}
103
+ <Text
104
+ color={
105
+ setting.key === 'enabled'
106
+ ? config.enabled ? 'green' : 'red'
107
+ : undefined
108
+ }
109
+ >
110
+ {value}
111
+ </Text>
112
+ {showArrows && <Text dimColor>{' ▶'}</Text>}
113
+ </Box>
114
+ );
115
+ })}
116
+
117
+ <Box marginTop={1}>
118
+ <Text dimColor>
119
+ {editing
120
+ ? ' type to edit · enter to save · esc to cancel'
121
+ : ' ↑↓ navigate · ←→ change · enter to edit URL · q quit'}
122
+ </Text>
123
+ </Box>
124
+
125
+ <Box>
126
+ <Text dimColor>{' Saves to ~/.safeclaw/config.json'}</Text>
127
+ </Box>
128
+ </Box>
129
+ );
130
+ }
package/tui/Status.tsx ADDED
@@ -0,0 +1,156 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Text, Box, useInput } from 'ink';
3
+ import { exec } from 'child_process';
4
+ import { type SafeClawConfig } from './config.js';
5
+
6
+ interface StatusProps {
7
+ config: SafeClawConfig;
8
+ }
9
+
10
+ interface HealthData {
11
+ status: string;
12
+ version?: string;
13
+ engine_ready?: boolean;
14
+ }
15
+
16
+ type OpenClawStatus = 'checking' | 'running' | 'not running' | 'error';
17
+
18
+ export default function Status({ config }: StatusProps) {
19
+ const [health, setHealth] = useState<HealthData | null>(null);
20
+ const [error, setError] = useState<string | null>(null);
21
+ const [lastCheck, setLastCheck] = useState<Date | null>(null);
22
+ const [openclawStatus, setOpenclawStatus] = useState<OpenClawStatus>('checking');
23
+ const [restartMsg, setRestartMsg] = useState<string | null>(null);
24
+
25
+ const checkHealth = async () => {
26
+ try {
27
+ const res = await fetch(`${config.serviceUrl}/health`, {
28
+ signal: AbortSignal.timeout(config.timeoutMs * 2),
29
+ });
30
+ if (res.ok) {
31
+ const data = await res.json() as HealthData;
32
+ setHealth(data);
33
+ setError(null);
34
+ } else {
35
+ setHealth(null);
36
+ setError(`HTTP ${res.status}`);
37
+ }
38
+ } catch {
39
+ setHealth(null);
40
+ setError('Cannot connect');
41
+ }
42
+ setLastCheck(new Date());
43
+ };
44
+
45
+ const checkOpenClaw = () => {
46
+ exec('openclaw daemon status', { timeout: 10000 }, (err, stdout) => {
47
+ if (err) {
48
+ setOpenclawStatus('not running');
49
+ } else {
50
+ const output = stdout.toLowerCase();
51
+ setOpenclawStatus(output.includes('running') ? 'running' : 'not running');
52
+ }
53
+ });
54
+ };
55
+
56
+ const restartOpenClaw = () => {
57
+ setRestartMsg('Restarting...');
58
+ exec('openclaw daemon restart', { timeout: 15000 }, (err) => {
59
+ if (err) {
60
+ setRestartMsg('Restart failed');
61
+ } else {
62
+ setRestartMsg('Restarted');
63
+ checkOpenClaw();
64
+ }
65
+ setTimeout(() => setRestartMsg(null), 3000);
66
+ });
67
+ };
68
+
69
+ useEffect(() => {
70
+ checkHealth();
71
+ checkOpenClaw();
72
+ const interval = setInterval(() => {
73
+ checkHealth();
74
+ checkOpenClaw();
75
+ }, 10000);
76
+ return () => clearInterval(interval);
77
+ }, []);
78
+
79
+ useInput((input) => {
80
+ if (input === 'r') {
81
+ restartOpenClaw();
82
+ }
83
+ });
84
+
85
+ const connected = health !== null;
86
+ const dot = '●';
87
+ const serviceDotColor = connected ? 'green' : 'red';
88
+ const serviceText = connected
89
+ ? `Connected (${config.serviceUrl.replace(/^https?:\/\//, '').replace(/\/api\/v1$/, '')})`
90
+ : error ?? 'Disconnected';
91
+
92
+ const openclawDotColor = openclawStatus === 'running' ? 'green' : openclawStatus === 'checking' ? 'yellow' : 'red';
93
+ const openclawText = openclawStatus === 'checking' ? 'Checking...'
94
+ : openclawStatus === 'running' ? 'Running'
95
+ : 'Not running';
96
+
97
+ return (
98
+ <Box flexDirection="column" paddingX={1}>
99
+ <Box marginBottom={1}>
100
+ <Text bold>Status</Text>
101
+ </Box>
102
+
103
+ <Box>
104
+ <Text dimColor>{' Service '}</Text>
105
+ <Text color={serviceDotColor}>{dot} </Text>
106
+ <Text>{serviceText}</Text>
107
+ </Box>
108
+
109
+ <Box>
110
+ <Text dimColor>{' OpenClaw '}</Text>
111
+ <Text color={openclawDotColor}>{dot} </Text>
112
+ <Text>{openclawText}</Text>
113
+ {restartMsg && <Text dimColor>{` (${restartMsg})`}</Text>}
114
+ </Box>
115
+
116
+ <Box>
117
+ <Text dimColor>{' Enforcement '}</Text>
118
+ <Text>{config.enforcement}</Text>
119
+ </Box>
120
+
121
+ <Box>
122
+ <Text dimColor>{' Fail Mode '}</Text>
123
+ <Text>{config.failMode}</Text>
124
+ </Box>
125
+
126
+ <Box>
127
+ <Text dimColor>{' Enabled '}</Text>
128
+ <Text color={config.enabled ? 'green' : 'red'}>
129
+ {config.enabled ? 'ON' : 'OFF'}
130
+ </Text>
131
+ </Box>
132
+
133
+ {health?.version && (
134
+ <Box marginTop={1}>
135
+ <Text dimColor>{' Service v'}</Text>
136
+ <Text>{health.version}</Text>
137
+ </Box>
138
+ )}
139
+
140
+ {lastCheck && (
141
+ <Box marginTop={1}>
142
+ <Text dimColor>
143
+ {' Last check: '}
144
+ {lastCheck.toLocaleTimeString()}
145
+ </Text>
146
+ </Box>
147
+ )}
148
+
149
+ <Box marginTop={1}>
150
+ <Text dimColor>{' Press '}</Text>
151
+ <Text bold>r</Text>
152
+ <Text dimColor>{' to restart OpenClaw daemon'}</Text>
153
+ </Box>
154
+ </Box>
155
+ );
156
+ }