The reviewer that took longest found the most, including one bug that lies
to the user about a security-relevant outcome.
- `agent stop` never sent the capability and never checked the reply. The
daemon refused the shutdown, the CLI printed {"agent":"stopped"} anyway,
and the seed stayed in memory and still served owner-mnemonic. The command
documents itself as wiping held seeds. It now authenticates and reports
what actually happened.
- The owner capability lived at <appDir>/agent.sock.cap -- inside the very
directory a scoped agent must be given to find fingerprints.json and the
socket. A token holder could `unset SEEDPASS_TOKEN` and the CLI would read
the capability on its behalf. It now lives under XDG_RUNTIME_DIR in a 0700
parent, created with wx so a pre-planted file is never adopted. This raises
the bar; it does not make a token a sandbox, and capabilities.ts no longer
claims otherwise.
- Redaction was a three-key denylist over deliberately loose schemas, so
`custom_fields` -- including values Python masks as is_hidden -- went to
any read-scoped token verbatim. It is now an allowlist that recurses into
custom_fields, reporting labels and presence but never values.
- Authorization ran after the vault was decrypted and after the entry
existence check, giving an unauthenticated caller an entry-id enumeration
oracle, no audit record for the misses, and a full PBKDF2 unlock per probe.
Tokens are now checked first, denials are indistinguishable for missing
and forbidden entries, and every denial is audited.
- The audit chain had no anchor: deleting the log, or a suffix of it, left
something that verified perfectly. A signed head now pins the record count
and tail signature. Separately, `loaded` was latched before the read that
could throw, so one transient EACCES made every later append sign from
genesis and broke the chain permanently.
- The exec allowlist -- documented as the only real containment for a `use`
grant -- was bypassable via the clipboard (a session-wide channel) and had
no CLI flag at all, so no shipped token could carry one. Both fixed.
- Unauthenticated connections could buffer without bound, growing to
gigabytes while each chunk rescanned the accumulated string. Line size and
concurrent connections are now capped.
- A token-supplied TOTP timestamp was unbounded, so one use minted codes
valid decades ahead, outliving the token's TTL. Clamped to a small window.
- Prototype keys ("__proto__", "constructor") resolved to inherited
properties, passed constraint checks against undefined values and burned a
use; now an own-property check. The socket was created with the umask mode
and chmod'd afterwards, leaving a connectable window; it is now created
under a restrictive umask. TTL was enforced only when a request arrived, so
an idle agent held the seed indefinitely past expiry; there is now a timer.
220 core + 78 CLI tests green; 36/36 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
6.0 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 beside the socket |
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 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.