Commit Graph
2423 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 5 7b5730910e Write the oversized request with backpressure, so the refusal can be read
The daemon fix landed but the test still failed on macOS and Windows, and the
remaining fault was the client. It blasted a megabyte in a single write, so
the moment the daemon capped the line and hung up, the write raised EPIPE —
and Node destroys a socket on error, discarding the refusal that was already
inbound. Ignoring the error was not enough: by then the socket was gone.

It now writes in 64KB chunks and honours the return value of `write`, waiting
for `drain` rather than pushing into a full buffer, and stops as soon as a
reply arrives. That is what a real client does, and it leaves room for `data`
to fire between chunks.

Worth stating plainly: the daemon was right the whole way through this. It
capped the line and refused, on every platform. Two layers of test artefact —
treating a client-side EPIPE as fatal, then writing without backpressure —
made correct behaviour look broken on the two platforms nobody had run.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-20 14:55:20 -04:00
thePR0M3TH3ANandClaude Opus 5 73ac93a9f1 Deliver the oversize refusal before hanging up, and pin a POSIX-only test
Two left from the platform run, one real.

THE DAEMON HUNG UP WITHOUT SAYING WHY, ON macOS ONLY

The line-length cap did `socket.write(refusal)` and then `socket.destroy()`.
destroy() tears the socket down immediately and discards whatever is still
buffered — on Linux the write usually reaches the kernel first and the peer
sees "request too large", on macOS it does not and the peer sees an
unexplained close. The refusal itself worked on both; only the reason was
lost, and only on one platform, which is why nothing noticed until this suite
ran there.

Now `end(payload, cb)` and destroy from the callback, so the bytes are
flushed before teardown, plus a `refused` flag so a peer still pushing the
rest of its megabyte is ignored rather than re-buffered.

The test that caught it was itself half the problem: it treated its own EPIPE
as fatal, when a client's broken pipe is the expected consequence of a server
correctly hanging up mid-write. Fixed in the previous commit; this one fixes
what that then exposed.

AND ONE TEST ASSERTING POSIX BEHAVIOUR WITHOUT SAYING SO

`honours backslash escapes outside single quotes` called parseCommandSpec with
no platform flag, so it read process.platform and asserted POSIX semantics on
Windows — where backslash is a path separator and deliberately is not an
escape. It now passes `{ windows: false }`, because that is the branch it is
about.

Windows went 15 failures to 1 to 0; macOS 2 to 1 to 0.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-20 14:47:24 -04:00
thePR0M3TH3ANandClaude Opus 5 e2e9aa851e Fix what three platforms found: two shipped bugs, four unportable tests
The platform workflow's first run produced seventeen failures on Windows and
macOS, where the JavaScript suite had never executed. Two were real defects
in shipped code.

A CONTAINMENT CHECK THAT REFUSED EVERYTHING ON WINDOWS

`resolveWithinProfile` compared `target.startsWith(root + "/")` with a
hardcoded forward slash. On Windows `resolve()` returns
`C:\profile\file.txt` and the comparison asked for `C:\profile/`, so it
matched nothing: document export and import refused every path, including
legitimate ones. It failed CLOSED, so nothing leaked — the feature simply did
not work. Now uses `relative()`, which needs no separator of our choosing.

A COMMAND PARSER THAT ATE WINDOWS PATHS

`parseCommandSpec` treated backslash as a POSIX escape unconditionally, so
`--exec "C:\Program Files\tool.exe"` was spawned as the command `C:Program`
with an argument `Filestool.exe`. Every Windows path handed to --exec or
--stdin-to was mangled before the spawn. This is the SECOND instance of this
exact bug today — the Python TUI palette had it too — which is what a whole
class looks like rather than a one-off.

`windows` is an explicit parameter, not a `process.platform` read, so both
branches are tested from either platform. That parameter is the actual fix
for the class: the reason these survived is that the Windows branch could
only ever run on Windows.

THE REST WERE TESTS, NOT CODE

- `/bin/true` does not exist on macOS (it is /usr/bin/true), so a spawn failed
  with ENOENT and the refusal looked like the daemon's crash-safety fix having
  regressed. Now uses process.execPath, which exists everywhere by definition.
