Claude Media
Claude Codeでipynbの出力に残る機密を止める — nbstripoutとhookの二段構え

Claude Codeでipynbの出力に残る機密を止める — nbstripoutとhookの二段構え

ipynbの出力セルにはAPIキーや個人情報が残ります。コミットにはnbstripoutのgit filter、Claudeの読み取りにはPreToolUse hookと、止める場所を分けて設定する手順です。

ipynbの出力セルに残ったAPIキーや個人情報は、2つの経路で外へ出ます。1つはgit commitで履歴に入る経路、もう1つはClaude Codeが出力ごとノートブックを読む経路です。止める場所が違うので、道具も分けます。

コミット側は、nbstripoutのgit filterが担当します。読み取り側は、PreToolUse hookでReadを止めます。両方を入れて、ようやくノートブックの出力が外に出る道を塞げます。

出力は「コミット」と「Read」の2か所から漏れる

.ipynbはJSONで、ソースコードと実行結果の出力が同じファイルに入ります。print(os.environ['API_KEY'])を実行したセルは、キーの値がoutputsに保存されます。

Claude CodeのReadツールは、.ipynbを開くとコード・Markdown・グラフなどの出力を含む全セルを返します。出力つきの内容がそのままモデルのコンテキストに入ります。

コミットのほうは、git addした時点で出力が履歴に入ります。一度pushすると、シークレットをコミットした後の対応は失効と履歴除去まで膨らみます。

経路漏れ方止める道具
git commit漏れ方出力セルが履歴とリモートに残る止める道具nbstripoutのgit filter
Claudeの読み取り漏れ方出力がコンテキストに入る止める道具PreToolUseのReadフック
コミット直前の取りこぼし漏れ方filter未設定のクローンで出力が入る止める道具git commitフックによる検査

gitleaksのような検出ツールは、出力に混ざった鍵の文字列を見つける用途で併用できます。ただ、出力を剥がしてしまえば検出対象そのものが減ります。

nbstripoutで、コミットに入る出力を剥がす

nbstripoutはノートブックから出力と一部のメタデータを取り除くツールです。git filterとして入れると、作業ツリーのファイルには出力を残したまま、gitが見るコミット内容だけから出力を消します。

pip install --upgrade nbstripout
nbstripout --install
nbstripout --status

--installは.git/configにフィルタを書き、属性を.git/info/attributesに書きます。属性をリポジトリに含めたいときは、nbstripout --install --attributes .gitattributesを使います。

手元のnbstripout 0.9.1で、ダミーの鍵を出力に持つノートブックを試しました。git addしてgit commitしたあと、コミット側と作業ツリー側で鍵の文字列を数えた結果は次のとおりです。

git show HEAD:a.ipynb | grep -c 'sk-test'   # コミット側
grep -c 'sk-test' a.ipynb                    # 作業ツリー側
0
1

上から順に、コミット側と作業ツリー側の件数です。

Claudeがディスク上のファイルを読めば、鍵はそのまま見えます。filterだけでは、読み取り側の漏れは止まりません。

filterが効かなくなる条件

フィルタの設定は.git/configに書かれるため、クローンごとの設定です。新しくクローンしたらnbstripout --installをやり直します。.gitattributesをコミットして共有できるのは属性だけで、フィルタの定義は各クローンで入れ直す必要があります。

pre-commitフレームワーク版のhookは、剥がした結果を作業ツリーにも書き戻します。filter方式と違い、手元のファイルから出力が消える点に注意が必要です。

Readが返すのは出力つきの全セル — PreToolUseで止める

Claudeに出力なしのノートブックを読ませるには、.ipynbへのReadを拒否し、nbstripoutの標準出力を読む道へ誘導します。nbstripout -tはファイルを書き換えず、剥がした結果を標準出力に出します。

hookの拒否理由はClaudeに伝わるので、代わりに実行するコマンドをそこに書いておきます。

#!/bin/bash
# .claude/hooks/ipynb-read-guard.sh
FILE=$(jq -r '.tool_input.file_path // empty')
 
jq -n --arg f "$FILE" '{
  hookSpecificOutput: {
    hookEventName: "PreToolUse",
    permissionDecision: "deny",
    permissionDecisionReason: (
      "出力つきのipynbは直接読めません。"
      + "Bashで nbstripout --keep-id -t " + $f
      + " を実行して、出力なしの内容を読んでください。"
    )
  }
}'

