It moved out of the app directory in the previous commit; the principals table still described the old location. Co-Authored-By: Claude Fable 5 <[email protected]>
6.3 KiB
Agent Security Model
What the SeedPass session agent protects, what it does not, and why. Written so a reviewer does not have to infer the intended boundary from the code.
Scope: js/packages/cli/src/agent.ts and the token surface it serves. The
vault formats and derivations are covered by
docs/typescript_port_compatibility_matrix.md.
The problem
A stateless CLI must hold an unlocked seed somewhere to be usable, and an AI agent or automation that drives it should be able to use a credential without being handed the vault. Those are two different principals sharing one machine.
Principals
| Principal | Proves itself with | Gets |
|---|---|---|
| Owner | The capability secret in a 0600 file under XDG_RUNTIME_DIR (deliberately not in the app directory) |
Everything: unlock/lock, the parent seed, token issuance, status, shutdown |
| Token holder | A bearer token issued by the owner | Only what the token's scopes and constraints allow, enforced by the daemon |
| Anyone else | Nothing | Nothing but a liveness ping |
Enforcement lives in the daemon, never in the CLI. The CLI is untrusted from the daemon's point of view: any local process can speak the socket protocol, so a check that exists only in the client is not a check at all. (This was a real defect, found in review: the socket accepted privileged operations from any local process because the restrictions lived in the CLI.)
What the capability does and does not do
The socket is 0600, which proves only "a process running as this user".
That does not distinguish the owner's CLI from a scoped agent that was
handed a token — both run as you. The capability file raises the bar to "can
read the owner's files", which is the same bar as the vault itself.
It is stored outside the app directory on purpose. A scoped agent has to be given that directory to find the socket and the profile registry, so a capability kept there would ship the escape hatch with the keys — a reviewer demonstrated exactly that escalation.
It does not defend against a same-uid attacker. Anyone who can read that
file, read the vault, or ptrace the daemon has already won. Defending
against that needs an OS-level boundary (a separate user, a container, a
hardware token) that a user-space daemon cannot provide. If your threat
model includes hostile code running as you, this design does not save you.
Token scopes
read— redacted entry metadata only. The daemon filters to entries the token's kind and label constraints cover, so out-of-scope entries are not disclosed at all, and secret-bearing fields are replaced withhas_*flags. A read token never receives the decrypted index.use— the daemon delivers a secret to a sink (clipboard, a child process's environment, a child's stdin) and returns a receipt. The plaintext does not cross the socket back to the holder.reveal— plaintext egress. Distinct fromuseon purpose.
Constraints: entry kinds, a label regex, a TTL, a use count, and an optional
exec allowlist. Denials do not consume uses. Tokens die with a vault lock,
a TTL expiry, or a revoke, and do not come back on the next unlock.
The honest limit of use
A use holder that chooses the sink command can read the secret from inside
that command. --exec 'sh -c "cat > /tmp/stolen"' is a legitimate use as
far as the daemon is concerned.
So use is not "can act without learning". It is:
- containment, when the token carries an exec allowlist — then the holder can only hand the secret to commands you named;
- accountability, always — the audit log records the exact argv that received each secret.
If you need a hard guarantee that an agent cannot learn a credential, do not
hand it a use token for that credential. Have it call something that holds
the credential itself.
Audit log
Append-only, HMAC-chained (sig = HMAC(key_index, prev_sig || canonical payload)), keyed by the profile's index key, so only the seed holder can
extend or verify it. A signed head file records the expected record count
and last signature — without it, deleting the log or a suffix of it leaves a
shorter file that still verifies perfectly. agent audit-verify walks the chain and fails at the
first break. Writes are serialized so concurrent requests cannot interleave
and desynchronize it. Recorded: unlocks, token lifecycle, index reads,
deliveries (with the sink and argv), and every denial with its reason.
Secret values are never recorded.
Known limits, stated rather than hidden
- Same-uid attackers win — see above. Concretely: a scoped agent is
given the app directory, so the capability is deliberately stored
outside it (in
XDG_RUNTIME_DIR), but a token holder that can read that file can still unset its token and act as the owner. Treat a token as a convenience and an audit trail, not as a sandbox. Real containment needs a separate uid or a container. usecan be turned intorevealby a holder that picks the command, unless an exec allowlist is set.- Memory is not scrubbed. Seeds live in the daemon's heap while unlocked; JavaScript gives no reliable zeroization. Lock when you are done — that drops the seed, its tokens, and the derived audit key.
- The capability file lives on disk for the daemon's lifetime, so it survives a crash of the CLI but also a crash of the daemon; a stale file is replaced on the next start.
- Two protocol-level issues are shared with the Python implementation and cannot be fixed in one implementation without breaking convergence: tombstone retention allows deletion replay once the cap is exceeded, and SSH and PGP entries at the same index derive the same Ed25519 key. Both need a versioned change in both implementations; both are tracked in the compatibility matrix.
Verifying these claims
js/packages/cli/test/agentSecurity.test.ts speaks the wire protocol
directly, bypassing the CLI, and asserts: no seed retrieval, token minting,
lock, shutdown, status or seed injection without the capability; no
decrypted index for a read token; no plaintext for a use token; no owner ops
via a token; no SEEDPASS_* inheritance in a sink child; correct rejection
of non-string credentials; and that tokens do not survive a lock.
If you change the daemon, add the attack to that file first and watch it fail before you make it pass.