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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ description = "エージェント向けの軽量ターミナル"
[dependencies]
# 普通の文字のあいだを飛ばすため。OSC の走査に使う。
memchr = "2"
# 貼り付けで伏せる場所を指す式に使う。env_logger が既に連れてきているので、
# 直接使っても組み立ての費用は増えない。後戻りしない実装なので、
# 利用者の書いた式で端末が固まることがない。
regex = "1"
alacritty_terminal = "0.26"
portable-pty = "0.9"
libc = "0.2"
Expand Down
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ 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 |
| `⌘C` / `⌘V` | Copy / paste (paste redacts credentials — see below) |
| `⌥⌘V` | Paste unchanged, without redacting |
| `⌘=` / `⌘-` | Font size |
| `Shift+PageUp` / `PageDown` | Scroll a page |
| Wheel / two fingers | Scroll the scrollback; hold `Shift` to keep it from the program |
Expand All @@ -148,6 +149,34 @@ 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:

```
aws_secret_access_key = [redacted]
"private_key": "[redacted]"
```

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.

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, AWS secret
keys and session tokens (bare or in `aws sts` JSON), GCP service-account
private keys, and Google API keys and OAuth tokens. Two 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.
- 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.

**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
arguments. Paths that need it are quoted for the shell, so spaces and quotes
Expand Down Expand Up @@ -336,6 +365,20 @@ 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.
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.
redact = [
'\b(?P<secret>(AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16})\b',
'(?i)"(aws_secret_access_key|secretaccesskey|sessiontoken|private_key|client_secret)"\s*:\s*"(?P<secret>[^"]+)"',
'(?i)\b(aws_secret_access_key|aws_session_token|account_key)\b\s*[=:]\s*(?P<secret>[A-Za-z0-9/+=_.-]{16,})',
'\b(?P<secret>AIza[0-9A-Za-z_-]{20,})\b',
'\b(?P<secret>ya29\.[0-9A-Za-z_-]+)',
]

[agent]
# The dot turns green while the title starts with one of these, even when the
# program has stopped printing. Agents spin one of them while they think.
Expand Down Expand Up @@ -404,6 +447,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/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
86 changes: 86 additions & 0 deletions docs/references/paste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# 貼り付け口で何をするか

作成日:2026-09-17

「クラウドの認証情報をエージェントに貼ってしまう」事故を防ぎたい、という求めに対し、
貼り付け口を一番作り込んでいる iTerm2 を読んだ記録。

## iTerm2 の作り

`sources/Pasting/iTermPasteHelper.m` の `sanitizePasteEvent:` が中心にある。
**変換を一本の関数に順番に並べ、旗(`iTermPasteFlags`)で選ぶ**形である。

| 順 | 変換 | 旗 |
|---|---|---|
| 1 | 改行を消す/`\r` にそろえる | `RemovingNewlines` / `SanitizingNewlines` |
| 2 | Unicode の約物を ASCII に寄せる | `ConvertUnicodePunctuation` |
| 3 | 危険な制御コードを落とす | `RemovingUnsafeControlCodes` |
| 4 | タブを空白か `^V` に | `tabTransform` |
| 5 | シェルに解釈される字を退避 | `EscapeSpecialCharacters` |
| 6 | **正規表現で置換** | `UseRegexSubstitution` |
| 7 | base64 に包む | `Base64Encode` |

`PasteEvent` は `originalString`(元)と `string`(変換後)を別々に持つ。

読み取れることが 3 つある。

**端末が貼り付けを書き換えるのは、例外ではなく普通である。** 制御コードを落とすのも
改行をそろえるのも、貼り付け攻撃と事故を防ぐためで、ブラケット貼り付け(`?2004`)が
存在する理由と同じである。認証情報を伏せるのは、この並びに 1 つ足すことにあたる。

**秘密の検出規則は 1 つも積んでいない。** 代わりに `regex` と `substitution` という
**道具だけ**を出し、中身は利用者が Advanced Paste やキー割り当てで与える。

**Advanced Paste は ⌥⌘V である**(⌘⇧V ではない)。

## termit が取ったもの

**正規表現の置換。** 自前の照合器(名前つき/語頭の 2 種類)を考えていたが、やめた。
`regex 1.13.1` は `env_logger` 経由で既に依存木にあり(`cargo tree -i regex`)、
直接使っても組み立ての費用が増えない。Rust の `regex` は後戻りしない実装なので、
利用者の書いた式で端末が固まることがない(PCRE と違い ReDoS が原理的に無い)。
概念が 1 つで済み、gitleaks などの式をそのまま持ち込める。

**⌥⌘V。** 逃げ道(伏せずに貼る)をここに置いた。当初は ⌘⇧V を考えていたが、
このコードには「Shift の同時押しが届かない環境があるため ⌘⇧R の代わりに ⌘I も受ける」
という前例がある。⌥⌘V なら Shift を使わないので、その心配ごと消える。

**一本の変換の並び。** ⌘V は `伏せる → ブラケットに包む → PTY` になった。

## termit が変えたもの

**伏せる範囲は `secret` という名前の捕獲組で指す。** iTerm2 は「式+置換文字列」の組で、
`$1` などを使って書く。termit は式の中で `(?P<secret>…)` と印を付け、
**その部分だけ**を `[redacted]` に替える。名前や引用符が自動的に残るので、
置換文字列を書き間違えて文脈ごと消す、ということが起きない。

```toml
redact = ['(?i)"(private_key)"\s*:\s*"(?P<secret>[^"]+)"']
```

**既定の式を持つ。** iTerm2 は道具だけを配るが、termit はクラウドの認証情報に絞った
5 つの式を既定で持つ。道具だけ配っても、書く人がいなければ誰も守られない。
ただし式は設定側にあり、実行ファイルの中に「AWS の鍵の形」は無い。

## 取らなかったもの

**⌘C 側とプログラムからの書き込み(OSC 52)。** 利用者の貼り付けだけにした。
守りたいのが「エージェントに食わせない」ことだからで、⌘C を書き換えると
「画面の鍵を選んで写して使う」という正当な用途が黙って壊れる。

**Advanced Paste のような対話窓。** 変換の一覧・試し表示・履歴は、端末の仕事を超える。

## 限界(利用者に伝えるべきこと)

**裸で貼った AWS のシークレットキーは捕まらない。** 40 文字の英数字に目印が無く、
これを捕まえる式はパスワード・ハッシュ・base64・git の SHA を軒並み巻き込む。
gitleaks などの既存ツールも同じ理由で文脈(名前)に頼っている。
捕まるのは「名前とセットのとき」と「`AKIA` などの目印があるとき」である。

**`password` や `token` は既定に入れない。** エージェントに貼るコードの変数名に当たり、
貼った内容のほうが壊れる。守るために貼り付けを壊しては、機能を切られて終わる。

## 参考

- [gnachman/iTerm2](https://github.com/gnachman/iTerm2) — `sources/Pasting/iTermPasteHelper.m`、`sources/Pasting/PasteEvent.h`
- [gitleaks](https://github.com/gitleaks/gitleaks) — クラウドの認証情報の式の書き方
107 changes: 107 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,63 @@ pub struct Config {
#[serde(default)]
pub agent: AgentConfig,
#[serde(default)]
pub paste: PasteConfig,
#[serde(default)]
pub profile: BTreeMap<String, Profile>,
}

/// 貼り付けるときの扱い。
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PasteConfig {
/// 認証情報らしき値を伏せてから貼り付けるか。
#[serde(default = "default_mask")]
pub mask: bool,
/// 伏せる場所を指す式。`secret` と名付けた組があれば、そこだけを伏せる。
///
/// 何を伏せるかはここにしかない。termit の実行ファイルの中に
/// 「AWS の鍵の形」は無く、相手の形が変わればこの表を直す。
#[serde(default = "default_redact")]
pub redact: Vec<String>,
}

fn default_mask() -> bool {
true
}

/// 既定で伏せるもの。クラウドの認証情報に絞る。
///
/// `password` や `token` のような広い語は入れない。エージェントへ貼る
/// コードの中の変数名に当たってしまい、貼った内容のほうが壊れる。
fn default_redact() -> Vec<String> {
[
// AWS のアクセスキー ID(長期 AKIA、一時 ASIA ほか)。
r"\b(?P<secret>(AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16})\b",
// JSON の中の値。aws sts の出力と、GCP のサービスアカウントの鍵。
// 閉じ引用符まで取るので、鍵の中の改行(\n)も丸ごと伏せる。
r#"(?i)"(aws_secret_access_key|secretaccesskey|sessiontoken|private_key|client_secret)"\s*:\s*"(?P<secret>[^"]+)""#,
// 裸の値。~/.aws/credentials と export の形。
r"(?i)\b(aws_secret_access_key|aws_session_token|account_key)\b\s*[=:]\s*(?P<secret>[A-Za-z0-9/+=_.-]{16,})",
// Google の API キーと OAuth の合鍵。
// 長さは決め打ちにしない。実物は AIza に続けて 35 文字だが、
// そこが 1 文字違うだけで素通りするほうが危ない。
r"\b(?P<secret>AIza[0-9A-Za-z_-]{20,})\b",
r"\b(?P<secret>ya29\.[0-9A-Za-z_-]+)",
]
.iter()
.map(|s| s.to_string())
.collect()
}

impl Default for PasteConfig {
fn default() -> Self {
Self {
mask: default_mask(),
redact: default_redact(),
}
}
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WindowConfig {
Expand Down Expand Up @@ -320,6 +374,9 @@ impl Config {
}
}
}
if let Err(e) = crate::secret::Redactor::new(&self.paste.redact) {
return Err(ConfigError::Invalid(e.to_string()));
}
if self.agent.blocked_lines == 0 || self.agent.blocked_lines > 200 {
return Err(ConfigError::Invalid(format!(
"agent.blocked_lines must be between 1 and 200 (got {})",
Expand Down Expand Up @@ -760,6 +817,56 @@ blocked_lines = 12
assert!(c.agent.blocked_lines > 0);
}

#[test]
fn readme_の_paste_設定を読める() {
let toml = r#"
[paste]
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);
}

/// README に載せた式と、実際に配る既定値がずれていないこと。
///
/// 利用者はあれを写して自分の設定を作る。ずれていれば、
/// 書いてあるとおりにしたのに守られない、ということが起きる。
#[test]
fn readme_の式は既定値と同じ() {
let readme = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))
.expect("README を読める");
// 本文にも `[paste]` と書いてあるので、行として独立したものだけを拾う。
let block = readme
.split("\n[paste]\n")
.nth(1)
.and_then(|s| s.split_once("redact = ["))
// 式の中にも `]` が出るので、行頭の `]` を表の終わりとする。
.map(|(_, rest)| rest.split_once("\n]").expect("表が閉じている").0)
.expect("README に [paste] の例がある");
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);
}

/// 壊れた式は起動時に断る。貼り付けてから気づくのでは遅い。
#[test]
fn 壊れた式のある設定を拒む() {
let toml = r#"
[paste]
redact = ["[unclosed"]
"#;
let c: Config = toml::from_str(toml).unwrap();
let e = c.validate().unwrap_err();
assert!(format!("{e}").contains("paste.redact[0]"), "{e}");
}

#[test]
fn 待ちの行数が範囲外なら拒む() {
let toml = r#"
Expand Down
36 changes: 36 additions & 0 deletions src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ pub enum Action {
ToggleSidebar,
Copy,
Paste,
/// 伏せずに、クリップボードのまま貼り付ける。
///
/// `[paste] mask` が効いていると、認証情報らしき値が伏せられる。
/// `aws configure` に本物を渡したいときの逃げ道。
PasteRaw,
/// 画面とスクロールバックを消し、プロンプトを出し直す。
ClearScreen,
/// 画面とスクロールバックの中を探す。
Expand Down Expand Up @@ -116,6 +121,12 @@ 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 @@ -170,6 +181,9 @@ 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 @@ -498,6 +512,28 @@ 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