Commit Graph
8 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 5 62e577a6e1 Collapse the duplicated high-risk expiry rule; close agent mutation gaps
Mutation testing carried agent.ts to zero survivors (37 mutants, all killed
or non-compiling). Four gaps, in descending order of how much they mattered:

The high-risk session's expiry was decided in two places -- the sweep in
expire() and a second comparison inside highRiskTag(). Breaking EITHER left
the whole suite green, because each covered for the other. Worse, the copy in
highRiskTag() was unreachable: handle() sweeps before it dispatches, so no
request could ever arrive with an expired session still in the map. It read
like a check and enforced nothing. highRiskTag() now sweeps instead of
re-testing expiresAt, so the rule has one home and a test can hold it to
account.

The remaining three were missing assertions, not missing checks:

- `put`'s `!fingerprint || !mnemonic` guard could become `&&` unnoticed. Both
  fields are re-validated downstream, so nothing is permitted that was not
  before -- but a blank mnemonic then reports "fingerprint does not match the
  supplied seed", sending whoever reads it after the wrong field. Tests now
  pin the message.
- Same shape on high-risk-unlock's fingerprint format guard: disabling it
  entirely still refused, via "profile not unlocked" -- a lie about a string
  that could never be a profile. It is also the guard between a
  caller-supplied string and the audit-log path join, so it should be the one
  that answers.
- highRiskStatus's client-side `expires_at === null` decode was never
  asserted; inverted, it reports 0 for a locked profile and null for a live
  one. That number is how a caller knows what is left of the second factor.

Also hoists FROZEN/TAG/daemonAt to module scope so the new tests share the
injectable clock.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 16:49:33 -04:00
thePR0M3TH3ANandClaude Opus 5 17b94d3e26 fix(agent): an empty exec allowlist now fails closed; test the allowlist at all
Mutation slice 2 scored 0% before this, and the reason was blunt: **the exec
allowlist had no test whatsoever.** The clipboard restriction could be
inverted, weakened, or deleted outright and the suite stayed green.

That control matters because the clipboard is readable by every process in the
session. A token restricted to `cat` that may still reach the clipboard is not
restricted to anything — the holder simply copies the secret out, and the
allowlist looks enforced while containing nothing. Now tested: a
command-restricted token is refused the clipboard, a token with no allowlist
is not (so the rule is conditional rather than a blanket ban passing as one),
an allowlisted command is permitted, and one outside the list is refused
without the sink ever running.

**A fail-open found while triaging one of the survivors.** An empty
`exec_allowlist` was DROPPED at issue time, which made the token unrestricted.
Automation that computes an allowlist and arrives at zero entries means
"permit nothing" and was silently handed "permit everything" — a fail-open in
the one field whose entire purpose is restriction. It is now refused at issue
time with an error saying which of the two readings the caller probably
wanted. Reachable only over the raw socket protocol, since commander requires
at least one value.

**An audit-accuracy gap.** A denial and a missing entry deliberately return
the SAME message, so the response cannot be used to enumerate ids — the audit
log is where the difference is supposed to be recorded, and nothing read it.
The two reasons could be swapped with every test passing. Testing it needed
two corrections of my own: the CLI resolves references locally so the request
never reaches the daemon, and the shared token in that file is revoked by an
earlier test, so both calls were refused at the token check instead. It also
needed a stronger assertion than the one I first wrote — checking that both
reasons merely APPEAR passes when they are swapped, which is exactly the
mutation. It now asserts the pairing: which id got which reason.

Three survivors are recorded as genuinely unkillable with reasons, rather than
chased: the two `length > 0` comparisons cannot differ from `>= 0` now that an
empty allowlist cannot be stored, and the capability early-return is redundant
with the length check below it.

agent slices 1-2 (mutants 1-16): 100% of viable mutants caught.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 16:07:25 -04:00
thePR0M3TH3ANandClaude Opus 5 12b945ca7e test(agent): pin token expiry at its exact boundary; give the daemon a clock
Mutation testing found `token.expires_at <= now` could become `<` with every
test still passing. The suite checked well before and well after expiry, which
cannot tell the two apart — so a token was free to be usable for one more
request than it claimed, which is the wrong direction for a credential.

The daemon now takes an injectable clock, so the boundary can be asserted
exactly rather than raced against wall time: at T-1 the token works, at
exactly T it is refused. Every expiry decision AND every expiry assignment
goes through that clock — a token issued against real time while checked
against test time would never expire, which would have made the test
meaningless. The TOTP timestamp-skew window uses it too, since a clock that
moved one but not the other is a difference no production path should have
either.

