Skip to content

Input ownership: prompt hooks and stale questions never eat type-ahead (#436) - #437

Closed
raiseCatError wants to merge 9 commits into
fix/v022-input-ownership-lookfrom
fix/v022-ctrlc-type-ahead
Closed

raiseCatError wants to merge 9 commits into
fix/v022-input-ownership-lookfrom
fix/v022-ctrlc-type-ahead

Conversation

@raiseCatError

@raiseCatError raiseCatError commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Draft, do not merge before v0.21 ships. #430 has carried dev into release/v0.21.0; a fix v0.21's physical QA needs would reach the release the same way, so anything merged into dev before the release would ride along. Part of v0.22 (#431). Now based on #433 (fix/v022-input-ownership-look): #433 merges first, then this PR. Its diff is #436's fix, the merge with #433, and the combined fix below.

#436: reproduced and explained

The issue recorded one live failure of a command typed right after a read returns, or right after Ctrl+C, reaches the shell once, in order and a guessed cause. The guess was wrong. An instrumented copy of the test (8 at a time, load ~30, never committed) reproduced it on unmodified dev, in 6 of 8 copies in one round and 4 of 8 in another. The command that ran was the typed line with its beginning missing: olumes/…/once, cho Y >> …/once, exit 127. A frontend log of every input-state change and hand-over showed two mechanisms:

  1. A late report of the question Ctrl+C ended. The session sampled read -r reply (likely line) before it saw the interrupt; the report reached the frontend after Ctrl+C. onInputState saw a draft typed after the question began and re-marked the post-Ctrl+C type-ahead as a reply; when the prompt came back (exit 130), "a reply typed for a finished command is discarded" cleared the composer mid-typing. The rest of the line was typed into an empty composer and ran truncated.
  2. The shell's own prompt hooks read as the command waiting. After a command ends, zsh/Bash run their prompt hooks (the person's and NMSh's) before the prompt marker. A slow hook holds the terminal, idle, with line settings, which is exactly what a builtin read looks like, so the watch reported "Probably waiting for input" (since 5 ms after the stop, so not stale). Same discard. This one is not tied to Ctrl+C or load: any slow prompt hook (a heavy theme, nvm) reproduces it deterministically at normal load.

Fix, at each cause

  • Stale questions (TerminalApp.onInputState): a request that began before Ctrl+C or Ctrl+Z is the one the stop ended. It is dropped: it never turns post-stop typing into a reply, nor routes keys to the program. A question asked after the stop is new and handled as before.
  • A done marker (shell protocol): each shell's first prompt hook prints an authenticated done before anything else runs. zsh: nmsh_done, kept first in precmd_functions, passes the command's status on unchanged (checked: later hooks see the real $?). Bash: the first line of __nmsh_precmd. Fish: the first line of the postexec handler. ShellSession passes it to InputWatch.onDone(), which closes any open wait at that moment, cancels its schedule and drops a sample already under way. Nothing is sampled again until the next command.

Verification

  • Regressions first, each failing on dev:
    • tests/ctrlCTypeAhead.test.ts replays the logged sequence: the late report never discards the draft; a stale hidden report does not route type-ahead to the program; a new question after the stop still does. Both fail on dev (the first exactly as live: composer cleared at the prompt).
    • tests/promptHookLive.test.ts (real zsh and Bash, a deliberately slow prompt hook of the person's: zselect / a timed read on a FIFO; a draft typed during it): both fail on dev at load 3 (Waiting for input shown for the hook). Both pass here.
    • Unit: the done marker is authenticated and split-safe (tests/shellProtocol.test.ts); after done an in-flight sample is dropped, nothing is sampled, an open wait closes at that moment, and the next command is watched again (tests/inputAwareness.test.ts).
  • Live comparison (instrumented reproducer, 8 at a time, load 20–30): dev failed with the symptom in 6/8, 2/8, 4/8 and 2/8 copies over several rounds. With only the stale-question fix, one symptom remained, and the log identified mechanism 2. Its own live test then failed at load 3 and passes now.
  • Suites: shell protocol, shell session, Powerlevel10k (hook composition), shell frameworks, adapters, shell switch, input awareness and ownership (unit and live), typing after read, query order, command queue: all pass. Full canonical npm run verify on 9aae2f03 (NMSH_TEST_TMPDIR=/Volumes/T7Tmp/ai-dev, load 3–15): exit 0, 3,149 tests, 3,147 pass, 2 skipped, 0 failed.

Review follow-up (second commit)

An automated security review pointed out that the first version dropped a question that began before Ctrl+C for the rest of the command. If that stayed the session's latest word (a program that read through the stop), the frontend would never be told again, and a later password could be drawn. Now:

  • the stale question is ignored only during the 2 s type-ahead window after the stop; if it is still the session's word when the window closes, it applies then, so a hidden question goes back to direct, never-drawn input;
  • a draft begun in that window is type-ahead for the prompt: never flipped to a reply nor handed to the program, whatever arrives later.

Regression: tests/ctrlCTypeAhead.test.ts "a question still open after the type-ahead window … applies again" fails without it.

Full npm run verify on the second commit (83135298) ran while the machine was at load ~20–23 from other work: 3,150 tests, 3,142 pass, 2 skipped, 6 failed: live startup timeouts (timed out waiting for /zsh|bash/u) in promptHookLive, queueConditionsLive, muxInterop (×2) and sessionLifecycle, plus a timing bound in contextKernel. All six files then passed 46/46 rerun at load 11–14. A clean full run on the final head is still owed before this leaves draft.

Combined with #433 (4556dfab merge, e2776680 fix)

The two PRs conflict in InputWatch.ts. The resolution is not just textual: it decides what held typing (#433) does when the shell's done marker (this PR) says the command finished. That marker reaches the session before the prompt reaches the frontend.

The danger, shown by regressions written against the plain merge (they fail there):

  • A look that answered 'idle' after done settled held typing as if the command were still running, so its Enter queued it.
    • A password typed for a read -rs -t 1 that gave up was queued as a shell command.
    • Typing held past the slow mark was queued too.
  • A probe the command finished during dropped its evidence of hidden input: 'idle' instead of 'hidden'.

The fix:

  • Session (InputWatch):
    • A look after done answers 'idle' without probing its prompt hooks.
    • A sample the command finished during is not applied, so no question is reported, but its evidence still counts: seen hidden input (line input with echo off) is reported 'hidden'.
  • Frontend (TerminalApp):

Why ordinary type-ahead during slow hooks is unaffected: measured in real PTYs, prompt hooks run with zsh 5.9 and Bash 5.3 at icanon echo (visible line input), and with Fish 4.9.3 at -icanon -echo (its own key mode). None looks hidden. A hook that really turns echo off is treated as possibly sensitive, which is the safe direction.

Combined regressions: tests/inputOwnershipDone.test.ts (deterministic; 4 of 8 fail on the plain merge, one of those a test timing fix):

  • Watch: a look after done answers 'idle' and never probes; hidden evidence from a look the command finished during is kept and never applied; visible evidence answers 'idle', and the next command is looked at afresh.
  • Frontend, done before the prompt:
    • a reply seen as hidden is discarded, never drawn, queued, written or run;
    • typing held past the slow mark is discarded;
    • plain type-ahead is kept and runs once on Enter.
  • Interrupt: Ctrl+C discards held typing; a late look answer and a stale question after it change nothing; typing after Ctrl+C survives the prompt.
  • Process exit: the look is unanswered when the command exits; late evidence and the next command never see the old typing.

Verification on e2776680:

Follow-ups from the v0.22 candidate (e391bcb0, 807d3c03)

A regression in the combined fix, caught by the candidate's CI (Ubuntu zsh, typingAfterRead, with #442's stricter check):

  • What happened: after NMSh answered a read -s, the next command typed at once was held for a look. The look still saw the answered question (echo off until the read returns). Counting that as possible password input discarded the next command when the read finished.
  • Fix (e391bcb0): hidden input seen after this command's hidden question was answered is that question, not new evidence.
  • Regression test: fails before the fix.

The automated security review of that commit was right: the grace was unbounded.

  • The danger: a second hidden question (read -s a; read -s -t 1 b, or a sudo retry) would have its reply treated as type-ahead, and run if the command then ended.
  • Fix (807d3c03): the grace covers only typing begun within 1 s of the answer, and a newer question reported by the session ends it at once.
  • Regression tests (both fail before the fix): a second prompt-less hidden question typed after the window; a sudo retry reported before the typing.

Rule now:

  • A reply typed before NMSh answered anything, typing held past the slow mark, and a reply to a newer hidden question are all discarded when the command ends, never run.
  • Only type-ahead begun right after an answer, with no newer question, stays for the next command.
  • Residual: two prompt-less hidden reads in a row, the second's reply typed within 1 s and finished within 800 ms, then the command ends before NMSh sees the second question. That reply is kept as type-ahead, as a plain terminal would deliver it.

Verification on 807d3c03: deterministic input suites 64/64. On the candidate (567cf533): live typingAfterRead 3/3 and inputOwnershipLive 6/6; CI dispatched.

Limits

Physical QA (not done)

Ghostty and Terminal.app, zsh, with a slow prompt hook that holds the shell itself (an external sleep would not): zmodload zsh/zselect; slowp() { zselect -t 150 }; precmd_functions+=(slowp) in ~/.zshrc. Run true, type the next command while the prompt is coming back → no "waiting for input", the draft stays and runs once (Bash is covered by the automated live test). read -r reply, Ctrl+C, type a command at once → the whole line runs.

Refs #436

#436 reproduced on dev under parallel load (an instrumented copy of the
live test, 8 at a time, load ~30): the command typed right after Ctrl+C on
a waiting read ran with its beginning missing (`cho Y >> …`, exit 127). A
frontend log of every input-state change and hand-over showed two causes:

1. The session sampled the read before it saw the interrupt; that report
   reached the frontend after Ctrl+C, re-marked the post-Ctrl+C type-ahead
   as a reply, and the prompt then discarded it mid-typing.
2. After any command, zsh and Bash run their prompt hooks before the prompt
   marker. A slow hook holds the terminal, idle, as a builtin read does, so
   the watch reported "Probably waiting for input" and the draft typed
   meanwhile was taken for a reply. This one needs neither Ctrl+C nor load:
   a slow hook of the person's reproduces it at load 3.

- TerminalApp.onInputState drops a request that began before Ctrl+C or
  Ctrl+Z: it is the question the stop ended. A question asked after the stop
  is new and handled as before.
- Shell protocol: each shell's first prompt hook prints an authenticated
  `done` marker (zsh: nmsh_done, kept first in precmd_functions, hands the
  command's status on unchanged; Bash: first line of __nmsh_precmd; Fish:
  first line of the postexec handler). ShellSession passes it to
  InputWatch.onDone(), which closes an open wait at that moment, cancels its
  schedule and drops a sample under way; nothing is sampled until the next
  command.

Regressions fail on dev and pass here: the logged sequence replayed in the
frontend (tests/ctrlCTypeAhead.test.ts), a slow prompt hook of the person's
in real zsh and Bash with a draft typed during it
(tests/promptHookLive.test.ts), and unit tests for the marker and the watch.

Refs #436
…e-ahead

Review of the #436 fix: dropping a question that began before Ctrl+C for the
rest of the command could fail open. If it stayed the session's word (a
program that read through the stop), the frontend would never be told again,
and a password typed later could be drawn in the composer.

