Skip to content

release: PLAN35 環境変数ファイルの暗号化 - #90

Merged
takemi-ohama merged 8 commits into
mainfrom
release/PLAN35
Aug 15, 2026
Merged

takemi-ohama merged 8 commits into
mainfrom
release/PLAN35

Conversation

@takemi-ohama

@takemi-ohama takemi-ohama commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

devbase が扱う認証情報を、保存時に暗号化して持てるようにする。

これまで、クラウドのアクセスキー・コード管理サービスの個人アクセストークン・各種 AI サービスの API キーは、すべて平文のテキストファイルとして開発者のマシン上に置かれていた。権限は 0600 に絞られているが内容は平文であり、ディスク・バックアップ・クラウド同期フォルダ・リポジトリへの誤コミット・画面共有といった経路から流出しうる状態だった。

暗号処理は自前で実装せず、既に依存関係に含まれている age の実装を使う。暗号化しない運用も引き続き可能で、移行は明示的なコマンドで行い、いつでも平文へ戻せる。

中心にある課題

機密を読む経路は 2 つあった。起動ラッパーがシェルで読み込む経路と、各プロジェクトの構成ファイルが env_file で直接参照する経路である。前者はシェルから読める場所に平文が必要で、後者は暗号文をそのまま値として読んでしまう。ファイルを暗号化するだけでは、コンテナに壊れた値が入って起動が失敗する。 この 2 経路の切り替えが本変更の中身になる。

利用者 → 起動ラッパー (非機密のみ) → devbase 本体 → 秘密ストア (復号)
                                          ↓
                          合成した環境変数 + 変数名だけの構成 → Compose → コンテナ

恒久的な平文ファイルを新たに作らない。 復号結果はプロセスのメモリ上だけで合成し、コンテナへは変数名だけを列挙した構成で渡す。値は devbase 自身の環境変数から解決されるため、暗号文も平文ファイルも Compose には渡らない。

変更内容

保存先

参照 暗号化しない場合(既定) 暗号化した場合
共通の機密 $DEVBASE_ROOT/.env $DEVBASE_ROOT/secrets/global.env.age
プロジェクトの機密 projects/<name>/.env secrets/projects/<name>.env.age

どちらを使うかはファイルの存在で自動判定する。同じ参照に両方が存在する状態は、どちらが最新か判断できないため明示的なエラーにする。

日々の操作は変わらない

devbase env init / sync / set / get / delete / edit / list の操作性はそのまま、保存先だけが切り替わる。一覧表示では暗号化された保存先に印が付く。暗号化された設定の編集は、復号結果を自分専用のディレクトリへ書いて編集させ、暗号化し直してから必ず削除する。

追加したコマンド

コマンド 内容
devbase env keygen devbase 専用の鍵を生成する
devbase env encrypt / decrypt 平文と暗号化構成のあいだを往復する
devbase env rekey 誰が復号できるかを変え、既存の機密を暗号化し直す
devbase env doctor 端末上に残る平文と、除外設定の穴を点検する
devbase env exec 復号した機密を環境変数として渡してコマンドを実行する

失うことのないように

暗号化は「鍵を失えば復旧できない」性質を持つ。壊し方を減らすことに重点を置いた。

  • 鍵とファイルの書き込みは原子的: 既存を上書きする書き込みは一時ファイル経由で差し替え、途中で失敗しても旧内容がそのまま残る。鍵の新規生成は排他的に行い、並行実行で先に作られた鍵を無確認で上書きしない
  • 読み戻せることを確認してから平文を退避する: 暗号化した直後に復号し、元と一致した対象だけ平文を移す。鍵の指定を誤ったまま平文を失うと、誰にも復号できないファイルだけが残る
  • 途中で失敗しても中間状態を残さない: 移行と鍵の更新は、実行した操作ごとに取り消し手続きを積み、どこで失敗しても逆順に巻き戻す
  • 無言で消さない: 元の平文はバックアップへ退避し、削除は利用者に委ねる。消し忘れは点検コマンドが指摘し続ける
  • 原文をそのまま保つ: 平文のバイト列をそのまま暗号化するため、コメント・空行・export 表記も往復で失われない

構成ファイルの書き換え

各プロジェクトの構成から機密ファイルの参照を外す作業は、行のコメントアウトで行う。YAML として読み書きし直すと利用者のコメントや整形が失われること、そして元の行が残っていれば平文へ戻す操作で機械的に復元できることによる。書き換え前には差分を提示する。

env_file の書き方は複数あり、行単位の走査だけでは未知の記法を取りこぼしうる。取りこぼしたまま平文を退避すると、存在しないファイルを参照したまま成功してしまうため、書き換え後を YAML としてパースし直し、機密参照が残っていないことを確認してから先へ進む。

守れること / 守れないこと

守れること: 端末のディスク上に残る保存ファイル、バックアップ、クラウド同期フォルダ、ファイル転送中、リポジトリへの誤コミット、画面共有時の誤表示。

守れないこと: 最終的にコンテナへは環境変数として平文で渡る。コンテナの詳細情報を参照できれば注入済みの環境変数は読め、devbase は開発コンテナに Docker の制御ソケットを渡す構成を既定に含むため、コンテナ内から他コンテナの環境変数も参照できる。構成の確認コマンドは変数名の列挙を実際の値へ解決して表示する。既定の鍵保管では、端末上で利用者権限を得た攻撃者は鍵も同時に読める。

これらは docs/user/env-encryption.md に明示している。

Test plan (結合観点)

  • 複数プロジェクトを含む構成で、平文 → 暗号化 → 平文の往復が非破壊で完了する(構成ファイルがバイト単位で元に戻る)
  • 台数を増やした構成でも機密の受け渡しが壊れない(各インスタンスに変数名が列挙され、生成物に値が残らない)
  • 共通設定だけを参照していたサービスに、プロジェクト専用の機密が渡らない
  • 元の構成が持っていた非機密の設定値が、生成後も失われない
  • 暗号化構成で export / import が往復し、取り込みが平文ファイルを作らない
  • 移行していない環境では従来どおり動作する(構成の参照が保持され、値も漏れない)
  • 起動ラッパーが機密ファイルを読み込まない
  • 点検コマンドが、退避された平文・除外設定の穴・鍵の権限を検出する
  • 全 1221 件のテストが green / lint パス
開発用: 個別 PR 進捗 (レビュー対象外)

いずれも codex / gemini の両方が APPROVE に収束するまでレビューを回した。plan は issues/plan35.md。段階 6 (SOPS を差し替え先として実装) は本 release のスコープ外。

takemi-ohama and others added 4 commits August 12, 2026 14:33
* chore: PLAN35-secret-store Draft PR 作成

* feat: 機密の保存先を抽象化する秘密ストアと age 実装を追加

