Agent capability scoping

The per-agent capability registry sits above the tier-based attribution policy. Where the attribution policy asks "is this write attributable at all?", the capability registry asks "is this specific agent allowed to touch this specific entity_type via this operation?".

Capabilities are modelled as first-class agent_grant entities, one per (user, agent identity) pair, managed in the Inspector under Agents → Agent grants. The previous environment-variable registry (NEOTOMA_AGENT_CAPABILITIES_JSON, NEOTOMA_AGENT_CAPABILITIES_FILE, NEOTOMA_AGENT_CAPABILITIES_ENFORCE, config/agent_capabilities.default.json) has been removed.

When does this apply?

  • User-authenticated callers (Bearer / OAuth / local Inspector session), not enforced; full access to their own user_id's data, modulo attribution policy.
  • AAuth-verified agent matched to an active grant , enforced; restricted to declared (op, entity_type) pairs on the grant.
  • AAuth-verified agent with no matching grant , falls through to attribution-only behaviour (no admission, must use Bearer/OAuth).
  • AAuth-verified agent whose sub / iss names a grant that pins no key , not admitted, and capability-gated writes are refused (capability_denied) even when Bearer/OAuth authenticates the request, until the grant is pinned.
  • Anonymous / unverified-client tier , no admission; subject to attribution policy.

The canonical use is pinning the Netlify forwarder (sub: agent-site@neotoma.io) to the neotoma_feedback entity type, so a compromised forwarder key cannot be used to write observations for unrelated entities.

Grant shape

An agent_grant is a normal Neotoma entity, observation history doubles as the audit log. Canonical fields:

{
  "entity_type": "agent_grant",
  "owner_user_id": "usr_…",
  "label": "Cursor on macbook-pro",
  "match_thumbprint": "abcd…",               // RFC 7638 JWK thumbprint; required to admit
  "match_sub": "agent-cursor@example.com",   // AAuth sub claim (descriptive)
  "match_iss": "https://agent.example.com",  // optional; descriptive
  "capabilities": [
    { "op": "store",               "entity_types": ["neotoma_feedback"] },
    { "op": "create_relationship", "entity_types": ["neotoma_feedback"] },
    { "op": "correct",             "entity_types": ["neotoma_feedback"] },
    { "op": "retrieve",            "entity_types": ["neotoma_feedback"] }
  ],
  "status": "active",   // active | suspended | revoked
  "notes": "issued 2026-04",
  "last_used_at": "2026-04-26T09:54:00Z"
}

Identity rule

Admission to a grant requires a key binding. A grant admits a signed request only when its match_thumbprint equals the RFC 7638 thumbprint of the key that signed the request. match_sub / match_iss are descriptive: they are recorded on the grant and shown in Inspector, but do not admit on their own.

A grant without match_thumbprint does not admit signed requests. A request whose sub / iss match such a grant gets admission reason grant_key_unbound, and its capability-gated writes are refused with capability_denied until the grant is pinned, even when Bearer/OAuth authenticates the request. Authentication and capability limits are separate decisions: the signature decides which limits apply, whatever authenticated the request.

Take the thumbprint from the agent's own key material (for example neotoma auth session on the agent's host), not from observed request traffic. A grant may be created with only match_sub while a key is provisioned; it stays inert until match_thumbprint is set, and the create response carries a warnings entry saying so.

Pin a key to an existing grant

There is no dedicated CLI edit subcommand. Use any of these from a user-authenticated session (or an agent holding the bootstrap capability), with the thumbprint that neotoma auth session prints on the agent's host:

  • Inspector: Agents → Agent grants → open the grant → set match_thumbprint → Save.
  • REST: PATCH /agents/grants/<grant_id> with { "match_thumbprint": "<thumbprint>" }.
  • MCP / REST correct: correct (or POST /correct) with { "entity_id": "<grant_id>", "entity_type": "agent_grant", "field": "match_thumbprint", "value": "<thumbprint>", "idempotency_key": "pin-<grant_id>-<thumbprint>" }.
  • CLI: neotoma corrections create <grant_id> --entity-type agent_grant --field-name match_thumbprint --corrected-value <thumbprint>.

Then run neotoma auth session on the agent's host and check admission_reason is admitted. Agents using the jkt_jwt Signature-Key scheme sign with a short-lived key and cannot be pinned; give an agent that needs a grant a long-lived signing key.

Capability ops

  • store, creating / observing entities (write path).
  • create_relationship, creating relationships between entities.
  • correct, correcting / updating existing observations / fields.
  • retrieve, reading entities and observations.

