@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/src/agent/cli.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  #!/usr/bin/env bun
2
- import { loadConfig } from './config';
2
+ import { loadConfig, type TunnelConfig } from './config';
3
3
  import { TunnelAgent } from './agent';
4
4
  import { CapabilityRegistry } from './capabilities/index';
5
5
  import { createFilesystemCapability } from './capabilities/filesystem';
6
6
  import { createShellCapability } from './capabilities/shell';
7
7
  import { createDesktopCapability } from './capabilities/desktop';
8
8
  import { hostname, platform, arch, release } from 'os';
9
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { homedir } from 'os';
12
+ import { execSync } from 'child_process';
9
13
 
10
14
  const c = {
11
15
  reset: '\x1b[0m',
@@ -114,30 +118,14 @@ async function printStartup(config: { tunnelId: string; apiUrl: string }, capabi
114
118
  console.log('');
115
119
  }
116
120
 
117
- async function commandConnect(flags: Record<string, string>): Promise<void> {
118
- const config = loadConfig({
119
- token: flags.token,
120
- tunnelId: flags['tunnel-id'],
121
- apiUrl: flags['api-url'],
122
- });
123
-
124
- if (!config.token) {
125
- console.error(`${c.red}${c.bold} error${c.reset} --token is required`);
126
- process.exit(1);
127
- }
128
-
129
- if (!config.tunnelId) {
130
- console.error(`${c.red}${c.bold} error${c.reset} --tunnel-id is required`);
131
- process.exit(1);
132
- }
133
-
121
+ function startAgent(config: TunnelConfig): void {
134
122
  const registry = new CapabilityRegistry();
135
123
  registry.register(createFilesystemCapability(config));
136
124
  registry.register(createShellCapability(config));
137
125
  registry.register(createDesktopCapability());
138
126
 
139
127
  clearScreen();
140
- await printStartup(config, registry.getCapabilityNames(), '0.1.0');
128
+ printStartup(config, registry.getCapabilityNames(), '0.1.2');
141
129
 
142
130
  const agent = new TunnelAgent(config, registry);
143
131
  agent.connect();
@@ -152,6 +140,152 @@ async function commandConnect(flags: Record<string, string>): Promise<void> {
152
140
  process.on('SIGINT', shutdown);
153
141
  }
154
142
 
143
+ function openBrowser(url: string): void {
144
+ try {
145
+ const plat = platform();
146
+ if (plat === 'darwin') execSync(`open "${url}"`);
147
+ else if (plat === 'win32') execSync(`start "" "${url}"`);
148
+ else execSync(`xdg-open "${url}"`);
149
+ } catch {}
150
+ }
151
+
152
+ const CONFIG_DIR = join(homedir(), '.agent-tunnel');
153
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
154
+
155
+ function saveCredentials(tunnelId: string, token: string, apiUrl: string): void {
156
+ mkdirSync(CONFIG_DIR, { recursive: true });
157
+ let existing: Record<string, unknown> = {};
158
+ if (existsSync(CONFIG_FILE)) {
159
+ try { existing = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')); } catch {}
160
+ }
161
+ writeFileSync(CONFIG_FILE, JSON.stringify({ ...existing, tunnelId, token, apiUrl }, null, 2));
162
+ }
163
+
164
+ async function commandConnectDeviceAuth(config: TunnelConfig): Promise<void> {
165
+ console.log('');
166
+ console.log(` ${c.cyan}◆${c.reset} ${c.bold}Device Authorization${c.reset}`);
167
+ console.log('');
168
+
169
+ // Step 1: Create device auth request
170
+ let deviceCode: string;
171
+ let deviceSecret: string;
172
+ let verificationUrl: string;
173
+ let expiresAt: string;
174
+ let pollIntervalMs: number;
175
+
176
+ try {
177
+ const res = await fetch(`${config.apiUrl}/device-auth`, {
178
+ method: 'POST',
179
+ headers: { 'Content-Type': 'application/json' },
180
+ body: JSON.stringify({ machineHostname: hostname() }),
181
+ });
182
+ if (!res.ok) {
183
+ const text = await res.text().catch(() => '');
184
+ console.error(` ${c.red}✗${c.reset} Failed to create device auth request: ${res.status} ${text.slice(0, 200)}`);
185
+ process.exit(1);
186
+ }
187
+ const data = await res.json();
188
+ deviceCode = data.deviceCode;
189
+ deviceSecret = data.deviceSecret;
190
+ verificationUrl = data.verificationUrl;
191
+ expiresAt = data.expiresAt;
192
+ pollIntervalMs = data.pollIntervalMs || 2000;
193
+ } catch (err) {
194
+ console.error(` ${c.red}✗${c.reset} Failed to reach API at ${config.apiUrl}`);
195
+ process.exit(1);
196
+ return;
197
+ }
198
+
199
+ // Step 2: Display code and open browser
200
+ console.log(` ${c.dim}Code:${c.reset} ${c.bold}${c.white}${deviceCode}${c.reset}`);
201
+ console.log('');
202
+ console.log(` ${c.dim}Open this URL on any device to approve:${c.reset}`);
203
+ console.log(` ${c.cyan}${verificationUrl}${c.reset}`);
204
+ console.log('');
205
+
206
+ openBrowser(verificationUrl);
207
+
208
+ // Step 3: Poll for approval
209
+ const expiresAtMs = new Date(expiresAt).getTime();
210
+
211
+ while (true) {
212
+ const remaining = Math.max(0, Math.floor((expiresAtMs - Date.now()) / 1000));
213
+ if (remaining <= 0) {
214
+ console.log(`\n ${c.red}✗${c.reset} Authorization expired. Please try again.`);
215
+ process.exit(1);
216
+ }
217
+
218
+ const min = Math.floor(remaining / 60);
219
+ const sec = remaining % 60;
220
+ process.stdout.write(`\r ${c.dim}Waiting for approval... ${c.white}${min}:${sec.toString().padStart(2, '0')}${c.reset} `);
221
+
222
+ try {
223
+ const res = await fetch(`${config.apiUrl}/device-auth/${deviceCode}/status?secret=${deviceSecret}`);
224
+ if (res.ok) {
225
+ const data = await res.json();
226
+
227
+ if (data.status === 'approved' && data.tunnelId && data.token) {
228
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
229
+ console.log(` ${c.green}●${c.reset} ${c.bold}Authorized!${c.reset}`);
230
+ console.log('');
231
+
232
+ // Save credentials
233
+ saveCredentials(data.tunnelId, data.token, config.apiUrl);
234
+ console.log(` ${c.dim}Credentials saved to ${CONFIG_FILE}${c.reset}`);
235
+ console.log('');
236
+
237
+ // Connect with received credentials
238
+ const fullConfig = loadConfig({
239
+ token: data.token,
240
+ tunnelId: data.tunnelId,
241
+ apiUrl: config.apiUrl,
242
+ });
243
+ startAgent(fullConfig);
244
+ return;
245
+ }
246
+
247
+ if (data.status === 'denied') {
248
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
249
+ console.log(` ${c.red}✗${c.reset} Authorization denied.`);
250
+ process.exit(1);
251
+ }
252
+
253
+ if (data.status === 'expired') {
254
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
255
+ console.log(` ${c.red}✗${c.reset} Authorization expired. Please try again.`);
256
+ process.exit(1);
257
+ }
258
+ }
259
+ } catch {}
260
+
261
+ await sleep(pollIntervalMs);
262
+ }
263
+ }
264
+
265
+ async function commandConnect(flags: Record<string, string>): Promise<void> {
266
+ const config = loadConfig({
267
+ token: flags.token,
268
+ tunnelId: flags['tunnel-id'],
269
+ apiUrl: flags['api-url'],
270
+ });
271
+
272
+ // If both token and tunnelId are provided, connect directly
273
+ if (config.token && config.tunnelId) {
274
+ startAgent(config);
275
+ return;
276
+ }
277
+
278
+ // If neither is provided, use device auth flow
279
+ if (!config.token && !config.tunnelId) {
280
+ await commandConnectDeviceAuth(config);
281
+ return;
282
+ }
283
+
284
+ // Partial — error
285
+ console.error(`${c.red}${c.bold} error${c.reset} Provide both --token and --tunnel-id, or neither (for device auth)`);
286
+ process.exit(1);
287
+ }
288
+
155
289
  async function commandStatus(flags: Record<string, string>): Promise<void> {
156
290
  const config = loadConfig({
157
291
  token: flags.token,
@@ -192,13 +326,13 @@ function showHelp(): void {
192
326
  console.log(` ${c.bold}Usage${c.reset} ${c.dim}npx @kortix/agent-tunnel <command> [options]${c.reset}`);
193
327
  console.log('');
194
328
  console.log(`${c.gray} ── Commands ────────────────────────────────────────${c.reset}`);
195
- console.log(` ${c.cyan}connect${c.reset} Connect and start handling RPC requests`);
329
+ console.log(` ${c.cyan}connect${c.reset} Connect via device auth (opens browser)`);
196
330
  console.log(` ${c.cyan}status${c.reset} Check tunnel connection status`);
197
331
  console.log(` ${c.cyan}help${c.reset} Show this help message`);
198
332
  console.log('');
199
333
  console.log(`${c.gray} ── Options ─────────────────────────────────────────${c.reset}`);
200
- console.log(` ${c.white}--token${c.reset} ${c.dim}<token>${c.reset} API token ${c.dim}(or TUNNEL_TOKEN)${c.reset}`);
201
- console.log(` ${c.white}--tunnel-id${c.reset} ${c.dim}<id>${c.reset} Tunnel ID ${c.dim}(or TUNNEL_ID)${c.reset}`);
334
+ console.log(` ${c.white}--token${c.reset} ${c.dim}<token>${c.reset} Skip device auth, connect directly`);
335
+ console.log(` ${c.white}--tunnel-id${c.reset} ${c.dim}<id>${c.reset} Tunnel ID ${c.dim}(required with --token)${c.reset}`);
202
336
  console.log(` ${c.white}--api-url${c.reset} ${c.dim}<url>${c.reset} API URL ${c.dim}(default: http://localhost:8080)${c.reset}`);
203
337
  console.log('');
204
338
  console.log(` ${c.dim}Config: ~/.agent-tunnel/config.json${c.reset}`);
@@ -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
  }