Skip to content

feat: PLAN35-env-commands 設定操作コマンドの保存先切替 - #92

Merged
takemi-ohama merged 4 commits into
release/PLAN35from
feature/PLAN35-env-commands
Aug 12, 2026
Merged

takemi-ohama merged 4 commits into
release/PLAN35from
feature/PLAN35-env-commands

Conversation

@takemi-ohama

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

Copy link
Copy Markdown
Contributor

Summary

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

暗号化された設定を作る手段はまだ無いため、既存の利用者から見た動作は変わらない (すべて平文のまま扱われる)。移行コマンドは後続の変更で入る。

収集処理を書き換えずに済ませる

設定の収集処理は EnvFile の get / set / save という素朴な API に対して書かれている。保存先ごとに書き分けると、収集処理まで暗号化を知ることになる。そこでストアの 1 参照を EnvFile と同じ形に見せるビューを挟み、呼び出し側は従来どおり書けるようにした。

暗号化された設定の編集

エディタは平文のファイルしか開けないため、復号結果を自分専用の 0700 ディレクトリへ 0600 で書き、編集後に暗号化し直してから finally で必ず消す。ここだけは平文が一瞬ディスクに載る — エディタという外部プロセスへ値を渡す手段が他に無いためで、恒久的な平文ファイルを作らないという方針の明示的な例外として扱う。

編集にまつわる扱い:

  • 内容が変わっていなければ書き戻さない: 同じ内容でも再暗号化すればファイルは変わる。差分やバックアップに無用な更新を生まないため
  • エディタが異常終了したら保存しない: 編集を中断したつもりが保存されていた、という取り違えを防ぐ
  • 編集結果が UTF-8 として読めなければ保存しない: 壊れた内容で既存の設定を上書きしない

プロジェクト設定を CLI から管理できるようにする

暗号化された設定はエディタで直接開けないため、CLI が唯一の操作手段になる。delete と edit に --project を追加し、set --project で入れた変数を後から消したり直したりできるようにした。これが無いと、env.yml に定義の無い変数は一度入れると取り除けない。

プロジェクト設定の参照先を直す

これまでプロジェクト設定は「実行時の CWD にある .env」を指していた。projects/<name>/sub で実行すると projects/<name>/sub/.env に書かれるが、コンテナ構成が読むのはプロジェクト直下であり、書いた設定が反映されない。参照先をプロジェクト直下に固定した。

パスの判定は論理パスと物理パスの両方を試す。プラグイン由来のプロジェクトは projects/<name> がシンボリックリンクになっているため物理パスだけでは配下と判定できず、逆に実体パスから入った場合は論理パスだけでは判定できない。論理パス側では .. を畳んでから突き合わせ、projects/web/../../outside のようにプロジェクト外へ抜けるパスが誤って配下と判定されないようにしている。

あわせて projects/ の外での --project は、どのプロジェクトを指すのか決められないため明示的に断るようにした (従来はその場に .env を作っていた)。

保存先ディレクトリの権限

機密の保存先ディレクトリを作る処理を 1 箇所に集約し、自分が新規に作った階層だけを作成時点から 0700 にする。作成後に chmod すると、その間だけ権限が緩い状態が露出する。既存ディレクトリの権限は変更しない (共有ディレクトリを指定されても他の利用者のアクセスを壊さないため)。

一覧表示

暗号化されている保存先にだけ [暗号化] の印を付ける。どちらで保存されているかは利用者が知りたい情報だが、平文側にも印を足すと既存の見た目が変わるため片側だけにした。

Test plan

  • tests/commands/test_env_store_switch.py — set / get / delete / edit / list / init が平文・暗号化のどちらでも同じ結果になること、暗号化時に平文が生まれないこと、--project 系の対象解決、シンボリックリンク経由と .. を含むパスの判定、両形式が同時に存在する場合の停止、複数受信者での暗号化
  • tests/env/test_io_common.py — 保存先の親ディレクトリが新規作成時に 0700 になり、既存ディレクトリの権限は変わらないこと
  • 編集の一時ファイルが正常時・異常時のいずれでも削除されること
  • 全 985 件 green / ruff check --select=E9,F63,F7,F82 lib パス
  • CLI リファレンス (docs/user/cli-reference/03-env.md) の追随
開発用: 関連情報 (レビュー対象外)
  • plan: issues/plan35.md (段階 2)
  • release PR: release: PLAN35 環境変数ファイルの暗号化 #90
  • 移行コマンド (encrypt / decrypt) は PR3 へ移した。共通の機密を暗号化した時点で平文ファイルは消えるため、コンテナ構成の変更と同じ PR に入っていないとその PR 単体で壊れる

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

@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 の方針と実装は適切ですが、プロジェクト固有設定の暗号化に伴うCLI操作の課題が1点あります。

lib/devbase/commands/env.py

[major / ユーザビリティ]
プロジェクト設定が暗号化(.env.age)されると、利用者が直接エディタでファイルを開いて不要なキーを消すことができなくなります。
現状 delete や edit コマンドに --project オプションがないため、間違って env set --project で追加した変数を手動で削除・修正する手段が(env.yml に定義がない限り)ありません。
本 PR または後続 PR で delete および edit に --project オプションを追加し、プロジェクト変数を CLI から管理できるようにすることを検討してください。

設定が暗号化 (.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
@takemi-ohama

Copy link
Copy Markdown
Contributor Author

@gemini の指摘 (major / ユーザビリティ) に対応しました。

devbase env delete <KEY> --project / -p と devbase env edit --project / -p を追加し、暗号化されたプロジェクト設定 (.env.age) からも CLI でキーの削除・編集ができるようにしました。edit --project は暗号化されていれば既存の _edit_encrypted 経路 (復号 → 一時ファイル編集 → 再暗号化) を通ります。projects/<name> 配下でない場所での --project は、どのプロジェクトを指すか決められないため env set --project と同じ文言で断ります (グローバルへフォールバックしません)。

set / delete / edit で重複していた参照解決は _target_env() ヘルパへ括り出し、zsh / bash 補完と docs/user/cli-reference/03-env.md も追随させました。テストは tests/commands/test_env_store_switch.py に 6 件追加 (プロジェクト設定の削除・編集、グローバルへの非波及、projects/ 外でのエラー) し、全 977 件 green です。

@takemi-ohama takemi-ohama reopened this Aug 12, 2026

@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 | 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 2 | gemini | REQUEST_CHANGES

保存先の抽象化 (SecretStore への切り替え) や、後続 PR を見据えた責務の分離 (移行コマンドの移動) など、全体的な設計とテストは非常に堅牢です。

1点、プラグイン等でシンボリックリンクされたプロジェクト配下でのパス解決にリグレッションをもたらす実装があったためインラインで指摘しています。ご確認をお願いします。

Comment thread lib/devbase/commands/env.py Outdated
_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

@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 | REQUEST_CHANGES

論理パスの正規化前判定により、プロジェクト外のパスをプロジェクト配下と誤認するため修正が必要です。

Comment thread lib/devbase/commands/env.py

@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 | REQUEST_CHANGES

  • 新規追加された devbase env keygen のドキュメントが CLI リファレンスから漏れているため、追記をリクエストします。
  • write_secure_bytes_atomic で作成される親ディレクトリ (secrets/ など) の権限が umask 依存 (0755等) になる点について、意図との整合性を確認するマイナーな指摘を行いました。

docs/user/cli-reference/03-env.md

[major / 完全性]
新規追加された devbase env keygen コマンドのリファレンスがドキュメントから漏れています。ユーザーが鍵の生成方法や --force などのオプションを知るための説明を追記してください。

lib/devbase/env/io_common.py

[minor / セキュリティ]
path.parent.mkdir(parents=True, exist_ok=True) はデフォルトの umask (例: 022) でディレクトリを作成するため、暗号化保存先である secrets/ が 0755 になる可能性があります。secrets/ は Git 管理外でありファイル自体は 0600 で保護されるため致命的ではありませんが、agekeys.py の _ensure_private_dir と同様に 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
@takemi-ohama

Copy link
Copy Markdown
Contributor Author

gemini のレビュー本文で指摘いただいた 2 件に 882e544 で対応しました。

[major/完全性] docs/user/cli-reference/03-env.md に devbase env keygen が無い

devbase env project と devbase env export の間 (CLI の登録順に合わせた位置) に節を追加しました。既存の他コマンド節と同じ書式・粒度です。

  • 生成方法とオプション表 (--force / -y, --yes)
  • 鍵ファイルの既定パス (~/.config/devbase/age/keys.txt、XDG_CONFIG_HOME を尊重) と DEVBASE_AGE_KEY_FILE による変更を表で併記。生成先を CLI 引数で指定できない理由 (復号時の探索先と構造的に一致させるため) も明記
  • コマンド例 3 種 (既定生成 / 置き場変更 / --force 作り直し)
  • 鍵を失うと暗号化した機密は復旧できないためバックアップが必須である旨を引用ブロックで強調。鍵が全ワークスペース共通であり --force は他ワークスペースの機密にも影響することも記載

docs/user/cli-reference/README.md の env グループのサブコマンド一覧にも keygen を追加しています。

[minor/セキュリティ] write_secure_bytes / write_secure_bytes_atomic の親ディレクトリ作成が umask 依存

採用しました。実装を二重に持たないよう、agekeys._ensure_private_dir の中身を io_common.ensure_private_dir へ移し、agekeys 側はそれを呼ぶだけの薄いラッパにしています (write_secure_bytes / write_secure_bytes_atomic も mkdir(parents=True, exist_ok=True) からこれに置き換え)。既存の重要な性質はすべて維持しています。

  • 自分が新規作成した階層だけを 0700 にし、既存ディレクトリは chmod しない
  • 未存在の階層を控えてから親→子に mkdir(mode=0o700)。作成時点から 0700 で、後追い chmod による露出ウィンドウを作らない
  • 並行作成の FileExistsError は握って続行

既存ディレクトリが緩いときの警告だけは warn_if_permissive で選択制にしました。agekeys 経路 (置き場を利用者が DEVBASE_AGE_KEY_FILE で明示的に選べる) では従来どおり有効ですが、write_secure_bytes は $DEVBASE_ROOT 直下や export 先の CWD のような「緩くて当たり前」の場所にも書くため、常に鳴らすと本当の警告が埋もれるためです。

テストは tests/env/test_agekeys.py / tests/commands/test_env_keygen.py の既存分 (agekeys._ensure_private_dir を monkeypatch するものを含む) がそのまま通ることを確認したうえで、tests/env/test_io_common.py に以下を追加しました。

  • write_secure_bytes / write_secure_bytes_atomic の両方について、親を新規作成する場合に umask 0 でも 0700 になること
  • 既存ディレクトリ (0755) の権限は変更されず、ファイルは 0600 で守られること
  • 既定では緩い既存ディレクトリを警告しないこと / warn_if_permissive=True なら警告し権限は変えないこと

uv run --project . python -m pytest -q は 985 passed、uvx ruff check --select=E9,F63,F7,F82 lib も clean です。

@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 4 | codex | APPROVE

修正が必要な指摘はありません。全 985 テストの成功を確認しました。

@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 4 | gemini | APPROVE

変更内容(機密の自動判定、排他生成、鍵解決、UI 統一など)を確認しました。修正提案はありません。

@takemi-ohama
takemi-ohama marked this pull request as ready for review August 12, 2026 06:24
@takemi-ohama
takemi-ohama merged commit 26dae89 into release/PLAN35 Aug 12, 2026
@takemi-ohama
takemi-ohama deleted the feature/PLAN35-env-commands branch August 12, 2026 06:25
takemi-ohama added a commit that referenced this pull request Aug 15, 2026
* docs: PLAN35 環境変数ファイル暗号化の方針と PR 分割計画

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

* feat: PLAN35-secret-store 秘密ストアの抽象層と age 実装 (#91)

* 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: PLAN35-env-commands 設定操作コマンドの保存先切替 (#92)

* 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: PLAN35-runtime 起動ラッパーとコンテナへの受け渡し (#93)

* 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: PLAN35-ops-docs 受信者更新・平文走査・ドキュメント (#94)

* 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>

* docs: PLAN35 の完了サマリを plan へ追記

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

* fix: env edit で原文のまま編集・保存しコメントを保持する

暗号化された設定を `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

* fix: up は復号と構成生成を既存コンテナ停止の前に完了させる

鍵の紛失・権限不備・暗号文の破損で復号に失敗すると、直前の
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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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