@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 +4 -2
- package/src/agent/agent.ts +72 -39
- package/src/agent/capabilities/desktop/atspi-helper.ts +1 -1
- package/src/agent/capabilities/desktop/csharp-helper.ts +1 -1
- package/src/agent/capabilities/desktop/linux-driver.ts +1 -1
- package/src/agent/capabilities/desktop/macos-driver.ts +1 -1
- package/src/agent/capabilities/desktop/swift-helper.ts +1 -1
- package/src/agent/capabilities/filesystem.ts +7 -7
- package/src/agent/capabilities/shell.ts +29 -19
- package/src/agent/cli.ts +156 -22
- package/src/agent/config.ts +21 -1
- package/src/agent/security/command-validator.ts +7 -39
- package/src/agent/security/path-validator.ts +16 -19
- package/src/agent/security/permission-guard.ts +2 -1
- package/src/client/cli.test.ts +758 -0
- package/src/client/cli.ts +716 -0
- package/src/client/index.ts +1 -0
- package/src/index.ts +2 -0
- package/src/server/relay.ts +50 -6
- package/src/server/routes.ts +15 -1
- package/src/server/server.ts +22 -22
- package/src/server/ws-handler.ts +76 -12
- package/src/shared/crypto.ts +2 -3
- package/src/shared/index.ts +2 -0
- package/src/shared/types.ts +30 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
201
|
-
console.log(` ${c.white}--tunnel-id${c.reset} ${c.dim}<id>${c.reset} Tunnel ID ${c.dim}(
|
|
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}`);
|
package/src/agent/config.ts
CHANGED
|
@@ -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(
|
|
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 (
|
|
53
|
-
throw new Error(`Command "${executable}" is blocked
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
40
|
-
if (resolved ===
|
|
41
|
-
throw new Error(`Access denied:
|
|
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
|
-
|
|
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
|
}
|