平文の .env に直接置かれていた機密の保存先を 1 箇所に閉じ込め、平文と
age 暗号化を差し替え可能にする層を追加する。上位の設定操作からは load /
save だけが見え、どちらの形式で保存されているかは意識しなくてよい。

- 保存先はファイルの存在で自動判定する。暗号化ファイルがあればそれを使い、
  無ければ平文を使う。同じ参照に両方が存在する状態はどちらが正か判断
  できないため、黙って一方を採用せず明示的に停止する
- devbase 専用の age 鍵を扱う層を分ける。export / import が使う ~/.ssh の
  鍵とは失効・保管・バックアップの扱いが異なるため流用しない
- 公開鍵は鍵ファイル中のコメントではなく秘密鍵から都度導出する。コメントは
  手で書き換えられるため、信じると受信者と実鍵が食い違ったまま暗号化される
- 受信者リストは公開鍵しか含まないが 0600 で保護する。第三者が自分の鍵を
  追記できると以後の暗号化がその相手にも復号可能になるため、機密性ではなく
  改竄防止のために権限を絞る
- プロジェクト名はそのままファイル名になるため、パス区切りを含む名前を拒否
  して保存先ディレクトリの外へ書き出せないようにする

devbase env keygen を新設し、鍵の生成と受信者リストへの登録、鍵を失うと
復旧できない旨のバックアップ喚起までを 1 コマンドで済ませる。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: age 鍵のローテーションを原子的にして旧鍵の消失を防ぐ

`devbase env keygen --force` は、既存鍵を `O_TRUNC` で直接上書きし、その後で
受信者リストを更新していた。このため次の 2 つの経路で「旧鍵は失われたのに
新しい状態も揃っていない」復旧不能な状況が起こりえた。

- 鍵の書き込みが途中で失敗する (ディスク枯渇・強制終了など) と、旧鍵だけが
  消えて既存の暗号文を誰も復号できなくなる
- 鍵の差し替えに成功しても、その後の受信者リスト更新が失敗すると旧鍵は
  戻らず、新公開鍵も受信者に載らない

対応:

- `io_common.write_secure_bytes_atomic` を追加。同一ディレクトリの一時ファイルへ
  0600 で書いて fsync し、`os.replace` で差し替えたうえでディレクトリも fsync
  する。失敗時は一時ファイルを掃除し、旧内容をそのまま残す。
- `agekeys.generate_key_file` / `save_recipients` をこの atomic 書き込みに変更。
  受信者リストも欠けたまま残ると暗号化対象から一部の受信者が黙って外れるため。
- `cmd_env_keygen` は鍵と受信者リストの中身を事前に控え、鍵生成〜受信者リスト
  更新のいずれかが失敗したら両方を元の状態へ書き戻してからエラーを返す。
  複数ファイルにまたがる更新は個々の書き込みが atomic でも原子的にならないため。

テストは差し替え失敗時に旧鍵・旧受信者リストが無傷で残ること、権限が 0600 の
ままであること、一時ファイルが残らないこと、`cmd_env_keygen` のロールバックを
確認する。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: 機密の保存を atomic 化し、keygen の生成先と受信者リストの契約を整理

秘密ストアと鍵生成まわりで「失うと復旧できないもの」を壊しうる 3 点を直す。

- secret_store の save を write_secure_bytes_atomic へ切り替え
  AgeBackend.save / PlaintextBackend.save が既存ファイルを直接 O_TRUNC して
  いたため、ディスク枯渇や中断で旧 ciphertext まで失われ機密を復旧できなかった。
  一時ファイル → fsync → os.replace の順にして、途中で失敗しても旧内容が
  そのまま残り、書きかけの一時ファイルも掃除されるようにした。

- devbase env keygen の --key-file を廃止
  既定外のパスへ鍵を生成できても agekeys.resolve_identities() はそこを探索
  しないため、「生成した鍵で保存した機密を復号できない」状態を作れてしまった。
  生成先を常に agekeys.key_file_path() に固定し、生成先と探索先が構造的に
  一致する契約にする。場所を変えたい場合は DEVBASE_AGE_KEY_FILE を設定して
  から実行する (help と zsh 補完も追随)。

- keygen が secrets/recipients.txt を書くのをやめる
  鍵はグローバルなのに受信者リストはワークスペースごとに存在するため、
  keygen で書き込むと別ワークスペースへ古い公開鍵が取り残され、既に失われた
  秘密鍵に対応する公開鍵で暗号化する事故が起きうる。resolve_recipients() は
  リストが無ければ鍵ファイルの公開鍵へフォールバックするので単独利用では
  不要で、明示的に受信者を足す経路 (後続の rekey) だけが作る設計にした。
  触るファイルが鍵 1 つになり、--force 時のロールバックも鍵ファイルだけに
  単純化される (旧鍵保全そのものは維持)。

テストは os.replace の失敗注入で旧 ciphertext が無傷かつ一時ファイルが残らない
ことを検証し、keygen 側は DEVBASE_AGE_KEY_FILE を差し替えて既定パスを tmp へ
向ける方式へ書き換えた。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: 既存ディレクトリの権限を壊さず、keygen --force を常に確認する

- agekeys._ensure_private_dir: 既存ディレクトリを 0700 へ落とすのをやめ、
  自分が新規作成した階層だけを chmod する。DEVBASE_AGE_KEY_FILE で /tmp などの
  共有ディレクトリを指定されると、そこを 0700 にして他ユーザーやサービスの
  アクセスを壊してしまうため。既存側が緩い場合は変更せず警告ログに留める。
  save_recipients も同じ関数を通るので secrets/ の扱いが一貫する。
- cmd_env_keygen: --force の確認条件から _has_encrypted_secrets を外し、既存鍵が
  あれば常に確認プロンプトを出す (--yes でのみスキップ)。鍵は全ワークスペース
  共通なのに条件がカレント DEVBASE_ROOT の機密有無に依存しており、まだ機密の無い
  別プロジェクトから --force すると無警告で鍵が消え、他プロジェクトの機密が
  復旧不能になっていた。_has_encrypted_secrets は文言の強弱にのみ使う。
- AgeBackend.load: 復号結果が不正 UTF-8 のときの UnicodeDecodeError を
  SecretStoreError へ包み、PlaintextBackend.load と例外の種類を揃える。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: 読み取り不能な既存鍵を「元は無かった」と誤認して消さないようにする

_snapshot_file が読み取り失敗時も None を返していたため、「ファイルが存在しなかった」
と「存在したが読めなかった」を区別できず、鍵生成が失敗して _restore_file が走ると
後者のケースで既存鍵を削除していた。権限を直せば回収できたはずの旧鍵まで失われる。

- _snapshot_file の戻り値を FileSnapshot(SnapshotStatus, data) の 3 状態にし、
  ABSENT / CAPTURED / UNREADABLE を持ち回るようにした
