@kortix/agent-tunnel 0.1.4 → 0.12.7

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.
Files changed (67) hide show
  1. package/README.md +65 -0
  2. package/dist/agent-cli.js +4676 -3174
  3. package/dist/client-cli.js +202 -493
  4. package/package.json +24 -29
  5. package/src/agent/agent.ts +67 -17
  6. package/src/agent/capabilities/desktop/cua-driver.ts +284 -0
  7. package/src/agent/capabilities/desktop.ts +100 -167
  8. package/src/agent/capabilities/enabled-registry.ts +24 -0
  9. package/src/agent/capabilities/filesystem.ts +136 -32
  10. package/src/agent/capabilities/index.ts +1 -1
  11. package/src/agent/capabilities/security.test.ts +204 -0
  12. package/src/agent/capabilities/shell.ts +37 -3
  13. package/src/agent/cli-device-auth.test.ts +179 -0
  14. package/src/agent/cli-help.test.ts +25 -0
  15. package/src/agent/cli.ts +475 -38
  16. package/src/agent/config.test.ts +53 -0
  17. package/src/agent/config.ts +169 -7
  18. package/src/agent/index.ts +1 -0
  19. package/src/agent/security/command-validator.ts +4 -2
  20. package/src/agent/security/path-validator.ts +73 -18
  21. package/src/agent/security/permission-guard.test.ts +52 -0
  22. package/src/agent/security/permission-guard.ts +35 -8
  23. package/src/agent/service.test.ts +63 -0
  24. package/src/agent/service.ts +410 -0
  25. package/src/client/cli.test.ts +150 -547
  26. package/src/client/cli.ts +116 -539
  27. package/src/client/index.ts +1 -1
  28. package/src/client/tools.ts +95 -356
  29. package/src/client/tunnel-client.ts +50 -80
  30. package/src/index.ts +7 -1
  31. package/src/node-ws-polyfill.test.ts +18 -0
  32. package/src/node-ws-polyfill.ts +5 -3
  33. package/src/server/heartbeat.ts +13 -6
  34. package/src/server/relay.test.ts +72 -0
  35. package/src/server/relay.ts +50 -9
  36. package/src/server/server.test.ts +33 -0
  37. package/src/server/server.ts +26 -6
  38. package/src/server/ws-handler.test.ts +158 -0
  39. package/src/server/ws-handler.ts +94 -36
  40. package/src/shared/crypto.ts +2 -3
  41. package/src/shared/index.ts +8 -0
  42. package/src/shared/permissions.ts +292 -0
  43. package/src/shared/types.ts +70 -41
  44. package/dist/agent/index.d.ts +0 -140
  45. package/dist/agent/index.js +0 -21
  46. package/dist/agent/index.js.map +0 -1
  47. package/dist/chunk-7N7GSU6K.js +0 -34
  48. package/dist/client/index.d.ts +0 -183
  49. package/dist/client/index.js +0 -8
  50. package/dist/client/index.js.map +0 -1
  51. package/dist/index.d.ts +0 -7
  52. package/dist/index.js +0 -55
  53. package/dist/index.js.map +0 -1
  54. package/dist/server/index.d.ts +0 -89
  55. package/dist/server/index.js +0 -14
  56. package/dist/server/index.js.map +0 -1
  57. package/dist/shared/index.d.ts +0 -10
  58. package/dist/shared/index.js +0 -20
  59. package/dist/shared/index.js.map +0 -1
  60. package/dist/types-Dpwrd8Ai.d.ts +0 -194
  61. package/src/agent/capabilities/desktop/atspi-helper.ts +0 -345
  62. package/src/agent/capabilities/desktop/csharp-helper.ts +0 -914
  63. package/src/agent/capabilities/desktop/linux-driver.ts +0 -368
  64. package/src/agent/capabilities/desktop/macos-driver.ts +0 -601
  65. package/src/agent/capabilities/desktop/swift-helper.ts +0 -736
  66. package/src/agent/capabilities/desktop/types.ts +0 -201
  67. package/src/agent/capabilities/desktop/windows-driver.ts +0 -220
