公開してから気づいた、自分のコードに残っていた 3 つの穴(KIOKU v0.4.0)

はじめに
Claude Code / Desktop の記憶 OSS「KIOKU」を公開してから数日、v0.2 と v0.3 で PDF と URL の取り込み機能を入れました。
そこから先、機能をさらに足すよりも先にやりたいことがありました。自分が書いたコードを、OSS の目線でもう一度読み直すことです。
その結果できあがったのが v0.4.0 で、これは 新機能ゼロのリリース です。ただし、修正した内容はどれも「見つからなくてよかった」という重さがあるものでした。
この記事では、v0.4.0 で直したもののうち、特に「見つけたときにヒヤッとした 3 件」を書きます。機能紹介ではなく、OSS として公開したあとに見えてきた穴の話 です。
Mac mini で 5 日間、git push が silent に失敗していた話
一番冷や汗をかいたのはこれでした。
KIOKU は Obsidian Vault を Git で複数マシン間同期します。MacBook と Mac mini の両方で KIOKU を走らせていて、それぞれが auto-ingest.sh で git commit && git push をしている。なので、片方のマシンで書いた知見がもう片方に反映されることが前提です。
ところが、ある日ふと Mac mini 側のログを眺めたら、5 日間 push が通っていない ことがわかりました。
何が起きていたか
auto-ingest.sh は git commit → git push の順で実行します。commit は成功していました。reflog を見ると、確かにコミットは積まれていた。
問題は push のほうでした。
$ cd $OBSIDIAN_VAULT
$ git status
HEAD detached at abc1234
detached HEAD だった。
いつ detached になったのか正確にはわかりませんが、おそらく数日前に Obsidian 側で何らかの操作(rebase 中断か、ブランチ切り替えの途中で諦めたとか)があり、HEAD がブランチから外れた状態のままになっていた。
detached HEAD 状態で git commit は成功します。HEAD が動くだけなので。でも git push は どのブランチに push するかを決められない ので silent に失敗する。エラーコードで返るけど、auto-ingest.sh 側は || true で受けていたので、ログには何も出ない。
結果として、commit は reflog に積まれ続け、remote には一切反映されない 状態が 5 日間続いていました。
修正: git symbolic-ref によるガード
修正自体は小さいです。auto-ingest.sh / auto-lint.sh / install-hooks.sh の SessionEnd で、git 処理の前に HEAD の状態を確認する。
if ! git symbolic-ref -q HEAD >/dev/null 2>&1; then
echo "WARNING: Vault is in detached HEAD state. Skipping git commit/push." >&2
echo "Recovery: run 'git checkout main' in the Vault." >&2
# git 書き込みは全部スキップ
return 0
fi
git symbolic-ref -q HEAD は、HEAD がブランチを指していれば refs/heads/<branch> を返し、detached ならエラーで返る。これで detached を検出できる。
考えたこと
この問題のイヤなところは、「エラーが出ないエラー」 だということです。git commit は成功、git push も exit code だけ見れば失敗と分かるけど、フェイルセーフ(|| true)で受けていたので沈黙する。
KIOKU は session-logs/ や wiki/ を git で同期することを前提に設計されているので、sync が 5 日間止まっている状態は second brain としての信用を削る。マシン間で知見が食い違う、片方のマシンで議論した内容がもう片方に出てこない。
「動いているように見える」だけで「正しく動いている」とは限らない、というのを一番痛感したケースでした。
ログに出ていない = 問題ない、ではないんです。フェイルセーフは便利ですが、何を silent に握りつぶしているか は意識する必要がある。これは今後コードを書くときに持ち続けたい視点です。
MCP の lock が 4 分以上保持されていた話
これは性能の問題ですが、設計として醜かった話です。
KIOKU の MCP server は、Vault への書き込みで排他をとるために $VAULT/.kioku-mcp.lock という lockfile を使っています。auto-ingest.sh(cron 起動)と MCP tool(Claude Desktop / Code 起動)が同じ Vault を触るので、それらを直列化するための仕組みです。
withLock というヘルパーで lock 取得 → 処理 → lock 解放 をラップする、よくあるパターン。
問題は、kioku_ingest_url が PDF URL を処理する経路にありました。
何が起きていたか
kioku_ingest_url は、URL の Content-Type が application/pdf だった場合、kioku_ingest_pdf に処理をディスパッチします。この実装が、以下のような構造になっていました:
await withLock(vault, async () => {
// ...URL fetch, PDF save...
// Content-Type が PDF だった場合
if (isPdf) {
await handleIngestPdf(vault, { path: savedPath }, { skipLock: true });
// ^^^^^^^^^^^^^^
// 「lock は既に持ってるから二重取得しないで」
}
});
outer の withLock が lock を保持したまま、inner で PDF の抽出・要約を全部走らせる構造です。
大型 PDF だと、poppler の pdftotext が数十秒、各 chunk の summarize が数十秒〜数分かかります。50 ページの PDF で実測すると lock 保持時間が 4.5 分 になっていました。
その間、cron の auto-ingest も、他の MCP tool も、全部 lock 取得待ちで詰まる。
修正: outer lock を先に release する
リファクタの要点は、「PDF の disk 書き込みまで」を withLock の範囲にして、それ以降の重い処理は lock 外に出すことでした。
const phase1Result = await withLock(vault, async () => {
// URL fetch して PDF を raw-sources/ に書く(数秒で終わる)
return { savedPath, needsPdfDispatch: true };
});
// ← ここで lock は release されている
if (phase1Result.needsPdfDispatch) {
// handleIngestPdf は自前で withLock を取得する
await handleIngestPdf(vault, { path: phase1Result.savedPath });
}
構造を変えたら、skipLock injection そのものが不要になりました。API からも削除しています。
考えたこと
skipLock という injection が存在していた時点で、設計として何かおかしいサインだったと思います。「lock の二重取得を避けるためのフラグ」は、呼び出し側が lock の保持状態を把握している前提 に立っている。これは責務の漏れです。
正しくは「lock を持っていない前提で呼び出せる API」にしておいて、必要なら内部で取得し直す。こっちのほうが、呼び出し関係が深くなっても破綻しない。
公開前の段階では「自分しか呼ばないから skipLock を渡せば十分」で済ませていた。OSS として公開すると、誰かが違う経路で handleIngestPdf を呼ぶ可能性があります。そうなると skipLock は罠になる。
「自分しか使わないから」で妥協した抽象化は、公開後に技術的負債として可視化される というのを、これもまた実感した修正でした。
Hook 層の隠れたバイパス穴
最後は地味ですが、一番怖かった話です。
KIOKU は Claude Code の Hook で session を記録するとき、プロンプトやツール出力に含まれる API キーやトークンをマスクします。sk-ant-... を sk-ant-*** に置換する、みたいなやつです。
この masking は MASK_RULES という正規表現の配列で定義されていて、Anthropic / OpenAI / GitHub / AWS / Slack などのトークンパターンが並んでいます。
session を GitHub のリポジトリ(Private 設定)にコミットするので、ここが漏れると永久に履歴に残る。要するに絶対に外せない処理です。
バイパス穴 1: zero-width space で素通り
v0.4.0 で Hook 層を改めて監査して、気付いたことがあります。
sk-ant-abcdefghijklmnopqrstuvwxyz
↑
U+200B (ゼロ幅スペース)
sk-ant- と本体の間に ゼロ幅スペースを 1 文字挟む と、正規表現 /sk-ant-[A-Za-z0-9_-]{20,}/ にマッチしないんです。視覚的には普通の sk-ant-... に見えるのに、マスクが効かない。
実際にこのパターンが session ログに入ることがあるかどうかは怪しいですが、ありうるシナリオとしては:
- 誰かが悪意を持ってプロンプトに混入する
- エディタの貼り付けで不可視文字が紛れ込む
- Markdown の前処理で zero-width space が挿入される
要するに、正規表現マッチだけではマスク処理として不十分 だった。
バイパス穴 2: frontmatter の YAML injection
session log は --- で囲まれた YAML frontmatter を持ちます:
---
session_id: abc123
cwd: /Users/me/project
---
ここに入る値は、hook から渡される実行環境の情報です。ほとんど信頼できる値ですが、cwd のような値は 改行を含みうる。
もし攻撃者が cwd に以下のような値を仕込めたら:
/tmp/x\n---\ntype: injected\nrelated: ["/etc/passwd"]
frontmatter は途中で閉じられて、偽の type / related キーが注入される。後段のパイプラインが frontmatter を信じて処理するなら、影響が出る可能性があります。
バイパス穴 3: KIOKU_NO_LOG の strict equality drift
KIOKU は claude -p から再帰的に呼ばれることがあります。auto-ingest が claude を起動 → それが Hook を発火 → また auto-ingest を起動... という無限ループ防止に、KIOKU_NO_LOG=1 という env var でガードしていました。
if (process.env.KIOKU_NO_LOG === '1') return;
問題は、'1' との strict equality であること。KIOKU_NO_LOG=true や KIOKU_NO_LOG=yes でガードが効かない。OSS として公開したあと、誰かが直感的に =true と書いた瞬間、silent に再帰ループが復活する。
修正: 正しい defense in depth
3 つとも修正しました。
- masking: 正規表現マッチ前に
INVISIBLE_CHARS_REでゼロ幅文字を剥がす + NFC 正規化 - frontmatter:
yamlSafeValue()で制御文字と YAML 構造文字を除去してからクオート - env check:
envTruthy()で1 / true / yes / onすべてに対応(case-insensitive)
考えたこと
これらのバグは、実害が出ていたわけではないです。ゼロ幅スペース込みのトークンが実際に流れ込んでいたとか、YAML injection が試みられていたとか、そういう報告はない。全部「起きうる」レベルの話です。
でも、セキュリティの穴は「起きたら終わり」なので、「起きうる」段階で潰しておきたい。特にトークンマスクは、漏れた瞬間に GitHub の commit history に永久に残る。取り消しが効かない。
OSS として公開していなかったら、「まあ自分しか使わないし」で後回しにしていた可能性があります。自分ひとりで使ってるなら、自分が sk-ant- の後にゼロ幅スペースを仕込まないし、攻撃者が自分の cwd を細工することもない。
公開されている、という一点で、「起きうる」を「起きるかもしれない」として扱う必要が出てくる。レビューの重みが違う。これは頭では分かっていたつもりだったけど、実際に Hook 層を舐め直して「これ穴だな」というのを 3 つ見つけて、初めて体で理解しました。
それ以外に直したこと
上の 3 件が個人的に一番ヒヤッとしたものですが、v0.4.0 では他にも修正を入れています。
-
A#1:
@mozilla/readabilityを 0.5 → 0.6 にアップグレード(ReDoS GHSA-3p6v-hrg8-8qj7 の修正) -
B#2: cron / setup scripts の環境変数オーバーライド規約を
tests/cron-guard-parity.test.sh(17 assertions)で形式化 -
B#3:
sync-to-app.shの cross-machine race をcheck_github_side_lock(α guard、120 秒窓、KIOKU_SYNC_LOCK_MAX_AGEで設定可能)で回避 - B#8: i18n parity — README の §10 MCP / §11 MCPB / Changelog セクションを残り 8 言語にも反映(+1,384 行)
- テスト: 299 Node テスト + 15 Bash スイート / 415 assertions すべて green
詳細は v0.4.0 の Release Notes にまとまっています。
学んだこと: 「動いてる」は「正しく動いてる」じゃない
3 件を通して共通する学びがあるとしたら、この一文に尽きます。
- Mac mini は「動いていた」(commit は積まれていた)けど、正しくは動いていなかった(push が届いていなかった)
- MCP server は「動いていた」(lock は取得されていた)けど、正しくは動いていなかった(4 分間他を詰まらせていた)
- Hook は「動いていた」(マスク処理は走っていた)けど、正しくは動いていなかった(ゼロ幅スペースで素通りしていた)
3 件とも、表面的には何も壊れていない。エラーログも出ていない。使用感も普段と変わらない。でも、裏では信用が削れている。
個人で使うだけなら「動いてるからヨシ」で済む。チームで使っていれば、「動いてるけど挙動が読めない」時点で誰かが指摘してくれる。OSS は使っている人の顔が見えない分、自分で自分のコードに対して厳しくなるしかない。「これで十分」と思った後に「まだあるかも」と引き返す、その根気だけが頼りです。
v0.5 に向けて
v0.4.0 で土台は整ったので、次は機能に戻ります。近いところで考えているのは:
-
Pluggable LLM backend: auto-ingest の
claude -pを OpenAI / Ollama に差し替え可能にして、Max プラン必須の制約を緩める - Morning Briefing: 朝に「昨日の ingest 結果」を 1 通のサマリーで出す
- Team Wiki: session-logs はローカル、wiki/ を Git で共有する形のチーム運用
ただ、機能を増やすたびに、また v0.4 のような「公開しないと見えない穴」が出てくるはずです。それは仕方ない。そのたびに拾って、潰して、次に進むしかない。
OSS は公開したら終わりではなくて、そこから 「見つけたら直す」の繰り返しを長く続けるフェーズ が始まる。v0.4.0 は、そのフェーズに入った最初のリリースになりました。
まとめ
- v0.4.0 は新機能ゼロ、既存コードの再監査とセキュリティ / 運用修正だけのリリース
- 3 つのヒヤッと話: 5 日間の detached HEAD / 4 分間の lock 保持 / Hook 層のバイパス穴
- どれも「表面的には動いていた」が「正しくは動いていなかった」もの
- OSS として公開することで、「起きうる」を「起きるかも」として扱う重みが変わる
- MIT License、フィードバック歓迎です
他のプロダクト
こんにちは、季節より。 / hello from the seasons.
季節の写真を集めたギャラリーサイトです。作者が撮影した四季折々の写真を眺められるだけでなく、自分の画像と季節の写真を AI で合成する機能もあります。
写真が好きで、AI で遊ぶのも好き、という個人的な興味から作りました。
作者: @megaphone_tokyo
コードと AI で何かつくる人 / フリーランスエンジニア 10 年目
Discussion