@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.
- package/README.md +65 -0
- package/dist/agent-cli.js +4676 -3174
- package/dist/client-cli.js +202 -493
- package/package.json +24 -29
- package/src/agent/agent.ts +67 -17
- package/src/agent/capabilities/desktop/cua-driver.ts +284 -0
- package/src/agent/capabilities/desktop.ts +100 -167
- package/src/agent/capabilities/enabled-registry.ts +24 -0
- package/src/agent/capabilities/filesystem.ts +136 -32
- package/src/agent/capabilities/index.ts +1 -1
- package/src/agent/capabilities/security.test.ts +204 -0
- package/src/agent/capabilities/shell.ts +37 -3
- package/src/agent/cli-device-auth.test.ts +179 -0
- package/src/agent/cli-help.test.ts +25 -0
- package/src/agent/cli.ts +475 -38
- package/src/agent/config.test.ts +53 -0
- package/src/agent/config.ts +169 -7
- package/src/agent/index.ts +1 -0
- package/src/agent/security/command-validator.ts +4 -2
- package/src/agent/security/path-validator.ts +73 -18
- package/src/agent/security/permission-guard.test.ts +52 -0
- package/src/agent/security/permission-guard.ts +35 -8
- package/src/agent/service.test.ts +63 -0
- package/src/agent/service.ts +410 -0
- package/src/client/cli.test.ts +150 -547
- package/src/client/cli.ts +116 -539
- package/src/client/index.ts +1 -1
- package/src/client/tools.ts +95 -356
- package/src/client/tunnel-client.ts +50 -80
- package/src/index.ts +7 -1
- package/src/node-ws-polyfill.test.ts +18 -0
- package/src/node-ws-polyfill.ts +5 -3
- package/src/server/heartbeat.ts +13 -6
- package/src/server/relay.test.ts +72 -0
- package/src/server/relay.ts +50 -9
- package/src/server/server.test.ts +33 -0
- package/src/server/server.ts +26 -6
- package/src/server/ws-handler.test.ts +158 -0
- package/src/server/ws-handler.ts +94 -36
- package/src/shared/crypto.ts +2 -3
- package/src/shared/index.ts +8 -0
- package/src/shared/permissions.ts +292 -0
- package/src/shared/types.ts +70 -41
- package/dist/agent/index.d.ts +0 -140
- package/dist/agent/index.js +0 -21
- package/dist/agent/index.js.map +0 -1
- package/dist/chunk-7N7GSU6K.js +0 -34
- package/dist/client/index.d.ts +0 -183
- package/dist/client/index.js +0 -8
- package/dist/client/index.js.map +0 -1
- package/dist/index.d.ts +0 -7
- package/dist/index.js +0 -55
- package/dist/index.js.map +0 -1
- package/dist/server/index.d.ts +0 -89
- package/dist/server/index.js +0 -14
- package/dist/server/index.js.map +0 -1
- package/dist/shared/index.d.ts +0 -10
- package/dist/shared/index.js +0 -20
- package/dist/shared/index.js.map +0 -1
- package/dist/types-Dpwrd8Ai.d.ts +0 -194
- package/src/agent/capabilities/desktop/atspi-helper.ts +0 -345
- package/src/agent/capabilities/desktop/csharp-helper.ts +0 -914
- package/src/agent/capabilities/desktop/linux-driver.ts +0 -368
- package/src/agent/capabilities/desktop/macos-driver.ts +0 -601
- package/src/agent/capabilities/desktop/swift-helper.ts +0 -736
- package/src/agent/capabilities/desktop/types.ts +0 -201
- 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
|
+
});
|
package/src/agent/config.ts
CHANGED
|
@@ -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: [
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
}
|
package/src/agent/index.ts
CHANGED
|
@@ -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
|
-
):
|
|
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
|
|
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
|
-
|
|
15
|
-
path
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
26
|
+
return realpathSync(normalized);
|
|
27
27
|
} catch (err) {
|
|
28
28
|
const code = (err as NodeJS.ErrnoException).code;
|
|
29
|
-
if (code
|
|
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
|
-
|
|
38
|
-
|
|
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 =
|
|
45
|
-
return resolved
|
|
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 "${
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
75
|
+
return null;
|
|
57
76
|
}
|
|
58
77
|
}
|
|
59
78
|
|
|
60
|
-
return
|
|
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
|
+
});
|