@ductape/mcp 0.2.9 → 0.2.10

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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.9
4
+
5
+ - Security: execute `ductape_cli` with an argument array and no shell, reject shell operators/substitutions, and validate quoted command input before checking the administrative subcommand allowlist.
6
+
3
7
  ## Unreleased
4
8
 
5
9
  - Broadened Feature guidance from durable/event-driven workflows to synchronous or asynchronous named product capabilities.
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Parse the user-facing command string into an argv array without invoking a shell.
3
+ * Supports ordinary single/double quoting and backslash escaping for paths/values.
4
+ */
5
+ export declare function parseCliCommand(command: string): string[];
6
+ //# sourceMappingURL=cli-command.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-command.d.ts","sourceRoot":"","sources":["../src/cli-command.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAmDzD"}
@@ -0,0 +1,66 @@
1
+ /** Characters with shell control/expansion meaning are never valid in ductape_cli input. */
2
+ const SHELL_META = /[;&|`$<>()\r\n\0]/;
3
+ /**
4
+ * Parse the user-facing command string into an argv array without invoking a shell.
5
+ * Supports ordinary single/double quoting and backslash escaping for paths/values.
6
+ */
7
+ export function parseCliCommand(command) {
8
+ if (typeof command !== 'string' || command.trim() === '') {
9
+ throw new Error('A non-empty Ductape CLI command is required.');
10
+ }
11
+ if (SHELL_META.test(command)) {
12
+ throw new Error('Shell operators, substitutions, redirects, and control characters are not allowed.');
13
+ }
14
+ const argv = [];
15
+ let token = '';
16
+ let quote = null;
17
+ let tokenStarted = false;
18
+ for (let index = 0; index < command.length; index += 1) {
19
+ const char = command[index];
20
+ if (quote) {
21
+ if (char === quote) {
22
+ quote = null;
23
+ }
24
+ else if (char === '\\' && quote === '"') {
25
+ index += 1;
26
+ if (index >= command.length)
27
+ throw new Error('Trailing escape in Ductape CLI command.');
28
+ token += command[index];
29
+ }
30
+ else {
31
+ token += char;
32
+ }
33
+ tokenStarted = true;
34
+ continue;
35
+ }
36
+ if (char === "'" || char === '"') {
37
+ quote = char;
38
+ tokenStarted = true;
39
+ }
40
+ else if (char === '\\') {
41
+ index += 1;
42
+ if (index >= command.length)
43
+ throw new Error('Trailing escape in Ductape CLI command.');
44
+ token += command[index];
45
+ tokenStarted = true;
46
+ }
47
+ else if (/\s/.test(char)) {
48
+ if (tokenStarted) {
49
+ argv.push(token);
50
+ token = '';
51
+ tokenStarted = false;
52
+ }
53
+ }
54
+ else {
55
+ token += char;
56
+ tokenStarted = true;
57
+ }
58
+ }
59
+ if (quote)
60
+ throw new Error('Unterminated quote in Ductape CLI command.');
61
+ if (tokenStarted)
62
+ argv.push(token);
63
+ if (argv.length === 0)
64
+ throw new Error('A non-empty Ductape CLI command is required.');
65
+ return argv;
66
+ }
package/dist/index.js CHANGED
@@ -10,7 +10,8 @@
10
10
  * the env var when provided.
11
11
  */
12
12
  import { createRequire } from 'module';
13
- import { execFileSync, execSync } from 'child_process';
13
+ import { execFileSync } from 'child_process';
14
+ import { parseCliCommand } from './cli-command.js';
14
15
  import { homedir } from 'os';
15
16
  import { delimiter, join } from 'path';
16
17
  import { z } from 'zod';
@@ -1250,7 +1251,8 @@ const ADMIN_SUBCOMMANDS = [
1250
1251
  ];
1251
1252
  function checkCli() {
1252
1253
  try {
1253
- const out = execSync('ductape --version', {
1254
+ const out = execFileSync('ductape', ['--version'], {
1255
+ shell: false,
1254
1256
  encoding: 'utf8',
1255
1257
  timeout: 15000,
1256
1258
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -1272,7 +1274,8 @@ function checkLoginState() {
1272
1274
  // `ductape whoami` only reports whether a local credentials file exists. It does not validate
1273
1275
  // the stored token, so an expired token would be cached as authenticated and fail later with 401.
1274
1276
  // Use a harmless authenticated read to validate the credential against the API.
1275
- execSync('ductape workspaces list --json', {
1277
+ execFileSync('ductape', ['workspaces', 'list', '--json'], {
1278
+ shell: false,
1276
1279
  encoding: 'utf8',
1277
1280
  timeout: 10000,
1278
1281
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -1293,7 +1296,8 @@ function syncWorkspace() {
1293
1296
  if (!target)
1294
1297
  return;
1295
1298
  try {
1296
- execSync(`ductape workspaces use "${target}"`, {
1299
+ execFileSync('ductape', ['workspaces', 'use', target], {
1300
+ shell: false,
1297
1301
  encoding: 'utf8',
1298
1302
  timeout: 10000,
1299
1303
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -1306,7 +1310,14 @@ function syncWorkspace() {
1306
1310
  }
1307
1311
  }
1308
1312
  function runCli(command) {
1309
- const first = command.trim().split(/\s+/)[0];
1313
+ let argv;
1314
+ try {
1315
+ argv = parseCliCommand(command);
1316
+ }
1317
+ catch (error) {
1318
+ return { success: false, output: `Rejected unsafe ductape_cli command: ${error.message}` };
1319
+ }
1320
+ const first = argv[0];
1310
1321
  if (!ADMIN_SUBCOMMANDS.includes(first)) {
1311
1322
  return {
1312
1323
  success: false,
@@ -1314,13 +1325,13 @@ function runCli(command) {
1314
1325
  };
1315
1326
  }
1316
1327
  // Auto-inject --workspace on login if DUCTAPE_WORKSPACE is set and caller hasn't specified one
1317
- let finalCommand = command;
1318
1328
  const ws = process.env.DUCTAPE_WORKSPACE;
1319
- if (first === 'login' && ws && !command.includes('--workspace') && !command.includes('--skip-workspace-select')) {
1320
- finalCommand = `${command} --workspace "${ws}"`;
1329
+ if (first === 'login' && ws && !argv.includes('--workspace') && !argv.includes('--skip-workspace-select')) {
1330
+ argv.push('--workspace', ws);
1321
1331
  }
1322
1332
  try {
1323
- const output = execSync(`ductape ${finalCommand}`, {
1333
+ const output = execFileSync('ductape', argv, {
1334
+ shell: false,
1324
1335
  encoding: 'utf8',
1325
1336
  // Must exceed the proxy's operation timeout so stderr can preserve the structured timeout
1326
1337
  // instead of this wrapper killing the CLI first and reducing it to "(no data)".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ductape/mcp",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "MCP server that exposes Ductape SDK operations via the backend proxy",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,7 +15,7 @@
15
15
  ],
16
16
  "scripts": {
17
17
  "build": "tsc",
18
- "test": "npm run build && node scripts/check-frontend-analytics-guidance.mjs && node scripts/check-events-discovery.mjs && node scripts/check-schema-fallback.mjs && node scripts/check-portable-functions.mjs",
18
+ "test": "npm run build && node scripts/check-cli-command-security.mjs && node scripts/check-frontend-analytics-guidance.mjs && node scripts/check-events-discovery.mjs && node scripts/check-schema-fallback.mjs && node scripts/check-portable-functions.mjs",
19
19
  "start": "node dist/index.js",
20
20
  "dev": "tsx src/index.ts"
21
21
  },