- _restore_file は UNREADABLE のとき削除も上書きもせず、警告を出して現状を残す
- cmd_env_keygen は既存鍵が読めない時点で上書きを中止する (控えが取れていない以上
  生成失敗時に巻き戻せず、成功すれば旧鍵が消えるため。回復可能な権限エラーが
  大半なので鍵を守る方を選び、権限確認か手動退避を案内する)
- スナップショットは確認プロンプトより前に取り、空振りの同意要求を避ける

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* refactor: keygen の手動ロールバックを廃し、原子性は io 層に一本化する

add_recipient の呼び出しが無くなり、keygen が触るファイルは鍵 1 つだけになった。
その 1 回の書き込みは agekeys.generate_key_file →
io_common.write_secure_bytes_atomic (一時ファイル + fsync + os.replace) が
原子性を担保しており、失敗しても既存の鍵は元のまま残る。その上に
「メモリへ退避して同じ内容を書き戻す」層を重ねてもリストア側が失敗しうるぶん
壊れ方の種類が増えるだけなので、_snapshot_file / _restore_file /
FileSnapshot / SnapshotStatus を削除した。

一方「既存鍵が在るのに読めないときは上書きせず中止する」保護は原子性とは
別の目的 (権限を直せば回収できたかもしれない鍵を握り潰さない) なので残し、
os.access(path, os.R_OK) による単純な判定へ置き換えた。将来この層へ再び
ロールバックを足さないよう、意図はコードコメントに残してある。

テストは snapshot/restore の実装依存テストを削除し、振る舞い
(読めない鍵では中止して exit 1・生成失敗でも旧鍵は無傷) のテストへ整理した。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: env keygen を SUBCMD_MAP に登録し短縮形が解決されるようにする

keygen は parser にのみ登録されており SUBCMD_MAP[('env',)] へ追加漏れが
あったため、_expand_argv() の prefix 展開対象にならず `devbase env k` が
invalid choice で失敗していた。他の env サブコマンドと同様に prefix 解決
されるよう SUBCMD_MAP に追加する。`k` で始まる既存サブコマンドは無いため
ambiguous にはならず、SUBCMD_PREFIX_PREFERENCES の追加は不要。

再発防止として、SUBCMD_MAP['env'] が parser 登録済みサブコマンドを漏れなく
含むことを検証するテストと、`devbase env k` → `env keygen` の解決テストを
追加した。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: 鍵ディレクトリを作成時点から 0700 にして権限露出の隙を無くす

mkdir(parents=True) で一括作成してから chmod する実装では、作成から chmod
完了までの間だけ umask 依存の緩い権限 (例 0755) が見える窓があり、その隙に
開かれた fd は後からの chmod では閉じられない。未存在の階層を親→子の順に
Path.mkdir(mode=0o700) で 1 階層ずつ作り、最初から 0700 で作成する。

並行して他プロセスが同じ階層を作った場合は FileExistsError を握りつぶし、
既存ディレクトリとして権限を触らない (既存ディレクトリは所有者の管轄として
chmod しないという既存方針と一貫させる)。0o700 は group/other ビットが無く
umask の影響を受けない旨をコメントに残し、将来の chmod 追加を防ぐ。

テストは、umask 0 でも各階層が「作成した瞬間から」0700 であること、および
途中階層が並行作成 (FileExistsError) されても失敗せずその権限を変えないことを
追加で検証する。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: 鍵の初回生成を O_EXCL で排他化し TOCTOU による鍵消失を塞ぐ

初回生成を並行実行すると、両プロセスが「鍵なし」と判定した後どちらも書き込みへ
進み、後発が先発の鍵を無確認で上書きできた。先発鍵で暗号化した機密はその瞬間から
復号不能になる。

- agekeys.generate_key_file(force=False) を os.open(O_WRONLY|O_CREAT|O_EXCL, 0600)
  による直接排他生成に変更。判定と作成の隙間をカーネル側で不可分に閉じる。既存
  ファイルが無い状況では守る旧内容も無いため atomic replace は不要で、むしろ
  os.replace は既存を無条件に置き換えるぶん危険だった。FileExistsError は従来の
  AgeKeyError (「鍵ファイルが既に存在します … --force」) へ変換する
- force=True は従来どおり一時ファイル + fsync + os.replace を維持 (旧鍵を失わない)
- cmd_env_keygen が無条件に force=True を渡すのをやめ、コマンドの --force をその
  まま渡す。事前チェック後に他プロセスが鍵を作っていた場合も上書きせず停止する
- --force 同士の並行実行にはロックを入れない判断理由をコメントに残した

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: 復号時の identity 解決を候補ごとに行い移行互換性を保つ

cipher.decrypt() は渡された identity をまとめて解決していたため、
agekeys.resolve_identities() が先頭に置く devbase 専用鍵が壊れている /
読めないだけでその場で例外になり、後続の ~/.ssh 既定鍵を試せなかった。
結果として「旧来 ~/.ssh の鍵で暗号化した暗号文も移行期間中は復号できる」
という意図が失われていた。

候補ごとに解決を試し、解決できなかった候補は理由を warning に残して
読み飛ばし、有効な候補だけで復号を続行するようにした。全候補が解決不能
だった場合のみ CipherError とし、各候補の失敗理由をメッセージに含めて
原因を切り分けられるようにしている。解決に成功したが鍵が一致しない場合の
メッセージ、passphrase 経路、identity と passphrase の排他契約は不変。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: 設定操作コマンドの保存先を秘密ストアへ切り替える

devbase env の各コマンドが .env のパスを直接組み立てていたのをやめ、秘密
ストア越しに読み書きするようにした。保存先が平文か暗号化かはストアが判定
するため、コマンド側も設定の収集処理も保存形式を意識しない。

- EnvFile と同じ操作性でストアを扱うビューを挟み、collectors を書き換えずに
  済むようにした。収集処理まで暗号化を知る必要はない
- 一覧表示では暗号化されている保存先にだけ印を付ける。どちらで保存されて
  いるかは利用者が知りたい情報だが、平文側に印を足すと既存の見た目が変わる
- 暗号化された設定の編集は、復号結果を自分専用の 0700 ディレクトリへ 0600 で
  書き、編集後に暗号化し直してから finally で必ず消す。エディタへ値を渡す
  手段が他に無いため、ここだけは平文が一瞬ディスクに載る例外として扱う
- 編集内容が変わっていなければ書き戻さない。同じ内容でも再暗号化すると
  ファイルが変わり、差分やバックアップに無用な更新が生まれるため
- エディタが異常終了した場合と、編集結果が UTF-8 として読めない場合は保存
  しない。壊れた内容で既存の設定を上書きしないため

