openclaw-safeclaw-plugin 0.1.2 → 0.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/cli.tsx +15 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +12 -0
- package/dist/index.js +52 -59
- package/dist/tui/About.d.ts +1 -0
- package/dist/tui/About.js +5 -0
- package/dist/tui/App.d.ts +1 -0
- package/dist/tui/App.js +32 -0
- package/dist/tui/Settings.d.ts +7 -0
- package/dist/tui/Settings.js +84 -0
- package/dist/tui/Status.d.ts +6 -0
- package/dist/tui/Status.js +41 -0
- package/dist/tui/config.d.ts +30 -0
- package/dist/tui/config.js +123 -0
- package/index.ts +51 -67
- package/package.json +15 -3
- package/tui/About.tsx +30 -0
- package/tui/App.tsx +65 -0
- package/tui/Settings.tsx +130 -0
- package/tui/Status.tsx +99 -0
- package/tui/config.ts +136 -0
package/cli.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { render } from 'ink';
|
|
4
|
+
import App from './tui/App.js';
|
|
5
|
+
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
|
|
8
|
+
if (args[0] !== 'tui') {
|
|
9
|
+
console.log('Usage: safeclaw tui');
|
|
10
|
+
console.log('');
|
|
11
|
+
console.log('Opens the interactive SafeClaw settings TUI.');
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
render(React.createElement(App));
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { render } from 'ink';
|
|
4
|
+
import App from './tui/App.js';
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
if (args[0] !== 'tui') {
|
|
7
|
+
console.log('Usage: safeclaw tui');
|
|
8
|
+
console.log('');
|
|
9
|
+
console.log('Opens the interactive SafeClaw settings TUI.');
|
|
10
|
+
process.exit(0);
|
|
11
|
+
}
|
|
12
|
+
render(React.createElement(App));
|
package/dist/index.js
CHANGED
|
@@ -6,59 +6,8 @@
|
|
|
6
6
|
* This plugin is a thin HTTP bridge that forwards OpenClaw events
|
|
7
7
|
* to the SafeClaw service and acts on the responses.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
import { homedir } from 'os';
|
|
12
|
-
function loadConfig() {
|
|
13
|
-
const defaults = {
|
|
14
|
-
serviceUrl: process.env.SAFECLAW_URL ?? 'https://api.safeclaw.eu/api/v1',
|
|
15
|
-
apiKey: process.env.SAFECLAW_API_KEY ?? '',
|
|
16
|
-
timeoutMs: parseInt(process.env.SAFECLAW_TIMEOUT_MS ?? '500', 10),
|
|
17
|
-
enabled: process.env.SAFECLAW_ENABLED !== 'false',
|
|
18
|
-
enforcement: process.env.SAFECLAW_ENFORCEMENT ?? 'enforce',
|
|
19
|
-
failMode: process.env.SAFECLAW_FAIL_MODE ?? 'closed',
|
|
20
|
-
agentId: process.env.SAFECLAW_AGENT_ID ?? '',
|
|
21
|
-
agentToken: process.env.SAFECLAW_AGENT_TOKEN ?? '',
|
|
22
|
-
};
|
|
23
|
-
// Try loading from config file
|
|
24
|
-
const configPath = join(homedir(), '.safeclaw', 'config.json');
|
|
25
|
-
if (existsSync(configPath)) {
|
|
26
|
-
try {
|
|
27
|
-
const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
28
|
-
if (raw.enabled === false)
|
|
29
|
-
defaults.enabled = false;
|
|
30
|
-
if (raw.remote?.serviceUrl)
|
|
31
|
-
defaults.serviceUrl = raw.remote.serviceUrl;
|
|
32
|
-
if (raw.remote?.apiKey)
|
|
33
|
-
defaults.apiKey = raw.remote.apiKey;
|
|
34
|
-
if (raw.remote?.timeoutMs)
|
|
35
|
-
defaults.timeoutMs = raw.remote.timeoutMs;
|
|
36
|
-
if (raw.enforcement?.mode)
|
|
37
|
-
defaults.enforcement = raw.enforcement.mode;
|
|
38
|
-
if (raw.enforcement?.failMode)
|
|
39
|
-
defaults.failMode = raw.enforcement.failMode;
|
|
40
|
-
if (raw.agentId)
|
|
41
|
-
defaults.agentId = raw.agentId;
|
|
42
|
-
if (raw.agentToken)
|
|
43
|
-
defaults.agentToken = raw.agentToken;
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
// Config file unreadable — use defaults
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
defaults.serviceUrl = defaults.serviceUrl.replace(/\/+$/, '');
|
|
50
|
-
const validModes = ['enforce', 'warn-only', 'audit-only', 'disabled'];
|
|
51
|
-
if (!validModes.includes(defaults.enforcement)) {
|
|
52
|
-
console.warn(`[SafeClaw] Invalid enforcement mode "${defaults.enforcement}", defaulting to "enforce"`);
|
|
53
|
-
defaults.enforcement = 'enforce';
|
|
54
|
-
}
|
|
55
|
-
const validFailModes = ['open', 'closed'];
|
|
56
|
-
if (!validFailModes.includes(defaults.failMode)) {
|
|
57
|
-
console.warn(`[SafeClaw] Invalid fail mode "${defaults.failMode}", defaulting to "closed"`);
|
|
58
|
-
defaults.failMode = 'closed';
|
|
59
|
-
}
|
|
60
|
-
return defaults;
|
|
61
|
-
}
|
|
9
|
+
import { loadConfig, configHash } from './tui/config.js';
|
|
10
|
+
// --- Configuration ---
|
|
62
11
|
const config = loadConfig();
|
|
63
12
|
// --- HTTP Client ---
|
|
64
13
|
async function post(path, body) {
|
|
@@ -139,8 +88,43 @@ export default {
|
|
|
139
88
|
console.log('[SafeClaw] Plugin disabled');
|
|
140
89
|
return;
|
|
141
90
|
}
|
|
142
|
-
//
|
|
143
|
-
|
|
91
|
+
// Heartbeat watchdog — send config hash to service every 30s
|
|
92
|
+
const sendHeartbeat = async () => {
|
|
93
|
+
try {
|
|
94
|
+
await fetch(`${config.serviceUrl}/heartbeat`, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { 'Content-Type': 'application/json' },
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
agentId: config.agentId || 'default',
|
|
99
|
+
configHash: configHash(config),
|
|
100
|
+
status: 'alive',
|
|
101
|
+
}),
|
|
102
|
+
signal: AbortSignal.timeout(config.timeoutMs),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Heartbeat failure is non-fatal
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
// Start heartbeat after connection check
|
|
110
|
+
checkConnection().then(() => sendHeartbeat()).catch(() => { });
|
|
111
|
+
const heartbeatInterval = setInterval(sendHeartbeat, 30000);
|
|
112
|
+
// Clean shutdown: send shutdown heartbeat and clear interval
|
|
113
|
+
const shutdown = () => {
|
|
114
|
+
clearInterval(heartbeatInterval);
|
|
115
|
+
fetch(`${config.serviceUrl}/heartbeat`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'Content-Type': 'application/json' },
|
|
118
|
+
body: JSON.stringify({
|
|
119
|
+
agentId: config.agentId || 'default',
|
|
120
|
+
configHash: configHash(config),
|
|
121
|
+
status: 'shutdown',
|
|
122
|
+
}),
|
|
123
|
+
}).catch(() => { });
|
|
124
|
+
};
|
|
125
|
+
process.on('exit', shutdown);
|
|
126
|
+
process.on('SIGINT', () => { shutdown(); process.exit(0); });
|
|
127
|
+
process.on('SIGTERM', () => { shutdown(); process.exit(0); });
|
|
144
128
|
// THE GATE — constraint checking on every tool call
|
|
145
129
|
api.on('before_tool_call', async (event, ctx) => {
|
|
146
130
|
const r = await post('/evaluate/tool-call', {
|
|
@@ -156,6 +140,9 @@ export default {
|
|
|
156
140
|
else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
|
|
157
141
|
console.warn(`[SafeClaw] Service unavailable at ${config.serviceUrl} (fail-closed mode, warn-only)`);
|
|
158
142
|
}
|
|
143
|
+
else if (r === null && config.failMode === 'closed' && config.enforcement === 'audit-only') {
|
|
144
|
+
console.warn(`[SafeClaw] Service unavailable at ${config.serviceUrl} (fail-closed mode, audit-only)`);
|
|
145
|
+
}
|
|
159
146
|
if (r?.block) {
|
|
160
147
|
if (config.enforcement === 'enforce') {
|
|
161
148
|
return { block: true, blockReason: r.reason };
|
|
@@ -190,8 +177,14 @@ export default {
|
|
|
190
177
|
else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
|
|
191
178
|
console.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
|
|
192
179
|
}
|
|
193
|
-
if (r?.block
|
|
194
|
-
|
|
180
|
+
if (r?.block) {
|
|
181
|
+
if (config.enforcement === 'enforce') {
|
|
182
|
+
return { cancel: true };
|
|
183
|
+
}
|
|
184
|
+
if (config.enforcement === 'warn-only') {
|
|
185
|
+
console.warn(`[SafeClaw] Warning: ${r.reason}`);
|
|
186
|
+
}
|
|
187
|
+
// audit-only: logged server-side, no action here
|
|
195
188
|
}
|
|
196
189
|
}, { priority: 100 });
|
|
197
190
|
// Async logging — fire-and-forget, no return value needed
|
|
@@ -213,8 +206,8 @@ export default {
|
|
|
213
206
|
toolName: event.toolName ?? event.tool_name,
|
|
214
207
|
params: event.params ?? {},
|
|
215
208
|
result: event.result ?? '',
|
|
216
|
-
success: event.success ??
|
|
217
|
-
}).catch(() =>
|
|
209
|
+
success: event.success ?? false,
|
|
210
|
+
}).catch((e) => console.warn('[SafeClaw] Failed to record tool result:', e));
|
|
218
211
|
});
|
|
219
212
|
},
|
|
220
213
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function About(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Text, Box } from 'ink';
|
|
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: " SafeClaw Neurosymbolic Governance" }), _jsx(Text, { dimColor: true, children: " Validates AI agent actions against OWL" }), _jsx(Text, { dimColor: true, children: " ontologies and SHACL constraints." }), _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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function App(): import("react/jsx-runtime").JSX.Element;
|
package/dist/tui/App.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Text, Box, useInput, useApp } from 'ink';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
import Status from './Status.js';
|
|
6
|
+
import Settings from './Settings.js';
|
|
7
|
+
import About from './About.js';
|
|
8
|
+
const TABS = ['Status', 'Settings', 'About'];
|
|
9
|
+
export default function App() {
|
|
10
|
+
const { exit } = useApp();
|
|
11
|
+
const [tab, setTab] = useState('Status');
|
|
12
|
+
const [config, setConfig] = useState(loadConfig());
|
|
13
|
+
useInput((input, key) => {
|
|
14
|
+
if (input === 'q' && tab !== 'Settings') {
|
|
15
|
+
exit();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (key.tab || (input === '1' || input === '2' || input === '3')) {
|
|
19
|
+
if (input === '1')
|
|
20
|
+
setTab('Status');
|
|
21
|
+
else if (input === '2')
|
|
22
|
+
setTab('Settings');
|
|
23
|
+
else if (input === '3')
|
|
24
|
+
setTab('About');
|
|
25
|
+
else {
|
|
26
|
+
const idx = TABS.indexOf(tab);
|
|
27
|
+
setTab(TABS[(idx + 1) % TABS.length]);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "single", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "SafeClaw " }), _jsx(Text, { dimColor: true, children: "v0.2.0" })] }), _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 to switch" })] }), _jsxs(Box, { marginTop: 1, children: [tab === 'Status' && _jsx(Status, { config: config }), tab === 'Settings' && (_jsx(Settings, { config: config, onConfigChange: setConfig })), tab === 'About' && _jsx(About, {})] })] }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type SafeClawConfig } from './config.js';
|
|
2
|
+
interface SettingsProps {
|
|
3
|
+
config: SafeClawConfig;
|
|
4
|
+
onConfigChange: (config: SafeClawConfig) => void;
|
|
5
|
+
}
|
|
6
|
+
export default function Settings({ config, onConfigChange }: SettingsProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Text, Box, useInput } from 'ink';
|
|
4
|
+
import { saveConfig } from './config.js';
|
|
5
|
+
const ENFORCEMENT_MODES = ['enforce', 'warn-only', 'audit-only', 'disabled'];
|
|
6
|
+
const FAIL_MODES = ['closed', 'open'];
|
|
7
|
+
const SETTINGS = [
|
|
8
|
+
{ key: 'enabled', label: 'Enabled', type: 'toggle' },
|
|
9
|
+
{ key: 'enforcement', label: 'Enforcement', type: 'cycle', values: ENFORCEMENT_MODES },
|
|
10
|
+
{ key: 'failMode', label: 'Fail Mode', type: 'cycle', values: FAIL_MODES },
|
|
11
|
+
{ key: 'serviceUrl', label: 'Service URL', type: 'text' },
|
|
12
|
+
];
|
|
13
|
+
export default function Settings({ config, onConfigChange }) {
|
|
14
|
+
const [selected, setSelected] = useState(0);
|
|
15
|
+
const [editing, setEditing] = useState(false);
|
|
16
|
+
const [editBuffer, setEditBuffer] = useState('');
|
|
17
|
+
const updateConfig = (patch) => {
|
|
18
|
+
const updated = { ...config, ...patch };
|
|
19
|
+
saveConfig(updated);
|
|
20
|
+
onConfigChange(updated);
|
|
21
|
+
};
|
|
22
|
+
useInput((input, key) => {
|
|
23
|
+
if (editing) {
|
|
24
|
+
if (key.return) {
|
|
25
|
+
updateConfig({ serviceUrl: editBuffer });
|
|
26
|
+
setEditing(false);
|
|
27
|
+
}
|
|
28
|
+
else if (key.escape) {
|
|
29
|
+
setEditing(false);
|
|
30
|
+
}
|
|
31
|
+
else if (key.backspace || key.delete) {
|
|
32
|
+
setEditBuffer(prev => prev.slice(0, -1));
|
|
33
|
+
}
|
|
34
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
35
|
+
setEditBuffer(prev => prev + input);
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (key.upArrow) {
|
|
40
|
+
setSelected(prev => Math.max(0, prev - 1));
|
|
41
|
+
}
|
|
42
|
+
else if (key.downArrow) {
|
|
43
|
+
setSelected(prev => Math.min(SETTINGS.length - 1, prev + 1));
|
|
44
|
+
}
|
|
45
|
+
else if (key.return || key.rightArrow || key.leftArrow) {
|
|
46
|
+
const setting = SETTINGS[selected];
|
|
47
|
+
if (setting.type === 'toggle') {
|
|
48
|
+
updateConfig({ enabled: !config.enabled });
|
|
49
|
+
}
|
|
50
|
+
else if (setting.type === 'cycle' && setting.values) {
|
|
51
|
+
const currentKey = setting.key;
|
|
52
|
+
const current = config[currentKey];
|
|
53
|
+
const idx = setting.values.indexOf(current);
|
|
54
|
+
const dir = key.leftArrow ? -1 : 1;
|
|
55
|
+
const next = setting.values[(idx + dir + setting.values.length) % setting.values.length];
|
|
56
|
+
updateConfig({ [currentKey]: next });
|
|
57
|
+
}
|
|
58
|
+
else if (setting.type === 'text' && key.return) {
|
|
59
|
+
setEditing(true);
|
|
60
|
+
setEditBuffer(config.serviceUrl);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "Settings" }) }), SETTINGS.map((setting, i) => {
|
|
65
|
+
const isSelected = i === selected;
|
|
66
|
+
const prefix = isSelected ? '▸ ' : ' ';
|
|
67
|
+
let value;
|
|
68
|
+
if (setting.key === 'enabled') {
|
|
69
|
+
value = config.enabled ? 'ON' : 'OFF';
|
|
70
|
+
}
|
|
71
|
+
else if (setting.key === 'serviceUrl' && editing && isSelected) {
|
|
72
|
+
value = editBuffer + '█';
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
value = String(config[setting.key]);
|
|
76
|
+
}
|
|
77
|
+
const showArrows = isSelected && setting.type === 'cycle';
|
|
78
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? 'cyan' : undefined, bold: isSelected, children: [prefix, setting.label.padEnd(16)] }), showArrows && _jsx(Text, { dimColor: true, children: '◀ ' }), _jsx(Text, { color: setting.key === 'enabled'
|
|
79
|
+
? config.enabled ? 'green' : 'red'
|
|
80
|
+
: undefined, children: value }), showArrows && _jsx(Text, { dimColor: true, children: ' ▶' })] }, setting.key));
|
|
81
|
+
}), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: editing
|
|
82
|
+
? ' type to edit · enter to save · esc to cancel'
|
|
83
|
+
: ' ↑↓ navigate · ←→ change · enter to edit URL · q quit' }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: ' Saves to ~/.safeclaw/config.json' }) })] }));
|
|
84
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Text, Box } from 'ink';
|
|
4
|
+
export default function Status({ config }) {
|
|
5
|
+
const [health, setHealth] = useState(null);
|
|
6
|
+
const [error, setError] = useState(null);
|
|
7
|
+
const [lastCheck, setLastCheck] = useState(null);
|
|
8
|
+
const checkHealth = async () => {
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(`${config.serviceUrl}/health`, {
|
|
11
|
+
signal: AbortSignal.timeout(config.timeoutMs * 2),
|
|
12
|
+
});
|
|
13
|
+
if (res.ok) {
|
|
14
|
+
const data = await res.json();
|
|
15
|
+
setHealth(data);
|
|
16
|
+
setError(null);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
setHealth(null);
|
|
20
|
+
setError(`HTTP ${res.status}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
setHealth(null);
|
|
25
|
+
setError('Cannot connect');
|
|
26
|
+
}
|
|
27
|
+
setLastCheck(new Date());
|
|
28
|
+
};
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
checkHealth();
|
|
31
|
+
const interval = setInterval(checkHealth, 10000);
|
|
32
|
+
return () => clearInterval(interval);
|
|
33
|
+
}, []);
|
|
34
|
+
const connected = health !== null;
|
|
35
|
+
const dot = '●';
|
|
36
|
+
const dotColor = connected ? 'green' : 'red';
|
|
37
|
+
const statusText = connected
|
|
38
|
+
? `Connected (${config.serviceUrl.replace(/^https?:\/\//, '').replace(/\/api\/v1$/, '')})`
|
|
39
|
+
: error ?? 'Disconnected';
|
|
40
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "Status" }) }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Service ' }), _jsxs(Text, { color: dotColor, children: [dot, " "] }), _jsx(Text, { children: statusText })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Enforcement ' }), _jsx(Text, { children: config.enforcement })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Fail Mode ' }), _jsx(Text, { children: config.failMode })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Enabled ' }), _jsx(Text, { color: config.enabled ? 'green' : 'red', children: config.enabled ? 'ON' : 'OFF' })] }), health?.version && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ' Service v' }), _jsx(Text, { children: health.version })] })), lastCheck && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [' Last check: ', lastCheck.toLocaleTimeString()] }) }))] }));
|
|
41
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SafeClaw shared configuration module.
|
|
3
|
+
*
|
|
4
|
+
* Used by both the OpenClaw plugin (index.ts) and the TUI.
|
|
5
|
+
* Reads ~/.safeclaw/config.json, applies env-var overrides,
|
|
6
|
+
* and exposes helpers for saving and hashing config state.
|
|
7
|
+
*/
|
|
8
|
+
export interface SafeClawConfig {
|
|
9
|
+
serviceUrl: string;
|
|
10
|
+
apiKey: string;
|
|
11
|
+
timeoutMs: number;
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
enforcement: 'enforce' | 'warn-only' | 'audit-only' | 'disabled';
|
|
14
|
+
failMode: 'open' | 'closed';
|
|
15
|
+
agentId: string;
|
|
16
|
+
agentToken: string;
|
|
17
|
+
}
|
|
18
|
+
export declare const CONFIG_PATH: string;
|
|
19
|
+
export declare function loadConfig(): SafeClawConfig;
|
|
20
|
+
/**
|
|
21
|
+
* Persist managed config fields back to ~/.safeclaw/config.json.
|
|
22
|
+
* Reads the existing file (if any), merges the fields SafeClaw manages,
|
|
23
|
+
* and writes the result. Fields not managed by SafeClaw are preserved.
|
|
24
|
+
*/
|
|
25
|
+
export declare function saveConfig(config: SafeClawConfig): void;
|
|
26
|
+
/**
|
|
27
|
+
* SHA-256 hash of the four TUI-managed config fields.
|
|
28
|
+
* Used to detect whether the on-disk config has drifted from the in-memory state.
|
|
29
|
+
*/
|
|
30
|
+
export declare function configHash(config: SafeClawConfig): string;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SafeClaw shared configuration module.
|
|
3
|
+
*
|
|
4
|
+
* Used by both the OpenClaw plugin (index.ts) and the TUI.
|
|
5
|
+
* Reads ~/.safeclaw/config.json, applies env-var overrides,
|
|
6
|
+
* and exposes helpers for saving and hashing config state.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
|
|
9
|
+
import { join, dirname } from 'path';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
import crypto from 'crypto';
|
|
12
|
+
// --- Constants ---
|
|
13
|
+
export const CONFIG_PATH = join(homedir(), '.safeclaw', 'config.json');
|
|
14
|
+
// --- Functions ---
|
|
15
|
+
export function loadConfig() {
|
|
16
|
+
const defaults = {
|
|
17
|
+
serviceUrl: 'https://api.safeclaw.eu/api/v1',
|
|
18
|
+
apiKey: '',
|
|
19
|
+
timeoutMs: 500,
|
|
20
|
+
enabled: true,
|
|
21
|
+
enforcement: 'enforce',
|
|
22
|
+
failMode: 'closed',
|
|
23
|
+
agentId: '',
|
|
24
|
+
agentToken: '',
|
|
25
|
+
};
|
|
26
|
+
// Load from config file first
|
|
27
|
+
if (existsSync(CONFIG_PATH)) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
|
30
|
+
if (raw.enabled === false)
|
|
31
|
+
defaults.enabled = false;
|
|
32
|
+
if (raw.remote?.serviceUrl)
|
|
33
|
+
defaults.serviceUrl = raw.remote.serviceUrl;
|
|
34
|
+
if (raw.remote?.apiKey)
|
|
35
|
+
defaults.apiKey = raw.remote.apiKey;
|
|
36
|
+
if (raw.remote?.timeoutMs)
|
|
37
|
+
defaults.timeoutMs = raw.remote.timeoutMs;
|
|
38
|
+
if (raw.enforcement?.mode)
|
|
39
|
+
defaults.enforcement = raw.enforcement.mode;
|
|
40
|
+
if (raw.enforcement?.failMode)
|
|
41
|
+
defaults.failMode = raw.enforcement.failMode;
|
|
42
|
+
if (raw.agentId)
|
|
43
|
+
defaults.agentId = raw.agentId;
|
|
44
|
+
if (raw.agentToken)
|
|
45
|
+
defaults.agentToken = raw.agentToken;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Config file unreadable — use defaults
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Env vars override config file
|
|
52
|
+
if (process.env.SAFECLAW_URL)
|
|
53
|
+
defaults.serviceUrl = process.env.SAFECLAW_URL;
|
|
54
|
+
if (process.env.SAFECLAW_API_KEY)
|
|
55
|
+
defaults.apiKey = process.env.SAFECLAW_API_KEY;
|
|
56
|
+
if (process.env.SAFECLAW_TIMEOUT_MS)
|
|
57
|
+
defaults.timeoutMs = parseInt(process.env.SAFECLAW_TIMEOUT_MS, 10);
|
|
58
|
+
if (process.env.SAFECLAW_ENABLED === 'false')
|
|
59
|
+
defaults.enabled = false;
|
|
60
|
+
if (process.env.SAFECLAW_ENFORCEMENT)
|
|
61
|
+
defaults.enforcement = process.env.SAFECLAW_ENFORCEMENT;
|
|
62
|
+
if (process.env.SAFECLAW_FAIL_MODE)
|
|
63
|
+
defaults.failMode = process.env.SAFECLAW_FAIL_MODE;
|
|
64
|
+
if (process.env.SAFECLAW_AGENT_ID)
|
|
65
|
+
defaults.agentId = process.env.SAFECLAW_AGENT_ID;
|
|
66
|
+
if (process.env.SAFECLAW_AGENT_TOKEN)
|
|
67
|
+
defaults.agentToken = process.env.SAFECLAW_AGENT_TOKEN;
|
|
68
|
+
defaults.serviceUrl = defaults.serviceUrl.replace(/\/+$/, '');
|
|
69
|
+
const validModes = ['enforce', 'warn-only', 'audit-only', 'disabled'];
|
|
70
|
+
if (!validModes.includes(defaults.enforcement)) {
|
|
71
|
+
console.warn(`[SafeClaw] Invalid enforcement mode "${defaults.enforcement}", defaulting to "enforce"`);
|
|
72
|
+
defaults.enforcement = 'enforce';
|
|
73
|
+
}
|
|
74
|
+
const validFailModes = ['open', 'closed'];
|
|
75
|
+
if (!validFailModes.includes(defaults.failMode)) {
|
|
76
|
+
console.warn(`[SafeClaw] Invalid fail mode "${defaults.failMode}", defaulting to "closed"`);
|
|
77
|
+
defaults.failMode = 'closed';
|
|
78
|
+
}
|
|
79
|
+
return defaults;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Persist managed config fields back to ~/.safeclaw/config.json.
|
|
83
|
+
* Reads the existing file (if any), merges the fields SafeClaw manages,
|
|
84
|
+
* and writes the result. Fields not managed by SafeClaw are preserved.
|
|
85
|
+
*/
|
|
86
|
+
export function saveConfig(config) {
|
|
87
|
+
let existing = {};
|
|
88
|
+
if (existsSync(CONFIG_PATH)) {
|
|
89
|
+
try {
|
|
90
|
+
existing = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Unreadable — start fresh
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Merge managed fields into existing structure
|
|
97
|
+
existing.enabled = config.enabled;
|
|
98
|
+
if (!existing.remote || typeof existing.remote !== 'object') {
|
|
99
|
+
existing.remote = {};
|
|
100
|
+
}
|
|
101
|
+
existing.remote.serviceUrl = config.serviceUrl;
|
|
102
|
+
if (!existing.enforcement || typeof existing.enforcement !== 'object') {
|
|
103
|
+
existing.enforcement = {};
|
|
104
|
+
}
|
|
105
|
+
existing.enforcement.mode = config.enforcement;
|
|
106
|
+
existing.enforcement.failMode = config.failMode;
|
|
107
|
+
// Ensure parent directory exists
|
|
108
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
109
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* SHA-256 hash of the four TUI-managed config fields.
|
|
113
|
+
* Used to detect whether the on-disk config has drifted from the in-memory state.
|
|
114
|
+
*/
|
|
115
|
+
export function configHash(config) {
|
|
116
|
+
const payload = JSON.stringify({
|
|
117
|
+
enabled: config.enabled,
|
|
118
|
+
enforcement: config.enforcement,
|
|
119
|
+
failMode: config.failMode,
|
|
120
|
+
serviceUrl: config.serviceUrl,
|
|
121
|
+
});
|
|
122
|
+
return crypto.createHash('sha256').update(payload).digest('hex');
|
|
123
|
+
}
|
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 {
|
|
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
|
-
//
|
|
179
|
-
|
|
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
|
|
234
|
-
|
|
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 ??
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "SafeClaw Neurosymbolic Governance plugin for OpenClaw — validates AI agent actions against OWL ontologies and SHACL constraints",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
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
|
-
"
|
|
44
|
-
"@types/
|
|
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
|
+
}
|
package/tui/Settings.tsx
ADDED
|
@@ -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,99 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Text, Box } from 'ink';
|
|
3
|
+
import { type SafeClawConfig } from './config.js';
|
|
4
|
+
|
|
5
|
+
interface StatusProps {
|
|
6
|
+
config: SafeClawConfig;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface HealthData {
|
|
10
|
+
status: string;
|
|
11
|
+
version?: string;
|
|
12
|
+
engine_ready?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function Status({ config }: StatusProps) {
|
|
16
|
+
const [health, setHealth] = useState<HealthData | null>(null);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
const [lastCheck, setLastCheck] = useState<Date | null>(null);
|
|
19
|
+
|
|
20
|
+
const checkHealth = async () => {
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(`${config.serviceUrl}/health`, {
|
|
23
|
+
signal: AbortSignal.timeout(config.timeoutMs * 2),
|
|
24
|
+
});
|
|
25
|
+
if (res.ok) {
|
|
26
|
+
const data = await res.json() as HealthData;
|
|
27
|
+
setHealth(data);
|
|
28
|
+
setError(null);
|
|
29
|
+
} else {
|
|
30
|
+
setHealth(null);
|
|
31
|
+
setError(`HTTP ${res.status}`);
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
setHealth(null);
|
|
35
|
+
setError('Cannot connect');
|
|
36
|
+
}
|
|
37
|
+
setLastCheck(new Date());
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
checkHealth();
|
|
42
|
+
const interval = setInterval(checkHealth, 10000);
|
|
43
|
+
return () => clearInterval(interval);
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const connected = health !== null;
|
|
47
|
+
const dot = '●';
|
|
48
|
+
const dotColor = connected ? 'green' : 'red';
|
|
49
|
+
const statusText = connected
|
|
50
|
+
? `Connected (${config.serviceUrl.replace(/^https?:\/\//, '').replace(/\/api\/v1$/, '')})`
|
|
51
|
+
: error ?? 'Disconnected';
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Box flexDirection="column" paddingX={1}>
|
|
55
|
+
<Box marginBottom={1}>
|
|
56
|
+
<Text bold>Status</Text>
|
|
57
|
+
</Box>
|
|
58
|
+
|
|
59
|
+
<Box>
|
|
60
|
+
<Text dimColor>{' Service '}</Text>
|
|
61
|
+
<Text color={dotColor}>{dot} </Text>
|
|
62
|
+
<Text>{statusText}</Text>
|
|
63
|
+
</Box>
|
|
64
|
+
|
|
65
|
+
<Box>
|
|
66
|
+
<Text dimColor>{' Enforcement '}</Text>
|
|
67
|
+
<Text>{config.enforcement}</Text>
|
|
68
|
+
</Box>
|
|
69
|
+
|
|
70
|
+
<Box>
|
|
71
|
+
<Text dimColor>{' Fail Mode '}</Text>
|
|
72
|
+
<Text>{config.failMode}</Text>
|
|
73
|
+
</Box>
|
|
74
|
+
|
|
75
|
+
<Box>
|
|
76
|
+
<Text dimColor>{' Enabled '}</Text>
|
|
77
|
+
<Text color={config.enabled ? 'green' : 'red'}>
|
|
78
|
+
{config.enabled ? 'ON' : 'OFF'}
|
|
79
|
+
</Text>
|
|
80
|
+
</Box>
|
|
81
|
+
|
|
82
|
+
{health?.version && (
|
|
83
|
+
<Box marginTop={1}>
|
|
84
|
+
<Text dimColor>{' Service v'}</Text>
|
|
85
|
+
<Text>{health.version}</Text>
|
|
86
|
+
</Box>
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
{lastCheck && (
|
|
90
|
+
<Box marginTop={1}>
|
|
91
|
+
<Text dimColor>
|
|
92
|
+
{' Last check: '}
|
|
93
|
+
{lastCheck.toLocaleTimeString()}
|
|
94
|
+
</Text>
|
|
95
|
+
</Box>
|
|
96
|
+
)}
|
|
97
|
+
</Box>
|
|
98
|
+
);
|
|
99
|
+
}
|
package/tui/config.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SafeClaw shared configuration module.
|
|
3
|
+
*
|
|
4
|
+
* Used by both the OpenClaw plugin (index.ts) and the TUI.
|
|
5
|
+
* Reads ~/.safeclaw/config.json, applies env-var overrides,
|
|
6
|
+
* and exposes helpers for saving and hashing config state.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
|
|
10
|
+
import { join, dirname } from 'path';
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
import crypto from 'crypto';
|
|
13
|
+
|
|
14
|
+
// --- Types ---
|
|
15
|
+
|
|
16
|
+
export interface SafeClawConfig {
|
|
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
|
+
// --- Constants ---
|
|
28
|
+
|
|
29
|
+
export const CONFIG_PATH = join(homedir(), '.safeclaw', 'config.json');
|
|
30
|
+
|
|
31
|
+
// --- Functions ---
|
|
32
|
+
|
|
33
|
+
export function loadConfig(): SafeClawConfig {
|
|
34
|
+
const defaults: SafeClawConfig = {
|
|
35
|
+
serviceUrl: 'https://api.safeclaw.eu/api/v1',
|
|
36
|
+
apiKey: '',
|
|
37
|
+
timeoutMs: 500,
|
|
38
|
+
enabled: true,
|
|
39
|
+
enforcement: 'enforce',
|
|
40
|
+
failMode: 'closed',
|
|
41
|
+
agentId: '',
|
|
42
|
+
agentToken: '',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Load from config file first
|
|
46
|
+
if (existsSync(CONFIG_PATH)) {
|
|
47
|
+
try {
|
|
48
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
|
49
|
+
if (raw.enabled === false) defaults.enabled = false;
|
|
50
|
+
if (raw.remote?.serviceUrl) defaults.serviceUrl = raw.remote.serviceUrl;
|
|
51
|
+
if (raw.remote?.apiKey) defaults.apiKey = raw.remote.apiKey;
|
|
52
|
+
if (raw.remote?.timeoutMs) defaults.timeoutMs = raw.remote.timeoutMs;
|
|
53
|
+
if (raw.enforcement?.mode) defaults.enforcement = raw.enforcement.mode;
|
|
54
|
+
if (raw.enforcement?.failMode) defaults.failMode = raw.enforcement.failMode;
|
|
55
|
+
if (raw.agentId) defaults.agentId = raw.agentId;
|
|
56
|
+
if (raw.agentToken) defaults.agentToken = raw.agentToken;
|
|
57
|
+
} catch {
|
|
58
|
+
// Config file unreadable — use defaults
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Env vars override config file
|
|
63
|
+
if (process.env.SAFECLAW_URL) defaults.serviceUrl = process.env.SAFECLAW_URL;
|
|
64
|
+
if (process.env.SAFECLAW_API_KEY) defaults.apiKey = process.env.SAFECLAW_API_KEY;
|
|
65
|
+
if (process.env.SAFECLAW_TIMEOUT_MS) defaults.timeoutMs = parseInt(process.env.SAFECLAW_TIMEOUT_MS, 10);
|
|
66
|
+
if (process.env.SAFECLAW_ENABLED === 'false') defaults.enabled = false;
|
|
67
|
+
if (process.env.SAFECLAW_ENFORCEMENT) defaults.enforcement = process.env.SAFECLAW_ENFORCEMENT as SafeClawConfig['enforcement'];
|
|
68
|
+
if (process.env.SAFECLAW_FAIL_MODE) defaults.failMode = process.env.SAFECLAW_FAIL_MODE as SafeClawConfig['failMode'];
|
|
69
|
+
if (process.env.SAFECLAW_AGENT_ID) defaults.agentId = process.env.SAFECLAW_AGENT_ID;
|
|
70
|
+
if (process.env.SAFECLAW_AGENT_TOKEN) defaults.agentToken = process.env.SAFECLAW_AGENT_TOKEN;
|
|
71
|
+
|
|
72
|
+
defaults.serviceUrl = defaults.serviceUrl.replace(/\/+$/, '');
|
|
73
|
+
|
|
74
|
+
const validModes = ['enforce', 'warn-only', 'audit-only', 'disabled'] as const;
|
|
75
|
+
if (!validModes.includes(defaults.enforcement as any)) {
|
|
76
|
+
console.warn(`[SafeClaw] Invalid enforcement mode "${defaults.enforcement}", defaulting to "enforce"`);
|
|
77
|
+
defaults.enforcement = 'enforce';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const validFailModes = ['open', 'closed'] as const;
|
|
81
|
+
if (!validFailModes.includes(defaults.failMode as any)) {
|
|
82
|
+
console.warn(`[SafeClaw] Invalid fail mode "${defaults.failMode}", defaulting to "closed"`);
|
|
83
|
+
defaults.failMode = 'closed';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return defaults;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Persist managed config fields back to ~/.safeclaw/config.json.
|
|
91
|
+
* Reads the existing file (if any), merges the fields SafeClaw manages,
|
|
92
|
+
* and writes the result. Fields not managed by SafeClaw are preserved.
|
|
93
|
+
*/
|
|
94
|
+
export function saveConfig(config: SafeClawConfig): void {
|
|
95
|
+
let existing: Record<string, unknown> = {};
|
|
96
|
+
if (existsSync(CONFIG_PATH)) {
|
|
97
|
+
try {
|
|
98
|
+
existing = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
|
99
|
+
} catch {
|
|
100
|
+
// Unreadable — start fresh
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Merge managed fields into existing structure
|
|
105
|
+
existing.enabled = config.enabled;
|
|
106
|
+
|
|
107
|
+
if (!existing.remote || typeof existing.remote !== 'object') {
|
|
108
|
+
existing.remote = {};
|
|
109
|
+
}
|
|
110
|
+
(existing.remote as Record<string, unknown>).serviceUrl = config.serviceUrl;
|
|
111
|
+
|
|
112
|
+
if (!existing.enforcement || typeof existing.enforcement !== 'object') {
|
|
113
|
+
existing.enforcement = {};
|
|
114
|
+
}
|
|
115
|
+
(existing.enforcement as Record<string, unknown>).mode = config.enforcement;
|
|
116
|
+
(existing.enforcement as Record<string, unknown>).failMode = config.failMode;
|
|
117
|
+
|
|
118
|
+
// Ensure parent directory exists
|
|
119
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
120
|
+
|
|
121
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* SHA-256 hash of the four TUI-managed config fields.
|
|
126
|
+
* Used to detect whether the on-disk config has drifted from the in-memory state.
|
|
127
|
+
*/
|
|
128
|
+
export function configHash(config: SafeClawConfig): string {
|
|
129
|
+
const payload = JSON.stringify({
|
|
130
|
+
enabled: config.enabled,
|
|
131
|
+
enforcement: config.enforcement,
|
|
132
|
+
failMode: config.failMode,
|
|
133
|
+
serviceUrl: config.serviceUrl,
|
|
134
|
+
});
|
|
135
|
+
return crypto.createHash('sha256').update(payload).digest('hex');
|
|
136
|
+
}
|