1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude CodeのStop hookからJevで差分を評価するPluginを作った

1
Posted at

はじめに

jevを知ったとき、最初に思ったのがこれでした。

「これ、Claude CodeのStop hookに挟んだら面白いのでは?」

Claudeが「実装できました!」と帰ろうとする直前に、そのターンの差分を別モデルで確認する。
怪しい変更があれば、一度だけ席に戻して調べ直してもらう。みたいな。

そこで、Claude Code pluginのjev-preflightを作り、v0.1.0をリリースしました。

気づけば、リスク判定本体よりも

  • ユーザーのGitを壊さずに差分を取る
  • 絶対に無限ループさせない
  • 6環境で同じreleaseを配る

に大量のコードを書いていました。

何が起きるのか

例えばClaudeが、こんな変更を入れたとします。

 func allowed(owner, actor string) bool {
-    return owner == actor
+    return true
 }

コンパイルは通りそうです。でも認可チェックは消えています。
lintは構文を見られても、この比較はtrust boundaryだという意図までは知りません。

jev-preflightは、Claudeが終了する直前にturn全体のdiffをTypeSafeのJev APIへ送り、次の8軸で確認します。

  • behavior regression
  • authorization
  • input validation
  • data integrity
  • error handling
  • compatibility
  • lifecycle
  • regression tests

閾値以上の軸があれば、Claudeへこんな形の追加contextを返します。

Investigate these changed-code risk axes once:
- auth_boundary: yes probability <score>; check this risk in the changed code.
Files: "check.go".
Jev scores are investigation priorities, not proof of defects.
Inspect the actual diff, surrounding code, and tests.

jev-preflight自身がバグを断定したり、自動修正したりするわけではありません。

やるのは「もう一度だけ調べて」まで。
Claudeは実際のdiff・周辺コード・テストを読み、根拠があるときだけ修正します。

全体アーキテクチャ

jev-preflightがやっていることを一言でいうと、
Claude Codeが作業を終える直前に、そのターンで生まれた差分だけを確認するpluginです。

処理の流れは、次のようになっています。

  1. ユーザーがpromptを送った時点のworking treeを保存する
  2. Claudeがコードを編集する
  3. Stop hookで現在のworking treeをもう一度保存し、turn差分を作る
  4. 差分をfilter・redactしてJev APIへ送る
  5. 閾値を超えたrisk axisがあれば、Claudeへ一度だけ再調査を依頼する

repository全体をJevへ送っているわけではありません。

外部へ送るのは、ローカルで生成してfilter・redactしたturn差分と、あらかじめ固定した8つの質問だけです。ユーザーのpromptやClaudeの会話履歴は送りません。

また、Jevが返すのもレビュー文章ではなく、それぞれのrisk axisに対するyesの確率です。

その確率を見て、実際に問題があるかを調査するのは、codebase全体を読めるClaudeへ戻しています。

plugin側のhook定義はかなり小さいです。

以下のコードは流れを追いやすくするため、error handlingなどを一部省略しています。

{
  "hooks": {
    "UserPromptSubmit": [{
      "hooks": [{
        "type": "command",
        "command": "bash",
        "args": [
          "${CLAUDE_PLUGIN_ROOT}/scripts/run",
          "hook",
          "user-prompt-submit"
        ]
      }]
    }],
    "Stop": [{
      "hooks": [{
        "type": "command",
        "command": "bash",
        "args": [
          "${CLAUDE_PLUGIN_ROOT}/scripts/run",
          "hook",
          "stop"
        ]
      }]
    }]
  }
}

UserPromptSubmitで開始地点を保存し、Stopで終了地点との差分を見る。

Editイベントだけを監視しないので、Writeやshell経由の変更も、ひとつのturnで行われた変更としてまとめて扱えます。

技術選定

全体図だけを見ると、「Stop hookからAPIを呼ぶだけ」に見えるかもしれません。

ところが、実際に他の人へ配れるpluginにしようとすると、APIを呼ぶ前に決めることがかなりあります。

  • どのタイミングの変更を見るのか
  • 「今回Claudeが変更した範囲」をどう切り出すのか
  • 外部APIが落ちたらどうするのか
  • 再調査が無限に続かないか
  • 利用者の環境へ何をインストールさせるのか

今回は「何ができるか」だけでなく、壊れたときに影響をどこまで限定できるかを基準に選びました。

なぜEditではなくStop hookなのか