- The oversized-request test treated its own EPIPE as fatal. The daemon caps
  the line, answers and hangs up; on macOS that hangup lands while the client
  is still pushing the remaining megabyte. Correct server behaviour was
  failing the test on one platform and passing on the other.
- Three sink tests wrote `#!/bin/sh` files. Shebangs and /bin/sh do not exist
  on Windows. Now Node scripts run by the interpreter already running the
  tests.
- Eight `0600` assertions and two clipboard tests are guarded by documented
  constants in test/helpers/platform.ts. Windows has no POSIX mode bits and
  the runner has no clipboard.

The skips state a gap rather than closing it, and TODO.md now records that the
Windows permissions question grew from two Python tests to ten across both
languages — including the vault index and the semantic index, which hold
secrets. With TypeScript becoming the only implementation that is a shipping
question, not a test-suite one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-20 14:39:52 -04:00
thePR0M3TH3ANandClaude Opus 5 4edf50e5de Attest the bundle on three platforms; give agents two gates with meanings
Adds ts-platform.yml and finishes the local-CI split.

THE DIVISION OF LABOUR

Properties of the CODE — cross-implementation agreement, fixture drift,
differential fuzzing — are asked once, on Linux, because asking them three
times measures nothing extra. ts-platform asks the questions where the
platform IS the variable: does the bundle build, lint, test, run and install
on a machine that is not the one we develop on.

That is not theoretical. Every CI failure today that survived local checking
was a platform difference: MSYS2 shadowing `python` on PATH, pwsh discarding
a non-final exit code, portalocker needing pywin32, shlex eating the
backslashes out of Windows paths in the TUI. None are visible from Linux, and
all of them would have reached users' machines rather than ours. With
TypeScript becoming the only implementation, the bundle working on Windows
stops being a nice-to-have.

The workflow sets `shell: bash` for every step, including Windows, for the
reason found today: under pwsh only the last command in a multi-line block
decides whether a step failed.

ts-parity's inline bash smoke test is replaced by the Node one, so a single
smoke test now runs locally, in parity, and on all three platforms — and it
checks that revealing an entry twice gives the same secret, rather than that
some output appeared.

REPRODUCIBLE BUILDS, AS AN EXPERIMENT

A second job compares the bundle's SHA-256 across the three platforms,
marked continue-on-error. Two builds on one machine are already
byte-identical, so the cross-platform question is real rather than a fantasy.
If the three agree that is an invariant worth promoting to required; if they
do not, the interesting part is why, and neither answer is worth blocking a
merge over before we know which.

TWO GATES WITH MEANINGS

AGENTS.md now says an agent may not report a task complete or push until
ci_prepush passes, and may not call a branch merge-ready until ci_parity
passes — plus the two rules that make those gates trustworthy: invoke tools
through the repository rather than off PATH, and never add a check that
cannot fail. Both rules are written with the evidence from today attached,
because a rule with its reason is one people keep.

Verified by running ci_parity end to end before this commit: 689 JavaScript
tests, 1132 Python tests, cross-implementation 41/41, fixtures clean, three
fuzz seeds with no divergences across 1050 compared cases each.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-20 13:53:39 -04:00
thePR0M3TH3ANandClaude Opus 5 72b3e52f50 Delete 38 assertions that were never necessary, and the bug one was hiding
I expected the 35 remaining findings to need a VaultIndex remodel. They did
not. `vaultIndexSchema` and every entry schema are `.loose()`, which already
gives both types an index signature — so `index["_system"]`, `entry["label"]`
and assignment to `Record<string, unknown>` all compile with no cast at all. I
checked before rewriting anything, and the answer was that 38 of the 44 were
simply redundant, copied from each other rather than needed.

That is the linter's real value here: it did not find a design flaw, it made
me test an assumption I would otherwise have acted on. The remodel would have
been work done to satisfy a belief that was false.

Removing them let the compiler see three things the casts had been hiding:

- `nextTotpIndex` filtered on `type === "totp" || kind === "totp"`, and TS
  reported the second half as unreachable. It is: withKind() populates kind
  from type at parse time and the union is discriminated on kind, so the
  fallback could never fire. Now filters on the discriminant alone.
