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 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) {
@@ -139,8 +88,43 @@ export default {
139
88
  console.log('[SafeClaw] Plugin disabled');
140
89
  return;
141
90
  }
142
- // Fire-and-forget startup health check
143
- checkConnection().catch(() => { });
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 && config.enforcement === 'enforce') {
194
- 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
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 ?? true,
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;
@@ -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 ---
@@ -175,8 +115,44 @@ export default {
175
115
  return;
176
116
  }
177
117
 
178
- // Fire-and-forget startup health check
179
- checkConnection().catch(() => {});
118
+ // Heartbeat watchdog send config hash to service every 30s
119
+ const sendHeartbeat = async () => {
120
+ try {
121
+ await fetch(`${config.serviceUrl}/heartbeat`, {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/json' },
124
+ body: JSON.stringify({
125
+ agentId: config.agentId || 'default',
126
+ configHash: configHash(config),
127
+ status: 'alive',
128
+ }),
129
+ signal: AbortSignal.timeout(config.timeoutMs),
130
+ });
131
+ } catch {
132
+ // Heartbeat failure is non-fatal
133
+ }
134
+ };
135
+
136
+ // Start heartbeat after connection check
137
+ checkConnection().then(() => sendHeartbeat()).catch(() => {});
138
+ const heartbeatInterval = setInterval(sendHeartbeat, 30000);
139
+
140
+ // Clean shutdown: send shutdown heartbeat and clear interval
141
+ const shutdown = () => {
142
+ clearInterval(heartbeatInterval);
143
+ fetch(`${config.serviceUrl}/heartbeat`, {
144
+ method: 'POST',
145
+ headers: { 'Content-Type': 'application/json' },
146
+ body: JSON.stringify({
147
+ agentId: config.agentId || 'default',
148
+ configHash: configHash(config),
149
+ status: 'shutdown',
150
+ }),
151
+ }).catch(() => {});
152
+ };
153
+ process.on('exit', shutdown);
154
+ process.on('SIGINT', () => { shutdown(); process.exit(0); });
155
+ process.on('SIGTERM', () => { shutdown(); process.exit(0); });
180
156
 
181
157
  // THE GATE — constraint checking on every tool call
182
158
  api.on('before_tool_call', async (event: PluginEvent, ctx: PluginContext) => {
@@ -192,6 +168,8 @@ export default {
192
168
  return { block: true, blockReason: `SafeClaw service unavailable at ${config.serviceUrl} (fail-closed)` };
193
169
  } else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
194
170
  console.warn(`[SafeClaw] Service unavailable at ${config.serviceUrl} (fail-closed mode, warn-only)`);
171
+ } else if (r === null && config.failMode === 'closed' && config.enforcement === 'audit-only') {
172
+ console.warn(`[SafeClaw] Service unavailable at ${config.serviceUrl} (fail-closed mode, audit-only)`);
195
173
  }
196
174
  if (r?.block) {
197
175
  if (config.enforcement === 'enforce') {
@@ -230,8 +208,14 @@ export default {
230
208
  } else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
231
209
  console.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
232
210
  }
233
- if (r?.block && config.enforcement === 'enforce') {
234
- return { cancel: true };
211
+ if (r?.block) {
212
+ if (config.enforcement === 'enforce') {
213
+ return { cancel: true };
214
+ }
215
+ if (config.enforcement === 'warn-only') {
216
+ console.warn(`[SafeClaw] Warning: ${r.reason}`);
217
+ }
218
+ // audit-only: logged server-side, no action here
235
219
  }
236
220
  }, { priority: 100 });
237
221
 
@@ -256,8 +240,8 @@ export default {
256
240
  toolName: event.toolName ?? event.tool_name,
257
241
  params: event.params ?? {},
258
242
  result: event.result ?? '',
259
- success: event.success ?? true,
260
- }).catch(() => {});
243
+ success: event.success ?? false,
244
+ }).catch((e) => console.warn('[SafeClaw] Failed to record tool result:', e));
261
245
  });
262
246
  },
263
247
  };
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "openclaw-safeclaw-plugin",
3
- "version": "0.1.2",
3
+ "version": "0.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
+ }