fix(guardrails): hold a wrapped destructive confirm to the args it previewed (#89) - #90
Merged
Conversation
…eviewed
The gate's `bind` parameter exists to fold confirmation-relevant arguments
into the token's identity, and the integration wrappers were not passing it.
The only thing tying a confirm to its preview was `target` — the first of
id/post_id/attachment_id in the input — so on any partner tool taking an id
plus a payload, every other argument was unbound and the delegate closed over
the *confirm* call's input. Preview {post_id: 12, description: "approved"},
confirm {post_id: 12, description: "something else"}, and the second one ran.
The user approved one change and got another, which is the two-step confirm
doing the opposite of its job.
Now `bind` is a sha256 of the whole argument set minus confirm_token, key
sorted so a client reordering its JSON doesn't break a legitimate confirm.
`target` stays the bare id, so the log line is still a readable "#12" rather
than a hash. The target key list is widened past the original three and is
filterable — a key missing from it now costs legibility, never safety.
Also here: denial_reason() had no branch for a capability denial, the one
refusal whose fix is a different WordPress account rather than anything in
the Saddle dashboard. It fell through to the generic paragraph, which lists
three fixes and not that one. permission() now records the capability each
ability asks for as it registers — one line, and it covers free, Pro and the
wrappers alike, because all three build their callback there.
Closes #89
Refs #63
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GkZr73cqaSesHRDG89Yy8S
4 tasks
ifahimreza
added a commit
that referenced
this pull request
Aug 16, 2026
Three weeks of main landed on top of this branch, and two changes collided head-on with it rather than beside it. **The wrapper bind.** Both this branch and #90 fixed the same hole — a destructive partner tool could be confirmed with different arguments than its preview showed — and they fixed it differently. This branch's extracted engine binds `substr( md5( json ), 0, 12 )` of the raw argument array. #90 binds a full sha256 over a recursively key-sorted copy. The sorting is the part that matters: without it, a client that serializes the same arguments in a different key order has a LEGITIMATE confirm refused. Kept this branch's structure (the engine owns the executor now) and ported #90's hash and its widened, filterable target-key list into it. **denial_reason().** This branch rewrote it to read the same recorded gate the closure enforces instead of re-deriving the tier from ability meta, which is strictly better and is what `$gates` is for — so `required_cap()` from #90 now reads that registry rather than keeping a second map that could drift. Two things were restored on top: the capability message names the capability (an agent told "you lack a permission" without being told WHICH cannot relay it), and the OAuth insufficient-scope branch, which distinguishes "this site does not allow that" from "this app was granted less than the site allows" — different screens, different fixes, and oauth-bearer-test.php pins it. One test changed meaning rather than breaking: it asserted that a disabled tool outranks a capability denial. permission() checks the capability FIRST, so that was simply wrong, and it only surfaced once denial_reason() started mirroring permission()'s real order. Rewritten to pin the ordering in both directions. Also fixed a docblock this branch shipped with, and one my own insertion split. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GkZr73cqaSesHRDG89Yy8S
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #89
Refs #63
What
Two guardrail fixes, both in the "an agent is told the wrong thing" family.
A wrapped destructive tool can no longer be confirmed with different arguments than it previewed. And a capability denial now says so, instead of being reported as one of the site owner's switches.
Why
The bind.
Saddle_Approval::gate()takes abindparameter precisely so that confirmation-relevant arguments are part of the token's identity — it is what stops a "move to trash" preview being confirmed into a permanent delete.Saddle_Integrations::executor()never passed it. The only thing holding a confirm to its preview wastarget, the first ofid/post_id/attachment_idfound in the input, and the$delegateclosure captured the confirm call's$input.So on any partner tool that takes an id plus a payload — the common shape — the payload was unbound:
{post_id: 12, description: "the one the user approved"}→ preview + token, nothing mutated{post_id: 12, description: "something else", confirm_token: …}→ the second one executesThe user approves one change and gets another. That is the two-step confirm doing the opposite of its job, and "no destructive action without a two-step confirm" is the third non-negotiable.
It was invisible in the suite because
tests/integrations-test.php's synthetic destructive tool takes a single argument that is the target — the one shape where the existing target hash incidentally binds everything.The denial.
denial_reason()covered paused / not-authenticated / tool-disabled / tier and then returnednull. A capability denial — already recorded under that exact name bylog_denial()— reached the agent as the generic "None of Saddle's site-wide gates blocked this…" paragraph, which names three fixes and not the real one. The fix is a different WordPress account; no access level or toggle touches it.How
bindis a sha256 of the full argument set minusconfirm_token, key-sorted recursively so a client that reorders its JSON doesn't break a legitimate confirm.confirm_tokenis excluded for the reason the original comment gives: the preview call doesn't carry one and the confirm call does.targetstays the bare id so the activity-log line remains a readable#12rather than a hash. Target keys widened past the original three and put behindsaddle_integration_target_keys— with the bind in place, a key missing from that list costs legibility, never safety.Saddle_Capabilities::permission()records the capability each ability asks for as it registers. One line, one site: free, Pro and the integration wrappers all build their callback there, so no registration site changes.denial_reason()checks it in the same orderpermission()does, so the owner's switches still outrank it.Testing
composer test— 518 tests, 1828 assertions, green (1 pre-existing skip)composer lint— 0 errors; the 3 warnings are pre-existing and in files this PR doesn't touchtest_destructive_wrapper_confirm_cannot_change_the_arguments_it_previeweddenial_reason()cases: the capability denial is explained, a disabled tool still outranks it, and a properly-privileged account gets no claim at allWP_DEBUGonNo CI in this repo, so "green" here means the commands above were run locally.
🤖 Generated with Claude Code
https://claude.ai/code/session_01GkZr73cqaSesHRDG89Yy8S