openclaw-safeclaw-plugin 1.2.0 → 1.4.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 CHANGED
@@ -2,7 +2,7 @@
2
2
  import React from 'react';
3
3
  import { render } from 'ink';
4
4
  import { execSync } from 'child_process';
5
- import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, lstatSync, unlinkSync, rmSync } from 'fs';
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, lstatSync, unlinkSync } from 'fs';
6
6
  import { join, dirname } from 'path';
7
7
  import { homedir } from 'os';
8
8
  import { fileURLToPath } from 'url';
@@ -94,7 +94,32 @@ const command = args[0];
94
94
 
95
95
  // Handle --help / -h for any command position
96
96
  if (!command || command === '--help' || command === '-h' || command === 'help') {
97
- // Fall through to the else block at the bottom which prints full help
97
+ console.log('safeclaw-plugin OpenClaw plugin CLI for SafeClaw governance');
98
+ console.log('');
99
+ console.log('Usage: safeclaw-plugin <command> [options]');
100
+ console.log('');
101
+ console.log('Setup:');
102
+ console.log(' connect <api-key> Save API key, validate via handshake, register with OpenClaw');
103
+ console.log(' Keys start with "sc_". Get yours at https://safeclaw.eu/dashboard');
104
+ console.log(' setup Register plugin with OpenClaw without an API key (manual setup)');
105
+ console.log(' restart-openclaw Restart the OpenClaw daemon to pick up plugin changes');
106
+ console.log('');
107
+ console.log('Diagnostics:');
108
+ console.log(' status Run 9 checks: config, API key, service health, evaluate endpoint,');
109
+ console.log(' handshake, OpenClaw binary, plugin files, OpenClaw config, NemoClaw');
110
+ console.log('');
111
+ console.log('Configuration:');
112
+ console.log(' config show Show current enforcement, failMode, enabled, serviceUrl, apiKey');
113
+ console.log(' config set <k> <v> Set a config value. Keys: enforcement, failMode, enabled, serviceUrl');
114
+ console.log(' enforcement: enforce | warn-only | audit-only | disabled');
115
+ console.log(' failMode: open (allow on error) | closed (block on error)');
116
+ console.log(' enabled: true | false');
117
+ console.log('');
118
+ console.log('Interactive:');
119
+ console.log(' tui Open the interactive settings TUI (Status, Settings, About tabs)');
120
+ console.log('');
121
+ console.log('For the service CLI (serve, audit, policy, pref), use the "safeclaw" command.');
122
+ process.exit(0);
98
123
  } else if (command === 'connect') {
99
124
  const apiKey = args[1];
100
125
  const serviceUrlIdx = args.indexOf('--service-url');
@@ -192,7 +217,7 @@ if (!command || command === '--help' || command === '-h' || command === 'help')
192
217
  console.log(`enforcement: ${cfg.enforcement}`);
193
218
  console.log(`failMode: ${cfg.failMode}`);
194
219
  console.log(`serviceUrl: ${cfg.serviceUrl}`);
195
- console.log(`apiKey: ${cfg.apiKey ? `${cfg.apiKey.slice(0, 6)}...` : '(not set)'}`);
220
+ console.log(`apiKey: ${cfg.apiKey ? `${cfg.apiKey.slice(0, 6)}..${cfg.apiKey.slice(-4)}` : '(not set)'}`);
196
221
  console.log(`timeoutMs: ${cfg.timeoutMs}`);
197
222
  } else if (subcommand === 'set') {
198
223
  const key = args[2];
@@ -479,30 +504,7 @@ if (!command || command === '--help' || command === '-h' || command === 'help')
479
504
  console.log('Some checks failed. Fix the issues above.');
480
505
  }
