Guardrails for running an agent against a production WordPress site
On this page, 11 sections
This is the entire record WordPress stores when you create an application password. It is in WP_Application_Passwords::create_new_application_password(), in wp-includes/class-wp-application-passwords.php:

array(
'uuid' => wp_generate_uuid4(),
'app_id' => empty( $args['app_id'] ) ? '' : $args['app_id'],
'name' => $args['name'],
'password' => wp_hash_password( $new_password ),
'created' => time(),
'last_used' => null,
'last_ip' => null,
)
Seven fields, and not one of them is a scope
Read the keys.
There is no scope. There is no expiry. There is no list of allowed routes, no read-only flag, and no capability subset.
An application password is the whole user, for as long as the user exists, until a human goes into the admin and revokes it.

The first guardrail is the user, not the hook
Which means the first guardrail is not a hook or a policy. It is the answer to: what user did you hand your agent? If the answer is your own administrator account, every other control in this post is decoration.
The general version of agent safety, and the destructive-command hooks I run at the tool layer, are in the writeup on running Claude Code against WordPress.
This post is the WordPress-specific half: the failure modes that come from how WordPress itself is built.
1. Give the agent its own user and its own role
Roles are cheap and nobody makes them. The capability names in the REST API route table map one to one onto a role definition.
If the agent drafts and publishes posts and does nothing else, add_role( 'agent_publisher', 'Agent Publisher', ... ) on plugin activation is the whole role.
These are the capabilities I grant, and the ones I write out as false on purpose so the decision is documented for the next person.
- Granted:
read,upload_files,edit_posts,edit_published_posts,publish_posts. - Denied explicitly:
delete_posts,delete_published_posts,edit_others_posts,manage_options,activate_plugins,edit_theme_options,unfiltered_html. - Everything omitted is denied anyway. Writing the dangerous ones out as false is documentation, not enforcement.
The add_role gotcha
The add_role() gotcha is the one that bites: it returns null and does nothing if the role already exists.
Editing the array and reloading the plugin changes nothing. Roles live in the wp_user_roles option in the database, not in your code.
Why unfiltered_html gets its own line
unfiltered_html deserves its own line. An agent that can publish arbitrary HTML including script tags is a stored XSS vector wearing a friendly name. Administrators have it by default on single site. Your agent role should not.
Verify what you actually built rather than what you meant to build.
wp user create agent [email protected] --role=agent_publisher --porcelain
wp user application-password create <id> wp-agent --porcelain
wp user list-caps agent | sort