Also pins the owner capability: an empty or absent capability is refused, and
so is one of the right length but wrong content — the latter proving the
constant-time comparison is what rejects it rather than the length check.

One survivor is now recorded as genuinely unkillable rather than chased.
`!presented || !this.capability` cannot change behaviour when flipped to `&&`,
because the length comparison below rejects an empty string either way. The
harness learned a `mutation-equivalent:` marker that must carry a REASON, so
future survivor lists mean "look at this" instead of "this again" — and the
judgement stays re-checkable rather than merely trusted.

The harness also gained `--offset`, so a long target is measured in
foreground-sized slices. That is a safety property, not a convenience: this
script writes broken security code into the working tree, and it must never
run unattended alongside commits again.

agent slice 1 (mutants 1-6): 100% of viable mutants caught.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 15:47:12 -04:00
thePR0M3TH3ANandClaude Opus 5 8653ee8006 fix(agent): validate put at the daemon boundary; state the two scope semantics (L-5, L-6, L-7)
L-7 — `put` is the only way a fingerprint enters `held`, and every later
handler joins that string onto a path (the vault it opens, the audit log it
writes). The CLI validated the format first, which is exactly why the daemon
could not rely on it: agent.ts's own header says the CLI is untrusted, and the
browser extension speaks this protocol next. `put` now requires 16 uppercase
hex, and additionally that the fingerprint actually belongs to the supplied
seed — a mismatch would unlock a vault the caller did not name and file its
audit records under the wrong profile.

Validation order matters and is now deliberate: cheap format check, then ttl,
then the seed match last, because deriving a fingerprint runs the BIP-39 KDF
and a malformed ttl should not pay for it. This keeps the existing
non-numeric-ttl test exercising the ttl path rather than short-circuiting on
the new check.

L-5 — `label_regex` is unanchored, so a token issued for `prod` also reaches
`not-prod-db`. Left as-is on purpose: Python matches with `re.search`, so
anchoring the TypeScript side alone would make one token mean two different
things depending on which implementation holds it, and would silently narrow
every token already issued. The audit asked for whichever behavior Python has,
documented and in the parity matrix — so the semantics are now stated in
--label-regex help, reported machine-readably as
capabilities().tokens.label_regex_semantics, explained at the enforcement
point, recorded in the compatibility matrix, and pinned by tests that assert
BOTH labels are visible to an unanchored token.

L-6 — the exec allowlist constrains the command word, never its arguments.
The token holder writes the argv and the child gets the secret in its
environment, so an allowlisted binary with an output-file or network flag
hands the secret straight back. Documented in the same three places rather
than changed: argument-level containment means allowlisting full argv
templates instead of command words, which is a token-format change that has to
land in both implementations together.

Tests: malformed and mismatched fingerprints refused with nothing held (path
traversal, wrong case, wrong length, trailing component); unanchored vs
anchored label_regex; capabilities reporting both semantics. Mutation-verified
— disabling either put guard turns the matching tests red.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-18 21:54:21 -04:00
thePR0M3TH3ANandClaude Opus 5 7f6edf10cc fix(agent): close two ways a local caller could subvert the session daemon
Both were found by the 2026-08-17 self-review and confirmed by speaking to
the socket directly. The daemon is the enforcement point for the whole token
model and treats every caller as untrusted, so neither fix leans on the CLI's
own validation.

1. A `put` reported as FAILED could leave the seed resident and unexpiring.
   State was committed to `held` before the audit append was awaited. A
   non-numeric ttl made `expiresAt` NaN; the audit write then threw on the
   non-finite value, so the caller saw ok:false while the seed stayed in
   memory forever (`NaN <= now` is always false) with no record it was ever
   unlocked. Now ttl is validated as a positive integer at the boundary, and
   an audit failure rolls the seed back out — ok:false means genuinely locked.

2. A `use`-scoped token could crash the whole agent. `stdinSink` wrote to the
   child's stdin with no error listener, so a command that exits before
   draining raised an unhandled EPIPE; the agent runs sinks in-process and
   installs no uncaughtException handler, so it exited and dropped every held
   seed. The least-privileged principal could take down the enforcement point
   by naming a command that exits early. stdin errors are now swallowed and
   the child's exit code carries the result, as feedClipboardTool already did.