@@ -0,0 +1,53 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+
3
+ import { loadConfig } from './config';
4
+
5
+ const ENV_KEYS = ['TUNNEL_API_URL', 'TUNNEL_WS_PATH'] as const;
6
+
7
+ let saved: Record<string, string | undefined>;
8
+
9
+ beforeEach(() => {
10
+ saved = {};
11
+ for (const key of ENV_KEYS) {
12
+ saved[key] = process.env[key];
13
+ delete process.env[key];
14
+ }
15
+ });
16
+
17
+ afterEach(() => {
18
+ for (const key of ENV_KEYS) {
19
+ if (saved[key] === undefined) delete process.env[key];
20
+ else process.env[key] = saved[key];
21
+ }
22
+ });
23
+
24
+ describe('loadConfig', () => {
25
+ it('does not let undefined overrides clear defaults or env config', () => {
26
+ process.env.TUNNEL_API_URL = 'https://relay.example/api?debug=1#token';
27
+ const config = loadConfig({ apiUrl: undefined });
28
+
29
+ expect(config.apiUrl).toBe('https://relay.example/api');
30
+ });
31
+
32
+ it('rejects non-http API URLs before network use', () => {
33
+ expect(() => loadConfig({ apiUrl: 'file:///tmp/config.json' })).toThrow(
34
+ 'Invalid tunnel API URL protocol',
35
+ );
36
+ });
37
+
38
+ it('rejects plaintext HTTP for remote tunnel APIs', () => {
39
+ expect(() => loadConfig({ apiUrl: 'http://relay.example/v1/tunnel' })).toThrow(
40
+ 'Remote tunnel API URLs must use https',
41
+ );
42
+ expect(loadConfig({ apiUrl: 'http://127.0.0.1:8008/v1/tunnel' }).apiUrl).toBe(
43
+ 'http://127.0.0.1:8008/v1/tunnel',
44
+ );
45
+ });
46
+
47
+ it('requires the websocket path to be an absolute path', () => {
48
+ expect(loadConfig({ wsPath: '/relay/ws' }).wsPath).toBe('/relay/ws');
49
+ expect(() => loadConfig({ wsPath: 'https://relay.example/ws' })).toThrow(
50
+ 'Tunnel WebSocket path must be an absolute path',
51
+ );
52
+ });
53
+ });
@@ -1,6 +1,8 @@
1
- import { existsSync, readFileSync } from 'fs';
1
+ import { chmodSync, existsSync, lstatSync, readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
+ import { isTunnelCapability } from '../shared/permissions';
5
+ import type { TunnelCapability } from '../shared/types';
4
6
 
5
7
  export interface TunnelConfig {
6
8
  token: string;
@@ -17,6 +19,8 @@ export interface TunnelConfig {
17
19
  shellMaxTimeout: number;
18
20
  shellMaxOutputSize: number;
19
21
  shellEnvPassthrough: string[];
22
+ /** Local maximum. An API permission cannot enable a capability outside this set. */
23
+ enabledCapabilities?: TunnelCapability[];
20
24
  }
21
25
 
22
26
  const CONFIG_DIR = join(homedir(), '.agent-tunnel');
@@ -43,16 +47,169 @@ const DEFAULTS: Partial<TunnelConfig> = {
43
47
  shellTimeout: 30_000,
44
48
  shellMaxTimeout: 120_000,
45
49
  shellMaxOutputSize: 1024 * 1024,
46
- shellEnvPassthrough: ['PATH', 'HOME', 'USER', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'NODE_ENV', 'HOSTNAME'],
50
+ shellEnvPassthrough: [
51
+ 'PATH',
52
+ 'HOME',
53
+ 'USER',
54
+ 'LANG',
55
+ 'LC_ALL',
56
+ 'LC_CTYPE',
57
+ 'TMPDIR',
58
+ 'NODE_ENV',
59
+ 'HOSTNAME',
60
+ ],
61
+ // Compatibility for existing configs. New device-auth connections persist
62
+ // the exact capability set that the user approved in the browser.
63
+ enabledCapabilities: ['filesystem', 'shell', 'desktop'],
47
64
  };
48
65
 
66
+ function compactConfig(input: Partial<TunnelConfig>): Partial<TunnelConfig> {
67
+ const output: Partial<TunnelConfig> = {};
68
+ for (const [key, value] of Object.entries(input)) {
69
+ if (value !== undefined) {
70
+ (output as Record<string, unknown>)[key] = value;
71
+ }
72
+ }
73
+ return output;
74
+ }
75
+
76
+ function assertPrivateOwnedPath(
77
+ path: string,
78
+ kind: 'directory' | 'file',
79
+ expectedMode: number,
80
+ ): void {
81
+ const stats = lstatSync(path);
82
+ const validType = kind === 'directory' ? stats.isDirectory() : stats.isFile();
83
+ if (stats.isSymbolicLink() || !validType) {
84
+ throw new Error(`Tunnel config ${kind} must be a regular ${kind}, not a symlink`);
85
+ }
86
+ if (
87
+ typeof process.getuid === 'function' &&
88
+ typeof stats.uid === 'number' &&
89
+ stats.uid !== process.getuid()
90
+ ) {
91
+ throw new Error(`Tunnel config ${kind} is not owned by the current user`);
92
+ }
93
+ if (process.platform !== 'win32') {
94
+ try {
95
+ chmodSync(path, expectedMode);
96
+ } catch (error) {
97
+ throw new Error(
98
+ `Cannot secure tunnel config ${kind}: ${error instanceof Error ? error.message : error}`,
99
+ );
100
+ }
101
+ const secured = lstatSync(path);
102
+ if ((secured.mode & 0o077) !== 0) {
103
+ throw new Error(`Tunnel config ${kind} permissions are not private`);
104
+ }
105
+ }
106
+ }
107
+
108
+ function assertStringArray(value: unknown, name: string): asserts value is string[] {
109
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
110
+ throw new Error(`Tunnel config ${name} must be an array of strings`);
111
+ }
112
+ }
113
+
114
+ function validateConfigValues(config: TunnelConfig): void {
115
+ assertStringArray(config.allowedPaths, 'allowedPaths');
116
+ assertStringArray(config.allowedCommands, 'allowedCommands');
117
+ assertStringArray(config.blockedCommands, 'blockedCommands');
118
+ assertStringArray(config.blockedPaths, 'blockedPaths');
119
+ assertStringArray(config.shellEnvPassthrough, 'shellEnvPassthrough');
120
+ if (config.enabledCapabilities !== undefined) {
121
+ assertStringArray(config.enabledCapabilities, 'enabledCapabilities');
122
+ if (
123
+ new Set(config.enabledCapabilities).size !== config.enabledCapabilities.length ||
124
+ !config.enabledCapabilities.every(isTunnelCapability)
125
+ ) {
126
+ throw new Error(
127
+ 'Tunnel config enabledCapabilities must contain unique supported capabilities',
128
+ );
129
+ }
130
+ }
131
+
132
+ for (const [name, value] of [
133
+ ['maxFileSize', config.maxFileSize],
134
+ ['shellTimeout', config.shellTimeout],
135
+ ['shellMaxTimeout', config.shellMaxTimeout],
136
+ ['shellMaxOutputSize', config.shellMaxOutputSize],
137
+ ] as const) {
138
+ if (!Number.isSafeInteger(value) || value <= 0) {
139
+ throw new Error(`Tunnel config ${name} must be a positive safe integer`);
140
+ }
141
+ }
142
+ if (typeof config.workingDir !== 'string' || config.workingDir.length === 0) {
143
+ throw new Error('Tunnel config workingDir must be a non-empty string');
144
+ }
145
+ }
146
+
147
+ export function trustedCredential(value: string, name: string): string {
148
+ if (!value || /[\r\n]/.test(value)) {
149
+ throw new Error(`Invalid tunnel ${name}`);
150
+ }
151
+ return value;
152
+ }
153
+
154
+ export function trustedHttpUrl(value: string): string {
155
+ const raw = trustedCredential(value, 'apiUrl');
156
+ const url = new URL(raw);
157
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
158
+ throw new Error('Tunnel API URL must use http or https');
159
+ }
160
+ assertEncryptedOrLoopback(url);
161
+ return url.toString().replace(/\/$/, '');
162
+ }
163
+
164
+ function assertEncryptedOrLoopback(url: URL): void {
165
+ const loopback =
166
+ url.hostname === 'localhost' ||
167
+ url.hostname === '127.0.0.1' ||
168
+ url.hostname === '[::1]' ||
169
+ url.hostname === '::1';
170
+ if (url.protocol !== 'https:' && !loopback) {
171
+ throw new Error('Remote tunnel API URLs must use https');
172
+ }
173
+ }
174
+
175
+ export function normalizeApiUrl(value: string): string {
176
+ const raw = trustedCredential(value, 'apiUrl');
177
+ let url: URL;
178
+ try {
179
+ url = new URL(raw);
180
+ } catch {
181
+ throw new Error('Invalid tunnel API URL protocol');
182
+ }
183
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
184
+ throw new Error('Invalid tunnel API URL protocol');
185
+ }
186
+ assertEncryptedOrLoopback(url);
187
+ return `${url.origin}${url.pathname}`.replace(/\/$/, '');
188
+ }
189
+
190
+ export function absoluteWsPath(value: string): string {
191
+ if (!value.startsWith('/')) {
192
+ throw new Error('Tunnel WebSocket path must be an absolute path');
193
+ }
194
+ return value;
195
+ }
196
+
49
197
  export function loadConfig(overrides: Partial<TunnelConfig> = {}): TunnelConfig {
50
198
  let fileConfig: Partial<TunnelConfig> = {};
51
199
  if (existsSync(CONFIG_FILE)) {
200
+ if (!existsSync(CONFIG_DIR)) throw new Error('Tunnel config directory is missing');
201
+ assertPrivateOwnedPath(CONFIG_DIR, 'directory', 0o700);
202
+ assertPrivateOwnedPath(CONFIG_FILE, 'file', 0o600);
52
203
  try {
53
- fileConfig = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
204
+ const parsed = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
205
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
206
+ throw new Error('root value must be an object');
207
+ }
208
+ fileConfig = parsed;
54
209
  } catch (err) {
55
- console.warn(`[config] Failed to parse ${CONFIG_FILE}:`, err);
210
+ throw new Error(
211
+ `Tunnel config is invalid: ${err instanceof Error ? err.message : String(err)}`,
212
+ );
56
213
  }
57
214
  }
