diff --git a/README.md b/README.md index 69cdeb6..f7d62f2 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,7 @@ takes nothing, because neither shells nor agents use Cmd. | `⌘[` / `⌘]` | Select the previous / next session | | `⌘1`…`⌘9`, then `⌘A` `⌘G` `⌘J` `⌘L` `⌘O` `⌘P` `⌘S` `⌘T` `⌘U` `⌘X` `⌘Y` `⌘Z` | Jump to that session | | `⌘W` | Close the session (stops it if it is still running) | -| `⌘C` / `⌘V` | Copy / paste (paste redacts credentials — see below) | -| `⌥⌘V` | Paste unchanged, without redacting | +| `⌘C` / `⌘V` | Copy / paste | | `⌘=` / `⌘-` | Font size | | `Shift+PageUp` / `PageDown` | Scroll a page | | Wheel / two fingers | Scroll the scrollback; hold `Shift` to keep it from the program | @@ -149,49 +148,44 @@ know what any agent looks like — it matches the phrases and title characters listed under `[agent]` in your config, and you can change them when an agent's UI changes. -**Pasting credentials.** `⌘V` scans the clipboard and replaces anything that -looks like a cloud credential with `[redacted]` before it reaches the session. -This is aimed at one accident: you copy a block of logs, JSON or `~/.aws/credentials` -to ask an agent about it, and a live key rides along into the model's context. -The surrounding text is kept, so the agent still sees what you meant to show it: +**Credentials on screen.** Anything on screen that looks like a cloud +credential is drawn as `••••`: ``` -aws_secret_access_key = [redacted] -"private_key": "[redacted]" +export AWS_SECRET_ACCESS_KEY=•••••••••••••••••••••••••••••••••••••••• +"private_key": "••••••••••••••••••••" ``` -The bottom bar says `pasted with 2 secrets redacted — ⌥⌘V pastes it unchanged`, -so it never happens silently, and `⌥⌘V` gives you the real thing when you -actually want it — typing a key into `aws configure`, say. `⌘C` is untouched: -copying out of termit gives you exactly what is on screen. +The masking is **display only**. The grid keeps the real text and the pty +receives the real bytes, so an `export` you paste at a prompt works exactly as +typed, `⌘C` copies the real value, and a program reading its own output is +unaffected. What it takes away is the credential being *visible* — in a +screenshot, a screen share, over your shoulder, or when you scroll back an hour +later and find it still sitting there. -What counts as a credential lives in `[agent]`'s neighbour `[paste]` in your -config, not in the binary. The defaults cover AWS access key IDs; any +The same width is kept, one bullet per cell, so a full-screen UI's alignment +does not shift. + +What counts as a credential lives in `[screen]` in your config, not in the +binary. The defaults cover AWS access key IDs; any `AWS_`/`GOOGLE_`/`GCP_`/`GCLOUD_`/`AZURE_` variable whose name carries SECRET, KEY, TOKEN, PASSWORD or CREDENTIAL; AWS secret keys and session tokens in `aws sts` JSON; PEM private keys, whole; and Google API keys, OAuth tokens and -client secrets. +client secrets. Plain cloud settings are left alone on purpose — +`AWS_REGION=us-east-1` stays readable, because a masked region helps nobody. -Plain cloud settings survive on purpose — `AWS_REGION=us-east-1` and -`AWS_PROFILE=default` reach the agent unchanged, because an agent that cannot -see your region cannot answer the question you pasted. Two limits worth knowing: +Three limits worth knowing: - **A bare AWS secret key cannot be detected.** It is 40 characters of base64 - with no marker; a rule that catches it also catches passwords, hashes and - git SHAs. It is caught when it appears next to its name, which is how it - arrives in a credentials file or an API response. + with no marker; a rule that catches it also catches passwords, hashes and git + SHAs. It is caught when it appears next to its name, which is how it arrives + in a credentials file or an API response. Claude Code's own detector has the + same limitation for the same reason. +- **Masking is not confidentiality.** The bytes are still in the grid, in the + scrollback, and in the command history database. Someone with your machine + can read them; someone looking at your screen cannot. - 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. + defaults. They would fire on ordinary code and turn it into bullets. **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 @@ -321,6 +315,27 @@ disappeared falls back to your home directory. Turn it off with `restore_sessions = false`. +## Leaving it running + +Agents spend much of their time waiting — on a usage limit that resets in a few +hours, on a build, on a long test run. Claude Code picks its task back up by +itself when the limit resets, but only while its session is still alive, so the +useful thing is simply to leave termit open. + +A sleeping Mac freezes every process, so the reset comes and goes and nothing +happens. Launch under `caffeinate` and the machine stays awake exactly as long +as termit runs: + +```sh +caffeinate -is termit +``` + +`-i` prevents idle sleep, `-s` prevents system sleep, and both are released +when termit exits — nothing is left holding the machine awake. Two conditions +worth knowing: `-s` applies only on AC power, and neither flag stops a laptop +from sleeping when you close the lid. If you want the lid shut, run the work +somewhere that is not your machine. + ## Sandbox profiles A profile says where a pane runs. `host` is the default and runs directly. @@ -381,12 +396,12 @@ restore_sessions = true # rebuild the session list on the next start program = "/bin/zsh" args = ["-l"] -[paste] -# Redact credentials on ⌘V. ⌥⌘V always pastes unchanged. +[screen] +# Mask credentials on screen. Display only: the pty still gets the real bytes. mask = true -# Each rule is a regex. The part named `secret` is what gets replaced, so the -# name and the quotes around it survive; a rule with no `secret` group replaces -# the whole match. A broken regex is reported at startup, not at paste time. +# Each rule is a regex. The part named `secret` is what gets masked, so the name +# and the quotes around it stay readable; a rule with no `secret` group masks the +# whole match. A broken regex is reported at startup. redact = [ '\b(?P(A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b', '(?i)\b(?:AWS|GOOGLE|GCP|GCLOUD|AZURE)_\w*(?:SECRET|KEY|TOKEN|PASSWORD|CREDENTIAL)\w*\s*[=:]\s*"?(?P[^"\s,;]{8,})"?', @@ -467,7 +482,7 @@ The detailed design record is in Japanese. - [`docs/superpowers/specs/2026-09-08-agent-terminal-design.md`](docs/superpowers/specs/2026-09-08-agent-terminal-design.md) — the specification - [`docs/performance.md`](docs/performance.md) — where the time actually goes, measured - [`docs/references/performance-techniques.md`](docs/references/performance-techniques.md) — techniques taken from other terminals, each marked adopted, rejected with the measurement, or still open -- [`docs/references/paste.md`](docs/references/paste.md) — what iTerm2 does at the paste boundary, and which half of it termit took +- [`docs/references/paste.md`](docs/references/paste.md) — where to mask a credential: why it moved from the paste boundary to the draw path, and what iTerm2 and Claude Code do at each - [`docs/references/agent-state.md`](docs/references/agent-state.md) — how other tools tell a working agent from one that is waiting for you, and which parts of that termit adopted - [`docs/references/sandbox.md`](docs/references/sandbox.md) — how agents are sandboxed elsewhere, what Apple's `container` measured at, and what termit deliberately leaves outside - [`docs/references/scrollback.md`](docs/references/scrollback.md) — how five other implementations handle scrollback, and which parts were copied diff --git a/docs/references/paste.md b/docs/references/paste.md index bf9f92e..72bf7a6 100644 --- a/docs/references/paste.md +++ b/docs/references/paste.md @@ -1,6 +1,15 @@ -# 貼り付け口で何をするか +# 認証情報を伏せる場所 -作成日:2026-09-17 +作成日:2026-09-17(2026-09-25 に方針を改めた) + +> **2026-09-25 の訂正。** 当初は「貼り付けるときに伏せる」形で作ったが、 +> **要件の取り違えだった**。求められていたのは +> 「エージェントに渡さない」ではなく「**渡すが、画面に出さない**」である。 +> 貼り付けで伏せると `export AWS_SECRET_ACCESS_KEY=…` が +> `export AWS_SECRET_ACCESS_KEY=[redacted]` になり、**シェルが壊れた値を受け取る**。 +> しかも失敗するのは後で AWS CLI を叩いたときなので、原因から遠い。 +> いまは**描画のときだけ**伏せる。PTY へは本物が流れる。 +> 以下の iTerm2 の調査は、貼り付け口を調べた当時の記録として残す。 「クラウドの認証情報をエージェントに貼ってしまう」事故を防ぎたい、という求めに対し、 貼り付け口を一番作り込んでいる iTerm2 を読んだ記録。 @@ -172,6 +181,49 @@ termit は `Ctrl+V` を割り当てていないので `0x16` がそのまま渡 なお Claude Code は OSC 52 の読み出しを要求しない(要求 `52;c;?` の出現数 0 で確認)。 +## 描画で伏せる(2026-09-25) + +### なぜ場所を変えたか + +守りたいものが「モデルに渡さないこと」ではなく「**画面に出さないこと**」だった。 +スクリーンショット、画面共有、肩越しの視線、1 時間後に遡った画面 —— +秘密が残るのはそこである。値そのものは動いてもらわないと困る。 + +| | 貼り付けで伏せる(誤り) | 描画で伏せる(いま) | +|---|---|---| +| PTY へ渡る値 | `[redacted]`(壊れる) | **本物**(そのまま動く) | +| 画面 | 本物が出る | **伏せる** | +| ⌘C | 伏せた値 | **本物** | + +### 作り + +`Redactor::spans` が、行の文字列に対して**伏せる範囲をバイト位置で**返す。 +描く側(`masked_cells`)が行ごとに文字を組み立て、バイト位置を桁へ直し、 +当たったコマを覚える。描くときだけ `•` に差し替える。 +**グリッドの中身は本物のまま**なので、⌘C も、プログラム自身の再描画も影響を受けない。 + +要は**バイト位置と桁のずれ**である。全角が前にあると両者は一致しない。 +`鍵は AKIA…` で確かめる試験を置いてある。 + +同じ幅を保つ(1 コマ 1 個の丸)。桁がずれると全画面 UI の枠線が崩れる。 + +### 費用 + +47 行 × 163 桁、8 行に 1 本認証情報のある画面で、**中央 0.041ms**(p90 0.043ms)。 +フレームの組み立て全体が約 1ms なので 4% ほど増える。 +`tests::伏せる位置::計測_伏せる位置の費用` で測り直せる。 + +### 確信度は high だけ + +Claude Code の `redactForDisplay` が同じ判断をしている。 +**人が読む画面では、誤検知のほうが害**だからである。 +`password|token|cookie` まで拾う広い規則は、画面を丸だらけにする。 + +### これは秘匿ではない + +バイトはグリッドにも、スクロールバックにも、コマンド履歴のデータベースにも残る。 +**機械を触れる人には読める。画面を見ている人には読めない。** それだけである。 + ## 限界(利用者に伝えるべきこと) **裸で貼った AWS のシークレットキーは捕まらない。** 40 文字の英数字に目印が無く、 diff --git a/src/config.rs b/src/config.rs index 538c2b2..9f89b24 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,16 +19,19 @@ pub struct Config { #[serde(default)] pub agent: AgentConfig, #[serde(default)] - pub paste: PasteConfig, + pub screen: ScreenConfig, #[serde(default)] pub profile: BTreeMap, } -/// 貼り付けるときの扱い。 +/// 画面に出すときの扱い。 #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] -pub struct PasteConfig { - /// 認証情報らしき値を伏せてから貼り付けるか。 +pub struct ScreenConfig { + /// 認証情報らしき値を、画面の上で伏せるか。 + /// + /// 伏せるのは見た目だけである。グリッドの中身も PTY へ流す値も本物のままなので、 + /// `export AWS_SECRET_ACCESS_KEY=…` を貼れば普通に効く。 #[serde(default = "default_mask")] pub mask: bool, /// 伏せる場所を指す式。`secret` と名付けた組があれば、そこだけを伏せる。 @@ -79,7 +82,7 @@ fn default_redact() -> Vec { .collect() } -impl Default for PasteConfig { +impl Default for ScreenConfig { fn default() -> Self { Self { mask: default_mask(), @@ -386,7 +389,7 @@ impl Config { } } } - if let Err(e) = crate::secret::Redactor::new(&self.paste.redact) { + if let Err(e) = crate::secret::Redactor::new(&self.screen.redact) { return Err(ConfigError::Invalid(e.to_string())); } if self.agent.blocked_lines == 0 || self.agent.blocked_lines > 200 { @@ -830,16 +833,16 @@ blocked_lines = 12 } #[test] - fn readme_の_paste_設定を読める() { + fn readme_の_screen_設定を読める() { let toml = r#" -[paste] +[screen] mask = true redact = ['(?PAKIA[0-9A-Z]{16})'] "#; let c: Config = toml::from_str(toml).unwrap(); c.validate().unwrap(); - assert!(c.paste.mask); - assert_eq!(c.paste.redact.len(), 1); + assert!(c.screen.mask); + assert_eq!(c.screen.redact.len(), 1); } /// README に載せた式と、実際に配る既定値がずれていないこと。 @@ -850,33 +853,33 @@ redact = ['(?PAKIA[0-9A-Z]{16})'] fn readme_の式は既定値と同じ() { let readme = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md")) .expect("README を読める"); - // 本文にも `[paste]` と書いてあるので、行として独立したものだけを拾う。 + // 本文にも `[screen]` と書いてあるので、行として独立したものだけを拾う。 let block = readme - .split("\n[paste]\n") + .split("\n[screen]\n") .nth(1) .and_then(|s| s.split_once("redact = [")) // 式の中にも `]` が出るので、行頭の `]` を表の終わりとする。 .map(|(_, rest)| rest.split_once("\n]").expect("表が閉じている").0) - .expect("README に [paste] の例がある"); + .expect("README に [screen] の例がある"); let listed: Vec = block .lines() .map(str::trim) .filter(|l| l.starts_with('\'')) .map(|l| l.trim_end_matches(',').trim_matches('\'').to_string()) .collect(); - assert_eq!(listed, PasteConfig::default().redact); + assert_eq!(listed, ScreenConfig::default().redact); } /// 壊れた式は起動時に断る。貼り付けてから気づくのでは遅い。 #[test] fn 壊れた式のある設定を拒む() { let toml = r#" -[paste] +[screen] redact = ["[unclosed"] "#; let c: Config = toml::from_str(toml).unwrap(); let e = c.validate().unwrap_err(); - assert!(format!("{e}").contains("paste.redact[0]"), "{e}"); + assert!(format!("{e}").contains("screen.redact[0]"), "{e}"); } #[test] diff --git a/src/input.rs b/src/input.rs index 3b1ee5f..06bf9fe 100644 --- a/src/input.rs +++ b/src/input.rs @@ -20,11 +20,6 @@ pub enum Action { ToggleSidebar, Copy, Paste, - /// 伏せずに、クリップボードのまま貼り付ける。 - /// - /// `[paste] mask` が効いていると、認証情報らしき値が伏せられる。 - /// `aws configure` に本物を渡したいときの逃げ道。 - PasteRaw, /// 画面とスクロールバックを消し、プロンプトを出し直す。 ClearScreen, /// 画面とスクロールバックの中を探す。 @@ -121,12 +116,6 @@ fn action_from_char(key: &Key, mods: ModifiersState) -> Option { _ => None, }; } - // ⌥⌘ の枝。伏せずに貼るためだけに使う。 - // iTerm2 が Advanced Paste に使っている枠で、Shift を使わないので - // 「Shift の同時押しが届かない」環境でも通る。 - if mods.super_key() && mods.alt_key() && !mods.control_key() { - return (c.as_str() == "v").then_some(Action::PasteRaw); - } // Cmd 側。Shift を併用する組み合わせは ⌘⇧R だけに限る。 if mods.super_key() && !mods.control_key() && !mods.alt_key() { if mods.shift_key() { @@ -181,9 +170,6 @@ fn action_from_physical(physical: PhysicalKey, mods: ModifiersState) -> Option None, }; } - if mods.super_key() && mods.alt_key() && !mods.control_key() { - return (code == KeyCode::KeyV).then_some(Action::PasteRaw); - } if mods.super_key() && !mods.control_key() && !mods.alt_key() { let command = match code { KeyCode::KeyN => Some(Action::NewSession), @@ -512,28 +498,6 @@ mod tests { assert_eq!(action_for(&ch("["), phys, cmd), Some(Action::SelectPrev)); } - /// ⌥⌘V は伏せずに貼る。Shift を使わないので、届かない環境の心配がない。 - #[test] - fn 伏せずに貼る組み合わせを受ける() { - let phys = PhysicalKey::Code(KeyCode::KeyV); - let alt_cmd = ModifiersState::SUPER | ModifiersState::ALT; - assert_eq!(action_for(&ch("v"), phys, alt_cmd), Some(Action::PasteRaw)); - // ⌥ を離せば、ふだんの貼り付け(伏せるほう)に戻る。 - assert_eq!( - action_for(&ch("v"), phys, ModifiersState::SUPER), - Some(Action::Paste) - ); - // 文字が取れない配列でも物理キーで通る。 - assert_eq!( - action_for(&Key::Dead(None), phys, alt_cmd), - Some(Action::PasteRaw) - ); - // ⌥⌘ に別のキーを足しても、何も起こさない。 - // ⌘C(写す)が ⌥ を足したせいで別の意味になる、ということがない。 - let phys_c = PhysicalKey::Code(KeyCode::KeyC); - assert_eq!(action_for(&ch("c"), phys_c, alt_cmd), None); - } - #[test] fn 物理キーでも照合できる() { // 文字が取れない配列でも、物理キーの位置で組み合わせが届く。 diff --git a/src/main.rs b/src/main.rs index e614486..8cb70ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -127,10 +127,10 @@ fn main() { } }; - // 貼り付けで伏せる式は、ここで 1 度だけ組み立てる。 + // 画面で伏せる式は、ここで 1 度だけ組み立てる。 // 設定の検査も同じものを通しているので、ここまで来れば必ず成功する。 - let redactor = match secret::Redactor::new(&config.paste.redact) { - Ok(r) if config.paste.mask => r, + let redactor = match secret::Redactor::new(&config.screen.redact) { + Ok(r) if config.screen.mask => r, // 伏せない設定なら、式を持たない。判定そのものが走らなくなる。 Ok(_) => secret::Redactor::default(), Err(e) => { @@ -156,7 +156,7 @@ fn main() { .then(Counters::default), resize_quiet_since: None, agent_state_at: None, - redactor, + redactor_seed: redactor, }; if let Err(e) = event_loop.run_app(&mut app) { eprintln!("termit: {e}"); @@ -304,6 +304,8 @@ pub(crate) struct State { needs_redraw: bool, /// 窓が完全に隠れているか。隠れているあいだは組み立てもしない。 occluded: bool, + /// 画面で伏せる式。起動時に 1 度だけ組み立てる。 + pub(crate) redactor: crate::secret::Redactor, /// セッションの並びが変わった。少し置いてから書き出す。 state_dirty: bool, /// 最後に書き出した時刻。 @@ -432,8 +434,8 @@ struct App { resize_quiet_since: Option, /// 返事待ちを最後に調べた時刻。毎フレーム画面を読まないための間隔。 agent_state_at: Option, - /// 貼り付けで伏せる式。起動時に 1 度だけ組み立てる。 - redactor: crate::secret::Redactor, + /// 画面を作るときに State へ渡す式。組み立ては起動時に済ませてある。 + redactor_seed: crate::secret::Redactor, } /// 画面の割り付け。すべてセル単位で扱う。 @@ -521,6 +523,7 @@ impl ApplicationHandler for App { pending_since: None, needs_redraw: false, occluded: false, + redactor: std::mem::take(&mut self.redactor_seed), state_dirty: false, state_saved_at: None, shown_title: String::new(), @@ -1033,27 +1036,11 @@ impl App { } } Action::Paste => { - if let Some(text) = clipboard::paste() { - // 認証情報らしき値を伏せてから渡す。伏せたことは必ず出す。 - // 黙って書き換えると、貼ったものが違う理由が分からない。 - // 伏せない設定なら、大きなクリップボードを写し取らない。 - let (text, hits) = if self.redactor.is_empty() { - (text, 0) - } else { - self.redactor.redact(&text) - }; - if let Some(s) = state.manager.selected() { - let mode = *s.term.lock().mode(); - s.pty.write(bracketed(&text, mode)); - } - state.status = (hits > 0).then(|| redacted_notice(hits)); - } - } - Action::PasteRaw => { + // 貼り付けは素通しである。認証情報は画面の上で伏せるだけで、 + // PTY へは本物が流れる。`export AWS_SECRET_ACCESS_KEY=…` は普通に効く。 if let (Some(s), Some(text)) = (state.manager.selected(), clipboard::paste()) { let mode = *s.term.lock().mode(); s.pty.write(bracketed(&text, mode)); - state.status = None; } } Action::ClearScreen => { @@ -1206,6 +1193,59 @@ fn hyperlink_at(state: &mut State, layout: &Layout) -> Option { }) } +/// 画面の上で伏せる文字。丸 1 つで 1 コマ。 +/// +/// 同じ幅のものに置き換える。桁がずれると、全画面 UI の枠線が崩れる。 +const MASK_CHAR: char = '\u{2022}'; + +/// 画面のうち、伏せるコマの位置を返す。鍵は履歴を含む行番号と桁。 +/// +/// 行ごとに文字を組み立てて式に当て、当たったバイトの範囲を桁へ直す。 +/// 認証情報は「名前と値」の形で行として現れるので、1 コマずつでは判定できない。 +fn masked_cells( + term: &alacritty_terminal::Term, + display_offset: usize, + rows: usize, + cols: usize, + redactor: &crate::secret::Redactor, +) -> std::collections::HashSet<(i32, usize)> { + use alacritty_terminal::grid::Dimensions; + let mut out = std::collections::HashSet::new(); + if redactor.is_empty() { + return out; + } + let grid = term.grid(); + let cols = grid.columns().min(cols); + // 見えている行だけを見る。遡っていれば、その位置の行になる。 + let first = -(display_offset as i32); + for row in 0..rows.min(grid.screen_lines()) { + let line = first + row as i32; + let grid_line = alacritty_terminal::index::Line(line); + let mut text = String::with_capacity(cols); + // バイトの位置から桁へ戻すための対応表。 + let mut at: Vec = Vec::with_capacity(cols + 1); + for col in 0..cols { + let cell = &grid[grid_line][alacritty_terminal::index::Column(col)]; + if cell.flags.contains(Flags::WIDE_CHAR_SPACER) { + continue; + } + for _ in 0..cell.c.len_utf8() { + at.push(col); + } + text.push(cell.c); + } + at.push(cols); + for span in redactor.spans(&text) { + let from = at.get(span.start).copied().unwrap_or(cols); + let to = at.get(span.end).copied().unwrap_or(cols); + for col in from..to.min(cols) { + out.insert((line, col)); + } + } + } + out +} + /// 表示行を画面の何行目かに直す。範囲の外なら `None`。 /// /// `display_iter` が返す行番号は履歴を含む座標で、 @@ -2061,12 +2101,6 @@ mod sidebar { /// 文字を使う。全画面 UI は選んだ行を書き直した時点で選択を捨てるため、 /// これが無いと、選べているのに何も写らないという形になる。 /// 控えは持ち主のセッションでだけ使う。 -/// 伏せたことを知らせる文。⌥⌘V が逃げ道であることも併せて出す。 -fn redacted_notice(hits: usize) -> String { - let what = if hits == 1 { "secret" } else { "secrets" }; - format!("pasted with {hits} {what} redacted — ⌥⌘V pastes it unchanged") -} - fn copy_text( live: Option, picked: Option<&(crate::session::SessionId, String)>, @@ -2535,6 +2569,15 @@ pub(crate) fn draw_terminal(state: &mut State, layout: &Layout, theme: &Theme) { let mut drawn_rows = std::collections::HashSet::new(); // 指しているリンクは、押せることが分かるよう下線を引く。 let hovered = state.hovered_link.clone(); + // 伏せるコマを先に出しておく。1 コマずつ式に当てることはできない。 + // 認証情報は「名前と値」のように行として現れるので、行ごとに見る。 + let masked = masked_cells( + &term, + display_offset, + layout.term_rows, + layout.term_cols, + &state.redactor, + ); for indexed in content.display_iter { let cell = indexed.cell; @@ -2595,6 +2638,13 @@ pub(crate) fn draw_terminal(state: &mut State, layout: &Layout, theme: &Theme) { continue; } drawn_rows.insert(row); + // 伏せるコマは、同じ幅の丸に置き換える。桁がずれると全画面 UI が崩れる。 + // 置き換えるのは描くものだけで、グリッドの中身は本物のままである。 + let c = if masked.contains(&(line, col)) { + MASK_CHAR + } else { + cell.c + }; let x = layout.term_col + col; let span = if wide { 2 } else { 1 }; let span = span.min(layout.term_cols.saturating_sub(col)).max(1); @@ -2604,7 +2654,7 @@ pub(crate) fn draw_terminal(state: &mut State, layout: &Layout, theme: &Theme) { state.renderer.put_char( x, row, - cell.c, + c, fg, cell.flags.contains(Flags::BOLD), cell.flags.contains(Flags::ITALIC), @@ -2964,6 +3014,150 @@ mod tests { std::time::Instant::now() } + /// 画面の上で伏せるコマを、行から正しく割り出せること。 + /// + /// バイトの位置を桁へ戻すところが要である。全角が混ざると両者はずれる。 + mod 伏せる位置 { + use super::*; + use alacritty_terminal::grid::Dimensions; + use alacritty_terminal::index::{Column, Line as GLine}; + use alacritty_terminal::vte::ansi::Processor; + use std::sync::mpsc::channel; + + fn term_of(script: &[u8]) -> alacritty_terminal::Term { + let (tx, _rx) = channel(); + let (ptx, _prx) = channel(); + let ws = std::sync::Arc::new(alacritty_terminal::sync::FairMutex::new( + alacritty_terminal::event::WindowSize { + num_lines: 6, + num_cols: 80, + cell_width: 8, + cell_height: 16, + }, + )); + let proxy = + crate::term::EventProxy::new(1, ptx, crate::term::UiSender::Channel(tx), ws); + let mut term = crate::term::new_term(crate::term::TermSize::new(80, 6), 50, proxy); + let mut parser: Processor = Processor::new(); + parser.advance(&mut term, script); + term + } + + /// 伏せた結果を、目で読める 1 行にする。 + fn shown(script: &[u8]) -> String { + let term = term_of(script); + let r = crate::secret::Redactor::new(&crate::config::ScreenConfig::default().redact) + .expect("既定の式は組み立てられる"); + let masked = masked_cells(&term, 0, 6, 80, &r); + let grid = term.grid(); + let mut out = String::new(); + for col in 0..grid.columns() { + let cell = &grid[GLine(0)][Column(col)]; + // 全角の 2 桁目は詰め物である。読める形にするため飛ばす。 + if cell.flags.contains(Flags::WIDE_CHAR_SPACER) { + continue; + } + out.push(if masked.contains(&(0, col)) { + MASK_CHAR + } else { + cell.c + }); + } + out.trim_end().to_string() + } + + #[test] + #[ignore] + fn 計測_伏せる位置の費用() { + // 現実に近い画面:47 行 x 163 桁、ところどころに認証情報。 + let mut script = Vec::new(); + for i in 0..47 { + if i % 8 == 3 { + script.extend_from_slice( + b"export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + ); + } else { + script.extend_from_slice( + b" let mut out = String::with_capacity(text.len()); // fill the row with code", + ); + } + script.extend_from_slice(b"\r\n"); + } + let (tx, _rx) = channel(); + let (ptx, _prx) = channel(); + let ws = std::sync::Arc::new(alacritty_terminal::sync::FairMutex::new( + alacritty_terminal::event::WindowSize { + num_lines: 47, + num_cols: 163, + cell_width: 8, + cell_height: 17, + }, + )); + let proxy = + crate::term::EventProxy::new(1, ptx, crate::term::UiSender::Channel(tx), ws); + let mut term = crate::term::new_term(crate::term::TermSize::new(163, 47), 100, proxy); + let mut parser: Processor = Processor::new(); + parser.advance(&mut term, &script); + let r = crate::secret::Redactor::new(&crate::config::ScreenConfig::default().redact) + .unwrap(); + + for _ in 0..20 { + let _ = masked_cells(&term, 0, 47, 163, &r); + } + let mut ts = Vec::new(); + for _ in 0..100 { + let t = std::time::Instant::now(); + let m = masked_cells(&term, 0, 47, 163, &r); + ts.push(t.elapsed().as_secs_f64() * 1000.0); + assert!(!m.is_empty()); + } + ts.sort_by(|a, b| a.partial_cmp(b).unwrap()); + println!( + "47 行 x 163 桁: 中央 {:.3}ms p90 {:.3}ms 最大 {:.3}ms", + ts[50], ts[90], ts[99] + ); + } + + #[test] + fn 値だけを伏せ_名前は残す() { + let line = + shown(b"export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); + assert!(line.starts_with("export AWS_SECRET_ACCESS_KEY="), "{line}"); + assert!(!line.contains("wJalr"), "{line}"); + assert_eq!( + line.matches(MASK_CHAR).count(), + 40, + "値の桁数だけ伏せる: {line}" + ); + } + + /// 全角が前にあると、バイトの位置と桁がずれる。 + /// ずれたまま使うと、値ではなく別の場所を消してしまう。 + #[test] + fn 全角があっても位置がずれない() { + let line = shown("鍵は AKIAIOSFODNN7EXAMPLE です".as_bytes()); + assert!(line.starts_with("鍵は "), "{line}"); + assert!(line.ends_with(" です"), "{line}"); + assert!(!line.contains("AKIA"), "{line}"); + assert_eq!(line.matches(MASK_CHAR).count(), 20, "{line}"); + } + + #[test] + fn 秘密でない行は触らない() { + assert_eq!( + shown(b"export AWS_REGION=us-east-1"), + "export AWS_REGION=us-east-1" + ); + } + + #[test] + fn 式が無ければ何も伏せない() { + let term = term_of(b"AKIAIOSFODNN7EXAMPLE"); + let off = crate::secret::Redactor::default(); + assert!(masked_cells(&term, 0, 6, 80, &off).is_empty()); + } + } + #[test] fn 遡っていなければ行番号はそのまま() { assert_eq!(screen_row(0, 0, 24), Some(0)); diff --git a/src/probe.rs b/src/probe.rs index e120554..4cbaedc 100644 --- a/src/probe.rs +++ b/src/probe.rs @@ -134,6 +134,7 @@ pub fn run(out_path: &str) { pending_since: None, needs_redraw: false, occluded: false, + redactor: crate::secret::Redactor::default(), state_dirty: false, state_saved_at: None, shown_title: String::new(), diff --git a/src/secret.rs b/src/secret.rs index 755a687..69e1ea8 100644 --- a/src/secret.rs +++ b/src/secret.rs @@ -1,20 +1,23 @@ -//! 貼り付ける文字列から、認証情報らしき値を伏せる。 +//! 画面に出す文字列のうち、認証情報らしき値の位置を返す。 //! -//! 何を伏せるかは設定(`[paste] redact`)にある。ここにあるのは +//! 伏せるのは**見た目だけ**である。グリッドの中身も、PTY へ流れる値も +//! 本物のままなので、`export AWS_SECRET_ACCESS_KEY=…` を貼れば普通に効く。 +//! 肩越しの視線・画面共有・スクリーンショット・遡った画面から消えるだけである。 +//! +//! 何を伏せるかは設定(`[screen] redact`)にある。ここにあるのは //! 「式に当てはめて、`secret` と名付けた部分を置き換える」という手続きだけで、 //! AWS や Google の鍵の形は 1 つも書かれていない。相手の形が変われば設定を直す。 //! -//! iTerm2 も同じ考え方で、貼り付けに正規表現の置換を持たせている -//! (`iTermPasteHelper.m` の `sanitizePasteEvent:`)。違いは、あちらが -//! 道具だけを配るのに対し、termit は既定の式を持つことである。 -//! 道具だけ配っても、書く人がいなければ誰も守られない。 +//! Claude Code も同じことをしている。実行ファイルの中の `redactForDisplay` は、 +//! 確信度 `high` の規則だけを使う。人が読む画面では、誤検知のほうが害だからである +//! (出ていくデータには広い規則も併せて使う)。termit の既定の式もその `high` 相当に絞る。 + +use std::ops::Range; -use regex::{Captures, Regex}; +use regex::Regex; -/// 伏せた跡に置く文字列。 -/// -/// 読む側(エージェント)に「消されている」と分かる形にする。 -/// 伏せ字だけだと、その文字が値そのものだと解釈されることがある。 +/// 試験で伏せた跡に置く文字列。画面では `MASK_CHAR` を 1 コマずつ置く。 +#[cfg(test)] const REDACTED: &str = "[redacted]"; /// 設定の式をまとめて持つ。起動時に 1 度だけ組み立てる。 @@ -36,7 +39,7 @@ impl std::fmt::Display for BadPattern { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, - "paste.redact[{}] is not a valid regex: {}\n {}", + "screen.redact[{}] is not a valid regex: {}\n {}", self.index, self.pattern, self.message ) } @@ -66,46 +69,67 @@ impl Redactor { self.rules.is_empty() } - /// 伏せた文字列と、伏せた件数を返す。 + /// 伏せる範囲を、バイトの位置で返す。重なりは畳んで、前から順に並べる。 /// - /// 式に `secret` という名前の組があれば、**その部分だけ**を置き換える。 - /// 名前や引用符は残るので、貼り付けた先には形が伝わる。 - /// 組が無ければ、当たった全体を置き換える。 - pub fn redact(&self, text: &str) -> (String, usize) { - let mut out = text.to_string(); - let mut hits = 0usize; + /// 式に `secret` という名前の組があれば**その部分だけ**を指す。 + /// 名前や引用符は画面に残るので、何が伏せてあるのかは読み取れる。 + /// 組が無ければ、当たった全体を指す。 + pub fn spans(&self, text: &str) -> Vec> { + if self.rules.is_empty() || text.is_empty() { + return Vec::new(); + } + let mut out: Vec> = Vec::new(); for re in &self.rules { - out = re - .replace_all(&out, |caps: &Captures| { - let whole = caps.get(0).expect("当たり全体は必ずある"); - let (from, to) = match caps.name("secret") { - Some(m) => (m.start() - whole.start(), m.end() - whole.start()), - None => (0, whole.len()), - }; - let s = whole.as_str(); - // 既に伏せてあるものを数え直さない。式は重なることがあり - // (`AWS_SECRET…=AKIA…` は語頭の式と名前の式の両方に当たる)、 - // そのたびに数えると「2 件伏せた」と嘘の件数が出る。 - if &s[from..to] == REDACTED { - return s.to_string(); - } - hits += 1; - format!("{}{REDACTED}{}", &s[..from], &s[to..]) - }) - .into_owned(); + for caps in re.captures_iter(text) { + let m = match caps.name("secret") { + Some(m) => m, + None => caps.get(0).expect("当たり全体は必ずある"), + }; + if m.start() < m.end() { + out.push(m.start()..m.end()); + } + } + } + if out.len() > 1 { + // 式どうしは重なる(`AWS_SECRET…=AKIA…` は語頭の式にも変数名の式にも当たる)。 + // 畳んでおかないと、描く側が同じコマを二度見ることになる。 + out.sort_by_key(|r| (r.start, r.end)); + let mut merged: Vec> = Vec::with_capacity(out.len()); + for r in out { + match merged.last_mut() { + Some(last) if r.start <= last.end => last.end = last.end.max(r.end), + _ => merged.push(r), + } + } + return merged; + } + out + } + + /// 伏せた文字列を組み立てる。読みやすさのため、試験でだけ使う。 + #[cfg(test)] + pub fn redact(&self, text: &str) -> (String, usize) { + let spans = self.spans(text); + let mut out = String::with_capacity(text.len()); + let mut at = 0usize; + for r in &spans { + out.push_str(&text[at..r.start]); + out.push_str(REDACTED); + at = r.end; } - (out, hits) + out.push_str(&text[at..]); + (out, spans.len()) } } #[cfg(test)] mod tests { use super::*; - use crate::config::PasteConfig; + use crate::config::ScreenConfig; /// 既定の式で試す。設定の既定値そのものが正しいことを確かめたい。 fn default_redactor() -> Redactor { - Redactor::new(&PasteConfig::default().redact).expect("既定の式は組み立てられる") + Redactor::new(&ScreenConfig::default().redact).expect("既定の式は組み立てられる") } #[test] @@ -258,18 +282,6 @@ mod tests { assert_eq!(n, 1, "1 つの秘密は 1 件と数える"); } - /// 二度通しても結果が変わらず、二度目は 0 件であること。 - #[test] - fn 二度伏せても変わらない() { - let r = default_redactor(); - let (once, n1) = - r.redact("aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - let (twice, n2) = r.redact(&once); - assert_eq!(once, twice); - assert_eq!(n1, 1); - assert_eq!(n2, 0); - } - #[test] fn 式が無ければ素通りする() { let r = Redactor::new(&[]).unwrap(); @@ -281,6 +293,31 @@ mod tests { } /// `secret` の組が無い式は、当たり全体を伏せる。 + /// 重なった式は畳んでから返すこと。描く側が同じコマを二度見ない。 + #[test] + fn 重なった範囲を畳む() { + let r = default_redactor(); + // 語頭の式にも、変数名の式にも当たる行。 + let spans = r.spans("AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE"); + assert_eq!(spans.len(), 1, "{spans:?}"); + let s = &spans[0]; + assert_eq!( + &"AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE"[s.clone()], + "AKIAIOSFODNN7EXAMPLE" + ); + } + + /// 範囲は前から順に並ぶこと。描く側が走査しながら使える。 + #[test] + fn 範囲は前から順に並ぶ() { + let r = default_redactor(); + let text = "a AKIAIOSFODNN7EXAMPLE b AIzaSyD_abcdefghijklmnopqrstuvwxyz01234 c"; + let spans = r.spans(text); + assert_eq!(spans.len(), 2, "{spans:?}"); + assert!(spans[0].end <= spans[1].start); + assert_eq!(&text[spans[0].clone()], "AKIAIOSFODNN7EXAMPLE"); + } + #[test] fn 組の無い式は当たり全体を伏せる() { let r = Redactor::new(&[r"hunter2".to_string()]).unwrap(); @@ -294,6 +331,6 @@ mod tests { fn 壊れた式は場所を言って断る() { let e = Redactor::new(&["ok".to_string(), "[unclosed".to_string()]).unwrap_err(); assert_eq!(e.index, 1); - assert!(e.to_string().contains("paste.redact[1]"), "{e}"); + assert!(e.to_string().contains("screen.redact[1]"), "{e}"); } }