プロジェクト設定の参照先を CWD ではなくプロジェクト直下に固定した。
projects/<name>/sub で実行すると従来はその場所に .env を作っていたが、
コンテナ構成が読むのはプロジェクト直下であり、書いた設定が反映されない。
あわせて projects/ の外での `env set --project` は、どのプロジェクトを指すか
決められないため明示的に断るようにした。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* feat: env delete / edit に --project を追加

設定が暗号化 (.env.age) されると、利用者がエディタで直接開いて不要なキーを
消せなくなる。しかし delete / edit には --project が無く、env set --project で
誤って入れた変数を CLI から取り除く手段が (env.yml に定義が無い限り) 無かった。

- delete / edit に --project / -p を追加し、カレントのプロジェクト設定を対象にする
- projects/<name> 配下でない場合は set --project と同じ理由・文言で明示的に断る
  (どのプロジェクトを指すか決められず、CWD に .env を作るとコンテナが読む先と
  ずれるため)
- set / delete / edit で重複していた参照解決を _target_env() へ集約
- zsh / bash 補完と CLI リファレンスを追随

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: symlink されたプロジェクト配下で --project が効かない問題を修正

_current_project_name が current.resolve() で物理パスに正規化していたため、
プラグイン経由で projects/<name> がシンボリックリンクになっているプロジェクト
配下で実行すると、リンク先の実体を指して projects/ の外と判定されていた。

判定を論理パス (absolute) → 物理パス (resolve) の 2 段に変え、リンク経由の
プロジェクトも、.. を含むパスや実体パスで入った場合も拾えるようにする。
symlink プロジェクトの解決テストを追加。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: --project のパス判定で .. を正規化し、機密の保存先を 0700 で掘る

PR #92 レビュー指摘 3 件への対応。

- _current_project_name: 論理パス側を Path.absolute から os.path.abspath
  (= normpath) に変更。.. が畳まれないため projects/web/../../outside のような
  プロジェクト外のパスが projects/web 配下と誤判定され、プロジェクト外からの
  --project が web の設定を書き換えていた。.. の textual な畳み込みはシェルの
  cd / PWD の意味論と一致するので、シンボリックリンク対応とも両立する。物理パス側
  (resolve) はリンク先の実体パスで入られた場合のフォールバックとして据え置き。
- io_common.write_secure_bytes{,_atomic}: 親ディレクトリを mkdir(parents=True)
  で掘ると umask 依存になり、secrets/ が 0755 で生まれうる。ファイルは 0600 でも
  ディレクトリが読めるとファイル名の一覧が漏れるため、agekeys._ensure_private_dir
  を io_common.ensure_private_dir へ移して両者で共有する。新規作成した階層だけを
  作成時点から 0700 にする / 既存ディレクトリは chmod しない / 並行作成の
  FileExistsError を握るという既存の性質はそのまま維持し、既存ディレクトリが緩い
  ときの警告は warn_if_permissive で選択制にした (agekeys 経路のみ有効。
  DEVBASE_ROOT 直下や export 先 CWD で毎回鳴ると本当の警告が埋もれるため)。
- docs: 前段の PR で追加された devbase env keygen のリファレンスが漏れていたので
  追記。鍵の既定パスと DEVBASE_AGE_KEY_FILE、--force / --yes、鍵を失うと復旧
  できないためバックアップが必須である旨を記載。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: 機密を平文ファイルを介さずコンテナへ渡す経路へ移行する

起動ラッパーが共通の機密ファイルを source するのをやめ、機密は Python 本体が
必要になった時点で復号してメモリ上で合成する。コンテナへは変数名だけを列挙
した構成で渡すため、暗号文も平文ファイルも Docker Compose には渡らない。

- 起動ラッパーは非機密設定 ($DEVBASE_ROOT/env) だけを読む。シェルから読める
  場所に機密を置かないことが暗号化の前提であり、ここで読むと意味が無くなる
- ホスト側で機密を必要とする処理は実測で 2 系統だけだった。Docker Compose の
  変数展開 (ローカル S3 互換サービスの資格情報) と、それを含むビルド呼び出し。
  ビルドは devbase env exec 経由にして Python 側から環境変数を渡す
- 生成する構成へは変数名のみを書き、値は devbase 自身の環境変数から解決させる。
  台数拡張時に生成する構成そのものへ書き込むため、別ファイルの上書きを重ねる
  必要がなく適用順序の問題が起きない
- 重ね順は従来の env_file の並びを維持する。共通機密とプロジェクト機密のキーを
  列挙しつつ、両方に同じキーがある場合は値として非機密設定側を採用する。
  environment は env_file より優先されるため、こうしないと「プロジェクト設定が
  共通設定を上書きする」関係が反転する
- 実在しない env_file 参照は生成時に落とす。暗号化で平文が無くなった参照が
  残っていると Compose が起動時に落ちるため

移行コマンド (encrypt / decrypt) を追加した。暗号化は「読み戻せることを確認して
から平文を退避する」順序で行う。鍵の指定を誤ったまま平文を失うと、誰にも復号
できないファイルだけが残るため。退避した平文は自動では消さず、場所を案内する。

構成ファイルの書き換えは行のコメントアウトで行い、元の行をそのまま残す。YAML と
して読み書きし直すと利用者のコメントや整形が失われること、および平文へ戻す操作で
元の行を機械的に復元できることの 2 点による。

コンテナ起動前の設定チェックは、ファイルの有無ではなく秘密ストアに設定があるかで
判定する。移行済みの環境で毎回 env init が走るのを避けるため。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: env encrypt/decrypt が途中で失敗しても中間状態を残さないようにする

encrypt / decrypt はどちらも対象ごとにループ内で破壊的な操作を実行していたため、
後続の対象で失敗すると先行対象だけ移行済みになり、しかも compose.yml の書き換えは
実行されず「構成ファイルが存在しないファイルを参照する」壊れた状態で終わっていた。
また _apply_compose_changes は書き込み失敗をログに出して次のファイルへ進むため、
機密の移動・削除が済んだあとでもコマンドが成功扱いになっていた。

実行した操作ごとに取り消し手続きを積み、どこで失敗しても逆順に巻き戻す _Rollback を
追加し、encrypt / decrypt の両方で共有する。

- encrypt: 全対象の暗号化と読み戻し検証 → 平文の退避 → compose.yml の書き換え
- decrypt: 全対象の復号確認 (生バイト列も控える) → 平文の書き出し →
  compose.yml の復元 → 最後に暗号文を削除。破壊的な削除を最後に置くことで、
  途中で失敗したときに失うものを最小にする
- _apply_compose_changes は書き込み失敗を MigrationError で呼び出し元へ伝え、
  書けたぶんは巻き戻す。巻き戻し自体が失敗したら何が残っているかを列挙する

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: 部分復号・空行・注入スキップ・インライン env_file の取りこぼしを直す