2. Scope the password that core gives you no way to scope
Core does provide an extension point. wp_authenticate_application_password() fires wp_authenticate_application_password_errors after the password hash matches and before it returns the user, specifically so plugins can add constraints.
It hands you the WP_Error, the WP_User, the stored password item and the password itself.
I hook it at priority 10 with four arguments, match on $item['name'] so only the agent password is constrained, and add three errors.
- An expiry, because core stores none. Compare
time() - (int) $item['created']against30 * DAY_IN_SECONDSand addagent_password_expiredpast it. - An address allow list. The agent runs from one host, so anything else is not the agent. Behind a proxy or a CDN
REMOTE_ADDRis the proxy, so resolve the real client IP the way your stack does before comparing. - A write window. Anything that is not a GET outside 04:00 to 16:00 UTC gets
agent_password_window.
What happens when one of those fires
If $error->has_errors() is true after that action runs, core fires application_password_failed_authentication and returns the error instead of the user. The agent gets a 401 and stops.
Two honest caveats
Two honest caveats. The write window is a blunt instrument and it will stop a legitimate 2am fix as readily as a 2am mistake, which is the point but you should choose it deliberately.
And I have written this hook against the core source rather than measured it under load on a busy site, so treat the IP resolution in particular as something to test in your own stack before you rely on it.
Where the password itself lives
Whatever you do, the password itself is a real credential and belongs in a real password manager rather than a config file in a repo.
I have written before about why a dedicated password manager beats the browser’s built-in one, and an agent credential is exactly the case where the difference shows up: you need to revoke it from somewhere other than the machine that has it.
3. Know which writes are one-way before the agent finds out
Not all writes are equal, and WordPress does not warn you about the difference. This is the list I check against:
| Action | Recoverable? | What actually saves you |
|---|---|---|
| Update a post | Yes | Revisions, if WP_POST_REVISIONS is not false and the post type supports them |
| Trash a post | Usually | Trash, unless EMPTY_TRASH_DAYS is 0, in which case it is a permanent delete |
DELETE with force=true | No | A database snapshot taken before the call |
| Change a site option | No | Nothing in core. Options have no revision history |
| Deactivate a plugin | Yes | Reactivation, though some plugins run cleanup on deactivate |
| Delete a plugin | No | Reinstall from the source, if you still have the licence |
| Delete a user | No | The reassign parameter only saves the content, not the user |
search-replace on the database | No | A snapshot, and a dry run first |
The trash row
The trash row is the one worth burning into a checklist. wp_trash_post() opens with a hard redirect to permanent deletion when trash is switched off, and plenty of performance guides tell people to switch it off.
I walk through the exact source and the surrounding delete semantics in the REST API post.
Options are the quiet one
Options are the quiet one. There is no revision history for wp_options.
An agent that writes siteurl or a serialized settings blob has replaced the previous value with nothing to compare against, and if the option was serialized, a length mismatch corrupts the whole structure rather than just that field.
4. The annotations are declarations, not enforcement
WordPress 6.9 gives abilities a metadata block that looks like a safety system. It carries annotations with readonly, destructive and idempotent, plus a public key.
Why the annotation is a claim, not a check
Core’s own three abilities, core/get-site-info, core/get-user-info and core/get-environment-info, all carry that exact block, and all three are genuinely read-only.
Nothing stops a plugin author from writing 'readonly' => true on an ability whose execute callback truncates a table.
The annotation is a claim by the author for the benefit of the client.
WordPress does not verify it and cannot.
Ability discovery is a security task
Which makes ability discovery a real security task rather than a curiosity. Enumerate every ability on the site, read the ones marked public, and decide about each one.
The MCP server survey covers how to list them and which permission gates them.
The permission callback that fails open
The same reasoning applies to the adapter’s own permission callbacks. The WordPress/mcp-adapter transport-permissions guide states that an exception thrown inside your permission callback falls back to is_user_logged_in().
A callback that was meant to require manage_options and throws on a bad option read silently becomes any-logged-in-user.
Write the callback so it cannot throw, and log inside it.
5. Staging is not a copy of production, and the differences are the ones that matter
Test on staging first is correct advice that people follow into a false sense of safety. The parts of a WordPress site that diverge between staging and production are precisely the parts that break an automated write:
- Serialized data with different string lengths. Domain and path appear inside serialized settings. A search-replace that worked on
staging.example.comis a different byte length onexample.com. - Cron. Staging usually has
DISABLE_WP_CRONset or no traffic to trigger it, so scheduled hooks that fire on production never fire in your test. - Object cache. Production has Redis or Memcached, staging often does not. Code paths that read from cache behave differently when there is no cache to read.
- Third-party webhooks. Payment, CRM and email integrations are usually in test mode or disconnected. A write that triggers an outbound call on production triggers nothing on staging.
- Data volume. A query that returns 40 rows on staging returns 400,000 on production and times out.
What staging is still worth
None of that argues against staging. It argues for treating a green staging run as evidence rather than proof, and for keeping the production snapshot regardless.
6. A snapshot, not a backup
The nightly backup is for disasters. What an agent session needs is a snapshot taken minutes ago, by you, in the same session, that you know how to restore because you have restored it. Mine is four steps.
wp db exportwith--add-drop-tableinto a timestamped directory.tar -czfoverwp-content.- Record the three constants that change what delete means on this site:
EMPTY_TRASH_DAYS,WP_POST_REVISIONSandDISALLOW_FILE_MODS, each read withwp config get --type=constantand defaulted in the shell when unset. - Print the snapshot path so the session has it.
The constants file is the part people skip
That constants file is the part people skip and the part that changes the risk calculation.
A site with EMPTY_TRASH_DAYS at 0 and WP_POST_REVISIONS at false has no undo for content at all.
On that site the agent gets read access and a report, and a human runs the writes.
Test the restore path on staging before you need it on production. A snapshot you have never restored is a hypothesis.
7. What never goes to an agent on a production site
- An administrator application password. It carries
manage_options,activate_plugins,edit_usersandunfiltered_htmlin one string with no expiry. - Arbitrary PHP execution inside the WordPress context. Useful on local and staging, and the single widest tool you can mount on a live site. Anything it can do, it can do without a capability check.
- Raw database write access without a snapshot in the same session. Especially
search-replacewithout--dry-runfirst. - Filesystem write access to the live document root. Deploy through git or rsync from a build you reviewed.
- The ability to install plugins from arbitrary URLs.
POST /wp/v2/pluginstakes a slug; a wrapper that also accepts a zip URL is a remote code execution path with a REST endpoint in front of it. - Credentials for anything other than that one site. One password, one site, one purpose. Shared credentials across a client fleet turn one mistake into every mistake, which is a large part of why plugin licence and subscription ownership on client sites is worth settling in writing.
8. Verify the result through a second mechanism
The failure that costs the most is not a destructive write. It is an agent confidently reporting a state that is not true, because the tool it used had a blind spot and it inherited the blind spot with no hedging.
The sharpest version of this I have hit
I ran into the sharpest version of this when a malware scanner reported a site clean while it was serving spam to Googlebot.
The scanner was not lying. It was answering a narrower question than the one that mattered, and the answer looked identical to the answer I wanted.
Confirm through a different code path
So after any agent write, confirm through something that does not share a code path with the write.
Wrote through REST? Read back with wp-cli. Changed a template? Fetch the rendered page with curl and a real user agent, and again as Googlebot.
Deactivated a plugin? Check the option, rather than the API response that told you it worked.
# Wrote via REST. Verify via a different path entirely.
wp --path=/var/www/example.com post get 4211 --field=post_status
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/?p=4211

One thing to do next
Run wp user list --role=administrator --fields=ID,user_login,user_email on the site your agent touches, then wp user application-password list <id> --fields=name,created,last_used,last_ip for each of them.
Every row is a credential with full site control and no expiry, and last_used tells you whether anything still needs it.
Revoke the ones that do not.
That is a ten-minute audit and on most client fleets it finds at least one password created for a tool that was uninstalled a year ago.
If you run more than a handful of sites, the operational side of that is in how to manage WordPress websites for clients.
Resources
- WP_Application_Passwords in wordpress-develop
- wp_authenticate_application_password() and its hooks
- wp_trash_post() in wp-includes/post.php
- add_role() in the code reference
- wp user application-password
- MCP Adapter transport permission callbacks
- wp_register_ability() and its meta annotations
Disclosure: I am CMO at POSIMYTH, which ships WordPress plugins across 500,000+ installs. Nothing above is a product pitch, and the role and hook code is core WordPress that works on any install.
More on agents on wordpress
- MCP servers for WordPress: what actually exists and what each one can do
- The WordPress REST API as an agent surface: what you can and cannot automate
- Automating WordPress maintenance with agents: what is worth automating and what is not