@kortix/agent-tunnel 0.1.0

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.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Filesystem Capability — handles fs.read, fs.write, fs.list, fs.stat, fs.delete.
3
+ *
4
+ * All operations go through local-side path validation (defense in depth)
5
+ * even though the server already validates permissions.
6
+ */
7
+
8
+ import { readFile, writeFile, readdir, stat, unlink, mkdir } from 'fs/promises';
9
+ import { join, dirname } from 'path';
10
+ import type { Capability, RpcHandler } from './index';
11
+ import { validatePath } from '../security/path-validator';
12
+ import type { TunnelConfig } from '../config';
13
+
14
+ export function createFilesystemCapability(config: TunnelConfig): Capability {
15
+ const methods = new Map<string, RpcHandler>();
16
+
17
+ methods.set('fs.read', async (params) => {
18
+ const path = params.path as string;
19
+ const encoding = (params.encoding as BufferEncoding) || 'utf-8';
20
+
21
+ validatePath(path, config.allowedPaths);
22
+
23
+ const content = await readFile(path, { encoding });
24
+ const stats = await stat(path);
25
+
26
+ if (stats.size > config.maxFileSize) {
27
+ throw new Error(`File exceeds max size (${stats.size} > ${config.maxFileSize})`);
28
+ }
29
+
30
+ return {
31
+ content,
32
+ size: stats.size,
33
+ encoding,
34
+ };
35
+ });
36
+
37
+
38
+ methods.set('fs.write', async (params) => {
39
+ const path = params.path as string;
40
+ const content = params.content as string;
41
+ const encoding = (params.encoding as BufferEncoding) || 'utf-8';
42
+
43
+ validatePath(path, config.allowedPaths);
44
+
45
+ if (content.length > config.maxFileSize) {
46
+ throw new Error(`Content exceeds max size (${content.length} > ${config.maxFileSize})`);
47
+ }
48
+
49
+ await mkdir(dirname(path), { recursive: true });
50
+
51
+ await writeFile(path, content, { encoding });
52
+ const stats = await stat(path);
53
+
54
+ return {
55
+ size: stats.size,
56
+ path,
57
+ };
58
+ });
59
+
60
+
61
+ methods.set('fs.list', async (params) => {
62
+ const path = params.path as string;
63
+ const recursive = params.recursive as boolean || false;
64
+
65
+ validatePath(path, config.allowedPaths);
66
+
67
+ const entries = await readdir(path, { withFileTypes: true });
68
+
69
+ const result = entries.map((entry) => ({
70
+ name: entry.name,
71
+ path: join(path, entry.name),
72
+ isDirectory: entry.isDirectory(),
73
+ isFile: entry.isFile(),
74
+ isSymlink: entry.isSymbolicLink(),
75
+ }));
76
+
77
+ if (recursive) {
78
+ const dirs = result.filter((e) => e.isDirectory);
79
+ for (const dir of dirs) {
80
+ try {
81
+ const subEntries = await readdir(dir.path, { withFileTypes: true });
82
+ for (const sub of subEntries) {
83
+ result.push({
84
+ name: sub.name,
85
+ path: join(dir.path, sub.name),
86
+ isDirectory: sub.isDirectory(),
87
+ isFile: sub.isFile(),
88
+ isSymlink: sub.isSymbolicLink(),
89
+ });
90
+ }
91
+ } catch {
92
+ }
93
+ }
94
+ }
95
+
96
+ return { entries: result, count: result.length };
97
+ });
98
+
99
+
100
+ methods.set('fs.stat', async (params) => {
101
+ const path = params.path as string;
102
+
103
+ validatePath(path, config.allowedPaths);
104
+
105
+ const stats = await stat(path);
106
+
107
+ return {
108
+ size: stats.size,
109
+ isDirectory: stats.isDirectory(),
110
+ isFile: stats.isFile(),
111
+ isSymlink: stats.isSymbolicLink(),
112
+ mode: stats.mode,
113
+ mtime: stats.mtime.toISOString(),
114
+ ctime: stats.ctime.toISOString(),
115
+ atime: stats.atime.toISOString(),
116
+ };
117
+ });
118
+
119
+ methods.set('fs.delete', async (params) => {
120
+ const path = params.path as string;
121
+
122
+ validatePath(path, config.allowedPaths);
123
+
124
+ await unlink(path);
125
+
126
+ return { deleted: true, path };
127
+ });
128
+
129
+ return {
130
+ name: 'filesystem',
131
+ methods,
132
+ };
133
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Capability Registry — extensible registry for tunnel capabilities.
3
+ *
4
+ * Each capability (filesystem, shell, network, etc.) registers its
5
+ * RPC method handlers here. The TunnelAgent dispatches incoming
6
+ * JSON-RPC requests to the matching handler.
7
+ */
8
+
9
+ export type RpcHandler = (params: Record<string, unknown>) => Promise<unknown>;
10
+
11
+ export interface Capability {
12
+ name: string;
13
+ methods: Map<string, RpcHandler>;
14
+ }
15
+
16
+ export class CapabilityRegistry {
17
+ private capabilities = new Map<string, Capability>();
18
+
19
+ register(capability: Capability): void {
20
+ this.capabilities.set(capability.name, capability);
21
+ }
22
+
23
+ unregister(name: string): void {
24
+ this.capabilities.delete(name);
25
+ }
26
+
27
+ getHandler(method: string): RpcHandler | null {
28
+ for (const cap of this.capabilities.values()) {
29
+ const handler = cap.methods.get(method);
30
+ if (handler) return handler;
31
+ }
32
+ return null;
33
+ }
34
+
35
+ getCapabilityNames(): string[] {
36
+ return Array.from(this.capabilities.keys());
37
+ }
38
+
39
+ has(name: string): boolean {
40
+ return this.capabilities.has(name);
41
+ }
42
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Shell Capability — handles shell.exec for running commands on the local machine.
3
+ *
4
+ * Security:
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
8
+ * - Timeout enforcement
9
+ */
10
+
11
+ import { spawn } from 'child_process';
12
+ import type { Capability, RpcHandler } from './index';
13
+ import { validateCommand } from '../security/command-validator';
14
+ import { validatePath } from '../security/path-validator';
15
+ import type { TunnelConfig } from '../config';
16
+
17
+ const DEFAULT_TIMEOUT_MS = 30_000;
18
+ const MAX_OUTPUT_SIZE = 1024 * 1024;
19
+
20
+ export function createShellCapability(config: TunnelConfig): Capability {
21
+ const methods = new Map<string, RpcHandler>();
22
+
23
+ methods.set('shell.exec', async (params) => {
24
+ const command = params.command as string;
25
+ const args = (params.args as string[]) || [];
26
+ const cwd = (params.cwd as string) || config.workingDir;
27
+ const timeout = Math.min(
28
+ (params.timeout as number) || DEFAULT_TIMEOUT_MS,
29
+ 120_000,
30
+ );
31
+
32
+ validateCommand(command, config.allowedCommands);
33
+
34
+ if (cwd) {
35
+ validatePath(cwd, config.allowedPaths);
36
+ }
37
+
38
+ const SAFE_ENV_KEYS = ['PATH', 'HOME', 'USER', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'NODE_ENV', 'HOSTNAME'];
39
+ const safeEnv: Record<string, string> = { TERM: 'dumb' };
40
+ for (const key of SAFE_ENV_KEYS) {
41
+ if (process.env[key]) {
42
+ safeEnv[key] = process.env[key]!;
43
+ }
44
+ }
45
+
46
+ return new Promise((resolve, reject) => {
47
+ const proc = spawn(command, args, {
48
+ cwd,
49
+ shell: false,
50
+ timeout,
51
+ env: safeEnv,
52
+ });
53
+
54
+ let stdout = '';
55
+ let stderr = '';
56
+ let stdoutTruncated = false;
57
+ let stderrTruncated = false;
58
+
59
+ proc.stdout?.on('data', (data: Buffer) => {
60
+ if (stdout.length < MAX_OUTPUT_SIZE) {
61
+ stdout += data.toString();
62
+ } else {
63
+ stdoutTruncated = true;
64
+ }
65
+ });
66
+
67
+ proc.stderr?.on('data', (data: Buffer) => {
68
+ if (stderr.length < MAX_OUTPUT_SIZE) {
69
+ stderr += data.toString();
70
+ } else {
71
+ stderrTruncated = true;
72
+ }
73
+ });
74
+
75
+ proc.on('error', (err) => {
76
+ reject(new Error(`Command failed to start: ${err.message}`));
77
+ });
78
+
79
+ proc.on('close', (code, signal) => {
80
+ resolve({
81
+ exitCode: code,
82
+ signal,
83
+ stdout: stdout.slice(0, MAX_OUTPUT_SIZE),
84
+ stderr: stderr.slice(0, MAX_OUTPUT_SIZE),
85
+ stdoutTruncated,
86
+ stderrTruncated,
87
+ });
88
+ });
89
+ });
90
+ });
91
+
92
+ return {
93
+ name: 'shell',
94
+ methods,
95
+ };
96
+ }
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env bun
2
+ import { loadConfig } from './config';
3
+ import { TunnelAgent } from './agent';
4
+ import { CapabilityRegistry } from './capabilities/index';
5
+ import { createFilesystemCapability } from './capabilities/filesystem';
6
+ import { createShellCapability } from './capabilities/shell';
7
+ import { createDesktopCapability } from './capabilities/desktop';
8
+ import { hostname, platform, arch, release } from 'os';
9
+
10
+ const c = {
11
+ reset: '\x1b[0m',
12
+ bold: '\x1b[1m',
13
+ dim: '\x1b[2m',
14
+ italic: '\x1b[3m',
15
+ cyan: '\x1b[36m',
16
+ blue: '\x1b[34m',
17
+ green: '\x1b[32m',
18
+ yellow: '\x1b[33m',
19
+ red: '\x1b[31m',
20
+ magenta: '\x1b[35m',
21
+ white: '\x1b[97m',
22
+ gray: '\x1b[90m',
23
+ bgCyan: '\x1b[46m',
24
+ bgBlue: '\x1b[44m',
25
+ };
26
+
27
+ function parseArgs(argv: string[]): { command: string; flags: Record<string, string> } {
28
+ const command = argv[2] || 'help';
29
+ const flags: Record<string, string> = {};
30
+
31
+ for (let i = 3; i < argv.length; i++) {
32
+ const arg = argv[i];
33
+ if (arg.startsWith('--')) {
34
+ const key = arg.slice(2);
35
+ const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : 'true';
36
+ flags[key] = value;
37
+ }
38
+ }
39
+
40
+ return { command, flags };
41
+ }
42
+
43
+ function clearScreen(): void {
44
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
45
+ }
46
+
47
+ const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
48
+
49
+ async function printStartup(config: { tunnelId: string; apiUrl: string }, capabilities: string[], version: string): Promise<void> {
50
+ const machine = hostname();
51
+ const plat = `${platform()} ${arch()}`;
52
+
53
+ const truncate = (s: string, max: number) => s.length > max ? s.slice(0, max) + '…' : s;
54
+ const tunnelDisplay = truncate(config.tunnelId, 40);
55
+ const apiDisplay = truncate(config.apiUrl, 40);
56
+ const machineDisplay = truncate(machine, 28);
57
+
58
+ // ── ASCII art ───────────────────────────────────────────
59
+ console.log('');
60
+ console.log(` ${c.cyan}▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀${c.reset} ${c.cyan}▀█▀ █ █ █▄ █ █▄ █ █▀▀ █ ${c.reset}`);
61
+ console.log(` ${c.cyan}█▀█ █▄█ ██▄ █ ▀█ █${c.reset} ${c.cyan} █ █▄█ █ ▀█ █ ▀█ ██▄ █▄▄${c.reset}`);
62
+ console.log('');
63
+
64
+ // ── Tunnel connection animation ─────────────────────────
65
+ const barW = 50;
66
+ const frames = 14;
67
+
68
+ for (let i = 0; i <= frames; i++) {
69
+ const filled = Math.round((i / frames) * barW);
70
+ const empty = barW - filled;
71
+ process.stdout.write(
72
+ `\r ${c.cyan}◇${c.reset} ${c.cyan}${'═'.repeat(filled)}${c.reset}${c.gray}${'─'.repeat(empty)}${c.reset} `,
73
+ );
74
+ await sleep(20);
75
+ }
76
+ process.stdout.write(`\r ${c.cyan}◇ ${'═'.repeat(barW)} ◆${c.reset} \n`);
77
+ await sleep(120);
78
+
79
+ // ── Info box ────────────────────────────────────────────
80
+ const W = 60;
81
+ const vLen = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
82
+
83
+ const row = (content: string) => {
84
+ const pad = Math.max(0, W - vLen(content));
85
+ console.log(` ${c.gray}│${c.reset}${content}${' '.repeat(pad)}${c.gray}│${c.reset}`);
86
+ };
87
+
88
+ const blank = () => console.log(` ${c.gray}│${c.reset}${' '.repeat(W)}${c.gray}│${c.reset}`);
89
+
90
+ const titleL = ` ${c.cyan}◆${c.reset} ${c.bold}${c.white}Agent Tunnel${c.reset}`;
91
+ const titleR = `${c.dim}v${version}${c.reset} `;
92
+ const titleLLen = 18;
93
+ const titleRLen = 1 + version.length + 3;
94
+ const titlePad = Math.max(1, W - titleLLen - titleRLen);
95
+
96
+ const capStr = capabilities
97
+ .map(name => `${c.green}●${c.reset} ${c.white}${name}${c.reset}`)
98
+ .join(' ');
99
+
100
+ const brand = 'created by kortix';
101
+ const brandFill = W - brand.length - 3;
102
+
103
+ console.log('');
104
+ console.log(` ${c.gray}╭${'─'.repeat(W)}╮${c.reset}`);
105
+ blank();
106
+ row(`${titleL}${' '.repeat(titlePad)}${titleR}`);
107
+ row(` ${c.dim}Bridge between AI agents & local machines${c.reset}`);
108
+ blank();
109
+ row(` ${c.dim}tunnel${c.reset} ${c.white}${tunnelDisplay}${c.reset}`);
110
+ row(` ${c.dim}relay${c.reset} ${c.white}${apiDisplay}${c.reset}`);
111
+ row(` ${c.dim}machine${c.reset} ${c.white}${machineDisplay}${c.reset} ${c.dim}(${plat})${c.reset}`);
112
+ blank();
113
+ console.log(` ${c.gray}╰${'─'.repeat(brandFill)} ${c.dim}created by ${c.cyan}kortix${c.reset} ${c.gray}─╯${c.reset}`);
114
+ console.log('');
115
+ }
116
+
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
+
134
+ const registry = new CapabilityRegistry();
135
+ registry.register(createFilesystemCapability(config));
136
+ registry.register(createShellCapability(config));
137
+ registry.register(createDesktopCapability());
138
+
139
+ clearScreen();
140
+ await printStartup(config, registry.getCapabilityNames(), '0.1.0');
141
+
142
+ const agent = new TunnelAgent(config, registry);
143
+ agent.connect();
144
+
145
+ const shutdown = () => {
146
+ console.log(`\n${c.dim} Shutting down…${c.reset}`);
147
+ agent.disconnect();
148
+ process.exit(0);
149
+ };
150
+
151
+ process.on('SIGTERM', shutdown);
152
+ process.on('SIGINT', shutdown);
153
+ }
154
+
155
+ async function commandStatus(flags: Record<string, string>): Promise<void> {
156
+ const config = loadConfig({
157
+ token: flags.token,
158
+ tunnelId: flags['tunnel-id'],
159
+ apiUrl: flags['api-url'],
160
+ });
161
+
162
+ if (!config.token || !config.tunnelId) {
163
+ console.error('Error: --token and --tunnel-id are required');
164
+ process.exit(1);
165
+ }
166
+
167
+ try {
168
+ const res = await fetch(`${config.apiUrl}/connections/${config.tunnelId}`, {
169
+ headers: { Authorization: `Bearer ${config.token}` },
170
+ });
171
+
172
+ if (!res.ok) {
173
+ console.error(`Error: ${res.status} ${await res.text()}`);
174
+ process.exit(1);
175
+ }
176
+
177
+ const data = await res.json();
178
+ console.log(JSON.stringify(data, null, 2));
179
+ } catch (err) {
180
+ console.error('Error:', err);
181
+ process.exit(1);
182
+ }
183
+ }
184
+
185
+ function showHelp(): void {
186
+ console.log('');
187
+ console.log(` ${c.cyan}▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀${c.reset} ${c.cyan}▀█▀ █ █ █▄ █ █▄ █ █▀▀ █ ${c.reset}`);
188
+ console.log(` ${c.cyan}█▀█ █▄█ ██▄ █ ▀█ █${c.reset} ${c.cyan} █ █▄█ █ ▀█ █ ▀█ ██▄ █▄▄${c.reset}`);
189
+ console.log('');
190
+ console.log(` ${c.dim}Secure bridge between AI agents & local machines${c.reset}`);
191
+ console.log('');
192
+ console.log(` ${c.bold}Usage${c.reset} ${c.dim}npx @kortix/agent-tunnel <command> [options]${c.reset}`);
193
+ console.log('');
194
+ console.log(`${c.gray} ── Commands ────────────────────────────────────────${c.reset}`);
195
+ console.log(` ${c.cyan}connect${c.reset} Connect and start handling RPC requests`);
196
+ console.log(` ${c.cyan}status${c.reset} Check tunnel connection status`);
197
+ console.log(` ${c.cyan}help${c.reset} Show this help message`);
198
+ console.log('');
199
+ 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}`);
202
+ console.log(` ${c.white}--api-url${c.reset} ${c.dim}<url>${c.reset} API URL ${c.dim}(default: http://localhost:8080)${c.reset}`);
203
+ console.log('');
204
+ console.log(` ${c.dim}Config: ~/.agent-tunnel/config.json${c.reset}`);
205
+ console.log(` ${c.dim}powered by ${c.cyan}kortix${c.reset}`);
206
+ console.log('');
207
+ }
208
+
209
+ const { command, flags } = parseArgs(process.argv);
210
+
211
+ switch (command) {
212
+ case 'connect':
213
+ commandConnect(flags);
214
+ break;
215
+ case 'status':
216
+ commandStatus(flags);
217
+ break;
218
+ case 'help':
219
+ default:
220
+ showHelp();
221
+ break;
222
+ }
@@ -0,0 +1,54 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ export interface TunnelConfig {
6
+ token: string;
7
+ tunnelId: string;
8
+ apiUrl: string;
9
+ /** WS path on the server (default: '/ws'). Override for custom server mounts. */
10
+ wsPath: string;
11
+ maxFileSize: number;
12
+ allowedPaths: string[];
13
+ allowedCommands: string[];
14
+ workingDir: string;
15
+ }
16
+
17
+ const CONFIG_DIR = join(homedir(), '.agent-tunnel');
18
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
19
+
20
+ const DEFAULTS: Partial<TunnelConfig> = {
21
+ apiUrl: 'http://localhost:8080',
22
+ wsPath: '/ws',
23
+ maxFileSize: 10 * 1024 * 1024,
24
+ allowedPaths: [homedir()],
25
+ allowedCommands: [],
26
+ workingDir: homedir(),
27
+ };
28
+
29
+ export function loadConfig(overrides: Partial<TunnelConfig> = {}): TunnelConfig {
30
+ let fileConfig: Partial<TunnelConfig> = {};
31
+ if (existsSync(CONFIG_FILE)) {
32
+ try {
33
+ fileConfig = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
34
+ } catch (err) {
35
+ console.warn(`[config] Failed to parse ${CONFIG_FILE}:`, err);
36
+ }
37
+ }
38
+
39
+ const envConfig: Partial<TunnelConfig> = {};
40
+ if (process.env.TUNNEL_TOKEN) envConfig.token = process.env.TUNNEL_TOKEN;
41
+ if (process.env.TUNNEL_ID) envConfig.tunnelId = process.env.TUNNEL_ID;
42
+ if (process.env.TUNNEL_API_URL) envConfig.apiUrl = process.env.TUNNEL_API_URL;
43
+ if (process.env.TUNNEL_WS_PATH) envConfig.wsPath = process.env.TUNNEL_WS_PATH;
44
+ if (process.env.TUNNEL_MAX_FILE_SIZE) envConfig.maxFileSize = parseInt(process.env.TUNNEL_MAX_FILE_SIZE, 10);
45
+
46
+ const merged = {
47
+ ...DEFAULTS,
48
+ ...fileConfig,
49
+ ...envConfig,
50
+ ...overrides,
51
+ } as TunnelConfig;
52
+
53
+ return merged;
54
+ }
@@ -0,0 +1,11 @@
1
+ export { TunnelAgent } from './agent';
2
+ export { loadConfig, type TunnelConfig } from './config';
3
+ export { CapabilityRegistry } from './capabilities/index';
4
+ export type { Capability, RpcHandler } from './capabilities/index';
5
+ export { createFilesystemCapability } from './capabilities/filesystem';
6
+ export { createShellCapability } from './capabilities/shell';
7
+ export { createDesktopCapability } from './capabilities/desktop';
8
+ export { PermissionGuard } from './security/permission-guard';
9
+ export type { LocalPermission } from './security/permission-guard';
10
+ export { validateCommand } from './security/command-validator';
11
+ export { validatePath } from './security/path-validator';
@@ -0,0 +1,61 @@
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
+ const SHELL_METACHAR_REGEX = /[;&|`$(){}[\]<>!#~]/;
38
+
39
+ export function validateCommand(command: string, allowedCommands: string[]): void {
40
+ if (!command || typeof command !== 'string') {
41
+ throw new Error('Command is required');
42
+ }
43
+
44
+ const trimmed = command.trim();
45
+
46
+ if (SHELL_METACHAR_REGEX.test(trimmed)) {
47
+ throw new Error(`Command contains disallowed characters: "${trimmed}"`);
48
+ }
49
+
50
+ const executable = trimmed.split(/\s+/)[0];
51
+
52
+ if (BLOCKED_COMMANDS.has(executable)) {
53
+ throw new Error(`Command "${executable}" is blocked for security reasons`);
54
+ }
55
+
56
+ if (allowedCommands.length > 0) {
57
+ if (!allowedCommands.includes(executable)) {
58
+ throw new Error(`Command "${executable}" is not in the allowed commands list`);
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Path Validator — defense-in-depth path traversal prevention.
3
+ *
4
+ * Validates that requested paths:
5
+ * 1. Are absolute
6
+ * 2. Resolve to an absolute path (follows symlinks)
7
+ * 3. Fall within allowed directories
8
+ * 4. Don't hit sensitive system paths
9
+ */
10
+
11
+ import { resolve, normalize } from 'path';
12
+ import { realpathSync } from 'fs';
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 {
27
+ if (!path) {
28
+ throw new Error('Path is required');
29
+ }
30
+
31
+ const normalized = normalize(resolve(path));
32
+ let resolved: string;
33
+ try {
34
+ resolved = realpathSync(normalized);
35
+ } catch {
36
+ resolved = normalized;
37
+ }
38
+
39
+ for (const sensitive of SENSITIVE_PATHS) {
40
+ if (resolved === sensitive || resolved.startsWith(sensitive + '/')) {
41
+ throw new Error(`Access denied: sensitive system path "${path}"`);
42
+ }
43
+ }
44
+
45
+ if (allowedPaths.length > 0) {
46
+ const withinAllowed = allowedPaths.some((allowed) => {
47
+ const normalizedAllowed = normalize(resolve(allowed));
48
+ return resolved === normalizedAllowed || resolved.startsWith(normalizedAllowed + '/');
49
+ });
50
+
51
+ if (!withinAllowed) {
52
+ throw new Error(`Access denied: path "${path}" is outside allowed directories`);
53
+ }
54
+ }
55
+ }