誰も本気で解決しない、地味な問題 #
個人で動かしているAIエージェントがある。ファイル整理とか、ルーチンなメンテナンスとか、重要だけど面倒なことをやってくれる。
AIそのものを動かすのは、意外とすぐにうまくいった。難しかったのは、あとから指示を出すことだった。
世の中にはツールがあふれている。コード生成、RAG、ベクトルDB、名前がSFの惑星みたいなマルチエージェントフレームワーク。でも、一番基本の「エージェントに仕事を頼んで、ちゃんとやったか確認する」には、だいたいマークダウンファイルと祈りが答えになる。
僕もそこから始めた。TODO.md。チェックボックス。エージェントがそれを読んで、未チェックの項目を処理する。シンプル。
でも、壊れまくった。
ファイルはクラウドで同期されている場所にあった。スマホから編集するには、アプリを開いてファイルを探し、80行ぐらいスクロールする。コンフリクトしないかも祈らないといけない。
タスクは全部一行で、文脈ゼロだった。しかもエージェントは30分おきのハートビートでしかファイルを見に来ない。「今やってほしい」と「エージェントが気づく」の間に、必ずタイムラグがあった。
既存のエージェントダッシュボードやタスクUIも調べた。どれも独自のDB、独自の認証、独自のデプロイが必要。「ちょっとやってよ」に対して、インフラが重すぎる。
そこで気づいた。本当は必要なもの、もう持ってた。モバイルアプリもある。APIもある。ラベルもある。リッチな説明文も、通知も、スレッドコメントも、履歴もある。プライベートリポで無料。
それがGitHub Issues。
構成 #
flowchart TD
A[GitHub Issue 作成・コメント] --> B[GitHub Webhook]
B --> C[Cloudflare トンネル]
C --> D[WAF — GitHub IPのみ許可]
D --> E[Go製インターセプタ]
E --> F[エージェントゲートウェイ — ループバック]
F --> G[エージェント起動]
プライベートリポをいくつか用意している。1つは汎用タスク管理用、あとはプロジェクトごと。それぞれに webhook を設定する。
Issue を作ったりコメントしたりすると、GitHub から POST が飛ぶ。それが Cloudflare トンネルを通る。次に、GitHub の IP だけを許可する WAF ルールをくぐる。小さな Go 製サービスで検証され、ローカルのエージェントゲートウェイに転送される。
エージェントは大体2秒で起動する。
他の方法よりいいところ #
GitHub Issuesは、もともとこの用途のために作られたわけじゃない。だからいい。成熟してて、実戦で鍛えられていて、問題にぴったりハマる。
タスクと一緒に情報が入る。 一行じゃない。説明文も、スクリーンショットも、チェックリストも、コミットへのリンクも全部入る。追加質問なしでエージェントに必要な情報を渡せる。
ラベルで分類する。 種別、担当、プロジェクトでタグ付け。エージェントは自分のラベルで絞り込んで、関係ある仕事を見つける。
コメントがフィードバックループになる。 エージェントが終わったらコメント。修正が必要ならこっちもコメント。スレッド形式で時刻付き、スマホから読める。
モバイルアプリが本当に使える。 通勤中にIssueを作る。次にスマホを見たときには、エージェントが結果をコメントしている。
監査証跡が自動。 誰が開けて、誰が閉じて、いつ、何が変わったか。Gitが全部記録してくれる。
セキュリティ #
AIエージェントをインターネットに出すとき、たとえwebhook経由でも、セキュリティは本気で考えないとダメ。エージェントはファイルシステムやシェル、APIトークンにアクセスできる。守らないと危険なwebhookエンドポイントになる。
以下は全部必須。
プライベートリポ #
リポがpublicだと、知らない人がIssueを作ってエージェントを動かせる。HMAC署名は有効だ。だってGitHubから来たんだから。ただ、あなたじゃない人から来ただけ。
webhookをエージェントに送るリポは全部privateにする。作ったら確認する。「あとで直す」と思わない。
IP許可 #
GitHubはwebhook用のIPレンジを <https://api.github.com/meta> の hooks フィールドで公開している。WAFルールに入れる。それ以外は403。
192.30.252.0/22
185.199.108.0/22
140.82.112.0/20
143.55.64.0/20
HMAC検証 #
GitHubはすべてのwebhookペイロードに X-Hub-Signature-256 で署名する。インターセプタは転送前に署名を検証する。有効な署名がなければ転送しない。
flowchart LR
A[リクエスト] --> B{GitHub IP?}
B -->|No| C[403]
B -->|Yes| D{HMAC有効?}
D -->|No| E[401]
D -->|Yes| F[転送]
IPごとのレート制限 #
有効な通信だって悪用される。インターセプタは golang.org/x/time/rate を使って、/github エンドポイントにIPごとのトークンバケットをかける。同じソースからのバーストは 429 Too Many Requests で抑制され、ゲートウェイに負荷をかけすぎない。
リプレイキャッシュ #
GitHubは X-GitHub-Delivery に一意の配信IDを入れる。インターセプタはTTL付きで受け取ったIDを記憶し、重複は 409 Conflict で弾く。これで有効な署名付きリクエストがリプレイされる可能性を狭める。
デフォルトはループバック #
デフォルトの待ち受けアドレスは 127.0.0.1:19876。これをループバック以外に上書きすると、ツールは警告を出す。TLSを終端する信頼できるリバースプロキシやトンネルの背後に置く必要がある、ということを思い出させる。インターセプタだけがトンネルから見える。ゲートウェイはループバックにとどまる。
リダイレクトを追わない #
共有HTTPクライアントはリダイレクトを拒否する。ゲートウェイがリダイレクトを誤設定しても、インターセプタはそのまま応答を返し、Bearerトークンを外部に漏らさない。
Go製インターセプタ #
GitHub webhookはHMAC署名を使う。エージェントゲートウェイの多くはBearerトークンを期待する。認証方式が違う。間に何かが必要。
それをGoで約500行で書いた。テスト込み。外部依存はレート制限用の golang.org/x/time/rate だけで、go test は約82%カバレッジ。単一バイナリにコンパイルできる。
やっていることは5つ。
- HMAC署名を検証する
- IPごとにレート制限し、配信IDを重複排除する
- JSONペイロードを型付きstructに変換する
- イベントの文脈を読みやすいメッセージにする
- 受信したリクエストのcontextを引き継いでゲートウェイにBearerトークンで転送する
主要な型はこんな感じ。
type issueEvent struct {
Action string `json:"action"`
Issue issueDetails `json:"issue"`
Sender sender `json:"sender"`
}
type issueDetails struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"html_url"`
Labels []label `json:"labels"`
}
type label struct {
Name string `json:"name"`
}
ハンドラはパース前にサイズ制限と署名チェックをする。
func readAndVerifyBody(w http.ResponseWriter, r *http.Request, secret string) ([]byte, bool) {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
body, err := io.ReadAll(r.Body)
if err != nil {
status := http.StatusBadRequest
if isTooLarge(err) {
status = http.StatusRequestEntityTooLarge
}
http.Error(w, "failed to read body", status)
return nil, false
}
sig := r.Header.Get("X-Hub-Signature-256")
if !validateSignature(body, sig, secret) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return nil, false
}
return body, true
}
代表的なテストは httptest でゲートウェイの代わりを立て、転送経路全体を確認する。
func TestHandleGitHubForwardsIssue(t *testing.T) {
secret := "secret"
gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/hooks/agent" {
t.Errorf("gateway path = %q", r.URL.Path)
}
if auth := r.Header.Get("Authorization"); auth != "Bearer gateway-token" {
t.Errorf("Authorization header = %q", auth)
}
w.WriteHeader(http.StatusOK)
}))
defer gateway.Close()
cfg := Config{
GitHubSecret: secret,
GatewayURL: gateway.URL,
GatewayToken: "gateway-token",
}
client := gateway.Client()
payload := issueEvent{
Action: "opened",
Issue: issueDetails{Number: 1, Title: "Test", Body: "body"},
}
body, _ := json.Marshal(payload)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
sig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
req := httptest.NewRequest(http.MethodPost, "/github", bytes.NewReader(body))
req.Header.Set("X-GitHub-Event", "issues")
req.Header.Set("X-GitHub-Delivery", "123e4567-e89b-12d3-a456-426614174000")
req.Header.Set("X-Hub-Signature-256", sig)
rec := httptest.NewRecorder()
handleGitHub(rec, req, cfg, client)
if rec.Code != http.StatusOK {
t.Errorf("forward status = %d, want %d", rec.Code, http.StatusOK)
}
}
共有クライアントは一度設定して使い回す。コネクションプールが効くし、リダイレクトも無効にしている。
gatewayClient := &http.Client{
Timeout: gatewayTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
転送するときは、受信したリクエストのcontextを使う。webhookがキャンセルされれば、ゲートウェイへの転送もキャンセルされる。
req, err := http.NewRequestWithContext(
r.Context(),
http.MethodPost,
gatewayURL,
bytes.NewReader(payloadBytes),
)
生成されるメッセージはこんな感じ。
[GitHub Issue #12 opened] Fix the SEO descriptions
By: username
Labels: content, agent
URL: https://github.com/<owner>/<repo>/issues/12
The about page is missing meta descriptions. Add them.
エージェントは人間が読むようにこれを受け取る。プロンプトの中でJSONをパースする必要はない。翻訳はインターセプタがやる。
なぜGoで、Cloudflare Workerじゃないのか。自分のマシン上のバイナリは、環境に依存せず、外部依存も最小限で済む。しかもゲートウェイが直接外部から見える必要がない。トンネルから見えるのはインターセプタだけ。ゲートウェイはループバック。
ダウンロード #
ビルド済みバイナリは GitHub Releases からmacOS、Linux、Windowsのamd64/arm64で入手できる。
コマンドラインから最新版を取ってくる例。
# macOS(Apple Silicon)
curl -L -o github-webhook-interceptor.tar.gz https://github.com/daveamit/github-webhook-interceptor/releases/latest/download/github-webhook-interceptor-darwin-arm64.tar.gz
tar -xzf github-webhook-interceptor.tar.gz
# macOS(Intel)
curl -L -o github-webhook-interceptor.tar.gz https://github.com/daveamit/github-webhook-interceptor/releases/latest/download/github-webhook-interceptor-darwin-amd64.tar.gz
tar -xzf github-webhook-interceptor.tar.gz
# Linux(x86_64)
curl -L -o github-webhook-interceptor.tar.gz https://github.com/daveamit/github-webhook-interceptor/releases/latest/download/github-webhook-interceptor-linux-amd64.tar.gz
tar -xzf github-webhook-interceptor.tar.gz
実行 #
環境変数4つ。設定ファイルはいらない。
| 変数名 | デフォルト | 説明 |
|---|---|---|
LISTEN_ADDR |
127.0.0.1:19876 |
インターセプタの待ち受けアドレス |
GATEWAY_URL |
http://localhost:18789 |
エージェントゲートウェイのURL |
GITHUB_SECRET |
必須 | HMAC検証用のGitHub webhook secret |
GATEWAY_TOKEN |
必須 | ゲートウェイ用のBearerトークン |
export GITHUB_SECRET="your-webhook-secret"
export GATEWAY_TOKEN="your-gateway-token"
export GATEWAY_URL="http://localhost:18789"
export LISTEN_ADDR="127.0.0.1:19876"
./github-webhook-interceptor
一回だけ試すならこれでいい。常時転送させたいマシンなら、サービス化する。
インストール #
バイナリは小さくて単一ファイル、ランタイム依存もない。僕はセルフマネージドなパッケージとして置く。決まったパスに1つのバイナリと、プロセスを管理するサービスファイル。webhook secretとgateway tokenを抱えるツールなので、パスもサービスも明確で、検証しやすく、すぐ撤去できる状態にしたい。
方針はこう。バイナリを決まった場所に置く。macOSならLaunchAgent、Linuxならsystemdのサービスファイルを作る。そうすれば、ログイン時や再起動後も自動で動く。詳しいコマンドやファイルの中身はREADMEを見てください。
macOS: LaunchAgent plist の例(展開して表示)
バイナリを永続的な場所に置く。~/.local/bin/ でいい。
mkdir -p ~/.local/bin
cp <binary-name> ~/.local/bin/
chmod 755 ~/.local/bin/<binary-name>
~/Library/LaunchAgents/com.example.stdout-feed.plist を作る。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.stdout-feed</string>
<key>ProgramArguments</key>
<array>
<string>/Users/<username>/.local/bin/<binary-name></string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>GITHUB_SECRET</key>
<string>your-webhook-secret</string>
<key>GATEWAY_TOKEN</key>
<string>your-gateway-token</string>
<key>GATEWAY_URL</key>
<string>http://localhost:18789</string>
<key>LISTEN_ADDR</key>
<string>127.0.0.1:19876</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/<log-prefix>.log</string>
<key>StandardErrorPath</key>
<string>/tmp/<log-prefix>.log</string>
</dict>
</plist>
読み込むと、同時に起動する。
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.stdout-feed.plist
古いmacOSの場合は、こちらでも動く。
launchctl load ~/Library/LaunchAgents/com.example.stdout-feed.plist
launchctl start com.example.stdout-feed
停止するときは、アンロードする。
launchctl bootout gui/$(id -u)/com.example.stdout-feed
# 古いmacOS:
# launchctl unload ~/Library/LaunchAgents/com.example.stdout-feed.plist
ログは /tmp/<log-prefix>.log に出る。プロセス名を指定して、リアルタイムで確認することもできる。
log stream --predicate 'process == "<binary-name>"' --info
Linux: systemd unit の例(展開して表示)
バイナリを /usr/local/bin/ など固定のシステムパスに置き、/etc/systemd/system/stdout-feed.service を作る。
[Unit]
Description=stdout feed webhook interceptor
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/<binary-name>
Restart=on-failure
RestartSec=5
Environment="GITHUB_SECRET=your-webhook-secret"
Environment="GATEWAY_TOKEN=your-gateway-token"
Environment="GATEWAY_URL=http://localhost:18789"
Environment="LISTEN_ADDR=127.0.0.1:19876"
[Install]
WantedBy=multi-user.target
systemdを再読み込み、ブート時起動を有効にして、今すぐ起動する。
sudo systemctl daemon-reload
sudo systemctl enable --now stdout-feed
状態とログを確認。
sudo systemctl status stdout-feed
sudo journalctl -u stdout-feed -f
unitファイルや環境変数を変えたら、こうする。
sudo systemctl daemon-reload
sudo systemctl restart stdout-feed
どちらにしても結果は同じ。ディスク上に小さなバイナリが1つと、それを管理するサービス。ゲートウェイはループバックに留まり、トンネルから見えるのはインターセプタだけ。
全体の流れ #
- プライベートリポを作る
- インターセプタを指すトンネルを設定する
- GitHubのIPを許可するWAFルールを追加する
- 共有secretでインターセプタをデプロイする
- リポにwebhookを追加する
- Issueを作る。エージェントが起動するのを見る。
最初から最後まで約2時間。ほとんどはHMACとBearerトークンのミスマッチを解決する時間だった。
今ではスマホからIssueを作る。
Aboutページの経験年数を直して 履歴書では16年、ブログでは「nearly two decades」になってる。
エージェントがそれを拾って、編集して、コミットして、プッシュして、Issueにコメントする。スマホをポケットに戻す前に終わってる。