PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.14
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.14
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
fluentform / app / Modules / MCP / Support / Mutation.php

Mutation.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.14, at app/Modules/MCP/Support/Mutation.php

285 lines 11.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentForm\App\Modules\MCP\Support;
4
5 defined('ABSPATH') || exit;
6
7 /**
8 * The audit + write path for MCP tools. Every mutation an agent performs, and
9 * every read of entry data, goes through here so these concerns live in one
10 * place:
11 *
12 * - Accountability: one audit record per write (and per entry read, via
13 * auditRead), into FluentForm's own
14 * fluentform_logs (component "MCP"), so it shows up in Tools → Logs next to
15 * every other form event — who (wp user), what tool, redacted params,
16 * success/failure — without a separate log file to find.
17 * - Safety for destructive writes: runGuarded() routes through WriteGuard's
18 * dry-run → confirm_token → idempotency flow before mutating.
19 *
20 * Reversible writes (status change, note, create) use run(): execute, then
21 * audit. Destructive writes (permanent delete, bulk) use runGuarded().
22 */
23 class Mutation
24 {
25 const AUDIT_COMPONENT = 'MCP';
26
27 /**
28 * Longest string kept in an audit row. Params are logged verbatim, so a note
29 * body, an email template, or a CSS blob would otherwise be copied whole
30 * into fluentform_logs on every call.
31 */
32 const MAX_LOGGED_VALUE = 200;
33
34 /**
35 * Run a reversible mutation and audit the outcome.
36 *
37 * @param string $tool Ability name.
38 * @param array $params Raw tool params (redacted before logging).
39 * @param callable $apply Performs the mutation; returns an envelope array or WP_Error.
40 * @param array|callable $target ['form_id'=>, 'entry_id'=>] for log linkage, or a
41 * callable($result) that derives it (e.g. create-form,
42 * whose form id only exists after $apply runs).
43 * @return array|\WP_Error
44 */
45 public static function run($tool, array $params, callable $apply, $target = [])
46 {
47 $result = $apply();
48 self::audit($tool, self::resolveTarget($target, $result), $params, $result);
49
50 return $result;
51 }
52
53 /**
54 * Run a destructive mutation behind WriteGuard. The tool must expose dry_run
55 * and confirm_token in its schema. First call with dry_run:true returns a
56 * preview + confirm_token bound to $fingerprint; the second call (same params
57 * + confirm_token) executes once and audits.
58 *
59 * @param string $tool Ability name.
60 * @param array $params Raw tool params (dry_run, confirm_token, idempotency_key).
61 * @param string $entityKey Stable id of the target, e.g. "submission:42".
62 * @param string $fingerprint State string that must still match at execute time.
63 * @param callable $preview Returns the preview payload (array).
64 * @param callable $apply Performs the mutation; returns an envelope array or WP_Error.
65 * @param array|callable $target Audit linkage (see run()).
66 * @return array|\WP_Error
67 */
68 public static function runGuarded($tool, array $params, $entityKey, $fingerprint, callable $preview, callable $apply, $target = [])
69 {
70 if (!empty($params['dry_run'])) {
71 return WriteGuard::preview($tool, $entityKey, $fingerprint, $preview());
72 }
73
74 $idemKey = isset($params['idempotency_key']) ? $params['idempotency_key'] : '';
75
76 // Replay before confirm: tokens are single-use, so a lost-response retry
77 // arrives with a consumed token and would otherwise die as "expired".
78 $replay = WriteGuard::replay($tool, $entityKey, $idemKey);
79 if (null !== $replay) {
80 return $replay;
81 }
82
83 // Everything from here down — consuming the single-use confirm token,
84 // checking the idempotency cache, and running the mutation — has to be
85 // one critical section. Each step is individually read-then-write, so
86 // without this two concurrent retries carrying the same token and key
87 // both pass every check and both execute.
88 $receipt = WriteGuard::claim($tool, $entityKey);
89 if (!$receipt) {
90 // The winner may have finished between our replay check and here.
91 $replay = WriteGuard::replay($tool, $entityKey, $idemKey);
92 if (null !== $replay) {
93 return $replay;
94 }
95
96 return MCPHelper::error(
97 ErrorCodes::CONCURRENT_REQUEST,
98 __('An identical write is already in progress. Wait for it to finish, then retry with the same idempotency_key to collect its result.', 'fluentform'),
99 ['retryable' => true, 'next_step' => 'retry with the same idempotency_key']
100 );
101 }
102
103 try {
104 $token = isset($params['confirm_token']) ? $params['confirm_token'] : '';
105 $confirm = WriteGuard::confirm($tool, $entityKey, $fingerprint, $token);
106 if (is_wp_error($confirm)) {
107 return $confirm;
108 }
109
110 $result = WriteGuard::idempotent($tool, $entityKey, $idemKey, $apply);
111
112 // release() reports whether we still owned the claim. False means
113 // the lease expired while $apply was running and someone else may
114 // have taken over and run too — a lease is the price of not
115 // deadlocking on a crashed request, so record it rather than
116 // pretend at-most-once held.
117 $held = WriteGuard::release($tool, $entityKey, $receipt);
118 $released = true;
119
120 self::audit(
121 $tool,
122 self::resolveTarget($target, $result),
123 $params,
124 $result,
125 $held ? [] : ['lease_expired_mid_write' => true]
126 );
127
128 return $result;
129 } finally {
130 // finally, not a trailing call: a throw from $apply must not strand
131 // the claim for CLAIM_TTL. AbilitiesRegistrar catches the throwable
132 // above us, so without this the key would look held to every retry.
133 if (empty($released)) {
134 WriteGuard::release($tool, $entityKey, $receipt);
135 }
136 }
137 }
138
139 /**
140 * Record that an agent READ entry data.
141 *
142 * Writes were audited from the start, but the headline capability here is
143 * "an AI can read your entries" — and reading is how personal data actually
144 * leaves. Without this, an agent could page through every submission on the
145 * site and leave no trace anywhere.
146 *
147 * Deliberately cheap and payload-free: one row per call carrying the tool,
148 * the actor and how many records were returned — never the records
149 * themselves, which would copy the entries into the log table.
150 *
151 * @param string $tool Ability name.
152 * @param array $target ['form_id' =>, 'entry_id' =>] for log linkage.
153 * @param array $detail Small scalars describing the read (e.g. count).
154 */
155 public static function auditRead($tool, array $target = [], array $detail = [])
156 {
157 /**
158 * Filter whether MCP reads are logged. Busy sites that page through
159 * entries constantly can turn this off, at the cost of losing the only
160 * record of what an agent looked at.
161 *
162 * @since 6.2.5
163 *
164 * @param bool $enabled Default true.
165 * @param string $tool Ability name.
166 */
167 if (!apply_filters('fluentform/mcp_audit_reads', true, $tool)) {
168 return;
169 }
170
171 $payload = array_merge([
172 'actor' => 'mcp',
173 'user_id' => get_current_user_id(),
174 'tool' => $tool,
175 'result' => 'read',
176 ], $detail);
177
178 try {
179 do_action('fluentform/log_data', [
180 'title' => $tool,
181 'status' => 'info',
182 'description' => wp_json_encode($payload),
183 'parent_source_id' => isset($target['form_id']) ? (int) $target['form_id'] : null,
184 'source_id' => isset($target['entry_id']) ? (int) $target['entry_id'] : null,
185 'source_type' => 'mcp',
186 'component' => self::AUDIT_COMPONENT,
187 ]);
188 } catch (\Throwable $e) {
189 // Never let auditing failure surface to the agent.
190 return;
191 }
192 }
193
194 private static function resolveTarget($target, $result)
195 {
196 if (is_callable($target)) {
197 $target = $target($result);
198 }
199
200 return is_array($target) ? $target : [];
201 }
202
203 /**
204 * Write one audit row via FluentForm's log pipeline. Best-effort: a logging
205 * failure must never break the tool call, so it's swallowed.
206 */
207 private static function audit($tool, array $target, array $params, $result, array $extra = [])
208 {
209 $isError = is_wp_error($result);
210
211 $payload = [
212 'actor' => 'mcp',
213 'user_id' => get_current_user_id(),
214 'tool' => $tool,
215 'params' => self::redact($params),
216 'result' => $isError ? 'error' : 'success',
217 ];
218 if ($isError) {
219 $code = $result->get_error_code();
220 $payload['error_code'] = $code ? $code : 'error';
221 }
222
223 $payload = array_merge($payload, $extra);
224
225 try {
226 do_action('fluentform/log_data', [
227 'title' => $tool,
228 'status' => $isError ? 'failed' : 'success',
229 'description' => wp_json_encode($payload),
230 'parent_source_id' => isset($target['form_id']) ? (int) $target['form_id'] : null,
231 'source_id' => isset($target['entry_id']) ? (int) $target['entry_id'] : null,
232 'source_type' => 'mcp',
233 'component' => self::AUDIT_COMPONENT,
234 ]);
235 } catch (\Throwable $e) {
236 // Never let auditing failure surface to the agent.
237 return;
238 }
239 }
240
241 /**
242 * Recursively mask values whose key looks like a credential, so a token an
243 * agent passes (or a tool echoes) never lands in the audit log in clear.
244 */
245 public static function redact($data, $depth = 0)
246 {
247 if ($depth > 6 || !is_array($data)) {
248 return $data;
249 }
250
251 $sensitive = '/(pass(word)?|secret|token|api[_-]?key|authorization|bearer|nonce)/i';
252
253 $out = [];
254 foreach ($data as $key => $value) {
255 if (is_string($key) && preg_match($sensitive, $key)) {
256 $out[$key] = '[redacted]';
257 continue;
258 }
259 if (is_array($value)) {
260 $out[$key] = self::redact($value, $depth + 1);
261 continue;
262 }
263 $out[$key] = self::truncate($value);
264 }
265
266 return $out;
267 }
268
269 /**
270 * Trim an over-long scalar for the audit row, keeping enough to identify
271 * what was written without copying the whole payload into the log.
272 */
273 private static function truncate($value)
274 {
275 if (!is_string($value) || mb_strlen($value) <= self::MAX_LOGGED_VALUE) {
276 return $value;
277 }
278
279 // mb_substr, not substr: cutting a multibyte character in half yields
280 // invalid UTF-8, and json_encode() then returns false — which would
281 // silently drop the entire audit description rather than shortening it.
282 return mb_substr($value, 0, self::MAX_LOGGED_VALUE) . '… [' . mb_strlen($value) . ' characters total]';
283 }
284 }
285