58
215
 
@@ -61,14 +218,19 @@ export function loadConfig(overrides: Partial<TunnelConfig> = {}): TunnelConfig
61
218
  if (process.env.TUNNEL_ID) envConfig.tunnelId = process.env.TUNNEL_ID;
62
219
  if (process.env.TUNNEL_API_URL) envConfig.apiUrl = process.env.TUNNEL_API_URL;
63
220
  if (process.env.TUNNEL_WS_PATH) envConfig.wsPath = process.env.TUNNEL_WS_PATH;
64
- if (process.env.TUNNEL_MAX_FILE_SIZE) envConfig.maxFileSize = parseInt(process.env.TUNNEL_MAX_FILE_SIZE, 10);
221
+ if (process.env.TUNNEL_MAX_FILE_SIZE)
222
+ envConfig.maxFileSize = parseInt(process.env.TUNNEL_MAX_FILE_SIZE, 10);
65
223
 
66
224
  const merged = {
67
225
  ...DEFAULTS,
68
- ...fileConfig,
226
+ ...compactConfig(fileConfig),
69
227
  ...envConfig,
70
- ...overrides,
228
+ ...compactConfig(overrides),
71
229
  } as TunnelConfig;
72
230
 
231
+ merged.apiUrl = normalizeApiUrl(merged.apiUrl);
232
+ merged.wsPath = absoluteWsPath(merged.wsPath);
233
+ validateConfigValues(merged);
234
+
73
235
  return merged;
74
236
  }
@@ -5,6 +5,7 @@ export type { Capability, RpcHandler } from './capabilities/index';
5
5
  export { createFilesystemCapability } from './capabilities/filesystem';
6
6
  export { createShellCapability } from './capabilities/shell';
7
7
  export { createDesktopCapability } from './capabilities/desktop';
8
+ export { createEnabledCapabilityRegistry } from './capabilities/enabled-registry';
8
9
  export { PermissionGuard } from './security/permission-guard';
9
10
  export type { LocalPermission } from './security/permission-guard';
10
11
  export { validateCommand } from './security/command-validator';
@@ -4,7 +4,7 @@ export function validateCommand(
4
4
  command: string,
5
5
  allowedCommands: string[],
6
6
  blockedCommands: string[],
7
- ): void {
7
+ ): string {
8
8
  if (!command || typeof command !== 'string') {
9
9
  throw new Error('Command is required');
10
10
  }
@@ -15,7 +15,7 @@ export function validateCommand(
15
15
  throw new Error(`Command contains disallowed characters: "${trimmed}"`);
16
16
  }
17
17
 
18
- const executable = trimmed.split(/\s+/)[0];
18
+ const executable = trimmed;
19
19
 
20
20
  if (blockedCommands.length > 0 && blockedCommands.includes(executable)) {
21
21
  throw new Error(`Command "${executable}" is blocked`);
@@ -26,4 +26,6 @@ export function validateCommand(
26
26
  throw new Error(`Command "${executable}" is not in the allowed commands list`);
27
27
  }
28
28
  }
29
+
30
+ return executable;
29
31
  }
@@ -8,45 +8,100 @@
8
8
  * 4. Don't hit blocked paths (configurable)
9
9
  */
10
10
 
11
- import { resolve, normalize } from 'path';
11
+ import { dirname, basename, join, resolve, normalize, relative, isAbsolute } from 'path';
12
12
  import { realpathSync } from 'fs';
13
13
 
14
- export function validatePath(
15
- path: string,
16
- allowedPaths: string[],
17
- blockedPaths: string[] = [],
18
- ): void {
19
- if (!path) {
20
- throw new Error('Path is required');
14
+ function resolveExistingRoot(path: string): string {
15
+ const normalized = normalize(resolve(path));
16
+ try {
17
+ return realpathSync(normalized);
18
+ } catch {
19
+ return normalized;
21
20
  }
21
+ }
22
22
 
23
+ function resolvePathForValidation(path: string): string {
23
24
  const normalized = normalize(resolve(path));
24
- let resolved: string;
25
25
  try {
26
- resolved = realpathSync(normalized);
26
+ return realpathSync(normalized);
27
27
  } catch (err) {
28
28
  const code = (err as NodeJS.ErrnoException).code;
29
- if (code === 'ENOENT') {
30
- resolved = normalized;
31
- } else {
29
+ if (code !== 'ENOENT') {
32
30
  throw new Error(`Access denied: cannot resolve path "${path}" (${code})`);
33
31
  }
32
+
33
+ const parent = dirname(normalized);
34
+ if (parent === normalized) return normalized;
35
+ return join(resolvePathForValidation(parent), basename(normalized));
34
36
  }
37
+ }
35
38
 
39
+ function assertAllowedResolvedPath(
40
+ originalPath: string,
41
+ resolved: string,
42
+ allowedPaths: string[],
43
+ blockedPaths: string[] = [],
44
+ ): void {
36
45
  for (const blocked of blockedPaths) {
37
- if (resolved === blocked || resolved.startsWith(blocked + '/')) {
38
- throw new Error(`Access denied: blocked path "${path}"`);
46
+ const normalizedBlocked = resolveExistingRoot(blocked);
47
+ if (isPathInside(resolved, normalizedBlocked)) {
48
+ throw new Error(`Access denied: blocked path "${originalPath}"`);
39
49
  }
40
50
  }
41
51
 
42
52
  if (allowedPaths.length > 0) {
43
53
  const withinAllowed = allowedPaths.some((allowed) => {
44
- const normalizedAllowed = normalize(resolve(allowed));
45
- return resolved === normalizedAllowed || resolved.startsWith(normalizedAllowed + '/');
54
+ const normalizedAllowed = resolveExistingRoot(allowed);
55
+ return isPathInside(resolved, normalizedAllowed);
46
56
  });
47
57
 
48
58
  if (!withinAllowed) {
49
- throw new Error(`Access denied: path "${path}" is outside allowed directories`);
59
+ throw new Error(`Access denied: path "${originalPath}" is outside allowed directories`);
50
60
  }
51
61
  }
52
62
  }
63
+
64
+ function isPathInside(target: string, root: string): boolean {
65
+ const child = relative(root, target);
66
+ return child === '' || (!child.startsWith('..') && !isAbsolute(child));
67
+ }
68
+
69
+ export function validatePath(
70
+ path: string,
71
+ allowedPaths: string[],
72
+ blockedPaths: string[] = [],
73
+ ): string {
74
+ if (!path) {
75
+ throw new Error('Path is required');
76
+ }
77
+
78
+ const resolved = resolvePathForValidation(path);
79
+
80
+ assertAllowedResolvedPath(path, resolved, allowedPaths, blockedPaths);
81
+ return resolved;
82
+ }
83
+
84
+ export function validateWritePath(
85
+ path: string,
86
+ allowedPaths: string[],
87
+ blockedPaths: string[] = [],
88
+ ): string {
89
+ const resolved = validatePath(path, allowedPaths, blockedPaths);
90
+
91
+ let parent = dirname(normalize(resolve(path)));
92
+ while (parent && parent !== dirname(parent)) {
93
+ try {
94
+ const resolvedParent = realpathSync(parent);
95
+ assertAllowedResolvedPath(path, resolvedParent, allowedPaths, blockedPaths);
96
+ return resolved;
97
+ } catch (err) {
98
+ const code = (err as NodeJS.ErrnoException).code;
99
+ if (code !== 'ENOENT') {
100
+ throw new Error(`Access denied: cannot resolve parent for "${path}" (${code})`);
101
+ }
102
+ parent = dirname(parent);
103
+ }
104
+ }
105
+
106
+ throw new Error(`Access denied: cannot resolve parent for "${path}"`);
107
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { PermissionGuard } from './permission-guard';
3
+
4
+ describe('PermissionGuard method binding', () => {
5
+ test('does not let a valid permission id authorize another capability', () => {
6
+ const guard = new PermissionGuard();
7
+ guard.addPermission({
8
+ permissionId: 'filesystem-permission',
9
+ capability: 'filesystem',
10
+ scope: {},
11
+ });
12
+
13
+ expect(guard.getPermissionForMethod('filesystem-permission', 'fs.read')).not.toBeNull();
14
+ expect(guard.getPermissionForMethod('filesystem-permission', 'shell.exec')).toBeNull();
15
+ expect(guard.getPermissionForMethod('filesystem-permission', 'desktop.cua.click')).toBeNull();
16
+ });
17
+
18
+ test('unknown methods fail closed', () => {
19
+ const guard = new PermissionGuard();
20
+ guard.addPermission({
21
+ permissionId: 'filesystem-permission',
22
+ capability: 'filesystem',
23
+ scope: {},
24
+ });
25
+ expect(guard.getPermissionForMethod('filesystem-permission', 'fs.unknown')).toBeNull();
26
+ });
27
+
28
+ test('malformed server scopes fail closed on the machine', () => {
29
+ const guard = new PermissionGuard();
30
+ guard.syncPermissions([
31
+ {
32
+ permissionId: 'empty-commands',
33
+ capability: 'shell',
34
+ scope: { commands: [] },
35
+ },
36
+ {
37
+ permissionId: 'unknown-field',
38
+ capability: 'filesystem',
39
+ scope: { path: '/tmp/secret' },
40
+ },
41
+ {
42
+ permissionId: 'invalid-timeout',
43
+ capability: 'shell',
44
+ scope: { maxTimeout: Number.NaN },
45
+ },
46
+ ]);
47
+
48
+ expect(guard.getPermissionForMethod('empty-commands', 'shell.exec')).toBeNull();
49
+ expect(guard.getPermissionForMethod('unknown-field', 'fs.read')).toBeNull();
50
+ expect(guard.getPermissionForMethod('invalid-timeout', 'shell.exec')).toBeNull();
51
+ });
52
+ });
@@ -1,3 +1,9 @@
1
+ import {
2
+ capabilityForMethod,
3
+ isTunnelCapability,
4
+ validateTunnelPermissionScope,
5
+ } from '../../shared/permissions';
6
+
1
7
  /**
2
8
  * Permission Guard — local-side permission enforcement (defense in depth).
3
9
  *
@@ -18,18 +24,27 @@ export interface LocalPermission {
18
24
 
19
25
  export class PermissionGuard {
20
26
  private permissions = new Map<string, LocalPermission>();
21
- private hasSynced = false;
22
27
 
23
28
  /** Bulk-load permissions from server sync notification. */
24
29
  syncPermissions(permissions: LocalPermission[]): void {
25
30
  this.permissions.clear();
26
31
  for (const perm of permissions) {
27
- this.permissions.set(perm.permissionId, perm);
32
+ this.addPermission(perm);
28
33
  }
29
- this.hasSynced = true;
30
34
  }
31
35
 
32
36
  addPermission(permission: LocalPermission): void {
37
+ if (
38
+ typeof permission?.permissionId !== 'string' ||
39
+ !permission.permissionId ||
40
+ !isTunnelCapability(permission.capability) ||
41
+ !validateTunnelPermissionScope(permission.capability, permission.scope).valid
42
+ ) {
43
+ if (typeof permission?.permissionId === 'string') {
44
+ this.permissions.delete(permission.permissionId);
45
+ }
46
+ return;
47
+ }
33
48
  this.permissions.set(permission.permissionId, permission);
34
49
  }
35
50
 
@@ -38,30 +53,42 @@ export class PermissionGuard {
38
53
  }
39
54
 
40
55
  checkPermission(permissionId: string | undefined): boolean {
56
+ return !!this.getPermission(permissionId);
57
+ }
58
+
59
+ getPermission(permissionId: string | undefined): LocalPermission | null {
41
60
  if (!permissionId) {
42
- return false;
61
+ return null;
43
62
  }
44
63
 
45
64
  const perm = this.permissions.get(permissionId);
46
65
  if (!perm) {
47
66
  // After sync, unknown permission = deny (fail-closed).
48
67
  // Before sync, also deny — we have no basis to allow.
49
- return false;
68
+ return null;
50
69
  }
51
70
 
52
71
  if (perm.expiresAt) {
53
72
  const expiry = new Date(perm.expiresAt).getTime();
54
73
  if (isNaN(expiry) || expiry < Date.now()) {
55
74
  this.permissions.delete(permissionId);
56
- return false;
75
+ return null;
57
76
  }
58
77
  }
59
78
 
60
- return true;
79
+ return perm;
80
+ }
81
+
82
+ getPermissionForMethod(permissionId: string | undefined, method: string): LocalPermission | null {
83
+ const permission = this.getPermission(permissionId);
84
+ const requiredCapability = capabilityForMethod(method);
85
+ if (!permission || !requiredCapability || permission.capability !== requiredCapability) {
86
+ return null;
87
+ }
88
+ return permission;
61
89
  }
62
90
 
63
91
  clear(): void {
64
92
  this.permissions.clear();
65
- this.hasSynced = false;
66
93
  }
67
94
  }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ DEFAULT_INSTALL_BACKGROUND_SERVICE,
4
+ SERVICE_LABEL,
5
+ buildServiceShellCommand,
6
+ getServicePaths,
7
+ renderLaunchdPlist,
8
+ renderSystemdUnit,
9
+ renderWindowsPowerShellScript,
10
+ } from './service';
11
+
12
+ describe('agent tunnel service definitions', () => {
13
+ test('defaults the interactive connection flow to the background service', () => {
14
+ expect(DEFAULT_INSTALL_BACKGROUND_SERVICE).toBe(true);
15
+ });
16
+
17
+ test('builds a command that runs the supervised tunnel agent', () => {
18
+ const command = buildServiceShellCommand();
19
+ expect(command).toContain("'run'");
20
+ expect(command).toContain("'--service'");
21
+ expect(command).toStartWith('exec ');
22
+ });
23
+
24
+ test('launchd plist restarts and runs at login', () => {
25
+ const plist = renderLaunchdPlist('exec /bin/echo tunnel');
26
+ expect(plist).toContain(`<string>${SERVICE_LABEL}</string>`);
27
+ expect(plist).toContain('<key>RunAtLoad</key>');
28
+ expect(plist).toContain('<key>KeepAlive</key>');
29
+ expect(plist).toContain('<key>Umask</key>');
30
+ expect(plist).toContain('agent-tunnel.out.log');
31
+ expect(plist).toContain('agent-tunnel.err.log');
32
+ });
33
+
34
+ test('systemd unit restarts forever', () => {
35
+ const unit = renderSystemdUnit('exec /bin/echo tunnel');
36
+ expect(unit).toContain('Description=Kortix Agent Tunnel');
37
+ expect(unit).toContain('Restart=always');
38
+ expect(unit).toContain('UMask=0077');
39
+ expect(unit).toContain('WantedBy=default.target');
40
+ expect(unit).toContain('agent-tunnel.out.log');
41
+ expect(unit).toContain('agent-tunnel.err.log');
42
+ });
43
+
44
+ test('windows scheduled-task script restarts forever', () => {
45
+ const script = renderWindowsPowerShellScript({
46
+ command: 'node',
47
+ args: ['agent-tunnel.js', 'run', '--service'],
48
+ });
49
+ expect(script).not.toContain('SetThreadExecutionState');
50
+ expect(script).toContain('while ($true)');
51
+ expect(script).toContain("& 'node' 'agent-tunnel.js' 'run' '--service'");
52
+ expect(script).toContain('Start-Sleep -Seconds 5');
53
+ });
54
+
55
+ test('service paths are under the user home', () => {
56
+ const paths = getServicePaths();
57
+ expect(paths.configDir).toContain('.agent-tunnel');
58
+ expect(paths.logDir).toContain('.agent-tunnel');
59
+ expect(paths.launchdPlist).toContain(`${SERVICE_LABEL}.plist`);
60
+ expect(paths.systemdUnit).toContain(`${SERVICE_LABEL}.service`);
61
+ expect(paths.windowsScript).toContain('agent-tunnel-service.ps1');
62
+ });
63
+ });