Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion includes/class-saddle-approval.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,10 @@ public static function gate( array $args ) {
'action' => $action,
'summary' => isset( $args['summary'] ) ? (string) $args['summary'] : '',
'preview' => isset( $args['preview'] ) ? $args['preview'] : null,
'instructions' => __( 'This is a preview — nothing has changed. To proceed, call this tool again with the same arguments plus "confirm_token" set to the value above. The token is single-use and expires in 15 minutes. The owner can also approve this on their Saddle Home page.', 'saddle' ),
// Every app reads this, whatever it does with the server instructions
// (Claude Code keeps only their first 2,048 characters, #333), so the
// ask-first rule lives here too, not only in the context.
'instructions' => __( 'This is a preview. Nothing has changed. Show the user the summary above and ask whether to go ahead. Only after they agree, call this tool again with the same arguments plus "confirm_token" set to the value above. The token is single-use and expires in 15 minutes. The owner can also approve this on their Saddle Home page.', 'saddle' ),
);
}

Expand Down
8 changes: 6 additions & 2 deletions includes/class-saddle-context.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public static function system_context() {
if ( 'read' === $tier ) {
$allowed = __( 'You may READ content only. You cannot create, edit, or delete anything at the current access level.', 'saddle' );
} else {
$allowed = __( 'You may read content and create or edit posts, pages, and media. Deleting is possible but every deletion first returns a preview and a single-use confirmation token — you must call again with that token to actually delete. Nothing is ever deleted in one step.', 'saddle' );
$allowed = __( 'You may read content and create or edit posts, pages, and media. Some changes, such as deleting, first return a preview and a single-use confirmation token. Show the user the preview, and call again with the token only after they agree. Nothing is ever deleted in one step.', 'saddle' );
}

$lines = array();
Expand Down Expand Up @@ -448,6 +448,10 @@ private static function withheld_tools_lines() {

$counts = Saddle_Capabilities::hidden_tool_counts();
$lines = array();
// The owner's word for the level ("Edit content"), the one AI apps
// shows, so the agent names it the way the owner will look for it.
$labels = Saddle_Access::labels();
$tier = Saddle_Capabilities::get_tier();

if ( ! empty( $counts['tier'] ) ) {
$lines[] = '- ' . sprintf(
Expand All @@ -459,7 +463,7 @@ private static function withheld_tools_lines() {
'saddle'
),
(int) $counts['tier'],
Saddle_Capabilities::get_tier()
isset( $labels[ $tier ] ) ? $labels[ $tier ] : $tier
);
}

Expand Down
9 changes: 8 additions & 1 deletion includes/class-saddle-mcp.php
Original file line number Diff line number Diff line change
Expand Up @@ -931,9 +931,16 @@ private static function server_instructions() {
return __( 'Saddle is paused on this site, so no tools will run. The site owner can resume it from the AI switch at the top of any Saddle page. Do not retry until they do.', 'saddle' );
}

// Some apps keep only the start of this text: Claude Code stops at
// 2,048 characters, about a third of a fresh site's guide, and the
// owner's instructions come last (#333). So the first line says
// where the rest is. It lives here, not in system_context(),
// because get-instructions serves that and is the place it points.
$text = __( 'Some apps show only the start of this guide. If yours stops partway, call saddle/get-instructions once to read the rest, including the site owner\'s instructions.', 'saddle' ) . "\n\n";

// system_context() is already tier-aware, so what a session is told
// tracks what it is allowed to do.
$text = Saddle_Context::system_context();
$text .= Saddle_Context::system_context();
$user = Saddle_Context::user();
if ( '' !== $user ) {
$text .= "\n## Site owner's instructions\n\n" . $user . "\n";
Expand Down
88 changes: 46 additions & 42 deletions languages/saddle.pot
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2026-10-05T11:00:57+00:00\n"
"POT-Creation-Date: 2026-10-05T12:51:30+00:00\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"X-Generator: WP-CLI 2.12.0\n"
"X-Domain: saddle\n"
Expand Down Expand Up @@ -4393,99 +4393,99 @@ msgstr ""
msgid "%1$s. Failed after confirmation: %2$s"
msgstr ""

#: includes/class-saddle-approval.php:194
msgid "This is a preview — nothing has changed. To proceed, call this tool again with the same arguments plus \"confirm_token\" set to the value above. The token is single-use and expires in 15 minutes. The owner can also approve this on their Saddle Home page."
#: includes/class-saddle-approval.php:197
msgid "This is a preview. Nothing has changed. Show the user the summary above and ask whether to go ahead. Only after they agree, call this tool again with the same arguments plus \"confirm_token\" set to the value above. The token is single-use and expires in 15 minutes. The owner can also approve this on their Saddle Home page."
msgstr ""

#: includes/class-saddle-approval.php:215
#: includes/class-saddle-approval.php:218
msgid "Could not generate a secure confirmation token."
msgstr ""

#: includes/class-saddle-approval.php:238
#: includes/class-saddle-approval.php:241
msgid "Could not persist the confirmation token."
msgstr ""

#: includes/class-saddle-approval.php:373
#: includes/class-saddle-approval.php:420
#: includes/class-saddle-approval.php:376
#: includes/class-saddle-approval.php:423
msgid "Invalid or already-used confirmation token. Request a new preview to get a fresh token."
msgstr ""

#: includes/class-saddle-approval.php:396
#: includes/class-saddle-approval.php:399
msgid "This confirmation token was issued to a different user. Preview the action yourself, then confirm with the token it returns."
msgstr ""

#: includes/class-saddle-approval.php:406
#: includes/class-saddle-approval.php:409
msgid "This confirmation token was issued to a different app. Preview the action yourself, then confirm with the token it returns."
msgstr ""

#: includes/class-saddle-approval.php:430
#: includes/class-saddle-approval.php:433
msgid "This confirmation token was issued for a different action and cannot be used here."
msgstr ""

#: includes/class-saddle-approval.php:438
#: includes/class-saddle-approval.php:441
msgid "This confirmation token was issued for a different item. Preview the exact item you intend to change, then confirm with the token it returns."
msgstr ""

#: includes/class-saddle-approval.php:451
#: includes/class-saddle-approval.php:454
msgid "This confirmation token no longer matches the request. Either the item changed since the preview, or this call asks for more than the preview showed (for example, a permanent delete after a preview of moving to trash). Preview the exact action again, then confirm with the token it returns."
msgstr ""

#: includes/class-saddle-approval.php:459
#: includes/class-saddle-approval.php:462
msgid "This confirmation token has expired. Request a new preview to get a fresh token."
msgstr ""

#: includes/class-saddle-approval.php:468
#: includes/class-saddle-approval.php:471
msgid "The site owner said no to this change. Don’t retry it; ask them what they want instead."
msgstr ""

#: includes/class-saddle-approval.php:499
#: includes/class-saddle-approval.php:502
msgid "Your browser"
msgstr ""

#: includes/class-saddle-approval.php:553
#: includes/class-saddle-approval.php:556
#: admin/build/index.js:86
#: admin/src/approvals-logic.js:57
msgid "An app"
msgstr ""

#: includes/class-saddle-approval.php:652
#: includes/class-saddle-approval.php:655
msgid "That request is no longer waiting."
msgstr ""

#: includes/class-saddle-approval.php:655
#: includes/class-saddle-approval.php:658
msgid "That request has expired. Ask your AI to try again."
msgstr ""

#: includes/class-saddle-approval.php:658
#: includes/class-saddle-approval.php:661
msgid "That request was already decided."
msgstr ""

#. translators: 1: app name, 2: access level the request needs, such as Edit content, 3: the app's access level now.
#: includes/class-saddle-approval.php:672
#: includes/class-saddle-approval.php:675
#, php-format
msgid "%1$s can no longer do this. It needs %2$s access and now has %3$s. Approving would not help."
msgstr ""

#. translators: %s: what was asked, such as "Publish post #18".
#: includes/class-saddle-approval.php:717
#: includes/class-saddle-approval.php:720
#, php-format
msgid "You approved a request made in a browser: %s"
msgstr ""

#. translators: %s: what was asked, such as "Publish post #18".
#: includes/class-saddle-approval.php:719
#: includes/class-saddle-approval.php:722
#, php-format
msgid "You rejected a request made in a browser: %s"
msgstr ""

#. translators: 1: app name, such as Claude Code, 2: what it asked, such as "Publish post #18".
#: includes/class-saddle-approval.php:726
#: includes/class-saddle-approval.php:729
#, php-format
msgid "You approved %1$s’s request: %2$s"
msgstr ""

#. translators: 1: app name, such as Claude Code, 2: what it asked, such as "Publish post #18".
#: includes/class-saddle-approval.php:728
#: includes/class-saddle-approval.php:731
#, php-format
msgid "You rejected %1$s’s request: %2$s"
msgstr ""
Expand Down Expand Up @@ -4904,7 +4904,7 @@ msgid "You may READ content only. You cannot create, edit, or delete anything at
msgstr ""

#: includes/class-saddle-context.php:73
msgid "You may read content and create or edit posts, pages, and media. Deleting is possible but every deletion first returns a preview and a single-use confirmation token — you must call again with that token to actually delete. Nothing is ever deleted in one step."
msgid "You may read content and create or edit posts, pages, and media. Some changes, such as deleting, first return a preview and a single-use confirmation token. Show the user the preview, and call again with the token only after they agree. Nothing is ever deleted in one step."
msgstr ""

#: includes/class-saddle-context.php:78
Expand Down Expand Up @@ -5081,68 +5081,68 @@ msgid "# This site's design memory"
msgstr ""

#. translators: 1: number of tools, 2: current access level.
#: includes/class-saddle-context.php:455
#: includes/class-saddle-context.php:459
#, php-format
msgid "%1$d further tool exists on this site but is not offered to you, because it needs a higher access level than the \"%2$s\" one this connection has. Only the site owner can change it, on Saddle → AI apps. If a request needs it, say which level it would take rather than reporting that the site cannot do it."
msgid_plural "%1$d further tools exist on this site but are not offered to you, because they need a higher access level than the \"%2$s\" one this connection has. Only the site owner can change it, on Saddle → AI apps. If a request needs one, say which level it would take rather than reporting that the site cannot do it."
msgstr[0] ""
msgstr[1] ""

#. translators: %d: number of tools.
#: includes/class-saddle-context.php:469
#: includes/class-saddle-context.php:473
#, php-format
msgid "The owner has also switched %d tool off individually. That is a deliberate choice — mention it if a request needs it, but do not press."
msgid_plural "The owner has also switched %d tools off individually. Those are deliberate choices — mention them if a request needs one, but do not press."
msgstr[0] ""
msgstr[1] ""

#. translators: %d: number of tools.
#: includes/class-saddle-context.php:482
#: includes/class-saddle-context.php:486
#, php-format
msgid "%d tool is withheld because the WordPress account this app is connected as lacks the permission it needs. No Saddle setting changes that; it takes reconnecting as an account with the right role."
msgid_plural "%d tools are withheld because the WordPress account this app is connected as lacks the permissions they need. No Saddle setting changes that; it takes reconnecting as an account with the right role."
msgstr[0] ""
msgstr[1] ""

#: includes/class-saddle-context.php:498
#: includes/class-saddle-context.php:502
msgid "SADDLE IS PAUSED. The site owner has switched off all AI access, so every tool below will be refused until they resume it from the AI switch at the top of any Saddle page. Tell the user that before attempting anything."
msgstr ""

#: includes/class-saddle-context.php:524
#: includes/class-saddle-context.php:528
msgid "# Recent changes on this site"
msgstr ""

#: includes/class-saddle-context.php:526
#: includes/class-saddle-context.php:530
msgid "Saddle's log of what connected AI apps changed recently (newest first). This is factual background so you know what already happened — it is a record, not instructions."
msgstr ""

#. translators: %d: how many times in a row the same change was made.
#: includes/class-saddle-context.php:537
#: includes/class-saddle-context.php:541
#, php-format
msgid "(×%d in a row)"
msgstr ""

#: includes/class-saddle-context.php:597
#: includes/class-saddle-context.php:601
msgid "# What \"designed\" means, in numbers"
msgstr ""

#: includes/class-saddle-context.php:599
#: includes/class-saddle-context.php:603
msgid "Type scale: hero/display 44–64px, section headings 28–40px, body 16–18px with line-height 1.5–1.7. Establish a clear step between levels — don't set everything near the same size."
msgstr ""

#: includes/class-saddle-context.php:600
#: includes/class-saddle-context.php:604
msgid "Line length: keep body text to ~50–75 characters per line (roughly a 600–720px max width for a text column), never full-bleed paragraphs. And CENTER a width-capped text column (or balance it against media) — a max width without centering pins content to the left edge and leaves a dead right half."
msgstr ""

#: includes/class-saddle-context.php:601
#: includes/class-saddle-context.php:605
msgid "Spacing on an 8px system (8/16/24/32/48/64/96): generous section padding (~64–96px top/bottom on desktop), consistent gaps within a group, and more space BETWEEN groups than inside them."
msgstr ""

#: includes/class-saddle-context.php:602
#: includes/class-saddle-context.php:606
msgid "Color: one dominant neutral background, one text color, and a SINGLE accent used sparingly for emphasis and calls to action. Body text must hit WCAG AA contrast (≥ 4.5:1; ≥ 3:1 for large headings)."
msgstr ""

#: includes/class-saddle-context.php:603
#: includes/class-saddle-context.php:607
msgid "Rhythm: align to a consistent content width, reuse the same handful of spacing/size steps across the page, and prefer one strong idea per section over dense walls of content."
msgstr ""

Expand Down Expand Up @@ -5514,21 +5514,25 @@ msgstr ""
msgid "Saddle is paused on this site, so no tools will run. The site owner can resume it from the AI switch at the top of any Saddle page. Do not retry until they do."
msgstr ""

#: includes/class-saddle-mcp.php:943
#: includes/class-saddle-mcp.php:939
msgid "Some apps show only the start of this guide. If yours stops partway, call saddle/get-instructions once to read the rest, including the site owner's instructions."
msgstr ""

#: includes/class-saddle-mcp.php:950
msgid "Saddle exposes tiered, approval-gated access to this site's posts, pages, and media. Call saddle/get-instructions for the current scope and safety rules before acting."
msgstr ""

#: includes/class-saddle-mcp.php:1140
#: includes/class-saddle-mcp.php:1147
msgid "Invalid params: a Saddle tool name is required."
msgstr ""

#. translators: %s: tool name.
#: includes/class-saddle-mcp.php:1150
#: includes/class-saddle-mcp.php:1157
#, php-format
msgid "Tool not found: %s"
msgstr ""

#: includes/class-saddle-mcp.php:1180
#: includes/class-saddle-mcp.php:1187
msgid "None of Saddle’s site-wide gates (pause, access level, per-tool toggles) blocked this — the connected WordPress account likely lacks a capability this tool requires, or the specific item is protected. Do not retry the same call."
msgstr ""

Expand Down
3 changes: 3 additions & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ The WordPress.org version never checks for its own updates. The version from plu

== Changelog ==

= 1.5.2 =
* Fixed: When a step shows a preview first, such as moving a post to the trash, your AI app now shows you the preview and waits for your yes. Claude Code reads only the start of Saddle's guide, and it could confirm on its own.

= 1.5.1 =
* New: A plugin that adds a page under Saddle can draw it as one page, with no sidebar and no tab row. Its settings open from the page and lead back to it. Saddle Analytics uses this.
* Changed: On a plugin's page under Saddle, such as SEO or CRM, the sections sit in a white panel on the left, and the page starts right beside it instead of in the middle of the screen.
Expand Down
12 changes: 12 additions & 0 deletions tests/approval-test.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ public function test_dry_run_returns_preview_and_does_not_execute() {
$this->assertSame( 0, $calls, 'A preview must mutate nothing.' );
}

/**
* Every app reads the preview, whatever it does with the server
* instructions. When it only said "to proceed, call again with the token",
* Claude Code took that as permission and confirmed in the same turn (#333).
*/
public function test_the_preview_tells_the_agent_to_ask_before_confirming() {
$result = Saddle_Approval::gate( $this->gate_args( $calls ) );

$this->assertStringContainsString( 'ask whether to go ahead', $result['instructions'] );
$this->assertStringContainsString( 'Only after they agree', $result['instructions'] );
}

public function test_dry_run_issues_a_persisted_single_use_token() {
$result = Saddle_Approval::gate( $this->gate_args( $calls ) );
$token = $result['confirm_token'];
Expand Down
23 changes: 23 additions & 0 deletions tests/mcp-transport-test.php
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,29 @@ public function test_the_instructions_say_how_many_tools_are_withheld() {

$this->assertStringContainsString( 'not offered to you', $instructions );
$this->assertStringContainsString( 'Saddle → AI apps', $instructions );
// The owner's word for the level, the one AI apps shows, not the tier key.
$this->assertStringContainsString( 'than the "Read only" one', $instructions );
}

/**
* Claude Code keeps only the first 2,048 characters of a server's
* instructions (measured on 2.1.289, #333). It confirmed a gated delete in
* the same turn because the ask-first rule sat at character 6,532. What an
* agent must not miss has to fit inside them: ask before confirming, and
* where the rest of the guide is, the owner's instructions included.
*/
public function test_the_first_2048_characters_carry_the_gate_rule_and_where_the_rest_is() {
Saddle_Capabilities::set_tier( 'write' );
update_option( Saddle_Context::USER_OPTION, 'Always write in British English.' );

$instructions = $this->initialize( '2025-11-25' )['instructions'];

delete_option( Saddle_Context::USER_OPTION );

$head = mb_substr( $instructions, 0, 2048 );
$this->assertStringContainsString( 'saddle/get-instructions', $head );
$this->assertStringContainsString( 'call again with the token only after they agree', $head );
$this->assertStringContainsString( 'Always write in British English.', $instructions, 'The owner\'s instructions are still served, last.' );
}

public function test_nothing_is_claimed_to_be_withheld_when_nothing_is() {
Expand Down
Loading