ho-04.13 — run signal ownership
Responds to a finding in ho-04.12 (; ho-04.12 is closed).
D2 of that proposed that sharibako run stop forwarding SIGINT, on the
premise that the child already received a terminal Ctrl-C through a shared
process group. The signed-install dogfood disproved the premise and D2 was
reverted. This ho decides, from scratch, who owns the child's signals — and
the answer is: nobody should, because there should be no wrapper process at all
once the child starts.
The finding. Foundation's Process spawns the child in its own process
group, off the terminal's foreground group. A terminal Ctrl-C is delivered by
the kernel only to the foreground group — i.e. to the sharibako wrapper, not
the child. So the shipped 04.12-revert behavior (wrapper traps and forwards
SIGINT/SIGTERM/SIGHUP with a countdown → SIGKILL escalation) is the child's
only path to a terminal signal. It works, but it is a parent emulating a
terminal.
Out of scope:
- The broker/handler model and output redaction — parked (Decision 4); a -2 pivot, gated on shipping the whole project first.
TempKeySignalGuard(ho-04.12 D1) — kept untouched; it guards the decrypt window before the child spawns, unrelated to signal ownership.- Keychain / Touch-ID gating (ho-04.12 D5) — unchanged.
Phase 1 — · RATIFIED 2026-07-07
Decision 1 — Signal model: exec-replace (execve)
sharibako run replaces its own process image with the child via
execve("/usr/bin/env", …) after decrypt, env-compose, and key-release. No
parent exists for the child's lifetime, so the child inherits the wrapper's PID,
process group, and terminal foreground — every signal and terminal behavior is
native (Ctrl-C, Ctrl-, stdin reads, exit code), with zero signal-handling
code in run.
Three options were weighed:
- A — keep forwarding (the shipped 04.12 revert). Works, fully policy-tested
via the
ChildControllerseam. But the child sits in a background process group, so an interactive child that reads the terminal is stopped bySIGTTIN. Fine for non-interactive children (servers, builds, env-reading CLIs); wrong for a shell, a REPL, or a dev server's keypress menu. It also keepsrunowning a signal-forwarding babysitter forever. - B — real job control (
tcsetpgrp). Rejected. It is A plus job control (non-tty SIGTERM/SIGHUP still need forwarding), and adds SIGTTOU handling, foreground restoration on every exit path, and atcsetpgrp/setpgidseam exercisable only against a real controlling terminal — maximal complexity for a benefit C delivers by getting out of the way. - C — exec-replace. Chosen. The idiomatic Unix wrapper pattern (
env,nohup,exec,sudo -E): do the one job (inject env), then become the command. Correct for interactive and non-interactive children alike, and it deletes the entire signal subsystem rather than growing it.
Decision 2 — Exec seam: _run returns .ready, run() execs
_run stays a pure decision function: resolve → decrypt → compose env → return
RunOutcome.ready(env:command:cwd:) (replacing .ran(exitCode:)). The thin,
coverage-excluded run() shim performs the execve — the same tier where
Foundation.exit already lives. This keeps env composition (parent env overlaid
with scope secrets, scope wins) unit-testable in-process via the .ready
payload; only the untestable syscall moves behind the shim. A pure, tested
helper builds the argv/envp C arrays, so the excluded surface is just
chdir + execve. Do not exec inside _run — that would drop env
composition to dogfood-only.
Decision 3 — Double-SIGINT concern: retired
D2's motivation — npm-class tools treat a second SIGINT as a hard abort — was an artifact of the false shared-group premise. The child is not in the foreground group, so the kernel delivers it zero tty copies; under exec-replace the child owns the foreground and receives exactly the one signal the user sends. The concern does not survive the finding and is not carried forward.
Decision 4 — Broker/handler model + output redaction: parked
Instead of a one-time env injector, Sharibako could stay alive as a broker
mediating secret access over the child's lifetime (serve-on-request, audit,
refresh, revoke — Vault-agent-shaped). A broker is parent-bound forever and would
supersede exec-replace; it is a system- (Kamae 2) pivot, not a per-ho
change. Output redaction (scrubbing known secret values from the child's
stdout/stderr) is parked with it — it needs the same lifetime-parent and is
foreclosed by exec-replace. Both are deferred until the whole project ships.
Recorded as a GitHub issue (draft in Phase 2); parked code pinned at tag
parked/run-signal-forwarder.
Decision 5 — Security tradeoff: env plaintext under a Mac-at-rest model
run puts secrets into the child's environment in plaintext for the child's
lifetime — inherent to the 12-factor pattern it targets. Sharibako's boundary
is a Mac at rest: FileVault protects the vault files, age key, and any temp
key at rest; the injected env is protected only by macOS process isolation while
the machine is unlocked. This is not total security (a hostile same-session
process; no output redaction) and the stronger broker model is deliberately
deferred. Accepted and recorded in SECURITY.md (Phase 2).
Context — why the parent road existed (for the record)
Not a design choice; it fell out of the tools. In Swift/Foundation, spawning a
child is Process, and Process is a parent; exec-replace requires the raw
POSIX execve C API. And the _run → RunOutcome.ran(exitCode:) test seam was
built to assert outcomes in-process, which requires a function that returns —
i.e. a parent. Signal-forwarding was never decided; it was inherited from a
parent that existed for unrelated reasons. C undoes both.
Phase 2 — Execute
Branch ho-04.13 off main. The parked-code tag is already done (below), so
the branch point is just current main whenever execution starts.
The exec seam (Decision 2, made concrete)
Add — replace RunCommand.spawnAndWait(...):
_run: compose the environment andreturn .ready(env:, command:, cwd:).- A pure, tested helper builds
argv=["/usr/bin/env"] + commandandenvp= the merged env asKEY=VALUE, both NUL-terminated C-string arrays. run():chdir(cwd.path)(execvetakes no working directory), thenexecve("/usr/bin/env", argv, envp). It returns only on failure → map toCLIError.runSpawnFailed(child-missing still surfaces as env exiting 127, unchanged). Thechdir+execvecall is the only coverage-excluded part.
Delete:
Support/SignalForwarder.swift(theChildController/ProcessChildControllerprotocol + impl, the countdown/escalation, theforwardedset).Tests/SharibakoCLITests/SignalForwarderTests.swift.- The forwarding-only
RunFeedbackformatters (signalName,forwardingLine,countdownLine,sigkillLine) and their tests. - The
forwardSignalsparameter on_run, andRunOutcome.ran(exitCode:)(replaced by.ready(env:command:cwd:))._runstill returns.dryRun(names:)and now.ready(...);run()execs on.ready, returns on.dryRun.
Keep untouched: TempKeySignalGuard, the startupLine feedback,
--dry-run, scope resolution, decrypt, env composition.
Execution gotchas
execvepreserves ignored signals and the signal mask across exec. Verify no signal is leftSIG_IGNbefore exec.TempKeySignalGuard.teardown()runs athandle.release()(before spawn today), restoring defaults, so the tree should be clean — confirm it on every path.--passthrough parity..captureForPassthroughkeeps a leading--incommand;/usr/bin/envconsumes it. Passcommandthrough as-is;startupLinestill drops--for display only.- Re-check the coverage floor after deletions. Net effect is deleting coverage-excluded plumbing plus some tested code; re-run and confirm ≥90%.
Deliverable — SECURITY.md entry (draft, ratified framing)
runinjects secrets as environment variables (accepted tradeoff)
sharibako rundecrypts a scope's secrets, composes them into the environment, andexecs into the command. For the child's lifetime the secret values live in its process environment in plaintext.This is a deliberate tradeoff, not an oversight. Environment variables are plaintext by nature — any tool that consumes secrets via the environment (the pattern
runtargets) holds them in plaintext while it runs. Sharibako's security boundary is a Mac at rest: the vault files, age key, and any temp key are protected by FileVault at rest; the injected environment is protected only by macOS process isolation while the machine is unlocked and the process is live.We know what this does not cover: a hostile process running as the same user in an unlocked session can potentially observe the child's environment, and
rundoes not redact secrets a child prints to its own stdout/stderr. The stronger model — a broker that never places secrets in the child's env and mediates access at runtime — is understood and deliberately deferred (see the parked issue). We have thought about it; this is the tradeoff we ship.
Deliverable — GitHub issue (draft, post after review)
Title: Park: secrets broker/handler model + output redaction for run
Context
sharibako runis a one-time environment injector: it decrypts a scope's secrets into the child's environment and (as of ho-04.13)execs into the command, replacing the wrapper. Two stronger, related capabilities were considered and deliberately parked to ship the project first.1. Broker / handler model
Instead of dumping secrets into the child's env at startup, Sharibako stays alive as a parent and mediates secret access over the child's lifetime (serve on request, audit per access, refresh/revoke at runtime). Vault-agent-shaped. Benefits: secrets never sit in the child's environment, smaller blast radius, auditable access, runtime rotation. Cost: a lifetime-parent process — the architectural opposite of the exec-replace model
runnow uses, and a system-design-level pivot (it changes whatrunfundamentally is).2. Output redaction
A live parent could sit in the pipe between child and terminal and replace known secret values with
****before they reach stdout/stderr — closing the "child echoes a secret into CI logs" exposure. exec-replace forecloses this (no parent in the pipe). Bundled here because it needs the same lifetime-parent the broker does.Security tradeoff accepted in the meantime
runholds secrets in the child's env in plaintext for its lifetime, under a Mac-at-rest security model (see SECURITY.md). Known limits: same-session process exposure, no output redaction. Accepted to ship.Parked code
The signal-forwarding parent built in ho-04.12 (
SignalForwarder,ChildController/ProcessChildController, countdown/escalation) is reusable plumbing for a future broker parent. Pinned at tagparked/run-signal-forwarder.Prerequisite
Do not start before the project ships. A broker is a Kamae-2 revisit, not a per-ho change.
Deliverable — park the code · DONE 2026-07-07
Tag parked/run-signal-forwarder created and pushed at a97b22a (the last
commit with the full seam). Immutable — nothing more to do here.
git tag -a parked/run-signal-forwarder a97b22a -m "…" # done + pushed to origin
Done means
runexec-replaces viaexecve; no wrapper parent survives the child's start.SignalForwarder,ChildController/ProcessChildController, andSignalForwarderTestsdeleted; forwarding-onlyRunFeedbackformatters trimmed._runreturns.ready(env:command:cwd:); env composition and the argv/envp builder are unit-tested in-process.SECURITY.mdcarries the accepted-tradeoff entry.- The parked GitHub issue is posted (tag already exists).
swift build(warnings-as-errors),swift-format lint,swiftlint --strict,swift testall green; coverage ≥90%.- Dogfood gate passed (see below).
Verification and the dogfood gate
- The rhythm:
swift build(warnings-as-errors) →swift-format lint→swiftlint --strict→swift test→ coverage ≥90%. - Dogfood gate (signed install + real terminal — the only thing that proves
C's payoff): on
scripts/install.sh, runsharibako run -- <cmd>in a real terminal and confirm native behavior — Ctrl-C reaches the child directly, the exit code is the child's (128+signum on signal death), an interactive child reads stdin without stopping, and a keypress-menu dev server (Vite / Jest watch) behaves. Not done until this passes.
Phase 3 — · CLOSED 2026-07-08
-
Did the design hold? Yes. exec-replace behaves natively for interactive and non-interactive children alike. Verified under a real controlling terminal (
os.forkpty, default termios): Ctrl-C → SIGINT and Ctrl-\ → SIGQUIT reach the child directly, an interactivereadcompletes without SIGTTIN-stopping, the exit code is the child's (128+signum on signal death), and PID identity holds (the child's$$equals the PID the shell launchedsharibakoas — no wrapper parent survives). The signal subsystem is gone, not grown. -
Decision review —
.readywas the right seam._runreturning.ready(env:command:cwd:)andrun()doing theexecvekept env composition, scope-wins, the startup line, and marker resolution in-process testable (assert the returned value, spawn nothing) and held the coverage floor. The argv/envp builder sits correctly insideExecReplace(the dogfood-only, coverage-excluded file) — no separate boundary wanted.--passthrough and cwd behaved exactly as Think specified. -
What the syscall revealed — the mask, not the dispositions. The Think flagged that
execvepreserves the signal mask andSIG_IGN. The first implementation reasoned about dispositions (which turned out clean — only SIGPIPE/SIGXFSZ ignored, inherited from the shell) and missed the mask.libdispatchblocks signals process-wide to observe them via kqueue (theDispatchSourceSignalage-key guards), so at exec time the main thread carried a mask with SIGINT/SIGTERM/SIGHUP/SIGTSTP/SIGTTIN/… all blocked — andexecvehanded that mask to the child. Shipped as-is, a terminal Ctrl-C would have done nothing: exec-replace strictly worse than the forwarding parent it replaced. Fix:ExecReplace.resetSignalState()empties the mask (pthread_sigmask, since the process is multithreaded) and resets the terminating/job-control dispositions toSIG_DFLimmediately beforeexecve. -
What broke that the tests didn't catch — and why the gate is non-negotiable. Exactly the mask bug. It is invisible to unit tests (the exec path is dogfood-only by design) and to the
.readyseam tests (they never exec). The full lint/type/test/coverage rhythm was green with a broken Ctrl-C. Only the dogfood gate caught it — specifically the SIGTERM-to-self and Ctrl-C legs, which is why those legs exist. A green suite is not a shipping decision forrun. -
Coverage — neutral-to-positive, as predicted. 94.16% overall (floor 90%).
RunCommand.swift93.15% lines,RunFeedback.swift100%,ExecReplace.swiftexcluded (swapped in forSignalForwarderin ci.yml's named-exclusion set). -
Followups.
- Broker/handler model + output redaction parked in issue #7; do not start before the project ships (Kamae-2 revisit).
- The keypress-menu dev server leg (Vite/Jest watch) was covered by proxy — raw interactive stdin + native signal delivery under a PTY — not exercised directly against a real dev server. Low risk given the primitives all pass.
- Standing up the dogfood vault re-surfaced the known
VaultLayout.createVaultLayoutnever-called-in-production bug (initis interactive-only;key generatedoesn't scaffoldscopes/+shared/). Still owed a ho, unchanged by this one.
Appendix — fresh-session bootstrap
To execute this ho in a new Claude Code session, load and run:
Execute ho-04.13 (Think is ratified — do NOT relitigate exec-replace). Read:
@/hos/ho-04.13-run-signal-ownership.md (this doc — the plan)
@Sources/SharibakoCLI/Commands/RunCommand.swift (_run → .ready; run() execs)
@Sources/SharibakoCLI/Support/SignalForwarder.swift (delete)
@Sources/SharibakoCLI/Support/RunFeedback.swift (trim forwarding formatters)
@Sources/SharibakoCLI/Support/TempKeySignalGuard.swift (KEEP — do not touch)
@Tests/SharibakoCLITests/SignalForwarderTests.swift (delete)
@Tests/SharibakoCLITests/RunCommandTests.swift (swap .ran seam for .ready)
@SECURITY.md (add the accepted-tradeoff entry)
@CLAUDE.md @~/.claude/modules/languages-swift.md (conventions)
Deliverables: the exec-replace code change; the SECURITY.md entry; post the
parked GitHub issue. (Tag parked/run-signal-forwarder already exists — do not
recreate it.) Verify with the lint/test/coverage rhythm, then the signed-install
+ real-terminal dogfood gate. Branch ho-04.13 off main. Do not sign commits/PRs.
Authored 2026-07-06; Think ratified 2026-07-07 as the forward-only response to ho-04.12's D2 dogfood finding; executed and closed 2026-07-08 (exec-replace shipped; dogfood gate caught the libdispatch signal-mask inheritance).
Rendered from the corpus, verbatim · source on GitHub →