PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk 1.2.0 All 47 releases
fluent-cart / app / Modules / MCP / Support / WriteGuard.php

WriteGuard.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Modules/MCP/Support/WriteGuard.php

214 lines 8.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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