@remcp/runtime 0.2.106 → 0.2.108

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/src/policy.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import path from 'node:path';
1
2
  import { runtimeConfig } from './config.mjs';
2
3
  import { recordEvent } from './telemetry.mjs';
3
4
  import { fail } from './util.mjs';
@@ -10,7 +11,10 @@ import { fail } from './util.mjs';
10
11
  // Rules are matched against the *command word* of each shell segment, so a read-only
11
12
  // command that merely mentions a dangerous word (`grep -n format README.md`,
12
13
  // `cat notes/shutdown.md`) is not refused.
13
- const WRAPPERS = new Set(['sudo', 'doas', 'env', 'nohup', 'command', 'builtin', 'time', 'nice', 'ionice', 'stdbuf', 'timeout', 'setsid', 'exec']);
14
+ const WRAPPERS = new Set(['sudo', 'doas', 'env', 'nohup', 'command', 'builtin', 'time', 'nice', 'ionice', 'stdbuf', 'timeout', 'setsid', 'exec', 'busybox']);
15
+ const SHELL_WRAPPERS = /^(?:sh|bash|zsh|dash|ksh|fish|cmd(?:\.exe)?|powershell(?:\.exe)?|pwsh(?:\.exe)?)$/i;
16
+ const INTERPRETER_EXECUTION = /\b(?:os\.system|subprocess\.(?:run|call|check_call|check_output|Popen)|child_process|\bexec\s*\(|\beval\s*\(|\bspawn\s*\()/i;
17
+ const CATASTROPHIC_TEXT = /(?:mkfs(?:\.[a-z0-9]+)?\b|\b(?:fdisk|sfdisk|cfdisk|parted|diskpart|gdisk)\b|\bdd\b[^\n]*(?:\bof=\s*\/dev\/|\bof=\s*\\\\\.\\)|\b(?:shutdown|reboot|halt|poweroff|restart-computer|stop-computer)\b|\brm\b[^\n]*(?:--recursive|--force|-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)[^\n]*(?:\s|\s*\/\s*|\s*~\s*|\s*\$HOME\b|\s*\$\{HOME\})|\bpython[0-9.]*\b[^\n]*(?:os\.system|subprocess|rm\s+-[^\n]*r[^\n]*f))/i;
14
18
 
15
19
  const DANGEROUS_RULES = [
16
20
  { id: 'filesystem-format', description: 'formats a filesystem', commands: /^mkfs(\.[a-z0-9]+)?$/i, args: () => true },
@@ -18,15 +22,19 @@ const DANGEROUS_RULES = [
18
22
  { id: 'disk-partition', description: 'repartitions a disk', commands: /^(fdisk|sfdisk|cfdisk|parted|diskpart|gdisk)$/i, args: () => true },
19
23
  { id: 'redirect-to-device', description: 'redirects output into a block device', commands: null, anySegment: />>?\s*\/dev\/(sd|hd|vd|nvme|mmcblk|disk)/i },
20
24
  { id: 'host-power', description: 'powers off or reboots the machine', commands: /^(shutdown|reboot|halt|poweroff|restart-computer|stop-computer)$/i, args: () => true },
25
+ { id: 'service-power', description: 'changes system power through a service manager', commands: /^systemctl$/i, args: segment => /\b(?:poweroff|reboot|halt|shutdown)\b/i.test(segment) },
21
26
  { id: 'init-runlevel', description: 'changes the init runlevel', commands: /^init$/i, args: segment => /\binit\s+[06]\b/i.test(segment) },
22
27
  { id: 'fork-bomb', description: 'starts a fork bomb', commands: null, anySegment: /:\s*\(\s*\)\s*\{[^}]*\}\s*;\s*:/ },
23
28
  {
24
29
  id: 'recursive-root-delete',
25
30
  description: 'recursively deletes a root or home path',
26
31
  commands: /^rm$/i,
27
- args: segment => /(^|\s)-[a-z]*r[a-z]*(\s|$)/i.test(segment)
28
- && /(^|\s)-[a-z]*f[a-z]*(\s|$)/i.test(segment)
29
- && /(\s)(\/|\/\*|~|~\/\*|\$HOME|\$\{HOME\})(\s|$)/i.test(segment),
32
+ args: segment => {
33
+ const recursive = /(^|\s)(?:--recursive|-[a-z]*r[a-z]*)(?=\s|$)/i.test(segment);
34
+ const force = /(^|\s)(?:--force|-[a-z]*f[a-z]*)(?=\s|$)/i.test(segment);
35
+ const rootTarget = /(\s)(\/|\/\*|~|~\/\*|\$HOME|\$\{HOME\})(\s|$)/i.test(segment);
36
+ return recursive && force && rootTarget;
37
+ },
30
38
  },
31
39
  { id: 'chmod-root', description: 'recursively rewrites permissions on a root path', commands: /^chmod$/i, args: segment => /(\s)(\/|\/\*)(\s|$)/.test(segment) },
32
40
  { id: 'chown-root', description: 'recursively rewrites ownership on a root path', commands: /^chown$/i, args: segment => /(\s)(\/|\/\*)(\s|$)/.test(segment) },
@@ -39,6 +47,10 @@ function normalize(command) {
39
47
  return String(command).replace(/\s+/g, ' ').trim();
40
48
  }
41
49
 
50
+ function matchingText(value) {
51
+ return String(value).replace(/(["'])(.*?)\1/g, '$2').replace(/\\(["'\\$`])/g, '$1');
52
+ }
53
+
42
54
  // Split on shell control operators so each simple command is judged on its own command
43
55
  // word instead of on every word in the line.
44
56
  function segments(command) {
@@ -49,14 +61,15 @@ function segments(command) {
49
61
  }
50
62
 
51
63
  function commandWord(segment) {
52
- const tokens = segment.split(' ').filter(Boolean);
64
+ const tokens = matchingText(segment).split(' ').filter(Boolean);
53
65
  while (tokens.length) {
54
66
  const token = tokens[0];
55
67
  if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { tokens.shift(); continue; }
56
- if (WRAPPERS.has(token.toLowerCase())) {
68
+ const tokenName = path.basename(token).replace(/\.(exe|cmd|bat)$/i, '').toLowerCase();
69
+ if (WRAPPERS.has(tokenName)) {
57
70
  tokens.shift();
58
71
  while (tokens.length && tokens[0].startsWith('-')) tokens.shift();
59
- if (token.toLowerCase() === 'timeout' && tokens.length && /^\d+[smhd]?$/.test(tokens[0])) tokens.shift();
72
+ if (tokenName === 'timeout' && tokens.length && /^\d+[smhd]?$/.test(tokens[0])) tokens.shift();
60
73
  continue;
61
74
  }
62
75
  break;
@@ -65,22 +78,42 @@ function commandWord(segment) {
65
78
  return word.replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat)$/i, '');
66
79
  }
67
80
 
81
+ function nestedCommandValues(command) {
82
+ const values = [];
83
+ const normalized = String(command || '');
84
+ const substitution = /\$\(([\s\S]*?)\)|`([^`]*)`/g;
85
+ for (const match of normalized.matchAll(substitution)) values.push(match[1] || match[2] || '');
86
+ const shellCommand = /(^|\s)(?:-c|--command|-e|--eval)\s+(?:"([\s\S]*?)"|'([\s\S]*?)'|([^\s;]+))/g;
87
+ for (const match of normalized.matchAll(shellCommand)) values.push(match[2] || match[3] || match[4] || '');
88
+ return values;
89
+ }
90
+
91
+ function nestedExecutionIsDangerous(command) {
92
+ const normalized = String(command || '');
93
+ const shell = normalized.split(/\s+/).some(token => SHELL_WRAPPERS.test(path.basename(token).replace(/\.(exe|cmd|bat)$/i, '')));
94
+ return (shell || INTERPRETER_EXECUTION.test(normalized)) && CATASTROPHIC_TEXT.test(normalized);
95
+ }
96
+
68
97
  export function checkCommand(command) {
69
98
  if (runtimeConfig.unrestricted) return { warned: false, mode: 'allow', unrestricted: true, findings: [] };
70
99
  const normalized = normalize(command);
71
- const parts = segments(normalized);
100
+ const nested = nestedCommandValues(normalized);
101
+ const commands = [normalized, ...nested];
102
+ const parts = commands.flatMap(segments);
72
103
  const findings = [];
73
104
  for (const blocked of runtimeConfig.blockedCommands) {
74
105
  if (normalized.toLowerCase().includes(blocked.toLowerCase())) findings.push({ id: 'policy', description: blocked, source: 'device-policy' });
75
106
  }
76
107
  if (runtimeConfig.dangerousCommands !== 'allow') {
77
108
  for (const part of parts) {
109
+ const matchText = matchingText(part);
78
110
  const word = commandWord(part);
79
111
  for (const rule of DANGEROUS_RULES) {
80
- const matches = rule.anySegment ? rule.anySegment.test(part) : Boolean(word && rule.commands?.test(word) && rule.args(part));
112
+ const matches = rule.anySegment ? rule.anySegment.test(matchText) : Boolean(word && rule.commands?.test(word) && rule.args(matchText));
81
113
  if (matches) findings.push({ id: rule.id, description: rule.description, source: 'builtin' });
82
114
  }
83
115
  }
116
+ if (nestedExecutionIsDangerous(matchingText(normalized))) findings.push({ id: 'nested-catastrophic-command', description: 'executes a catastrophic command through a shell or interpreter', source: 'builtin' });
84
117
  }
85
118
  if (!findings.length) return { warned: false, mode: runtimeConfig.dangerousCommands };
86
119
  const unique = [...new Map(findings.map(item => [item.id + item.description, item])).values()];