@claw-link/gateway-host 0.2.4 → 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 +16 -8
- package/SECURITY.md +16 -9
- package/package.json +1 -1
- package/scripts/install.js +48 -3
- package/src/adapters/base.js +19 -6
- package/src/adapters/claude.js +2 -1
- package/src/adapters/cli.js +4 -2
- package/src/adapters/codex.js +12 -3
- package/src/paths.js +22 -11
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
|
|
68
|
-
|
|
69
|
-
| Admin **Setup via Chat** / apply skill | `configure` | `--permission-mode acceptEdits`
|
|
70
|
-
| Customer chat + discovery | `customer` | `--disallowedTools "Write Edit MultiEdit NotebookEdit Bash"`
|
|
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
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
25
|
-
argvArray, { shell: false })
|
|
26
|
-
token or via stdin —
|
|
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
|
+
"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": {
|
package/scripts/install.js
CHANGED
|
@@ -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
|
-
|
|
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') {
|
package/src/adapters/base.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
// Shared spawn helper for CLI-wrapping adapters.
|
|
3
3
|
//
|
|
4
|
-
// SECURITY: commands are
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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,
|
|
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
|
|
package/src/adapters/claude.js
CHANGED
|
@@ -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
|
package/src/adapters/cli.js
CHANGED
|
@@ -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.
|
|
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
|
-
:
|
|
88
|
+
: cfg.defaultArgs(fullPrompt, rid, cfg.promptViaStdin, permArgs);
|
|
87
89
|
|
|
88
90
|
let emittedAny = false;
|
|
89
91
|
const runOnce = async function* (rid) {
|
package/src/adapters/codex.js
CHANGED
|
@@ -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
|
|
11
|
-
// as a positional argument, never as a flag (
|
|
12
|
-
|
|
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/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
|
-
|
|
15
|
-
|
|
16
|
-
path.join(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
'
|
|
22
|
-
|
|
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));
|