@diviops/mcp-server 0.2.14 → 0.2.16
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 +14 -1
- package/dist/compatibility.d.ts +1 -1
- package/dist/compatibility.js +1 -1
- package/dist/index.js +5 -5
- package/dist/wp-cli-fs-validator.d.ts +117 -0
- package/dist/wp-cli-fs-validator.js +337 -0
- package/dist/wp-cli.js +25 -0
- package/package.json +1 -1
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
|
-
|
|
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/compatibility.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Version compatibility between MCP server and WP plugin.
|
|
3
3
|
*/
|
|
4
4
|
/** Minimum WP plugin version this server requires. */
|
|
5
|
-
export declare const MIN_PLUGIN_VERSION = "1.0.0-beta.
|
|
5
|
+
export declare const MIN_PLUGIN_VERSION = "1.0.0-beta.34";
|
|
6
6
|
/**
|
|
7
7
|
* Compare two semver-like version strings (supports pre-release tags).
|
|
8
8
|
*
|
package/dist/compatibility.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Version compatibility between MCP server and WP plugin.
|
|
3
3
|
*/
|
|
4
4
|
/** Minimum WP plugin version this server requires. */
|
|
5
|
-
export const MIN_PLUGIN_VERSION = '1.0.0-beta.
|
|
5
|
+
export const MIN_PLUGIN_VERSION = '1.0.0-beta.34';
|
|
6
6
|
/**
|
|
7
7
|
* Compare two semver-like version strings (supports pre-release tags).
|
|
8
8
|
*
|
package/dist/index.js
CHANGED
|
@@ -582,7 +582,7 @@ server.registerTool("diviops_create_page", {
|
|
|
582
582
|
});
|
|
583
583
|
// ── Preset Tools ────────────────────────────────────────────────────
|
|
584
584
|
server.registerTool("diviops_preset_audit", {
|
|
585
|
-
description: "Audit all Divi presets (module + group). Each entry reports `block_ref_count` (page-content refs via modulePreset / groupPreset block markup), `group_ref_count` (in-registry chain refs from other presets via attrs.
|
|
585
|
+
description: "Audit all Divi presets (module + group). Each entry reports `block_ref_count` (page-content refs via modulePreset / groupPreset block markup), `group_ref_count` (in-registry chain refs from other presets — module presets via top-level `groupPresets.<slot>.presetId`, group presets via `attrs.groupPreset.<slot>.presetId`), and `referenced` (true if either > 0). Group presets that are chain-referenced also expose `referenced_by_presets` (UUIDs of the presets that wire them in — typically module presets, but type-agnostic). Use this before deleting — orphan-cleanup based only on page refs would silently wipe load-bearing chain-wired group presets (font, border, box-shadow, spacing, button).",
|
|
586
586
|
}, async () => {
|
|
587
587
|
const result = await wp.request("/preset-audit");
|
|
588
588
|
return {
|
|
@@ -726,7 +726,7 @@ server.registerTool("diviops_preset_create", {
|
|
|
726
726
|
};
|
|
727
727
|
});
|
|
728
728
|
server.registerTool("diviops_preset_reassign", {
|
|
729
|
-
description: 'Reassign a preset UUID across page content. Covers both module-level refs (`attrs.modulePreset[...]`) and attribute-level group-preset refs (`attrs.groupPreset.<slot>.presetId`), plus — for group presets — registry chain refs
|
|
729
|
+
description: 'Reassign a preset UUID across page content. Covers both module-level refs (`attrs.modulePreset[...]`) and attribute-level group-preset refs (`attrs.groupPreset.<slot>.presetId`), plus — for group presets — registry chain refs: module-bucket presets via top-level `groupPresets.<slot>.presetId`, group-bucket presets via `attrs.groupPreset.<slot>.presetId`. The `scope` param controls which ref types are walked (default "both", auto-selects based on new_uuid\'s bucket). Cross-bucket swaps (module ↔ group) are rejected. For module-scope swaps, optionally strips inline attrs that duplicate the new preset\'s attrs (otherwise inline wins over preset); slot-scoped inline strip for group scope is not yet implemented and is skipped with an advisory. Defaults to dry-run — set mode="apply" to actually rewrite. Use this to consolidate repeated inline styling into a reusable preset after creating one with diviops_preset_create.',
|
|
730
730
|
inputSchema: {
|
|
731
731
|
old_uuid: z
|
|
732
732
|
.string()
|
|
@@ -752,7 +752,7 @@ server.registerTool("diviops_preset_reassign", {
|
|
|
752
752
|
.enum(["module", "group", "both"])
|
|
753
753
|
.optional()
|
|
754
754
|
.default("both")
|
|
755
|
-
.describe('"module" walks `attrs.modulePreset[...]` only. "group" walks `attrs.groupPreset.<slot>.presetId` plus registry chain refs (`attrs.
|
|
755
|
+
.describe('"module" walks `attrs.modulePreset[...]` only. "group" walks `attrs.groupPreset.<slot>.presetId` plus registry chain refs (top-level `groupPresets.<slot>.presetId` on module presets, `attrs.groupPreset.<slot>.presetId` on group presets). "both" (default) auto-selects based on new_uuid\'s bucket — module/group identity is disjoint, so there is one valid walk per swap. An explicit "module" or "group" rejects if new_uuid is in the wrong bucket.'),
|
|
756
756
|
},
|
|
757
757
|
}, async ({ old_uuid, new_uuid, page_ids, mode, strip_inline, scope }) => {
|
|
758
758
|
const body = {
|
|
@@ -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", "
|
|
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'];
|