mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-31 09:52:03 +00:00
bb1f4d5f2b335808f43dd5ce34a3ec61557aa6bb
2403
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bb1f4d5f2b |
Test the graph traversal that put api.py under its coverage gate
With the Python suite finally running end to end in CI — 1223 passed — the last failure was the critical-coverage gate: src/seedpass/core/api.py at 76.11% against a threshold of 85%. That is not a misconfigured gate. This branch added 1343 lines to that file for the graph, atlas and semantic-search work, and roughly 240 of the 269 uncovered lines were three methods that had no test at all: multi_hop_neighbors, filtered_neighbors and graph_pivot_results — the traversal behind atlas pivoting. They are tested against their documented contracts rather than against whatever they currently return: hop numbering and the recorded path, shortest-path retention when an entry is reachable two ways, cycle termination, each filter, and the stated pivot scoring (direct outgoing 1.0, incoming −0.1). A failure here means the documented behaviour changed. One behaviour is pinned because it is surprising rather than because it is wrong: `kinds` filters the results, and since the filter is applied before a neighbor is queued it also prunes traversal THROUGH that neighbor. A document two hops away, reachable only through a password, is not returned when filtering to documents. Worth a test so it is a decision rather than a discovery. Also covers _safe_excerpt and its _SECRET_EXCERPT_KINDS companion, which decide what entry text a search result may disclose — asserted per kind, and asserted to reach no secret-bearing field for any kind. api.py: 76.11% -> 87.83%. All five critical gates pass. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
80a5c3cdb6 |
Locate the test interpreter via pythonLocation, not just PATH names
The previous attempt failed loudly with the resolved paths, which is what it was built to do, and they said the rest: on Windows BOTH `python` and `python3` are MSYS2's. The toolchain that must be on PATH so native wheels can build is the very thing hiding the interpreter the tests need, and no bare name can reach past it. setup-python exports `pythonLocation` (here C:\hostedtoolcache\...\3.10.11) without necessarily putting it first on PATH, so that is now tried before the bare names, along with a last-resort candidate derived from wherever `pytest` itself resolves — the determinism gate proves that one is reachable on this runner. Selection is still by capability: the first candidate that can actually import pytest wins, and a total failure prints every path it tried. It also now echoes the interpreter it chose, so the next surprise of this kind is one line of log rather than a deduction. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
98816c4b70 |
Pick a test interpreter that can import pytest, not just one named python
With Windows finally installing its dependencies, the CI build job reached the test step for the first time and died on "msys64/mingw64/bin/python.exe: No module named pytest". The MSYS2 toolchain is prepended to PATH so native wheels can build, and MSYS2 ships its own python.exe. run_ci_tests.sh took the first `python` on PATH, which was therefore MSYS2's. The determinism gate immediately before it had passed on the same runner because it invokes the `pytest` executable, which MSYS2 does not provide — so PATH resolution quietly sent the two scripts to different interpreters. Selection is now by capability rather than by name: try each candidate and keep the first that can actually import pytest, failing loudly with the resolved paths if none can. Unchanged on Linux and macOS, where the first `python` is the right one. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
4503b7eee4 |
Make requirements.lock universal, so Windows can install it at all
The Windows entry of the CI build job has never been able to install from this lockfile, and no arrangement of pip-compile can fix that. The lockfile has to serve Linux, macOS and Windows across Python 3.10-3.12. pip-compile resolves for the one environment it runs on and emits no markers, and two dependencies are environment-conditional: aiohttp needs async-timeout only below 3.11, and portalocker needs pywin32 only on Windows. So every possible pip-compile output is wrong somewhere. A Linux/3.11 lockfile killed Windows with "must have their versions pinned" for pywin32; a Windows/3.10 one could never match the diff on the other platforms. Both failure modes turned up in this session, one after the other, as each fix exposed the next. `uv pip compile --universal` resolves across all target environments and emits the markers — `pywin32 ; sys_platform == 'win32'`, `async-timeout ; python_full_version < '3.11'` — pinned and hashed, so one file covers everything. This changes the lock GENERATOR from pip-tools to uv, which is a real tooling decision and worth saying plainly. What it does not change is the consumer: the output is an ordinary pip requirements file, and every build job still installs it with plain `pip install --require-hashes -r requirements.lock`. Nothing depends on uv at install time, so reverting means regenerating the file, not unpicking anything. uv is pinned to 0.10.4 so the regenerate-and- diff stays deterministic. Verified locally: regenerating produces a byte-identical file, and it installs under --require-hashes on both 3.10 and 3.11. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
c4c1e1dff9 |
Compile requirements.lock on the lowest supported Python, and verify it once
The Windows job could never install from this lockfile and the Linux/macOS jobs could never verify it, for the same underlying reason: pip-compile resolves version-conditional dependencies for the interpreter it runs on and emits no markers, so 3.10 and 3.11 produce genuinely different files — 135 lines apart, argon2-cffi-bindings among them. Which direction matters. A 3.10 resolution is a superset: it carries async-timeout, which aiohttp requires below 3.11, and installs cleanly under --require-hashes on 3.11. A 3.11 resolution is missing it, so on 3.10 pip tries to fetch async-timeout unpinned and --require-hashes refuses — which is exactly how the Windows job died. So the lockfile is now compiled on 3.10, the minimum in pyproject's `python = ">=3.10,<3.13"`, and verified to install on both 3.10 and 3.11. Verification moves out of the build matrix into its own job pinned to that same interpreter. Regenerate-and-diff answers "did this file really come from src/requirements.txt", which is one question about one artifact and wants asking once, on the interpreter that produced it — not once per matrix entry on whichever Python that entry happens to use. Every platform still installs 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 |
||
|
|
cd1ced63e1 |
Pin nostr-sdk below 0.44, which the client code cannot build against
CI went red where the Tests workflow stayed green, and the reason is that the two install from different lockfiles which had drifted apart: poetry.lock held nostr-sdk 0.43.0, requirements.lock had resolved to 0.45.0 under the unbounded `>=0.43`. Two lockfiles disagreeing about a protocol SDK is not a CI quirk — it means the code was being tested against one version and shipped against another. 0.44 removed NostrSigner's constructors, including the NostrSigner.keys the client calls, and changed Client's constructor. Verified against 0.45.0: NostrSigner is left with only get_public_key, sign_event and the nip04/nip44 methods, and Client.__init__ takes no arguments. So this is a real migration touching signing and client setup, not a rename to be done in passing. Bounded at <0.44 in all three places the constraint is declared, and both lockfiles regenerated: requirements.lock resolves 0.43.1, poetry.lock keeps 0.43.0, and the relock changed exactly one line — the content hash — with no version churn anywhere else. Confirmed 0.43.1 still exposes NostrSigner.keys and a Client that takes an argument, since that is what CI will actually install. pip-audit reports no advisory against the 0.43 line, so the bound costs nothing today. The migration is recorded in TODO.md rather than attempted here: it wants the sync round-trip re-validated against a real relay, not folded into a CI fix. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
dec2b0f677 |
Guard the v3 TUI test's textual import, as every other TUI test does
`textual` is not a declared dependency — not in dependencies, not an extra,
not in the dev group — so it is absent from any environment built from the
lockfile. Every other TUI test opens with pytest.importorskip("textual") and
skips. This file did not, so instead of skipping it failed COLLECTION, and a
collection error takes the whole run down: the entire Python suite went red
on Linux and macOS over a module no test here can rely on.
The guard is the immediate fix. The larger finding is recorded in TODO.md:
the whole TUI test surface has been passing by not running, and the step
meant to catch that — `seedpass tui2 --check` — uses find_spec and reports
"unavailable" with a zero exit, so it passes whether or not the TUI can
start. A fresh install from the lockfile ships a TUI that does not work and
nothing in CI says so. Which way to resolve that is a product call, so it is
written down rather than decided here.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
|
||
|
|
4204a23cab |
Fix three CI failures: a stale black, my scratchpad path, an impossible check
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 |
||
|
|
bc6ee2288e |
Declare tsx where the fuzz runner actually lives
The differential-fuzz CI job failed with "tsx: not found" — the TypeScript side of the fuzzer never ran at all, so the job was reporting on Python output alone. `tsx` was a devDependency of @seedpass/cli only, while the fuzz runner lives in packages/core and is invoked with cwd there. It worked locally because core's node_modules/.bin had a stale tsx from an earlier install, and could never work from a clean `pnpm install --frozen-lockfile`. Precisely the class of bug that only CI can see, on the first run after wiring these checks up. Declared in @seedpass/core rather than papering over it with a path or a --filter, so the invocation is correct by construction. Verified by removing core's node_modules, reinstalling frozen, and running CI's own seed 1 end to end: 1050 cases, no divergences. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
bb43a0729e |
Record the final mutation numbers and the three harness blind spots
234 mutants across seven targets, zero survivors. Notes the pattern the sweep kept turning up — two paths both refuse, so only the reason or the boundary is observable and nothing observed either — and that each harness blind spot was found by fixing the one before it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
32f76a2f20 |
Cover every owner-op client method's denial, completing the agent sweep
Extends the denied-request test to a table over all nine owner-op methods
rather than the three that happened to have surviving mutants, so a method
added later is covered the day it appears. Each one's dropped check produces
a different false success:
shutdown() -> void, reading as "the daemon stopped" while it is still
running and still holding seeds. The test now pings the
socket afterwards to prove it did not.
status() -> [], reading as "no profiles are held"
tokenIssue() -> the STRING "undefined" handed back as a token
tokenRevoke()-> void, "token revoked", while the token stays live
tokenList() -> undefined
put() -> NaN
agent.ts now sweeps to zero survivors across all 86 mutants, up from 39 under
the old vocabulary rule. Every mutation target is clean: 234 mutants, none
surviving.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
|
||
|
|
a9e31215ea |
Throw on a denied agent request instead of returning a quiet zero
Every owner-op client method checks `ok` and throws, and none of those checks
was tested. Removing one does not produce an error — it produces whatever the
failed response coerces to, and for each of these that is a value which reads
as a legitimate outcome:
lock() -> 0, printed as "0 profiles locked" while the seeds are
still resident and the user has been told the vault is
locked
highRiskLock() -> false, indistinguishable from "there was nothing to
lock"
highRiskStatus() -> { unlocked: false, expires_at: NaN }
A refusal that looks like a successful no-op is worse than an error, because
nobody retries it. Forced by pointing SEEDPASS_AGENT_CAP at the wrong
capability, which is also the shape of the real failure: a caller that has
lost owner authority.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
|
||
|
|
d188005b10 |
Refuse unrecognised token scopes and malformed sink commands over the wire
Both checks fail closed if removed, and both fail closed SILENTLY, which is the reason they exist. An unknown scope grants nothing downstream — tokenAllows asks for read, use or reveal by name — so a token issued with `["admin"]` simply never authorises anything. The operator gets a token back, believes they issued it, and finds out when the job using it is denied. The likely cases are near-misses: "Read", "reveal " with a trailing space, "write" by analogy with other systems. Naming the bad scope in the refusal is the whole value. The sink `command` shape check is the same shape of argument. Every malformed command already fails closed, because the allowlist is checked with parseCommandSpec against the SAME array that is later spawned — so the check and the spawn cannot disagree. But failing closed there means a TypeError or a nonsense command word, and this daemon's stated posture is that every caller is untrusted. Refuse the shape while it is still describable. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
7a6b0a5f01 |
Refuse to replace a live agent; clean a stale socket
AgentDaemon.start() guards both cases and neither was tested. Without the live check the second daemon deletes the socket and listens in its place — the first keeps running, holding unlocked seeds, with an expiry sweep nobody can reach and an audit log nobody is writing to. Silently orphaning a process that holds seeds is worse than failing to start, which is what makes it a refusal rather than a convenience. The other half matters too: a crashed agent leaves its socket behind, and treating that as "already running" would make the agent unstartable until someone deleted a file by hand. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
f487a001a2 |
Test job-profile revocation, completing the routes.ts sweep
The job-profile surface had no API test at all. The DELETE is the one that matters: without its existence check it answers 200 "revoked" for a job that never existed, so an operator revoking an agent's access is TOLD it worked while nothing happened. For a revocation that is the worst available answer. Revoking twice is asserted too, since a second success is how the false positive shows itself. routes.ts now sweeps to zero survivors across all 85 mutants under the refusal-based selection, up from 30 mutants under the old vocabulary rule. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
46638e4b86 |
Test relay deletion by index, which had no test at all
DELETE /api/v1/relays/:idx guards the index twice and neither guard was exercised. An off-by-one here does not fail loudly — it removes the WRONG relay, so a user dropping one they distrust keeps it and loses one they wanted. Only asserting the exact resulting list can see that; a length check passes either way. Covers index 0 (a valid index, not a missing one), the last index versus one past it (the `>=` boundary), and a malformed index answering 400 rather than 404 — the request is wrong, not pointing at something absent. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
910693e952 |
Refuse an empty vault-import body as a 400, and cover a non-backup body
Without the explicit length check, parseBackupWrapper's JSON failure on an empty body escapes as an internal error — the server reporting a fault for a request the caller simply sent empty. Also pins that a body which is not a backup at all answers 4xx rather than 5xx, since the two failures take different paths and only one of them was exercised. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
5aa6d04248 |
Refuse a config write with no value, and 404 a document import with no file
Two more from the routes sweep, both cases where removing the check produced a wrong answer rather than no answer. `PUT /api/v1/config/:key` with a body carrying no `value` was only refused by the explicit check. Boolean(undefined) is false, so for every boolean key — offline_mode, secret_mode_enabled — an empty body would have quietly SET the setting to false instead of refusing. The test now confirms a refused write leaves the existing value alone, which is the property that actually matters. `POST /api/v1/entry/document/import` from a path checks the file exists; without it readFile's ENOENT surfaces as 500, "the server broke" for a filename the caller got wrong. Also asserts the traversal guard applies to the import side and not only to export, which nothing covered. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
d8292db325 |
Cover 404 on every entry-scoped route, not just the two that had tests
PUT, archive, unarchive and GET links each check that the entry exists and none of them was tested, so all four checks could be removed with the suite green — the underlying op would then run against a missing entry and answer 400 or 500, telling a client to fix its request or retry when neither would help. The secret route had the same gap. Enumerated from the route table rather than listed by hand, so a new entry-scoped route is covered the day it is added, which is exactly when its existence check is most likely to be forgotten. DELETE links validates its `target` before looking the entry up. That ordering is right — validate the request before touching state — so the test supplies a valid target rather than treating it as a finding. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
2d27b5aaa9 |
Assert optInt's three edges and the path guard's positive case
Both checks could only be observed failing, so both could have been quietly inverted. `resolveWithinProfile` is `target !== root && !target.startsWith(root + "/")`. Turn that `&&` into `||` and every path except the profile root itself is refused — which the traversal cases could not see, because they expect a refusal either way. A check that refuses everything is not a working check, so the test now writes to a legitimate path inside the profile and confirms the file lands there. `optInt` guards length, period, digits, index, delay and the history target, and each of its three clauses was unasserted: a non-integer (1.5), a negative, and — the one that matters for the boundary — zero, which is NOT negative and must stay accepted. `n < 0` quietly becoming `n <= 0` would reject a clipboard delay of 0 and a derivation index of 0, both real choices. Records one thing found on the way and deliberately not fixed here: a nested export path answers 500 rather than a refusal naming the missing directory. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
bd17ad5eae |
Pin the API's body-shape refusal and the partition gate's expiry case
Two more from the routes sweep. `bodyObject` rejects arrays explicitly because typeof [] is "object" — the array check is doing real work, not restating the typeof. Without it an array body reached the handlers, where `body["kind"]` is undefined and the caller was told a field was missing, sending them after the wrong problem. The partition gate's test covers the case that actually matters. With no tag at all, dropping the gate fails anyway because readPartition cannot decrypt with null — so the mutation survived. But an EXPIRED session still holds its tag, and without the gate readPartition succeeds with the stale key and returns the secret: the second factor lapsing silently. No test read a partition entry through the API at all before this. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
5315d8fae9 |
Fix: migrating a seed entry produced an index that would not parse
Found while writing a test for a mutation survivor on the API's partition gate. A high-risk STUB — what the index keeps after `agent high-risk migrate` moves an entry's substance into the encrypted partition file — was being validated against the full schema of the kind it stands in for. It is a different shape on purpose: kind, index, label, archived and the partition pointers, so an index read without the factor discloses that a high-risk entry exists and nothing about it. `seedEntrySchema` requires `word_count`, which a stub does not carry. So: - `agent high-risk migrate` on any vault holding a seed entry built an index saveVault refused to write — correctly, that check exists so a malformed index never reaches disk — but only AFTER writePartition had copied the secret into the partition file. The migration failed while leaving a copy of the secrets behind. - Worse, Python builds the byte-identical stub (high_risk_partition_store.py, migrate_high_risk_entries) and validates nothing on save, so it writes one happily. A vault Python had migrated could not be OPENED by this implementation at all. That is a cross-implementation data-compatibility break, not a TypeScript-only bug. Fixed by modelling the stub as its own shape in the entry union rather than by adding `word_count` to the stub, which would have made the two implementations write different bytes and diverged sync's canonical hashing. It is not a validation bypass: a stub holds no secret material, the `partition: "high_risk"` literal is the discriminator, and an entry claiming a known kind without it still faces that kind's own schema — asserted. Only `ssh` was ever exercised, which is exactly why this survived: ssh's schema has no required field a stub omits, and pgp's extras carry defaults. The new tests cover all five high-risk kinds, so a future kind with a required field breaks a test rather than a vault. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
4184de9b9e |
Close four more gaps the widened mutation rule found
Sweeping the smaller targets under the refusal-based selection. All four are cases where two code paths both refuse, so only the reason or the boundary was observable — and nothing observed either. - Approval RESOURCE SCOPING was tested only on its failing side. The check is `record !== "*" && record !== requested`; for a scoped record and a different resource both halves are true, so `&&` and `||` agree and the mutation was invisible. The two cases where they differ are exactly the ones that make scoping useful rather than merely restrictive: a scoped approval must WORK for the resource it names, and a wildcard must work for any resource. Under `||` both stop working entirely. - The request body cap's boundary. A limit of N bytes has to mean N bytes are allowed; `>` accepts exactly N and `>=` rejects it, and the existing test sent 500 bytes at a 64-byte cap, which cannot tell them apart. The cap could have gone off-by-one and surfaced only as requests failing at a size the operator configured as fine. - readEnvelope's existsSync guard. Without it, ENOENT is caught by the same handler as malformed JSON, so a profile that never configured a factor is told its partition envelope is invalid — "restore a backup" instead of "set one up". - sinks.ts and server.ts swept clean under the new rule with no further changes. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
9c9544b725 |
Mutate guards by what they DO, not just what they are about
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 |
||
|
|
33aeeead9c |
Record the adversarial pass: what held up, and the one shared design gap
Closes the "what does this trust?" item. Records what was checked and held, so a later pass starts from the gaps rather than re-deriving the same conclusions: socket input bounds, the exec allowlist's single-parse guarantee, CORS exact-matching, that no path is ever built from entry data, and that the prototype-pollution-shaped writes in merge sit behind encryption that already requires the seed. The pass found one real defect (the config API, fixed separately) and one property that should not be fixed unilaterally: an unencrypted portable backup is unauthenticated. Its checksum and its fingerprint both live in the same attacker-supplied file, so they catch corruption and nothing else, while a seed-only backup is genuinely authenticated by its Fernet HMAC — and import treats them identically, then REPLACES the vault. Python does the same, so this is a shared design property rather than a port defect, and any of the three fixes has to land in both implementations at once. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
ae3889bfca |
Stop the config API accepting any key and any value
Adversarial pass, first finding. `PUT /api/v1/config/:key` took the key from the URL and the value from the body with no validation beyond blocking two credential verifiers. Two ways that goes wrong, both silent: A typo is stored forever. `inactivity_timout` was accepted, written into the encrypted config, and answered ok — while the setting it was meant to change never moved. Nothing downstream reads an unknown key, so this failure can only ever be silent; the caller has no way to find out. A wrong type is stored verbatim. `inactivity_timeout: "soon"` turns every later comparison into NaN, which compares false, so the vault simply never times out. That is a security setting failing open because a string reached a numeric field. There is now an allowlist of 23 keys, each with a normalizer that rejects values it cannot make sense of: negative delays, a zero inactivity timeout (indistinguishable from a broken vault), an empty relay list (not a configuration, an outage), a string where an array belongs. Unknown keys get Python's 400 "Unknown key"; the credential verifiers move from 400 to 403, matching Python, since the key is real and the refusal is about authority. The allowlist is deliberately a SUPERSET of Python's, which cannot set the password-policy keys this implementation honours. Copying Python exactly would have deleted working behaviour to reproduce a limitation. Recorded in TODO.md as a divergence to settle rather than left as an accident. Separately: the shared API test fixture was inheriting the default 100 requests/minute budget and had grown past it, so unrelated tests failed with 429 depending on how many requests ran before them. Rate limiting is tested on its own servers with explicit budgets, so the fixture no longer enforces one. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
d6ffbac312 |
Run mutation testing weekly in CI, and make it able to fail
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 |
||
|
|
f043c95672 |
Restore on SIGTERM, not just SIGINT; record the mutation sweep as done
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 |
||
|
|
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
|
||
|
|
0f59602622 |
Close every routes.ts mutation gap: seven unasserted API behaviours
routes.ts now reaches zero mutation survivors (30 mutants). The suite covered what the API refuses very thoroughly and what it REPORTS almost not at all, which is where all seven gaps were. The one that could destroy data: DELETE /api/v1/fingerprint/:fingerprint guards against deleting the profile the server is currently serving. Removing that guard entirely left the suite green -- the fixture only ever had one profile, so the guard was never exercisable. The test now seeds a second profile and asserts the pairing: the served one is refused, another is deleted. A refuse-everything implementation cannot pass it. The rest report vault state that nothing checked: - GET /api/v1/fingerprint's `current` flag. Inverted, every profile EXCEPT the served one is flagged, which a profile switcher shows straight to the user. Asserted as a pairing, since "at least one is current" survives the inversion. - GET /api/v1/vault/status's `locked`. Inverted, it reports locked while unlocked; the test also confirms the flag tracks something real by checking a locked vault refuses secrets. - GET /api/v1/high-risk/status, which had no test at all: the exact-expiry boundary, the tag-AND-unexpired conjunction (a stale expiry with no tag read as open), and the expires_at null decode. Built on its own context so the clock can be frozen. - POST /api/v1/entry with an empty totp `secret` must DERIVE one, not import the empty string as a key. An entry that looks like a second factor and is keyed on nothing. - GET /api/v1/totp/export must export a stored secret as stored and derive past a blank one. Inverted, every exported secret is still well-formed base32 -- so shape assertions pass while the codes are for another account. The fixture gains an entry with a present-but-empty secret, the shape a legacy or Python-written index can hold and the API cannot create. - POST /api/v1/high-risk/unlock answers 409 not_configured rather than falling through to 401 factor_invalid, which tells someone who never set a factor that the one they just chose is wrong. Two fixture counts were hardcoded literals; they now derive from the fixture, so adding an entry cannot silently weaken "no secrets in the listing" into "no secrets in an empty listing". Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
c2a6a11472 |
fix: revert an inverted security check I committed by accident, and make it impossible
Commit
|
||
|
|
cf18ab8091 |
test: close the gaps mutation testing found in approvals, sinks, and a flaky test
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
|
||
|
|
c12f5de213 |
fix(api): password-gate live TOTP codes; add mutation testing for the un-oracled surfaces
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 |
||
|
|
3007b12d13 |
docs: diagnose the red CI checks from the actual logs
TODO had these as "pre-existing, not yet diagnosed". They are three unrelated causes, and only one was what the note assumed: CI -> 100 real dependency vulnerabilities (fixed) Tests, all OSes -> black --check, 28 files (fixed) Installer Smoke -> Windows-only lock gap (diagnosed, needs a decision) The Windows one is honest to leave open: portalocker pulls pywin32 on Windows, pip-compile runs on Linux and drops the marker, and the Windows installer uses --require-hashes so the unpinned transitive dep is refused. Naming it with a sys_platform marker does not survive pip-compile on Linux, and hand-editing the lock is pointless because four workflows regenerate it. The fix is a multi-platform locking decision, recorded with its options rather than hacked around — dropping --require-hashes would buy a green check by giving up supply-chain integrity, which is the wrong trade. Also corrects an earlier claim of mine in this file: cross_impl_check.py was already wired into CI. Only the fuzzer was missing, and now is not. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
3395aadd1c |
style: satisfy the black gate that was failing the Tests workflow
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
|
||
|
|
71db3bc498 |
fix(deps): clear all 100 known vulnerabilities; the CI red was real
`dependency_scan.sh` had been failing CI on the port branch, and it was right
to: pip-audit reported 100 known vulnerabilities across 15 packages, including
`cryptography` itself — the library doing the vault's actual encryption.
Raised the floors to the fixed versions pip-audit named. `pip-audit` on the
regenerated lock now reports zero (one ignored exception remains, the
pre-existing GHSA-wj6h-64fc-37mp).
The one that needed judgement was starlette. It was pinned `<0.48` "must
remain compatible with FastAPI", which kept seven advisories permanently open.
That ceiling predated fastapi 0.141, which requires `starlette>=0.46` with no
upper bound — so the pin was protecting against a constraint that no longer
exists. Lifting it took starlette across a major version (0.47 -> 1.6), which
is why this was verified rather than assumed:
- 1223 Python tests pass, 171 of them API tests
- the API app builds all 64 routes and still answers 401 for a missing
token, a bad token, and 404 for an unknown path
- cross_impl_check: 41/41
- differential fuzz: no divergences
poetry.lock was regenerated too. It dated from March, and `poetry check --lock`
reported it inconsistent with pyproject.toml — CI runs `poetry install`, so
that alone would have kept the build red no matter what else was fixed.
None of this touches the TypeScript port, which has an empty production
dependency list. It matters because the Python implementation is still the
normative reference and still what a user runs today.
Also wires the differential fuzzer into ts-parity: three fixed seeds so a
regression is deterministic and bisectable, plus one seed derived from the run
id so the explored input space grows over time rather than freezing at
whatever was chosen today.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
|
||
|
|
f0a775d83c |
docs: restate the review gate around oracles rather than people
The gate asked for a third party; there isn't one. What it was actually for was catching what the author's model misses, which needs an oracle that is not the author's judgement — not necessarily another person. Records the four substitutes in order of how little they depend on already knowing what to look for, marks differential fuzzing done, and states the technique's blind spot plainly: it can only find bugs where the two implementations DIFFER, so a flaw both share by design (like the recovery-split secrecy defect, found by reading) and anything TypeScript implements alone are both invisible to it. Those need mutation testing and adversarial framing, which is what remains. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
991c71189e |
test: differential fuzzing against Python, as the substitute for outside review
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
|
||
|
|
dd60eabbcf |
docs: bring the cutover gate list back in line with what is actually ported
Gate 5 still listed api, semantic, QR and index0 as exclusions, and the non-gates section still said TS "deliberately does not emit" index0 — all untrue as of today. Two exclusions remain, both choices rather than unfinished work: PGP RSA (not reproducible, so refusing beats diverging) and the Textual TUIs. Also marks the 2026-08-17 self-review blocker as done — all ten findings are closed, as are all seven from the 2026-08-18 audit — and notes that transport is no longer a blocker for the browser extension now that the API exists, leaving only the design question of whether an extension should talk to the API over loopback or embed the core. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
fbb9a6a7d3 |
docs: record the index0/atlas port
Notes the two Python quirks replicated on purpose (str(None) in required fields, and preserving a disagreeing integrity_hash) and the one deliberate design difference (funnel-level diffing instead of per-call-site emission), so a future reader finds the reasoning rather than rediscovering the choice. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
c6cb85e840 |
feat(vault): port index0/atlas, and keep it current on every mutation
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 |
||
|
|
64b27d7bf9 |
docs: record the recovery-split secrecy fix and the remaining decision
Old shares still recover, so nothing is broken — but shares produced before the fix remain a guessing oracle for whoever holds one, and cannot be rotated without changing the secret. Flagged as worth acting on only where a share may have been exposed or the secret has low entropy, rather than as blanket advice. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
7f095cba86 |
feat(agent): port job profiles and recovery split; fix Shamir's sub-threshold secrecy
Recovery split is Shamir over GF(257), one polynomial per secret byte, shares
encoded as sprec1:<label>:<threshold>:<total>:<index>:<digest>:<payload>.
Verified interoperable in both directions on real shares, including multi-byte
UTF-8 and payloads containing colons: Python recovers TS-produced shares and
TS recovers Python-produced ones. Confirmed end to end by splitting a live
parent seed 3-of-5 with the TS binary and recovering it in Python.
**Python's coefficients were derived from the secret, and that is now fixed.**
`_coef` HMAC'd the secret to produce every polynomial coefficient, which made
the entire share set a deterministic function of it. Shamir's defining
property is that any sub-threshold set of shares is information-theoretically
independent of the secret; deriving coefficients from the secret destroys it,
because one share becomes an offline verifier — guess a secret, re-run the
split, compare. Demonstrated recovering "hunter2" from one share of five at
threshold three, before changing anything. It also meant re-splitting produced
byte-identical shares, so a leaked share could never be rotated out without
changing the secret.
The determinism was load-bearing in the wrong way: coefficients were
recomputed inside the per-share loop, so they HAD to be reproducible or the
points would not lie on one polynomial. Fixed by hoisting them out and drawing
from `secrets`. The share token format is unchanged and `recover_secret` is
untouched — recovery is Lagrange interpolation over (x, y) pairs and never
recomputes coefficients — so old shares keep recovering and both versions
interoperate. Zero interop cost for a real security property.
TypeScript never had the defect and its test suite pins it: reintroducing
secret-derived coefficients turns three tests red, including the guessing
attack. Coefficients use rejection sampling because 257 does not divide 2^16,
and a modulo draw would bias them.
Job profiles port straight across, with policy_stamp as the security-relevant
field: sha256 of the canonical agent_policy.json at creation, so a policy
tightened afterwards shows as a mismatch instead of leaving the job running
under rules nobody reviewed it against. The canonical form matches Python's
`sort_keys=True, separators=(",",":"), ensure_ascii=True` — including the
ensure_ascii escaping, which is easy to miss and would otherwise make a
non-ASCII policy hash differently in the two implementations. Verified
byte-identical stamps for both an empty policy and one with non-ASCII values.
An unreadable policy hashes to its own distinct value rather than to the empty
policy, so jobs read as stale rather than silently current.
Recovery drills use the same HMAC-chained log design as the audit log, with
tests that an edited record, a removed record and an unsigned appended record
are all detected.
Every Python API endpoint now has a TypeScript equivalent: UNPORTED_PREFIXES
is empty. The 501 mechanism stays, because the honest answer for a dropped
subsystem is 501 naming the feature rather than 404.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
|
||
|
|
727ddeff62 |
docs: record that Python's high-risk unlock writes the partition key to disk
agent_high_risk_unlock.json stores the session's partition_key_tag, and the partition file's encryption key is derived from exactly that value. While a session is live the partition therefore decrypts from disk with no factor, for anything that can read APP_DIR — and the session file sits beside the key envelope, so reading one implies reading the other. That is the guarantee the partition exists to provide. Documented at grant_high_risk_unlock, where it happens, rather than only in a tracking file. The exposure is inherent to on-disk session state in a CLI with no resident process, and encrypting the session under the master password or seed does not fix it — that would make the password alone sufficient, which is what the second factor is meant to prevent. The two real fixes are product decisions (drop the session model, or give Python a resident agent), so they are recorded rather than chosen. Interim guidance: short TTLs, high-risk-lock when finished, treat read access to APP_DIR as equivalent to holding the factor. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
0c48c1f9dc |
feat(agent): port high-risk partitions and approval gates
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
|
||
|
|
885cbe5665 |
fix(semantic): delete indexes written before secrets stopped being indexed
Fixing the writer does not rewrite files already on disk, so a profile that built an index before today still has one holding stored secrets in plaintext. Reporting a version string in a status field and expecting the user to notice is not a remedy for that. Both implementations now check the manifest's model_id on every read — status, search, and the API equivalents — and delete records + manifest when it predates the fix. Reading a stale index would also mean serving results derived from the secrets it should never have held, so search purges before it reads rather than after. Deleting is safe: the index is a derived cache, rebuilt from the vault in milliseconds, and the user is told to rebuild. A manifest that cannot be parsed is deliberately NOT treated as stale. "Cannot tell" must not mean "delete it", or every corrupt-manifest read would throw away a working index; that case is tested on both sides. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |
||
|
|
fdbdb12596 |
docs: record the API, CLI, QR and semantic ports, and the index secret leak
Also rewrites capabilities().not_yet_ported so each remaining entry says WHY it is not ported, since automation branches on that list: PGP RSA is a hard constraint (seeded prime search is not reproducible, so a port would derive a different key from the same seed), index0 is carried verbatim but not recomputed, and the agent authorization subsystems and Textual TUIs are choices. Flags one thing that needs a decision: existing semantic index files written before today still contain plaintext secrets, and the fix does not rewrite them. The model id distinguishes them, but relying on a user to read a status field is weak. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN |