From 9aae2f038c7fa676f83a87a1ca7797572ccec172 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:08:03 +0530 Subject: [PATCH 1/8] Input ownership: prompt hooks and stale questions never eat type-ahead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- CHANGELOG.md | 1 + docs/design/input-awareness.md | 7 +++ src/app/TerminalApp.ts | 8 ++++ src/session/InputWatch.ts | 27 +++++++++--- src/shell/ShellProtocol.ts | 6 +++ src/shell/ShellSession.ts | 2 + src/shell/adapters/BashAdapter.ts | 2 + src/shell/adapters/FishAdapter.ts | 2 + src/shell/adapters/ZshAdapter.ts | 11 ++++- tests/ctrlCTypeAhead.test.ts | 71 +++++++++++++++++++++++++++++++ tests/inputAwareness.test.ts | 33 ++++++++++++++ tests/promptHookLive.test.ts | 54 +++++++++++++++++++++++ tests/shellProtocol.test.ts | 8 ++++ 13 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 tests/ctrlCTypeAhead.test.ts create mode 100644 tests/promptHookLive.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 41472b72..e8c4cac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Paste review and `/track`: a redirect into a file is recognized wherever `>/dev/null` also appears (`echo x > ~/.zshrc 2>/dev/null` was labelled read-only), and `>|` is a redirection, not a pipe. - `/track` and `/watch` no longer start, without asking, commands that change things through the network or another command: `env `, `NAME=value` prefixes, git's `-c`/`--ext-diff`, `npm publish`/`login`/`x`, `pnpm dlx`, `ssh`, `curl -X POST`/`-d`/`-o file`, plain `wget`; options that write files (`git … --output`, `sort -o`, `jest -u`, `--fix`) are refused. Read-only probes (`ping`, `dig`, `nc -z`, a curl GET) still start at once, and `npm test 2>&1` is no longer refused as a background job ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). - `/track stop` and a closing window end every process a tracked command left in its process group, including one that ignored TERM; a tracker whose directory disappeared names it instead of reporting `spawn /bin/sh ENOENT`. +- A command typed while the shell was showing its prompt again (after Ctrl+C, or after any command when a prompt hook is slow, as a heavy theme or `nvm` can be) no longer loses its beginning ([#436](https://github.com/raiseCatError/notMyShell/issues/436)). NMSh no longer reads the shell's own prompt hooks as the finished command waiting for input (`Probably waiting for input` while the prompt came back), and a late report of the question Ctrl+C ended no longer turns what you typed after it into a discarded reply; the rest of the line used to run on its own (`cho Y …`, command not found). ## [0.20.0] - 2026-10-18 diff --git a/docs/design/input-awareness.md b/docs/design/input-awareness.md index 1f7f529a..d99f4231 100644 --- a/docs/design/input-awareness.md +++ b/docs/design/input-awareness.md @@ -44,6 +44,13 @@ process), so the open question plus an idle process is the best available eviden (`cat`, Python `input()` with no prompt). The shell's own `read` is detected with or without a prompt. - On macOS, a `wait` builtin looks like a line read (the shell holds the terminal, idle); it is shown as "Probably". Linux's kernel report tells them apart. +- The shell's own prompt hooks are never taken for the command reading (#436). After a command ends, zsh and Bash run + their prompt hooks (the person's and NMSh's) before the prompt marker, and a slow hook holds the terminal, idle, as a + builtin `read` does. Each shell's first hook prints an authenticated `done` marker; from it to the next command + nothing is sampled and a sample already under way is dropped. zsh's single `precmd` function and Fish's other + postexec handlers can run before it. +- A question that began before Ctrl+C or Ctrl+Z, reported late, is the one the stop ended: the frontend drops it, so it + never turns what was typed after the stop into a reply (#436). - Programs reading through `poll`/`select` on macOS. - Programs that own the terminal. Fish 4's own `read` is one: it is fish's line editor, with bracketed paste and OSC 133 marks, so NMSh hands it the terminal and fish answers natively. It is not reported as waiting. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 62148e4a..63033e7c 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -11840,6 +11840,14 @@ export class TerminalApp { private onInputState(state: InputState): void { // A state for a finished run (a late message) never resurrects it. if (!this.running) return; + // A question that began before Ctrl+C or Ctrl+Z is the one the stop ended, reported late (the session sampled it + // before it saw the stop). It never turns what was typed after the stop into a reply, nor routes keys to the + // program (#436); a question the program asks after the stop is new. + const stoppedAt = this.running.stoppedAt; + if (state.request && stoppedAt !== undefined && state.request.since < stoppedAt) { + const {request: _ended, ...current} = state; + state = current; + } this.inputState = state; const draft = this.runningDraft; if (state.terminalOwner) { diff --git a/src/session/InputWatch.ts b/src/session/InputWatch.ts index b3d64110..6d4feec4 100644 --- a/src/session/InputWatch.ts +++ b/src/session/InputWatch.ts @@ -69,6 +69,8 @@ const defaultTimer = (callback: () => void, ms: number) => { export class InputWatch extends EventEmitter { private running?: {command: string; since: number}; private reader = false; + /** The shell said the command is done and runs its prompt hooks: nothing until its prompt is the command's input. */ + private done = false; private readonly line = new OpenLine(); private readonly screen = new AlternateScreenTracker(); private request?: InputRequest; @@ -126,8 +128,22 @@ export class InputWatch extends EventEmitter { this.emit('change', this.state); } + /** + * The shell's first prompt hook ran: the command has finished (#436). A slow hook holds the terminal, idle, exactly + * as a builtin read does, so from here to the prompt nothing is sampled and a sample already under way is dropped. + */ + onDone(at = this.now()): void { + if (!this.running || this.done) return; + this.done = true; + this.cancelTimer?.(); + this.cancelTimer = undefined; + this.generation += 1; + if (this.request) this.closeWait(at, true); + } + onExec(command: string, at = this.now()): void { this.reader = false; + this.done = false; this.closeWait(at, false); this.running = {command, since: at}; this.waitedMs = 0; @@ -165,6 +181,7 @@ export class InputWatch extends EventEmitter { /** The command finished (the shell is back at its prompt): close any wait; the final timing stays readable. */ onPrompt(at = this.now()): void { + this.done = false; if (!this.running) return; this.closeWait(at, false); this.running = undefined; @@ -182,7 +199,7 @@ export class InputWatch extends EventEmitter { async lookNow(): Promise { for (let sample = 0; sample < 2; sample += 1) { while (this.probing) await this.probing; - if (this.disposed || !this.running || !this.options.probe) return; + if (this.disposed || !this.running || this.done || !this.options.probe) return; this.followUp = false; await this.look(true); if (!this.followUp || this.request) return; @@ -221,7 +238,7 @@ export class InputWatch extends EventEmitter { private schedule(): void { this.cancelTimer?.(); this.cancelTimer = undefined; - if (this.disposed || !this.running || !this.options.probe) return; + if (this.disposed || !this.running || this.done || !this.options.probe) return; const quiet = this.now() - this.lastActivity; const planned = this.looks === 0 ? (this.line.open ? FIRST_LOOK_OPEN_MS : FIRST_LOOK_MS) : LOOKS_MS[this.looks - 1] ?? (quiet > SLOW_LOOK_AFTER_MS ? SLOW_LOOK_MS : STEADY_LOOK_MS); @@ -231,7 +248,7 @@ export class InputWatch extends EventEmitter { /** One sample; onDemand looks (lookNow) leave the schedule's back-off where it was. */ private async look(onDemand = false): Promise { - if (this.disposed || !this.running || this.probing) return; + if (this.disposed || !this.running || this.done || this.probing) return; if (!onDemand) this.looks += 1; if (this.ownsTerminal) { if (this.request) this.closeWait(this.now(), true); @@ -247,14 +264,14 @@ export class InputWatch extends EventEmitter { const probing = (async () => { try { sample = await probe({tty}); } catch { sample = undefined; } })(); this.probing = probing; try { await probing; } finally { this.probing = undefined; } - if (this.disposed || !this.running) return; + if (this.disposed || !this.running || this.done) return; if (generation === this.generation && sample) this.apply(sample, this.now()); this.schedule(); } /** Decide from one sample. Exposed for tests through InputWatch.evaluate. */ apply(sample: TerminalSample, now: number): void { - if (!this.running || this.ownsTerminal) return; + if (!this.running || this.done || this.ownsTerminal) return; const busy = this.busy(sample.foreground, now); this.followUp = false; const next = this.decide(sample, busy, now); diff --git a/src/shell/ShellProtocol.ts b/src/shell/ShellProtocol.ts index 7e5455ea..ad8d9cb1 100644 --- a/src/shell/ShellProtocol.ts +++ b/src/shell/ShellProtocol.ts @@ -10,6 +10,11 @@ export interface ShellMarker { export type ProtocolEvent = | {kind: 'data'; data: string} | {kind: 'reader'} + /** + * The first prompt hook: the command has finished and the shell is running its prompt hooks (the person's and + * NMSh's) until its prompt marker. Nothing the shell does meanwhile is the command reading input (#436). + */ + | {kind: 'done'} | {kind: 'marker'; marker: ShellMarker} /** zsh preexec: a command line is about to run in the foreground. */ | {kind: 'exec'; command: string; historyAllowed?: number}; @@ -81,6 +86,7 @@ export class ShellProtocolDecoder { private emit(payload: string, events: ProtocolEvent[]): void { if (payload === 'reader') { events.push({kind: 'reader'}); return; } + if (payload === 'done') { events.push({kind: 'done'}); return; } if (/^exec2;[01];/u.test(payload)) { events.push({kind: 'exec', command: payload.slice(8), historyAllowed: Number(payload[6])}); return; diff --git a/src/shell/ShellSession.ts b/src/shell/ShellSession.ts index 03b1594f..bbc41401 100644 --- a/src/shell/ShellSession.ts +++ b/src/shell/ShellSession.ts @@ -227,6 +227,8 @@ export class ShellSession extends EventEmitter { } } else if (event.kind === 'reader') { if (this.ready && !this.atPrompt) this.inputWatch.onReader(); + } else if (event.kind === 'done') { + if (this.ready && !this.atPrompt) this.inputWatch.onDone(); } else if (event.kind === 'exec') { this.atPrompt = false; this.commandStart = true; diff --git a/src/shell/adapters/BashAdapter.ts b/src/shell/adapters/BashAdapter.ts index 79b6ca4f..8974bcab 100644 --- a/src/shell/adapters/BashAdapter.ts +++ b/src/shell/adapters/BashAdapter.ts @@ -106,6 +106,8 @@ __nmsh_history_last= ${context.bridgeEnvPath ? bridgeBootstrap('bash', context.bridgeEnvPath) : ''} __nmsh_precmd() { local nmsh_status=$? nmsh_command + # The command is done (#436): the prompt hooks below may hold the terminal; nothing they do is its input. + ${marker('done')} for nmsh_command in "\${__nmsh_user_prompt_command[@]}"; do [[ -n $nmsh_command ]] || continue __nmsh_set_status "$nmsh_status" diff --git a/src/shell/adapters/FishAdapter.ts b/src/shell/adapters/FishAdapter.ts index d4ef5f6f..f56ddb6d 100644 --- a/src/shell/adapters/FishAdapter.ts +++ b/src/shell/adapters/FishAdapter.ts @@ -86,6 +86,8 @@ function __nmsh_preexec --on-event fish_preexec end function __nmsh_postexec --on-event fish_postexec set -l nmsh_status $status + # The command is done (#436): nothing until the prompt marker is its input. + ${marker('done')} __nmsh_knowledge${bridgeEnvPath ? '\n nmsh_bridge_sync' : ''} ${marker('%d;%s')} $nmsh_status "$PWD" end diff --git a/src/shell/adapters/ZshAdapter.ts b/src/shell/adapters/ZshAdapter.ts index ca7fde3b..69eca831 100644 --- a/src/shell/adapters/ZshAdapter.ts +++ b/src/shell/adapters/ZshAdapter.ts @@ -82,6 +82,14 @@ function nmsh_tty_echo { } ${bridgeEnvPath ? bridgeBootstrap('zsh', bridgeEnvPath) : ''} +# The first prompt hook: the command is done (#436). Hooks after it (the person's, then NMSh's) may hold the +# terminal for a while; nothing they do is the command reading input. It hands the status on to them unchanged. +function nmsh_done { + local nmsh_status=$? + printf '\\e]777;nmsh;${token};done\\a' + return \$nmsh_status +} + function nmsh_precmd { local nmsh_status=$? nmsh_capture_knowledge${bridgeEnvPath ? '\n nmsh_bridge_sync' : ''} @@ -94,7 +102,7 @@ function nmsh_precmd { # Themes such as Powerlevel10k move their own hook to the end of # precmd_functions every cycle; move ours back after it so the next cycle # still blanks last. Their prompt-spacing options must not return either. - precmd_functions=(\${precmd_functions:#nmsh_precmd} nmsh_precmd) + precmd_functions=(nmsh_done \${precmd_functions:#nmsh_(done|precmd)} nmsh_precmd) unsetopt prompt_cr prompt_sp nmsh_tty_echo -echo printf '\\e]777;nmsh;${token};%d;%s\\a' "\$nmsh_status" "\$PWD" @@ -116,6 +124,7 @@ ${shellKnowledgeBootstrap(join(stateDir, '.nmsh-knowledge'))} autoload -Uz add-zsh-hook add-zsh-hook precmd nmsh_precmd add-zsh-hook preexec nmsh_preexec +precmd_functions=(nmsh_done \${precmd_functions:#nmsh_done}) # Startup is over: this shell has read its files. A zsh started from it (a tmux # pane, a nested shell) must be an ordinary zsh, not a second copy of this diff --git a/tests/ctrlCTypeAhead.test.ts b/tests/ctrlCTypeAhead.test.ts new file mode 100644 index 00000000..fa4a74e0 --- /dev/null +++ b/tests/ctrlCTypeAhead.test.ts @@ -0,0 +1,71 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {InputRequest} from '../src/session/inputState.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #436: typing right after Ctrl+C is type-ahead for the prompt the stop brings back. Under load the session's report + * of the question Ctrl+C ended can reach the frontend after the stop; it used to mark what was typed after the stop + * as a reply, and the prompt then discarded it, so the rest of the line ran truncated (`cho Y >> …`, exit 127, seen + * live at load ~30). A question that began before the stop is the one the stop ended: it changes nothing. + * Deterministic: the session's late report is replayed in the order the live log recorded. No shell is started. + */ + +function fixture() { + const frame = executionFrameFixture(100, 30); + const written: string[] = []; + const submitted: string[] = []; + Object.defineProperty(frame.session, 'write', {value: (data: string) => { written.push(data); }, configurable: true}); + Object.defineProperty(frame.app, 'submit', {value: async () => { submitted.push(frame.app['editor'].text); frame.app['editor'].clear(); }, configurable: true}); + const state = (request?: InputRequest) => frame.session.emit('inputState', {...(request ? {request} : {}), timing: {waitedMs: 0, waits: 0}}); + const type = (text: string) => { for (const char of text) frame.app['onInput'](char); }; + const transcript = () => (frame.app['output'].transcript().lines as Array>) + .map(line => line.map(cell => cell?.text ?? ' ').join('')).join('\n'); + return {...frame, written, submitted, state, type, transcript, text: () => frame.app['editor'].text as string}; +} + +test('#436: the report of the question Ctrl+C ended, arriving late, never turns type-ahead into a discarded reply', () => { + const f = fixture(); + try { + f.begin('read -r reply'); + const asked = Date.now(); + f.state({since: asked, confidence: 'likely', mode: 'line'}); + f.tick(200); + f.app['onInput']('\u0003'); + f.tick(20); + f.type('echo Y'); + // The session sampled the read before it saw the interrupt; its report and then the closing one arrive now. + f.state({since: asked, confidence: 'likely', mode: 'line'}); + f.state(); + f.tick(20); + f.complete(130); + assert.equal(f.text(), 'echo Y', 'what was typed after Ctrl+C is still the draft at the prompt'); + assert.doesNotMatch(f.transcript(), /reply was discarded/u); + f.type(' >> once'); + f.app['onInput']('\r'); + assert.deepEqual(f.submitted, ['echo Y >> once'], 'the whole line runs, once'); + assert.deepEqual(f.written, [], 'nothing reached the interrupted program'); + } finally { f.dispose(); } +}); + +test('#436: a stale hidden report after Ctrl+C does not route type-ahead to the program; a new question does', () => { + const f = fixture(); + try { + f.begin('read -s secret'); + const asked = Date.now(); + f.state({since: asked, confidence: 'confirmed', mode: 'hidden'}); + f.tick(200); + f.app['onInput']('\u0003'); + f.tick(20); + f.state({since: asked, confidence: 'confirmed', mode: 'hidden'}); + f.type('ls'); + assert.equal(f.text(), 'ls', 'type-ahead for the prompt stays in the composer'); + assert.deepEqual(f.written, []); + // A program that survived the interrupt and asks again: that question is new, and keys are its own. + f.tick(50); + f.state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + f.type('x'); + assert.deepEqual(f.written, ['x'], 'keys typed for the new question are its own'); + assert.equal(f.text(), 'ls', 'what was typed before it began stays the draft'); + } finally { f.dispose(); } +}); diff --git a/tests/inputAwareness.test.ts b/tests/inputAwareness.test.ts index c10e66f9..e95bdb0e 100644 --- a/tests/inputAwareness.test.ts +++ b/tests/inputAwareness.test.ts @@ -40,6 +40,39 @@ test('a shell reader owns input until the command returns, without claiming a wa watch.dispose(); }); +test('#436: after the shell says the command is done, its prompt hooks are never read as the command waiting', async () => { + // A slow hook holds the terminal, idle, with line settings: exactly what a builtin read looks like. + let resolve!: (sample: TerminalSample) => void; + const changes: InputState[] = []; + const watch = new InputWatch({probe: () => new Promise(done => { resolve = done; }), tty: () => '/dev/ttys9'}); + watch.on('change', state => changes.push(state)); + watch.onExec('make', 0); + const looking = watch.lookNow(); + for (let index = 0; index < 3; index++) await Promise.resolve(); + watch.onDone(50); + resolve({modes: {canonical: true, echo: false, signals: true}, foreground: [{pid: 1, state: 'Ss+', cpuMs: 1, command: 'zsh'}]}); + await looking; + assert.equal(watch.state.request, undefined, 'a sample under way when the command finished is dropped'); + assert.ok(changes.every(state => !state.request), 'never reported, not even for a moment'); + await watch.lookNow(); + assert.equal(watch.state.request, undefined, 'and nothing is sampled until the prompt'); + // An open wait is closed by done: its time is counted to that moment. + const asked = new InputWatch({probe: async () => ({modes: {canonical: true, echo: false, signals: true}, foreground: [{pid: 1, state: 'S+', cpuMs: 1, command: 'sudo'}]}), tty: () => '/dev/ttys9', now: () => 100}); + asked.onExec('sudo -k true', 0); + await asked.lookNow(); + assert.equal(asked.state.request?.mode, 'hidden'); + asked.onDone(400); + assert.equal(asked.state.request, undefined); + assert.equal(asked.state.timing.waitedMs, 400, 'the wait counts to the moment the command finished'); + // The next command is watched again. + asked.onPrompt(500); + asked.onExec('read -s x', 600); + await asked.lookNow(); + assert.equal(asked.state.request?.mode, 'hidden'); + watch.dispose(); + asked.dispose(); +}); + test('open line: a prompt left without a newline is open; a finished line, a rewrite or cursor drawing is not', () => { const line = new OpenLine(); line.push('Pass 1: Checking inodes\r\nPadding at end of inode bitmap is not set. Fix? '); diff --git a/tests/promptHookLive.test.ts b/tests/promptHookLive.test.ts new file mode 100644 index 00000000..d61721d0 --- /dev/null +++ b/tests/promptHookLive.test.ts @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +/** + * #436: after a command ends the shell runs its prompt hooks (the person's own and NMSh's) before it reports its + * prompt. A slow hook holds the terminal, idle, exactly as a builtin `read` does, and the input watch used to report + * it as the finished command waiting for a line: a draft typed meanwhile was then taken for a reply and discarded + * when the prompt came (or, after Ctrl+C, lost in the shell). The shell now says the command is done as its first + * hook, and nothing after that is read as the command's input. + */ +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); + +/** A prompt hook of the person's that holds the shell, idle, for 2 s once ~/slow exists (startup stays fast). */ +const SLOW_HOOK = { + zsh: ['.zshrc', 'zmodload zsh/zselect\nnmsh_test_slow() { [[ -f ~/slow ]] && zselect -t 200; return 0 }\nprecmd_functions+=(nmsh_test_slow)\n'], + bash: ['.bashrc', 'mkfifo ~/.slow-wait 2>/dev/null; exec 9<>~/.slow-wait\nnmsh_test_slow() { [[ -f ~/slow ]] && read -t 2 -u 9; return 0; }\nPROMPT_COMMAND=nmsh_test_slow\n'], +} as const; + +for (const shell of ['zsh', 'bash'] as const) { + const available = supported && (shell === 'zsh' || bash); + test(`live ${shell}: a slow prompt hook is never taken for the command reading input; a draft typed during it survives`, {skip: available ? false : `${shell} not available`, timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + const [file, content] = SLOW_HOOK[shell]; + writeFileSync(join(sandbox.home, file), content); + const frontend = sandbox.launch([], {cols: 120, rows: 36}); + try { + await frontend.waitFor(/zsh|bash/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 30_000, 'the first prompt'); + await frontend.run('echo READY', /READY/u); + writeFileSync(join(sandbox.home, 'slow'), ''); + const mark = frontend.mark; + frontend.pty.write('true\r'); + // The command ends at once; the hook then holds the shell for 2 s. Give the watch time to look during it. + await pause(900); + for (const char of 'echo NEXT-HOOK') { frontend.pty.write(char); await pause(20); } + await until(async () => (await sandbox.sessions())[0]?.running === undefined, 15_000, 'the prompt after the slow hook'); + await pause(400); + const shown = strip(frontend.output.slice(mark)); + assert.doesNotMatch(shown, /[Ww]aiting for input/u, 'the shell running its own hook is not the command waiting'); + assert.doesNotMatch(shown, /reply was discarded/u, 'the draft was never taken for a reply'); + frontend.pty.write('\r'); + await frontend.waitFor(/NEXT-HOOK/u, mark, 15_000); + await until(async () => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records) + .filter(record => record.command === 'echo NEXT-HOOK').length === 1, 15_000, 'the draft to run once'); + } finally { await sandbox.dispose(); } + }); +} diff --git a/tests/shellProtocol.test.ts b/tests/shellProtocol.test.ts index e65fd7f7..1dca977a 100644 --- a/tests/shellProtocol.test.ts +++ b/tests/shellProtocol.test.ts @@ -10,6 +10,14 @@ test('only an authenticated reader marker transfers terminal ownership, includin assert.deepEqual(decoder.push('der\u0007Name: '), [{kind: 'reader'}, {kind: 'data', data: 'Name: '}]); }); +test('#436: only an authenticated done marker says the command finished before the prompt hooks run', () => { + const decoder = new ShellProtocolDecoder('token'); + const foreign = '\u001b]777;nmsh;other;done\u0007'; + assert.deepEqual(decoder.push(foreign), [{kind: 'data', data: foreign}], 'another token is ordinary output'); + assert.deepEqual(decoder.push('out\u001b]777;nmsh;token;do'), [{kind: 'data', data: 'out'}]); + assert.deepEqual(decoder.push('ne\u0007\u001b]777;nmsh;token;0;/tmp\u0007'), [{kind: 'done'}, {kind: 'marker', marker: {exitCode: 0, cwd: '/tmp'}}]); +}); + test('extracts split shell markers without leaking them into output', () => { const decoder = new ShellProtocolDecoder('token'); assert.deepEqual(decoder.push('hello\u001B]777;nmsh;tok'), [{kind: 'data', data: 'hello'}]); From 83135298b59a8352b51e31a9ac7483b6a2700a78 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:14:45 +0530 Subject: [PATCH 2/8] Input ownership: a stale question is ignored only while typing is type-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 --- src/app/TerminalApp.ts | 23 ++++++++++++++++------- tests/ctrlCTypeAhead.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 63033e7c..ab319e78 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -842,7 +842,8 @@ export class TerminalApp { * answer, discarded rather than run if the command ends first). Drafts started before the command's latest output * are type-ahead for the shell and stay in the composer. */ - private runningDraft?: {startId: number; at: number; answering: boolean}; + /** typeAhead: begun right after Ctrl+C or Ctrl+Z, for the prompt the stop brings back: never a reply (#436). */ + private runningDraft?: {startId: number; at: number; answering: boolean; typeAhead: boolean}; /** * Keys held while the session looks whether the running command waits for input: the first key of a draft (a * hidden reply is never drawn) and an Enter that would queue (a reply never becomes a queued command). @@ -2330,7 +2331,8 @@ export class TerminalApp { // is discarded rather than left to run as the next shell command. if (this.running && !this.editor.text && (key.kind === 'text' || key.kind === 'paste')) { // Right after Ctrl+C or Ctrl+Z, typing is type-ahead for the prompt, not a reply. - this.runningDraft = {startId: this.running.startId, at: Date.now(), answering: this.answersByDefault() && !this.justStopped()}; + const typeAhead = this.justStopped(); + this.runningDraft = {startId: this.running.startId, at: Date.now(), answering: this.answersByDefault() && !typeAhead, typeAhead}; } // Flow keeps the composer in the document: editing while scrolled back // returns to it first. Scrolling and mouse navigation alone never do. @@ -11841,11 +11843,18 @@ export class TerminalApp { // A state for a finished run (a late message) never resurrects it. if (!this.running) return; // A question that began before Ctrl+C or Ctrl+Z is the one the stop ended, reported late (the session sampled it - // before it saw the stop). It never turns what was typed after the stop into a reply, nor routes keys to the - // program (#436); a question the program asks after the stop is new. + // before it saw the stop): while typing is type-ahead for the prompt, it routes nothing to the program (#436). If it + // is still the session's word when that window closes (the program kept reading through the stop), it applies + // then, so a hidden question goes back to direct, never drawn input. A question asked after the stop is new. const stoppedAt = this.running.stoppedAt; - if (state.request && stoppedAt !== undefined && state.request.since < stoppedAt) { + if (state.request && stoppedAt !== undefined && this.justStopped() && state.request.since < stoppedAt) { const {request: _ended, ...current} = state; + const running = this.running; + const reported = state; + const later = setTimeout(() => { + if (this.running === running && this.inputState === current) this.onInputState(reported); + }, Math.max(0, stoppedAt + STOP_TYPE_AHEAD_MS - Date.now())); + later.unref?.(); state = current; } this.inputState = state; @@ -11862,12 +11871,12 @@ export class TerminalApp { const request = state.request; // Keys typed after the program wrote its question, in the moment before NMSh could tell how it reads, were // meant for it: a terminal would already have delivered them. Hand them over now instead of hiding them. - if (request && directInput(request) && draft && draft.startId === this.running.startId && draft.at >= request.since && this.editor.text) { + if (request && directInput(request) && draft && !draft.typeAhead && draft.startId === this.running.startId && draft.at >= request.since && this.editor.text) { const typed = this.editor.text; this.editor.clear(); this.runningDraft = undefined; this.session.write(typed); - } else if (request && draft && draft.at >= request.since && this.editor.text) draft.answering = true; + } else if (request && draft && !draft.typeAhead && draft.at >= request.since && this.editor.text) draft.answering = true; // The evidence a held key waited for has arrived. if (request) this.ownershipLook?.release(); this.render(); diff --git a/tests/ctrlCTypeAhead.test.ts b/tests/ctrlCTypeAhead.test.ts index fa4a74e0..729d6071 100644 --- a/tests/ctrlCTypeAhead.test.ts +++ b/tests/ctrlCTypeAhead.test.ts @@ -69,3 +69,28 @@ test('#436: a stale hidden report after Ctrl+C does not route type-ahead to the assert.equal(f.text(), 'ls', 'what was typed before it began stays the draft'); } finally { f.dispose(); } }); + +test('#436 review: a question still open after the type-ahead window (the program read through Ctrl+C) applies again', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('stubborn-prompt'); + const asked = Date.now(); + f.state({since: asked, confidence: 'confirmed', mode: 'hidden'}); + f.tick(200); + f.app['onInput']('\u0003'); + f.tick(20); + // The session never saw the wait close: its word is still the hidden question from before the stop. + f.state({since: asked, confidence: 'confirmed', mode: 'hidden'}); + f.type('ls'); + assert.equal(f.text(), 'ls', 'within the window: type-ahead for the prompt'); + assert.deepEqual(f.written, []); + // The window closes (2 s after the stop) with no newer word from the session. + f.tick(2000); + t.mock.timers.tick(2000); + assert.equal(f.app['inputState']?.request?.mode, 'hidden', 'the open hidden question applies again'); + f.type('pw'); + assert.deepEqual(f.written, ['p', 'w'], 'new keys go straight to the program, never drawn'); + assert.equal(f.text(), 'ls', 'the draft typed as type-ahead is never handed over as a reply'); + } finally { f.dispose(); } +}); From e27766807b85333505e38da8d3914d8ff077ed91 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:42:59 +0530 Subject: [PATCH 3/8] Input ownership with done: a hold settles as ended; hidden evidence is 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. --- CHANGELOG.md | 2 +- src/app/TerminalApp.ts | 14 +- src/session/InputWatch.ts | 19 ++- tests/inputOwnershipDone.test.ts | 246 +++++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 11 deletions(-) create mode 100644 tests/inputOwnershipDone.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e9d675a3..64de441c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Paste review and `/track`: a redirect into a file is recognized wherever `>/dev/null` also appears (`echo x > ~/.zshrc 2>/dev/null` was labelled read-only), and `>|` is a redirection, not a pipe. - `/track` and `/watch` no longer start, without asking, commands that change things through the network or another command: `env `, `NAME=value` prefixes, git's `-c`/`--ext-diff`, `npm publish`/`login`/`x`, `pnpm dlx`, `ssh`, `curl -X POST`/`-d`/`-o file`, plain `wget`; options that write files (`git … --output`, `sort -o`, `jest -u`, `--fix`) are refused. Read-only probes (`ping`, `dig`, `nc -z`, a curl GET) still start at once, and `npm test 2>&1` is no longer refused as a background job ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). - `/track stop` and a closing window end every process a tracked command left in its process group, including one that ignored TERM; a tracker whose directory disappeared names it instead of reporting `spawn /bin/sh ENOENT`. -- Typing while a command runs can no longer be shown, queued or run as a command before NMSh knows whether that command reads it ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). On a heavily loaded machine a reply typed right after starting a prompt-less read (`read -r reply`, `read -s`) used to be queued, and a password could be queued as a shell command or shown in the composer. Keys now wait for NMSh's check (`Checking whether … · your typing is kept` after 0.8 s). When the check cannot tell, the typing stays held and hidden — `Can't tell whether is reading your typing` — until you choose: Ctrl+S sends it to the program, Tab shows it in the composer, Esc discards it. Input with echo turned off is never drawn, whatever else NMSh knows. Ctrl+C discards held typing, as a terminal flushes its input, and a command that ends while typing was held for its check never runs it (a note says so). Type-ahead on an idle machine is unchanged. +- Typing while a command runs can no longer be shown, queued or run as a command before NMSh knows whether that command reads it ([#426](https://github.com/raiseCatError/notMyShell/issues/426)). On a heavily loaded machine a reply typed right after starting a prompt-less read (`read -r reply`, `read -s`) used to be queued, and a password could be queued as a shell command or shown in the composer. Keys now wait for NMSh's check (`Checking whether … · your typing is kept` after 0.8 s). When the check cannot tell, the typing stays held and hidden — `Can't tell whether is reading your typing` — until you choose: Ctrl+S sends it to the program, Tab shows it in the composer, Esc discards it. Input with echo turned off is never drawn, whatever else NMSh knows. Ctrl+C discards held typing, as a terminal flushes its input. When a command ends while typing is held for its check, including before its prompt returns while the shell's prompt hooks run, typing that may have been a reply or a password is discarded with a note, never run; plain type-ahead that the check cleared at once stays for the next command. Type-ahead on an idle machine is unchanged. - A command typed while the shell was showing its prompt again (after Ctrl+C, or after any command when a prompt hook is slow, as a heavy theme or `nvm` can be) no longer loses its beginning ([#436](https://github.com/raiseCatError/notMyShell/issues/436)). NMSh no longer reads the shell's own prompt hooks as the finished command waiting for input (`Probably waiting for input` while the prompt came back), and a late report of the question Ctrl+C ended no longer turns what you typed after it into a discarded reply; the rest of the line used to run on its own (`cho Y …`, command not found). ## [0.20.0] - 2026-10-18 diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 799d5c25..a3b8eb14 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -441,6 +441,10 @@ interface OwnershipHold { slow: boolean; /** No usable evidence: the typing stays held and hidden until evidence arrives or the person chooses. */ uncertain: boolean; + /** The session said nothing runs any more (the shell's done marker): the command ended, though its prompt is still coming (#436). */ + ended: boolean; + /** A look saw hidden input: the typing may be a password, so a command that ends never runs it. */ + sawHidden: boolean; settle: (how: HoldSettle) => void; } /** How long after confirming a queue review an empty Enter is not sent to the command that just started. */ @@ -11948,8 +11952,8 @@ export class TerminalApp { clearTimeout(bound); clearTimeout(slow); if (this.ownershipLook === hold) this.ownershipLook = undefined; - const ended = this.running?.startId !== running.startId; - const doubted = hold.slow || hold.uncertain; + const ended = hold.ended || this.running?.startId !== running.startId; + const doubted = hold.slow || hold.uncertain || hold.sawHidden; if (how === 'discard') { if (doubted) discarded('Your held typing was discarded, not sent or run.'); this.render(); @@ -12006,7 +12010,7 @@ export class TerminalApp { bound.unref?.(); const slow = setTimeout(() => { hold.slow = true; this.render(); }, OWNERSHIP_LOOK_SLOW_MS); slow.unref?.(); - const hold: OwnershipHold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, uncertain: false, settle}; + const hold: OwnershipHold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, uncertain: false, ended: false, sawHidden: false, settle}; this.ownershipLook = hold; // A look that saw nothing usable (a probe failed or timed out) gets one more look before the typing is left held: // gathering evidence once more, not waiting longer. An older service does not say what it saw: its answer is @@ -12016,6 +12020,10 @@ export class TerminalApp { const look = () => void this.lookWhenStarted(running.startId, deadline).then(answered, () => answered('unknown')); const answered = (result: LookResult | undefined) => { if (settled) return; + if (result === 'hidden') hold.sawHidden = true; + // Nothing runs: the command ended (its done marker can come well before its prompt), so the rules for typing + // held by a command that ended apply now, not those for one still running. + if (result === 'idle') hold.ended = true; const blind = result === 'unknown' || (result === 'hidden' && !this.inputState?.request) || (result === undefined && this.session.features.has('input-look-result')); if (!blind) settle('route'); diff --git a/src/session/InputWatch.ts b/src/session/InputWatch.ts index 778aebbf..9d6e2eab 100644 --- a/src/session/InputWatch.ts +++ b/src/session/InputWatch.ts @@ -209,17 +209,20 @@ export class InputWatch extends EventEmitter { let seen: TerminalModes | undefined; for (let sample = 0; sample < 2; sample += 1) { while (this.probing) await this.probing; - if (this.disposed || !this.running || this.done) return 'idle'; + if (this.disposed || !this.running || this.done) break; if (!this.options.probe) return 'unavailable'; this.followUp = false; seen = (await this.look(true))?.modes ?? seen; - if (!this.followUp || this.request) break; + if (!this.followUp || this.request || this.done) break; await new Promise(done => { this.setTimer(done, FOLLOW_UP_MS); }); } - if (this.disposed || !this.running || this.done) return 'idle'; - if (this.request?.mode === 'hidden') return 'hidden'; - if (!seen) return 'unknown'; - return seen.canonical && !seen.echo ? 'hidden' : 'visible'; + if (this.disposed) return 'idle'; + // Seen hidden input is reported even when the command finished during the look (#436's done): the typing may have + // been a password for it, and the frontend then discards it rather than run it as the next command. + if (this.request?.mode === 'hidden' || (seen && seen.canonical && !seen.echo)) return 'hidden'; + // Done (its prompt hooks run) or over: the shell owns typing again. + if (!this.running || this.done) return 'idle'; + return seen ? 'visible' : 'unknown'; } dispose(): void { @@ -279,7 +282,9 @@ export class InputWatch extends EventEmitter { const probing = (async () => { try { sample = await probe({tty}); } catch { sample = undefined; } })(); this.probing = probing; try { await probing; } finally { this.probing = undefined; } - if (this.disposed || !this.running || this.done) return undefined; + if (this.disposed || !this.running) return undefined; + // The command finished during the probe: nothing is applied or scheduled, but the sample is still evidence. + if (this.done) return sample; if (generation === this.generation && sample) this.apply(sample, this.now()); this.schedule(); return sample; diff --git a/tests/inputOwnershipDone.test.ts b/tests/inputOwnershipDone.test.ts new file mode 100644 index 00000000..a067af18 --- /dev/null +++ b/tests/inputOwnershipDone.test.ts @@ -0,0 +1,246 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {QueueOp} from '../src/session/CommandQueue.js'; +import {InputWatch} from '../src/session/InputWatch.js'; +import type {InputRequest, InputState, LookResult} from '../src/session/inputState.js'; +import type {TerminalSample} from '../src/session/terminalProbe.js'; +import {stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #426 (#433) with #436 (#437). Keys typed while NMSh cannot yet tell who reads them are held for a look; the shell's + * `done` marker says the command finished while its prompt hooks still run, before the prompt itself arrives. Held + * typing must then settle as typing for a command that ended: a possible password or a reply is discarded, never + * shown, queued, sent or run; only plain type-ahead is kept for the next command. Evidence of hidden input is never + * lost because the command finished during the look. Stale questions, interrupts, process exit and observations + * that arrive late change none of this. + * + * Deterministic: looks are promises the test settles, samples are given by the test, timers are mocked. No shell runs. + */ + +const SECRET = 'QSECRETQ'; +const SLOW_MS = 800; +const hiddenModes = {canonical: true, echo: false, signals: true}; +const lineModes = {canonical: true, echo: true, signals: true}; +const settled = () => new Promise(done => setImmediate(done)); +const turns = async () => { for (let index = 0; index < 4; index += 1) await Promise.resolve(); }; + +// ------------------------------------------------------------------ the session's watch + +function watchWith(probe: () => Promise) { + let probes = 0; + const changes: InputState[] = []; + const watch = new InputWatch({probe: () => { probes += 1; return probe(); }, tty: () => '/dev/ttys9'}); + watch.on('change', state => changes.push(state)); + return {watch, changes, probes: () => probes}; +} + +test('watch: once the shell says the command is done, a look answers idle and never probes', async () => { + const {watch, changes, probes} = watchWith(async () => ({modes: lineModes, foreground: [{pid: 1, state: 'S+', cpuMs: 1, command: 'make'}]})); + try { + watch.onExec('make', 0); + watch.onDone(10); + assert.equal(await watch.lookNow(), 'idle', 'the shell owns typing again'); + assert.equal(probes(), 0, 'its prompt hooks are not sampled'); + assert.ok(changes.every(state => !state.request)); + } finally { watch.dispose(); } +}); + +test('watch: hidden input seen by a look that the command finished during is still reported hidden, never applied', async () => { + let resolve!: (sample: TerminalSample) => void; + const {watch, changes} = watchWith(() => new Promise(done => { resolve = done; })); + try { + watch.onExec('read -rs -t 1 pw', 0); + const looking = watch.lookNow(); + await turns(); + // The read gave up and the shell's hooks began while the probe was under way; the probe had seen echo off. + watch.onDone(50); + resolve({modes: hiddenModes, foreground: [{pid: 1, state: 'S+', cpuMs: 1, command: 'zsh'}]}); + assert.equal(await looking, 'hidden', 'the evidence of a possible password is not dropped'); + assert.equal(watch.state.request, undefined, 'but no question is reported for a command that finished'); + assert.ok(changes.every(state => !state.request)); + assert.equal(await watch.lookNow(), 'idle', 'a later look says the command is over'); + } finally { watch.dispose(); } +}); + +test('watch: visible evidence from a look the command finished during answers idle; the next command is watched again', async () => { + let resolve!: (sample: TerminalSample) => void; + const {watch, changes, probes} = watchWith(() => new Promise(done => { resolve = done; })); + try { + watch.onExec('make', 0); + const looking = watch.lookNow(); + await turns(); + watch.onDone(50); + // A slow prompt hook: line settings, echo on, the shell holding its terminal (zsh and Bash show exactly this). + resolve({modes: lineModes, foreground: [{pid: 1, state: 'Ss+', cpuMs: 1, command: 'zsh'}]}); + assert.equal(await looking, 'idle'); + assert.equal(watch.state.request, undefined, 'a hook is never a question'); + assert.ok(changes.every(state => !state.request)); + watch.onPrompt(60); + watch.onExec('read -s x', 70); + const next = watch.lookNow(); + await turns(); + resolve({modes: hiddenModes, foreground: [{pid: 2, state: 'S+', cpuMs: 1, command: 'zsh'}]}); + assert.equal(await next, 'hidden', 'the next command is looked at afresh'); + assert.equal(probes(), 2); + } finally { watch.dispose(); } +}); + +// ------------------------------------------------------------------ the frontend's hold + +function fixture() { + const frame = executionFrameFixture(100, 30); + const written: string[] = []; + const queueOps: QueueOp[] = []; + const submitted: string[] = []; + let interrupts = 0; + Object.defineProperty(frame.session, 'write', {value: (data: string) => { written.push(data); }, configurable: true}); + Object.defineProperty(frame.session, 'interrupt', {value: () => { interrupts += 1; }, configurable: true}); + const originalQueue = frame.session.queue.bind(frame.session); + Object.defineProperty(frame.session, 'queue', {value: (op: QueueOp) => { queueOps.push(op); originalQueue(op); }, configurable: true}); + Object.defineProperty(frame.app, 'submit', {value: async () => { submitted.push(frame.app['editor'].text); frame.app['editor'].clear(); }, configurable: true}); + let looks = 0; + let settle: (result: LookResult | undefined) => void = () => {}; + Object.defineProperty(frame.session, 'lookInput', {value: () => { + looks += 1; + return new Promise(resolve => { settle = resolve; }); + }, configurable: true}); + const type = (text: string) => { for (const char of text) frame.app['onInput'](char); }; + const text = () => frame.app['editor'].text as string; + const adds = () => queueOps.filter(op => op.op === 'add').map(op => (op as {text: string}).text); + const screen = () => frame.capture().frame.rows.map(line => stripAnsi(line)).join('\n'); + const transcript = () => (frame.app['output'].transcript().lines as Array>) + .map(line => line.map(cell => cell?.text ?? ' ').join('')).join('\n'); + const state = (request?: InputRequest) => frame.session.emit('inputState', {...(request ? {request} : {}), timing: {waitedMs: 0, waits: 0}}); + /** Nothing anywhere a person, a log or the shell could read holds the secret. */ + const nowhere = (secret: string, what: string) => { + assert.equal(screen().includes(secret), false, `${what}: not on screen`); + assert.equal(text().includes(secret), false, `${what}: not in the composer`); + assert.equal(adds().some(entry => entry.includes(secret)), false, `${what}: not queued`); + assert.equal(submitted.some(entry => entry.includes(secret)), false, `${what}: not run as a command`); + assert.equal(written.some(entry => entry.includes(secret)), false, `${what}: not written to the terminal`); + assert.equal(transcript().includes(secret), false, `${what}: not in the transcript`); + }; + return {...frame, written, submitted, type, text, adds, screen, transcript, state, nowhere, + enter: () => frame.app['onInput']('\r'), interrupts: () => interrupts, looks: () => looks, + answer: (result: LookResult | undefined) => settle(result)}; +} + +test('done before the prompt: a reply the look saw as hidden is discarded, never drawn, queued, sent or run', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -rs -t 1 pw'); + f.type(SECRET); + f.enter(); + f.nowhere(SECRET, 'held'); + // The look saw echo off, but the read had already given up: no question ever arrives; then the shell says done. + f.answer('hidden'); + await settled(); + assert.equal(f.looks(), 2, 'hidden with no question: one more look'); + f.nowhere(SECRET, 'looking again'); + f.answer('idle'); + await settled(); + f.nowhere(SECRET, 'after done'); + assert.match(f.transcript(), /discarded, not run/u, 'a note says what happened, without the text'); + f.complete(1); + await settled(); + f.nowhere(SECRET, 'after the prompt'); + assert.deepEqual(f.adds(), []); + assert.deepEqual(f.submitted, []); + } finally { f.dispose(); } +}); + +test('done before the prompt: typing held past the slow mark is discarded, never run', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('./deploy.sh'); + f.type('yes'); + f.enter(); + t.mock.timers.tick(SLOW_MS + 100); + f.answer('idle'); + await settled(); + assert.equal(f.text(), '', 'not left in the composer'); + assert.deepEqual(f.adds(), [], 'not queued'); + f.complete(0); + await settled(); + assert.deepEqual(f.submitted, [], 'never run: NMSh never learned whether it was a reply'); + assert.deepEqual(f.written, []); + assert.match(f.transcript(), /discarded, not run/u); + } finally { f.dispose(); } +}); + +test('done before the prompt: plain type-ahead the look answered quickly is kept for the next command, once', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('make'); + f.type('echo next'); + f.answer('idle'); + await settled(); + assert.equal(f.text(), 'echo next', 'the next command\'s draft, visible'); + assert.deepEqual(f.written, [], 'nothing went to the program that finished'); + f.complete(0); + await settled(); + assert.equal(f.text(), 'echo next', 'it survives the prompt'); + f.enter(); + assert.deepEqual(f.submitted, ['echo next'], 'and runs once, on the person\'s Enter'); + assert.deepEqual(f.adds(), []); + assert.deepEqual(f.written, []); + } finally { f.dispose(); } +}); + +test('interrupt: Ctrl+C discards held typing; done, a late answer and a stale question after it change nothing', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -rs pw'); + const asked = Date.now(); + f.tick(200); + f.type(SECRET); + f.enter(); + f.app['onInput']('\u0003'); + f.tick(20); + assert.equal(f.interrupts(), 1); + f.nowhere(SECRET, 'after Ctrl+C'); + // The interrupted read's late evidence: the look answers, and the question it ended is reported after the stop. + f.answer('hidden'); + f.state({since: asked, confidence: 'confirmed', mode: 'hidden'}); + await settled(); + f.nowhere(SECRET, 'after the late reports'); + // What is typed after Ctrl+C is type-ahead for the prompt: never held, never routed to the read that ended. + f.type('echo Y'); + f.state(); + f.complete(130); + await settled(); + assert.equal(f.text(), 'echo Y'); + f.enter(); + assert.deepEqual(f.submitted, ['echo Y']); + assert.deepEqual(f.written, []); + f.nowhere(SECRET, 'at the end'); + } finally { f.dispose(); } +}); + +test('process exit with the look unanswered, then late evidence: nothing reaches the next prompt or the next command', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sudo -k true', 'Password:'); + f.type(SECRET); + f.enter(); + t.mock.timers.tick(SLOW_MS + 100); + f.complete(1); + await settled(); + f.nowhere(SECRET, 'after exit'); + assert.match(f.transcript(), /discarded, not run/u); + // The old look answers after the command is gone, then a new command starts: neither sees the old typing. + f.answer('hidden'); + await settled(); + f.begin('cat'); + f.state({since: Date.now(), confidence: 'likely', mode: 'line'}); + await settled(); + f.nowhere(SECRET, 'after the late answer and the next command'); + assert.deepEqual(f.written, []); + } finally { f.dispose(); } +}); From e391bcb0c62f48b2e16e0ad27116e9318623323d Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:24:15 +0530 Subject: [PATCH 4/8] Input ownership: type-ahead after answering a hidden question is kept 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. --- src/app/TerminalApp.ts | 8 +++++++- tests/inputOwnershipDone.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index a3b8eb14..19362de0 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -872,6 +872,8 @@ export class TerminalApp { * hidden reply is never drawn) and an Enter that would queue (a reply never becomes a queued command). */ private ownershipLook?: OwnershipHold; + /** The running command (its startId) whose hidden question NMSh has sent an answer to: later typing is type-ahead, not that reply. */ + private hiddenAnsweredFor?: number; private replayingHeld = false; /** * The queue review a first empty Enter armed at idle (resume a paused queue, or approve the entry that asks first). @@ -12010,6 +12012,7 @@ export class TerminalApp { bound.unref?.(); const slow = setTimeout(() => { hold.slow = true; this.render(); }, OWNERSHIP_LOOK_SLOW_MS); slow.unref?.(); + const afterHiddenAnswer = this.hiddenAnsweredFor === running.startId; const hold: OwnershipHold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, uncertain: false, ended: false, sawHidden: false, settle}; this.ownershipLook = hold; // A look that saw nothing usable (a probe failed or timed out) gets one more look before the typing is left held: @@ -12020,7 +12023,9 @@ export class TerminalApp { const look = () => void this.lookWhenStarted(running.startId, deadline).then(answered, () => answered('unknown')); const answered = (result: LookResult | undefined) => { if (settled) return; - if (result === 'hidden') hold.sawHidden = true; + // Hidden input seen after NMSh already answered this command's hidden question is that question, still open + // until the read returns: the typing that follows the answer is type-ahead, not a reply. + if (result === 'hidden' && !afterHiddenAnswer) hold.sawHidden = true; // Nothing runs: the command ended (its done marker can come well before its prompt), so the rules for typing // held by a command that ended apply now, not those for one still running. if (result === 'idle') hold.ended = true; @@ -12053,6 +12058,7 @@ export class TerminalApp { const bytes = directKeyBytes(key); if (!bytes) return; const mode = this.inputState?.request?.mode; + if (mode === 'hidden' && /[\r\n]/u.test(bytes) && this.running) this.hiddenAnsweredFor = this.running.startId; this.session.write(bytes); // One key answers a key read; Enter ends a hidden line (as the session's watch also decides). if (mode === 'key' || /[\r\n]/u.test(bytes)) this.answerSent(); diff --git a/tests/inputOwnershipDone.test.ts b/tests/inputOwnershipDone.test.ts index a067af18..63a9f878 100644 --- a/tests/inputOwnershipDone.test.ts +++ b/tests/inputOwnershipDone.test.ts @@ -191,6 +191,32 @@ test('done before the prompt: plain type-ahead the look answered quickly is kept } finally { f.dispose(); } }); +test('type-ahead after answering a hidden question is kept when the command ends, though a look still sees that question', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -s x'); + f.state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + f.tick(200); + f.type(SECRET); + f.enter(); + assert.equal(f.written.join(''), `${SECRET}\r`, 'the answer went to the hidden read'); + f.tick(20); + // The next command, typed at once: the read has its answer but has not returned yet. + f.type('echo NEXT'); + f.enter(); + // A look still sees the question just answered (echo off), then the shell says the command is done. + f.answer('hidden'); + await settled(); + f.answer('idle'); + await settled(); + f.complete(0); + await settled(); + assert.deepEqual(f.adds(), ['echo NEXT'], 'the type-ahead runs next, once; it is not discarded'); + f.nowhere(SECRET, 'the answer itself'); + } finally { f.dispose(); } +}); + test('interrupt: Ctrl+C discards held typing; done, a late answer and a stale question after it change nothing', async t => { const f = fixture(); try { From 807d3c030608b6a4f99401be612f139819ecbd35 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:27:52 +0530 Subject: [PATCH 5/8] Input ownership: the hidden-answer grace is bounded and ends at a newer 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. --- src/app/TerminalApp.ts | 20 +++++++++---- tests/inputOwnershipDone.test.ts | 50 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 19362de0..14435bf2 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -429,6 +429,8 @@ const STOP_TYPE_AHEAD_MS = 2000; const OWNERSHIP_LOOK_MS = 5000; /** A look still running after this long (a loaded machine) says so in the composer: the typing is kept, not lost. */ const OWNERSHIP_LOOK_SLOW_MS = 800; +/** Typing begun this soon after an answer to a hidden question is type-ahead for what comes next, not that reply. */ +const HIDDEN_ANSWER_TYPE_AHEAD_MS = 1000; /** How held keys end: on the evidence (route), dropped, sent to the program as typed, or shown in the composer. */ type HoldSettle = 'route' | 'discard' | 'send' | 'reveal'; /** Keys held while NMSh looks at whether the running command reads input (#426). */ @@ -872,8 +874,11 @@ export class TerminalApp { * hidden reply is never drawn) and an Enter that would queue (a reply never becomes a queued command). */ private ownershipLook?: OwnershipHold; - /** The running command (its startId) whose hidden question NMSh has sent an answer to: later typing is type-ahead, not that reply. */ - private hiddenAnsweredFor?: number; + /** + * When NMSh last sent an answer to the running command's hidden question. Typing that begins right after it is + * type-ahead, and a look that still sees that question is not new evidence; a newer question ends this at once. + */ + private hiddenAnswer?: {startId: number; at: number}; private replayingHeld = false; /** * The queue review a first empty Enter armed at idle (resume a paused queue, or approve the entry that asks first). @@ -11878,6 +11883,8 @@ export class TerminalApp { private onInputState(state: InputState): void { // A state for a finished run (a late message) never resurrects it. if (!this.running) return; + // A question newer than the hidden one NMSh answered: typing from now on may be its reply, not type-ahead. + if (state.request && this.hiddenAnswer && state.request.since > this.hiddenAnswer.at) this.hiddenAnswer = undefined; // A question that began before Ctrl+C or Ctrl+Z is the one the stop ended, reported late (the session sampled it // before it saw the stop): while typing is type-ahead for the prompt, it routes nothing to the program (#436). If it // is still the session's word when that window closes (the program kept reading through the stop), it applies @@ -12012,7 +12019,10 @@ export class TerminalApp { bound.unref?.(); const slow = setTimeout(() => { hold.slow = true; this.render(); }, OWNERSHIP_LOOK_SLOW_MS); slow.unref?.(); - const afterHiddenAnswer = this.hiddenAnsweredFor === running.startId; + // Only typing begun just after the answer, with no newer question since, is type-ahead (#433 review): a second hidden + // question that NMSh has not reported yet must still count its reply as possibly a password. + const startedAt = Date.now(); + const afterHiddenAnswer = () => this.hiddenAnswer?.startId === running.startId && startedAt - this.hiddenAnswer.at <= HIDDEN_ANSWER_TYPE_AHEAD_MS; const hold: OwnershipHold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, uncertain: false, ended: false, sawHidden: false, settle}; this.ownershipLook = hold; // A look that saw nothing usable (a probe failed or timed out) gets one more look before the typing is left held: @@ -12025,7 +12035,7 @@ export class TerminalApp { if (settled) return; // Hidden input seen after NMSh already answered this command's hidden question is that question, still open // until the read returns: the typing that follows the answer is type-ahead, not a reply. - if (result === 'hidden' && !afterHiddenAnswer) hold.sawHidden = true; + if (result === 'hidden' && !afterHiddenAnswer()) hold.sawHidden = true; // Nothing runs: the command ended (its done marker can come well before its prompt), so the rules for typing // held by a command that ended apply now, not those for one still running. if (result === 'idle') hold.ended = true; @@ -12058,7 +12068,7 @@ export class TerminalApp { const bytes = directKeyBytes(key); if (!bytes) return; const mode = this.inputState?.request?.mode; - if (mode === 'hidden' && /[\r\n]/u.test(bytes) && this.running) this.hiddenAnsweredFor = this.running.startId; + if (mode === 'hidden' && /[\r\n]/u.test(bytes) && this.running) this.hiddenAnswer = {startId: this.running.startId, at: Date.now()}; this.session.write(bytes); // One key answers a key read; Enter ends a hidden line (as the session's watch also decides). if (mode === 'key' || /[\r\n]/u.test(bytes)) this.answerSent(); diff --git a/tests/inputOwnershipDone.test.ts b/tests/inputOwnershipDone.test.ts index 63a9f878..ac3b832b 100644 --- a/tests/inputOwnershipDone.test.ts +++ b/tests/inputOwnershipDone.test.ts @@ -217,6 +217,56 @@ test('type-ahead after answering a hidden question is kept when the command ends } finally { f.dispose(); } }); +test('review: a second hidden question after the first answer: its reply is never run when the command ends', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -s a; read -s -t 1 b'); + f.state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + f.tick(200); + f.type('first'); + f.enter(); + // The second read asks with no prompt; NMSh has not reported it yet when its reply is typed, well after the first. + f.tick(1500); + f.type(SECRET); + f.enter(); + f.answer('hidden'); + await settled(); + f.answer('idle'); + await settled(); + f.complete(1); + await settled(); + f.nowhere(SECRET, 'the second reply'); + assert.match(f.transcript(), /discarded, not run/u); + } finally { f.dispose(); } +}); + +test('review: a new hidden question reported after the answer ends the grace at once', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sudo -k true', 'Password:'); + f.state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + f.tick(200); + f.type('wrong'); + f.enter(); + f.tick(20); + // "Sorry, try again." and a new question, reported and then closed before the next typing (the probe lagged). + f.state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + f.state(); + f.tick(20); + f.type(SECRET); + f.enter(); + f.answer('hidden'); + await settled(); + f.answer('idle'); + await settled(); + f.complete(1); + await settled(); + f.nowhere(SECRET, 'the retry'); + } finally { f.dispose(); } +}); + test('interrupt: Ctrl+C discards held typing; done, a late answer and a stale question after it change nothing', async t => { const f = fixture(); try { From 6486d9c5fa0718c26b9c6358d99e2295ec9c8544 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:47:16 +0530 Subject: [PATCH 6/8] Input ownership: a possible password never runs as a command by itself 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 567cf533 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. --- src/app/TerminalApp.ts | 101 ++++++-- src/status/inputStatus.ts | 8 + tests/inputOwnershipDone.test.ts | 14 +- tests/inputOwnershipSecrets.test.ts | 321 ++++++++++++++++++++++++ tests/inputOwnershipSecretsLive.test.ts | 66 +++++ tests/typingAfterRead.test.ts | 21 +- 6 files changed, 509 insertions(+), 22 deletions(-) create mode 100644 tests/inputOwnershipSecrets.test.ts create mode 100644 tests/inputOwnershipSecretsLive.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 14435bf2..601be6b1 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -211,7 +211,7 @@ import {PATH_DISPLAY_LEVELS} from '../prompt/pathDisplay.js'; import {completedActivity, liveActivityParts} from '../status/activity.js'; import {ACTIVITY_VERBS, selectActivityVerb, type ActivityVerbPair} from '../status/activityVerbs.js'; import {OpenLine, promptShaped} from '../session/openLine.js'; -import {completionWaitFact, heldInputActions, heldInputRows, inputActivityRows, inputComposerHint, inputGlyph, inputProgram, waitedDetail} from '../status/inputStatus.js'; +import {completionWaitFact, heldAtPromptActions, heldInputActions, heldInputRows, inputActivityRows, inputComposerHint, inputGlyph, inputProgram, waitedDetail} from '../status/inputStatus.js'; import {pauseReason, pausedGlyph, queueGlyph, queuePreview, queueRow, queueBlock, type QueueAffordance} from '../status/queueStatus.js'; import {queueSettingsPreview} from '../status/queueSettingsPreview.js'; import {queuePanelKey, renderQueuePanel, type QueuePanelState} from '../queue/QueuePanel.js'; @@ -221,7 +221,7 @@ import {buildQueueSummary, recognizedExecutables, reviewedTextSafe, type QueueSu import type {QueueTimeline} from '../session/ExecutionTimeline.js'; import type {QueueEntry, QueueState} from '../session/CommandQueue.js'; import type {QueueNews} from '../session/SessionClient.js'; -import {directInput, type InputState, type InputTiming, type LookResult} from '../session/inputState.js'; +import {directInput, type InputRequest, type InputState, type InputTiming, type LookResult} from '../session/inputState.js'; import {directKeyBytes} from '../terminal/keyBytes.js'; import {extractFacts} from '../status/adapters.js'; import {focusForeground, foreground, background, UI_COLORS, lazyForeground} from '../ui/palette.js'; @@ -429,7 +429,11 @@ const STOP_TYPE_AHEAD_MS = 2000; const OWNERSHIP_LOOK_MS = 5000; /** A look still running after this long (a loaded machine) says so in the composer: the typing is kept, not lost. */ const OWNERSHIP_LOOK_SLOW_MS = 800; -/** Typing begun this soon after an answer to a hidden question is type-ahead for what comes next, not that reply. */ +/** + * Typing begun this soon after an answer to a hidden question may be type-ahead for what comes next rather than a reply + * to a newer question. This only decides whether held typing counts as a probable reply (discarded) or as unconfirmed + * (left for the person at the prompt); it never lets typing run while a look saw hidden input (#426 review). + */ const HIDDEN_ANSWER_TYPE_AHEAD_MS = 1000; /** How held keys end: on the evidence (route), dropped, sent to the program as typed, or shown in the composer. */ type HoldSettle = 'route' | 'discard' | 'send' | 'reveal'; @@ -447,6 +451,15 @@ interface OwnershipHold { ended: boolean; /** A look saw hidden input: the typing may be a password, so a command that ends never runs it. */ sawHidden: boolean; + /** + * A look saw hidden input right after NMSh answered a hidden question: that question still closing, or a newer one + * with no prompt. NMSh cannot tell which, so a command that ends never runs this typing either: it waits at the prompt. + */ + sawAnsweredHidden: boolean; + /** The command ended with the typing unconfirmed: held, hidden, at the prompt until the person shows or discards it. */ + atPrompt: boolean; + /** The command line's first word, as the person saw it running. */ + program: string; settle: (how: HoldSettle) => void; } /** How long after confirming a queue review an empty Enter is not sent to the command that just started. */ @@ -879,6 +892,8 @@ export class TerminalApp { * type-ahead, and a look that still sees that question is not new evidence; a newer question ends this at once. */ private hiddenAnswer?: {startId: number; at: number}; + /** The running command's hidden question whose queued entries were last checked (a question is reported repeatedly). */ + private hiddenQueueCheck?: {startId: number; since: number}; private replayingHeld = false; /** * The queue review a first empty Enter armed at idle (resume a paused queue, or approve the entry that asks first). @@ -1414,8 +1429,9 @@ export class TerminalApp { private readonly onInput = (data: string): void => { // Before probes classify a read, or inside a native reader, input can already be sensitive. - // Key diagnostics therefore observe only the idle composer, never startup or running-program input. - if (process.env.NMSH_DEBUG_KEYS === '1' && !this.running && !this.startupPending && !this.passthrough && !this.externalPassthrough) { + // Key diagnostics therefore observe only the idle composer, never startup or running-program input, nor typing held + // at the prompt because it may be a reply to hidden input (#426). + if (process.env.NMSH_DEBUG_KEYS === '1' && !this.running && !this.ownershipLook && !this.startupPending && !this.passthrough && !this.externalPassthrough) { this.logEditorKeyInput(data); } if (this.passthrough && !this.startupPending) { @@ -2330,6 +2346,8 @@ export class TerminalApp { // Ctrl+C is never held, and what was typed before it whose reader NMSh did not know is discarded, as a terminal // flushes its input queue on an interrupt: it never becomes a queued entry or the next command (#426). if (key.kind === 'interrupt') hold.settle('discard'); + // Held at the prompt there is no program to send it to: only showing or discarding it settles it. + else if (hold.atPrompt && key.kind === 'sendToProgram') return; else if (hold.uncertain && key.kind === 'sendToProgram') { hold.settle('send'); return; } else if (hold.uncertain && key.kind === 'complete') { hold.settle('reveal'); return; } else if (hold.uncertain && key.kind === 'escape') { hold.settle('discard'); return; } @@ -11470,8 +11488,9 @@ export class TerminalApp { const queue = listed?.[0] ?? this.queueLine(columns); if (!this.running) return queue ? (listed ? ['', queue] : [queue, '']) : []; const request = this.inputState?.request; - // Typing held without evidence of who reads it: say so, and how to choose, in the rows a wait would use. - if (this.ownershipLook?.uncertain) return heldInputRows(this.running.command.trim().split(/\s+/u)[0] || 'the command', columns); + // Typing held without evidence of who reads it: say so, and how to choose, in the rows a wait would use. Typing held + // at the prompt for a command that ended is not this command's: the composer says it. + if (this.ownershipLook?.uncertain && !this.ownershipLook.atPrompt) return heldInputRows(this.running.command.trim().split(/\s+/u)[0] || 'the command', columns); // A wait reads top to bottom (headline, then the question) wherever the composer is docked. if (request) { const rows = inputActivityRows({...this.inputState!, request}, this.running.command, this.running.startedAt, this.activityAnimationNow, columns); @@ -11489,6 +11508,8 @@ export class TerminalApp { /** While a command runs, the empty composer says what Enter will do with what you type. */ private composerHint(): {text: string; waiting: boolean} | undefined { + // Typing held when a command ended right after its hidden input: whatever runs now, only the person settles it. + if (this.ownershipLook?.atPrompt && !this.passthrough) return {text: heldAtPromptActions(this.ownershipLook.program), waiting: true}; if (!this.running || this.passthrough || this.queueEdit || this.justStopped()) return undefined; // The program's question keeps the activity rows, so the composer itself says how compose mode ends. if (this.queueCompose) return {text: 'Queue a command · Enter adds it · Esc returns to the program', waiting: false}; @@ -11920,11 +11941,39 @@ export class TerminalApp { this.runningDraft = undefined; this.session.write(typed); } else if (request && draft && !draft.typeAhead && draft.at >= request.since && this.editor.text) draft.answering = true; + if (request?.mode === 'hidden') this.holdQueuedReplies(request); // The evidence a held key waited for has arrived. if (request) this.ownershipLook?.settle('route'); this.render(); } + /** + * A hidden question with no prompt can come after its reply was already typed and entered: with no question to + * answer yet, that Enter queued it as a command (#426, documented limit). When the command asks for hidden input, the + * entries queued while it ran may be that reply: they wait for the person's approval instead of running after it, so + * a password never runs as a shell command by itself. Entries queued before the command started are left as they + * are, and so are those queued while this question is already shown (Ctrl+Q): each question is checked once, when it + * is first reported. Its `since` is no boundary: it can be when the shell went idle (a `sleep` before the `read`). + */ + private holdQueuedReplies(request: InputRequest): void { + const running = this.running; + if (!running || !this.queueAvailable()) return; + if (this.hiddenQueueCheck?.startId === running.startId && this.hiddenQueueCheck.since === request.since) return; + this.hiddenQueueCheck = {startId: running.startId, since: request.since}; + const typed = this.queueState.entries.filter(entry => entry.addedAt >= running.startedAt && entry.condition !== 'approve'); + if (!typed.length) return; + const program = commandWords(running.command)[0] ?? 'The command'; + if (this.queueConditionsAvailable()) { + for (const entry of typed) this.session.queue({op: 'condition', id: entry.id, condition: 'approve'}); + const count = typed.length === 1 ? 'the command queued while it ran waits' : `the ${typed.length} commands queued while it ran wait`; + this.output.addFrontendInteraction(program, `${program} asked for hidden input; ${count} for your approval (/queue), in case one was its reply.`, INFO); + } else { + // An older session service without per-entry approval: the whole queue waits instead. + this.session.queue({op: 'pause'}); + this.output.addFrontendInteraction(program, `${program} asked for hidden input; the queue is paused (/queue), in case a command queued while it ran was its reply.`, INFO); + } + } + /** * Whether this key decides where typing goes while a command runs and NMSh has no evidence yet that the command * waits for input: the first key of a draft, or an Enter that would otherwise queue. @@ -11945,7 +11994,8 @@ export class TerminalApp { * is kept. When the look saw nothing usable, or never answers within OWNERSHIP_LOOK_MS, the typing stays held and * hidden (uncertain) until evidence arrives or the person chooses: Ctrl+S sends it to the program, Tab shows it in * the composer, Esc discards it. It is never routed on a guess. Ctrl+C discards it, as a terminal flushes its input. - * A command that ends while its typing was held past the slow mark never runs it. + * A command that ends while its typing was held past the slow mark never runs it. One that ends while a look saw + * hidden input right after a hidden answer leaves the typing held at the prompt for the person (Tab or Esc). */ private holdForOwnershipLook(key: Key): void { const running = this.running!; @@ -11957,12 +12007,25 @@ export class TerminalApp { let settled = false; const settle = (how: HoldSettle) => { if (settled) return; + // Held at the prompt: no evidence, timer or later command settles it, only the person (Tab, Esc, Ctrl+C). + if (hold.atPrompt && how !== 'reveal' && how !== 'discard') return; + const ended = hold.ended || this.running?.startId !== running.startId; + const doubted = hold.slow || hold.uncertain || hold.sawHidden; + // The command ended while a look still saw hidden input right after NMSh answered a hidden question. The typing + // is the next command or a reply to a second question with no prompt that gave up first; nothing tells them + // apart, and a time window is no proof. It is neither run nor dropped: it waits, hidden, for the person. + if (how === 'route' && ended && !hold.answering && !doubted && hold.sawAnsweredHidden && keys.some(typing)) { + clearTimeout(bound); + clearTimeout(slow); + hold.atPrompt = true; + hold.uncertain = true; + this.render(); + return; + } settled = true; clearTimeout(bound); clearTimeout(slow); if (this.ownershipLook === hold) this.ownershipLook = undefined; - const ended = hold.ended || this.running?.startId !== running.startId; - const doubted = hold.slow || hold.uncertain || hold.sawHidden; if (how === 'discard') { if (doubted) discarded('Your held typing was discarded, not sent or run.'); this.render(); @@ -12010,7 +12073,7 @@ export class TerminalApp { }; // No usable evidence yet: keep the typing held and hidden, and say so (never route it on a guess). const doubt = () => { - if (settled) return; + if (settled || hold.atPrompt) return; if (this.running?.startId !== running.startId || this.passthrough || this.inputState?.request || this.inputState?.terminalOwner) { settle('route'); return; } hold.uncertain = true; this.render(); @@ -12019,11 +12082,12 @@ export class TerminalApp { bound.unref?.(); const slow = setTimeout(() => { hold.slow = true; this.render(); }, OWNERSHIP_LOOK_SLOW_MS); slow.unref?.(); - // Only typing begun just after the answer, with no newer question since, is type-ahead (#433 review): a second hidden - // question that NMSh has not reported yet must still count its reply as possibly a password. + // Only typing begun just after the answer, with no newer question since, may be type-ahead (#433 review): a second + // hidden question that NMSh has not reported yet must still count its reply as possibly a password. const startedAt = Date.now(); const afterHiddenAnswer = () => this.hiddenAnswer?.startId === running.startId && startedAt - this.hiddenAnswer.at <= HIDDEN_ANSWER_TYPE_AHEAD_MS; - const hold: OwnershipHold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, uncertain: false, ended: false, sawHidden: false, settle}; + const hold: OwnershipHold = {keys, startId: running.startId, answering: this.answersByDefault(), slow: false, uncertain: false, ended: false, + sawHidden: false, sawAnsweredHidden: false, atPrompt: false, program, settle}; this.ownershipLook = hold; // A look that saw nothing usable (a probe failed or timed out) gets one more look before the typing is left held: // gathering evidence once more, not waiting longer. An older service does not say what it saw: its answer is @@ -12032,10 +12096,11 @@ export class TerminalApp { const deadline = Date.now() + OWNERSHIP_LOOK_MS; const look = () => void this.lookWhenStarted(running.startId, deadline).then(answered, () => answered('unknown')); const answered = (result: LookResult | undefined) => { - if (settled) return; - // Hidden input seen after NMSh already answered this command's hidden question is that question, still open - // until the read returns: the typing that follows the answer is type-ahead, not a reply. - if (result === 'hidden' && !afterHiddenAnswer()) hold.sawHidden = true; + if (settled || hold.atPrompt) return; + // Hidden input seen just after NMSh answered this command's hidden question is usually that question, open until + // its read returns, and the typing type-ahead; but it can be a second question with no prompt. Either way the + // typing is not a known reply (not discarded) and not known type-ahead (never run by itself if the command ends). + if (result === 'hidden') { if (afterHiddenAnswer()) hold.sawAnsweredHidden = true; else hold.sawHidden = true; } // Nothing runs: the command ended (its done marker can come well before its prompt), so the rules for typing // held by a command that ended apply now, not those for one still running. if (result === 'idle') hold.ended = true; diff --git a/src/status/inputStatus.ts b/src/status/inputStatus.ts index c304c0a7..55f4f9f4 100644 --- a/src/status/inputStatus.ts +++ b/src/status/inputStatus.ts @@ -90,6 +90,14 @@ export function heldInputActions(program: string): string { return `Ctrl+S sends it to ${program} · Tab shows it · Esc discards it`; } +/** + * Typing held at the prompt (#426): the command ended right after hidden input, and NMSh could not tell whether the + * typing was the next command or a reply to a question with no prompt. Nothing typed is shown until the person chooses. + */ +export function heldAtPromptActions(program: string): string { + return `Typed during ${program}'s hidden input · held, not run · Tab shows it · Esc discards it`; +} + /** * The activity rows while typing is held without evidence of who reads it: what happened, then how to choose. Nothing * typed is shown; the person decides, or later evidence does. diff --git a/tests/inputOwnershipDone.test.ts b/tests/inputOwnershipDone.test.ts index ac3b832b..dba3aabb 100644 --- a/tests/inputOwnershipDone.test.ts +++ b/tests/inputOwnershipDone.test.ts @@ -191,7 +191,7 @@ test('done before the prompt: plain type-ahead the look answered quickly is kept } finally { f.dispose(); } }); -test('type-ahead after answering a hidden question is kept when the command ends, though a look still sees that question', async t => { +test('type-ahead after answering a hidden question, while a look still sees hidden input, is never run or dropped: it waits for the person', async t => { const f = fixture(); try { t.mock.timers.enable({apis: ['setTimeout']}); @@ -205,14 +205,22 @@ test('type-ahead after answering a hidden question is kept when the command ends // The next command, typed at once: the read has its answer but has not returned yet. f.type('echo NEXT'); f.enter(); - // A look still sees the question just answered (echo off), then the shell says the command is done. + // A look still sees hidden input (the question just answered, or a second one with no prompt: NMSh cannot tell), + // then the shell says the command is done. A time window after the answer is no proof that this is not a reply. f.answer('hidden'); await settled(); f.answer('idle'); await settled(); f.complete(0); await settled(); - assert.deepEqual(f.adds(), ['echo NEXT'], 'the type-ahead runs next, once; it is not discarded'); + assert.deepEqual(f.adds(), [], 'not queued'); + assert.deepEqual(f.submitted, [], 'not run by itself'); + assert.equal(f.text(), '', 'not drawn'); + assert.match(f.screen(), /held, not run · Tab shows it · Esc discards it/u, 'kept for the person, who knows what it was'); + f.app['onInput']('\t'); + assert.equal(f.text(), 'echo NEXT', 'Tab shows it in the composer'); + f.enter(); + assert.deepEqual(f.submitted, ['echo NEXT'], 'an ordinary command is never lost: it runs once, on the person\'s Enter'); f.nowhere(SECRET, 'the answer itself'); } finally { f.dispose(); } }); diff --git a/tests/inputOwnershipSecrets.test.ts b/tests/inputOwnershipSecrets.test.ts new file mode 100644 index 00000000..6c5338fe --- /dev/null +++ b/tests/inputOwnershipSecrets.test.ts @@ -0,0 +1,321 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {QueueOp} from '../src/session/CommandQueue.js'; +import type {InputRequest, LookResult} from '../src/session/inputState.js'; +import {stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * #426 security review for the v0.22 release: hidden replies (passwords) next to the timing NMSh cannot observe. + * + * After NMSh sends a hidden answer, a look can still see hidden input: the question just answered (its read has not + * returned yet) or a second question asked with no prompt. NMSh cannot tell these apart, and a time window after the + * answer is not a security boundary. So typing that began while a look saw hidden input is never run by itself: + * a question NMSh then sees gets it as its reply, and a command that ends first leaves it held, hidden, at the prompt + * until the person chooses (Tab shows it, Esc discards it). A reply typed and entered before a question with no prompt + * existed is queued like any command (NMSh could not know); once the command asks for hidden input, such entries wait + * for approval instead of running after it. + * + * Deterministic: looks are promises the test settles, the clock and timers are the test's. No shell runs. + */ + +const SECRET = 'QSECRETQ'; +const SLOW_MS = 800; +const TAB = '\t'; +const settled = () => new Promise(done => setImmediate(done)); +const HELD = /held, not run · Tab shows it · Esc discards it/u; + +function fixture() { + const frame = executionFrameFixture(110, 30); + const written: string[] = []; + const queueOps: QueueOp[] = []; + const submitted: string[] = []; + const dispatched: string[] = []; + let interrupts = 0; + Object.defineProperty(frame.session, 'write', {value: (data: string) => { written.push(data); }, configurable: true}); + Object.defineProperty(frame.session, 'interrupt', {value: () => { interrupts += 1; }, configurable: true}); + // The session's real queue: entries added here are what the session would run after the command. + Object.defineProperty(frame.session, 'queue', {value: (op: QueueOp) => { queueOps.push(op); frame.owner.request(op); }, configurable: true}); + frame.owner.on('state', (_state, news) => { if (news.dispatched) dispatched.push(news.dispatched.text); }); + Object.defineProperty(frame.app, 'submit', {value: async () => { submitted.push(frame.app['editor'].text); frame.app['editor'].clear(); }, configurable: true}); + let looks = 0; + let settle: (result: LookResult | undefined) => void = () => {}; + Object.defineProperty(frame.session, 'lookInput', {value: () => { + looks += 1; + return new Promise(resolve => { settle = resolve; }); + }, configurable: true}); + const type = (text: string) => { for (const char of text) frame.app['onInput'](char); }; + const text = () => frame.app['editor'].text as string; + const adds = () => queueOps.filter(op => op.op === 'add').map(op => (op as {text: string}).text); + const screen = () => frame.capture().frame.rows.map(line => stripAnsi(line)).join('\n'); + const transcript = () => (frame.app['output'].transcript().lines as Array>) + .map(line => line.map(cell => cell?.text ?? ' ').join('')).join('\n'); + const state = (request?: InputRequest) => frame.session.emit('inputState', {...(request ? {request} : {}), timing: {waitedMs: 0, waits: 0}}); + const hidden = () => state({since: Date.now(), confidence: 'confirmed', mode: 'hidden'}); + /** Never run as a command: not submitted, not dispatched from the queue, not written to the shell. */ + const neverRun = (secret: string, what: string) => { + assert.equal(submitted.some(entry => entry.includes(secret)), false, `${what}: not run from the composer`); + assert.equal(dispatched.some(entry => entry.includes(secret)), false, `${what}: not run from the queue`); + }; + /** Nothing anywhere a person, a log or the shell could read holds the secret. */ + const nowhere = (secret: string, what: string) => { + neverRun(secret, what); + assert.equal(screen().includes(secret), false, `${what}: not on screen`); + assert.equal(text().includes(secret), false, `${what}: not in the composer`); + assert.equal(adds().some(entry => entry.includes(secret)), false, `${what}: not queued`); + assert.equal(written.some(entry => entry.includes(secret)), false, `${what}: not written to the terminal`); + assert.equal(transcript().includes(secret), false, `${what}: not in the transcript`); + }; + /** Answer a hidden question NMSh reported, as the person types it. */ + const answerHidden = (reply: string) => { + hidden(); + frame.tick(200); + type(reply); + frame.app['onInput']('\r'); + assert.equal(written.join('').endsWith(`${reply}\r`), true, 'the answer went to the hidden question'); + }; + return {...frame, written, submitted, dispatched, type, text, adds, screen, transcript, state, hidden, neverRun, nowhere, answerHidden, + key: (data: string) => frame.app['onInput'](data), enter: () => frame.app['onInput']('\r'), + interrupts: () => interrupts, looks: () => looks, answer: (result: LookResult | undefined) => settle(result)}; +} + +/** Consecutive questions with no prompt: the second gives up (a timed read) before NMSh could report it. */ +async function secondReplyHeldAtPrompt(f: ReturnType, reply: string) { + f.begin('read -s a; read -s -t 1 b'); + f.answerHidden('first'); + f.tick(20); + // Typed at once for the second question, which has no prompt; NMSh has not reported it. + f.type(reply); + f.enter(); + f.nowhere(reply, 'held'); + // The look sees echo off: the answered question or the new one, NMSh cannot tell. + f.answer('hidden'); + await settled(); + assert.equal(f.looks(), 2, 'hidden input with no question reported: one more look'); + // The second read gives up; the shell says the command is done. + f.tick(500); + f.answer('idle'); + await settled(); + f.complete(1); + await settled(); +} + +test('consecutive hidden questions with no prompt: a reply the second one never took is not run, shown or queued; it waits at the prompt', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await secondReplyHeldAtPrompt(f, SECRET); + f.nowhere(SECRET, 'at the prompt'); + assert.match(f.screen(), HELD, 'the composer says typing is held and how to choose'); + // Held is not lost to time: no timer settles it. + t.mock.timers.tick(60_000); + await settled(); + f.nowhere(SECRET, 'a minute later'); + assert.match(f.screen(), HELD); + // (A lone ESC byte waits in the key decoder for a possible sequence; the key itself is what Esc delivers.) + f.app['handleKey']({kind: 'escape'}); + f.nowhere(SECRET, 'after Esc'); + assert.doesNotMatch(f.screen(), HELD); + assert.match(f.transcript(), /discarded, not sent or run/u, 'a note says what happened, without the text'); + } finally { f.dispose(); } +}); + +test('typing held at the prompt is the person\'s to show: Tab puts it in the composer, never run until their own Enter', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await secondReplyHeldAtPrompt(f, 'echo NEXT'); + f.neverRun('echo NEXT', 'held'); + assert.equal(f.text(), '', 'not drawn'); + f.key(TAB); + assert.equal(f.text(), 'echo NEXT', 'shown for review, without its Enter'); + f.neverRun('echo NEXT', 'shown'); + f.enter(); + assert.deepEqual(f.submitted, ['echo NEXT'], 'an ordinary command is never lost; it runs once, on the person\'s Enter'); + } finally { f.dispose(); } +}); + +test('typing held at the prompt: more keys stay held, Ctrl+C discards it all, and nothing reaches the shell or the debug key log', async t => { + const f = fixture(); + const logged: string[] = []; + const saved = process.env.NMSH_DEBUG_KEYS; + try { + t.mock.timers.enable({apis: ['setTimeout']}); + Object.defineProperty(f.app, 'logEditorKeyInput', {value: (data: string) => { logged.push(data); }, configurable: true}); + process.env.NMSH_DEBUG_KEYS = '1'; + await secondReplyHeldAtPrompt(f, SECRET); + f.type('MORE'); + f.enter(); + f.nowhere(SECRET, 'more keys'); + f.nowhere('MORE', 'more keys'); + assert.match(f.screen(), HELD); + f.key('\u0003'); + f.nowhere(SECRET, 'after Ctrl+C'); + f.nowhere('MORE', 'after Ctrl+C'); + assert.doesNotMatch(f.screen(), HELD); + assert.equal(logged.join('').includes(SECRET) || logged.join('').includes('MORE'), false, 'held keys are never logged'); + } finally { + if (saved === undefined) delete process.env.NMSH_DEBUG_KEYS; else process.env.NMSH_DEBUG_KEYS = saved; + f.dispose(); + } +}); + +test('typing held at the prompt never reaches the next command (one started from the queue), and late looks change nothing', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await secondReplyHeldAtPrompt(f, SECRET); + f.begin('make'); + f.answer('visible'); + f.state({since: Date.now(), confidence: 'likely', mode: 'line'}); + await settled(); + f.nowhere(SECRET, 'while the next command runs and asks'); + f.complete(0); + await settled(); + f.nowhere(SECRET, 'after it'); + assert.match(f.screen(), HELD, 'still the person\'s to choose'); + } finally { f.dispose(); } +}); + +test('a second hidden question NMSh sees while the reply is held gets it, never drawn', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -s a; read -s b'); + f.answerHidden('first'); + f.tick(20); + f.type(SECRET); + f.enter(); + f.answer('hidden'); + await settled(); + f.tick(300); + f.hidden(); + await settled(); + // Keys reach a hidden question one by one, as a terminal sends them. + assert.equal(f.written.join(''), `first\r${SECRET}\r`, 'the second question gets its reply, once, in order'); + f.complete(0); + await settled(); + f.neverRun(SECRET, 'after'); + assert.equal(f.screen().includes(SECRET) || f.text().includes(SECRET) || f.adds().some(entry => entry.includes(SECRET)), false); + assert.doesNotMatch(f.screen(), HELD); + } finally { f.dispose(); } +}); + +test('an ordinary command typed after a hidden answer, once the terminal echoes again, is type-ahead: never held, never discarded', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sudo -k make install', 'Password:'); + f.answerHidden('pw'); + f.state(); + f.tick(300); + f.type('make test'); + f.answer('visible'); + await settled(); + assert.equal(f.text(), 'make test', 'drawn as the next command'); + f.enter(); + f.answer('visible'); + await settled(); + assert.deepEqual(f.adds(), ['make test'], 'queued for after the command'); + f.complete(0); + await settled(); + assert.deepEqual(f.dispatched, ['make test'], 'and runs after it, once'); + assert.doesNotMatch(f.screen(), HELD); + } finally { f.dispose(); } +}); + +test('a reply typed before a later hidden question with no prompt is queued (NMSh could not know), then waits for approval, never running by itself', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -s a; sleep 2; read -s b'); + f.answerHidden('first'); + // The read returned; the command works with the terminal echoing; nothing asks. + f.state(); + f.tick(1500); + f.type(SECRET); + f.answer('visible'); + await settled(); + f.enter(); + f.answer('visible'); + await settled(); + assert.deepEqual(f.adds(), [SECRET], 'queued like any command typed while one runs (documented limit)'); + // The second question, with no prompt: from now on that entry may be its reply. + f.tick(500); + f.hidden(); + await settled(); + const entry = f.owner.state.entries.find(item => item.text === SECRET); + assert.equal(entry?.condition, 'approve', 'it waits for approval'); + assert.match(f.transcript(), /hidden input/u, 'a note says why, without the text'); + assert.equal(f.transcript().includes(SECRET), false); + // The person answers the question again; the command succeeds: the entry still does not run. + f.type('second'); + f.enter(); + f.complete(0); + await settled(); + f.neverRun(SECRET, 'after the command succeeded'); + } finally { f.dispose(); } +}); + +test('entries queued before the command started are the person\'s plan: a hidden question leaves them as they were', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + // Both queued while an earlier command ran; the first then runs and asks for a password. + f.begin('make build'); + f.app['session'].queue({op: 'add', text: 'sudo -k make install'}); + f.app['session'].queue({op: 'add', text: 'make test'}); + f.tick(100); + f.complete(0); + await settled(); + assert.deepEqual(f.dispatched, ['sudo -k make install']); + f.tick(10); + f.begin('sudo -k make install', 'Password:'); + f.tick(100); + f.hidden(); + await settled(); + assert.equal(f.owner.state.entries.find(item => item.text === 'make test')?.condition, undefined); + } finally { f.dispose(); } +}); + +test('a command queued with Ctrl+Q while the hidden question is shown is the person\'s: re-reports of that question leave it', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('sudo -k make install', 'Password:'); + f.tick(100); + const asked = {since: Date.now(), confidence: 'likely', mode: 'hidden'} as const; + f.state(asked); + await settled(); + // Ctrl+Q: the composer takes the next command for the queue; the program never sees those keys. + f.key('\u0011'); + f.type('make test'); + f.enter(); + assert.deepEqual(f.adds(), ['make test']); + // The same question, reported again (the watch's confidence changes as it samples). + f.tick(500); + f.state({...asked, confidence: 'confirmed'}); + await settled(); + assert.equal(f.owner.state.entries.find(item => item.text === 'make test')?.condition, undefined, 'not made to wait for approval'); + assert.doesNotMatch(f.transcript(), /asked for hidden input/u); + } finally { f.dispose(); } +}); + +test('a look slower than the slow mark after a hidden answer, then the command ends: the typing is discarded, never shown or run', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -s a; read -s -t 1 b'); + f.answerHidden('first'); + f.tick(20); + f.type(SECRET); + f.enter(); + t.mock.timers.tick(SLOW_MS + 100); + f.answer('idle'); + await settled(); + f.complete(1); + await settled(); + f.nowhere(SECRET, 'after the slow look'); + assert.match(f.transcript(), /discarded, not run/u); + } finally { f.dispose(); } +}); diff --git a/tests/inputOwnershipSecretsLive.test.ts b/tests/inputOwnershipSecretsLive.test.ts new file mode 100644 index 00000000..ef933182 --- /dev/null +++ b/tests/inputOwnershipSecretsLive.test.ts @@ -0,0 +1,66 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {existsSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, strip, until} from './helpers/liveFrontend.js'; + +/** + * #426 review, through the real composer, PTY, session service and shell: a command reads a password, works with the + * terminal echoing, then reads a second password with no prompt. The second reply, typed and entered before that + * question exists, can only be queued (nothing asked yet; documented limit). Once the command asks for hidden input the + * entry waits for approval: when the command succeeds it never runs, so the password reaches neither NMSh's journal nor + * the shell's own history file, and the shell never reports it as a command. + */ +const bash = ['/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/usr/bin/bash', '/bin/bash'] + .find(path => existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); +const supported = process.platform === 'darwin' || process.platform === 'linux'; +const pause = (ms: number) => new Promise(done => setTimeout(done, ms)); +const SECRET = 'QZSECRETZQ'; + +/** The shell's history written as each command is entered, so the test reads exactly what the shell recorded. */ +const HISTORY = { + zsh: ['.zshrc', '.zhist', 'HISTFILE=~/.zhist\nHISTSIZE=1000\nSAVEHIST=1000\nsetopt INC_APPEND_HISTORY\n'], + bash: ['.bashrc', '.bhist', 'HISTFILE=~/.bhist\nshopt -s histappend\nPROMPT_COMMAND="history -a${PROMPT_COMMAND:+; $PROMPT_COMMAND}"\n'], +} as const; + +for (const shell of ['zsh', 'bash'] as const) { + const available = supported && (shell === 'zsh' || bash); + test(`live ${shell}: a reply entered before a second hidden question with no prompt never runs as a command or reaches history`, {skip: available ? false : `${shell} not available`, timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + const [rc, histfile, content] = HISTORY[shell]; + writeFileSync(join(sandbox.home, rc), content); + const frontend = sandbox.launch([], {cols: 120, rows: 36}); + try { + await frontend.waitFor(/zsh|bash/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 30_000, 'the first prompt'); + await frontend.run('echo READY', /READY/u); + const mark = frontend.mark; + const command = 'read -rs a; sleep 3; read -rs b; echo "LENGTHS=${#a}/${#b}"'; + frontend.pty.write(`${command}\r`); + await frontend.waitFor(/[Ww]aiting for input/u, mark, 20_000); + frontend.pty.write('first\r'); + // The first read returned; `sleep` runs with the terminal echoing again and nothing asks. + await pause(400); + const typed = frontend.mark; + for (const char of SECRET) { frontend.pty.write(char); await pause(15); } + frontend.pty.write('\r'); + await frontend.waitFor(/1 queued/u, typed, 10_000); + // The second read, with no prompt: NMSh reports it, and the entry may be its reply. + await frontend.waitFor(/asked for hidden input/u, typed, 20_000); + frontend.pty.write('second\r'); + await frontend.waitFor(/LENGTHS=5\/6/u, mark, 20_000); + const records = async () => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records); + await until(async () => (await records()).some(record => record.command === command), 15_000, 'the command to complete'); + // Anything that would run after it has had time to start. + await pause(2500); + assert.equal((await records()).some(record => record.command?.includes(SECRET)), false, 'never run: not in NMSh\'s journal'); + assert.doesNotMatch(strip(frontend.output.slice(typed)), new RegExp(`${SECRET}[^\\n]*(?:not found|No such)|(?:not found|No such)[^\\n]*${SECRET}`, 'u'), + 'the shell never saw it as a command'); + const history = readFileSync(join(sandbox.home, histfile), 'utf8'); + assert.match(history, /echo READY/u, 'the shell records history here'); + assert.equal(history.includes(SECRET), false, 'not in the shell\'s history'); + assert.match(strip(frontend.output.slice(typed)), /waits for your approval/u); + } finally { await sandbox.dispose(); } + }); +} diff --git a/tests/typingAfterRead.test.ts b/tests/typingAfterRead.test.ts index 6daa58ab..6c68ffc6 100644 --- a/tests/typingAfterRead.test.ts +++ b/tests/typingAfterRead.test.ts @@ -10,7 +10,22 @@ import {LiveSandbox, strip, until, type Frontend} from './helpers/liveFrontend.j * command the moment the previous one is shown complete, with no pause; zsh and Bash also type ahead while `read` * still runs. Fish is not held to type-ahead during `read`: fish 4 itself drops keys that are already queued when its * reader starts a terminal query, with any terminal (see docs/development/fish-typeahead.md). + * + * One outcome besides running at once is correct (#426 review): a command typed during a hidden read's answer, when a + * look still saw hidden input and the read's command then ended, may be a reply to a second question with no prompt. + * NMSh holds it at the prompt, never run by itself; the person's Tab shows it and their Enter runs it. It must still + * run exactly once, and only typing ahead during a hidden read may be held. */ +const HELD = /held, not run · Tab shows it · Esc discards it/u; + +async function showAndRunHeld(frontend: Frontend, mode: string, run: number, next: string, mark: number): Promise { + assert.ok(mode === 'ahead' && run % 2 === 1, `only typing ahead during a hidden read is held (${mode}${run})`); + assert.equal(new RegExp(`(? existsSync(path) && /version (?:4\.[4-9]|[5-9]\.)/u.test(spawnSync(path, ['--version'], {encoding: 'utf8'}).stdout ?? '')); const fish = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync); @@ -60,7 +75,11 @@ for (const shell of ['fish', 'zsh', 'bash'] as const) { frontend.pty.write(`v${tag}\r`); if (mode === 'after') await frontend.waitFor(new RegExp(`got=v${tag}[\\s\\S]*Completed`, 'u'), mark); frontend.pty.write(`echo NEXT-${tag}\r`); - await frontend.waitFor(new RegExp(`NEXT-${tag}[\\s\\S]*Completed`, 'u'), mark, 10_000); + await frontend.waitFor(new RegExp(`NEXT-${tag}[\\s\\S]*Completed|${HELD.source}`, 'u'), mark, 10_000); + if (HELD.test(strip(frontend.output.slice(mark))) && !new RegExp(`NEXT-${tag}[\\s\\S]*Completed`, 'u').test(strip(frontend.output.slice(mark)))) { + await showAndRunHeld(frontend, mode, run, `echo NEXT-${tag}`, mark); + await frontend.waitFor(new RegExp(`NEXT-${tag}[\\s\\S]*Completed`, 'u'), mark, 10_000); + } assert.match(strip(frontend.output.slice(mark)), new RegExp(`got=v${tag}`, 'u'), 'the answer reached read'); } } From 6ebf383209d4f4cfb6fde2710d7d5f4dd6133528 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:42:09 +0530 Subject: [PATCH 7/8] Input ownership: release gate fixes; held typing, pre-prompt drafts and 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. --- src/app/TerminalApp.ts | 95 ++++++++++++++++++++---- tests/inputOwnershipSecrets.test.ts | 107 ++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 14 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 601be6b1..7b81fcbc 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -437,6 +437,13 @@ const OWNERSHIP_LOOK_SLOW_MS = 800; const HIDDEN_ANSWER_TYPE_AHEAD_MS = 1000; /** How held keys end: on the evidence (route), dropped, sent to the program as typed, or shown in the composer. */ type HoldSettle = 'route' | 'discard' | 'send' | 'reveal'; +/** + * The keys Tab replays when it shows held typing: text and the keys that edit it, never one that acts. A Ctrl+Q (queue), + * Ctrl+S, history, palette or failure key typed while the typing was held must not queue, send or run it (#426 gate). + */ +const REVEALABLE_KEYS = new Set(['text', 'paste', 'enter', 'newline', 'backspace', 'delete', 'deleteWord', 'deleteLineBefore', + 'deleteLineAfter', 'left', 'right', 'wordLeft', 'wordRight', 'lineHome', 'lineEnd', 'bufferHome', 'bufferEnd', 'selectLeft', 'selectRight', + 'selectWordLeft', 'selectWordRight', 'selectLineHome', 'selectLineEnd', 'selectBufferHome', 'selectBufferEnd', 'selectAll']); /** Keys held while NMSh looks at whether the running command reads input (#426). */ interface OwnershipHold { keys: Key[]; @@ -892,8 +899,13 @@ export class TerminalApp { * type-ahead, and a look that still sees that question is not new evidence; a newer question ends this at once. */ private hiddenAnswer?: {startId: number; at: number}; - /** The running command's hidden question whose queued entries were last checked (a question is reported repeatedly). */ + /** The running command's question whose queued entries were last checked (a question is reported repeatedly). */ private hiddenQueueCheck?: {startId: number; since: number}; + /** + * A composer draft typed during the running command before it asked for hidden input: it may be the password, typed + * before the prompt appeared. It is kept off the composer while the question is open and held at the prompt after. + */ + private stashedDraft?: {startId: number; text: string; program: string}; private replayingHeld = false; /** * The queue review a first empty Enter armed at idle (resume a paused queue, or approve the entry that asks first). @@ -4434,6 +4446,12 @@ export class TerminalApp { } // Keys still held for this command's look are settled now that it is over (#426). this.ownershipLook?.settle('route'); + // A draft typed before this command asked for hidden input may be the password: held, never back in the composer. + if (this.stashedDraft?.startId === command.startId) { + const stashed = this.stashedDraft; + this.stashedDraft = undefined; + this.holdAtPrompt(stashed.text, stashed.program); + } // A command may have changed what workspace facts say (npm version, terraform workspace select, aws configure). this.contextEngine.invalidate('command'); void this.refreshContext(cwd); @@ -11941,23 +11959,25 @@ export class TerminalApp { this.runningDraft = undefined; this.session.write(typed); } else if (request && draft && !draft.typeAhead && draft.at >= request.since && this.editor.text) draft.answering = true; - if (request?.mode === 'hidden') this.holdQueuedReplies(request); + if (request?.mode === 'hidden') this.stashDraftTypedBefore(request); + if (request) this.holdQueuedReplies(request); // The evidence a held key waited for has arrived. if (request) this.ownershipLook?.settle('route'); this.render(); } /** - * A hidden question with no prompt can come after its reply was already typed and entered: with no question to - * answer yet, that Enter queued it as a command (#426, documented limit). When the command asks for hidden input, the - * entries queued while it ran may be that reply: they wait for the person's approval instead of running after it, so - * a password never runs as a shell command by itself. Entries queued before the command started are left as they + * A question can come after its reply was already typed and entered: with no question to answer yet (or one NMSh + * could not yet see, on a loaded machine), that Enter queued it as a command (#426, documented limit). When the + * command asks for input, the entries queued while it ran may be that reply: they wait for the person's approval + * instead of running after it, so a reply, or a password, never runs as a shell command by itself. Entries queued before the command started are left as they * are, and so are those queued while this question is already shown (Ctrl+Q): each question is checked once, when it * is first reported. Its `since` is no boundary: it can be when the shell went idle (a `sleep` before the `read`). */ private holdQueuedReplies(request: InputRequest): void { const running = this.running; if (!running || !this.queueAvailable()) return; + const hidden = request.mode === 'hidden'; if (this.hiddenQueueCheck?.startId === running.startId && this.hiddenQueueCheck.since === request.since) return; this.hiddenQueueCheck = {startId: running.startId, since: request.since}; const typed = this.queueState.entries.filter(entry => entry.addedAt >= running.startedAt && entry.condition !== 'approve'); @@ -11966,14 +11986,66 @@ export class TerminalApp { if (this.queueConditionsAvailable()) { for (const entry of typed) this.session.queue({op: 'condition', id: entry.id, condition: 'approve'}); const count = typed.length === 1 ? 'the command queued while it ran waits' : `the ${typed.length} commands queued while it ran wait`; - this.output.addFrontendInteraction(program, `${program} asked for hidden input; ${count} for your approval (/queue), in case one was its reply.`, INFO); + this.output.addFrontendInteraction(program, `${program} asked for ${hidden ? 'hidden ' : ''}input; ${count} for your approval (/queue), in case one was its reply.`, INFO); } else { // An older session service without per-entry approval: the whole queue waits instead. this.session.queue({op: 'pause'}); - this.output.addFrontendInteraction(program, `${program} asked for hidden input; the queue is paused (/queue), in case a command queued while it ran was its reply.`, INFO); + this.output.addFrontendInteraction(program, `${program} asked for ${hidden ? 'hidden ' : ''}input; the queue is paused (/queue), in case a command queued while it ran was its reply.`, INFO); } } + /** + * When the running command asks for hidden input, a draft typed in the composer during it, before the question, + * may be the password typed before the prompt appeared (a terminal's program would usually flush it). It leaves the + * composer, so it is neither shown nor sent as the answer, and is held at the prompt when the command ends; an Enter + * held with it was for that draft, never an empty answer for the question. + */ + private stashDraftTypedBefore(request: InputRequest): void { + const running = this.running; + const draft = this.runningDraft; + // Type-ahead typed right after Ctrl+C or Ctrl+Z is the person's next command for the prompt (#436), not a reply. + if (!running || !draft || draft.typeAhead || draft.startId !== running.startId || draft.at >= request.since) return; + if (this.queueCompose || this.queueEdit || !this.editor.text) return; + const text = this.editor.text; + this.editor.clear(); + this.runningDraft = undefined; + const program = running.command.trim().split(/\s+/u)[0] || 'the command'; + this.stashedDraft = this.stashedDraft?.startId === running.startId + ? {...this.stashedDraft, text: `${this.stashedDraft.text}\n${text}`} : {startId: running.startId, text, program}; + const hold = this.ownershipLook; + if (hold && !hold.atPrompt && hold.keys.every(key => key.kind === 'enter' || key.kind === 'newline')) hold.settle('discard'); + } + + /** Show held typing in the composer: its text and edits only (REVEALABLE_KEYS), and never its final Enter. */ + private revealHeld(keys: readonly Key[]): void { + const shown = keys.filter(key => REVEALABLE_KEYS.has(key.kind)); + let end = shown.length; + while (end > 0 && shown[end - 1]!.kind === 'enter') end -= 1; + this.replayingHeld = true; + try { for (const item of shown.slice(0, end)) this.handleKey(item.kind === 'enter' ? {kind: 'newline'} as Key : item); } finally { this.replayingHeld = false; } + this.render(); + } + + /** Hold text at the prompt, hidden, until the person shows it (Tab) or discards it (Esc, Ctrl+C); nothing else settles it. */ + private holdAtPrompt(text: string, program: string): void { + const keys: Key[] = text.split('\n').flatMap((line, index) => index ? [{kind: 'newline'} as Key, {kind: 'text', value: line} as Key] : [{kind: 'text', value: line} as Key]); + const existing = this.ownershipLook; + if (existing?.atPrompt) { existing.keys.unshift(...keys); this.render(); return; } + if (existing) existing.settle('route'); + let settled = false; + const hold: OwnershipHold = {keys, startId: -1, answering: false, slow: false, uncertain: true, ended: true, sawHidden: false, + sawAnsweredHidden: false, atPrompt: true, program, settle: how => { + if (settled || (how !== 'reveal' && how !== 'discard')) return; + settled = true; + if (this.ownershipLook === hold) this.ownershipLook = undefined; + if (how === 'reveal') { this.revealHeld(hold.keys); return; } + this.output.addFrontendInteraction(program, 'Your held typing was discarded, not sent or run.', INFO); + this.render(); + }}; + this.ownershipLook = hold; + this.render(); + } + /** * Whether this key decides where typing goes while a command runs and NMSh has no evidence yet that the command * waits for input: the first key of a draft, or an Enter that would otherwise queue. @@ -12042,12 +12114,7 @@ export class TerminalApp { return; } if (how === 'reveal') { - // Shown for review, never routed: a final Enter is not replayed, earlier ones become new lines. - let end = keys.length; - while (end > 0 && keys[end - 1]!.kind === 'enter') end -= 1; - this.replayingHeld = true; - try { for (const item of keys.slice(0, end)) this.handleKey(item.kind === 'enter' ? {kind: 'newline'} as Key : item); } finally { this.replayingHeld = false; } - this.render(); + this.revealHeld(keys); return; } // The command ended while NMSh looked. A reply to its open question is discarded, never run; so is typing held diff --git a/tests/inputOwnershipSecrets.test.ts b/tests/inputOwnershipSecrets.test.ts index 6c5338fe..092db791 100644 --- a/tests/inputOwnershipSecrets.test.ts +++ b/tests/inputOwnershipSecrets.test.ts @@ -319,3 +319,110 @@ test('a look slower than the slow mark after a hidden answer, then the command e assert.match(f.transcript(), /discarded, not run/u); } finally { f.dispose(); } }); + +// ------------------------------------------------------------------ release gate review (v0.22.0, #444) + +test('gate: held typing is released only as typed text; a Ctrl+Q typed while held is not replayed by Tab, so nothing is queued or run', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await secondReplyHeldAtPrompt(f, SECRET); + // Ctrl+Q at an idle prompt would queue the composer's text, and an idle queue runs it at once. + f.key('\u0011'); + f.key(TAB); + await settled(); + assert.equal(f.text(), SECRET, 'Tab shows the held text in the composer: the person chose to see it'); + assert.deepEqual(f.adds(), [], 'not queued'); + f.neverRun(SECRET, 'after Ctrl+Q and Tab'); + } finally { f.dispose(); } +}); + +test('gate: while a command runs, a Ctrl+Q typed while typing is held is not replayed by Tab either', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin('read -rs pw'); + f.type(SECRET); + f.answer('unknown'); await settled(); + f.answer('unknown'); await settled(); + assert.match(f.screen(), /Can.t tell whether read is reading your typing/u); + f.key('\u0011'); + f.key(TAB); + await settled(); + assert.deepEqual(f.adds(), [], 'not queued'); + f.complete(0); await settled(); + f.neverRun(SECRET, 'after the command'); + } finally { f.dispose(); } +}); + +/** A password typed (and, with `enter`, entered) before sudo prints its prompt; the person then answers the prompt. */ +async function typedBeforeThePrompt(f: ReturnType, enter: boolean) { + f.begin('sudo -k true'); + f.tick(50); + f.type(SECRET); + f.answer('visible'); await settled(); + if (enter) f.enter(); + // sudo prints its prompt and turns echo off; NMSh sees the hidden question. + f.tick(300); + f.app['onShellData']('Password:'); + if (enter) { f.answer('hidden'); await settled(); } + f.hidden(); await settled(); + assert.equal(f.screen().includes(SECRET), false, 'not shown while the program asks'); + f.type('pw'); + f.enter(); + f.complete(0); await settled(); +} + +test('gate: a password typed before the program asks is never left as a draft one Enter from running: held at the prompt', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await typedBeforeThePrompt(f, false); + assert.equal(f.text(), '', 'not back in the composer'); + assert.equal(f.screen().includes(SECRET), false, 'not shown again when the command ends'); + assert.match(f.screen(), HELD, 'held for the person'); + f.enter(); + await settled(); + f.neverRun(SECRET, 'after an Enter at the prompt'); + f.app['handleKey']({kind: 'escape'}); + f.nowhere(SECRET, 'after Esc'); + } finally { f.dispose(); } +}); + +test('gate: a password and Enter typed before the program asks: not run, not left in the composer', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + await typedBeforeThePrompt(f, true); + assert.equal(f.text(), '', 'not back in the composer'); + assert.equal(f.screen().includes(SECRET), false, 'not shown again'); + f.enter(); + await settled(); + f.neverRun(SECRET, 'after an Enter at the prompt'); + assert.deepEqual(f.adds(), []); + } finally { f.dispose(); } +}); + +test('gate: a visible reply queued before NMSh saw the read waits for approval once the question is seen; never run after it', async t => { + const f = fixture(); + try { + t.mock.timers.enable({apis: ['setTimeout']}); + f.begin(`read -r QA_REPLY; printf 'ANSWER=%s\\n' "$QA_REPLY"`); + // A loaded machine: the look cannot yet tell that the shell's builtin reads, so the Enter queues the reply. + f.type('hello'); + f.answer('visible'); await settled(); + f.enter(); + f.answer('visible'); await settled(); + assert.deepEqual(f.adds(), ['hello'], 'queued (documented limit under load)'); + f.tick(1500); + f.state({since: Date.now() - 1400, confidence: 'likely', mode: 'line'}); + await settled(); + assert.equal(f.owner.state.entries.find(item => item.text === 'hello')?.condition, 'approve', 'it waits for approval'); + assert.match(f.transcript(), /asked for input/u); + // The person answers again; the command succeeds; the reply is never run as a command. + f.type('hello'); + f.enter(); + f.complete(0); await settled(); + assert.deepEqual(f.dispatched, [], 'never run after the command'); + } finally { f.dispose(); } +}); From 271e67bbf4623bf90411afcb58f34b9858a8ac17 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:45:36 +0530 Subject: [PATCH 8/8] Input ownership live test: a password typed before the program's prompt 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 ab5390a4 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. --- tests/inputOwnershipSecretsLive.test.ts | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/inputOwnershipSecretsLive.test.ts b/tests/inputOwnershipSecretsLive.test.ts index ef933182..da7fc929 100644 --- a/tests/inputOwnershipSecretsLive.test.ts +++ b/tests/inputOwnershipSecretsLive.test.ts @@ -63,4 +63,41 @@ for (const shell of ['zsh', 'bash'] as const) { assert.match(strip(frontend.output.slice(typed)), /waits for your approval/u); } finally { await sandbox.dispose(); } }); + + test(`live ${shell}: a password typed before the program prints its prompt is never left one Enter from running`, {skip: available ? false : `${shell} not available`, timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: shell}, shell === 'bash' && bash ? {PATH: `${bash.replace(/\/bash$/u, '')}:${process.env.PATH}`} : {}); + const [rc, histfile, content] = HISTORY[shell]; + writeFileSync(join(sandbox.home, rc), content); + const frontend = sandbox.launch([], {cols: 120, rows: 36}); + try { + await frontend.waitFor(/zsh|bash/u); + await until(async () => (await sandbox.sessions()).some(session => session.lastExit !== undefined), 30_000, 'the first prompt'); + await frontend.run('echo READY', /READY/u); + const mark = frontend.mark; + // As sudo does: a moment's work, then a prompt and a read with echo off. + const command = `sh -c 'sleep 1; printf "QA-%s: " Password; stty -echo; IFS= read -r pw; stty echo; printf "\\nLEN=%s\\n" "\${#pw}"'`; + frontend.pty.write(`${command}\r`); + await pause(300); + // Typed before the prompt exists (no Enter): NMSh can only draw it, as a terminal would echo it. + for (const char of SECRET) { frontend.pty.write(char); await pause(15); } + await frontend.waitFor(/QA-Password: /u, mark, 20_000); + await frontend.waitFor(/[Ww]aiting for input/u, mark, 20_000); + // From here on the earlier typing must never be drawn again, and it must end up held, not in the composer. + const asked = frontend.mark; + for (const char of 'answer') { frontend.pty.write(char); await pause(15); } + frontend.pty.write('\r'); + await frontend.waitFor(/LEN=6/u, mark, 20_000); + const records = async () => (await sandbox.transcripts().list()).flatMap(session => session.transcript.records); + await until(async () => (await records()).some(record => record.command === command), 15_000, 'the command to complete'); + await frontend.waitFor(/held, not run · Tab shows it · Esc discards it/u, asked, 10_000); + // An Enter at the prompt does not run what was typed before the question. + frontend.pty.write('\r'); + await pause(2500); + assert.equal((await records()).some(record => record.command?.includes(SECRET)), false, 'never run: not in NMSh\'s journal'); + assert.equal(strip(frontend.output.slice(asked)).includes(SECRET), false, 'never drawn again once the program asked'); + const history = readFileSync(join(sandbox.home, histfile), 'utf8'); + assert.match(history, /echo READY/u, 'the shell records history here'); + assert.equal(history.includes(SECRET), false, 'not in the shell\'s history'); + } finally { await sandbox.dispose(); } + }); }