Skip to content

Plan 006: Disable terminal echo when prompting for API keys #21

Description

@duyetbot

Plan 006: Disable terminal echo when prompting for API keys

Executor instructions: Follow this plan step by step. Run every
verification command and confirm the expected result before moving to the
next step. If anything in the "STOP conditions" section occurs, stop and
report — do not improvise. When done, update the status row for this plan
in plans/README.md — unless a reviewer dispatched you and told you they
maintain the index.

Drift check (run first): git diff --stat 61ee3c7..HEAD -- src/term.rs src/auth.rs src/commands.rs
If any in-scope file changed since this plan was written, compare the
"Current state" excerpts against the live code before proceeding; on a
mismatch, treat it as a STOP condition.

Status

  • Priority: P2
  • Effort: S
  • Risk: LOW
  • Depends on: none
  • Category: security
  • Planned at: commit 61ee3c7, 2026-08-26

Why this matters

anyr login --paste (and every fallback that prompts "Paste your AnyRouter
API key") reads stdin with echo left on: the full live key lands in terminal
scrollback, screen recordings, and any terminal-capture tooling. Every other
CLI hides secret input. The fix is small, unix+windows scoped, and must not
change behavior when stdin is not a TTY.

Current state

  • src/term.rs lines 319–338:
pub fn is_interactive() -> bool {
    io::stdin().is_terminal() && io::stdout().is_terminal()
}

pub fn prompt(label: &str) -> Result<String, String> {
    eprint!("{label}");
    let _ = io::stderr().flush();
    let mut line = String::new();
    io::stdin()
        .read_line(&mut line)
        .map_err(|e| format!("Could not read input: {e}"))?;
    Ok(line.trim().to_string())
}

pub fn confirm(question: &str) -> bool {
    match prompt(&format!("{question} [y/N] ")) {
        Ok(ans) => matches!(ans.to_ascii_lowercase().as_str(), "y" | "yes"),
        Err(_) => false,
    }
}
  • Secret entry points (callers, do not change them):
    • src/auth.rs:335 and auth.rs:357term::prompt("Paste your AnyRouter API key (sk-ar-...): ")
    • src/commands.rs may call term::prompt for non-secret values too — grep before assuming; non-secret prompts keep using plain prompt.

Repo conventions: unix-only code sits behind #[cfg(unix)] (see
upgrade.rs:220); the crate already depends on libc directly (Cargo.toml
line 27) and uses raw libc calls in buildinfo.rs:38-40, so termios via libc
matches house style — no new dependency needed.

Commands you will need

Purpose Command Expected on success
Unit tests cargo test --locked --lib term all pass
Full suite cargo test --locked --all-targets all pass
Clippy cargo clippy --locked --all-targets no new warnings

Scope

In scope:

  • src/term.rs
  • Call sites that prompt for secrets ONLY if they must switch from
    term::prompt to the new term::prompt_secret (expected: two in auth.rs)

Out of scope:

  • confirm() and all non-secret prompts.
  • Any TUI code (src/tui/*).
  • No new dependencies (libc is already direct).

Git workflow

  • Branch: advisor/006-secret-prompt-noecho
  • Commit style: conventional commits, e.g. feat(security): read pasted API keys with terminal echo off
  • Do NOT push or open a PR.

Steps

Step 1: Add prompt_secret to term.rs

Implement with libc termios on unix; on Windows use cmd /C is NOT
acceptable (no shell-outs) — implement a #[cfg(windows)] fallback that
prints a warning and reads normally? NO: the crate targets windows-x86_64 in
CI, so provide a real implementation using the Win32 console API via
libc-free approach:

  • Unix (#[cfg(unix)]): tcgetattr/tcsetattr via libc on fd 0 — clear
    ECHO, set attributes, read line, ALWAYS restore in a scopeguard-style
    finally (implement restore manually since no scopeguard dep):
/// Read a line with terminal echo disabled (for API keys). Falls back to
/// normal reading when stdin is not a TTY or termios control fails.
pub fn prompt_secret(label: &str) -> Result<String, String> {
    #[cfg(unix)]
    {
        // ... save termios; clear ECHO; tcsetattr(TCSANOW); read; restore ...
    }
    #[cfg(not(unix))]
    {
        prompt(label) // Windows: documented limitation, same as before
    }
}

Requirements regardless of platform branch:

  • Echo restored even if read_line errors (restore BEFORE returning).
  • After reading, print one newline to stderr so the shell prompt doesn't
    glue onto the label line.
  • If stdin is not a TTY, delegate to prompt(label) unchanged (pipes/tests).

Step 2: Switch the secret call sites

In src/auth.rs, both occurrences of
term::prompt("Paste your AnyRouter API key (sk-ar-...): ") become
term::prompt_secret(...). Grep first: grep -n 'term::prompt' src/auth.rs.

Verify: grep -n 'sk-ar-\.\.\.' src/auth.rs | xargs -I{} true then visually confirm both use prompt_secret; cargo build --locked → exit 0.

Step 3: Test what is testable without a TTY

The no-TTY path IS unit-testable (echo-off path needs a pty — out of scope;
documented instead). Add to term.rs mod tests (create the module if absent):

#[test]
fn prompt_secret_falls_back_without_tty() {
    // In `cargo test` stdin is typically not a tty; assert it delegates and
    // returns whatever the underlying reader yields. Feed empty stdin via
    // the existing harness pattern — simplest: just call it and accept Ok/Err
    // but require no panic.
    let _ = prompt_secret("x: ");
}

Plus an integration guard in tests/cli.rs style is NOT possible for hidden
input without a pty; skip. Keep the test minimal and honest.

Verify: cargo test --locked --lib term → passes.

Step 4: Full suite

Verify: cargo test --locked --all-targets → exit 0.

Test plan

  • Unit: fallback path (non-TTY) does not panic.
  • Manual verification note for the maintainer (put in commit body, not tests):
    run anyr login --paste locally and confirm keystrokes don't render.
  • Existing suites prove no regression on piped stdin (integration tests drive
    the binary with pipes everywhere — they'd catch a broken fallback).

Done criteria

ALL must hold:

  • grep -n "prompt_secret" src/term.rs src/auth.rs shows definition + ≥2 call sites.
  • grep -c "tcsetattr\|SetConsoleDisplayMode" src/term.rs ≥ 1 (unix path present).
  • cargo test --locked --all-targets exits 0.
  • Only src/term.rs and src/auth.rs modified.

STOP conditions

Stop and report if:

  • The excerpts drifted.
  • You find MORE secret-prompt sites than the two in auth.rs while grepping
    (list them in your report; switch only ones clearly reading keys/tokens).
  • Adding the termios block trips clippy in a way you can't resolve without
    unsafe beyond the two libc calls (keep unsafe blocks minimal and commented).

Maintenance notes

  • Windows currently keeps visible input (pre-existing behavior). If someone
    later wants parity, the winapi CONSOLE_MODE/ENABLE_ECHO_INPUT toggle is
    the shape — needs a windows-sys dev-dep decision, out of scope here.
  • Reviewer: verify restore-on-error actually restores (read the control flow,
    not just the happy path).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions