Install
$ agentstack add skill-lonsdale201-wp-agent-skills-br-resource-policy ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
better-route: Resource permission policies
For developers configuring authorization on a better-route Resource — both per-action (who can list / get / create / update / delete) and per-field (who can set the featured flag, who can write price). Four ResourcePolicy presets cover most patterns; ->fieldPolicy handles the rest.
Misconception this skill corrects
> "I'll declare ->fieldPolicy(['featured' => fn ($req) => current_user_can('manage_options')]) and a non-admin will see a 403 when they try to write that field."
Wrong — denied fields are stripped silently from the payload before the handler runs, not rejected with an error. If a non-admin POSTs {title: "X", featured: true}, the handler receives {title: "X"} — featured is silently dropped, the post is created without the privileged flag, and the consumer thinks the request succeeded.
This is the deliberate design — fieldPolicy is for forward-compat scenarios where the API surface should accept the same payload from all roles, but only certain roles can modify certain fields. If you need the consumer to know "you tried to set a privileged field and weren't allowed", do the explicit check inside the handler:
->fieldPolicy([
'featured' => static fn ($request, string $action): bool
=> current_user_can('manage_options'),
])
// AND, in your handler / via writeSchema:
'featured' => ['type' => 'bool', 'sanitize' => function ($value, $field) {
if (!current_user_can('manage_options') && $value) {
throw \BetterRoute\Http\ApiException::forbidden('only admins can set featured');
}
return $value;
}],
Other AI-prone misconceptions:
- "
adminOnly()is more restrictive thanpublicReadPrivateWrite()." Half-true —adminOnlyrequires the cap on EVERY action including reads;publicReadPrivateWriteopens reads. Pick based on whether the data is publicly readable (events, public posts) or private (audit logs, user accounts). - "I'll combine
->policy(...)with route-level->permission(...)." Wrong —policyis read at action-registration time inside Resource; once you register the resource, the per-action permissions are baked. Mixing route-level overrides on resource-generated routes is undefined behavior. - "Resource policy uses WordPress's role/capability system directly." Wrong —
ResourcePolicy::capabilitiesaccepts strings (cap names), arrays of cap names (any-of), booleans (always-allow / always-deny), or callables. The strings ARE WP capabilities, but the wrapper accepts more.
When to use this skill
Trigger when ANY of the following is true:
- The diff calls
Resource::make(...)->policy(...). - The diff uses
ResourcePolicy::factory methods. - The diff calls
->fieldPolicy([...])on a Resource. - Reviewing a PR with hand-written permission arrays on a resource.
- Triaging "everyone can write to my Resource" / "no one can read my Resource".
Workflow
1. Pick a preset (most common case)
use \BetterRoute\Resource\ResourcePolicy;
// Public reads, admin-only writes
->policy(ResourcePolicy::publicReadPrivateWrite('manage_options'))
// Same, with a different write cap
->policy(ResourcePolicy::publicReadPrivateWrite('edit_posts'))
// Same, with multiple acceptable write caps (any-of)
->policy(ResourcePolicy::publicReadPrivateWrite(['edit_posts', 'manage_woocommerce']))
// Everything gated on one cap
->policy(ResourcePolicy::adminOnly('manage_options'))
// Per-action map
->policy(ResourcePolicy::capabilities([
'*' => 'edit_posts',
'delete' => 'manage_options', // delete needs higher cap than other writes
]))
// Per-action callbacks (cross-action logic)
->policy(ResourcePolicy::callbacks([
'update' => fn ($req, $action, $self) => current_user_can('edit_post', (int) $req->get_param('id')),
]))
2. Verified preset behavior
publicReadPrivateWrite($writeCap) ([src/Resource/ResourcePolicy.php:13-26](ResourcePolicy.php)) returns:
['permissions' => [
'list' => true, // public
'get' => true, // public
'create' => $writeCap,
'update' => $writeCap,
'delete' => $writeCap,
]]
adminOnly($cap) ([ResourcePolicy.php:29-39](ResourcePolicy.php)) returns:
['permissions' => [
'list' => $cap,
'get' => $cap,
'create' => $cap,
'update' => $cap,
'delete' => $cap,
]]
capabilities([...]) ([ResourcePolicy.php:44-47](ResourcePolicy.php)) — passes the array through unchanged. The * wildcard key applies to any action not explicitly listed.
callbacks([...]) ([ResourcePolicy.php:53-56](ResourcePolicy.php)) — same shape, but every value is a callable instead of a cap string.
3. Per-action permission value types
The permissions array maps action keys (list, get, create, update, delete, *) to one of:
| Value | Meaning | |---|---| | true | Always allow. | | false | Always deny. | | 'manage_options' (cap string) | Allow when current_user_can($cap). | | ['edit_posts', 'manage_woocommerce'] (cap list) | Allow when ANY of the caps match. | | fn ($request, $action, $self): bool | Custom — receives the request, action name, resource instance. |
Verified at [Resource.php:792-833](Resource.php) — permissionForAction walks the value type chain.
4. Field-level write authorization
Resource::make('books')
->sourceCpt('book')
->fieldPolicy([
'featured' => static fn ($request, string $action): bool
=> current_user_can('manage_options'),
'price' => static fn ($request, string $action): bool
=> current_user_can('edit_posts'),
])
->register($router);
The closure receives ($request, $action) — $action is 'create' / 'update' / 'patch'. Returning false strips the field from the payload silently.
Use case: a consumer-facing API where authors can write title, content, but only admins can flip featured. The same endpoint accepts the same payload structure; the role gate is per-field.
5. Combining policy and fieldPolicy
->policy(ResourcePolicy::publicReadPrivateWrite('edit_posts'))
->fieldPolicy([
'featured' => static fn ($request, $action) => current_user_can('manage_options'),
])
policy controls who can call create/update at all. fieldPolicy controls which fields they can set within an authorized call. So a non-admin author with edit_posts can call POST /books, the request is authorized at the action layer, and featured is stripped at the field layer.
6. Public preset (when reads should be world-accessible)
If you want to open a resource to anonymous reads (cache-friendly public catalog), there's no preset for that — write the policy directly:
->policy([
'public' => true, // bypasses all per-action permissions
])
Verified at [Resource.php:794-797](Resource.php):
if (($this->policy['public'] ?? false) === true) {
return static fn (): bool => true;
}
The public => true flag short-circuits the permission resolver and returns true for every action. Use sparingly — it bypasses every other check.
7. Permission callback (custom logic)
->policy([
'permissionCallback' => static fn ($request, $action) => /* your logic */,
])
A single callback for ALL actions. Use when the action distinction matters less than other context (e.g. JWT scopes — same callback inspects the token's scope claim per action).
Critical rules
- Pick a preset over hand-rolling permissions.
publicReadPrivateWrite,adminOnly,capabilities,callbackscover 95% of cases. adminOnlyrequires the cap on reads too. UsepublicReadPrivateWriteif reads should be public.fieldPolicystrips silently — does not return an error. If consumers need to know, validate explicitly inwriteSchemasanitize callable or in the handler.- Per-action permission values:
bool, cap string, list of caps (any-of), or callable. Strings callcurrent_user_can; arrays of strings call any-of viacurrentUserCanAny. '*'wildcard applies to any action not explicitly listed. Use it with overrides for the exception case.- Custom-table resources are deny-by-default (verified at [Resource.php:840-844](Resource.php)). Always set
->policy(...)for table sources, otherwise list/get/create/update/delete all return 403. - CPT-source default permission allows list/get when no policy is set, but writes deny. Still declare
->policy()explicitly — relying on defaults is fragile. 'public' => truebypasses ALL per-action checks. Use only when the resource is genuinely public; otherwise pick a preset.
Common mistakes
// WRONG — assuming fieldPolicy returns a 403
->fieldPolicy([
'featured' => fn ($req) => current_user_can('manage_options'),
])
// Non-admin POSTs {featured: true}; gets 200 with featured silently dropped.
// RIGHT — for "tell the consumer they can't set this field"
'featured' => [
'type' => 'bool',
'sanitize' => function ($value, $field) {
if (!current_user_can('manage_options') && $value) {
throw \BetterRoute\Http\ApiException::forbidden('only admins can set featured');
}
return $value;
},
]
// WRONG — adminOnly when reads should be public
Resource::make('events')
->policy(ResourcePolicy::adminOnly('manage_options'))
->register($router);
// Public can't even read the event list.
// RIGHT — publicReadPrivateWrite
->policy(ResourcePolicy::publicReadPrivateWrite('manage_options'))
// WRONG — string capability assumed to be ANY-of when wrapped in array implicitly
'permissions' => [
'create' => 'edit_posts manage_woocommerce', // 🔴 single string with spaces; treated as the literal cap name
]
// RIGHT — array of cap names for any-of
'permissions' => [
'create' => ['edit_posts', 'manage_woocommerce'],
]
// WRONG — combining route-level permission with resource-generated routes
Resource::make('books')->register($router);
// later:
$router->put('/books/{id}', $myCustomHandler)->permission(...);
// Now there are two PUT /books/{id} registrations — undefined which wins.
// RIGHT — pick one. Either let Resource generate the route, or use ->allow([]) to skip
// the action, then register manually.
Resource::make('books')->allow(['list', 'get'])->register($router);
$router->put('/books/{id}', $myCustomHandler)->permission(...);
// WRONG — public-true with a sensitive write resource
->policy(['public' => true])
// Anonymous users can create / update / delete.
// RIGHT — public => true is for read-only public resources only
->policy(['public' => true])
->allow(['list', 'get'])
// WRONG — fieldPolicy without writeSchema declaring the field
->writeSchema(['title' => ['type' => 'string']]) // doesn't list 'featured'
->fieldPolicy(['featured' => fn () => false])
// 🔴 'featured' isn't in writeSchema, so it's dropped at the schema layer regardless.
// fieldPolicy never runs because the field never enters the validated payload.
// RIGHT — declare in writeSchema
->writeSchema([
'title' => ['type' => 'string'],
'featured' => ['type' => 'bool'],
])
->fieldPolicy(['featured' => fn () => current_user_can('manage_options')])
Cross-references
- Run
br-resource-cpt/br-resource-tablefor the Resource builder context —policyandfieldPolicyare fluent methods on the Resource. - Run
br-write-schemawhen the field-level rejection should be visible to the consumer — combine withsanitizecallable +ApiException. - Run
br-routesfor raw-route permission patterns (->permission,->protectedByMiddleware,->publicRoute) — the route-layer equivalent.
What this skill does NOT cover
- WP capability design (which capabilities to require for which actions). WP-level decision; choose
edit_posts,manage_options,delete_posts, etc. based on the data model. - Custom capability registration. Use
add_role/add_capin plugin activation if you need a custom capability layer. - Row-level access control inside reads ("user can only see their own posts"). Add a query filter to a custom repository OR via WP's
posts_wherefilter. - Cross-resource policy reuse. If two Resources share a policy, define a function
static fn () => ResourcePolicy::publicReadPrivateWrite('edit_posts')and call it from both. - Multi-tenancy / per-site policy. WP multisite — handle in the callback by reading
get_current_blog_id().
References
- ResourcePolicy presets: [libraries/better-route/src/Resource/ResourcePolicy.php](ResourcePolicy.php) —
publicReadPrivateWriteline 13,adminOnlyline 29,capabilitiesline 44,callbacksline 53. - Resource.policy fluent: [libraries/better-route/src/Resource/Resource.php:141-147](Resource.php) —
policy(array). - Resource.fieldPolicy fluent: [Resource.php:167-171](Resource.php) —
fieldPolicy(array). - Permission resolution: [Resource.php:792-833](Resource.php) —
permissionForAction(string)walkspublic => trueshort-circuit,permissionCallback, thenpermissions[$action] ?? permissions['*']with bool / string / array / callable handling. - Default permission for unset policy: [Resource.php:835-844](Resource.php) — CPT source allows list/get, denies writes. Table source denies everything.
- Field-write enforcement (silent strip): [Resource.php:1147-1180](Resource.php) —
enforceFieldPolicywalks the policy map, strips denied fields without raising.
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: Lonsdale201
- Source: Lonsdale201/wp-agent-skills
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.