Skip to content

Commit fb67a4b

Browse files
ifahimrezaclaude
andauthored
Phase 3: DRY — shared integration engine, accessor resolver, gate registry (#68)
* fix: audit-log failed confirmed-destructive calls; warn on wrapper collisions - Saddle_Approval::gate now logs a confirmed destructive execution even when the executor returns WP_Error — a partial mutation with no audit trail is worse than a noisy one. The entry carries a "FAILED after confirmation" marker plus the error message. - Saddle_Integrations::wrap surfaces genuine wrapper-name collisions via _doing_it_wrong instead of silently dropping the partner tool; its own wrappers are tracked so idempotent re-runs stay silent. Part of Phase 1 (correctness) of the 2026-07 architecture review (plugpressco/saddle-pro#39). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0133CZhoFPY6BBChDDGEa22Q * feat: security hardening — token user-binding, credential marker, split log caps, domain enforcement - Approval tokens are bound to the previewing user: with several agents on one site, agent A's preview can no longer be confirmed by agent B (saddle_token_user_mismatch). - Saddle-issued app passwords are recorded by UUID in user meta (saddle_issued_credentials); credential scoping, the XML-RPC block, and client revoke/rotate/list key on that immutable marker instead of the user-editable display name. Legacy prefix-named keys migrate on sight. - Saddle_Log GC caps denials (300) and executed mutations (1000) as separate filterable buckets, batch raised to 500, so denial noise can never evict change history. - Opt-in domain-drift enforcement (saddle_enforce_tier_domain, default off): write/admin abilities refuse on a migrated/cloned domain until the owner re-confirms the tier; exposed via GET/POST /settings. Phase 2 of the 2026-07 architecture review (#63). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0133CZhoFPY6BBChDDGEa22Q * refactor: shared integration engine, accessor resolver, gate registry, small dedupes - New Saddle_Integration_Engine: the wrap/executor safety logic that existed as ~280 near-identical lines in free and Pro now lives once in free. Saddle_Integrations is catalog + filter names + context shape. The engine also lands the Phase-2 hardening deferred to it: full-args `bind` on wrapped destructive tools (a confirm can no longer change arguments after preview), merged target key list, and a force_destructive catalog override for partners that forget the destructive annotation. - New Saddle_Accessors: one build→filter→instanceof→409 resolution for lint-page / render-node / verify-page (was three hand-synced copies); verify's magic count(skipped)>=3 heuristic replaced with the explicit resolver verdict. Error codes and message strings unchanged. - Saddle_Capabilities records each ability's enforced (level, cap) at permission() build time; denial_reason() now mirrors the closure's exact order, gains the previously-missing capability reason, and reads the enforced tier instead of re-deriving from meta. - Saddle_Abilities::require_readable_post() replaces the copy-pasted read preamble; the two theme.json preset resolvers in the Gutenberg lint accessor merge into one preset_map(). Phase 3 of the 2026-07 architecture review (#64). record()/record_action() was reviewed and intentionally left split: record() is the sole entry carrying `type`, which denial logging needs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0133CZhoFPY6BBChDDGEa22Q --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 59b2003 commit fb67a4b

13 files changed

Lines changed: 1008 additions & 601 deletions

includes/abilities/core-content.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1762,6 +1762,25 @@ private static function create_of_type( $type, array $input ) {
17621762
return $detail;
17631763
}
17641764

1765+
/**
1766+
* Resolve and authorize the post/page a read ability targets: it exists,
1767+
* is a post or page, and the current user can read it. The shared
1768+
* preamble of lint-page / render-node / verify-page.
1769+
*
1770+
* @param array $input Ability input (reads `post_id`).
1771+
* @return WP_Post|WP_Error
1772+
*/
1773+
public static function require_readable_post( array $input ) {
1774+
$post = get_post( isset( $input['post_id'] ) ? (int) $input['post_id'] : 0 );
1775+
if ( ! $post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
1776+
return new WP_Error( 'saddle_not_found', __( 'No post or page with that ID.', 'saddle' ), array( 'status' => 404 ) );
1777+
}
1778+
if ( ! current_user_can( 'read_post', $post->ID ) ) {
1779+
return new WP_Error( 'saddle_forbidden', __( 'You cannot read this post.', 'saddle' ), array( 'status' => 403 ) );
1780+
}
1781+
return $post;
1782+
}
1783+
17651784
/**
17661785
* Which page builder (if any) owns a post's content.
17671786
*

includes/abilities/lint.php

Lines changed: 6 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -54,41 +54,15 @@ class Saddle_Lint_Abilities {
5454
*/
5555
public static function lint_page( $input = null ) {
5656
$input = is_array( $input ) ? $input : array();
57-
$post = get_post( isset( $input['post_id'] ) ? (int) $input['post_id'] : 0 );
58-
if ( ! $post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
59-
return new WP_Error( 'saddle_not_found', __( 'No post or page with that ID.', 'saddle' ), array( 'status' => 404 ) );
60-
}
61-
if ( ! current_user_can( 'read_post', $post->ID ) ) {
62-
return new WP_Error( 'saddle_forbidden', __( 'You cannot read this post.', 'saddle' ), array( 'status' => 403 ) );
57+
$post = Saddle_Abilities::require_readable_post( $input );
58+
if ( is_wp_error( $post ) ) {
59+
return $post;
6360
}
6461

6562
$builder = Saddle_Abilities::builder_signature( $post );
66-
$accessor = null === $builder ? new Saddle_Lint_Gutenberg_Accessor() : null;
67-
68-
/**
69-
* Filter the lint accessor for a page.
70-
*
71-
* Builder integrations (Saddle Pro's Divi driver, Elementor/Bricks
72-
* later) return their Saddle_Lint_Accessor implementation when they
73-
* own $builder. Null means the page cannot be linted here.
74-
*
75-
* @param Saddle_Lint_Accessor|null $accessor Accessor (Gutenberg's for native pages).
76-
* @param string|null $builder Detected builder, null = native.
77-
* @param WP_Post $post The post.
78-
*/
79-
$accessor = apply_filters( 'saddle_lint_accessor', $accessor, $builder, $post );
80-
81-
if ( ! $accessor instanceof Saddle_Lint_Accessor ) {
82-
return new WP_Error(
83-
'saddle_lint_unsupported',
84-
sprintf(
85-
/* translators: 1: post ID, 2: builder name. */
86-
__( 'Post #%1$d is built with %2$s, and no lint accessor for that builder is installed. Divi 5 pages need Saddle Pro.', 'saddle' ),
87-
$post->ID,
88-
(string) $builder
89-
),
90-
array( 'status' => 409 )
91-
);
63+
$accessor = Saddle_Accessors::lint( $post );
64+
if ( is_wp_error( $accessor ) ) {
65+
return $accessor;
9266
}
9367

9468
$violations = Saddle_Lint::run( Saddle_Tree::parse( $post->post_content ), $accessor );

includes/abilities/render.php

Lines changed: 4 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,9 @@ class Saddle_Render_Abilities {
8989
*/
9090
public static function render_node( $input = null ) {
9191
$input = is_array( $input ) ? $input : array();
92-
$post = get_post( isset( $input['post_id'] ) ? (int) $input['post_id'] : 0 );
93-
if ( ! $post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
94-
return new WP_Error( 'saddle_not_found', __( 'No post or page with that ID.', 'saddle' ), array( 'status' => 404 ) );
95-
}
96-
if ( ! current_user_can( 'read_post', $post->ID ) ) {
97-
return new WP_Error( 'saddle_forbidden', __( 'You cannot read this post.', 'saddle' ), array( 'status' => 403 ) );
92+
$post = Saddle_Abilities::require_readable_post( $input );
93+
if ( is_wp_error( $post ) ) {
94+
return $post;
9895
}
9996

10097
$accessor = self::accessor_for( $post );
@@ -173,35 +170,7 @@ public static function get_preview_url( $input = null ) {
173170
* @return Saddle_Render_Accessor|WP_Error
174171
*/
175172
private static function accessor_for( WP_Post $post ) {
176-
$builder = Saddle_Abilities::builder_signature( $post );
177-
$accessor = null === $builder ? new Saddle_Render_Gutenberg_Accessor() : null;
178-
179-
/**
180-
* Filter the render accessor for a page.
181-
*
182-
* Builder integrations (Saddle Pro's Divi driver, Elementor/Bricks
183-
* later) return their Saddle_Render_Accessor implementation when
184-
* they own $builder. Null means the page cannot be rendered here.
185-
*
186-
* @param Saddle_Render_Accessor|null $accessor Accessor (Gutenberg's for native pages).
187-
* @param string|null $builder Detected builder, null = native.
188-
* @param WP_Post $post The post.
189-
*/
190-
$accessor = apply_filters( 'saddle_render_accessor', $accessor, $builder, $post );
191-
192-
if ( ! $accessor instanceof Saddle_Render_Accessor ) {
193-
return new WP_Error(
194-
'saddle_render_unsupported',
195-
sprintf(
196-
/* translators: 1: post ID, 2: builder name. */
197-
__( 'Post #%1$d is built with %2$s, and no render accessor for that builder is installed. Divi 5 pages need Saddle Pro.', 'saddle' ),
198-
$post->ID,
199-
(string) $builder
200-
),
201-
array( 'status' => 409 )
202-
);
203-
}
204-
return $accessor;
173+
return Saddle_Accessors::render( $post );
205174
}
206175

207176
/**

includes/abilities/verify.php

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -54,37 +54,31 @@ class Saddle_Verify_Abilities {
5454
*/
5555
public static function verify_page( $input = null ) {
5656
$input = is_array( $input ) ? $input : array();
57-
$post = get_post( isset( $input['post_id'] ) ? (int) $input['post_id'] : 0 );
58-
if ( ! $post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
59-
return new WP_Error( 'saddle_not_found', __( 'No post or page with that ID.', 'saddle' ), array( 'status' => 404 ) );
60-
}
61-
if ( ! current_user_can( 'read_post', $post->ID ) ) {
62-
return new WP_Error( 'saddle_forbidden', __( 'You cannot read this post.', 'saddle' ), array( 'status' => 403 ) );
57+
$post = Saddle_Abilities::require_readable_post( $input );
58+
if ( is_wp_error( $post ) ) {
59+
return $post;
6360
}
6461

6562
$builder = Saddle_Abilities::builder_signature( $post );
66-
$accessor = null === $builder ? new Saddle_Lint_Gutenberg_Accessor() : null;
67-
68-
/** This filter is documented in includes/abilities/lint.php */
69-
$accessor = apply_filters( 'saddle_lint_accessor', $accessor, $builder, $post );
70-
if ( ! $accessor instanceof Saddle_Lint_Accessor ) {
71-
$accessor = null;
72-
}
63+
$resolved = Saddle_Accessors::lint(
64+
$post,
65+
'saddle_verify_unsupported',
66+
/* translators: 1: post ID, 2: builder name. */
67+
__( 'Post #%1$d is built with %2$s, and no verifier for that builder is installed. Divi 5 pages need Saddle Pro.', 'saddle' )
68+
);
7369

74-
$report = Saddle_Verify::run( $post, $builder, $accessor );
70+
// Verify can still run its structural + echo passes without a lint
71+
// accessor (a builder may provide findings without lint), so an
72+
// unresolved accessor only skips pass three here.
73+
$report = Saddle_Verify::run( $post, $builder, is_wp_error( $resolved ) ? null : $resolved );
7574

76-
// A builder page where NOTHING could run isn't a report, it's a gap.
77-
if ( count( $report['skipped'] ) >= 3 ) {
78-
return new WP_Error(
79-
'saddle_verify_unsupported',
80-
sprintf(
81-
/* translators: 1: post ID, 2: builder name. */
82-
__( 'Post #%1$d is built with %2$s, and no verifier for that builder is installed. Divi 5 pages need Saddle Pro.', 'saddle' ),
83-
$post->ID,
84-
(string) $builder
85-
),
86-
array( 'status' => 409 )
87-
);
75+
// A builder page where NOTHING could run isn't a report, it's a gap:
76+
// no lint accessor resolved AND the builder's structural/echo passes
77+
// were skipped too.
78+
if ( is_wp_error( $resolved )
79+
&& in_array( 'structural', $report['skipped'], true )
80+
&& in_array( 'echo', $report['skipped'], true ) ) {
81+
return $resolved;
8882
}
8983

9084
return array_merge(
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
/**
3+
* Accessor resolution — one place where a post's builder is detected and the
4+
* matching lint/render accessor is resolved through the integration filters.
5+
*
6+
* @package Saddle
7+
*/
8+
9+
defined( 'ABSPATH' ) || exit;
10+
11+
/**
12+
* The build-native-accessor → filter → instanceof-gate sequence used to be
13+
* copy-pasted across lint-page, render-node, and verify-page, with three
14+
* hand-synced copies of the same 409 error. It lives here once, so the
15+
* resolution semantics (and the "needs Saddle Pro" message) cannot drift
16+
* between the three abilities.
17+
*/
18+
class Saddle_Accessors {
19+
20+
/**
21+
* Resolve the lint accessor for a post: Gutenberg for native pages, the
22+
* `saddle_lint_accessor` filter for builder pages.
23+
*
24+
* @param WP_Post $post The post.
25+
* @param string $code Error code for the unsupported case.
26+
* @param string|null $message Optional full error message (sprintf template
27+
* with %1$d post ID and %2$s builder); defaults
28+
* to the lint-flavored one.
29+
* @return Saddle_Lint_Accessor|WP_Error
30+
*/
31+
public static function lint( WP_Post $post, $code = 'saddle_lint_unsupported', $message = null ) {
32+
$builder = Saddle_Abilities::builder_signature( $post );
33+
$accessor = null === $builder ? new Saddle_Lint_Gutenberg_Accessor() : null;
34+
35+
/**
36+
* Filter the lint accessor for a page.
37+
*
38+
* Builder integrations (Saddle Pro's Divi driver, Elementor/Bricks
39+
* later) return their Saddle_Lint_Accessor implementation when they
40+
* own $builder. Null means the page cannot be linted here.
41+
*
42+
* @param Saddle_Lint_Accessor|null $accessor Accessor (Gutenberg's for native pages).
43+
* @param string|null $builder Detected builder, null = native.
44+
* @param WP_Post $post The post.
45+
*/
46+
$accessor = apply_filters( 'saddle_lint_accessor', $accessor, $builder, $post );
47+
48+
if ( ! $accessor instanceof Saddle_Lint_Accessor ) {
49+
return self::unsupported(
50+
$post,
51+
$builder,
52+
$code,
53+
null !== $message
54+
? $message
55+
/* translators: 1: post ID, 2: builder name. */
56+
: __( 'Post #%1$d is built with %2$s, and no lint accessor for that builder is installed. Divi 5 pages need Saddle Pro.', 'saddle' )
57+
);
58+
}
59+
return $accessor;
60+
}
61+
62+
/**
63+
* Resolve the render accessor for a post: Gutenberg for native pages, the
64+
* `saddle_render_accessor` filter for builder pages.
65+
*
66+
* @param WP_Post $post The post.
67+
* @return Saddle_Render_Accessor|WP_Error
68+
*/
69+
public static function render( WP_Post $post ) {
70+
$builder = Saddle_Abilities::builder_signature( $post );
71+
$accessor = null === $builder ? new Saddle_Render_Gutenberg_Accessor() : null;
72+
73+
/**
74+
* Filter the render accessor for a page.
75+
*
76+
* Builder integrations (Saddle Pro's Divi driver, Elementor/Bricks
77+
* later) return their Saddle_Render_Accessor implementation when
78+
* they own $builder. Null means the page cannot be rendered here.
79+
*
80+
* @param Saddle_Render_Accessor|null $accessor Accessor (Gutenberg's for native pages).
81+
* @param string|null $builder Detected builder, null = native.
82+
* @param WP_Post $post The post.
83+
*/
84+
$accessor = apply_filters( 'saddle_render_accessor', $accessor, $builder, $post );
85+
86+
if ( ! $accessor instanceof Saddle_Render_Accessor ) {
87+
return self::unsupported(
88+
$post,
89+
$builder,
90+
'saddle_render_unsupported',
91+
/* translators: 1: post ID, 2: builder name. */
92+
__( 'Post #%1$d is built with %2$s, and no render accessor for that builder is installed. Divi 5 pages need Saddle Pro.', 'saddle' )
93+
);
94+
}
95+
return $accessor;
96+
}
97+
98+
/**
99+
* The shared 409 for "this builder has no installed accessor".
100+
*
101+
* @param WP_Post $post The post.
102+
* @param string|null $builder Detected builder.
103+
* @param string $code Error code.
104+
* @param string $message sprintf template (%1$d post ID, %2$s builder).
105+
* @return WP_Error
106+
*/
107+
private static function unsupported( WP_Post $post, $builder, $code, $message ) {
108+
return new WP_Error(
109+
$code,
110+
sprintf( $message, $post->ID, (string) $builder ),
111+
array( 'status' => 409 )
112+
);
113+
}
114+
}

0 commit comments

Comments
 (0)