Developer guide

Crux Daemon · 3. Capabilities and grants

Crux Daemon developer guide

3. Capabilities and grants

Two grant models exist in this repo. They share vocabulary, they do not share storage, code, or enforcement. Getting them confused is the single most common way to build something that silently does nothing.

3.1 The 14 capabilities

ALLOWED_CAPABILITIES is a fixed array (crates/crux-integrations/src/lib.rs:40). Anything else in a manifest's capabilities[] fails validation with unknown capability '<c>'.

CapabilityMeaning as declaredRisk contribution
integrations:readRead the integrations librarylow
integrations:installInstall packsmedium (lib.rs:1470)
integrations:grantIssue grantsmedium
integrations:disableRevoke grantslow
facts:readRead factslow
facts:writeWrite factsmedium (ends :write)
facts:private:readRead private factshigh (lib.rs:1461) via data_access.private_facts
sessions:readRead sessionslow
sessions:writeWrite sessionsmedium
passport:readRead passport identitylow
tenant:metadata:readRead tenant metadatalow
tenant:chunks:readRead tenant chunk-level datalow
tenant:content:previewPreview stored contenthigh via data_access.content_preview
admin:readAdministrative readshigh (lib.rs:1463)

Six of these are classed dangerous by the community publishing rail and require a maintainer-signed review.json alongside the manifest: admin:read, facts:private:read, integrations:grant, integrations:install, sessions:write, tenant:content:preview (tests/community_packs.rs:13, integrations/README.md:22).

Read the list back from a live daemon rather than hard-coding it:

curl -s http://127.0.0.1:14800/v1/console/integrations \
  -H "Authorization: Bearer $CRUX_AGENT_TOKEN" | jq .allowed_capabilities

The field is emitted by the console integrations handler (console.rs:2201).

Capabilities are not HTTP scopes

The 14 strings above are a manifest vocabulary. They constrain what an operator may grant a pack. They are not checked by require_http_scopes, and holding facts:write as a manifest capability grants you nothing at the HTTP layer. Several strings appear in both namespaces (facts:write, admin:read, integrations:install); that is a naming coincidence with two enforcement points, not one shared check.

3.2 Model A — pack grants (flat files)

Used by: declarative packs and the file_watcher runtime. Storage: <data_dir>/integrations/grants/<passport_fpr>/<pack_id>.json. Type: IntegrationGrant (lib.rs:335).

{
  "passport_fpr": "p_agent",
  "pack_id": "vault.markdown-watcher",
  "version": "0.1.0",
  "capabilities": ["facts:read", "facts:write"],
  "enabled": true,
  "granted_by_passport_fpr": "p_operator",
  "granted_at_unix_ms": 1753440000000,
  "disabled_at_unix_ms": null,
  "reason": "vault ingest"
}

Rules enforced by grant_pack (lib.rs:963):

  • The pack must be installed on disk, else pack '<id>' version '<v>' is not installed.
  • Every capability must be in the global allowlist, else unknown capability '<c>'.
  • Every capability must also be declared by the manifest, else capability '<c>' is not declared by pack '<id>'.
  • A capability containing / or \ is rejected (lib.rs:1383).
  • The list is sorted and deduped before writing.

Revocation (disable_pack, lib.rs:1011) sets enabled: false and stamps disabled_at_unix_ms. The file stays, so the history is legible.

What consumes a pack grant

Exactly one runtime does today: enabled_packs_of_kind (lib.rs:1070). It scans grants across all passports, because a background daemon job has no calling passport — the operator's intent is expressed by any enabled grant on the node. Its robustness rules matter if you build on it:

  • Missing integrations root, missing grants directory, or zero grants all return an empty vector, never an error.
  • A grant file that fails to parse is skipped, not fatal — one corrupt file cannot wedge an unrelated runtime.
  • A first-party builtin granted without an on-disk manifest copy still counts; the compiled-in manifest is used.
  • Results are deduped by (pack_id, version) across passports (lib.rs:1109).

The declarative kinds (mcp_config, http_recipe, sdk_recipe, cli_recipe, webhook_adapter) have no consumer inside the daemon. Their grants are an operator record and a signal to client-side tooling.

3.3 Model B — extension grants (facts)

Used by: external_tool and wasm extensions, and the MCP tool catalogue. Storage: fact __extension_grant__::<extension_id>::<passport_fpr> key record. Type: ExtensionGrant (extension_grants.rs:62).

{
  "extension_id": "ext.example.quote",
  "passport_fpr": "p_alice",
  "allowed_tool_names": ["ext.example.quote.daily"],
  "allowed_prefixes_read": ["personal::quotes::"],
  "allowed_prefixes_write": ["personal::quotes::"],
  "rate_limit_per_min": 30,
  "granted_at_unix_ms": 1753440000000,
  "granted_by_passport": "agent-claude"
}
FieldSemantics
allowed_tool_namesSubset of the manifest's tools. Empty means every tool the manifest declares (extension_grants.rs:65)
allowed_prefixes_readFact-entity prefixes the extension may read (WASM host ABI only)
allowed_prefixes_writeFact-entity prefixes whose writes the daemon will persist. Empty means no write is accepted — the filter is any(prefix) over an empty list (extension_outbound.rs:314)
rate_limit_per_minPer-(extension, passport) cap. null falls back to the daemon default of 10/min (extension_outbound.rs:50)

Note the asymmetry: an empty allowed_tool_names is permissive, an empty allowed_prefixes_write is restrictive. This is intentional — tools are already bounded by the signed manifest; fact prefixes are not.

Privacy-gated prefixes can never be granted

is_prefix_grantable (extension_grants.rs:92) rejects any read or write prefix starting with one of the daemon's reserved, born-private prefixes. Attempting it fails at issue time with invalid prefix '<p>': community extensions cannot grant access to a privacy-gated prefix.

The reserved list (verbatim, extension_grants.rs:95):

__ax__::            __ax_session::       __constraints__::    __project_layer__::
__plane__::         __plane_layer__::    __workspace__::      __workspace_scan__::
__repo_registry__:: __repo_scan__::      __repo_codegraph_ids__::  __repo_extdeps__::
__storybook__::     __dossier__::        __project_repo_link__::   __extension__::
__extension_grant__:: __work__::         __work_transition__::     __passport__::
__mint_request__::  __bootstrap__::      __project__::        decisions::
github::

Even if a grant somehow named one, the privacy gate runs again at storage time on every accepted write (http/extensions.rs:827, wasm_dispatcher.rs:198).

Why facts and not capability tokens

The design note is in the module header (extension_grants.rs:14): tokens travel between agents, so a grant inside a bearer token would let any agent re-mint it with a different scope. Grants are operator-managed central state; issuance and revocation must be an operator authority, not a bearer property.

3.4 Managing extension grants over HTTP

# List grants for one extension.
curl -s http://127.0.0.1:14800/v1/extensions/ext.example.quote/grants \
  -H "Authorization: Bearer $CRUX_AGENT_TOKEN"

# Issue a grant.
curl -s -X POST http://127.0.0.1:14800/v1/extensions/ext.example.quote/grants \
  -H "Authorization: Bearer $CRUX_AGENT_TOKEN" \
  -H "X-Corecrux-Passport-Id: p_operator" \
  -H 'Content-Type: application/json' \
  -d '{
        "passport_fpr": "p_alice",
        "allowed_tool_names": ["ext.example.quote.daily"],
        "allowed_prefixes_read": ["personal::quotes::"],
        "allowed_prefixes_write": ["personal::quotes::"],
        "rate_limit_per_min": 30
      }'

# Revoke.
curl -s -X DELETE http://127.0.0.1:14800/v1/extensions/ext.example.quote/grants/p_alice \
  -H "Authorization: Bearer $CRUX_AGENT_TOKEN"
RouteMethodScopesHandler
/v1/extensions/{id}/grantsGETadmin:readextensions.rs:587
/v1/extensions/{id}/grantsPOSTadmin:read + facts:writeextensions.rs:610
/v1/extensions/{id}/grants/{passport_fpr}DELETEadmin:read + facts:writeextensions.rs:837

Behaviours worth knowing:

  • Issuing a grant for an extension that is not installed returns 404 extension '<id>' not installed — the HTTP layer checks before delegating, to give a more specific status than the domain error would (extensions.rs:624).
  • A duplicate grant returns 409 grant already exists; revoke first to replace (extensions.rs:649). There is no update-in-place; revoke, then re-issue.
  • The acting passport comes from X-Corecrux-Passport-Id and is recorded as granted_by_passport and in the audit event (extensions.rs:619).

3.5 Operator posture — what an operator should actually check

Before granting anything, an operator has four honest signals:

SignalWhere it comes from
trust_tierResolved against the local keyring at install (chapter 4)
risk_levelDerived from capabilities and data_access (lib.rs:1457)
Declared capabilities[]The manifest, covered by the signature
network.allowed_hostsThe manifest, covered by the signature, enforced at dispatch

And two switches to fall back on:

  • CORECRUXD_INTEGRATIONS_SAFE_MODE=1 blocks pack install and grant, and reports every non-first-party enabled pack as blocked (console.rs:3211).
  • CORECRUXD_INTEGRATIONS_ENABLED=0 turns the pack plane off entirely (config.rs:1377).

Neither switch affects the /v1/extensions/* family. Extensions are gated by signature, keyring, and grant.

3.6 Audit

Both models append to the same <data_dir>/integrations/audit.jsonl. Action constants (lib.rs:401):

ActionEmitted by
install / grant / disablePack model (lib.rs:939, lib.rs:994, lib.rs:1034)
extension_install / extension_uninstallextension_registry.rs:179
extension_grant_added / extension_grant_removedextension_grants.rs:207
trusted_key_added / trusted_key_removedhttp/extensions.rs:552
extension_invoke_ok / extension_invoke_rejectedextension_outbound.rs:553
audit_suppressedRate-limit marker, see chapter 5

The last 50 events are returned inline by GET /v1/console/integrations as audit_tail (lib.rs:890).

Ground truth