From b46b0c2334d1d10a453c31c0a439c7894b755d2f Mon Sep 17 00:00:00 2001 From: tkc Date: Fri, 18 Sep 2026 08:54:22 +0900 Subject: [PATCH 1/2] docs: note that a sleeping Mac stops an agent waiting on a reset Claude Code resumes its task by itself when a usage limit resets, but only while its session is alive, which makes leaving termit open the thing that gets work done overnight. A sleeping Mac freezes every process, so the reset passes and nothing happens -- and none of the assertions already on this machine (coreaudiod, sharingd, powerd) hold PreventSystemSleep, so the default state is not enough. `caffeinate -is termit` covers it, and releases both assertions when termit exits. Verified: PreventSystemSleep goes 0 -> 1 while it runs and back to 0 after. The two limits are stated, because both are easy to trip over: -s applies only on AC power, per its man page, and neither flag stops a laptop sleeping when the lid closes. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 69cdeb6..21e8c3f 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,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. From 134804b66a85c41a1d08e9b5cec79db4c18edd79 Mon Sep 17 00:00:00 2001 From: tkc Date: Fri, 25 Sep 2026 15:44:12 +0900 Subject: [PATCH 2/2] fix: mask credentials on screen, not on paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paste-time redaction shipped in #34/#35 solved the wrong problem. The requirement is that a credential still works and simply is not visible, not that it never reaches the program. Redacting the paste turned `export AWS_SECRET_ACCESS_KEY=wJalr...` into `export AWS_SECRET_ACCESS_KEY=[redacted]`, so the shell assigned the literal string and the failure surfaced much later, in an AWS call far from its cause. Masking now happens while drawing. The grid keeps the real text and the pty gets the real bytes, so a pasted export works exactly as typed and ⌘C copies the real value; what disappears is the credential being visible in a screenshot, a screen share, or scrollback an hour later. Redactor::spans returns byte ranges, masked_cells maps them to columns, and the draw loop substitutes a bullet per cell so column alignment -- and any full-screen UI drawn on top of it -- does not shift. The byte-to-column mapping is the part that can silently go wrong, so a test masks a line with double-width characters ahead of the secret and asserts the bullets land on the key and nowhere else. Costs 0.041ms median for a full 47x163 screen with a credential every eighth line, against roughly 1ms to build a frame. An ignored test re-measures it. `[paste]` becomes `[screen]`, since the table now describes what is drawn. ⌥⌘V and Action::PasteRaw are gone: with paste left alone there is nothing to escape from. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 72 +++++------ docs/references/paste.md | 56 ++++++++- src/config.rs | 35 +++--- src/input.rs | 36 ------ src/main.rs | 256 ++++++++++++++++++++++++++++++++++----- src/probe.rs | 1 + src/secret.rs | 143 ++++++++++++++-------- 7 files changed, 422 insertions(+), 177 deletions(-) diff --git a/README.md b/README.md index 21e8c3f..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 @@ -402,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,})"?', @@ -488,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}"); } }