| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCart\App\Modules\MCP\Support; |
| 4 |
|
| 5 |
/** |
| 6 |
* Safety rails for mutating MCP tools. Annotations are UX hints, not safety — |
| 7 |
* this is where real protection lives for the sensitive writes (refund, cancel). |
| 8 |
* |
| 9 |
* Two mechanisms: |
| 10 |
* |
| 11 |
* 1. Dry-run + confirmation token. A destructive tool called with dry_run:true |
| 12 |
* computes the effect, binds it to the entity's CURRENT state (a fingerprint), |
| 13 |
* stashes a short-lived token, and returns a preview. To actually execute, |
| 14 |
* the caller passes that confirm_token back. If the entity changed in the |
| 15 |
* meantime, the fingerprint no longer matches and we force a fresh preview — |
| 16 |
* so an agent can never act on stale numbers (e.g. refund a balance that was |
| 17 |
* already refunded by someone else). |
| 18 |
* |
| 19 |
* 2. Idempotency keys. The caller passes an idempotency_key; the first execution |
| 20 |
* for that key is cached, and a retry with the same key returns the cached |
| 21 |
* result instead of charging/cancelling twice. This is the guard against an |
| 22 |
* agent re-issuing a refund after a timeout. |
| 23 |
* |
| 24 |
* CONTRACT (enforced by convention, not the framework): every ability whose |
| 25 |
* annotations include `destructive => true` MUST route its mutation through |
| 26 |
* confirm() (dry-run + confirm_token) and, for gateway/real-money actions, also |
| 27 |
* liveGatewayAllowed(), before mutating — and MUST expose `dry_run` and |
| 28 |
* `confirm_token` in its input_schema. Reversible CRUD writes (coupon, customer, |
| 29 |
* label, order-status) are intentionally NOT marked destructive and rely on |
| 30 |
* their permission_callback alone. When adding a new destructive ability (here |
| 31 |
* or in Pro, which registers under the same namespace via fluent_cart/mcp_loaded), |
| 32 |
* follow this contract — refund-order and change-subscription-status are the |
| 33 |
* reference implementations. |
| 34 |
*/ |
| 35 |
class WriteGuard |
| 36 |
{ |
| 37 |
const CONFIRM_TTL = 300; // 5 minutes to confirm a previewed action. |
| 38 |
|
| 39 |
const IDEM_TTL = 86400; // remember an idempotency key for a day. |
| 40 |
|
| 41 |
/** |
| 42 |
* Build a dry-run preview response with a confirmation token bound to the |
| 43 |
* entity's current state. |
| 44 |
* |
| 45 |
* @param string $tool Ability name (namespacing the token). |
| 46 |
* @param string $entityKey Stable id of the target, e.g. "order:42". |
| 47 |
* @param string $fingerprint A string capturing the mutable state we care |
| 48 |
* about (e.g. "paid:8000|refund:0"). If this |
| 49 |
* differs at execute time, the token is rejected. |
| 50 |
* @param array $preview The human/agent-facing preview payload. |
| 51 |
*/ |
| 52 |
public static function preview($tool, $entityKey, $fingerprint, array $preview) |
| 53 |
{ |
| 54 |
$token = substr(wp_hash($tool . '|' . $entityKey . '|' . $fingerprint . '|' . wp_generate_uuid4()), 0, 32); |
| 55 |
|
| 56 |
set_transient(self::confirmKey($tool, $entityKey), [ |
| 57 |
'token' => $token, |
| 58 |
'fingerprint' => $fingerprint, |
| 59 |
], self::CONFIRM_TTL); |
| 60 |
|
| 61 |
return [ |
| 62 |
'dry_run' => true, |
| 63 |
'preview' => $preview, |
| 64 |
'confirm_token' => $token, |
| 65 |
'expires_in_seconds' => self::CONFIRM_TTL, |
| 66 |
'next_step' => 'Call this tool again with the same parameters plus confirm_token (and an idempotency_key) to execute.', |
| 67 |
]; |
| 68 |
} |
| 69 |
|
| 70 |
/** |
| 71 |
* Validate a confirm_token against the entity's current fingerprint. |
| 72 |
* Returns true, or a WP_Error the agent can act on. |
| 73 |
* |
| 74 |
* @return true|\WP_Error |
| 75 |
*/ |
| 76 |
public static function confirm($tool, $entityKey, $currentFingerprint, $token) |
| 77 |
{ |
| 78 |
if (empty($token)) { |
| 79 |
return MCPHelper::error( |
| 80 |
'confirmation_required', |
| 81 |
__('This action changes data. Call again with dry_run:true to preview, then pass the returned confirm_token to execute.', 'fluent-cart'), |
| 82 |
['next_step' => 'set dry_run:true'] |
| 83 |
); |
| 84 |
} |
| 85 |
|
| 86 |
$stored = get_transient(self::confirmKey($tool, $entityKey)); |
| 87 |
|
| 88 |
if (!is_array($stored) || empty($stored['token'])) { |
| 89 |
return MCPHelper::error( |
| 90 |
'confirmation_expired', |
| 91 |
__('Your confirmation has expired. Run a fresh dry_run to preview and get a new confirm_token.', 'fluent-cart'), |
| 92 |
['next_step' => 'set dry_run:true'] |
| 93 |
); |
| 94 |
} |
| 95 |
|
| 96 |
if (!hash_equals((string) $stored['token'], (string) $token)) { |
| 97 |
return MCPHelper::error( |
| 98 |
'confirmation_invalid', |
| 99 |
__('The confirm_token does not match. Run a fresh dry_run.', 'fluent-cart'), |
| 100 |
['next_step' => 'set dry_run:true'] |
| 101 |
); |
| 102 |
} |
| 103 |
|
| 104 |
if ((string) $stored['fingerprint'] !== (string) $currentFingerprint) { |
| 105 |
delete_transient(self::confirmKey($tool, $entityKey)); |
| 106 |
return MCPHelper::error( |
| 107 |
'state_changed', |
| 108 |
__('The record changed since you previewed it. Run a fresh dry_run to see the current state before executing.', 'fluent-cart'), |
| 109 |
['next_step' => 'set dry_run:true'] |
| 110 |
); |
| 111 |
} |
| 112 |
|
| 113 |
// One-shot: consume the token so it can't be replayed. |
| 114 |
delete_transient(self::confirmKey($tool, $entityKey)); |
| 115 |
|
| 116 |
return true; |
| 117 |
} |
| 118 |
|
| 119 |
/** |
| 120 |
* Run $fn at most once per idempotency key (per user + tool + entity). A |
| 121 |
* repeat call with the same key on the SAME entity returns the cached result. |
| 122 |
* If no key is supplied, $fn runs normally (no dedupe) — keys are recommended |
| 123 |
* but not forced. |
| 124 |
* |
| 125 |
* The key is entity-scoped so reusing one idempotency_key across different |
| 126 |
* records (e.g. "refund-1" for two orders) can't replay the first entity's |
| 127 |
* result and silently skip the second mutation. |
| 128 |
*/ |
| 129 |
public static function idempotent($tool, $entityKey, $key, callable $fn) |
| 130 |
{ |
| 131 |
if (empty($key)) { |
| 132 |
return $fn(); |
| 133 |
} |
| 134 |
|
| 135 |
$cacheKey = self::idemKey($tool, $entityKey, $key); |
| 136 |
$cached = get_transient($cacheKey); |
| 137 |
if ($cached !== false) { |
| 138 |
return is_array($cached) ? array_merge($cached, ['idempotent_replay' => true]) : $cached; |
| 139 |
} |
| 140 |
|
| 141 |
$result = $fn(); |
| 142 |
|
| 143 |
// Only cache successful, serializable results. |
| 144 |
if (!is_wp_error($result)) { |
| 145 |
set_transient($cacheKey, $result, self::IDEM_TTL); |
| 146 |
} |
| 147 |
|
| 148 |
return $result; |
| 149 |
} |
| 150 |
|
| 151 |
/** |
| 152 |
* True when a gateway action would hit live (real-money) mode. An unknown or |
| 153 |
* empty mode is treated as live — we fail safe rather than assume sandbox. |
| 154 |
*/ |
| 155 |
public static function isLiveMode($paymentMode) |
| 156 |
{ |
| 157 |
$mode = strtolower((string) $paymentMode); |
| 158 |
return $mode !== 'test' && $mode !== 'sandbox'; |
| 159 |
} |
| 160 |
|
| 161 |
/** |
| 162 |
* Gate real-money gateway mutations (refund, cancel). Test/sandbox records are |
| 163 |
* always allowed; LIVE requires explicit opt-in via the `mcp_allow_live_gateway` |
| 164 |
* option ('yes') or the `fluent_cart/mcp_allow_live_gateway` filter — so an |
| 165 |
* agent cannot fire a live refund/cancellation by default, even holding a |
| 166 |
* valid confirm_token. The dry_run preview still works in either mode. |
| 167 |
* |
| 168 |
* @return true|\WP_Error |
| 169 |
*/ |
| 170 |
public static function liveGatewayAllowed($paymentMode) |
| 171 |
{ |
| 172 |
if (!self::isLiveMode($paymentMode)) { |
| 173 |
return true; |
| 174 |
} |
| 175 |
|
| 176 |
$allowed = fluent_cart_get_option('mcp_allow_live_gateway', 'no') === 'yes'; |
| 177 |
|
| 178 |
/** |
| 179 |
* Allow live (real-money) MCP gateway mutations. Default false; flip via |
| 180 |
* this filter or the mcp_allow_live_gateway option. |
| 181 |
* |
| 182 |
* @since 1.0.0 |
| 183 |
* |
| 184 |
* @param bool $allowed Whether live refunds/cancellations are permitted. |
| 185 |
*/ |
| 186 |
$allowed = (bool) apply_filters('fluent_cart/mcp_allow_live_gateway', $allowed); |
| 187 |
|
| 188 |
if ($allowed) { |
| 189 |
return true; |
| 190 |
} |
| 191 |
|
| 192 |
return MCPHelper::error( |
| 193 |
'live_gateway_blocked', |
| 194 |
__('This is a live (real-money) gateway action and live mutations are disabled. Enable the mcp_allow_live_gateway option or the fluent_cart/mcp_allow_live_gateway filter to permit live refunds and cancellations.', 'fluent-cart'), |
| 195 |
[ |
| 196 |
'payment_mode' => 'live', |
| 197 |
'hint' => 'Test-mode records can be refunded or cancelled without this flag.', |
| 198 |
] |
| 199 |
); |
| 200 |
} |
| 201 |
|
| 202 |
private static function confirmKey($tool, $entityKey) |
| 203 |
{ |
| 204 |
// User-scoped: a token minted by one operator/session can't be consumed |
| 205 |
// by another, even for the same entity. |
| 206 |
return 'fct_mcp_confirm_' . get_current_user_id() . '_' . md5($tool . '|' . $entityKey); |
| 207 |
} |
| 208 |
|
| 209 |
private static function idemKey($tool, $entityKey, $key) |
| 210 |
{ |
| 211 |
return 'fct_mcp_idem_' . get_current_user_id() . '_' . md5($tool . '|' . $entityKey . '|' . $key); |
| 212 |
} |
| 213 |
} |
| 214 |
|