ho-04.11 — CLI trust boundary & scan resilience
Fourth post-sweep hardening (fable sweep, 2026-07-03; Tier B candidate #6 plus the scan-resilience deferral from ho-04.9). The theme is the boundary between the CLI and whoever is driving it: what a script can trust about exit codes, what a human can trust about prompts, and what neither should have to fear from one bad file in a scan root.
A pre-session scout confirmed the findings and softened one: the --force/--yes "naming drift" is in fact a nearly coherent grammar — --force authorizes a destructive variant, --yes skips a confirmation, and key generate needing both is the grammar working. The decision ratifies and documents rather than renames.
Out of scope:
runsignal semantics and Keychain modernization — Tier B #2 + #7, ho-04.12 (-gated).- Config layer — Tier B #10, waits for ho-05.
Phase 1 —
Seven decisions, ratified in session.
Decision 1 — clean fails loudly on non-interactive stdin. Without --yes, clean prompts; on non-TTY stdin the prompt reads nothing, declines by default, and exits 0 having cleaned nothing — a hygiene false positive for every script that calls it. New behavior: --yes absent and stdin not a TTY → user error naming the fix (--yes), the same pattern init's notInteractiveTerminal established. Declining by default is only safe when a human actually declined.
Decision 2 — .aborted exits 130 and drops the "Error:" prefix. An abort currently exits 0 while printing "Error: Aborted." — the text says failure, the code says success, and both are wrong: an abort is the user choosing not to proceed. New userAbort = 130 in the exit taxonomy (the 128 + SIGINT shell convention; what fzf and shells already mean by user-cancel), message rendered as plain Aborted. on stderr.
Decision 3 — no-echo interactive value entry as the default path. --value transits argv and shell history — the convenient path is the leaky path, inverting the product's stated posture. add/rotate on a TTY with no value flag now prompt Value: with echo off (termios); non-TTY without a flag keeps valueInputRequired. --value stays for scripts that accept the tradeoff; SECURITY.md gains an honest paragraph naming the argv/history exposure and pointing at the prompt and --from-stdin as the hygienic forms.
Decision 4 — init's scope-type prompt re-asks on an unrecognized answer. A typo'd scope type silently became project-dev. Same loop the scope-ID prompt got in ho-04.9: empty answer accepts the default (a deliberate Enter), an unrecognized non-empty answer re-prompts with the valid options listed.
Decision 5 — scan skips bad markers and reports each one. Ho-04.9 made markers stricter; since then one malformed or hostile marker aborts an entire scan — and status, clean, and scope resolution all ride on scan. Materializer.scan now returns a ScanReport carrying loaded markers plus per-marker failures (path + reason); verbs print each failure as a stderr warning and proceed. The hostile-marker signal ho-04.9 created becomes a visible finding instead of a denial-of-service on the vault's own tooling. Direct loadMarker paths keep throwing — acting on a bad marker is an error; surveying past one is not.
Decision 6 — flag grammar ratified, not renamed. --force authorizes the destructive variant (add, materialize, key generate); --yes skips an interactive confirmation (clean, key generate). The two consents are genuinely different and key generate needs both. Help text states the grammar uniformly; the README's command section carries a line. No renames — no breaking change to purchase consistency that already exists.
Decision 7 — InteractiveSelect is removed, tests and all. Dead since ho-04.6's plain-prompt redesign; nothing references it but its own tests. Decision 3's echo-off reader is written fresh (~a dozen termios lines) rather than kept hostage to a whole selection widget. Git history retains the widget if a future flow wants one.
Phase 2 — Execute
Single-session execution, no agent-task children — the 04.9/04.10 posture. Three work areas:
Prompt and exit contract (Decisions 1, 2, 4). CleanCommand gains the TTY guard; SharibakoExitCode gains userAbort = 130 and ErrorReporter renders .aborted without the error prefix; InitCommand.promptScopeIDAndType gains the scope-type re-prompt loop.
Value entry and dead code (Decisions 3, 7). A fresh SecureValuePrompt (echo-off termios reader, injectable for tests) wired into ValueInput as the TTY default for add/rotate; InteractiveSelect.swift and its test file deleted; SECURITY.md paragraph on argv/history exposure.
Scan resilience (Decision 5). Materializer.scan returns ScanReport { markers, failures }; scan/status/ScopeResolver/resolveMarker(forScope:) consume it; ScanCommand and StatusCommand print failure warnings to stderr (and carry them in --json output).
Documentation (Decision 6). Flag help text unified; README command section updated.
Testing and iteration approach
Extend suites in place: CleanCommand non-TTY spec (injectable TTY check), abort-exit-code specs at the ErrorReporter seam, init scope-type re-prompt via scripted lineReader, ValueInput TTY-prompt routing via injected reader, scan skip-and-report with one good and one hostile marker in a root (victim assertions that status still lists the good scope). InteractiveSelectTests deleted with its subject; coverage recomputed after removal.
Done means
- All seven decisions implemented, each covered by a test that fails on the old behavior.
- A scan root containing one hostile marker still yields
scan/statusoutput for every healthy scope, with the hostile marker named on stderr. - Full suite green;
swift-format lint --strictandswiftlint lint --strictclean; warning-free build; coverage at or above the CI floor with ho-04.8's named-exclusion list. - filled in.
Phase 3 — Reflect
Did the hold? All seven decisions implemented as ratified. Four things surfaced:
- Changing a default changes every caller's environment assumptions. Making the secure prompt the flagless default turned two existing specs (
ValueInputandAddCommand's "neither flag" tests) TTY-dependent — run from a real terminal they would have prompted and hung. Both got injectable prompt seams and pin the non-TTY case explicitly. The new path was easy; the old paths' assumptions were the real work. - Decline-is-not-success applies to humans too. The ratified decisions covered the non-TTY false positive and the abort exit code; implementation extended the same logic to an interactive "n" at
clean's prompt, which now throws.aborted(exit 130) instead of returning success — matchinginit's collision confirm. scan --jsonchanged from a bare marker array to{markers, failures}— breaking for JSON consumers, and required: failures must travel in-band for scripts, not just on stderr.Materializer.swiftcrossed the 500-line file ceiling with the report plumbing; scan/status moved toMaterializer+Scan.swift, matching the existing ingest split.
Decision review. 130 flows through cleanly — ErrorReporter.report exits with the taxonomy code directly, ArgumentParser isn't in that path. Removing InteractiveSelect plus excluding the TTY-only SecureValuePrompt raised coverage: 94.28% against 04.10's 93.77%.
What broke that the tests didn't catch? Nothing at runtime; the lint layer again caught the structural debt (file length, one trailing- idiom).
Numbers. 560 tests in 67 suites (563/68 before — InteractiveSelect's suite left, the trust-boundary and scan specs arrived); coverage 94.28% by CI's computation with SecureValuePrompt added to the named-exclusion list (same justification class as TerminalDetector). One known deprecation warning (kSecUseAuthenticationUIFail) stands until ho-04.12's Keychain modernization, per the sweep plan.
Followups.
- ho-04.12 unchanged: run-signal semantics, Keychain modernization, then
-warnings-as-errors— dogfood-gated. - The signed-install smoke now also covers the echo-off prompt (
addwith no value flag on a real terminal) alongside the ho-04.8 encrypt path androtate --shared.
Authored: 2026-07-03 (Think phase ratified in session). Executed: 2026-07-03, same session.
Rendered from the corpus, verbatim · source on GitHub →