Also hardens token-issue: scopes/kinds are shape-checked before the array
methods run against them (a bare-string kinds turned an exact match into a
substring match), and ttl/uses go through the same positive-integer boundary
so a token can no longer be minted with a NaN, never-expiring lifetime.

Three regression tests speak the wire protocol directly. Verified as negative
controls: with the fixes stashed, the stdin test reproduces the EPIPE uncaught
exception at sinks.ts:143 and two of the three fail; with them restored all
131 CLI tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
2026-08-17 22:15:52 -04:00
thePR0M3TH3ANandClaude Fable 5 dafae50ca4 security: fix agent-boundary findings from the fourth reviewer
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]>
2026-08-14 23:58:58 -04:00
thePR0M3TH3ANandClaude Fable 5 6b862baa71 security: reject non-string credentials on the agent protocol
Found by running attacks against the socket rather than reading the code:
a token supplied as a single-element array was accepted, because
String(["abc"]) is "abc" and the comparison went through String() coercion.

No privilege was gained -- the attacker still needed the real token -- but
type coercion on a credential path is the kind of sloppiness that becomes a
bug the moment the surrounding code changes. The handler now requires op,
cap, token, fingerprint, id and sink to be strings, and command to be an
array of strings, rejecting anything else outright.

The same run confirmed the rest of the boundary behaves: by-id access to
entries excluded by a token's label regex or kind constraint is refused
server-side with the correct reason, reveal without the reveal scope is
refused, denials do not consume uses, concurrent requests cannot overrun a
one-use token, and a sink child spawned by the agent sees exactly one
SEEDPASS_ variable -- the secret it was given.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 23:24:08 -04:00
thePR0M3TH3ANandClaude Fable 5 9926b48e16 security: fix agent trust boundary and secret-environment leaks
A security review found the agent's authorization model was enforced in the
client, not the server: any local process that could open the 0600 socket
could retrieve the parent seed, mint tokens, unlock/lock, or shut the agent
down, because the daemon accepted privileged operations unauthenticated. It
also found that scoped tokens received far more than their grant, and that
sink children inherited the caller's entire environment.

Server-side authorization:
- Privileged operations (put, owner-mnemonic, token issue/list/revoke, lock,
  status, shutdown) now require a capability secret written to a 0600 file
  beside the socket at startup, compared in constant time. Socket permissions
  alone cannot distinguish the owner's CLI from a scoped agent holding a
  token. The residual risk is documented in the code: a same-uid attacker who
  can read that file, or ptrace the daemon, still wins -- that needs an
  OS-level boundary this process cannot provide.
- The mnemonic-returning op is renamed owner-mnemonic and is owner-gated. It
  is unreachable with a token.

Token containment:
- vault-index returned the decrypted index to any read-scoped token, handing
  over every imported TOTP secret, key-value value and document body. It now
  returns redacted metadata, filtered to entries the token's kind and label
  constraints actually cover, so out-of-scope entries are not even disclosed.
- The `secret` op now requires the reveal scope specifically. A use-scoped
  token can no longer pull plaintext back across the socket.
- `use` is served by a new use-sink op: the agent runs the sink itself and
  returns a receipt, so plaintext never crosses back to the token holder.
  Tokens gained an optional exec allowlist, because a holder who chooses the
  command can always read the secret from inside it -- the allowlist is what
  contains a `use` grant, the audit record is what makes it accountable.

Secret hygiene and state:
- Sink children get a minimal allowlisted environment. SEEDPASS_MNEMONIC,
  SEEDPASS_PASSWORD, SEEDPASS_TOKEN, the agent socket and app dir are never
  inherited: a helper invoked to receive one password could previously have
  taken the whole vault.
- Locking (and TTL expiry) now clears that profile's tokens and its audit
  logger, so tokens cannot spring back to life after a later unlock and the
  seed-derived audit key does not stay resident.
- Audit appends are serialized, so concurrent requests cannot interleave and
  break the HMAC chain.
- Numeric CLI options are validated before anything is persisted. A single
  `--length not-a-number` previously stored NaN as JSON null and made every
  later vault open fail schema validation.

test/agentSecurity.test.ts speaks the wire protocol directly, bypassing the
CLI, and asserts each of the review's reproductions now fails: no seed, no
token minting, no lock/shutdown/status/put, no raw index for a read token, no
plaintext for a use token, no owner ops via a token, and no SEEDPASS_* in a
sink child. Also verifies the capability file is 0600 and that tokens die
with a lock.

193 core + 60 CLI tests green; 36/36 cross-implementation checks green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 23:02:07 -04:00