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
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
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
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
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 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
`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
Secret input:
- Values that are secrets can now be read from stdin: `entry add key-value
--stdin`, `entry add totp --secret-stdin`, `entry add document --stdin`,
and `entry modify --value-stdin`. Arguments are visible in shell history
and process listings, so the positional forms now warn (without echoing
the secret they are warning about) rather than silently accepting.
Packaging (the published package was broken):
- `exports` pointed at src/index.ts while `files` shipped only dist/, so an
installed package had no entry point; exports now points at the bundle.
- A `workspace:*` runtime dependency would have failed any real install. The
bundle inlines everything, so the package now declares no runtime
dependencies at all. Verified by packing the tarball, installing it into a
clean project and running the binary -- and CI now does the same on every
change, because this class of mistake is invisible to build and test.
- The workspace globs cover apps/* so a future extension cannot sit outside
CI.
- @types/node aligned with the advertised Node 22 runtime.
Release and supply chain:
- release-integrity now builds the CLI bundle into dist/ before checksums
are generated and signed, so the executable TypeScript artifact is covered
by the same manifest and cosign signature as the Python ones.
- A new javascript-supply-chain job runs `pnpm audit` and produces a
CycloneDX SBOM; the JS tree previously had neither.
Also documents two findings that are protocol-level and shared with Python,
where changing the port alone would break convergence: tombstone retention
allowing deletion replay after the 2048-entry cap, and SSH/PGP sharing a
derivation domain (same index yields the same Ed25519 key). Both need a
versioned change in both implementations and are recorded in the
compatibility matrix rather than silently patched here.
193 core + 70 CLI tests green; pnpm audit clean.
Co-Authored-By: Claude Fable 5 <[email protected]>
Gate 5 (feature parity) -- document import/export ported: `entry
import-document` reads a text file (label defaults to the file stem, type
from the extension) and `entry export-document` writes one back, reproducing
Python's filename sanitization, directory-vs-file destination handling and
overwrite refusal. That was the last concrete item; the three remaining
exclusions (PGP RSA, semantic, api) are now documented decisions rather than
open work, and none of them blocks cutover.
Gate 6 (packaging) -- js/packages/cli/build.mjs bundles the CLI into a
single self-contained ESM file with no runtime dependencies beyond Node 22,
writes a .sha256 beside it, and is wired up as the package `bin` and
`prepack` so npm install/pack produce a working command. --version now
reports a real version stamped at build time. CI builds the bundle and
smoke-tests it from a directory with no node_modules -- version,
capabilities, profile creation, entry creation, reveal -- then uploads it.
Verified locally that the bundle runs standalone outside the workspace.
Gate 7 (migration and rollback) -- docs/typescript_migration_guide.md: the
migration is a no-op (same profiles), so the guide covers what to back up,
how to rehearse against a copy with SEEDPASS_APP_DIR, the two behavioral
differences users will notice, the known exclusions, and seed-only recovery.
Rollback is proven rather than asserted: new cross-impl phase K drives a
profile through the TS CLI -- several entry kinds created, one edited, one
archived -- then checks Python reopens it, sees every change, derives the
same secrets, and can still write to it afterwards.
Also adds js/packages/cli/README.md documenting the reference-first output
model and the scoped-token workflow.
193 core + 49 CLI tests green; 36/36 cross-implementation checks green.
All eight cutover gates are now green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Fixtures show both implementations compute the same values. This adds the
missing half: proof they can read each other's on-disk profiles and
backups, using each side's real code paths.
scripts/cross_impl_check.py (12 checks, wired into CI):
A. Python creates a profile -> TS opens the index, derives every secret
identically, and unlocks it with the master password alone
B. TS creates a profile -> Python decrypts parent_seed.enc and the index,
recovers the same seed, derives the same secrets
C. Portable backups round-trip both directions (Python verifies the
checksum of a TS-written export)
D. Both implementations reject a bad-checksum mnemonic
Two real compatibility bugs it caught, both fixed:
- TS-created profiles were unopenable by Python. Python only honors the
recorded parent-seed KDF parameters when kdf.name is exactly "pbkdf2";
TS wrote "pbkdf2-sha256" with an empty salt and 100k iterations, so
Python fell back to its config defaults (200k, fingerprint salt) and
derived a different key. TS now writes exactly what
PasswordManager._build_seed_kdf_config does, and its own unlock path
honors the recorded salt and iteration count.
- TS accepted invalid BIP-39 mnemonics. Seed derivation takes any string,
so a typo'd phrase silently produced a *different* vault -- unopenable
by Python and unrecoverable by the user. Mnemonics are now validated
wherever they enter (profile creation and SEEDPASS_MNEMONIC), with an
error that says why it matters.
Also: docs/typescript_cutover_plan.md records the gate list for promoting
TS to main and retiring Python to legacy/reference -- P0 gates 1-4 are
green, 5-7 (feature parity tail, packaging, migration/rollback) are not.
The Python tree stays in CI after cutover as the compatibility oracle.
Regression tests for both bugs. 151 core + 46 CLI tests green,
12/12 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
- Portable backup (format_version 1): TS imports Python exports, re-exports
byte-identically under pinned nonce/timestamp, verifies the canonical
checksum, rejects tampering and unknown versions.
- .github/workflows/ts-parity.yml: typecheck + Node/jsdom parity suite on
js/ changes, plus a fixture-drift job that regenerates fixtures from
Python and fails if the committed ones don't match (manifest commit field
excluded by construction).
- packages/cli — seedpass-js MVP implementing plan section 9.3:
* reference-first output: entry list/get/search emit sp://entry/<id> refs
and metadata; secret-bearing fields become has_* flags
* 'entry reveal' is the only plaintext egress command
* 'use --clipboard|--exec|--stdin-to' delivers secrets to sinks; --exec
injects SEEDPASS_SECRET into the child env, never argv or stdout
* vault export/import over portable backups; capabilities --format json
* mnemonic comes from SEEDPASS_MNEMONIC env, never the command line
- CLI tests prove the blindness properties: derived password matches the v2
fixture, TOTP at pinned time, secrets absent from all default output and
from sink-delivery confirmations.
- 125 core parity tests (Node + jsdom) + 11 CLI tests green.
Co-Authored-By: Claude Fable 5 <[email protected]>
L5: dependency-audit.yml now generates a CycloneDX SBOM from
requirements.lock and uploads it. pip-audit answers "are there known CVEs
today"; an SBOM answers "which exact versions were in this build", which is
the question that arises when something is disclosed later against a release
already in the wild.
Verified against the real tool rather than written from memory -- the first
flag spelling (--output-format / --outfile) does not exist in cyclonedx-py
and would have failed in CI. The working form is
`--of json --output-reproducible -o <file>`, which emits CycloneDX 1.6 with
96 components and records [email protected], [email protected],
[email protected], [email protected], [email protected], [email protected] and
[email protected].0 at exact versions.
L4: torch/services/memory/index.js built its temp filename from Date.now()
plus Math.random() before an atomic rename -- predictable enough to
pre-create or symlink between the write and the rename. Now randomBytes(8).
Also renames the Math.random-derived value in relay-health.mjs from `nonce`
to `probeSuffix` and draws it from randomBytes(6). It is a public probe tag
used to correlate a publish with its readback, not a cryptographic nonce,
and nothing was wrong with it -- but the name invited someone to copy the
pattern somewhere it would matter.
torch has no test/ directory in the repo, so there is no suite to run for
these two files; both were syntax-checked and confirmed to import cleanly.
The SeedPass determinism gate is green (84 tests).
Not done, and recorded as a decision in TODO.md instead: torch/_backups/
holds 5240 tracked files across six dated snapshots. Untracking them is a
large deletion and not something to do as a side effect of an entropy audit.
They currently duplicate every security-relevant grep hit, and bare
`node --test` in torch/ discovers them and fails 18 tests from stale
snapshot code.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
- Regenerate requirements.lock using pip-compile to match CI expectation
- Fix brew install command on macOS to handle existing package
- (poetry.lock was already updated in previous commit)
Co-authored-by: PR0M3TH3AN <[email protected]>