Vendors two rules from github.com/dmmulroy/anti-slop into
js/tools/oxlint/anti-slop and wires oxlint into the workspace. This is the
first JS linter in the repo.
TWO of the project's fifteen rules are enabled. The rest were measured
against this codebase and rejected, with reasons recorded in
oxlint.config.ts, because "why is this off" is the question a future reader
will have. Briefly: no-conditional-empty-object-spread has 77 hits that are
all correct, since that idiom is REQUIRED by exactOptionalPropertyTypes;
no-unsafe-dictionary-type and no-runtime-typeof have 176 hits that are
overwhelmingly the agent's untrusted socket protocol, where a precise type
would be false confidence and the per-field typeof checks ARE the boundary
validation both rules ask for; three more have zero hits because the suite
already uses real dependency seams rather than module mocks.
The two that are on catch the same move: an assertion that manufactures
certainty the code never established. That is the failure mode a vault cannot
afford, because a wrong belief about a decrypted record's shape is a wrong
belief about someone's secrets.
They find 44 sites, and no-widen-then-assert finds none. Deliberately NOT
wired into CI yet: a gate that lands red is a gate people learn to ignore.
The findings get triaged and fixed first, then the CI step goes in green.
Kept deliberately revertible and separate from any change it motivates:
nothing under packages/*/src imports it, it is dev-only, and the shipped CLI
bundles with no externals so it cannot reach the published artifact —
verified, the rebuilt bundle contains no reference to it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
With every Windows test finally passing — 1239 passed, 0 failed — the last
red was the critical-coverage gate: manager.py at 59.80% against a 60.00%
floor, on Windows only. It measures 60.74% on Linux.
The gap is not missing tests. Windows runs MORE of them (1239 against 1223)
and still covers less of that module, because manager.py holds POSIX-only
paths that cannot execute there at all. A floor checked on Windows therefore
measures platform reachability rather than test quality, and fails by two
tenths of a point for a reason no test author can act on.
So the floor is measured on Linux, the same reasoning as the lockfile check
earlier in this session: a property of the codebase is one question about one
artifact and wants asking once, on a canonical platform, not re-derived per
matrix entry where platform differences make the number wobble. Every
platform still RUNS the full suite and the 20% overall floor; only the
per-module floor is Linux-measured.
Recorded separately, because the gate being right does not make the number
good: a 2970-statement module central to the application sits at 60% with no
headroom, and its uncovered part is almost all interactive prompt handling.
Raising it honestly means extracting logic out of the input() loops, not
writing tests that drive prompts.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
The last two Windows failures were not POSIX-assuming tests. The tests were
right and the application was wrong.
`parse_palette_command` split its input with `shlex.split`, which defaults to
POSIX mode where a backslash is an ESCAPE character. On Windows that quietly
destroys every path a user types:
C:\Users\me\Temp\exports -> C:UsersmeTempexports
So doc-export, export-field, db-export, db-import, parent-seed-backup and
totp-export all wrote to a mangled relative path — or nowhere — and then
reported success. A user asking the TUI to export their vault on Windows got
a success message and no file. The two failing tests were reporting exactly
that, from the one platform that runs them.
Both palettes now share `split_palette_args`, which uses non-POSIX splitting
on Windows and strips the quotes that mode leaves on a quoted token, so paths
with spaces still work. v3 imports it from v2 rather than keeping its own
copy, since two parsers that must agree will eventually not.
The `windows` argument is an explicit parameter rather than a bare os.name
check, so both branches are tested from either platform — including a test
that pins what POSIX mode does to a Windows path, so anyone "simplifying"
this back to shlex.split sees precisely what they are restoring. That
parameter is the actual fix for the class of bug: the reason this survived is
that the Windows branch could only ever run on Windows.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
The sweep in the previous commit assumed every one of these assertions
compares against a path the app normalizes through Path. That is true for
four of them — each was observed failing on Windows with a "\tmp\..." actual
— and false for the fifth. CreateProfileScreen stores backup_path as the raw
`.value.strip()` from its input, so the POSIX literal was already correct
and my change broke a test that had been passing on Windows.
Nothing local could catch it: the two forms are identical on POSIX, so the
assertion reads the same either way here. That is the whole trap in this
class of fix, and the only defence is to change an expectation because a
failure showed you the actual value, not because a neighbouring line looked
similar. Noted in the test so the next sweep does not redo it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
Fixing the first two path assertions let those tests run further and reach
three more of the same kind — a parent-seed-backup status string, a second
backup-request tuple, and a profile-restore path. They were unreachable
before because the test aborted at the first failure, which is how a
platform-portability problem hides behind itself.
Swept rather than fixed one at a time: every remaining `/tmp/...` literal
compared against app output in these two files now derives from Path. No-op
on POSIX, all 91 tests in both files still pass locally.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
Two of the four Windows TUI failures are literal `/tmp/...` comparisons
against paths the app echoes as the platform renders them — on Windows
"\tmp\seedpass-db-export.enc", which no POSIX literal will ever match. The
expected strings now come from Path, which is a no-op on Linux and macOS
(verified: all 91 tests in both files still pass locally) and correct on
Windows.
The other two are left red on purpose. One raises FileNotFoundError reading
back an `export-field nsec` target that was never written; the other asserts
False after `doc-export`. Both depend on palette argument parsing and file
writing on Windows, which I cannot reproduce here — and a fix I cannot run is
a guess dressed as a change. They are named in TODO.md instead.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
Adding `shell: bash` to the install step in the previous commit was right for
error propagation and wrong about which python it would get: under bash on
Windows a bare `python` resolves to MSYS2's, which has no pip, so the step
died with "No module named pip" where it had previously worked under pwsh.
Same shape as the test-runner fix, so the same answer: try candidates and
keep the first that can actually run pip, including the pythonLocation that
setup-python exports without putting first on PATH, and fail loudly naming
what was tried. It also echoes the interpreter it picked, so the next
surprise of this kind is a 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
The Tests workflow reported SUCCESS on Windows while its own log read
"2 failed, 1114 passed". On Windows a multi-line `run:` block executes under
pwsh, where only the last command decides the step's result -- so
`coverage xml` succeeding hid `pytest` failing. Linux and macOS use bash -e
and did catch it, which is why this was invisible. `shell: bash` on that step
fixes it, and the same hazard is fixed in python-ci.yml's install step. An
audit of every Windows-capable multi-line step found no others; the installer
smoke steps are Unix-guarded and already set -euo pipefail.
A check that cannot go red is worse than no check, because it is trusted --
and this one has been trusted while six Windows tests failed underneath it.
Two of those six are now skipped on Windows: they assert POSIX mode 0o600,
which cannot hold where os.chmod only toggles a read-only flag and st_mode
reads back 0o666. Skipped rather than relaxed, because the property is real
and a weakened assertion would answer it falsely. Whether these files -- the
semantic index among them, which holds stored secrets -- are actually
protected from other users on Windows is an ACL question, now recorded.
The remaining four are TUI tests that assume POSIX paths. They are recorded
rather than patched: reproducing them needs a Windows host, and guessing at
path normalization and palette-argument parsing would mean changing tests to
match an assumption rather than a behaviour. They also interact with the
undeclared-textual finding -- those tests SKIP everywhere except Windows, the
one platform they were not written for.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
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
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
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
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
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
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
`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
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 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
`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
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
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
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