- The stale question is ignored only during the type-ahead window after the
  stop (2 s). If it is still the session's latest word when the window
  closes, it applies then, so a hidden question goes back to direct, never
  drawn input.
- A draft begun in that window is type-ahead for the prompt: it is never
  flipped to a reply nor handed to the program, whatever arrives later.

A regression covers the reviewed case: the question re-applies after the
window, new keys go straight to the program, and the type-ahead draft stays.
It fails without this change.

Refs #436
raiseCatError added a commit that referenced this pull request Oct 18, 2026
…QA passes

The ledger lists every slice and PR with its verification and open
risks; the physical QA checklist gains the held-input states and passes
for #437, #438/#441, #439 and #440; the NL design records the decisions
and the first slice's scope.
InputWatch: #436's done state stops looking, as before, and a look now
answers 'idle' once the shell said the command is done. Both changelog
entries are kept.
…s kept

The shell's done marker reaches the session before the prompt reaches
the frontend. A look answering idle then settled held typing as if the
command still ran: an Enter queued it, so a password for a read that
gave up, or typing held past the slow mark, ran as the next command.

An idle answer now means the command ended for that hold, and a hold a
look saw as hidden is never run. A sample the command finished during
is no longer applied, but its hidden evidence is still reported.
zsh and Bash show echo on during prompt hooks and Fish its own key
mode, so ordinary type-ahead typed during slow hooks is unaffected.
After NMSh sends an answer to a hidden read, typing that follows is
held for a look; that look can still see the answered question (echo
off until the read returns). Counting it as possible password input
discarded the next command when the read then finished: caught on the
v0.22 candidate in CI (zsh read -s, then echo NEXT, never run).

