Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ jobs:
- name: gitleaks
uses: gitleaks/gitleaks-action@v2
env:
# The action downloads a fixed filename; keep it in job-local temp.
TMPDIR: ${{ runner.temp }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_CONFIG: .gitleaks.toml

Expand Down
7 changes: 7 additions & 0 deletions bws-tui/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions bws-tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ anyhow = "1"
humantime = "2"
clap = { version = "4", features = ["derive"] }
arboard = "3"
base64 = "0.22"
6 changes: 3 additions & 3 deletions bws-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ The official `bws` CLI already has the authentication and API access. What it do
- **Type to find.** Fuzzy filtering is always live; arrows always move.
- **Enter means actions, never exit.** Copy, Reveal, Edit, Delete, or Cancel appear as plain English.
- **Values stay out of lists and logs.** Reveal is deliberate; copy is explicit.
- **Clipboard copies expire.** The value clears after 30 seconds if it is still unchanged and `hush` remains open.
- **Native clipboard copies expire.** The value clears after 30 seconds if it is still unchanged and `hush` remains open. Headless terminal copies use OSC 52 and must be cleared manually.
- **Projects never drift.** The menu comes from `bws project list` every time.
- **No config file.** `bws` keeps its token/profile configuration; `hush` adds none.

Expand Down Expand Up @@ -101,7 +101,7 @@ cargo install --locked bws-tui
hush
```

Tested on macOS. The crate uses `arboard` for cross-platform clipboard access, but other platforms are not yet part of the release verification matrix.
On macOS and Linux desktops, `hush` uses the native clipboard through `arboard`. On headless Linux, it sends an OSC 52 clipboard write through the terminal. This fallback requires stdout to be a terminal; redirected output is rejected before writing value bytes. Native clipboard copies can still work with redirected stdout. OSC 52 cannot confirm delivery or read back the clipboard, so `hush` does not automatically clear terminal copies. Clear them manually. Terminal copies are limited to 128 KiB of value text. Under tmux, enable `set-clipboard on` and ensure the outer terminal supports clipboard writes.

## The interface

Expand Down Expand Up @@ -198,7 +198,7 @@ That narrow boundary is the point. `hush` is an interface, not another secrets p
- **Masked entry:** TUI value input is never rendered as plaintext while typing.
- **No values in lists:** search rows contain only key and project name.
- **Sanitized failures:** failed `bws` operations report the operation and exit status, never the secret-bearing argv.
- **Conditional clipboard clear:** after 30 seconds, `hush` clears the clipboard only if it still contains the value it copied. Your newer clipboard content is left alone.
- **Conditional native clipboard clear:** after 30 seconds, `hush` clears a native clipboard only if it still contains the value it copied. Your newer clipboard content is left alone. OSC 52 copies are not auto-cleared because the terminal cannot report whether the clipboard changed.
- **Delete guard:** the TUI confirms deletion; scripts require `--yes`.
- **Scoped reads:** `get` and `exec` fetch single secrets by id; only `list`/`sync` refreshes see the full metadata set, and nothing persists values.
- **Names-only audit log:** every operation is recorded with timestamp, verb, and key names — never values.
Expand Down
35 changes: 4 additions & 31 deletions bws-tui/src/tui.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
mod actions;
mod chrome;
mod clipboard;
mod events;
mod forms;
mod render;
mod secrets;
mod terminal;

use crate::bws::{self, Project, Secret};
use anyhow::{Context, Result};
use arboard::Clipboard;
use secrecy::{ExposeSecret, SecretString};
use anyhow::Result;
use clipboard::*;
use secrecy::SecretString;
use std::time::{Duration, Instant};

const ACCENT: ratatui::style::Color = ratatui::style::Color::Cyan;
Expand Down Expand Up @@ -208,34 +209,6 @@ fn fuzzy_match(hay: &str, needle: &str) -> bool {
cur.is_none()
}

fn clipboard_holds_copied_value(current: &str, copied: &SecretString) -> bool {
current == copied.expose_secret()
}

fn clear_clipboard_value(copied: &SecretString) -> Result<bool> {
let mut clipboard = Clipboard::new().context("macOS clipboard is unavailable")?;
let current = clipboard
.get_text()
.context("failed to read the clipboard")?;
if !clipboard_holds_copied_value(&current, copied) {
return Ok(false);
}
clipboard
.set_text(String::new())
.context("failed to clear the clipboard")?;
Ok(true)
}

fn copy_with_autoclear(app: &mut App, value: String) -> Result<()> {
let mut clipboard = Clipboard::new().context("macOS clipboard is unavailable")?;
clipboard
.set_text(value.clone())
.context("failed to copy the secret value")?;
app.clipboard_value = Some(SecretString::from(value));
app.clipboard_clear_at = Some(Instant::now() + Duration::from_secs(30));
Ok(())
}

pub fn run() -> Result<()> {
let mut app = App::new(bws::list_projects()?);
terminal::run(&mut app)
Expand Down
24 changes: 19 additions & 5 deletions bws-tui/src/tui/actions.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
use super::*;
use std::io::{IsTerminal, Write};

pub(super) fn copy_action(app: &mut App) {
/// Copies the selected value and reports the delivery method without revealing it.
pub(super) fn copy_action(app: &mut App, output: &mut impl Write) {
if let Some(s) = app.selected_secret() {
let key = s.key.clone();
let value = s.value.clone();
match copy_with_autoclear(app, value) {
Ok(()) => app.set_ok(format!("✓ copied “{key}” — clears in 30s (keep app open)")),
match copy_value(
app,
value,
output,
should_try_native_clipboard(),
std::io::stdout().is_terminal(),
) {
Ok(CopyMethod::Native) => {
app.set_ok(format!("✓ copied “{key}” — clears in 30s (keep app open)"))
}
Ok(CopyMethod::Terminal) => app.set_ok(format!(
"sent “{key}” to terminal clipboard (OSC 52); clear manually"
)),
Err(error) => app.set_err(&error),
}
}
Expand All @@ -22,9 +35,10 @@ pub(super) fn edit_action(app: &mut App) {
}
}

pub(super) fn run_action(app: &mut App) {
/// Dispatches the selected secret action using the TUI's terminal output.
pub(super) fn run_action(app: &mut App, output: &mut impl Write) {
match app.action_idx {
0 => copy_action(app),
0 => copy_action(app, output),
1 => app.revealed = true,
2 => edit_action(app),
3 => app.mode = Mode::ConfirmDelete,
Expand Down
108 changes: 108 additions & 0 deletions bws-tui/src/tui/clipboard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
use super::App;
use anyhow::{Context, Result};
use arboard::Clipboard;
use base64::{engine::general_purpose::STANDARD, Engine};
use secrecy::{ExposeSecret, SecretString};
use std::{
ffi::OsStr,
io::Write,
time::{Duration, Instant},
};
use zeroize::Zeroizing;

/// Reports whether the clipboard still holds the value hush copied.
pub(super) fn clipboard_holds_copied_value(current: &str, copied: &SecretString) -> bool {
current == copied.expose_secret()
}

/// Clears a native copy only when it has not been replaced.
pub(super) fn clear_clipboard_value(copied: &SecretString) -> Result<bool> {
let mut clipboard = Clipboard::new().context("native clipboard is unavailable")?;
let current = clipboard
.get_text()
.context("failed to read the clipboard")?;
if !clipboard_holds_copied_value(&current, copied) {
return Ok(false);
}
clipboard
.set_text(String::new())
.context("failed to clear the clipboard")?;
Ok(true)
}

#[derive(Debug, PartialEq)]
pub(super) enum CopyMethod {
Native,
Terminal,
}

const MAX_OSC52_VALUE_BYTES: usize = 128 * 1024;

/// Detects a configured X11 or Wayland display.
pub(super) fn has_display_server(display: Option<&OsStr>, wayland: Option<&OsStr>) -> bool {
display.is_some_and(|value| !value.is_empty()) || wayland.is_some_and(|value| !value.is_empty())
}

/// Skips arboard on headless Linux while keeping the native path elsewhere.
pub(super) fn should_try_native_clipboard() -> bool {
!cfg!(target_os = "linux")
|| has_display_server(
std::env::var_os("DISPLAY").as_deref(),
std::env::var_os("WAYLAND_DISPLAY").as_deref(),
)
}

/// Sends a bounded OSC 52 clipboard write through the active terminal output.
pub(super) fn write_osc52(value: &str, output: &mut impl Write) -> Result<()> {
anyhow::ensure!(
value.len() <= MAX_OSC52_VALUE_BYTES,
"terminal clipboard OSC 52 value exceeds 128 KiB"
);
let encoded = Zeroizing::new(STANDARD.encode(value));
output.write_all(b"\x1b]52;c;")?;
output.write_all(encoded.as_bytes())?;
output.write_all(b"\x07")?;
output
.flush()
.context("failed to flush terminal clipboard")?;
Ok(())
}

/// Uses the native clipboard first and falls back to OSC 52 when unavailable.
/// OSC 52 requires terminal output; only native copies schedule a conditional clear.
pub(super) fn copy_value(
app: &mut App,
value: String,
output: &mut impl Write,
try_native: bool,
terminal_output: bool,
) -> Result<CopyMethod> {
let native_error = if try_native {
match Clipboard::new()
.context("native clipboard is unavailable")
.and_then(|mut clipboard| {
clipboard
.set_text(value.clone())
.context("failed to copy using native clipboard")
}) {
Ok(()) => {
app.clipboard_value = Some(SecretString::from(value));
app.clipboard_clear_at = Some(Instant::now() + Duration::from_secs(30));
return Ok(CopyMethod::Native);
}
Err(error) => error,
}
} else {
anyhow::anyhow!("native clipboard unavailable: no display server")
};

anyhow::ensure!(
terminal_output,
"{native_error:#}; terminal clipboard OSC 52 unavailable: stdout is not a terminal"
);
write_osc52(&value, output).map_err(|error| {
anyhow::anyhow!("{native_error:#}; terminal clipboard OSC 52 failed: {error:#}")
})?;
// OSC 52 cannot read back the clipboard. Keep any earlier native clear pending.
Ok(CopyMethod::Terminal)
}
4 changes: 2 additions & 2 deletions bws-tui/src/tui/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ pub(super) fn event_loop(
app.action_idx = (app.action_idx + 1).min(ACTIONS.len() - 1);
}
}
KeyCode::Enter => run_action(app),
KeyCode::Char('c') => copy_action(app),
KeyCode::Enter => run_action(app, term.backend_mut()),
KeyCode::Char('c') => copy_action(app, term.backend_mut()),
KeyCode::Char('r') => app.revealed = !app.revealed,
KeyCode::Char('e') => edit_action(app),
KeyCode::Char('d') => app.mode = Mode::ConfirmDelete,
Expand Down
Loading
Loading