openclaw-safeclaw-plugin 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ Neurosymbolic governance plugin for OpenClaw AI agents. Validates every tool cal
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- npm install openclaw-safeclaw-plugin
8
+ openclaw plugins install openclaw-safeclaw-plugin
9
9
  ```
10
10
 
11
11
  ## Quick Start
@@ -13,7 +13,7 @@ npm install openclaw-safeclaw-plugin
13
13
  Install and go — the plugin connects to SafeClaw's hosted service by default:
14
14
 
15
15
  ```bash
16
- npm install openclaw-safeclaw-plugin
16
+ openclaw plugins install openclaw-safeclaw-plugin
17
17
  ```
18
18
 
19
19
  No configuration needed. The default service URL is `https://api.safeclaw.eu/api/v1`.
package/cli.tsx ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ import React from 'react';
3
+ import { render } from 'ink';
4
+ import { execSync } from 'child_process';
5
+ import App from './tui/App.js';
6
+
7
+ const args = process.argv.slice(2);
8
+ const command = args[0];
9
+
10
+ if (command === 'tui') {
11
+ render(React.createElement(App));
12
+ } else if (command === 'restart-openclaw') {
13
+ try {
14
+ const output = execSync('openclaw daemon restart', { encoding: 'utf-8', timeout: 15000 });
15
+ console.log(output.trim());
16
+ console.log('OpenClaw daemon restarted successfully.');
17
+ } catch (err: unknown) {
18
+ const message = err instanceof Error ? err.message : String(err);
19
+ console.error('Failed to restart OpenClaw daemon:', message);
20
+ process.exit(1);
21
+ }
22
+ } else {
23
+ console.log('Usage: safeclaw <command>');
24
+ console.log('');
25
+ console.log('Commands:');
26
+ console.log(' tui Open the interactive SafeClaw settings TUI');
27
+ console.log(' restart-openclaw Restart the OpenClaw daemon');
28
+ process.exit(0);
29
+ }
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,30 @@
1
+ #!/usr/bin/env node
2
+ import React from 'react';
3
+ import { render } from 'ink';
4
+ import { execSync } from 'child_process';
5
+ import App from './tui/App.js';
6
+ const args = process.argv.slice(2);
7
+ const command = args[0];
8
+ if (command === 'tui') {
9
+ render(React.createElement(App));
10
+ }
11
+ else if (command === 'restart-openclaw') {
12
+ try {
13
+ const output = execSync('openclaw daemon restart', { encoding: 'utf-8', timeout: 15000 });
14
+ console.log(output.trim());
15
+ console.log('OpenClaw daemon restarted successfully.');
16
+ }
17
+ catch (err) {
18
+ const message = err instanceof Error ? err.message : String(err);
19
+ console.error('Failed to restart OpenClaw daemon:', message);
20
+ process.exit(1);
21
+ }
22
+ }
23
+ else {
24
+ console.log('Usage: safeclaw <command>');
25
+ console.log('');
26
+ console.log('Commands:');
27
+ console.log(' tui Open the interactive SafeClaw settings TUI');
28
+ console.log(' restart-openclaw Restart the OpenClaw daemon');
29
+ process.exit(0);
30
+ }
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,81 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from 'react';
3
+ import { Text, Box, useInput } from 'ink';
4
+ import { exec } from 'child_process';
5
+ export default function Status({ config }) {
6
+ const [health, setHealth] = useState(null);
7
+ const [error, setError] = useState(null);
8
+ const [lastCheck, setLastCheck] = useState(null);
9
+ const [openclawStatus, setOpenclawStatus] = useState('checking');
10
+ const [restartMsg, setRestartMsg] = useState(null);
11
+ const checkHealth = async () => {
12
+ try {
13
+ const res = await fetch(`${config.serviceUrl}/health`, {
14
+ signal: AbortSignal.timeout(config.timeoutMs * 2),
15
+ });
16
+ if (res.ok) {
17
+ const data = await res.json();
18
+ setHealth(data);
19
+ setError(null);
20
+ }
21
+ else {
22
+ setHealth(null);
23
+ setError(`HTTP ${res.status}`);
24
+ }
25
+ }
26
+ catch {
27
+ setHealth(null);
28
+ setError('Cannot connect');
29
+ }
30
+ setLastCheck(new Date());
31
+ };
32
+ const checkOpenClaw = () => {
33
+ exec('openclaw daemon status', { timeout: 10000 }, (err, stdout) => {
34
+ if (err) {
35
+ setOpenclawStatus('not running');
36
+ }
37
+ else {
38
+ const output = stdout.toLowerCase();
39
+ setOpenclawStatus(output.includes('running') ? 'running' : 'not running');
40
+ }
41
+ });
42
+ };
43
+ const restartOpenClaw = () => {
44
+ setRestartMsg('Restarting...');
45
+ exec('openclaw daemon restart', { timeout: 15000 }, (err) => {
46
+ if (err) {
47
+ setRestartMsg('Restart failed');
48
+ }
49
+ else {
50
+ setRestartMsg('Restarted');
51
+ checkOpenClaw();
52
+ }
53
+ setTimeout(() => setRestartMsg(null), 3000);
54
+ });
55
+ };
56
+ useEffect(() => {
57
+ checkHealth();
58
+ checkOpenClaw();
59
+ const interval = setInterval(() => {
60
+ checkHealth();
61
+ checkOpenClaw();
62
+ }, 10000);
63
+ return () => clearInterval(interval);
64
+ }, []);
65
+ useInput((input) => {
66
+ if (input === 'r') {
67
+ restartOpenClaw();
68
+ }
69
+ });
70
+ const connected = health !== null;
71
+ const dot = '●';
72
+ const serviceDotColor = connected ? 'green' : 'red';
73
+ const serviceText = connected
74
+ ? `Connected (${config.serviceUrl.replace(/^https?:\/\//, '').replace(/\/api\/v1$/, '')})`
75
+ : error ?? 'Disconnected';
76
+ const openclawDotColor = openclawStatus === 'running' ? 'green' : openclawStatus === 'checking' ? 'yellow' : 'red';
77
+ const openclawText = openclawStatus === 'checking' ? 'Checking...'
78
+ : openclawStatus === 'running' ? 'Running'
79
+ : 'Not running';
80
+ 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: serviceDotColor, children: [dot, " "] }), _jsx(Text, { children: serviceText })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' OpenClaw ' }), _jsxs(Text, { color: openclawDotColor, children: [dot, " "] }), _jsx(Text, { children: openclawText }), restartMsg && _jsx(Text, { dimColor: true, children: ` (${restartMsg})` })] }), _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()] }) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ' Press ' }), _jsx(Text, { bold: true, children: "r" }), _jsx(Text, { dimColor: true, children: ' to restart OpenClaw daemon' })] })] }));
81
+ }
@@ -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
+ }