---
title: "Guardrails for running an agent against a production WordPress site"
url: https://adityaarsharma.com/guardrails-agent-production-wordpress/
date: 2026-09-12
modified: 2026-09-03
author: "Aditya Sharma"
description: "A WordPress application password has no scope and no expiry. Here is the role, the auth hook and the snapshot routine that put limits around an agent."
categories:
  - "AI"
  - "WordPress"
image: https://adityaarsharma.com/wp-content/uploads/2026/09/7c65b9e5-a714-45e4-aa49-edc0006a170c_2912x1632-1024x574.webp
word_count: 2187
---

# Guardrails for running an agent against a production WordPress site

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`:

![A WordPress application password stores seven fields. None of them is a scope.](https://adityaarsharma.com/wp-content/uploads/2026/09/7c65b9e5-a714-45e4-aa49-edc0006a170c_2912x1632-scaled.png)

`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.

![Seven fields stored, zero scopes, zero expiry fields.](https://adityaarsharma.com/wp-content/uploads/2026/09/d1296d83-96ef-43bd-9800-95c4e2d80813_2912x1632-scaled.png)Built from the record at the top of this post, read from wp-includes/class-wp-application-passwords.php on 2 September 2026.

### 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](https://adityaarsharma.com/running-claude-code-against-wordpress-the-complete-setup/).

This post is the WordPress-specific half: the failure modes that come from how WordPress itself is built.

On this page

- यह नक्षत्र और पद असल में क्या दर्शाते हैं- यह प्लेसमेंट कैसे दिखता है- वह बात जो कम बताई जाती है- यह चंद्रमा कब मजबूत या कमज़ोर होता है- लोग अक्सर गलत समझते हैं- महादशा में यह कब जागता है- अक्सर पूछे जाने वाले सवाल

## 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](https://adityaarsharma.com/wordpress-rest-api-agent-surface/) 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 agent@example.com --role=agent_publisher --porcelain
wp user application-password create <id> wp-agent --porcelain
wp user list-caps agent | sort`

![No role can grant unfiltered_upload without the ALLOW_UNFILTERED_UPLOADS constant.](https://adityaarsharma.com/wp-content/uploads/2026/09/4110062e-990c-4947-9acc-ddaa4c9cc473_2912x1632-scaled.png)Built from map_meta_cap() in wp-includes/capabilities.php, WordPress 7.1, read 2 September 2026.

## 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']` against `30 * DAY_IN_SECONDS` and add `agent_password_expired` past 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_ADDR` is 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](https://adityaarsharma.com/password-manager-vs-browser-password-manager/), 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](https://adityaarsharma.com/wordpress-rest-api-agent-surface/).

### 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](https://adityaarsharma.com/mcp-servers-for-wordpress-what-exists/) 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.

Newsletter

## Automating the boring half

I publish one researched piece a week on putting agents to work on real sites. What I built, what broke, and the commands to check it yourself.

Email address

Get it weekly

Free. One email a week. Unsubscribe in one click, and I do not send anything else.

## 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.com` is a different byte length on `example.com`.- **Cron.** Staging usually has `DISABLE_WP_CRON` set 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 export` with `--add-drop-table` into a timestamped directory.- `tar -czf` over `wp-content`.- Record the three constants that change what delete means on this site: `EMPTY_TRASH_DAYS`, `WP_POST_REVISIONS` and `DISALLOW_FILE_MODS`, each read with `wp config get --type=constant` and 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_users` and `unfiltered_html` in 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-replace` without `--dry-run` first.- **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/plugins` takes 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](https://adityaarsharma.com/managing-wordpress-plugin-subscriptions-for-clients/) 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](https://adityaarsharma.com/the-scanner-said-clean-the-site-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`

![The wp user application-password command reference on developer.wordpress.org.](https://adityaarsharma.com/wp-content/uploads/2026/09/eb7abcbb-25dc-424c-af26-6cddbc158982_2880x1800-scaled.png)developer.wordpress.org/cli/commands/user/application-password/, screenshot taken 3 September 2026.

## 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](https://adityaarsharma.com/how-to-manage-wordpress-websites-for-clients/).

## Resources

- [WP_Application_Passwords in wordpress-develop](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/class-wp-application-passwords.php)- [wp_authenticate_application_password() and its hooks](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/user.php)- [wp_trash_post() in wp-includes/post.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/post.php)- [add_role() in the code reference](https://developer.wordpress.org/reference/functions/add_role/)- [wp user application-password](https://developer.wordpress.org/cli/commands/user/application-password/)- [MCP Adapter transport permission callbacks](https://github.com/WordPress/mcp-adapter/blob/trunk/docs/guides/transport-permissions.md)- [wp_register_ability() and its meta annotations](https://developer.wordpress.org/reference/functions/wp_register_ability/)

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](https://adityaarsharma.com/mcp-servers-for-wordpress-what-exists/)- [The WordPress REST API as an agent surface: what you can and cannot automate](https://adityaarsharma.com/wordpress-rest-api-agent-surface/)- [Automating WordPress maintenance with agents: what is worth automating and what is not](https://adityaarsharma.com/automating-wordpress-maintenance-with-agents/)