PR #93 のレビュー指摘 4 件に対応する。

- compose_migrate.enable() に disable() と同じ targets 引数を持たせた。
  `env decrypt --project` で一部だけ復号したとき、これまでは compose 内の
  全マーカーを戻していたため、まだ暗号化されたままの共通設定
  (${DEVBASE_ROOT}/.env) の参照まで有効になり Compose の起動が失敗していた。
  無効化と復元で同じ判定 (_compose_targets) を使うようにして揃えた。
  キー行 (`env_file:`) は有効なエントリが 1 つ以上戻ったときだけ復元する。
- disable() が env_file リスト内の空行で break していたのをスキップに変えた。
  空行以降のエントリを無効化し損ねるうえ、「有効なエントリ 0 件」と誤判定して
  `env_file:` キー自体をコメントアウトし、起動失敗を招いていた。ブロックの
  終端判定はインデントが受け持つため、空行で止める必要はない。
- 機密注入のスキップ判定を (コマンド, サブコマンド) の組に変えた。args.command
  にはトップレベルしか入らないため、鍵がまだ無い / 復号できない状態で実行される
  `env keygen` / `env encrypt` / `env decrypt` でも注入が走っていた。
- 行単位では扱えない env_file 記法 (インライン配列・単一文字列) を検出して
  ファイルと行番号つきで警告するようにした。移行の対象から漏れることを黙って
  いると、利用者は壊れた構成のまま起動して初めて気付く。対応範囲はモジュールの
  docstring にも明記した。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: scale 生成で非機密 environment を残し、機密以外の env_file 欠落を隠さない

生成する構成ファイル (.docker-compose.scale.yml) の作り方を 2 点直した。

- environment を丸ごと落としていたため、元の compose.yml が持つ非機密の固定値や
  機能フラグまで消え、スケールした途端に生成コンテナの挙動が変わっていた。
  secret_env_names に挙がったキーだけを値なし参照へ置き換え、それ以外は値ごと
  残すようにした。元の記法は尊重し、map なら値 None の map、list なら裸のキー名
  として出力する。機密キーの値が生成ファイルに残らないことは従来どおり保証する。
- 実在しない env_file 参照を無条件に落としていたため、利用者のタイプミスや未配置
  の必須設定まで黙って成功扱いになり、Compose が知らせてくれる構成不備を隠して
  いた。落とす対象を「暗号化移行で消える既知の機密参照」に限定し、判定は
  compose_migrate.is_secret_entry (新規の公開関数) に集約した。

既存テストのうち、environment を落とす前提だったものは新仕様に合わせて更新した。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: 移行の中断条件を厳しくし、compose.yml の書き込みと機密の渡し先を直す

暗号化移行が「機密だけ退避されて構成は壊れたまま成功する」経路を塞ぎ、
生成する構成で非 dev サービスに機密が渡らない問題を直す。

- compose.yml を読めない場合は警告してスキップせず MigrationError で移行
  全体を中止する。飛ばして続けると平文だけが退避され、存在しないファイルを
  指す参照が残ったままコマンドが成功してしまうため
- 機密ファイルを指すインライン記法 (`env_file: [.env]` / `env_file: .env`)
  を検出したら移行を失敗させ、手で直してからの再実行を案内する。機密と
  無関係なインライン記法は移行に影響しないので従来どおり警告のみ。判定は
  compose_migrate.secret_inline_env_file_lines() に切り出した
- compose.yml の書き込みを write_secure_bytes_atomic へ差し替え、途中で
  失敗しても部分的なファイルが残らないようにする。compose.yml は機密では
  ないため、既存ファイルの権限を読み取って mode に渡し 0600 へ落とさない
- 元々機密ファイルを env_file で参照していた非 dev サービス (db など) にも
  機密の変数名を列挙する。移行後は参照がコメントアウトされ YAML から消える
  ため、compose.yml の生テキストを見る
  compose_migrate.services_with_secret_env_file() で渡し先を決める。参照を
  持たないサービスには従来どおり注入しない
- 末尾スペース / 行末コメント付きのエントリ (`- ${DEVBASE_ROOT}/.env  # 共通設定`)
  が正しく無効化・復元されることを示すテストを追加 (現行実装で処理済み)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: 機密は「そのサービスが元々参照していた由来」のキーだけに絞って渡す

これまでは「機密参照を持つか」の真偽だけで渡し先を決めていたため、共通の
.env だけを参照していた db のようなサービスにも、プロジェクト専用のトークン
まで全件が environment へ列挙されていた。元々受け取っていなかった機密が渡る
のは機密範囲の拡大にあたるため、由来 (共通 / プロジェクト) 単位で絞り込む。

- compose_migrate.services_with_secret_env_file() の戻り値を
  「サービス名 → 参照種別の集合 (TARGET_GLOBAL / TARGET_PROJECT)」へ変更。
  コメントアウト済みの参照も従来どおり種別つきで数える
- runtime.SecretEnv に global_names / project_names を持たせ、names は
  両者を畳んだ全体を返すプロパティへ (呼び出し側の互換は維持)
- generate_scaled_compose は非 dev サービスへ、そのサービスが参照していた
  由来のキーだけを列挙する。dev は従来どおり全件
- 生テキストを読めない場合の「dev のみ・全件」フォールバックは維持

既知の限界として、同じキーが共通機密とプロジェクト機密の両方にある場合は
Compose が実行プロセスの環境変数から 1 つの値しか解決できないため、共通側
だけを参照していたサービスにも合成後 (プロジェクト優先) の値が渡る。値を
サービスごとに変えるには生成ファイルへ機密の値を書く必要があり、本 PR の
前提と矛盾するため受け入れる。コードコメントと plan35.md §7 に明記した。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: env_file ブロック内のコメント行で走査を止めない

`env_file:` 配下に利用者が書いた単独のコメント行があると
`_LIST_ITEM_RE` に一致せず走査が打ち切られ、コメント行より後ろの機密参照が
無効化されないまま残っていた。その状態で平文を退避すると、Compose が存在
しないファイルを参照して起動できなくなる。

空行と同じく単独のコメント行も読み飛ばすようにし、ブロックの終端は
インデントだけが決めるようにした。無効化済みの行 (DISABLED_MARK 付き) は
見た目がコメント行でも中身はエントリなので、コメント判定より先に除いて
いる (順序を誤ると enable が何も復元できなくなる)。

disable / enable で重複していたブロック走査は `_scan_env_file_block` へ
括り出した。片方だけ直すと無効化と復元がずれるため。
`services_with_secret_env_file` も同じ理由で参照種別を取りこぼしていたので、
共通の `_is_skippable` で読み飛ばすようにした。

テストは、既存の test_user_comments_are_preserved が「何も書き換えられて
いない」ために往復の一致だけで通っていた点を補強し
(修正前にこのアサートが失敗することを確認済み)、コメント行の後ろの参照が
無効化されること・往復で元に戻ること・コメントと空行が混在する場合・
services_with_secret_env_file が種別を拾えることを追加した。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: env_file の long syntax・クォート付きサービス名・CRLF を取りこぼさない

行ベースの走査に残っていた 3 つの穴を塞ぎ、あわせて「何を扱い、何を扱わないか」
をモジュールの契約として docstring に書き出した。

- long syntax (`- path: .env`) を参照として認識する。1 行で閉じているものは
  従来どおり無効化・復元し、`required: false` などの続きの行を持つ形・フロー
  記法・シーケンスでない値は書き換えず、機密を指していれば移行を中止する。
  続きの行で走査を打ち切らないので、その後ろに並ぶ機密参照も取りこぼさない。
- サービス名を YAML と同じ姿へ正規化する。`"db":` を引用符込みで記録すると
  パース済みの `db` と一致せず、そのサービスへ機密が渡らなかった。
- 各行の元の行末を保って書き換える。`rstrip('\n') + '\n'` で CRLF が LF に
  変わり、encrypt → decrypt の往復で元の compose.yml に戻らなかった。移行
  コマンド側も read_bytes で読み、改行コードを勝手に揃えないようにした。

インライン記法だけを対象にしていた中止判定は扱えない記法全体に広げ、名前を
secret_unsupported_env_file_lines へ変更した。扱えない記法は黙って通さず、
必ず中止か警告のどちらかに落ちる不変条件を docstring に明記している。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: 単一文字列の env_file を中止せず移行対象に含める

`env_file: .env` のように値が単一文字列で書かれた定義は、これまで「行単位では
扱えない記法」として扱い、機密を指す場合は移行ごと中止していた。利用者は手で
`- ...` の並びへ書き換えないと暗号化できず、実質的に使えない状態だった。

この形はエントリが 1 つしかなく 1 行で完結するため、`env_file:` の行そのものを
コメントアウトすれば安全に無効化でき、`enable` でも元のバイト列へ戻せる。
`_inline_scalar_ref` で「1 行で完結する単一文字列」だけを切り出し、disable /
enable / 中止判定 (`secret_unsupported_env_file_lines`) の 3 箇所で同じ判定を
使うようにした。

フロー記法 (`env_file: [ ... ]` / `{ path: ... }`)、ブロックスカラー、閉じていない
クォートなど 1 行で安全に判断できない記法は、従来どおり警告・中止のままにする。
機密を指さない単一文字列 (`env_file: config/app.env`) は触らない。モジュール
冒頭の「扱う記法 / 中止する記法 / 触らない記法」の契約も実装に合わせて更新した。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: 書き換え後の compose.yml を YAML として検証し機密参照の残りを検出する

`env_file: >-` のようなブロックスカラーは先頭行に参照先が無く、行ベースの
走査では機密を指しているか判別できない。その結果 `env_file: >-` +次行 `.env`
の構成は無効化も中止もされず、暗号化後も存在しない平文への参照が残ったまま
コマンドが成功していた。モジュールが掲げる「扱えない記法は必ず中止か警告に
落ちる」という不変条件が破れている。

記法ごとに穴を塞ぐ対応では同種の見落としが出続けるため、記法の判別に依らない
事後検証を最後の砦として追加する:

- `compose_migrate.remaining_secret_env_file_refs()` を追加。書き換え後の
  テキストを `yaml.safe_load` でパースし、各サービスの `env_file` に残った
  機密参照を返す。文字列 / 文字列のリスト / long syntax の dict のいずれも
  平坦化して拾う。無効化した行は YAML のコメントなのでパーサからは見えず、
  残っていれば走査が取りこぼしたことを意味する
- パースできない `compose.yml` は `ComposeParseError` を投げる。検証できない
  以上「参照が無い」とは言い切れないため、読み取り失敗と同じ扱いで中止する
- `env_migrate` の暗号化側で全 `compose.yml` に検証を掛け、残っていれば
  どのファイルのどのサービスにどの参照が残るかを示して `MigrationError` で
  中止する。差分ゼロのファイルも対象にする (走査が何も見つけられなかった
  ファイルこそ取りこぼしの疑いが濃い)
- 復号側では行わない。平文が戻る以上その参照は有効で正しく、ここで止めると
  壊れた状態からの復帰手段を塞いでしまう

行ベースの走査は「うまく書き換えられれば書き換える、取りこぼしたら事後検証が
止める」という二段構えになる。既存の
`secret_unsupported_env_file_lines()` はより早い段階で分かりやすいエラーを
出すための仕組みとして残す。設計意図はモジュール docstring とコード内
コメントに明記した。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: 退避先を排他的に作り、機密は原文のバイト列のまま往復させる

- バックアップ先の衝突で過去の平文を失わないようにする
  秒単位の日時ディレクトリは既存のバックアップと衝突しうる。従来は
  `exist_ok=True` で掘っていたため、衝突すると `shutil.move` が同名の
  `global.env` / プロジェクトの env を上書きし、「削除しないはずの過去の
  平文」を失っていた。`_create_backup_dir` を追加して `exist_ok=False` で
  排他的に作成し、既にあれば `-2` `-3` … と一意な名前へ寄せる。上限
  (100 回) まで空きが無ければ平文に触れないまま中止する。退避先には平文の
  機密が置かれるため 0700 で作る (親は他機能と共有するので既定のまま)。

- 往復でコメント・空行・`export` 表記が失われないようにする
  暗号化時に平文を辞書へ畳んでいたため、`decrypt` してもコメント・空行・
  `export KEY=...` 表記・値のクォートが戻らず、案内している「暗号化前の
  状態へそのまま復帰」を満たしていなかった。`SecretStore` /
  `PlaintextBackend` / `AgeBackend` に生バイト列を扱う `save_bytes` /
  `load_bytes` を追加し、移行は原文のバイト列のまま暗号化・復元する。
  読み戻し検証も「暗号化 → 復号 → 元のバイト列と一致」で維持する。
  辞書経由の `save` / `load` はそのまま残し、`env set` などで値を書き換えた
  ときに正規化されるのは平文だけを使っていた頃と同じ挙動として変えない
  (「値を書き換えるまでは原文が保たれ、書き換えると正規化される」)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: プロジェクト切替で切替元の機密が環境変数に残らないようにする

cli._load_secret_env は dispatch の前に「現在地のプロジェクト」の機密を
os.environ へ載せるが、TUI や `project up <other>` の直接起動ではその後に
対象プロジェクトへ切り替わる。切替先に同名キーが無い機密 (切替元固有の
トークン等) は載せ直しでは上書きされず残り、Compose や子プロセスへ
引き継がれてしまう。

