From 52692b88dbcaf9f41f1119a21cf0db1fb1a654d4 Mon Sep 17 00:00:00 2001 From: "Brandon W. King" <70168+kingb@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:42:54 -0700 Subject: [PATCH] feat(settings): a live toggle for OSC 52 clipboard reads osc52_read was a config.toml-only switch. It is now also a Settings row, "Programs can read clipboard", in the Terminal section: off by default, with help text that names the use (nvim or tmux over SSH pasting from the local clipboard) and the risk (any program in the terminal can read what you copied). The switch is also live now. Before, each pane copied the value when it was spawned, so turning reads OFF left every open pane still sharing the clipboard, which is the wrong failure for a privacy switch. The app now holds one shared gate, hands every pane a clone, and updates it on every settings change; each read checks it at that moment. - ember-session: LocalPtyConfig::osc52_read is a shared Arc, and the listener reads the clipboard through an injectable source so tests can prove what is and isn't read. - ember-core: the Settings row and its toggle, plus the config doc. - ember-app: the gate lives in Shared, seeded from the loaded config and updated in adjust_setting alongside the other live side effects. Tests: the row is off by default and flips only osc52_read; the setting survives a config.toml save and load (and an older file without the key loads with reads off); and the gate is live in a running pane, off -> on -> off, with the clipboard never read while off. The existing listener tests now fail if anything reads the clipboard while reads are off. Sabotage-checked: ignoring the gate, or always reading, each fails. Builds on the OSC 52 engine-gate fix, without which no read reaches the listener. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014xVjnf8UuQPRQtv2QqgpPK --- CHANGELOG.md | 9 ++++ crates/ember-app/src/main.rs | 8 +++ crates/ember-app/src/window_state.rs | 6 ++- crates/ember-core/src/config.rs | 16 +++++- crates/ember-core/src/settings.rs | 33 ++++++++++++ crates/ember-session/src/local_pty.rs | 77 ++++++++++++++++++++++----- 6 files changed, 134 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7816528..3b740eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ follow [Semantic Versioning](https://semver.org). ## [Unreleased] +### Added + +- A Settings toggle for clipboard reads: "Programs can read clipboard", in the + Terminal section. It is the same `osc52_read` switch that config.toml + offers, now one keypress away, for when you want nvim or tmux over + SSH to paste from your local clipboard. Any program in the terminal can read + what you copied while it is on, so it stays off by default. Changes apply + at once, including in panes that are already open. + ### Fixed - OSC 52 clipboard reads now work as documented. Since 0.5.0, a program diff --git a/crates/ember-app/src/main.rs b/crates/ember-app/src/main.rs index ff811aa..0d84595 100644 --- a/crates/ember-app/src/main.rs +++ b/crates/ember-app/src/main.rs @@ -344,6 +344,11 @@ pub(crate) struct Shared { pub(crate) control_server: Option, /// User config (the Settings overlay reads + mutates it). pub(crate) config: Config, + /// The live OSC 52 read gate every pane's session shares (see + /// `LocalPtyConfig::osc52_read`). Mirrors `config.osc52_read`: seeded from + /// it here and updated whenever a setting changes, so turning reads off + /// reaches panes that are already open. + pub(crate) osc52_read_gate: std::sync::Arc, /// Backdrop animation clock. pub(crate) backdrop_since: Instant, /// Native menu bar (macOS); inert elsewhere. Kept alive for the app's life. @@ -936,6 +941,9 @@ impl ApplicationHandler for App { next_tab: 2, control_rx: self.control_rx.take(), control_server: self.control_server.take(), + osc52_read_gate: std::sync::Arc::new(std::sync::atomic::AtomicBool::new( + config.osc52_read, + )), config, backdrop_since: Instant::now(), menu: ember_platform::build_menu(), diff --git a/crates/ember-app/src/window_state.rs b/crates/ember-app/src/window_state.rs index 64f624e..e0fe509 100644 --- a/crates/ember-app/src/window_state.rs +++ b/crates/ember-app/src/window_state.rs @@ -1018,7 +1018,7 @@ impl WindowState { ) -> bool { let mut cfg = LocalPtyConfig::new(id.clone(), dims); cfg.shell_integration = shared.config.shell_integration; - cfg.osc52_read = shared.config.osc52_read; + cfg.osc52_read = std::sync::Arc::clone(&shared.osc52_read_gate); cfg.cwd = cwd.map(std::path::PathBuf::from); let handle = match LocalPty::spawn(cfg) { Ok(h) => h, @@ -4756,6 +4756,10 @@ impl WindowState { } self.apply_appearance(shared); shared.set_developer_mode(shared.config.developer_mode); + shared.osc52_read_gate.store( + shared.config.osc52_read, + std::sync::atomic::Ordering::Relaxed, + ); let mut relayout = self.renderer.set_font_size(shared.config.font.size); relayout |= self.renderer.set_family(shared.config.font.family.clone()); if relayout { diff --git a/crates/ember-core/src/config.rs b/crates/ember-core/src/config.rs index 41f792b..f0b050d 100644 --- a/crates/ember-core/src/config.rs +++ b/crates/ember-core/src/config.rs @@ -39,7 +39,8 @@ pub struct Config { /// the clipboard's CONTENTS. A data-exfiltration surface (any program in /// the terminal can quietly read what you copied), so OFF by default - /// the request is answered with an empty payload. Copy direction (OSC 52 - /// write) is always on. config.toml only. + /// write) is always on. Also the Settings toggle "Programs can read + /// clipboard" (Terminal section), which applies to open panes at once. pub osc52_read: bool, /// Which of the wisp's visual styles to draw — see [`WispStyleSelection`]. /// Orthogonal to `wisp` (the on/off switch): this only matters while @@ -367,6 +368,19 @@ mod tests { assert_eq!(c.background.image_fit, "cover"); } + #[test] + fn osc52_read_survives_a_save_and_load() { + let c = Config { + osc52_read: true, + ..Config::default() + }; + let back: Config = toml::from_str(&toml::to_string_pretty(&c).unwrap()).unwrap(); + assert!(back.osc52_read); + // An older config.toml without the key still loads with reads off. + let old: Config = toml::from_str("").unwrap(); + assert!(!old.osc52_read); + } + #[test] fn roundtrips_through_toml() { let c = Config::default(); diff --git a/crates/ember-core/src/settings.rs b/crates/ember-core/src/settings.rs index b87b9c1..1829500 100644 --- a/crates/ember-core/src/settings.rs +++ b/crates/ember-core/src/settings.rs @@ -301,6 +301,12 @@ fn fmt_option_as_meta(c: &Config) -> String { fn adjust_option_as_meta(c: &mut Config, _dir: f32) { c.option_as_meta = !c.option_as_meta; } +fn fmt_osc52_read(c: &Config) -> String { + on_off(c.osc52_read) +} +fn adjust_osc52_read(c: &mut Config, _dir: f32) { + c.osc52_read = !c.osc52_read; +} // --- Session restore --------------------------------------------------------- @@ -469,6 +475,18 @@ pub fn setting_rows() -> &'static [SettingRow] { characters. Takes effect immediately.", ), }, + SettingRow { + label: "Programs can read clipboard", + kind: RowKind::Toggle, + format: fmt_osc52_read, + adjust: Some(adjust_osc52_read), + help: Help::Inline( + "Lets programs ask for your clipboard's contents (OSC 52), so nvim or tmux over \ + SSH can paste from your local clipboard. Any program running in the terminal \ + can then read what you copied, so leave it off unless you need it. Off answers \ + with nothing. Takes effect immediately, in open panes too.", + ), + }, SettingRow { label: "Session", kind: RowKind::SectionHeader, @@ -798,6 +816,21 @@ mod tests { assert_eq!(c.shell_integration, before.shell_integration); } + #[test] + fn clipboard_read_toggle_is_off_by_default_and_mutates_only_osc52_read() { + let mut c = Config::default(); + assert!(!c.osc52_read, "reading the clipboard must stay opt-in"); + assert_eq!(fmt_osc52_read(&c), on_off(false)); + let before = c.clone(); + (row("Programs can read clipboard").adjust.unwrap())(&mut c, 1.0); + assert!(c.osc52_read); + assert_eq!(fmt_osc52_read(&c), on_off(true)); + assert_eq!(c.option_as_meta, before.option_as_meta); + assert_eq!(c.shell_integration, before.shell_integration); + (row("Programs can read clipboard").adjust.unwrap())(&mut c, 1.0); + assert!(!c.osc52_read, "the toggle turns it back off"); + } + #[test] fn developer_mode_toggle_mutates_only_developer_mode() { let mut c = Config::default(); diff --git a/crates/ember-session/src/local_pty.rs b/crates/ember-session/src/local_pty.rs index 4fc162f..7fae6fc 100644 --- a/crates/ember-session/src/local_pty.rs +++ b/crates/ember-session/src/local_pty.rs @@ -39,8 +39,10 @@ pub struct LocalPtyConfig { pub shell_integration: bool, /// Answer OSC 52 clipboard READ requests with real clipboard contents. /// Off = reply with an empty payload (the safe default; see the config - /// knob's doc in ember-core). - pub osc52_read: bool, + /// knob's doc in ember-core). A shared, live gate: the app holds one and + /// hands every pane a clone, and each read checks it at that moment, so + /// turning the setting off takes effect in panes that are already open. + pub osc52_read: Arc, } impl LocalPtyConfig { @@ -52,7 +54,7 @@ impl LocalPtyConfig { args: Vec::new(), cwd: None, shell_integration: true, - osc52_read: false, + osc52_read: Arc::new(AtomicBool::new(false)), } } } @@ -224,8 +226,11 @@ enum Ev { struct EmberListener { events: Sender, outbox: Arc>>, - /// See [`LocalPtyConfig::osc52_read`]. - osc52_read: bool, + /// See [`LocalPtyConfig::osc52_read`]. Checked per request, never cached. + osc52_read: Arc, + /// Where a permitted read gets the clipboard's text. The system clipboard + /// in production; a fixed value in tests. + read_clipboard: fn() -> String, /// For answering OSC 10/11 color queries (nvim's background detection /// blocks on this at startup). Static defaults — good enough for queries. palette: crate::palette::Palette, @@ -256,11 +261,8 @@ impl EventListener for EmberListener { // data-exfiltration surface, so gated (default off = empty reply, // which unblocks well-behaved clients instead of hanging them). AlacEvent::ClipboardLoad(_, format) => { - let text = if self.osc52_read { - arboard::Clipboard::new() - .ok() - .and_then(|mut c| c.get_text().ok()) - .unwrap_or_default() + let text = if self.osc52_read.load(Ordering::Relaxed) { + (self.read_clipboard)() } else { String::new() }; @@ -298,6 +300,14 @@ fn reader_loop(mut reader: Box, itx: SyncSender) { } } +/// The system clipboard's text, or empty if it can't be read. +fn system_clipboard_text() -> String { + arboard::Clipboard::new() + .ok() + .and_then(|mut c| c.get_text().ok()) + .unwrap_or_default() +} + #[allow(clippy::too_many_arguments)] fn emulation_loop( dims: GridDims, @@ -308,7 +318,7 @@ fn emulation_loop( master: Box, mut child: Box, busy: Arc, - osc52_read: bool, + osc52_read: Arc, ) { // The shell is its own process-group leader; when a foreground command runs, // the PTY's foreground pgrp differs from the shell pid. Recompute after each @@ -324,6 +334,7 @@ fn emulation_loop( events: event_tx.clone(), outbox: Arc::clone(&outbox), osc52_read, + read_clipboard: system_clipboard_text, palette: crate::palette::Palette::dark(), }; let mut proj = AlacrittyProjection::new(dims, listener); @@ -508,7 +519,8 @@ mod tests { let l = EmberListener { events: tx, outbox: Arc::clone(&outbox), - osc52_read: false, + osc52_read: Arc::new(AtomicBool::new(false)), + read_clipboard: || panic!("the clipboard must not be read while reads are off"), palette: crate::palette::Palette::dark(), }; l.send_event(AlacEvent::ClipboardLoad( @@ -533,7 +545,8 @@ mod tests { let listener = EmberListener { events: tx, outbox: Arc::clone(&outbox), - osc52_read: false, + osc52_read: Arc::new(AtomicBool::new(false)), + read_clipboard: || panic!("the clipboard must not be read while reads are off"), palette: crate::palette::Palette::dark(), }; let mut proj = AlacrittyProjection::new(GridDims::new(80, 24), listener); @@ -545,6 +558,44 @@ mod tests { ); } + /// The setting is live: flipping the shared gate changes the answer for a + /// pane that's already running, in both directions, and nothing is read + /// from the clipboard while it's off. + #[test] + fn osc52_read_gate_is_live_in_a_running_pane() { + let (tx, _rx) = mpsc::channel(); + let outbox = Arc::new(Mutex::new(Vec::new())); + let gate = Arc::new(AtomicBool::new(false)); + let listener = EmberListener { + events: tx, + outbox: Arc::clone(&outbox), + osc52_read: Arc::clone(&gate), + read_clipboard: || "secret".to_string(), + palette: crate::palette::Palette::dark(), + }; + let mut proj = AlacrittyProjection::new(GridDims::new(80, 24), listener); + let mut read = || { + outbox.lock().unwrap().clear(); + proj.advance(b"\x1b]52;c;?\x07"); + String::from_utf8(outbox.lock().unwrap().clone()).unwrap() + }; + + assert_eq!(read(), "\x1b]52;c;\x07", "off: an empty reply"); + gate.store(true, Ordering::Relaxed); + // "secret" in base64, as the engine formats it. + assert_eq!( + read(), + "\x1b]52;c;c2VjcmV0\x07", + "on: the clipboard's contents" + ); + gate.store(false, Ordering::Relaxed); + assert_eq!( + read(), + "\x1b]52;c;\x07", + "off again: empty, with no restart" + ); + } + /// Reconstruct row 0's text from the frame lane until `needle` appears or we /// time out. Proves the full path: shell → PTY → engine → projection → lane. #[test]