openclaw-safeclaw-plugin 0.1.1 → 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 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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 { readFileSync, existsSync } from 'fs';
10
- import { join } from 'path';
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) {
@@ -77,28 +26,105 @@ async function post(path, body) {
77
26
  signal: AbortSignal.timeout(config.timeoutMs),
78
27
  });
79
28
  if (!res.ok) {
80
- console.warn(`[SafeClaw] HTTP ${res.status} from ${path}`);
29
+ // Try to parse structured error body from service
30
+ try {
31
+ const errBody = await res.json();
32
+ const detail = errBody.detail ?? `HTTP ${res.status}`;
33
+ const hint = errBody.hint ? ` (${errBody.hint})` : '';
34
+ console.warn(`[SafeClaw] ${path}: ${detail}${hint}`);
35
+ }
36
+ catch {
37
+ console.warn(`[SafeClaw] HTTP ${res.status} from ${path}`);
38
+ }
81
39
  return null; // Caller checks failMode
82
40
  }
83
41
  return await res.json();
84
42
  }
85
43
  catch (e) {
86
44
  if (e instanceof DOMException && e.name === 'TimeoutError') {
87
- console.debug(`[SafeClaw] Timeout on ${path}`);
45
+ console.warn(`[SafeClaw] Timeout after ${config.timeoutMs}ms on ${path} (${config.serviceUrl})`);
46
+ }
47
+ else if (e instanceof TypeError && (e.message.includes('fetch') || e.message.includes('ECONNREFUSED'))) {
48
+ console.warn(`[SafeClaw] Connection refused: ${config.serviceUrl}${path} — is the service running?`);
88
49
  }
89
50
  else {
90
- console.debug(`[SafeClaw] Service unavailable: ${path}`);
51
+ console.warn(`[SafeClaw] Service unavailable: ${config.serviceUrl}${path}`);
91
52
  }
92
53
  return null; // Caller checks failMode
93
54
  }
94
55
  }
56
+ async function checkConnection() {
57
+ const label = `[SafeClaw]`;
58
+ console.log(`${label} Connecting to ${config.serviceUrl} ...`);
59
+ console.log(`${label} Mode: enforcement=${config.enforcement}, failMode=${config.failMode}`);
60
+ try {
61
+ const res = await fetch(`${config.serviceUrl}/health`, {
62
+ signal: AbortSignal.timeout(config.timeoutMs * 2),
63
+ });
64
+ if (res.ok) {
65
+ const data = await res.json();
66
+ console.log(`${label} ✓ Connected — service ${data.status ?? 'ok'}`);
67
+ }
68
+ else {
69
+ console.warn(`${label} ✗ Service responded with HTTP ${res.status}`);
70
+ }
71
+ }
72
+ catch {
73
+ console.warn(`${label} ✗ Cannot reach service at ${config.serviceUrl}`);
74
+ if (config.failMode === 'closed') {
75
+ console.warn(`${label} fail-mode=closed → tool calls will be BLOCKED until service is reachable`);
76
+ }
77
+ else {
78
+ console.warn(`${label} fail-mode=open → tool calls will be ALLOWED despite no connection`);
79
+ }
80
+ }
81
+ }
95
82
  export default {
96
83
  id: 'openclaw-safeclaw-plugin',
97
84
  name: 'SafeClaw Neurosymbolic Governance',
98
- version: '0.1.0',
85
+ version: '0.1.2',
99
86
  register(api) {
100
- if (!config.enabled)
87
+ if (!config.enabled) {
88
+ console.log('[SafeClaw] Plugin disabled');
101
89
  return;
90
+ }
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); });
102
128
  // THE GATE — constraint checking on every tool call
