Why your agent runtime should not hold your operator key

When you create an agent in STACK, your agent gets its own cryptographic identity -- an Ed25519 keypair generated locally on the machine where the SDK runs. The private half lives on disk under ~/.stack/agents/<agent_id>.json with mode 0600. The public half is registered with STACK at enrollment time. Every API call from your agent runtime is signed with the private key, time-bound to 60 seconds, and audience-bound to stack:agent.

Your agent runtime never holds your operator key. It holds only the keypair for that one agent, and it acts within that agent's allowed_skills and allowed_connections. Compromising the agent runtime gives the attacker that one agent's narrow scope -- not your operator key, not your billing settings, not your team management, not any other agent.

How the SDK enrolls an agent

typescript
import { Stack } from '@getstackrun/sdk';

// First time, on your dev machine where you've already run `stack-cli auth login`:
const stack = new Stack({ agent_id: 'agt_xxx' });

// 1. SDK reads ~/.stack/agents/agt_xxx.json -- not found.
// 2. Authenticates as you (using ~/.stack/credentials.json or
//    STACK_API_KEY env var) to:
//      POST /v1/agents/agt_xxx/enrollment-challenge
//      ~> { challenge_id, challenge }
// 3. Generates an Ed25519 keypair locally.
// 4. Signs the challenge bytes with the new private key.
// 5. POST /v1/agents/agt_xxx/enroll
//      { public_key (JWK), challenge_id, signed_challenge }
// 6. Server verifies the signature against the submitted public_key
//    (proof-of-possession), stores the public_key on the agents row.
// 7. SDK persists the private key to disk.

await stack.passports.issue({ agent_id: 'agt_xxx', services: ['github'] });
// Every subsequent call signs a fresh 60-second JWT with the private key.

What each request looks like over the wire

text
Authorization: Bearer <jwt>

JWT header:  { alg: 'EdDSA' }
JWT claims:  {
  iss:   'stack-sdk',
  sub:   'agt_xxx',
  aud:   'stack:agent',
  iat:   1714850000,
  nbf:   1714850000,
  exp:   1714850060,
  jti:   'aj_1714850000_a3f9c2'
}
JWT signed by the agent's local private key.

This is request signing, not bearer-token replay. Each request is independently signed, time-bound, and audience-bound. Capturing a JWT in flight gives you 60 seconds of replay window before it expires AND before our middleware's replay-cache rejects the second presentation. Stealing a previous request's Authorization header gives you nothing.

Replay protection

The API caches every jti it accepts in Redis with a TTL of twice the JWT max. The second presentation of the same jti returns 401 Unauthorized. There is no opt-out.

Rotating the keypair

If you suspect a compromise, rotate the keypair. POST to /v1/agents/<id>/enroll?force=true (admin role required). This:

  • Cascade-revokes every live passport for the agent (the previous privkey holder loses access immediately).
  • Overwrites the stored pubkey with the new one.
  • Audits an agent.enroll.rotate row in your chain.
bash
# CLI helper coming in 0.5.0; for now:
curl -X POST -H "Authorization: Bearer sk_live_..." \
  "https://api.getstack.run/v1/agents/agt_xxx/enrollment-challenge"
# ... sign the challenge in your tool of choice ...
curl -X POST -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"public_key":{...},"challenge_id":"enr_...","signed_challenge":"..."}' \
  "https://api.getstack.run/v1/agents/agt_xxx/enroll?force=true"

Member-role gate

Enrolling an agent requires write access to that agent. Readonly team members cannot enroll. Standard and admin members can. Re-enrollment with ?force=true requires admin role specifically -- without that gate, an attacker who phished a standard member could overwrite the stored pubkey with their own and silently take over the agent.

Threat model

  • Process memory dump on the agent runtime: leaks one agent’s privkey only. Operator key not present.
  • Stolen laptop, decrypted: leaks one agent’s privkey + the developer’s OAuth refresh token. Bounded by per-agent scope. Phase 3 (passkey-rooted) closes the laptop-decryption gap.
  • Cross-agent contagion: keypair is per-agent. Compromising one does not compromise siblings.
  • Token replay: 60-second TTL + Redis replay-cache. Stolen Authorization headers expire fast.
  • Stolen enrollment challenge: bound to one agent + one operator + single-use. Cross-operator replay refused + recorded as security event.

This mirrors what AWS does with IAM Roles for Service Accounts versus raw access keys: the runtime credential is bounded to what the runtime needs, and the developer credential never leaves the developer's machine.

stack | docs