Files
SeedPass/docs/agent_security_model.md
T
thePR0M3TH3ANandClaude Fable 5 3a50f57b66 docs: correct the capability location in the security model
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]>
2026-08-14 23:59:11 -04:00

127 lines
6.3 KiB
Markdown

# 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 with `has_*`
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 from `use` on 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
1. **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.
2. **`use` can be turned into `reveal`** by a holder that picks the command,
unless an exec allowlist is set.
3. **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.
4. **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.
5. **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.