最初に決めたのは、Jevへ評価を依頼するタイミングです。

Editのたびに評価すると、まだ作業途中のコードへ何度も反応してしまいます。一時的にコンパイルが通らない状態や、後から修正する予定のコードまで拾うため、noiseもAPI callも増えます。

jev-preflightが見たいのは、編集操作ひとつひとつではありません。

Claudeが「このターンの作業は終わった」と判断した時点の変更全体です。

そこで、Claudeが応答を終えようとしたタイミングで呼ばれるStop hookを使いました。これならEditだけでなく、Writeやshell command経由の変更も、ひとつのturnで行われた変更としてまとめて確認できます。

なぜJevにレビュー文章を書かせないのか

別のLLMへdiffを渡して、自由形式のコードレビューを書かせる方法も考えられます。

ただ、それをやると今度は「そのレビューが正しいのか」を確認したくなります。Claudeと別モデルが異なる修正案を出し、どちらを信じるかという話にもなりかねません。

今回Jevに求めたのは、完成されたレビューではありません。

欲しかったのは、Claudeがどのrisk axisを再調査すべきかを決める、小さなrouting signalでした。

そのため、Jevへ渡す質問は8つのyes/no形式に固定しています。

  • behavior regression
  • authorization
  • input validation
  • data integrity
  • error handling
  • compatibility
  • lifecycle
  • regression tests

Jevから受け取るのも、それぞれの質問に対するyesの確率だけです。

閾値を超えた軸があれば、その中から上位3つをClaudeへ返します。実際に問題があるかを判断するのは、diffだけでなく周辺コードやテストも読めるClaude側です。

Jevのscoreは欠陥の証明ではなく、次にどこを調べるべきかを決める優先度として扱っています。

なぜGoなのか

Claude Codeのpluginとして配る以上、利用者にはできるだけ追加のruntimeを要求したくありませんでした。

Node.jsやPythonで実装すると、利用者の環境によってversionやpackage manager、依存packageの状態が変わります。plugin本体より先に、環境差分の問題を調査することになりかねません。

Goなら、次の6環境へ単一binaryとして配布できます。

  • macOS amd64
  • macOS arm64
  • Linux amd64
  • Linux arm64
  • Windows amd64
  • Windows arm64

runtime dependencyはGo standard libraryだけです。

「pluginを入れるために、まず別のruntimeを整えてください」という状態を避けられることが、Goを選んだ一番大きな理由でした。

なぜprivate Git treeが必要なのか

今回もっとも悩んだのが、そのターンでClaudeが変更した範囲だけをどう取り出すかでした。

単純にgit diff HEADを見ると、ユーザーがpromptを送る前から持っていたstaged、unstaged、untrackedな変更まで混ざります。

それでは、人間が作業中だったコードまでClaudeの変更として外部APIへ送ることになります。

一方で、差分を取るために利用者のGit indexやrefsを書き換えるのはもっと避けたい。

そこで、prompt開始時とStop時のworking treeを、privateなindexとobject storeへそれぞれ保存し、その2つのtreeを比較する構成にしました。

Gitの差分計算は利用しつつ、実際のrepositoryにあるindex、refs、objectsには書き込みません。

この仕組みによって、ユーザーがもともと持っていた変更と、Claudeがそのturnで行った変更を分離しています。

なぜsecurity pluginなのにfail-openなのか

security系のpluginなら、API errorが起きたときも作業を止めた方が安全に見えます。

ただし、jev-preflightは外部APIを利用するPublic Betaです。timeout、network error、設定ミス、API側の障害は必ず起こります。

そのたびにClaude Codeまで停止すると、補助tool自体が開発作業のsingle point of failureになります。

また、Jevのscoreは欠陥の証明ではありません。あくまで再調査の優先度なので、jev-preflightを強制的なsecurity gateとして扱うのも違うと考えました。

そのため、API errorやtimeoutが発生した場合は、短いnoticeだけを表示してClaudeの終了を許可します。

jev-preflightは、通過しなければ開発できないgateではなく、終了前に一度だけ振り返るためのguardrailとして設計しています。

git diff HEADではダメだった

最初に困ったのが、今回Claudeが変えたものだけをどう取るかでした。

git diff HEADを使うと、ユーザーがプロンプトを送る前から持っていたstaged・unstaged・untracked changesまで混ざります。
人間が書きかけていたコードを、Claudeの変更として外部APIへ送るのはまずい。

