シェル出力の自動マスキングと TTY 保持(PTY プロキシ)
端末に出る前に秘匿値を書き換える手法は「子プロセスから見た isatty() を壊さないか」で評価が決まる。通常のパイプは TTY を失わせるので、全コマンドに効く汎用解は PTY プロキシだけになる。
調査時点: 2026-04。ツールの挙動や Issue の状態は当時のもの。
TTY はプログラムから見た端末の入出力先で、PTY(疑似端末)はそれをソフトウェアで提供する仕組み。PTY プロキシは、プログラムを疑似端末につないだまま入出力を中継する構成を指す。
評価軸
シェルやコマンドの標準出力から秘匿値(API キー・トークンなど)を自動で隠す仕組みは、次の 5 軸で比較できる。
| 軸 | 意味 |
|---|---|
| TTY 保持 | 子プロセスから見て isatty(1) が true のままか。false になると色・TUI・progress bar・対話入力が壊れる |
| 表示前フィルタ | 端末に届く前に内容を書き換えられるか。書き換え不可ならロギング専用 |
| exit code 保持 | ラップしたコマンドの終了コードをそのまま外へ返せるか |
| 全コマンド自動適用 | 個別コマンドに手で付けるのでなく、セッション全体に漏れなく効くか |
| 検出方式 | 既知値の一致(known-value)か、正規表現で未知値を拾う(pattern)か |
根本にあるのは Unix の仕様で、通常のパイプ(|)は PTY ではない。パイプを挟んだ子プロセスが isatty(1) を呼ぶと false が返る。ls --color=auto や grep --color=auto は色を消し、ncurses や Bubbletea 製の TUI は初期化に失敗するか空白画面になり、Ansible のように TTY 前提の CLI も動かなくなる。これを避ける手段は、子プロセスを PTY slave 上で起動することだけである。
手法の比較
| アプローチ | TTY 保持 | 表示前フィルタ | exit code | 全コマンド自動 | 実装難度 | 限界 |
|---|---|---|---|---|---|---|
| シェル関数ラッパー | × | ○ | △(PIPESTATUS) | × | 低 | コマンドごとに書く。パイプで TTY を失う |
| zsh preexec / precmd | 不適用 | × | — | × | — | stdout に触れない(実行前フック) |
| DEBUG トラップ / BASH_XTRACEFD | 不適用 | × | — | — | — | stdout フィルタ不可 |
| script / unbuffer | × | ○ | △ | × | 中 | 子は PTY を得るが、後段のパイプで最終 fd が非 TTY 化 |
| PTY プロキシ | ○ | ○ | ○ | ○ | 高 | TTY を保てる唯一の汎用手法 |
LD_PRELOAD で write() を横取り | ○ | ○ | ○ | △ | 高 | 静的リンク・Go バイナリ・syscall 直呼びに無効 |
| tmux pipe-pane | ○(非干渉) | × | — | △ | 低 | 出力の複製のみ。ロギング専用 |
| Warp 方式(描画レイヤー) | ○ | ○ | ○ | ○ | 極高 | ターミナルエミュレータ自作が前提 |
シェル関数ラッパー
.zshrc や .bashrc に docker() { command docker "$@" | mask; return ${PIPESTATUS[0]}; } のように書く方式。無限再帰を避けるため command で PATH 上の実体を呼ぶ。終了コードは PIPESTATUS[0] で取れるが、シグナル終了時は 128+N になり、パイプ閉鎖のタイミングで SIGPIPE が出ることもある。パイプを挟んだ時点で TTY が消えるので、色・TUI・対話プロンプトが壊れる。全コマンドにラッパーを書くのは現実的でなく、サブシェルからの直呼びはラッパーを素通りする。
実行前フック(preexec / DEBUG トラップ)
いずれも「コマンド実行の前にコードを走らせる」仕組みで、実行中の stdout を横取りする機能はない。zsh で exec 1> >(mask) を試みても exec | pipe 構文が動かず、coprocess 経由の回避策は zsh が coprocess を 1 つしか持てない制約と衝突する。動いたとしても TTY は失われる。BASH_XTRACEFD は set -x の出力先を変える変数で、stdout とは無関係。
script / unbuffer
unbuffer(expect 付属)や script -q -c "bash" /dev/null は子プロセスを PTY 上で起動するので、子から見た isatty() は true になり色や progress bar が残る。ただし出力を | mask に渡した時点で最終 fd が非 TTY になり、対話性は失われる。個別コマンドへの手動適用が前提で、全コマンド自動化には向かない。faketty は PTY を割り当てつつ stdout と stderr を分離する変種。
PTY プロキシ
マスクプロセスがユーザー端末(PTY1 slave)と内側の PTY2 の間に立ち、シェル自体を PTY2 slave 上で起動する。
[ユーザー端末 (PTY1 slave)] ↕ raw TTY[マスクプロセス: PTY1 を読み書き / PTY2 master でフィルタ] ↕ PTY master/slave[bash / zsh / コマンド (PTY2 slave, isatty()=true)]シェルから見ると isatty() は true のままで、色・TUI・対話入力が保たれる。Go なら creack/pty、Python なら pexpect の output_filter で組める。実装上の要点と既知の難所は次のとおり。
- PTY slave 上でシェルを起動する(
posix_openpt→grantpt→unlockpt→ptsname→exec。creack/ptyのpty.Start()が相当) - SIGWINCH を両方向に伝播する(外側のサイズ変更を
TIOCSWINSZで内側へ。pty.InheritSize) - stdin を raw モードにしてユーザー入力を透過する(
term.MakeRaw/term.Restore) - ANSI エスケープの断片化に備える。
read()のバッファ境界が\x1b[31mの途中で切れたり、秘匿値がエスケープをまたいで分割されたりするので、ストリームをバッファしながらマッチする - シグナル伝播。Ctrl+C は raw モードなら PTY 経由で自動的に届くが、一部は手動伝播が要る
- PTY 上では stdout と stderr が混ざる(分離したいなら faketty 的な工夫が要る)
- 全出力を中継するので大量出力でオーバーヘッドが出る
sudo/su/vimのようにフルスクリーン化や新 PTY を起動するコマンドとの相性は未検証領域が残る。macOS ではposix_openptの挙動差の報告がある
LD_PRELOAD で write() を横取り
write(fd, ...) を上書きし、fd == STDOUT_FILENO のときバッファをスキャンして置換する共有ライブラリを LD_PRELOAD で注入する。fd を変えないので TTY 判定に影響しない点が利点。一方、静的リンクされたバイナリ、CGO 無効でビルドされた Go バイナリ(docker / kubectl など、libc の write() を使わない)、syscall.Write() の直呼び、setuid バイナリには効かない。マルチスレッドではロックが要り、バイト境界の問題も残る。Go 製 CLI が多い現在の環境では適用範囲が狭い。
tmux pipe-pane
tmux pipe-pane -o 'cmd' はペインの出力を外部コマンドに複製する。表示と並行してコピーするだけで、表示前に書き換えることはできない。マスキングには使えず、ロギングや監査ログの用途になる。流れるのはペイン全体のストリーム(入力・プロンプト・制御文字を含む)なので、純粋な stdout を取り出すには後処理が要る。
Warp 方式(描画レイヤー)
Warp は PTY から読んだバイト列を内部グリッドに書き、描画レイヤーで正規表現マッチして表示文字列をマスク文字に置換する。
PTY master(生出力) → パース → グリッド(内部バッファ) → 描画レイヤー(マスキング)子プロセスは通常の PTY slave に書くだけなので TTY を一切壊さない。設計としては最も安全だが、ターミナルエミュレータそのものを作らない限り再現できない。「子プロセスは通常の PTY に繋いだまま、表示前にフィルタする」という思想は PTY プロキシにも応用できる。
関連
更新履歴
- 2026-04-16: 初版(ローカル調査ノート)
- 2026-10-17: Wiki へ移植、概念ページとして再構成。冒頭に定義を追加
出典
- https://www.1password.community/discussions/developers/op-run-changes-stdout-and-stderr-to-not-be-ttys-when-masking/26040
- https://github.com/creack/pty
- https://pexpect.readthedocs.io/en/stable/_modules/pexpect/pty_spawn.html
- https://www.warp.dev/blog/the-data-structure-behind-terminals
- https://linux.die.net/man/1/unbuffer
- https://github.com/dtolnay/faketty
- https://zsh-users.zsh.narkive.com/9qYxg2Zb/redirecting-shell-output-to-a-pipe