@remcp/runtime 0.2.105 → 0.2.107

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.
@@ -1,6 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import process from 'node:process';
3
- import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { constants } from 'node:fs';
5
+ import { copyFile, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
4
6
  import { readDocxText, readPdfText } from '../documents.mjs';
5
7
  import { resolveSafePath, text } from '../util.mjs';
6
8
  import {
@@ -26,6 +28,51 @@ function xmlUnescape(value) {
26
28
 
27
29
  const MAX_OOXML_ENTRIES = 10_000;
28
30
  const MAX_OOXML_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
31
+ const MAX_DOCUMENT_BYTES = 128 * 1024 * 1024;
32
+
33
+ async function readDocumentFile(filePath) {
34
+ const info = await lstat(filePath);
35
+ if (!info.isFile()) throw new Error('Document path must be a regular file');
36
+ if (info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document is too large (maximum ${MAX_DOCUMENT_BYTES} bytes)`);
37
+ return readFile(filePath);
38
+ }
39
+
40
+ async function copyRegularNoFollow(source, destination) {
41
+ const sourceHandle = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
42
+ try {
43
+ const info = await sourceHandle.stat();
44
+ if (!info.isFile()) throw new Error('Document path must be a regular file');
45
+ if (info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document is too large (maximum ${MAX_DOCUMENT_BYTES} bytes)`);
46
+ const destinationHandle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
47
+ try {
48
+ await destinationHandle.writeFile(await sourceHandle.readFile());
49
+ } finally {
50
+ await destinationHandle.close();
51
+ }
52
+ } finally {
53
+ await sourceHandle.close();
54
+ }
55
+ }
56
+
57
+ async function assertSafeExtractedTree(root, state = { entries: 0 }) {
58
+ const info = await lstat(root);
59
+ if (info.isSymbolicLink()) throw new Error('OOXML archive contains a symbolic link');
60
+ if (!info.isDirectory() && !info.isFile()) throw new Error('OOXML archive contains a special file');
61
+ state.entries += 1;
62
+ if (state.entries > MAX_OOXML_ENTRIES) throw new Error('OOXML archive has too many extracted entries');
63
+ if (!info.isDirectory()) return;
64
+ for (const entry of await readdir(root, { withFileTypes: true })) {
65
+ await assertSafeExtractedTree(path.join(root, entry.name), state);
66
+ }
67
+ }
68
+
69
+ function safePdfPattern(value, fallback) {
70
+ const pattern = String(value || fallback);
71
+ if (!pattern || pattern.length > 240 || pattern.includes('\0') || path.isAbsolute(pattern) || /[\\/]/.test(pattern) || pattern.split(/[\\/]/).includes('..')) {
72
+ throw new Error('PDF split pattern must be a filename without path separators');
73
+ }
74
+ return pattern;
75
+ }
29
76
 
30
77
  function safeArchiveEntry(name) {
31
78
  const normalized = String(name || '').replace(/\\/g, '/');
@@ -51,14 +98,22 @@ async function validateZipArchive(source) {
51
98
  }
52
99
 
53
100
  async function extractZip(source, destination) {
54
- await validateZipArchive(source);
55
- await mkdir(destination, { recursive:true });
56
- if (process.platform === 'win32') {
57
- await runPowerShell(`Add-Type -AssemblyName System.IO.Compression.FileSystem;[IO.Compression.ZipFile]::ExtractToDirectory('${escapePowerShellSingle(source)}','${escapePowerShellSingle(destination)}')`, { label:'extract OOXML archive', timeout:30_000 });
58
- return;
101
+ const sourceDirectory = await tempDir('remcp-ooxml-source-');
102
+ const sourceCopy = path.join(sourceDirectory, 'source.zip');
103
+ try {
104
+ await copyRegularNoFollow(source, sourceCopy);
105
+ await validateZipArchive(sourceCopy);
106
+ await mkdir(destination, { recursive:true });
107
+ if (process.platform === 'win32') {
108
+ await runPowerShell(`Add-Type -AssemblyName System.IO.Compression.FileSystem;[IO.Compression.ZipFile]::ExtractToDirectory('${escapePowerShellSingle(sourceCopy)}','${escapePowerShellSingle(destination)}')`, { label:'extract OOXML archive', timeout:30_000 });
109
+ } else {
110
+ if (!commandExists('unzip')) unavailable('OOXML document editing', 'unzip is required');
111
+ await runFile('unzip', ['-qq',sourceCopy,'-d',destination], { label:'extract OOXML archive', timeout:30_000 });
112
+ }
113
+ await assertSafeExtractedTree(destination);
114
+ } finally {
115
+ await removeTemp(sourceDirectory);
59
116
  }
60
- if (!commandExists('unzip')) unavailable('OOXML document editing', 'unzip is required');
61
- await runFile('unzip', ['-qq',source,'-d',destination], { label:'extract OOXML archive', timeout:30_000 });
62
117
  }
63
118
 
64
119
  async function createZip(sourceDir, destination) {
@@ -71,6 +126,26 @@ async function createZip(sourceDir, destination) {
71
126
  await runFile('zip', ['-qr',destination,'.'], { cwd:sourceDir, label:'create OOXML archive', timeout:30_000 });
72
127
  }
73
128
 
129
+ async function commitDocumentOutput(staged, destination) {
130
+ try {
131
+ await rename(staged, destination);
132
+ return;
133
+ } catch (error) {
134
+ if (!['EEXIST', 'EPERM', 'ENOTEMPTY'].includes(error?.code)) throw error;
135
+ }
136
+ const backup = `${destination}.remcp-old-${randomUUID()}`;
137
+ let moved = false;
138
+ try {
139
+ await rename(destination, backup);
140
+ moved = true;
141
+ await rename(staged, destination);
142
+ await rm(backup, { force: true }).catch(() => {});
143
+ } catch (error) {
144
+ if (moved && !await lstat(destination).catch(() => null)) await rename(backup, destination).catch(() => {});
145
+ throw error;
146
+ }
147
+ }
148
+
74
149
  async function ensureCreateTargetIsNew(filePath) {
75
150
  try {
76
151
  await stat(filePath);
@@ -188,7 +263,7 @@ async function readPdfDocument(filePath, data) {
188
263
  export async function readDocument(args) {
189
264
  const filePath = await resolveSafePath(args.path,'path');
190
265
  const lower = filePath.toLowerCase();
191
- const data = await readFile(filePath);
266
+ const data = await readDocumentFile(filePath);
192
267
  if (lower.endsWith('.docx')) return text(readDocxText(data));
193
268
  if (lower.endsWith('.pdf')) return text(await readPdfDocument(filePath,data));
194
269
  if (lower.endsWith('.xlsx')) return jsonResult(await readXlsx(filePath,args));
@@ -275,8 +350,12 @@ export async function editSpreadsheet(args) {
275
350
  const edits = expandSpreadsheetEdits(requestedEdits);
276
351
  const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
277
352
  const dir = await tempDir(create ? 'remcp-xlsx-create-' : 'remcp-xlsx-edit-');
278
- const tempOut = path.join(path.dirname(output), `.remcp-${Date.now()}-${path.basename(output)}`);
353
+ let outputStage = '';
354
+ let tempOut = '';
279
355
  try {
356
+ await mkdir(path.dirname(output), { recursive:true });
357
+ outputStage = await mkdtemp(path.join(path.dirname(output), '.remcp-xlsx-output-'));
358
+ tempOut = path.join(outputStage, path.basename(output));
280
359
  if (create) {
281
360
  await ensureCreateTargetIsNew(filePath);
282
361
  await createBlankXlsxTree(dir, optionalString(args.sheet));
@@ -290,12 +369,12 @@ export async function editSpreadsheet(args) {
290
369
  let xml = await readFile(worksheetPath,'utf8');
291
370
  for (const edit of edits) xml = setWorksheetCell(xml,String(edit.cell || '').toUpperCase(),edit.value,edit.formula);
292
371
  await writeFile(worksheetPath,xml,'utf8');
293
- await mkdir(path.dirname(output), { recursive:true });
294
372
  await createZip(dir,tempOut);
295
- await copyFile(tempOut,output);
373
+ await commitDocumentOutput(tempOut,output);
296
374
  return jsonResult({ path:output, sheet:sheet.name, edited_cells:edits.length, bytes:(await stat(output)).size, created:create });
297
375
  } finally {
298
- await rm(tempOut,{force:true}).catch(() => {});
376
+ if (tempOut) await rm(tempOut,{force:true}).catch(() => {});
377
+ if (outputStage) await rm(outputStage,{recursive:true, force:true}).catch(() => {});
299
378
  await removeTemp(dir);
300
379
  }
301
380
  }
@@ -356,9 +435,13 @@ export async function editDocument(args) {
356
435
  if (!operations.length || operations.length > 100) throw new Error('operations must contain 1..100 edits');
357
436
  const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
358
437
  const dir = await tempDir(create ? 'remcp-docx-create-' : 'remcp-docx-edit-');
359
- const tempOut = path.join(path.dirname(output), `.remcp-${Date.now()}-${path.basename(output)}`);
438
+ let outputStage = '';
439
+ let tempOut = '';
360
440
  let changes = 0;
361
441
  try {
442
+ await mkdir(path.dirname(output), { recursive:true });
443
+ outputStage = await mkdtemp(path.join(path.dirname(output), '.remcp-docx-output-'));
444
+ tempOut = path.join(outputStage, path.basename(output));
362
445
  if (create) {
363
446
  await ensureCreateTargetIsNew(filePath);
364
447
  await createBlankDocxTree(dir);
@@ -383,12 +466,12 @@ export async function editDocument(args) {
383
466
  }
384
467
  }
385
468
  await writeFile(documentPath,xml,'utf8');
386
- await mkdir(path.dirname(output), { recursive:true });
387
469
  await createZip(dir,tempOut);
388
- await copyFile(tempOut,output);
470
+ await commitDocumentOutput(tempOut,output);
389
471
  return jsonResult({ path:output, operations:operations.length, changes, bytes:(await stat(output)).size, created:create });
390
472
  } finally {
391
- await rm(tempOut,{force:true}).catch(() => {});
473
+ if (tempOut) await rm(tempOut,{force:true}).catch(() => {});
474
+ if (outputStage) await rm(outputStage,{recursive:true, force:true}).catch(() => {});
392
475
  await removeTemp(dir);
393
476
  }
394
477
  }
@@ -437,12 +520,12 @@ export async function pdfAction(args) {
437
520
  const action = requireEnum(args.action,'action',['merge','split','extract_pages','annotations','info']);
438
521
  if (action === 'annotations') {
439
522
  const source = await resolveSafePath(args.path,'path');
440
- return jsonResult({ path:source, annotations:pdfAnnotations(await readFile(source)) });
523
+ return jsonResult({ path:source, annotations:pdfAnnotations(await readDocumentFile(source)) });
441
524
  }
442
525
  if (action === 'info') {
443
526
  const source = await resolveSafePath(args.path,'path');
444
527
  if (commandExists('pdfinfo')) return text((await runFile('pdfinfo',[source],{label:'pdfinfo'})).stdout);
445
- const data = await readFile(source); return jsonResult({ path:source, bytes:data.length, annotation_count:pdfAnnotations(data).length });
528
+ const data = await readDocumentFile(source); return jsonResult({ path:source, bytes:data.length, annotation_count:pdfAnnotations(data).length });
446
529
  }
447
530
  if (action === 'merge') {
448
531
  const requested = Array.isArray(args.paths) ? args.paths : [];
@@ -456,8 +539,11 @@ export async function pdfAction(args) {
456
539
  }
457
540
  const source=await resolveSafePath(args.path,'path');
458
541
  if(action==='split'){
459
- const outputDir=await resolveSafePath(args.output_dir || path.dirname(source),'output_dir'); await mkdir(outputDir,{recursive:true});
460
- const pattern=path.join(outputDir,optionalString(args.pattern)||`${path.basename(source,path.extname(source))}-%d.pdf`);
542
+ const requestedOutputDir=await resolveSafePath(args.output_dir || path.dirname(source),'output_dir');
543
+ await mkdir(requestedOutputDir,{recursive:true});
544
+ const outputDir=await resolveSafePath(requestedOutputDir,'output_dir');
545
+ const patternName=safePdfPattern(optionalString(args.pattern),`${path.basename(source,path.extname(source))}-%d.pdf`);
546
+ const pattern=path.join(outputDir,patternName);
461
547
  if(commandExists('pdfseparate')) await runFile('pdfseparate',[source,pattern],{label:'PDF split',timeout:60_000});
462
548
  else unavailable('PDF split','install poppler-utils (pdfseparate)');
463
549
  return jsonResult({action,output_dir:outputDir,files:(await readdir(outputDir)).filter(name=>name.toLowerCase().endsWith('.pdf')).sort()});
@@ -248,6 +248,9 @@ class EiSender:
248
248
  self.connected = False
249
249
  self.pointer_device_key = None
250
250
  self.pointer_position = None
251
+ # A press/release pair must stay on the same emulated device even if
252
+ # absolute motion crosses an EIS region and changes pointer_device_key.
253
+ self.button_device_keys = {}
251
254
 
252
255
  def _device_key(self, ptr):
253
256
  return int(ptr or 0)
@@ -267,6 +270,9 @@ class EiSender:
267
270
  if key == self.pointer_device_key:
268
271
  self.pointer_device_key = None
269
272
  self.pointer_position = None
273
+ for button, device_key in list(self.button_device_keys.items()):
274
+ if device_key == key:
275
+ self.button_device_keys.pop(button, None)
270
276
  if state:
271
277
  if state["emulating"]:
272
278
  try:
@@ -313,10 +319,14 @@ class EiSender:
313
319
  state["emulating"] = True
314
320
  elif event_type == EI_EVENT_DEVICE_PAUSED:
315
321
  device = self.lib.ei_event_get_device(event)
316
- state = self.devices.get(self._device_key(device))
322
+ key = self._device_key(device)
323
+ state = self.devices.get(key)
317
324
  if state:
318
325
  state["resumed"] = False
319
326
  state["emulating"] = False
327
+ for button, device_key in list(self.button_device_keys.items()):
328
+ if device_key == key:
329
+ self.button_device_keys.pop(button, None)
320
330
  elif event_type == EI_EVENT_DEVICE_REMOVED:
321
331
  self._drop_device(self.lib.ei_event_get_device(event))
322
332
  finally:
@@ -419,8 +429,24 @@ class EiSender:
419
429
  self.remember_pointer(device, (x, y))
420
430
  self.frame(device)
421
431
  elif op == "button":
422
- device = self.device_for_pointer_context(EI_CAP_BUTTON)
423
- self.lib.ei_device_button_button(device, int(payload["button"]), bool(payload.get("pressed")))
432
+ button = int(payload["button"])
433
+ pressed = bool(payload.get("pressed"))
434
+ if pressed:
435
+ device = self.device_for_pointer_context(EI_CAP_BUTTON)
436
+ self.button_device_keys[button] = self._device_key(device)
437
+ else:
438
+ key = self.button_device_keys.pop(button, None)
439
+ state = self.devices.get(key) if key else None
440
+ if (
441
+ state
442
+ and state["resumed"]
443
+ and state["emulating"]
444
+ and self.lib.ei_device_has_capability(state["ptr"], EI_CAP_BUTTON)
445
+ ):
446
+ device = state["ptr"]
447
+ else:
448
+ device = self.device_for_pointer_context(EI_CAP_BUTTON)
449
+ self.lib.ei_device_button_button(device, button, pressed)
424
450
  self.frame(device)
425
451
  elif op == "scroll":
426
452
  device = self.device_for_pointer_context(EI_CAP_SCROLL)
@@ -446,6 +472,7 @@ class EiSender:
446
472
  except Exception:
447
473
  pass
448
474
  self.devices.clear()
475
+ self.button_device_keys.clear()
449
476
  if self.ei:
450
477
  self.lib.ei_unref(self.ei)
451
478
  self.ei = None
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()];