@kortix/agent-tunnel 0.1.0 → 0.1.2

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.2",
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 { signMessage, verifyMessageSignature } from '../shared/crypto';
7
7
 
8
8
  interface JsonRpcRequest {
9
9
  jsonrpc: '2.0';
@@ -54,14 +54,14 @@ export class TunnelAgent {
54
54
  private uptimeInterval: ReturnType<typeof setInterval> | null = null;
55
55
 
56
56
  // HMAC signature verification
57
- private signingKey: string;
57
+ private signingKey: string | null = null;
58
58
  private lastNonce = 0;
59
+ private responseNonce = 0;
59
60
 
60
61
  constructor(config: TunnelConfig, registry: CapabilityRegistry) {
61
62
  this.config = config;
62
63
  this.registry = registry;
63
64
  this.permissionGuard = new PermissionGuard();
64
- this.signingKey = deriveSigningKey(config.token);
65
65
  }
66
66
 
67
67
  connect(): void {
@@ -113,10 +113,13 @@ export class TunnelAgent {
113
113
  this.ws.addEventListener('open', () => {
114
114
  this.reconnectAttempts = 0;
115
115
  this.uptime = 0;
116
- this.lastNonce = 0; // Reset nonce on each new connection
116
+ this.lastNonce = 0;
117
+ this.responseNonce = 0;
118
+ this.signingKey = null;
117
119
  this.uptimeInterval = setInterval(() => { this.uptime++; }, 1000);
118
120
 
119
- log(`${c.green}●${c.reset}`, `Connected ${c.reset}${c.gray}(${this.registry.getCapabilityNames().join(', ')})${c.reset}`);
121
+ // Send auth handshake as first message (token never in URL)
122
+ this.send({ type: 'auth', token: this.config.token });
120
123
  });
121
124
 
122
125
  this.ws.addEventListener('message', (event) => {
@@ -130,6 +133,10 @@ export class TunnelAgent {
130
133
  }
131
134
 
132
135
  if (!this.isShuttingDown) {
136
+ if (event.code === 4001) {
137
+ log(`${c.red}✗${c.reset}`, `Authentication failed — check your token`);
138
+ return; // Don't reconnect on auth failure
139
+ }
133
140
  log(`${c.yellow}○${c.reset}`, `Disconnected ${c.gray}(code: ${event.code})${c.reset}`);
134
141
  this.scheduleReconnect();
135
142
  }
@@ -141,7 +148,7 @@ export class TunnelAgent {
141
148
  }
142
149
 
143
150
  private async handleMessage(raw: string): Promise<void> {
144
- let msg: IncomingMessage;
151
+ let msg: any;
145
152
  try {
146
153
  msg = JSON.parse(raw);
147
154
  } catch {
@@ -149,20 +156,31 @@ export class TunnelAgent {
149
156
  return;
150
157
  }
151
158
 
152
- // ── Heartbeat ping no signature required ──────────────────────
153
- if ('method' in msg && msg.method === 'tunnel.ping') {
154
- this.sendPong();
159
+ // Handle auth_okserver sends signing key after successful auth
160
+ if (msg.type === 'auth_ok' && msg.signingKey) {
161
+ this.signingKey = msg.signingKey;
162
+ log(`${c.green}●${c.reset}`, `Connected ${c.reset}${c.gray}(${this.registry.getCapabilityNames().join(', ')})${c.reset}`);
163
+ return;
164
+ }
165
+
166
+ if (!this.signingKey) {
167
+ log(`${c.yellow}!${c.reset}`, `Message received before auth completed`);
155
168
  return;
156
169
  }
157
170
 
158
- // ── Verify HMAC signature on all other messages ─────────────────
159
171
  if (!this.verifyIncomingSignature(msg, raw)) {
160
172
  if ('id' in msg && msg.id) {
161
- this.sendError(msg.id, -32000, 'Invalid message signature');
173
+ this.sendSignedError(msg.id, -32000, 'Invalid message signature');
162
174
  }
163
175
  return;
164
176
  }
165
177
 
178
+ // ── Heartbeat ping (signature verified above) ────────────────────
179
+ if ('method' in msg && msg.method === 'tunnel.ping') {
180
+ this.sendPong();
181
+ return;
182
+ }
183
+
166
184
  // ── Permission sync notification ────────────────────────────────
167
185
  if ('method' in msg && msg.method === 'tunnel.permissions.sync') {
168
186
  const permissions = (msg.params?.permissions || []) as LocalPermission[];
@@ -194,8 +212,6 @@ export class TunnelAgent {
194
212
  // ── Token rotation notification ─────────────────────────────────
195
213
  if ('method' in msg && msg.method === 'tunnel.token.rotated') {
196
214
  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
215
  return;
200
216
  }
201
217
 
@@ -208,7 +224,6 @@ export class TunnelAgent {
208
224
 
209
225
  /**
210
226
  * Verify HMAC signature on incoming messages (excluding pings).
211
- * Returns true if valid, false if signature check fails.
212
227
  */
213
228
  private verifyIncomingSignature(msg: IncomingMessage, _raw: string): boolean {
214
229
  const sig = (msg as any)._sig as string | undefined;
@@ -219,17 +234,15 @@ export class TunnelAgent {
219
234
  return false;
220
235
  }
221
236
 
222
- // Replay protection: nonce must be strictly increasing
223
237
  if (nonce <= this.lastNonce) {
224
238
  log(`${c.red}✗${c.reset}`, `Replay detected: nonce ${nonce} <= ${this.lastNonce}`);
225
239
  return false;
226
240
  }
227
241
 
228
- // Build the payload to verify (message without _sig and _nonce)
229
242
  const { _sig, _nonce, ...payloadObj } = msg as any;
230
243
  const payload = JSON.stringify(payloadObj);
231
244
 
232
- if (!verifyMessageSignature(this.signingKey, payload, nonce, sig)) {
245
+ if (!verifyMessageSignature(this.signingKey!, payload, nonce, sig)) {
233
246
  log(`${c.red}✗${c.reset}`, `Invalid HMAC signature`);
234
247
  return false;
235
248
  }
@@ -241,38 +254,65 @@ export class TunnelAgent {
241
254
  private async handleRpcRequest(request: JsonRpcRequest): Promise<void> {
242
255
  const { id, method, params = {} } = request;
243
256
 
244
- // ── Permission enforcement (defense-in-depth) ───────────────────
245
257
  const permissionId = params.permissionId as string | undefined;
246
258
  if (!this.permissionGuard.checkPermission(permissionId)) {
247
- this.sendError(id, -32000, `Permission denied: ${permissionId ? 'invalid or expired permission' : 'no permissionId provided'}`);
259
+ this.sendSignedError(id, -32000, `Permission denied: ${permissionId ? 'invalid or expired permission' : 'no permissionId provided'}`);
248
260
  return;
249
261
  }
250
262
 
251
263
  const handler = this.registry.getHandler(method);
252
264
  if (!handler) {
253
- this.sendError(id, -32001, `Capability not registered for method: ${method}`);
265
+ this.sendSignedError(id, -32001, `Capability not registered for method: ${method}`);
254
266
  return;
255
267
  }
256
268
 
257
269
  try {
258
270
  const result = await handler(params);
259
- this.sendResult(id, result);
271
+ this.sendSignedResult(id, result);
260
272
  } catch (err) {
261
273
  const message = err instanceof Error ? err.message : String(err);
262
- this.sendError(id, -32003, message);
274
+ this.sendSignedError(id, -32003, message);
263
275
  }
264
276
  }
265
277
 
266
- private sendResult(id: string, result: unknown): void {
267
- this.send({ jsonrpc: '2.0', id, result });
278
+ /** Send HMAC-signed RPC result. */
279
+ private sendSignedResult(id: string, result: unknown): void {
280
+ const data = { jsonrpc: '2.0' as const, id, result };
281
+ this.sendSigned(data);
282
+ }
283
+
284
+ /** Send HMAC-signed RPC error. */
285
+ private sendSignedError(id: string, code: number, message: string): void {
286
+ const data = { jsonrpc: '2.0' as const, id, error: { code, message } };
287
+ this.sendSigned(data);
268
288
  }
269
289
 
270
- private sendError(id: string, code: number, message: string): void {
271
- this.send({ jsonrpc: '2.0', id, error: { code, message } });
290
+ private sendSigned(data: Record<string, unknown>): void {
291
+ if (this.ws?.readyState === WebSocket.OPEN && this.signingKey) {
292
+ const nonce = ++this.responseNonce;
293
+ const payload = JSON.stringify(data);
294
+ const sig = signMessage(this.signingKey, payload, nonce);
295
+ const signed = { ...data, _sig: sig, _nonce: nonce };
296
+ try {
297
+ this.ws.send(JSON.stringify(signed));
298
+ } catch (err) {
299
+ log(`${c.red}✗${c.reset}`, `Send failed`);
300
+ }
301
+ }
302
+ }
303
+
304
+ private send(data: unknown): void {
305
+ if (this.ws?.readyState === WebSocket.OPEN) {
306
+ try {
307
+ this.ws.send(JSON.stringify(data));
308
+ } catch (err) {
309
+ log(`${c.red}✗${c.reset}`, `Send failed`);
310
+ }
311
+ }
272
312
  }
273
313
 
274
314
  private sendPong(): void {
275
- this.send({
315
+ this.sendSigned({
276
316
  jsonrpc: '2.0',
277
317
  method: 'tunnel.pong',
278
318
  params: {
@@ -283,22 +323,12 @@ export class TunnelAgent {
283
323
  platform: platform(),
284
324
  arch: arch(),
285
325
  osVersion: release(),
286
- agentVersion: '0.1.0',
326
+ agentVersion: '0.1.2',
287
327
  },
288
328
  },
289
329
  });
290
330
  }
291
331
 
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
332
  private scheduleReconnect(): void {
303
333
  if (this.isShuttingDown) return;
304
334
 
@@ -320,9 +350,12 @@ export class TunnelAgent {
320
350
  .replace(/^http:/, 'ws:')
321
351
  .replace(/^https:/, 'wss:');
322
352
 
353
+ if (base.startsWith('ws://') && !base.includes('localhost') && !base.includes('127.0.0.1')) {
354
+ 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}`);
355
+ }
356
+
323
357
  const wsPath = this.config.wsPath || '/ws';
324
358
  const params = new URLSearchParams({
325
- token: this.config.token,
326
359
  tunnelId: this.config.tunnelId,
327
360
  });
328
361
 
@@ -4,7 +4,7 @@ import { join } from 'path';
4
4
  import { homedir } from 'os';
5
5
 
6
6
  const HELPER_VERSION = 'v1';
7
- const BIN_DIR = join(homedir(), '.kortix-tunnel', 'bin');
7
+ const BIN_DIR = join(homedir(), '.agent-tunnel', 'bin');
8
8
  const HELPER_PATH = join(BIN_DIR, `atspi-helper-${HELPER_VERSION}.py`);
9
9
 
10
10
  const PYTHON_SOURCE = `#!/usr/bin/env python3
@@ -4,7 +4,7 @@ import { join } from 'path';
4
4
  import { homedir } from 'os';
5
5
 
6
6
  const HELPER_VERSION = 'v1';
7
- const BIN_DIR = join(homedir(), '.kortix-tunnel', 'bin');
7
+ const BIN_DIR = join(homedir(), '.agent-tunnel', 'bin');
8
8
  const HELPER_PATH = join(BIN_DIR, `desktop-helper-win-${HELPER_VERSION}.exe`);
9
9
 
10
10
  const CSHARP_SOURCE = `
@@ -32,7 +32,7 @@ import type {
32
32
  } from './types';
33
33
 
34
34
  function tmpPath(): string {
35
- return join(tmpdir(), `kortix-ss-${randomBytes(6).toString('hex')}.png`);
35
+ return join(tmpdir(), `tunnel-ss-${randomBytes(6).toString('hex')}.png`);
36
36
  }
37
37
 
38
38
  function exec(cmd: string, args: string[]): Promise<string> {
@@ -32,7 +32,7 @@ import type {
32
32
  } from './types';
33
33
 
34
34
  function tmpPath(ext: string = '.png'): string {
35
- return join(tmpdir(), `kortix-ss-${randomBytes(6).toString('hex')}${ext}`);
35
+ return join(tmpdir(), `tunnel-ss-${randomBytes(6).toString('hex')}${ext}`);
36
36
  }
37
37
 
38
38
  function exec(cmd: string, args: string[], timeoutMs = 15000): Promise<string> {
@@ -4,7 +4,7 @@ import { join } from 'path';
4
4
  import { homedir } from 'os';
5
5
 
6
6
  const HELPER_VERSION = 'v4';
7
- const BIN_DIR = join(homedir(), '.kortix-tunnel', 'bin');
7
+ const BIN_DIR = join(homedir(), '.agent-tunnel', 'bin');
8
8
  const HELPER_PATH = join(BIN_DIR, `desktop-helper-${HELPER_VERSION}`);
9
9
 
10
10
  const SWIFT_SOURCE = `
@@ -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
  });