PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.8
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.8
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / Mcp / Mcp_Activity_Log.php

Mcp_Activity_Log.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.1.8, at includes/modules/Mcp/Mcp_Activity_Log.php

294 lines 8.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Mcp_Activity_Log — audit trail of every MCP tool call.
4 *
5 * The site's whole AI story rests on the admin being able to answer
6 * "what did the assistant actually do to my site?". Settings mutations
7 * already land in the change log via Settings_Manager, but that only
8 * covers writes to the schema — a purge, a benchmark, a preloader run,
9 * or a read of the site's settings leaves no trace at all. This is the
10 * record for all of them.
11 *
12 * Storage is an OPTION, not a transient, and deliberately so: an audit
13 * trail that an object-cache flush can evaporate is not an audit trail.
14 * Autoload is off (it's only read by the panel/REST/CLI) and the ring is
15 * capped at MAX_ENTRIES so the row can't grow without bound.
16 *
17 * Entry shape:
18 * [ ts => int, tool => string, args => string, ok => bool,
19 * error => string, scope => 'read'|'write', channel => string ]
20 *
21 * `args` is a short human-readable summary, never the raw argument
22 * array — tool arguments can carry long option blobs, and an audit row
23 * needs to stay glanceable (and small). Values are truncated to
24 * ARGS_MAX chars in total.
25 *
26 * @package XSpeed
27 */
28
29 declare(strict_types=1);
30
31 namespace XSpeed\Modules\Mcp;
32
33 defined( 'ABSPATH' ) || exit;
34
35 final class Mcp_Activity_Log {
36
37 public const OPTION = 'xspeed_mcp_activity';
38 public const MAX_ENTRIES = 200;
39 public const ARGS_MAX = 200;
40
41 /** Placeholder written in place of a credential-looking value. */
42 public const REDACTED = '[redacted]';
43
44 /**
45 * Argument names whose VALUE must never reach the log.
46 *
47 * The audit trail is read in the dashboard, included in support
48 * snapshots, and lives in a database row that gets copied into
49 * staging and backups. A tool call that sets a Cloudflare API token
50 * or a license key would otherwise write that secret verbatim into
51 * all three. Matching is a case-insensitive substring test on the
52 * argument NAME, so `api_key`, `gtmetrix_api_key`, and `apiKey` all
53 * hit the same rule — the row still records that the key was set,
54 * just not what it was set to.
55 *
56 * @var string[]
57 */
58 private const SECRET_KEY_HINTS = array(
59 'token',
60 'secret',
61 'password',
62 'passwd',
63 '_pass',
64 'api_key',
65 'apikey',
66 'access_key',
67 'private_key',
68 'license',
69 'credential',
70 'auth',
71 'nonce',
72 'signature',
73 );
74
75 /**
76 * True when an argument name looks like it carries a credential.
77 *
78 * Deliberately generous: a false positive costs one unreadable audit
79 * value, a false negative writes a live secret to the database.
80 */
81 public static function is_secret_key( string $key ): bool {
82 $needle = strtolower( $key );
83
84 // A bare `key` (as opposed to, say, `cache_key`) is almost always
85 // a credential in this catalog's argument shapes.
86 if ( 'key' === $needle ) {
87 return true;
88 }
89
90 foreach ( self::SECRET_KEY_HINTS as $hint ) {
91 if ( false !== strpos( $needle, $hint ) ) {
92 return true;
93 }
94 }
95
96 return false;
97 }
98
99 /**
100 * Condense a decoded argument array into one short, readable line.
101 *
102 * Pure — no WP, no I/O — so the truncation contract is unit-testable.
103 * Scalars render as `key=value`; arrays/objects collapse to a shape
104 * hint (`key=[3 items]`) because an audit row should say that a list
105 * was passed, not reproduce it. Values under a credential-looking key
106 * render as `key=[redacted]` — see SECRET_KEY_HINTS.
107 *
108 * @param array<string,mixed> $args Decoded tool arguments.
109 */
110 public static function summarize_args( array $args ): string {
111 if ( empty( $args ) ) {
112 return '';
113 }
114
115 $parts = array();
116 foreach ( $args as $key => $value ) {
117 $key = (string) $key;
118
119 if ( self::is_secret_key( $key ) ) {
120 // Redact before any type branch. Arrays already collapse
121 // to a count and leak nothing, but a secret under a
122 // scalar key would otherwise be written verbatim.
123 $rendered = self::REDACTED;
124 } elseif ( is_bool( $value ) ) {
125 $rendered = $value ? 'true' : 'false';
126 } elseif ( is_scalar( $value ) || null === $value ) {
127 $rendered = (string) $value;
128 if ( strlen( $rendered ) > 60 ) {
129 $rendered = substr( $rendered, 0, 57 ) . '...';
130 }
131 } elseif ( is_array( $value ) ) {
132 $count = count( $value );
133 $rendered = sprintf( '[%d item%s]', $count, 1 === $count ? '' : 's' );
134 } else {
135 $rendered = '{object}';
136 }
137
138 $parts[] = $key . '=' . $rendered;
139 }
140
141 $summary = implode( ' ', $parts );
142
143 return strlen( $summary ) > self::ARGS_MAX
144 ? substr( $summary, 0, self::ARGS_MAX - 3 ) . '...'
145 : $summary;
146 }
147
148 /**
149 * Append one call to the ring. Never throws — an audit write must not
150 * be able to fail the tool call it is describing.
151 *
152 * @param array<string,mixed> $args Decoded tool arguments.
153 * @param string $error Error message when the call failed.
154 * @param string $scope 'read' or 'write'.
155 * @param string $channel Transport that carried the call.
156 */
157 public static function record(
158 string $tool,
159 array $args,
160 bool $ok,
161 string $error = '',
162 string $scope = 'write',
163 string $channel = 'mcp'
164 ): void {
165 try {
166 $entries = self::entries();
167
168 array_unshift(
169 $entries,
170 array(
171 'ts' => time(),
172 'tool' => sanitize_key( $tool ),
173 'args' => self::summarize_args( $args ),
174 'ok' => $ok,
175 'error' => $ok ? '' : substr( $error, 0, 200 ),
176 'scope' => 'read' === $scope ? 'read' : 'write',
177 'channel' => sanitize_key( $channel ),
178 )
179 );
180
181 if ( count( $entries ) > self::MAX_ENTRIES ) {
182 $entries = array_slice( $entries, 0, self::MAX_ENTRIES );
183 }
184
185 update_option( self::OPTION, $entries, false );
186 } catch ( \Throwable $e ) {
187 // An audit-trail failure must never surface as a tool failure.
188 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
189 error_log( 'xSpeed MCP activity log write failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated diagnostic.
190 }
191 }
192 }
193
194 /**
195 * Newest-first entries. Defensive shape coercion so a hand-edited or
196 * partially-written option can never break the panel.
197 *
198 * @param int $limit Max entries to return; 0 means all.
199 * @return array<int,array{ts:int,tool:string,args:string,ok:bool,error:string,scope:string,channel:string}>
200 */
201 public static function entries( int $limit = 0 ): array {
202 $raw = get_option( self::OPTION, array() );
203 if ( ! is_array( $raw ) ) {
204 return array();
205 }
206
207 $out = array();
208 foreach ( $raw as $entry ) {
209 if ( ! is_array( $entry ) || ! isset( $entry['ts'], $entry['tool'] ) ) {
210 continue;
211 }
212 $out[] = array(
213 'ts' => (int) $entry['ts'],
214 'tool' => (string) $entry['tool'],
215 'args' => isset( $entry['args'] ) ? (string) $entry['args'] : '',
216 'ok' => ! empty( $entry['ok'] ),
217 'error' => isset( $entry['error'] ) ? (string) $entry['error'] : '',
218 'scope' => isset( $entry['scope'] ) && 'read' === $entry['scope'] ? 'read' : 'write',
219 'channel' => isset( $entry['channel'] ) ? (string) $entry['channel'] : 'mcp',
220 );
221 }
222
223 if ( $limit > 0 && count( $out ) > $limit ) {
224 $out = array_slice( $out, 0, $limit );
225 }
226
227 return $out;
228 }
229
230 /**
231 * Roll-up for the panel header: total calls, failures, last call time,
232 * and the most-used tool. Computed from the ring rather than stored,
233 * so it can never disagree with the rows shown underneath it.
234 *
235 * @return array{total:int,failed:int,last_ts:int,top_tool:string}
236 */
237 public static function summary(): array {
238 $entries = self::entries();
239
240 $failed = 0;
241 $counts = array();
242 foreach ( $entries as $entry ) {
243 if ( ! $entry['ok'] ) {
244 ++$failed;
245 }
246 $tool = $entry['tool'];
247 $counts[ $tool ] = isset( $counts[ $tool ] ) ? $counts[ $tool ] + 1 : 1;
248 }
249
250 $top_tool = '';
251 if ( ! empty( $counts ) ) {
252 arsort( $counts );
253 $top_tool = (string) array_key_first( $counts );
254 }
255
256 return array(
257 'total' => count( $entries ),
258 'failed' => $failed,
259 'last_ts' => empty( $entries ) ? 0 : $entries[0]['ts'],
260 'top_tool' => $top_tool,
261 );
262 }
263
264 /**
265 * Wipe the audit trail.
266 *
267 * Refused while an MCP tool call is being dispatched. The whole point
268 * of the trail is to answer "what did the assistant do to my site?",
269 * and `run_command("mcp activity", …, {"clear": true})` let the
270 * assistant answer that question with a blank page — an audit log the
271 * audited party can erase is not one. Clearing stays available to a
272 * human from the dashboard button and from real WP-CLI, neither of
273 * which runs inside a dispatch.
274 *
275 * @return bool True when the log was cleared, false when refused.
276 */
277 public static function clear(): bool {
278 if ( Mcp_Tools::in_dispatch() ) {
279 self::record(
280 'mcp_activity_clear',
281 array(),
282 false,
283 'Refused: the audit trail cannot be cleared from an MCP tool call.',
284 'write',
285 'mcp'
286 );
287 return false;
288 }
289
290 delete_option( self::OPTION );
291 return true;
292 }
293 }
294