From 2ba5e401df772b1f853aedfbfa0b04fb9d25875f Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 17 Sep 2026 17:10:17 +0900 Subject: [PATCH] fix: state the OSC 52 policy instead of inheriting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checked whether anything else can lift the clipboard now that ⌘V redacts. OSC 52 has a read request as well as a write, and the read needs no keystroke: text arriving on the terminal is enough to trigger it, so `cat`ing a hostile file would be enough to send what you copied to whatever is running. termit was already closed. alacritty_terminal gates clipboard_load on config.osc52, and the default Osc52::OnlyCopy does not include reads, so the request never even reaches an event. Verified by feeding the sequence to a real Term: no ClipboardLoad arrives. Set it explicitly anyway. Inheriting the default means that if the default ever changes, termit starts answering clipboard reads without anyone deciding to. Writes stay allowed -- an agent inside a container has no other way to hand you something. Two tests next to the policy cover both halves, and reversing the value to CopyPaste makes the read test fail, so it has teeth. The README capability table now separates clipboard write from clipboard read, and the paste section says what redaction does not cover: a program that reads the clipboard itself never passes through it, which is what Claude Code's Ctrl+V image paste does through its own native clipboard module. ⌘V is seen, Ctrl+V is not. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 13 ++++++++- docs/references/paste.md | 35 ++++++++++++++++++++++++ src/term.rs | 57 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) 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` の理由である。