hosystem Engagements

ho-04.5 — Runtime injection (sharibako run)

created 2026-07-03
status complete
type ho-document
project sharibako
ho 04.5
kamae 5
shape ha

sharibako run [--scope <id>] [--dry-run] [--] <command> [args...] decrypts a scope's secrets into memory, spawns the given command with those values set in its environment, forwards stdio and signals, waits, and exits with the child's status. Nothing is written to disk. This is the second output verb the injection decision (kamae-2.1) committed to — the peer of materialize — and it closes the Class 4 workspace-file-reader exposure for consumers that can be wrapped.

What this is, and what it already isn't

The overview framed ho-04.5 as three deliverables — run, clean, and the SECURITY.md draft. Two of the three already exist:

run is the one piece nothing else needed — a brand-new process-spawning verb no other command's work touches. The overview said as much: its spec was to be authored "after ho-04 lands." So this ho is run alone. clean and SECURITY.md are treated as shipped; the only work against them here is a reconcile-at-close check that the doc's run/--dry-run sections match what actually ships.

The ho-04.4→04.6 lesson (validate rough before hardening; don't over-build a UI) applies only weakly: run has no interactive surface — it is argv passthrough after --. The half that bites is " the rough version before writing the full test matrix." That's built into the task.

Decision 1 — Child spawn: Foundation.Process

The injection decision described the mechanism as fork() + exec(). In Swift, Foundation.Process is that mechanism with the sharp edges handled: it sets the child's environment, inherits the parent's stdio when the standard handles are left at their defaults, exposes .processIdentifier for signal forwarding, and reports both terminationStatus and terminationReason (.exit vs. .uncaughtSignal) so the wrapper can propagate the child's fate faithfully. No raw posix_spawn. run spawns via Process, inherits stdin/stdout/stderr, sets .environment to the composed dict, and waitUntilExit()s.

Decision 2 — Vault Core addition: secrets(inScope:)

Add one public method to VaultCore (in the encryption extension, beside getValue):

func secrets(inScope scopeID: String) throws -> [String: String]

It loops inspect(scopeID) for the owned keys and calls getValue on each — which already resolves .link files to their shared targets — returning [key: plaintextValue]. This is the injection decision's get_all_secrets, named to match Swift's inspect/getValue neighbors. materialize does this same loop privately (loadVaultValues); run needs it at the CLI boundary, so it graduates to a public method. One age-key unlock (one Touch ID) per invocation covers every key in the scope.

Decision 3 — Environment merge: parent env overlaid, scope wins

The child's environment is ProcessInfo.processInfo.environment with the scope's secrets merged on top — on a key collision, the vault value wins. This matches the injection decision ("scope wins on conflict") and is what a developer expects: sharibako run should make the scope's OPENAI_API_KEY authoritative even if a stale one is already exported in the shell.

Decision 4 — --dry-run needs no age key

--dry-run prints the names of the secrets that would be set, one per line, sorted, then exits 0. It resolves those names via inspect (filenames only) — so, like clean, it needs no age key and triggers no Touch ID. Decrypting to print nothing but names would be pointless biometry. This gives the "safe summary I can paste to an agent" use case a genuinely zero-exposure path: no values are ever decrypted. --json output is deferred (overview default: one line per name; add JSON when a real user needs it).

Decision 5 — Exit-code propagation

run uses Foundation.exit(...) with the mapped code rather than letting ArgumentParser return, so the child's status is what the parent shell sees.

Decision 6 — Signal forwarding: SIGINT, SIGTERM, SIGHUP; 5s grace → SIGKILL

run installs handlers for SIGINT, SIGTERM, and SIGHUP (via DispatchSourceSignal, which composes with Process far better than C signal() and needs the default disposition set to ignore first). On receipt, it forwards the same signal to the child's PID, waits a 5-second grace period, then sends SIGKILL if the child is still alive. SIGKILL and SIGSTOP are uncatchable and are not in the set. SIGQUIT and SIGUSR1/2 are follow-ons only if a real use case surfaces (overview).

The process-group nuance, named so the executor doesn't chase a ghost. In an interactive terminal, Process does not put the child in a new session or process group, so terminal-generated signals (Ctrl-C → SIGINT, Ctrl-\ → SIGQUIT) are delivered by the tty to the entire foreground process group — the child already receives them directly. Explicit forwarding therefore matters mainly for signals delivered programmatically to the wrapper's PID (kill <sharibako-pid>, a process manager, a parent script). Double-delivery in the terminal case is harmless: the child is already handling its own SIGINT, and a forwarded second one arrives against a process that's already shutting down. Do not try to suppress it by creating a new process group — that would break the terminal Ctrl-C path, which is the common case.

Decision 7 — Memory scrub: minimal best-effort

The temp age-key file is already scrubbed-and-deleted on handle.release() (KeychainAgeKeyProvider.scrubAndDelete); run keeps that via the same defer handle.release() pattern materialize uses. For the decrypted values, v1 does nothing beyond letting them go out of scope. Swift String backing storage is immutable and copy-on-write — there's no reliable in-place wipe without restructuring the whole decrypt→env pipeline onto [UInt8]/Data, and even then the values still live in the child's environ for the child's whole lifetime, which is the point of the verb. The residual risk (recovering a wrapper-process copy from physical memory after a crash) is low and already stated honestly in SECURITY.md's "Known limits." We harden later if a real reason appears. No memset_s theater over [String: String] in v1.

Decision 8 — Flags and passthrough parsing

run takes --scope <id> as an @Option (not a positional, since positionals after it are the command), reuses ScopeResolver.resolve(explicit:startingFrom:) for the cwd walk-up when --scope is omitted (same resolution as materialize), and captures everything after -- as the command with ArgumentParser's passthrough capture (@Argument(parsing: .captureForPassthrough) var command: [String]). --dry-run is a @Flag. Reuses GlobalOptions (--vault, --age-key, --json, --verbose) unchanged.

Decision 9 — Edge cases

Execute

One . The work is a single bounded conversation — a new verb plus a one-method Core addition, no natural seam worth splitting (the Core helper and the command are written and tested together, and splitting them would force cross-task coordination for no gain).

Ho-04.5-AT-01 — sharibako run (claude-sonnet-4-6). Adds VaultCore.secrets(inScope:), builds RunCommand (spawn, env merge, stdio inherit, signal forwarding, exit propagation, --dry-run, --scope, passthrough parsing), registers it in SharibakoCommand, and tests the injection end-to-end against a real shell subcommand. Carries the rough-first discipline explicitly: get sharibako run -- sh -c 'echo $FOO' working and dogfooded before the full signal/exit test matrix. The task's final step reconciles SECURITY.md's run/--dry-run verb sections against what shipped — confirming, not rewriting; any real drift is surfaced to the practitioner rather than silently edited, because SECURITY.md is a trust document.

run shipped as specified. VaultCore.secrets(inScope:) + RunCommand + SignalForwarder landed in 5e174ee; the SECURITY.md reconcile (below) in 23db99a. Build clean, 362 tests green, both linters strict-clean. Dogfooded end-to-end against the real diary scope through the real Keychain and Touch ID after reinstalling the signed build. Every acceptance criterion met; both stop conditions cleared.

Did signal forwarding hold — Ctrl-C terminates the child cleanly, no orphan? Yes. Ctrl-C on run -- sh -c 'sleep 30' returned to the prompt immediately with no orphaned sleep. The immediacy is the diagnostic: waitUntilExit() returned at once rather than waiting out the 5-second grace, so the child died on the tty-delivered SIGINT directly — exactly the Decision 6 (no new process group; the child is in the foreground group and the tty signals it; the wrapper's forwarded second SIGINT is a harmless no-op against an already-dying process). No hang, no double-delivery crash. Stop condition #2 cleared. Not yet exercised against a long-lived dev server with children of its own (Node HMR, a Python autoreloader that forks workers) — the single-child case is proven; the process-tree case is a follow-on if a real wrapper leaks a grandchild.

Did Foundation.Process propagate exit codes and signal-death faithfully? Yes — no posix_spawn fallback needed. exit 3 propagated as wrapper exited: 3 through .exit/terminationStatus. Signal-death mapping (128 + signum) is proven by the self-signalling child test in the suite; .uncaughtSignal vs .exit distinguishes cleanly. Stop condition #1 cleared.

Is one Touch ID per run invocation tolerable in the edit-run loop? Tolerable for the single-invocation dogfood — one prompt, unlocks the whole scope, feels the same as get/materialize. Not yet stress-tested under a tight restart loop (dev-server crash → rerun → prompt again). The friction is real but bounded; the sharibako-agent daemon stays post-MVP unless the restart cadence proves it painful in daily use. Replan Checkpoint 1's question is answered "not yet" — no forcing pressure surfaced.

Does SECURITY.md read true against shipped behavior? Reconciliation surfaced drift, now fixed. Two spots claimed memset_s scrubbing of decrypted values (verb-section Mitigations + Known limits) that the shipped code deliberately does not do — Decision 7 forbids it; the only scrub is the temp age-key file wipe on handle.release(). Both rewritten to state what ships (values released, not wiped). The run/--dry-run no-file / no-decryption claims held and were confirmed accurate. (Surfaced to the practitioner rather than silently edited, per the trust-document discipline; the fix was approved before commit.)

Unplanned finding — the Keychain access-group gate. The swift build debug binary cannot reach the Keychain item (errSecItemNotFound): keychain-access-groups is a restricted entitlement macOS only honours for a codesigned .app bundle with an embedded provisioning profile, which swift build does not produce. The real Keychain/Touch ID path is only exercisable from the signed install (scripts/install.sh). This isn't a run defect — run's unlock is the same three lines as materialize — but it means CLI-level dogfooding of any Keychain-gated verb requires the signed build or the --age-key bypass. Worth remembering for future ho dogfood steps; not a change this ho makes.


Authored: 2026-07-03. Executed and closed: 2026-07-03.

Rendered from the corpus, verbatim · source on GitHub →

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