Skip to content

Phase 3: opt-in script post-processor (fail-open) - #54

Merged
initcore0 merged 2 commits into
mainfrom
feat/phase3-script-postprocessor
Jun 24, 2026
Merged

Phase 3: opt-in script post-processor (fail-open)#54
initcore0 merged 2 commits into
mainfrom
feat/phase3-script-postprocessor

Conversation

@initcore0

Copy link
Copy Markdown
Owner

Final Phase 3 item — completes the hackability phase. Pipe the final transcript through any executable just before insertion (your text on stdin, result on stdout).

Safety model (per the agreed design)

  • Off by default; runs only when enabled and a valid executable path is set.
  • ~2s timeout (SIGTERM → SIGKILL).
  • Fail-open: launch failure, timeout, non-zero exit, or empty/whitespace output → the original transcript is kept. A broken script can't drop or mangle dictation.
  • Runs after the secure-field guard, so it never sees password-field content.

Changes

  • ScriptOutcome (OpenWhispCore, pure): decides use-output vs keep-original from (stdout, exitCode, timedOut, launchFailed). Strips exactly one trailing newline, preserving other whitespace.
  • ScriptPathValidator (pure, injectable fs checks) for inline Settings validation.
  • ScriptRunner (app-side): Process + stdin write + background stdout read (no pipe-buffer deadlock) + timeout, resolving through ScriptOutcome.
  • AppState: scriptPostProcessorEnabled/scriptPostProcessorPath; runs at the single insertion choke point.
  • SettingsView: Script Post-processor section (Advanced) — toggle, file picker, live validation, trust warning.
  • Tests: +15. The CRLF test caught a real grapheme bug"\r\n" is one Character, so dropLast(2) ate the preceding char; fixed to a grapheme-correct single dropLast. 156 → 171. ScriptRunner also verified end-to-end against real scripts (uppercase / empty / exit-7 / missing / a 1s timeout that fires at ~1.01s, not 5s).
  • README + ROADMAP updated; Phase 3 marked complete.

swift test 171/171 · ./build.sh clean.

🤖 Generated with Claude Code

initcore0 and others added 2 commits June 23, 2026 18:04
Final Phase 3 item — completes the hackability phase. Lets power users pipe the
final transcript through any executable just before insertion.

- ScriptOutcome (OpenWhispCore, pure): given (stdout, exitCode, timedOut,
  launchFailed), decides whether to use the script output or keep the original.
  Fail-open by contract — launch failure, timeout, non-zero exit, nil exit, and
  empty/whitespace output all keep the original transcript. Strips exactly one
  trailing newline (the echo convention), preserving other whitespace.
- ScriptPathValidator (pure, injectable fs checks): empty / not-found /
  not-executable / ok, for inline Settings validation.
- ScriptRunner (app-side glue): spawns the script via Process, writes stdin,
  reads stdout off a background queue (no pipe-buffer deadlock), enforces a ~2s
  timeout with SIGTERM->SIGKILL, and resolves through ScriptOutcome.
- AppState: scriptPostProcessorEnabled / scriptPostProcessorPath settings; runs
  the script at the single insertion choke point (insertCompletedText), after the
  secure-field guard so it never sees password-field content. Off by default.
- SettingsView: "Script Post-processor" section (Advanced) — toggle, file picker,
  live path validation, and a trust warning.
- Tests: +15 (ScriptOutcome all fail-open branches + newline handling;
  ScriptPathValidator). The CRLF newline test caught a real grapheme bug
  ("\r\n" is ONE Character, so dropLast(2) ate the preceding char) — fixed to a
  grapheme-correct single dropLast. 156 -> 171. ScriptRunner additionally verified
  end-to-end against real scripts (uppercase / empty / exit-7 / missing / 1s
  timeout that actually fires at ~1.01s).
- Phase 3 marked complete; README + ROADMAP updated.

swift test 171/171; ./build.sh clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Security review of the script post-processor found two should-fix issues sharing
one root cause: stdoutData was read even when the background reader hadn't
finished (a data race on the timeout path), and a killed script that left a
grandchild holding the stdout pipe open would orphan the reader thread + FDs.

- Only read stdoutData AFTER readGroup.wait() returns success (establishes the
  happens-before edge). On the timeout path, force-close the read end to make the
  blocked readDataToEndOfFile return EOF, then wait for the now-unblocked reader.
- Put the child in its own process group (setpgid) and SIGKILL the whole group
  (kill(-pid)) plus the child directly, so daemonized grandchildren are reaped.
- Redirect stderr to /dev/null (was an undrained Pipe) so a script flooding stderr
  can't block on a full buffer and force a needless timeout.

Verified end-to-end against real scripts: uppercase transform, empty/exit-7/
missing (fail-open), 1s timeout fires at ~1.01s, a grandchild-holds-pipe script
now returns in ~0.78s (was a 5s hang), and a 100k-line-stderr script completes in
~0.74s. swift test 171/171; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@initcore0

Copy link
Copy Markdown
Owner Author

Hardening pass (after a security review of the spawn path):

  • Fixed a data race on stdoutData (it was read even when the background reader hadn't finished on the timeout path) — now read only after readGroup.wait() succeeds.
  • Fixed a reader-thread / FD leak: a killed script that left a grandchild holding the stdout pipe open would orphan the reader. Now the read end is force-closed to unblock it, and the child runs in its own process group so kill(-pid) reaps daemonized grandchildren.
  • stderr → /dev/null (was an undrained Pipe) so a stderr-flooding script can't stall on a full buffer.

Verified end-to-end: the grandchild-holds-pipe case now returns in ~0.78s (was a 5s hang), 100k-line-stderr completes in ~0.74s, timeout fires at ~1.01s. The core safety model (opt-in, fail-open, secure-field-first, no shell) was confirmed sound by the review.

@initcore0
initcore0 merged commit 18c71d5 into main Jun 24, 2026
3 checks passed
@initcore0
initcore0 deleted the feat/phase3-script-postprocessor branch June 24, 2026 01:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant