@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,158 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { signMessage } from '../shared/crypto';
|
|
3
|
+
import { TunnelRelay } from './relay';
|
|
4
|
+
import { createWsHandlers } from './ws-handler';
|
|
5
|
+
|
|
6
|
+
interface FakeSocket extends WebSocket {
|
|
7
|
+
sent: string[];
|
|
8
|
+
closes: Array<{ code?: number; reason?: string }>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fakeWs(): FakeSocket {
|
|
12
|
+
const socket = {
|
|
13
|
+
readyState: WebSocket.OPEN,
|
|
14
|
+
sent: [] as string[],
|
|
15
|
+
closes: [] as Array<{ code?: number; reason?: string }>,
|
|
16
|
+
send(data: string) {
|
|
17
|
+
this.sent.push(data);
|
|
18
|
+
},
|
|
19
|
+
close(code?: number, reason?: string) {
|
|
20
|
+
this.closes.push({ code, reason });
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
return socket as unknown as FakeSocket;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function throwingWs(): FakeSocket {
|
|
27
|
+
const socket = fakeWs();
|
|
28
|
+
socket.send = () => {
|
|
29
|
+
throw new Error('socket closed');
|
|
30
|
+
};
|
|
31
|
+
return socket;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('tunnel WebSocket identity binding', () => {
|
|
35
|
+
test('passes the exact live capability registration into authentication', async () => {
|
|
36
|
+
const relay = new TunnelRelay();
|
|
37
|
+
let receivedAuth: unknown;
|
|
38
|
+
const handlers = createWsHandlers(relay, {
|
|
39
|
+
onAuthenticate: async (_tunnelId, _token, auth) => {
|
|
40
|
+
receivedAuth = auth;
|
|
41
|
+
return { signingKey: 'session-key', metadata: { capabilities: auth.capabilities } };
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
const socket = fakeWs();
|
|
45
|
+
handlers.onOpen('tunnel-1', socket);
|
|
46
|
+
await handlers.onMessage(
|
|
47
|
+
'tunnel-1',
|
|
48
|
+
socket,
|
|
49
|
+
JSON.stringify({
|
|
50
|
+
type: 'auth',
|
|
51
|
+
token: 'legitimate-token',
|
|
52
|
+
capabilities: ['filesystem', 'desktop'],
|
|
53
|
+
agentVersion: '0.1.2',
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(receivedAuth).toEqual({
|
|
58
|
+
type: 'auth',
|
|
59
|
+
token: 'legitimate-token',
|
|
60
|
+
capabilities: ['filesystem', 'desktop'],
|
|
61
|
+
agentVersion: '0.1.2',
|
|
62
|
+
});
|
|
63
|
+
expect(relay.getAgentMetadata('tunnel-1')?.capabilities).toEqual([
|
|
64
|
+
'filesystem',
|
|
65
|
+
'desktop',
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('authenticates the socket that supplied the credential during a same-tunnel race', async () => {
|
|
70
|
+
const relay = new TunnelRelay();
|
|
71
|
+
const handlers = createWsHandlers(relay, {
|
|
72
|
+
onAuthenticate: async (_tunnelId, token) =>
|
|
73
|
+
token === 'legitimate-token' ? { signingKey: 'session-key' } : null,
|
|
74
|
+
});
|
|
75
|
+
const legitimate = fakeWs();
|
|
76
|
+
const racing = fakeWs();
|
|
77
|
+
|
|
78
|
+
handlers.onOpen('tunnel-1', legitimate);
|
|
79
|
+
handlers.onOpen('tunnel-1', racing);
|
|
80
|
+
await handlers.onMessage(
|
|
81
|
+
'tunnel-1',
|
|
82
|
+
legitimate,
|
|
83
|
+
JSON.stringify({ type: 'auth', token: 'legitimate-token' }),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(legitimate.sent).toHaveLength(1);
|
|
87
|
+
expect(JSON.parse(legitimate.sent[0]!)).toEqual({
|
|
88
|
+
type: 'auth_ok',
|
|
89
|
+
signingKey: 'session-key',
|
|
90
|
+
});
|
|
91
|
+
expect(racing.sent).toEqual([]);
|
|
92
|
+
expect(relay.isConnected('tunnel-1')).toBe(true);
|
|
93
|
+
|
|
94
|
+
await handlers.onMessage(
|
|
95
|
+
'tunnel-1',
|
|
96
|
+
racing,
|
|
97
|
+
JSON.stringify({ type: 'auth', token: 'wrong-token' }),
|
|
98
|
+
);
|
|
99
|
+
expect(racing.closes).toContainEqual({ code: 4001, reason: 'authentication failed' });
|
|
100
|
+
expect(relay.isConnected('tunnel-1')).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('discards signed messages from a socket after that socket was replaced', () => {
|
|
104
|
+
const relay = new TunnelRelay();
|
|
105
|
+
const first = fakeWs();
|
|
106
|
+
const second = fakeWs();
|
|
107
|
+
const pongs: unknown[] = [];
|
|
108
|
+
relay.on('message:pong', (event) => pongs.push(event));
|
|
109
|
+
relay.registerAgent('tunnel-1', first, 'old-key');
|
|
110
|
+
relay.registerAgent('tunnel-1', second, 'new-key');
|
|
111
|
+
|
|
112
|
+
const payload = {
|
|
113
|
+
jsonrpc: '2.0' as const,
|
|
114
|
+
method: 'tunnel.pong',
|
|
115
|
+
params: { source: 'old-socket' },
|
|
116
|
+
};
|
|
117
|
+
const raw = JSON.stringify({
|
|
118
|
+
...payload,
|
|
119
|
+
_sig: signMessage('old-key', JSON.stringify(payload), 1),
|
|
120
|
+
_nonce: 1,
|
|
121
|
+
});
|
|
122
|
+
relay.handleAgentMessage('tunnel-1', first, raw);
|
|
123
|
+
|
|
124
|
+
expect(pongs).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('does not register a socket that cannot receive the session key', async () => {
|
|
128
|
+
const relay = new TunnelRelay();
|
|
129
|
+
const handlers = createWsHandlers(relay, {
|
|
130
|
+
onAuthenticate: async () => ({ signingKey: 'session-key' }),
|
|
131
|
+
});
|
|
132
|
+
const socket = throwingWs();
|
|
133
|
+
handlers.onOpen('tunnel-1', socket);
|
|
134
|
+
|
|
135
|
+
await handlers.onMessage(
|
|
136
|
+
'tunnel-1',
|
|
137
|
+
socket,
|
|
138
|
+
JSON.stringify({ type: 'auth', token: 'legitimate-token' }),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(relay.isConnected('tunnel-1')).toBe(false);
|
|
142
|
+
expect(socket.closes).toContainEqual({
|
|
143
|
+
code: 4001,
|
|
144
|
+
reason: 'authentication response failed',
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('closes an authenticated socket after an oversized message', () => {
|
|
149
|
+
const relay = new TunnelRelay();
|
|
150
|
+
const handlers = createWsHandlers(relay, { maxMessageSize: 8 });
|
|
151
|
+
const socket = fakeWs();
|
|
152
|
+
relay.registerAgent('tunnel-1', socket, 'session-key');
|
|
153
|
+
|
|
154
|
+
handlers.onMessage('tunnel-1', socket, '123456789');
|
|
155
|
+
|
|
156
|
+
expect(socket.closes).toContainEqual({ code: 4002, reason: 'message too large' });
|
|
157
|
+
});
|
|
158
|
+
});
|
package/src/server/ws-handler.ts
CHANGED
|
@@ -5,14 +5,18 @@ import type { TunnelAuthMessage, AuthResult } from '../shared/types';
|
|
|
5
5
|
export interface WsHandlerOptions {
|
|
6
6
|
heartbeat?: HeartbeatManager;
|
|
7
7
|
maxMessageSize?: number;
|
|
8
|
-
onAuthenticate?: (
|
|
8
|
+
onAuthenticate?: (
|
|
9
|
+
tunnelId: string,
|
|
10
|
+
token: string,
|
|
11
|
+
auth: TunnelAuthMessage,
|
|
12
|
+
) => Promise<AuthResult | null>;
|
|
9
13
|
authTimeoutMs?: number;
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
export interface WsHandlers {
|
|
13
17
|
onOpen(tunnelId: string, ws: WebSocket): void;
|
|
14
|
-
onMessage(tunnelId: string, message: string | Buffer): void;
|
|
15
|
-
onClose(tunnelId: string): void;
|
|
18
|
+
onMessage(tunnelId: string, ws: WebSocket, message: string | Buffer): void;
|
|
19
|
+
onClose(tunnelId: string, ws?: WebSocket): void;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export function createWsHandlers(relay: TunnelRelay, opts?: WsHandlerOptions): WsHandlers {
|
|
@@ -21,96 +25,150 @@ export function createWsHandlers(relay: TunnelRelay, opts?: WsHandlerOptions): W
|
|
|
21
25
|
const onAuthenticate = opts?.onAuthenticate;
|
|
22
26
|
const authTimeoutMs = opts?.authTimeoutMs ?? 10_000;
|
|
23
27
|
|
|
24
|
-
const pendingConnections = new Map<
|
|
28
|
+
const pendingConnections = new Map<
|
|
29
|
+
WebSocket,
|
|
30
|
+
{
|
|
31
|
+
tunnelId: string;
|
|
32
|
+
timer: ReturnType<typeof setTimeout>;
|
|
33
|
+
authenticating: boolean;
|
|
34
|
+
}
|
|
35
|
+
>();
|
|
25
36
|
|
|
26
37
|
return {
|
|
27
38
|
onOpen(tunnelId: string, ws: WebSocket) {
|
|
28
39
|
const timer = setTimeout(() => {
|
|
29
|
-
pendingConnections.delete(
|
|
30
|
-
try {
|
|
40
|
+
pendingConnections.delete(ws);
|
|
41
|
+
try {
|
|
42
|
+
ws.close(4001, 'auth timeout');
|
|
43
|
+
} catch {}
|
|
31
44
|
}, authTimeoutMs);
|
|
32
45
|
|
|
33
|
-
pendingConnections.set(
|
|
46
|
+
pendingConnections.set(ws, { tunnelId, timer, authenticating: false });
|
|
34
47
|
},
|
|
35
48
|
|
|
36
|
-
async onMessage(tunnelId: string, message: string | Buffer) {
|
|
49
|
+
async onMessage(tunnelId: string, ws: WebSocket, message: string | Buffer) {
|
|
37
50
|
const msgStr = typeof message === 'string' ? message : message.toString('utf-8');
|
|
38
|
-
const msgSize =
|
|
51
|
+
const msgSize =
|
|
52
|
+
typeof message === 'string'
|
|
53
|
+
? Buffer.byteLength(message, 'utf8')
|
|
54
|
+
: (message as Buffer).byteLength;
|
|
39
55
|
|
|
40
56
|
if (msgSize > maxMessageSize) {
|
|
41
|
-
console.warn(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
57
|
+
console.warn(
|
|
58
|
+
`[tunnel-ws] Oversized message from ${tunnelId}: ${msgSize} bytes (limit: ${maxMessageSize})`,
|
|
59
|
+
);
|
|
60
|
+
try {
|
|
61
|
+
ws.close(4002, 'message too large');
|
|
62
|
+
} catch {}
|
|
63
|
+
if (pendingConnections.has(ws)) {
|
|
64
|
+
const pending = pendingConnections.get(ws);
|
|
65
|
+
if (pending) clearTimeout(pending.timer);
|
|
66
|
+
pendingConnections.delete(ws);
|
|
46
67
|
}
|
|
47
68
|
return;
|
|
48
69
|
}
|
|
49
70
|
|
|
50
|
-
const pending = pendingConnections.get(
|
|
71
|
+
const pending = pendingConnections.get(ws);
|
|
51
72
|
if (pending) {
|
|
73
|
+
if (pending.tunnelId !== tunnelId) {
|
|
74
|
+
clearTimeout(pending.timer);
|
|
75
|
+
pendingConnections.delete(ws);
|
|
76
|
+
try {
|
|
77
|
+
ws.close(4001, 'tunnel identity mismatch');
|
|
78
|
+
} catch {}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (pending.authenticating) return;
|
|
52
82
|
let authMsg: TunnelAuthMessage;
|
|
53
83
|
try {
|
|
54
84
|
authMsg = JSON.parse(msgStr);
|
|
55
85
|
} catch {
|
|
56
|
-
try {
|
|
86
|
+
try {
|
|
87
|
+
ws.close(4001, 'invalid auth message');
|
|
88
|
+
} catch {}
|
|
57
89
|
clearTimeout(pending.timer);
|
|
58
|
-
pendingConnections.delete(
|
|
90
|
+
pendingConnections.delete(ws);
|
|
59
91
|
return;
|
|
60
92
|
}
|
|
61
93
|
|
|
62
94
|
if (authMsg.type !== 'auth' || !authMsg.token) {
|
|
63
|
-
try {
|
|
95
|
+
try {
|
|
96
|
+
ws.close(4001, 'expected auth message');
|
|
97
|
+
} catch {}
|
|
64
98
|
clearTimeout(pending.timer);
|
|
65
|
-
pendingConnections.delete(
|
|
99
|
+
pendingConnections.delete(ws);
|
|
66
100
|
return;
|
|
67
101
|
}
|
|
68
102
|
|
|
69
|
-
clearTimeout(pending.timer);
|
|
70
|
-
pendingConnections.delete(tunnelId);
|
|
71
|
-
|
|
72
103
|
if (!onAuthenticate) {
|
|
73
|
-
|
|
104
|
+
clearTimeout(pending.timer);
|
|
105
|
+
pendingConnections.delete(ws);
|
|
106
|
+
try {
|
|
107
|
+
ws.close(4001, 'no authenticator configured');
|
|
108
|
+
} catch {}
|
|
74
109
|
return;
|
|
75
110
|
}
|
|
76
111
|
|
|
112
|
+
pending.authenticating = true;
|
|
77
113
|
try {
|
|
78
|
-
const result = await onAuthenticate(tunnelId, authMsg.token);
|
|
114
|
+
const result = await onAuthenticate(tunnelId, authMsg.token, authMsg);
|
|
115
|
+
if (pendingConnections.get(ws) !== pending || ws.readyState !== WebSocket.OPEN) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
clearTimeout(pending.timer);
|
|
119
|
+
pendingConnections.delete(ws);
|
|
79
120
|
if (!result) {
|
|
80
|
-
try {
|
|
121
|
+
try {
|
|
122
|
+
ws.close(4001, 'authentication failed');
|
|
123
|
+
} catch {}
|
|
81
124
|
return;
|
|
82
125
|
}
|
|
83
126
|
|
|
84
|
-
// Send signing key to agent so it never needs the server secret
|
|
127
|
+
// Send signing key to agent so it never needs the server secret.
|
|
128
|
+
// A socket that cannot receive the key must never become active.
|
|
85
129
|
try {
|
|
86
|
-
|
|
87
|
-
|
|
130
|
+
ws.send(
|
|
131
|
+
JSON.stringify({
|
|
132
|
+
type: 'auth_ok',
|
|
133
|
+
signingKey: result.signingKey,
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
} catch {
|
|
137
|
+
try {
|
|
138
|
+
ws.close(4001, 'authentication response failed');
|
|
139
|
+
} catch {}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
88
142
|
|
|
89
|
-
relay.registerAgent(tunnelId,
|
|
143
|
+
relay.registerAgent(tunnelId, ws, result.signingKey, result.metadata);
|
|
90
144
|
if (heartbeat) {
|
|
91
145
|
heartbeat.register(tunnelId);
|
|
92
146
|
}
|
|
93
147
|
} catch (err) {
|
|
148
|
+
clearTimeout(pending.timer);
|
|
149
|
+
pendingConnections.delete(ws);
|
|
94
150
|
console.error(`[tunnel-ws] Auth error for ${tunnelId}:`, err);
|
|
95
|
-
try {
|
|
151
|
+
try {
|
|
152
|
+
ws.close(4001, 'authentication error');
|
|
153
|
+
} catch {}
|
|
96
154
|
}
|
|
97
155
|
|
|
98
156
|
return;
|
|
99
157
|
}
|
|
100
158
|
|
|
101
|
-
relay.handleAgentMessage(tunnelId, message);
|
|
159
|
+
relay.handleAgentMessage(tunnelId, ws, message);
|
|
102
160
|
},
|
|
103
161
|
|
|
104
|
-
onClose(tunnelId: string) {
|
|
105
|
-
const pending = pendingConnections.get(
|
|
162
|
+
onClose(tunnelId: string, ws?: WebSocket) {
|
|
163
|
+
const pending = ws ? pendingConnections.get(ws) : undefined;
|
|
106
164
|
if (pending) {
|
|
107
165
|
clearTimeout(pending.timer);
|
|
108
|
-
pendingConnections.delete(
|
|
166
|
+
pendingConnections.delete(ws!);
|
|
109
167
|
return;
|
|
110
168
|
}
|
|
111
169
|
|
|
112
|
-
relay.unregisterAgent(tunnelId);
|
|
113
|
-
if (heartbeat) {
|
|
170
|
+
const removed = relay.unregisterAgent(tunnelId, ws);
|
|
171
|
+
if (removed && heartbeat) {
|
|
114
172
|
heartbeat.unregister(tunnelId);
|
|
115
173
|
}
|
|
116
174
|
},
|
package/src/shared/crypto.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import { createHash, createHmac, timingSafeEqual,
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual, randomInt } from 'crypto';
|
|
2
2
|
|
|
3
3
|
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
4
4
|
|
|
5
5
|
function randomAlphanumeric(length: number): string {
|
|
6
|
-
const bytes = randomBytes(length);
|
|
7
6
|
let result = '';
|
|
8
7
|
for (let i = 0; i < length; i++) {
|
|
9
|
-
result += CHARS[
|
|
8
|
+
result += CHARS[randomInt(CHARS.length)];
|
|
10
9
|
}
|
|
11
10
|
return result;
|
|
12
11
|
}
|
package/src/shared/index.ts
CHANGED
|
@@ -33,3 +33,11 @@ export type {
|
|
|
33
33
|
} from './types';
|
|
34
34
|
|
|
35
35
|
export { TunnelErrorCode, TunnelMethods } from './types';
|
|
36
|
+
export {
|
|
37
|
+
capabilityForMethod,
|
|
38
|
+
desktopFeatureForMethod,
|
|
39
|
+
isTunnelCapability,
|
|
40
|
+
operationForMethod,
|
|
41
|
+
validateTunnelPermissionScope,
|
|
42
|
+
} from './permissions';
|
|
43
|
+
export type { PermissionScopeValidationResult } from './permissions';
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { TunnelMethods, type TunnelCapability } from './types';
|
|
2
|
+
|
|
3
|
+
const VALID_CAPABILITIES = new Set<TunnelCapability>(['filesystem', 'shell', 'desktop']);
|
|
4
|
+
const VALID_FILESYSTEM_OPERATIONS = new Set(['read', 'write', 'list', 'delete']);
|
|
5
|
+
const VALID_DESKTOP_FEATURES = new Set([
|
|
6
|
+
'screenshot',
|
|
7
|
+
'mouse',
|
|
8
|
+
'keyboard',
|
|
9
|
+
'windows',
|
|
10
|
+
'apps',
|
|
11
|
+
'clipboard',
|
|
12
|
+
'accessibility',
|
|
13
|
+
'computer_use',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export interface PermissionScopeValidationResult {
|
|
17
|
+
valid: boolean;
|
|
18
|
+
error?: string;
|
|
19
|
+
sanitized?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DESKTOP_METHOD_FEATURES: Readonly<Record<string, string>> = {
|
|
23
|
+
'desktop.cua.ensure': 'computer_use',
|
|
24
|
+
'desktop.cua.start_daemon': 'computer_use',
|
|
25
|
+
'desktop.cua.status': 'computer_use',
|
|
26
|
+
'desktop.cua.version': 'computer_use',
|
|
27
|
+
'desktop.cua.list_tools': 'computer_use',
|
|
28
|
+
'desktop.cua.describe': 'computer_use',
|
|
29
|
+
'desktop.cua.bring_to_front': 'windows',
|
|
30
|
+
'desktop.cua.check_for_update': 'computer_use',
|
|
31
|
+
'desktop.cua.check_permissions': 'computer_use',
|
|
32
|
+
'desktop.cua.click': 'mouse',
|
|
33
|
+
'desktop.cua.double_click': 'mouse',
|
|
34
|
+
'desktop.cua.drag': 'mouse',
|
|
35
|
+
'desktop.cua.end_session': 'computer_use',
|
|
36
|
+
'desktop.cua.get_accessibility_tree': 'accessibility',
|
|
37
|
+
'desktop.cua.get_agent_cursor_state': 'mouse',
|
|
38
|
+
'desktop.cua.get_config': 'computer_use',
|
|
39
|
+
'desktop.cua.get_cursor_position': 'mouse',
|
|
40
|
+
'desktop.cua.get_recording_state': 'computer_use',
|
|
41
|
+
'desktop.cua.get_screen_size': 'screenshot',
|
|
42
|
+
'desktop.cua.get_window_state': 'accessibility',
|
|
43
|
+
'desktop.cua.hotkey': 'keyboard',
|
|
44
|
+
'desktop.cua.kill_app': 'apps',
|
|
45
|
+
'desktop.cua.launch_app': 'apps',
|
|
46
|
+
'desktop.cua.list_apps': 'apps',
|
|
47
|
+
'desktop.cua.list_windows': 'windows',
|
|
48
|
+
'desktop.cua.move_cursor': 'mouse',
|
|
49
|
+
'desktop.cua.page': 'accessibility',
|
|
50
|
+
'desktop.cua.press_key': 'keyboard',
|
|
51
|
+
'desktop.cua.replay_trajectory': 'computer_use',
|
|
52
|
+
'desktop.cua.right_click': 'mouse',
|
|
53
|
+
'desktop.cua.scroll': 'keyboard',
|
|
54
|
+
'desktop.cua.set_agent_cursor_enabled': 'mouse',
|
|
55
|
+
'desktop.cua.set_agent_cursor_motion': 'mouse',
|
|
56
|
+
'desktop.cua.set_agent_cursor_style': 'mouse',
|
|
57
|
+
'desktop.cua.set_config': 'computer_use',
|
|
58
|
+
'desktop.cua.set_value': 'accessibility',
|
|
59
|
+
'desktop.cua.start_recording': 'screenshot',
|
|
60
|
+
'desktop.cua.install_ffmpeg': 'computer_use',
|
|
61
|
+
'desktop.cua.start_session': 'computer_use',
|
|
62
|
+
'desktop.cua.stop_recording': 'screenshot',
|
|
63
|
+
'desktop.cua.type_text': 'keyboard',
|
|
64
|
+
'desktop.cua.zoom': 'screenshot',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function capabilityForMethod(method: string): TunnelCapability | null {
|
|
68
|
+
const capability = (TunnelMethods as Readonly<Record<string, TunnelCapability | null>>)[method];
|
|
69
|
+
return capability ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isTunnelCapability(value: string): value is TunnelCapability {
|
|
73
|
+
return VALID_CAPABILITIES.has(value as TunnelCapability);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Validate permission scopes at every trust boundary.
|
|
78
|
+
*
|
|
79
|
+
* An empty object is the explicit unrestricted scope. A present restriction
|
|
80
|
+
* field must contain at least one value. This prevents `{ commands: [] }`,
|
|
81
|
+
* `{ paths: [] }`, or `{ features: [] }` from silently becoming unrestricted.
|
|
82
|
+
*/
|
|
83
|
+
export function validateTunnelPermissionScope(
|
|
84
|
+
capability: string,
|
|
85
|
+
input: unknown,
|
|
86
|
+
): PermissionScopeValidationResult {
|
|
87
|
+
if (!isTunnelCapability(capability)) {
|
|
88
|
+
return { valid: false, error: `Unknown capability: ${capability}` };
|
|
89
|
+
}
|
|
90
|
+
if (!isPlainRecord(input)) {
|
|
91
|
+
return { valid: false, error: 'Scope must be an object' };
|
|
92
|
+
}
|
|
93
|
+
if (Object.keys(input).length === 0) {
|
|
94
|
+
return { valid: true, sanitized: {} };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
switch (capability) {
|
|
98
|
+
case 'filesystem':
|
|
99
|
+
return validateFilesystemScope(input);
|
|
100
|
+
case 'shell':
|
|
101
|
+
return validateShellScope(input);
|
|
102
|
+
case 'desktop':
|
|
103
|
+
return validateDesktopScope(input);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
108
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function rejectUnknownFields(
|
|
112
|
+
scope: Record<string, unknown>,
|
|
113
|
+
allowed: readonly string[],
|
|
114
|
+
): PermissionScopeValidationResult | null {
|
|
115
|
+
const unknown = Object.keys(scope).find((key) => !allowed.includes(key));
|
|
116
|
+
return unknown ? { valid: false, error: `Unknown scope field: "${unknown}"` } : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function copyScopeLabel(
|
|
120
|
+
scope: Record<string, unknown>,
|
|
121
|
+
sanitized: Record<string, unknown>,
|
|
122
|
+
): PermissionScopeValidationResult | null {
|
|
123
|
+
if (!('scope' in scope)) return null;
|
|
124
|
+
if (typeof scope.scope !== 'string' || scope.scope.length === 0 || scope.scope.length > 255) {
|
|
125
|
+
return { valid: false, error: 'scope.scope must be a string between 1 and 255 characters' };
|
|
126
|
+
}
|
|
127
|
+
sanitized.scope = scope.scope;
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function validateNonEmptyStringArray(
|
|
132
|
+
value: unknown,
|
|
133
|
+
field: string,
|
|
134
|
+
options: { maxItems?: number; maxLength?: number } = {},
|
|
135
|
+
): PermissionScopeValidationResult | string[] {
|
|
136
|
+
const maxItems = options.maxItems ?? 100;
|
|
137
|
+
const maxLength = options.maxLength ?? 4096;
|
|
138
|
+
if (
|
|
139
|
+
!Array.isArray(value) ||
|
|
140
|
+
value.length === 0 ||
|
|
141
|
+
value.length > maxItems ||
|
|
142
|
+
!value.every(
|
|
143
|
+
(item) =>
|
|
144
|
+
typeof item === 'string' &&
|
|
145
|
+
item.length > 0 &&
|
|
146
|
+
item.length <= maxLength,
|
|
147
|
+
) ||
|
|
148
|
+
new Set(value).size !== value.length
|
|
149
|
+
) {
|
|
150
|
+
return {
|
|
151
|
+
valid: false,
|
|
152
|
+
error: `scope.${field} must contain 1-${maxItems} unique non-empty strings`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return [...value];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function validateFilesystemScope(scope: Record<string, unknown>): PermissionScopeValidationResult {
|
|
159
|
+
const unknown = rejectUnknownFields(scope, [
|
|
160
|
+
'scope',
|
|
161
|
+
'paths',
|
|
162
|
+
'operations',
|
|
163
|
+
'excludePatterns',
|
|
164
|
+
'maxFileSize',
|
|
165
|
+
]);
|
|
166
|
+
if (unknown) return unknown;
|
|
167
|
+
const sanitized: Record<string, unknown> = {};
|
|
168
|
+
const labelError = copyScopeLabel(scope, sanitized);
|
|
169
|
+
if (labelError) return labelError;
|
|
170
|
+
|
|
171
|
+
if ('paths' in scope) {
|
|
172
|
+
const paths = validateNonEmptyStringArray(scope.paths, 'paths');
|
|
173
|
+
if (!Array.isArray(paths)) return paths;
|
|
174
|
+
sanitized.paths = paths;
|
|
175
|
+
}
|
|
176
|
+
if ('operations' in scope) {
|
|
177
|
+
const operations = validateNonEmptyStringArray(scope.operations, 'operations', {
|
|
178
|
+
maxItems: VALID_FILESYSTEM_OPERATIONS.size,
|
|
179
|
+
maxLength: 16,
|
|
180
|
+
});
|
|
181
|
+
if (!Array.isArray(operations)) return operations;
|
|
182
|
+
const invalid = operations.find((operation) => !VALID_FILESYSTEM_OPERATIONS.has(operation));
|
|
183
|
+
if (invalid) return { valid: false, error: `Invalid filesystem operation: "${invalid}"` };
|
|
184
|
+
sanitized.operations = operations;
|
|
185
|
+
}
|
|
186
|
+
if ('excludePatterns' in scope) {
|
|
187
|
+
if (!Array.isArray(scope.excludePatterns)) {
|
|
188
|
+
return { valid: false, error: 'scope.excludePatterns must be an array of strings' };
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
scope.excludePatterns.length > 100 ||
|
|
192
|
+
!scope.excludePatterns.every(
|
|
193
|
+
(pattern) => typeof pattern === 'string' && pattern.length > 0 && pattern.length <= 1024,
|
|
194
|
+
) ||
|
|
195
|
+
new Set(scope.excludePatterns).size !== scope.excludePatterns.length
|
|
196
|
+
) {
|
|
197
|
+
return {
|
|
198
|
+
valid: false,
|
|
199
|
+
error: 'scope.excludePatterns must contain at most 100 unique non-empty strings',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
sanitized.excludePatterns = [...scope.excludePatterns];
|
|
203
|
+
}
|
|
204
|
+
if ('maxFileSize' in scope) {
|
|
205
|
+
if (
|
|
206
|
+
typeof scope.maxFileSize !== 'number' ||
|
|
207
|
+
!Number.isSafeInteger(scope.maxFileSize) ||
|
|
208
|
+
scope.maxFileSize <= 0
|
|
209
|
+
) {
|
|
210
|
+
return { valid: false, error: 'scope.maxFileSize must be a positive safe integer' };
|
|
211
|
+
}
|
|
212
|
+
sanitized.maxFileSize = scope.maxFileSize;
|
|
213
|
+
}
|
|
214
|
+
return { valid: true, sanitized };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function validateShellScope(scope: Record<string, unknown>): PermissionScopeValidationResult {
|
|
218
|
+
const unknown = rejectUnknownFields(scope, ['scope', 'commands', 'workingDir', 'maxTimeout']);
|
|
219
|
+
if (unknown) return unknown;
|
|
220
|
+
const sanitized: Record<string, unknown> = {};
|
|
221
|
+
const labelError = copyScopeLabel(scope, sanitized);
|
|
222
|
+
if (labelError) return labelError;
|
|
223
|
+
|
|
224
|
+
if ('commands' in scope) {
|
|
225
|
+
const commands = validateNonEmptyStringArray(scope.commands, 'commands', {
|
|
226
|
+
maxItems: 100,
|
|
227
|
+
maxLength: 4096,
|
|
228
|
+
});
|
|
229
|
+
if (!Array.isArray(commands)) return commands;
|
|
230
|
+
sanitized.commands = commands;
|
|
231
|
+
}
|
|
232
|
+
if ('workingDir' in scope) {
|
|
233
|
+
if (
|
|
234
|
+
typeof scope.workingDir !== 'string' ||
|
|
235
|
+
scope.workingDir.length === 0 ||
|
|
236
|
+
scope.workingDir.length > 4096
|
|
237
|
+
) {
|
|
238
|
+
return { valid: false, error: 'scope.workingDir must be a non-empty string' };
|
|
239
|
+
}
|
|
240
|
+
sanitized.workingDir = scope.workingDir;
|
|
241
|
+
}
|
|
242
|
+
if ('maxTimeout' in scope) {
|
|
243
|
+
if (
|
|
244
|
+
typeof scope.maxTimeout !== 'number' ||
|
|
245
|
+
!Number.isSafeInteger(scope.maxTimeout) ||
|
|
246
|
+
scope.maxTimeout <= 0
|
|
247
|
+
) {
|
|
248
|
+
return { valid: false, error: 'scope.maxTimeout must be a positive safe integer' };
|
|
249
|
+
}
|
|
250
|
+
sanitized.maxTimeout = scope.maxTimeout;
|
|
251
|
+
}
|
|
252
|
+
return { valid: true, sanitized };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function validateDesktopScope(scope: Record<string, unknown>): PermissionScopeValidationResult {
|
|
256
|
+
const unknown = rejectUnknownFields(scope, ['scope', 'features']);
|
|
257
|
+
if (unknown) return unknown;
|
|
258
|
+
const sanitized: Record<string, unknown> = {};
|
|
259
|
+
const labelError = copyScopeLabel(scope, sanitized);
|
|
260
|
+
if (labelError) return labelError;
|
|
261
|
+
|
|
262
|
+
if ('features' in scope) {
|
|
263
|
+
const features = validateNonEmptyStringArray(scope.features, 'features', {
|
|
264
|
+
maxItems: VALID_DESKTOP_FEATURES.size,
|
|
265
|
+
maxLength: 32,
|
|
266
|
+
});
|
|
267
|
+
if (!Array.isArray(features)) return features;
|
|
268
|
+
const invalid = features.find((feature) => !VALID_DESKTOP_FEATURES.has(feature));
|
|
269
|
+
if (invalid) return { valid: false, error: `Invalid desktop feature: "${invalid}"` };
|
|
270
|
+
sanitized.features = features;
|
|
271
|
+
}
|
|
272
|
+
return { valid: true, sanitized };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function operationForMethod(method: string): string {
|
|
276
|
+
if (method === 'fs.stat') return 'read';
|
|
277
|
+
const separator = method.indexOf('.');
|
|
278
|
+
return separator === -1 ? method : method.slice(separator + 1);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function desktopFeatureForMethod(
|
|
282
|
+
method: string,
|
|
283
|
+
args: Record<string, unknown> = {},
|
|
284
|
+
): string | undefined {
|
|
285
|
+
if (method === 'desktop.cua.call') {
|
|
286
|
+
const tool = args.tool;
|
|
287
|
+
if (typeof tool !== 'string' || tool.length === 0) return undefined;
|
|
288
|
+
const toolMethod = tool.startsWith('desktop.cua.') ? tool : `desktop.cua.${tool}`;
|
|
289
|
+
return DESKTOP_METHOD_FEATURES[toolMethod];
|
|
290
|
+
}
|
|
291
|
+
return DESKTOP_METHOD_FEATURES[method];
|
|
292
|
+
}
|