- `ensureIndex0Payload` always ran normalizeIndex0 and then returned `Dict`,
  throwing the guarantee away, so all three callers asserted it back with
  `as unknown as Index0`. It now returns an `Index0Payload` that carries what
  the function established, built in that shape rather than asserted into it,
  and the type propagates through compactIndex0Payload and
  rebuildCanonicalViewsPayload.
- `sign()` in recoveryDrills declared a DrillRecord parameter it never used
  as one — it HMACs a canonical serialization — which forced the verifier to
  assert a shape it had not established while checking a signature.

Also `rebuildIndex0Checkpoints` typed to the one field it reads, and
console.ts asking `"isTTY" in this.out` rather than asserting a shape onto a
WritableStream.

One assertion remains, in entrySchema, and it is structural rather than
evidential: z.preprocess types its output from an inner union deliberately
wider than Entry, and no zod spelling expresses "parses to a superset, hands
back the narrow type". Disabled inline with that written down.

44 -> 0. The lint step is now in ci_prepush.sh, and the gate runs green in
44.5 seconds. 689 tests still pass.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-20 13:40:42 -04:00
thePR0M3TH3ANandClaude Opus 5 e9d49d10bc Stop 13 jobs firing on a README edit, and delete an SBOM check that never ran
Two GitHub-side changes, both about not wasting runs.

