はじめに
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です。
処理の流れは、次のようになっています。
- ユーザーがpromptを送った時点のworking treeを保存する
- Claudeがコードを編集する
-
Stophookで現在のworking treeをもう一度保存し、turn差分を作る - 差分をfilter・redactしてJev APIへ送る
- 閾値を超えた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を止めています。
- Claude Codeから来る
stop_hook_active - promptごとに永続化した
ContinuationCount - 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とは提携・承認関係にありません。
