hosystem Engagements

ho-04.12 — run signal semantics & Keychain modernization

created 2026-07-06
status complete
type ho-document
project sharibako
ho 04.12
kamae 5
shape ha

Fifth post-sweep hardening (fable sweep, 2026-07-03; Tier B candidates #2 and #7), plus the vault-scaffold defect found in the 2026-07-03 signed-install smoke. The theme is what happens when the world interrupts sharibako: a signal arrives mid-operation, the Keychain API ages out from under the provider, a vault arrives from a git clone without the directory a verb assumes.

This ho is -gated. Two of its changes cannot be verified by the test suite: Ctrl-C behavior against a real child process on a real terminal, and Keychain itemExists semantics under the signed entitlement. Both get verified on the signed install (scripts/install.sh) before this ho closes. The suite covers everything else through injectable seams.

In scope:

Out of scope:


Phase 1 —

Eight decisions, ratified in session (2026-07-06, following a code scout of SignalForwarder, KeychainAgeKeyProvider, and both Shell copies).

Decision 1 — temp-key signal hygiene (cleanup-then-reraise). KeychainAgeKeyProvider writes the decrypted age key to $TMPDIR/sharibako-key-<uuid> (0600) and scrubs it only on normal return — any fatal signal in that window leaks the plaintext key to disk, disclosed in SECURITY.md's Known Limits since the sweep. New behavior: for the life of the temp file, SIGINT/SIGTERM/SIGHUP/SIGQUIT are trapped by a handler that scrubs and deletes the file, restores the default disposition, and re-raises — the process still dies of the same signal, observably, after the key is gone. The prior art (InteractiveSelect._sigintRestoreAndReraise) was deleted with its widget in ho-04.11; this is written fresh, with the handler seam injectable so tests can drive it without signaling the test runner.

Decision 2 — stop forwarding SIGINT; keep the countdown. run's child shares sharibako's process group, so a terminal Ctrl-C is delivered to the child by the kernel already; SignalForwarder then forwards a second SIGINT, and npm-class tools treat the second as hard-abort. Rather than move the child to its own process group (which buys foreground/job-control complexity a secrets wrapper has no business owning), the forwarder stops forwarding SIGINT and only starts the escalation countdown — the child already has the signal. SIGTERM and SIGHUP keep forwarding (they target the wrapper alone), and SIGQUIT joins the forwarded set. Known cost, accepted: a targeted kill -INT <wrapper-pid> no longer reaches the child; SIGTERM is the targeted-shutdown path.

Decision 3 — repeated signal escalates instead of resetting. startCountdown() currently cancels and restarts the one-second SIGKILL countdown on every signal — mashing Ctrl-C postpones SIGKILL indefinitely, the inverse of what mashing means. New behavior: a signal arriving while a countdown is active sends SIGKILL immediately.

Decision 4 — liveness through the Process object. kill(childPID, 0) probes and kill(childPID, SIGKILL) fires against a raw PID that the kernel may have recycled between grace expiry and delivery. Both go through the Process reference (isRunning, terminate/interrupt where they fit, raw kill only guarded by isRunning), which cannot alias a recycled PID.

Decision 5 — Keychain itemExists off the deprecated API. kSecUseAuthenticationUI: kSecUseAuthenticationUIFail (deprecated since macOS 11) is replaced with kSecUseAuthenticationContext carrying an LAContext with interactionNotAllowed = true; SecItemCopyMatching statuses are branched at the provider (errSecSuccess and errSecInteractionNotAllowed mean present; errSecItemNotFound means absent; anything else surfaces as an error rather than a silent false). Dogfood gate: on the signed install, itemExists must still detect the item without firing Touch ID.

Decision 6 — findExecutable honors PATH, fixed dirs become the fallback. Both Shell (core) and CLIShell probe four hardcoded directories while the error text and CLAUDE.md say "on PATH" — the code and the contract disagree. Ratified: search PATH first, then the existing fixed list (which is what keeps GUI-launched contexts working, where PATH is minimal). The threat-model call — a poisoned PATH could serve a hostile age, but anyone who can edit PATH can alias sharibako itself — gets one honest sentence in SECURITY.md. The duplicated search logic stays duplicated for now; the two copies exist because Shell is internal to the core (noted in CLIShell's header), and unifying them is not this ho's business.

Decision 7 — -warnings-as-errors, sequenced after Decision 5. With the deprecation cleared, CI's build/test invocations and the pre-commit swift build hook gain -Xswiftc -warnings-as-errors, per languages-swift.md. Sequencing matters: adding the flag first would redden CI on the warning Decision 5 exists to remove.

Decision 8 — vault scaffold ensured at the write choke point. VaultLayout.createVaultLayout(at:) is called only from test fixtures — no production path ever creates shared/, and scopes/ exists only by the accident of createScope's withIntermediateDirectories: true. The first promote-to-shared in a fresh vault dies with a raw age "failed to write header" error (2026-07-03 smoke, reproduced). A vault synced by git has the same hole: git doesn't track empty directories, so a clone with no shared entries arrives without shared/. Fix at the choke point: encryptAndWrite ensures the destination's parent directory before invoking age, covering addSharedEntry, every scope write, and any future writer. createVaultLayout remains for fixtures; the fixture convenience is why the suite never saw the bug, and the new test runs against a deliberately unscaffolded vault.


Phase 2 — Execute

Single-session execution, no agent-task children — the 04.9-through-04.11 posture. Execution model per the operating discipline's implementation slot (strong-at-code tier; the decisions above are the spec). Four work areas:

Signal hygiene (Decisions 1–4). KeychainAgeKeyProvider gains the scrub-on-signal window around the temp-key lifetime; SignalForwarder's forwarded set becomes SIGTERM/SIGHUP/SIGQUIT with SIGINT countdown-only, escalation replaces reset, and child liveness goes through the Process object. RunFeedback lines stay truthful to the new behavior (a "forwarding" line must not print for a signal that isn't forwarded).

Keychain provider (Decision 5). itemExists rebuilt on kSecUseAuthenticationContext + LAContext.interactionNotAllowed; status branching at the provider with the unexpected-status path surfaced, not swallowed.

Toolchain and search (Decisions 6–7). PATH-first search in both Shell copies, error text verified against the new truth; then -warnings-as-errors into ci.yml and .pre-commit-config.yaml once the build is warning-free.

Vault scaffold (Decision 8). Parent-directory ensure in encryptAndWrite; unscaffolded-vault test.

Session

Testing and iteration approach

Extend suites in place. Signal work tests through seams, not signals: the scrub handler's action is a function the suite calls directly; forwarder policy (which signals forward, what a second signal does) asserts against an injected kill/feedback recorder rather than delivering real signals to the test process — the ho-04.5 precedent. Keychain itemExists branching tests at the OSStatus seam with the statuses enumerated. findExecutable gets a PATH fixture (temp dir prepended) proving PATH-first and fallback order. The unscaffolded-vault test creates a bare vault directory — no scopes/, no shared/ — and asserts the first shared write succeeds.

Done means


Phase 3 — Reflect

Seven of the eight decisions shipped as ratified. The dogfood gate — the reason this ho was gated — earned its place: it caught one decision built on a false premise and one pre-existing bug the suite structurally could not see.

What shipped as ratified: D1 (temp-key scrub-on-signal), D3 (escalate on repeat), D4 (Process-seam liveness), D6 (PATH-first search), D7 (-warnings-as-errors), D8 (vault scaffold at the encrypt choke point). All suite-verified; D1/D3/D4 also confirmed on the signed install.

D2 — reverted on a dogfood finding. D2 stopped forwarding SIGINT on the premise that the child already receives a terminal Ctrl-C from the kernel via a shared process group. The dogfood disproved it: a child that traps SIGINT never saw one under sharibako run. Foundation's Process spawns the child in its own process group, off the terminal's foreground group, so the kernel never delivers Ctrl-C to it — the wrapper's forward is the child's only SIGINT. Not-forwarding therefore left the child with no signal until the 5-second SIGKILL, a regression. Reverted to forwarding SIGINT/SIGTERM/SIGHUP; D3 and D4 kept (independent, and they passed). The real question D2 was reaching for — who owns the child's signals, and whether run should manage its own process group and terminal foreground (job-control complexity this ho deliberately declined) — is a decision, not a patch. It moves to ho-04.13 with a fresh Think phase. The shipped revert is correct, working behavior, not a placeholder.

D5 — fixed after the dogfood exposed a deeper bug. D5 modernized itemExists off the deprecated kSecUseAuthenticationUIFail onto an LAContext with interactionNotAllowed = true. The dogfood showed key generate still overwrote an existing Keychain key — because interactionNotAllowed tells the Keychain to treat any item that would require interaction as non-matching, so a .userPresence-protected key always reported errSecItemNotFound. The old deprecated API failed identically; the detection bug predated this ho and the suite never saw it (the Keychain path is coverage-excluded and needs the signed entitlement). The API modernization was worth keeping — it cleared the deprecation D7 depends on — but the mechanism was wrong. Fixed within this ho (not deferred: a key-clobbering data-loss bug is not something to let linger): existence is now probed with no data request and no auth context, since the access control guards the secret value, not the item's presence. Dogfood-verified on the signed install — key generate refuses an existing key with no Touch ID prompt, closing the clobber path.

Dogfood gate, both halves paid. (1) sharibako run against a trapped child: one Ctrl-C forwards exactly one SIGINT (child's trap fires once), a second during the countdown SIGKILLs immediately, and no sharibako-key-* survives in $TMPDIR after an interrupt. (2) itemExists detects the Keychain item without firing Touch ID. Build is warning-free and CI enforces it. Full suite green (578 tests); swift-format/swiftlint --strict clean; coverage above the CI floor (≈94%). SECURITY.md's temp-key Known Limit narrowed to the uncatchable SIGKILL residual.

Process note. Two of the eight ratified decisions did not survive contact with the signed binary. Both survived the test suite — which is exactly the seam the dogfood gate exists to cover, and the argument for keeping high-stakes, platform-boundary decisions gated on a real install rather than green unit tests. The response for D2 (a genuine design question) is a new ho; the in-ho fix for D5 (a bug meeting a ratified goal the mechanism failed to reach) is the correct place, since the ho was still open and its own gate demanded it.

A follow-on incident during the gate: an early Half-2 probe used key generate against the still-installed pre-D5 binary, whose broken itemExists clobbered a throwaway dev vault's Keychain key. No real data lost. It is the D5 bug demonstrated end to end, and the reason the fix landed here rather than later.


Authored: 2026-07-06 (Think phase ratified in session; code scout of SignalForwarder, KeychainAgeKeyProvider, Shell/CLIShell preceded ratification).

Rendered from the corpus, verbatim · source on GitHub →

ingested: sharibako @ a97b22af9b61 · ho-system @ 79e96b801a13 · the glossary · the colophon