PATH FILTERS. tests.yml (nine jobs across three OSes and three Pythons) and
python-ci.yml (four) both triggered on every push and every pull request with
no path restriction, so editing a markdown file spent thirteen jobs. They now
trigger on the Python implementation and the files that pin its environment.
ts-parity keeps triggering on src/** as well as js/**, deliberately: Python is
the reference implementation the port is validated against, so a change there
can break parity even when no TypeScript moved.

THE SBOM STEP IS GONE. It invoked `@cyclonedx/cyclonedx-npm@latest
--package-lock-only`, which is the NPM tool and reads package-lock.json — a
file this pnpm workspace does not have and never will. It could not have
worked on any run. It then swallowed the failure with `|| echo "non-fatal"`
and uploaded with `if-no-files-found: warn`, two independent layers stopping
it from going red, and its log said "No files were found with the provided
path" every time.

Removed rather than pinned. Pinning the version of a tool that cannot read
this repository's lockfile fixes the wrong problem, and a security check that
cannot fail is the same anti-pattern as the pwsh exit-code bug fixed earlier
today — worse than absent, because it is trusted. `pnpm audit --audit-level
moderate` stays and does fail on findings. A real bill of materials wants a
pnpm-native generator, tracked in TODO.md.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-20 13:26:50 -04:00
thePR0M3TH3ANandClaude Opus 5 864229bad1 Add local CI: a preflight that refuses to lie, and a gate under a minute
Today cost roughly sixteen push-and-wait cycles discovering serially what one
local run could have surfaced at once. This is the local half of that, built
around one rule: a local gate is worth having only if it reaches the same
verdict CI would.

THE PREFLIGHT IS THE POINT

scripts/ci_preflight.py checks that this machine agrees with the lockfiles,
and refuses everything downstream when it does not. That is not theoretical
hygiene. Run right now it says:

    black 26.1.0 != locked 24.10.0
    pytest 9.0.2 != locked 8.4.1
    coverage 7.10.1 != locked 7.10.2

which is exactly today's failure: `poetry run black --check .` passes here and
fails on GitHub, and an earlier commit titled "satisfy the black gate" had
already reformatted five files with the WRONG black, making the gate worse
while looking like a fix. A green local gate that disagrees with CI is not a
weak check, it is a harmful one, because people stop reading the real result.

It is stdlib-only and run by the system interpreter, so it works when the
environment it is checking is broken -- which is when it is needed. And it
only ever CHECKS. scripts/dev_sync.sh repairs. A gate that silently fixes
your environment hides the drift it exists to surface.

TOOLS COME FROM THE REPOSITORY, NEVER FROM PATH

Every invocation is `poetry run ...` or `pnpm -C js ...`. A bare tool name is
precisely how a local gate ends up disagreeing with CI, so the preflight
inspects those environments rather than PATH.

THE BUDGET IS THE DESIGN

Measured here: preflight 2.6s, lock check 0.6s, black 2.5s, typecheck 7.3s,
core vitest 24.0s, determinism subset 5.4s — about 44s. What that excludes
was measured too: the cli suite alone is 176.3s, three times the whole
budget, and full pytest is ~350s. Those go in ci_parity.sh, which is what you
run before calling a branch ready. A hook that takes minutes becomes
`git push --no-verify` inside a week, at which point it is a control everyone
believes in and nobody runs.

The cli suite is slow for a good reason — real daemons, real sockets, real
subprocesses instead of mocks — so the answer is to run it less often, not to
make it lie faster.

THE SMOKE TEST IS NOW NODE, NOT BASH

js/scripts/smoke-cli.mjs replaces an inline shell test that used mktemp,
export and `test -n`, and therefore could only run on Unix — leaving the one
artifact users actually execute never smoke-tested on Windows. Every Windows
failure today was a Unix assumption leaking. It also checks more than the
original: that the bundle runs with no node_modules in sight, that the secret
honours the requested length, and that revealing twice yields the same value,
because determinism is the product rather than an incidental property.

The lint step is written but commented out: `pnpm -C js lint` still exits
non-zero on 35 known assertions, and shipping a red check into the push gate
is how gates get routed around. It goes in when it goes green.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-20 13:21:38 -04:00
thePR0M3TH3ANandClaude Opus 5 74a5c23dfb Clear the linter's built-in findings, keeping the one that was deliberate
Six warnings from oxlint's default rules, five of them genuinely redundant:
`...(x ?? {})` in an object literal, where spreading undefined is already a
no-op, and a `Boolean()` wrapping an `if` condition.

The sixth is kept. `for (const held of [...this.held.keys()]) this.forget(held)`
looks like a pointless array copy, and oxlint is right that for..of accepts an
iterable directly — but forget() deletes from this.held, so the spread is
what makes the loop iterate a snapshot instead of the map it is mutating.
Disabled on that line with the reason written next to it.

That distinction is the whole reason to read lint output rather than apply
it: a rule that is correct in general was wrong about the one site here where
it mattered, and the site it was wrong about locks every held seed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-20 13:02:55 -04:00
thePR0M3TH3ANandClaude Opus 5 de43ace259 Type two functions to what they read, removing nine assertions
Nine of the gate's findings were not assertions at all in spirit — they were
two function signatures narrower than their callers, paid for at every call
site.

`recomputeIndex0Stats` took a bare `Dict` while all five of its callers hold
a real `Index0`, so each one laundered it through `as unknown as Dict`. It
reads exactly three fields, so it now says so: a Pick over events,
checkpoints and heads. Any Index0 satisfies it, a partial one still does, and
the five casts are gone.

`exportBackup` took `Record<string, unknown>` and is genuinely called two
ways: with a live VaultIndex from three places in the CLI and API, and with a
round-tripped backup payload from the tests. It treats either as an opaque
JSON document to canonicalize and encrypt, so the signature now names both
shapes instead of making the majority caller pretend to be the minority one.

No type surgery and no behaviour change: the same values reach the same code,
they just no longer have to lie about what they are on the way in. 44 -> 35.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-20 12:58:17 -04:00
thePR0M3TH3ANandClaude Opus 5 51855ddea6 Validate high-risk partition records before deriving secrets from them
The first real finding from the new lint gate, and the one that justified
turning it on.

`decryptPartition` proves a partition file is AUTHENTIC — it decrypts under
the partition key and Fernet carries an HMAC, so nobody without the factor
wrote it. It proves nothing about a record's SHAPE: it checks only that each
value is an object. Both hydration paths then asserted the result straight
into `Entry` and handed it to materializeSecret.

That was the one place in the vault where a record reached secret derivation
without passing the schema. Every entry arriving through the index goes
through parseVaultIndex; the partition path did not — and the partition is
where the ssh keys, pgp keys and seeds live. Python writes these files and
validates nothing, so a shape divergence between the implementations, a
renamed field or a kind added on one side, would not have raised anything
here. It would have derived from whatever fields it found and returned a
confidently wrong secret, which is worse than an error by a wide margin.

`parsePartitionRecord` validates per RECORD rather than per file, so one odd
record fails one entry instead of locking the user out of every secret in the
partition. The error names the entry and nothing else, because the record IS
the secret — asserted, along with the fact that a record of an unknown kind
is still carried through untouched per spec §8.2, which validation must not
quietly have turned into a refusal.

Typing the API's hydration path properly removed the round trip it used to
make — Entry to Record and back — so the fix subtracts assertions rather than
trading one for another.

Also clears the twelve dead imports the gate found. One of them was not dead:
the `cause` binding in decryptPartition is discarded on purpose, because the
reason a Fernet decrypt failed distinguishes "wrong key" from "corrupt blob"
and surfacing that hands a caller an oracle for probing the factor. It is now
`catch {}` with that written down, which says it better than an ignored name.

Chained assertions: 44 -> 42. Tests: 689 passing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-20 12:52:58 -04:00
thePR0M3TH3ANandClaude Opus 5 ba4e92d454 Restore the lint gate after verifying it removes cleanly
Reverts the revert. The round trip was the point: the claim that this is easy
to back out is now tested rather than asserted. `git revert` of the single
gate commit followed by `pnpm install` left no oxlint.config.ts, no tools/
directory, no reference in package.json, and a workspace that typechecks and
passes all 684 tests without it.

Same instinct as killing a live mutation run earlier to prove its SIGTERM
cleanup worked: an escape hatch nobody has opened is a guess.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-20 12:36:40 -04:00
thePR0M3TH3AN 7ca39df513 Revert "Add a lint gate for assertions that fabricate type evidence"
This reverts commit 1ff47caf15.
2026-08-20 12:32:38 -04:00
thePR0M3TH3ANandClaude Opus 5 1ff47caf15 Add a lint gate for assertions that fabricate type evidence
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
2026-08-20 12:32:32 -04:00
thePR0M3TH3ANandClaude Opus 5 0509c48def Measure the critical-coverage floor on Linux, not once per platform
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
2026-08-20 11:49:17 -04:00
thePR0M3TH3ANandClaude Opus 5 81b0117c37 Fix: every TUI palette command taking a path was broken on Windows
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
2026-08-20 11:37:17 -04:00
thePR0M3TH3ANandClaude Opus 5 6fcd94b51a Revert one path assertion I changed on inference rather than evidence
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
2026-08-20 11:21:57 -04:00
thePR0M3TH3ANandClaude Opus 5 967b33764a Sweep the rest of the hardcoded POSIX paths in the TUI tests
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
2026-08-20 11:09:26 -04:00
thePR0M3TH3ANandClaude Opus 5 c52104da38 Derive two TUI path expectations from Path instead of hardcoding POSIX
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
2026-08-20 10:56:43 -04:00
thePR0M3TH3ANandClaude Opus 5 01960e9234 Choose the install interpreter by capability, like the test script does
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
2026-08-20 10:45:34 -04:00
thePR0M3TH3ANandClaude Opus 5 527d7be5c5 Make the Tests workflow able to fail on Windows, and skip two POSIX-only tests
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
2026-08-20 10:37:35 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-20 10:23:08 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-20 09:56:37 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-20 09:50:44 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-20 09:43:23 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-20 09:36:26 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-20 09:30:26 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-20 09:19:58 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-20 09:14:19 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-20 09:05:13 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 23:59:38 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 23:59:16 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 23:49:01 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 23:34:21 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 23:24:11 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 23:11:23 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 22:58:53 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 22:39:46 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 22:25:40 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 21:54:18 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 21:35:10 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 21:08:59 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 21:03:55 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 20:38:35 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 20:10:31 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 19:37:43 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 19:34:23 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 19:26:24 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 19:24:39 -04:00
thePR0M3TH3ANandClaude Opus 5 e1ea9c2b48 Teach the mutation harness two blind spots it had, and close what they found
The harness only mutated block-form guards (`if (cond) {`), so every guard
written as a one-liner was invisible to it — which turned out to be most of
the load-bearing ones. src/highRisk.ts generated ZERO mutants and printed as
a clean sweep. Three changes to the tool:

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

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

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

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

All six targets now sweep to zero survivors.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 18:19:15 -04:00
thePR0M3TH3ANandClaude Opus 5 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
2026-08-19 17:30:42 -04:00