Description
A route whose tool:, resource:, prompt:, or llm: selector contains a * never runs its policy body. The route still resolves for everything that goes through resolve_route (its authentication: steps fire, an assertions: contract resolves from it), but the APL handler that carries authorization:, args:, and result: is never found, the entry list comes back empty, and the request is allowed. Exact names and explicit name lists work. So a config that reads as "authenticate every tool, then authorize it" authenticates and authorizes nothing, and does it silently.
This matters most for resources and prompts, which is what prompted the survey. A resource is addressed by URI, and URIs are open ended: resource: hr://employees/* is the natural way to write the route, and it is the shape that does not work. Tools can be enumerated, so the tool routes in our tests and demos happen to use exact names and hide the problem.
Evidence
Run against 36a2357 with temporary probes in crates/ppe-apl-runtime/tests/visitor_e2e.rs, dispatching deny-gate from authorization.pre_invocation:
selector deny fired
tool: get_weather yes
tool: [get_weather, other] yes
tool: "get_*" no
tool: "*" no
resource: hr://employees/* no (entity_name hr://employees/E1)
resource: hr://employees/E1 yes
A second probe in crates/ppe-core/tests/identity_route_e2e.rs dispatched identity.resolve for tool get_compensation against a route declaring tool: "get_*" with authentication: [agent-context]. The step fired. That is the asymmetry: the authentication half of a glob route works, the authorization half does not.
The probes were removed again; nothing is committed.
crates/ppe-apl-runtime/tests/visitor_e2e.rs::resource_route_annotates_on_resource_pre_fetch_hook is written against resource: hr://employees/* and asserts continue_processing == true. It passes because the handler never runs, so it is currently pinning the bug rather than the behaviour it names.
Where it comes from
Two sides of the same key are built by different code that agrees only for exact names.
The visitor installs one annotation per name the selector contributes, and for a name selector those names are the pattern exactly as written (route_entity_identity to selector_names in crates/ppe-core/src/config.rs, then install_handler to annotate_route in crates/ppe-apl-runtime/src/visitor.rs). A tool: "get_*" route installs its handler under the literal name get_*.
The engine looks the annotation up by exact HashMap key on (entity_type, resolved_name, scope, hook_name), and for the four named entity types resolved_name is meta.entity_name off the request (filter_entries_by_route in crates/ppe-core/src/engine.rs). The request carries get_weather, the table holds get_*, the lookup misses.
Generic HTTP does not have this problem because both sides render the same string: http_selector_names renders the selector and the request resolves to matched_http_name, so the resolved name is the annotation key by construction. Named selectors have no equivalent step.
After the miss, the slow path resolves the route (the glob matches there), resolves plugins for the hook, gets nothing for a policy-mode route whose plugins are named from APL rather than from a structural list, and returns an empty entry list. An empty list is an allow.
Proposed fix
Smallest change that closes it: when the exact annotation lookup misses, resolve the route (resolve_route already handles globs and specificity) and retry the lookup under the name the matched route declared. MatchedRoute carries name, and for a glob selector that is the pattern, which is exactly the annotation key. The request-keyed route cache stays keyed on the request name, so the second lookup costs one table walk on first sight of a name and nothing after.
Worth considering instead, if it is not much more work: key the annotation table on the route's own identity rather than on a name, and have route resolution hand back that identity. That removes the class of bug rather than the instance, and it also removes the rendered-name dance HTTP needs today.
Either way the duplicate-name check in validate_config needs a look: two routes with overlapping globs are not a duplicate name today, and whichever wins should be the one resolve_route scores highest, not whichever installed last.
Acceptance criteria
- A route with a
* in any of tool:, resource:, prompt:, llm: runs its authorization:, args:, and result: blocks for a request whose entity name the selector matches.
- Specificity is unchanged: an exact route still wins over a glob, a glob over
*, and a scoped route over an unscoped one.
- Tests assert a deny, not an allow. A test that asserts
continue_processing == true cannot tell "the route fired and allowed" from "the route never fired", and that is what let this survive. resource_route_annotates_on_resource_pre_fetch_hook and any sibling with the same shape get rewritten to dispatch a deny.
- Coverage for each of the four named entity types, since the same key is shared and each has its own hook pair.
- A test pinning the authentication and authorization halves of one glob route firing together, which is the invariant that was broken.
Description
A route whose
tool:,resource:,prompt:, orllm:selector contains a*never runs its policy body. The route still resolves for everything that goes throughresolve_route(itsauthentication:steps fire, anassertions:contract resolves from it), but the APL handler that carriesauthorization:,args:, andresult:is never found, the entry list comes back empty, and the request is allowed. Exact names and explicit name lists work. So a config that reads as "authenticate every tool, then authorize it" authenticates and authorizes nothing, and does it silently.This matters most for resources and prompts, which is what prompted the survey. A resource is addressed by URI, and URIs are open ended:
resource: hr://employees/*is the natural way to write the route, and it is the shape that does not work. Tools can be enumerated, so the tool routes in our tests and demos happen to use exact names and hide the problem.Evidence
Run against
36a2357with temporary probes incrates/ppe-apl-runtime/tests/visitor_e2e.rs, dispatchingdeny-gatefromauthorization.pre_invocation:A second probe in
crates/ppe-core/tests/identity_route_e2e.rsdispatchedidentity.resolvefor toolget_compensationagainst a route declaringtool: "get_*"withauthentication: [agent-context]. The step fired. That is the asymmetry: the authentication half of a glob route works, the authorization half does not.The probes were removed again; nothing is committed.
crates/ppe-apl-runtime/tests/visitor_e2e.rs::resource_route_annotates_on_resource_pre_fetch_hookis written againstresource: hr://employees/*and assertscontinue_processing == true. It passes because the handler never runs, so it is currently pinning the bug rather than the behaviour it names.Where it comes from
Two sides of the same key are built by different code that agrees only for exact names.
The visitor installs one annotation per name the selector contributes, and for a name selector those names are the pattern exactly as written (
route_entity_identitytoselector_namesincrates/ppe-core/src/config.rs, theninstall_handlertoannotate_routeincrates/ppe-apl-runtime/src/visitor.rs). Atool: "get_*"route installs its handler under the literal nameget_*.The engine looks the annotation up by exact
HashMapkey on(entity_type, resolved_name, scope, hook_name), and for the four named entity typesresolved_nameismeta.entity_nameoff the request (filter_entries_by_routeincrates/ppe-core/src/engine.rs). The request carriesget_weather, the table holdsget_*, the lookup misses.Generic HTTP does not have this problem because both sides render the same string:
http_selector_namesrenders the selector and the request resolves tomatched_http_name, so the resolved name is the annotation key by construction. Named selectors have no equivalent step.After the miss, the slow path resolves the route (the glob matches there), resolves plugins for the hook, gets nothing for a policy-mode route whose plugins are named from APL rather than from a structural list, and returns an empty entry list. An empty list is an allow.
Proposed fix
Smallest change that closes it: when the exact annotation lookup misses, resolve the route (
resolve_routealready handles globs and specificity) and retry the lookup under the name the matched route declared.MatchedRoutecarriesname, and for a glob selector that is the pattern, which is exactly the annotation key. The request-keyed route cache stays keyed on the request name, so the second lookup costs one table walk on first sight of a name and nothing after.Worth considering instead, if it is not much more work: key the annotation table on the route's own identity rather than on a name, and have route resolution hand back that identity. That removes the class of bug rather than the instance, and it also removes the rendered-name dance HTTP needs today.
Either way the duplicate-name check in
validate_configneeds a look: two routes with overlapping globs are not a duplicate name today, and whichever wins should be the oneresolve_routescores highest, not whichever installed last.Acceptance criteria
*in any oftool:,resource:,prompt:,llm:runs itsauthorization:,args:, andresult:blocks for a request whose entity name the selector matches.*, and a scoped route over an unscoped one.continue_processing == truecannot tell "the route fired and allowed" from "the route never fired", and that is what let this survive.resource_route_annotates_on_resource_pre_fetch_hookand any sibling with the same shape get rewritten to dispatch a deny.