From a3c6e050ea9f0f7f7d944b46203b9fee640e4921 Mon Sep 17 00:00:00 2001 From: Svetlin Ralchev Date: Tue, 6 Oct 2026 10:50:11 +0400 Subject: [PATCH] fix: don't print secrets on the terminal when `load` can't change the shell Without the shell integration, `load` and `unload` printed their export statements to stdout. On a terminal that showed secret values on screen, and in the scrollback, without changing the shell. When stdout is a terminal, the integration isn't active and no --format was given, they now print nothing and explain how to set up the integration or evaluate the output. Evaluated, piped or with an explicit --format, they print as before. --- src/app/args.rs | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 6 +++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/app/args.rs b/src/app/args.rs index f53ce27..e91a638 100644 --- a/src/app/args.rs +++ b/src/app/args.rs @@ -465,6 +465,22 @@ impl OutputArgs { self.eval.is_some() && self.format.is_none() } + /// Fails when the shell statements of `command` would only be printed on a terminal: + /// without the shell integration and without an explicit --format, they would show + /// secret values on screen without changing the shell. + pub fn check_destination(&self, command: &str, stdout_is_terminal: bool) -> anyhow::Result<()> { + if stdout_is_terminal && self.eval.is_none() && self.format.is_none() { + let (shell, rc) = match self.export_format() { + ExportFormat::Bash => ("bash", "~/.bashrc"), + _ => ("zsh", "~/.zshrc"), + }; + anyhow::bail!( + "`{command}` changes your shell through the shell integration, which isn't active here; add `eval \"$(keysafe init {shell})\"` to {rc}, or run `eval \"$(keysafe {command})\"`" + ); + } + Ok(()) + } + /// Get the export format: the explicit one, the integration's shell, or $SHELL detection. pub fn export_format(&self) -> ExportFormat { self.format @@ -853,6 +869,36 @@ mod tests { assert!(!args.is_eval()); } + #[test] + fn statements_are_not_printed_on_a_terminal_without_the_integration() { + let plain = OutputArgs { + format: None, + eval: None, + ..Default::default() + }; + let err = plain + .check_destination("load", true) + .unwrap_err() + .to_string(); + assert!( + err.starts_with("`load` changes your shell through the shell integration"), + "{err}" + ); + + // Piped or evaluated, under the integration, or with an explicit format: printed + assert!(plain.check_destination("load", false).is_ok()); + let integrated = OutputArgs { + eval: Some(Shell::Zsh), + ..Default::default() + }; + assert!(integrated.check_destination("load", true).is_ok()); + let explicit = OutputArgs { + format: Some(ExportFormat::Zsh), + ..Default::default() + }; + assert!(explicit.check_destination("unload", true).is_ok()); + } + #[test] fn export_format_follows_shell_integration() { let args = OutputArgs { diff --git a/src/main.rs b/src/main.rs index da9337b..d921410 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,7 @@ mod log; mod vault; use std::fs::File; -use std::io::Write; +use std::io::{IsTerminal, Write}; use std::os::fd::FromRawFd; use std::os::unix::process::ExitStatusExt; use std::path::PathBuf; @@ -71,6 +71,8 @@ fn main() -> ExitCode { fn run(program: Program) -> Result { match program.command { ProgramCommand::Load(args) => { + args.output + .check_destination("load", std::io::stdout().is_terminal())?; let writer = statements(&args.output); let loader = loader(&args.parent); let agent = Box::new(Agent::new()); @@ -82,6 +84,8 @@ fn run(program: Program) -> Result { command.execute(&args)? } ProgramCommand::Unload(args) => { + args.output + .check_destination("unload", std::io::stdout().is_terminal())?; let writer = statements(&args.output); let cache = cache(&args.parent); let agent = Box::new(Agent::new());