Aditya Sharma

AI

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:

A WordPress application password stores seven fields. None of them is a scope.
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.
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.

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
No role can grant unfiltered_upload without the ALLOW_UNFILTERED_UPLOADS constant.
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, 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:

ActionRecoverable?What actually saves you
Update a postYesRevisions, if WP_POST_REVISIONS is not false and the post type supports them
Trash a postUsuallyTrash, unless EMPTY_TRASH_DAYS is 0, in which case it is a permanent delete
DELETE with force=trueNoA database snapshot taken before the call
Change a site optionNoNothing in core. Options have no revision history
Deactivate a pluginYesReactivation, though some plugins run cleanup on deactivate
Delete a pluginNoReinstall from the source, if you still have the licence
Delete a userNoThe reassign parameter only saves the content, not the user
search-replace on the databaseNoA 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.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

  1. An administrator application password. It carries manage_options, activate_plugins, edit_users and unfiltered_html in one string with no expiry.
  2. 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.
  3. Raw database write access without a snapshot in the same session. Especially search-replace without --dry-run first.
  4. Filesystem write access to the live document root. Deploy through git or rsync from a build you reviewed.
  5. 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.
  6. 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
The wp user application-password command reference on developer.wordpress.org.
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.

Resources

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

Tell me where I am wrong

Your email is not published and I do not add it to any list. Corrections with a source are the ones I act on fastest.