First push of this branch in a while, and CI found three things.
BLACK VERSION DRIFT. The repo pins black ^24.3 (24.10.0 locked) and the local
environment has 26.1.0, whose stable style differs. An earlier commit titled
"satisfy the black gate" reformatted five tui_v3 screens with 26.1.0, which
UNformatted them as far as CI was concerned — the fix made the gate worse.
Everything is now formatted with 24.10.0, the pinned version, reproducing
CI's six files exactly before and zero after. scripts/mutation_test.py is in
there because I wrote it this session and never ran black on it at all.
MY SCRATCHPAD PATH IN requirements.lock. pip-compile records the command that
generated a lockfile in its header, and when I regenerated it during the
dependency-vulnerability fix I compiled to a temp path and copied the result
in. That baked an absolute path from my machine into a committed file, so
CI's regenerate-and-diff could never match. Regenerated with CI's exact
command on Python 3.11; only the two header lines change, so the dependency
set was never wrong.
AN UNSATISFIABLE LOCKFILE CHECK. Separately, and not caused by me: the step
regenerated the lockfile and diffed it on every matrix entry, including
Windows on 3.10 while Linux and macOS use 3.11. pip-compile resolves
version-conditional dependencies per interpreter, so those two produce
genuinely different files — 135 lines apart here, argon2-cffi-bindings among
them. One committed lockfile cannot equal both outputs, so whichever version
it was built on, the other job failed. Verification now runs once on
Linux/3.11, the canonical generator; every platform still installs from the
lockfile with --require-hashes, which is the part that had to stay everywhere.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
The generator selected lines by an auth vocabulary — token, scope, expire,
password. src/configFile.ts, whose validators are the API's only defence
against a nonsense value being written permanently into an encrypted file,
generated ZERO mutants: `if (n <= 0) throw new ConfigValueError(...)` is a
refusal in every meaningful sense and contains not one word that list knows.
The NOT MEASURED reporting added earlier is what surfaced it, on its first
run against a new target.
A guard whose body refuses is now selected on that basis, vocabulary or not.
That takes the corpus from 132 mutants to 234 and it is the right trade: a
refusal is the security surface, definitionally.
It immediately found three gaps in the config validators written in the
previous commit:
- The `||` chain rejecting non-numbers was only tested with a string.
Number(true) is 1 and Number(null) is 0, so `inactivity_timeout: true`
became a one-second timeout and `clipboard_clear_delay: null` became zero —
a setting silently changed to something nobody asked for.
- `n < 0` versus `n <= 0` on the non-negative fields: zero was never tested,
and it is a legitimate value that must not be conflated with negative.
- The `kdf_iterations` floor had no test at all. Zero iterations is not a
slow KDF, it is no KDF, in the config of a vault whose master password
protects everything else.
configFile.ts is registered as a mutation target so it stays measured.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
The sweep found real gaps but nothing re-runs it, which puts it in exactly
the category this branch already has a bad history with: a check nobody runs
is not a check. Two changes make it one.
The script now exits non-zero. Exit 1 means a mutant survived — a security
check whose removal no test notices. That is not automatically a defect
(equivalent mutants exist) but it always needs a human to either fix it or
annotate it `mutation-equivalent:` with a reason, which is what a red check
should mean. Exit 2 means a target generated no mutants at all: not a pass,
and it must not share a signal with one.
The workflow is scheduled weekly rather than run per-push, deliberately. A
full sweep is ~132 mutants at roughly 25s each, and what it measures does not
change commit to commit — it drifts as checks and tests are added. Per-push
it would be an hour of CI for a signal that moves monthly.
It also verifies the tree is clean at the end. The script restores after
every mutant, verifies the restore, and traps SIGINT/SIGTERM/SIGHUP — but the
entire technique depends on deliberately broken security code never escaping
the run, so that gets checked rather than trusted.
Also corrects a stale TODO: the fuzzer and cross_impl_check were wired into
ts-parity.yml some commits ago and the list still claimed neither ran.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
The harness trapped SIGINT and nothing else, so a run wrapped in `timeout`
was killed with SIGTERM straight past the handler — leaving an INVERTED
fingerprint comparison in js/packages/cli/src/api/routes.ts along with the
lock file. That happened during this sweep. The lock is exactly what it is
for and it worked: the mutant was spotted and reverted before anything was
committed. But the restore should have run, so SIGTERM and SIGHUP now go
through the same handler. Verified by killing a live run and confirming the
tree came back clean.
Full verification sweep across all six targets, run after every fix: 132
mutants, 87 killed, 45 non-compiling, ZERO survivors.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
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
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
Commit cf18ab8 included a one-line change to agent.ts that was never intended:
-if (generateFingerprint(mnemonic) !== fingerprint) {
+if (generateFingerprint(mnemonic) === fingerprint) {
That inverts the daemon's `put` check, so it would refuse every CORRECT
fingerprint and accept every wrong one — the exact L-7 hole this branch closed
two days ago, reopened backwards.
Cause: scripts/mutation_test.py deliberately writes broken security code into
the working tree one mutant at a time, and I ran it in the background and then
committed with `git add -A` from another shell while a mutant was live. The
script restored the file correctly when it finished; the mistake was entirely
mine, in committing during the window.
Reverted. The check is back, typecheck is clean, and all 630 tests pass.
The real fix is structural, because "remember not to commit during a mutation
run" is not a control. The script now:
- takes a lock file at start and refuses to run if one exists, so a
previous run that died with a mutant applied is loud rather than silent;
- releases it on success, on failure, and on Ctrl-C;
- VERIFIES after each target that the file it restored actually matches the
original, and aborts loudly if not — a mutant left behind is a broken
security check sitting in the tree waiting to be committed;
- prints a warning at start that the tree contains deliberately broken code.
The lock file is gitignored, so its presence never itself becomes a commit.
Worth stating plainly: this was found by reading `git diff` after a test
failure looked wrong, not by any check. A CI run on that commit would have
caught it — agentSecurity.test.ts fails on that mutation — which is an
argument for the CI wiring done earlier today, not against it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
Approvals scored 58%. Five survivors, all genuine:
- `ttlSeconds` and `uses` validation was unguarded by any test. An approval
issued with ttl 0 is expired the instant it exists; one with uses 0 can
never be consumed. Either would look like a granted authorization to
whoever asked for it and silently authorize nothing — the confusing
failure rather than the safe one.
- The expiry boundary (`<=` vs `<`) was unpinned. It now asserts that an
approval expiring exactly now is refused, and that one millisecond earlier
it still works, so the boundary is where it claims to be rather than
approximately there. Python uses `<=`, so this is parity as well as the
safer direction.
Approvals now 100%.
Sinks scored 67%. The survivor turned `&&` into `||` in the command-spec
parser, which makes a multi-element spec whose FIRST element contains
whitespace get tokenized — silently discarding every element after it. Losing
an argument changes which command runs, and dropping a `--dry-run` or a target
path is exactly the kind of silent change that matters for a sink being handed
a secret. Sinks now 100%.
Separately, the full suite caught a flaky test of my own: the recovery-split
tamper check rewrote the payload's tail and, depending on the payload's
padding, sometimes reproduced the original string exactly — asserting nothing.
It only failed when a randomized share happened to end the wrong way. Now it
flips a character and asserts the result actually differs before expecting the
throw, plus a second case that tampers at four positions rather than trusting
one. Five consecutive runs are clean.
The agent's mapping was also wrong: agent.ts holds the high-risk session, whose
tests live in highRisk.test.ts, so leaving that suite out scored the agent at
38% and blamed the code for a gap that was really a gap in the harness.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
Differential fuzzing can only reach code both implementations have. The
session agent, the API's HTTP layer, high-risk session handling and the sinks
exist only in TypeScript — and that is where the security properties live. For
those the question "would the suite notice if this broke?" has no oracle, so
it has to be answered by breaking things deliberately.
scripts/mutation_test.py does that: it flips comparison and boolean operators
and forces guards to always-allow, but only on lines that decide whether
something is permitted, expired, within a limit, or authentic. Mutating a log
line would produce a survivor that means nothing. Every mutant is typechecked
first, so a mutant that does not compile is reported as invalid rather than
counted as caught, and the original file is restored even on interrupt.
The first run on the API server found two survivors, and they were different
in kind:
**A real gap.** Disabling the rate-limit check entirely left every test
passing — nothing exercised it. Now tested: the limit refuses the request past
its budget, and unauthenticated requests consume budget too, since otherwise
the limit is bypassed by simply not sending a token.
**An equivalent mutant, which pointed at a real hole anyway.** The route-level
`requiresPassword` flag turned out to be fully redundant with the
`requirePassword` call in each handler, so disabling it changed nothing. The
redundancy is worth keeping, but nothing ENFORCED the pairing — so a future
route could set one and forget the other with the suite silent either way.
Two structural tests now pin it.
Writing the second of those tests immediately found a security regression I
had introduced: **`GET /api/v1/totp` returned live TOTP codes on a bearer
token alone.** Python gates that endpoint on the master password
(`_require_password` in api.py) and this port did not, so a leaked token
yielded working second factors for every TOTP entry in the vault. Now gated,
and tested for.
Server mutation score: 88% of viable mutants caught, the single survivor being
the equivalent one above.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
The Tests workflow was red on every OS and Python version for one reason:
`black --check` found 28 unformatted files. Part predates this branch (the
tui_v3 screens, core/api.py, core/index0.py); part is mine from this session.
Formatting only — verified rather than assumed, because a formatter touching
363 files is exactly where an unnoticed behaviour change would hide:
- 1223 Python tests pass, unchanged
- regenerating the fixtures after reformatting the generator produces
byte-identical output, so no hashed value moved
- differential fuzz: no divergences
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
The cutover plan's last open gate is an independent security review, and there
is no third party to hand the branch to. Self-review has a known failure mode:
you cannot see what you did not think of, which is why two earlier rounds each
found criticals that reading had missed. Me reviewing this session's code has
exactly that defect.
The substitute for another pair of eyes is not more reading. It is an oracle
that is not the author's judgement — and this project has the best one
available: two implementations required to agree. Feed both the same randomly
generated input and any disagreement is a bug in one of them, established
without anyone knowing in advance which answer is right or what to look for.
scripts/differential_fuzz.py covers the surfaces where a disagreement would be
silent and expensive: canonical JSON and hashing (underpins everything else),
password derivation with random policies, the sync CRDT (a mismatch means two
clients converge to DIFFERENT vaults), index0, recovery shares, the semantic
index, and entry hashing. Results are compared as canonical JSON so the
comparison cannot itself paper over an ordering difference. ~10,000 cases
across four seeds currently agree.
IT FOUND A REAL DEFECT, and then found a defect in itself.
canonical.ts asserted that the int/float ambiguity "does not arise" because
SeedPass data never carries float-typed whole numbers. That is true of data
SeedPass writes and false of data it carries: unknown-kind records, hand-edited
vaults, third-party tools. Python distinguishes int from float; JavaScript does
not, so `JSON.parse("0.0")` and `JSON.parse("0")` are the same value and TS
emits "0" where CPython emits "0.0". Entry hashes break same-timestamp merge
ties, so two clients could converge to different vaults — silently. No amount
of formatting fixes it: the information is destroyed at parse time.
The comment is now accurate rather than reassuring, and `findAmbiguousNumbers`
detects the condition on raw JSON text, which is the only moment the
distinction still exists. A second, separate limitation is documented in the
same place: values outside the safe-integer range are refused where Python
encodes them, which is the SAFE direction, because JSON.parse has already
rounded such a value and any hash of it would be a hash of the wrong number.
Then mutation testing was turned on the fuzzer itself, and it failed: two
deliberate off-by-ones in the tombstone-retention and checkpoint-subject caps
went undetected, because random small payloads never reach a bound of 2048 or
64. Two generator bugs behind it — tombstone keys drawn from 31 distinct
values, so 2050 writes landed in 31 slots; and event subjects that peaked at
63 distinct against a cap of 64, one short. Both fixed by generating well past
each bound with margin, and both mutations are now caught. Bounded code is
only tested at its bound.
A fuzzer that cannot fail is worthless, so that is verified too: inverting the
merge tie-break is caught in 20 of 120 cases.
Known number-representation divergences are classified separately rather than
filtered, and the classifier is deliberately narrow — "known limitation" is
exactly the category that quietly grows to hide real bugs.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
index0 is the per-vault activity ledger under `_system.index0`: an append-only
event stream per writer, daily checkpoints rolling those events up, and
derived canonical views (children_of, counts_by_kind, recent_activity).
It was the last piece carried through verbatim rather than computed — which
was safe for round-tripping but meant a TypeScript client never UPDATED it. A
vault written by this port went stale the moment it was touched, and a Python
client reading it afterwards saw an activity log missing everything the TS
client had done. Worse, `nostr restore` kept the local block verbatim, so a
restore silently discarded the remote's history.
Hash parity is the entire contract here: event ids, integrity hashes,
checkpoint summaries and view hashes are SHA-256 over Python's canonical JSON,
and the merge picks winners by comparing those hashes — one byte of encoding
disagreement means the two implementations permanently disagree about which
record wins. Everything routes through the existing canonicalJson (Python's
ensure_ascii escaping, code-point key ordering, CPython number formatting)
rather than JSON.stringify, and the tests compare whole structures via
canonical JSON rather than toEqual, so they pass only if the bytes that get
hashed are identical. Verified against Python fixtures covering the append
chain, compaction, manifest metadata, views and a two-replica merge —
including non-ASCII labels and tags, a non-numeric entry id, two writers
across two days, and a managed-account scope path.
Two Python quirks are replicated deliberately rather than "fixed", because
diverging would change which events exist rather than merely how they hash:
`str(None)` is "None" (so a JSON null in a required field is a non-empty
string there), and a stored integrity_hash is preserved even when it
disagrees — this normalizes, it does not verify, and silently rewriting a
mismatched hash would hide the tampering the hash exists to reveal.
Events are emitted by DIFFING the index at the single mutation funnel each
surface goes through, rather than from each call site as Python does. That is
deliberate: a call-site emitter is something a new code path can forget, and a
silently incomplete ledger is worse than an absent one because it reads as
"nothing happened". The event vocabulary matches Python's, so a stream from
either implementation reads the same. Emission is best-effort — the atlas is
derived state, and it must never fail a committed vault write.
mergeIndexPayloads now defaults to merging index0 with the same deterministic
rules; "preserve-current" and "reject" remain for callers that want them, and
`nostr restore` no longer discards the remote's history.
Also made gen_high_risk deterministic. Fernet embeds a random IV and the
current time, so that fixture regenerated differently every run and would have
failed the fixture-drift CI job each time — it now pins both, with a note that
production must never do this.
Verified end to end: the TS binary performed four mutations, and Python read
the resulting atlas, normalized it without dropping a record, and compacted it
into checkpoints and views.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
High-risk partitions move ssh/pgp/seed/nostr/managed_account records out of
the vault index into a separately encrypted file, so those kinds need a SECOND
factor beyond the master password. The index keeps a stub carrying kind, label
and archived — enough to list, nothing more.
Both file formats are byte-compatible with Python and verified bidirectionally:
TS unwraps a Python-written key envelope and reads a Python-written partition,
and Python unwraps a TS-written envelope and reads a TS-written partition. That
matters more than usual here — the entries are SSH keys, PGP keys and seeds, so
a partition only one implementation can open is lost secrets, not an
inconvenience. This required a Fernet encryptor in core, which had been
decrypt-only by design; the header now says why that exception exists and that
no other Fernet payload should ever be written.
**One deliberate divergence, and it is the reason this is not a straight port.**
Python records an unlock session in agent_high_risk_unlock.json, and that record
contains the partition_key_tag — the value the partition file's encryption key
is derived from. So for the life of any Python unlock session the partition is
decryptable from disk alone, with no factor, by anything that can read the app
directory; both files live in the same directory, so "can read one" implies "can
read the other". That voids the guarantee the partition exists to provide.
Verified empirically before writing any of this.
Here the tag lives only in the session agent's memory, beside the parent seeds
it already holds, with the same TTL discipline — and in the API process's memory
for the API. Nothing derived from the factor reaches disk, asserted by a test
that scans every file in the app directory for the tag, and confirmed on a live
install. No file format or interop changes; only where the live session lives.
Other properties the tests pin:
- a locked partition refuses reveal rather than deriving from the stub, which
would return a plausible wrong secret
- locking the vault drops the high-risk grant, which is scoped to it
- the grant expires on its own clock, typically much shorter than the seed's
- unlock is owner-gated: a scoped token cannot reach it
- the tag is absent from the audit log, which records only that an unlock
happened
- the factor comes from the environment, never argv
- factor-set refuses to replace an existing factor, since a new partition key
would make an existing partition permanently unreadable
- migrate writes the partition BEFORE the index, so a crash between them
cannot leave stubs pointing at records that were never stored
Approval gates port straight across: one-shot (or N-use) authorizations for
export / reveal_parent_seed / private_key_retrieval, consumed under the store
lock so two callers cannot both spend the last use, with every failure mode
getting its own reason string matching Python's. A corrupt store reads as empty,
which fails closed.
API: /api/v1/high-risk/{status,unlock,lock} are real rather than 501. Unlock
needs the bearer token, the master password header AND the factor header; a
wrong factor gets one indistinguishable reason so probing cannot tell failures
apart.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
The "semantic (vector search)" entry in not_yet_ported was a misnomer. It is
not vector search and needs no model: MODEL_ID is "seedpass-token-overlap-v1",
and it scores by Jaccard overlap over tokenized metadata. That is why it could
be ported at all — there is nothing to embed.
Porting it surfaced two defects in the Python implementation.
**Stored secrets were written to a plaintext, 0664 file.** `_extract_text`
appended a key_value entry's `value` — the stored secret itself — and
`build()` wrote it to semantic_index/records.json in the clear, along with a
tokenized copy that leaked it just as thoroughly. The file was created at the
process umask, so on a default install it was group- and world-readable, next
to a vault that is encrypted and 0600. Anything that could read the directory
— a backup, a sync client, another user on a shared machine — got the secret
without the master password.
Fixed in Python (index the key NAME, never the value; chmod 0600 on both
files) and never done in TypeScript. The rule is worth keeping simple: nobody
searches for a secret they do not already know, so indexing one is pure
downside. MODEL_ID is bumped to v2 so an index built by the old code is
distinguishable — a v1 index on disk still contains the secrets and should be
rebuilt.
**Entry 0 was silently unsearchable.** The check was
`int(entry.get("id", 0) or 0) <= 0`, conflating "no id" with "id 0" — and
entry ids start at 0, so the first entry any user ever created was absent from
every search result with nothing to indicate why. Found by writing a test that
queried for it. Fixed in both implementations: skip an id that is absent,
unparseable or negative, which is what the check was reaching for.
The TypeScript port is wired into the CLI (`semantic build/status/search`),
the API (the /api/v1/semantic routes are now real rather than 501), and
writes through atomicWrite for a genuine 0600 inode. Fixtures come from
scripts/generate_ts_port_fixtures.py, which asserts the leaked value is absent
from its own output, so the fixture-drift job proves provenance the way it
does for every other fixture.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
The TUI had a QR action that answered "QR rendering is not available in this
build". The stated reason was that a QR library is not worth a dependency in a
process holding unlocked seeds — which is true, and is why this implements the
encoder instead: byte mode, versions 1-40, all four error-correction levels,
zero dependencies.
A wrong QR code is worse than no QR code. It scans cleanly, yields the wrong
secret, and the user finds out when their 2FA stops working — so this is
verified rather than eyeballed:
- The block-structure table was extracted programmatically from qrcode's
RS_BLOCK_TABLE rather than transcribed. 160 rows of three-number triples
are exactly the kind of data a human copies wrong once and never notices.
- qr.json holds reference matrices from that library, and the tests compare
EVERY module — 10 cases spanning all four EC levels and versions 1 to 17,
including past version 7 where version-information blocks start being
written.
- Generation moved into scripts/generate_ts_port_fixtures.py, so the
fixture-drift CI job proves the committed fixture came from the committed
generator, like every other fixture. Regenerating produced byte-identical
output for all pre-existing fixtures.
Two things are pinned so the comparison is between implementations rather than
between configurations. Byte mode is forced, because the reference
auto-selects alphanumeric for an all-uppercase string — a different encoding
of the same text, not a different answer. And the mask is pinned, because the
two disagree about mask SELECTION: the spec scores the finished symbol, while
the reference scores one with its format modules blanked and its dark module
light (makeImpl(test=True, ...)), which is not a legal symbol. All eight masks
produce valid scannable codes, so the selection rule is checked as a property
instead of reproducing a known quirk to make an equality assertion pass. At a
pinned mask the matrices are identical, which is what establishes that the
data encoding, Reed-Solomon, interleaving, placement, format and version
information are all correct.
In the TUI, TOTP entries encode their otpauth:// URI so an authenticator can
consume them; everything else encodes its own value. Secret Mode suppresses
the code entirely — a QR code is the secret in a form a camera reads from
across a room, so the setting that exists to keep secrets off the screen
applies here more than anywhere — and an entry too large to encode says so
rather than looking unavailable.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
The round-trip conformance check from spec section 8.6, now enforced in CI:
a vault holding a foreign record (unknown kind, nested foreign data, and
trap fields) plus an unknown top-level index key goes through an interleaved
Python -> TypeScript -> Python read-modify-write cycle, and every foreign
byte must come back exactly as planted. Allocation must skip past the
foreign id, and TS must refuse to reveal the record rather than guess.
Landing the check forced the normalization decision it existed to force.
Python's _normalize_entry_defaults ran its legacy renames on every entry,
foreign ones included -- and those renames are migrations for OUR old
shapes: the negative control shows the old code turning a foreign
"blacklisted" string into our "archived" flag, which is not backfill but
reinterpretation of another application's data. The normalizer now skips
unknown kinds entirely, so both implementations carry foreign records
byte-for-byte untouched and produce identical canonical state -- the
property sync convergence is defined by. Entries with neither kind nor
type are pre-kind legacy Python data and still normalize as before.
The spec's gap ledger entry for the divergence is resolved, and the Python
tolerance tests are tightened from field-subset to exact equality, plus a
trap-field test pinning that blacklisted/website/words on a foreign record
mean whatever the other application says they mean.
Verified: with the normalizer gate stashed, Phase L fails on the exact
mangling ("archived": "foreign meaning, not our archive flag"); with it in
place, 38/38 cross-impl checks and 1183 Python tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
Four independent reviewers went over the branch. The worst findings were in
code I had just written and reviewed myself, which is the argument for
having done this.
CRITICAL
- A mnemonic was validated in one form and derived from in another. @scure's
normalize only counts words, so a phrase with a trailing newline passed
validation and then derived a completely different seed, while Python
canonicalizes and derives the correct one. The result was a vault encrypted
under a key that neither implementation -- nor the user's written-down
phrase -- could ever reproduce. Mnemonics are now canonicalized (NFKD,
lowercase, single-space) once, and every derivation site uses that form:
Bip85.fromMnemonic, deriveIndexKeyBytes, deriveTotpSecret, deriveKeyIndex
and generateFingerprint.
- atomicWrite ignored short writes. `handle.write()` may write fewer bytes
than asked (ENOSPC, RLIMIT_FSIZE, EINTR); the truncated temp file was then
fsynced and renamed over the only copy of the vault, with exit code 0 and
no stderr -- inside the function whose comment promised crash safety. It
now loops until every byte lands, fails loudly otherwise, and removes the
temp file on any failure.
- `fingerprint add` overwrote an existing profile. It consulted only
fingerprints.json, which readFingerprints returns empty for whenever the
file is corrupt or unreadable, then rewrote parent_seed.enc and the index.
Restoring a profile directory from backup and re-registering it -- the
canonical recovery gesture -- destroyed the vault. It now refuses if the
seed or index file already exists.
- Entry-id allocation could collide. A non-numeric or above-2^53 key (which
can arrive through import or a merge) made nextIndex return NaN or stop
incrementing, and insert overwrote blindly, so each new entry destroyed the
previous one. Both are now rejected, and insert refuses to overwrite.
HIGH
- RelayPool accepted any event a relay chose to send: verifyEvent only proves
an event is internally consistent, never that it came from the pubkey,
kind, or id we asked for. Events are now structurally validated (including
created_at being a finite safe integer -- "1e999" parsed to Infinity and
sorted ahead of everything forever) and matched against the filter.
- Snapshot handling had no resource budget: a decompression bomb turned 20 KB
of relay data into 20 MB, unbounded. Chunk count, chunk size, total
compressed and total decompressed bytes are now capped, the gunzip is
aborted mid-stream when it exceeds the budget, and each chunk must match
the size its signed manifest declares.
- Manifest tie-breaking used relay arrival order, letting a relay pick which
of two same-second snapshots won; it now breaks ties on event id, which no
party can steer. Falling back to an older manifest is recorded so callers
can surface a downgrade.
- Argon2/PBKDF2 parameters were read unbounded from the kdf block, which sits
outside the AEAD and is writable by anyone with directory access: 1e9 time
cost hung indefinitely, and this core targets browsers too. Now clamped.
- Config and fingerprint-registry writes held no lock at all, losing 4 of 6
concurrent updates in testing. Both now use a read-modify-write lock, and
the vault lock itself only breaks a lock whose owner pid is gone, steals it
atomically by rename, and releases only if it still owns it.
- Plaintext egress (document export, vault export) used writeFile's mode,
which applies only on creation, and followed symlinks -- an existing
world-readable file or a planted symlink received the secret. Both now go
through atomicWrite, which creates a fresh 0600 inode.
MEDIUM
- canonicalJson did not match Python. Exponent padding (1e-7 vs 1e-07), the
1e16 threshold, -0, and key ordering (UTF-16 code units vs code points) all
differed, and the code comment claiming they agreed was false. Since entry
hashes decide merge winners, two clients could keep different versions of
an entry forever. Now matched, with 24 differential fixtures generated from
CPython, and values above 2^53 refused rather than guessed at.
- "__proto__" as an entry or tombstone key silently vanished and polluted the
prototype; containers are now null-prototype. Python truthiness differs
from JS on empty containers, so `"_deleted": []` tombstoned an entry in JS
and kept it in Python; now matched.
- Policy blocks with JSON null produced the literal string "null" as the
special-character alphabet. TOTP secrets that pyotp accepts (lowercase,
unpadded) were rejected. Both fixed.
- hexToBytes accepted malformed input ("1z" -> 0x01, "-1" -> 0xff) where
Python raises; relay-supplied ids and signatures reach it.
- Password/fingerprint normalization used JS trim(), which strips a different
set than Python's strip(), changing derived keys; now uses Python's set.
Also removes a scratch file a reviewer's session left committed.
220 core + 71 CLI tests green; 36/36 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Gate 5 (feature parity) -- document import/export ported: `entry
import-document` reads a text file (label defaults to the file stem, type
from the extension) and `entry export-document` writes one back, reproducing
Python's filename sanitization, directory-vs-file destination handling and
overwrite refusal. That was the last concrete item; the three remaining
exclusions (PGP RSA, semantic, api) are now documented decisions rather than
open work, and none of them blocks cutover.
Gate 6 (packaging) -- js/packages/cli/build.mjs bundles the CLI into a
single self-contained ESM file with no runtime dependencies beyond Node 22,
writes a .sha256 beside it, and is wired up as the package `bin` and
`prepack` so npm install/pack produce a working command. --version now
reports a real version stamped at build time. CI builds the bundle and
smoke-tests it from a directory with no node_modules -- version,
capabilities, profile creation, entry creation, reveal -- then uploads it.
Verified locally that the bundle runs standalone outside the workspace.
Gate 7 (migration and rollback) -- docs/typescript_migration_guide.md: the
migration is a no-op (same profiles), so the guide covers what to back up,
how to rehearse against a copy with SEEDPASS_APP_DIR, the two behavioral
differences users will notice, the known exclusions, and seed-only recovery.
Rollback is proven rather than asserted: new cross-impl phase K drives a
profile through the TS CLI -- several entry kinds created, one edited, one
archived -- then checks Python reopens it, sees every change, derives the
same secrets, and can still write to it afterwards.
Also adds js/packages/cli/README.md documenting the reference-first output
model and the scoped-token workflow.
193 core + 49 CLI tests green; 36/36 cross-implementation checks green.
All eight cutover gates are now green.
Co-Authored-By: Claude Fable 5 <[email protected]>
test_profile_init_integration failed on any machine with a generated test
profile already present. Two causes, both real bugs rather than test noise:
- scripts/generate_test_profile.py mutated constants.APP_DIR at import time
to append "tests". The value was captured when constants was first
imported -- long before a test can monkeypatch Path.home -- so the script
read the developer's real ~/.seedpass/tests, and a reload appended another
"tests" segment. The mutation also leaked into every other importer of
constants in the same process. The tests directory is now derived per call
from constants.APP_DIR instead.
- initialize_profile assumed add_fingerprint returns None for an existing
profile, which is what its docstring still promises; it actually raises
ValueError. Re-running the generator against an existing profile therefore
crashed. It now treats "already exists" as the ordinary case.
test_profile_init_integration also repoints constants.APP_DIR so it no
longer depends on the developer's home directory, and
test_generate_test_profile asserts against the derived tests directory
rather than the mutated global.
Verified hermetic: the test passes repeatedly and touches nothing under the
real ~/.seedpass. Full Python suite: 1178 passed, 16 skipped, 0 failed.
Co-Authored-By: Claude Fable 5 <[email protected]>
The last unported derivation, and the one the plan flagged as highest risk.
It turned out to be fully reproducible: the key material is deterministic,
EdDSA signatures are deterministic (RFC 8032), and the packet layout is
fixed -- so the armored blocks match exactly, with no OpenPGP dependency.
core/derive/pgp.ts emits the packets directly: new-format headers, the
secret-key packet (unencrypted MPI plus 16-bit checksum), the user-id
packet, and the self-certification signature with PGPy's exact subpacket
set and order, wrapped in ASCII armor with a CRC-24 footer. Verified
against PGPy across two seeds, three indices and three user-id shapes --
armored private key, armored public key, and fingerprint.
RSA is deliberately unsupported: PyCryptodome's seeded prime search is not
reproducible, so the TS port refuses the key type rather than silently
deriving a different key.
Two things worth recording from the build: the v4 fingerprint reproduces
from first principles (SHA-1 over the public-key packet), which is what
made byte-parity look feasible before committing to it; and the only
mismatch in the first run was packet framing -- PGPy uses new-format
headers where I had emitted old-format.
CLI: `entry add pgp`, reveal (armored private key), `entry pgp-public`.
Cross-impl phases A/B now cover a pgp entry in both directions, completing
coverage of every creatable entry kind.
193 core + 47 CLI tests green; 30/30 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
TotpManager built pyotp.TOTP(secret) with library defaults (6 digits, 30s)
and ignored what the entry recorded, so any imported or derived entry using
other values produced codes the issuing service rejects. The service was
configured from the entry's values -- those govern the code.
- current_code_from_secret and current_code take period/digits
- EntryManager.get_totp_code passes the entry's values for both the
derived and imported paths
- display_service, menu_handler (totp_list now carries digits) and the GUI
updated at every call site
- regression tests for imported and deterministic entries with custom
parameters, asserting against pyotp with the matching interval/digits,
and confirming default entries are byte-identical to before
Found by scripts/cross_impl_check.py, which flagged it as a divergence
against the TypeScript port. That allowance is now removed: the suite
asserts TOTP equality and reports 30/30 with zero divergences.
Co-Authored-By: Claude Fable 5 <[email protected]>
Expanded the cross-implementation suite from 19 to 30 checks so every
feature is exercised against the other implementation, not just the ones
that were convenient:
G edits (modify / archive / links) made by either side are seen by the other
H deterministic conflict merge produces byte-identical output on both sides
I an Argon2id-protected Python profile unlocks in TS
J the encrypted config file interoperates both directions
Entry coverage in phases A/B now spans every creatable kind, including a
policy-constrained password and an imported (non-deterministic) TOTP.
Two real bugs this surfaced, both fixed:
- Password entries carrying a `policy` block derived the WRONG password in
TS. Python's _generate_password_for_entry merges the entry's overrides
onto the base policy before deriving; TS ignored the block entirely, so
any entry created with policy flags produced a different password.
core now exposes passwordPolicyFromRecord and the CLI applies it.
- TS could not read a Python-written config file: Python wraps it in the
kdf/ct JSON envelope (same class as the snapshot bug fixed last commit).
loadConfig now parses the envelope; regression test included.
One behavioral divergence found and deliberately NOT papered over:
Python generates TOTP codes with pyotp defaults (6 digits / 30s) even when
the entry records different values, so an imported 8-digit/45s secret
yields different codes in each implementation -- and Python's would be
rejected by the issuing service. The suite now reports it as a [DIVERGE]
line rather than a pass or a failure, and the matrix records it as awaiting
a product decision. TS behavior is believed correct.
173 core + 47 CLI tests green; 30/30 cross-implementation checks green
with 2 documented divergences.
Co-Authored-By: Claude Fable 5 <[email protected]>
Phase F starts the in-process NIP-01 relay, has the real Python NostrClient
publish a chunked snapshot to it, then restores that snapshot with the TS
CLI on a clean profile and checks the secrets survive.
It immediately caught a genuine restore bug: Python's index files wrap the
ciphertext in a kdf/ct JSON envelope, and a published snapshot carries
whatever the local file held. TS decrypted the fetched bytes directly, so
it failed on anything Python published -- invisible to the TS-only tests
because TS writes bare ciphertext. Restore (and delta replay) now parse the
wrapper first, exactly as decrypt_and_save_index_from_nostr does.
Regression test in relay.test.ts publishes a wrapper-format payload through
a live relay and restores it.
Cutover plan updated: gate 4 (sync protocol) now cites this end-to-end
evidence, and gate 5's remaining list drops SSH and migrations.
171 core + 46 CLI tests green; 19/19 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
- core/derive/ssh.ts: BIP-85 app 32 entropy used directly as the Ed25519
private key, serialized as PKCS#8 and SubjectPublicKeyInfo PEM. Both DER
shapes are fixed for Ed25519, so they are emitted from literal prefixes
rather than adding an ASN.1 dependency to the crypto path.
- Output matches Python's `cryptography` byte-for-byte across two seeds and
three indices, including PEM line wrapping.
- CLI: `entry add ssh`, reveal (private key PEM), and `entry ssh-public`
which prints the public key without touching secret material. The
OpenSSH authorized_keys line is a TS-only convenience -- Python emits
only PEM, which ssh(1) cannot consume directly -- and is marked as such
in the compatibility matrix.
- Cross-impl phases A and B now include an ssh entry in both directions.
One harness fix: PEM values carry a trailing newline that the CLI capture
strips, so the cross-impl comparison normalizes trailing whitespace. The
unit tests still compare exactly.
170 core + 46 CLI tests green; 16/16 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
This was the one remaining gap that could strand a real user: TS refused
any index below schema_version 4, which is every profile written before
tags were added.
- core/schema/migrations.ts ports seedpass.core.migrations exactly,
including the quirks: v1->v2 copies `website` to `label` and drops it
only for password entries; `kind` is NOT backfilled (Python leaves
pre-v2 entries `type`-only and falls back on read). Migration works on a
copy, so a failure can never leave a half-converted vault.
- parseVaultIndex now migrates on open and still refuses future versions;
pass {migrate:false} where silent upgrades are unwanted.
- Two bugs this surfaced: the version probe could not read a v0 index (no
schema_version field at all), and migrated legacy entries failed schema
validation because the discriminator `kind` was missing. The parser now
fills `kind` from `type` at validation time -- the same shape Python
writes for new entries, documented in the matrix.
Evidence: fixtures generated by running Python's own apply_migrations over
v0/v1/v2/v3 payloads, plus cross-impl phase E, which writes a real legacy
v2 index into a Python profile, opens it with the TS CLI, reveals a
migrated secret, and confirms Python still reads the profile afterwards.
162 core + 46 CLI tests green; 16/16 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Fixtures show both implementations compute the same values. This adds the
missing half: proof they can read each other's on-disk profiles and
backups, using each side's real code paths.
scripts/cross_impl_check.py (12 checks, wired into CI):
A. Python creates a profile -> TS opens the index, derives every secret
identically, and unlocks it with the master password alone
B. TS creates a profile -> Python decrypts parent_seed.enc and the index,
recovers the same seed, derives the same secrets
C. Portable backups round-trip both directions (Python verifies the
checksum of a TS-written export)
D. Both implementations reject a bad-checksum mnemonic
Two real compatibility bugs it caught, both fixed:
- TS-created profiles were unopenable by Python. Python only honors the
recorded parent-seed KDF parameters when kdf.name is exactly "pbkdf2";
TS wrote "pbkdf2-sha256" with an empty salt and 100k iterations, so
Python fell back to its config defaults (200k, fingerprint salt) and
derived a different key. TS now writes exactly what
PasswordManager._build_seed_kdf_config does, and its own unlock path
honors the recorded salt and iteration count.
- TS accepted invalid BIP-39 mnemonics. Seed derivation takes any string,
so a typo'd phrase silently produced a *different* vault -- unopenable
by Python and unrecoverable by the user. Mnemonics are now validated
wherever they enter (profile creation and SEEDPASS_MNEMONIC), with an
error that says why it matters.
Also: docs/typescript_cutover_plan.md records the gate list for promoting
TS to main and retiring Python to legacy/reference -- P0 gates 1-4 are
green, 5-7 (feature parity tail, packaging, migration/rollback) are not.
The Python tree stays in CI after cutover as the compatibility oracle.
Regression tests for both bugs. 151 core + 46 CLI tests green,
12/12 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
- core entryMod: modify_entry port with the per-kind allowed-field matrix,
policy merging, blacklisted-field cleanup and timestamp touching;
archive/restore; typed links (add/remove/get with Python's normalization,
dedupe on target/relation/note, self-link and missing-target refusal).
- Parity fixture entry_mods.json: the real Python EntryManager applies a
10-op modification sequence at a later pinned clock; TS replays the same
ops from the creation state and matches byte-for-byte.
- CLI: entry modify/archive/unarchive/links/link-add/link-remove,
entry totp-codes (explicit egress, honors --at and archived state),
util generate-password (Python parity: index 0 + gen v1 defaults, full
policy flags — v1/v2 outputs verified against password fixtures).
- capabilities: egress command list updated.
- 148 core parity tests (Node + jsdom) + 22 CLI tests green.
Co-Authored-By: Claude Fable 5 <[email protected]>
- NIP-01 event serialization/id computation, BIP-340 signing and
verification via @noble/curves, SeedPass event builders (manifest 30070,
chunk 30071, delta 30072 with d/e tags), and REQ/EVENT/EOSE/OK/NOTICE/
CLOSED client-relay framing. Transport-agnostic; the WebSocket adapter
lands with the interface milestones.
- Cross-implementation fixtures generated through the Python side's
nostr_sdk (rust-nostr) with pinned created_at: TS ids match theirs
byte-for-byte, their signatures verify in TS, and TS-signed events
reproduce identical ids. The generator caches signatures across runs
(BIP-340 aux randomness) so fixture output stays byte-deterministic and
the CI drift job holds.
- 143 core parity tests (Node + jsdom) + 16 CLI tests green.
Co-Authored-By: Claude Fable 5 <[email protected]>
- core entryOps: add* functions for all creatable kinds rebuild the Python
EntryManager fixture index byte-for-byte under a pinned clock — field
shapes, id allocation, independent TOTP derivation-index allocation, and
Python's ISO timestamp format all verified.
- CLI 'entry add password/totp/key-value/document/seed/managed-account/
nostr': provision-blind — returns the reference and metadata, never the
secret the entry represents; tests assert the derived password / imported
secret / child mnemonic appear nowhere in provisioning output.
- Parity bug found and fixed by the new entry_secrets fixtures: nostr ENTRY
keys derive via the default BIP-85 app 39 path (EntryManager), not app
1237 (sync client identity); the CLI had used 1237. Reveal values for
password/TOTP/nostr/seed/managed-account now match Python retrieval.
- 129 core parity tests (Node + jsdom) + 16 CLI tests green.
Co-Authored-By: Claude Fable 5 <[email protected]>
- Portable backup (format_version 1): TS imports Python exports, re-exports
byte-identically under pinned nonce/timestamp, verifies the canonical
checksum, rejects tampering and unknown versions.
- .github/workflows/ts-parity.yml: typecheck + Node/jsdom parity suite on
js/ changes, plus a fixture-drift job that regenerates fixtures from
Python and fails if the committed ones don't match (manifest commit field
excluded by construction).
- packages/cli — seedpass-js MVP implementing plan section 9.3:
* reference-first output: entry list/get/search emit sp://entry/<id> refs
and metadata; secret-bearing fields become has_* flags
* 'entry reveal' is the only plaintext egress command
* 'use --clipboard|--exec|--stdin-to' delivers secrets to sinks; --exec
injects SEEDPASS_SECRET into the child env, never argv or stdout
* vault export/import over portable backups; capabilities --format json
* mnemonic comes from SEEDPASS_MNEMONIC env, never the command line
- CLI tests prove the blindness properties: derived password matches the v2
fixture, TOTP at pinned time, secrets absent from all default output and
from sink-delivery confirmations.
- 125 core parity tests (Node + jsdom) + 11 CLI tests green.
Co-Authored-By: Claude Fable 5 <[email protected]>
- Snapshot model parity (kinds 30070/30071/30072): gzip chunking via
platform CompressionStream, chunk-hash verification, reassembly of
Python-produced chunks, manifest JSON schema, manifest id
HMAC-SHA256(key_index, 'manifest|'+nonce) with the key_index HKDF chain.
- Full merge_index_payloads port (modified_ts_hash_tombstone_v2):
Python-compatible canonical JSON (sort_keys + ensure_ascii) for hash
parity, equal-ts field-level union, tombstone create/prefer/apply/cap,
_sync_meta bookkeeping. Populated _system.index0 content refuses to merge
loudly (atlas-milestone work) instead of silently dropping data.
- Delta replay: encrypted V3 deltas decrypt and merge in order with
source_tag = sha256(payload)[:16]; final state matches Python.
- New fixtures: nostr_snapshot.json, sync_merge.json (8 scenarios),
delta_replay.json; generator remains byte-deterministic.
- 117 parity tests green in Node and jsdom.
Co-Authored-By: Claude Fable 5 <[email protected]>
- scripts/generate_ts_port_fixtures.py: deterministic fixtures from public
test mnemonics (passwords v1+v2, BIP-39/85, TOTP, Nostr keys, managed
seeds, fingerprints, index keys, entry schemas, V3 vault payload).
Byte-identical across runs; v1 values match the frozen entropy-integrity
vectors.
- js/: pnpm workspace with @seedpass/core and @seedpass/test-vectors.
Core implements BIP-85, password gen v1 (frozen) + v2, TOTP, Nostr keys,
fingerprints, vault index key, and V3 AES-GCM decrypt using only
noble/scure crypto + WebCrypto. Strict TS, no Node/DOM deps in core.
- 82 parity tests pass in Node and a jsdom browser-like environment.
- docs/typescript_port_compatibility_matrix.md tracks per-area status.
Co-Authored-By: Claude Fable 5 <[email protected]>
Replaced the crowded side-board settings with a dedicated SettingsScreen
pushed via push_screen(). Added InspectorScreen for maximized entry
details (bound to 'z' key). Added required CSS for full-screen layout.
Updated palette and action strip to route to these new screens. Updated
interactive agent test to verify screen transitions.
Broadened PasswordManager.load_managed_account to accept both 'seed' and
'managed_account' entry kinds. Updated TUI palette to permit this logic.
Fixed a bug in _show_sensitive_panel where double-press confirmation was
not correctly state-tracked. Enhanced visual feedback for confirmed
actions with bold panel prompts and explicit status instructions. Updated
interactive agent test to verify sub-profile loading for both types.
Replaced the 'Settings' shortcut palette pre-fill with a real inspector
board. Added #settings-board widget and settings pane mode. Implemented
_refresh_settings_board to show live config values and palette hints for
Security, Backup, and Nostr settings. Updated interactive agent test to
verify settings board activation via action strip click.
Corrected _handle_action_strip_click coordinate mapping to account for
widget border and padding. Improved segment detection using regex to be
layout-agnostic. Refined _action_strip_segment_action prefix matching to
support shorthand labels. Updated interactive agent test to verify real
user interaction patterns (typing and clicking).
Added _profile_tree_expanded state to track node visibility. Implemented
action_profile_tree_toggle and profile-tree-toggle command. Bound 'space'
and 'right' keys to toggle tree nodes. Added [+] / [-] visual indicators
to profile nodes. Updated interactive agent test to verify hierarchy
logic across collapse/expand transitions.
Added docs/TEST_INVENTORY.md as a central index for all tests and runners.
Created scripts/interactive_agent_tui_test.py for password-free TUI v2
exploratory testing. Integrated standalone permission tests into main
suite and fixed TUI v2 parity regression tests broken by auto-selection
removal. Updated AGENTS.md and CLAUDE.md with strategic plan references.
This resolves issues where `scripts/tui2_check_smoke.sh` and `scripts/run_ci_tests.sh` didn't appropriately resolve `py_bin` nor invoke `python` within `poetry run` or `.venv/bin/python` leading to `ModuleNotFoundError` for packages like `typer`. It also fixes a failing test test_legacy_index_reinit_syncs_once_when_confirmed that expects a single synchronization event instead of 0.
Co-authored-by: PR0M3TH3AN <[email protected]>
- fix unformatted test file src/tests/test_nostr_resilience_failure_modes.py
- fix missing PYTHONPATH and use poetry run in scripts/tui2_check_smoke.sh
- fix failing test test_legacy_index_reinit_syncs_once_when_confirmed by updating sync call assertion
- configure run_ci_tests.sh to use proper python binary
Co-authored-by: PR0M3TH3AN <[email protected]>