そこで、prompt開始時のworking treeをprivate Git treeとして保存し、Stop時点のtreeと比較しています。

// UserPromptSubmit
tree, err := repo.Snapshot(ctx, store.SnapshotPath())
if err != nil {
    return r.failure(store, "snapshot")
}

st := store.InitialState()
st.BaselineTree = tree
if store.Save(st) != nil {
    return r.failure(store, "state")
}

Stop側では、同じprivate object databaseへcurrent treeを作ります。

// Stop
current, err := repo.Snapshot(ctx, store.SnapshotPath())
if err != nil {
    return r.failure(store, "snapshot")
}

changes, err := repo.Diff(
    ctx,
    store.SnapshotPath(),
    st.BaselineTree,
    current,
)

snapshot用のindexとobject storeは実リポジトリの外に置きます。
実際のindex、refs、objectsへ書き込まないためです。

func (r *Repo) Snapshot(ctx context.Context, privateDir string) (string, error) {
    dir, err := r.privatePath(privateDir)
    if err != nil {
        return "", err
    }

    if err := os.MkdirAll(filepath.Join(dir, "objects"), 0700); err != nil {
        return "", errors.New("cannot create private snapshot")
    }

    // Copy the real index, then write only through the private environment.
    index := filepath.Join(dir, "index")
    if _, err := os.Lstat(index); errors.Is(err, os.ErrNotExist) {
        if err := copyFile(r.Index, index); err != nil {
            return "", errors.New("cannot copy Git index")
        }
    }

    if _, err := r.writeCommand(ctx, dir, filters, "add", "-A", "--", "."); err != nil {
        return "", err
    }
    out, err := r.writeCommand(ctx, dir, filters, "write-tree", "--missing-ok")
    return strings.TrimSpace(string(out)), err
}

実装はinternal/gitstateにあります。
preflight toolを入れたらGitが壊れました。は、さすがに笑えません。

外へ送るdiffは、いったん細くする

tree間のdiffをそのまま送ることもしません。

まずlockfile、build output、vendor、minified asset、document、mediaなどを除外します。その後、よくあるsecret形式をbest-effortでredactし、canonical JSONへ変換してsize limitを確認します。

for _, c := range ordered {
    if !Included(c.Path, exclude) {
        continue
    }

    body := c.Patch
    if c.Binary {
        meta := struct {
            Path    string `json:"path"`
            OldPath string `json:"old_path,omitempty"`
            Status  string `json:"status"`
        }{c.Path, c.OldPath, c.Status}
        encoded, _ := json.Marshal(meta)
        body = "Binary change: " + string(encoded) + "\n"
    }

    body = redact.Text(strings.ToValidUTF8(body, "\uFFFD"))
    patches.WriteString(body)
}

canonical, err := json.Marshal(state)
if err != nil {
    return Result{}, errors.New("invalid normalized diff")
}
if len(canonical) > maxBytes {
    return Result{}, ErrTooLarge
}

hash := sha256.Sum256(canonical)

既定のmaxDiffBytesは64 KiBです。超えた場合は途中で切って送るのではなく、その評価全体をskipします。中途半端なdiffを完全な変更だと誤解させたくなかったためです。

もちろん、redactionはDLPではありません。未知のsecret、file name、business logic、personal dataは残る可能性があります。pluginを有効にすると選択されたdiffがTypeSafeへ送られるので、data boundaryを読んだ上で使う前提にしています。

Jevには「レビュー文」を書かせない

Jevへ渡すのは、固定した8つのyes/no questionです。返ってきた確率から閾値以上を選び、上位3軸だけをClaudeへ渡します。

func Select(scores map[string]float64, threshold float64) []Risk {
    var risks []Risk
    for _, id := range expectedIDs {
        score, found := scores[id]
        if found && score >= threshold && score >= 0 && score <= 1 {
            risks = append(risks, Risk{ID: id, Probability: score})
        }
    }

    sort.Slice(risks, func(i, j int) bool {
        return risks[i].Probability > risks[j].Probability
    })
    if len(risks) > 3 {
        risks = risks[:3]
    }
    return risks
}

長いレビュー文をもう一つ生成するのではなく、Claudeの注意を向ける場所だけを返す。
この小ささがjev-preflightの設計の芯です。

Jevそのものについては、すでに面白い解説がいくつもあります。
僕はJevをClaudeの代わりではなく、Claudeへ再調査をrouteする軽い判定層として使いました。

一番怖いのは無限「もう一回」

Stop hookからadditional contextを返すと、Claudeは再び処理して、またStopへ来ます。
ここで毎回「もう一度調べて」と返すと、Claudeは永遠に帰れません。かわいそうです。

そのため、次の3つでloopを止めています。

  1. Claude Codeから来るstop_hook_active
  2. promptごとに永続化したContinuationCount
  3. normalized diffのLastDiffHash
func (state State) CanContinue(stopHookActive bool) bool {
    return !stopHookActive && state.ContinuationCount == 0
}

func (state State) ShouldEvaluate(hash string, stopHookActive bool) bool {
    return state.CanContinue(stopHookActive) &&
        validHash(hash) &&
        state.LastDiffHash != hash
}

func (state *State) RecordEvaluation(hash string, continuation bool) error {
    state.LastDiffHash = hash
    if continuation {
        state.ContinuationCount++
    }
    return nil
}

API callの前にdiff hashを保存し、feedbackを返す前にcontinuation countを保存します。途中でprocessが落ちても、同じrequestやfeedbackを繰り返しにくくしています。

壊れたときは、Claudeを帰す

v0.1.0はfail-openです。

  • API keyがない
  • timeoutした
  • network errorになった
  • responseが壊れている
  • diffが大きすぎる
  • configが不正

このどれかが起きても、短いnoticeを出してClaudeの終了を許します。

func (r Runner) failure(store *session.Store, class string) Output {
    r.diagnostic(class)

    emit, err := store.Notice(class)
    if err != nil || !emit {
        return Output{}
    }

    return Output{
        SystemMessage: "jev-preflight: skipped (" + class + "); Claude may finish.",
    }
}

Public Betaの外部API連携を、開発を止めるsecurity gateにはしたくありませんでした。
jev-preflightはmerge blockerでも、tests・linters・SAST・secret scannerの代わりでもありません。

release周り

runtimeはGo standard libraryだけで作った単一binaryです。配布対象はこの6つ。

  • macOS: amd64 / arm64
  • Linux: amd64 / arm64
  • Windows: amd64 / arm64

archiveのentry順、timestamp、permission、compression条件を固定し、source path・locale・timezoneが違っても同じSHA-256になるようにしました。
Claude Code marketplace側もversioned archiveのSHA-256をpinしています。

使ってみる

v0.1.0 Public Betaを公開しています。

claude plugin marketplace add muse0509/jev-preflight
claude plugin install jev-preflight@jev-preflight
claude plugin enable jev-preflight@jev-preflight

Claude Code 2.1.257以降、Git、Bash、TypeSafe API keyが必要です。WindowsではGit for WindowsのGit Bashを使います。

API keyはClaude Codeの/plugin UIにあるsensitive optionへ設定できます。repositoryやcommand argumentへ書かないでください。

repositoryごとの任意設定は.jev-preflight.jsonです。

{
  "mode": "assist",
  "riskThreshold": 0.85,
  "timeoutMs": 2000,
  "maxDiffBytes": 65536,
  "exclude": ["dist/**", "vendor/**"]
}

assistは一度だけ再調査、reportは結果表示だけ、offはsnapshotもAPI callも行いません。

まだできないこと

正直に、v0.1.0でできないことも書いておきます。

  • scoreは欠陥の証明ではない
  • 既定の0.85は未calibration
  • 誤検知も見逃しもある
  • redactionはDLPではない
  • API障害時はfail-openする
  • diffが大きすぎる場合は評価しない
  • 自動修正やmerge blockingはしない

このpluginの価値は「安全を保証する」ことではなく、Claudeが自信満々に帰る前に、怪しい場所へもう一度だけ視線を戻すことだと思っています。

おわりに

今回で一番学んだのは、AIのscoreを出す部分より、その前後の境界を作る方がずっと大変だということでした。

どの差分を送るのか。何を送らないのか。何回までやり直させるのか。
壊れたら止めるのか、通すのか。検証できなかったことを、できたように書かないか。

AI agentが速くなるほど、こういう小さな制約の積み重ねが効いてくる気がしています。

Repository: https://github.com/muse0509/jev-preflight

Issueやfeedback、お待ちしています。

jev-preflightは独立したopen-source projectであり、TypeSafe AIおよびAnthropicとは提携・承認関係にありません。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?