Match exec で人と AI エージェントの ssh 接続先を記録する
シェル履歴には接続先が残らない。~/.ssh/config の Match exec を使い、人と AI エージェントの ssh を同じ場所に記録する。
まとめ
- シェル履歴に残るのは打ったコマンドだけで、接続先をメニューで選ぶラッパー経由だと、どこへ入ったかが手元に残らない
~/.ssh/configのMatch execは、scp、rsync、git、Python の subprocess を含め、ssh コマンドを起動するものなら何から起動しても呼ばれる。記録はここに置くと取りこぼしが少ない- 環境変数で人と AI エージェントを分けて記録しておけば、AI が本番に入った回だけを翌朝に洗い出せる
何が困ったか
本番サーバーでファイル変更の検知が出て、「その時刻に作業した人はいるか」と聞かれた。 シェル履歴には、踏み台経由で接続するラッパーを起動した記録が 1 行だけあった。 ラッパーは接続先を fzf で選ぶ作りで、選んだホストはコマンドラインに出ない。 そのため履歴からは、本番に入ったのか staging に入ったのかが分からなかった。
結局、本番と staging の踏み台 3 台に入り、sshd のログを自分の鍵の指紋で探して、staging だったと確かめた。 同じ問いにまた答えるたびにこれをやるのは割に合わない。 加えて、AI エージェントにも ssh させているので、そちらの接続も同じように後から引ける必要があった。
どこで記録するか
表1: 選定フロー
| 手順 | 判断すること | 結果 |
|---|---|---|
| 1 | 人の ssh と AI の ssh を両方拾えるか | AI のコマンド hook は AI の分しか見ないので落ちる |
| 2 | ラッパーを通さない ssh(scp や rsync、スクリプトからの ssh)も拾えるか | ラッパーにログを書かせる方式は、ラッパー経由の分しか見ないので落ちる |
| 3 | 残った方式 | Match exec |
既定は Match exec。 ssh コマンドが設定を読むたびに必ず評価されるので、何から起動したかに依らない。
AI のコマンド hook(Claude Code の PreToolUse など)は、AI が実行するコマンドの文字列しか見ない。 人のターミナルは対象外で、AI が書いた Python が中で ssh を呼んだ場合も文字列には現れない。
表2: 判断の根拠
| 方式 | 人の ssh | AI の ssh | scp や Python 経由 | 接続先の名前 |
|---|---|---|---|---|
| ラッパーにログを書かせる | ラッパー経由のみ | ラッパー経由のみ | 拾えない | 拾える |
| AI のコマンド hook | 拾えない | 文字列に出る分のみ | 拾えない | 文字列から推測 |
Match exec | 拾える | 拾える | 拾える | %n で受け取れる |
Match exec で記録する
~/.ssh/config の先頭(最初の Host より前)に置く。
Match exec "/path/to/ssh-gate.sh %n"
ProxyCommand /bin/sh -c 'echo "blocked" >&2; exit 1'Match exec はコマンドの終了コードで条件の成否を決める。 0 なら成立して下の ProxyCommand が使われ、接続が失敗する。 記録だけしたい接続では、ログを書いてから 1 を返して素通しさせる。 私の環境ではこのスクリプトが AI の本番接続を止める役も持っているので、成立させる側の分岐もある。
記録部分の要点は次のとおり。
if [ "${CLAUDECODE:-}" = "1" ]; then actor=claude; else actor=human; fi
log_connection() {
(
umask 077
jq -cn --arg ts "$(date '+%Y-%m-%dT%H:%M:%S%z')" --arg actor "$actor" \
--arg host "$alias_host" --arg resolved "$1" --arg cwd "$PWD" \
'{ts:$ts, actor:$actor, host:$host, resolved:$resolved, cwd:$cwd}' \
>> "$CONN_LOG"
) 2>/dev/null || true
}人と AI は環境変数で分ける。 Claude Code が起動したプロセスには CLAUDECODE=1 が入る。 cwd を残すと、AI の接続についてはどの作業ディレクトリのセッションから入ったかまで追える。 コマンドの引数は記録しない。 接続先と時刻が分かれば問いには答えられるし、引数を残すと秘密まで書き残すおそれがある。
踏み台を ProxyCommand で中継する接続では、1 回の接続で 2 行残る。 踏み台への接続と、その先のインスタンスへの接続が、別々の ssh として評価されるからだ。 冒頭の件なら、staging の踏み台名とインスタンスの IP が並んで残っていたことになる。
ログイン中の PC を乗っ取られたら、このログも読まれる。 ただ、~/.ssh/config と known_hosts から踏み台と接続履歴はもともと読める。 増えるのは「いつ、どの社内 IP に入ったか」だけなので、ディレクトリを 700、ファイルを 600 で作るにとどめた。
何から起動した ssh が記録されるか
接続先を書き出すだけの Match exec を持つ設定ファイルを作り、到達しない社内 IP に向けて各コマンドを実行した。 下 3 行は試していない。
表3: 起動元ごとの記録
| 起動元 | 記録されるか | 根拠 |
|---|---|---|
| scp | される | scp -F <cfg> 10.255.255.1:/x out で記録された |
| sftp | される | sftp -F <cfg> -b /dev/null 10.255.255.2 で記録された |
| rsync | される | rsync -e "ssh -F <cfg>" 10.255.255.3:/x out で記録された |
| Python の subprocess | される | subprocess.run(['ssh', '-F', cfg, '10.255.255.4', 'true']) で記録された |
| git | される | GIT_SSH_COMMAND="ssh -F <cfg>" git ls-remote ssh://... で記録された |
| paramiko などの SSH ライブラリ | されない | ssh コマンドを使わず、Match exec も実行しない |
ssh -F /dev/null など別の設定を指定した ssh | されない | ~/.ssh/config を読まない |
| SSM Session Manager など | されない | そもそも ssh ではない |
記録されたものは、どれも中で ssh コマンドを起動している。 設定ファイルを読む時点で Match exec が実行されるので、起動の仕方は問わない。 AI 側の -F 指定は、コマンド hook で ssh の呼び出しを止めれば補える。 SSH ライブラリは使う段になってから考える。 いまの手元には入っていない。
逆に、ssh -G(設定を表示するだけで接続しない)でも 1 行残る。 1 行は「ssh を起動した」記録で、接続できたことまでは示さない。
AI の本番接続を翌朝に知る
毎朝回している定型チェックに 1 ステップ足した。 前営業日の actor=claude の行のうち、本番の踏み台に当たるものがあれば警告を出す。
本番かどうかは踏み台で判定している。 本番のインスタンスには必ず本番の踏み台を通って入るし、その先のインスタンスの IP は staging と同じ 10.x の範囲で、IP だけでは区別できない。 踏み台はエイリアスと ssh -G で引いた HostName の両方と比べ、同じ IP を別の名前で指していても拾えるようにしている。
人の接続は警告しない。 自分で入った分は自分で分かっているので、警告は AI の分だけで足りる。
▸ 前営業日(2026-10-27)に Claude が production に接続しています(2 回)
10:00:00 prod-bastion /path/to/work-dir
11:00:00 other-alias /path/to/work-dir
その操作を許可したか、どのセッションの作業かを確かめる