跳到正文
DEV Community· Gabriel Anhaia·· 3 小時前AI 評分68

終端以兩個 TTY 位元識別密碼提示,sudo 2025 年修補令檢查失準

Your Terminal Spots Password Prompts From Two TTY Bits. sudo's 2025 Fix Breaks the Check

AI 導讀

文章指出,終端常以 termios 的 ECHO 和 ICANON 兩個位元辨識密碼提示;採用 2025 年修補的 sudo 會同時關閉兩者,令其狀態與 bash 命令列相同。

正文

You type sudo apt upgrade and the prompt says Password:. Nothing appears as you type. Your shell never sees those keys. sudo reads them straight from the terminal device. But your terminal app sees every single one, because it is the program that sends them down the pseudo-terminal in the first place.

So the terminal is holding your password in its hands for a moment. If it has a feature that remembers what you type (ghost-text suggestions, a session recorder, an agent that reads the scrollback), how does it know to look away?

On macOS and Linux the usual answer starts with one bit in a struct called termios. You can watch it flip from the other side of the pty with a short Python script, and it is a weaker signal than it looks. Since late 2025 it is weaker still, because sudo changed how it reads passwords.

The bit: ECHO in c_lflag

Every terminal device on a POSIX system carries a termios struct. Its local-mode field, c_lflag, holds the flags that decide how the line discipline treats your keystrokes. Two of them matter here. ECHO means "echo input characters", and ICANON turns on canonical mode, where "input is made available line by line" (termios(3)).

A program that wants a secret clears ECHO, reads, and puts it back. OpenSSH's readpassphrase does exactly that: unless the caller asks for echo, it runs term.c_lflag &= ~(ECHO | ECHONL) and leaves ICANON alone (readpassphrase.c). Python's own termios docs show the same pattern as their example: copy the attributes, clear termios.ECHO in the lflag slot, read a line, restore in a finally.

You can watch this from a second terminal with stty. In terminal A, run tty to get its device name, then start something that asks for a passphrase and leave it sitting at the prompt. In terminal B:

# Linux (GNU stty)
stty -F /dev/pts/4 -a
# macOS (BSD stty)
stty -f /dev/ttys004 -a

Use the device name tty printed. In the output, a leading - means the setting is off, so -echo is the bit you are after, and icanon without a dash means canonical mode is still on (GNU stty, BSD stty).

Run the same stty while terminal A sits at a plain bash prompt and you will also see -echo. GNU Readline, which bash uses to edit your command line, runs tiop->c_lflag &= ~(ICANON | ECHO) and draws every character itself. Full-screen programs do the same thing through raw mode: cfmakeraw() clears ECHO, ICANON and a few more (termios(3)). "Echo is off" alone describes most interactive programs you run.

That is why terminals that detect password prompts check two bits. WezTerm looks at the pty's termios "to see whether local echo is disabled and canonical input is enabled", and only then turns the cursor into a lock. Ghostty reads the same pair with tcgetattr on the master and draws the same lock glyph.

Read it from the master side

A terminal emulator holds the master end of the pty. The program in it, your shell or sudo, has the slave end. WezTerm's pty code gets the termios with tcgetattr on the master's file descriptor, and so does Ghostty. You can do the same in Python, where pty.fork() hands the parent a descriptor "connected to the child's controlling terminal".

This script starts a command on a fresh pty, passes your keys through, and prints a note whenever ECHO or ICANON changes:

# echowatch.py: run a command on a pty, report ECHO/ICANON
import os, pty, select, sys, termios, tty

pid, fd = pty.fork()
if pid == 0:
    os.execvp(sys.argv[1], sys.argv[1:])

saved = termios.tcgetattr(0)
tty.setraw(0)  # our terminal stops echoing; the pty decides
last = None
try:
    while True:
        try:
            lflag = termios.tcgetattr(fd)[3]  # master side
        except termios.error:
            break
        mode = (bool(lflag & termios.ECHO),
                bool(lflag & termios.ICANON))
        if mode != last:
            note = f"\r\n[echo={mode[0]} icanon={mode[1]}]\r\n"
            os.write(2, note.encode())
            last = mode
        ready, _, _ = select.select([fd, 0], [], [], 0.1)
        if 0 in ready:
            os.write(fd, os.read(0, 1024))
        if fd in ready:
            try:
                data = os.read(fd, 1024)
            except OSError:  # some systems raise EIO at exit
                break
            if not data:     # others return EOF
                break
            os.write(1, data)
finally:
    termios.tcsetattr(0, termios.TCSAFLUSH, saved)
    os.waitpid(pid, 0)

The tty.setraw(0) line matters. Without it, your outer terminal would echo the password locally while the inner pty politely hid it. Point the watcher at the plainest password prompt there is:

python3 echowatch.py python3 -c \
  "import getpass; getpass.getpass()"

getpass prompts "without echoing" and reads from /dev/tty, which here is the pty (getpass docs). When the prompt appears, the note should switch to echo=False with icanon=True: the pair WezTerm and Ghostty treat as a password prompt. Then try python3 echowatch.py bash and watch the plain bash prompt report both bits off, as Readline's code says it will. I haven't run this myself, so treat those notes as what the man pages and source code predict, and trust your own run over this paragraph.

Where the bit lies

The two-bit rule is a heuristic, and its own docs say so. Ghostty's config reference says password prompt detection "is based on heuristics and may not always work as expected" (Ghostty docs). Here is where it breaks.

ssh and tmux. When you ssh to a server, the OpenSSH client puts your local terminal into raw mode, clearing ISIG, ICANON, ECHO and more for the whole session (sshtty.c). The remote sudo flips bits on a pty on the remote machine, which your terminal cannot read. tmux does the same locally: your terminal talks to tmux, and the programs in tmux panes live on ptys tmux owns. WezTerm's docs say its check "will not work with tmux or remote processes spawned via ssh".

Prompts that draw their own mask. A prompt that shows * per key has to see each key as you press it, so it turns canonical mode off too. Python's getpass gained an echo_char parameter in 3.14, and the docs say that with it set "the terminal will be configured to operate in noncanonical mode" (getpass docs). sudo-rs made pwfeedback the default in 0.2.13 on 2026-03-10, and Ubuntu, which switched to sudo-rs in 25.10, now shows asterisks at the sudo prompt in 26.04 (omgubuntu.co.uk). To a two-bit check, those prompts look like Readline.

sudo itself, since late 2025. sudo-rs 0.2.10 fixed a bug where "a partially typed password would be output to standard input if a timeout occurred" when pwfeedback was off (sudo-rs changelog). It is CVE-2025-64170, rated 3.8 (Low). The root cause, per Wiz's write-up, was canonical mode left on during the timed read. The original sudo hit the same class of bug. A November 2025 commit, "tgetpass: Use cbreak mode if possible even if pwfeedback is disabled", says it fixes pending input being "echoed after sudo exits" when sudo is killed, and that "the password is read one character at a time rather than by line" (sudo ChangeLog). sudo's current tgetpass.c calls sudo_term_cbreak, which clears ECHO | ECHONL | ICANON | IEXTEN (term.c).

So the fix that made sudo safer also takes ICANON off at the prompt, so a sudo built from that code shows the same two bits as a bash prompt. Which sudo release ships it depends on your distro, so check yours with stty before you trust a lock icon.

Why this matters more with agents in the terminal

In normal operation none of this touches your shell history. The shell isn't the program reading the password, so ~/.bash_history doesn't get it. The 2025 sudo bugs above were the exception: leftover keystrokes could reach the shell after sudo exited. The risk sits one layer up, in features that watch the terminal itself: autosuggest that learns from what you typed into the pane, recordings, and coding agents that run in the same terminal or read its scrollback.

That layer sees the raw keystrokes, because it writes them into the pty master. A feature built there needs its own answer to "was that a secret?", and the termios bit is the obvious first answer. The previous section is why it can't be the only one.

What Hermes 2.3.2 does instead

I build Hermes IDE, so weigh this section with that in mind. Its terminal has ghost-text suggestions from your command history, and release 2.3.2 (October 13, 2026) lists two fixes: "What you type at a hidden password prompt (sudo, ssh, a script asking for a passphrase) is never remembered or suggested," and commands that carry a secret are never shown as a suggestion or as faded text after the cursor. Your shell's own history is not changed.

The history recorder does not read the ECHO bit. The code in TerminalPool.ts records a typed line only when the shell is the foreground program and the pane is not on the alternate screen, so lines typed while another program holds the foreground are skipped before any other check. For a line that passes that gate, it waits 500 ms after Enter and reads the line back from the screen. The comment says it plainly: "a line the terminal did not show is never remembered." A prompt with echo off shows nothing, and a masked prompt shows asterisks rather than your text, so either way the line is dropped.

The second fix is a pattern filter, secrets.ts. It drops lines with known token shapes (GitHub, AWS, Stripe, JWTs, private key headers and more), sensitive variables being set, flags like --password and --token, auth headers and Bearer values, and URLs with user:pass@ in them. Its doc comment sets the trade-off: "a missed suggestion costs a few keystrokes, a shown secret can end up on a shared screen or recording."

Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It is not affiliated with Nous Research, Anthropic, OpenAI or any agent vendor. Download it or read the code on GitHub.

Check your own setup

Run echowatch.py against the prompts you actually hit: sudo -k true, an ssh-keygen passphrase, and the password prompt of whatever CLI you use daily. Then run it with ssh and tmux as the command and watch the bits stay off for the whole session. If your terminal draws a lock icon, try it at a sudo prompt with pwfeedback on. If a tool you depend on decides "secret or not" from termios alone, you now know the three places to test it.


If this was useful

The pattern here is the one that keeps showing up with agents: a guardrail built on one signal works until the signal changes under it. The AI Agents Pocket Guide covers the patterns for building autonomous systems with LLMs, including guardrails that don't rest on a single signal. It is one of 8 short books in Pocket Guides for Developers, covering agents, RAG, prompts, observability, events, databases and system design.

AI Agents Pocket Guide: Patterns for Building Autonomous Systems with LLMs

來源:DEV Community · dev.to