- runtime.inject が「載せた変数名とその注入前の値」を記録し、
  runtime.clear_injected で注入前の状態へ戻せるようにした。自分が載せた
  キーだけを対象にし、利用者がシェルで設定していた同名の変数は元の値へ
  戻すので消えない
- container._inject_secrets は対象プロジェクトへ chdir した後に呼ばれる
  ため、載せ直しの前に clear_injected を通して切替元の機密を落とす
- _resolve_project_name は os.chdir と併せて PWD も切り替える。機密の
  解決 (runtime.current_project_name) は wrapper の cd を前提に PWD を
  先に見るため、PWD が切替前のままだと切替先ではなく呼び出し元の機密を
  読んでしまい、載せ直しが機能しないため (TUI の _run_in_project と同様)

非機密設定 (env) 側の _CALLER_ENV_KEYS / _resolve_project_name と同じ性質を
機密にも与えることになる。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: 注入履歴を対象の環境マッピングごとに持たせる

注入履歴がモジュールレベルに 1 つしか無かったため、inject(environ=A) の後に
clear_injected(environ=B) を呼ぶと、A に対して記録した内容で B を誤って
「復元」し、かつ A には機密が載ったまま残っていた。

履歴を「どの環境マッピングへ注入したか」と結び付け、clear_injected は同じ
対象に記録された履歴だけを解除して、その対象の履歴を破棄するようにした。
dict は hashable でないため id() をキーにするが、対象そのものへの参照も
一緒に保持し、id の再利用による誤爆を防ぐ。os.environ を既定対象とする
従来の使い勝手は変えていない。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: 受信者の更新と平文の点検を追加し、書き出し・取り込みを暗号化へ揃える

暗号化は「平文がどこにも残っていないこと」で初めて意味を持つ。移行で取り残された
控えや除外設定の穴は黙って残り続けるため、点検する手段を用意して繰り返し確認
できるようにする。あわせて誰が復号できるかを変える手段を追加する。

- devbase env rekey: 受信者を足し引きし、暗号化済みの機密をまとめて暗号化し直す。
  先に全件を復号してから書き直すため、途中で復号に失敗しても一部だけ新しい
  受信者で暗号化された状態にならない。受信者リストが無い状態からの追加では
  自分の公開鍵も登録する。登録しないと自分が受信者から外れ、自分の機密を
  復号できなくなるため
- devbase env doctor: 鍵の権限、保存先の衝突、退避された平文、日時付きの控え、
  除外設定の穴を点検する。問題があれば非ゼロで返し、定期実行でも気付けるように
  した。権限は勝手に直さず、直し方だけを示す

書き出し・取り込みを秘密ストア経由へ揃えた。移行後の環境で取り込みが平文の
.env を作ると、暗号化ファイルと平文が同時に存在する状態を自分で作り出して
しまう。既存内容の読み取りと書き出しの両方をストア越しに行い、保存形式を
維持する。副次的に、取り込み前の控えも暗号文のまま保存されるようになり、
バックアップに平文が滞留する経路が塞がる。

除外設定に日時付きの控え (.env.bak-20260807172231 等) を追加した。完全一致の
パターンでは弾けず、実際に未追跡のまま検出された経緯がある。

containers/lfm/compose.yml が YAML として壊れていた (8 行目に迷子の c) のを
直した。本方針の変更とは独立した既存の不具合だが、構成ファイルを機械的に
読む処理が増えるため、この機会に直しておく。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: rekey を巻き戻せる単位にし、除外設定のルート指定を誤検知しないようにする

## env rekey が途中失敗で不整合を残す問題

受信者リストを先に更新したあとに暗号文の書き直しが失敗すると、旧受信者宛と
新受信者宛の暗号文が混在した。自分の鍵を外す操作では残った旧暗号文をもう
復号できないため、`devbase env rekey` の再実行でも復旧できなくなる。

env_migrate の `_Rollback` を `devbase.env.rollback.Rollback` へ移して共有し、
rekey を「全件の新しい暗号文を用意 → 受信者リストを更新 → 各暗号文を差し替え」
の順で適用する単一のトランザクションにした。旧暗号文と旧受信者リストは生の
バイト列で控え、どこで失敗しても逆順に書き戻す。破壊的な操作を後ろへ寄せる
考え方と、巻き戻し自体が失敗したときに何が残っているかを列挙する挙動は
env_migrate から引き継いでいる。

## env doctor が除外設定のルート指定を誤検知する問題

`/.env` や `/secrets/` のように先頭に `/` を付けたリポジトリルート指定が
考慮されておらず、正しく除外できているのに「不足」と報告していた。正しい
設定を毎回叱るのは点検コマンドとして害になる。

判定を `_normalize_ignore_pattern` へ括り出し、先頭の `/` と `**/`、末尾の
`/`、行末コメント、前後の空白を吸収してから突き合わせるようにした。日時付き
控えの判定も完全一致の前方後方比較をやめ、代表的な名前 `.env.bak-<日時>` に
実際にマッチするかで見る (`.env*` のような広い指定も拾えるようにするため)。
`!` の再包含や配下の一部だけの除外は従来どおり不足として報告する。
どこまでを許容しどこからを検出漏れとして受け入れるかは docstring に記した。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

* fix: 除外設定の点検を git check-ignore に委ね、誤って「除外できている」と言わないようにする

`.gitignore` を独自に文字列正規化して判定していたため、Git の実際の解釈と
食い違っていた。

- Git は行頭の `#` だけをコメントとして扱うのに行末コメントを落としていたため、
  `.env # 機密` (実際には `.env # 機密` というパターン) を「`.env` を除外している」
  と誤判定していた
- 後段の `!` による再包含を見ていなかったため、`.env` の後に `!.env` があっても
  「除外されている」と判定していた
- 行頭の空白も同様に落としていたが、Git は落とさない

いずれも「除外できていないのに doctor が成功する」方向の誤りで、平文の誤コミット
につながる。`.gitignore` の解釈は Git の実装が正であり、独自に真似る限り同種の
食い違いは残るため、判定そのものを Git へ委ねる。

- 代表パス (`.env` / `.env.bak-<日時>` / `secrets/` 配下 / `projects/<name>/.env`)
  を `git check-ignore --no-index` で評価し、除外されないものを実パスで報告する
- `git` が無い / `DEVBASE_ROOT` が Git リポジトリでない場合は「確認できませんでした」
  と報告する。文字列判定へはフォールバックしない (不正確な判定を残さないため)
- 不要になった `_normalize_ignore_pattern` と `fnmatch` による突き合わせを削除
- テストは実際に `git init` した一時リポジトリで Git の解釈と一致することを確かめる

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@takemi-ohama
takemi-ohama marked this pull request as ready for review August 12, 2026 10:15
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1

@takemi-ohama takemi-ohama left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 1 | codex | APPROVE

