diff --git a/README.md b/README.md index 82dbd97..69cdeb6 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,16 @@ see your region cannot answer the question you pasted. Two limits worth knowing: arrives in a credentials file or an API response. - Broad words like `password` and `token` are deliberately **not** in the defaults. They would fire on the code you paste for review and damage it. +- **Redaction is tied to `⌘V`.** A program that reads your clipboard itself + never goes through it — Claude Code's `Ctrl+V` image paste does exactly that, + through its own native clipboard module. Paste with `⌘V` and termit sees it; + paste with `Ctrl+V` and it does not. + +A program can also *ask* the terminal for your clipboard with `OSC 52 ?`. +termit refuses, because that request needs no keystroke from you — text +arriving on the terminal is enough to trigger it, so `cat`ing a hostile file +would be enough to lift what you copied. Writing to the clipboard stays +allowed: an agent inside a container has no other way to hand you something. **Dropping files.** Drag a file onto the window and its path is typed into the session, followed by a space, so several files dropped together line up as @@ -430,7 +440,8 @@ Chosen by recording what an agent's full-screen UI actually asks for. | Alternate screen (`?1049`) | yes | | Alternate scroll (`?1007`) | yes | | Window title (`OSC 0` / `OSC 2`) | yes | -| Clipboard (`OSC 52`) | yes | +| Clipboard write (`OSC 52`) | yes | +| Clipboard **read** (`OSC 52` `?`) | no, deliberately | | Device attributes (`CSI c`) | yes | | Synchronized output (`?2026`) | yes | | Bell | no | diff --git a/docs/references/paste.md b/docs/references/paste.md index 8031fc5..bf9f92e 100644 --- a/docs/references/paste.md +++ b/docs/references/paste.md @@ -137,6 +137,41 @@ termit が貼り付け口で伏せるのは、ちょうどこの空いている termit の貼り付けは `redactForDisplay` と同じく「人とエージェントが読んで意味を取る」 経路なので、high 寄りに留めるのが正しい。 +## OSC 52 の読み出し(2026-09-17 追記) + +貼り付けで伏せても、**クリップボードを持ち出す口が他に無いか**を調べた。 +OSC 52 には書き込み(プログラム → クリップボード)だけでなく、 +読み出しの要求(`\x1b]52;c;?`)がある。これは**利用者の操作を必要としない**。 +画面に文字列が流れるだけで起こるので、仕掛けのあるファイルを `cat` すれば、 +写したものが相手へ渡る。貼り付けと違い、伏せる機会もない。 + +**termit は初めから塞がっていた。** `alacritty_terminal` が +`clipboard_load` の入口で `config.osc52` を見ており、既定値の `Osc52::OnlyCopy` は +読み出しを含まない(向こうの注釈:「完全に無効にするのと、paste を許すのとの折衷」)。 +実際に `\x1b]52;c;?` を流しても、通知そのものが上がってこないことを確かめた。 + +**それでも `new_term` に明示した。** 既定に任せていると、向こうの既定が変わった日に +termit は黙って読み出しを許すことになる。方針は自分で書いておく。 + +```rust +osc52: alacritty_terminal::term::Osc52::OnlyCopy, +``` + +書き込みは断らない。コンテナの中のエージェントが写す手段が他に無いためである。 +`term.rs` の `osc52_policy_tests` が、読み出しに答えないことと書き込みを受けることの +両方を押さえている(`CopyPaste` に変えると落ちることも確かめた)。 + +### 塞げない口 + +**エージェントが自分でクリップボードを読む経路。** Claude Code は +ネイティブ拡張(`CLIPBOARD_NAPI_NODE_PATH`)を持ち、`Ctrl+V` で +端末を通さずホストのクリップボードを読む(少なくとも画像)。 +termit は `Ctrl+V` を割り当てていないので `0x16` がそのまま渡り、伏せ字は走らない。 +奪えば readline の quoted-insert と画像貼り付けの両方を壊すので、奪わない。 +**`⌘V` で貼れば効き、`Ctrl+V` では効かない** ——これは利用者に伝えるしかない。 + +なお Claude Code は OSC 52 の読み出しを要求しない(要求 `52;c;?` の出現数 0 で確認)。 + ## 限界(利用者に伝えるべきこと) **裸で貼った AWS のシークレットキーは捕まらない。** 40 文字の英数字に目印が無く、 diff --git a/src/term.rs b/src/term.rs index f20705e..cd262ed 100644 --- a/src/term.rs +++ b/src/term.rs @@ -185,11 +185,68 @@ pub fn now_ms() -> u64 { pub fn new_term(size: TermSize, scrollback: usize, proxy: EventProxy) -> Term { let config = TermConfig { scrolling_history: scrollback, + // OSC 52 は書き込みだけ受け、読み出しは断る。 + // + // 読み出しの要求は、画面に流れた文字列だけで起こせる。利用者は何もしていない + // のにクリップボードの中身が相手へ渡るので、仕掛けのあるファイルを `cat` する + // だけで、写したものを持ち出せてしまう。貼り付けと違い、伏せる機会もない。 + // 書き込みのほうは断らない。コンテナの中のエージェントが写す手段が他に無い。 + // + // これは `alacritty_terminal` の既定と同じ値だが、既定に任せず書いておく。 + // 向こうの既定が変われば、termit は黙って読み出しを許すことになる。 + osc52: alacritty_terminal::term::Osc52::OnlyCopy, ..TermConfig::default() }; Term::new(config, &size, proxy) } +/// OSC 52 の扱い。書き込みは受け、読み出しは断る。 +#[cfg(test)] +mod osc52_policy_tests { + use super::*; + use alacritty_terminal::vte::ansi::Processor; + + /// 台本を端末へ流し、上がってきた通知を集める。 + fn feed(bytes: &[u8]) -> Vec { + let (tx, rx) = std::sync::mpsc::channel(); + let (ptx, _prx) = std::sync::mpsc::channel(); + let ws = Arc::new(FairMutex::new(WindowSize { + num_lines: 10, + num_cols: 80, + cell_width: 8, + cell_height: 16, + })); + let proxy = EventProxy::new(1, ptx, UiSender::Channel(tx), ws); + let mut term = new_term(TermSize::new(80, 10), 100, proxy); + let mut parser: Processor = Processor::new(); + parser.advance(&mut term, bytes); + rx.try_iter().collect() + } + + /// 利用者は何も操作していない。画面に文字列が流れただけである。 + /// ここが開くと、仕掛けのあるファイルを `cat` するだけで写したものが出ていく。 + #[test] + fn 読み出し要求には答えない() { + let got = feed(b"\x1b]52;c;?\x07"); + assert!( + !got.iter() + .any(|e| matches!(e, UiEvent::ClipboardLoad(_, _))), + "クリップボードの読み出しに答えてしまった" + ); + } + + /// 書き込みは断らない。コンテナの中のエージェントが写す手段が他に無い。 + #[test] + fn 書き込みは受ける() { + let got = feed(b"\x1b]52;c;aGVsbG8=\x07"); + let stored = got.iter().find_map(|e| match e { + UiEvent::ClipboardStore(_, text) => Some(text.clone()), + _ => None, + }); + assert_eq!(stored.as_deref(), Some("hello")); + } +} + /// 全画面 UI の描き直しで選択が消えることを確かめる。 /// /// これが `State::picked` の理由である。