Skip to content

fix(agents): close privilege-escalation and session-bypass paths - #460

Merged
epeicher merged 1 commit into
add/agents-phase-afrom
fix/agents-security-hardening
Jul 31, 2026
Merged

fix(agents): close privilege-escalation and session-bypass paths#460
epeicher merged 1 commit into
add/agents-phase-afrom
fix/agents-security-hardening

Conversation

@AllTerrainDeveloper

@AllTerrainDeveloper AllTerrainDeveloper commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

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

authenticate never runs for cookie validation. Any SSO / JWT / magic-link / "log in as user" plugin calling wp_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_user guard at PHP_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 via wp_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 agents extended 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 to includes/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 hold administrator. So:

A contributor POSTs /agents/{id}/invoke with "publish post 42" → the editor-role agent's update-post permission check passes as the agent → published.

edit_postspublish_posts escalation via the message body.

The run is now ceilinged at the invoker's capabilities by a user_has_cap filter installed alongside the switch and released in finally. It intersects primitive caps — user_has_cap fires after map_meta_cap() has resolved edit_post into 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_invoker is 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_options had 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.php documented the get_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 bare apply_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 the administrator role. Closes: a role plugin grants edit_users to 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's permission_callback. It is not the reason a mutating ability is safe.

Also

  • Per-invoker rate limit (default 120/hr across all agents). The per-agent limit did nothing to stop one edit_posts user 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.
  • Agent author archives 404 on the front end. /?author=N advertised agent-* 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 in docs/README.md and docs/api-index.md; hooks-reference.md documents the four new filters and corrects the get_editable_roles() claim; includes/rest/README.md updates the invoke row.

New filters

Filter Default
desktop_mode_agent_restrict_to_invoker true when a human triggered the run
desktop_mode_agent_actor_can_assign_role promote_users + admin for administrator
desktop_mode_agent_user_can_invoke_agent honours the trigger's capability
desktop_mode_agent_invoker_rate_limit 120/hr per user

Verification

  • npm run test:php1789 tests, 4765 assertions, 0 failures (29 new in agentsSecurity.php)
  • npm run build / lint / typecheck / test:js (2562) — all green
  • php -l on every changed PHP file

No TS changed, so no bundle diff. The unrelated assets/vendor/pixi.min.js drift 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 with edit_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

Open WordPress Playground Preview

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
AllTerrainDeveloper force-pushed the fix/agents-security-hardening branch from 75add7e to f4c17f3 Compare July 31, 2026 09:51
@epeicher
epeicher merged commit bbde33f into trunk Jul 31, 2026
5 checks passed
@epeicher
epeicher deleted the fix/agents-security-hardening branch July 31, 2026 10:10
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.

2 participants