fix(agents): close privilege-escalation and session-bypass paths - #460
Merged
Conversation
Agents act with capability on a user's behalf, which makes them the one part of the framework where a gap is an escalation rather than a bug. Six fixes, each with a test asserting the property rather than the behavior. Session bypass. `authenticate` never runs for cookie validation, so any SSO / JWT / magic-link plugin calling `wp_set_auth_cookie( $agent_id )` handed out a live agent session with no credential involved. Adds a `determine_current_user` guard at PHP_INT_MAX — every authenticated request funnels through it. Does not affect the runner, which switches via `wp_set_current_user()`. Guard loads unconditionally. The login blocks sat behind the `agents` extended option, but disabling the feature does not delete the agent rows — it just un-blocked application passwords and password resets for them. Marker meta, `desktop_mode_agent_is_agent()` and every block move to includes/agents/guard.php, required ahead of the flag. Confused deputy. The runner switches to the agent for the tool loop, invoking is gated on `edit_posts`, and agents may hold `administrator` — so a contributor could ask an editor-role agent to publish and the ability's own permission_callback would allow it. The run is now ceilinged at the invoker's capabilities via a `user_has_cap` filter installed alongside the switch and released in `finally`. Intersects primitive caps, so object-level ownership still resolves per-user and the ceiling can only remove reach, never grant it. Trigger capability was decorative. The Triggers pane collects a required capability and the store persisted it, but nothing read it. Adds `desktop_mode_agent_user_can_invoke_agent()`, enforced on the invoke route. Agents with no configured trigger keep falling back to the route-level check. Role assignment. `get_editable_roles()` is not current-user-aware — core implements it as a bare `apply_filters( 'editable_roles', ... )` — so the documented protection did not exist and an `edit_users`- capable non-admin could mint an administrator agent. Now requires `promote_users`, plus a genuine administrator for `administrator`. Prompt injection. Tool results carry attacker-authored site content into a loop holding mutating abilities. Results are fenced in `<untrusted-tool-output>` (delimiter neutralized in the payload so content cannot close the fence early) with a system-prompt trust rule. Mitigation, not a guarantee — documented as the third layer. Also adds a per-invoker hourly rate limit (the per-agent one did not stop one user walking every agent in turn) and 404s agent front-end author archives. New: docs/agents-security.md — the trust model, and a checklist for anyone registering an agent-callable ability or a trigger intake. Tests: 1789 pass, 4765 assertions. Build, lint, typecheck, test:js green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sKW1mu1SGg5k9oSHgxv5s
AllTerrainDeveloper
force-pushed
the
fix/agents-security-hardening
branch
from
July 31, 2026 09:51
75add7e to
f4c17f3
Compare
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.
Stacked on
add/agents-phase-a— base is that branch, so the diff here is only the security layer.Agents are the one part of the framework that acts with capability on a user's behalf. Everything else renders, routes, or stores. That makes a gap here an escalation rather than a bug. Six fixes, each with a test asserting the security property rather than the behavior.
1. Session bypass — the one that actually mattered
authenticatenever runs for cookie validation. Any SSO / JWT / magic-link / "log in as user" plugin callingwp_set_auth_cookie( $agent_id )handed out a live agent session with no credential involved at all — the existing blocks never saw it.Adds a
determine_current_userguard atPHP_INT_MAX. Every authenticated request funnels through that filter, so it covers cookies and every third-party token scheme in one place. It does not affect the runner, which switches viawp_set_current_user()and bypasses the filter by design.The pre-existing blocks were otherwise solid — application passwords in particular were already covered on both sides (creation and use).
2. The guard now loads unconditionally
Every login block sat behind the
agentsextended option. Turning the feature off does not delete the agent rows — it just un-blocked application passwords and password resets for them.Marker meta,
desktop_mode_agent_is_agent(), and all blocks move toincludes/agents/guard.php, required ahead of the flag. The blocks are a property of the rows, not of the feature.3. Confused deputy → capability ceiling
The runner switches to the agent for the whole tool loop, invoking is gated on
edit_posts, and agents may holdadministrator. So:edit_posts→publish_postsescalation via the message body.The run is now ceilinged at the invoker's capabilities by a
user_has_capfilter installed alongside the switch and released infinally. It intersects primitive caps —user_has_capfires aftermap_meta_cap()has resolvededit_postinto the primitive that specific post needs, so object-level ownership still resolves per-user and the ceiling can only remove reach, never grant it.Skipped only when there is no invoker (hook/cron), because intersecting with the logged-out cap set would leave the agent unable to act. That case is documented, not silent —
desktop_mode_agent_restrict_to_invokeris the seam.4. The trigger capability was decorative
The Triggers pane renders "Required capability", the store sanitizes and persists it, and nothing ever read it. An administrator restricting an agent to
manage_optionshad every reason to believe it took effect.Adds
desktop_mode_agent_user_can_invoke_agent(), enforced on the invoke route. Agents with no configured trigger still fall back to the route-level check — requiring a trigger would lock out every agent created before triggers were set up, which is all of them by default.5. Role assignment
store.phpdocumented theget_editable_roles()intersection as "prevents a user from minting an agent with a role they could not assign to a human." Core implements that function as a bareapply_filters( 'editable_roles', wp_roles()->roles )with no reference to the current user — on a stock install it excludes nothing. The protection did not exist.Now requires
promote_users, plus a genuine administrator (super admin on multisite) for theadministratorrole. Closes: a role plugin grantsedit_usersto a shop-manager-shaped role → that role mints an administrator agent → the agent acts with capabilities its creator never had.6. Prompt injection
The Copilot is read-only specifically because a search turn can be driven by attacker-controlled content. Agents deliberately hold mutating abilities, so that structural defence is unavailable — a comment body or a contributor's draft reaches the model in the same context as the instructions it acts on.
Tool results are now fenced in
<untrusted-tool-output>, with the delimiter neutralized inside the payload so content cannot close the fence early, plus a system-prompt trust rule. Documented explicitly as mitigation, not a guarantee — the third layer, behind the cap ceiling and each ability'spermission_callback. It is not the reason a mutating ability is safe.Also
edit_postsuser walking every agent in turn and spending the AI budget N times over. Checked before the per-agent limit so a rejected run doesn't consume the agent's quota./?author=Nadvertisedagent-*logins. Not exploitable given the blocks, but no reason to publish the list.Docs
New
docs/agents-security.md— the trust model, why each boundary exists, and a checklist for anyone registering an agent-callable ability or adding a trigger intake. Indexed indocs/README.mdanddocs/api-index.md;hooks-reference.mddocuments the four new filters and corrects theget_editable_roles()claim;includes/rest/README.mdupdates the invoke row.New filters
desktop_mode_agent_restrict_to_invokerdesktop_mode_agent_actor_can_assign_rolepromote_users+ admin foradministratordesktop_mode_agent_user_can_invoke_agentcapabilitydesktop_mode_agent_invoker_rate_limitVerification
npm run test:php— 1789 tests, 4765 assertions, 0 failures (29 new inagentsSecurity.php)npm run build/lint/typecheck/test:js(2562) — all greenphp -lon every changed PHP fileNo TS changed, so no bundle diff. The unrelated
assets/vendor/pixi.min.jsdrift the build produces was deliberately kept out of this diff — it predates this branch and deserves its own chore PR.Not fixed here
desktop_mode_agent_{create,update,delete}()still carry no capability check of their own — they are safe because the REST routes gate them withedit_users. Documented as privileged internal APIs rather than changed, since adding a hard check inside would break programmatic and WP-CLI callers. Say the word if you'd rather they fail closed.🤖 Generated with Claude Code
https://claude.ai/code/session_015sKW1mu1SGg5k9oSHgxv5s