settings.jsonでは、Readだけにこのhookを掛けます。ifにRead(**/*.ipynb)を書くと、.ipynb以外のReadでは起動しません。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read",
        "hooks": [
          {
            "type": "command",
            "if": "Read(**/*.ipynb)",
            "command": "\"${CLAUDE_PROJECT_DIR}\"/.claude/hooks/ipynb-read-guard.sh"
          }
        ]
      }
    ]
  }
}

chmod +xで実行権限を付け、jqを入れておきます。拒否の書き方は、PreToolUseのpermissionDecisionに"deny"を入れる形です。詳しい出力の種類はPreToolUse hookの解説にあります。

--keep-idを付ける理由

--keep-idは、セルIDを連番に振り直さない指定です。nbstripoutは既定でIDを連番にします。

先ほどのノートブックはセルIDがabc123でした。手元でnbstripout -tにかけると"0"になり、--keep-idを付けるとabc123のままでした。

NotebookEditはセルをcell_idで指定して書き換えます。読んだ内容のIDが実ファイルとずれると、別のセルを上書きする原因になります。NotebookEditの使い方はJupyter Notebookの編集に詳しく書きました。

@参照にはhookが掛からない

PreToolUseは、Claudeがツールを呼んだときだけ動きます。プロンプトに@notebooks/eda.ipynbと書くと、Claude Codeはツール呼び出しなしでファイルの中身を挿入するため、Readのhookは起動しません。

対策は2つあります。

  • Readの拒否ルールで、対象のパスを塞ぐ
  • CLAUDE_CODE_DISABLE_FILE_MENTIONS=1で、@参照の挿入そのものを止める

後者はv2.1.299以降で、シェルかユーザー設定・managed settingsに書いたときだけ有効です。プロジェクト設定とローカル設定に書いても無視されます。

git commitの前に、ステージ済みの出力を検査する

filterを入れ忘れたクローンでは、出力つきでコミットされます。コミット直前に、ステージ済みの内容を検査するhookを足しておくと、取りこぼしを止められます。

#!/bin/bash
# .claude/hooks/ipynb-commit-guard.sh
bad=0
while IFS= read -r f; do
  if ! git show ":$f" | nbstripout | cmp -s - <(git show ":$f"); then
    echo "出力が残っています: $f (nbstripout --install を実行してください)" >&2
    bad=1
  fi
done < <(git diff --cached --name-only --diff-filter=ACM -- '*.ipynb')
exit $((bad ? 2 : 0))

ステージ済みの内容を標準入力でnbstripoutに通し、元と一致しなければ出力が残っています。終了コード2はPreToolUseのブロックで、標準エラーの文面がClaudeに渡ります。

{
  "matcher": "Bash",
  "hooks": [
    {
      "type": "command",
      "if": "Bash(git commit *)",
      "command": "\"${CLAUDE_PROJECT_DIR}\"/.claude/hooks/ipynb-commit-guard.sh"
    }
  ]
}

これは上のPreToolUse配列に、もう1要素として足します。ifは1つの権限ルールしか書けないので、条件ごとにhookを分けます。

フィルタが入っているクローンでは、ステージ済みの内容がすでに剥がされており、このhookは何も言わず通ります。入っていないクローンでだけ働く安全網です。

比較に使った出力が、nbstripoutを2回通しても変わらないこと(冪等)は、手元の0.9.1で確かめました。別バージョンで使うときは、剥がし済みのノートブックで一度試してください。

CIでの最終防衛線は、nbstripout側にGitHub Actionが用意されています。ローカルのfilterとCIの両方を入れる構成が、取りこぼしの多い「filter未設定のクローン」への備えです。

matcherと権限ルールの書き方で、効かなくなる箇所

ノートブックまわりのhookと権限は、見た目の直感とずれる箇所が3つあります。

落とし穴

matcherのEdit.*はNotebookEditまで含む

Edit.*のように記号を含むmatcherは正規表現として扱われ、EditとNotebookEditの両方に当たります。Editだけにしたいときは^Edit$と書きます。

落とし穴

NotebookEdit(path)のルールは判定に使われない

