Summary
resolveCapabilities (the documented mechanism for serving a dynamic capability list instead of the static capabilities array) is correctly threaded through discovery (/capability/list), registration validation, and capability-request validation -- but not through location enforcement in the JWT audience-verification before-hook. A capability that only exists via resolveCapabilities (never in the static array) is discoverable and grantable, but its location is silently never checked at request time.
Where
getCapabilityLocation(), src/... (compiled to dist/index.js):
function getCapabilityLocation(capabilities, capabilityName) {
if (!capabilities) return void 0;
const cap = capabilities.find((c) => c.name === capabilityName);
return cap?.location;
}
Called from exactly two places, both inside the before-hook / introspection JWT-audience-verification path, both passing only the static array:
const expectedLocation = jwtCapabilities.length === 1
? getCapabilityLocation(opts.capabilities, jwtCapabilities[0])
: void 0;
Every other capability-list consumer I found (listCapabilities, registration validation, capability-request validation) checks opts.resolveCapabilities first and falls back to/replaces the static list. getCapabilityLocation never does.
Impact
Anyone using resolveCapabilities to serve capabilities from a dynamic source (a DB-backed catalog, a per-tenant capability set, etc. -- exactly the use case the option exists for) gets a capability that:
- shows up correctly in
GET /capability/list
- can be requested and granted correctly via
/agent/request-capability
- but has its
location silently ignored the moment a JWT actually carries it, because verifyAudience()'s expectedLocation param comes back undefined for anything not in the static array
This is a quiet gap, not a loud failure -- the request doesn't error, it just doesn't get the audience/location check it should. For anyone relying on location-scoped capabilities (routing execution to a service-owned REST handler outside the plugin's own onExecute, per the "Default Execute vs Custom Location" docs), this means location enforcement effectively only works for statically-declared capabilities today.
Suggested fix
Make getCapabilityLocation check the static array first (fast path, no dynamic resolution needed for the common case), then fall back to resolveCapabilities if the capability isn't found there -- mirroring the pattern listCapabilities already uses:
async function getCapabilityLocation(opts, capabilityName) {
const staticCap = opts.capabilities?.find((c) => c.name === capabilityName);
if (staticCap) return staticCap.location;
if (opts.resolveCapabilities) {
const resolved = await opts.resolveCapabilities({
capabilities: opts.capabilities ?? [],
query: null,
agentSession: null,
hostSession: null,
});
return resolved.find((c) => c.name === capabilityName)?.location;
}
return void 0;
}
Both call sites are already inside async functions (the before-hook handler and the introspect endpoint handler), so adding await at the call site is a small, contained change:
const expectedLocation = jwtCapabilities.length === 1
? await getCapabilityLocation(opts, jwtCapabilities[0])
: void 0;
I verified this fix against a real deployment (patched via patch-package, 0.6.2) -- our full agent-auth test suite (24 tests, real Postgres, real signed JWTs, no stubs) passes unchanged with the patch applied, confirming it's additive and doesn't change existing static-array-only behavior.
Related
Cross-referencing #33 -- that RFC's design question 2 ("Should verification use an explicit route map, capability location, or both?") is directly adjacent to this: whatever shape that lands in, it's worth knowing location's own existing enforcement has this gap today, independent of that proposal.
Happy to open a PR with this fix plus a regression test if that's useful.
Summary
resolveCapabilities(the documented mechanism for serving a dynamic capability list instead of the staticcapabilitiesarray) is correctly threaded through discovery (/capability/list), registration validation, and capability-request validation -- but not throughlocationenforcement in the JWT audience-verification before-hook. A capability that only exists viaresolveCapabilities(never in the static array) is discoverable and grantable, but itslocationis silently never checked at request time.Where
getCapabilityLocation(),src/...(compiled todist/index.js):Called from exactly two places, both inside the before-hook / introspection JWT-audience-verification path, both passing only the static array:
Every other capability-list consumer I found (
listCapabilities, registration validation, capability-request validation) checksopts.resolveCapabilitiesfirst and falls back to/replaces the static list.getCapabilityLocationnever does.Impact
Anyone using
resolveCapabilitiesto serve capabilities from a dynamic source (a DB-backed catalog, a per-tenant capability set, etc. -- exactly the use case the option exists for) gets a capability that:GET /capability/list/agent/request-capabilitylocationsilently ignored the moment a JWT actually carries it, becauseverifyAudience()'sexpectedLocationparam comes backundefinedfor anything not in the static arrayThis is a quiet gap, not a loud failure -- the request doesn't error, it just doesn't get the audience/location check it should. For anyone relying on
location-scoped capabilities (routing execution to a service-owned REST handler outside the plugin's ownonExecute, per the "Default Execute vs Custom Location" docs), this means location enforcement effectively only works for statically-declared capabilities today.Suggested fix
Make
getCapabilityLocationcheck the static array first (fast path, no dynamic resolution needed for the common case), then fall back toresolveCapabilitiesif the capability isn't found there -- mirroring the patternlistCapabilitiesalready uses:Both call sites are already inside
asyncfunctions (the before-hook handler and the introspect endpoint handler), so addingawaitat the call site is a small, contained change:I verified this fix against a real deployment (patched via
patch-package,0.6.2) -- our fullagent-authtest suite (24 tests, real Postgres, real signed JWTs, no stubs) passes unchanged with the patch applied, confirming it's additive and doesn't change existing static-array-only behavior.Related
Cross-referencing #33 -- that RFC's design question 2 ("Should verification use an explicit route map, capability
location, or both?") is directly adjacent to this: whatever shape that lands in, it's worth knowinglocation's own existing enforcement has this gap today, independent of that proposal.Happy to open a PR with this fix plus a regression test if that's useful.