Skip to content

v0.22 hardening: input ownership by lifecycle, not vocabulary (#426); stable shell terminal size (#443) - #457

Merged
raiseCatError merged 10 commits into
release/v0.22.0from
fix/v022-harden
Oct 19, 2026
Merged

raiseCatError merged 10 commits into
release/v0.22.0from
fix/v022-harden

Conversation

@raiseCatError

Copy link
Copy Markdown
Owner

Final v0.22 security and reliability pass, on release/v0.22.0 = fa7ec665. Part of #444. Draft: physical QA and the release decision stay with the maintainer.

#426: nothing typed while a command runs becomes a shell command by itself

Replaces #448's vocabulary rule. That rule held a line only when its first word wasn't a command the shell knows, so a reply like yes, ls, sudo or X=1 still ran when its read gave up unseen. Three lifecycle layers now decide instead:

  • Withheld lines. A plain Enter while a command runs, with no question NMSh saw, holds the line for approval (typedWhile). The owner (session service or in-process client) enforces this: such a line can't be set to run by itself. Every queue surface shows it as (typed while make ran) until Tab, and Up never offers it. Ctrl+Q is the explicit run-after. Keys held during an ownership look whose command ended follow the same rule. Decided with the maintainer.
  • Delivered bytes. When NMSh delivered input to a command, the shell's first prompt hook takes back whatever the command never read (a flag file, then stty -icanon min 0 time 0, head and od), and the owner holds it for approval. Input that arrives after the done marker is never written. zsh, Bash and Fish.
  • Stop window. A line typed right after Ctrl+C or Ctrl+Z is held until the result is verified: the prompt comes back (it runs), the program asks after the stop (it's the reply), or the program is still alive when the window closes (held for approval).
  • Older services fail closed: typing with no evidence stays held and hidden until the person presses Ctrl+S, Tab or Esc.

#443: NMSh's own layout never resizes the shell's terminal

The terminal's rows used to follow the current frame, so a command starting or ending, the composer growing, a menu, a notice or Settings each sent SIGWINCH. Fish 3 drops a write that signal interrupts. The rows now come from a reference layout for the window and settings. While a command runs, only a real window change resizes the terminal.

Evidence (local, macOS, Fish 4.9.3; Linux Fish 3.7 loop in run 39283359699)

Check fa7ec665 This branch
inputQuarantine (deterministic) 15 of 19 fail (4 pass) 20/20
Live: reply ls before an unseen timed read runs (zsh, Bash, Fish) held; never runs
Live: unread echo QZ-RAN⏎ + partial QZSECRET ran echo QZ-RAN, then QZSECRETecho NEXT held; next command whole
ptyStableSize: UI-only resizes in one sequence 6 0
ptyResizeLive: SIGWINCH from chrome / from 1 window resize n/a 0 / 1

Remaining limits are in the CHANGELOG's Known limitations, and the ledger has the full record.

…ommand by itself (#426)

The #448 rule decided by the text: a line typed while a command ran was held
only if its first word was not a command the shell knows. A reply that is
itself a command name (`yes`, `ls`, `sudo`), a one-time code or an
assignment still ran when its read gave up unseen. Ownership, not
vocabulary, now decides:

- Withheld lines. A plain Enter while a command runs, with no question NMSh
  saw, queues the line with `typedWhile`: the owner (service or in-process)
  forces it to wait for approval and refuses to clear that, frontends show
  it as "(typed while <program> ran)" until the person presses Tab, and it is
  never offered by Up. Ctrl+Q stays the explicit run-after. Keys held for a
  look whose command ended are replayed with the same rule, so their Enter
  never submits at the prompt. A held head no longer blocks a command
  submitted at the prompt.
- Delivered bytes. Input NMSh wrote for a program that never read it stayed
  in the terminal: the shell ran a whole line as its next command and glued
  a partial one onto the next submission (`QZSECRETecho NEXT`). When NMSh
  delivered input to a command (a flag file, written before the bytes), the
  shell's first prompt hook drains the terminal (stty -icanon min 0 time 0,
  head, od) and reports it as an authenticated `unread` marker; the owner
  holds each line for approval. Input arriving after the done marker is
  held, never written. zsh, Bash and Fish.
- Stop window. A line entered right after Ctrl+C or Ctrl+Z is held, never
  written, until the outcome is verified: the prompt (it runs there), a
  question asked after the stop (its reply), or the window ending with the
  program alive or having asked (held for approval).
- Older services (no look result) fail closed: typing with no evidence stays
  held and hidden until Ctrl+S, Tab or Esc, and the notice says so.

Tests first: inputQuarantine (19 fail on fa7ec66), queueQuarantine,
unreadInputLive; live residual test with `ls` ran in zsh, Bash and Fish on
fa7ec66. Queue-mechanics live tests queue with Ctrl+Q; tests that typed
ahead with Enter now approve the held line.
…dow does (#443)

Every resize of the shell's PTY sends SIGWINCH to the foreground job, and
Fish 3 drops a terminal write that signal interrupts ("write: Interrupted
system call"). The PTY's rows were the transcript capacity of the current
frame, so a command starting or ending (the activity row), a growing
composer, a completion menu, a notice, the inspector or Settings each
resized it: six resizes in one ordinary sequence of the new deterministic
test on fa7ec66.

The rows now come from a reference layout for the window and settings (a
running command's activity row, a one-line composer, no menu, inspector,
notice or panel), and while a command runs only a real window change
(onResize, or the end of a full-screen handover, both reset lastPtyRows) or
a width change resizes it. The transcript region may be a few rows shorter
than the terminal, as under Dock Top or with the queue list: the newest rows
show. Full-screen programs keep the whole window.

Tests: ptyStableSize (deterministic, fails on fa7ec66), ptyResizeLive
(SIGWINCH counted by a real foreground program in zsh, Bash and Fish), and
fishOutputIntegrityLive for the Fish 3.7 CI loop.
… for the prompt (#426, #436)

With lines entered while a command runs held for approval, a command typed
during a slow prompt hook (after the command ended, before its prompt) was
held too, though #436 (physical QA A10 step 3) made it run once at the
prompt. Only the shell's authenticated done marker proves the command is
over, and a look's "idle" dates the look, not the key: a reply begun just
before an unseen read gave up would pass for type-ahead.

The input state now carries `done` from the shell's done marker (an
optional field older windows ignore). A draft begun after the window
received it is held, never written, until the verified prompt, and runs
there once. A draft begun before is held for approval as before. Older
services send no `done`, so such lines are held (fail closed).

Tests: two deterministic cases (after-done type-ahead runs at the prompt;
a draft begun before done is held), the done marker on the wire, and a live
zsh/Bash test that types and enters a command during a slow prompt hook.
…or it; held-back reports apply first (#426)

When the stop window ends with the program still running, the lines held
since Ctrl+C go to it, as a terminal delivers type-ahead: on macOS NMSh
cannot see a read after Ctrl+C, because the terminal's echoed ^C ends the
question-shaped line. What it never reads is taken back at the end of the
command and held for approval, so the line still never runs as a command.
A question report held back during the window applies before the window
settles, whatever order the two timers have. Live test: a bash survivor
answered with the line typed right after Ctrl+C.

QA and changelog: R4 rewritten, R5-R7 replace A9, A10, A1 (steps 1-3, 6)
and B1 for the hardened candidate; fixtures for a timed prompt and a
survivor. The changelog describes the new routing, its limits and Fish 3.
…anel PTY rows follow the reference layout (#426, #443)

Under the new routing a line typed while a command still runs waits for
approval, so live tests that type the next command right after starting one
(an approval, an answer, a full-screen program's exit) raced its prompt on
slower CI runners. Frontend.run now waits for the shells to be idle first;
tests that mean to queue use Ctrl+Q. The PTY-row tests assert the reference
layout (a panel opening or closing never resizes the shell's terminal).
…kes the next command's keys (#426)

After the Enter that answers a hidden line (or the key that answers a key
read), a report of that same question the session sampled before it saw the
answer could arrive afterwards and put typing back into direct mode: the
first keys of the next command went to the program, were never read, and
were taken back as a held fragment while the rest reached the composer (seen
in CI as a split type-ahead in typingAfterRead). A report of a question no
newer than the one just answered is stale and is dropped; a new question
starts later. Ctrl+C and Ctrl+Z are no answer: a program that reads through
them still asks (#436).
…ext; mux tests wait for each prompt (#426)

typingAfterRead decided whether its next command was held from screen text;
a redraw repeats older rows and notes, so a stale "did not read" note read
as a new held line (CI). It now waits for the read's journaled completion
and asks the session's queue. The idle wait in Frontend.run applies only to a
sandbox's single session. The GNU screen test's own run helper waits for
each command's completion row before typing the next.
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