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:357 — term::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:
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).
Plan 006: Disable terminal echo when prompting for API keys
Status
61ee3c7, 2026-08-26Why this matters
anyr login --paste(and every fallback that prompts "Paste your AnyRouterAPI 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.rslines 319–338:src/auth.rs:335andauth.rs:357—term::prompt("Paste your AnyRouter API key (sk-ar-...): ")src/commands.rsmay callterm::promptfor non-secret values too — grep before assuming; non-secret prompts keep using plainprompt.Repo conventions: unix-only code sits behind
#[cfg(unix)](seeupgrade.rs:220); the crate already depends onlibcdirectly (Cargo.tomlline 27) and uses raw libc calls in
buildinfo.rs:38-40, so termios via libcmatches house style — no new dependency needed.
Commands you will need
cargo test --locked --lib termcargo test --locked --all-targetscargo clippy --locked --all-targetsScope
In scope:
src/term.rsterm::promptto the newterm::prompt_secret(expected: two in auth.rs)Out of scope:
confirm()and all non-secret prompts.src/tui/*).Git workflow
advisor/006-secret-prompt-noechofeat(security): read pasted API keys with terminal echo offSteps
Step 1: Add
prompt_secretto term.rsImplement with libc termios on unix; on Windows use
cmd /Cis NOTacceptable (no shell-outs) — implement a
#[cfg(windows)]fallback thatprints 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:#[cfg(unix)]):tcgetattr/tcsetattrvialibcon fd 0 — clearECHO, set attributes, read line, ALWAYS restore in a scopeguard-stylefinally (implement restore manually since no scopeguard dep):
Requirements regardless of platform branch:
read_lineerrors (restore BEFORE returning).glue onto the label line.
prompt(label)unchanged (pipes/tests).Step 2: Switch the secret call sites
In
src/auth.rs, both occurrences ofterm::prompt("Paste your AnyRouter API key (sk-ar-...): ")becometerm::prompt_secret(...). Grep first:grep -n 'term::prompt' src/auth.rs.Verify:
grep -n 'sk-ar-\.\.\.' src/auth.rs | xargs -I{} truethen 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):Plus an integration guard in
tests/cli.rsstyle is NOT possible for hiddeninput 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
run
anyr login --pastelocally and confirm keystrokes don't render.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.rsshows definition + ≥2 call sites.grep -c "tcsetattr\|SetConsoleDisplayMode" src/term.rs≥ 1 (unix path present).cargo test --locked --all-targetsexits 0.src/term.rsandsrc/auth.rsmodified.STOP conditions
Stop and report if:
(list them in your report; switch only ones clearly reading keys/tokens).
unsafebeyond the two libc calls (keep unsafe blocks minimal and commented).Maintenance notes
later wants parity, the winapi
CONSOLE_MODE/ENABLE_ECHO_INPUTtoggle isthe shape — needs a windows-sys dev-dep decision, out of scope here.
not just the happy path).