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
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
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.
# 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.