@kortix/agent-tunnel 0.15.2 → 0.15.4

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 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. The allow list matches the executable
237
- name exactly and the block list also matches every path ending in that name
238
- (`/bin/rm` is `rm`). Arguments are not restricted: an allowed `git`, `find`, or
239
- `python` can run any code through its own flags. Grant `shell` only to a session
240
- you trust with this computer.
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.includes(executable)) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kortix/agent-tunnel",
3
- "version": "0.15.2",
3
+ "version": "0.15.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/kortix-ai/CompanyOS.git",
@@ -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
- * - First arg (executable) is validated against allowedCommands / blockedCommands
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
- allowedCommands: string[],
6
- blockedCommands: string[],
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.includes(executable)) {
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
@@ -12,6 +12,7 @@ export {
12
12
  normalizeDesktopCall,
13
13
  isTunnelCapability,
14
14
  operationForMethod,
15
+ shellCommandAllowed,
15
16
  validateTunnelPermissionScope,
16
17
  validateFilesystemParams,
17
18
  } from './shared';
@@ -38,6 +38,7 @@ export {
38
38
  normalizeDesktopCall,
39
39
  isTunnelCapability,
40
40
  operationForMethod,
41
+ shellCommandAllowed,
41
42
  validateTunnelPermissionScope,
42
43
  } from './permissions';
43
44
  export type { PermissionScopeValidationResult } from './permissions';
@@ -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('.');