ファイルの権限判定はEdit(path)とRead(path)だけを見ます。NotebookEdit(notebooks/**)と書いても受理されますが、一致しません。ノートブックの編集を制限するときはEdit(notebooks/**)を使います。

落とし穴

Readの拒否ルールはNotebookEditを止めない

通常のファイルではReadの拒否ルールがEditとWriteも止めます。NotebookEditはこの対象外なので、書き込みも止めたいパスにはEditの拒否を足します。

Readを拒否するhookにすると、Claudeは.ipynbを読めません。Writeでノートブックを丸ごと上書きする場合は、どのモデルでも事前の読み込みが必須です。Readを拒否した構成では、セルの書き換えをNotebookEditに任せる運用が前提になります。

hookを設定したあと、止まるかを手元で確かめる

hookスクリプトは標準入力でJSONを受け取るだけなので、Claudeを起動しなくても動作を確かめられます。ダミーの入力を流して、拒否のJSONが返るかを見ます。

echo '{"tool_input":{"file_path":"/path/to/eda.ipynb"}}' \
  | .claude/hooks/ipynb-read-guard.sh

permissionDecisionがdenyのJSONが返れば、スクリプト側は正しく動いています。コミット検査のほうは、出力つきのノートブックをgit addして、スクリプトを直接実行します。終了コードが2なら、検査が働いています。

そのうえでClaude Codeから、.ipynbを読ませる依頼を出します。Readが拒否され、Bashでnbstripout --keep-id -tを使う流れになれば、設定は反映されています。反映されないときは、/hooksのメニューで該当のhookが読み込まれているかを見ます。

出力を残したいノートブックの例外

学習用のノートブックなど、グラフの出力を残したいケースもあります。nbstripoutには、剥がす範囲を絞るオプションがあります。

目的指定
大きな出力(画像など)だけ剥がす指定--max-size 1k
実行結果は残し、エラーと標準出力を剥がす指定--keep-output-type execute_result
標準エラーだけ剥がす指定--drop-output-type stream:stderr
特定セルの出力を残す指定セルにkeep_outputタグ、またはメタデータ"keep_output": true

APIキーが出る可能性があるのは標準出力と例外メッセージです。--keep-output-type execute_resultは、printの出力が剥がれる一方で最後の式の値は残ります。式の値にも機密が出るノートブックでは、この指定は使えません。

keep_outputをノートブック全体のメタデータに付けると、全セルの出力が残ります。CIでの検査から外れる経路になるため、機密を扱うリポジトリでは付けないと決めておくほうが安全です。

どこまで防げて、どこから防げないか

nbstripoutが取り除くのは出力と一部のメタデータです。ソースセルに直書きしたキーは、剥がしても残ります。

認証情報そのものをClaudeから隠す仕組みは、別の層の話です。認証情報マスキング(mask)の設定は、サンドボックス内の環境変数を伏せ字にします。ノートブックの出力は、このマスキングの対象には含まれません。

層守るもの守れないもの
nbstripoutのgit filter守るものコミットに入る出力守れないもの作業ツリーのファイル、Claudeの読み取り
ReadのPreToolUse hook守るものReadツール経由の出力守れないもの@参照、hookを通らないBash経由の読み取り
git commitの検査hook守るものfilter未設定のクローン守れないものClaude以外が打つコミット
nbstripoutのGitHub Action守るものpushされたノートブック守れないものすでに履歴にある出力

cat notebook.ipynbのようなBashでの読み取りは、Readの拒否ルールが認識できるファイルコマンドに入ります。hookとは別に、Read(**/*.ipynb)の拒否ルールで二重に塞ぐこともできます。

まとめ

ノートブックの出力に機密が混ざる経路は、コミットと読み取りの2つで、止める道具は別です。コミットはnbstripoutのfilter、読み取りはReadを止めるPreToolUse hookが受け持ち、取りこぼしはコミット直前の検査とCIで拾います。

最初に入れる1本を選ぶなら、nbstripoutの--installです。履歴に入った鍵は、あとから消すコストが桁違いに大きいためです。Claudeの読み取りは、機密を出力に出すノートブックがあるリポジトリから、hookを足していけば足ります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/hooks
  2. code.claude.com/docs/en/tools-reference
  3. code.claude.com/docs/en/permissions
  4. github.com/kynan/nbstripout
この記事を共有:XはてブLinkedIn