Commit Graph
5 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 5 e1ea9c2b48 Teach the mutation harness two blind spots it had, and close what they found
The harness only mutated block-form guards (`if (cond) {`), so every guard
written as a one-liner was invisible to it — which turned out to be most of
the load-bearing ones. src/highRisk.ts generated ZERO mutants and printed as
a clean sweep. Three changes to the tool:

- Mutate single-statement guards too (`if (cond) return ...;`). This alone
  surfaced 27 previously unreachable mutants, including the entire token
  validation chain: revoked, expired, exhausted, scope-not-granted.
- Report a target that generates no mutants as NOT MEASURED. Zero killed and
  zero survived prints exactly like a clean sweep, and it is the one result
  that must never be read as a pass.
- Add `kind`, `mnemonic` and `seed` to the security hints. A token's `kinds`
  list is half of what it may reach, and leaving the word out hid the shape
  check that stops a string `kinds` becoming a substring match.

Also adds `--kind` so a new mutation rule can be swept across every target
without re-judging the mutants already settled.

What the new mutants found, all of it previously unasserted:

- A token's `kinds` restriction was never tested. This file's own header
  claimed kinds and labels were both covered; only labels were. Removing the
  check let a token scoped to one kind read every other kind's secrets.
- `token-issue` would mint a token for a profile the agent is not holding.
  The seed being resident is the only proof the caller had authority over
  that vault.
- `token-issue`'s array shape checks for `scopes` and `kinds`. A string
  `kinds` turns tokenMaySee's exact match into String.includes.
- An empty high-risk factor. Refused in both implementations, tested in
  neither; it would wrap the partition key under nothing while
  `factorConfigured` still answered true.
- POST /api/v1/fingerprint/select accepting a profile that does not exist —
  the caller's string becomes ctx.fingerprint, which later requests join onto
  a path. AppDir repeats the check, so the guard's real value is answering
  404 instead of an unhandled 500.
- The API's requiresPassword flag is redundant for every route today, so
  disabling it changed nothing. Its purpose is the route that does not exist
  yet, whose author sets the flag and forgets the call — so the test now
  registers exactly that route and confirms the server layer stops it before
  the handler runs.
- sinks.ts drops SEEDPASS_-prefixed variables from a child's environment.
  Nothing on the allowlist starts with SEEDPASS_, so the backstop cannot fire
  against the real list. sinkEnv now takes the allowlist as a defaulted
  parameter purely so that future-edit case can be exercised.

All six targets now sweep to zero survivors.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 18:19:15 -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 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 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
thePR0M3TH3ANandClaude Fable 5 274bc0e1b8 TS port slice 10: scoped tokens, agent-side enforcement, audit chain
The section-9.3 permission model is now working code, enforced where the
keys live:

- Token model (aligned with the Python agent surface): bearer tokens with
  read/use/reveal scopes, entry-kind and label-regex constraints, TTL and
  use counts. Issued and held by the session agent — hash-stored, plaintext
  shown exactly once at issuance; list/revoke commands.
- Token mode: with SEEDPASS_TOKEN set (and no mnemonic), the CLI reads the
  decrypted index and materializes secrets AGENT-SIDE. The constrained
  process never receives the mnemonic, and resolveMnemonic refuses owner
  fallback in token mode — a token can never silently escalate (the test
  suite caught exactly this hole and it is now closed).
- Denials are precise and audited: wrong scope, wrong kind, unmatched
  label, expired, exhausted, revoked. Reads don't consume uses; secret
  deliveries do.
- Audit chain in Python AuditLogger's scheme: sig = HMAC-SHA256(key_index,
  prev_sig + canonical_payload), genesis 64 zeros, per-profile audit.log;
  agent records unlocks, token lifecycle, index reads, deliveries and
  denials (never secret values). audit-verify/audit-tail commands; a forged
  record breaks the chain in tests.
- 151 core + 41 CLI tests green.

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