Claude Code に専用の認証情報を持たせて、人間の承認を挟まずに Git と GitHub の操作を任せる

Claude Code に人間とは別の署名鍵と GitHub のトークンを持たせ、人間による 1Password の承認なしでコミットやプッシュを行えるようにした。この構成と、構成を決めるうえで考えたことについて記す。

Claude Code に専用の認証情報を持たせて、人間の承認を挟まずに Git と GitHub の操作を任せる

自分は Git のコミット署名と GitHub への SSH 認証を 1Password の SSH 機能で行っている (1Password の SSH 機能を使って署名付きコミットを設定する)。鍵が 1Password の外に出ず、鍵を利用した操作には人間による承認が求められる。鍵をセキュアに管理できる一方で、Claude Code にコミットやプッシュを任せると、そのたびに承認のプロンプトが表示される。

少し前までは、同時に動かすエージェントは1つだったので、プロンプトの承認に煩わしさは感じつつも、運用は回っていた。しかし、最近は herdr のようなツールで複数のエージェントを同時並行で動かす作業スタイルにトライしている。こうなると、すべての操作を毎回承認していたら作業が回らなくなってくる。人間が判断するのは重要な場面に絞り、それ以外はエージェントが自律的に作業を進められる状態を作りたい。

そこで、Claude Code に人間とは別の署名鍵と GitHub のトークンを持たせることにした。人間の側の 1Password の設定はそのままで、エージェントの Git と GitHub の操作だけが 1Password を通らなくなる。この記事ではその構成と、構成を決めるうえで考えたことについて記す。

環境

ソフトウェア バージョン
macOS 26.6.2
Claude Code 2.1.292
1Password 8.12.40
git 2.50.1
gh 2.102.0

解決したい課題

鍵を利用する操作の承認待ちで、エージェントの作業が中断される

1Password の鍵の利用の承認を求められていたのは次の2つの操作だ。

  • コミットの署名: gpg.ssh.program に 1Password の op-ssh-sign を指定しているので、署名のたびに 1Password が呼ばれる。
  • SSH でのプッシュ: ~/.ssh/config の IdentityAgent で 1Password の SSH エージェントを指定しているので、認証のたびに 1Password が呼ばれる。

このため、気づいたらコミットやプッシュのタイミングでエージェントの作業が止まっている。1Password 側で承認の頻度を減らす設定もあるが、人間の操作に対する保護も一緒に弱めることになる。エージェントにはできるだけ自由に作業を進めてもらいたいが、危険な操作は未然に防ぎたい。

人間の認証情報や権限にエージェントから手が届く

これまでは git と gh をサンドボックスの対象外 (excludedCommands) にしていた。サンドボックスの外で動くコマンドは、人間と同じようにどのファイルでも読めるし、人間の認証情報も使える。たとえばエージェントが実行する gh は、人間が gh auth login でキーチェーンに保存した OAuth トークンをそのまま使える。このトークンは repo スコープを持ち、有効期限もない。つまり、エージェントの誤操作やプロンプトインジェクションが、人間と同じ権限で実行されうる状態だった。

方針: エージェントに専用の認証情報を持たせる

人間とエージェントで、次のように認証情報を分けることにした。

用途 人間 エージェント (Claude Code)
コミットの署名 1Password の鍵 エージェント専用の鍵 (専用の ssh-agent に読み込む)
Git の通信 SSH (1Password の SSH エージェント) HTTPS (fine-grained PAT)
gh gh auth login のトークン fine-grained PAT

コミットの署名に使う鍵とプログラムは、Git の user.signingkey と gpg.ssh.program で決まる。エージェントのプロセスでだけこれらを上書きし、1Password の op-ssh-sign の代わりに ssh-keygen を使って、専用の ssh-agent にあるエージェントの鍵で署名させる。

Git の通信の認証方式は、人ごとではなくリモートの URL ごとに決まる。git@github.com:owner/repo.git のような SSH の URL なら SSH 鍵で、https://github.com/owner/repo.git なら credential helper が返すトークンで認証する。人間とエージェントは同じリポジトリの同じリモートを使うので、リモートの URL を HTTPS に書き換えると人間のプッシュもトークンを使うことになってしまう。

そこでリモートは SSH のままにしておき、エージェントのプロセスでだけ url.<base>.insteadOf で HTTPS に読み替える。HTTPS の認証には、credential helper に gh を指定して、gh でログインしたエージェント用の fine-grained PAT を返させる。fine-grained PAT は対象のリポジトリや権限を細かく絞れるので、エージェントには必要な操作の分だけ権限を渡せる。

remote: git@github.com:owner/repo.git
  ├─ 人間の git → そのまま SSH → 1Password の鍵
  └─ Claude Code の git → https://github.com/owner/repo.git に読み替え → エージェント用の PAT