Hidden input seen after this command's hidden question was answered is
now that question, not new evidence. A reply typed before any answer is
still discarded, and a new hidden question still routes the typing.
…er question

Review of the previous commit: remembering the answer for the rest of
the command let a second hidden question (read -s a; read -s -t 1 b, or
a sudo retry) have its reply treated as type-ahead and run when the
command ended. A look that still sees hidden input now counts as the
answered question only for typing begun within 1 s of the answer, and a
newer question reported by the session ends that at once.
raiseCatError added a commit that referenced this pull request Oct 19, 2026
…source findings

The candidate (all nine PRs in dependency order) is green in CI on
567cf53 after two #437 follow-ups it surfaced. Records #442 and #443,
the stacked #437, the conflict rule, the scratch combined /view editor
test, what was recovered after the 2026-10-19 kernel panic, and the
findings for the planned test-resource pass.
Security review of #426 for the release. Two paths still let a hidden
reply run as a shell command:

1. After NMSh answered a hidden question, a look that still saw hidden
   input was attributed to that question for 1 s, and if the command
   then ended the held typing ran as the next command. A second
   question with no prompt that gave up first (a timed read) is
   indistinguishable, and a time window is no proof. Such typing now
   waits at the prompt, hidden, until the person chooses: Tab shows
   it in the composer (their Enter runs it), Esc or Ctrl+C discards
   it. No timer, later look or next command settles it. The window
   now only separates a probable reply (discarded) from unconfirmed
   typing (held); it never lets typing run.

2. A reply typed and entered before a question with no prompt existed
   (the command working with the terminal echoing) is queued like any
   command. When the command then asks for hidden input, the entries
   queued while it ran wait for approval (an older service without
   per-entry approval pauses the queue), so a successful exit no longer
   runs the password. Each question is checked once; entries queued
   before the command, or with Ctrl+Q during the question, stay as
   they are. The question's "since" is no boundary: the watch can
   date it to when the shell went idle (a sleep before the read).

NMSH_DEBUG_KEYS never logs typing held at the prompt.

Regressions: tests/inputOwnershipSecrets.test.ts (10, deterministic;
6 fail before this change) and tests/inputOwnershipSecretsLive.test.ts
(zsh and Bash: on 567cf53 the second password ran and entered NMSh's
journal; with this change it never runs and is not in the shell's
history file). The #437 test that expected type-ahead after a hidden
answer to run by itself now expects it held, then run once on Tab and
Enter. typingAfterRead accepts that held outcome only for typing ahead
during a hidden read, and still requires the command to run once.
raiseCatError added a commit that referenced this pull request Oct 19, 2026
…un once

With #437's #426 review change, a command typed ahead during a hidden
read's answer can be held at the prompt when a look still saw hidden
input as the command ended (it may be a reply to a second question with
no prompt). It is then never run by itself; the person's Tab shows it
and Enter runs it. The test accepts that outcome only for typing ahead
during a hidden read, presses Tab and Enter, and still requires the
next command to run exactly once (journal count). Without #437 the
held branch is never taken.
@raiseCatError

Copy link
Copy Markdown
Owner Author

