@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,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
+ });
@@ -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?: (tunnelId: string, token: string) => Promise<AuthResult | null>;
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<string, { ws: WebSocket; timer: ReturnType<typeof setTimeout> }>();
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(tunnelId);
30
- try { ws.close(4001, 'auth timeout'); } catch {}
40
+ pendingConnections.delete(ws);
41
+ try {
42
+ ws.close(4001, 'auth timeout');
43
+ } catch {}
31
44
  }, authTimeoutMs);
32
45
 
33
- pendingConnections.set(tunnelId, { ws, timer });
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 = typeof message === 'string' ? message.length : (message as Buffer).byteLength;
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(`[tunnel-ws] Oversized message from ${tunnelId}: ${msgSize} bytes (limit: ${maxMessageSize})`);
42
- const pending = pendingConnections.get(tunnelId);
43
- const ws = pending?.ws;
44
- if (ws) {
45
- try { ws.close(4002, 'message too large'); } catch {}
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(tunnelId);
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 { pending.ws.close(4001, 'invalid auth message'); } catch {}
86
+ try {
87
+ ws.close(4001, 'invalid auth message');
88
+ } catch {}
57
89
  clearTimeout(pending.timer);
58
- pendingConnections.delete(tunnelId);
90
+ pendingConnections.delete(ws);
59
91
  return;
60
92
  }
61
93
 
62
94
  if (authMsg.type !== 'auth' || !authMsg.token) {
63
- try { pending.ws.close(4001, 'expected auth message'); } catch {}
95
+ try {
96
+ ws.close(4001, 'expected auth message');
97
+ } catch {}
64
98
  clearTimeout(pending.timer);
65
- pendingConnections.delete(tunnelId);
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
- try { pending.ws.close(4001, 'no authenticator configured'); } catch {}
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 { pending.ws.close(4001, 'authentication failed'); } catch {}
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
- pending.ws.send(JSON.stringify({ type: 'auth_ok', signingKey: result.signingKey }));
87
- } catch {}
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, pending.ws, result.signingKey, result.metadata);
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 { pending.ws.close(4001, 'authentication error'); } catch {}
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(tunnelId);
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(tunnelId);
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
  },
@@ -1,12 +1,11 @@
1
- import { createHash, createHmac, timingSafeEqual, randomBytes } from 'crypto';
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[bytes[i]! % CHARS.length];
8
+ result += CHARS[randomInt(CHARS.length)];
10
9
  }
11
10
  return result;
12
11
  }
@@ -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
+ }