@diviops/mcp-server 0.2.14 → 0.2.15

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/README.md CHANGED
@@ -69,6 +69,8 @@ claude mcp add diviops-mcp -- env \
69
69
  | `WP_CLI_CMD` | No | Custom WP-CLI command prefix for containerized environments, e.g. `ddev wp`, `npx wp-env run cli wp`, `docker exec -u www-data devkinsta_fpm wp --path=/www/kinsta/public/sitename` |
70
70
  | `LOCAL_SITE_ID` | No | Override auto-detection of Local by Flywheel site ID |
71
71
  | `DIVIOPS_WP_CLI_ALLOW` | No | Comma-separated list of extended WP-CLI commands to enable (see [WP-CLI Security](#wp-cli-security)) |
72
+ | `DIVIOPS_WP_CLI_SAFE_FS_ROOT` | No | Absolute path to constrain filesystem-touching commands (`wp export`, `acf export/import`). Defaults to `<WP_PATH>/.diviops-tmp/` in host mode. **Required** in `WP_CLI_CMD` wrapper mode (must be the container-namespace path, since the host path can't be inferred) |
73
+ | `DIVIOPS_WP_CLI_UNSAFE_FS` | No | Set to `1` to disable filesystem flag validation entirely. For trusted single-user local-dev setups where the `--dir` / positional-path safety checks get in the way |
72
74
 
73
75
  ### Local Development Environments
74
76
 
@@ -206,7 +208,18 @@ Only list the specific commands you need. Unknown entries are ignored with a war
206
208
 
207
209
  > **Note on `acf import`**: included in the default allowlist because it's an idempotent dev-time schema operation (re-creates field groups from JSON). Bulk content imports use `wp import` instead, which is opt-in.
208
210
 
209
- > **Known limitation — filesystem access**: Validation is prefix-based, not flag-aware. Commands that read from or write to the filesystem (`acf export`/`acf import`, `export`, opt-in `import`/`eval-file`) can target any path reachable by the WP-CLI user. For shared or multi-tenant environments, consider wrapping the MCP server with a stricter proxy or running it under an account with limited filesystem permissions. Flag-level validation is a candidate future enhancement.
211
+ ### Filesystem flag validation
212
+
213
+ The three DEFAULT-tier filesystem commands (`wp export`, `acf export <path>`, `acf import <path>`) are second-pass validated against a safe root so wrong-path arguments can't write WXR to the web root or read ACF configs from arbitrary locations.
214
+
215
+ - **Safe root**: `<WP_PATH>/.diviops-tmp/` by default (auto-created on first use in host mode). Override with `DIVIOPS_WP_CLI_SAFE_FS_ROOT=/absolute/path`. All path arguments must canonicalize under this directory; symlinks are resolved via `realpath` so a planted symlink inside the safe root pointing outside it is caught.
216
+ - **`wp export` must pass `--dir=<path-under-safe-root>`** (or `--stdout`). Without `--dir`, wp-cli writes to the current working directory; on prod that's typically the web root.
217
+ - **`--filename_format=` must be a filename template**, not a path — separators (`/`, `\`) are rejected so a crafted template can't escape `--dir`'s scope.
218
+ - **`acf export/import`'s positional path** must resolve under the safe root.
219
+ - **Wrapper mode (`WP_CLI_CMD`)**: the host-derived safe root doesn't correspond to the wrapper's filesystem (e.g., container paths like `/www/app`), so `DIVIOPS_WP_CLI_SAFE_FS_ROOT` is **required** and must be set to the container-namespace path. FS-sensitive commands are rejected with a clear error if it's missing.
220
+ - **Escape hatch**: `DIVIOPS_WP_CLI_UNSAFE_FS=1` disables validation entirely. Appropriate for trusted single-user local-dev setups that don't want the guard.
221
+
222
+ **EXTENDED-tier filesystem commands** (`import`, `eval-file`) are not flag-validated here — opt-in via `DIVIOPS_WP_CLI_ALLOW` signals the caller has accepted the path-scope risk. That constraint is a candidate future enhancement if the MCP server ships in multi-tenant contexts.
210
223
 
211
224
  ## Safety Patterns
212
225
 
package/dist/index.js CHANGED
@@ -1091,11 +1091,11 @@ server.registerTool("diviops_delete_canvas", {
1091
1091
  });
1092
1092
  // ── WP-CLI ──────────────────────────────────────────────────────────
1093
1093
  server.registerTool("diviops_wp_cli", {
1094
- description: "Run a WP-CLI command on the WordPress site. Requires WP_PATH env var (LOCAL_SITE_ID auto-detected from Local by Flywheel). Commands validated against a safety allowlist. Default tier covers read ops across options/posts/post-types/taxonomies/users/info, non-destructive writes (post/term create+update, post meta read/write, cache/rewrite/transient flush), ACF schema ops (export/import/list/get field-group), and WXR export. Extended tier (requires DIVIOPS_WP_CLI_ALLOW env var) adds destructive or bulk-modifying ops: option update, post/post meta/term delete, search-replace, import, plugin activate/deactivate, eval-file. Use --format=json for structured output. Full allowlist + tier rationale in the MCP server README.",
1094
+ description: "Run a WP-CLI command on the WordPress site. Requires WP_PATH env var (LOCAL_SITE_ID auto-detected from Local by Flywheel), or WP_CLI_CMD for containerized wrappers. Commands validated against a safety allowlist. Default tier covers read ops across options/posts/post-types/taxonomies/users/info/core/db, non-destructive writes (post/term create+update, post meta read/write, cache/rewrite/transient flush), ACF schema ops (export/import/list/get field-group), and WXR export. Extended tier (requires DIVIOPS_WP_CLI_ALLOW env var) adds destructive or bulk-modifying ops: option update, post/post meta/term delete, search-replace, import, plugin activate/deactivate, eval-file. Filesystem-touching commands (`wp export`, `acf export/import`) are additionally constrained: path arguments must resolve under a safe root (defaults to `<WP_PATH>/.diviops-tmp/`, overridable via DIVIOPS_WP_CLI_SAFE_FS_ROOT, disable via DIVIOPS_WP_CLI_UNSAFE_FS=1); `wp export` requires an explicit `--dir=<path>` (or `--stdout`). In WP_CLI_CMD wrapper mode, DIVIOPS_WP_CLI_SAFE_FS_ROOT is required for FS-sensitive commands. Use --format=json for structured output. Full allowlist + tier rationale + filesystem semantics in the MCP server README.",
1095
1095
  inputSchema: {
1096
1096
  command: z
1097
1097
  .string()
1098
- .describe('WP-CLI command without the "wp" prefix. E.g. "option get blogname", "post list --format=json", "db query \\"SELECT COUNT(*) FROM wp_posts\\""'),
1098
+ .describe('WP-CLI command without the "wp" prefix. E.g. "option get blogname", "post list --format=json", "export --dir=$DIVIOPS_WP_CLI_SAFE_FS_ROOT --filename_format={site}.{date}.xml"'),
1099
1099
  },
1100
1100
  }, async ({ command }) => {
1101
1101
  if (!wpCli) {
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Filesystem flag validation for wp-cli commands that read/write outside
3
+ * the standard WP data surface (options, posts, meta, etc.).
4
+ *
5
+ * The main allowlist (wp-cli.ts) uses prefix matching and accepts arbitrary
6
+ * flags after the prefix. That's fine for commands like `option get` but
7
+ * leaves path arguments on FS-touching commands unchecked — `wp export
8
+ * --dir=/var/www/html/public/` would pass the prefix check and dump a WXR
9
+ * containing user data + password hashes into a web-reachable folder.
10
+ *
11
+ * This module adds a second-pass validator specifically for DEFAULT-tier
12
+ * FS commands. EXTENDED-tier FS commands (`import`, `eval-file`) are
13
+ * already opt-in via DIVIOPS_WP_CLI_ALLOW; their validation is tracked
14
+ * separately (see dev-repo #271 scope discussion).
15
+ *
16
+ * Guarantees:
17
+ * - All path arguments must resolve under SAFE_FS_ROOT after canonicalization
18
+ * - `wp export` requires an explicit --dir= so it can't write to the CWD
19
+ * - `--filename=` on `wp export` must be a filename, not a path
20
+ * - `.diviops-tmp/` (or the configured override) is auto-created on first use
21
+ *
22
+ * Escape hatch: `DIVIOPS_WP_CLI_UNSAFE_FS=1` disables all validation for
23
+ * trusted single-user local-dev setups.
24
+ */
25
+ export interface FsValidationResult {
26
+ allowed: boolean;
27
+ reason?: string;
28
+ }
29
+ export interface FsValidationOptions {
30
+ /**
31
+ * Whether wp-cli is invoked via WP_CLI_CMD wrapper (e.g. `docker exec ... wp`).
32
+ * In wrapper mode, the host-derived safe root (from WP_PATH / process.cwd)
33
+ * has no correspondence to the wrapper's filesystem namespace, so the
34
+ * validator requires an explicit DIVIOPS_WP_CLI_SAFE_FS_ROOT before
35
+ * accepting FS-sensitive commands.
36
+ */
37
+ isWrapper?: boolean;
38
+ }
39
+ /**
40
+ * Resolve the effective safe filesystem root for this wp-cli instance.
41
+ * Precedence: DIVIOPS_WP_CLI_SAFE_FS_ROOT env var > <wpPath>/.diviops-tmp/.
42
+ * Returned path is canonical absolute.
43
+ */
44
+ export declare function resolveSafeFsRoot(wpPath: string): string;
45
+ /** Whether FS validation is disabled via escape-hatch env var. */
46
+ export declare function fsValidationDisabled(): boolean;
47
+ /**
48
+ * Idempotently create the safe root so first-invocation FS commands don't
49
+ * fail with "no such directory" when the caller points at a not-yet-existing
50
+ * safe path. Silently no-ops on failure — if mkdir fails the subsequent
51
+ * wp-cli call will surface its own error.
52
+ */
53
+ export declare function ensureSafeFsRoot(safeRoot: string): void;
54
+ /**
55
+ * Identify which FS-sensitive command a parsed arg vector represents.
56
+ * Returns the canonical prefix ("export", "acf export", "acf import") or
57
+ * null if the args don't match a FS-sensitive command.
58
+ */
59
+ export declare function matchFsSensitiveCommand(args: string[]): string | null;
60
+ /**
61
+ * Check whether a user-supplied absolute path resolves under safeRoot.
62
+ *
63
+ * Requires absolute input — relative paths return false. This matches the
64
+ * validator's contract that callers must reject relative paths BEFORE this
65
+ * function runs, because wp-cli resolves relative args against its own CWD
66
+ * (process.cwd in host mode, config.wpPath in wrapper mode), NOT against
67
+ * our safe root. Accepting relative paths here would create a validation-
68
+ * vs-execution semantic gap: the validator would happily resolve the
69
+ * relative input against safeRoot and pass, but wp-cli would execute it
70
+ * against a different base and write outside the intended scope.
71
+ *
72
+ * Canonicalization uses `fs.realpathSync` with a walk-up fallback (see
73
+ * `canonicalize`). Realpath follows symlinks, so an attacker-planted
74
+ * symlink inside SAFE_FS_ROOT pointing outside it is caught.
75
+ *
76
+ * The trailing-separator check (`startsWith(rootCanonical + sep)`) prevents
77
+ * prefix-match tricks like /safe-root-evil passing because /safe-root is
78
+ * the allowed prefix.
79
+ */
80
+ export declare function isPathUnderSafeRoot(userPath: string, safeRoot: string): boolean;
81
+ /**
82
+ * Extract `--dir=`, `--filename_format=`, and `--stdout` from a parsed
83
+ * arg vector. Supports both `--foo=bar` and `--foo bar` forms.
84
+ *
85
+ * wp-cli's `wp export` uses `--filename_format=<template>` for the output
86
+ * filename template (containing `{site}`, `{date}`, `{n}` placeholders) —
87
+ * NOT `--filename` (which isn't a real flag). An earlier version of this
88
+ * validator checked `--filename`, which wp-cli silently ignores, so an
89
+ * `--filename_format=../../evil` attempt would have passed validation
90
+ * while wp-cli actually used the malicious template. Fix: check the real
91
+ * flag name.
92
+ *
93
+ * `--stdout` writes WXR to stdout instead of a file — no FS write, so
94
+ * callers using it don't need `--dir`. Returned as a bool flag so the
95
+ * validator can waive the `--dir` requirement.
96
+ */
97
+ export declare function extractExportFlags(args: string[]): {
98
+ dir: string | null;
99
+ filenameFormat: string | null;
100
+ stdout: boolean;
101
+ };
102
+ /**
103
+ * Extract the first positional (non-flag) argument after a command prefix.
104
+ * Used for `acf export <path>` / `acf import <path>` where the path is a
105
+ * positional arg, not a flag value.
106
+ */
107
+ export declare function extractPositionalAfterPrefix(args: string[], prefixLength: number): string | null;
108
+ /**
109
+ * Validate a parsed wp-cli arg vector against the FS safe-root rules.
110
+ * Returns `{ allowed: true }` for any command not in FS_SENSITIVE_COMMANDS
111
+ * — non-FS commands are out of scope for this validator.
112
+ *
113
+ * Caller should invoke only after the main allowlist has accepted the
114
+ * command; this validator assumes the command itself is already authorized
115
+ * and only checks path arguments.
116
+ */
117
+ export declare function validateFilesystemFlags(args: string[], safeRoot: string, opts?: FsValidationOptions): FsValidationResult;
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Filesystem flag validation for wp-cli commands that read/write outside
3
+ * the standard WP data surface (options, posts, meta, etc.).
4
+ *
5
+ * The main allowlist (wp-cli.ts) uses prefix matching and accepts arbitrary
6
+ * flags after the prefix. That's fine for commands like `option get` but
7
+ * leaves path arguments on FS-touching commands unchecked — `wp export
8
+ * --dir=/var/www/html/public/` would pass the prefix check and dump a WXR
9
+ * containing user data + password hashes into a web-reachable folder.
10
+ *
11
+ * This module adds a second-pass validator specifically for DEFAULT-tier
12
+ * FS commands. EXTENDED-tier FS commands (`import`, `eval-file`) are
13
+ * already opt-in via DIVIOPS_WP_CLI_ALLOW; their validation is tracked
14
+ * separately (see dev-repo #271 scope discussion).
15
+ *
16
+ * Guarantees:
17
+ * - All path arguments must resolve under SAFE_FS_ROOT after canonicalization
18
+ * - `wp export` requires an explicit --dir= so it can't write to the CWD
19
+ * - `--filename=` on `wp export` must be a filename, not a path
20
+ * - `.diviops-tmp/` (or the configured override) is auto-created on first use
21
+ *
22
+ * Escape hatch: `DIVIOPS_WP_CLI_UNSAFE_FS=1` disables all validation for
23
+ * trusted single-user local-dev setups.
24
+ */
25
+ import { isAbsolute, resolve, sep, dirname, basename, join } from 'path';
26
+ import { mkdirSync, realpathSync } from 'fs';
27
+ const SAFE_FS_ROOT_SUBDIR = '.diviops-tmp';
28
+ const UNSAFE_FS_ENV = 'DIVIOPS_WP_CLI_UNSAFE_FS';
29
+ const SAFE_FS_ROOT_OVERRIDE_ENV = 'DIVIOPS_WP_CLI_SAFE_FS_ROOT';
30
+ /**
31
+ * DEFAULT-tier commands whose arguments can write/read to arbitrary paths.
32
+ * EXTENDED-tier FS commands (`import`, `eval-file`) are opt-in and not
33
+ * validated here — opting in implies accepting the path-scope risk.
34
+ */
35
+ const FS_SENSITIVE_COMMANDS = [
36
+ 'export',
37
+ 'acf export',
38
+ 'acf import',
39
+ ];
40
+ /**
41
+ * Resolve the effective safe filesystem root for this wp-cli instance.
42
+ * Precedence: DIVIOPS_WP_CLI_SAFE_FS_ROOT env var > <wpPath>/.diviops-tmp/.
43
+ * Returned path is canonical absolute.
44
+ */
45
+ export function resolveSafeFsRoot(wpPath) {
46
+ const override = process.env[SAFE_FS_ROOT_OVERRIDE_ENV]?.trim();
47
+ if (override)
48
+ return resolve(override);
49
+ return resolve(wpPath, SAFE_FS_ROOT_SUBDIR);
50
+ }
51
+ /** Whether FS validation is disabled via escape-hatch env var. */
52
+ export function fsValidationDisabled() {
53
+ const v = process.env[UNSAFE_FS_ENV]?.trim();
54
+ return v === '1' || v?.toLowerCase() === 'true';
55
+ }
56
+ /**
57
+ * Idempotently create the safe root so first-invocation FS commands don't
58
+ * fail with "no such directory" when the caller points at a not-yet-existing
59
+ * safe path. Silently no-ops on failure — if mkdir fails the subsequent
60
+ * wp-cli call will surface its own error.
61
+ */
62
+ export function ensureSafeFsRoot(safeRoot) {
63
+ try {
64
+ mkdirSync(safeRoot, { recursive: true });
65
+ }
66
+ catch {
67
+ // Ignore — downstream wp-cli will report a clearer error if the dir
68
+ // genuinely isn't usable (permissions, etc.).
69
+ }
70
+ }
71
+ /**
72
+ * Identify which FS-sensitive command a parsed arg vector represents.
73
+ * Returns the canonical prefix ("export", "acf export", "acf import") or
74
+ * null if the args don't match a FS-sensitive command.
75
+ */
76
+ export function matchFsSensitiveCommand(args) {
77
+ if (args.length === 0)
78
+ return null;
79
+ const twoWord = args.slice(0, 2).join(' ');
80
+ if (FS_SENSITIVE_COMMANDS.includes(twoWord))
81
+ return twoWord;
82
+ const oneWord = args[0];
83
+ if (FS_SENSITIVE_COMMANDS.includes(oneWord))
84
+ return oneWord;
85
+ return null;
86
+ }
87
+ /**
88
+ * Resolve a filesystem path to its canonical form including symlink
89
+ * resolution. Handles non-existent paths (e.g. `wp export --dir=<new>`)
90
+ * by walking up to the deepest existing ancestor, realpath'ing that, and
91
+ * preserving the non-existing tail.
92
+ *
93
+ * Why the walk-up matters: a naive `realpathSync → catch → path.resolve()`
94
+ * fallback produces asymmetric canonicalization. If the user path doesn't
95
+ * exist but the safe root DOES exist and contains a symlink in its ancestry
96
+ * (e.g. macOS `/tmp` → `/private/tmp`), the fallback keeps the un-resolved
97
+ * `/tmp/...` form while the root canonicalizes to `/private/tmp/...`, so
98
+ * the `startsWith` check fails for legitimate non-existing paths inside
99
+ * the safe root. Walking up ensures the existing portion of both paths
100
+ * goes through the same symlink resolution.
101
+ *
102
+ * Also closes the original security case: realpath on an existing symlink
103
+ * inside SAFE_FS_ROOT returns the symlink's true target, so
104
+ * `safe-root/escape-hatch → /var/www/evil` is caught by the `startsWith`
105
+ * check because the canonical path is `/var/www/evil`, not under safe root.
106
+ *
107
+ * Terminates when `dirname` stops changing (root reached); falls back to
108
+ * `path.resolve` in that edge case.
109
+ */
110
+ function canonicalize(p) {
111
+ try {
112
+ return realpathSync(p);
113
+ }
114
+ catch {
115
+ const abs = resolve(p);
116
+ const parent = dirname(abs);
117
+ if (parent === abs)
118
+ return abs;
119
+ return join(canonicalize(parent), basename(abs));
120
+ }
121
+ }
122
+ /**
123
+ * Check whether a user-supplied absolute path resolves under safeRoot.
124
+ *
125
+ * Requires absolute input — relative paths return false. This matches the
126
+ * validator's contract that callers must reject relative paths BEFORE this
127
+ * function runs, because wp-cli resolves relative args against its own CWD
128
+ * (process.cwd in host mode, config.wpPath in wrapper mode), NOT against
129
+ * our safe root. Accepting relative paths here would create a validation-
130
+ * vs-execution semantic gap: the validator would happily resolve the
131
+ * relative input against safeRoot and pass, but wp-cli would execute it
132
+ * against a different base and write outside the intended scope.
133
+ *
134
+ * Canonicalization uses `fs.realpathSync` with a walk-up fallback (see
135
+ * `canonicalize`). Realpath follows symlinks, so an attacker-planted
136
+ * symlink inside SAFE_FS_ROOT pointing outside it is caught.
137
+ *
138
+ * The trailing-separator check (`startsWith(rootCanonical + sep)`) prevents
139
+ * prefix-match tricks like /safe-root-evil passing because /safe-root is
140
+ * the allowed prefix.
141
+ */
142
+ export function isPathUnderSafeRoot(userPath, safeRoot) {
143
+ if (!userPath)
144
+ return false;
145
+ if (!isAbsolute(userPath))
146
+ return false;
147
+ const canonical = canonicalize(userPath);
148
+ const rootCanonical = canonicalize(safeRoot);
149
+ return canonical === rootCanonical || canonical.startsWith(rootCanonical + sep);
150
+ }
151
+ /**
152
+ * Reject a user-supplied path argument if it's relative. wp-cli resolves
153
+ * relative FS-command args against its own CWD (process.cwd in host mode,
154
+ * config.wpPath in wrapper mode) — neither of which is our safe root.
155
+ * Accepting relative input at the validator level would silently bypass
156
+ * the safe-root guarantee on execution.
157
+ *
158
+ * Returns a FsValidationResult rejection when relative, or null when absolute.
159
+ * Callers should short-circuit on a non-null return.
160
+ */
161
+ function rejectIfRelative(userPath, label, safeRootCanonical) {
162
+ if (isAbsolute(userPath))
163
+ return null;
164
+ return {
165
+ allowed: false,
166
+ reason: `${label} "${userPath}" is a relative path. FS-sensitive commands require ` +
167
+ `absolute paths so the validator's safe-root check matches wp-cli's execution ` +
168
+ `semantics (wp-cli resolves relative args against the process CWD, not ` +
169
+ `against the validator's safe root). Use an absolute path under ` +
170
+ `${safeRootCanonical}, or set ${UNSAFE_FS_ENV}=1 to disable validation ` +
171
+ `entirely for trusted setups.`,
172
+ };
173
+ }
174
+ /**
175
+ * Extract `--dir=`, `--filename_format=`, and `--stdout` from a parsed
176
+ * arg vector. Supports both `--foo=bar` and `--foo bar` forms.
177
+ *
178
+ * wp-cli's `wp export` uses `--filename_format=<template>` for the output
179
+ * filename template (containing `{site}`, `{date}`, `{n}` placeholders) —
180
+ * NOT `--filename` (which isn't a real flag). An earlier version of this
181
+ * validator checked `--filename`, which wp-cli silently ignores, so an
182
+ * `--filename_format=../../evil` attempt would have passed validation
183
+ * while wp-cli actually used the malicious template. Fix: check the real
184
+ * flag name.
185
+ *
186
+ * `--stdout` writes WXR to stdout instead of a file — no FS write, so
187
+ * callers using it don't need `--dir`. Returned as a bool flag so the
188
+ * validator can waive the `--dir` requirement.
189
+ */
190
+ export function extractExportFlags(args) {
191
+ let dir = null;
192
+ let filenameFormat = null;
193
+ let stdout = false;
194
+ for (let i = 0; i < args.length; i++) {
195
+ const a = args[i];
196
+ if (a.startsWith('--dir=')) {
197
+ dir = a.slice('--dir='.length);
198
+ }
199
+ else if (a === '--dir' && i + 1 < args.length) {
200
+ dir = args[i + 1];
201
+ i++;
202
+ }
203
+ else if (a.startsWith('--filename_format=')) {
204
+ filenameFormat = a.slice('--filename_format='.length);
205
+ }
206
+ else if (a === '--filename_format' && i + 1 < args.length) {
207
+ filenameFormat = args[i + 1];
208
+ i++;
209
+ }
210
+ else if (a === '--stdout') {
211
+ stdout = true;
212
+ }
213
+ }
214
+ return { dir, filenameFormat, stdout };
215
+ }
216
+ /**
217
+ * Extract the first positional (non-flag) argument after a command prefix.
218
+ * Used for `acf export <path>` / `acf import <path>` where the path is a
219
+ * positional arg, not a flag value.
220
+ */
221
+ export function extractPositionalAfterPrefix(args, prefixLength) {
222
+ for (let i = prefixLength; i < args.length; i++) {
223
+ const a = args[i];
224
+ if (a.startsWith('--'))
225
+ continue;
226
+ return a;
227
+ }
228
+ return null;
229
+ }
230
+ /**
231
+ * Validate a parsed wp-cli arg vector against the FS safe-root rules.
232
+ * Returns `{ allowed: true }` for any command not in FS_SENSITIVE_COMMANDS
233
+ * — non-FS commands are out of scope for this validator.
234
+ *
235
+ * Caller should invoke only after the main allowlist has accepted the
236
+ * command; this validator assumes the command itself is already authorized
237
+ * and only checks path arguments.
238
+ */
239
+ export function validateFilesystemFlags(args, safeRoot, opts = {}) {
240
+ const cmd = matchFsSensitiveCommand(args);
241
+ if (!cmd)
242
+ return { allowed: true };
243
+ // Wrapper-mode gate: when wp-cli runs inside a container/wrapper (WP_CLI_CMD),
244
+ // the host path we derived for safeRoot has no correspondence to the
245
+ // wrapper's filesystem namespace. Evaluating user-supplied container paths
246
+ // against a host path would either (a) reject legitimate wrapper paths or
247
+ // (b) suggest host-only safe paths the wrapper can't reach. Require the
248
+ // caller to set DIVIOPS_WP_CLI_SAFE_FS_ROOT explicitly to the correct
249
+ // container-namespace path before we'll validate FS-sensitive commands.
250
+ const hasExplicitSafeRoot = !!process.env[SAFE_FS_ROOT_OVERRIDE_ENV]?.trim();
251
+ if (opts.isWrapper && !hasExplicitSafeRoot) {
252
+ return {
253
+ allowed: false,
254
+ reason: `FS-sensitive command "${cmd}" runs through a WP_CLI_CMD wrapper, but ` +
255
+ `${SAFE_FS_ROOT_OVERRIDE_ENV} is not set. Host-derived safe roots don't ` +
256
+ `correspond to the wrapper's filesystem namespace (e.g. container paths ` +
257
+ `like /www/app). Set ${SAFE_FS_ROOT_OVERRIDE_ENV}=<container-namespace ` +
258
+ `path> to enable validation, or ${UNSAFE_FS_ENV}=1 to disable validation ` +
259
+ `entirely for trusted setups.`,
260
+ };
261
+ }
262
+ const safeRootCanonical = resolve(safeRoot);
263
+ if (cmd === 'export') {
264
+ const { dir, filenameFormat, stdout } = extractExportFlags(args);
265
+ // --stdout writes WXR to the response stream instead of the filesystem.
266
+ // No file is created, so --dir is irrelevant. Still validate
267
+ // --filename_format shape in case it's set (defensive — wp-cli ignores
268
+ // it under --stdout, but an attacker-supplied value shouldn't influence
269
+ // any future path-using code path).
270
+ if (!stdout) {
271
+ // Require explicit --dir. Without it, wp-cli writes to the current
272
+ // working directory, which on prod sites is typically the web root —
273
+ // our whole reason for existing.
274
+ if (!dir) {
275
+ return {
276
+ allowed: false,
277
+ reason: `wp export requires an explicit --dir=<path> (or --stdout) when filesystem ` +
278
+ `validation is enabled. Without --dir, wp-cli writes WXR files to the current ` +
279
+ `working directory — on prod sites that's the web root, which would expose ` +
280
+ `exported data publicly. Pass --dir=${safeRootCanonical} (or any path under ` +
281
+ `it), use --stdout for in-memory transfer, or set ${UNSAFE_FS_ENV}=1 to ` +
282
+ `disable validation for trusted setups.`,
283
+ };
284
+ }
285
+ // Reject relative --dir — wp-cli would resolve it against process.cwd,
286
+ // not our safe root, defeating the validation. See rejectIfRelative.
287
+ const rel = rejectIfRelative(dir, 'wp export --dir', safeRootCanonical);
288
+ if (rel)
289
+ return rel;
290
+ if (!isPathUnderSafeRoot(dir, safeRootCanonical)) {
291
+ return {
292
+ allowed: false,
293
+ reason: `wp export --dir="${dir}" resolves outside the safe filesystem root ` +
294
+ `"${safeRootCanonical}". Use a path under the safe root, or set ` +
295
+ `${UNSAFE_FS_ENV}=1 to disable validation.`,
296
+ };
297
+ }
298
+ }
299
+ // --filename_format is a filename template, not a path. wp-cli uses it
300
+ // to format files produced inside --dir (and contains placeholders like
301
+ // {site}, {date}, {n}). Reject path separators so a crafted
302
+ // --filename_format=../../etc/passwd can't escape --dir's scope.
303
+ if (filenameFormat !== null && (filenameFormat.includes('/') || filenameFormat.includes('\\'))) {
304
+ return {
305
+ allowed: false,
306
+ reason: `wp export --filename_format="${filenameFormat}" contains path separators. ` +
307
+ `--filename_format must be a filename template (e.g. "{site}.{date}.{n}.xml"), ` +
308
+ `not a path. Use --dir to set the output directory.`,
309
+ };
310
+ }
311
+ return { allowed: true };
312
+ }
313
+ if (cmd === 'acf export' || cmd === 'acf import') {
314
+ const userPath = extractPositionalAfterPrefix(args, 2);
315
+ if (!userPath) {
316
+ // Missing positional — let wp-cli surface its own usage error rather
317
+ // than returning an allowlist rejection that obscures the real issue.
318
+ return { allowed: true };
319
+ }
320
+ // Reject relative positional path — same reason as --dir above. Without
321
+ // this gate, `acf export relative/file.json` would resolve against
322
+ // process.cwd at execution time and escape the safe-root scope.
323
+ const rel = rejectIfRelative(userPath, cmd, safeRootCanonical);
324
+ if (rel)
325
+ return rel;
326
+ if (!isPathUnderSafeRoot(userPath, safeRootCanonical)) {
327
+ return {
328
+ allowed: false,
329
+ reason: `${cmd} "${userPath}" resolves outside the safe filesystem root ` +
330
+ `"${safeRootCanonical}". Use a path under the safe root, or set ` +
331
+ `${UNSAFE_FS_ENV}=1 to disable validation.`,
332
+ };
333
+ }
334
+ return { allowed: true };
335
+ }
336
+ return { allowed: true };
337
+ }
package/dist/wp-cli.js CHANGED
@@ -9,6 +9,7 @@ import { execFile } from 'child_process';
9
9
  import { readdirSync, readFileSync, existsSync } from 'fs';
10
10
  import { homedir } from 'os';
11
11
  import { join } from 'path';
12
+ import { resolveSafeFsRoot, fsValidationDisabled, ensureSafeFsRoot, matchFsSensitiveCommand, validateFilesystemFlags, } from './wp-cli-fs-validator.js';
12
13
  /**
13
14
  * Default WP-CLI commands — safe for public distribution.
14
15
  * Read-only commands + non-destructive writes needed for core MCP functionality.
@@ -321,6 +322,30 @@ export function createWpCli(config) {
321
322
  if (!check.allowed) {
322
323
  return { success: false, output: '', error: check.reason };
323
324
  }
325
+ // Second-pass FS validation for commands whose flags/args can read/write
326
+ // arbitrary paths. Scoped to DEFAULT-tier FS commands only — EXTENDED
327
+ // (`import`, `eval-file`) are opt-in via DIVIOPS_WP_CLI_ALLOW, so opting
328
+ // in signals the caller accepts path-scope risk. Skip entirely when the
329
+ // user explicitly disables via DIVIOPS_WP_CLI_UNSAFE_FS=1.
330
+ //
331
+ // Wrapper mode (WP_CLI_CMD set) is gated separately inside
332
+ // validateFilesystemFlags — host-derived safe roots don't correspond to
333
+ // the wrapper's filesystem namespace, so the validator requires an
334
+ // explicit DIVIOPS_WP_CLI_SAFE_FS_ROOT there. We also skip the host-side
335
+ // mkdir in wrapper mode since the safe root is either container-scoped
336
+ // (user-managed) or unset (validator rejects).
337
+ if (!fsValidationDisabled() && matchFsSensitiveCommand(args)) {
338
+ const safeRoot = resolveSafeFsRoot(config.wpPath);
339
+ if (!customWpCliCmd) {
340
+ ensureSafeFsRoot(safeRoot);
341
+ }
342
+ const fsCheck = validateFilesystemFlags(args, safeRoot, {
343
+ isWrapper: !!customWpCliCmd,
344
+ });
345
+ if (!fsCheck.allowed) {
346
+ return { success: false, output: '', error: fsCheck.reason };
347
+ }
348
+ }
324
349
  const fullArgs = customWpCliCmd
325
350
  ? [...prefixArgs, ...args, '--no-color']
326
351
  : [...args, `--path=${config.wpPath}`, '--no-color'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diviops/mcp-server",
3
- "version": "0.2.14",
3
+ "version": "0.2.15",
4
4
  "description": "MCP server exposing Divi 5 Visual Builder as tools for Claude",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",