103
129
  api.on('before_tool_call', async (event, ctx) => {
104
130
  const r = await post('/evaluate/tool-call', {
@@ -109,10 +135,13 @@ export default {
109
135
  sessionHistory: event.sessionHistory ?? [],
110
136
  });
111
137
  if (r === null && config.failMode === 'closed' && config.enforcement === 'enforce') {
112
- return { block: true, blockReason: 'SafeClaw service unavailable (fail-closed)' };
138
+ return { block: true, blockReason: `SafeClaw service unavailable at ${config.serviceUrl} (fail-closed)` };
113
139
  }
114
140
  else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
115
- console.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
141
+ console.warn(`[SafeClaw] Service unavailable at ${config.serviceUrl} (fail-closed mode, warn-only)`);
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)`);
116
145
  }
117
146
  if (r?.block) {
118
147
  if (config.enforcement === 'enforce') {
@@ -148,8 +177,14 @@ export default {
148
177
  else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
149
178
  console.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
150
179
  }
151
- if (r?.block && config.enforcement === 'enforce') {
152
- return { cancel: true };
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
153
188
  }
154
189
  }, { priority: 100 });
155
190
  // Async logging — fire-and-forget, no return value needed
@@ -171,8 +206,8 @@ export default {
171
206
  toolName: event.toolName ?? event.tool_name,
172
207
  params: event.params ?? {},
173
208
  result: event.result ?? '',
174
- success: event.success ?? true,
175
- }).catch(() => { });
209
+ success: event.success ?? false,
210
+ }).catch((e) => console.warn('[SafeClaw] Failed to record tool result:', e));
176
211
  });
177
212
  },
178
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;
@@ -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,6 @@
1
+ import { type SafeClawConfig } from './config.js';
2
+ interface StatusProps {
3
+ config: SafeClawConfig;
4
+ }
5
+ export default function Status({ config }: StatusProps): import("react/jsx-runtime").JSX.Element;
6
+ export {};
@@ -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 { 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 ---
@@ -93,15 +33,25 @@ async function post(path: string, body: Record<string, unknown>): Promise<Record
93
33
  signal: AbortSignal.timeout(config.timeoutMs),
94
34
  });
95
35
  if (!res.ok) {
96
- console.warn(`[SafeClaw] HTTP ${res.status} from ${path}`);
36
+ // Try to parse structured error body from service
37
+ try {
38
+ const errBody = await res.json() as Record<string, unknown>;
39
+ const detail = errBody.detail ?? `HTTP ${res.status}`;
40
+ const hint = errBody.hint ? ` (${errBody.hint})` : '';
41
+ console.warn(`[SafeClaw] ${path}: ${detail}${hint}`);
42
+ } catch {
43
+ console.warn(`[SafeClaw] HTTP ${res.status} from ${path}`);
44
+ }
97
45
  return null; // Caller checks failMode
98
46
  }
99
47
  return await res.json() as Record<string, unknown>;
100
48
  } catch (e) {
101
49
  if (e instanceof DOMException && e.name === 'TimeoutError') {
102
- console.debug(`[SafeClaw] Timeout on ${path}`);
50
+ console.warn(`[SafeClaw] Timeout after ${config.timeoutMs}ms on ${path} (${config.serviceUrl})`);
51
+ } else if (e instanceof TypeError && (e.message.includes('fetch') || e.message.includes('ECONNREFUSED'))) {
52
+ console.warn(`[SafeClaw] Connection refused: ${config.serviceUrl}${path} — is the service running?`);
103
53
  } else {
104
- console.debug(`[SafeClaw] Service unavailable: ${path}`);
54
+ console.warn(`[SafeClaw] Service unavailable: ${config.serviceUrl}${path}`);
105
55
  }
106
56
  return null; // Caller checks failMode
107
57
  }
@@ -129,13 +79,80 @@ interface PluginApi {
129
79
  ): void;
130
80
  }
131
81
 
82
+ async function checkConnection(): Promise<void> {
83
+ const label = `[SafeClaw]`;
84
+ console.log(`${label} Connecting to ${config.serviceUrl} ...`);
85
+ console.log(`${label} Mode: enforcement=${config.enforcement}, failMode=${config.failMode}`);
86
+
87
+ try {
88
+ const res = await fetch(`${config.serviceUrl}/health`, {
89
+ signal: AbortSignal.timeout(config.timeoutMs * 2),
90
+ });
91
+ if (res.ok) {
92
+ const data = await res.json() as Record<string, unknown>;
93
+ console.log(`${label} ✓ Connected — service ${data.status ?? 'ok'}`);
94
+ } else {
95
+ console.warn(`${label} ✗ Service responded with HTTP ${res.status}`);
96
+ }
97
+ } catch {
98
+ console.warn(`${label} ✗ Cannot reach service at ${config.serviceUrl}`);
99
+ if (config.failMode === 'closed') {
100
+ console.warn(`${label} fail-mode=closed → tool calls will be BLOCKED until service is reachable`);
101
+ } else {
102
+ console.warn(`${label} fail-mode=open → tool calls will be ALLOWED despite no connection`);
103
+ }
104
+ }
105
+ }
106
+
132
107
  export default {
133
108
  id: 'openclaw-safeclaw-plugin',
134
109
  name: 'SafeClaw Neurosymbolic Governance',
135
- version: '0.1.0',
110
+ version: '0.1.2',
136
111
 
137
112
  register(api: PluginApi) {
138
- if (!config.enabled) return;
113
+ if (!config.enabled) {
114
+ console.log('[SafeClaw] Plugin disabled');
115
+ return;
116
+ }
117
+
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); });
139
156
 
140
157
  // THE GATE — constraint checking on every tool call
141
158
  api.on('before_tool_call', async (event: PluginEvent, ctx: PluginContext) => {
@@ -148,9 +165,11 @@ export default {
148
165
  });
149
166
 
150
167
  if (r === null && config.failMode === 'closed' && config.enforcement === 'enforce') {
151
- return { block: true, blockReason: 'SafeClaw service unavailable (fail-closed)' };
168
+ return { block: true, blockReason: `SafeClaw service unavailable at ${config.serviceUrl} (fail-closed)` };
152
169
  } else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
153
- console.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
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)`);
154
173
  }
155
174
  if (r?.block) {
156
175
  if (config.enforcement === 'enforce') {
@@ -189,8 +208,14 @@ export default {
189
208
  } else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
190
209
  console.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
191
210
  }
192
- if (r?.block && config.enforcement === 'enforce') {
193
- 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
194
219
  }
195
220
  }, { priority: 100 });
196
221
 
@@ -215,8 +240,8 @@ export default {
215
240
  toolName: event.toolName ?? event.tool_name,
216
241
  params: event.params ?? {},
217
242
  result: event.result ?? '',
218
- success: event.success ?? true,
219
- }).catch(() => {});
243
+ success: event.success ?? false,
244
+ }).catch((e) => console.warn('[SafeClaw] Failed to record tool result:', e));
220
245
  });
221
246
  },
222
247
  };
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "openclaw-safeclaw-plugin",
3
- "version": "0.1.1",
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
- "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,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
+ }