Companion to ARCHITECTURE.md. Each phase has explicit entry/exit criteria. Phases are sequenced; do not start phase N+1 until phase N's exit gate is green. The refactor assumes pre-1.0 status — no on-disk backward compatibility is required.
Issues are referenced by the W# IDs from ARCHITECTURE.md §2 and §4.
Status: COMPLETE (2026-07-16, branch
refactor/phase-0). All exit-gate checks pass. Vendored assets are pinned at @xterm/xterm@5.5.0 + @xterm/addon-fit@0.10.0 viascripts/vendor-update(dual-CDN cross-check). One note: task 6's regression test is a plain-bashtests/bash/test_*.sh(always runs, even without bats); the bats suite proper arrives in Phase 5.
Goal: the repo no longer ships broken code or fetches code from the internet at install time. A single local check script is the validation entry point — used by developers, by a pre-push hook, and (later) by CI as a thin wrapper.
-
Vendor xterm.js into the repo. (W5 closed.)
- Create
lib/ui/vendor/. - Pin specific versions (e.g.
xterm-5.5.0.js,xterm-5.5.0.css,xterm-addon-fit-0.10.0.js). Filename includes the version so a bump is a file rename plus aSHA384SUMSedit — both visible in the diff. - Record SHA-384 hashes in
lib/ui/vendor/SHA384SUMS(<sha384> <relpath>per line — same format asshasum -a 384). - Drop
socket.io-cliententirely. Not vendored; not needed. (Phase 1 replacesflask_socketiowith a hand-rolled WebSocket and switches the client to the browser-nativeWebSocketAPI.) - Update
lib/ui/index.htmland the install script's xterm block to reference the vendored paths. - Update
.gitignoreto un-ignorelib/ui/xterm/if needed, or move tovendor/and remove that gitignore line. - Remove the
download_latestblock and theINSTALL_XTERM=askinteractive prompt frominstall.sh.
- Create
-
Build
scripts/check. A single bash entry point with subcommands:scripts/check # run all stages scripts/check syntax # bash -n, python -m py_compile scripts/check lint # shellcheck, ruff/pyflakes scripts/check vendor # verify lib/ui/vendor/SHA384SUMS scripts/check tests # bats + python tests (when they exist) scripts/check secrets # grep for accidentally-committed secretsStage modules live in
scripts/check.d/*.sh. Each module:- Exits 0 if its checks pass.
- Exits non-zero (≥1) on failure.
- Skips with a
[SKIP] tool 'foo' not installedline if an optional tool is absent (and does not fail the overall run). - Required tools:
bash,python3,shasum/sha384sum. Optional:shellcheck,ruff,bats.
-
Pre-push hook installer.
scripts/install-hookswrites.git/hooks/pre-pushas a one-liner:exec "$(git rev-parse --show-toplevel)/scripts/check". README mentions how to run it. The hook is opt-in (we never auto-install behind the user's back). -
Thin CI wrapper.
.github/workflows/check.ymldoes exactly one thing: installshellcheck/ruff/batsand runscripts/check. No duplicated lint logic in YAML. Anyone outside GitHub can run the same script locally and get the same result. -
Fix the broken bash scripts.
ssh-rotateandssh-template-rotatereference undefined helpers. For Phase 0, choose one of:- Remove them with a stub script that prints "rebuilt in Phase 5" — preferred, keeps the codebase honest about what works.
- Inline minimal
validate_hostname/validate_template_name/logstubs in_ssh-unique-key.inc.shso they at least syntax-check, while making it loud that they're untested.
Document the decision in the commit. (W9 will be properly closed in Phase 5.)
-
De-duplicate
ssh-newarg parsing. Lines 13-14, 26-27, and 39-40 contain near-duplicate--commentblocks. One copy, one usage line. Add a test (undertests/bash/) that runsssh-new --helpand grep-asserts a single--commentmention. -
Bin
bin/__pycache__. Ensure it's gitignored; remove from the working tree. -
README updates.
- Replace the install xterm prompt section.
- Add a "Development" section pointing at
scripts/checkandscripts/install-hooks.
-
Pin provisioning-time connections to the stored scan. (Landed 2026-07-16, ahead of the phase.)
ssh-newandssh-user-rotatepreviously connected withStrictHostKeyChecking=accept-new, which accepted whatever key was presented (not necessarily the scanned one) and wrote it into the default~/.ssh/known_hosts— silently trusting a provision-time MITM on the immediate follow-on connection. All connections these scripts make now use-o UserKnownHostsFile=<host-uuid/UUID/known_host_keys> -o StrictHostKeyChecking=yes(ssh-user-rotaterewrites the hostname column to the connect alias in a temp pin file). The remainingaccept-newinlib/ssh-ui.py:/api/deployis covered by the Phase 1 rewrite + Phase 4bin/ssh-deploy(see Phase 4 task 4). -
UUID derivation over
<type> <base64>. (Landed 2026-07-16, ahead of the phase.) Previously the code hashed only the base64 column and ARCHITECTURE.md claimed the full<host> <type> <base64>line — both wrong. Nowsha256("<key-type> <base64-key>")of the best key; hostname column stripped. Pre-1.0: existinghost-uuid/<sha256>dirs no longer match the new derivation — re-provision (no migration shipped).
scripts/checkruns locally on a fresh clone and exits 0.scripts/check vendorsucceeds againstlib/ui/vendor/SHA384SUMS.install.shmakes zero network requests (verified by running it withcurl/wgetshadowed to fail).git grep "validate_hostname\|CONFIG_DIR\|validate_template\|get_hosts_using_template" bin/returns nothing OR returns only inside_ssh-unique-key.inc.sh(the stubs).bash -nclean on everybin/*script.git grep "accept-new" bin/ install.shreturns nothing (provisioning connections are pinned; task 9).
Goal: the web UI runs on the system python3 with zero pip dependencies. lib/requirements-ui.txt is deleted. lib/venv/ is no longer created.
The existing lib/ssh-ui.py is replaced wholesale by a stdlib-only implementation. Because the old code is monolithic anyway, this phase doubles as the Python module split — we build the new layout from the start rather than splitting an inherited monolith later.
lib/
├── ssh-ui.py ← entry point: starts the server
└── ssh_ui/
├── __init__.py
├── server.py ← ThreadingHTTPServer + dispatch (~150 LOC)
├── auth.py ← token gen, cookie set, @require_auth (~80 LOC)
├── ws.py ← RFC 6455 handshake + frame codec (~200 LOC)
├── pty_manager.py ← spawn/reap, reader loop, grace timer (~150 LOC)
├── handlers.py ← one function per route (~250 LOC)
├── identity_scan.py ← FS walk → dict; the get_identities/get_templates_list logic (~120 LOC)
├── subprocess_runner.py ← single wrapper around subprocess.run for bin/ssh-* (~60 LOC)
└── validators.py ← input validators (Phase 2 ties them to bash twins)
No file over ~250 lines. Each module has a tests/python/test_<name>.py companion (added in Phase 2 — Phase 1 ships the modules without exhaustive tests but with at least one smoke test per file).
- Stdlib HTTP server.
http.server.ThreadingHTTPServer+ a small dispatch table mapping(method, path)→ handler function. Path params (/api/templates/<name>) handled by route regex compiled once at startup. - Static SPA.
lib/ui/index.htmlbecomes a static file served directly. Jinja2 references removed:{% if xterm_enabled %}→ JS feature-detects (or a/api/capabilitiesendpoint).{% for identity in identities %}table →app.jsrenders into the table afterfetch('/api/identities').result_output/result_statusmodal → replaced by client-side toast tied to API call results.
- Hand-rolled WebSocket.
lib/ssh_ui/ws.py:- Validates the
Upgrade: websockethandshake, computes theSec-WebSocket-AcceptSHA-1 response (stdlibhashlib), sends the 101. - Implements RFC 6455 frame parsing and writing for text frames (opcode 0x1) and pings/pongs (0x9/0xA). Binary not used.
- Enforces a max frame size (e.g. 64 KiB) to bound memory.
- One thread per connection; reads via
select.select(...)so both PTY-fd and websocket-fd can drive the loop. - Includes a small fuzz suite in
tests/python/test_ws.pyagainst malformed frames.
- Validates the
- Plain-WebSocket client.
lib/ui/app.jsdropsio.connect(...)and usesnew WebSocket(...). Message envelopes match ARCHITECTURE.md §8 "WebSocket protocol". - Auth.
auth.pyprovides:verify_request(handler) -> bool(checks cookie viahmac.compare_digest).@require_authdecorator on every handler.- Handshake path for the one-time URL token (same model as today).
- WebSocket handshake calls
verify_requestbefore upgrading.
- Subprocess wrapper.
subprocess_runner.pyexposesrun_cli(name, *args, json=True)that:- Builds argv from
bin/<name>+ args (no shell, no string concat). - Captures stdout/stderr.
- Parses stdout as JSON if
json=True. - Returns a
resultdict the handler maps to an HTTP response. - Phase 4 adds the
--jsonflag to the CLI scripts; until thenjson=False.
- Builds argv from
- Delete the unused. Remove
flask,flask_socketio,gevent,eventlet,webbrowser(?),requirements-ui.txt, thevenv/bootstrap frombin/ssh-ui. The newssh-uiis one line:exec python3 "$LIB_DIR/ssh-ui.py" "$@". - CSP header. Every response includes
Content-Security-Policy: default-src 'self'; script-src 'self'; img-src 'self' data:; style-src 'self'; connect-src 'self' ws://*:* ws://localhost:* ws://127.0.0.1:*. No'unsafe-inline'; inline styles inindex.htmlare moved tostyle.cssas part of this phase.
python3 lib/ssh-ui.pyruns on macOS and Linux with system Python (≥ 3.10), nopip installof anything.wc -l lib/ssh_ui/*.py— no single file over ~250 lines.- All current UI features work: list identities, create identity (xterm flow), connect (xterm flow), rotate user key, delete user, list/create/delete templates, view history. Manual smoke-test checklist documented in
tests/manual/phase-1.md. grep -r "flask\|jinja\|gevent\|eventlet\|socketio" lib/ ssh-ui.py install.shreturns nothing.
Goal: every input crossing a trust boundary goes through one validator per language. Bash and Python validators are tested against the same golden corpus.
bin/_validators.inc.sh+lib/ssh_ui/validators.py. Paired modules exposing the same function set with identical semantics:validate_user_host,validate_hostname,validate_user,validate_template_name,validate_key_comment,validate_comment_text,validate_uuid,validate_key_type.- Each function: takes a string, returns the string unchanged on success,
errs (bash) or raises (Python) on failure. No silent transformation.
- Golden corpus.
tests/validators/inputs.tsv—<validator>\t<input>\t<expect: accept|reject>triples covering: nominal input, leading/trailing whitespace, embedded newlines, NUL bytes, shell metacharacters, very long strings, Unicode tricky cases (RTL override, zero-width joiner). Tests in both bash (bats) and Python (unittest) load the corpus and verify both languages agree. - Route every endpoint through the validators. Replace the ad-hoc
re.match/"".join(c for c in name if ...)calls inhandlers.py. Replace the per-script regex sanitisers in the bash CLI withvalidate_*calls. - Structured config emitter for
--comment(W2 closed). Write the comment text tohost-uuid/UUID/notice.txt; the config line becomesMatch host %h exec "cat ~/.ssh/unique_keys/host-uuid/UUID/notice.txt". No interpolation of user text. hmac.compare_digesteverywhere a token is compared. Currentlytoken == AUTH_TOKEN; replace with constant-time compare. (Minor, but cheap.)/deploypassword handling. Move tempSSH_ASKPASSscript creation into$XDG_RUNTIME_DIRwhen present (memory-backed on Linux), else into~/.ssh/unique_keys/tmpwith 0700.os.O_CREAT | os.O_EXCL | O_NOFOLLOWto defeat races. Delete in afinallyblock, not after a bare success path.- CSP, X-Content-Type-Options, X-Frame-Options. Add
nosniffandDENYheaders to every response. - WebSocket frame-size bound, per-IP rate limiting on
/ws, message-rate limit per connection. Defends against the trivial DoS where a malicious page on the user's machine repeatedly opens WSs to localhost.
- One validator per concept;
git grep "re.match\|^.join.*isalnum" lib/ssh_ui/returns nothing. - Golden corpus passes in both bash and Python.
- Three injection vectors (a
--commentpayload that escapes, a hostname containing$(...), a template name with../) are encoded as regression tests; they fail without the validators and pass with them.
Goal: the on-disk layout is a versioned, validated artifact. ssh-doctor walks the tree and reports anomalies.
-
Introduce
$BASE_DIR/SCHEMA. Contains the integer2.ensure_base_dirswrites it on init. (W7 closed.) -
Migrate
history.log→history.jsonl. Phase 1 may temporarily keep both; this phase removes the legacy file.ssh-historyreads JSONL; one-shot in-place rewrite of any existing v1 log. (W6 closed.) -
bin/ssh-doctor. Walks$BASE_DIR, checks: perms (0700/0600/0644 as schemed), every symlink target is inside$BASE_DIR, everyknown_host_keysparses, everyidentityis either real-file or valid-symlink, no orphanby-key/by-hostsymlinks, SCHEMA matches expected. Reports anomalies; exits non-zero on any failure. -
CI fixtures. A
tests/fixtures/tree representing: clean install, after-provision, after-rotation, after-template-create.scripts/check fixturesrunsssh-doctoragainst each. -
Resolve W1 (UUID under partial scans). Three options on the table:
- (a) Refuse to create new identity if the scan returns fewer than N (e.g. 2) keys.
- (b) Switch UUID derivation to
sha256(sorted(all-keys))(over<type> <base64>lines, hostname column stripped — consistent with the Phase 0 task 10 derivation fix). Partial scans cannot collide with a previous full scan; the diff check inssh-newalready covers the rest. - (c) Keep current; document.
Recommendation: (b). Cleanest, no thresholds to tune, no extra UX. Costs: a layout incompatibility — fine, we're pre-1.0. Decide and implement.
ssh-doctorruns clean on every fixture tree in CI.- Existing-install detection: running
ssh-doctoragainst a v1 layout prints a clear "schema v1 detected; runssh-doctor --migrateto upgrade orrm -rf ~/.ssh/unique_keysand re-provision" message. cat $BASE_DIR/SCHEMAreturns2on a freshly installed tree.
Goal: every state mutation goes through a bin/ssh-* script with --json output. Python is pure orchestration.
- Add
--jsonmode to every state-mutating script. Output an object per significant event to stdout; final line is the result object. Human-readable progress goes to stderr. - Move standard-template key generation from
handlers.pytobin/ssh-template generate-keys --json. Today the PythonPOST /api/templatesforstandardtype invokesssh-keygendirectly, bypassinglog_event. After this, every state mutation passes through bash and lands inhistory.jsonl. - Move
/api/user/delete'sshutil.rmtreetobin/ssh-del --force --json. Same rationale. - Move
/api/deploy's directssh-copy-idinto a newbin/ssh-deploythat handles theSSH_ASKPASSsetup and the post-run cleanup. Python becomes a thin caller.ssh-deploymust pin viaUserKnownHostsFile+StrictHostKeyChecking=yeslikessh-new(Phase 0 task 9) — this retires the lastaccept-new, currently inlib/ssh-ui.py. - Strict WebSocket message validation at handler entry. Discriminated union on
op; reject unknown ops; validate every field against the validators from Phase 2.
git grep "subprocess.run\|subprocess.Popen" lib/ssh_ui/shows only calls intobin/ssh-*.git grep "ssh-keygen\|ssh-keyscan\|ssh-copy-id" lib/ssh_ui/returns nothing.- Manually running
ssh-historyafter a sequence of UI operations shows entries for every mutation.
Goal: every bash script is consistent, lint-clean, and tested.
- Rebuild
ssh-rotatefrom scratch against the current_ssh-unique-key.inc.sh. Use the symlink-updating logic fromssh-newas the template. bats tests for: simple rotation, rotation whenby-keysymlinks include slashes, rotation refusing when keys haven't changed. (W9 closed.) - Rebuild
ssh-template-rotateusing a newget_hosts_using_templatehelper that walkshost-uuid/*/<user>/identitysymlinks and filters by template name. - Audit every script for
set -ecorrectness. Pipelines,readinsidewhile,grep | true. Verify each script's intended behaviour on partial failures. - Replace
sed -i.bakpatterns with structured config helpers.config-set <file> <key> <value>that reads → parses → writes atomically. Defeats the regex-escape minefield inssh-conf. - Convert
cat file | while read ltowhile read l < file. Avoids subshell-variable-scoping pitfall. - Make every script honour
--json. From Phase 4 we need it on mutating scripts; in this phase finish the read-only ones (ssh-history,ssh-template list) too, for symmetry.
shellcheckclean on everybin/*andinstall.shwith no excluded rules.batssuite (tests/bash/) covers: provision, re-provision (idempotence), delete, rotate, template rotate, backup+restore round trip,--jsonoutput well-formed.
Goal: every defended threat in ARCHITECTURE.md §2 has an automated test that would fail if the defence regressed.
For each defended threat (A–F):
- A: per-host uniqueness. Provision two fixture hosts. Assert that the two
identity.pubfiles have distinct contents. - B: no public-key correlation. Same as A but also assert that no file under
$BASE_DIR/host-uuid/<host1>/contains text equal to any file underhost-uuid/<host2>/. - C: MITM on re-engagement. Provision a fake host (write a
known_host_keysby hand). Runssh-newagainst a script that emits different keys. Assert non-zero exit and that the originalknown_host_keysis untouched. - D: filesystem permissions. Walk
$BASE_DIRafter every operation in the bats suite; assert every dir is 0700, everyidentityis 0600, every*.pubis 0644. - E: web UI auth. Start the server, hit
/api/templateswithout a cookie — expect 401. With a wrong token — 401. Right token — 200. Open a WebSocket without the cookie — fail handshake. - F: supply chain.
scripts/check vendoris already part of the check script; add a test that mutates one byte of a vendored file and assertsscripts/check vendorexits non-zero. - W1 regression. Construct a
known_host_keyswith ed25519+ecdsa+rsa. Simulate a re-scan returning onlyrsa. Assert behaviour matches the Phase 3 decision (e.g. recompute-from-sorted-all-keys still matches → identity preserved).
- All threat tests pass in CI on every commit.
- A failure of any of them blocks merge.
| Phase | Focus | Approx duration | Blocks |
|---|---|---|---|
| 0 | Stop the bleeding, vendor JS, local check | 1 wk | everything |
| 1 | Drop Flask: stdlib rewrite + module split | 2 wk | Phase 2+ |
| 2 | Security hardening + paired validators | 1-2 wk | Phase 4 |
| 3 | Formal schema + ssh-doctor | 1 wk | Phase 4, 5 |
| 4 | UI/CLI seam (--json everywhere) |
1-2 wk | Phase 5 (loosely) |
| 5 | Bash cleanup + rotation rebuild | 1 wk | Phase 6 |
| 6 | Threat-model regression tests | 1 wk | — |
Total: ~8-9 weeks of focused work. Phase 3 and Phase 4 can overlap partially; Phase 5 can begin once Phase 4 has landed --json on the scripts it touches.
Deliberate scope cuts; revisit post-1.0:
- Multi-machine sync. Sharing
unique_keys/across machines. Workaround today:ssh-backupto encrypted store on Syncthing / iCloud / etc. - TOFU bypass on first scan. (X2.) Pinning the very first scan to an out-of-band-verified key would require user workflow change.
- Same-user code reading the store (X1). Out of scope as a deliberate platform gate; if it matters, the answers are platform isolation or hardware-bound keys via
ssh-keygen-sktemplates. See ARCHITECTURE.md §2 for the rationale. - Agent forwarding policies. Per-host config can do this via
ssh-conf+ForwardAgent. - Non-OpenSSH clients. PuTTY, mosh, etc.
- Windows support. The PTY path uses
pty.fork()which is Unix-only. - Remote (non-localhost) web UI. Would require TLS, real auth, CSRF tokens.