Release security review (v0.22.0, #444): 6486d9c5. Two paths still let a password run as a shell command on the green candidate 567cf533. Both are now fixed.

  1. A reply held after a hidden answer. For 1 s after NMSh answered a hidden question, a look that still saw hidden input was attributed to that question. If the command then ended, the held typing ran as the next command. A second question with no prompt that gave up first (a timed read) is indistinguishable, and a time window is no proof.
    • Now that typing waits at the prompt, hidden: Typed during <program>'s hidden input · held, not run · Tab shows it · Esc discards it.
    • No timer, later look or next command settles it.
    • The 1 s window now only separates a probable reply (discarded) from unconfirmed typing (held).
  2. A reply queued before its question. A reply typed and entered before a question with no prompt existed is queued like any command.
    • When the command then asks for hidden input, the entries queued while it ran wait for approval. An older service pauses the queue instead.
    • Each question is checked once. Entries queued before the command, or with Ctrl+Q during the question, are left alone.
    • The question's since is not used as a boundary, because the watch can date it to when the shell went idle (a sleep before the read). The live test showed this.

NMSH_DEBUG_KEYS never logs typing held at the prompt.

Tests

  • tests/inputOwnershipSecrets.test.ts, 10 deterministic tests. 6 fail before the fix.
  • tests/inputOwnershipSecretsLive.test.ts, live zsh and Bash. On 567cf533 the second password of read -rs a; sleep 3; read -rs b ran as a command and was recorded in NMSh's journal. With the fix it never runs, and it is not in the shell's history file.
  • This PR's test "type-ahead after answering a hidden question" now expects that typing to be held, then run once on Tab and Enter.
  • typingAfterRead (here, and in Tests: live tests wait for NMSh's journal, not screen text (CI races seen on #441 and #442) #442's version) accepts that held outcome only for typing ahead during a hidden read, and still requires the command to run exactly once.

Locally on this branch: input-ownership suites 45/45 and related suites 107/107. Live: typingAfterRead 3/3, inputOwnershipLive 6/6, promptHookLive 2/2, the new live test 2/2. Type check clean.

…nd queued replies

The release gate review of v0.22.0 (#444) found three more ways for a
possible password, or a reply, to run without the person's intent:

1. Tab on held typing replayed every held key. A Ctrl+Q typed while
   the typing was held then queued the revealed text, and an idle
   queue ran it at once (held at the prompt, or while a command ran).
   Tab now replays only text and the keys that edit it.
2. A password typed in the composer before the program printed its
   prompt (sudo) stayed the draft: hidden while the question was open,
   shown again when the command ended, one Enter from running as a
   shell command. Typed with its Enter, that Enter also went to sudo
   as an empty answer and the draft was drawn during the retry. When
   the command asks for hidden input, such a draft now leaves the
   composer, an Enter held with it is dropped, and the draft is held
   at the prompt when the command ends (Tab shows it, Esc discards
   it). Type-ahead typed right after Ctrl+C stays the next command.
3. A visible reply queued before NMSh could see a line read (a loaded
   machine) ran as a command after the read's command succeeded. The
   approval rule for entries queued while a command ran now applies
   to any question NMSh sees, not only hidden ones.

Regressions in tests/inputOwnershipSecrets.test.ts (5; all fail
before this change). Input ownership and related suites: 158/158.
…pt is held at the prompt, never run

Real zsh and Bash, as sudo behaves (a moment's work, then a prompt and a
read with echo off). On ab5390a one Enter at the prompt ran the
password as a shell command (NMSh's journal); with the gate fix it is
held at the prompt, never drawn again, and not in the shell's history.
raiseCatError added a commit that referenced this pull request Oct 19, 2026
…fts and queued replies (fix/v022-ctrlc-type-ahead 6ebf383)
raiseCatError added a commit that referenced this pull request Oct 19, 2026
@raiseCatError

Copy link
Copy Markdown
Owner Author

Its commits (head 271e67bb) are in master and dev through v0.22.0 (https://github.com/raiseCatError/notMyShell/releases/tag/v0.22.0). This PR was stacked on #433's branch, so GitHub cannot retarget it to dev (no new commits) and it cannot show as merged; closing as completed. The branch is kept.

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