GPG秘密鍵を専用ユーザに隔離してAIエージェントのうっかりアップロードを防ぎつつ利便性を維持する

背景

Grok Buildの全リポジトリアップロード事件

xAIのコーディングエージェントであるGrok Build CLIが、実際に読んだファイルの送信とは別の経路で、カレントディレクトリのGitリポジトリ全体をxAIのクラウドストレージにアップロードしていたことが、 2026年7月に発覚しました。

mitmproxyによる通信解析によると、モデルへの推論リクエストとは独立したPOST /v1/storageという経路で、リポジトリ全体がgit bundleとして送信されていたようです。「読むな」と指示したファイルも、コミットされていればbundle経由で結局送信されていました。 .envの認証情報も未マスクのまま送信されていたと報告されています。

読める平文はいずれ送信される

この話を聞いて私は、「暗号化してないファイルがプロセスから読める状態になっているなら、そりゃ送信もされるだろ」と思いました。

AIコーディングエージェントは、ユーザ権限で動いてファイルを読み、その内容を外部のサーバに送ることが本質的な動作です。どこまで送るかはツールの実装と設定次第でしかなく、今回のように実装がやらかすこともあります。エージェントに限らず、テレメトリやクラッシュレポートで似たことをやらかすソフトウェアは昔からあります。

なので平文のシークレットがユーザ権限で読める場所に置いてある時点で、うっかり流出はいつか起きるものとして扱うべきだと考えています。

私はシークレットをsops-nixで管理していて、リポジトリにコミットされるのはGPGで暗号化されたyamlファイルだけです。

そもそも私のdotfilesはGitHubのpublicリポジトリなので、暗号文は最初から全世界に公開されています。エージェントにアップロードされるまでもありません。リポジトリのアップロード自体は何も問題ではないのです。

dotfilesに限らず、各種リポジトリの.envのようなシークレットファイルも、 sopsで暗号化した形で.env.sopsファイルとしてコミットしています。 Grok Buildの件では.envが未マスクのまま送信されていたと報告されていますが、リポジトリがpublicかprivateかにかかわらず、暗号化が解除されない限りリポジトリの流出自体は問題になりません。

裏を返せば、この運用のセキュリティは復号鍵であるGPG秘密鍵1つに完全に懸かっています。秘密鍵が流出したら暗号化していた意味が全て消し飛び、 publicリポジトリに積み上げてきた暗号文全てが誰からでも復号可能になります。

そして従来の構成では、 GPG秘密鍵は~/.gnupg/private-keys-v1.d/に、つまり通常ユーザ権限で読めるファイルとして置いてありました。ホームディレクトリを丸ごとアップロードするタイプの事故が起きたら普通に漏れます。

というわけで、 feat(gpg): GPG秘密鍵を専用ユーザgpg-vaultに隔離 #1377 で対策を行いました。

脅威モデル

先に防ぐ範囲をはっきりさせておきます。

防ぐのは「うっかり」だけです。

  • エージェントやその他のツールが、ユーザ権限でディレクトリを一括で読んでアップロードしてしまう事故

悪意のあるプログラムは防ぎません。ユーザ権限で任意コードが動く時点で、キーロガーでも仕込めばいいし、そもそも秘密鍵なんて狙わなくてもブラウザのクッキーを盗んだほうが速いです。悪意を防ぎたいならユーザ権限で悪意あるコードを動かさないことが唯一の対策で、それはsandboxや権限承認など別レイヤーの仕事です。

「ファイルパーミッションで読めなくする」という地味な仕組みで、「読めるファイルを送ってしまう」という事故だけを潰します。

設計

GnuPGの設計をそのまま利用する

都合の良いことに、 GnuPGはもともとこの隔離をやりやすい設計になっています。

gpgクライアントは秘密鍵ファイルを直接読みません。署名や復号などの秘密鍵操作は、全てgpg-agentにUnixソケット経由で委譲します。秘密鍵ファイルを読むのはgpg-agentだけです。

つまりgpg-agentさえ別ユーザで動かしてソケットだけ公開すれば、通常ユーザから秘密鍵ファイルを読めなくしても、 gpgコマンドは今まで通り動くはずです。

構成は以下の通りです。

  • 秘密鍵の実体は専用システムユーザgpg-vault/var/lib/gpg-vault/.gnupg(mode 700)に置く
  • gpg-agentをgpg-vaultユーザのsystemdサービスとして動かす
  • ソケットは/run/gpg-vault/に置き、 gpg-vaultグループのみ接続可能にする
  • 通常ユーザは自分のgpg-agentを起動せず、標準ソケットパスからのsymlinkとSSH_AUTH_SOCKでvaultのソケットを参照する

これで通常ユーザのgpg署名、 SSH認証(GPGサブキーのenable-ssh-support経由)、 Gitのコミット署名は全部そのまま動きつつ、秘密鍵ファイルの読み出しはパーミッションで拒否されます。

Qubes OSのSplit GPGと同じ方向性

この「秘密鍵は別ドメインに置いて、操作だけを窓口越しに依頼する」という方向性は、 Qubes OSのSplit GPGがずっと前からやっていることです。

Split GPGでは秘密鍵を専用のVMに隔離して、作業用VMからはQubes RPC経由でgpg操作だけを依頼します。作業用VMが完全に侵害されても秘密鍵ファイル自体は盗めません。

私の構成はそれのずっと弱い版です。 VM分離ではなく同一カーネル上のユーザ分離なので、カーネルの脆弱性やroot奪取には無力ですし、 Split GPGにある操作ごとの承認プロンプトもありません。

その代わり導入は劇的に軽くて、 Nixの設定ファイルを少々書くだけで済み、日常の使い勝手は何も変わりません。「うっかりアップロード」という脅威モデルにはこれで十分だと判断しました。

実装

実装の全体は、 nixos/core/gpg-vault.nix と、 home/core/gpg.nix にあります。

vault側: 専用ユーザとgpg-agentサービス

NixOS側で専用ユーザとグループを作り、通常ユーザをグループに参加させます。

users = {
  users = {
    gpg-vault = {
      isSystemUser = true;
      group = "gpg-vault";
      home = vaultHome;
      description = "GPG secret key vault";
    };
    # ソケットへの接続はgpg-vaultグループで制限します。
    ${username}.extraGroups = [ "gpg-vault" ];
  };
  groups.gpg-vault = { };
};

gpg-agentはsystemdのシステムサービスとしてgpg-vaultユーザで動かします。

systemd.services.gpg-vault-agent = {
  description = "gpg-agent holding secret keys isolated from normal users";
  wantedBy = [ "multi-user.target" ];
  serviceConfig = {
    User = "gpg-vault";
    Group = "gpg-vault";
    # gpg-agentはソケットを作り終えてからforkするため、
    # forkingなら起動完了時点でソケットが利用可能です。
    Type = "forking";
    ExecStart = "${pkgs.gnupg}/bin/gpg-agent --daemon";
    # gpg-agentはumaskに関係なくソケットを0700で生成するため、
    # このchmodによるグループ書き込み(=接続)許可は必須です。
    ExecStartPost = "${pkgs.coreutils}/bin/chmod 0660 ${socketDir}/S.gpg-agent ${socketDir}/S.gpg-agent.ssh";
    Restart = "on-failure";
    Environment = [ "GNUPGHOME=${gnupgHome}" ];
    UMask = "0007";
    RuntimeDirectory = "gpg-vault";
    RuntimeDirectoryMode = "0750";
    StateDirectory = "gpg-vault";
    StateDirectoryMode = "0700";
    # 以下ハードニング設定は省略
  };
};

秘密鍵を扱うプロセスなので、 ProtectHomeRestrictAddressFamiliesなどのsystemdハードニングも一通りかけていますが、本題ではないので省略します。

ポイント: ソケットの配置にはAssuanリダイレクトが必要

素直にいかなかったのがソケットの配置場所です。

gpg-vaultはシステムユーザなのでログインセッションを持たず、 /run/user/<uid>が作られません。その場合gpg-agentはソケットをGNUPGHOME直下に作りますが、 GNUPGHOMEはmode 700なので通常ユーザからソケットに届きません。

GnuPGにはAssuanソケットリダイレクトという仕組みがあり、ソケットと同名のファイルに以下のような内容を書いておくと、実際のソケットを別の場所に作らせることができます。

%Assuan%
socket=/run/gpg-vault/S.gpg-agent

これで実ソケットだけを共有ディレクトリ/run/gpg-vault/に出せます。

さらにgpg-agentはumaskを無視してソケットを0700で作るため、サービスのExecStartPostでchmodしてグループからの接続を許可しています。 ExecStartPostの完了までunitはstartedにならないので、依存サービスが0700の窓に当たることはありません。

ユーザ側: agentを起動せずソケットだけ参照する

home-manager側では、 gpg-agentを起動する代わりに標準ソケットパスからvaultへのsymlinkを張ります。

systemd.user = {
  # gpgクライアントは標準のソケットパス($XDG_RUNTIME_DIR/gnupg/)を参照するため、
  # symlinkでvault agentのソケットへ誘導します。
  tmpfiles.rules = [
    "d %t/gnupg 0700 - -"
    "L+ %t/gnupg/S.gpg-agent - - - - /run/gpg-vault/S.gpg-agent"
    "L+ %t/gnupg/S.gpg-agent.ssh - - - - /run/gpg-vault/S.gpg-agent.ssh"
  ];
  sessionVariables.SSH_AUTH_SOCK = "/run/gpg-vault/S.gpg-agent.ssh";
};

gpgクライアントは$XDG_RUNTIME_DIR/gnupg/のソケットを見に行くので、 symlinkを張るだけで既存のツールは全部そのまま動きます。

ポイント: autostartを無効化する

1つ罠があって、 gpgはソケットが見つからないと、鍵を持たない自分のgpg-agentを勝手にspawnします。そうなると「秘密鍵が見つからない」という紛らわしいエラーになるので、 autostartを無効化してソケット不在を明示的なエラーにしています。

programs.gpg.settings.no-autostart = true;

sops-nixとの連携

sops-nixのシステムシークレットはrootで動くsops-install-secretsが復号します。これの参照先GNUPGHOMEをvault側に変更しました。

sops.gnupg.home = "/var/lib/gpg-vault/.gnupg";

rootなのでvaultのGNUPGHOMEを直接読めますが、 gpg-agentへの接続自体はvaultのagentに繋がるので問題なく復号できます。鍵ID解決に必要な公開鍵はサービスのpreStartでvault側のpubringにも取り込んでいます。

またagentが起動する前に復号が走ると失敗するので、 sops-install-secretsとhome-manager activationのユニットに、 gpg-vault-agent.serviceへのsystemd依存を張って順序を保証しています。

環境の分岐

NixOSではないstandalone home-manager環境では隔離用のシステムユーザを作れないので、 osConfigの有無で判別して従来構成(ユーザ自身のgpg-agent)を維持しています。

Termuxは単一ユーザ環境でそもそも隔離が不可能なので、こちらも従来構成のままです。

検証

適用後の環境で、通常ユーザ権限から実際に確認しました。

まず秘密鍵ファイルの実体に、通常ユーザからは普通にアクセスできないことを確認します。

❯ ls ~/.gnupg/private-keys-v1.d/
ls: cannot access '/home/ncaq/.gnupg/private-keys-v1.d/': そのようなファイルやディレクトリはありません

❯ ls /var/lib/gpg-vault/
ls: cannot open directory '/var/lib/gpg-vault/': 許可がありません

~/.gnupgに残っているのは公開鍵keyringと設定ファイルだけで、 vault側はディレクトリの列挙すらできません。ホームディレクトリを丸ごとアップロードされても、 GPG秘密鍵ファイルはそこに存在しないので漏れようがありません。

ちなみにSSHの認証もGPGサブキーで行っているので、 ~/.sshにも秘密鍵ファイルはもともと存在しません。

次に機能が生きていることの確認です。

❯ gpg --list-secret-keys
sec#  ed25519/0x42248C7D0FB73D57 2026-01-05 [C] [有効期限: 2031-01-04]
      フィンガープリント = 7DDE 3BC4 05DC 58D9 4BF6  61D3 4224 8C7D 0FB7 3D57
uid                   [  究極  ] ncaq <ncaq@ncaq.net>
ssb   cv25519/0xC65B759E5D5B3E95 2026-01-05 [E] [有効期限: 2031-01-04]
ssb   ed25519/0xACA66AB679E75544 2026-01-22 [SA] [有効期限: 2031-01-21]

❯ echo test | gpg --sign --armor > /dev/null; echo $?
0

❯ ssh-add -l
256 SHA256:ngQqr8v3RIFqUg0ZZU1w57B105hd11MlI2IFajxqAes (none) (ED25519)

署名もSSH認証も今まで通り動きます。 gpg --list-secret-keysが答えを返せるのは、クライアントがソケット越しにvaultのagentへ問い合わせているからで、ファイルとしては何も見えていません。

主鍵がsec#表示なのは主鍵をオフラインの隔離ストレージに保管しているからで、これは今回の変更以前からです。主鍵の運用については GPGを導入して署名用サブキーを端末ごとに分けて運用することにしました - ncaq に書きました。

制限

繰り返しになりますが、これは厳密なアクセス制御ではありません。

  • ソケット経由のgpg --export-secret-keysは通ります。ただし「ファイルを読んで送る」タイプのうっかりツールがexportコマンドを叩くことはないので、この脅威モデルでは問題ないと判断しています。
  • ソケットに接続できるプロセスは署名や復号を自由に実行できます。パスフレーズなし運用なので操作ごとの確認もありません。 Split GPGのような承認プロンプトが欲しくなったらまた考えます。
  • sops-nixが復号した後の平文シークレットは、結局アプリケーションが読める場所に置かれます。復号鍵が守られていれば「過去の暗号文全部が事後的に復号可能になる」最悪ケースは防げる、というのがこの対策の主眼です。

感想

Grok Buildの件を聞いた時は、「AIエージェントがファイルに触れられるならアップロードもできるだろ、騒いでるのは馬鹿じゃね?」という感想でしたが、自分の環境を見直したら復号鍵が普通にユーザ権限で読める場所に置いてあったので、人のことを笑える状態ではありませんでした。

GnuPGのクライアントとagentの分離設計のおかげで、使い勝手を一切損なわずにある程度の隔離ができました。

やはりこういう時に宣言的なNixOSは強くて、専用ユーザ、 systemdサービス、 tmpfilesによるファイル配置といった少し複雑な構成も、複数のマシンで簡単に再現可能です。

でも別にNixOSでなくても同じようなことはだいたいの環境でできると思います。

AIエージェントを使う人は、エージェントに悪意がなくても実装がやらかす前提で、「ユーザ権限で読める平文には何があるか」を一度洗い出してみると良いと思います。私の場合はそれがGPG秘密鍵でした。