diff --git a/.agents/skills/add-mcp-tool.md b/.agents/skills/add-mcp-tool.md index 3854389..8d19955 100644 --- a/.agents/skills/add-mcp-tool.md +++ b/.agents/skills/add-mcp-tool.md @@ -87,11 +87,12 @@ The following are intentionally excluded from MCP: - Site creation (Pressable or WPCOM) - User deletion -- WP-CLI command execution - Deployment triggers If the user requests one, explain that these operations are excluded by design and must be run via the CLI. +**Sanctioned exception — command execution with guardrails.** WP-CLI and SSH execution are exposed, but only because they ship with guardrails: WP-CLI tools (`*_run_wp_cli_command`) use a restrictive allowlist (`is_allowed_wp_cli_command()`); SSH tools (`*_run_ssh_command`) use a catastrophic-command denylist (`is_blocked_ssh_command()`). Both audit-log every call (`audit_wp_cli_command()` / `audit_ssh_command()`) and carry a `destructiveHint: true` annotation. The MCP server cannot itself enforce human approval (no elicitation support in `php-mcp/server`), so per-command approval relies entirely on the client's approval prompt — these tools must never be auto-approved/allowlisted in the client. Follow this same pattern (guardrail + audit + destructive annotation) for any new execution-style tool; do not add an unguarded one. + ## Verification 1. Restart the MCP server: `team51 --mcp` (Ctrl+C to stop). diff --git a/AGENTS.md b/AGENTS.md index 07cecc3..72cf8bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,7 +153,9 @@ See `.agents/subagents/add-cli-command.md` for a detailed runbook. 4. Use existing `get_*` functions from includes. 5. Return arrays (or error arrays with `'error' => '...'`). No STDOUT — reserved for JSON-RPC. -**MUST**: Do not add high-risk tools (site creation, user deletion, WP-CLI execution, deployments). See README MCP section. +**MUST**: Do not add high-risk tools (site creation, user deletion, deployments). See README MCP section. + +Command execution (WP-CLI and SSH) is the one sanctioned exception, and only *with guardrails*: `pressable_run_wp_cli_command`/`wpcom_run_wp_cli_command` use an allowlist + audit log; `pressable_run_ssh_command`/`wpcom_run_ssh_command` use a denylist + audit log + `destructiveHint` annotation. The MCP server cannot enforce human approval itself (no elicitation support), so these tools depend on the client's per-command approval prompt and must never be auto-approved/allowlisted in the client. **For full details**: `.agents/skills/add-mcp-tool.md` diff --git a/README.md b/README.md index 3e50875..4e6378f 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ claude mcp add team51 -- team51 --mcp ### Available Tools -The MCP server currently exposes 67 tools across all services. The table below shows a subset of commonly used tools: +The MCP server currently exposes 69 tools across all services. The table below shows a subset of commonly used tools: | Service | Read Tools | Write Tools | |---------|-----------|-------------| @@ -109,7 +109,18 @@ The MCP server currently exposes 67 tools across all services. The table below s Write tools are annotated with MCP `ToolAnnotations` (`destructiveHint`, `readOnlyHint`, etc.) so clients prompt for confirmation before executing destructive actions. -High-risk operations (site creation, user deletion, WP-CLI execution, deployments) are intentionally excluded. +#### High-risk command execution (WP-CLI and SSH) + +Command-execution tools are exposed but guarded: + +| Tool | Guardrail | +|------|-----------| +| `pressable_run_wp_cli_command`, `wpcom_run_wp_cli_command` | Restrictive **allowlist** of read-only WP-CLI subcommands + audit log | +| `pressable_run_ssh_command`, `wpcom_run_ssh_command` | Arbitrary commands allowed, but a **denylist** blocks catastrophic patterns (`rm -rf /`, fork bombs, `dd`/`mkfs`, `reboot`, …) + audit log | + +**Human approval is required for every SSH command.** The MCP protocol cannot enforce this server-side (the `php-mcp/server` version in use has no elicitation support), so each call relies on the **client's per-command approval prompt** — guaranteed only by the `destructiveHint` annotation and by **never adding these tools to the client's auto-approve allowlist**. The denylist is defense-in-depth, not a hard boundary; the human prompt is the real gate. Interactive shell sessions remain unsupported over MCP (use the `pressable:open-site-shell` CLI command for those). + +Other high-risk operations (site creation, user deletion, deployments) are intentionally excluded — run them via the CLI. ### Extending diff --git a/commands/Pressable_Site_WP_CLI_Command_Run.php b/commands/Pressable_Site_WP_CLI_Command_Run.php index c0b1113..77781e9 100644 --- a/commands/Pressable_Site_WP_CLI_Command_Run.php +++ b/commands/Pressable_Site_WP_CLI_Command_Run.php @@ -128,10 +128,11 @@ protected function execute( InputInterface $input, OutputInterface $output ): in try { $ssh->setTimeout( 0 ); // Disable timeout in case the command takes a long time. + $GLOBALS['wp_cli_output'] = ''; // Reset before each run; the callback appends each chunk so multi-packet output is captured in full. $ssh->exec( "wp $this->wp_command", function ( string $str ): void { - $GLOBALS['wp_cli_output'] = $str; + $GLOBALS['wp_cli_output'] .= $str; if ( ! $this->skip_output ) { echo "$str\n"; } diff --git a/commands/WPCOM_Site_WP_CLI_Command_Run.php b/commands/WPCOM_Site_WP_CLI_Command_Run.php index 697c10a..0ed5163 100644 --- a/commands/WPCOM_Site_WP_CLI_Command_Run.php +++ b/commands/WPCOM_Site_WP_CLI_Command_Run.php @@ -60,14 +60,18 @@ protected function configure(): void { /** * {@inheritDoc} + * + * @throws \InvalidArgumentException If the site is not a WordPress.com Atomic site. */ protected function initialize( InputInterface $input, OutputInterface $output ): void { $this->site = get_wpcom_site_input( $input, fn() => $this->prompt_site_input( $input, $output ) ); $input->setArgument( 'site', $this->site ); if ( ! $this->site->is_wpcom_atomic ) { - $output->writeln( 'This command is only available for WordPress.com Atomic sites.' ); - exit( 1 ); + // Do not exit() here: this command is hosted inside the long-lived MCP server + // (via run_app_command), where exit() would terminate the whole server process. + // Throwing lets Symfony (CLI) and the MCP tool layer surface a clean error instead. + throw new \InvalidArgumentException( 'This command is only available for WordPress.com Atomic sites.' ); } $this->wp_command = get_string_input( $input, 'wp-cli-command', fn() => $this->prompt_command_input( $input, $output ) ); @@ -107,10 +111,11 @@ protected function execute( InputInterface $input, OutputInterface $output ): in try { $ssh->setTimeout( 0 ); // Disable timeout in case the command takes a long time. + $GLOBALS['wp_cli_output'] = ''; // Reset before each run; the callback appends each chunk so multi-packet output is captured in full. $ssh->exec( "wp $this->wp_command", function ( string $str ): void { - $GLOBALS['wp_cli_output'] = $str; + $GLOBALS['wp_cli_output'] .= $str; if ( ! $this->skip_output ) { echo "$str\n"; } diff --git a/includes/functions-pressable.php b/includes/functions-pressable.php index e8afe93..cf36f52 100644 --- a/includes/functions-pressable.php +++ b/includes/functions-pressable.php @@ -622,6 +622,50 @@ function run_pressable_site_wp_cli_command( string $site_id_or_url, string $wp_c ); } +/** + * Runs an arbitrary, non-interactive shell command on the specified Pressable site over SSH and returns the captured output. + * + * Unlike run_pressable_site_wp_cli_command(), the command is not prefixed with `wp` and is executed verbatim. This is a + * high-risk operation: callers are responsible for guarding/approving the command (e.g. the MCP layer requires human approval). + * + * @param string $site_id_or_url The ID or URL of the site to run the command on. + * @param string $ssh_command The raw shell command to run. + * + * @return string|null The captured command output, or null if the site could not be resolved or the SSH connection failed. + */ +function run_pressable_site_ssh_command( string $site_id_or_url, string $ssh_command ): ?string { + // Accept a full URL, a bare domain, or a numeric ID. Normalize URLs to a host, mirroring get_site_input(). + if ( str_contains( $site_id_or_url, 'http' ) ) { + $host = parse_url( $site_id_or_url, PHP_URL_HOST ); + $site_id_or_url = empty( $host ) ? $site_id_or_url : $host; + } + + $site = get_pressable_site( $site_id_or_url ); + if ( is_null( $site ) ) { + return null; + } + + $ssh = Pressable_Connection_Helper::get_ssh_connection( $site->id ); + if ( is_null( $ssh ) ) { + return null; + } + + $output = ''; + try { + $ssh->setTimeout( 0 ); // Disable timeout in case the command takes a long time. + $ssh->exec( + $ssh_command, + static function ( string $str ) use ( &$output ): void { + $output .= $str; + } + ); + } finally { + $ssh->disconnect(); + } + + return $output; +} + /** * Creates a new DeployHQ project for the specified Pressable site. * diff --git a/includes/functions-wpcom.php b/includes/functions-wpcom.php index 7763502..dd4f9fc 100644 --- a/includes/functions-wpcom.php +++ b/includes/functions-wpcom.php @@ -971,6 +971,51 @@ function run_wpcom_site_wp_cli_command( string $site_id_or_url, string $wp_cli_c ); } +/** + * Runs an arbitrary, non-interactive shell command on the specified WordPress.com Atomic site over SSH and returns the captured output. + * + * Unlike run_wpcom_site_wp_cli_command(), the command is not prefixed with `wp` and is executed verbatim. SSH is only available on + * Atomic sites. This is a high-risk operation: callers are responsible for guarding/approving the command (e.g. the MCP layer + * requires human approval). + * + * @param string $site_id_or_url The ID or URL of the site to run the command on. + * @param string $ssh_command The raw shell command to run. + * + * @return string|null The captured command output, or null if the site could not be resolved, is not Atomic, or the SSH connection failed. + */ +function run_wpcom_site_ssh_command( string $site_id_or_url, string $ssh_command ): ?string { + // Accept a full URL, a bare domain, or a numeric ID. Normalize URLs to a host, mirroring get_site_input(). + if ( str_contains( $site_id_or_url, 'http' ) ) { + $host = parse_url( $site_id_or_url, PHP_URL_HOST ); + $site_id_or_url = empty( $host ) ? $site_id_or_url : $host; + } + + $site = get_wpcom_site( $site_id_or_url ); + if ( is_null( $site ) || empty( $site->is_wpcom_atomic ) ) { + return null; + } + + $ssh = WPCOM_Connection_Helper::get_ssh_connection( $site->ID ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + if ( is_null( $ssh ) ) { + return null; + } + + $output = ''; + try { + $ssh->setTimeout( 0 ); // Disable timeout in case the command takes a long time. + $ssh->exec( + $ssh_command, + static function ( string $str ) use ( &$output ): void { + $output .= $str; + } + ); + } finally { + $ssh->disconnect(); + } + + return $output; +} + // endregion // region CONSOLE diff --git a/mcp-server.php b/mcp-server.php index 0daefe0..c1aa350 100755 --- a/mcp-server.php +++ b/mcp-server.php @@ -22,10 +22,14 @@ * } * } * } + * + * @package WPCOMSpecialProjects\CLI */ use PhpMcp\Server\Server; use PhpMcp\Server\Transports\StdioServerTransport; +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\StreamOutput; // Set up constants needed by the CLI environment. @@ -47,6 +51,25 @@ // Mark as non-autocomplete so that identity loading proceeds when needed. $GLOBALS['team51_is_autocomplete'] = false; +// Build the Symfony Console application and register ONLY the commands the MCP tools +// dispatch internally via run_app_command(). Without an app, run_app_command() fatals with +// "Call to a member function find() on null" in MCP mode — team51-cli.php builds the app for +// normal CLI runs, but its `--mcp` branch returns before reaching that code. The SSH tools +// connect directly and need no command; only the WP-CLI execution tools delegate to one. +// Register a command here only if a new MCP tool needs to run it via run_app_command(). +$team51_cli_app = new Application(); +$team51_mcp_command_classes = array( + \WPCOMSpecialProjects\CLI\Command\Pressable_Site_WP_CLI_Command_Run::class, + \WPCOMSpecialProjects\CLI\Command\WPCOM_Site_WP_CLI_Command_Run::class, +); +foreach ( $team51_mcp_command_classes as $team51_command_class ) { + $team51_command = new $team51_command_class(); + // The commands' interactive site-prompt fallback reads --no-autocomplete; define it so + // getOption() always resolves. (MCP supplies the site argument, so the prompt isn't hit.) + $team51_command->addOption( '--no-autocomplete', null, InputOption::VALUE_NONE, 'Do not provide options to initialization questions.' ); + $team51_cli_app->add( $team51_command ); +} + // Identity (1Password credentials) is loaded lazily on first tool call, // not at startup. This prevents Cursor from prompting for 1Password unlock // every time a project is opened. See Team51McpTools::ensure_identity(). diff --git a/mcp/Team51McpTools.php b/mcp/Team51McpTools.php index 1500ce8..612d6fb 100644 --- a/mcp/Team51McpTools.php +++ b/mcp/Team51McpTools.php @@ -14,7 +14,11 @@ * * High-risk operations are exposed only when explicitly needed and are marked * with ToolAnnotations so MCP clients can prompt/guard appropriately. This - * includes WP-CLI execution tools, which are allowlisted and audit-logged. + * includes WP-CLI execution tools (allowlisted and audit-logged) and SSH + * command execution tools (denylisted and audit-logged). Because the MCP + * protocol cannot itself enforce human approval, these tools rely on the + * client's per-command approval prompt and MUST NOT be auto-approved/allowlisted + * in the client configuration. * * IMPORTANT: When adding new tools, keep in mind that STDOUT is reserved for * JSON-RPC communication. Use STDERR for any debug output. @@ -177,6 +181,70 @@ private static function audit_wp_cli_command( string $provider, string $site_id_ ); } + /** + * Denylist of catastrophic shell commands that must never run over MCP SSH, regardless of approval. + * + * This is intentionally NOT an allowlist: arbitrary SSH is the point of the tool. The denylist is + * defense-in-depth against an accidental rubber-stamp, NOT a hard security boundary — it is trivially + * bypassable (encodings, aliases, scripts). The real gate is the client's per-command human approval prompt. + * + * @param string $command Raw shell command. + * + * @return bool True if the command is blocked. + */ + private static function is_blocked_ssh_command( string $command ): bool { + // Normalize: collapse whitespace and lowercase for pattern matching. + $normalized = strtolower( trim( preg_replace( '/\s+/', ' ', $command ) ?? '' ) ); + if ( '' === $normalized ) { + return false; + } + + $blocked_patterns = array( + '/\brm\s+(-[a-z]*\s+)*-[a-z]*r[a-z]*f|\brm\s+(-[a-z]*\s+)*-[a-z]*f[a-z]*r/', // rm -rf / -fr in any flag order. + '/--no-preserve-root/', + '/:\s*\(\s*\)\s*\{/', // Fork bomb declaration of the form colon-paren-paren-brace. + '/\bmkfs\b/', // Format a filesystem. + '/\bdd\b.*\bof=\/dev\//', // dd writing to a device. + '/>\s*\/dev\/(sd|nvme|disk|hd|vd)/', // Redirect into a block device. + '/\b(shutdown|reboot|halt|poweroff)\b/', + '/\binit\s+[06]\b/', + '/\bchmod\s+(-[a-z]*\s+)*-[a-z]*r[a-z]*\s+0{3}\s+\//', // chmod -R 000 / + ); + + foreach ( $blocked_patterns as $pattern ) { + if ( preg_match( $pattern, $normalized ) ) { + return true; + } + } + + return false; + } + + /** + * Emits a structured audit entry for high-risk SSH command execution. + * + * @param string $provider Either wpcom or pressable. + * @param string $site_id_or_url Site identifier passed by caller. + * @param string $command Raw shell command. + * + * @return void + */ + private static function audit_ssh_command( string $provider, string $site_id_or_url, string $command ): void { + $actor = defined( 'OPSOASIS_WP_USERNAME' ) ? OPSOASIS_WP_USERNAME : 'unknown'; + + error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + '[MCP SSH AUDIT] ' . ( encode_json_content( + array( + 'provider' => $provider, + 'site' => $site_id_or_url, + 'command' => trim( $command ), + 'actor' => $actor, + 'timestamp' => gmdate( DATE_ATOM ), + ) + ) ?? '' ) + ); + } + /** * Filters sites by deny-list. * @@ -1891,6 +1959,48 @@ public function wpcom_run_wp_cli_command( string $site_id_or_url, string $wp_cli ); } + /** + * Runs a single, non-interactive shell command on a WordPress.com Atomic site over SSH and returns the captured output. + * + * HIGH RISK: this runs arbitrary shell commands. The MCP server cannot itself enforce human approval; that gate + * is the client's per-call approval prompt, which the destructive annotation below ensures is shown. This tool + * must NOT be added to the client's auto-approve allowlist. Server-side safeguards are a catastrophic-command + * denylist (defense-in-depth, not a hard boundary) and an audit log of every executed command. SSH is only + * available on Atomic sites. + * + * @param string $site_id_or_url The WordPress.com site ID or URL. + * @param string $ssh_command The shell command to run. + */ + #[McpTool( + name: 'wpcom_run_ssh_command', + annotations: new ToolAnnotations( + title: 'Run SSH Command on WordPress.com Site (High Risk — Requires Human Approval)', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + ) + )] + public function wpcom_run_ssh_command( string $site_id_or_url, string $ssh_command ): array { + $identity_error = self::ensure_identity(); + if ( $identity_error ) { + return $identity_error; + } + + if ( self::is_blocked_ssh_command( $ssh_command ) ) { + return array( 'error' => 'Command is blocked by the MCP SSH denylist (potentially catastrophic operation).' ); + } + + self::audit_ssh_command( 'wpcom', $site_id_or_url, $ssh_command ); + + $output = run_wpcom_site_ssh_command( $site_id_or_url, $ssh_command ); + if ( null === $output ) { + return array( 'error' => "Failed to run SSH command on WordPress.com site '$site_id_or_url'. The site may not exist, may not be an Atomic site (SSH unavailable), or the SSH connection could not be established." ); + } + + return array( 'output' => $output ); + } + #[McpTool( name: 'wpcom_connect_site_repository' )] public function wpcom_connect_site_repository( string $site_id_or_url, string $repository, string $branch = 'trunk', string $target_dir = '/wp-content/', bool $deploy = false ): array { $identity_error = self::ensure_identity(); @@ -2166,10 +2276,51 @@ public function pressable_run_wp_cli_command( string $site_id_or_url, string $wp ); } + /** + * Runs a single, non-interactive shell command on a Pressable site over SSH and returns the captured output. + * + * HIGH RISK: this runs arbitrary shell commands. The MCP server cannot itself enforce human approval; that gate + * is the client's per-call approval prompt, which the destructive annotation below ensures is shown. This tool + * must NOT be added to the client's auto-approve allowlist. Server-side safeguards are a catastrophic-command + * denylist (defense-in-depth, not a hard boundary) and an audit log of every executed command. + * + * @param string $site_id_or_url The Pressable site ID or URL. + * @param string $ssh_command The shell command to run. + */ + #[McpTool( + name: 'pressable_run_ssh_command', + annotations: new ToolAnnotations( + title: 'Run SSH Command on Pressable Site (High Risk — Requires Human Approval)', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + ) + )] + public function pressable_run_ssh_command( string $site_id_or_url, string $ssh_command ): array { + $identity_error = self::ensure_identity(); + if ( $identity_error ) { + return $identity_error; + } + + if ( self::is_blocked_ssh_command( $ssh_command ) ) { + return array( 'error' => 'Command is blocked by the MCP SSH denylist (potentially catastrophic operation).' ); + } + + self::audit_ssh_command( 'pressable', $site_id_or_url, $ssh_command ); + + $output = run_pressable_site_ssh_command( $site_id_or_url, $ssh_command ); + if ( null === $output ) { + return array( 'error' => "Failed to run SSH command on Pressable site '$site_id_or_url'. The site may not exist or the SSH connection could not be established." ); + } + + return array( 'output' => $output ); + } + #[McpTool( name: 'pressable_open_site_shell' )] public function pressable_open_site_shell( string $site_id_or_url, string $shell_type = 'ssh' ): array { return array( - 'error' => 'Unsupported operation in MCP context: interactive shell sessions are not supported over JSON-RPC.', + 'error' => 'Unsupported operation in MCP context: interactive shell sessions are not supported over JSON-RPC. To run a single non-interactive command, use the pressable_run_ssh_command tool instead.', 'site' => $site_id_or_url, 'shell_type' => $shell_type, );