@kortix/agent-tunnel 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kortix/agent-tunnel",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Tunnel relay between cloud AI agents and local machines — server relay, local agent, client SDK, JSON-RPC, HMAC signing",
@@ -11,10 +11,12 @@
11
11
  "./shared": "./src/shared/index.ts",
12
12
  "./server": "./src/server/index.ts",
13
13
  "./client": "./src/client/index.ts",
14
+ "./client/cli": "./src/client/cli.ts",
14
15
  "./agent": "./src/agent/index.ts"
15
16
  },
16
17
  "bin": {
17
- "agent-tunnel": "src/agent/cli.ts"
18
+ "agent-tunnel": "src/agent/cli.ts",
19
+ "agent-tunnel-cli": "src/client/cli.ts"
18
20
  },
19
21
  "files": [
20
22
  "src/"
@@ -3,7 +3,7 @@ import type { TunnelConfig } from './config';
3
3
  import { CapabilityRegistry, type RpcHandler } from './capabilities/index';
4
4
  import { PermissionGuard } from './security/permission-guard';
5
5
  import type { LocalPermission } from './security/permission-guard';
6
- import { deriveSigningKey, verifyMessageSignature } from '../shared/crypto';
6
+ import { deriveSigningKey, signMessage, verifyMessageSignature } from '../shared/crypto';
7
7
 
8
8
  interface JsonRpcRequest {
9
9
  jsonrpc: '2.0';
@@ -56,6 +56,7 @@ export class TunnelAgent {
56
56
  // HMAC signature verification
57
57
  private signingKey: string;
58
58
  private lastNonce = 0;
59
+ private responseNonce = 0;
59
60
 
60
61
  constructor(config: TunnelConfig, registry: CapabilityRegistry) {
61
62
  this.config = config;
@@ -113,9 +114,13 @@ export class TunnelAgent {
113
114
  this.ws.addEventListener('open', () => {
114
115
  this.reconnectAttempts = 0;
115
116
  this.uptime = 0;
116
- this.lastNonce = 0; // Reset nonce on each new connection
117
+ this.lastNonce = 0;
118
+ this.responseNonce = 0;
117
119
  this.uptimeInterval = setInterval(() => { this.uptime++; }, 1000);
118
120
 
121
+ // Send auth handshake as first message (token never in URL)
122
+ this.send({ type: 'auth', token: this.config.token });
123
+
119
124
  log(`${c.green}●${c.reset}`, `Connected ${c.reset}${c.gray}(${this.registry.getCapabilityNames().join(', ')})${c.reset}`);
120
125
  });
121
126
 
@@ -130,6 +135,10 @@ export class TunnelAgent {
130
135
  }
131
136
 
132
137
  if (!this.isShuttingDown) {
138
+ if (event.code === 4001) {
139
+ log(`${c.red}✗${c.reset}`, `Authentication failed — check your token`);
140
+ return; // Don't reconnect on auth failure
141
+ }
133
142
  log(`${c.yellow}○${c.reset}`, `Disconnected ${c.gray}(code: ${event.code})${c.reset}`);
134
143
  this.scheduleReconnect();
135
144
  }
@@ -149,20 +158,19 @@ export class TunnelAgent {
149
158
  return;
150
159
  }
151
160
 
152
- // ── Heartbeat ping — no signature required ──────────────────────
153
- if ('method' in msg && msg.method === 'tunnel.ping') {
154
- this.sendPong();
155
- return;
156
- }
157
-
158
- // ── Verify HMAC signature on all other messages ─────────────────
159
161
  if (!this.verifyIncomingSignature(msg, raw)) {
160
162
  if ('id' in msg && msg.id) {
161
- this.sendError(msg.id, -32000, 'Invalid message signature');
163
+ this.sendSignedError(msg.id, -32000, 'Invalid message signature');
162
164
  }
163
165
  return;
164
166
  }
165
167
 
168
+ // ── Heartbeat ping (signature verified above) ────────────────────
169
+ if ('method' in msg && msg.method === 'tunnel.ping') {
170
+ this.sendPong();
171
+ return;
172
+ }
173
+
166
174
  // ── Permission sync notification ────────────────────────────────
167
175
  if ('method' in msg && msg.method === 'tunnel.permissions.sync') {
168
176
  const permissions = (msg.params?.permissions || []) as LocalPermission[];
@@ -194,8 +202,6 @@ export class TunnelAgent {
194
202
  // ── Token rotation notification ─────────────────────────────────
195
203
  if ('method' in msg && msg.method === 'tunnel.token.rotated') {
196
204
  log(`${c.yellow}!${c.reset}`, `Token rotated — reconnecting with new token`);
197
- // The server will close the WS shortly; the reconnect logic handles the rest.
198
- // Caller must update config.token before reconnect succeeds.
199
205
  return;
200
206
  }
201
207
 
@@ -208,7 +214,6 @@ export class TunnelAgent {
208
214
 
209
215
  /**
210
216
  * Verify HMAC signature on incoming messages (excluding pings).
211
- * Returns true if valid, false if signature check fails.
212
217
  */
213
218
  private verifyIncomingSignature(msg: IncomingMessage, _raw: string): boolean {
214
219
  const sig = (msg as any)._sig as string | undefined;
@@ -219,13 +224,11 @@ export class TunnelAgent {
219
224
  return false;
220
225
  }
221
226
 
222
- // Replay protection: nonce must be strictly increasing
223
227
  if (nonce <= this.lastNonce) {
224
228
  log(`${c.red}✗${c.reset}`, `Replay detected: nonce ${nonce} <= ${this.lastNonce}`);
225
229
  return false;
226
230
  }
227
231
 
228
- // Build the payload to verify (message without _sig and _nonce)
229
232
  const { _sig, _nonce, ...payloadObj } = msg as any;
230
233
  const payload = JSON.stringify(payloadObj);
231
234
 
@@ -241,38 +244,65 @@ export class TunnelAgent {
241
244
  private async handleRpcRequest(request: JsonRpcRequest): Promise<void> {
242
245
  const { id, method, params = {} } = request;
243
246
 
244
- // ── Permission enforcement (defense-in-depth) ───────────────────
245
247
  const permissionId = params.permissionId as string | undefined;
246
248
  if (!this.permissionGuard.checkPermission(permissionId)) {
247
- this.sendError(id, -32000, `Permission denied: ${permissionId ? 'invalid or expired permission' : 'no permissionId provided'}`);
249
+ this.sendSignedError(id, -32000, `Permission denied: ${permissionId ? 'invalid or expired permission' : 'no permissionId provided'}`);
248
250
  return;
249
251
  }
250
252
 
251
253
  const handler = this.registry.getHandler(method);
252
254
  if (!handler) {
253
- this.sendError(id, -32001, `Capability not registered for method: ${method}`);
255
+ this.sendSignedError(id, -32001, `Capability not registered for method: ${method}`);
254
256
  return;
255
257
  }
256
258
 
257
259
  try {
258
260
  const result = await handler(params);
259
- this.sendResult(id, result);
261
+ this.sendSignedResult(id, result);
260
262
  } catch (err) {
261
263
  const message = err instanceof Error ? err.message : String(err);
262
- this.sendError(id, -32003, message);
264
+ this.sendSignedError(id, -32003, message);
263
265
  }
264
266
  }
265
267
 
266
- private sendResult(id: string, result: unknown): void {
267
- this.send({ jsonrpc: '2.0', id, result });
268
+ /** Send HMAC-signed RPC result. */
269
+ private sendSignedResult(id: string, result: unknown): void {
270
+ const data = { jsonrpc: '2.0' as const, id, result };
271
+ this.sendSigned(data);
268
272
  }
269
273
 
270
- private sendError(id: string, code: number, message: string): void {
271
- this.send({ jsonrpc: '2.0', id, error: { code, message } });
274
+ /** Send HMAC-signed RPC error. */
275
+ private sendSignedError(id: string, code: number, message: string): void {
276
+ const data = { jsonrpc: '2.0' as const, id, error: { code, message } };
277
+ this.sendSigned(data);
278
+ }
279
+
280
+ private sendSigned(data: Record<string, unknown>): void {
281
+ if (this.ws?.readyState === WebSocket.OPEN) {
282
+ const nonce = ++this.responseNonce;
283
+ const payload = JSON.stringify(data);
284
+ const sig = signMessage(this.signingKey, payload, nonce);
285
+ const signed = { ...data, _sig: sig, _nonce: nonce };
286
+ try {
287
+ this.ws.send(JSON.stringify(signed));
288
+ } catch (err) {
289
+ log(`${c.red}✗${c.reset}`, `Send failed`);
290
+ }
291
+ }
292
+ }
293
+
294
+ private send(data: unknown): void {
295
+ if (this.ws?.readyState === WebSocket.OPEN) {
296
+ try {
297
+ this.ws.send(JSON.stringify(data));
298
+ } catch (err) {
299
+ log(`${c.red}✗${c.reset}`, `Send failed`);
300
+ }
301
+ }
272
302
  }
273
303
 
274
304
  private sendPong(): void {
275
- this.send({
305
+ this.sendSigned({
276
306
  jsonrpc: '2.0',
277
307
  method: 'tunnel.pong',
278
308
  params: {
@@ -283,22 +313,12 @@ export class TunnelAgent {
283
313
  platform: platform(),
284
314
  arch: arch(),
285
315
  osVersion: release(),
286
- agentVersion: '0.1.0',
316
+ agentVersion: '0.1.1',
287
317
  },
288
318
  },
289
319
  });
290
320
  }
291
321
 
292
- private send(data: unknown): void {
293
- if (this.ws?.readyState === WebSocket.OPEN) {
294
- try {
295
- this.ws.send(JSON.stringify(data));
296
- } catch (err) {
297
- log(`${c.red}✗${c.reset}`, `Send failed`);
298
- }
299
- }
300
- }
301
-
302
322
  private scheduleReconnect(): void {
303
323
  if (this.isShuttingDown) return;
304
324
 
@@ -320,9 +340,12 @@ export class TunnelAgent {
320
340
  .replace(/^http:/, 'ws:')
321
341
  .replace(/^https:/, 'wss:');
322
342
 
343
+ if (base.startsWith('ws://') && !base.includes('localhost') && !base.includes('127.0.0.1')) {
344
+ log(`${c.red}!${c.reset}`, `${c.red}WARNING: Connecting over unencrypted ws:// to a remote host. Token will be sent in plaintext. Use https:// API URL for production.${c.reset}`);
345
+ }
346
+
323
347
  const wsPath = this.config.wsPath || '/ws';
324
348
  const params = new URLSearchParams({
325
- token: this.config.token,
326
349
  tunnelId: this.config.tunnelId,
327
350
  });
328
351
 
@@ -18,15 +18,15 @@ export function createFilesystemCapability(config: TunnelConfig): Capability {
18
18
  const path = params.path as string;
19
19
  const encoding = (params.encoding as BufferEncoding) || 'utf-8';
20
20
 
21
- validatePath(path, config.allowedPaths);
21
+ validatePath(path, config.allowedPaths, config.blockedPaths);
22
22
 
23
- const content = await readFile(path, { encoding });
24
23
  const stats = await stat(path);
25
-
26
24
  if (stats.size > config.maxFileSize) {
27
25
  throw new Error(`File exceeds max size (${stats.size} > ${config.maxFileSize})`);
28
26
  }
29
27
 
28
+ const content = await readFile(path, { encoding });
29
+
30
30
  return {
31
31
  content,
32
32
  size: stats.size,
@@ -40,7 +40,7 @@ export function createFilesystemCapability(config: TunnelConfig): Capability {
40
40
  const content = params.content as string;
41
41
  const encoding = (params.encoding as BufferEncoding) || 'utf-8';
42
42
 
43
- validatePath(path, config.allowedPaths);
43
+ validatePath(path, config.allowedPaths, config.blockedPaths);
44
44
 
45
45
  if (content.length > config.maxFileSize) {
46
46
  throw new Error(`Content exceeds max size (${content.length} > ${config.maxFileSize})`);
@@ -62,7 +62,7 @@ export function createFilesystemCapability(config: TunnelConfig): Capability {
62
62
  const path = params.path as string;
63
63
  const recursive = params.recursive as boolean || false;
64
64
 
65
- validatePath(path, config.allowedPaths);
65
+ validatePath(path, config.allowedPaths, config.blockedPaths);
66
66
 
67
67
  const entries = await readdir(path, { withFileTypes: true });
68
68
 
@@ -100,7 +100,7 @@ export function createFilesystemCapability(config: TunnelConfig): Capability {
100
100
  methods.set('fs.stat', async (params) => {
101
101
  const path = params.path as string;
102
102
 
103
- validatePath(path, config.allowedPaths);
103
+ validatePath(path, config.allowedPaths, config.blockedPaths);
104
104
 
105
105
  const stats = await stat(path);
106
106
 
@@ -119,7 +119,7 @@ export function createFilesystemCapability(config: TunnelConfig): Capability {
119
119
  methods.set('fs.delete', async (params) => {
120
120
  const path = params.path as string;
121
121
 
122
- validatePath(path, config.allowedPaths);
122
+ validatePath(path, config.allowedPaths, config.blockedPaths);
123
123
 
124
124
  await unlink(path);
125
125
 
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Security:
5
5
  * - Commands are executed as array args (no shell interpolation)
6
- * - First arg (executable) is validated against allowedCommands
7
- * - Working directory is validated against allowedPaths
6
+ * - First arg (executable) is validated against allowedCommands / blockedCommands
7
+ * - Working directory is validated against allowedPaths / blockedPaths
8
8
  * - Timeout enforcement
9
9
  */
10
10
 
@@ -14,9 +14,6 @@ import { validateCommand } from '../security/command-validator';
14
14
  import { validatePath } from '../security/path-validator';
15
15
  import type { TunnelConfig } from '../config';
16
16
 
17
- const DEFAULT_TIMEOUT_MS = 30_000;
18
- const MAX_OUTPUT_SIZE = 1024 * 1024;
19
-
20
17
  export function createShellCapability(config: TunnelConfig): Capability {
21
18
  const methods = new Map<string, RpcHandler>();
22
19
 
@@ -25,19 +22,18 @@ export function createShellCapability(config: TunnelConfig): Capability {
25
22
  const args = (params.args as string[]) || [];
26
23
  const cwd = (params.cwd as string) || config.workingDir;
27
24
  const timeout = Math.min(
28
- (params.timeout as number) || DEFAULT_TIMEOUT_MS,
29
- 120_000,
25
+ (params.timeout as number) || config.shellTimeout,
26
+ config.shellMaxTimeout,
30
27
  );
31
28
 
32
- validateCommand(command, config.allowedCommands);
29
+ validateCommand(command, config.allowedCommands, config.blockedCommands);
33
30
 
34
31
  if (cwd) {
35
- validatePath(cwd, config.allowedPaths);
32
+ validatePath(cwd, config.allowedPaths, config.blockedPaths);
36
33
  }
37
34
 
38
- const SAFE_ENV_KEYS = ['PATH', 'HOME', 'USER', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'NODE_ENV', 'HOSTNAME'];
39
35
  const safeEnv: Record<string, string> = { TERM: 'dumb' };
40
- for (const key of SAFE_ENV_KEYS) {
36
+ for (const key of config.shellEnvPassthrough) {
41
37
  if (process.env[key]) {
42
38
  safeEnv[key] = process.env[key]!;
43
39
  }
@@ -57,18 +53,32 @@ export function createShellCapability(config: TunnelConfig): Capability {
57
53
  let stderrTruncated = false;
58
54
 
59
55
  proc.stdout?.on('data', (data: Buffer) => {
60
- if (stdout.length < MAX_OUTPUT_SIZE) {
61
- stdout += data.toString();
62
- } else {
56
+ if (stdout.length >= config.shellMaxOutputSize) {
63
57
  stdoutTruncated = true;
58
+ return;
59
+ }
60
+ const chunk = data.toString();
61
+ const remaining = config.shellMaxOutputSize - stdout.length;
62
+ if (chunk.length > remaining) {
63
+ stdout += chunk.slice(0, remaining);
64
+ stdoutTruncated = true;
65
+ } else {
66
+ stdout += chunk;
64
67
  }
65
68
  });
66
69
 
67
70
  proc.stderr?.on('data', (data: Buffer) => {
68
- if (stderr.length < MAX_OUTPUT_SIZE) {
69
- stderr += data.toString();
70
- } else {
71
+ if (stderr.length >= config.shellMaxOutputSize) {
72
+ stderrTruncated = true;
73
+ return;
74
+ }
75
+ const chunk = data.toString();
76
+ const remaining = config.shellMaxOutputSize - stderr.length;
77
+ if (chunk.length > remaining) {
78
+ stderr += chunk.slice(0, remaining);
71
79
  stderrTruncated = true;
80
+ } else {
81
+ stderr += chunk;
72
82
  }
73
83
  });
74
84
 
@@ -80,8 +90,8 @@ export function createShellCapability(config: TunnelConfig): Capability {
80
90
  resolve({
81
91
  exitCode: code,
82
92
  signal,
83
- stdout: stdout.slice(0, MAX_OUTPUT_SIZE),
84
- stderr: stderr.slice(0, MAX_OUTPUT_SIZE),
93
+ stdout,
94
+ stderr,
85
95
  stdoutTruncated,
86
96
  stderrTruncated,
87
97
  });
@@ -6,12 +6,17 @@ export interface TunnelConfig {
6
6
  token: string;
7
7
  tunnelId: string;
8
8
  apiUrl: string;
9
- /** WS path on the server (default: '/ws'). Override for custom server mounts. */
10
9
  wsPath: string;
11
10
  maxFileSize: number;
12
11
  allowedPaths: string[];
13
12
  allowedCommands: string[];
13
+ blockedCommands: string[];
14
+ blockedPaths: string[];
14
15
  workingDir: string;
16
+ shellTimeout: number;
17
+ shellMaxTimeout: number;
18
+ shellMaxOutputSize: number;
19
+ shellEnvPassthrough: string[];
15
20
  }
16
21
 
17
22
  const CONFIG_DIR = join(homedir(), '.agent-tunnel');
@@ -23,7 +28,22 @@ const DEFAULTS: Partial<TunnelConfig> = {
23
28
  maxFileSize: 10 * 1024 * 1024,
24
29
  allowedPaths: [homedir()],
25
30
  allowedCommands: [],
31
+ blockedCommands: [],
32
+ blockedPaths: [
33
+ '/etc/shadow',
34
+ '/etc/passwd',
35
+ '/etc/sudoers',
36
+ '/etc/ssh',
37
+ '/root/.ssh',
38
+ '/proc',
39
+ '/sys',
40
+ '/dev',
41
+ ],
26
42
  workingDir: homedir(),
43
+ shellTimeout: 30_000,
44
+ shellMaxTimeout: 120_000,
45
+ shellMaxOutputSize: 1024 * 1024,
46
+ shellEnvPassthrough: ['PATH', 'HOME', 'USER', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'NODE_ENV', 'HOSTNAME'],
27
47
  };
28
48
 
29
49
  export function loadConfig(overrides: Partial<TunnelConfig> = {}): TunnelConfig {
@@ -1,42 +1,10 @@
1
- /**
2
- * Command Validator — defense-in-depth command injection prevention.
3
- *
4
- * Validates that shell commands:
5
- * 1. Are in the allowed commands list (if configured)
6
- * 2. Don't contain shell metacharacters
7
- * 3. Don't execute dangerous system commands
8
- */
9
-
10
- /** Commands that should never be executed via tunnel regardless of config. */
11
- const BLOCKED_COMMANDS = new Set([
12
- 'rm',
13
- 'rmdir',
14
- 'mkfs',
15
- 'dd',
16
- 'shutdown',
17
- 'reboot',
18
- 'halt',
19
- 'poweroff',
20
- 'init',
21
- 'systemctl',
22
- 'sudo',
23
- 'su',
24
- 'passwd',
25
- 'chown',
26
- 'chmod',
27
- 'chgrp',
28
- 'mount',
29
- 'umount',
30
- 'fdisk',
31
- 'parted',
32
- 'iptables',
33
- 'ufw',
34
- 'firewall-cmd',
35
- ]);
36
-
37
1
  const SHELL_METACHAR_REGEX = /[;&|`$(){}[\]<>!#~]/;
38
2
 
39
- export function validateCommand(command: string, allowedCommands: string[]): void {
3
+ export function validateCommand(
4
+ command: string,
5
+ allowedCommands: string[],
6
+ blockedCommands: string[],
7
+ ): void {
40
8
  if (!command || typeof command !== 'string') {
41
9
  throw new Error('Command is required');
42
10
  }
@@ -49,8 +17,8 @@ export function validateCommand(command: string, allowedCommands: string[]): voi
49
17
 
50
18
  const executable = trimmed.split(/\s+/)[0];
51
19
 
52
- if (BLOCKED_COMMANDS.has(executable)) {
53
- throw new Error(`Command "${executable}" is blocked for security reasons`);
20
+ if (blockedCommands.length > 0 && blockedCommands.includes(executable)) {
21
+ throw new Error(`Command "${executable}" is blocked`);
54
22
  }
55
23
 
56
24
  if (allowedCommands.length > 0) {
@@ -5,25 +5,17 @@
5
5
  * 1. Are absolute
6
6
  * 2. Resolve to an absolute path (follows symlinks)
7
7
  * 3. Fall within allowed directories
8
- * 4. Don't hit sensitive system paths
8
+ * 4. Don't hit blocked paths (configurable)
9
9
  */
10
10
 
11
11
  import { resolve, normalize } from 'path';
12
12
  import { realpathSync } from 'fs';
13
13
 
14
- const SENSITIVE_PATHS = [
15
- '/etc/shadow',
16
- '/etc/passwd',
17
- '/etc/sudoers',
18
- '/etc/ssh',
19
- '/root/.ssh',
20
- '/proc',
21
- '/sys',
22
- '/dev',
23
- ];
24
-
25
-
26
- export function validatePath(path: string, allowedPaths: string[]): void {
14
+ export function validatePath(
15
+ path: string,
16
+ allowedPaths: string[],
17
+ blockedPaths: string[] = [],
18
+ ): void {
27
19
  if (!path) {
28
20
  throw new Error('Path is required');
29
21
  }
@@ -32,13 +24,18 @@ export function validatePath(path: string, allowedPaths: string[]): void {
32
24
  let resolved: string;
33
25
  try {
34
26
  resolved = realpathSync(normalized);
35
- } catch {
36
- resolved = normalized;
27
+ } catch (err) {
28
+ const code = (err as NodeJS.ErrnoException).code;
29
+ if (code === 'ENOENT') {
30
+ resolved = normalized;
31
+ } else {
32
+ throw new Error(`Access denied: cannot resolve path "${path}" (${code})`);
33
+ }
37
34
  }
38
35
 
39
- for (const sensitive of SENSITIVE_PATHS) {
40
- if (resolved === sensitive || resolved.startsWith(sensitive + '/')) {
41
- throw new Error(`Access denied: sensitive system path "${path}"`);
36
+ for (const blocked of blockedPaths) {
37
+ if (resolved === blocked || resolved.startsWith(blocked + '/')) {
38
+ throw new Error(`Access denied: blocked path "${path}"`);
42
39
  }
43
40
  }
44
41
 
@@ -50,7 +50,8 @@ export class PermissionGuard {
50
50
  }
51
51
 
52
52
  if (perm.expiresAt) {
53
- if (new Date(perm.expiresAt) < new Date()) {
53
+ const expiry = new Date(perm.expiresAt).getTime();
54
+ if (isNaN(expiry) || expiry < Date.now()) {
54
55
  this.permissions.delete(permissionId);
55
56
  return false;
56
57
  }