Skip to content

Give the command /dev/null when standard input is a terminal - #37

Merged
IonDen merged 5 commits into
mainfrom
fix/terminal-stdin-null
Sep 18, 2026
Merged

IonDen merged 5 commits into
mainfrom
fix/terminal-stdin-null

Conversation

@IonDen

@IonDen IonDen commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Until now, mlx-guard refused to start when its standard input was a terminal: exit 64, interactive terminal input is unsupported, nothing launched. That is what every README quick-start command and the Python example did when typed into Terminal, and the retry with the same --report path then exited 74 because the refused launch had already used it. The docs worked around it with < /dev/null on every command.

This change removes the refusal. When the supervisor's standard input is a terminal, the supervised command receives /dev/null instead and the supervisor prints one line on stderr:

mlx-guard: standard input is a terminal, so the command reads from /dev/null instead

Any other standard input (a file, a pipe, an explicit < /dev/null) reaches the command unchanged and prints nothing. Shell job control stays unsupported, and the CLI and support pages now say why in plain terms: the command runs in its own process group, so a command that reads the keyboard would be stopped on its first read. There is no new flag for inheriting the terminal, since that is exactly the case that cannot work.

In the core crate, LaunchErrorKind::InteractiveTerminalUnsupported and validate_noninteractive_terminal are gone; resolve_stdin (a pure function) and OwnedProcess::stdin_disposition replace them. The Python client is unchanged: start() still inherits the script's stdin, so a pipe fed to the script reaches the command, and a script started from a terminal gets the same substitution and line through the native binary.

Tests: a unit test on resolve_stdin (the existing pseudo-terminal test in owned_process_group.rs, rewritten), three real-binary tests in crates/mlx-guard-cli/tests/terminal_stdin.rs (run and observe under an openpty slave, bounded by the wall time so a regression that passes the terminal on ends in 75 or a timed-out wait instead of a hang, plus a piped-stdin control that pins no substitution and no line), and a Python pty.fork test that mirrors the ticket's evidence. Each went red first: the pseudo-terminal tests against the refusal, the Python test against the published 0.2.0 wheel. Removing the substitution turned all of them red again before the revert. The in-house trial harness's T0 probe validator moved to the new contract.

Docs: docs/CLI.md, docs/PROCESS_CONTROL.md, docs/SUPPORT.md, the README (quick start, Python paragraph, exit-code table, limits, docs index), docs/EXAMPLES.md, docs/PYTHON_API.md, and the wrap-a-command page. The < /dev/null advice is gone from the quick starts; the wrap-a-command transcripts keep it, since that is how they were recorded. CHANGELOG under Unreleased / Changed.

No evidence bundle is re-run: sampling, policy, identity, and intervention code are untouched. The recorded 0.2.0 trial bundle keeps its T0 row as a record of the old behaviour.

Until now a terminal on the supervisor's standard input refused the
launch with exit 64 before anything started, which is what every
quick-start command did when typed into Terminal. The supervised
command now receives /dev/null instead and the supervisor prints one
stderr line saying so. A file, a pipe, or an explicit < /dev/null is
passed on unchanged and silently. Shell job control stays unsupported.

LaunchErrorKind::InteractiveTerminalUnsupported and
validate_noninteractive_terminal are removed; resolve_stdin (pure) and
OwnedProcess::stdin_disposition replace them. The run banner and the
new line share one announce_launch helper, printed only after a
successful launch.

Tests: resolve_stdin unit cases in the existing openpty test; three
real-binary cases (run and observe under a pseudo-terminal, bounded by
the wall time so a regression ends in 75 instead of a hang, and a
piped-stdin control); a Python pty.fork case that mirrors the failure
recorded against the 0.2.0 wheel. The trial harness's T0 probe
validator moves to the new contract.
The CLI, process-control, and support pages describe the /dev/null
substitution and the one stderr line, and say why a command that reads
the keyboard cannot run in its own process group. The README quick
start, the Python paragraph, the exit-code table, the limits list, and
the docs index drop the < /dev/null advice; so do EXAMPLES.md and
PYTHON_API.md. The wrap-a-command transcripts keep the redirect they
were recorded with, and the page says the commands work without it.
CHANGELOG entry under Unreleased / Changed.
@IonDen IonDen self-assigned this Sep 18, 2026
@IonDen IonDen added enhancement New feature or request documentation Improvements or additions to documentation labels Sep 18, 2026
The line moves from after identity inspection into the two launch
sites, so a command that exits at once (the early-exit window) is
still told that it read /dev/null; the run banner stays where it was.
A new real-binary case pins the line for /usr/bin/true under a
pseudo-terminal. The bounded waits kill the supervisor before failing,
the test directory cleanup no longer panics during unwinding, and the
Python pseudo-terminal test gains a 20 s bound and moves above the
unittest main block so a direct run collects it.

Docs: the exit-64 row no longer says the report path was used (only a
launch-time refusal does that); CLI.md says the supervisor prints the
line; the CHANGELOG entry reads "through 0.2.0", attributes the 74
retry to the CLI, and drops crate-internal names; EXAMPLES.md notes the
transcript was captured with input redirected; the wrap-a-command page
says "alongside"; the 0.2.0 trial bundle README marks T0 as recorded
before this change.
The fast-exit case's comment now says it is a pin of the line for a
command that exits at once, red on the old placement only when the
root is gone at the first inspect; the placement is pinned by the two
call sites. The Python pseudo-terminal test reaps the child it kills on
the timeout path instead of leaving a zombie.
@IonDen
IonDen merged commit cab0b82 into main Sep 18, 2026
5 checks passed
@IonDen
IonDen deleted the fix/terminal-stdin-null branch September 18, 2026 20:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant