@ductape/mcp 0.2.9 → 0.2.11

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';
@@ -234,6 +235,36 @@ There are THREE categories of operations. Use the right tool for each:
234
235
  - For cloud-linked envs, set config.cloud to the connection tag for that env and
235
236
  omit raw credentials; the cloud connection must exist for that env too.
236
237
 
238
+ ⚠ PERSIST AGAINST AN ALREADY-REGISTERED COMPONENT TAG CAN SILENTLY NO-OP:
239
+ import-persist-all / provision-persist-all call the component's create method first and only
240
+ fall back to update if create throws. Depending on platform version, create against an existing
241
+ tag can resolve successfully without changing anything instead of throwing — so the response can
242
+ report success (and, for provision, a real cloud resource gets created) while the Ductape
243
+ component record is left completely unchanged, still pointing at whatever it pointed at before.
244
+ Never trust a success response alone when persisting against an existing tag:
245
+ 1. ALWAYS re-fetch the component immediately after — ductape_cli("resources <type> get -t <tag> --json")
246
+ 2. Diff the fields you meant to change (e.g. envs[].config.cloud) against what you intended.
247
+ 3. If unchanged, treat the persist as failed even though it reported success, and say so —
248
+ do not retry the same call in a loop; report it and ask before continuing.
249
+
250
+ ⚠ DUCTAPE NEVER DEPROVISIONS REAL CLOUD RESOURCES — THIS IS INTENTIONAL, NOT A GAP:
251
+ There is no verb, flag, or code path anywhere in Ductape (CLI, SDK, or backend) that deletes an
252
+ actual cloud resource (a GCS/S3 bucket, a Pub/Sub/SNS topic, a database instance, etc.). This is a
253
+ deliberate product decision — auto-deprovisioning real infrastructure risks destroying real data,
254
+ so that responsibility is left entirely to the human operator via the cloud provider's own console
255
+ or CLI (gcloud, aws, az, …). Concretely:
256
+ - ductape_cli("resources <type> delete -t <tag>") only clears Ductape's own catalog record
257
+ (soft delete). It never touches the underlying cloud resource — that resource still exists and
258
+ still costs money/still needs manual cleanup after this call succeeds.
259
+ - provision-persist(-all) and import-persist(-all) create/link real cloud resources but there
260
+ is no symmetric "deprovision" or "cloud resources delete" operation — attempting
261
+ ductape_cli("cloud resources delete ...") returns a clear error explaining this by design.
262
+ - If a provisioning attempt fails partway through, or a component is deleted, any real cloud
263
+ resources it created are orphaned and will keep existing (and accruing cost) until the operator
264
+ deletes them manually. Surface this to the user explicitly rather than treating it as something
265
+ this tooling can clean up — do not propose scripting cloud-provider deletes on the user's behalf
266
+ unless they explicitly ask for that as a separate, one-off action outside of Ductape.
267
+
237
268
  2. RUNTIME OPERATIONS (run, dispatch, execute, start, send, produce, query, insert, update, delete…)
238
269
  The "input" field shape is product- and operation-specific — it is NOT derivable from Joi validators.
239
270
  It is defined by how the product's action/feature/session/quota/etc. was configured in Ductape.
@@ -1250,7 +1281,8 @@ const ADMIN_SUBCOMMANDS = [
1250
1281
  ];
1251
1282
  function checkCli() {
1252
1283
  try {
1253
- const out = execSync('ductape --version', {
1284
+ const out = execFileSync('ductape', ['--version'], {
1285
+ shell: false,
1254
1286
  encoding: 'utf8',
1255
1287
  timeout: 15000,
1256
1288
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -1272,7 +1304,8 @@ function checkLoginState() {
1272
1304
  // `ductape whoami` only reports whether a local credentials file exists. It does not validate
1273
1305
  // the stored token, so an expired token would be cached as authenticated and fail later with 401.
1274
1306
  // Use a harmless authenticated read to validate the credential against the API.
1275
- execSync('ductape workspaces list --json', {
1307
+ execFileSync('ductape', ['workspaces', 'list', '--json'], {
1308
+ shell: false,
1276
1309
  encoding: 'utf8',
1277
1310
  timeout: 10000,
1278
1311
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -1293,7 +1326,8 @@ function syncWorkspace() {
1293
1326
  if (!target)
1294
1327
  return;
1295
1328
  try {
1296
- execSync(`ductape workspaces use "${target}"`, {
1329
+ execFileSync('ductape', ['workspaces', 'use', target], {
1330
+ shell: false,
1297
1331
  encoding: 'utf8',
1298
1332
  timeout: 10000,
1299
1333
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -1306,7 +1340,14 @@ function syncWorkspace() {
1306
1340
  }
1307
1341
  }
1308
1342
  function runCli(command) {
1309
- const first = command.trim().split(/\s+/)[0];
1343
+ let argv;
1344
+ try {
1345
+ argv = parseCliCommand(command);
1346
+ }
1347
+ catch (error) {
1348
+ return { success: false, output: `Rejected unsafe ductape_cli command: ${error.message}` };
1349
+ }
1350
+ const first = argv[0];
1310
1351
  if (!ADMIN_SUBCOMMANDS.includes(first)) {
1311
1352
  return {
1312
1353
  success: false,
@@ -1314,13 +1355,13 @@ function runCli(command) {
1314
1355
  };
1315
1356
  }
1316
1357
  // Auto-inject --workspace on login if DUCTAPE_WORKSPACE is set and caller hasn't specified one
1317
- let finalCommand = command;
1318
1358
  const ws = process.env.DUCTAPE_WORKSPACE;
1319
- if (first === 'login' && ws && !command.includes('--workspace') && !command.includes('--skip-workspace-select')) {
1320
- finalCommand = `${command} --workspace "${ws}"`;
1359
+ if (first === 'login' && ws && !argv.includes('--workspace') && !argv.includes('--skip-workspace-select')) {
1360
+ argv.push('--workspace', ws);
1321
1361
  }
1322
1362
  try {
1323
- const output = execSync(`ductape ${finalCommand}`, {
1363
+ const output = execFileSync('ductape', argv, {
1364
+ shell: false,
1324
1365
  encoding: 'utf8',
1325
1366
  // Must exceed the proxy's operation timeout so stderr can preserve the structured timeout
1326
1367
  // 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.11",
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
  },