@kortix/agent-tunnel 0.15.1 → 0.15.3
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 +18 -5
- package/dist/agent-cli.js +19 -4
- package/package.json +1 -1
- package/src/agent/capabilities/security.test.ts +29 -1
- package/src/agent/capabilities/shell.ts +4 -3
- package/src/agent/security/command-validator.test.ts +4 -4
- package/src/agent/security/command-validator.ts +11 -5
- package/src/index.ts +1 -0
- package/src/shared/index.ts +1 -0
- package/src/shared/permissions.ts +28 -0
package/README.md
CHANGED
|
@@ -233,11 +233,24 @@ The local config is the maximum boundary. The server cannot widen configured
|
|
|
233
233
|
filesystem paths, blocked paths, shell commands, timeouts, file sizes, or desktop
|
|
234
234
|
features.
|
|
235
235
|
|
|
236
|
-
A `shell` grant is local code execution.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
236
|
+
A `shell` grant with no command list is local code execution. Grant it only to
|
|
237
|
+
a session you trust with this computer.
|
|
238
|
+
|
|
239
|
+
A command list (`allowedCommands` in the local config, `commands` in a
|
|
240
|
+
permission scope) holds whole command lines, and both lists must match:
|
|
241
|
+
|
|
242
|
+
- `git status` allows `git` with the one argument `status`, and nothing else.
|
|
243
|
+
- A trailing ` *` allows any further arguments: `npm run *` allows
|
|
244
|
+
`npm run build` but not `npm exec`.
|
|
245
|
+
- `git *`, `node *`, `python3 *`, `sh *`, `find *` and similar entries allow
|
|
246
|
+
any code: these programs run commands through their own flags
|
|
247
|
+
(`git -c alias.x=!cmd x`, `node -e`, `find -exec`).
|
|
248
|
+
- A bare `git` allows only `git` with no arguments. Agent Tunnel 0.1.2 and
|
|
249
|
+
earlier let it take any arguments; write `git *` for that.
|
|
250
|
+
|
|
251
|
+
Arguments compare one at a time, and no shell parses the command. The block
|
|
252
|
+
list matches executable names, and every path ending in that name (`/bin/rm`
|
|
253
|
+
is `rm`).
|
|
241
254
|
|
|
242
255
|
## Computer Use driver
|
|
243
256
|
|
package/dist/agent-cli.js
CHANGED
|
@@ -3209,6 +3209,21 @@ function validateDesktopScope(scope) {
|
|
|
3209
3209
|
}
|
|
3210
3210
|
return { valid: true, sanitized };
|
|
3211
3211
|
}
|
|
3212
|
+
function shellCommandAllowed(entries, command, args) {
|
|
3213
|
+
const argv = [command.trim(), ...args];
|
|
3214
|
+
return entries.some((entry) => {
|
|
3215
|
+
const anyArgs = entry.endsWith(" *");
|
|
3216
|
+
const fixed = (anyArgs ? entry.slice(0, -2) : entry).trim();
|
|
3217
|
+
if (!fixed)
|
|
3218
|
+
return false;
|
|
3219
|
+
if (fixed === argv[0] && (anyArgs || args.length === 0))
|
|
3220
|
+
return true;
|
|
3221
|
+
const tokens = fixed.split(/\s+/);
|
|
3222
|
+
if (anyArgs ? argv.length < tokens.length : argv.length !== tokens.length)
|
|
3223
|
+
return false;
|
|
3224
|
+
return tokens.every((token, index) => token === argv[index]);
|
|
3225
|
+
});
|
|
3226
|
+
}
|
|
3212
3227
|
function operationForMethod(method) {
|
|
3213
3228
|
if (method === "fs.stat")
|
|
3214
3229
|
return "read";
|
|
@@ -5532,7 +5547,7 @@ import { spawn as spawn5 } from "child_process";
|
|
|
5532
5547
|
|
|
5533
5548
|
// src/agent/security/command-validator.ts
|
|
5534
5549
|
var SHELL_METACHAR_REGEX = /[;&|`$(){}[\]<>!#~]/;
|
|
5535
|
-
function validateCommand(command, allowedCommands, blockedCommands) {
|
|
5550
|
+
function validateCommand(command, args, allowedCommands, blockedCommands) {
|
|
5536
5551
|
if (!command || typeof command !== "string") {
|
|
5537
5552
|
throw new Error("Command is required");
|
|
5538
5553
|
}
|
|
@@ -5546,7 +5561,7 @@ function validateCommand(command, allowedCommands, blockedCommands) {
|
|
|
5546
5561
|
throw new Error(`Command "${executable}" is blocked`);
|
|
5547
5562
|
}
|
|
5548
5563
|
if (allowedCommands.length > 0) {
|
|
5549
|
-
if (!allowedCommands
|
|
5564
|
+
if (!shellCommandAllowed(allowedCommands, executable, args)) {
|
|
5550
5565
|
throw new Error(`Command "${executable}" is not in the allowed commands list`);
|
|
5551
5566
|
}
|
|
5552
5567
|
}
|
|
@@ -5577,9 +5592,9 @@ function createShellCapability(config) {
|
|
|
5577
5592
|
}
|
|
5578
5593
|
const timeout = Math.min(requestedTimeout, config.shellMaxTimeout, typeof scope.maxTimeout === "number" ? scope.maxTimeout : config.shellMaxTimeout);
|
|
5579
5594
|
const scopedCommands = Array.isArray(scope.commands) ? scope.commands.filter((value) => typeof value === "string") : [];
|
|
5580
|
-
const executable = validateCommand(command, config.allowedCommands, config.blockedCommands);
|
|
5595
|
+
const executable = validateCommand(command, args, config.allowedCommands, config.blockedCommands);
|
|
5581
5596
|
if (scopedCommands.length > 0) {
|
|
5582
|
-
validateCommand(executable, scopedCommands, []);
|
|
5597
|
+
validateCommand(executable, args, scopedCommands, []);
|
|
5583
5598
|
}
|
|
5584
5599
|
if (cwd) {
|
|
5585
5600
|
validatePath(cwd, config.allowedPaths, config.blockedPaths);
|
package/package.json
CHANGED
|
@@ -145,12 +145,40 @@ describe('local capability permission enforcement', () => {
|
|
|
145
145
|
__permission: {
|
|
146
146
|
permissionId: 'permission-1',
|
|
147
147
|
capability: 'shell',
|
|
148
|
-
scope: { commands: ['node'], workingDir: root },
|
|
148
|
+
scope: { commands: ['node --version'], workingDir: root },
|
|
149
149
|
},
|
|
150
150
|
}),
|
|
151
151
|
).rejects.toThrow('outside allowed directories');
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
test('an allowed executable cannot take unapproved interpreter arguments', async () => {
|
|
155
|
+
const marker = join(root, 'pwned');
|
|
156
|
+
const payload = `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'x')`;
|
|
157
|
+
const permission = (commands: string[]) => ({
|
|
158
|
+
permissionId: 'permission-1',
|
|
159
|
+
capability: 'shell',
|
|
160
|
+
scope: { commands },
|
|
161
|
+
});
|
|
162
|
+
const scoped = createShellCapability(config()).methods.get('shell.exec')!;
|
|
163
|
+
await expect(
|
|
164
|
+
scoped({ command: 'node', args: ['-e', payload], __permission: permission(['node']) }),
|
|
165
|
+
).rejects.toThrow('not in the allowed commands list');
|
|
166
|
+
const local = createShellCapability({ ...config(), allowedCommands: ['node --version'] })
|
|
167
|
+
.methods.get('shell.exec')!;
|
|
168
|
+
await expect(
|
|
169
|
+
local({ command: 'node', args: ['-e', payload], __permission: permission(['node *']) }),
|
|
170
|
+
).rejects.toThrow('not in the allowed commands list');
|
|
171
|
+
await expect(readFile(marker, 'utf8')).rejects.toThrow();
|
|
172
|
+
|
|
173
|
+
const result = (await local({
|
|
174
|
+
command: 'node',
|
|
175
|
+
args: ['--version'],
|
|
176
|
+
__permission: permission(['node *']),
|
|
177
|
+
})) as { exitCode: number; stdout: string };
|
|
178
|
+
expect(result.exitCode).toBe(0);
|
|
179
|
+
expect(result.stdout).toStartWith('v');
|
|
180
|
+
});
|
|
181
|
+
|
|
154
182
|
test('disjoint local and permission command allowlists fail closed', async () => {
|
|
155
183
|
const handler = createShellCapability({ ...config(), allowedCommands: ['node'] }).methods.get(
|
|
156
184
|
'shell.exec',
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Security:
|
|
5
5
|
* - Commands are executed as array args (no shell interpolation)
|
|
6
|
-
* -
|
|
6
|
+
* - The whole command line (executable + args) is matched against allowedCommands
|
|
7
|
+
* and the permission's scope.commands; the executable against blockedCommands
|
|
7
8
|
* - Working directory is validated against allowedPaths / blockedPaths
|
|
8
9
|
* - Timeout enforcement
|
|
9
10
|
*/
|
|
@@ -54,9 +55,9 @@ export function createShellCapability(config: TunnelConfig): Capability {
|
|
|
54
55
|
const scopedCommands = Array.isArray(scope.commands)
|
|
55
56
|
? scope.commands.filter((value): value is string => typeof value === 'string')
|
|
56
57
|
: [];
|
|
57
|
-
const executable = validateCommand(command, config.allowedCommands, config.blockedCommands);
|
|
58
|
+
const executable = validateCommand(command, args, config.allowedCommands, config.blockedCommands);
|
|
58
59
|
if (scopedCommands.length > 0) {
|
|
59
|
-
validateCommand(executable, scopedCommands, []);
|
|
60
|
+
validateCommand(executable, args, scopedCommands, []);
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
if (cwd) {
|
|
@@ -4,14 +4,14 @@ import { validateCommand } from './command-validator';
|
|
|
4
4
|
describe('validateCommand blocked commands', () => {
|
|
5
5
|
test('a blocked name blocks its absolute and relative path spellings', () => {
|
|
6
6
|
for (const spelling of ['rm', '/bin/rm', '/usr/bin/rm', './rm']) {
|
|
7
|
-
expect(() => validateCommand(spelling, [], ['rm'])).toThrow(/is blocked/);
|
|
7
|
+
expect(() => validateCommand(spelling, [], [], ['rm'])).toThrow(/is blocked/);
|
|
8
8
|
}
|
|
9
9
|
});
|
|
10
10
|
test('an unrelated command with the blocked name inside a directory still runs', () => {
|
|
11
|
-
expect(validateCommand('/opt/rm-tools/ls', [], ['rm'])).toBe('/opt/rm-tools/ls');
|
|
11
|
+
expect(validateCommand('/opt/rm-tools/ls', [], [], ['rm'])).toBe('/opt/rm-tools/ls');
|
|
12
12
|
});
|
|
13
13
|
test('an allowlist stays an exact match', () => {
|
|
14
|
-
expect(() => validateCommand('/usr/bin/git', ['git'], [])).toThrow(/not in the allowed/);
|
|
15
|
-
expect(validateCommand('git', ['git'], [])).toBe('git');
|
|
14
|
+
expect(() => validateCommand('/usr/bin/git', [], ['git'], [])).toThrow(/not in the allowed/);
|
|
15
|
+
expect(validateCommand('git', [], ['git'], [])).toBe('git');
|
|
16
16
|
});
|
|
17
17
|
});
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
import { shellCommandAllowed } from '../../shared/permissions';
|
|
2
|
+
|
|
1
3
|
const SHELL_METACHAR_REGEX = /[;&|`$(){}[\]<>!#~]/;
|
|
2
4
|
|
|
5
|
+
/**
|
|
6
|
+
* `allowedCommands` entries are whole command lines (see `shellCommandAllowed`):
|
|
7
|
+
* `git status` allows only that, `git *` allows any git arguments.
|
|
8
|
+
* `blockedCommands` entries are executable names.
|
|
9
|
+
*/
|
|
3
10
|
export function validateCommand(
|
|
4
11
|
command: string,
|
|
5
|
-
|
|
6
|
-
|
|
12
|
+
args: readonly string[],
|
|
13
|
+
allowedCommands: readonly string[],
|
|
14
|
+
blockedCommands: readonly string[],
|
|
7
15
|
): string {
|
|
8
16
|
if (!command || typeof command !== 'string') {
|
|
9
17
|
throw new Error('Command is required');
|
|
@@ -18,15 +26,13 @@ export function validateCommand(
|
|
|
18
26
|
const executable = trimmed;
|
|
19
27
|
|
|
20
28
|
// A blocked name also blocks every path that ends in it: `/bin/rm` is `rm`.
|
|
21
|
-
// Arguments stay unrestricted: an allowed `git` or `python` runs code through
|
|
22
|
-
// its own flags, so a `shell` grant is local code execution, not a sandbox.
|
|
23
29
|
const name = executable.slice(executable.lastIndexOf('/') + 1);
|
|
24
30
|
if (blockedCommands.length > 0 && (blockedCommands.includes(executable) || blockedCommands.includes(name))) {
|
|
25
31
|
throw new Error(`Command "${executable}" is blocked`);
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
if (allowedCommands.length > 0) {
|
|
29
|
-
if (!allowedCommands
|
|
35
|
+
if (!shellCommandAllowed(allowedCommands, executable, args)) {
|
|
30
36
|
throw new Error(`Command "${executable}" is not in the allowed commands list`);
|
|
31
37
|
}
|
|
32
38
|
}
|
package/src/index.ts
CHANGED
package/src/shared/index.ts
CHANGED
|
@@ -253,6 +253,34 @@ function validateDesktopScope(scope: Record<string, unknown>): PermissionScopeVa
|
|
|
253
253
|
return { valid: true, sanitized };
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
/**
|
|
257
|
+
* A shell allow-list entry is the whole command line the person approved, not
|
|
258
|
+
* an executable name. `git status` allows exactly `git` with the one argument
|
|
259
|
+
* `status`. A trailing ` *` allows any further arguments: `git *` is every git
|
|
260
|
+
* invocation, and `git *`, `node *`, `python3 *` or `sh *` run any code through
|
|
261
|
+
* their own flags (`git -c alias.x=!cmd x`, `node -e`, `sh -c`).
|
|
262
|
+
* Arguments compare one token at a time, so an argument never matches a
|
|
263
|
+
* pattern token by containing whitespace. An entry with no ` *` whose whole
|
|
264
|
+
* text is the executable (a path with spaces) allows that executable with no
|
|
265
|
+
* arguments.
|
|
266
|
+
*/
|
|
267
|
+
export function shellCommandAllowed(
|
|
268
|
+
entries: readonly string[],
|
|
269
|
+
command: string,
|
|
270
|
+
args: readonly string[],
|
|
271
|
+
): boolean {
|
|
272
|
+
const argv = [command.trim(), ...args];
|
|
273
|
+
return entries.some((entry) => {
|
|
274
|
+
const anyArgs = entry.endsWith(' *');
|
|
275
|
+
const fixed = (anyArgs ? entry.slice(0, -2) : entry).trim();
|
|
276
|
+
if (!fixed) return false;
|
|
277
|
+
if (fixed === argv[0] && (anyArgs || args.length === 0)) return true;
|
|
278
|
+
const tokens = fixed.split(/\s+/);
|
|
279
|
+
if (anyArgs ? argv.length < tokens.length : argv.length !== tokens.length) return false;
|
|
280
|
+
return tokens.every((token, index) => token === argv[index]);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
256
284
|
export function operationForMethod(method: string): string {
|
|
257
285
|
if (method === 'fs.stat') return 'read';
|
|
258
286
|
const separator = method.indexOf('.');
|