Applies to
admin.client-mode, admin.capability-boundaries.
Covers admin.client-mode, admin.capability-boundaries. Related: security.submission-store-private, security.rest-route-authorisation.
A tidy admin and a locked admin look identical from the sidebar, and only one of them is a boundary. Both rules here are house rules: they came out of auditing two WordPress themes we had already delivered, no client asked for them, and no published specification requires them.
Hiding is not a boundary
remove_menu_page() takes a row out of the sidebar and changes no permission, so the screen still
answers at its own URL.
The measured split is what makes that concrete. Both delivered builds deny one thing by capability:
creating posts in the inbox types, with create_posts => do_not_allow and map_meta_cap. Users,
Plugins, Tools and Themes are menu-hidden in both and carry no capability change at all - a grep
across either theme for add_role, add_cap, remove_cap or user_has_cap returns three hits and
all three are the same map_meta_cap line. So the third row of the table below is failing in
delivered work, and it looks exactly like the second row from the sidebar.
One of the two builds says so in its own theme docs, in its words: "Hiding is UI-only, not a hard
capability block - the pages are still reachable by direct URL ... not a security boundary". The
other ships the identical mechanism and says nothing; a grep of its PHP, docs and README for
cosmetic, security boundary and UI-only returns zero matches. That split is why
admin.capability-boundaries carries a second sentence in its accept, and why it scores
contested against our own work. The mechanism was never the disagreement. Writing it down was.
Clutter or boundary
| What it is | Which | What fixes it |
|---|---|---|
| A plugin's upsell menu, a dashboard widget, an unused post type | Clutter | Hide it, and record in the repo that hiding is cosmetic |
| Creating a record in a log the site writes for itself | Boundary | Deny the capability where the type is registered |
| Users, Plugins, Themes, Tools | Boundary, if you mean it | A role with the capability removed. Hidden, these read most like a lock |
The boundary both builds drew one edge of
Both deny exactly one thing, and they deny it properly:
register_post_type( 'message', [
'map_meta_cap' => true, // without this the next line is inert
'capabilities' => [ 'create_posts' => 'do_not_allow' ],
] );
map_meta_cap => true is the load-bearing half. Without it WordPress never maps the meta
capability, the denial is never consulted, and the screen you believed you closed opens.
What neither build sets is capability_type. It defaults to post, so every verb except create
maps onto the ordinary post capabilities, and any role that can edit a post can list and read every
stored message and consent record inside wp-admin. security.submission-store-private is the
cleanest verdict in this dimension, verified live on both sites with the permalink and all three
REST routes returning 404 anonymously, and none of it says anything about a logged-in editor.
Denying create_posts stops the client fabricating a consent record, not an author reading the lot.
The gap costs more elsewhere. Both builds put the theme's settings screen, which holds an SMTP
password and a reCAPTCHA secret, at edit_posts, so every editor can read both.
practices.no-secrets misses it by design: it grades git history and the web root, not wp-admin.
Why the trim has to be reversible
Both builds keep the hide list as a PHP array in inc/admin-cleanup.php, and each part of it earns
its place.
- A list in code is greppable, diffable and survives a database restore. A trim configured in a
plugin's settings is a database row, and a database sync carries rows both ways. Same failure
shape as
env.env-config-not-in-db. - Both hook
admin_menuatPHP_INT_MAX. At the default priority you run before half the plugins have registered their menus, so the sweep misses precisely what it was written for. It reads as a machine-specific bug and it is a plugin ordering one. - Neither puts its own settings page in the list. Both sweep on a fragment of the slug and of the title, so a sweep that matches your own options page locks you out of the switch that turns the trim off. That is the whole of "no account can be locked out of its own reversal".
- The toggle ships off, defaulted to 0 in both, so a trim is something somebody decided rather than something the theme did on its way past. It does not follow that switching it off restores the full admin: in one of the two builds two removals sit outside the toggle entirely, dropping the Posts tags and comments columns unconditionally, where the other gates the same filter correctly. A reversal that does not fully reverse is the failure this clause exists to catch, and it is already in delivered work.
The trim that applied to nobody
admin.client-mode originally asked for the site-wide toggle both builds ship, and a live project
had removed that exact toggle the day before the rule was written. WPML will not let an editor open
a translated post at all, so the agency's own people were given administrator accounts, and the
toggle exempted administrators by design, which is right for exactly as long as the only
administrators are developers. The moment the client's own people held the role it applied to
nobody it existed to help, and nothing reported it. The accept names the account that settles it:
one the client actually uses. Not yours.
Checking it
# Ask WordPress rather than the sidebar, as the client's own account.
wp eval --user=editor@example.com '$t = get_post_type_object( "message" );
var_dump( current_user_can( $t->cap->create_posts ) );' # bool(false)
# Then open the screen as that account. A hidden menu still returns 200.
curl -sI -b client-cookies.txt 'https://example.com/wp-admin/post-new.php?post_type=message' | head -1
The second has to refuse rather than render the editor, and that same account has to reach the control that undoes the trim. The audit behind these rules never ran the refusal: it read the registration and inferred it. Inferring it is how a hide ships as a boundary, so run it.