@claw-link/gateway-host 0.2.3 → 0.2.5

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
@@ -64,13 +64,14 @@ tool's own settings).
64
64
  **Permissions by scope** — each job carries a scope, and the Claude adapter runs with
65
65
  matching permissions automatically:
66
66
 
67
- | Trigger | Scope | Claude permissions |
68
- |---------|-------|--------------------|
69
- | Admin **Setup via Chat** / apply skill | `configure` | `--permission-mode acceptEdits` may write workspace files (GUARDRAILS/SOUL/BOOTSTRAP/TOOLS/AGENTS) |
70
- | Customer chat + discovery | `customer` | `--disallowedTools "Write Edit MultiEdit NotebookEdit Bash"` **read-only**, cannot edit config or run shell |
67
+ | Trigger | Scope | Claude | Codex |
68
+ |---------|-------|--------|-------|
69
+ | Admin **Setup via Chat** / apply skill | `configure` | `--permission-mode acceptEdits` (may write workspace files) | `--sandbox workspace-write --ask-for-approval never` |
70
+ | Customer chat + discovery | `customer` | `--disallowedTools "Write Edit MultiEdit NotebookEdit Bash"` (**read-only**) | `--sandbox read-only --ask-for-approval never` |
71
71
 
72
72
  So an agent's files are editable from the admin panel but never from customer/discovery
73
- chat. (Operator `args_template` overrides this for advanced setups.)
73
+ chat. (Operator `args_template` overrides this for advanced setups. Hermes/Cursor/OpenClaw
74
+ manage their own permissions.)
74
75
 
75
76
  ## Commands
76
77
 
@@ -111,11 +112,18 @@ cached back in). `~/.clawlink-host/config.json` (chmod 600):
111
112
  }