481
506
  } else {
482
- console.log('safeclaw-plugin OpenClaw plugin CLI for SafeClaw governance');
483
- console.log('');
484
- console.log('Usage: safeclaw-plugin <command> [options]');
485
- console.log('');
486
- console.log('Setup:');
487
- console.log(' connect <api-key> Save API key, validate via handshake, register with OpenClaw');
488
- console.log(' Keys start with "sc_". Get yours at https://safeclaw.eu/dashboard');
489
- console.log(' setup Register plugin with OpenClaw without an API key (manual setup)');
490
- console.log(' restart-openclaw Restart the OpenClaw daemon to pick up plugin changes');
491
- console.log('');
492
- console.log('Diagnostics:');
493
- console.log(' status Run 8 checks: config, API key, service health, evaluate endpoint,');
494
- console.log(' handshake, OpenClaw binary, plugin files, OpenClaw config');
495
- console.log('');
496
- console.log('Configuration:');
497
- console.log(' config show Show current enforcement, failMode, enabled, serviceUrl, apiKey');
498
- console.log(' config set <k> <v> Set a config value. Keys: enforcement, failMode, enabled, serviceUrl');
499
- console.log(' enforcement: enforce | warn-only | audit-only | disabled');
500
- console.log(' failMode: open (allow on error) | closed (block on error)');
501
- console.log(' enabled: true | false');
502
- console.log('');
503
- console.log('Interactive:');
504
- console.log(' tui Open the interactive settings TUI (Status, Settings, About tabs)');
505
- console.log('');
506
- console.log('For the service CLI (serve, audit, policy, pref), use the "safeclaw" command.');
507
- process.exit(0);
507
+ console.error(`Unknown command: "${command}"`);
508
+ console.error('Run "safeclaw-plugin --help" for usage information.');
509
+ process.exit(1);
508
510
  }
package/dist/cli.js CHANGED
@@ -85,7 +85,32 @@ const args = process.argv.slice(2);
85
85
  const command = args[0];
86
86
  // Handle --help / -h for any command position
87
87
  if (!command || command === '--help' || command === '-h' || command === 'help') {
88
- // Fall through to the else block at the bottom which prints full help
88
+ console.log('safeclaw-plugin OpenClaw plugin CLI for SafeClaw governance');
89
+ console.log('');
90
+ console.log('Usage: safeclaw-plugin <command> [options]');
91
+ console.log('');
92
+ console.log('Setup:');
93
+ console.log(' connect <api-key> Save API key, validate via handshake, register with OpenClaw');
94
+ console.log(' Keys start with "sc_". Get yours at https://safeclaw.eu/dashboard');
95
+ console.log(' setup Register plugin with OpenClaw without an API key (manual setup)');
96
+ console.log(' restart-openclaw Restart the OpenClaw daemon to pick up plugin changes');
97
+ console.log('');
98
+ console.log('Diagnostics:');
99
+ console.log(' status Run 9 checks: config, API key, service health, evaluate endpoint,');
100
+ console.log(' handshake, OpenClaw binary, plugin files, OpenClaw config, NemoClaw');
101
+ console.log('');
102
+ console.log('Configuration:');
103
+ console.log(' config show Show current enforcement, failMode, enabled, serviceUrl, apiKey');
104
+ console.log(' config set <k> <v> Set a config value. Keys: enforcement, failMode, enabled, serviceUrl');
105
+ console.log(' enforcement: enforce | warn-only | audit-only | disabled');
106
+ console.log(' failMode: open (allow on error) | closed (block on error)');
107
+ console.log(' enabled: true | false');
108
+ console.log('');
109
+ console.log('Interactive:');
110
+ console.log(' tui Open the interactive settings TUI (Status, Settings, About tabs)');
111
+ console.log('');
112
+ console.log('For the service CLI (serve, audit, policy, pref), use the "safeclaw" command.');
113
+ process.exit(0);
89
114
  }
90
115
  else if (command === 'connect') {
91
116
  const apiKey = args[1];
@@ -180,7 +205,7 @@ else if (command === 'config') {
180
205
  console.log(`enforcement: ${cfg.enforcement}`);
181
206
  console.log(`failMode: ${cfg.failMode}`);
182
207
  console.log(`serviceUrl: ${cfg.serviceUrl}`);
183
- console.log(`apiKey: ${cfg.apiKey ? `${cfg.apiKey.slice(0, 6)}...` : '(not set)'}`);
208
+ console.log(`apiKey: ${cfg.apiKey ? `${cfg.apiKey.slice(0, 6)}..${cfg.apiKey.slice(-4)}` : '(not set)'}`);
184
209
  console.log(`timeoutMs: ${cfg.timeoutMs}`);
185
210
  }
186
211
  else if (subcommand === 'set') {
@@ -490,30 +515,7 @@ else if (command === 'status') {
490
515
  }
491
516
  }
492
517
  else {
493
- console.log('safeclaw-plugin OpenClaw plugin CLI for SafeClaw governance');
494
- console.log('');
495
- console.log('Usage: safeclaw-plugin <command> [options]');
496
- console.log('');
497
- console.log('Setup:');
498
- console.log(' connect <api-key> Save API key, validate via handshake, register with OpenClaw');
499
- console.log(' Keys start with "sc_". Get yours at https://safeclaw.eu/dashboard');
500
- console.log(' setup Register plugin with OpenClaw without an API key (manual setup)');
501
- console.log(' restart-openclaw Restart the OpenClaw daemon to pick up plugin changes');
502
- console.log('');
503
- console.log('Diagnostics:');
504
- console.log(' status Run 8 checks: config, API key, service health, evaluate endpoint,');
505
- console.log(' handshake, OpenClaw binary, plugin files, OpenClaw config');
506
- console.log('');
507
- console.log('Configuration:');
508
- console.log(' config show Show current enforcement, failMode, enabled, serviceUrl, apiKey');
509
- console.log(' config set <k> <v> Set a config value. Keys: enforcement, failMode, enabled, serviceUrl');
510
- console.log(' enforcement: enforce | warn-only | audit-only | disabled');
511
- console.log(' failMode: open (allow on error) | closed (block on error)');
512
- console.log(' enabled: true | false');
513
- console.log('');
514
- console.log('Interactive:');
515
- console.log(' tui Open the interactive settings TUI (Status, Settings, About tabs)');
516
- console.log('');
517
- console.log('For the service CLI (serve, audit, policy, pref), use the "safeclaw" command.');
518
- process.exit(0);
518
+ console.error(`Unknown command: "${command}"`);
519
+ console.error('Run "safeclaw-plugin --help" for usage information.');
520
+ process.exit(1);
519
521
  }
package/dist/index.js CHANGED
@@ -34,7 +34,7 @@ function getConfig() {
34
34
  // --- HTTP Client ---
35
35
  async function post(path, body) {
36
36
  const cfg = getConfig();
37
- if (!cfg.enabled)
37
+ if (!cfg.enabled || cfg.enforcement === 'disabled')
38
38
  return null;
39
39
  const headers = { 'Content-Type': 'application/json' };
40
40
  if (cfg.apiKey) {
@@ -79,7 +79,7 @@ async function post(path, body) {
79
79
  }
80
80
  async function get(path) {
81
81
  const cfg = getConfig();
82
- if (!cfg.enabled)
82
+ if (!cfg.enabled || cfg.enforcement === 'disabled')
83
83
  return null;
84
84
  const headers = {};
85
85
  if (cfg.apiKey)
@@ -149,12 +149,12 @@ export default {
149
149
  name: 'SafeClaw Neurosymbolic Governance',
150
150
  version: PLUGIN_VERSION,
151
151
  register(api) {
152
+ _ocPluginConfig = api.pluginConfig ?? {};
153
+ log = api.logger ?? console;
152
154
  if (!getConfig().enabled) {
153
- console.log('[SafeClaw] Plugin disabled');
155
+ log.info('[SafeClaw] Plugin disabled');
154
156
  return;
155
157
  }
156
- log = api.logger ?? console;
157
- _ocPluginConfig = api.pluginConfig ?? {};
158
158
  // Generate a unique instance ID for this plugin run (fallback when agentId is not configured)
159
159
  const instanceId = getConfig().agentId || `instance-${crypto.randomUUID()}`;
160
160
  // Heartbeat watchdog — send config hash to service every 30s
@@ -333,6 +333,16 @@ export default {
333
333
  childConfig: event.childConfig ?? {},
334
334
  reason: event.reason ?? '',
335
335
  });
336
+ // Fail-closed handling (matches before_tool_call / message_sending pattern)
337
+ if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
338
+ throw new Error('SafeClaw service unavailable (fail-closed mode)');
339
+ }
340
+ else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
341
+ log.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
342
+ }
343
+ else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
344
+ log.warn('[SafeClaw] Service unavailable (fail-closed mode, audit-only)');
345
+ }
336
346
  if (r?.block && cfg.enforcement === 'enforce') {
337
347
  throw new Error(r.reason || 'Blocked by SafeClaw: delegation bypass detected');
338
348
  }
@@ -15,10 +15,17 @@ export default function Settings({ config, onConfigChange }) {
15
15
  const [selected, setSelected] = useState(0);
16
16
  const [editing, setEditing] = useState(false);
17
17
  const [editBuffer, setEditBuffer] = useState('');
18
+ const [saveError, setSaveError] = useState(null);
18
19
  const updateConfig = (patch) => {
19
20
  const updated = { ...config, ...patch };
20
- saveConfig(updated);
21
- onConfigChange(updated);
21
+ try {
22
+ saveConfig(updated);
23
+ setSaveError(null);
24
+ onConfigChange(updated);
25
+ }
26
+ catch (e) {
27
+ setSaveError(e instanceof Error ? e.message : String(e));
28
+ }
22
29
  };
23
30
  useInput((input, key) => {
24
31
  if (editing) {
@@ -28,13 +35,7 @@ export default function Settings({ config, onConfigChange }) {
28
35
  // Reject invalid API keys — must start with sc_
29
36
  return;
30
37
  }
31
- try {
32
- updateConfig({ [editingKey]: editBuffer });
33
- }
34
- catch {
35
- // saveConfig may throw on invalid URL — stay in edit mode
36
- return;
37
- }
38
+ updateConfig({ [editingKey]: editBuffer });
38
39
  setEditing(false);
39
40
  }
40
41
  else if (key.escape) {
@@ -99,5 +100,5 @@ export default function Settings({ config, onConfigChange }) {
99
100
  ? SETTINGS[selected].key === 'apiKey' && editBuffer && !editBuffer.startsWith('sc_')
100
101
  ? ' API key must start with sc_ · esc to cancel'
101
102
  : ' type to edit · enter to save · esc to cancel'
102
- : ' ↑↓ navigate · ←→/enter cycle/toggle · enter to edit text fields · q quit' }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: ' Enforcement: enforce=block violations, warn-only=log only, audit-only=record only, disabled=off' }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: ' Fail Mode: open=allow if service unreachable, closed=block if service unreachable' }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: ' All changes save to ~/.safeclaw/config.json immediately.' }) })] }));
103
+ : ' ↑↓ navigate · ←→/enter cycle/toggle · enter to edit text fields · q quit' }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: ' Enforcement: enforce=block violations, warn-only=log only, audit-only=record only, disabled=off' }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: ' Fail Mode: open=allow if service unreachable, closed=block if service unreachable' }) }), saveError && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: [' Error saving config: ', saveError] }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: ' All changes save to ~/.safeclaw/config.json immediately.' }) })] }));
103
104
  }
@@ -129,7 +129,7 @@ export default function Status({ config }) {
129
129
  checkOpenClaw();
130
130
  }, 10000);
131
131
  return () => clearInterval(interval);
132
- }, []);
132
+ }, [config.serviceUrl, config.apiKey]);
133
133
  useInput((input) => {
134
134
  if (input === 'r') {
135
135
  restartOpenClaw();
@@ -33,8 +33,11 @@ export function loadConfig() {
33
33
  defaults.serviceUrl = raw.remote.serviceUrl;
34
34
  if (raw.remote?.apiKey)
35
35
  defaults.apiKey = raw.remote.apiKey;
36
- if (raw.remote?.timeoutMs)
37
- defaults.timeoutMs = raw.remote.timeoutMs;
36
+ if (raw.remote?.timeoutMs != null) {
37
+ const t = Number(raw.remote.timeoutMs);
38
+ if (Number.isFinite(t) && t > 0)
39
+ defaults.timeoutMs = t;
40
+ }
38
41
  if (raw.enforcement?.mode)
39
42
  defaults.enforcement = raw.enforcement.mode;
40
43
  if (raw.enforcement?.failMode)
@@ -67,10 +70,16 @@ export function loadConfig() {
67
70
  }
68
71
  if (process.env.SAFECLAW_ENABLED === 'false')
69
72
  defaults.enabled = false;
70
- if (process.env.SAFECLAW_ENFORCEMENT)
71
- defaults.enforcement = process.env.SAFECLAW_ENFORCEMENT;
72
- if (process.env.SAFECLAW_FAIL_MODE)
73
- defaults.failMode = process.env.SAFECLAW_FAIL_MODE;
73
+ else if (process.env.SAFECLAW_ENABLED === 'true')
74
+ defaults.enabled = true;
75
+ const enforcement = process.env.SAFECLAW_ENFORCEMENT;
76
+ if (enforcement && ['enforce', 'warn-only', 'audit-only', 'disabled'].includes(enforcement)) {
77
+ defaults.enforcement = enforcement;
78
+ }
79
+ const failMode = process.env.SAFECLAW_FAIL_MODE;
80
+ if (failMode && ['open', 'closed'].includes(failMode)) {
81
+ defaults.failMode = failMode;
82
+ }
74
83
  if (process.env.SAFECLAW_AGENT_ID)
75
84
  defaults.agentId = process.env.SAFECLAW_AGENT_ID;
76
85
  if (process.env.SAFECLAW_AGENT_TOKEN)
@@ -136,9 +145,13 @@ export function saveConfig(config) {
136
145
  existing.remote = {};
137
146
  }
138
147
  existing.remote.serviceUrl = config.serviceUrl;
148
+ existing.remote.timeoutMs = config.timeoutMs;
139
149
  if (config.apiKey) {
140
150
  existing.remote.apiKey = config.apiKey;
141
151
  }
152
+ else {
153
+ delete existing.remote.apiKey;
154
+ }
142
155
  if (!existing.enforcement || typeof existing.enforcement !== 'object') {
143
156
  existing.enforcement = {};
144
157
  }
@@ -156,9 +169,12 @@ export function saveConfig(config) {
156
169
  writeFileSync(CONFIG_PATH, JSON.stringify(existing, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
157
170
  }
158
171
  catch (e) {
159
- if (e.code === 'EROFS' || e.code === 'EACCES') {
160
- // Sandbox filesystem is read-only — silently skip
161
- return;
172
+ const code = e.code;
173
+ if (code === 'EROFS') {
174
+ throw new Error('Cannot save config: filesystem is read-only (sandbox environment)');
175
+ }
176
+ if (code === 'EACCES') {
177
+ throw new Error(`Cannot save config: permission denied for ${CONFIG_PATH}`);
162
178
  }
163
179
  throw e;
164
180
  }
package/index.ts CHANGED
@@ -45,7 +45,7 @@ function getConfig(): typeof config {
45
45
 
46
46
  async function post(path: string, body: Record<string, unknown>): Promise<Record<string, unknown> | null> {
47
47
  const cfg = getConfig();
48
- if (!cfg.enabled) return null;
48
+ if (!cfg.enabled || cfg.enforcement === 'disabled') return null;
49
49
 
50
50
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
51
51
  if (cfg.apiKey) {
@@ -89,7 +89,7 @@ async function post(path: string, body: Record<string, unknown>): Promise<Record
89
89
 
90
90
  async function get(path: string): Promise<Record<string, unknown> | null> {
91
91
  const cfg = getConfig();
92
- if (!cfg.enabled) return null;
92
+ if (!cfg.enabled || cfg.enforcement === 'disabled') return null;
93
93
  const headers: Record<string, string> = {};
94
94
  if (cfg.apiKey) headers['Authorization'] = `Bearer ${cfg.apiKey}`;
95
95
  try {
@@ -162,14 +162,14 @@ export default {
162
162
  version: PLUGIN_VERSION,
163
163
 
164
164
  register(api: OpenClawPluginApi) {
165
+ _ocPluginConfig = api.pluginConfig ?? {};
166
+ log = api.logger ?? console;
167
+
165
168
  if (!getConfig().enabled) {
166
- console.log('[SafeClaw] Plugin disabled');
169
+ log.info('[SafeClaw] Plugin disabled');
167
170
  return;
168
171
  }
169
172
 
170
- log = api.logger ?? console;
171
- _ocPluginConfig = api.pluginConfig ?? {};
172
-
173
173
  // Generate a unique instance ID for this plugin run (fallback when agentId is not configured)
174
174
  const instanceId = getConfig().agentId || `instance-${crypto.randomUUID()}`;
175
175
 
@@ -357,6 +357,15 @@ export default {
357
357
  reason: event.reason ?? '',
358
358
  });
359
359
 
360
+ // Fail-closed handling (matches before_tool_call / message_sending pattern)
361
+ if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
362
+ throw new Error('SafeClaw service unavailable (fail-closed mode)');
363
+ } else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
364
+ log.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
365
+ } else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
366
+ log.warn('[SafeClaw] Service unavailable (fail-closed mode, audit-only)');
367
+ }
368
+
360
369
  if (r?.block && cfg.enforcement === 'enforce') {
361
370
  throw new Error((r.reason as string) || 'Blocked by SafeClaw: delegation bypass detected');
362
371
  }
@@ -2,7 +2,7 @@
2
2
  "id": "safeclaw",
3
3
  "name": "SafeClaw Neurosymbolic Governance",
4
4
  "description": "Validates AI agent actions against OWL ontologies and SHACL constraints before execution",
5
- "version": "1.1.0",
5
+ "version": "1.3.0",
6
6
  "author": "Tendly EU",
7
7
  "license": "MIT",
8
8
  "homepage": "https://safeclaw.eu",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-safeclaw-plugin",
3
- "version": "1.2.0",
3
+ "version": "1.4.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",
@@ -20,6 +20,7 @@
20
20
  },
21
21
  "files": [
22
22
  "dist/",
23
+ "types/",
23
24
  "openclaw.plugin.json",
24
25
  "index.ts",
25
26
  "cli.tsx",
package/tui/Settings.tsx CHANGED
@@ -29,11 +29,17 @@ export default function Settings({ config, onConfigChange }: SettingsProps) {
29
29
  const [selected, setSelected] = useState(0);
30
30
  const [editing, setEditing] = useState(false);
31
31
  const [editBuffer, setEditBuffer] = useState('');
32
+ const [saveError, setSaveError] = useState<string | null>(null);
32
33
 
33
34
  const updateConfig = (patch: Partial<SafeClawConfig>) => {
34
35
  const updated = { ...config, ...patch };
35
- saveConfig(updated);
36
- onConfigChange(updated);
36
+ try {
37
+ saveConfig(updated);
38
+ setSaveError(null);
39
+ onConfigChange(updated);
40
+ } catch (e) {
41
+ setSaveError(e instanceof Error ? e.message : String(e));
42
+ }
37
43
  };
38
44
 
39
45
  useInput((input, key) => {
@@ -44,12 +50,7 @@ export default function Settings({ config, onConfigChange }: SettingsProps) {
44
50
  // Reject invalid API keys — must start with sc_
45
51
  return;
46
52
  }
47
- try {
48
- updateConfig({ [editingKey]: editBuffer } as Partial<SafeClawConfig>);
49
- } catch {
50
- // saveConfig may throw on invalid URL — stay in edit mode
51
- return;
52
- }
53
+ updateConfig({ [editingKey]: editBuffer } as Partial<SafeClawConfig>);
53
54
  setEditing(false);
54
55
  } else if (key.escape) {
55
56
  setEditing(false);
@@ -146,6 +147,12 @@ export default function Settings({ config, onConfigChange }: SettingsProps) {
146
147
  <Text dimColor>{' Fail Mode: open=allow if service unreachable, closed=block if service unreachable'}</Text>
147
148
  </Box>
148
149
 
150
+ {saveError && (
151
+ <Box marginTop={1}>
152
+ <Text color="red">{' Error saving config: '}{saveError}</Text>
153
+ </Box>
154
+ )}
155
+
149
156
  <Box marginTop={1}>
150
157
  <Text dimColor>{' All changes save to ~/.safeclaw/config.json immediately.'}</Text>
151
158
  </Box>
package/tui/Status.tsx CHANGED
@@ -141,7 +141,7 @@ export default function Status({ config }: StatusProps) {
141
141
  checkOpenClaw();
142
142
  }, 10000);
143
143
  return () => clearInterval(interval);
144
- }, []);
144
+ }, [config.serviceUrl, config.apiKey]);
145
145
 
146
146
  useInput((input) => {
147
147
  if (input === 'r') {
package/tui/config.ts CHANGED
@@ -49,7 +49,10 @@ export function loadConfig(): SafeClawConfig {
49
49
  if (raw.enabled === false) defaults.enabled = false;
50
50
  if (raw.remote?.serviceUrl) defaults.serviceUrl = raw.remote.serviceUrl;
51
51
  if (raw.remote?.apiKey) defaults.apiKey = raw.remote.apiKey;
52
- if (raw.remote?.timeoutMs) defaults.timeoutMs = raw.remote.timeoutMs;
52
+ if (raw.remote?.timeoutMs != null) {
53
+ const t = Number(raw.remote.timeoutMs);
54
+ if (Number.isFinite(t) && t > 0) defaults.timeoutMs = t;
55
+ }
53
56
  if (raw.enforcement?.mode) defaults.enforcement = raw.enforcement.mode;
54
57
  if (raw.enforcement?.failMode) defaults.failMode = raw.enforcement.failMode;
55
58
  if (raw.agentId) defaults.agentId = raw.agentId;
@@ -73,8 +76,15 @@ export function loadConfig(): SafeClawConfig {
73
76
  }
74
77
  }
75
78
  if (process.env.SAFECLAW_ENABLED === 'false') defaults.enabled = false;
76
- if (process.env.SAFECLAW_ENFORCEMENT) defaults.enforcement = process.env.SAFECLAW_ENFORCEMENT as SafeClawConfig['enforcement'];
77
- if (process.env.SAFECLAW_FAIL_MODE) defaults.failMode = process.env.SAFECLAW_FAIL_MODE as SafeClawConfig['failMode'];
79
+ else if (process.env.SAFECLAW_ENABLED === 'true') defaults.enabled = true;
80
+ const enforcement = process.env.SAFECLAW_ENFORCEMENT;
81
+ if (enforcement && ['enforce', 'warn-only', 'audit-only', 'disabled'].includes(enforcement)) {
82
+ defaults.enforcement = enforcement as SafeClawConfig['enforcement'];
83
+ }
84
+ const failMode = process.env.SAFECLAW_FAIL_MODE;
85
+ if (failMode && ['open', 'closed'].includes(failMode)) {
86
+ defaults.failMode = failMode as SafeClawConfig['failMode'];
87
+ }
78
88
  if (process.env.SAFECLAW_AGENT_ID) defaults.agentId = process.env.SAFECLAW_AGENT_ID;
79
89
  if (process.env.SAFECLAW_AGENT_TOKEN) defaults.agentToken = process.env.SAFECLAW_AGENT_TOKEN;
80
90
 
@@ -146,8 +156,11 @@ export function saveConfig(config: SafeClawConfig): void {
146
156
  existing.remote = {};
147
157
  }
148
158
  (existing.remote as Record<string, unknown>).serviceUrl = config.serviceUrl;
159
+ (existing.remote as Record<string, unknown>).timeoutMs = config.timeoutMs;
149
160
  if (config.apiKey) {
150
161
  (existing.remote as Record<string, unknown>).apiKey = config.apiKey;
162
+ } else {
163
+ delete (existing.remote as Record<string, unknown>).apiKey;
151
164
  }
152
165
 
153
166
  if (!existing.enforcement || typeof existing.enforcement !== 'object') {
@@ -169,9 +182,12 @@ export function saveConfig(config: SafeClawConfig): void {
169
182
  try {
170
183
  writeFileSync(CONFIG_PATH, JSON.stringify(existing, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
171
184
  } catch (e) {
172
- if ((e as NodeJS.ErrnoException).code === 'EROFS' || (e as NodeJS.ErrnoException).code === 'EACCES') {
173
- // Sandbox filesystem is read-only — silently skip
174
- return;
185
+ const code = (e as NodeJS.ErrnoException).code;
186
+ if (code === 'EROFS') {
187
+ throw new Error('Cannot save config: filesystem is read-only (sandbox environment)');
188
+ }
189
+ if (code === 'EACCES') {
190
+ throw new Error(`Cannot save config: permission denied for ${CONFIG_PATH}`);
175
191
  }
176
192
  throw e;
177
193
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Ambient type declarations for OpenClaw Plugin SDK.
3
+ * These match the types exported by openclaw/plugin-sdk as of v2026.3.
4
+ * When installed inside OpenClaw, the real SDK types take precedence.
5
+ */
6
+
7
+ declare module 'openclaw/plugin-sdk/core' {
8
+ export interface OpenClawPluginApi {
9
+ on(
10
+ hookName: string,
11
+ handler: (event: OpenClawPluginEvent, ctx: OpenClawPluginContext) => Promise<Record<string, unknown> | void> | void,
12
+ options?: { priority?: number },
13
+ ): void;
14
+ registerService?(service: OpenClawPluginService): void;
15
+ registerCli?(registrar: (ctx: { program: unknown; config: unknown }) => void, opts?: { commands: string[] }): void;
16
+ registerTool?(tool: OpenClawPluginTool): void;
17
+ registerCommand?(def: { name: string; description: string; execute: (ctx: unknown) => Promise<string | void> }): void;
18
+ pluginConfig?: Record<string, unknown>;
19
+ logger?: OpenClawPluginLogger;
20
+ }
21
+
22
+ export interface OpenClawPluginEvent {
23
+ sessionId?: string;
24
+ toolName?: string;
25
+ params?: Record<string, unknown>;
26
+ to?: string;
27
+ content?: string;
28
+ result?: unknown;
29
+ error?: string;
30
+ durationMs?: number;
31
+ prompt?: string;
32
+ provider?: string;
33
+ model?: string;
34
+ lastAssistant?: string;
35
+ usage?: Record<string, unknown>;
36
+ parentAgentId?: string;
37
+ childConfig?: Record<string, unknown>;
38
+ reason?: string;
39
+ metadata?: Record<string, unknown>;
40
+ channel?: string;
41
+ sender?: string;
42
+ [key: string]: unknown;
43
+ }
44
+
45
+ export interface OpenClawPluginContext {
46
+ sessionId?: string;
47
+ agentId?: string;
48
+ runId?: string;
49
+ conversationId?: string;
50
+ accountId?: string;
51
+ channelId?: string;
52
+ workspaceDir?: string;
53
+ [key: string]: unknown;
54
+ }
55
+
56
+ export interface OpenClawPluginService {
57
+ id: string;
58
+ start: () => void | Promise<void>;
59
+ stop?: () => void | Promise<void>;
60
+ }
61
+
62
+ export interface OpenClawPluginTool {
63
+ name: string;
64
+ description: string;
65
+ parameters: Record<string, unknown>;
66
+ execute: (params: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<unknown>;
67
+ }
68
+
69
+ export interface OpenClawPluginLogger {
70
+ info: (...args: unknown[]) => void;
71
+ warn: (...args: unknown[]) => void;
72
+ error: (...args: unknown[]) => void;
73
+ }
74
+ }
75
+
76
+ declare module 'openclaw/plugin-sdk/plugin-entry' {
77
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk/core';
78
+
79
+ export interface PluginDefinition {
80
+ id: string;
81
+ name: string;
82
+ description?: string;
83
+ version?: string;
84
+ configSchema?: Record<string, unknown>;
85
+ register?: (api: OpenClawPluginApi) => void | Promise<void>;
86
+ }
87
+
88
+ export function definePluginEntry(def: PluginDefinition): PluginDefinition;
89
+ }