Skip to content

fix(guardrails): hold a wrapped destructive confirm to the args it previewed (#89) - #90

Merged
ifahimreza merged 1 commit into
mainfrom
fix/89-wrapper-bind
Aug 15, 2026
Merged

fix(guardrails): hold a wrapped destructive confirm to the args it previewed (#89)#90
ifahimreza merged 1 commit into
mainfrom
fix/89-wrapper-bind

Conversation

@ifahimreza

Copy link
Copy Markdown
Contributor

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 a bind parameter 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 was target, the first of id / post_id / attachment_id found in the input, and the $delegate closure 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:

  1. preview with {post_id: 12, description: "the one the user approved"} → preview + token, nothing mutated
  2. confirm with {post_id: 12, description: "something else", confirm_token: …} → the second one executes

The 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 returned null. A capability denial — already recorded under that exact name by log_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

  • bind is a sha256 of the full argument set minus confirm_token, key-sorted recursively so a client that reorders its JSON doesn't break a legitimate confirm. confirm_token is excluded for the reason the original comment gives: the preview call doesn't carry one and the confirm call does.
  • target stays the bare id so the activity-log line remains a readable #12 rather than a hash. Target keys widened past the original three and put behind saddle_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 order permission() 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 touch
  • The bind regression was pinned as a failing test first, and it named the symptom: test_destructive_wrapper_confirm_cannot_change_the_arguments_it_previewed
  • Its other half is pinned too — unchanged arguments must still confirm, or the gate would be unusable on every id-bearing partner tool
  • Three denial_reason() cases: the capability denial is explained, a disabled tool still outranks it, and a properly-privileged account gets no claim at all
  • No notices or warnings with WP_DEBUG on

No CI in this repo, so "green" here means the commands above were run locally.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GkZr73cqaSesHRDG89Yy8S

…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
@ifahimreza
ifahimreza merged commit 4055676 into main Aug 15, 2026
1 of 7 checks passed
@ifahimreza
ifahimreza deleted the fix/89-wrapper-bind branch August 15, 2026 14:39
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Guardrails: a wrapped destructive confirm can carry different arguments than its preview

1 participant