112
113
  ```
113
114
 
115
+ ## Platforms
116
+
117
+ macOS (launchd), Linux (systemd user service), and **Windows** (a hidden, auto-restarting
118
+ Scheduled Task; logs to `%USERPROFILE%\.clawlink-host\host.log`). On Windows the worker
119
+ handles npm `.cmd` CLI shims automatically; for the strongest isolation prefer Claude
120
+ (prompt via stdin) or a native `.exe` runtime.
121
+
114
122
  ## Security
115
123
 
116
- Outbound-only, per-agent token (stored server-side as a hash only), **machine-bound**
117
- on first connect (a stolen token fails from any other host), no shell injection, runs
118
- only the configured binary. See [SECURITY.md](./SECURITY.md).
124
+ Outbound-only, per-agent token (stored server-side as a hash only), **machine-bound** on
125
+ first connect to a **stable hardware id** (a stolen token fails from any other host), no
126
+ shell injection, runs only the configured binary. See [SECURITY.md](./SECURITY.md).
119
127
 
120
128
  ---
121
129
 
package/SECURITY.md CHANGED
@@ -12,18 +12,25 @@ a secret, never run an attacker-controlled command, never open a port.
12
12
  exactly one `agent_id`. Rotate any time from the dashboard (the old token dies
13
13
  immediately).
14
14
  2. **Machine binding** — on first connect the worker sends a **hash** of the host's
15
- machine fingerprint (MAC address(es) + hostname); ClawLink binds the agent to that
16
- machine. Every later call must present the same fingerprint, so a stolen token is
17
- rejected from any other host. Only the hash is sent raw MACs never leave the
18
- machine. Rotating the token clears the binding (also how you move to a new host).
19
- (A MAC can be spoofed by an attacker who *also* learns it, so treat this as a strong
20
- second factor on top of the token, not a replacement.)
15
+ **stable hardware machine id** (macOS `IOPlatformUUID`, Linux `/etc/machine-id`,
16
+ Windows `MachineGuid`; a persisted random id as fallback); ClawLink binds the agent
17
+ to that machine. Every later call must present the same id, so a stolen token is
18
+ rejected from any other host. Only the hash is sent the raw id never leaves the
19
+ machine. The id is **stable across reboots, network changes, and version upgrades**
20
+ (earlier builds hashed MAC addresses, which rotate and caused false mismatches).
21
+ Rotating the token clears the binding (also how you move to a new host). Treat this
22
+ as a strong second factor on top of the token, not a replacement.
21
23
  3. **Outbound-only** — the worker **dials out** to the `host-bridge` HTTPS endpoint
22
24
  and short-polls for work. It opens **no inbound port**, needs **no Tailscale
23
25
  Funnel**, and works behind NAT. There is nothing to attack from the network.
24
- 4. **No shell injection** — every CLI is launched with `child_process.spawn(binary,
25
- argvArray, { shell: false })`. Untrusted prompt text is passed as a single argv
26
- token or via stdin — it is **never** concatenated into a command string.
26
+ 4. **No shell injection** — on macOS/Linux every CLI is launched with
27
+ `child_process.spawn(binary, argvArray, { shell: false })`; untrusted prompt text is
28
+ a single argv token or comes via stdin — **never** concatenated into a command
29
+ string. On Windows, npm CLI shims are `.cmd`/`.bat` files that Node can only spawn
30
+ through the shell, so those go through `cmd.exe` with Node's hardened argument
31
+ escaping; native `.exe` runtimes still use `shell: false`. For Windows, prefer a
32
+ runtime that reads the prompt via **stdin** (Claude) or a native `.exe` so untrusted
33
+ text never enters a Windows command line.
27
34
  5. **No arbitrary command execution** — the worker runs **only** the runtime binary
28
35
  you configured. The job payload it receives carries `{ content, system_prompt,
29
36
  attachments, session }` — it is structurally incapable of carrying a command, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claw-link/gateway-host",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "ClawLink Host Gateway — a secure, outbound-only worker that bridges a local agent CLI (OpenClaw, Hermes, Claude, Codex, Cursor) to your ClawLink agents. No inbound ports; authenticated per-agent by a Host Token.",
5
5
  "homepage": "https://claw-link.co",
6
6
  "bin": {
@@ -262,14 +262,59 @@ WantedBy=default.target
262
262
  return execSafe('systemctl --user enable --now clawlink-host') !== null;
263
263
  }
264
264
  if (platform === 'win32') {
265
- // Best effort: a scheduled task that runs at logon.
266
- const taskCmd = `"${nodeBin()}" "${cliEntry()}" run`;
267
- return execSafe(`schtasks /Create /F /SC ONLOGON /TN ClawLinkHost /TR ${JSON.stringify(taskCmd)}`) !== null;
265
+ return installServiceWindows();
268
266
  }
269
267
  } catch { /* fall through */ }
270
268
  return false;
271
269
  }
272
270
 
271
+ // Windows: a hidden, auto-restarting Scheduled Task created from an XML definition
272
+ // (avoids schtasks /TR quoting issues with spaces in paths). A .cmd launcher pipes
273
+ // the worker's output to host.log; a .vbs runs that launcher with NO console window.
274
+ function installServiceWindows() {
275
+ const logPath = path.join(config.HOME_DIR, 'host.log');
276
+ const cmdPath = path.join(config.HOME_DIR, 'run-host.cmd');
277
+ const vbsPath = path.join(config.HOME_DIR, 'run-host.vbs');
278
+ const xmlPath = path.join(config.HOME_DIR, 'task.xml');
279
+ fs.mkdirSync(config.HOME_DIR, { recursive: true });
280
+
281
+ // 1) launcher that runs the worker and redirects stdout+stderr to the log
282
+ fs.writeFileSync(cmdPath,
283
+ `@echo off\r\n"${nodeBin()}" "${cliEntry()}" run >> "${logPath}" 2>&1\r\n`);
284
+
285
+ // 2) VBScript that runs the launcher hidden (0) and waits (True) so the task
286
+ // process stays alive while the worker runs. "" -> a literal quote in VBScript.
287
+ fs.writeFileSync(vbsPath,
288
+ `CreateObject("WScript.Shell").Run "cmd /c ""${cmdPath}""", 0, True\r\n`);
289
+
290
+ // 3) Task XML: logon trigger, hidden, no time limit, restart-on-failure (parity
291
+ // with launchd KeepAlive / systemd Restart=always). UTF-16 + BOM (schtasks /XML).
292
+ const xml = `<?xml version="1.0" encoding="UTF-16"?>
293
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
294
+ <RegistrationInfo><Description>ClawLink Host Gateway</Description></RegistrationInfo>
295
+ <Triggers><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Triggers>
296
+ <Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
297
+ <Settings>
298
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
299
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
300
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
301
+ <StartWhenAvailable>true</StartWhenAvailable>
302
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
303
+ <Hidden>true</Hidden>
304
+ <Enabled>true</Enabled>
305
+ <RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
306
+ </Settings>
307
+ <Actions Context="Author"><Exec><Command>wscript.exe</Command><Arguments>//B //Nologo "${vbsPath}"</Arguments></Exec></Actions>
308
+ </Task>`;
309
+ fs.writeFileSync(xmlPath, '' + xml, 'utf16le');
310
+
311
+ execSafe('schtasks /End /TN ClawLinkHost');
312
+ execSafe('schtasks /Delete /F /TN ClawLinkHost');
313
+ const created = execSafe(`schtasks /Create /F /TN ClawLinkHost /XML "${xmlPath}"`) !== null;
314
+ if (created) execSafe('schtasks /Run /TN ClawLinkHost');
315
+ return created;
316
+ }
317
+
273
318
  function serviceRunning() {
274
319
  const platform = process.platform;
275
320
  if (platform === 'darwin') {
@@ -1,11 +1,15 @@
1
1
  'use strict';
2
2
  // Shared spawn helper for CLI-wrapping adapters.
3
3
  //
4
- // SECURITY: commands are ALWAYS invoked with an argv ARRAY and shell:false, so
5
- // untrusted prompt text can never be interpreted by a shell. Placeholders in the
6
- // configured args_template ({prompt}, {sessionId}, {systemPrompt}) are substituted
7
- // PER ARRAY ELEMENT each becomes exactly one argv token, never concatenated into
8
- // a command string. The host only ever runs the configured runtime binary.
4
+ // SECURITY: commands are invoked with an argv ARRAY (never a concatenated command
5
+ // string). Placeholders in args_template ({prompt}, {sessionId}, {systemPrompt}) are
6
+ // substituted PER ARRAY ELEMENT each becomes exactly one argv token. On POSIX we
7
+ // always use shell:false so untrusted prompt text can never reach a shell. On Windows,
8
+ // `.cmd`/`.bat` shims (e.g. npm-installed CLIs) MUST go through the shell because Node
9
+ // refuses to spawn them otherwise; Node then applies its hardened cmd.exe escaping to
10
+ // each argv token. For Windows, prefer a runtime that takes the prompt via STDIN
11
+ // (Claude does) or a native `.exe` so untrusted text never enters a Windows argv at
12
+ // all. The host only ever runs the configured runtime binary.
9
13
 
10
14
  const { spawn } = require('child_process');
11
15
  const logger = require('../logger');
@@ -44,11 +48,20 @@ function buildArgv(template, vars) {
44
48
  async function* spawnStreaming({ binary, argv, cwd, env, timeoutMs = 600000, stdinInput = null, parseLine }) {
45
49
  if (!binary) throw new Error('no runtime binary configured');
46
50
 
51
+ // Windows: npm CLIs are `.cmd`/`.bat` shims, and modern Node REFUSES to spawn those
52
+ // with shell:false (EINVAL, post-CVE-2024-27980). Such shims (and bare names that
53
+ // resolve via PATHEXT) must go through the shell; Node then applies its hardened
54
+ // cmd.exe argument escaping. Native `.exe`/`.com` are spawned directly (no shell).
55
+ // On POSIX we ALWAYS use shell:false (untrusted prompt can never reach a shell).
56
+ const isWin = process.platform === 'win32';
57
+ const useShell = isWin && !/\.(exe|com)$/i.test(String(binary));
58
+
47
59
  const child = spawn(binary, argv, {
48
60
  cwd: cwd || undefined,
49
61
  // Enriched PATH so a CLI in ~/.local/bin etc. resolves under a service's minimal env.
50
62
  env: enrichedEnv(env),
51
- shell: false, // CRITICAL: never use a shell
63
+ shell: useShell, // false on POSIX (no shell, ever); on Windows true only for .cmd/.bat shims
64
+ windowsHide: true, // no flashing console window on Windows
52
65
  stdio: ['pipe', 'pipe', 'pipe'],
53
66
  });
54
67
 
@@ -10,10 +10,11 @@ module.exports = makeCliAdapter({
10
10
  streamJson: true,
11
11
  // Prompt via stdin keeps it out of argv/process listings.
12
12
  promptViaStdin: true,
13
- defaultArgs: (_fullPrompt, resumeId) => [
13
+ defaultArgs: (_fullPrompt, resumeId, _stdin, perm = []) => [
14
14
  '-p',
15
15
  '--output-format', 'stream-json',
16
16
  '--verbose',
17
+ ...perm,
17
18
  ...(resumeId ? ['--resume', resumeId] : []),
18
19
  ],
19
20
  // Admin "configure" turns may write workspace files (auto-accept edits, no shell
@@ -78,12 +78,14 @@ function makeCliAdapter(cfg) {
78
78
 
79
79
  // Scope-based permissions: admin "configure" turns may write workspace files
80
80
  // (GUARDRAILS/SOUL/etc.); customer/discovery turns are read-only and cannot
81
- // edit or run shell. Operator args_template overrides take full control.
81
+ // edit or run shell. permArgs is handed to defaultArgs so each adapter can place
82
+ // it correctly (e.g. Codex must put flags BEFORE its `--` end-of-options).
83
+ // Operator args_template overrides take full control.
82
84
  const scope = job.scope || 'customer';
83
85
  const permArgs = (!Array.isArray(agent.args_template) && cfg.permissions) ? cfg.permissions(scope) : [];
84
86
  const argvFor = (rid) => Array.isArray(agent.args_template)
85
87
  ? buildArgv(agent.args_template, { prompt: fullPrompt, sessionId: rid || '', systemPrompt })
86
- : [...cfg.defaultArgs(fullPrompt, rid, cfg.promptViaStdin), ...permArgs];
88
+ : cfg.defaultArgs(fullPrompt, rid, cfg.promptViaStdin, permArgs);
87
89
 
88
90
  let emittedAny = false;
89
91
  const runOnce = async function* (rid) {
@@ -7,7 +7,16 @@ module.exports = makeCliAdapter({
7
7
  name: 'codex',
8
8
  binaries: ['codex'],
9
9
  promptViaStdin: false,
10
- // `--` ends option parsing so an untrusted prompt starting with '-' is treated
11
- // as a positional argument, never as a flag (argument-injection hardening).
12
- defaultArgs: (fullPrompt) => ['exec', '--', fullPrompt],
10
+ // Flags go BEFORE `--`; `--` ends option parsing so an untrusted prompt starting
11
+ // with '-' is treated as a positional argument, never as a flag (arg-injection
12
+ // hardening). `--ask-for-approval never` ensures it never blocks waiting for an
13
+ // interactive approval in headless mode.
14
+ defaultArgs: (fullPrompt, _resumeId, _stdin, perm = []) => ['exec', ...perm, '--', fullPrompt],
15
+ // Admin "configure" → workspace-write sandbox (may write files in its work dir);
16
+ // customer/discovery → read-only sandbox (cannot modify anything). Never the
17
+ // danger/full-access sandbox.
18
+ permissions: (scope) =>
19
+ (scope === 'configure' || scope === 'sub_configure')
20
+ ? ['--sandbox', 'workspace-write', '--ask-for-approval', 'never']
21
+ : ['--sandbox', 'read-only', '--ask-for-approval', 'never'],
13
22
  });
package/src/machine.js CHANGED
@@ -1,29 +1,58 @@
1
1
  'use strict';
2
- // Stable machine fingerprint for host binding. Derived from the host's physical
3
- // MAC address(es) + hostname, then hashed we send only the HASH to ClawLink, so
4
- // raw MACs never leave the machine. Supabase binds this on first connect; after
5
- // that, only this machine may act for the agent (a stolen token from elsewhere
6
- // fails the binding check).
2
+ // Stable machine fingerprint for host binding. We bind an agent's token to ONE
3
+ // machine so a stolen token can't be used elsewhere. The fingerprint MUST be
4
+ // stable across reboots and network changes earlier versions hashed MAC
5
+ // addresses, which rotate (Tailscale/Docker/VPN/private-WiFi) and caused false
6
+ // "host binding mismatch" errors. We now use the OS hardware machine id, with a
7
+ // persisted random id as a fallback, and send only the HASH.
7
8
 
8
9
  const os = require('os');
10
+ const fs = require('fs');
11
+ const path = require('path');
9
12
  const crypto = require('crypto');
13
+ const { execSync } = require('child_process');
14
+ const { enrichedEnv } = require('./paths');
10
15
 
11
- function macAddresses() {
12
- const macs = [];
13
- const ifaces = os.networkInterfaces();
14
- for (const name of Object.keys(ifaces)) {
15
- for (const ni of ifaces[name] || []) {
16
- if (ni.internal) continue;
17
- if (!ni.mac || ni.mac === '00:00:00:00:00:00') continue;
18
- macs.push(ni.mac.toLowerCase());
16
+ const HOME_DIR = path.join(os.homedir(), '.clawlink-host');
17
+
18
+ function execSafe(cmd) {
19
+ try { return execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000, env: enrichedEnv() }).toString(); }
20
+ catch { return ''; }
21
+ }
22
+
23
+ // Stable, hardware-tied id where available.
24
+ function hardwareId() {
25
+ try {
26
+ if (process.platform === 'darwin') {
27
+ const m = execSafe('ioreg -rd1 -c IOPlatformExpertDevice').match(/IOPlatformUUID"\s*=\s*"([^"]+)"/);
28
+ if (m) return 'hw:' + m[1];
29
+ } else if (process.platform === 'linux') {
30
+ for (const p of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
31
+ try { const id = fs.readFileSync(p, 'utf8').trim(); if (id) return 'hw:' + id; } catch { /* next */ }
32
+ }
33
+ } else if (process.platform === 'win32') {
34
+ const m = execSafe('reg query "HKLM\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid').match(/MachineGuid\s+REG_SZ\s+([\w-]+)/i);
35
+ if (m) return 'hw:' + m[1];
19
36
  }
20
- }
21
- return [...new Set(macs)].sort();
37
+ } catch { /* fall through */ }
38
+ return null;
39
+ }
40
+
41
+ // Stable random id persisted under the home dir (fallback when no hardware id).
42
+ function persistedId() {
43
+ const idPath = path.join(HOME_DIR, 'machine-id');
44
+ try { const id = fs.readFileSync(idPath, 'utf8').trim(); if (id) return 'id:' + id; } catch { /* create below */ }
45
+ try {
46
+ fs.mkdirSync(HOME_DIR, { recursive: true });
47
+ const id = crypto.randomBytes(32).toString('hex');
48
+ fs.writeFileSync(idPath, id, { mode: 0o600 });
49
+ try { fs.chmodSync(idPath, 0o600); } catch { /* best effort */ }
50
+ return 'id:' + id;
51
+ } catch { return null; }
22
52
  }
23
53
 
24
54
  function machineId() {
25
- const macs = macAddresses();
26
- const basis = (macs.length ? macs.join(',') : 'no-mac') + '|' + os.hostname();
55
+ const basis = hardwareId() || persistedId() || ('host:' + os.hostname());
27
56
  return crypto.createHash('sha256').update(basis).digest('hex');
28
57
  }
29
58
 
package/src/paths.js CHANGED
@@ -10,17 +10,28 @@ const path = require('path');
10
10
 
11
11
  function enrichedPath() {
12
12
  const home = os.homedir();
13
- const extras = [
14
- path.dirname(process.execPath), // dir of the running node (npm-global CLIs)
15
- path.join(home, '.local', 'bin'),
16
- path.join(home, 'bin'),
17
- path.join(home, '.npm-global', 'bin'),
18
- path.join(home, '.bun', 'bin'),
19
- path.join(home, '.cargo', 'bin'),
20
- '/opt/homebrew/bin',
21
- '/usr/local/bin',
22
- '/usr/bin', '/bin', '/usr/sbin', '/sbin',
23
- ];
13
+ const extras = [path.dirname(process.execPath)]; // dir of the running node (npm-global CLIs)
14
+ if (process.platform === 'win32') {
15
+ const { APPDATA, LOCALAPPDATA, ProgramFiles, SystemRoot } = process.env;
16
+ if (APPDATA) extras.push(path.join(APPDATA, 'npm')); // npm global shims
17
+ if (LOCALAPPDATA) {
18
+ extras.push(path.join(LOCALAPPDATA, 'Programs')); // per-user installers (e.g. claude)
19
+ extras.push(path.join(LOCALAPPDATA, 'Microsoft', 'WindowsApps'));
20
+ }
21
+ if (ProgramFiles) extras.push(path.join(ProgramFiles, 'nodejs'));
22
+ if (SystemRoot) { extras.push(path.join(SystemRoot, 'System32')); extras.push(SystemRoot); }
23
+ } else {
24
+ extras.push(
25
+ path.join(home, '.local', 'bin'),
26
+ path.join(home, 'bin'),
27
+ path.join(home, '.npm-global', 'bin'),
28
+ path.join(home, '.bun', 'bin'),
29
+ path.join(home, '.cargo', 'bin'),
30
+ '/opt/homebrew/bin',
31
+ '/usr/local/bin',
32
+ '/usr/bin', '/bin', '/usr/sbin', '/sbin',
33
+ );
34
+ }
24
35
  const seen = new Set();
25
36
  const parts = [...extras, ...String(process.env.PATH || '').split(path.delimiter)]
26
37
  .filter((p) => p && !seen.has(p) && seen.add(p));