修正必須の指摘はありません。

@takemi-ohama takemi-ohama left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 1 | gemini | REQUEST_CHANGES

PR全体の設計は概ね良好ですが、エディタ編集時の UX に関わる重要な不具合があります。詳細はインラインコメントを参照してください。

Comment thread lib/devbase/commands/env.py
暗号化された設定を `devbase env edit` で開くと、辞書経由 (dump_bytes /
parse_bytes) で往復していたためコメント・空行・export 表記が失われていた。
SecretEnvFile に load_bytes / save_bytes を追加し、_edit_encrypted は
編集後のバイト列をそのまま保存するようにした。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lzjfzt1hB9Bt8aV359dvW4
@takemi-ohama

Copy link
Copy Markdown
Contributor Author

🔧 fix | round 1

判定 件数
修正 1 (major 1)
deferred 0
rejected 0

修正内容

major — lib/devbase/commands/env.py:465 (gemini)
devbase env edit が暗号化設定を辞書経由で往復しており、コメント・空行・export 表記が編集のたびに失われていた。

  • SecretEnvFile に load_bytes() / save_bytes() を追加し、SecretStore の同名 API へ委譲
  • _edit_encrypted は復号した原文をそのままエディタへ渡し、編集後のバイト列をそのまま暗号化して書き戻す(parse_bytes は UTF-8 妥当性確認と件数表示のみ)
  • 回帰テスト test_edit_keeps_comments_and_blank_lines を追加

codex は指摘 0 件 (APPROVE)。

検証

  • ruff check --select=E9,F63,F7,F82 lib — pass(CI と同条件)
  • uv run pytest — 1222 passed

fix commit: 88d9c8c

@takemi-ohama takemi-ohama left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 2 | codex | REQUEST_CHANGES

既存コンテナを停止する前に、復号と起動構成生成を完了させる順序へ変更してください。

Comment thread lib/devbase/commands/container.py Outdated

@takemi-ohama takemi-ohama left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 2 | gemini | APPROVE

前回の指摘事項(devbase env edit 実行時に暗号化ファイルのフォーマットやコメントが失われる問題)が load_bytes / save_bytes によって適切に解消されていることを確認しました。追加された移行・点検コマンドやそのテストカバレッジも含め、コード品質・セキュリティ観点で問題となる箇所は見当たりません。

鍵の紛失・権限不備・暗号文の破損で復号に失敗すると、直前の
docker compose down により稼働中の開発環境まで止まったままになっていた。

- cmd_up の順序を「生成 (=復号) → 停止 → 起動」へ変更
- 停止には生成前の構成を渡す (_previous_scale_compose)。生成は
  .docker-compose.scale.yml を上書きするため、新構成で停止すると
  スケールを縮める起動で旧インスタンスが取り残される
- 生成が途中で失敗した場合は旧構成を書き戻す
- 退避ファイル (.docker-compose.scale.yml.prev) を .gitignore へ追加
- 回帰テスト tests/commands/test_container_up_order.py を追加

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lzjfzt1hB9Bt8aV359dvW4
@takemi-ohama

Copy link
Copy Markdown
Contributor Author

🔧 fix | round 2

判定 件数
修正 1 (major 1)
deferred 0
rejected 0

修正内容

major — lib/devbase/commands/container.py (codex)
cmd_up が [2/6] 既存コンテナ停止 → [3/6] 構成生成 (= 機密の復号) の順だったため、鍵の紛失・権限不備・暗号文の破損で復号に失敗すると、起動できないだけでなく直前の docker compose down により稼働中の開発環境まで止まったままになっていた。

  • 順序を「生成 (= 復号) → 停止 → 起動」へ変更
  • _previous_scale_compose() を追加し、生成前の構成を退避して down に渡す。generate_scaled_compose は .docker-compose.scale.yml を上書きするため、新構成で停止するとスケールを縮める起動 (4 → 2 等) で旧インスタンスが取り残される
  • 生成が途中で失敗した場合は旧構成を書き戻し、down / ps が参照する構成を壊さない
  • 退避ファイル .docker-compose.scale.yml.prev を .gitignore へ追加
  • 回帰テスト tests/commands/test_container_up_order.py (4 件) を追加

gemini は指摘 0 件 (APPROVE)。round 1 の env edit 原文保持に対する regression 指摘ではないことをコードを読んで確認済み。

検証

  • ruff check --select=E9,F63,F7,F82 lib tests — pass(CI と同条件)
  • uv run python -m compileall lib bin — pass
  • uv run pytest — 1226 passed

fix commit: 9b82159

@takemi-ohama takemi-ohama left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 3 | codex | APPROVE

修正必須の指摘はありません。uv run pytest -q は 1226 passed でした。

@takemi-ohama takemi-ohama left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 cross-review | round 3 | gemini | APPROVE

全体を通して堅牢な設計と実装です。暗号化と復号の双方向における compose.yml のパース・書き換えの正確性、エラー時の丁寧なロールバック、ディレクトリ権限 (umask 対応) および原子的な書き換え (atomic replace)、さらに機密がコンテナや子プロセスへ意図せず漏出しないためのライフサイクル管理など、セキュリティと運用リスクが細部まで適切に考慮されています。

不要な抽象化や重複コードも見当たらず、修正が必要な指摘事項はありません。

@takemi-ohama

Copy link
Copy Markdown
Contributor Author

🧹 /ndf:fix 最終スイープ

cross-review 収束後の取りこぼし回収として、PR #90 の全 review thread を再走査しました。

項目 件数
review thread 総数 2
追加で修正 0
追加で Resolve 0
残 open thread 0

走査結果

GraphQL の reviewThreads を全件取得したところ、既存の 2 スレッドはいずれもループ内 fix で対応済み・Resolve 済みでした。

# ファイル 指摘 対応
1 lib/devbase/commands/env.py:465 [major / UX] env edit が辞書往復でコメント・空行を破棄 88d9c8c で load_bytes() / save_bytes() 追加 — resolved
2 lib/devbase/commands/container.py:624 [major / エラーハンドリング] 復号失敗時に down 済みの環境が停止したまま 9b82159 で「生成 → 停止 → 起動」へ順序変更 + 旧構成退避 — resolved

最終 APPROVE ラウンド (round 3) は codex / gemini とも インラインコメント 0 件 での APPROVE だったため、回収すべき minor/nit の取りこぼしはありませんでした。

  • codex round 3: APPROVE / comments 0 (#pullrequestreview-4933721472)
  • gemini round 3: APPROVE / comments 0 (#pullrequestreview-4933727771)

CI

現時点の checks は全 pass(Python syntax check 3.10/3.11/3.12、Ruff lint、ShellCheck)。本スイープでのコード変更はないため追加コミットはありません。

未解決スレッド 0 件。マージ可能な状態です。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant