Skip to content

Commit 297b849

Browse files
authored
Merge pull request #220 from imagewize/fix/ssh-passthrough-credential-guard
Require confirm before reading credential files via MCP
2 parents 36a907e + 09ba583 commit 297b849

3 files changed

Lines changed: 60 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [5.20.1] - 2026-09-09
11+
12+
### Security
13+
14+
- **MCP `ssh_command` and `wp_cli` no longer auto-approve reads of credential-bearing
15+
files.** `ssh_command`'s read-only allowlist (`cat`, `grep`, `head`, `tail`, ...) let
16+
`cat web/wp-config.php` or `cat ~/.ssh/id_rsa` through with no `confirm: true` step,
17+
because the *command* was harmless in general even though this particular argument
18+
wasn't — same failure mode as [a WordPress consultant's LinkedIn post this fix was
19+
prompted by](https://www.linkedin.com/posts/remkusdevries_connecting-ai-to-a-wordpress-site-now-takes-share-7503145873036685313-vCPk/)
20+
describes: "once it has local file access, it can find your ssh credential... the
21+
database credentials in your wp-config.php." `isReadOnlySshCommand()` now also
22+
refuses when any argument matches a credential-shaped path (`wp-config.php`, `.env`,
23+
`.ssh/`, `id_rsa`/`id_ed25519`/`id_ecdsa`, `*.pem`, `authorized_keys`, `.netrc`,
24+
`.pgpass`, `.git-credentials`), forcing the normal confirm-after-explicit-approval
25+
flow. Separately, `wp_cli`'s `isReadOnlyWpCommand()` treated `wp config get
26+
DB_PASSWORD` as read-only too — `get` is a safe verb in general, but `wp config` is
27+
WP-CLI's direct interface onto wp-config.php's constants, an even more direct route
28+
to the same credentials. `wp config *` (any verb) now always needs `confirm: true`.
29+
Neither change blocks legitimate reads or edits — it only removes the no-confirmation
30+
fast path, matching how every other sensitive MCP operation already works.
31+
1032
## [5.20.0] - 2026-09-09
1133

1234
### Added

mcp-server/src/tools/sshPassthrough.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,39 @@ function tokenizeCommand(command: string): string[] {
9797
return tokens;
9898
}
9999

100+
// Path fragments that indicate a credential-bearing file. Even a command in
101+
// READ_ONLY_COMMANDS (cat, grep, head, tail, ...) needs confirm: true when any
102+
// argument targets one of these — "cat" being harmless in general doesn't mean
103+
// "cat wp-config.php" or "cat ~/.ssh/id_rsa" should be auto-approved, since that
104+
// prints database credentials or an SSH private key straight into the response.
105+
const CREDENTIAL_PATH_PATTERNS: RegExp[] = [
106+
/wp-config\.php$/i,
107+
/(^|\/)\.env(\.|$)/,
108+
/(^|\/)\.ssh(\/|$)/,
109+
/(^|\/)id_rsa/,
110+
/(^|\/)id_ed25519/,
111+
/(^|\/)id_ecdsa/,
112+
/\.pem$/i,
113+
/(^|\/)authorized_keys$/,
114+
/(^|\/)\.netrc$/,
115+
/(^|\/)\.pgpass$/,
116+
/(^|\/)\.git-credentials$/,
117+
];
118+
119+
function touchesCredentialPath(tokens: string[]): boolean {
120+
return tokens
121+
.slice(1)
122+
.some((token) => CREDENTIAL_PATH_PATTERNS.some((pattern) => pattern.test(token)));
123+
}
124+
100125
export function isReadOnlySshCommand(command: string): boolean {
101126
const tokens = tokenizeCommand(command);
102127
const first = tokens[0];
103128
if (!first) return false;
104129
const base = path.basename(first);
105-
return READ_ONLY_COMMANDS.has(base);
130+
if (!READ_ONLY_COMMANDS.has(base)) return false;
131+
if (touchesCredentialPath(tokens)) return false;
132+
return true;
106133
}
107134

108135
export function runSshCommand(entry: EnvEntry, command: string): Promise<SshCommandResult> {

mcp-server/src/tools/wpCli.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,18 @@ const NESTED_RESOURCE_VERBS: Record<string, Set<string>> = {
3636
cron: new Set(["event", "schedule"]),
3737
};
3838

39+
// "wp config" is WP-CLI's dedicated interface onto wp-config.php constants —
40+
// DB_PASSWORD, DB_USER, AUTH_KEY salts, and whatever else a site defines there.
41+
// "get"/"list"/"export" all being in SAFE_READ_VERBS would otherwise let
42+
// "wp config get DB_PASSWORD" through with no confirmation, which is a plainer
43+
// path to the same credentials `cat wp-config.php` would print. Always confirm.
44+
const ALWAYS_CONFIRM_COMMANDS = new Set(["config"]);
45+
3946
export function isReadOnlyWpCommand(args: string[]): boolean {
4047
const [command, verbOrResource, thirdToken] = args;
48+
if (command !== undefined && ALWAYS_CONFIRM_COMMANDS.has(command)) {
49+
return false;
50+
}
4151
if (verbOrResource !== undefined && SAFE_READ_VERBS.has(verbOrResource)) {
4252
return true;
4353
}

0 commit comments

Comments
 (0)