Summary
Define a kernel-level capability-token mechanism that lets uplinks publish events with kernel-verified principal attribution instead of the current uplink-claimed (unverified) attribution. The kernel mints ed25519-signed tokens binding (principal_id, session_id, expiry) on capsule request; uplinks present those tokens to astridd at connect; astridd verifies the signature and applies principal-attribution::verified(<principal>) to subsequent publish-as calls on that connection.
This closes a forgeability hole that limits what subscribers can trust on the bus today, and provides the cryptographic basis for non-spoofable hook events from native subprocess bridges (e.g. astrid-emit per unicity-astrid/astrid#814).
Motivation
Astrid's current uplink contract (wit/host/[email protected]) marks publish-as events as principal-attribution::claimed(<principal>):
"A claimed principal is uplink-asserted (via publish-as) and the kernel has NOT verified the asserting uplink actually authenticated the named principal. Treat claimed as caller-input, not authenticated context."
This is correct as a security disclaimer but problematic in practice: subscribers that want to act on a principal claim (capability checks, policy gates, per-principal audit aggregation) have to either trust claimed attribution (insecure) or implement their own validation chain (every-subscriber-validates anti-pattern).
The driving concrete case is astrid-emit (per astrid#814) — a native binary exec'd by Claude Code hooks to forward events onto the bus. Any same-OS-user process can connect to astridd as an uplink and claim any principal. The interim mitigation uses sage as a per-session validator (sage mints a secret, the binary forwards it, sage validates against its kernel-namespaced KV) — but this still leaks on Linux because /proc/<pid>/environ is readable by the same OS user, allowing token theft from an active Claude session.
Cryptographic tokens minted and verified by the kernel close the forgeability gap independently of any one capsule's validation logic, and align with Astrid's stated design principle ("Cryptography over prompts. Authorization = ed25519 signatures + capability tokens, not LLM instructions.").
Proposed mechanism
Token shape
HookToken = (principal_id, session_id, issued_at, expiry, nonce, signature)
signature = ed25519_sign(kernel_signing_key, principal_id || session_id || issued_at || expiry || nonce)
principal_id — the principal the token authorizes
session_id — narrows the token to one Astrid session; multiple concurrent sessions per principal get different tokens
issued_at, expiry — bounded validity (proposed: 1h default, configurable per mint request)
nonce — random 16+ bytes; allows token revocation by nonce-blocklist
signature — ed25519 over the canonicalized fields with the kernel's signing key
Token is serialized as base64-url for env-var-safe transport.
Mint surface
New IPC topic on the identity capsule (or kernel-direct, TBD):
request: identity.v1.request.mint_hook_token { principal_id, session_id, ttl_seconds }
response: identity.v1.response.mint_hook_token.<corr_id> { token: string, expires_at: timestamp }
Mint authorization: capsule must hold a new mint_hook_token capability declared in its Capsule.toml. Reserved for trusted-spawner capsules (sage, future codex/gemini providers). Audit trail: every mint published on astrid.v1.audit.hook_token_mint.
Verify surface
New uplink protocol message in wit/host/[email protected]:
present_hook_token: func(token: string) -> result<_, error-code>;
Called once per uplink connection, after connect, before any publish-as. Astridd:
- Decodes the token bytes.
- Verifies the signature with its public key (which is the same key all astridd processes share; kernel infrastructure already manages it).
- Checks
expiry > now.
- Checks the nonce isn't on the revocation blocklist.
- On success: stores the verified
principal_id against this uplink connection. All subsequent publish-as calls on this connection that target this principal are tagged principal-attribution::verified(<principal>). publish-as for a different principal returns capability-denied.
- On failure: the connection's permission set is unchanged (still
claimed-only); calls to publish-as continue to produce claimed attribution as today.
Revocation
Optional but recommended:
identity.v1.request.revoke_hook_token { nonce }
Adds the nonce to astridd's in-memory revocation set; persists across restarts via a small file under ~/.astrid/var/. Capsules revoke their own minted tokens on session end (in addition to letting them expire naturally).
How astrid-emit consumes this (post-RFC)
- Sage's
handle_spawn: publish identity.v1.request.mint_hook_token { principal_id, session_id, ttl: session_lifetime }. Drain the response; capture the token.
- Set
ASTRID_HOOK_TOKEN=<token> in claude's env (replacing the random-secret-into-KV pattern).
astrid-emit reads the env, calls present_hook_token(<token>) on its uplink connection, then publish-as(<topic>, <payload>, <principal_id>).
- Subscribers receive events with
principal-attribution::verified(<principal>). The sage-as-validator pattern becomes optional; subscribers can trust the canonical topic directly.
- Sage's
handle_stop: publish identity.v1.request.revoke_hook_token { nonce } to harden against token reuse after session end.
The sage-as-validator pattern (per astrid#814) remains in place as a fallback during the transition — sage can dual-write to sage.v1.unverified_hook.* and (post-RFC) directly to hook.v1.event.* based on whether tokens are kernel-mintable. Once the RFC ships, the unverified topic can be deprecated.
Residual Linux env-stealability
Token-in-env is still readable on Linux via /proc/<pid>/environ by same-OS-user processes. The kernel-signed token is unforgeable cryptographically but not un-stealable. With a stolen token, an attacker can publish as the principal until the token expires.
Mitigations (in this RFC's scope):
- Short default TTL (1h) caps damage window.
- Revocation on session end kills tokens before natural expiry.
- Per-session token binding — a stolen token is only valid for the session that minted it; killing that session revokes the token automatically (server-side: astridd treats
session_id not in the active set as expired).
Fully closing the env-stealability gap requires fd inheritance — sage opens a pre-authenticated uplink fd and passes it to claude via the spawn API, claude passes it to the hook command, the bridge uses the inherited fd. This is a much larger contract change (the kernel needs an exec API that accepts fd handoff from a capsule context) and is out of this RFC's scope; flagged as a separate future RFC if/when the threat-model demands it.
Acceptance criteria
Alternatives considered
- HMAC instead of ed25519: simpler but ties astridd instances to a shared secret; ed25519 lets the kernel publish its public key for offline verification (e.g. by external audit tooling).
- JWT-style envelopes: heavier wire format, more parsing surface, no advantage over a fixed-shape signed blob.
- Per-principal Unix sockets: kernel creates
~/.astrid/principals/<id>/uplink.sock with the principal binding baked into the socket. Avoids tokens entirely. Trade-off: every connection is bound to one principal, harder to multiplex; doesn't solve fd-inheritance directly.
- Status quo + sage-as-validator only: capsule validation chain (sage-as-CA) works for the immediate hook-events use case but each new validating capsule reinvents the wheel. Kernel-level tokens factor the trust-anchor pattern out once.
Related
unicity-astrid/astrid#814 — astrid-emit binary. Drives the immediate need.
unicity-astrid/wit/host/[email protected] — current uplink contract this RFC extends.
astrid-rfcs/rfc/hook-lifecycle-protocol — in-flight hook taxonomy work. Parallel; this RFC is orthogonal (covers attribution, not naming).
- Sage v1.1+ (
unicity-astrid/sage, commit 4ae16de+) — first consumer of both this RFC and #814.
Summary
Define a kernel-level capability-token mechanism that lets uplinks publish events with kernel-verified principal attribution instead of the current uplink-claimed (unverified) attribution. The kernel mints ed25519-signed tokens binding
(principal_id, session_id, expiry)on capsule request; uplinks present those tokens to astridd at connect; astridd verifies the signature and appliesprincipal-attribution::verified(<principal>)to subsequentpublish-ascalls on that connection.This closes a forgeability hole that limits what subscribers can trust on the bus today, and provides the cryptographic basis for non-spoofable hook events from native subprocess bridges (e.g.
astrid-emitperunicity-astrid/astrid#814).Motivation
Astrid's current uplink contract (
wit/host/[email protected]) markspublish-asevents asprincipal-attribution::claimed(<principal>):This is correct as a security disclaimer but problematic in practice: subscribers that want to act on a principal claim (capability checks, policy gates, per-principal audit aggregation) have to either trust claimed attribution (insecure) or implement their own validation chain (every-subscriber-validates anti-pattern).
The driving concrete case is
astrid-emit(per astrid#814) — a native binary exec'd by Claude Code hooks to forward events onto the bus. Any same-OS-user process can connect to astridd as an uplink and claim any principal. The interim mitigation uses sage as a per-session validator (sage mints a secret, the binary forwards it, sage validates against its kernel-namespaced KV) — but this still leaks on Linux because/proc/<pid>/environis readable by the same OS user, allowing token theft from an active Claude session.Cryptographic tokens minted and verified by the kernel close the forgeability gap independently of any one capsule's validation logic, and align with Astrid's stated design principle ("Cryptography over prompts. Authorization = ed25519 signatures + capability tokens, not LLM instructions.").
Proposed mechanism
Token shape
principal_id— the principal the token authorizessession_id— narrows the token to one Astrid session; multiple concurrent sessions per principal get different tokensissued_at,expiry— bounded validity (proposed: 1h default, configurable per mint request)nonce— random 16+ bytes; allows token revocation by nonce-blocklistsignature— ed25519 over the canonicalized fields with the kernel's signing keyToken is serialized as base64-url for env-var-safe transport.
Mint surface
New IPC topic on the identity capsule (or kernel-direct, TBD):
Mint authorization: capsule must hold a new
mint_hook_tokencapability declared in itsCapsule.toml. Reserved for trusted-spawner capsules (sage, future codex/gemini providers). Audit trail: every mint published onastrid.v1.audit.hook_token_mint.Verify surface
New uplink protocol message in
wit/host/[email protected]:Called once per uplink connection, after
connect, before anypublish-as. Astridd:expiry > now.principal_idagainst this uplink connection. All subsequentpublish-ascalls on this connection that target this principal are taggedprincipal-attribution::verified(<principal>).publish-asfor a different principal returnscapability-denied.claimed-only); calls topublish-ascontinue to produceclaimedattribution as today.Revocation
Optional but recommended:
Adds the nonce to astridd's in-memory revocation set; persists across restarts via a small file under
~/.astrid/var/. Capsules revoke their own minted tokens on session end (in addition to letting them expire naturally).How
astrid-emitconsumes this (post-RFC)handle_spawn: publishidentity.v1.request.mint_hook_token { principal_id, session_id, ttl: session_lifetime }. Drain the response; capture thetoken.ASTRID_HOOK_TOKEN=<token>in claude's env (replacing the random-secret-into-KV pattern).astrid-emitreads the env, callspresent_hook_token(<token>)on its uplink connection, thenpublish-as(<topic>, <payload>, <principal_id>).principal-attribution::verified(<principal>). The sage-as-validator pattern becomes optional; subscribers can trust the canonical topic directly.handle_stop: publishidentity.v1.request.revoke_hook_token { nonce }to harden against token reuse after session end.The sage-as-validator pattern (per astrid#814) remains in place as a fallback during the transition — sage can dual-write to
sage.v1.unverified_hook.*and (post-RFC) directly tohook.v1.event.*based on whether tokens are kernel-mintable. Once the RFC ships, the unverified topic can be deprecated.Residual Linux env-stealability
Token-in-env is still readable on Linux via
/proc/<pid>/environby same-OS-user processes. The kernel-signed token is unforgeable cryptographically but not un-stealable. With a stolen token, an attacker can publish as the principal until the token expires.Mitigations (in this RFC's scope):
session_idnot in the active set as expired).Fully closing the env-stealability gap requires fd inheritance — sage opens a pre-authenticated uplink fd and passes it to claude via the spawn API, claude passes it to the hook command, the bridge uses the inherited fd. This is a much larger contract change (the kernel needs an exec API that accepts fd handoff from a capsule context) and is out of this RFC's scope; flagged as a separate future RFC if/when the threat-model demands it.
Acceptance criteria
present_hook_tokentoastrid:[email protected](per the ABI evolution discipline: ships as a new file at a new version path; existing v1.0.0 unchanged).mint_hook_token = truecapability flag.publish-aswithoutpresent_hook_tokenkeep currentclaimedbehavior (backwards-compatible). New tokens upgrade the connection toverified.Alternatives considered
~/.astrid/principals/<id>/uplink.sockwith the principal binding baked into the socket. Avoids tokens entirely. Trade-off: every connection is bound to one principal, harder to multiplex; doesn't solve fd-inheritance directly.Related
unicity-astrid/astrid#814—astrid-emitbinary. Drives the immediate need.unicity-astrid/wit/host/[email protected]— current uplink contract this RFC extends.astrid-rfcs/rfc/hook-lifecycle-protocol— in-flight hook taxonomy work. Parallel; this RFC is orthogonal (covers attribution, not naming).unicity-astrid/sage, commit4ae16de+) — first consumer of both this RFC and #814.