Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 54 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<secret>(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<secret>[^"\s,;]{8,})"?',
Expand Down Expand Up @@ -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
Expand Down
56 changes: 54 additions & 2 deletions docs/references/paste.md
Original file line number Diff line number Diff line change
@@ -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 を読んだ記録。
Expand Down Expand Up @@ -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 文字の英数字に目印が無く、
Expand Down
35 changes: 19 additions & 16 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Profile>,
}

/// 貼り付けるときの扱い。
/// 画面に出すときの扱い。
#[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` と名付けた組があれば、そこだけを伏せる。
Expand Down Expand Up @@ -79,7 +82,7 @@ fn default_redact() -> Vec<String> {
.collect()
}

impl Default for PasteConfig {
impl Default for ScreenConfig {
fn default() -> Self {
Self {
mask: default_mask(),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -830,16 +833,16 @@ blocked_lines = 12
}

#[test]
fn readme_の_paste_設定を読める() {
fn readme_の_screen_設定を読める() {
let toml = r#"
[paste]
[screen]
mask = true
redact = ['(?P<secret>AKIA[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 に載せた式と、実際に配る既定値がずれていないこと。
Expand All @@ -850,33 +853,33 @@ redact = ['(?P<secret>AKIA[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<String> = 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]
Expand Down
36 changes: 0 additions & 36 deletions src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,6 @@ pub enum Action {
ToggleSidebar,
Copy,
Paste,
/// 伏せずに、クリップボードのまま貼り付ける。
///
/// `[paste] mask` が効いていると、認証情報らしき値が伏せられる。
/// `aws configure` に本物を渡したいときの逃げ道。
PasteRaw,
/// 画面とスクロールバックを消し、プロンプトを出し直す。
ClearScreen,
/// 画面とスクロールバックの中を探す。
Expand Down Expand Up @@ -121,12 +116,6 @@ fn action_from_char(key: &Key, mods: ModifiersState) -> Option<Action> {
_ => 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() {
Expand Down Expand Up @@ -181,9 +170,6 @@ fn action_from_physical(physical: PhysicalKey, mods: ModifiersState) -> Option<A
_ => 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),
Expand Down Expand Up @@ -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 物理キーでも照合できる() {
// 文字が取れない配列でも、物理キーの位置で組み合わせが届く。
Expand Down
Loading
Loading