gh はログイン情報を設定ディレクトリごとに管理していて、設定ディレクトリは GH_CONFIG_DIR で切り替えられる。エージェントのプロセスでだけ、PAT でログインした別のディレクトリを指定する。

署名鍵を ssh-agent に持たせる

エージェント用の署名鍵は、パスフレーズなしで作成する。エージェントが無人で署名できるようにするためだ。

ssh-keygen -t ed25519 -N "" -C "coding-agent-signing@$(hostname -s)" -f ~/.ssh/id_ed25519_coding_agent_signing

公開鍵は GitHub に Signing Key としてのみ 登録し、Authentication Key としては登録しない。万一鍵が漏れても、偽造されうるのは署名だけで、この鍵でプッシュすることはできない。

鍵ファイルをそのまま Git に使わせると、エージェントのコマンドがファイルを読めることになる。そこで鍵はエージェント専用の ssh-agent に読み込んでおき、エージェントにはソケット経由で署名だけをさせる。ssh-agent は鍵の中身を返さないので、署名はできても鍵は取り出せない。Git は user.signingkey に公開鍵のパスを指定すると、対応する鍵を ssh-agent から探して署名してくれる。

ssh-agent の起動には Claude Code の SessionStart hook を使った。hook は Bash のサンドボックスの外で動くので 1、鍵ファイルを読んで ssh-agent に渡せる。一方、後述するようにエージェントのコマンドはサンドボックスの中で動くので、鍵ファイルは読めない。hook のスクリプトはサンドボックスで書き込みが禁止されている ~/.claude/hooks 2 に置くので、エージェントが hook を書き換えて鍵を取り出すこともできない。launchd などで常駐させる方法もあるが、鍵を使うのは Claude Code だけなので、Claude Code の起動に合わせて起動するのが自然だと考えた。

start-coding-agent-ssh-agent.sh の内容
#!/bin/sh
#
# Start the ssh-agent that signs commits made by coding agents, and load the
# coding agent signing key into it.
#
# Hooks run outside the Bash sandbox, so this script can read the key file
# while commands run by agents cannot. Agents sign through the socket, which
# the sandbox allows, and never see the key itself.
#
# The agent keeps running after the session ends, so later sessions reuse it.

set -eu

key=${HOME}/.ssh/id_ed25519_coding_agent_signing
socket_dir=${HOME}/.local/state/coding-agent
socket=${socket_dir}/ssh-agent.sock
lock=${socket_dir}/ssh-agent.lock

if [ ! -f "${key}" ]; then
    echo "${key} does not exist, so commits by coding agents cannot be signed. See the dotfiles README to create it." >&2
    exit 1
fi

mkdir -p "${socket_dir}"
chmod 700 "${socket_dir}"

# Sessions started at the same time run this hook concurrently. mkdir is
# atomic, so only one of them starts the agent while the others wait.
attempts=0
until mkdir "${lock}" 2> /dev/null; do
    attempts=$((attempts + 1))
    if [ "${attempts}" -ge 50 ]; then
        echo "Timed out waiting for ${lock}. Remove it if no session is starting." >&2
        exit 1
    fi
    sleep 0.1
done
trap 'rmdir "${lock}"' EXIT

# ssh-add -T exits with 0 only when the agent holds the key matching the given
# public key, so an agent holding another key, such as one replaced by a newer
# key, gets the current key loaded
if SSH_AUTH_SOCK=${socket} ssh-add -T "${key}.pub" > /dev/null 2>&1; then
    exit 0
fi

# ssh-add -l exits with 2 when no agent listens on the socket
status=0
SSH_AUTH_SOCK=${socket} ssh-add -l > /dev/null 2>&1 || status=$?
if [ "${status}" -eq 2 ]; then
    # A socket left by an agent that stopped would block binding the path
    rm -f "${socket}"
    ssh-agent -a "${socket}" > /dev/null
fi

# The standard output of a SessionStart hook is added to the context, so keep
# it quiet
SSH_AUTH_SOCK=${socket} ssh-add -q "${key}"

ssh-agent は一度起動すると常駐するので、2回目以降のセッションでは鍵が読み込まれていることを確認するだけで終わる。herdr などで複数のセッションを同時に起動したときに ssh-agent が重複して起動しないよう、mkdir でロックを取っている。

GitHub へのアクセスに fine-grained PAT を使う

エージェント用の GitHub のトークンには fine-grained PAT を使う。権限は次のようにした。

  • Resource owner: 自分のアカウント。所属している組織のリポジトリは対象外になる
  • Expiration: 90日
  • Repository access: All repositories。触らせたくないリポジトリがないので、リポジトリを追加するたびに PAT を編集する手間を省いた
  • Permissions: コードの変更と Issue や PR の操作に必要な権限と、CI の結果を確認するための権限だけを付けた
    • Contents / Issues / Pull requests: Read and write
    • Actions / Commit statuses: Read-only

PAT は人間の gh とは別の設定ディレクトリに保存する。

mkdir -p ~/.config/gh-coding-agent && chmod 700 ~/.config/gh-coding-agent
pbpaste | GH_CONFIG_DIR=~/.config/gh-coding-agent gh auth login --hostname github.com --git-protocol https --insecure-storage --with-token

--insecure-storage はトークンをキーチェーンではなく設定ディレクトリの hosts.yml に保存させるためのものだ。キーチェーンに入れると人間のトークンと保存先が混ざるうえ、サンドボックスの中からは読めなくなる。

Claude Code にだけ設定を効かせる

ここまで用意した鍵と PAT を、Claude Code の中で動く git と gh にだけ使わせる。これには ~/.claude/settings.json の env を使った。設定ファイルは Claude Code のどの起動経路でも読み込まれるので、herdr などから起動したすべてのセッションに同じ env が効く。なお、settings.json では ~ や $HOME が展開されないので、パスは絶対パスで書く必要がある。以下の例の /Users/you は自分のホームディレクトリに読み替えてほしい。

{
  "env": {
    "GH_CONFIG_DIR": "/Users/you/.config/gh-coding-agent",
    "GIT_CONFIG_COUNT": "6",
    "GIT_CONFIG_KEY_0": "user.signingkey",
    "GIT_CONFIG_VALUE_0": "/Users/you/.ssh/id_ed25519_coding_agent_signing.pub",
    "GIT_CONFIG_KEY_1": "gpg.ssh.program",
    "GIT_CONFIG_VALUE_1": "ssh-keygen",
    "GIT_CONFIG_KEY_2": "url.https://github.com/.insteadOf",
    "GIT_CONFIG_VALUE_2": "git@github.com:",
    "GIT_CONFIG_KEY_3": "url.https://github.com/.insteadOf",
    "GIT_CONFIG_VALUE_3": "ssh://git@github.com/",
    "GIT_CONFIG_KEY_4": "credential.helper",
    "GIT_CONFIG_VALUE_4": "",
    "GIT_CONFIG_KEY_5": "credential.https://github.com.helper",
    "GIT_CONFIG_VALUE_5": "!gh auth git-credential",
    "SSH_AUTH_SOCK": "/Users/you/.local/state/coding-agent/ssh-agent.sock"
  }
}

GIT_CONFIG_COUNT と GIT_CONFIG_KEY_<n> / GIT_CONFIG_VALUE_<n> は、環境変数で Git の設定を追加する仕組みだ。設定ファイルより優先されるので、グローバルの設定を書き換えずに、このプロセスでだけ値を上書きできる。それぞれの役割は次のとおりだ。

設定 役割
GH_CONFIG_DIR gh がエージェント用の PAT を使うようにする
user.signingkey 署名にエージェント用の鍵 (の公開鍵) を使う
gpg.ssh.program 署名プログラムを op-ssh-sign から ssh-keygen に戻す
url.https://github.com/.insteadOf SSH の URL を HTTPS に読み替える。submodule やパッケージマネージャの依存で使われる ssh:// 形式も対象にする
credential.helper (空) macOS の Git で既定になっている osxkeychain をリセットし、キーチェーンにある人間の認証情報を使わせない
credential.https://github.com.helper github.com の認証を gh、つまりエージェント用の PAT に限定する
SSH_AUTH_SOCK 署名に使う ssh-agent を、エージェント専用のものに向ける

git と gh をサンドボックスの中で動かす

最後に、git と gh を excludedCommands から外し、サンドボックスの中で動かすようにした。外に出したままだと、人間のトークンを使えるだけでなく、エージェント用の秘密鍵のファイルも読めてしまい、ssh-agent に鍵を持たせた意味がなくなる。

サンドボックスの中で動かすために、次の設定を追加した。

{
  "sandbox": {
    "enableWeakerNetworkIsolation": true,
    "filesystem": {
      "allowRead": [
        "~/.config/gh-coding-agent",
        "~/.ssh/id_ed25519_coding_agent_signing.pub"
      ]
    },
    "network": {
      "allowUnixSockets": ["/Users/you/.local/state/coding-agent/ssh-agent.sock"],
      "allowedDomains": ["api.github.com", "github.com"]
    }
  }
}

自分の設定では ~/.ssh と ~/.config を denyRead で禁止していて、公開鍵と PAT のディレクトリだけを allowRead で許可している。秘密鍵は読めないままで、署名は allowUnixSockets で許可したソケット経由で行う。

enableWeakerNetworkIsolation は gh のために必要になった設定だ。gh をサンドボックスの中で動かすと、x509: OSStatus -26276 というエラーで TLS の証明書の検証に失敗した。

gh のような Go 製の CLI は、macOS では証明書の検証を OS の API に任せていて 3、実際の検証は trustd というプロセスが行う。サンドボックスはこの trustd への問い合わせを遮断するので、検証に失敗する。git や curl は証明書の検証を自分のプロセスの中で行うので、この問題は起きない。公式ドキュメントでも Go 製の CLI の既知の問題として紹介されている 4。ドキュメントでは excludedCommands に入れる方法が案内されていて、enableWeakerNetworkIsolation は MITM プロキシを使う場合の設定として挙がっているが、手元ではこの設定を有効にすることで解消した。

enableWeakerNetworkIsolation を有効にすると、サンドボックスの中のコマンドに trustd への問い合わせだけが許可される。trustd は証明書の失効を確認するために、サンドボックスのプロキシを通らずに外部と通信することがある。そのため、細工した証明書を使えば、allowedDomains に含まれないサーバーにデータを送る経路になりうる。ただ、gh をサンドボックスの外に出せば、任意のファイルを読めて人間のトークンも使える状態になる。それと比べればリスクは小さいと判断し、こちらを選んだ。

動作確認

設定を反映して Claude Code を再起動し、次のことを確認した。

  • SessionStart hook で ssh-agent が起動し、エージェント用の鍵が読み込まれている
  • 署名付きのコミットが 1Password の承認なしで作成でき、GitHub で Verified と表示される
  • git push と gh pr create がエージェント用の PAT で成功する
  • git からも gh からも秘密鍵を読めない

エージェントは環境変数を自由に書き換えられるので、上書きして人間の認証情報に届かないかも確認した。

試したこと 結果
SSH_AUTH_SOCK を 1Password のソケットに向ける サンドボックスがソケットへの接続を拒否する
env -u GH_CONFIG_DIR gh auth status で人間の gh の設定を使わせる ~/.config/gh を読めずに失敗する (denyRead に ~/.config を指定しているため)
偽の hosts.yml を作ってキーチェーンのトークンを使わせる サンドボックスの中からはログインキーチェーンが見えず失敗する (denyRead に ~/Library/Keychains も指定している)
git の credential helper を osxkeychain に戻す 認証情報を取得できない

環境変数による設定はあくまで「エージェントがふだん使う認証情報」を決めるもので、人間の認証情報に届かないことを保証しているのはサンドボックスの方だ。

残る課題

この構成でもまだ塞げていない経路がある。

  • Claude Code のファイルのツール: サンドボックスが制限するのは Bash のコマンドだけで、Read などのファイルのツールには denyRead が効かない 5。permissions の blockReadsOutsideWorkingDirectories 6 で作業ディレクトリの外の読み取りを禁止できそう。
  • herdr: エージェントが herdr の CLI から別のエージェントを起動できるよう、herdr はサンドボックスの外で動かしている。herdr pane run を使えば、サンドボックスの外のシェルで任意のコマンドを実行できてしまう。
  • docker: 開発で docker を使うリポジトリでは、docker をサンドボックスの外で動かす必要がある。ホームディレクトリをマウントしたコンテナを起動されると、サンドボックスの制限は意味をなさない。

後ろの2つはサンドボックスの仕組みでは塞ぎにくい。検討の途中では、herdr やエージェントごと Lima の VM に入れる構成も考えた。VM の中なら docker も herdr も自由に使わせられ、人間の認証情報はそもそも VM に存在しない。ただ、いきなり大きな構成にすると想定外の問題に多く当たって頓挫しそうだったので、今回は見送った。まずはこの構成で運用してみて、安定してきたら VM 方式も試してみるつもりだ。

おわりに

今回の作業も Claude Code と相談しながら進めたのだが、構成が整ってからは、その Claude Code 自身がエージェント用の鍵でコミットに署名し、PAT でプッシュして PR を作るようになった。それまでは自分が 1Password で承認したり、コマンドを手で実行したりしていたので、ようやく解放された感がある。

とはいえ、まだ完全に手放せたわけではない。コミット時に pre-commit hook が走る場合、pnpm がキャッシュを書き込めずに失敗するなど、結局自分が手を動かさなければならない場面がまだある。サンドボックスに必要な許可を足していく調整はしばらく続きそうだ。

Footnotes

  1. Configure the sandboxed Bash tool - What runs outside the sandbox ↩

  2. Configure the sandboxed Bash tool - Protected paths ↩

  3. Go 1.18 Release Notes - crypto/x509 ↩

  4. Configure the sandboxed Bash tool - Go-based CLIs fail TLS verification on macOS ↩

  5. Configure the sandboxed Bash tool - What runs outside the sandbox ↩

  6. Settings reference - permissions.blockReadsOutsideWorkingDirectories ↩