entity_types is a string array of permitted entity types for that op. Use ["*"] to widen to every type, only do this for trusted grants.

Matching order

Admission resolves the verified identity to at most one grant:

  1. The most recently observed active grant whose match_thumbprint equals the signing key's thumbprint wins.
  2. Otherwise, no admission, the request stays attribution-only. The reason is grant_key_unbound when a grant without a thumbprint pin matched sub / iss, and no_match otherwise.

Status lifecycle

active  ⇄  suspended
   │           │
   ▼           ▼
       revoked (terminal in normal flow)
       │
       ▼  restore (within grace window)
     active

Only the user who owns the grant (or an agent the user has authorised with the bootstrap (store | correct, agent_grant) capability) can flip status. Admission caches the resolved grant for a small TTL plus invalidates on observation events, so a revoke propagates to in-flight clients within seconds.

Protected entity types, the trust mechanism

Writes to agent_grant (and any future protected type) are gated by the protected-entity-types guard:

  • User-authenticated callers (Bearer / OAuth / local Inspector session for the same user) pass through.
  • AAuth-admitted callers must hold an explicit capability in their grant for the protected type. The bootstrap capability is { op: "store", entity_types: ["agent_grant"] } (and correct).
  • Anonymous / unverified-client tier writes to protected types are rejected with capability_denied.

This is what lets a user safely delegate grant management to a trusted agent: only that one grant carries the bootstrap capability; every other grant remains locked out of agent_grant writes by the protected-types guard, even if it has otherwise broad capabilities.

Strict-require AAuth for claimed subjects

Set NEOTOMA_STRICT_AAUTH_SUBS to a comma-separated list of agent subjects that MUST present a valid AAuth signature whenever the request claims that identity via the X-Agent-Label header. This is a second line of defence against a compromised tunnel / edge:

  • X-Agent-Label: agent-site@neotoma.io + missing signature → 401.
  • X-Agent-Label: agent-site@neotoma.io + signature verified, but the signing key is not pinned by an active grant whose match_sub is that label → 401. The sub inside the agent token is not consulted.
  • Any label NOT listed in NEOTOMA_STRICT_AAUTH_SUBS behaves as before (best-effort attribution hint).

Error surface

A denial produces HTTP 403 with:

{
  "error": {
    "code": "capability_denied",
    "message": "Agent \"agent-site@neotoma.io\" is not permitted to store entity_type \"person\".",
    "op": "store",
    "entity_type": "person",
    "agent_label": "agent-site@neotoma.io",
    "hint": "Agent \"agent-site@neotoma.io\" holds an active grant but no \"store\" capability for entity_type \"person\". Edit the grant in Inspector → Agents → Agent grants if intentional."
  }
}

Operator runbook

Upgrading from the env-config era

The previous release loaded capabilities from NEOTOMA_AGENT_CAPABILITIES_JSON / _FILE / config/agent_capabilities.default.json. After upgrading, starting the server with any of those variables set fails fast with a structured error linking to the import command.

Migrate once, per deployment:

neotoma agents grants import --owner-user-id <usr_…> \
  [--file path/to/agent_capabilities.json]
  • --owner-user-id decides which user account owns the imported operational grants. Pick the operator's own user account, or a dedicated account for infrastructure agents.
  • The command is idempotent on (match_sub, match_iss, match_thumbprint), re-running it after a partial migration upserts grants without duplicating.
  • Each created/updated grant is stamped with provenance import_source: "env_config" so the audit timeline clearly records the migration origin.
  • Once the import succeeds, unset the legacy variables and redeploy.

Grant a new scope

  1. In Inspector, go to Agents → Agent grants → New grant.
  2. Paste the agent's key thumbprint (required for admission), its AAuth sub / iss, and a readable label.
  3. Select capabilities by (op, entity_type).
  4. Save. Admission picks up the new grant within the cache TTL.

Revoke or suspend a scope

  1. Open the grant in Inspector → Agents → Agent grants → :id.
  2. Click Suspend (reversible) or Revoke (terminal).
  3. The next request from that agent reverts to attribution-only after the admission cache TTL.

Roll back a botched grant edit

Grant edits are observations, open the grant detail view and use the audit timeline to see what changed. Apply a correct to restore the prior values (or use the Restore action to roll back a recent revoke within the grace window).

Back to AAuth overview. See also integration, AAuth spec, attestation, CLI keys.