@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/CHANGELOG.md +6 -0
- package/README.md +14 -2
- package/package.json +2 -2
- package/src/catalog.mjs +8 -0
- package/src/compact-catalog.mjs +2 -2
- package/src/config.mjs +12 -2
- package/src/documents.mjs +7 -0
- package/src/extended/browser.mjs +431 -34
- package/src/extended/catalog.mjs +11 -8
- package/src/extended/desktop.mjs +14 -10
- package/src/extended/documents.mjs +389 -69
- package/src/index.mjs +2 -2
- package/src/mcp-v2.mjs +7 -3
- package/src/policy.mjs +42 -9
- package/src/tools/files.mjs +665 -85
- package/src/tools/terminal.mjs +4 -2
- package/src/util.mjs +7 -1
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 =>
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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(
|
|
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()];
|