Skip to content

getCapabilityLocation() ignores resolveCapabilities -- location enforcement silently doesn't apply to dynamically-resolved capabilities #39

Description

@envisean

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions