From 40450ec7f6af60684f483e3a041c6ffbfdc1fea1 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 19 Sep 2026 05:11:30 +0000 Subject: [PATCH 01/12] fix: drop bwrap setuid, unmask /proc for bubblewrap sandbox - Dockerfile: remove chmod u+s on /usr/bin/bwrap - compose.yaml: unmask /proc via systempaths=unconfined so bubblewrap 0.12.0 can mount a fresh procfs without setuid - .claude/settings.json, .codex/config.toml: consolidate credential denies onto directory-level entries to avoid multi-file masking issues (openai/codex#43929) and drop the duplicate **/.env.* glob - .devcontainer/setup-devcontainer.sh: install the pnpm version pinned in packageManager before pnpm install, so sandboxed pnpm commands don't try to download a mismatched version - docs/dev-notes: plan/survey/learning for the bwrap setuid fix Co-Authored-By: Claude Sonnet 5 --- .claude/settings.json | 10 +- .codex/config.toml | 13 +- .devcontainer/setup-devcontainer.sh | 5 + Dockerfile | 1 - compose.yaml | 3 + .../2026-09-18/fix-bwrap-setuid/plan.md | 322 ++++++++++++++++++ .../2026-09-18/fix-bwrap-setuid/survey.md | 141 ++++++++ .../2026-09-19/fix-bwrap-setuid/learning.md | 29 ++ 8 files changed, 508 insertions(+), 16 deletions(-) create mode 100644 docs/dev-notes/2026-09-18/fix-bwrap-setuid/plan.md create mode 100644 docs/dev-notes/2026-09-18/fix-bwrap-setuid/survey.md create mode 100644 docs/dev-notes/2026-09-19/fix-bwrap-setuid/learning.md diff --git a/.claude/settings.json b/.claude/settings.json index 602db4f4e..b254c5746 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,14 +2,9 @@ "sandbox": { "enabled": true, "allowUnsandboxedCommands": false, + "enableWeakerNestedSandbox": true, "filesystem": { - "denyRead": [ - "~/.ssh", - "~/.claude/.credentials.json", - "~/.codex/auth.json", - "**/.env", - "**/.env.*" - ] + "denyRead": ["~/.ssh", "~/.claude/.credentials.json", "~/.codex/auth.json", "**/.env"] } }, "permissions": { @@ -30,7 +25,6 @@ "Read(~/.claude/.credentials.json)", "Read(~/.codex/auth.json)", "Read(**/.env)", - "Read(**/.env.*)", "Read(**/secrets/**)", "Read(**/config/credentials.json)", "Read(**/*.pem)", diff --git a/.codex/config.toml b/.codex/config.toml index 08e6abfc4..1825cb45d 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -7,19 +7,18 @@ extends = ":workspace" [permissions.project-edit.filesystem] glob_scan_max_depth = 8 -"~/.codex/auth.json" = "deny" -"~/.claude/.credentials.json" = "deny" +# openai/codex#43929: masking two or more files aborts the sandbox at startup, while directory +# denies are unaffected. Credentials are therefore denied by directory, keeping the single +# file slot for `.env`. +"~/.codex" = "deny" +"~/.claude" = "deny" "~/.ssh/**" = "deny" # Keep the denied set aligned with `.claude/settings.json`. Both files are committed and # therefore apply to host clones and cloud agents, not only to this devcontainer. [permissions.project-edit.filesystem.":workspace_roots"] +# The single file slot (see above). Listing `**/.env` too could count the same file twice. ".env" = "deny" -".env.*" = "deny" -# A leading `**/` does not match a top-level path in every glob implementation, so the -# bare and recursive forms are both listed on purpose. -"**/.env" = "deny" -"**/.env.*" = "deny" "**/secrets/**" = "deny" "**/config/credentials.json" = "deny" "**/*.pem" = "deny" diff --git a/.devcontainer/setup-devcontainer.sh b/.devcontainer/setup-devcontainer.sh index b54f1a157..78e9ebf36 100644 --- a/.devcontainer/setup-devcontainer.sh +++ b/.devcontainer/setup-devcontainer.sh @@ -32,5 +32,10 @@ rtk gain >/dev/null # Agent integration is optional and separate from installing the RTK CLI. rtk init -g --auto-patch || echo 'RTK init failed, continuing...' +# Match the global pnpm to `packageManager`; a mismatch makes pnpm download the pinned version, +# which the agent sandboxes cannot write, so every sandboxed `pnpm` command fails. +pnpm_version="$(node -p "require('./package.json').packageManager.split('@')[1].split('+')[0]")" +npm install -g "pnpm@${pnpm_version}" + # Install project dependencies pnpm install diff --git a/Dockerfile b/Dockerfile index 1264b1b27..bb6da7470 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,6 @@ COPY . /usr/src/app RUN apt-get update \ && apt-get -y install --no-install-recommends bubblewrap fish \ - && chmod u+s /usr/bin/bwrap \ && rm -rf /var/lib/apt/lists/* ENV NODE_PATH=/node_modules diff --git a/compose.yaml b/compose.yaml index 33624c0b9..f283efe1c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,6 +13,9 @@ services: security_opt: - apparmor=unconfined - seccomp=unconfined + # Unmask /proc so bubblewrap 0.12.0 can mount a fresh procfs without setuid; Codex's + # retry-without-/proc fallback misses the new error text (openai/codex#44329). + - systempaths=unconfined ports: - '5173:5173' - '5555:5555' diff --git a/docs/dev-notes/2026-09-18/fix-bwrap-setuid/plan.md b/docs/dev-notes/2026-09-18/fix-bwrap-setuid/plan.md new file mode 100644 index 000000000..77cebd708 --- /dev/null +++ b/docs/dev-notes/2026-09-18/fix-bwrap-setuid/plan.md @@ -0,0 +1,322 @@ +# Plan: devcontainer 内で Claude / Codex のサンドボックスを復旧する + +Issue: https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/4059 +調査結果: [survey.md](./survey.md) + +## 概要 + +- bubblewrap 0.12.0(Debian trixie の DSA-6472-1 で配信)は setuid での起動を拒否するため、`Dockerfile` の `chmod u+s /usr/bin/bwrap` で Claude / Codex の Bash がすべて失敗している。[1][2][3] +- setuid を外すと、隠れていた問題が 2 つ表に出る。 + - Claude: コンテナ内で新しい `/proc` をマウントできない(`Can't mount proc on /proc: Operation not permitted`)。[4] + - Codex: deny ルールに一致するファイルが 2 つ以上あると起動時に失敗する既知バグ(`Can't write data to file /usr/src/app/.env.example: Bad file descriptor`)。[5] +- 3 つを設定ファイルの変更だけで解消する。 + +## 設計判断 + +### setuid を外す + +- 上流の bubblewrap 0.12.0 は setuid 対応を削除しており、setuid 付きの bwrap は `acquire_privs()` で即終了する。[1][2] +- 残す選択肢はない。 + +### Claude: `sandbox.enableWeakerNestedSandbox: true` + +- Claude Code 公式ドキュメントの Troubleshooting に載っている、この症状への対処法そのもの。[4] + - "Set `enableWeakerNestedSandbox` to `true` so the inner sandbox bind-mounts the container's existing `/proc` instead." +- 設定リファレンス上の Scope は "Any file" で、プロジェクトの `.claude/settings.json` に書ける。[6] +- 公式ドキュメントの注意書きは "considerably weakens security and should only be used when additional isolation is otherwise enforced."。[4] + - この環境ではコンテナ自体が外側の隔離境界になっているため許容する。 + - ファイルシステムの deny とネットワーク制限は引き続き有効で、弱まるのはサンドボックス内のコマンドから `/proc` のプロセス情報が見えるようになる点。 +- Linux 専用の設定のため、macOS のホストで clone した場合は影響しない。Linux ホストで直接使う場合は同じく弱まる。 + +### Codex: `.env.example` を deny 対象から外す + +- openai/codex#43929 は未修正で、回避策も示されていない。[5] +- `.env.example` は `.gitignore` の `!.env.example` で commit 済みのテンプレートで、秘密情報を含まない。`git show` でも読めるため、deny しても守れるものがない。 +- `.env.*` を `.gitignore` と同じ意図の具体的なパターン(`.env.local`, `.env.*.local`)に置き換え、`.env.example` が一致しないようにする。 +- `.codex/config.toml` のコメントに従い、`.claude/settings.json` の deny も同じ集合に揃える。 + +## 却下した代替案 + +| 案 | 却下理由 | +| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `allowUnsandboxedCommands: true` / `sandbox.enabled: false` | 原因を直さずに防御を外すだけ。`denyRead` による認証情報の保護も効かなくなる | +| bubblewrap を 0.11.x に固定 | 廃止済みの setuid 方式を延命するだけで、CVE-2026-87766 の修正も受けられない [3] | +| `compose.yaml` に `security_opt: systempaths=unconfined` | Claude のサンドボックスの強度は保てるが、コンテナ全体で `/proc` のマスクと読み取り専用の保護が外れる(Phase 3d のトレードオフを参照)。Claude の公式ドキュメントの対処法でもない。※ Codex 用には Phase 3d で採用 | +| Codex の `.env.*` deny を残して上流の修正を待つ | 修正の目処が立っておらず、その間 Codex が起動しない | + +## Phase 1: `Dockerfile` から setuid を外す(低リスク) + +- レイヤー: 開発環境の設定(コンテナイメージ) +- 変更: `&& chmod u+s /usr/bin/bwrap \` の 1 行を削除する。 + +```dockerfile +RUN apt-get update \ + && apt-get -y install --no-install-recommends bubblewrap fish \ + && rm -rf /var/lib/apt/lists/* +``` + +- 確認: リビルド後に `ls -l /usr/bin/bwrap` の結果に `s` ビットが付いていないこと。 +- テスト: 分岐のある振る舞いを持たない設定変更のため、テストファーストは省略する。 + +## Phase 2: Claude の入れ子サンドボックスを有効にする(中リスク) + +- レイヤー: Claude Code 設定 +- 変更: `.claude/settings.json` の `sandbox` に `enableWeakerNestedSandbox: true` を追加する。 + +```json +{ + "sandbox": { + "enabled": true, + "allowUnsandboxedCommands": false, + "enableWeakerNestedSandbox": true, + "filesystem": { "...": "..." } + } +} +``` + +- 確認: Claude から `true`、`git status`、`git commit --dry-run` が成功すること。 + +## Phase 3: `.env.example` を deny 対象から外す(中リスク・秘密情報の扱いの変更) + +- レイヤー: Codex / Claude Code 設定 +- `.codex/config.toml` の `":workspace_roots"` テーブル: + +```toml +".env" = "deny" +".env.local" = "deny" +".env.*.local" = "deny" +"**/.env" = "deny" +"**/.env.local" = "deny" +"**/.env.*.local" = "deny" +``` + +- `.claude/settings.json`: `sandbox.filesystem.denyRead` と `permissions.deny` の `**/.env.*` / `Read(**/.env.*)` を同じ集合(`**/.env.local`, `**/.env.*.local`)に置き換える。 +- 確認: `codex sandbox -- /bin/true` が成功し、Claude / Codex から `.env` が読めず、`.env.example` が読めること。 +- 既知の制約: ワークスペースに `.env` と `.env.local` が同時に存在すると、再び openai/codex#43929 に当たる。現状は `.env` のみの想定。 + +## Phase 3b: deny を `.env` のみに絞る(Phase 3 の見直し) + +### 経緯 + +- Phase 3 の適用とリビルドのあと、Codex が `bwrap: Can't write data to file /usr/src/app/.env.local: Bad file descriptor` で失敗した。 +- `.env.local` は実在しない。リポジトリ直下の env ファイルは `.env` と `.env.example` のみ。 + +### 見直した原因の理解 + +- Phase 3 では「実在するファイルが 2 つ以上一致すると失敗する」と考えていたが、不十分だった。 +- `".env.local"` のようにワイルドカードを含まない完全パスは、ファイルが実在しなくても Codex がマスク対象として扱い、1 つに数えると考えられる。 +- 今回は `.env`(実在)と `.env.local`(実在しない完全パス)で 2 つになり、2 つ目で失敗した。 +- `".env.*.local"` のようなワイルドカードのパターンは、実在するファイルにしか一致しないと考えられる(未検証)。 + +| 時点 | 失敗したファイル | 一致したルール | +| -------------- | ---------------- | ------------------------------------ | +| Phase 3 適用前 | `.env.example` | `.env.*`(ワイルドカード、実在) | +| Phase 3 適用後 | `.env.local` | `.env.local`(完全パス、実在しない) | + +### 変更 + +- `.env.local` は運用上置いていないため、`.local` の系統を Codex / Claude の両方から外し、`.env` のみを deny する。 +- `.codex/config.toml` の `":workspace_roots"` テーブル: + +```toml +".env" = "deny" +"**/.env" = "deny" +``` + +- `.claude/settings.json`: `denyRead` と `permissions.deny` から `.env.local` / `.env.*.local` の行を外し、`**/.env` / `Read(**/.env)` のみ残す。 + +### 未確認の点とフォールバック + +- `".env"` と `"**/.env"` は同じ `.env` を指すが、Codex がこれを 1 つと数えるか 2 つと数えるかは未確認。 +- 2 つと数えられて失敗した場合は、`"**/.env"` を外して `".env"` のみにする。 + +### トレードオフ + +- 将来 `.env.local` などを置くと、Codex / Claude の deny から外れて読めるようになる。置く際に deny を足し直す必要がある(その時点で openai/codex#43929 が未修正なら Codex が起動しなくなる点にも注意)。 + +### 確認 + +- リビルド不要(設定ファイルのみ)。Codex CLI を再起動し、セッションが開始できること。 +- Codex / Claude から `.env` が読めず、`.env.example` が読めること。 + +## Phase 3c: Codex のファイル単位の deny を `.env` の 1 つに絞る(最優先) + +### 経緯 + +- Phase 3b のあと、Codex CLI が `bwrap: Can't write data to file /home/node/.claude/.credentials.json: Bad file descriptor` で起動しなくなった。 +- ファイル単位でマスクされる上限(1 つ)は、ワークスペース内だけでなく Codex の設定全体で数えられていた。 +- 現状でファイル単位にマスクされるのは `.env`、`~/.codex/auth.json`、`~/.claude/.credentials.json` の 3 つ(`~/.ssh` は存在せず、`**/*.pem` などに一致するファイルもない)。 + +### 判定: Codex CLI の不具合(設定ミスではない) + +- 公式ドキュメントは、完全パス(例: `~/.ssh`)とワイルドカード(例: `"**/*.env" = "deny"`)による deny を推奨している。現在の設定はこの使い方どおり。[7] +- openai/codex#43929 では、完全パスかワイルドカードかなどの条件によらず「deny に一致するファイルが 2 つ以上で必ず失敗、ディレクトリなら動く」と報告されている。0.155.1 でも未修正。[5] + +### 方針 + +- `.env` を守ることを最優先し、ファイル単位の 1 枠を `.env` に使う。 +- 認証ファイルはディレクトリ単位の deny に置き換える(#43929 によると、ディレクトリは上限に数えられない)。 + +| 対象 | 変更前 | 変更後 | +| ----------------------------- | ---------------------------------------- | ---------------------------------------------------------- | +| `.env` | `".env"` と `"**/.env"`(ファイル) | `".env"` のみ(同じファイルが 2 つと数えられるのを避ける) | +| `~/.claude/.credentials.json` | ファイル | `"~/.claude"`(ディレクトリ) | +| `~/.codex/auth.json` | ファイル | `"~/.codex"`(ディレクトリ) | +| `~/.ssh/**` | ワイルドカード(現在は何にも一致しない) | 変更なし | + +### リスクとフォールバック + +- `~/.codex` を deny すると、AGENTS.md を読み込むサンドボックスが `~/.codex` 配下(グローバルの AGENTS.md や skills)を読めず、失敗する可能性がある。 + - 失敗した場合は `"~/.codex"` の deny だけを外す。そうすると `~/.codex/auth.json` は Codex のコマンドから読めるようになるが、`.env` と `~/.claude` は守られたまま。 +- `"**/.env"` を外すため、サブディレクトリの `.env` は Codex の deny から外れる(現在は存在しない)。 +- `**/*.pem`、`**/*.key`、`**/secrets/**`、`**/config/credentials.json` に一致するファイルが今後 1 つでも置かれると、再び #43929 に当たる。 +- 次に #44304(bubblewrap 0.12.0 で `/proc` をマウントできないときの代替手段が働かない)に当たる可能性がある。[8] +- Claude 側の deny(`.claude/settings.json`)は変更しない。Claude にはこの制約がないため。 + +### 確認 + +- Codex CLI を再起動し、セッションが開始できること。 +- Codex から `.env`、`~/.claude/.credentials.json`、`~/.codex/auth.json` が読めず、`.env.example` が読めること。 + +## Phase 3d: `compose.yaml` に `systempaths=unconfined` を追加する(Codex の `/proc` 対策) + +### 経緯 + +- Phase 3c のあと、古い deny 設定を読み込んだまま動いていた VS Code 拡張の app server(PID 804、03:04 起動)を再起動した。すると Codex のエラーが `bwrap: Can't mount proc on /proc: Operation not permitted` に変わった。 +- deny の変更は反映された。次の問題として、[8] の `/proc` の問題が表に出た。 + +### 原因(根拠) + +- Codex 公式の secure devcontainer は「bubblewrap を setuid で入れる」方式で、Docker 内で `bwrap --proc /proc` が拒否されたときは、Codex が `--proc` なしで再試行する設計になっている。[9] +- bubblewrap 0.12.0 では setuid 方式が使えず、エラー文言も `/newroot/proc` から `/proc` に変わった。Codex は `/newroot/proc` という文字列で失敗を判定しているため、再試行されない。[8][10] +- #44304 と #44329 はどちらも Open で、PR もない。 +- Codex 公式ドキュメント(Permissions)には、コンテナ内での `/proc` の扱いについての記述がない。[7] + +### 変更 + +- `compose.yaml` の `web.security_opt` に `systempaths=unconfined` を追加する。 +- Docker が `/proc` の一部をマスクするのをやめるので、user namespace の中でも bwrap が新しい `/proc` をマウントできるようにする狙い(未検証)。 +- コンテナの作り直しが必要(Rebuild Container)。 + +### トレードオフ + +- Docker 公式の説明は "Turn off confinement for system paths (masked paths, read-only paths) for the container"。[11] 影響は次の 2 つで、コンテナ内の全プロセスが対象になる。 + - マスクの解除: Docker が隠していた `/proc` のパス(`/proc/kcore` など)が見えるようになる。 + - 読み取り専用の解除: `/proc/sys`、`/proc/sysrq-trigger` などが書き込めるようになる。こちらのほうが影響が大きい。 +- このコンテナは user namespace で root を分離していない(userns-remap なし)。そのため、コンテナ内で root(`sudo`)になると、ホストのカーネル設定を変えたり、`/proc/sysrq-trigger` でホストを再起動したりできる経路が開く。 + +#### 実害の範囲(ローカルの OrbStack で動かす場合) + +- ここでの「ホストのカーネル」は、macOS ではなく OrbStack が動かしている Linux VM のカーネル。影響が及ぶのはこの VM と、同じ VM で動くほかのコンテナまでで、Mac 本体のファイルには届かない。 +- 悪意のあるコードがコンテナ内で root になれた場合に、新たにできるようになること: + +| できるようになること | 実害 | +| -------------------------------- | -------------------------------------------------------------------------------------- | +| `/proc/sysrq-trigger` に書き込む | VM を即座に再起動・停止できる。OrbStack の全コンテナが止まる | +| `/proc/sys` に書き込む | VM のカーネル設定を変えられる。ほかのコンテナの保護を弱めることもできる | +| `/proc/kcore` を読む | VM のカーネルメモリを読める。ほかのコンテナの秘密情報(DB のパスワードなど)が漏れうる | + +- root でなくても、`/proc/timer_list` などから VM 内のプロセスの情報が少し見えるようになる(軽微)。 + +#### 誰がこれをできるか + +- devcontainer の `node` ユーザーは、通常パスワードなしで `sudo` を使える。`pnpm install` で入ったパッケージの postinstall スクリプトなど、サンドボックスの外で動くコードが悪意を持っていれば、root になれる。 +- Claude / Codex がサンドボックスの中で実行するコマンドは、bwrap が `sudo` による昇格を防ぐため、この経路は通らない。 + +#### 判断 + +- このコンテナにはすでに `SYS_ADMIN` と `seccomp` / `apparmor` の unconfined が与えられており、root ならこれらの保護を自分で外せる。`systempaths=unconfined` で新しくできるようになることはほとんどなく、変わるのは攻撃に必要な手間が 1 段減ること。 +- ただし「すでに緩めてあるから、さらに緩めてもよい」という理屈は、緩和を重ねる理由にはならない点に注意する。 +- ローカルの開発環境で、同じ OrbStack の VM に重要なコンテナを同居させない前提で許容する。この前提と実害の範囲は、チームに共有する。 +- Phase 2 の「却下した代替案」では Claude 用として退けたが、Codex には代わりの手段がないため採用する。Claude の `enableWeakerNestedSandbox` は、効果を確認できるまで残す。 + +### フォールバック + +- それでも `/proc` のエラーが出る場合は、この変更を戻し、上流の修正を待つ。 +- 上流の修正を待つ間は、`default_permissions` を外して組み込みの `:workspace` で動かす(`.env` が Codex から読めるようになる)かどうかを、チームで判断する。 + +### 確認 + +- Rebuild Container のあと、Codex CLI と VS Code 拡張の両方で、セッションが開始できること。 +- Codex から `.env`、`~/.claude/.credentials.json`、`~/.codex/auth.json` が読めず、`.env.example` が読めること。 +- Claude の Bash が引き続き動くこと。 + +## Phase 3e: コンテナの pnpm を `packageManager` に揃える + +### 経緯 + +- Codex が動いたあと、Claude CLI から commit できなかった。lefthook の `format` ジョブ(`pnpm exec prettier`)がサンドボックス内で `create the temporary package manager install directory: Read-only file system` で失敗していた。 +- `pnpm test:unit` や `pnpm lint` など、Claude がサンドボックス内で実行する `pnpm` はすべて同じ理由で失敗する。 + +### 原因 + +- pnpm 11 以降は、実行中の pnpm と `package.json` の `packageManager` のバージョンが違うと、指定のバージョンを自動でダウンロードして使う(`pmOnFail: download` が既定)。[12] +- コンテナの pnpm はベースイメージ(`mcr.microsoft.com/devcontainers/javascript-node:24`)に入っている 12.3.4。`packageManager` は Renovate が上げていて 12.4.2。 +- サンドボックスの中ではダウンロード先に書き込めないため失敗する。サンドボックスの外では黙ってダウンロードして動くため、これまで気づかなかった。 +- `Dockerfile` と `setup-devcontainer.sh` は、過去に一度も pnpm のバージョンを固定していない(git 履歴で確認)。 + +| 日付 | `packageManager` | コンテナの 12.3.4 との関係 | +| ----- | ---------------- | -------------------------- | +| 09-05 | pnpm@12.3.4 | 一致 | +| 09-13 | pnpm@12.4.1 | ここからずれた | +| 09-18 | pnpm@12.4.2 | ずれたまま | + +- ずれ始めた 09-13 は、#4033 / #4034 で Claude の Bash が全滅した時期と重なる。そのため、サンドボックスを直すまで表に出なかった。 +- 09-01 以前(pnpm 11 の時期)に問題にならなかった理由は、当時のイメージの pnpm の版が分からず未確認。 + +### 変更(案 A) + +- `.devcontainer/setup-devcontainer.sh` の `pnpm install` の直前で、`package.json` の `packageManager` と同じ版の pnpm をグローバルに入れる。 +- バージョンを 2 か所に書かないので、Renovate が `packageManager` を上げても、次のリビルドで自動的に追従する。 + +```bash +# Match the global pnpm to `packageManager`; a mismatch makes pnpm download the pinned version, +# which the agent sandboxes cannot write, so every sandboxed `pnpm` command fails. +pnpm_version="$(node -p "require('./package.json').packageManager.split('@')[1].split('+')[0]")" +npm install -g "pnpm@${pnpm_version}" +``` + +- `+sha512...` のハッシュが `packageManager` に付いた場合に備え、`+` 以降は取り除く。 +- 失敗したときは、後続の `pnpm install` と同じくセットアップ全体を失敗させる(黙ってずれたままにしない)。 + +### 却下した代替案 + +| 案 | 却下理由 | +| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| サンドボックスで pnpm のキャッシュへの書き込みを許可(`allowWrite`) | サンドボックスを緩める。書き込めるようになった pnpm 本体を書き換えられる余地が生まれる | +| `Dockerfile` に pnpm の版を直接書く | `package.json` と 2 か所で管理することになり、Renovate の更新でまたずれる | +| `pmOnFail: ignore` にしてダウンロードさせない | `packageManager` による版の固定が効かなくなる | + +### 確認 + +- Rebuild Container のあと、`pnpm --version` が `packageManager` と一致すること。 +- Claude CLI から `pnpm exec prettier --version` と `git commit` が通ること。 +- 今すぐの回避(リビルド前): ご自身のターミナルで `npm install -g pnpm@12.4.2`。 + +## Phase 4: リビルドと検証(高リスク・開発環境全体に影響) + +- devcontainer を Rebuild Container する。 +- Claude / Codex の両方で次を確認する。 + - `git commit` まで通ること + - `.env`、`~/.codex/auth.json`、`~/.claude/.credentials.json` が読めないこと +- `pnpm format`、`pnpm lint`、`git diff --check` を実行する。 + +## レビュー + +- 秘密情報の扱いの変更(deny 設定)を含むため、AGENTS.md に従いクロスレビューの対象とする。 +- Claude 主導の変更なので Codex でレビューする。Codex が使えない場合は `coderabbit review --plain` を使う。 + +## 出典 + +1. bubblewrap releases(0.11.2 で setuid を非推奨化、0.12.0 で削除): https://github.com/containers/bubblewrap/releases +2. bubblewrap source(`acquire_privs()` の `die ("setuid use of bubblewrap is not supported")`): https://github.com/containers/bubblewrap/blob/main/bubblewrap.c +3. Debian security tracker(DSA-6472-1 / CVE-2026-87766、trixie で 0.12.0-1~deb13u1): https://security-tracker.debian.org/tracker/DSA-6472-1 / https://security-tracker.debian.org/tracker/CVE-2026-87766 +4. Claude Code sandboxing(Troubleshooting「Bubblewrap fails to start inside a container」、Security limitations): https://code.claude.com/docs/en/sandboxing +5. openai/codex#43929(deny ルールに一致するファイルが 2 つ以上あると起動に失敗): https://github.com/openai/codex/issues/43929 +6. Claude Code settings reference(`sandbox.enableWeakerNestedSandbox`、Scope: Any file): https://code.claude.com/docs/en/settings-reference +7. Codex Permissions(deny の完全パスとワイルドカード、`:workspace_roots`、`glob_scan_max_depth`): https://learn.chatgpt.com/docs/permissions +8. openai/codex#44304(bubblewrap 0.12.0 で `/proc` のエラー文言が変わり、代替手段が働かない): https://github.com/openai/codex/issues/44304 +9. openai/codex PR #17547(secure devcontainer で bubblewrap を setuid で入れる。`/proc` のマウントが拒否されたら `--proc` なしで再試行): https://github.com/openai/codex/pull/17547 +10. openai/codex#44329(CLI と VS Code 拡張が `/proc` のマウント失敗を扱えない): https://github.com/openai/codex/issues/44329 +11. Docker `docker container run`(`--security-opt systempaths=unconfined`): https://docs.docker.com/reference/cli/docker/container/run/ +12. pnpm 11.0 release notes(`pmOnFail`、既定は `download`): https://pnpm.io/blog/releases/11.0 diff --git a/docs/dev-notes/2026-09-18/fix-bwrap-setuid/survey.md b/docs/dev-notes/2026-09-18/fix-bwrap-setuid/survey.md new file mode 100644 index 000000000..a007ed991 --- /dev/null +++ b/docs/dev-notes/2026-09-18/fix-bwrap-setuid/survey.md @@ -0,0 +1,141 @@ +# Survey: #4033 以降に Claude / Codex から commit できない問題 + +Issue: https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/4059 + +## 症状 + +- Claude Code / Codex 経由の Bash コマンドがすべて `bwrap: setuid use of bubblewrap is not supported` で失敗する。 +- `git commit` だけでなく `gh`・`ls` なども含め、Bash が一切動かない。 + +## 結論 + +- Debian stable のセキュリティ更新で bubblewrap 0.12.0 が入り、setuid で bwrap を動かす方式が上流で廃止されていた。 +- `Dockerfile` の `chmod u+s /usr/bin/bwrap` がこの廃止済みの使い方に該当し、コンテナの再ビルド後に bwrap が起動直後に終了するようになった。 +- 設定ミスというより、上流の方針変更とバージョン未固定の `apt-get install` が重なったことが原因。 + +```dockerfile +RUN apt-get update \ + && apt-get -y install --no-install-recommends bubblewrap fish \ + && chmod u+s /usr/bin/bwrap \ + && rm -rf /var/lib/apt/lists/* +``` + +## 根本原因 + +### 1. bwrap の 2 つの動作方式 + +| 方式 | 仕組み | 現状 | +| ------------------- | ---------------------------------------------------------------------------- | ---------- | +| user namespace 方式 | 一般ユーザーのままカーネルの unprivileged user namespace で namespace を作る | 現在の標準 | +| setuid 方式 | `chmod u+s` で root 権限付きで起動し、その権限で namespace を作る | 廃止 | + +- Claude Code 公式ドキュメントも bubblewrap を "the unprivileged sandboxing tool" と説明している。 +- setuid 方式は user namespace が使えない古いカーネル向けの後方互換手段だった。 + +### 2. 上流 bubblewrap での廃止(公式リリースノート) + +- 0.11.2(2026-04): CVE-2026-41163(setuid で入れた bwrap に ptrace で割り込み、サンドボックス準備処理を乗っ取れる)を受けて setuid を非推奨化した。 + - 新しいビルドオプション `-Dsupport_setuid` の既定値は false で、"Binaries built with this will refuse to run if made setuid." +- 0.12.0(2026-08-26): setuid 対応を完全に削除した。 + - "This version removes the support for building a setuid bubblewrap. ... basically all modern linux distributions now support unprivileged user namespaces" + +今回のエラーは `bubblewrap.c` の `acquire_privs()` から出ている。 + +```c +/* Are we setuid ? */ +if (real_uid != euid) + { + /* Historically we supported this, but now we only do user namespaces */ + die ("setuid use of bubblewrap is not supported"); + } +``` + +- setuid ビットが付いていると実 UID(node)と実効 UID(root)が食い違い、bwrap はそれを検知して即終了する。 + +### 3. 私たちの環境に入った経路(Debian security tracker) + +- 2026-08-27: DSA-6472-1 で trixie-security に `bubblewrap 0.12.0-1~deb13u1` が配信された。 + - 修正対象は CVE-2026-87766(サンドボックス準備中に親ディレクトリのシンボリックリンクをたどり、ホスト側へ書き込めてしまう)。 + - Debian は stable にもかかわらずメジャー更新の 0.12.0 をそのまま入れた(bookworm には修正が大がかりすぎるとして backport されていない)。 +- `Dockerfile` は `apt-get install bubblewrap` でバージョンを固定していない。 + - そのため、ビルドした日によって 0.11.x(setuid で動く)と 0.12.0(setuid だと即終了)のどちらが入るかが変わる。 + +### 4. Bash が全滅する理由(Claude Code 公式ドキュメント) + +- Linux では Bash コマンドを 1 つずつ bwrap で包んで実行する。 +- `.claude/settings.json` は `allowUnsandboxedCommands: false`(公式ドキュメント上は Strict sandbox mode)。 + - この設定では "every command Claude runs must run sandboxed" となり、サンドボックスの外で再実行する逃げ道がない。 +- よって bwrap の起動自体が失敗すると、個別コマンドの許可設定とは無関係にすべての Bash が失敗する。 +- Codex も同じ `/usr/bin/bwrap` を使うため、両方同時に影響を受ける。 + +### 5. setuid が実際に回避していたもの(当初の「setuid は不要」は誤り) + +- setuid を外すと、Claude の Bash は `bwrap: Can't mount proc on /proc: Operation not permitted` で失敗するようになった。 +- user namespace 方式では、カーネルは既存の `/proc` がすべて見えている場合にしか新しい procfs のマウントを許さない。 +- Docker は `/proc/kcore` などをマスク(上書きマウント)しているため、この条件を満たせず EPERM になる。 +- setuid 方式では bwrap がコンテナの root 権限(`SYS_ADMIN`)で動くため、この制約を受けずに `/proc` をマウントできていた。 +- Claude Code 公式ドキュメントの Troubleshooting にも同じ症状が載っている: "in an unprivileged container, bubblewrap can't mount a fresh `/proc` filesystem ... Set `enableWeakerNestedSandbox` to `true` so the inner sandbox bind-mounts the container's existing `/proc` instead." +- つまり setuid は「コンテナ内で `/proc` をマウントする」ための回避策として機能していた。 + +## 現環境での確認結果(2026-09-19) + +```bash +$ cat /etc/debian_version; bwrap --version +13.6 +bubblewrap 0.12.0 +``` + +- Debian 13(trixie)上で bubblewrap 0.12.0 が入っていることを確認した。上記の根本原因と一致する。 + +## #4034 時点で動いていた理由(推測) + +- #4034 のマージは 2026-09-13 で、DSA の配信(2026-08-27)より後。 +- PR の作業中(8/27 より前)にビルドしたイメージ、またはビルドキャッシュを使っていたため、setuid 対応の 0.11.x が残っていたと推測している。 + +## setuid を外した後に出た 2 つのエラー + +setuid を外すと、Claude と Codex でそれぞれ別の問題が表に出た。 + +### Claude: `Can't mount proc on /proc: Operation not permitted` + +- 原因は上記 5。コンテナ内で新しい `/proc` をマウントできない。 +- 案 A(公式ドキュメントの対処): `.claude/settings.json` に `sandbox.enableWeakerNestedSandbox: true` を追加する。 + - bwrap は新しい `/proc` をマウントせず、コンテナの `/proc` を bind mount する。 + - 公式ドキュメントの注意書き: "considerably weakens security and should only be used when additional isolation is otherwise enforced." この環境ではコンテナ自体が外側の隔離境界になる。 +- 案 B: `compose.yaml` の `security_opt` に `systempaths=unconfined` を追加し、Docker による `/proc` のマスクをやめる。 + - Claude のサンドボックスの強度は保てるが、コンテナ内の全プロセスからマスク対象だった `/proc` のパスが見えるようになる。 + +### Codex: `Can't write data to file /usr/src/app/.env.example: Bad file descriptor` + +- Codex 側の既知の未修正バグ openai/codex#43929: ワークスペース直下で deny ルールにマッチするファイルが 2 つ以上あると、Codex が起動時に失敗する(0.152.1 / 0.153.4 で再現が報告されている)。 +- `.codex/config.toml` の `.env` / `.env.*` に、`.env` と `.env.example` の 2 ファイルがマッチしている。 +- bwrap の setuid 問題とは別件で、setuid の問題で起動前に止まっていたため今まで見えていなかったと推測している(#4034 の時点で動いていた理由は未確認)。 +- 対処候補: 上流の修正を待つか、deny にマッチする実ファイルを 1 つ以下に絞る(例: 秘密情報を含まないテンプレートの `.env.example` を deny 対象から外す)。 + +## 修正方針 + +- `Dockerfile` から `&& chmod u+s /usr/bin/bwrap \` の 1 行を削除する(bubblewrap 0.12.0 では setuid 方式自体が使えないため必須)。 +- Claude: 案 A または案 B で `/proc` の問題に対処する。 +- Codex: openai/codex#43929 への対処を決める。 + +## 却下した代替案 + +- `allowUnsandboxedCommands: true` にする: サンドボックスの外での実行を許すことになり、`denyRead` による認証情報の保護が弱まる。 +- `sandbox.enabled: false` にする: 同上。原因を直さずに防御を外すだけになる。 +- bubblewrap を 0.11.x に固定する: 廃止済みの setuid 方式を延命するだけで、CVE-2026-87766 の修正も受けられなくなる。 + +## 未確認事項 + +- 修正後に Claude / Codex の両方で `git commit` が通ることを確認する必要がある。 + +## 参考 + +- bubblewrap releases: https://github.com/containers/bubblewrap/releases +- bubblewrap source (`acquire_privs`): https://github.com/containers/bubblewrap/blob/main/bubblewrap.c +- Debian tracker: https://tracker.debian.org/pkg/bubblewrap +- DSA-6472-1: https://security-tracker.debian.org/tracker/DSA-6472-1 +- CVE-2026-41163: https://security-tracker.debian.org/tracker/CVE-2026-41163 +- CVE-2026-87766: https://security-tracker.debian.org/tracker/CVE-2026-87766 +- Claude Code sandboxing: https://code.claude.com/docs/en/sandboxing +- Claude Code settings reference(`sandbox.enableWeakerNestedSandbox`): https://code.claude.com/docs/en/settings-reference +- openai/codex#43929: https://github.com/openai/codex/issues/43929 diff --git a/docs/dev-notes/2026-09-19/fix-bwrap-setuid/learning.md b/docs/dev-notes/2026-09-19/fix-bwrap-setuid/learning.md new file mode 100644 index 000000000..9860d0706 --- /dev/null +++ b/docs/dev-notes/2026-09-19/fix-bwrap-setuid/learning.md @@ -0,0 +1,29 @@ +# Learning: devcontainer 内で Claude / Codex のサンドボックスが全滅した件 + +## 問題 + +- 症状: devcontainer 内で Claude / Codex の Bash が全滅し、`git commit` もできない(`bwrap: setuid use of bubblewrap is not supported` → `/proc` マウント失敗 → Codex の `Bad file descriptor`)。 +- 根本原因: Debian のセキュリティ更新で入った bubblewrap 0.12.0 が setuid 方式を廃止し、setuid が暗黙に担っていた `/proc` マウントの回避策が消えた。その結果、Codex 側の未修正バグ 2 件(ファイル単位の deny が 2 つ以上で失敗、`/proc` 失敗時の代替手段が新しいエラー文言を検知できない)が順に表に出た。 + +## 有効だったアプローチ + +- エラー文言で上流のソース(`bubblewrap.c` の `acquire_privs()`)とリリースノート、Debian security tracker を引き、「いつ・なぜ挙動が変わったか」を一次情報で確定させた。 +- エラーの対象パスが変わるたびに「どの設定行が効いているか」を実ファイル(`ls`、`find`)と突き合わせ、仮説を 1 つずつ潰した。 +- 設定が反映されないときは、常駐プロセスの起動時刻と設定ファイルの更新時刻を比べた(VS Code 拡張が起動した Codex の app server が、古い設定のまま CLI の接続先になっていた)。 +- Claude は公式ドキュメントの `enableWeakerNestedSandbox`、Codex は `systempaths=unconfined` で `/proc` のマウントを通し、実害の範囲(OrbStack の VM 内に閉じる)を明記して許容した。 + +## ハマった点 + +- 「setuid は不要」と判断した: setuid が `/proc` マウントの回避策を兼ねていたことを見落としていた。 +- 「ワークスペース内の実在ファイルが 2 つ以上で失敗」と狭く理解した: 実際は、実在しない完全パスも含めて、Codex の設定全体で数えられていた。そのため `.env.example` → `.env.local` → `~/.claude/.credentials.json` と、3 回に分けて潰すことになった。 +- 「CLI を再起動すれば反映される」と説明した: CLI は常駐している app server に接続するだけで、設定を読み直していなかった。 +- `.env` を deny から外す案を推した: 「動かすこと」を優先して目的(秘密情報の保護)を損ねる本末転倒だった。 +- 「すでに緩めてあるから」を理由に `systempaths=unconfined` のリスクを小さく見積もった: 緩和を重ねる理由にはならない。 + +## 教訓 + +- 権限まわりの設定(setuid、capability、security_opt)を外すときは、それが暗黙に回避していた制約を先に洗い出す。例: setuid が `/proc` マウントの制約を回避していた。 +- バージョンを固定しないパッケージは、再ビルドした日によって挙動が変わる。「以前は動いた」ときは、まず実環境のバージョンを確認する。例: `bwrap --version` で 0.12.0 を確認。 +- 設定の変更が効かないときは、その設定を読むのが常駐プロセスかどうかを確かめ、プロセスの起動時刻を設定の更新時刻と比べる。 +- 回避策を選ぶときは、守りたいもの(今回は `.env` と認証情報)を先に固定し、それを削る案は候補から外す。 +- セキュリティの緩和は、既存の緩和を根拠にせず、単体で実害(誰が・何を・どこまで)を書き出してから判断する。 From a3a1aff731c3fafc2f887bf7449e8ba51c882193 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 19 Sep 2026 05:43:06 +0000 Subject: [PATCH 02/12] docs: consolidate bwrap setuid dev-notes into a single summary Merge plan.md, survey.md and learning.md into summary.md, keeping the decisions, trade-offs, root causes of the detours and primary sources. Co-Authored-By: Claude Opus 5 --- .../2026-09-18/fix-bwrap-setuid/plan.md | 322 ------------------ .../2026-09-18/fix-bwrap-setuid/summary.md | 141 ++++++++ .../2026-09-18/fix-bwrap-setuid/survey.md | 141 -------- .../2026-09-19/fix-bwrap-setuid/learning.md | 29 -- 4 files changed, 141 insertions(+), 492 deletions(-) delete mode 100644 docs/dev-notes/2026-09-18/fix-bwrap-setuid/plan.md create mode 100644 docs/dev-notes/2026-09-18/fix-bwrap-setuid/summary.md delete mode 100644 docs/dev-notes/2026-09-18/fix-bwrap-setuid/survey.md delete mode 100644 docs/dev-notes/2026-09-19/fix-bwrap-setuid/learning.md diff --git a/docs/dev-notes/2026-09-18/fix-bwrap-setuid/plan.md b/docs/dev-notes/2026-09-18/fix-bwrap-setuid/plan.md deleted file mode 100644 index 77cebd708..000000000 --- a/docs/dev-notes/2026-09-18/fix-bwrap-setuid/plan.md +++ /dev/null @@ -1,322 +0,0 @@ -# Plan: devcontainer 内で Claude / Codex のサンドボックスを復旧する - -Issue: https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/4059 -調査結果: [survey.md](./survey.md) - -## 概要 - -- bubblewrap 0.12.0(Debian trixie の DSA-6472-1 で配信)は setuid での起動を拒否するため、`Dockerfile` の `chmod u+s /usr/bin/bwrap` で Claude / Codex の Bash がすべて失敗している。[1][2][3] -- setuid を外すと、隠れていた問題が 2 つ表に出る。 - - Claude: コンテナ内で新しい `/proc` をマウントできない(`Can't mount proc on /proc: Operation not permitted`)。[4] - - Codex: deny ルールに一致するファイルが 2 つ以上あると起動時に失敗する既知バグ(`Can't write data to file /usr/src/app/.env.example: Bad file descriptor`)。[5] -- 3 つを設定ファイルの変更だけで解消する。 - -## 設計判断 - -### setuid を外す - -- 上流の bubblewrap 0.12.0 は setuid 対応を削除しており、setuid 付きの bwrap は `acquire_privs()` で即終了する。[1][2] -- 残す選択肢はない。 - -### Claude: `sandbox.enableWeakerNestedSandbox: true` - -- Claude Code 公式ドキュメントの Troubleshooting に載っている、この症状への対処法そのもの。[4] - - "Set `enableWeakerNestedSandbox` to `true` so the inner sandbox bind-mounts the container's existing `/proc` instead." -- 設定リファレンス上の Scope は "Any file" で、プロジェクトの `.claude/settings.json` に書ける。[6] -- 公式ドキュメントの注意書きは "considerably weakens security and should only be used when additional isolation is otherwise enforced."。[4] - - この環境ではコンテナ自体が外側の隔離境界になっているため許容する。 - - ファイルシステムの deny とネットワーク制限は引き続き有効で、弱まるのはサンドボックス内のコマンドから `/proc` のプロセス情報が見えるようになる点。 -- Linux 専用の設定のため、macOS のホストで clone した場合は影響しない。Linux ホストで直接使う場合は同じく弱まる。 - -### Codex: `.env.example` を deny 対象から外す - -- openai/codex#43929 は未修正で、回避策も示されていない。[5] -- `.env.example` は `.gitignore` の `!.env.example` で commit 済みのテンプレートで、秘密情報を含まない。`git show` でも読めるため、deny しても守れるものがない。 -- `.env.*` を `.gitignore` と同じ意図の具体的なパターン(`.env.local`, `.env.*.local`)に置き換え、`.env.example` が一致しないようにする。 -- `.codex/config.toml` のコメントに従い、`.claude/settings.json` の deny も同じ集合に揃える。 - -## 却下した代替案 - -| 案 | 却下理由 | -| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `allowUnsandboxedCommands: true` / `sandbox.enabled: false` | 原因を直さずに防御を外すだけ。`denyRead` による認証情報の保護も効かなくなる | -| bubblewrap を 0.11.x に固定 | 廃止済みの setuid 方式を延命するだけで、CVE-2026-87766 の修正も受けられない [3] | -| `compose.yaml` に `security_opt: systempaths=unconfined` | Claude のサンドボックスの強度は保てるが、コンテナ全体で `/proc` のマスクと読み取り専用の保護が外れる(Phase 3d のトレードオフを参照)。Claude の公式ドキュメントの対処法でもない。※ Codex 用には Phase 3d で採用 | -| Codex の `.env.*` deny を残して上流の修正を待つ | 修正の目処が立っておらず、その間 Codex が起動しない | - -## Phase 1: `Dockerfile` から setuid を外す(低リスク) - -- レイヤー: 開発環境の設定(コンテナイメージ) -- 変更: `&& chmod u+s /usr/bin/bwrap \` の 1 行を削除する。 - -```dockerfile -RUN apt-get update \ - && apt-get -y install --no-install-recommends bubblewrap fish \ - && rm -rf /var/lib/apt/lists/* -``` - -- 確認: リビルド後に `ls -l /usr/bin/bwrap` の結果に `s` ビットが付いていないこと。 -- テスト: 分岐のある振る舞いを持たない設定変更のため、テストファーストは省略する。 - -## Phase 2: Claude の入れ子サンドボックスを有効にする(中リスク) - -- レイヤー: Claude Code 設定 -- 変更: `.claude/settings.json` の `sandbox` に `enableWeakerNestedSandbox: true` を追加する。 - -```json -{ - "sandbox": { - "enabled": true, - "allowUnsandboxedCommands": false, - "enableWeakerNestedSandbox": true, - "filesystem": { "...": "..." } - } -} -``` - -- 確認: Claude から `true`、`git status`、`git commit --dry-run` が成功すること。 - -## Phase 3: `.env.example` を deny 対象から外す(中リスク・秘密情報の扱いの変更) - -- レイヤー: Codex / Claude Code 設定 -- `.codex/config.toml` の `":workspace_roots"` テーブル: - -```toml -".env" = "deny" -".env.local" = "deny" -".env.*.local" = "deny" -"**/.env" = "deny" -"**/.env.local" = "deny" -"**/.env.*.local" = "deny" -``` - -- `.claude/settings.json`: `sandbox.filesystem.denyRead` と `permissions.deny` の `**/.env.*` / `Read(**/.env.*)` を同じ集合(`**/.env.local`, `**/.env.*.local`)に置き換える。 -- 確認: `codex sandbox -- /bin/true` が成功し、Claude / Codex から `.env` が読めず、`.env.example` が読めること。 -- 既知の制約: ワークスペースに `.env` と `.env.local` が同時に存在すると、再び openai/codex#43929 に当たる。現状は `.env` のみの想定。 - -## Phase 3b: deny を `.env` のみに絞る(Phase 3 の見直し) - -### 経緯 - -- Phase 3 の適用とリビルドのあと、Codex が `bwrap: Can't write data to file /usr/src/app/.env.local: Bad file descriptor` で失敗した。 -- `.env.local` は実在しない。リポジトリ直下の env ファイルは `.env` と `.env.example` のみ。 - -### 見直した原因の理解 - -- Phase 3 では「実在するファイルが 2 つ以上一致すると失敗する」と考えていたが、不十分だった。 -- `".env.local"` のようにワイルドカードを含まない完全パスは、ファイルが実在しなくても Codex がマスク対象として扱い、1 つに数えると考えられる。 -- 今回は `.env`(実在)と `.env.local`(実在しない完全パス)で 2 つになり、2 つ目で失敗した。 -- `".env.*.local"` のようなワイルドカードのパターンは、実在するファイルにしか一致しないと考えられる(未検証)。 - -| 時点 | 失敗したファイル | 一致したルール | -| -------------- | ---------------- | ------------------------------------ | -| Phase 3 適用前 | `.env.example` | `.env.*`(ワイルドカード、実在) | -| Phase 3 適用後 | `.env.local` | `.env.local`(完全パス、実在しない) | - -### 変更 - -- `.env.local` は運用上置いていないため、`.local` の系統を Codex / Claude の両方から外し、`.env` のみを deny する。 -- `.codex/config.toml` の `":workspace_roots"` テーブル: - -```toml -".env" = "deny" -"**/.env" = "deny" -``` - -- `.claude/settings.json`: `denyRead` と `permissions.deny` から `.env.local` / `.env.*.local` の行を外し、`**/.env` / `Read(**/.env)` のみ残す。 - -### 未確認の点とフォールバック - -- `".env"` と `"**/.env"` は同じ `.env` を指すが、Codex がこれを 1 つと数えるか 2 つと数えるかは未確認。 -- 2 つと数えられて失敗した場合は、`"**/.env"` を外して `".env"` のみにする。 - -### トレードオフ - -- 将来 `.env.local` などを置くと、Codex / Claude の deny から外れて読めるようになる。置く際に deny を足し直す必要がある(その時点で openai/codex#43929 が未修正なら Codex が起動しなくなる点にも注意)。 - -### 確認 - -- リビルド不要(設定ファイルのみ)。Codex CLI を再起動し、セッションが開始できること。 -- Codex / Claude から `.env` が読めず、`.env.example` が読めること。 - -## Phase 3c: Codex のファイル単位の deny を `.env` の 1 つに絞る(最優先) - -### 経緯 - -- Phase 3b のあと、Codex CLI が `bwrap: Can't write data to file /home/node/.claude/.credentials.json: Bad file descriptor` で起動しなくなった。 -- ファイル単位でマスクされる上限(1 つ)は、ワークスペース内だけでなく Codex の設定全体で数えられていた。 -- 現状でファイル単位にマスクされるのは `.env`、`~/.codex/auth.json`、`~/.claude/.credentials.json` の 3 つ(`~/.ssh` は存在せず、`**/*.pem` などに一致するファイルもない)。 - -### 判定: Codex CLI の不具合(設定ミスではない) - -- 公式ドキュメントは、完全パス(例: `~/.ssh`)とワイルドカード(例: `"**/*.env" = "deny"`)による deny を推奨している。現在の設定はこの使い方どおり。[7] -- openai/codex#43929 では、完全パスかワイルドカードかなどの条件によらず「deny に一致するファイルが 2 つ以上で必ず失敗、ディレクトリなら動く」と報告されている。0.155.1 でも未修正。[5] - -### 方針 - -- `.env` を守ることを最優先し、ファイル単位の 1 枠を `.env` に使う。 -- 認証ファイルはディレクトリ単位の deny に置き換える(#43929 によると、ディレクトリは上限に数えられない)。 - -| 対象 | 変更前 | 変更後 | -| ----------------------------- | ---------------------------------------- | ---------------------------------------------------------- | -| `.env` | `".env"` と `"**/.env"`(ファイル) | `".env"` のみ(同じファイルが 2 つと数えられるのを避ける) | -| `~/.claude/.credentials.json` | ファイル | `"~/.claude"`(ディレクトリ) | -| `~/.codex/auth.json` | ファイル | `"~/.codex"`(ディレクトリ) | -| `~/.ssh/**` | ワイルドカード(現在は何にも一致しない) | 変更なし | - -### リスクとフォールバック - -- `~/.codex` を deny すると、AGENTS.md を読み込むサンドボックスが `~/.codex` 配下(グローバルの AGENTS.md や skills)を読めず、失敗する可能性がある。 - - 失敗した場合は `"~/.codex"` の deny だけを外す。そうすると `~/.codex/auth.json` は Codex のコマンドから読めるようになるが、`.env` と `~/.claude` は守られたまま。 -- `"**/.env"` を外すため、サブディレクトリの `.env` は Codex の deny から外れる(現在は存在しない)。 -- `**/*.pem`、`**/*.key`、`**/secrets/**`、`**/config/credentials.json` に一致するファイルが今後 1 つでも置かれると、再び #43929 に当たる。 -- 次に #44304(bubblewrap 0.12.0 で `/proc` をマウントできないときの代替手段が働かない)に当たる可能性がある。[8] -- Claude 側の deny(`.claude/settings.json`)は変更しない。Claude にはこの制約がないため。 - -### 確認 - -- Codex CLI を再起動し、セッションが開始できること。 -- Codex から `.env`、`~/.claude/.credentials.json`、`~/.codex/auth.json` が読めず、`.env.example` が読めること。 - -## Phase 3d: `compose.yaml` に `systempaths=unconfined` を追加する(Codex の `/proc` 対策) - -### 経緯 - -- Phase 3c のあと、古い deny 設定を読み込んだまま動いていた VS Code 拡張の app server(PID 804、03:04 起動)を再起動した。すると Codex のエラーが `bwrap: Can't mount proc on /proc: Operation not permitted` に変わった。 -- deny の変更は反映された。次の問題として、[8] の `/proc` の問題が表に出た。 - -### 原因(根拠) - -- Codex 公式の secure devcontainer は「bubblewrap を setuid で入れる」方式で、Docker 内で `bwrap --proc /proc` が拒否されたときは、Codex が `--proc` なしで再試行する設計になっている。[9] -- bubblewrap 0.12.0 では setuid 方式が使えず、エラー文言も `/newroot/proc` から `/proc` に変わった。Codex は `/newroot/proc` という文字列で失敗を判定しているため、再試行されない。[8][10] -- #44304 と #44329 はどちらも Open で、PR もない。 -- Codex 公式ドキュメント(Permissions)には、コンテナ内での `/proc` の扱いについての記述がない。[7] - -### 変更 - -- `compose.yaml` の `web.security_opt` に `systempaths=unconfined` を追加する。 -- Docker が `/proc` の一部をマスクするのをやめるので、user namespace の中でも bwrap が新しい `/proc` をマウントできるようにする狙い(未検証)。 -- コンテナの作り直しが必要(Rebuild Container)。 - -### トレードオフ - -- Docker 公式の説明は "Turn off confinement for system paths (masked paths, read-only paths) for the container"。[11] 影響は次の 2 つで、コンテナ内の全プロセスが対象になる。 - - マスクの解除: Docker が隠していた `/proc` のパス(`/proc/kcore` など)が見えるようになる。 - - 読み取り専用の解除: `/proc/sys`、`/proc/sysrq-trigger` などが書き込めるようになる。こちらのほうが影響が大きい。 -- このコンテナは user namespace で root を分離していない(userns-remap なし)。そのため、コンテナ内で root(`sudo`)になると、ホストのカーネル設定を変えたり、`/proc/sysrq-trigger` でホストを再起動したりできる経路が開く。 - -#### 実害の範囲(ローカルの OrbStack で動かす場合) - -- ここでの「ホストのカーネル」は、macOS ではなく OrbStack が動かしている Linux VM のカーネル。影響が及ぶのはこの VM と、同じ VM で動くほかのコンテナまでで、Mac 本体のファイルには届かない。 -- 悪意のあるコードがコンテナ内で root になれた場合に、新たにできるようになること: - -| できるようになること | 実害 | -| -------------------------------- | -------------------------------------------------------------------------------------- | -| `/proc/sysrq-trigger` に書き込む | VM を即座に再起動・停止できる。OrbStack の全コンテナが止まる | -| `/proc/sys` に書き込む | VM のカーネル設定を変えられる。ほかのコンテナの保護を弱めることもできる | -| `/proc/kcore` を読む | VM のカーネルメモリを読める。ほかのコンテナの秘密情報(DB のパスワードなど)が漏れうる | - -- root でなくても、`/proc/timer_list` などから VM 内のプロセスの情報が少し見えるようになる(軽微)。 - -#### 誰がこれをできるか - -- devcontainer の `node` ユーザーは、通常パスワードなしで `sudo` を使える。`pnpm install` で入ったパッケージの postinstall スクリプトなど、サンドボックスの外で動くコードが悪意を持っていれば、root になれる。 -- Claude / Codex がサンドボックスの中で実行するコマンドは、bwrap が `sudo` による昇格を防ぐため、この経路は通らない。 - -#### 判断 - -- このコンテナにはすでに `SYS_ADMIN` と `seccomp` / `apparmor` の unconfined が与えられており、root ならこれらの保護を自分で外せる。`systempaths=unconfined` で新しくできるようになることはほとんどなく、変わるのは攻撃に必要な手間が 1 段減ること。 -- ただし「すでに緩めてあるから、さらに緩めてもよい」という理屈は、緩和を重ねる理由にはならない点に注意する。 -- ローカルの開発環境で、同じ OrbStack の VM に重要なコンテナを同居させない前提で許容する。この前提と実害の範囲は、チームに共有する。 -- Phase 2 の「却下した代替案」では Claude 用として退けたが、Codex には代わりの手段がないため採用する。Claude の `enableWeakerNestedSandbox` は、効果を確認できるまで残す。 - -### フォールバック - -- それでも `/proc` のエラーが出る場合は、この変更を戻し、上流の修正を待つ。 -- 上流の修正を待つ間は、`default_permissions` を外して組み込みの `:workspace` で動かす(`.env` が Codex から読めるようになる)かどうかを、チームで判断する。 - -### 確認 - -- Rebuild Container のあと、Codex CLI と VS Code 拡張の両方で、セッションが開始できること。 -- Codex から `.env`、`~/.claude/.credentials.json`、`~/.codex/auth.json` が読めず、`.env.example` が読めること。 -- Claude の Bash が引き続き動くこと。 - -## Phase 3e: コンテナの pnpm を `packageManager` に揃える - -### 経緯 - -- Codex が動いたあと、Claude CLI から commit できなかった。lefthook の `format` ジョブ(`pnpm exec prettier`)がサンドボックス内で `create the temporary package manager install directory: Read-only file system` で失敗していた。 -- `pnpm test:unit` や `pnpm lint` など、Claude がサンドボックス内で実行する `pnpm` はすべて同じ理由で失敗する。 - -### 原因 - -- pnpm 11 以降は、実行中の pnpm と `package.json` の `packageManager` のバージョンが違うと、指定のバージョンを自動でダウンロードして使う(`pmOnFail: download` が既定)。[12] -- コンテナの pnpm はベースイメージ(`mcr.microsoft.com/devcontainers/javascript-node:24`)に入っている 12.3.4。`packageManager` は Renovate が上げていて 12.4.2。 -- サンドボックスの中ではダウンロード先に書き込めないため失敗する。サンドボックスの外では黙ってダウンロードして動くため、これまで気づかなかった。 -- `Dockerfile` と `setup-devcontainer.sh` は、過去に一度も pnpm のバージョンを固定していない(git 履歴で確認)。 - -| 日付 | `packageManager` | コンテナの 12.3.4 との関係 | -| ----- | ---------------- | -------------------------- | -| 09-05 | pnpm@12.3.4 | 一致 | -| 09-13 | pnpm@12.4.1 | ここからずれた | -| 09-18 | pnpm@12.4.2 | ずれたまま | - -- ずれ始めた 09-13 は、#4033 / #4034 で Claude の Bash が全滅した時期と重なる。そのため、サンドボックスを直すまで表に出なかった。 -- 09-01 以前(pnpm 11 の時期)に問題にならなかった理由は、当時のイメージの pnpm の版が分からず未確認。 - -### 変更(案 A) - -- `.devcontainer/setup-devcontainer.sh` の `pnpm install` の直前で、`package.json` の `packageManager` と同じ版の pnpm をグローバルに入れる。 -- バージョンを 2 か所に書かないので、Renovate が `packageManager` を上げても、次のリビルドで自動的に追従する。 - -```bash -# Match the global pnpm to `packageManager`; a mismatch makes pnpm download the pinned version, -# which the agent sandboxes cannot write, so every sandboxed `pnpm` command fails. -pnpm_version="$(node -p "require('./package.json').packageManager.split('@')[1].split('+')[0]")" -npm install -g "pnpm@${pnpm_version}" -``` - -- `+sha512...` のハッシュが `packageManager` に付いた場合に備え、`+` 以降は取り除く。 -- 失敗したときは、後続の `pnpm install` と同じくセットアップ全体を失敗させる(黙ってずれたままにしない)。 - -### 却下した代替案 - -| 案 | 却下理由 | -| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| サンドボックスで pnpm のキャッシュへの書き込みを許可(`allowWrite`) | サンドボックスを緩める。書き込めるようになった pnpm 本体を書き換えられる余地が生まれる | -| `Dockerfile` に pnpm の版を直接書く | `package.json` と 2 か所で管理することになり、Renovate の更新でまたずれる | -| `pmOnFail: ignore` にしてダウンロードさせない | `packageManager` による版の固定が効かなくなる | - -### 確認 - -- Rebuild Container のあと、`pnpm --version` が `packageManager` と一致すること。 -- Claude CLI から `pnpm exec prettier --version` と `git commit` が通ること。 -- 今すぐの回避(リビルド前): ご自身のターミナルで `npm install -g pnpm@12.4.2`。 - -## Phase 4: リビルドと検証(高リスク・開発環境全体に影響) - -- devcontainer を Rebuild Container する。 -- Claude / Codex の両方で次を確認する。 - - `git commit` まで通ること - - `.env`、`~/.codex/auth.json`、`~/.claude/.credentials.json` が読めないこと -- `pnpm format`、`pnpm lint`、`git diff --check` を実行する。 - -## レビュー - -- 秘密情報の扱いの変更(deny 設定)を含むため、AGENTS.md に従いクロスレビューの対象とする。 -- Claude 主導の変更なので Codex でレビューする。Codex が使えない場合は `coderabbit review --plain` を使う。 - -## 出典 - -1. bubblewrap releases(0.11.2 で setuid を非推奨化、0.12.0 で削除): https://github.com/containers/bubblewrap/releases -2. bubblewrap source(`acquire_privs()` の `die ("setuid use of bubblewrap is not supported")`): https://github.com/containers/bubblewrap/blob/main/bubblewrap.c -3. Debian security tracker(DSA-6472-1 / CVE-2026-87766、trixie で 0.12.0-1~deb13u1): https://security-tracker.debian.org/tracker/DSA-6472-1 / https://security-tracker.debian.org/tracker/CVE-2026-87766 -4. Claude Code sandboxing(Troubleshooting「Bubblewrap fails to start inside a container」、Security limitations): https://code.claude.com/docs/en/sandboxing -5. openai/codex#43929(deny ルールに一致するファイルが 2 つ以上あると起動に失敗): https://github.com/openai/codex/issues/43929 -6. Claude Code settings reference(`sandbox.enableWeakerNestedSandbox`、Scope: Any file): https://code.claude.com/docs/en/settings-reference -7. Codex Permissions(deny の完全パスとワイルドカード、`:workspace_roots`、`glob_scan_max_depth`): https://learn.chatgpt.com/docs/permissions -8. openai/codex#44304(bubblewrap 0.12.0 で `/proc` のエラー文言が変わり、代替手段が働かない): https://github.com/openai/codex/issues/44304 -9. openai/codex PR #17547(secure devcontainer で bubblewrap を setuid で入れる。`/proc` のマウントが拒否されたら `--proc` なしで再試行): https://github.com/openai/codex/pull/17547 -10. openai/codex#44329(CLI と VS Code 拡張が `/proc` のマウント失敗を扱えない): https://github.com/openai/codex/issues/44329 -11. Docker `docker container run`(`--security-opt systempaths=unconfined`): https://docs.docker.com/reference/cli/docker/container/run/ -12. pnpm 11.0 release notes(`pmOnFail`、既定は `download`): https://pnpm.io/blog/releases/11.0 diff --git a/docs/dev-notes/2026-09-18/fix-bwrap-setuid/summary.md b/docs/dev-notes/2026-09-18/fix-bwrap-setuid/summary.md new file mode 100644 index 000000000..8b479e22f --- /dev/null +++ b/docs/dev-notes/2026-09-18/fix-bwrap-setuid/summary.md @@ -0,0 +1,141 @@ +# devcontainer 内で Claude / Codex のサンドボックスが全滅した件 + +Issue: https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/4059 +Commit: 40450ec7(旧 `plan.md` / `survey.md` / `learning.md` を本ファイルに統合。原文は同コミットで参照可) + +## 症状と発端 + +- Claude / Codex の Bash がすべて `bwrap: setuid use of bubblewrap is not supported` で失敗し、`git commit` もできない。 +- 発端: DSA-6472-1(2026-08-27、CVE-2026-87766 修正)で Debian trixie に bubblewrap 0.12.0 が入った。0.12.0 は setuid 対応を削除しており、`acquire_privs()` が実 UID ≠ 実効 UID を検知して即終了する。[1][2][3] +- `Dockerfile` は `apt-get install bubblewrap` で版を固定していないため、再ビルドした日によって 0.11.x(setuid で動く)か 0.12.0 かが変わる。実環境で `bwrap --version` = 0.12.0、Debian 13.6 を確認(2026-09-19)。 +- `allowUnsandboxedCommands: false`(Strict sandbox mode)なので、bwrap が起動できないとサンドボックス外で再実行する逃げ道がなく、Bash が全滅する。[4] +- #4034(2026-09-13 マージ)の時点で動いていたのは、DSA 配信前にビルドしたイメージ/キャッシュが残っていたためと推測(未確認)。 + +## 障害の連鎖と対応する修正 + +setuid の削除で、それまで先頭の失敗に隠れていた問題が 1 つずつ表に出た。 + +| # | 表に出たエラー | 原因 | 修正(40450ec7) | +| --- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| 1 | `setuid use of bubblewrap is not supported` | 0.12.0 が setuid を拒否 | `Dockerfile` の `chmod u+s /usr/bin/bwrap` を削除 | +| 2 | Claude: `Can't mount proc on /proc: Operation not permitted` | setuid が暗黙に回避していた `/proc` マウント制約(下記) | `.claude/settings.json` に `enableWeakerNestedSandbox: true` | +| 3 | Codex: `Can't write data to file : Bad file descriptor` | openai/codex#43929(ファイル単位の deny が 2 つ以上で起動失敗) | 認証情報の deny をディレクトリ単位へ。ファイル単位は `.env` の 1 つだけ | +| 4 | Codex: `Can't mount proc on /proc` | #2 と同じ制約 + Codex の代替手段が新しいエラー文言を検知しない(#44304 / #44329) | `compose.yaml` に `security_opt: systempaths=unconfined` | +| 5 | サンドボックス内の `pnpm` が `Read-only file system` | コンテナの pnpm と `packageManager` の版ずれ → pnpm が指定版をダウンロードしようとする | `setup-devcontainer.sh` で `packageManager` と同じ版を入れる | + +### #2 / #4: setuid が回避していた `/proc` の制約 + +- user namespace 方式では、既存の `/proc` がすべて見えている場合にしかカーネルは新しい procfs のマウントを許さない。Docker は `/proc/kcore` などをマスクしているので EPERM になる。 +- setuid 方式ではコンテナの root(`SYS_ADMIN`)で動くため、この制約を受けなかった。つまり setuid は「コンテナ内で `/proc` をマウントする」回避策を兼ねていた。 +- Claude: 公式 Troubleshooting の対処そのもの("the inner sandbox bind-mounts the container's existing `/proc` instead")。注意書き "considerably weakens security and should only be used when additional isolation is otherwise enforced" は、コンテナが外側の隔離境界になるので許容。[4][6] +- Codex: 公式 secure devcontainer は setuid 前提で、`--proc` が拒否されたら `--proc` なしで再試行する設計。[9] だが失敗判定が旧文言 `/newroot/proc` の文字列一致なので、0.12.0 では再試行されない。[8][10] Codex 側に設定で逃げる手段がないため、Docker のマスク自体を外した。 +- `enableWeakerNestedSandbox` は `systempaths=unconfined` 導入前に入れたもので、「効果を確認できるまで残す」としている。現状では不要かもしれない(未検証)。 + +### #3: openai/codex#43929 の実際の数え方 + +- 「deny に一致するファイルが 2 つ以上で必ず失敗、ディレクトリなら動く」。完全パスかワイルドカードかによらない。0.155.1 でも未修正。[5] +- 実測で分かったこと: + - 数える範囲はワークスペース内ではなく Codex の設定全体(`~/.claude/.credentials.json` で失敗した)。 + - ワイルドカードを含まない完全パス(例 `".env.local"`)は、実在しなくても 1 つに数えられる。 + - ワイルドカードのパターンは実在ファイルにのみ一致すると考えられる(未検証)。 + - `".env"` と `"**/.env"` を同じファイルとして 2 回数えるかは未確認。安全側で `".env"` だけにした。 +- 設定は公式ドキュメント推奨の書き方どおりで、設定ミスではなく Codex のバグ。[7] + +### #5: pnpm の版ずれ + +- pnpm 11 以降は `pmOnFail: download` が既定で、版がずれると指定版を自動ダウンロードする。[12] サンドボックス外では黙って成功するため気づかなかった。 +- ベースイメージの pnpm は 12.3.4、`packageManager` は Renovate により 09-13 に 12.4.1、09-18 に 12.4.2。ずれ始めた 09-13 は Bash 全滅の時期と重なり、サンドボックス復旧まで表に出なかった。 +- 版を `package.json` から読むので、Renovate が上げても次のリビルドで追従する(2 か所管理にしない)。 + +## 意思決定 + +### 守るもの(固定) + +- `.env`、`~/.claude/.credentials.json`、`~/.codex/auth.json` を Claude / Codex の両方から読めないこと。これを削る案(`.env` の deny 解除、`default_permissions` を外す等)は採らない。 + +### deny 設定の最終形とトレードオフ + +- Codex: `"~/.codex"` / `"~/.claude"` をディレクトリで deny、ファイル単位は `".env"` のみ。`**/.env` と `.env.*` 系は外した。 +- Claude: `**/.env.*` 系を外し `**/.env` のみ(Codex と集合を揃えるため)。Claude 自体には #43929 の制約はない。 +- 失うもの: + - サブディレクトリの `.env` と、`.env.local` などは deny から外れる(現状は存在しない)。置くときは deny を足し直す必要があり、#43929 が未修正なら Codex が再び起動しなくなる。 + - `**/*.pem`、`**/*.key`、`**/secrets/**`、`**/config/credentials.json` に一致するファイルが 1 つでも置かれると #43929 に当たる。 + - `~/.codex` の deny で、サンドボックス内から `~/.codex` 配下(グローバル AGENTS.md、skills)が読めなくなる可能性。問題が出たら `"~/.codex"` だけ外す。 + +### `systempaths=unconfined` のリスク評価 + +- Docker の説明は "Turn off confinement for system paths (masked paths, read-only paths)"。[11] コンテナ内の全プロセスで、`/proc/kcore` 等のマスクと `/proc/sys`、`/proc/sysrq-trigger` の読み取り専用が外れる。 +- userns-remap がないため、コンテナ内 root は VM カーネルを直接操作できる。 + +| 新たにできること(root の場合) | 実害 | +| -------------------------------- | ----------------------------------------------------------- | +| `/proc/sysrq-trigger` に書き込む | VM を即座に再起動・停止。OrbStack の全コンテナが止まる | +| `/proc/sys` に書き込む | VM のカーネル設定変更。他コンテナの保護も弱められる | +| `/proc/kcore` を読む | VM のカーネルメモリ読み出し。他コンテナの秘密情報が漏れうる | + +- 範囲: OrbStack の Linux VM とその中のコンテナまで。Mac 本体のファイルには届かない。 +- 誰が: `node` はパスワードなし `sudo` が使えるので、サンドボックス外で動くコード(悪意ある postinstall など)。サンドボックス内のコマンドは bwrap が `sudo` 昇格を防ぐので通らない。 +- 判断: 既存の `SYS_ADMIN` / seccomp・apparmor unconfined で root は元々これらを外せるため、新たな能力はほぼ増えず攻撃の手間が 1 段減るのみ。ただしこれは緩和を重ねる理由ではないので、単体で実害を評価した上で「同じ VM に重要なコンテナを同居させない」前提で許容する。この前提はチームに共有する。 +- フォールバック: それでも `/proc` エラーが出るなら戻して上流の修正を待つ。 + +### 却下した代替案 + +| 案 | 却下理由 | +| ----------------------------------------------------------- | ------------------------------------------------------------------------- | +| `allowUnsandboxedCommands: true` / `sandbox.enabled: false` | 原因を直さず防御を外すだけ。`denyRead` による認証情報の保護も効かなくなる | +| bubblewrap を 0.11.x に固定 | 廃止済みの setuid 方式の延命。CVE-2026-87766 の修正も受けられない [3] | +| `.env` を deny から外して Codex を動かす | 守るものを削る本末転倒 | +| Codex の deny を残して上流の修正を待つ | 修正の目処がなく、その間 Codex が起動しない | +| サンドボックスに pnpm キャッシュへの書き込みを許可 | サンドボックスを緩め、pnpm 本体を書き換えられる余地が生まれる | +| `Dockerfile` に pnpm の版を直書き | `package.json` と 2 か所管理になり、Renovate の更新でまたずれる | +| `pmOnFail: ignore` | `packageManager` による版の固定が効かなくなる | + +## 運用上の注意 + +- Codex の設定を変えたら、VS Code 拡張が起動した app server も再起動する。CLI は常駐 app server に接続するだけで設定を読み直さない(今回、03:04 起動の古い app server が古い deny のまま動いていた)。 +- 上流の修正(#43929、#44304 / #44329)が入ったら、ディレクトリ単位の deny、`systempaths=unconfined`、`enableWeakerNestedSandbox` の要否を見直す。 + +## 振り返り + +### 紆余曲折の根本原因 + +修正は 1 コミットだが、到達までに Phase 3 → 3b → 3c → 3d → 3e と 5 回方針を出し直した。原因は次の 3 つに集約される。 + +1. setuid が暗黙に担っていた役割を洗い出さずに外した。 + - setuid が `/proc` マウントの回避策を兼ねていたことを見落とし、Claude / Codex の `/proc` 問題を別々に扱った。そのため `systempaths=unconfined` を Claude 用に却下 → Codex 用に採用という往復が起きた。#44304 は Phase 3c の時点で「次に当たる可能性」と把握していたのに先送りした。 +2. 一次資料を読み切らず、観測したエラーから仮説を狭く立てた。 + - #43929 の条件(ファイル単位の deny が 2 つ以上で失敗、ディレクトリは可)を「ワークスペース内の実在ファイルが 2 つ以上」と読み替えたため、`.env.example` → `.env.local` → `~/.claude/.credentials.json` と 3 回に分けて潰すことになった。 +3. 検証環境が変更を反映しているか確認しなかった。 + - 「CLI を再起動すれば反映される」と説明し、常駐 app server が古い設定のまま出したエラーで判断した回があった。 + +背景として、先頭の失敗が後ろの問題をすべて隠す構造(Strict sandbox mode で Bash が全滅 → Codex のバグと pnpm の版ずれが見えない)があり、1 つ直すたびに次の層が現れた。これ自体は避けられないが、上の 1〜3 がなければ往復は 1〜2 回で済んだ。 + +### 判断の誤り(結果には残っていないもの) + +- `.env` を deny から外す案を推した: 動かすことを優先して目的(秘密情報の保護)を損ねる本末転倒。 +- 「すでに緩めてあるから」を理由に `systempaths=unconfined` のリスクを小さく見積もった: 既存の緩和は追加の緩和の根拠にならない。 + +### 教訓 + +- 権限まわりの設定(setuid、capability、security_opt)を外すときは、それが暗黙に回避していた制約を先に洗い出す。 +- エラーが連鎖しそうなときは、既知の上流 issue をまとめて確認し、方針を 1 回で決める。 +- 上流 issue を根拠にするときは、報告された条件をそのまま採用し、自分の観測で狭めない。 +- 設定変更が効かないときは、その設定を読むのが常駐プロセスかを確かめ、プロセスの起動時刻と設定の更新時刻を比べる。 +- バージョン未固定のパッケージは、再ビルドした日によって挙動が変わる。「以前は動いた」ときは、まず実環境の版を確認する(`bwrap --version`)。 +- 回避策を選ぶ前に守るもの(今回は `.env` と認証情報)を固定し、それを削る案は候補から外す。 +- セキュリティの緩和は、既存の緩和を根拠にせず、単体で実害(誰が・何を・どこまで)を書き出して判断する。 + +## 出典 + +1. bubblewrap releases(0.11.2 で setuid 非推奨化・CVE-2026-41163、0.12.0 で削除): https://github.com/containers/bubblewrap/releases +2. bubblewrap source(`acquire_privs()` の `die ("setuid use of bubblewrap is not supported")`): https://github.com/containers/bubblewrap/blob/main/bubblewrap.c +3. Debian security tracker: https://security-tracker.debian.org/tracker/DSA-6472-1 / https://security-tracker.debian.org/tracker/CVE-2026-87766 / https://security-tracker.debian.org/tracker/CVE-2026-41163 +4. Claude Code sandboxing(Troubleshooting「Bubblewrap fails to start inside a container」、Strict sandbox mode): https://code.claude.com/docs/en/sandboxing +5. openai/codex#43929(deny に一致するファイルが 2 つ以上で起動失敗): https://github.com/openai/codex/issues/43929 +6. Claude Code settings reference(`sandbox.enableWeakerNestedSandbox`): https://code.claude.com/docs/en/settings-reference +7. Codex Permissions(deny の完全パスとワイルドカード、`:workspace_roots`): https://learn.chatgpt.com/docs/permissions +8. openai/codex#44304(0.12.0 で `/proc` のエラー文言が変わり代替手段が働かない): https://github.com/openai/codex/issues/44304 +9. openai/codex PR #17547(secure devcontainer、`--proc` なしで再試行): https://github.com/openai/codex/pull/17547 +10. openai/codex#44329(CLI と VS Code 拡張が `/proc` マウント失敗を扱えない): https://github.com/openai/codex/issues/44329 +11. Docker `docker container run`(`--security-opt systempaths=unconfined`): https://docs.docker.com/reference/cli/docker/container/run/ +12. pnpm 11.0 release notes(`pmOnFail`): https://pnpm.io/blog/releases/11.0 diff --git a/docs/dev-notes/2026-09-18/fix-bwrap-setuid/survey.md b/docs/dev-notes/2026-09-18/fix-bwrap-setuid/survey.md deleted file mode 100644 index a007ed991..000000000 --- a/docs/dev-notes/2026-09-18/fix-bwrap-setuid/survey.md +++ /dev/null @@ -1,141 +0,0 @@ -# Survey: #4033 以降に Claude / Codex から commit できない問題 - -Issue: https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/4059 - -## 症状 - -- Claude Code / Codex 経由の Bash コマンドがすべて `bwrap: setuid use of bubblewrap is not supported` で失敗する。 -- `git commit` だけでなく `gh`・`ls` なども含め、Bash が一切動かない。 - -## 結論 - -- Debian stable のセキュリティ更新で bubblewrap 0.12.0 が入り、setuid で bwrap を動かす方式が上流で廃止されていた。 -- `Dockerfile` の `chmod u+s /usr/bin/bwrap` がこの廃止済みの使い方に該当し、コンテナの再ビルド後に bwrap が起動直後に終了するようになった。 -- 設定ミスというより、上流の方針変更とバージョン未固定の `apt-get install` が重なったことが原因。 - -```dockerfile -RUN apt-get update \ - && apt-get -y install --no-install-recommends bubblewrap fish \ - && chmod u+s /usr/bin/bwrap \ - && rm -rf /var/lib/apt/lists/* -``` - -## 根本原因 - -### 1. bwrap の 2 つの動作方式 - -| 方式 | 仕組み | 現状 | -| ------------------- | ---------------------------------------------------------------------------- | ---------- | -| user namespace 方式 | 一般ユーザーのままカーネルの unprivileged user namespace で namespace を作る | 現在の標準 | -| setuid 方式 | `chmod u+s` で root 権限付きで起動し、その権限で namespace を作る | 廃止 | - -- Claude Code 公式ドキュメントも bubblewrap を "the unprivileged sandboxing tool" と説明している。 -- setuid 方式は user namespace が使えない古いカーネル向けの後方互換手段だった。 - -### 2. 上流 bubblewrap での廃止(公式リリースノート) - -- 0.11.2(2026-04): CVE-2026-41163(setuid で入れた bwrap に ptrace で割り込み、サンドボックス準備処理を乗っ取れる)を受けて setuid を非推奨化した。 - - 新しいビルドオプション `-Dsupport_setuid` の既定値は false で、"Binaries built with this will refuse to run if made setuid." -- 0.12.0(2026-08-26): setuid 対応を完全に削除した。 - - "This version removes the support for building a setuid bubblewrap. ... basically all modern linux distributions now support unprivileged user namespaces" - -今回のエラーは `bubblewrap.c` の `acquire_privs()` から出ている。 - -```c -/* Are we setuid ? */ -if (real_uid != euid) - { - /* Historically we supported this, but now we only do user namespaces */ - die ("setuid use of bubblewrap is not supported"); - } -``` - -- setuid ビットが付いていると実 UID(node)と実効 UID(root)が食い違い、bwrap はそれを検知して即終了する。 - -### 3. 私たちの環境に入った経路(Debian security tracker) - -- 2026-08-27: DSA-6472-1 で trixie-security に `bubblewrap 0.12.0-1~deb13u1` が配信された。 - - 修正対象は CVE-2026-87766(サンドボックス準備中に親ディレクトリのシンボリックリンクをたどり、ホスト側へ書き込めてしまう)。 - - Debian は stable にもかかわらずメジャー更新の 0.12.0 をそのまま入れた(bookworm には修正が大がかりすぎるとして backport されていない)。 -- `Dockerfile` は `apt-get install bubblewrap` でバージョンを固定していない。 - - そのため、ビルドした日によって 0.11.x(setuid で動く)と 0.12.0(setuid だと即終了)のどちらが入るかが変わる。 - -### 4. Bash が全滅する理由(Claude Code 公式ドキュメント) - -- Linux では Bash コマンドを 1 つずつ bwrap で包んで実行する。 -- `.claude/settings.json` は `allowUnsandboxedCommands: false`(公式ドキュメント上は Strict sandbox mode)。 - - この設定では "every command Claude runs must run sandboxed" となり、サンドボックスの外で再実行する逃げ道がない。 -- よって bwrap の起動自体が失敗すると、個別コマンドの許可設定とは無関係にすべての Bash が失敗する。 -- Codex も同じ `/usr/bin/bwrap` を使うため、両方同時に影響を受ける。 - -### 5. setuid が実際に回避していたもの(当初の「setuid は不要」は誤り) - -- setuid を外すと、Claude の Bash は `bwrap: Can't mount proc on /proc: Operation not permitted` で失敗するようになった。 -- user namespace 方式では、カーネルは既存の `/proc` がすべて見えている場合にしか新しい procfs のマウントを許さない。 -- Docker は `/proc/kcore` などをマスク(上書きマウント)しているため、この条件を満たせず EPERM になる。 -- setuid 方式では bwrap がコンテナの root 権限(`SYS_ADMIN`)で動くため、この制約を受けずに `/proc` をマウントできていた。 -- Claude Code 公式ドキュメントの Troubleshooting にも同じ症状が載っている: "in an unprivileged container, bubblewrap can't mount a fresh `/proc` filesystem ... Set `enableWeakerNestedSandbox` to `true` so the inner sandbox bind-mounts the container's existing `/proc` instead." -- つまり setuid は「コンテナ内で `/proc` をマウントする」ための回避策として機能していた。 - -## 現環境での確認結果(2026-09-19) - -```bash -$ cat /etc/debian_version; bwrap --version -13.6 -bubblewrap 0.12.0 -``` - -- Debian 13(trixie)上で bubblewrap 0.12.0 が入っていることを確認した。上記の根本原因と一致する。 - -## #4034 時点で動いていた理由(推測) - -- #4034 のマージは 2026-09-13 で、DSA の配信(2026-08-27)より後。 -- PR の作業中(8/27 より前)にビルドしたイメージ、またはビルドキャッシュを使っていたため、setuid 対応の 0.11.x が残っていたと推測している。 - -## setuid を外した後に出た 2 つのエラー - -setuid を外すと、Claude と Codex でそれぞれ別の問題が表に出た。 - -### Claude: `Can't mount proc on /proc: Operation not permitted` - -- 原因は上記 5。コンテナ内で新しい `/proc` をマウントできない。 -- 案 A(公式ドキュメントの対処): `.claude/settings.json` に `sandbox.enableWeakerNestedSandbox: true` を追加する。 - - bwrap は新しい `/proc` をマウントせず、コンテナの `/proc` を bind mount する。 - - 公式ドキュメントの注意書き: "considerably weakens security and should only be used when additional isolation is otherwise enforced." この環境ではコンテナ自体が外側の隔離境界になる。 -- 案 B: `compose.yaml` の `security_opt` に `systempaths=unconfined` を追加し、Docker による `/proc` のマスクをやめる。 - - Claude のサンドボックスの強度は保てるが、コンテナ内の全プロセスからマスク対象だった `/proc` のパスが見えるようになる。 - -### Codex: `Can't write data to file /usr/src/app/.env.example: Bad file descriptor` - -- Codex 側の既知の未修正バグ openai/codex#43929: ワークスペース直下で deny ルールにマッチするファイルが 2 つ以上あると、Codex が起動時に失敗する(0.152.1 / 0.153.4 で再現が報告されている)。 -- `.codex/config.toml` の `.env` / `.env.*` に、`.env` と `.env.example` の 2 ファイルがマッチしている。 -- bwrap の setuid 問題とは別件で、setuid の問題で起動前に止まっていたため今まで見えていなかったと推測している(#4034 の時点で動いていた理由は未確認)。 -- 対処候補: 上流の修正を待つか、deny にマッチする実ファイルを 1 つ以下に絞る(例: 秘密情報を含まないテンプレートの `.env.example` を deny 対象から外す)。 - -## 修正方針 - -- `Dockerfile` から `&& chmod u+s /usr/bin/bwrap \` の 1 行を削除する(bubblewrap 0.12.0 では setuid 方式自体が使えないため必須)。 -- Claude: 案 A または案 B で `/proc` の問題に対処する。 -- Codex: openai/codex#43929 への対処を決める。 - -## 却下した代替案 - -- `allowUnsandboxedCommands: true` にする: サンドボックスの外での実行を許すことになり、`denyRead` による認証情報の保護が弱まる。 -- `sandbox.enabled: false` にする: 同上。原因を直さずに防御を外すだけになる。 -- bubblewrap を 0.11.x に固定する: 廃止済みの setuid 方式を延命するだけで、CVE-2026-87766 の修正も受けられなくなる。 - -## 未確認事項 - -- 修正後に Claude / Codex の両方で `git commit` が通ることを確認する必要がある。 - -## 参考 - -- bubblewrap releases: https://github.com/containers/bubblewrap/releases -- bubblewrap source (`acquire_privs`): https://github.com/containers/bubblewrap/blob/main/bubblewrap.c -- Debian tracker: https://tracker.debian.org/pkg/bubblewrap -- DSA-6472-1: https://security-tracker.debian.org/tracker/DSA-6472-1 -- CVE-2026-41163: https://security-tracker.debian.org/tracker/CVE-2026-41163 -- CVE-2026-87766: https://security-tracker.debian.org/tracker/CVE-2026-87766 -- Claude Code sandboxing: https://code.claude.com/docs/en/sandboxing -- Claude Code settings reference(`sandbox.enableWeakerNestedSandbox`): https://code.claude.com/docs/en/settings-reference -- openai/codex#43929: https://github.com/openai/codex/issues/43929 diff --git a/docs/dev-notes/2026-09-19/fix-bwrap-setuid/learning.md b/docs/dev-notes/2026-09-19/fix-bwrap-setuid/learning.md deleted file mode 100644 index 9860d0706..000000000 --- a/docs/dev-notes/2026-09-19/fix-bwrap-setuid/learning.md +++ /dev/null @@ -1,29 +0,0 @@ -# Learning: devcontainer 内で Claude / Codex のサンドボックスが全滅した件 - -## 問題 - -- 症状: devcontainer 内で Claude / Codex の Bash が全滅し、`git commit` もできない(`bwrap: setuid use of bubblewrap is not supported` → `/proc` マウント失敗 → Codex の `Bad file descriptor`)。 -- 根本原因: Debian のセキュリティ更新で入った bubblewrap 0.12.0 が setuid 方式を廃止し、setuid が暗黙に担っていた `/proc` マウントの回避策が消えた。その結果、Codex 側の未修正バグ 2 件(ファイル単位の deny が 2 つ以上で失敗、`/proc` 失敗時の代替手段が新しいエラー文言を検知できない)が順に表に出た。 - -## 有効だったアプローチ - -- エラー文言で上流のソース(`bubblewrap.c` の `acquire_privs()`)とリリースノート、Debian security tracker を引き、「いつ・なぜ挙動が変わったか」を一次情報で確定させた。 -- エラーの対象パスが変わるたびに「どの設定行が効いているか」を実ファイル(`ls`、`find`)と突き合わせ、仮説を 1 つずつ潰した。 -- 設定が反映されないときは、常駐プロセスの起動時刻と設定ファイルの更新時刻を比べた(VS Code 拡張が起動した Codex の app server が、古い設定のまま CLI の接続先になっていた)。 -- Claude は公式ドキュメントの `enableWeakerNestedSandbox`、Codex は `systempaths=unconfined` で `/proc` のマウントを通し、実害の範囲(OrbStack の VM 内に閉じる)を明記して許容した。 - -## ハマった点 - -- 「setuid は不要」と判断した: setuid が `/proc` マウントの回避策を兼ねていたことを見落としていた。 -- 「ワークスペース内の実在ファイルが 2 つ以上で失敗」と狭く理解した: 実際は、実在しない完全パスも含めて、Codex の設定全体で数えられていた。そのため `.env.example` → `.env.local` → `~/.claude/.credentials.json` と、3 回に分けて潰すことになった。 -- 「CLI を再起動すれば反映される」と説明した: CLI は常駐している app server に接続するだけで、設定を読み直していなかった。 -- `.env` を deny から外す案を推した: 「動かすこと」を優先して目的(秘密情報の保護)を損ねる本末転倒だった。 -- 「すでに緩めてあるから」を理由に `systempaths=unconfined` のリスクを小さく見積もった: 緩和を重ねる理由にはならない。 - -## 教訓 - -- 権限まわりの設定(setuid、capability、security_opt)を外すときは、それが暗黙に回避していた制約を先に洗い出す。例: setuid が `/proc` マウントの制約を回避していた。 -- バージョンを固定しないパッケージは、再ビルドした日によって挙動が変わる。「以前は動いた」ときは、まず実環境のバージョンを確認する。例: `bwrap --version` で 0.12.0 を確認。 -- 設定の変更が効かないときは、その設定を読むのが常駐プロセスかどうかを確かめ、プロセスの起動時刻を設定の更新時刻と比べる。 -- 回避策を選ぶときは、守りたいもの(今回は `.env` と認証情報)を先に固定し、それを削る案は候補から外す。 -- セキュリティの緩和は、既存の緩和を根拠にせず、単体で実害(誰が・何を・どこまで)を書き出してから判断する。 From 6696b6043b73fe72a59dc1b1e5a9540d710ad33a Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 19 Sep 2026 10:07:12 +0000 Subject: [PATCH 03/12] feat: build secret-free devcontainer for Claude/Codex sandboxes Remove secrets from the devcontainer environment so Claude and Codex can run without approval prompts, using managed configs to restrict sandbox filesystem/network access instead of relying on secrets being absent from the container. Co-Authored-By: Claude Sonnet 5 --- .claude/settings.json | 1 - .devcontainer/claude-managed-settings.json | 5 + .devcontainer/codex-managed-config.toml | 5 + .devcontainer/empty.env | 0 .devcontainer/setup-devcontainer.sh | 6 + .env.example | 6 +- AGENTS.md | 2 + CONTRIBUTING.md | 15 +- Dockerfile | 7 +- compose.yaml | 19 +- .../secret-free-devcontainer/learning.md | 26 ++ .../secret-free-devcontainer/plan.md | 286 ++++++++++++++++++ docs/guides/claude-code.md | 4 +- docs/guides/codex.md | 14 +- prisma/seed.ts | 40 +++ prisma/users.ts | 6 +- 16 files changed, 405 insertions(+), 37 deletions(-) create mode 100644 .devcontainer/claude-managed-settings.json create mode 100644 .devcontainer/codex-managed-config.toml create mode 100644 .devcontainer/empty.env create mode 100644 docs/dev-notes/2026-09-19/secret-free-devcontainer/learning.md create mode 100644 docs/dev-notes/2026-09-19/secret-free-devcontainer/plan.md diff --git a/.claude/settings.json b/.claude/settings.json index b254c5746..37229864d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,7 +2,6 @@ "sandbox": { "enabled": true, "allowUnsandboxedCommands": false, - "enableWeakerNestedSandbox": true, "filesystem": { "denyRead": ["~/.ssh", "~/.claude/.credentials.json", "~/.codex/auth.json", "**/.env"] } diff --git a/.devcontainer/claude-managed-settings.json b/.devcontainer/claude-managed-settings.json new file mode 100644 index 000000000..c4beb6f87 --- /dev/null +++ b/.devcontainer/claude-managed-settings.json @@ -0,0 +1,5 @@ +{ + "sandbox": { + "enabled": false + } +} diff --git a/.devcontainer/codex-managed-config.toml b/.devcontainer/codex-managed-config.toml new file mode 100644 index 000000000..5badf04bd --- /dev/null +++ b/.devcontainer/codex-managed-config.toml @@ -0,0 +1,5 @@ +# Despite its name, ":danger-full-access" grants nothing beyond the container user's own access; +# it only skips Codex's bwrap sandbox ("No sandbox"), so the container is the boundary here. +# See https://learn.chatgpt.com/docs/agent-approvals-security +# This managed layer overrides `default_permissions` in `.codex/config.toml`, which still applies to host clones. +default_permissions = ":danger-full-access" diff --git a/.devcontainer/empty.env b/.devcontainer/empty.env new file mode 100644 index 000000000..e69de29bb diff --git a/.devcontainer/setup-devcontainer.sh b/.devcontainer/setup-devcontainer.sh index 78e9ebf36..5b36bcb30 100644 --- a/.devcontainer/setup-devcontainer.sh +++ b/.devcontainer/setup-devcontainer.sh @@ -1,6 +1,12 @@ #!/bin/bash set -euo pipefail +# Compose reads the host `.env` for substitution, so a forgotten value would be injected silently. +if [[ -n "${CONFIRM_API_URL:-}" ]]; then + echo 'WARNING: The real CONFIRM_API_URL is injected into this container.' >&2 + echo 'WARNING: Do not use Claude / Codex. After checking, remove the value on the host and rebuild.' >&2 +fi + # Install agent CLIs independently so one unavailable registry package does not block setup. npm install -g @anthropic-ai/claude-code || echo 'Claude Code CLI installation failed, continuing...' diff --git a/.env.example b/.env.example index 3f811d189..27ac55894 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ # AtCoder affiliation confirmation API endpoint (NoviSteps organization crawler) -# See team documentation for the actual URL. -CONFIRM_API_URL=https://your-confirm-api-endpoint.example.com/confirm +# Not needed for local development: seeded `admin` and `guest` are already verified. +# Set it only for a local verification session with the real value, do not use agents meanwhile, +# then remove it and rebuild the container. See team documentation for the actual URL. +# CONFIRM_API_URL=https://your-confirm-api-endpoint.example.com/confirm diff --git a/AGENTS.md b/AGENTS.md index d246694ee..a55dace4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,8 @@ Lefthook runs Prettier, oxlint for JS/TS, and ESLint for Svelte before commit. ## Verification and Cross-review Before a PR +Agents never run `git push`; the human pushes after reviewing the work. + Every PR must pass the CI build, lint, type/Svelte check, and unit test jobs. Before handing work off, run `pnpm format`, `pnpm lint`, `pnpm check`, relevant tests, and `git diff --check`. Cross-review is required for AI-led non-trivial changes when any of these apply: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9dacbc83f..f5df861f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,7 +94,9 @@ Claude Code と Codex は用途や利用可能な契約に応じて選択でき 0. [AtCoder NoviSteps](https://github.com/AtCoder-NoviSteps)にメンバー申請をします。[@KATO-Hiro](https://twitter.com/k_hiro1818)にDMなどでご連絡いただければ、GitHubで登録しているメールアドレスに招待メールが届きますので、承認してください。 1. ターミナルなどを利用して、[本レポジトリ](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps)の内容をローカル環境にダウンロードします。 - `git clone https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps.git` + `git clone git@github.com:AtCoder-NoviSteps/AtCoderNoviSteps.git` + + - HTTPS で clone 済みの場合は `git remote set-url origin <上の URL>` で SSH に切り替えてください。 2. 作業ディレクトリを`AtCoderNovisteps`に変更します。 @@ -160,9 +162,14 @@ Claude Code と Codex は用途や利用可能な契約に応じて選択でき - Windows: `Ctrl + Shift + P` 3. ローカルサーバを動作させるために必要な環境が自動的に構築され、VS Codeの拡張機能もインストールされます。 -#### (SSH で GitHub を利用する場合) ホスト側で鍵を ssh-agent へ登録 +エージェントはコンテナを境界として動くため、コンテナに秘密を置きません。 + +- `CONFIRM_API_URL` はローカル開発では不要です(連携済みユーザーはシードで作れます)。ホストの `.env` とシェルに設定しないでください。本物の値で確認するときだけ設定して Rebuild し、エージェントを使わずに確認後、値を外して再度 Rebuild します。 +- ホストの VS Code のユーザー設定に `"dev.containers.gitCredentialHelperConfigLocation": "none"` を追加し、GitHub のトークンをコンテナに共有しないようにします。 -秘密鍵はコンテナに mount せず、SSH agent forwarding でホストの `ssh-agent` に署名だけを依頼します。ホスト側で鍵が agent に載っていないと、コンテナ内の Git 操作が `Permission denied (publickey)` で失敗します。HTTPS 利用時は不要です。 +#### ホスト側で SSH の鍵を ssh-agent へ登録 + +秘密鍵はコンテナに mount せず、SSH agent forwarding でホストの `ssh-agent` に署名だけを依頼します。ホスト側で鍵が agent に載っていないと、コンテナ内の Git 操作が `Permission denied (publickey)` で失敗します。 ホストの `~/.ssh/config` に次を書いておくと、ホストで `ssh` を使うたびに鍵が自動で agent に載ります。`IdentityFile` は実際の鍵の path に置き換えてください(`ls -la ~/.ssh/` で確認。`.pub` が付かない方が秘密鍵)。 @@ -208,6 +215,8 @@ Set-Service ssh-agent -StartupType Automatic; Start-Service ssh-agent `pnpm db:seed` + - `admin` と `guest` は AtCoder アカウント連携済みになります(既存の DB も再実行で反映)。 + `sh -lc "pkill -f 'prisma.*studio' || true"` `pnpm db:studio --port 5555` diff --git a/Dockerfile b/Dockerfile index bb6da7470..71d6c7413 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,9 +7,14 @@ WORKDIR /usr/src/app COPY . /usr/src/app RUN apt-get update \ - && apt-get -y install --no-install-recommends bubblewrap fish \ + && apt-get -y install --no-install-recommends fish \ && rm -rf /var/lib/apt/lists/* +# The container is the isolation boundary; managed settings disable the agents' nested sandboxes +# here only, while the committed project settings keep them on host clones. +COPY .devcontainer/claude-managed-settings.json /etc/claude-code/managed-settings.json +COPY .devcontainer/codex-managed-config.toml /etc/codex/managed_config.toml + ENV NODE_PATH=/node_modules ENV PATH=/home/node/.local/bin:$PATH:/node_modules/.bin diff --git a/compose.yaml b/compose.yaml index f283efe1c..1f48e0abb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,21 +1,10 @@ services: web: build: . - # Codex creates its narrower bubblewrap sandbox inside this development container. - # Keep these aligned with OpenAI's secure devcontainer requirements. + # No nested agent sandbox runs here, so Docker's default capabilities and profiles stay in place. + # NET_ADMIN is reserved for the planned egress firewall. cap_add: - NET_ADMIN - - SETGID - - SETUID - - SYS_ADMIN - - SYS_CHROOT - - SYS_PTRACE - security_opt: - - apparmor=unconfined - - seccomp=unconfined - # Unmask /proc so bubblewrap 0.12.0 can mount a fresh procfs without setuid; Codex's - # retry-without-/proc fallback misses the new error text (openai/codex#44329). - - systempaths=unconfined ports: - '5173:5173' - '5555:5555' @@ -23,11 +12,13 @@ services: volumes: - .:/usr/src/app - ./node_modules:/usr/src/app/node_modules + # Hide the host .env from the container; compose still reads it on the host for substitution. + - ./.devcontainer/empty.env:/usr/src/app/.env:ro environment: - NODE_ENV=development - DATABASE_URL=postgresql://db_user:db_password@db:5432/test_db?pgbouncer=true&connection_limit=10&connect_timeout=60&statement_timeout=60000 # Note: Local server cannot start if port is set to db:6543. - DIRECT_URL=postgresql://db_user:db_password@db:5432/test_db - - CONFIRM_API_URL=${CONFIRM_API_URL:?CONFIRM_API_URL environment variable is required} # AtCoder affiliation confirmation API endpoint + - CONFIRM_API_URL=${CONFIRM_API_URL:-} # Unset by default; set only for a real verification session without agents command: sleep infinity depends_on: - db diff --git a/docs/dev-notes/2026-09-19/secret-free-devcontainer/learning.md b/docs/dev-notes/2026-09-19/secret-free-devcontainer/learning.md new file mode 100644 index 000000000..d52293d36 --- /dev/null +++ b/docs/dev-notes/2026-09-19/secret-free-devcontainer/learning.md @@ -0,0 +1,26 @@ +# Learning: devcontainer でエージェントのサンドボックスを外すときの判断 + +## 問題 + +- 症状: 計画の「apt の bubblewrap を外せば入れ子のサンドボックスは消える」という前提が、Codex では成り立たなかった。 +- 根本原因: Codex は `bwrap` がないと同梱の `codex-resources/bwrap` を使う。サンドボックスを止めるには、パッケージの削除ではなく設定(`/etc/codex/managed_config.toml` の `default_permissions = ":danger-full-access"`)が必要だった。 + +## 有効だったアプローチ + +- ドキュメントの記述を、インストール済みの実物で裏付けた(`find .../@openai/codex -name 'bwrap*'` で同梱の bwrap を確認し、`strings` で setuid 非対応のビルドだと確認した)。 +- 「同梱の bwrap なら動くか」は、bwrap の版ではなくコンテナの制限(既定の seccomp / AppArmor が `pivot_root` やマウントを拒否する)で決まる、と一次資料(openai/codex#17547、Docker の seccomp のドキュメント)から切り分けた。これで、どの bwrap を使っても権限の緩和が必要、と結論できた。 +- 過去の障害を、原因がバイナリにあるものと、コンテナや Codex 本体にあるものの表に分けた。「システムの bwrap が悪かったのでは」という問いに、項目ごとに答えられた。 +- 上書きの優先順位のように手元で検証できない点は、「未検証、Rebuild 後に確認」と plan.md に明記した。 + +## ハマった点 + +- 計画を覆す選択肢(「Codex は現状を維持」)を、推奨案と並べて提示した。ユーザーが混乱し、「なぜ覆そうとしているのか」と問われた。 +- `danger-full-access` を説明するとき、「承認のプロンプトは残る」と書いた。しかし、サンドボックスがなければ `on-request` で昇格を求める場面はほぼなく、実際にプロンプトが出る頻度は下がる。 +- エージェントのサンドボックス内では、DB(`db:5432`)への接続も `tsx` の IPC ソケットの作成もできず、`pnpm db:seed` を検証できなかった。 + +## 教訓 + +- ツールのサンドボックスを「依存パッケージを外す」ことで無効にしようとするときは、そのツールが代替のバイナリを同梱していないかを先に確認する。 +- サンドボックスが動くかどうかは、バイナリの版より、実行環境が許すシステムコールと LSM(seccomp / AppArmor)で決まることが多い。どちらが原因かを先に切り分ける。 +- 承認済みの計画の方針に反する案は、選択肢に並べない。どうしても出すなら、「計画を覆す案」だと明記する。 +- エージェントのサンドボックス内で検証できない手順(DB、ネットワーク、`/etc` への書き込み)は、早めに見極めて、ユーザーに実行を依頼するコマンドを提示する。 diff --git a/docs/dev-notes/2026-09-19/secret-free-devcontainer/plan.md b/docs/dev-notes/2026-09-19/secret-free-devcontainer/plan.md new file mode 100644 index 000000000..82a4a73cc --- /dev/null +++ b/docs/dev-notes/2026-09-19/secret-free-devcontainer/plan.md @@ -0,0 +1,286 @@ +# Plan: devcontainer から秘密を排除し、入れ子のサンドボックスを撤去する + +前回の経緯: [summary.md](../../2026-09-18/fix-bwrap-setuid/summary.md) + +## 概要 + +- 方針: 「エージェントに秘密を読ませない」を、ツールごとのサンドボックスではなく「コンテナに秘密を置かない」ことで実現する。 +- これにより bubblewrap、コンテナの権限緩和(`SYS_ADMIN` など)、`/proc` の回避策が不要になり、障害と重さ(コマンドごとの glob 走査、約 1 秒)の原因がまとめて消える。 +- 要件(ユーザー決定): + - `.env` の本番の値を LLM のベンダーに渡さない。 + - エージェントは push しない。ただし人間の push の手間は増やさない(D1)。 + - 外部通信は許可リストのみ。各ツールのテレメトリは送らない。 + - Claude / Codex の両方を使い続ける(特定ベンダーに依存しない)。Windows / WSL のメンバーがいるため、OS に依存しない構成にする。 + +## 調査で判明した事実 + +- 本番の秘密は `CONFIRM_API_URL` の 1 つだけ。使うのは [atcoder_verification.ts:12](../../../../src/features/account/services/atcoder_verification.ts) のみ(`/users/edit` の連携確認)。 +- **`compose.yaml` が `CONFIRM_API_URL=${CONFIRM_API_URL:?...}` でコンテナの環境変数に注入している。** そのため、`printenv` でエージェントから常に読めた。`.env` の deny もサンドボックスも、これは防いでいなかった。 +- `:?` で必須のため、値がないとコンテナが起動しない。これが「全員が本物の値を持つ」運用の直接の原因。 +- シード(`prisma/seed.ts`)は連携済みの `AtCoderAccount` を作らない。そのため、投票などの連携済みユーザー向けの機能をローカルで試すには、本物の値での連携が必要だった。 +- 本物の値は、単体テスト(`vi.stubEnv` + `fetch` のモック)、e2e、CI のいずれでも使っていない。 +- e2e の `votes.spec.ts` は `guest` でログインし、未連携なら skip している。 +- bubblewrap とコンテナの権限(`SYS_ADMIN`、`SYS_PTRACE`、seccomp・apparmor の無効化)は、2026-09-12 の Codex 導入時(c276af73)に入った。それ以前の Claude のサンドボックスは、bubblewrap がなく実質的に動いていなかった可能性が高い(未検証)。 +- `/etc/gitconfig` に VS Code の git credential helper が設定されている。コンテナ内のどのプロセスも、`git credential fill` で GitHub の認証情報を取得できる。 +- 公式の devcontainer の立場: "dev containers do not prevent a malicious project from exfiltrating anything accessible inside the container" "Avoid mounting host secrets"。[1] + +## 設計判断 + +### 本番の値は普段ローカルに置かず、Vercel で管理する + +- 本番と staging(Preview)の値は、Vercel の環境変数で管理する。 +- ローカルでは `CONFIRM_API_URL` を既定で未設定にする。連携確認のボタンは `Failed to validate AtCoder account.` を返すだけで、ほかの機能には影響しない。 +- 連携の手順そのものの確認は、普段は単体テスト(既存のモック)と staging で行う。 +- ローカルで本物の値を使った確認が必要なときは、例外として明示的に注入できるようにする(Phase 2)。その間はエージェントを使わない運用で守る。 + +### シードで連携済みアカウントを作る + +- 連携済みの状態が必要な機能を、本物の値なしでローカルで試せるようにする。これで本物の値を必要とする人がほぼいなくなる。 +- 既存の DB にも反映されるよう、ユーザーの作成とは別に upsert する(既存の `addUsers` は、登録済みのユーザーをスキップするため)。 + +### エージェントのサンドボックスは devcontainer の中でだけ無効にする + +- `.claude/settings.json` と `.codex/config.toml` はリポジトリで共有しており、ホスト(Mac の Seatbelt、WSL の bubblewrap)でもそのまま適用される。そのため、プロジェクトの設定は残す。 +- コンテナ内だけ、優先順位が最も高い managed settings で無効にする。 + - Claude: `/etc/claude-code/managed-settings.json` を Dockerfile で配置する(公式の devcontainer の手順どおり)。[1] + - Codex: 同等の managed config を配置する。仕組みと優先順位は Phase 3 の最初に検証する。 + +### コンテナに残る秘密は、エージェント自身の認証トークンだけにする + +- `.env` と GitHub のトークン(https の credential helper)をコンテナから外す。SSH は agent forwarding のため、鍵そのものはコンテナにない。 +- `~/.claude/.credentials.json` と `~/.codex/auth.json` は、各ツールの動作に必要なので残る。互いのトークンを読めてしまう点は、送信先を最小限に絞ったうえで受け入れる(D3)。 + +### push はコンテナ内のまま、エージェントはルールで禁止する(D1) + +- 人間はコンテナ内の VS Code とターミナルから、承認なしで普段どおり push する。 +- エージェントの push は、ルールで禁止する(Claude の `Bash(git push *)` の deny、AGENTS.md)。仕組みでの強制はしない。 +- 根拠: コンテナから秘密を外せば、push で持ち出せるものがほぼない(リポジトリは公開)。勝手な push の実害は不要なブランチ程度で、`staging` / `main` はブランチ保護で守る(設定済みであることをユーザーが確認済み)。 +- 残るリスク: 悪意ある指示を受けたエージェントがルールを回避して push し、エージェントの認証トークンを公開の場所へ書き込む可能性。D3 と同種のリスクとして受け入れる。 + +### `CONFIRM_API_URL` の値は変更しない(D2) + +- これまで全メンバーのコンテナの環境変数に注入されており、エージェントや LLM のベンダーに渡った可能性は否定できない。ただし漏れたときのリスクは低いと判断した。 + - 機密性: 返すのは AtCoder の所属欄で、`user` に渡すユーザー名も AtCoder のランキングで公開されている。 + - 完全性: 本人確認は「所属欄に検証コードを書けるのは本人だけ」で成り立っており、URL の秘匿には依存しない。GET のみで書き換えの経路もない。 + - 可用性(唯一の論点): 公開のユーザー名を列挙して大量に GET されると、クローラーが AtCoder に遮断されたり、実行回数の上限を使い切られたりしうる。DDoS などにならなければ一旦許容する。 +- 対応: 値の変更ではなく、エンドポイント側にキャッシュか流量制限を足す(値を変えても、次に漏れれば同じことが起きるため)。現状はどちらもない(ユーザー確認済み)。エンドポイントは本リポジトリの外にあるため、本計画とは別のタスクとして扱う。 +- 「LLM のベンダーに渡さない」方針は、リスクの大小とは別の原則として維持する(Phase 2 / 4 は変更しない)。 + +### エージェントの認証トークンが互いに読める点は受け入れ、送信先を最小限に絞る(D3) + +- コンテナを分けずに受け入れる。代わりに Phase 5 の許可リストを最小限にする。 +- 許可するのは、各ツールの公式ドキュメントが必須とする推論と認証の宛先、および開発に必要な宛先だけ。「あると便利」な宛先は入れない。 +- 限界: 許可した宛先を経由した持ち出しは防げない。例えば Claude が `~/.codex/auth.json` を読めば、その内容は Anthropic に渡る。また iptables は IP で判定するため、同じ IP を共有するサービス(github.com の repo と gist など)は区別できない。 + +## 却下した代替案 + +| 案 | 却下理由 | +| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 入れ子のサンドボックスを維持(現状) | 障害と重さの原因そのもの。しかも環境変数経由の露出を防げていなかった | +| エージェントを各自の OS で直接動かす | Windows ネイティブは Claude のサンドボックスに非対応。WSL の Codex では #43929 を再び踏む。Mac とコンテナで `node_modules` のネイティブバイナリが衝突する | +| dotenvx | 秘密鍵を持つ人がほぼいなくなるため、利点が小さい。公開リポジトリに本番の暗号文が永久に残る。秘密が増えたら再検討する | +| Infisical / mise / direnv | Infisical はログイン済みのセッションからエージェントも取得でき、1 変数には過剰。mise / direnv はシェルの環境変数経由でエージェントに引き継がれる | +| `.env` を使うときだけ置く(普段の運用として) | 置いている間は読める。外し忘れで元に戻る。例外時の手段としてだけ、警告付きで残す(Phase 2) | +| push はホストから行う | 作業環境が二つに分かれ、VS Code の Source Control から push できない。Windows / WSL のメンバーはホスト側にも git と鍵の準備が必要 | +| SSH エージェントで署名のたびに人間が承認する | push のたびに承認が必要になり、開発の流れが止まる | +| 開発サーバとエージェントのコンテナを分ける | compose の再設計が大きい。ローカルに本物の値が不要になれば、分ける理由がない | + +## Phase 0: `origin` を本来のリポジトリ(SSH)に戻す(低リスク・手作業) + +- 経緯: SSH の push が `Permission denied (publickey)` で失敗したとき、VS Code が fork を作って remote を繋ぎ替えた(2026-09-19 05:58)。 + - 元のリポジトリ(SSH)は `upstream` に改名された。 + - `origin` は fork(`https://github.com/KATO-Hiro/AtCoderNoviSteps.git`)に変わった。 + - `#4059` ブランチは fork に push された(05:43)。 +- 変更: `origin` の fetch / push を、どちらも `git@github.com:AtCoder-NoviSteps/AtCoderNoviSteps.git` にする。`upstream` は `origin` と重複するため削除する。 + +```bash +git remote set-url origin git@github.com:AtCoder-NoviSteps/AtCoderNoviSteps.git +git remote remove upstream +git fetch --prune origin +``` + +- `.git/config` は各自のクローンのローカル設定で、リポジトリの変更ではない。ユーザーがコンテナ内のターミナルで実行する(エージェントのサンドボックスからは `.git/config` を書けない可能性がある)。 +- 前提: ホストの ssh-agent に鍵が登録されていること(CONTRIBUTING の「ホスト側で鍵を ssh-agent へ登録」)。コンテナ内で `ssh-add -l` と `ssh -T git@github.com` が通ることを先に確認する。 +- 後片付け(ユーザーの判断): fork 側の `#4059` ブランチと fork 自体が不要なら、GitHub 上で削除する。`#4059` は `git push -u origin '#4059'` で本来のリポジトリに push し直す。 +- 確認: `git remote -v` の fetch / push がどちらも `git@github.com:AtCoder-NoviSteps/AtCoderNoviSteps.git` で、`upstream` がないこと。 + +## Phase 1: シードに連携済みアカウントを追加する(低リスク) + +- レイヤー: DB のシード(`prisma/`) +- 変更: `prisma/users.ts` に、任意の `atCoderHandle` を追加する。`prisma/seed.ts` に、連携済みの `AtCoderAccount` を upsert する処理を追加する。 + +```typescript +// prisma/users.ts +export const users = [ + { id: '1', name: 'admin', role: Roles.ADMIN, atCoderHandle: 'novisteps_admin' }, + { id: '2', name: 'guest', role: Roles.USER, atCoderHandle: 'novisteps_guest' }, + // Other users stay unverified so the unverified path remains testable. +]; + +// prisma/seed.ts +async function addAtCoderAccounts(): Promise; +async function addAtCoderAccount(userId: string, handle: string): Promise; // upsert with isValidated: true +``` + +- 連携済みにするのは e2e で使う `admin` と `guest`。未連携の画面を確かめられるよう、ほかのユーザーは未連携のまま残す。 +- ハンドルは、実在の AtCoder ユーザーと衝突しない明らかな架空名にする(`novisteps_` 接頭辞)。 +- 影響: `votes.spec.ts` の「ログイン済みユーザー」のテストが skip されずに実行されるようになる。失敗した場合は、このフェーズで原因を切り分ける。 +- テスト: シードには単体テストがなく、分岐も持たないデータ追加のため、テストファーストは省略する。`pnpm db:seed` を 2 回実行して冪等性を確認し、`pnpm test:e2e` で votes を確認する。 + +## Phase 2: `CONFIRM_API_URL` を既定でコンテナに入れない(低リスク) + +- レイヤー: 開発環境の設定 +- `compose.yaml`: 必須(`:?`)から任意(`:-`)に変える。行を削除すると、ローカルで本物の値を使った確認ができなくなるため残す。 + +```yaml +- CONFIRM_API_URL=${CONFIRM_API_URL:-} # Unset by default; set only for a real verification session +``` + +- `.env.example`: 値の行をコメントアウトし、「ローカル開発では不要。ローカルで本物の値を使った確認をするときだけ設定し、終わったら外す」と書く。 +- `.devcontainer/setup-devcontainer.sh`: `CONFIRM_API_URL` が設定されていたら、「本物の値が注入されています。エージェントを使わず、確認後は値を外して Rebuild してください」と警告する。compose はホストの `.env` を変数の置き換えに自動で読むため、消し忘れた値が黙って注入され続けるのを防ぐ。 +- ローカルで本物の値を使って確認する手順(例外時): + 1. ホストで値を設定する(シェルの環境変数か `.env`)。 + 2. Rebuild する。 + 3. エージェントを使わずに確認する。 + 4. 値を外して、もう一度 Rebuild する。 +- 確認: + - 値なしでコンテナが起動し、`printenv CONFIRM_API_URL` が空になる。 + - `/users/edit` の連携確認が `Failed to validate AtCoder account.` を返す。 + - 値を設定して Rebuild すると、注入され、警告が表示される。 +- 手作業(ユーザー): + - Vercel の Production と Preview に `CONFIRM_API_URL` が設定されていることを確認する。 + - メンバーに、ホストの `.env` とシェルの環境変数から値を削除するよう依頼する。 + +## Phase 3: bubblewrap とエージェントのサンドボックスをコンテナ内で撤去する(中リスク) + +- レイヤー: 開発環境の設定(イメージ、compose、エージェントの設定) +- 最初に検証すること: Codex の managed config の配置場所と、プロジェクトの `.codex/config.toml` との優先順位。確認できない場合は、代わりの手段(CLI 引数、`CODEX_HOME` の設定)を比べてから進める。 + - 結果(2026-09-19): 公式ドキュメント上、Linux は `/etc/codex/managed_config.toml` で、プロジェクトの設定より優先される。プロジェクトが権限プロファイル(`default_permissions`)を使っており、`sandbox_mode` とは併用できないため、`default_permissions = ":danger-full-access"` で指定する。実際に上書きされるかは、エージェントのサンドボックスからは `/etc/codex` に書けないため未検証。Rebuild 後に確認する。 + - apt の `bubblewrap` を外すだけでは不十分: Codex は `bwrap` がないと同梱の `codex-resources/bwrap` を使う(README、実物も確認)。 + - 同梱の bwrap で Codex のサンドボックスを続ける案は却下: Docker の既定の seccomp / AppArmor は `pivot_root` やマウントを拒否し、bwrap の版によらず失敗する(openai/codex#17547)。続けるにはコンテナの権限の緩和が要り、本計画の目的と両立しない。 +- `Dockerfile`: + - `bubblewrap` のインストールを削除する。 + - Claude の managed settings(`sandbox.enabled: false`)と Codex の managed config(`default_permissions = ":danger-full-access"`)をコピーする。 +- `compose.yaml`: + - `cap_add` の `SYS_ADMIN`、`SYS_PTRACE`、`SETGID`、`SETUID`、`SYS_CHROOT` を削除する。後ろの 3 つは Docker の既定の capability に含まれる。 + - `security_opt` の 3 つを削除する。 + - `NET_ADMIN` は Phase 5 のファイアウォールで使うため、ここでは残す。 +- `.claude/settings.json`: `enableWeakerNestedSandbox` を削除する(ホストでは不要)。`permissions.deny` の Read と Bash のルールは、コストがほぼないので残す。 +- `.codex/config.toml`: 変更しない(ホスト向けの設定として残す)。 +- 確認: + - Rebuild Container 後、`command -v bwrap` が空になる。 + - Claude / Codex の CLI と VS Code 拡張の両方で、コマンドを実行できる。 + - `git commit` が数秒で終わる(lefthook 込み)。 + +## Phase 4: `.env` と GitHub の認証情報をコンテナから外す(中リスク) + +- レイヤー: 開発環境の設定 +- `.env` を空のファイルで覆い隠す(保険): + - `compose.yaml` の `volumes` に `./.devcontainer/empty.env:/usr/src/app/.env:ro` を追加する。 + - 覆い隠すのはコンテナ内からの読み取りだけで、compose がホスト側で `.env` を変数の置き換えに読むことは妨げない。そのため、Phase 2 の例外時の注入とは両立する。 + - Phase 3 で `SYS_ADMIN` を外しているため、コンテナ内の root でも覆いを外せない。 + - ホストに `.env` がない場合、マウント先として空の `.env` がホストに作られる(gitignore 済み)。 +- GitHub の認証情報(D1 で決定): + - push はコンテナ内で普段どおり、SSH agent forwarding で行う。署名だけを依頼する方式で、秘密鍵はコンテナに渡らない。 + - VS Code の https 用 git credential helper は止める。`git credential fill` で GitHub のトークンそのもの(push 以外の操作もできる)を取り出せるため。止める設定は各自のホストの VS Code 側にあると見込まれ、リポジトリからは強制できない。設定名を実装前に確認し、CONTRIBUTING で案内する。 + - `origin` は Phase 0 で SSH の URL に戻し済み(https のままだと、ヘルパーを止めた後に push できない)。 +- 確認: + - コンテナ内で `cat /usr/src/app/.env` が空になる。 + - `git credential fill` でトークンを取得できない。 + - コンテナ内の VS Code とターミナルから、承認なしで push できる。 + +## Phase 5: 外部通信の許可リスト(ファイアウォール)を導入する(高リスク) + +- 2026-09-19: 許可リストの洗い出しが大きいため、別の PR に分ける(ユーザー決定)。それまで D3 の送信先の制限はない。 + +- レイヤー: 開発環境の設定 +- Anthropic の公式リファレンスの `init-firewall.sh` を元に、コンテナの起動時に許可リスト以外への通信を遮断する。[2] +- `compose.yaml`: `NET_ADMIN` と `NET_RAW` を指定する。 +- 許可リストの候補: + - GitHub + - npm レジストリ + - Anthropic、OpenAI(推論と認証のみ。テレメトリとエラー報告の宛先は許可しない) + - VS Code のマーケットプレイスと更新 + - Playwright のブラウザ配布元 + - Prisma のエンジン配布元 + - アプリが開発中に呼ぶ外部 API + - 実際の一覧は、実装前にコードと各公式ドキュメントから洗い出す。D3 のとおり、公式に必須とされる宛先だけに絞る。宛先ごとに根拠(どのドキュメントか、どの機能が使うか)をスクリプトのコメントに残す。 +- テレメトリの拒否: 遮断に加えて、各ツールの設定でも送信を止める(遮断だけだと、送信の失敗や再試行で遅くなりうるため)。 + - Claude: `containerEnv` に `DISABLE_TELEMETRY=1` と `DISABLE_ERROR_REPORTING=1` を設定する。`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` は、機能フラグの取得まで止めて一部の機能が使えなくなるため採らない。[1] + - Codex: テレメトリと分析の送信を止める設定を、実装前に公式ドキュメントで確認する。 +- sudo の制限: node ユーザーの sudo を、ファイアウォールのスクリプトだけに絞る(公式リファレンスと同じ)。これをしないと、エージェントが `sudo iptables -F` で遮断を解除できる。 +- リスク: CDN の IP が変わると通信が止まる。許可リストの漏れで、開発中に突然失敗する。失敗したときの切り分け手順を CONTRIBUTING に書く。 + +## Phase 6: ドキュメントを更新する(低リスク) + +| ファイル | 変更 | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CONTRIBUTING.md` | 環境構築で `CONFIRM_API_URL` が不要になったことを書く。シードの連携済みユーザーの説明を足す。SSH の節と push の手順を D1 に合わせて書き直す。ファイアウォールの許可リストの追加方法とトラブルシューティングを足す | +| `docs/guides/claude-code.md` | 「実行権限」を書き直す(ホストはサンドボックス、devcontainer はコンテナが境界)。bubblewrap の記述を削除する。「動作確認」を更新する | +| `docs/guides/codex.md` | 「実行権限」の「`danger-full-access` は使用しない」「Dockerfile で setuid 付きで導入」を、devcontainer 内の方針に合わせて書き直す。「動作確認」の `bwrap` のコマンドを削除する | +| `.env.example` | Phase 2 のとおり | +| `compose.yaml` のコメント | 「Codex の bubblewrap のため」のコメントを、ファイアウォール用の説明に置き換える | + +## Phase 7: 検証(高リスク・開発環境全体に影響) + +- Rebuild Container 後、Claude / Codex の両方で次を確認する。 + - コマンドの実行と `git commit` が数秒で終わる。 + - `printenv CONFIRM_API_URL`、`.env`、`git credential fill` から秘密を取得できない。 + - 許可リストにない宛先(例: `example.com`)へ接続できない(Phase 5 の実装後)。 +- 結果は「実装後の確認」を参照。 +- `pnpm format`、`pnpm lint`、`pnpm check`、`pnpm test:unit`、`pnpm test:e2e`、`git diff --check` を実行する。 +- 可能であれば、Windows / WSL のメンバーにも Rebuild と上の確認を依頼する。 + +## 実装後の確認(2026-09-19、Rebuild 後) + +| 確認 | 結果 | 状態 | 確認者 | +| ------------------------------------------------------------------------------------------ | ------------------------------------------------- | -------------------------------------------------------- | -------- | +| `bwrap --unshare-user --ro-bind / / true` が失敗する | `No permissions to create a new namespace` で失敗 | 完了 | Claude | +| `printenv CONFIRM_API_URL`、`.env` が空 | どちらも空(`.env` は 0 バイト) | 完了 | Claude | +| `printf 'protocol=https\nhost=github.com\n\n' \| git credential fill` がトークンを返さない | credential helper なし、トークンは返らない | 完了 | Claude | +| Claude のサンドボックスが無効で、コマンドが速い | サンドボックスなしで実行、`git status` 0.16 秒 | 完了 | Claude | +| Codex の managed config がプロジェクトの設定を上書きする | `codex doctor` で下記のとおり | 完了 | Claude | +| Codex(CLI と VS Code 拡張)でコマンドを実行できる | 動作 | 完了 | ユーザー | +| `pnpm db:seed` を 2 回、`pnpm test:e2e` で votes のテストが skip されずに成功する | 成功 | 完了 | ユーザー | +| `git push` が承認なしで通る | — | 未実施 | ユーザー | +| `git commit`(lefthook 込み)が数秒で終わる | — | 未実施 | ユーザー | +| 外部通信の遮断 | — | 未実施(Phase 5 は別 PR のため、現状は遮断されない想定) | ユーザー | + +- `bwrap` はベースイメージ(`javascript-node` の common-utils)が入れており、Dockerfile から外しても残る。コンテナの権限を外したため namespace を作れず、起動できないので害はない。当初の確認項目「`command -v bwrap` が空」は誤りだった。 +- `codex doctor` の比較(managed config を一時的に退避して確認): + +| 条件 | sandbox | +| ----------------------------------------------------------- | ---------------------------------------------------- | +| managed config あり | unrestricted fs + enabled network | +| managed config あり + `-c default_permissions=":workspace"` | unrestricted のまま(managed が CLI より優先) | +| managed config なし、プロジェクト内 | restricted fs + restricted network、deny ルール 8 件 | +| managed config なし、`/tmp`(Codex の既定値) | restricted fs + restricted network、deny ルール 0 件 | + +## 計画外の変更と発見(2026-09-19) + +- 変更: + - Codex の managed config に、`:danger-full-access` は名前に反してコンテナのユーザー以上の権限を与えず、サンドボックスを使わないだけだというコメントを、公式ドキュメントを出典に付けた。 + - CONTRIBUTING の clone の URL を SSH に変え、HTTPS で clone 済みの人向けの切り替え方法を 1 行足した。 + - `codex.md` の「動作確認」のコマンド一覧を削り、この plan.md の確認表に任せた。 + - 3 つのガイドの加筆は、ユーザーの指摘で半分以下に絞った。 +- 発見: + - `node` はパスワードなしで `sudo` を使える。検証のために managed config を一時的に退避できたのもこのため(すでに元に戻した)。エージェントも managed 設定を外せるので、Phase 5 の sudo の制限はファイアウォールだけでなく、managed 設定を守るためにも必要。 + - エージェントのサンドボックス(Rebuild 前)の中では、DB(`db:5432`)への接続と、`tsx` の IPC ソケットの作成ができなかった。そのため、シードの検証はユーザーに依頼した。 + - `pnpm format` は、サンドボックスが読めないリポジトリ直下のファイル(`.bashrc`、`.profile`、`.zshrc` など。git 未追跡で由来は不明)のために終了コード 2 になった。変更したファイルは個別に整形した。 + +## ルール追加の候補(未反映) + +- learning.md の教訓から。AGENTS.md の Implementation Workflow の 2 の後に追加するか、後で判断する。 + +> When asking the user to choose, do not list an option that contradicts the approved plan unless it is explicitly labeled as overturning the plan. + +## レビュー + +- 秘密情報の扱いと開発環境の構成を変えるため、AGENTS.md に従いクロスレビューの対象とする(Claude 主導のため Codex、使えなければ `coderabbit review --plain`)。 + +## 出典 + +1. Claude Code: Development containers: https://code.claude.com/docs/en/devcontainer +2. Claude Code reference devcontainer(`init-firewall.sh`): https://github.com/anthropics/claude-code/tree/main/.devcontainer +3. Claude Code: Configure the sandboxed Bash tool(macOS は Seatbelt、Windows ネイティブは非対応): https://code.claude.com/docs/en/sandboxing +4. dotenvx: Encryption quickstart: https://dotenvx.com/docs/quickstart/encryption diff --git a/docs/guides/claude-code.md b/docs/guides/claude-code.md index 7ef9cb102..6cc6f8fe4 100644 --- a/docs/guides/claude-code.md +++ b/docs/guides/claude-code.md @@ -16,7 +16,7 @@ sandboxは有効化し、利用できない場合のunsandboxed実行へのfallb `.claude/settings.json` はGit管理されproject scopeで適用されるため、denyはdevcontainerだけでなくhost cloneやcloud agentにも効く。devcontainerに存在しない秘密でも、他環境で実在するものはdenyを外さない。 -Linux sandboxには `bubblewrap` を使い、Dockerfileで導入する。SSH秘密鍵はmountせず、hostの `ssh-agent` からDev Containersのagent forwardingを使う。projectのMCP serverは登録しない。 +hostではproject設定のsandboxが境界になる。devcontainerではcontainerが境界で、[managed settings](../../.devcontainer/claude-managed-settings.json)がsandboxを無効にし、秘密はcontainerに置かない。SSH秘密鍵はmountせずagent forwardingを使い、agentはpushしない。projectのMCP serverは登録しない。 ## Skillsとplugin @@ -24,7 +24,7 @@ project固有skillの正本は `.agents/skills/` に置く。Superpowersはproje ## 動作確認 -設定変更後はdummy secretだけを使って検証し、実credentialの内容は表示しない。`.env` とmountされる認証fileのreadが、bash経路とRead tool経路の両方で拒否されることを確認する。 +設定変更後はdummy secretだけを使って検証し、実credentialの内容は表示しない。hostでは `.env` と認証fileのreadがbashとRead toolの両方で拒否されること、devcontainerではrebuild後に `printenv CONFIRM_API_URL` と `.env` が空であることを確認する。 ## 参考 diff --git a/docs/guides/codex.md b/docs/guides/codex.md index 97c331bcb..05920d751 100644 --- a/docs/guides/codex.md +++ b/docs/guides/codex.md @@ -13,20 +13,10 @@ `project-edit` profileはworkspaceの編集を許可し、`.env*`、credential、秘密鍵などのreadを拒否する。具体的なdeny対象は原本を参照し、`.claude/settings.json` と揃える。子processの環境変数は `core` を基準に、既定のsecret名filterも有効にする。 -Linux sandboxには `bubblewrap` を使う。Dockerfileでsetuid付きで導入し、composeのweb serviceにnested sandbox用のcapabilityとseccomp / AppArmorの緩和を設定する。 - -`on-request` はsandbox外の操作に対する承認方針であり、sandboxの代替ではない。credentialを保護するため、`danger-full-access` と `--dangerously-bypass-approvals-and-sandbox` は使用しない。 +hostでは `project-edit` profileのsandboxが境界で、`danger-full-access` は使用しない。devcontainerではcontainerが境界で、[managed config](../../.devcontainer/codex-managed-config.toml)がsandboxを無効にし、秘密はcontainerに置かない。Codexは `bwrap` がないと同梱版を使うため、bubblewrapを外すだけではsandboxは止まらない。 SSH秘密鍵はmountせず、hostの `ssh-agent` からDev Containersのagent forwardingを使う。projectのMCP serverは登録しない。 ## 動作確認 -sandboxの実行基盤を変更したらclean rebuildし、通常のcontainer terminalで確認する。 - -```bash -command -v bwrap -bwrap --unshare-user --dev-bind / / true -codex sandbox -- true -``` - -CLIとVS Code拡張の両方で新規sessionを開始し、command実行とdummy credentialのread拒否を確認する。 +実行基盤を変更したらclean rebuildし、常駐のapp serverも再起動してから、CLIとVS Code拡張の両方でcommandを実行できることを確認する。hostではdummy credentialのread拒否を確認する。 diff --git a/prisma/seed.ts b/prisma/seed.ts index 959c921d7..6e45ebea5 100755 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -69,6 +69,7 @@ async function main() { console.log('Seeding has been started.'); await addUsers(); + await addAtCoderAccounts(); await addTasks(); await addContestTaskPairs(); await addWorkBooks(); @@ -138,6 +139,45 @@ async function addUser( }); } +// Separate from addUsers, which skips registered users, so existing databases also get verified accounts. +async function addAtCoderAccounts() { + console.log('Start adding AtCoder accounts...'); + + for (const user of users) { + if (!user.atCoderHandle) { + continue; + } + + try { + const registeredUser = await prisma.user.findUnique({ + where: { + username: user.name, + }, + }); + + if (!registeredUser) { + console.error('Failed to add AtCoder account: user', user.name, 'is not registered.'); + continue; + } + + await addAtCoderAccount(registeredUser.id, user.atCoderHandle); + console.log('AtCoder account:', user.atCoderHandle, 'was verified for', user.name); + } catch (e) { + console.error('Failed to add AtCoder account for', user.name, e); + } + } + + console.log('Finished adding AtCoder accounts.'); +} + +async function addAtCoderAccount(userId: string, handle: string) { + await prisma.atCoderAccount.upsert({ + where: { userId }, + update: { handle, isValidated: true, validationCode: '' }, + create: { userId, handle, isValidated: true }, + }); +} + async function addTasks() { console.log('Start adding tasks...'); diff --git a/prisma/users.ts b/prisma/users.ts index e6c91fdc8..07c3cf76f 100644 --- a/prisma/users.ts +++ b/prisma/users.ts @@ -1,8 +1,10 @@ import { Roles } from '@prisma/client'; export const users = [ - { id: '1', name: 'admin', role: Roles.ADMIN }, - { id: '2', name: 'guest', role: Roles.USER }, + // Verified with fictional handles so verified-only features work without the real confirm API. + { id: '1', name: 'admin', role: Roles.ADMIN, atCoderHandle: 'novisteps_admin' }, + { id: '2', name: 'guest', role: Roles.USER, atCoderHandle: 'novisteps_guest' }, + // Other users stay unverified so the unverified path remains testable. { id: '3', name: 'Alice', role: Roles.USER }, { id: '4', name: 'Bob23', role: Roles.USER }, { id: '5', name: 'Carol', role: Roles.USER }, From 10cdbf97885f7eef98d89774f32ab4ab83ff85e3 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 19 Sep 2026 11:22:27 +0000 Subject: [PATCH 04/12] docs: record devcontainer security review conclusions --- docs/dev-notes/2026-09-19/secret-free-devcontainer/plan.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/dev-notes/2026-09-19/secret-free-devcontainer/plan.md b/docs/dev-notes/2026-09-19/secret-free-devcontainer/plan.md index 82a4a73cc..704191650 100644 --- a/docs/dev-notes/2026-09-19/secret-free-devcontainer/plan.md +++ b/docs/dev-notes/2026-09-19/secret-free-devcontainer/plan.md @@ -277,6 +277,12 @@ async function addAtCoderAccount(userId: string, handle: string): Promise; ## レビュー - 秘密情報の扱いと開発環境の構成を変えるため、AGENTS.md に従いクロスレビューの対象とする(Claude 主導のため Codex、使えなければ `coderabbit review --plain`)。 +- Codex によるセキュリティレビュー(2026-09-19): 初回は設定だけで全メンバーを保護できるかを基準に複数項目を「高リスク」と評価したが、開発メンバーは事実上 2 人で、新規参加も当面想定しないという運用条件を踏まえると、一律に高リスクとするのは過大だった。このコミットを止める重大な未対応問題が 4 件あるという評価は採らない。 +- `CONFIRM_API_URL` の再注入は低〜中リスク。Compose はホストの `.env` や環境変数に値が残れば注入し、セットアップスクリプトは警告のみで停止しない。もう 1 人のメンバーにも値を外してもらい、Rebuild 後に空であることを確認する。実値を使った例外的な確認の後も、値の削除と Rebuild が必要。 +- GitHub の HTTPS credential helper は低〜中リスク。VS Code のホスト設定はリポジトリから強制できないが、少人数なら各メンバーの設定と、コンテナ内で `git credential fill` がトークンを返さないことの確認で管理する。新規メンバーが参加する場合も同じ確認を行う。 +- エージェントの push は低〜中リスクとして受容する。AGENTS.md と Claude の deny で原則禁止し、`main` と `staging` のブランチ保護で影響を抑える。SSH agent forwarding は秘密鍵を渡さない一方、コンテナ内のプロセスにその鍵で認証する能力を与えるため、ルールだけで push を技術的に禁止できるとは扱わない。 +- エージェント自身の認証トークンがコンテナ内で読めることが主な残余リスク。両ツールを併用する人が少なければ相互のトークンを読む場面は限られるが、使用中のツールのトークンは残る。Phase 5 まで外向き通信に制限はなく、Phase 5 後も許可した送信先への持ち出しは防げない。D3 として受容した範囲を超える保証はしない。 +- Phase 5 では、通信の許可リストを導入する前に `node` のパスワードなし `sudo` を制限する。これを残すとエージェントが通信制限を解除できる。許可リストは持ち出しリスクを下げる施策であり、完全な遮断とは評価しない。 ## 出典 From 30b5f2078bd9ee68a252f536b72ed4d2c1fc236d Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 19 Sep 2026 12:22:49 +0000 Subject: [PATCH 05/12] Add devcontainer egress firewall --- .devcontainer/codex-managed-config.toml | 7 + .devcontainer/devcontainer.json | 9 +- .devcontainer/init-firewall.sh | 93 +++++ CONTRIBUTING.md | 10 +- Dockerfile | 12 +- compose.yaml | 2 +- .../2026-09-18/fix-bwrap-setuid/summary.md | 141 -------- .../secret-free-devcontainer/learning.md | 26 -- .../secret-free-devcontainer/plan.md | 321 ++++-------------- docs/guides/claude-code.md | 2 +- docs/guides/codex.md | 2 +- 11 files changed, 180 insertions(+), 445 deletions(-) create mode 100644 .devcontainer/init-firewall.sh delete mode 100644 docs/dev-notes/2026-09-18/fix-bwrap-setuid/summary.md delete mode 100644 docs/dev-notes/2026-09-19/secret-free-devcontainer/learning.md diff --git a/.devcontainer/codex-managed-config.toml b/.devcontainer/codex-managed-config.toml index 5badf04bd..e4278c3d0 100644 --- a/.devcontainer/codex-managed-config.toml +++ b/.devcontainer/codex-managed-config.toml @@ -3,3 +3,10 @@ # See https://learn.chatgpt.com/docs/agent-approvals-security # This managed layer overrides `default_permissions` in `.codex/config.toml`, which still applies to host clones. default_permissions = ":danger-full-access" + +# Analytics share chatgpt.com with inference, so the firewall cannot block them. +[analytics] +enabled = false + +[otel] +metrics_exporter = "none" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b743d2d1f..79856d23e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -30,6 +30,9 @@ // Use 'postCreateCommand' to run commands after the container is created. "postCreateCommand": "bash .devcontainer/setup-devcontainer.sh", // + "postStartCommand": "sudo /usr/local/bin/init-firewall.sh", + "waitFor": "postStartCommand", + // // Configure tool-specific properties. "customizations": { "vscode": { @@ -101,7 +104,11 @@ "containerEnv": { "NODE_OPTIONS": "--max-old-space-size=4096 --dns-result-order=ipv4first", "CLAUDE_CONFIG_DIR": "/home/node/.claude", - "CODEX_HOME": "/home/node/.codex" + "CODEX_HOME": "/home/node/.codex", + // Opt out so blocked sends do not retry; this also disables Remote Control. + "DISABLE_TELEMETRY": "1", + "DISABLE_ERROR_REPORTING": "1", + "CHECKPOINT_DISABLE": "1" // Prisma } // // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh new file mode 100644 index 000000000..b9d306241 --- /dev/null +++ b/.devcontainer/init-firewall.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# Egress allowlist, adapted from https://github.com/anthropics/claude-code/blob/main/.devcontainer/init-firewall.sh +set -euo pipefail + +# Destinations the container may reach; each one is also an exfiltration path, so keep it minimal. +allowed_domains=( + # npm and Prisma engines + registry.npmjs.org + binaries.prisma.sh + # Claude Code + api.anthropic.com + claude.ai + platform.claude.com + # Codex (ChatGPT sign-in) + chatgpt.com + auth.openai.com + # VS Code + marketplace.visualstudio.com + vscode.blob.core.windows.net + update.code.visualstudio.com + # External APIs the app calls (src/lib/constants/urls.ts) + kenkoooo.com + judgeapi.u-aizu.ac.jp + # CodeRabbit CLI + cli.coderabbit.ai + app.coderabbit.ai + ide.coderabbit.ai +) + +# 1. Reset rules from a previous run, but keep Docker's embedded DNS, which lives in the NAT table. +docker_dns_rules="$(iptables-save -t nat | grep '127\.0\.0\.11' || true)" +iptables -F +iptables -X +iptables -t nat -F +iptables -t nat -X +ipset destroy allowed-domains 2>/dev/null || true + +if [[ -n "${docker_dns_rules}" ]]; then + iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true + iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true + echo "${docker_dns_rules}" | xargs -L 1 iptables -t nat +fi + +# 2. Build the set of allowed IPs, since iptables matches IPs rather than domain names. +ipset create allowed-domains hash:net + +# GitHub publishes its IPv4 ranges for web, API and git (SSH); merge adjacent ranges before adding. +curl -fsS https://api.github.com/meta \ + | jq -r '(.web + .api + .git)[] | select(contains(":") | not)' \ + | aggregate -q \ + | xargs -L 1 ipset add allowed-domains + +# Other destinations: resolve each domain once at startup and add its IPv4 addresses. +for domain in "${allowed_domains[@]}"; do + if ! ips="$(dig +short A "${domain}" | grep -E '^[0-9.]+$')"; then + echo "Failed to resolve ${domain}" >&2 + exit 1 + fi + + xargs -L 1 ipset add -exist allowed-domains <<<"${ips}" +done + +# 3. Allow local traffic: loopback, DNS, and the compose network that holds `db` and the host gateway. +host_network="$(ip route | awk '/^default/ {print $3}' | sed 's/\.[0-9]*$/.0\/24/')" + +iptables -A INPUT -i lo -j ACCEPT +iptables -A OUTPUT -o lo -j ACCEPT +iptables -A OUTPUT -p udp --dport 53 -j ACCEPT +iptables -A INPUT -s "${host_network}" -j ACCEPT +iptables -A OUTPUT -d "${host_network}" -j ACCEPT + +# 4. Allow replies and the allowed set, then reject everything else. +iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT +iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT +iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT +# REJECT rather than DROP so a blocked request fails immediately instead of timing out. +iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited +iptables -P INPUT DROP +iptables -P FORWARD DROP +iptables -P OUTPUT DROP + +# 5. The allowed set is IPv4 only, so close IPv6 except loopback. +ip6tables -F +ip6tables -A OUTPUT -o lo -j ACCEPT +ip6tables -P OUTPUT DROP + +# 6. Verify that an unlisted destination is blocked. +if curl -fsS --connect-timeout 5 https://example.com >/dev/null 2>&1; then + echo 'Firewall check failed: example.com is reachable' >&2 + exit 1 +fi + +echo 'Firewall configured' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f5df861f6..d50d82bac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -131,10 +131,6 @@ Claude Code と Codex は用途や利用可能な契約に応じて選択でき `docker compose exec web pnpm install` - `docker compose exec web pnpm exec playwright install` - - `docker compose exec web pnpm exec playwright install-deps` - `docker compose exec -e DATABASE_URL=postgresql://db_user:db_password@db:5432/test_db?pgbouncer=true&connection_limit=10&connect_timeout=60&statement_timeout=60000 -e DIRECT_URL=postgresql://db_user:db_password@db:5432/test_db web pnpm prisma db push` `docker compose exec web pnpm prisma generate` @@ -166,6 +162,8 @@ Claude Code と Codex は用途や利用可能な契約に応じて選択でき - `CONFIRM_API_URL` はローカル開発では不要です(連携済みユーザーはシードで作れます)。ホストの `.env` とシェルに設定しないでください。本物の値で確認するときだけ設定して Rebuild し、エージェントを使わずに確認後、値を外して再度 Rebuild します。 - ホストの VS Code のユーザー設定に `"dev.containers.gitCredentialHelperConfigLocation": "none"` を追加し、GitHub のトークンをコンテナに共有しないようにします。 +- コンテナ内の `sudo` はファイアウォール専用です。apt のパッケージや Playwright のブラウザは `Dockerfile` を変更して Rebuild します。 +- 外部通信は [init-firewall.sh](.devcontainer/init-firewall.sh) の許可リストに限られます。許可リストの宛先が突然つながらないときは CDN の IP が変わった可能性があるので、コンテナを Rebuild します。宛先の追加は、持ち出し経路が増えるため必要なものだけにします。 #### ホスト側で SSH の鍵を ssh-agent へ登録 @@ -195,10 +193,6 @@ Set-Service ssh-agent -StartupType Automatic; Start-Service ssh-agent `pnpm install` - `pnpm exec playwright install` - - `pnpm exec playwright install-deps` - `pnpm exec prisma db push` `pnpm dev` diff --git a/Dockerfile b/Dockerfile index 71d6c7413..4e7effe9a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /usr/src/app COPY . /usr/src/app RUN apt-get update \ - && apt-get -y install --no-install-recommends fish \ + && apt-get -y install --no-install-recommends fish iptables ipset dnsutils aggregate \ && rm -rf /var/lib/apt/lists/* # The container is the isolation boundary; managed settings disable the agents' nested sandboxes @@ -15,9 +15,17 @@ RUN apt-get update \ COPY .devcontainer/claude-managed-settings.json /etc/claude-code/managed-settings.json COPY .devcontainer/codex-managed-config.toml /etc/codex/managed_config.toml +# Limit sudo to the firewall so agents cannot undo it or the managed settings. +COPY --chmod=755 .devcontainer/init-firewall.sh /usr/local/bin/init-firewall.sh +RUN echo 'node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh' > /etc/sudoers.d/node \ + && chmod 0440 /etc/sudoers.d/node + ENV NODE_PATH=/node_modules ENV PATH=/home/node/.local/bin:$PATH:/node_modules/.bin +# `playwright install` cannot run later without sudo. +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright -RUN pnpm install +RUN pnpm install \ + && pnpm exec playwright install --with-deps chromium CMD ["pnpm", "dev"] diff --git a/compose.yaml b/compose.yaml index 1f48e0abb..057d8c420 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,7 +2,7 @@ services: web: build: . # No nested agent sandbox runs here, so Docker's default capabilities and profiles stay in place. - # NET_ADMIN is reserved for the planned egress firewall. + # NET_ADMIN is for .devcontainer/init-firewall.sh. cap_add: - NET_ADMIN ports: diff --git a/docs/dev-notes/2026-09-18/fix-bwrap-setuid/summary.md b/docs/dev-notes/2026-09-18/fix-bwrap-setuid/summary.md deleted file mode 100644 index 8b479e22f..000000000 --- a/docs/dev-notes/2026-09-18/fix-bwrap-setuid/summary.md +++ /dev/null @@ -1,141 +0,0 @@ -# devcontainer 内で Claude / Codex のサンドボックスが全滅した件 - -Issue: https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/4059 -Commit: 40450ec7(旧 `plan.md` / `survey.md` / `learning.md` を本ファイルに統合。原文は同コミットで参照可) - -## 症状と発端 - -- Claude / Codex の Bash がすべて `bwrap: setuid use of bubblewrap is not supported` で失敗し、`git commit` もできない。 -- 発端: DSA-6472-1(2026-08-27、CVE-2026-87766 修正)で Debian trixie に bubblewrap 0.12.0 が入った。0.12.0 は setuid 対応を削除しており、`acquire_privs()` が実 UID ≠ 実効 UID を検知して即終了する。[1][2][3] -- `Dockerfile` は `apt-get install bubblewrap` で版を固定していないため、再ビルドした日によって 0.11.x(setuid で動く)か 0.12.0 かが変わる。実環境で `bwrap --version` = 0.12.0、Debian 13.6 を確認(2026-09-19)。 -- `allowUnsandboxedCommands: false`(Strict sandbox mode)なので、bwrap が起動できないとサンドボックス外で再実行する逃げ道がなく、Bash が全滅する。[4] -- #4034(2026-09-13 マージ)の時点で動いていたのは、DSA 配信前にビルドしたイメージ/キャッシュが残っていたためと推測(未確認)。 - -## 障害の連鎖と対応する修正 - -setuid の削除で、それまで先頭の失敗に隠れていた問題が 1 つずつ表に出た。 - -| # | 表に出たエラー | 原因 | 修正(40450ec7) | -| --- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -| 1 | `setuid use of bubblewrap is not supported` | 0.12.0 が setuid を拒否 | `Dockerfile` の `chmod u+s /usr/bin/bwrap` を削除 | -| 2 | Claude: `Can't mount proc on /proc: Operation not permitted` | setuid が暗黙に回避していた `/proc` マウント制約(下記) | `.claude/settings.json` に `enableWeakerNestedSandbox: true` | -| 3 | Codex: `Can't write data to file : Bad file descriptor` | openai/codex#43929(ファイル単位の deny が 2 つ以上で起動失敗) | 認証情報の deny をディレクトリ単位へ。ファイル単位は `.env` の 1 つだけ | -| 4 | Codex: `Can't mount proc on /proc` | #2 と同じ制約 + Codex の代替手段が新しいエラー文言を検知しない(#44304 / #44329) | `compose.yaml` に `security_opt: systempaths=unconfined` | -| 5 | サンドボックス内の `pnpm` が `Read-only file system` | コンテナの pnpm と `packageManager` の版ずれ → pnpm が指定版をダウンロードしようとする | `setup-devcontainer.sh` で `packageManager` と同じ版を入れる | - -### #2 / #4: setuid が回避していた `/proc` の制約 - -- user namespace 方式では、既存の `/proc` がすべて見えている場合にしかカーネルは新しい procfs のマウントを許さない。Docker は `/proc/kcore` などをマスクしているので EPERM になる。 -- setuid 方式ではコンテナの root(`SYS_ADMIN`)で動くため、この制約を受けなかった。つまり setuid は「コンテナ内で `/proc` をマウントする」回避策を兼ねていた。 -- Claude: 公式 Troubleshooting の対処そのもの("the inner sandbox bind-mounts the container's existing `/proc` instead")。注意書き "considerably weakens security and should only be used when additional isolation is otherwise enforced" は、コンテナが外側の隔離境界になるので許容。[4][6] -- Codex: 公式 secure devcontainer は setuid 前提で、`--proc` が拒否されたら `--proc` なしで再試行する設計。[9] だが失敗判定が旧文言 `/newroot/proc` の文字列一致なので、0.12.0 では再試行されない。[8][10] Codex 側に設定で逃げる手段がないため、Docker のマスク自体を外した。 -- `enableWeakerNestedSandbox` は `systempaths=unconfined` 導入前に入れたもので、「効果を確認できるまで残す」としている。現状では不要かもしれない(未検証)。 - -### #3: openai/codex#43929 の実際の数え方 - -- 「deny に一致するファイルが 2 つ以上で必ず失敗、ディレクトリなら動く」。完全パスかワイルドカードかによらない。0.155.1 でも未修正。[5] -- 実測で分かったこと: - - 数える範囲はワークスペース内ではなく Codex の設定全体(`~/.claude/.credentials.json` で失敗した)。 - - ワイルドカードを含まない完全パス(例 `".env.local"`)は、実在しなくても 1 つに数えられる。 - - ワイルドカードのパターンは実在ファイルにのみ一致すると考えられる(未検証)。 - - `".env"` と `"**/.env"` を同じファイルとして 2 回数えるかは未確認。安全側で `".env"` だけにした。 -- 設定は公式ドキュメント推奨の書き方どおりで、設定ミスではなく Codex のバグ。[7] - -### #5: pnpm の版ずれ - -- pnpm 11 以降は `pmOnFail: download` が既定で、版がずれると指定版を自動ダウンロードする。[12] サンドボックス外では黙って成功するため気づかなかった。 -- ベースイメージの pnpm は 12.3.4、`packageManager` は Renovate により 09-13 に 12.4.1、09-18 に 12.4.2。ずれ始めた 09-13 は Bash 全滅の時期と重なり、サンドボックス復旧まで表に出なかった。 -- 版を `package.json` から読むので、Renovate が上げても次のリビルドで追従する(2 か所管理にしない)。 - -## 意思決定 - -### 守るもの(固定) - -- `.env`、`~/.claude/.credentials.json`、`~/.codex/auth.json` を Claude / Codex の両方から読めないこと。これを削る案(`.env` の deny 解除、`default_permissions` を外す等)は採らない。 - -### deny 設定の最終形とトレードオフ - -- Codex: `"~/.codex"` / `"~/.claude"` をディレクトリで deny、ファイル単位は `".env"` のみ。`**/.env` と `.env.*` 系は外した。 -- Claude: `**/.env.*` 系を外し `**/.env` のみ(Codex と集合を揃えるため)。Claude 自体には #43929 の制約はない。 -- 失うもの: - - サブディレクトリの `.env` と、`.env.local` などは deny から外れる(現状は存在しない)。置くときは deny を足し直す必要があり、#43929 が未修正なら Codex が再び起動しなくなる。 - - `**/*.pem`、`**/*.key`、`**/secrets/**`、`**/config/credentials.json` に一致するファイルが 1 つでも置かれると #43929 に当たる。 - - `~/.codex` の deny で、サンドボックス内から `~/.codex` 配下(グローバル AGENTS.md、skills)が読めなくなる可能性。問題が出たら `"~/.codex"` だけ外す。 - -### `systempaths=unconfined` のリスク評価 - -- Docker の説明は "Turn off confinement for system paths (masked paths, read-only paths)"。[11] コンテナ内の全プロセスで、`/proc/kcore` 等のマスクと `/proc/sys`、`/proc/sysrq-trigger` の読み取り専用が外れる。 -- userns-remap がないため、コンテナ内 root は VM カーネルを直接操作できる。 - -| 新たにできること(root の場合) | 実害 | -| -------------------------------- | ----------------------------------------------------------- | -| `/proc/sysrq-trigger` に書き込む | VM を即座に再起動・停止。OrbStack の全コンテナが止まる | -| `/proc/sys` に書き込む | VM のカーネル設定変更。他コンテナの保護も弱められる | -| `/proc/kcore` を読む | VM のカーネルメモリ読み出し。他コンテナの秘密情報が漏れうる | - -- 範囲: OrbStack の Linux VM とその中のコンテナまで。Mac 本体のファイルには届かない。 -- 誰が: `node` はパスワードなし `sudo` が使えるので、サンドボックス外で動くコード(悪意ある postinstall など)。サンドボックス内のコマンドは bwrap が `sudo` 昇格を防ぐので通らない。 -- 判断: 既存の `SYS_ADMIN` / seccomp・apparmor unconfined で root は元々これらを外せるため、新たな能力はほぼ増えず攻撃の手間が 1 段減るのみ。ただしこれは緩和を重ねる理由ではないので、単体で実害を評価した上で「同じ VM に重要なコンテナを同居させない」前提で許容する。この前提はチームに共有する。 -- フォールバック: それでも `/proc` エラーが出るなら戻して上流の修正を待つ。 - -### 却下した代替案 - -| 案 | 却下理由 | -| ----------------------------------------------------------- | ------------------------------------------------------------------------- | -| `allowUnsandboxedCommands: true` / `sandbox.enabled: false` | 原因を直さず防御を外すだけ。`denyRead` による認証情報の保護も効かなくなる | -| bubblewrap を 0.11.x に固定 | 廃止済みの setuid 方式の延命。CVE-2026-87766 の修正も受けられない [3] | -| `.env` を deny から外して Codex を動かす | 守るものを削る本末転倒 | -| Codex の deny を残して上流の修正を待つ | 修正の目処がなく、その間 Codex が起動しない | -| サンドボックスに pnpm キャッシュへの書き込みを許可 | サンドボックスを緩め、pnpm 本体を書き換えられる余地が生まれる | -| `Dockerfile` に pnpm の版を直書き | `package.json` と 2 か所管理になり、Renovate の更新でまたずれる | -| `pmOnFail: ignore` | `packageManager` による版の固定が効かなくなる | - -## 運用上の注意 - -- Codex の設定を変えたら、VS Code 拡張が起動した app server も再起動する。CLI は常駐 app server に接続するだけで設定を読み直さない(今回、03:04 起動の古い app server が古い deny のまま動いていた)。 -- 上流の修正(#43929、#44304 / #44329)が入ったら、ディレクトリ単位の deny、`systempaths=unconfined`、`enableWeakerNestedSandbox` の要否を見直す。 - -## 振り返り - -### 紆余曲折の根本原因 - -修正は 1 コミットだが、到達までに Phase 3 → 3b → 3c → 3d → 3e と 5 回方針を出し直した。原因は次の 3 つに集約される。 - -1. setuid が暗黙に担っていた役割を洗い出さずに外した。 - - setuid が `/proc` マウントの回避策を兼ねていたことを見落とし、Claude / Codex の `/proc` 問題を別々に扱った。そのため `systempaths=unconfined` を Claude 用に却下 → Codex 用に採用という往復が起きた。#44304 は Phase 3c の時点で「次に当たる可能性」と把握していたのに先送りした。 -2. 一次資料を読み切らず、観測したエラーから仮説を狭く立てた。 - - #43929 の条件(ファイル単位の deny が 2 つ以上で失敗、ディレクトリは可)を「ワークスペース内の実在ファイルが 2 つ以上」と読み替えたため、`.env.example` → `.env.local` → `~/.claude/.credentials.json` と 3 回に分けて潰すことになった。 -3. 検証環境が変更を反映しているか確認しなかった。 - - 「CLI を再起動すれば反映される」と説明し、常駐 app server が古い設定のまま出したエラーで判断した回があった。 - -背景として、先頭の失敗が後ろの問題をすべて隠す構造(Strict sandbox mode で Bash が全滅 → Codex のバグと pnpm の版ずれが見えない)があり、1 つ直すたびに次の層が現れた。これ自体は避けられないが、上の 1〜3 がなければ往復は 1〜2 回で済んだ。 - -### 判断の誤り(結果には残っていないもの) - -- `.env` を deny から外す案を推した: 動かすことを優先して目的(秘密情報の保護)を損ねる本末転倒。 -- 「すでに緩めてあるから」を理由に `systempaths=unconfined` のリスクを小さく見積もった: 既存の緩和は追加の緩和の根拠にならない。 - -### 教訓 - -- 権限まわりの設定(setuid、capability、security_opt)を外すときは、それが暗黙に回避していた制約を先に洗い出す。 -- エラーが連鎖しそうなときは、既知の上流 issue をまとめて確認し、方針を 1 回で決める。 -- 上流 issue を根拠にするときは、報告された条件をそのまま採用し、自分の観測で狭めない。 -- 設定変更が効かないときは、その設定を読むのが常駐プロセスかを確かめ、プロセスの起動時刻と設定の更新時刻を比べる。 -- バージョン未固定のパッケージは、再ビルドした日によって挙動が変わる。「以前は動いた」ときは、まず実環境の版を確認する(`bwrap --version`)。 -- 回避策を選ぶ前に守るもの(今回は `.env` と認証情報)を固定し、それを削る案は候補から外す。 -- セキュリティの緩和は、既存の緩和を根拠にせず、単体で実害(誰が・何を・どこまで)を書き出して判断する。 - -## 出典 - -1. bubblewrap releases(0.11.2 で setuid 非推奨化・CVE-2026-41163、0.12.0 で削除): https://github.com/containers/bubblewrap/releases -2. bubblewrap source(`acquire_privs()` の `die ("setuid use of bubblewrap is not supported")`): https://github.com/containers/bubblewrap/blob/main/bubblewrap.c -3. Debian security tracker: https://security-tracker.debian.org/tracker/DSA-6472-1 / https://security-tracker.debian.org/tracker/CVE-2026-87766 / https://security-tracker.debian.org/tracker/CVE-2026-41163 -4. Claude Code sandboxing(Troubleshooting「Bubblewrap fails to start inside a container」、Strict sandbox mode): https://code.claude.com/docs/en/sandboxing -5. openai/codex#43929(deny に一致するファイルが 2 つ以上で起動失敗): https://github.com/openai/codex/issues/43929 -6. Claude Code settings reference(`sandbox.enableWeakerNestedSandbox`): https://code.claude.com/docs/en/settings-reference -7. Codex Permissions(deny の完全パスとワイルドカード、`:workspace_roots`): https://learn.chatgpt.com/docs/permissions -8. openai/codex#44304(0.12.0 で `/proc` のエラー文言が変わり代替手段が働かない): https://github.com/openai/codex/issues/44304 -9. openai/codex PR #17547(secure devcontainer、`--proc` なしで再試行): https://github.com/openai/codex/pull/17547 -10. openai/codex#44329(CLI と VS Code 拡張が `/proc` マウント失敗を扱えない): https://github.com/openai/codex/issues/44329 -11. Docker `docker container run`(`--security-opt systempaths=unconfined`): https://docs.docker.com/reference/cli/docker/container/run/ -12. pnpm 11.0 release notes(`pmOnFail`): https://pnpm.io/blog/releases/11.0 diff --git a/docs/dev-notes/2026-09-19/secret-free-devcontainer/learning.md b/docs/dev-notes/2026-09-19/secret-free-devcontainer/learning.md deleted file mode 100644 index d52293d36..000000000 --- a/docs/dev-notes/2026-09-19/secret-free-devcontainer/learning.md +++ /dev/null @@ -1,26 +0,0 @@ -# Learning: devcontainer でエージェントのサンドボックスを外すときの判断 - -## 問題 - -- 症状: 計画の「apt の bubblewrap を外せば入れ子のサンドボックスは消える」という前提が、Codex では成り立たなかった。 -- 根本原因: Codex は `bwrap` がないと同梱の `codex-resources/bwrap` を使う。サンドボックスを止めるには、パッケージの削除ではなく設定(`/etc/codex/managed_config.toml` の `default_permissions = ":danger-full-access"`)が必要だった。 - -## 有効だったアプローチ - -- ドキュメントの記述を、インストール済みの実物で裏付けた(`find .../@openai/codex -name 'bwrap*'` で同梱の bwrap を確認し、`strings` で setuid 非対応のビルドだと確認した)。 -- 「同梱の bwrap なら動くか」は、bwrap の版ではなくコンテナの制限(既定の seccomp / AppArmor が `pivot_root` やマウントを拒否する)で決まる、と一次資料(openai/codex#17547、Docker の seccomp のドキュメント)から切り分けた。これで、どの bwrap を使っても権限の緩和が必要、と結論できた。 -- 過去の障害を、原因がバイナリにあるものと、コンテナや Codex 本体にあるものの表に分けた。「システムの bwrap が悪かったのでは」という問いに、項目ごとに答えられた。 -- 上書きの優先順位のように手元で検証できない点は、「未検証、Rebuild 後に確認」と plan.md に明記した。 - -## ハマった点 - -- 計画を覆す選択肢(「Codex は現状を維持」)を、推奨案と並べて提示した。ユーザーが混乱し、「なぜ覆そうとしているのか」と問われた。 -- `danger-full-access` を説明するとき、「承認のプロンプトは残る」と書いた。しかし、サンドボックスがなければ `on-request` で昇格を求める場面はほぼなく、実際にプロンプトが出る頻度は下がる。 -- エージェントのサンドボックス内では、DB(`db:5432`)への接続も `tsx` の IPC ソケットの作成もできず、`pnpm db:seed` を検証できなかった。 - -## 教訓 - -- ツールのサンドボックスを「依存パッケージを外す」ことで無効にしようとするときは、そのツールが代替のバイナリを同梱していないかを先に確認する。 -- サンドボックスが動くかどうかは、バイナリの版より、実行環境が許すシステムコールと LSM(seccomp / AppArmor)で決まることが多い。どちらが原因かを先に切り分ける。 -- 承認済みの計画の方針に反する案は、選択肢に並べない。どうしても出すなら、「計画を覆す案」だと明記する。 -- エージェントのサンドボックス内で検証できない手順(DB、ネットワーク、`/etc` への書き込み)は、早めに見極めて、ユーザーに実行を依頼するコマンドを提示する。 diff --git a/docs/dev-notes/2026-09-19/secret-free-devcontainer/plan.md b/docs/dev-notes/2026-09-19/secret-free-devcontainer/plan.md index 704191650..a676c1811 100644 --- a/docs/dev-notes/2026-09-19/secret-free-devcontainer/plan.md +++ b/docs/dev-notes/2026-09-19/secret-free-devcontainer/plan.md @@ -1,292 +1,85 @@ # Plan: devcontainer から秘密を排除し、入れ子のサンドボックスを撤去する -前回の経緯: [summary.md](../../2026-09-18/fix-bwrap-setuid/summary.md) +## 経緯(深刻だったもの) -## 概要 +#4034 で導入したエージェントのサンドボックスの構成で、次の問題が分かった。 -- 方針: 「エージェントに秘密を読ませない」を、ツールごとのサンドボックスではなく「コンテナに秘密を置かない」ことで実現する。 -- これにより bubblewrap、コンテナの権限緩和(`SYS_ADMIN` など)、`/proc` の回避策が不要になり、障害と重さ(コマンドごとの glob 走査、約 1 秒)の原因がまとめて消える。 -- 要件(ユーザー決定): - - `.env` の本番の値を LLM のベンダーに渡さない。 - - エージェントは push しない。ただし人間の push の手間は増やさない(D1)。 - - 外部通信は許可リストのみ。各ツールのテレメトリは送らない。 - - Claude / Codex の両方を使い続ける(特定ベンダーに依存しない)。Windows / WSL のメンバーがいるため、OS に依存しない構成にする。 +1. **エージェントのコマンドが全く動かなくなった(2026-09-18)** + - Debian のセキュリティ更新で bubblewrap が 0.12.0 になり、setuid での起動が廃止された。[4] version を固定していなかったため、コンテナを作った日によって動作が不安定に。 + - 直そうとすると、コンテナのセキュリティ制限を次々に緩める必要があった(コンテナの中でさらにサンドボックスを動かす「入れ子」は、そもそも相性が悪い)。 +2. **サンドボックスがあっても、秘密は守れていなかった** + - 本番の秘密 `CONFIRM_API_URL` は、compose がコンテナの環境変数として必須で注入していた。`printenv` を実行すれば誰でも読め、サンドボックスでは防げていなかった。 + - VS Code が GitHub のトークンをコンテナに共有しており、`git credential fill` で取り出せた。 +3. **エージェントが何でもできる権限を持っていた** + - ベースイメージが `node` ユーザーにパスワードなしの `sudo`(管理者権限)を与えていた。エージェントも設定を書き換えたり、ファイアウォールを外したりできた。 -## 調査で判明した事実 +## 方針: コンテナに秘密を置かず、外向きの通信は許可リストで絞る -- 本番の秘密は `CONFIRM_API_URL` の 1 つだけ。使うのは [atcoder_verification.ts:12](../../../../src/features/account/services/atcoder_verification.ts) のみ(`/users/edit` の連携確認)。 -- **`compose.yaml` が `CONFIRM_API_URL=${CONFIRM_API_URL:?...}` でコンテナの環境変数に注入している。** そのため、`printenv` でエージェントから常に読めた。`.env` の deny もサンドボックスも、これは防いでいなかった。 -- `:?` で必須のため、値がないとコンテナが起動しない。これが「全員が本物の値を持つ」運用の直接の原因。 -- シード(`prisma/seed.ts`)は連携済みの `AtCoderAccount` を作らない。そのため、投票などの連携済みユーザー向けの機能をローカルで試すには、本物の値での連携が必要だった。 -- 本物の値は、単体テスト(`vi.stubEnv` + `fetch` のモック)、e2e、CI のいずれでも使っていない。 -- e2e の `votes.spec.ts` は `guest` でログインし、未連携なら skip している。 -- bubblewrap とコンテナの権限(`SYS_ADMIN`、`SYS_PTRACE`、seccomp・apparmor の無効化)は、2026-09-12 の Codex 導入時(c276af73)に入った。それ以前の Claude のサンドボックスは、bubblewrap がなく実質的に動いていなかった可能性が高い(未検証)。 -- `/etc/gitconfig` に VS Code の git credential helper が設定されている。コンテナ内のどのプロセスも、`git credential fill` で GitHub の認証情報を取得できる。 -- 公式の devcontainer の立場: "dev containers do not prevent a malicious project from exfiltrating anything accessible inside the container" "Avoid mounting host secrets"。[1] +コンテナ全体を境界とし、コンテナ内ではエージェントのサンドボックスを使わない。 -## 設計判断 +### 本番の値はローカルに置かない -### 本番の値は普段ローカルに置かず、Vercel で管理する +- `CONFIRM_API_URL`(AtCoder アカウント連携の確認にだけ使う)は、既定でコンテナに入れない。連携確認のボタンが失敗するだけで、ほかの機能には影響しない。 +- 連携済みユーザーは、シード(`pnpm db:seed`)で作る(`admin`、`guest`)。本物の値での確認が必要なときだけ、エージェントを使わずに行う。 +- 値そのものは変更しない。返すのは AtCoder の公開情報で、本人確認は URL の秘匿に依存しないため。大量アクセスへの対策は、エンドポイント側で別途行う。 -- 本番と staging(Preview)の値は、Vercel の環境変数で管理する。 -- ローカルでは `CONFIRM_API_URL` を既定で未設定にする。連携確認のボタンは `Failed to validate AtCoder account.` を返すだけで、ほかの機能には影響しない。 -- 連携の手順そのものの確認は、普段は単体テスト(既存のモック)と staging で行う。 -- ローカルで本物の値を使った確認が必要なときは、例外として明示的に注入できるようにする(Phase 2)。その間はエージェントを使わない運用で守る。 +### エージェントのサンドボックスはコンテナ内だけ無効にする -### シードで連携済みアカウントを作る +- プロジェクトの設定は、コンテナを使わないホストでも効くため残す。 +- bubblewrap のパッケージを外すだけでは不十分だった。Codex は自前の bwrap を同梱しており、それを使うため。 -- 連携済みの状態が必要な機能を、本物の値なしでローカルで試せるようにする。これで本物の値を必要とする人がほぼいなくなる。 -- 既存の DB にも反映されるよう、ユーザーの作成とは別に upsert する(既存の `addUsers` は、登録済みのユーザーをスキップするため)。 +### コンテナに残る秘密は、エージェント自身のログイン情報だけ -### エージェントのサンドボックスは devcontainer の中でだけ無効にする +- エージェントの push はルールで禁止し、`staging` / `main` はブランチ保護で守る。人間の push の手間は増やさない。ルールだけではエージェントによる push を技術的には防げない。 +- Claude と Codex のログイン情報はコンテナに残り、エージェントのコマンドから読める。この残余リスクを受け入れ、送信先を絞る。 -- `.claude/settings.json` と `.codex/config.toml` はリポジトリで共有しており、ホスト(Mac の Seatbelt、WSL の bubblewrap)でもそのまま適用される。そのため、プロジェクトの設定は残す。 -- コンテナ内だけ、優先順位が最も高い managed settings で無効にする。 - - Claude: `/etc/claude-code/managed-settings.json` を Dockerfile で配置する(公式の devcontainer の手順どおり)。[1] - - Codex: 同等の managed config を配置する。仕組みと優先順位は Phase 3 の最初に検証する。 +### 外部通信の許可リスト -### コンテナに残る秘密は、エージェント自身の認証トークンだけにする - -- `.env` と GitHub のトークン(https の credential helper)をコンテナから外す。SSH は agent forwarding のため、鍵そのものはコンテナにない。 -- `~/.claude/.credentials.json` と `~/.codex/auth.json` は、各ツールの動作に必要なので残る。互いのトークンを読めてしまう点は、送信先を最小限に絞ったうえで受け入れる(D3)。 - -### push はコンテナ内のまま、エージェントはルールで禁止する(D1) - -- 人間はコンテナ内の VS Code とターミナルから、承認なしで普段どおり push する。 -- エージェントの push は、ルールで禁止する(Claude の `Bash(git push *)` の deny、AGENTS.md)。仕組みでの強制はしない。 -- 根拠: コンテナから秘密を外せば、push で持ち出せるものがほぼない(リポジトリは公開)。勝手な push の実害は不要なブランチ程度で、`staging` / `main` はブランチ保護で守る(設定済みであることをユーザーが確認済み)。 -- 残るリスク: 悪意ある指示を受けたエージェントがルールを回避して push し、エージェントの認証トークンを公開の場所へ書き込む可能性。D3 と同種のリスクとして受け入れる。 - -### `CONFIRM_API_URL` の値は変更しない(D2) - -- これまで全メンバーのコンテナの環境変数に注入されており、エージェントや LLM のベンダーに渡った可能性は否定できない。ただし漏れたときのリスクは低いと判断した。 - - 機密性: 返すのは AtCoder の所属欄で、`user` に渡すユーザー名も AtCoder のランキングで公開されている。 - - 完全性: 本人確認は「所属欄に検証コードを書けるのは本人だけ」で成り立っており、URL の秘匿には依存しない。GET のみで書き換えの経路もない。 - - 可用性(唯一の論点): 公開のユーザー名を列挙して大量に GET されると、クローラーが AtCoder に遮断されたり、実行回数の上限を使い切られたりしうる。DDoS などにならなければ一旦許容する。 -- 対応: 値の変更ではなく、エンドポイント側にキャッシュか流量制限を足す(値を変えても、次に漏れれば同じことが起きるため)。現状はどちらもない(ユーザー確認済み)。エンドポイントは本リポジトリの外にあるため、本計画とは別のタスクとして扱う。 -- 「LLM のベンダーに渡さない」方針は、リスクの大小とは別の原則として維持する(Phase 2 / 4 は変更しない)。 - -### エージェントの認証トークンが互いに読める点は受け入れ、送信先を最小限に絞る(D3) - -- コンテナを分けずに受け入れる。代わりに Phase 5 の許可リストを最小限にする。 -- 許可するのは、各ツールの公式ドキュメントが必須とする推論と認証の宛先、および開発に必要な宛先だけ。「あると便利」な宛先は入れない。 -- 限界: 許可した宛先を経由した持ち出しは防げない。例えば Claude が `~/.codex/auth.json` を読めば、その内容は Anthropic に渡る。また iptables は IP で判定するため、同じ IP を共有するサービス(github.com の repo と gist など)は区別できない。 +- Anthropic の公式リファレンスの `init-firewall.sh` を元にし、コンテナの起動のたびに適用する。[1] +- 各ツールの任意機能の宛先 [2] と、`api.openai.com`(Codex を API キーで使うときだけ必要)[5] は入れない。 +- テレメトリは、各ツールの設定でも止める(遮断だけだと再送で遅くなる)。 +- CDN の IP 変更で許可先につながらなくなった場合は Rebuild する。稼働中の `init-firewall.sh` 再実行は、既存の `OUTPUT DROP` 規則を消した後に GitHub から IP 一覧を取得できず、通信不能になるため行わない。 +- 受け入れた制約: + - Claude の Remote Control が使えない(テレメトリを止める設定が、その機能も止めるため)。[6] + - Codex の利用状況の送信先は推論と同じ `chatgpt.com` で、ファイアウォールでは止められない。設定でだけ止めている。[3][7] ## 却下した代替案 -| 案 | 却下理由 | -| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 入れ子のサンドボックスを維持(現状) | 障害と重さの原因そのもの。しかも環境変数経由の露出を防げていなかった | -| エージェントを各自の OS で直接動かす | Windows ネイティブは Claude のサンドボックスに非対応。WSL の Codex では #43929 を再び踏む。Mac とコンテナで `node_modules` のネイティブバイナリが衝突する | -| dotenvx | 秘密鍵を持つ人がほぼいなくなるため、利点が小さい。公開リポジトリに本番の暗号文が永久に残る。秘密が増えたら再検討する | -| Infisical / mise / direnv | Infisical はログイン済みのセッションからエージェントも取得でき、1 変数には過剰。mise / direnv はシェルの環境変数経由でエージェントに引き継がれる | -| `.env` を使うときだけ置く(普段の運用として) | 置いている間は読める。外し忘れで元に戻る。例外時の手段としてだけ、警告付きで残す(Phase 2) | -| push はホストから行う | 作業環境が二つに分かれ、VS Code の Source Control から push できない。Windows / WSL のメンバーはホスト側にも git と鍵の準備が必要 | -| SSH エージェントで署名のたびに人間が承認する | push のたびに承認が必要になり、開発の流れが止まる | -| 開発サーバとエージェントのコンテナを分ける | compose の再設計が大きい。ローカルに本物の値が不要になれば、分ける理由がない | - -## Phase 0: `origin` を本来のリポジトリ(SSH)に戻す(低リスク・手作業) - -- 経緯: SSH の push が `Permission denied (publickey)` で失敗したとき、VS Code が fork を作って remote を繋ぎ替えた(2026-09-19 05:58)。 - - 元のリポジトリ(SSH)は `upstream` に改名された。 - - `origin` は fork(`https://github.com/KATO-Hiro/AtCoderNoviSteps.git`)に変わった。 - - `#4059` ブランチは fork に push された(05:43)。 -- 変更: `origin` の fetch / push を、どちらも `git@github.com:AtCoder-NoviSteps/AtCoderNoviSteps.git` にする。`upstream` は `origin` と重複するため削除する。 - -```bash -git remote set-url origin git@github.com:AtCoder-NoviSteps/AtCoderNoviSteps.git -git remote remove upstream -git fetch --prune origin -``` - -- `.git/config` は各自のクローンのローカル設定で、リポジトリの変更ではない。ユーザーがコンテナ内のターミナルで実行する(エージェントのサンドボックスからは `.git/config` を書けない可能性がある)。 -- 前提: ホストの ssh-agent に鍵が登録されていること(CONTRIBUTING の「ホスト側で鍵を ssh-agent へ登録」)。コンテナ内で `ssh-add -l` と `ssh -T git@github.com` が通ることを先に確認する。 -- 後片付け(ユーザーの判断): fork 側の `#4059` ブランチと fork 自体が不要なら、GitHub 上で削除する。`#4059` は `git push -u origin '#4059'` で本来のリポジトリに push し直す。 -- 確認: `git remote -v` の fetch / push がどちらも `git@github.com:AtCoder-NoviSteps/AtCoderNoviSteps.git` で、`upstream` がないこと。 - -## Phase 1: シードに連携済みアカウントを追加する(低リスク) - -- レイヤー: DB のシード(`prisma/`) -- 変更: `prisma/users.ts` に、任意の `atCoderHandle` を追加する。`prisma/seed.ts` に、連携済みの `AtCoderAccount` を upsert する処理を追加する。 - -```typescript -// prisma/users.ts -export const users = [ - { id: '1', name: 'admin', role: Roles.ADMIN, atCoderHandle: 'novisteps_admin' }, - { id: '2', name: 'guest', role: Roles.USER, atCoderHandle: 'novisteps_guest' }, - // Other users stay unverified so the unverified path remains testable. -]; - -// prisma/seed.ts -async function addAtCoderAccounts(): Promise; -async function addAtCoderAccount(userId: string, handle: string): Promise; // upsert with isValidated: true -``` - -- 連携済みにするのは e2e で使う `admin` と `guest`。未連携の画面を確かめられるよう、ほかのユーザーは未連携のまま残す。 -- ハンドルは、実在の AtCoder ユーザーと衝突しない明らかな架空名にする(`novisteps_` 接頭辞)。 -- 影響: `votes.spec.ts` の「ログイン済みユーザー」のテストが skip されずに実行されるようになる。失敗した場合は、このフェーズで原因を切り分ける。 -- テスト: シードには単体テストがなく、分岐も持たないデータ追加のため、テストファーストは省略する。`pnpm db:seed` を 2 回実行して冪等性を確認し、`pnpm test:e2e` で votes を確認する。 - -## Phase 2: `CONFIRM_API_URL` を既定でコンテナに入れない(低リスク) - -- レイヤー: 開発環境の設定 -- `compose.yaml`: 必須(`:?`)から任意(`:-`)に変える。行を削除すると、ローカルで本物の値を使った確認ができなくなるため残す。 - -```yaml -- CONFIRM_API_URL=${CONFIRM_API_URL:-} # Unset by default; set only for a real verification session -``` - -- `.env.example`: 値の行をコメントアウトし、「ローカル開発では不要。ローカルで本物の値を使った確認をするときだけ設定し、終わったら外す」と書く。 -- `.devcontainer/setup-devcontainer.sh`: `CONFIRM_API_URL` が設定されていたら、「本物の値が注入されています。エージェントを使わず、確認後は値を外して Rebuild してください」と警告する。compose はホストの `.env` を変数の置き換えに自動で読むため、消し忘れた値が黙って注入され続けるのを防ぐ。 -- ローカルで本物の値を使って確認する手順(例外時): - 1. ホストで値を設定する(シェルの環境変数か `.env`)。 - 2. Rebuild する。 - 3. エージェントを使わずに確認する。 - 4. 値を外して、もう一度 Rebuild する。 -- 確認: - - 値なしでコンテナが起動し、`printenv CONFIRM_API_URL` が空になる。 - - `/users/edit` の連携確認が `Failed to validate AtCoder account.` を返す。 - - 値を設定して Rebuild すると、注入され、警告が表示される。 -- 手作業(ユーザー): - - Vercel の Production と Preview に `CONFIRM_API_URL` が設定されていることを確認する。 - - メンバーに、ホストの `.env` とシェルの環境変数から値を削除するよう依頼する。 - -## Phase 3: bubblewrap とエージェントのサンドボックスをコンテナ内で撤去する(中リスク) - -- レイヤー: 開発環境の設定(イメージ、compose、エージェントの設定) -- 最初に検証すること: Codex の managed config の配置場所と、プロジェクトの `.codex/config.toml` との優先順位。確認できない場合は、代わりの手段(CLI 引数、`CODEX_HOME` の設定)を比べてから進める。 - - 結果(2026-09-19): 公式ドキュメント上、Linux は `/etc/codex/managed_config.toml` で、プロジェクトの設定より優先される。プロジェクトが権限プロファイル(`default_permissions`)を使っており、`sandbox_mode` とは併用できないため、`default_permissions = ":danger-full-access"` で指定する。実際に上書きされるかは、エージェントのサンドボックスからは `/etc/codex` に書けないため未検証。Rebuild 後に確認する。 - - apt の `bubblewrap` を外すだけでは不十分: Codex は `bwrap` がないと同梱の `codex-resources/bwrap` を使う(README、実物も確認)。 - - 同梱の bwrap で Codex のサンドボックスを続ける案は却下: Docker の既定の seccomp / AppArmor は `pivot_root` やマウントを拒否し、bwrap の版によらず失敗する(openai/codex#17547)。続けるにはコンテナの権限の緩和が要り、本計画の目的と両立しない。 -- `Dockerfile`: - - `bubblewrap` のインストールを削除する。 - - Claude の managed settings(`sandbox.enabled: false`)と Codex の managed config(`default_permissions = ":danger-full-access"`)をコピーする。 -- `compose.yaml`: - - `cap_add` の `SYS_ADMIN`、`SYS_PTRACE`、`SETGID`、`SETUID`、`SYS_CHROOT` を削除する。後ろの 3 つは Docker の既定の capability に含まれる。 - - `security_opt` の 3 つを削除する。 - - `NET_ADMIN` は Phase 5 のファイアウォールで使うため、ここでは残す。 -- `.claude/settings.json`: `enableWeakerNestedSandbox` を削除する(ホストでは不要)。`permissions.deny` の Read と Bash のルールは、コストがほぼないので残す。 -- `.codex/config.toml`: 変更しない(ホスト向けの設定として残す)。 -- 確認: - - Rebuild Container 後、`command -v bwrap` が空になる。 - - Claude / Codex の CLI と VS Code 拡張の両方で、コマンドを実行できる。 - - `git commit` が数秒で終わる(lefthook 込み)。 - -## Phase 4: `.env` と GitHub の認証情報をコンテナから外す(中リスク) - -- レイヤー: 開発環境の設定 -- `.env` を空のファイルで覆い隠す(保険): - - `compose.yaml` の `volumes` に `./.devcontainer/empty.env:/usr/src/app/.env:ro` を追加する。 - - 覆い隠すのはコンテナ内からの読み取りだけで、compose がホスト側で `.env` を変数の置き換えに読むことは妨げない。そのため、Phase 2 の例外時の注入とは両立する。 - - Phase 3 で `SYS_ADMIN` を外しているため、コンテナ内の root でも覆いを外せない。 - - ホストに `.env` がない場合、マウント先として空の `.env` がホストに作られる(gitignore 済み)。 -- GitHub の認証情報(D1 で決定): - - push はコンテナ内で普段どおり、SSH agent forwarding で行う。署名だけを依頼する方式で、秘密鍵はコンテナに渡らない。 - - VS Code の https 用 git credential helper は止める。`git credential fill` で GitHub のトークンそのもの(push 以外の操作もできる)を取り出せるため。止める設定は各自のホストの VS Code 側にあると見込まれ、リポジトリからは強制できない。設定名を実装前に確認し、CONTRIBUTING で案内する。 - - `origin` は Phase 0 で SSH の URL に戻し済み(https のままだと、ヘルパーを止めた後に push できない)。 -- 確認: - - コンテナ内で `cat /usr/src/app/.env` が空になる。 - - `git credential fill` でトークンを取得できない。 - - コンテナ内の VS Code とターミナルから、承認なしで push できる。 - -## Phase 5: 外部通信の許可リスト(ファイアウォール)を導入する(高リスク) - -- 2026-09-19: 許可リストの洗い出しが大きいため、別の PR に分ける(ユーザー決定)。それまで D3 の送信先の制限はない。 - -- レイヤー: 開発環境の設定 -- Anthropic の公式リファレンスの `init-firewall.sh` を元に、コンテナの起動時に許可リスト以外への通信を遮断する。[2] -- `compose.yaml`: `NET_ADMIN` と `NET_RAW` を指定する。 -- 許可リストの候補: - - GitHub - - npm レジストリ - - Anthropic、OpenAI(推論と認証のみ。テレメトリとエラー報告の宛先は許可しない) - - VS Code のマーケットプレイスと更新 - - Playwright のブラウザ配布元 - - Prisma のエンジン配布元 - - アプリが開発中に呼ぶ外部 API - - 実際の一覧は、実装前にコードと各公式ドキュメントから洗い出す。D3 のとおり、公式に必須とされる宛先だけに絞る。宛先ごとに根拠(どのドキュメントか、どの機能が使うか)をスクリプトのコメントに残す。 -- テレメトリの拒否: 遮断に加えて、各ツールの設定でも送信を止める(遮断だけだと、送信の失敗や再試行で遅くなりうるため)。 - - Claude: `containerEnv` に `DISABLE_TELEMETRY=1` と `DISABLE_ERROR_REPORTING=1` を設定する。`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` は、機能フラグの取得まで止めて一部の機能が使えなくなるため採らない。[1] - - Codex: テレメトリと分析の送信を止める設定を、実装前に公式ドキュメントで確認する。 -- sudo の制限: node ユーザーの sudo を、ファイアウォールのスクリプトだけに絞る(公式リファレンスと同じ)。これをしないと、エージェントが `sudo iptables -F` で遮断を解除できる。 -- リスク: CDN の IP が変わると通信が止まる。許可リストの漏れで、開発中に突然失敗する。失敗したときの切り分け手順を CONTRIBUTING に書く。 - -## Phase 6: ドキュメントを更新する(低リスク) - -| ファイル | 変更 | -| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `CONTRIBUTING.md` | 環境構築で `CONFIRM_API_URL` が不要になったことを書く。シードの連携済みユーザーの説明を足す。SSH の節と push の手順を D1 に合わせて書き直す。ファイアウォールの許可リストの追加方法とトラブルシューティングを足す | -| `docs/guides/claude-code.md` | 「実行権限」を書き直す(ホストはサンドボックス、devcontainer はコンテナが境界)。bubblewrap の記述を削除する。「動作確認」を更新する | -| `docs/guides/codex.md` | 「実行権限」の「`danger-full-access` は使用しない」「Dockerfile で setuid 付きで導入」を、devcontainer 内の方針に合わせて書き直す。「動作確認」の `bwrap` のコマンドを削除する | -| `.env.example` | Phase 2 のとおり | -| `compose.yaml` のコメント | 「Codex の bubblewrap のため」のコメントを、ファイアウォール用の説明に置き換える | - -## Phase 7: 検証(高リスク・開発環境全体に影響) - -- Rebuild Container 後、Claude / Codex の両方で次を確認する。 - - コマンドの実行と `git commit` が数秒で終わる。 - - `printenv CONFIRM_API_URL`、`.env`、`git credential fill` から秘密を取得できない。 - - 許可リストにない宛先(例: `example.com`)へ接続できない(Phase 5 の実装後)。 -- 結果は「実装後の確認」を参照。 -- `pnpm format`、`pnpm lint`、`pnpm check`、`pnpm test:unit`、`pnpm test:e2e`、`git diff --check` を実行する。 -- 可能であれば、Windows / WSL のメンバーにも Rebuild と上の確認を依頼する。 - -## 実装後の確認(2026-09-19、Rebuild 後) - -| 確認 | 結果 | 状態 | 確認者 | -| ------------------------------------------------------------------------------------------ | ------------------------------------------------- | -------------------------------------------------------- | -------- | -| `bwrap --unshare-user --ro-bind / / true` が失敗する | `No permissions to create a new namespace` で失敗 | 完了 | Claude | -| `printenv CONFIRM_API_URL`、`.env` が空 | どちらも空(`.env` は 0 バイト) | 完了 | Claude | -| `printf 'protocol=https\nhost=github.com\n\n' \| git credential fill` がトークンを返さない | credential helper なし、トークンは返らない | 完了 | Claude | -| Claude のサンドボックスが無効で、コマンドが速い | サンドボックスなしで実行、`git status` 0.16 秒 | 完了 | Claude | -| Codex の managed config がプロジェクトの設定を上書きする | `codex doctor` で下記のとおり | 完了 | Claude | -| Codex(CLI と VS Code 拡張)でコマンドを実行できる | 動作 | 完了 | ユーザー | -| `pnpm db:seed` を 2 回、`pnpm test:e2e` で votes のテストが skip されずに成功する | 成功 | 完了 | ユーザー | -| `git push` が承認なしで通る | — | 未実施 | ユーザー | -| `git commit`(lefthook 込み)が数秒で終わる | — | 未実施 | ユーザー | -| 外部通信の遮断 | — | 未実施(Phase 5 は別 PR のため、現状は遮断されない想定) | ユーザー | - -- `bwrap` はベースイメージ(`javascript-node` の common-utils)が入れており、Dockerfile から外しても残る。コンテナの権限を外したため namespace を作れず、起動できないので害はない。当初の確認項目「`command -v bwrap` が空」は誤りだった。 -- `codex doctor` の比較(managed config を一時的に退避して確認): +| 案 | 却下理由 | +| ----------------------------------------- | ------------------------------------------------------------------- | +| エージェントを各自の OS で直接動かす | Windows は Claude のサンドボックスに非対応。OS ごとに挙動が分かれる | +| 秘密管理ツール(dotenvx、Infisical など) | 秘密 1 つには過剰。エージェントからも取得できてしまう | +| push はホストから行う | 作業環境が二つに分かれ、手間が増える | -| 条件 | sandbox | -| ----------------------------------------------------------- | ---------------------------------------------------- | -| managed config あり | unrestricted fs + enabled network | -| managed config あり + `-c default_permissions=":workspace"` | unrestricted のまま(managed が CLI より優先) | -| managed config なし、プロジェクト内 | restricted fs + restricted network、deny ルール 8 件 | -| managed config なし、`/tmp`(Codex の既定値) | restricted fs + restricted network、deny ルール 0 件 | +## Rebuild 後の確認(2026-09-19) -## 計画外の変更と発見(2026-09-19) +ユーザーの確認結果: -- 変更: - - Codex の managed config に、`:danger-full-access` は名前に反してコンテナのユーザー以上の権限を与えず、サンドボックスを使わないだけだというコメントを、公式ドキュメントを出典に付けた。 - - CONTRIBUTING の clone の URL を SSH に変え、HTTPS で clone 済みの人向けの切り替え方法を 1 行足した。 - - `codex.md` の「動作確認」のコマンド一覧を削り、この plan.md の確認表に任せた。 - - 3 つのガイドの加筆は、ユーザーの指摘で半分以下に絞った。 -- 発見: - - `node` はパスワードなしで `sudo` を使える。検証のために managed config を一時的に退避できたのもこのため(すでに元に戻した)。エージェントも managed 設定を外せるので、Phase 5 の sudo の制限はファイアウォールだけでなく、managed 設定を守るためにも必要。 - - エージェントのサンドボックス(Rebuild 前)の中では、DB(`db:5432`)への接続と、`tsx` の IPC ソケットの作成ができなかった。そのため、シードの検証はユーザーに依頼した。 - - `pnpm format` は、サンドボックスが読めないリポジトリ直下のファイル(`.bashrc`、`.profile`、`.zshrc` など。git 未追跡で由来は不明)のために終了コード 2 になった。変更したファイルは個別に整形した。 +- `curl` による許可先・非許可先への接続と `sudo -n true` は、それぞれ期待どおりに成功・失敗した。 +- VS Code の拡張と Claude / Codex の CLI・拡張で会話とコマンドを実行できた。 +- `pnpm install` と `pnpm test:e2e` は成功した。 -## ルール追加の候補(未反映) +実際の `git commit` / `git push` は、このレビュー前の確認項目から外した。`coderabbit review --plain` も今回の動作確認には含めない。 -- learning.md の教訓から。AGENTS.md の Implementation Workflow の 2 の後に追加するか、後で判断する。 +## 残るリスク -> When asking the user to choose, do not list an option that contradicts the approved plan unless it is explicitly labeled as overturning the plan. +- Claude / Codex のログイン情報はエージェントのコマンドから読める。許可した宛先や DNS の問い合わせを経由した持ち出しは防げない。DNS の宛先だけを絞っても、このリスクは解消しない。 +- ファイアウォールは IP アドレスで判定するため、同じ IP を共有する他のサイトにも届く。 +- コンテナ作成直後のセットアップと、VS Code を使わない `docker compose up` では、ファイアウォールが効かない。この起動時の例外は、ローカル開発の運用として受け入れる。 +- ホストに `CONFIRM_API_URL` が残っていると再注入される(警告は出る)。GitHub のトークン共有の停止も各自のホスト設定に依存する。新しいメンバーにも確認を依頼する。 -## レビュー +## 教訓 -- 秘密情報の扱いと開発環境の構成を変えるため、AGENTS.md に従いクロスレビューの対象とする(Claude 主導のため Codex、使えなければ `coderabbit review --plain`)。 -- Codex によるセキュリティレビュー(2026-09-19): 初回は設定だけで全メンバーを保護できるかを基準に複数項目を「高リスク」と評価したが、開発メンバーは事実上 2 人で、新規参加も当面想定しないという運用条件を踏まえると、一律に高リスクとするのは過大だった。このコミットを止める重大な未対応問題が 4 件あるという評価は採らない。 -- `CONFIRM_API_URL` の再注入は低〜中リスク。Compose はホストの `.env` や環境変数に値が残れば注入し、セットアップスクリプトは警告のみで停止しない。もう 1 人のメンバーにも値を外してもらい、Rebuild 後に空であることを確認する。実値を使った例外的な確認の後も、値の削除と Rebuild が必要。 -- GitHub の HTTPS credential helper は低〜中リスク。VS Code のホスト設定はリポジトリから強制できないが、少人数なら各メンバーの設定と、コンテナ内で `git credential fill` がトークンを返さないことの確認で管理する。新規メンバーが参加する場合も同じ確認を行う。 -- エージェントの push は低〜中リスクとして受容する。AGENTS.md と Claude の deny で原則禁止し、`main` と `staging` のブランチ保護で影響を抑える。SSH agent forwarding は秘密鍵を渡さない一方、コンテナ内のプロセスにその鍵で認証する能力を与えるため、ルールだけで push を技術的に禁止できるとは扱わない。 -- エージェント自身の認証トークンがコンテナ内で読めることが主な残余リスク。両ツールを併用する人が少なければ相互のトークンを読む場面は限られるが、使用中のツールのトークンは残る。Phase 5 まで外向き通信に制限はなく、Phase 5 後も許可した送信先への持ち出しは防げない。D3 として受容した範囲を超える保証はしない。 -- Phase 5 では、通信の許可リストを導入する前に `node` のパスワードなし `sudo` を制限する。これを残すとエージェントが通信制限を解除できる。許可リストは持ち出しリスクを下げる施策であり、完全な遮断とは評価しない。 +- セキュリティの設定を外すときは、それが裏で何を回避していたかを先に洗い出す。 +- エラーが連鎖しそうなら、既知の上流 issue をまとめて確認し、方針を 1 回で決める。 +- 守るもの(秘密とログイン情報)を先に固定し、それを削る案は候補にしない。 ## 出典 -1. Claude Code: Development containers: https://code.claude.com/docs/en/devcontainer -2. Claude Code reference devcontainer(`init-firewall.sh`): https://github.com/anthropics/claude-code/tree/main/.devcontainer -3. Claude Code: Configure the sandboxed Bash tool(macOS は Seatbelt、Windows ネイティブは非対応): https://code.claude.com/docs/en/sandboxing -4. dotenvx: Encryption quickstart: https://dotenvx.com/docs/quickstart/encryption +1. Claude Code reference devcontainer: https://github.com/anthropics/claude-code/tree/main/.devcontainer +2. Claude Code: Network access requirements: https://code.claude.com/docs/en/network-config +3. Codex: Configuration reference: https://learn.chatgpt.com/docs/config-file/config-reference +4. Debian: DSA-6472-1: https://security-tracker.debian.org/tracker/DSA-6472-1 +5. Codex: Authentication: https://developers.openai.com/codex/auth +6. Claude Code: Data usage: https://code.claude.com/docs/en/data-usage +7. openai/codex Discussion #8291(Client Analytics): https://github.com/openai/codex/discussions/8291 diff --git a/docs/guides/claude-code.md b/docs/guides/claude-code.md index 6cc6f8fe4..f4632a986 100644 --- a/docs/guides/claude-code.md +++ b/docs/guides/claude-code.md @@ -16,7 +16,7 @@ sandboxは有効化し、利用できない場合のunsandboxed実行へのfallb `.claude/settings.json` はGit管理されproject scopeで適用されるため、denyはdevcontainerだけでなくhost cloneやcloud agentにも効く。devcontainerに存在しない秘密でも、他環境で実在するものはdenyを外さない。 -hostではproject設定のsandboxが境界になる。devcontainerではcontainerが境界で、[managed settings](../../.devcontainer/claude-managed-settings.json)がsandboxを無効にし、秘密はcontainerに置かない。SSH秘密鍵はmountせずagent forwardingを使い、agentはpushしない。projectのMCP serverは登録しない。 +hostではproject設定のsandboxが境界になる。devcontainerではcontainerが境界で、[managed settings](../../.devcontainer/claude-managed-settings.json)がsandboxを無効にし、秘密はcontainerに置かない。SSH秘密鍵はmountせずagent forwardingを使い、agentはpushしない。projectのMCP serverは登録しない。外向き通信は [init-firewall.sh](../../.devcontainer/init-firewall.sh) で制限する。 ## Skillsとplugin diff --git a/docs/guides/codex.md b/docs/guides/codex.md index 05920d751..35c85d40f 100644 --- a/docs/guides/codex.md +++ b/docs/guides/codex.md @@ -15,7 +15,7 @@ hostでは `project-edit` profileのsandboxが境界で、`danger-full-access` は使用しない。devcontainerではcontainerが境界で、[managed config](../../.devcontainer/codex-managed-config.toml)がsandboxを無効にし、秘密はcontainerに置かない。Codexは `bwrap` がないと同梱版を使うため、bubblewrapを外すだけではsandboxは止まらない。 -SSH秘密鍵はmountせず、hostの `ssh-agent` からDev Containersのagent forwardingを使う。projectのMCP serverは登録しない。 +SSH秘密鍵はmountせず、hostの `ssh-agent` からDev Containersのagent forwardingを使う。projectのMCP serverは登録しない。外向き通信は [init-firewall.sh](../../.devcontainer/init-firewall.sh) で制限し、analyticsはmanaged configで止める。 ## 動作確認 From f15d8254182fd2aff6449d2cbfef974d66bbd09c Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 19 Sep 2026 12:25:02 +0000 Subject: [PATCH 06/12] Remove obsolete devcontainer plan --- .../secret-free-devcontainer/plan.md | 85 ------------------- 1 file changed, 85 deletions(-) delete mode 100644 docs/dev-notes/2026-09-19/secret-free-devcontainer/plan.md diff --git a/docs/dev-notes/2026-09-19/secret-free-devcontainer/plan.md b/docs/dev-notes/2026-09-19/secret-free-devcontainer/plan.md deleted file mode 100644 index a676c1811..000000000 --- a/docs/dev-notes/2026-09-19/secret-free-devcontainer/plan.md +++ /dev/null @@ -1,85 +0,0 @@ -# Plan: devcontainer から秘密を排除し、入れ子のサンドボックスを撤去する - -## 経緯(深刻だったもの) - -#4034 で導入したエージェントのサンドボックスの構成で、次の問題が分かった。 - -1. **エージェントのコマンドが全く動かなくなった(2026-09-18)** - - Debian のセキュリティ更新で bubblewrap が 0.12.0 になり、setuid での起動が廃止された。[4] version を固定していなかったため、コンテナを作った日によって動作が不安定に。 - - 直そうとすると、コンテナのセキュリティ制限を次々に緩める必要があった(コンテナの中でさらにサンドボックスを動かす「入れ子」は、そもそも相性が悪い)。 -2. **サンドボックスがあっても、秘密は守れていなかった** - - 本番の秘密 `CONFIRM_API_URL` は、compose がコンテナの環境変数として必須で注入していた。`printenv` を実行すれば誰でも読め、サンドボックスでは防げていなかった。 - - VS Code が GitHub のトークンをコンテナに共有しており、`git credential fill` で取り出せた。 -3. **エージェントが何でもできる権限を持っていた** - - ベースイメージが `node` ユーザーにパスワードなしの `sudo`(管理者権限)を与えていた。エージェントも設定を書き換えたり、ファイアウォールを外したりできた。 - -## 方針: コンテナに秘密を置かず、外向きの通信は許可リストで絞る - -コンテナ全体を境界とし、コンテナ内ではエージェントのサンドボックスを使わない。 - -### 本番の値はローカルに置かない - -- `CONFIRM_API_URL`(AtCoder アカウント連携の確認にだけ使う)は、既定でコンテナに入れない。連携確認のボタンが失敗するだけで、ほかの機能には影響しない。 -- 連携済みユーザーは、シード(`pnpm db:seed`)で作る(`admin`、`guest`)。本物の値での確認が必要なときだけ、エージェントを使わずに行う。 -- 値そのものは変更しない。返すのは AtCoder の公開情報で、本人確認は URL の秘匿に依存しないため。大量アクセスへの対策は、エンドポイント側で別途行う。 - -### エージェントのサンドボックスはコンテナ内だけ無効にする - -- プロジェクトの設定は、コンテナを使わないホストでも効くため残す。 -- bubblewrap のパッケージを外すだけでは不十分だった。Codex は自前の bwrap を同梱しており、それを使うため。 - -### コンテナに残る秘密は、エージェント自身のログイン情報だけ - -- エージェントの push はルールで禁止し、`staging` / `main` はブランチ保護で守る。人間の push の手間は増やさない。ルールだけではエージェントによる push を技術的には防げない。 -- Claude と Codex のログイン情報はコンテナに残り、エージェントのコマンドから読める。この残余リスクを受け入れ、送信先を絞る。 - -### 外部通信の許可リスト - -- Anthropic の公式リファレンスの `init-firewall.sh` を元にし、コンテナの起動のたびに適用する。[1] -- 各ツールの任意機能の宛先 [2] と、`api.openai.com`(Codex を API キーで使うときだけ必要)[5] は入れない。 -- テレメトリは、各ツールの設定でも止める(遮断だけだと再送で遅くなる)。 -- CDN の IP 変更で許可先につながらなくなった場合は Rebuild する。稼働中の `init-firewall.sh` 再実行は、既存の `OUTPUT DROP` 規則を消した後に GitHub から IP 一覧を取得できず、通信不能になるため行わない。 -- 受け入れた制約: - - Claude の Remote Control が使えない(テレメトリを止める設定が、その機能も止めるため)。[6] - - Codex の利用状況の送信先は推論と同じ `chatgpt.com` で、ファイアウォールでは止められない。設定でだけ止めている。[3][7] - -## 却下した代替案 - -| 案 | 却下理由 | -| ----------------------------------------- | ------------------------------------------------------------------- | -| エージェントを各自の OS で直接動かす | Windows は Claude のサンドボックスに非対応。OS ごとに挙動が分かれる | -| 秘密管理ツール(dotenvx、Infisical など) | 秘密 1 つには過剰。エージェントからも取得できてしまう | -| push はホストから行う | 作業環境が二つに分かれ、手間が増える | - -## Rebuild 後の確認(2026-09-19) - -ユーザーの確認結果: - -- `curl` による許可先・非許可先への接続と `sudo -n true` は、それぞれ期待どおりに成功・失敗した。 -- VS Code の拡張と Claude / Codex の CLI・拡張で会話とコマンドを実行できた。 -- `pnpm install` と `pnpm test:e2e` は成功した。 - -実際の `git commit` / `git push` は、このレビュー前の確認項目から外した。`coderabbit review --plain` も今回の動作確認には含めない。 - -## 残るリスク - -- Claude / Codex のログイン情報はエージェントのコマンドから読める。許可した宛先や DNS の問い合わせを経由した持ち出しは防げない。DNS の宛先だけを絞っても、このリスクは解消しない。 -- ファイアウォールは IP アドレスで判定するため、同じ IP を共有する他のサイトにも届く。 -- コンテナ作成直後のセットアップと、VS Code を使わない `docker compose up` では、ファイアウォールが効かない。この起動時の例外は、ローカル開発の運用として受け入れる。 -- ホストに `CONFIRM_API_URL` が残っていると再注入される(警告は出る)。GitHub のトークン共有の停止も各自のホスト設定に依存する。新しいメンバーにも確認を依頼する。 - -## 教訓 - -- セキュリティの設定を外すときは、それが裏で何を回避していたかを先に洗い出す。 -- エラーが連鎖しそうなら、既知の上流 issue をまとめて確認し、方針を 1 回で決める。 -- 守るもの(秘密とログイン情報)を先に固定し、それを削る案は候補にしない。 - -## 出典 - -1. Claude Code reference devcontainer: https://github.com/anthropics/claude-code/tree/main/.devcontainer -2. Claude Code: Network access requirements: https://code.claude.com/docs/en/network-config -3. Codex: Configuration reference: https://learn.chatgpt.com/docs/config-file/config-reference -4. Debian: DSA-6472-1: https://security-tracker.debian.org/tracker/DSA-6472-1 -5. Codex: Authentication: https://developers.openai.com/codex/auth -6. Claude Code: Data usage: https://code.claude.com/docs/en/data-usage -7. openai/codex Discussion #8291(Client Analytics): https://github.com/openai/codex/discussions/8291 From e3720d8f918ee94111b70504e0d63f0f780796aa Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 19 Sep 2026 13:28:16 +0000 Subject: [PATCH 07/12] Harden devcontainer DNS and isolate Claude config --- .devcontainer/devcontainer.json | 4 ++-- .devcontainer/init-firewall.sh | 4 +++- CONTRIBUTING.md | 4 ++-- .../verification.md | 17 +++++++++++++++++ docs/guides/claude-code.md | 4 ++-- docs/guides/codex.md | 4 ++-- 6 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 docs/dev-notes/2026-09-19/devcontainer-review-followup/verification.md diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 79856d23e..baeee1973 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,7 +7,7 @@ // Use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile "dockerComposeFile": ["../compose.yaml"], "mounts": [ - "source=${localEnv:HOME}/.claude,target=/home/node/.claude,type=bind,consistency=cached", + "source=${localEnv:HOME}/.claude-devcontainer/AtCoderNoviSteps,target=/home/node/.claude,type=bind,consistency=cached", "source=${localEnv:HOME}/.codex-devcontainer/AtCoderNoviSteps,target=/home/node/.codex,type=bind,consistency=cached", "source=${localEnv:HOME}/.gitconfig,target=/home/node/.gitconfig,type=bind,consistency=cached" ], @@ -25,7 +25,7 @@ // "shutdownAction": "none", // // Use 'initializeCommand' to run commands before the container is created. - "initializeCommand": "mkdir -p ~/.claude ~/.codex-devcontainer/AtCoderNoviSteps && touch ~/.gitconfig", + "initializeCommand": "mkdir -p ~/.claude-devcontainer/AtCoderNoviSteps ~/.codex-devcontainer/AtCoderNoviSteps && touch ~/.gitconfig", // // Use 'postCreateCommand' to run commands after the container is created. "postCreateCommand": "bash .devcontainer/setup-devcontainer.sh", diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh index b9d306241..f1a45989f 100644 --- a/.devcontainer/init-firewall.sh +++ b/.devcontainer/init-firewall.sh @@ -65,7 +65,9 @@ host_network="$(ip route | awk '/^default/ {print $3}' | sed 's/\.[0-9]*$/.0\/24 iptables -A INPUT -i lo -j ACCEPT iptables -A OUTPUT -o lo -j ACCEPT -iptables -A OUTPUT -p udp --dport 53 -j ACCEPT +# Only Docker's embedded DNS; port 53 to any other IP would bypass the allowlist. +iptables -A OUTPUT -p udp -d 127.0.0.11/32 --dport 53 -j ACCEPT +iptables -A OUTPUT -p tcp -d 127.0.0.11/32 --dport 53 -j ACCEPT iptables -A INPUT -s "${host_network}" -j ACCEPT iptables -A OUTPUT -d "${host_network}" -j ACCEPT diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d50d82bac..713a2a4b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,7 +96,7 @@ Claude Code と Codex は用途や利用可能な契約に応じて選択でき `git clone git@github.com:AtCoder-NoviSteps/AtCoderNoviSteps.git` - - HTTPS で clone 済みの場合は `git remote set-url origin <上の URL>` で SSH に切り替えてください。 + - HTTPS で clone 済みの場合は `git remote set-url origin git@github.com:AtCoder-NoviSteps/AtCoderNoviSteps.git` で SSH に切り替えてください。 2. 作業ディレクトリを`AtCoderNovisteps`に変更します。 @@ -158,7 +158,7 @@ Claude Code と Codex は用途や利用可能な契約に応じて選択でき - Windows: `Ctrl + Shift + P` 3. ローカルサーバを動作させるために必要な環境が自動的に構築され、VS Codeの拡張機能もインストールされます。 -エージェントはコンテナを境界として動くため、コンテナに秘密を置きません。 +エージェントはコンテナを境界として動くため、エージェント自身のログイン情報以外はコンテナに置きません。 - `CONFIRM_API_URL` はローカル開発では不要です(連携済みユーザーはシードで作れます)。ホストの `.env` とシェルに設定しないでください。本物の値で確認するときだけ設定して Rebuild し、エージェントを使わずに確認後、値を外して再度 Rebuild します。 - ホストの VS Code のユーザー設定に `"dev.containers.gitCredentialHelperConfigLocation": "none"` を追加し、GitHub のトークンをコンテナに共有しないようにします。 diff --git a/docs/dev-notes/2026-09-19/devcontainer-review-followup/verification.md b/docs/dev-notes/2026-09-19/devcontainer-review-followup/verification.md new file mode 100644 index 000000000..1e00f1883 --- /dev/null +++ b/docs/dev-notes/2026-09-19/devcontainer-review-followup/verification.md @@ -0,0 +1,17 @@ +# Rebuild 後の確認: devcontainer レビュー対応 + +CodeRabbit のレビューを受けて、DNS の許可を Docker の組み込み DNS(`127.0.0.11`)宛てに限定し、Claude の mount 元を `~/.claude-devcontainer/AtCoderNoviSteps` に分離した。 +ホストの `~/.claude` の memory・会話履歴は引き継がない。 + +## 確認項目 + +- [ ] `postStartCommand`(`sudo /usr/local/bin/init-firewall.sh`)が `Firewall configured` で終わる +- [ ] 名前解決できる: `dig +short api.anthropic.com` +- [ ] 許可先に接続できる: `curl -fsS -o /dev/null https://registry.npmjs.org` +- [ ] 非許可先は拒否される: `curl -fsS --connect-timeout 5 https://example.com` が失敗する +- [ ] 53 番ポートでも他の DNS には届かない: `dig +short +time=3 +tries=1 @1.1.1.1 example.com` が失敗する +- [ ] Claude に再ログインでき、CLI と拡張で会話とコマンドを実行できる + +## 失敗したとき + +- 名前解決が失敗する場合は、`/etc/resolv.conf` の `nameserver` が `127.0.0.11` かを確認する。別の値なら、`init-firewall.sh` の DNS 規則の宛先がずれている。 diff --git a/docs/guides/claude-code.md b/docs/guides/claude-code.md index f4632a986..d99d5cb28 100644 --- a/docs/guides/claude-code.md +++ b/docs/guides/claude-code.md @@ -8,7 +8,7 @@ - `CLAUDE.md` は `AGENTS.md` をimportし、Claude固有の入口だけを定義する。 - `.claude/rules/` は `docs/guides/agent-rules/` の共通本文へのsymlinkで、`paths` frontmatterでpathごとに読み込む。`coding-style.md` は計画時にも必要なため常時適用する。 - `.claude/skills/` は `.agents/skills/` の共通skillへのsymlinkで、project固有workflowを必要な時だけ読み込む。本文をLLM別に複製しない。 -- devcontainerではhostの `~/.claude` を `/home/node/.claude`(`CLAUDE_CONFIG_DIR`)へmountし、認証やsessionをrebuild後も保持する。 +- devcontainerではhostの `~/.claude-devcontainer/AtCoderNoviSteps` を `/home/node/.claude`(`CLAUDE_CONFIG_DIR`)へmountし、認証やsessionをrebuild後も保持する。hostの通常の `~/.claude` とは分離し、他projectの会話やmemoryをcontainerから読めないようにする。 ## 実行権限 @@ -16,7 +16,7 @@ sandboxは有効化し、利用できない場合のunsandboxed実行へのfallb `.claude/settings.json` はGit管理されproject scopeで適用されるため、denyはdevcontainerだけでなくhost cloneやcloud agentにも効く。devcontainerに存在しない秘密でも、他環境で実在するものはdenyを外さない。 -hostではproject設定のsandboxが境界になる。devcontainerではcontainerが境界で、[managed settings](../../.devcontainer/claude-managed-settings.json)がsandboxを無効にし、秘密はcontainerに置かない。SSH秘密鍵はmountせずagent forwardingを使い、agentはpushしない。projectのMCP serverは登録しない。外向き通信は [init-firewall.sh](../../.devcontainer/init-firewall.sh) で制限する。 +hostではproject設定のsandboxが境界になる。devcontainerではcontainerが境界で、[managed settings](../../.devcontainer/claude-managed-settings.json)がsandboxを無効にし、agent自身のlogin情報以外の秘密はcontainerに置かない。SSH秘密鍵はmountせずagent forwardingを使い、agentはpushしない。projectのMCP serverは登録しない。外向き通信は [init-firewall.sh](../../.devcontainer/init-firewall.sh) で制限する。 ## Skillsとplugin diff --git a/docs/guides/codex.md b/docs/guides/codex.md index 35c85d40f..e00bae352 100644 --- a/docs/guides/codex.md +++ b/docs/guides/codex.md @@ -11,9 +11,9 @@ ## 実行権限 -`project-edit` profileはworkspaceの編集を許可し、`.env*`、credential、秘密鍵などのreadを拒否する。具体的なdeny対象は原本を参照し、`.claude/settings.json` と揃える。子processの環境変数は `core` を基準に、既定のsecret名filterも有効にする。 +`project-edit` profileはworkspaceの編集を許可し、`.env`、credential、秘密鍵などのreadを拒否する。具体的なdeny対象は原本を参照し、`.claude/settings.json` と揃える。子processの環境変数は `core` を基準に、既定のsecret名filterも有効にする。 -hostでは `project-edit` profileのsandboxが境界で、`danger-full-access` は使用しない。devcontainerではcontainerが境界で、[managed config](../../.devcontainer/codex-managed-config.toml)がsandboxを無効にし、秘密はcontainerに置かない。Codexは `bwrap` がないと同梱版を使うため、bubblewrapを外すだけではsandboxは止まらない。 +hostでは `project-edit` profileのsandboxが境界で、`danger-full-access` は使用しない。devcontainerではcontainerが境界で、[managed config](../../.devcontainer/codex-managed-config.toml)がsandboxを無効にし、agent自身のlogin情報以外の秘密はcontainerに置かない。Codexは `bwrap` がないと同梱版を使うため、bubblewrapを外すだけではsandboxは止まらない。 SSH秘密鍵はmountせず、hostの `ssh-agent` からDev Containersのagent forwardingを使う。projectのMCP serverは登録しない。外向き通信は [init-firewall.sh](../../.devcontainer/init-firewall.sh) で制限し、analyticsはmanaged configで止める。 From 4a285608d24a19ade051da78f25ed8d2cfa09e90 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 19 Sep 2026 13:29:30 +0000 Subject: [PATCH 08/12] Remove devcontainer review verification note --- .../verification.md | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 docs/dev-notes/2026-09-19/devcontainer-review-followup/verification.md diff --git a/docs/dev-notes/2026-09-19/devcontainer-review-followup/verification.md b/docs/dev-notes/2026-09-19/devcontainer-review-followup/verification.md deleted file mode 100644 index 1e00f1883..000000000 --- a/docs/dev-notes/2026-09-19/devcontainer-review-followup/verification.md +++ /dev/null @@ -1,17 +0,0 @@ -# Rebuild 後の確認: devcontainer レビュー対応 - -CodeRabbit のレビューを受けて、DNS の許可を Docker の組み込み DNS(`127.0.0.11`)宛てに限定し、Claude の mount 元を `~/.claude-devcontainer/AtCoderNoviSteps` に分離した。 -ホストの `~/.claude` の memory・会話履歴は引き継がない。 - -## 確認項目 - -- [ ] `postStartCommand`(`sudo /usr/local/bin/init-firewall.sh`)が `Firewall configured` で終わる -- [ ] 名前解決できる: `dig +short api.anthropic.com` -- [ ] 許可先に接続できる: `curl -fsS -o /dev/null https://registry.npmjs.org` -- [ ] 非許可先は拒否される: `curl -fsS --connect-timeout 5 https://example.com` が失敗する -- [ ] 53 番ポートでも他の DNS には届かない: `dig +short +time=3 +tries=1 @1.1.1.1 example.com` が失敗する -- [ ] Claude に再ログインでき、CLI と拡張で会話とコマンドを実行できる - -## 失敗したとき - -- 名前解決が失敗する場合は、`/etc/resolv.conf` の `nameserver` が `127.0.0.11` かを確認する。別の値なら、`init-firewall.sh` の DNS 規則の宛先がずれている。 From 19b68150108348055b380d8ebd051829ebead6b2 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 19 Sep 2026 23:12:43 +0000 Subject: [PATCH 09/12] Make devcontainer firewall restart safe --- .devcontainer/init-firewall.sh | 162 ++++++++++++------ .../firewall-restart-safety/plan.md | 50 ++++++ src/test/init-firewall.test.ts | 22 +++ 3 files changed, 184 insertions(+), 50 deletions(-) create mode 100644 docs/dev-notes/2026-09-19/firewall-restart-safety/plan.md create mode 100644 src/test/init-firewall.test.ts diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh index f1a45989f..1d8ae69eb 100644 --- a/.devcontainer/init-firewall.sh +++ b/.devcontainer/init-firewall.sh @@ -27,68 +27,130 @@ allowed_domains=( ide.coderabbit.ai ) -# 1. Reset rules from a previous run, but keep Docker's embedded DNS, which lives in the NAT table. -docker_dns_rules="$(iptables-save -t nat | grep '127\.0\.0\.11' || true)" -iptables -F -iptables -X -iptables -t nat -F -iptables -t nat -X -ipset destroy allowed-domains 2>/dev/null || true - -if [[ -n "${docker_dns_rules}" ]]; then - iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true - iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true - echo "${docker_dns_rules}" | xargs -L 1 iptables -t nat -fi +temporary_set="allowed-domains-$$" +swapped=0 + +cleanup() { + local status="$1" -# 2. Build the set of allowed IPs, since iptables matches IPs rather than domain names. -ipset create allowed-domains hash:net + if [[ "${swapped}" -eq 1 && "${status}" -ne 0 ]]; then + if ! ipset swap "${temporary_set}" allowed-domains; then + echo 'Failed to restore the previous allowed domains' >&2 + fi + fi + + if [[ -n "${temporary_set}" ]]; then + ipset destroy "${temporary_set}" 2>/dev/null || true + fi +} +trap 'cleanup "$?"' EXIT + +# Keep the active set and rules intact until every destination is available. +ipset create "${temporary_set}" hash:net # GitHub publishes its IPv4 ranges for web, API and git (SSH); merge adjacent ranges before adding. -curl -fsS https://api.github.com/meta \ - | jq -r '(.web + .api + .git)[] | select(contains(":") | not)' \ - | aggregate -q \ - | xargs -L 1 ipset add allowed-domains +github_ranges="$(curl -fsS --connect-timeout 5 --max-time 30 https://api.github.com/meta \ + | jq -er '(.web + .api + .git)[] | select(contains(":") | not)' \ + | aggregate -q)" + +if [[ -z "${github_ranges}" ]]; then + echo 'GitHub metadata contains no IPv4 ranges' >&2 + exit 1 +fi + +while IFS= read -r range; do + ipset add -exist "${temporary_set}" "${range}" +done <<<"${github_ranges}" # Other destinations: resolve each domain once at startup and add its IPv4 addresses. for domain in "${allowed_domains[@]}"; do - if ! ips="$(dig +short A "${domain}" | grep -E '^[0-9.]+$')"; then + if ! ips="$(dig +short +time=2 +tries=1 A "${domain}" | grep -E '^[0-9.]+$')"; then echo "Failed to resolve ${domain}" >&2 exit 1 fi - xargs -L 1 ipset add -exist allowed-domains <<<"${ips}" + while IFS= read -r address; do + ipset add -exist "${temporary_set}" "${address}" + done <<<"${ips}" done -# 3. Allow local traffic: loopback, DNS, and the compose network that holds `db` and the host gateway. -host_network="$(ip route | awk '/^default/ {print $3}' | sed 's/\.[0-9]*$/.0\/24/')" - -iptables -A INPUT -i lo -j ACCEPT -iptables -A OUTPUT -o lo -j ACCEPT -# Only Docker's embedded DNS; port 53 to any other IP would bypass the allowlist. -iptables -A OUTPUT -p udp -d 127.0.0.11/32 --dport 53 -j ACCEPT -iptables -A OUTPUT -p tcp -d 127.0.0.11/32 --dport 53 -j ACCEPT -iptables -A INPUT -s "${host_network}" -j ACCEPT -iptables -A OUTPUT -d "${host_network}" -j ACCEPT - -# 4. Allow replies and the allowed set, then reject everything else. -iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT -iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT -iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT -# REJECT rather than DROP so a blocked request fails immediately instead of timing out. -iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited -iptables -P INPUT DROP -iptables -P FORWARD DROP -iptables -P OUTPUT DROP - -# 5. The allowed set is IPv4 only, so close IPv6 except loopback. -ip6tables -F -ip6tables -A OUTPUT -o lo -j ACCEPT -ip6tables -P OUTPUT DROP - -# 6. Verify that an unlisted destination is blocked. -if curl -fsS --connect-timeout 5 https://example.com >/dev/null 2>&1; then - echo 'Firewall check failed: example.com is reachable' >&2 +if ipset list -n | grep -Fxq allowed-domains; then + ipset swap "${temporary_set}" allowed-domains + swapped=1 +else + ipset rename "${temporary_set}" allowed-domains + temporary_set='' +fi + +# An existing installation only needs an ipset swap; the rules remain in place. +# Known gap: an interruption after the IPv4 OUTPUT jump but before the IPv6 jump leaves IPv6 open on reruns. +# Accepted as unlikely: those steps are plain -I/-P after ip6tables already succeeded, and a container restart presumably gets a fresh netns (unverified). +if ! iptables -C OUTPUT -j NOVISTEPS_OUTPUT 2>/dev/null; then + host_network="$(ip route | awk '/^default/ {print $3}' | sed 's/\.[0-9]*$/.0\/24/')" + + # Flush chains left by an interrupted installation so the next start can finish it. + iptables -N NOVISTEPS_INPUT 2>/dev/null || iptables -F NOVISTEPS_INPUT + iptables -N NOVISTEPS_OUTPUT 2>/dev/null || iptables -F NOVISTEPS_OUTPUT + iptables -N NOVISTEPS_FORWARD 2>/dev/null || iptables -F NOVISTEPS_FORWARD + iptables -A NOVISTEPS_INPUT -i lo -j ACCEPT + iptables -A NOVISTEPS_INPUT -s "${host_network}" -j ACCEPT + iptables -A NOVISTEPS_INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + iptables -A NOVISTEPS_INPUT -j DROP + iptables -A NOVISTEPS_OUTPUT -o lo -j ACCEPT + # Only Docker's embedded DNS; port 53 to any other IP would bypass the allowlist. + iptables -A NOVISTEPS_OUTPUT -p udp -d 127.0.0.11/32 --dport 53 -j ACCEPT + iptables -A NOVISTEPS_OUTPUT -p tcp -d 127.0.0.11/32 --dport 53 -j ACCEPT + iptables -A NOVISTEPS_OUTPUT -d "${host_network}" -j ACCEPT + iptables -A NOVISTEPS_OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + iptables -A NOVISTEPS_OUTPUT -m set --match-set allowed-domains dst -j ACCEPT + # Reject blocked requests immediately instead of waiting for a timeout. + iptables -A NOVISTEPS_OUTPUT -j REJECT --reject-with icmp-admin-prohibited + iptables -A NOVISTEPS_FORWARD -j DROP + + ip6tables -N NOVISTEPS_IPV6 2>/dev/null || ip6tables -F NOVISTEPS_IPV6 + ip6tables -A NOVISTEPS_IPV6 -o lo -j ACCEPT + ip6tables -A NOVISTEPS_IPV6 -j REJECT --reject-with icmp6-adm-prohibited + + iptables -I INPUT 1 -j NOVISTEPS_INPUT + iptables -I OUTPUT 1 -j NOVISTEPS_OUTPUT + iptables -I FORWARD 1 -j NOVISTEPS_FORWARD + ip6tables -I OUTPUT 1 -j NOVISTEPS_IPV6 + iptables -P INPUT DROP + iptables -P FORWARD DROP + iptables -P OUTPUT DROP + ip6tables -P OUTPUT DROP +fi + +# An HTTP error status still proves the connection was allowed, so omit -f. +# Keep the body to one command: set -e is disabled inside functions called from conditionals. +probe() { + curl -sS -o /dev/null --connect-timeout 5 --max-time 8 "https://$1" +} + +# Verify both directions; a check that only tests blocking passes even when everything is blocked. +check_failed=0 + +for destination in api.github.com registry.npmjs.org api.anthropic.com; do + if probe "${destination}"; then + echo "Firewall check OK: ${destination} is reachable" + else + echo "Firewall check failed: ${destination} is unreachable" >&2 + check_failed=1 + fi +done + +# Only curl's exit 7 (couldn't connect) proves the REJECT rule; DNS, TLS or timeout failures do not. +blocked_status=0 +probe example.com 2>/dev/null || blocked_status=$? + +if [[ "${blocked_status}" -eq 7 ]]; then + echo 'Firewall check OK: example.com is blocked' +else + echo "Firewall check failed: example.com was not rejected (exit ${blocked_status})" >&2 + check_failed=1 +fi + +if [[ "${check_failed}" -ne 0 ]]; then exit 1 fi diff --git a/docs/dev-notes/2026-09-19/firewall-restart-safety/plan.md b/docs/dev-notes/2026-09-19/firewall-restart-safety/plan.md new file mode 100644 index 000000000..02b11fc99 --- /dev/null +++ b/docs/dev-notes/2026-09-19/firewall-restart-safety/plan.md @@ -0,0 +1,50 @@ +# ファイアウォール再実行の安全化 + +## 概要 + +devcontainer の起動待ちが長時間続き、コンテナ破棄後は起動できた。 +確認できた欠陥は、再実行時の `iptables -F` が許可規則を消して `OUTPUT DROP` だけを残し、GitHub メタデータ取得を妨げることである。ログがなく、実際の停止箇所は未特定。 +専用チェーン・一時 ipset の swap・全外部取得への期限・双方向の自己検証を導入した。 + +## 判断 + +- 稼働中の規則は、新しい宛先の取得と検証が終わるまで保持する。Docker の DNS を壊さないよう NAT と組み込みチェーンの flush には触れない。 +- このスクリプトは誤っていても症状が出ない(許可リストが無効でも通信は成功する)ため、起動時の自己検証で「許可先に届く」と「未許可先が塞がる」の両方を確かめる。片方だけでは全遮断でも合格する。 +- stub による挙動テストは全廃した。自己検証まで stub が答えるため、ファイアウォール無効化・全遮断・DNS 断・IPv6 素通し・freeze 再発の 5 点をどれも検出しなかった。実機で現れない「全 `curl`/`dig` に期限がある」ことだけを静的に検査する。 +- 個人環境で Rebuild により回復できるため、恒久的に壊れる経路だけを潰す。途中失敗で以後毎回 "Chain already exists" になる問題は `-N || -F` で直し、Rebuild で戻る不具合(`host_network` の固定、ジャンプ規則の重複、IPv4 ジャンプ追加後・IPv6 ジャンプ追加前の中断で再実行が完了済みと判定し IPv6 が素通しになること)は見送る。最後の件は該当区間が ip6tables 成功後の `-I`/`-P` だけで発生率が低い。コンテナ再起動で netns が作り直され規則も消えると想定しているが未検証。 +- 未許可先の検証は curl の終了コード 7(接続失敗)のみを遮断の証拠とする。DNS・TLS・タイムアウトによる失敗は遮断を証明しないため検証失敗とする。 + +## 却下 + +- 取得前に `OUTPUT ACCEPT` にする。一時的に許可範囲が広がる。 +- `waitFor: postStartCommand` を外す。設定失敗を隠す。 +- 別言語での書き直しや `iptables-restore` による宣言化。書き直し量に対し、要件(実態が分かること)に効かない。 +- 初回設定の fail-closed 化。2 経路を抱える複雑さが Rebuild で回復できる状況に見合わない。 + +## 補足: チェーン + +`NOVISTEPS_*` は変数ではなく iptables のチェーン名である。チェーンは上から照合される規則のリストで、`-N` でカーネル内に作られスクリプト終了後も残る。組み込みの `INPUT`/`OUTPUT` からのジャンプで呼ばれる。 +中身はホスト側から `docker compose exec -u root web iptables -L NOVISTEPS_OUTPUT -n -v --line-numbers` で確認できる(node の sudo は init-firewall.sh のみ許可)。 + +## 未解決 + +- 反映には Rebuild が必要(image に焼き込んだコピーを sudo 実行する構成は、agent による改変を防ぐため維持)。 +- オフライン時や probe 先の障害時は自己検証が失敗する。 +- コンテナ起動から `postStartCommand` までの窓は無防備のまま。 +- コンテナ再起動で netns が作り直され規則も消える、という想定は未検証のまま(下記「残る任意確認」)。 + +## Rebuild 後の確認(2026-09-19 実施・合格) + +- 起動ログ: freeze せず、`Firewall check OK` 4 行と `Firewall configured` が出る。 +- コンテナ内: + - `diff /usr/local/bin/init-firewall.sh .devcontainer/init-firewall.sh` が差分なし。 + - `curl -sS -o /dev/null --max-time 8 https://example.com; echo $?` が `7`。 + - `sudo /usr/local/bin/init-firewall.sh` の再実行が `Firewall configured` まで進む。 + - `ip -6 route` にデフォルト経路がなければ、IPv6 素通しの影響は実質ない。 +- ホスト側(`docker compose exec -u root web ...`): + - `iptables -S OUTPUT` に `-P OUTPUT DROP` と、再実行後も 1 行だけの `-j NOVISTEPS_OUTPUT`。 + - `ip6tables -S OUTPUT` に `-P OUTPUT DROP` と `-j NOVISTEPS_IPV6`。 + +## 残る任意確認 + +- `iptables -N NETNS_MARKER` 後にコンテナを再起動し、`iptables -L NETNS_MARKER` が失敗すれば netns 再作成の想定を確認済みとして「判断」の「未検証」を外す。残っていれば IPv6 素通しの見送り判断を見直す。 diff --git a/src/test/init-firewall.test.ts b/src/test/init-firewall.test.ts new file mode 100644 index 000000000..961abcc70 --- /dev/null +++ b/src/test/init-firewall.test.ts @@ -0,0 +1,22 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, test } from 'vitest'; + +// Behavior is checked by the script's startup self-check; a missing timeout only shows when DNS stalls. +const commandLines = readFileSync('.devcontainer/init-firewall.sh', 'utf8') + .split('\n') + .filter((line) => !line.trimStart().startsWith('#')); + +const findCalls = (command: string) => + commandLines.filter((line) => new RegExp(`\\b${command}\\s`).test(line)); + +describe('init-firewall.sh', () => { + test('bounds every curl call with a total time limit', () => { + expect(findCalls('curl').length).toBeGreaterThan(0); + expect(findCalls('curl').filter((line) => !line.includes('--max-time'))).toEqual([]); + }); + + test('bounds every dig call with a timeout and a retry limit', () => { + expect(findCalls('dig').length).toBeGreaterThan(0); + expect(findCalls('dig').filter((line) => !/\+time=\d+ \+tries=\d+/.test(line))).toEqual([]); + }); +}); From 74398fe37ed2745292ee1aacc1f955eebe2ad234 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 19 Sep 2026 23:14:17 +0000 Subject: [PATCH 10/12] Remove firewall restart plan --- .../firewall-restart-safety/plan.md | 50 ------------------- 1 file changed, 50 deletions(-) delete mode 100644 docs/dev-notes/2026-09-19/firewall-restart-safety/plan.md diff --git a/docs/dev-notes/2026-09-19/firewall-restart-safety/plan.md b/docs/dev-notes/2026-09-19/firewall-restart-safety/plan.md deleted file mode 100644 index 02b11fc99..000000000 --- a/docs/dev-notes/2026-09-19/firewall-restart-safety/plan.md +++ /dev/null @@ -1,50 +0,0 @@ -# ファイアウォール再実行の安全化 - -## 概要 - -devcontainer の起動待ちが長時間続き、コンテナ破棄後は起動できた。 -確認できた欠陥は、再実行時の `iptables -F` が許可規則を消して `OUTPUT DROP` だけを残し、GitHub メタデータ取得を妨げることである。ログがなく、実際の停止箇所は未特定。 -専用チェーン・一時 ipset の swap・全外部取得への期限・双方向の自己検証を導入した。 - -## 判断 - -- 稼働中の規則は、新しい宛先の取得と検証が終わるまで保持する。Docker の DNS を壊さないよう NAT と組み込みチェーンの flush には触れない。 -- このスクリプトは誤っていても症状が出ない(許可リストが無効でも通信は成功する)ため、起動時の自己検証で「許可先に届く」と「未許可先が塞がる」の両方を確かめる。片方だけでは全遮断でも合格する。 -- stub による挙動テストは全廃した。自己検証まで stub が答えるため、ファイアウォール無効化・全遮断・DNS 断・IPv6 素通し・freeze 再発の 5 点をどれも検出しなかった。実機で現れない「全 `curl`/`dig` に期限がある」ことだけを静的に検査する。 -- 個人環境で Rebuild により回復できるため、恒久的に壊れる経路だけを潰す。途中失敗で以後毎回 "Chain already exists" になる問題は `-N || -F` で直し、Rebuild で戻る不具合(`host_network` の固定、ジャンプ規則の重複、IPv4 ジャンプ追加後・IPv6 ジャンプ追加前の中断で再実行が完了済みと判定し IPv6 が素通しになること)は見送る。最後の件は該当区間が ip6tables 成功後の `-I`/`-P` だけで発生率が低い。コンテナ再起動で netns が作り直され規則も消えると想定しているが未検証。 -- 未許可先の検証は curl の終了コード 7(接続失敗)のみを遮断の証拠とする。DNS・TLS・タイムアウトによる失敗は遮断を証明しないため検証失敗とする。 - -## 却下 - -- 取得前に `OUTPUT ACCEPT` にする。一時的に許可範囲が広がる。 -- `waitFor: postStartCommand` を外す。設定失敗を隠す。 -- 別言語での書き直しや `iptables-restore` による宣言化。書き直し量に対し、要件(実態が分かること)に効かない。 -- 初回設定の fail-closed 化。2 経路を抱える複雑さが Rebuild で回復できる状況に見合わない。 - -## 補足: チェーン - -`NOVISTEPS_*` は変数ではなく iptables のチェーン名である。チェーンは上から照合される規則のリストで、`-N` でカーネル内に作られスクリプト終了後も残る。組み込みの `INPUT`/`OUTPUT` からのジャンプで呼ばれる。 -中身はホスト側から `docker compose exec -u root web iptables -L NOVISTEPS_OUTPUT -n -v --line-numbers` で確認できる(node の sudo は init-firewall.sh のみ許可)。 - -## 未解決 - -- 反映には Rebuild が必要(image に焼き込んだコピーを sudo 実行する構成は、agent による改変を防ぐため維持)。 -- オフライン時や probe 先の障害時は自己検証が失敗する。 -- コンテナ起動から `postStartCommand` までの窓は無防備のまま。 -- コンテナ再起動で netns が作り直され規則も消える、という想定は未検証のまま(下記「残る任意確認」)。 - -## Rebuild 後の確認(2026-09-19 実施・合格) - -- 起動ログ: freeze せず、`Firewall check OK` 4 行と `Firewall configured` が出る。 -- コンテナ内: - - `diff /usr/local/bin/init-firewall.sh .devcontainer/init-firewall.sh` が差分なし。 - - `curl -sS -o /dev/null --max-time 8 https://example.com; echo $?` が `7`。 - - `sudo /usr/local/bin/init-firewall.sh` の再実行が `Firewall configured` まで進む。 - - `ip -6 route` にデフォルト経路がなければ、IPv6 素通しの影響は実質ない。 -- ホスト側(`docker compose exec -u root web ...`): - - `iptables -S OUTPUT` に `-P OUTPUT DROP` と、再実行後も 1 行だけの `-j NOVISTEPS_OUTPUT`。 - - `ip6tables -S OUTPUT` に `-P OUTPUT DROP` と `-j NOVISTEPS_IPV6`。 - -## 残る任意確認 - -- `iptables -N NETNS_MARKER` 後にコンテナを再起動し、`iptables -L NETNS_MARKER` が失敗すれば netns 再作成の想定を確認済みとして「判断」の「未検証」を外す。残っていれば IPv6 素通しの見送り判断を見直す。 From 79a0460f5d481bf87d8c6d4c65e817b1706018b8 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 20 Sep 2026 00:23:39 +0000 Subject: [PATCH 11/12] Narrow devcontainer firewall to required destinations Rebuild the iptables chains on every start so an interrupted IPv4 or IPv6 installation is repaired, set the DROP policies before inserting rules, restrict inbound to the published Compose ports, and allow outbound PostgreSQL only to the resolved db container addresses. Add the VS Code extension publisher CDN hosts to the allowlist. Co-Authored-By: Claude Opus 5 --- .devcontainer/init-firewall.sh | 109 ++++++++++++------ CONTRIBUTING.md | 11 +- .../narrow-devcontainer-firewall/plan.md | 33 ++++++ src/test/init-firewall.test.ts | 42 +++++++ 4 files changed, 152 insertions(+), 43 deletions(-) create mode 100644 docs/dev-notes/2026-09-19/narrow-devcontainer-firewall/plan.md diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh index 1d8ae69eb..efcb7a6f9 100644 --- a/.devcontainer/init-firewall.sh +++ b/.devcontainer/init-firewall.sh @@ -14,10 +14,11 @@ allowed_domains=( # Codex (ChatGPT sign-in) chatgpt.com auth.openai.com - # VS Code + # VS Code: the gallery API, and the two hosts the server download redirects to marketplace.visualstudio.com vscode.blob.core.windows.net update.code.visualstudio.com + vscode.download.prss.microsoft.com # External APIs the app calls (src/lib/constants/urls.ts) kenkoooo.com judgeapi.u-aizu.ac.jp @@ -27,6 +28,28 @@ allowed_domains=( ide.coderabbit.ai ) +# The gallery API only returns metadata; each VSIX is served from its publisher's own CDN host. +# Those hosts may well share one set of IPs, but that is unverified, so list every publisher. +vscode_extension_publishers=( + anthropic + bradlc + christian-kohler + csstools + dbaeumer + esbenp + formulahendry + ms-playwright + openai + prisma + streetsidesoftware + svelte + vscode-icons-team +) + +for publisher in "${vscode_extension_publishers[@]}"; do + allowed_domains+=("${publisher}.gallerycdn.vsassets.io") +done + temporary_set="allowed-domains-$$" swapped=0 @@ -82,45 +105,55 @@ else temporary_set='' fi -# An existing installation only needs an ipset swap; the rules remain in place. -# Known gap: an interruption after the IPv4 OUTPUT jump but before the IPv6 jump leaves IPv6 open on reruns. -# Accepted as unlikely: those steps are plain -I/-P after ip6tables already succeeded, and a container restart presumably gets a fresh netns (unverified). -if ! iptables -C OUTPUT -j NOVISTEPS_OUTPUT 2>/dev/null; then - host_network="$(ip route | awk '/^default/ {print $3}' | sed 's/\.[0-9]*$/.0\/24/')" - - # Flush chains left by an interrupted installation so the next start can finish it. - iptables -N NOVISTEPS_INPUT 2>/dev/null || iptables -F NOVISTEPS_INPUT - iptables -N NOVISTEPS_OUTPUT 2>/dev/null || iptables -F NOVISTEPS_OUTPUT - iptables -N NOVISTEPS_FORWARD 2>/dev/null || iptables -F NOVISTEPS_FORWARD - iptables -A NOVISTEPS_INPUT -i lo -j ACCEPT - iptables -A NOVISTEPS_INPUT -s "${host_network}" -j ACCEPT - iptables -A NOVISTEPS_INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT - iptables -A NOVISTEPS_INPUT -j DROP - iptables -A NOVISTEPS_OUTPUT -o lo -j ACCEPT - # Only Docker's embedded DNS; port 53 to any other IP would bypass the allowlist. - iptables -A NOVISTEPS_OUTPUT -p udp -d 127.0.0.11/32 --dport 53 -j ACCEPT - iptables -A NOVISTEPS_OUTPUT -p tcp -d 127.0.0.11/32 --dport 53 -j ACCEPT - iptables -A NOVISTEPS_OUTPUT -d "${host_network}" -j ACCEPT - iptables -A NOVISTEPS_OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT - iptables -A NOVISTEPS_OUTPUT -m set --match-set allowed-domains dst -j ACCEPT - # Reject blocked requests immediately instead of waiting for a timeout. - iptables -A NOVISTEPS_OUTPUT -j REJECT --reject-with icmp-admin-prohibited - iptables -A NOVISTEPS_FORWARD -j DROP - - ip6tables -N NOVISTEPS_IPV6 2>/dev/null || ip6tables -F NOVISTEPS_IPV6 - ip6tables -A NOVISTEPS_IPV6 -o lo -j ACCEPT - ip6tables -A NOVISTEPS_IPV6 -j REJECT --reject-with icmp6-adm-prohibited - - iptables -I INPUT 1 -j NOVISTEPS_INPUT - iptables -I OUTPUT 1 -j NOVISTEPS_OUTPUT - iptables -I FORWARD 1 -j NOVISTEPS_FORWARD - ip6tables -I OUTPUT 1 -j NOVISTEPS_IPV6 - iptables -P INPUT DROP - iptables -P FORWARD DROP - iptables -P OUTPUT DROP - ip6tables -P OUTPUT DROP +# Docker DNS resolves the Compose service to its current container address. +if ! db_addresses="$(getent ahostsv4 db | awk '$2 == "STREAM" { print $1 }' | sort -u)" || [[ -z "${db_addresses}" ]]; then + echo 'Failed to resolve the Compose database' >&2 + exit 1 fi +# Rebuild on every start so an interrupted IPv4 or IPv6 installation is repaired. +# Set policies first so a failed rule insertion leaves outbound traffic blocked. +ip6tables -P OUTPUT DROP +iptables -P INPUT DROP +iptables -P FORWARD DROP +iptables -P OUTPUT DROP + +iptables -N NOVISTEPS_INPUT 2>/dev/null || iptables -F NOVISTEPS_INPUT +iptables -N NOVISTEPS_OUTPUT 2>/dev/null || iptables -F NOVISTEPS_OUTPUT +iptables -N NOVISTEPS_FORWARD 2>/dev/null || iptables -F NOVISTEPS_FORWARD +iptables -A NOVISTEPS_INPUT -i lo -j ACCEPT + +# The web service publishes these two TCP ports in compose.yaml. +iptables -A NOVISTEPS_INPUT -p tcp --dport 5173 -j ACCEPT +iptables -A NOVISTEPS_INPUT -p tcp --dport 5555 -j ACCEPT +iptables -A NOVISTEPS_INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT +iptables -A NOVISTEPS_INPUT -j DROP +iptables -A NOVISTEPS_OUTPUT -o lo -j ACCEPT + +# Only Docker's embedded DNS; port 53 to any other IP would bypass the allowlist. +iptables -A NOVISTEPS_OUTPUT -p udp -d 127.0.0.11/32 --dport 53 -j ACCEPT +iptables -A NOVISTEPS_OUTPUT -p tcp -d 127.0.0.11/32 --dport 53 -j ACCEPT + +while IFS= read -r db_address; do + iptables -A NOVISTEPS_OUTPUT -p tcp -d "${db_address}" --dport 5432 -j ACCEPT +done <<<"${db_addresses}" + +iptables -A NOVISTEPS_OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT +iptables -A NOVISTEPS_OUTPUT -m set --match-set allowed-domains dst -j ACCEPT + +# Reject blocked requests immediately instead of waiting for a timeout. +iptables -A NOVISTEPS_OUTPUT -j REJECT --reject-with icmp-admin-prohibited +iptables -A NOVISTEPS_FORWARD -j DROP + +ip6tables -N NOVISTEPS_IPV6 2>/dev/null || ip6tables -F NOVISTEPS_IPV6 +ip6tables -A NOVISTEPS_IPV6 -o lo -j ACCEPT +ip6tables -A NOVISTEPS_IPV6 -j REJECT --reject-with icmp6-adm-prohibited + +iptables -C INPUT -j NOVISTEPS_INPUT 2>/dev/null || iptables -I INPUT 1 -j NOVISTEPS_INPUT +iptables -C OUTPUT -j NOVISTEPS_OUTPUT 2>/dev/null || iptables -I OUTPUT 1 -j NOVISTEPS_OUTPUT +iptables -C FORWARD -j NOVISTEPS_FORWARD 2>/dev/null || iptables -I FORWARD 1 -j NOVISTEPS_FORWARD +ip6tables -C OUTPUT -j NOVISTEPS_IPV6 2>/dev/null || ip6tables -I OUTPUT 1 -j NOVISTEPS_IPV6 + # An HTTP error status still proves the connection was allowed, so omit -f. # Keep the body to one command: set -e is disabled inside functions called from conditionals. probe() { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 713a2a4b2..a2c076e81 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -135,12 +135,12 @@ Claude Code と Codex は用途や利用可能な契約に応じて選択でき `docker compose exec web pnpm prisma generate` -- 開発サーバ(port番号: 5174)を起動します。その後、以下のリンクを順番にクリックしてください。 - - Note: リンクのアドレス・ポート番号は、環境によって変わる可能性もあります。 +- 開発サーバ(port番号: 5173)を起動します。その後、以下のリンクを順番にクリックしてください。 + - Note: 5173 番ポートが使用中なら、使用中のプロセスを停止してから起動してください。自動的に切り替わる 5174 番は Compose で公開していません。 `docker compose exec web pnpm dev --host` - [http://localhost:5174/](http://localhost:5174/) + [http://localhost:5173/](http://localhost:5173/) - ホーム画面が起動し、ユーザの登録・ログインができれば、環境構築は完了です。 @@ -163,7 +163,8 @@ Claude Code と Codex は用途や利用可能な契約に応じて選択でき - `CONFIRM_API_URL` はローカル開発では不要です(連携済みユーザーはシードで作れます)。ホストの `.env` とシェルに設定しないでください。本物の値で確認するときだけ設定して Rebuild し、エージェントを使わずに確認後、値を外して再度 Rebuild します。 - ホストの VS Code のユーザー設定に `"dev.containers.gitCredentialHelperConfigLocation": "none"` を追加し、GitHub のトークンをコンテナに共有しないようにします。 - コンテナ内の `sudo` はファイアウォール専用です。apt のパッケージや Playwright のブラウザは `Dockerfile` を変更して Rebuild します。 -- 外部通信は [init-firewall.sh](.devcontainer/init-firewall.sh) の許可リストに限られます。許可リストの宛先が突然つながらないときは CDN の IP が変わった可能性があるので、コンテナを Rebuild します。宛先の追加は、持ち出し経路が増えるため必要なものだけにします。 +- インターネット向けの通信は [init-firewall.sh](.devcontainer/init-firewall.sh) の許可リストに限られ、Docker ネットワーク内では `web` から `db:5432` への通信を許可します。許可リストの宛先が突然つながらないときは CDN の IP が変わった可能性があるので、コンテナを Rebuild します。スクリプトを変更した場合も Rebuild が必要です。宛先の追加は、持ち出し経路が増えるため必要なものだけにします。 +- `devcontainer.json` に VS Code の拡張機能を追加したときは、`init-firewall.sh` の `vscode_extension_publishers` にも発行者 ID(`esbenp.prettier-vscode` なら `esbenp`)を追加します。 #### ホスト側で SSH の鍵を ssh-agent へ登録 @@ -199,7 +200,7 @@ Set-Service ssh-agent -StartupType Automatic; Start-Service ssh-agent - 以下のリンクをクリックしてください。 - + - また、開発サーバの起動と同時に新しいブラウザタブでアプリを開くこともできます。 diff --git a/docs/dev-notes/2026-09-19/narrow-devcontainer-firewall/plan.md b/docs/dev-notes/2026-09-19/narrow-devcontainer-firewall/plan.md new file mode 100644 index 000000000..826347360 --- /dev/null +++ b/docs/dev-notes/2026-09-19/narrow-devcontainer-firewall/plan.md @@ -0,0 +1,33 @@ +# Devcontainer ファイアウォールの許可範囲と再実行の修正 + +## 概要 + +Compose の `web` から `db:5432` への通信を残し、Docker ブリッジ全体への許可を削除する。IPv4 と IPv6 の設定状態を確認し、途中で止まった初期化を再実行できるようにする。 + +## 設計 + +ファイアウォール設定は `.devcontainer/init-firewall.sh` の単一責務とする。既存のユーティリティやサービスに同等の処理はない。Docker DNS で `db` の IPv4 アドレスを取得し、TCP 5432 だけ許可する。公開ポート 5173 と 5555 への着信のみ許可する。初期化済み判定は設けず、再実行時も両系統のチェーンを作り直して欠落を補う。再構築前に DROP ポリシーを設定し、途中失敗で許可的な IPv6 OUTPUT を残さない。 + +完全な `iptables-save` / `restore` による状態保存は、ローカル開発コンテナには複雑すぎるため採用しない。ホスト側の宛先を許可する必要は Compose の通信契約から確認できないため採用しない。 + +## フェーズ + +1. テスト層: `init-firewall.sh` の許可範囲と再実行条件を検証するテストを先に追加する。 +2. 設定層: DB 宛て、着信ポート、IPv4/IPv6 の再実行処理を修正する。 +3. 文書: `CONTRIBUTING.md` の通信範囲、Rebuild、公開ポートの説明を現行設定に合わせる。 +4. 検証: `pnpm test:unit`、変更ファイルの Prettier、`pnpm lint`、`pnpm check`、`git diff --check` を実行する。 + +## Rebuild 後の確認 + +以下のコマンドはホスト側のリポジトリで実行する。`web` と `db` が起動してから確認する。 +全項目を 2026-09-20 に確認した。手順 1・2 の再実行・5 の DB 接続・6 は `web` コンテナ内(`node` ユーザー)から、手順 2 の `postStartCommand`・3・4・5 のホストからの 5173 番ポートはホスト側から確認した。 +`node` ユーザーの `sudo` は `/usr/local/bin/init-firewall.sh` だけが NOPASSWD で許可されており、`iptables` の参照はできないため、残りはホスト側で実行する。 + +1. [x] `docker compose exec web cmp -s .devcontainer/init-firewall.sh /usr/local/bin/init-firewall.sh` が成功すること。イメージ内の実行ファイルが修正版であることを確かめる。 +2. [x] Dev Container の `postStartCommand` が `Firewall configured` で終了すること。 + [x] 続けて `docker compose exec web sudo /usr/local/bin/init-firewall.sh` をもう一度実行し、再実行でも成功することを確かめる(自己チェック 4 件が通り `Firewall configured` で終了)。 +3. [x] `docker compose exec -u root web iptables -S NOVISTEPS_OUTPUT` で、`db` の IPv4 アドレス宛て TCP 5432 の許可があり、Docker サブネット全体またはホスト側への無条件の許可がないことを確かめる。`docker compose exec -u root web iptables -S NOVISTEPS_INPUT` では、着信の許可が公開ポート 5173 と 5555、および既存接続とループバックに限られることを確かめる。 +4. [x] `docker compose exec -u root web iptables -S OUTPUT` と `docker compose exec -u root web ip6tables -S OUTPUT` で、それぞれの `NOVISTEPS_*` チェーンへのジャンプが1つずつあり、両方の OUTPUT ポリシーが DROP であることを確かめる。再実行後もジャンプが増えないことを確認する。手順 2 の再実行を済ませてあるので、ここでジャンプが重複していなければ冪等性の確認を兼ねる。 +5. [x] `docker compose exec web node -e "const socket = require('node:net').connect(5432, 'db'); socket.on('connect', () => { console.log('DB TCP OK'); socket.destroy(); }); socket.on('error', (error) => { console.error(error); process.exitCode = 1; });"` が `DB TCP OK` と表示することを確かめる(手順 2 の再実行の前後どちらも接続できた)。 + [x] アプリを起動している場合はホストから 5173 番ポートにアクセスできることも確認する。 +6. [x] `docker compose exec web curl -I --connect-timeout 5 --max-time 8 https://api.github.com` が通信でき、`docker compose exec web curl -sS --connect-timeout 5 --max-time 8 https://example.com` は接続拒否で終了することを確かめる。これは初期化スクリプト自身の許可先・遮断先チェックの再確認である。 diff --git a/src/test/init-firewall.test.ts b/src/test/init-firewall.test.ts index 961abcc70..49442d9fb 100644 --- a/src/test/init-firewall.test.ts +++ b/src/test/init-firewall.test.ts @@ -9,7 +9,49 @@ const commandLines = readFileSync('.devcontainer/init-firewall.sh', 'utf8') const findCalls = (command: string) => commandLines.filter((line) => new RegExp(`\\b${command}\\s`).test(line)); +// VS Code downloads each VSIX from its publisher's own CDN host, so the allowlist has to track this list. +const configuredPublishers = () => { + const devcontainer = readFileSync('.devcontainer/devcontainer.json', 'utf8'); + const extensions = devcontainer.match(/"extensions":\s*\[([^\]]*)\]/)?.[1] ?? ''; + + return [...extensions.matchAll(/"([^".]+)\.[^"]+"/g)].map((match) => match[1].toLowerCase()); +}; + describe('init-firewall.sh', () => { + test('allows only the Compose database port on the Docker network', () => { + const script = commandLines.join('\n'); + + expect(script).toContain('getent ahostsv4 db'); + expect(script).toMatch(/iptables -A NOVISTEPS_OUTPUT -p tcp -d .* --dport 5432 -j ACCEPT/); + expect(script).not.toContain('host_network'); + expect(script).toContain('iptables -A NOVISTEPS_INPUT -p tcp --dport 5173 -j ACCEPT'); + expect(script).toContain('iptables -A NOVISTEPS_INPUT -p tcp --dport 5555 -j ACCEPT'); + }); + + test('allows the hosts that serve the VS Code server and extension packages', () => { + const script = commandLines.join('\n'); + const publishers = configuredPublishers(); + + const allowedPublishers = script.match(/vscode_extension_publishers=\(([^)]*)\)/)?.[1] ?? ''; + + expect(publishers.length).toBeGreaterThan(0); + expect(script).toContain('vscode.download.prss.microsoft.com'); + expect(script).toContain('.gallerycdn.vsassets.io'); + expect( + publishers.filter((publisher) => !allowedPublishers.split(/\s+/).includes(publisher)), + ).toEqual([]); + }); + + test('sets IPv6 output policy before rebuilding its chain on every run', () => { + const script = commandLines.join('\n'); + + expect(script).not.toMatch(/if ! iptables -C OUTPUT -j NOVISTEPS_OUTPUT/); + expect(script).toContain('ip6tables -C OUTPUT -j NOVISTEPS_IPV6'); + expect(script.indexOf('ip6tables -P OUTPUT DROP')).toBeLessThan( + script.indexOf('ip6tables -N NOVISTEPS_IPV6'), + ); + }); + test('bounds every curl call with a total time limit', () => { expect(findCalls('curl').length).toBeGreaterThan(0); expect(findCalls('curl').filter((line) => !line.includes('--max-time'))).toEqual([]); From 3835c7761fbb379249b8889542b5fe956fbba520 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 20 Sep 2026 00:24:06 +0000 Subject: [PATCH 12/12] Remove devcontainer firewall narrowing plan Co-Authored-By: Claude Opus 5 --- .../narrow-devcontainer-firewall/plan.md | 33 ------------------- 1 file changed, 33 deletions(-) delete mode 100644 docs/dev-notes/2026-09-19/narrow-devcontainer-firewall/plan.md diff --git a/docs/dev-notes/2026-09-19/narrow-devcontainer-firewall/plan.md b/docs/dev-notes/2026-09-19/narrow-devcontainer-firewall/plan.md deleted file mode 100644 index 826347360..000000000 --- a/docs/dev-notes/2026-09-19/narrow-devcontainer-firewall/plan.md +++ /dev/null @@ -1,33 +0,0 @@ -# Devcontainer ファイアウォールの許可範囲と再実行の修正 - -## 概要 - -Compose の `web` から `db:5432` への通信を残し、Docker ブリッジ全体への許可を削除する。IPv4 と IPv6 の設定状態を確認し、途中で止まった初期化を再実行できるようにする。 - -## 設計 - -ファイアウォール設定は `.devcontainer/init-firewall.sh` の単一責務とする。既存のユーティリティやサービスに同等の処理はない。Docker DNS で `db` の IPv4 アドレスを取得し、TCP 5432 だけ許可する。公開ポート 5173 と 5555 への着信のみ許可する。初期化済み判定は設けず、再実行時も両系統のチェーンを作り直して欠落を補う。再構築前に DROP ポリシーを設定し、途中失敗で許可的な IPv6 OUTPUT を残さない。 - -完全な `iptables-save` / `restore` による状態保存は、ローカル開発コンテナには複雑すぎるため採用しない。ホスト側の宛先を許可する必要は Compose の通信契約から確認できないため採用しない。 - -## フェーズ - -1. テスト層: `init-firewall.sh` の許可範囲と再実行条件を検証するテストを先に追加する。 -2. 設定層: DB 宛て、着信ポート、IPv4/IPv6 の再実行処理を修正する。 -3. 文書: `CONTRIBUTING.md` の通信範囲、Rebuild、公開ポートの説明を現行設定に合わせる。 -4. 検証: `pnpm test:unit`、変更ファイルの Prettier、`pnpm lint`、`pnpm check`、`git diff --check` を実行する。 - -## Rebuild 後の確認 - -以下のコマンドはホスト側のリポジトリで実行する。`web` と `db` が起動してから確認する。 -全項目を 2026-09-20 に確認した。手順 1・2 の再実行・5 の DB 接続・6 は `web` コンテナ内(`node` ユーザー)から、手順 2 の `postStartCommand`・3・4・5 のホストからの 5173 番ポートはホスト側から確認した。 -`node` ユーザーの `sudo` は `/usr/local/bin/init-firewall.sh` だけが NOPASSWD で許可されており、`iptables` の参照はできないため、残りはホスト側で実行する。 - -1. [x] `docker compose exec web cmp -s .devcontainer/init-firewall.sh /usr/local/bin/init-firewall.sh` が成功すること。イメージ内の実行ファイルが修正版であることを確かめる。 -2. [x] Dev Container の `postStartCommand` が `Firewall configured` で終了すること。 - [x] 続けて `docker compose exec web sudo /usr/local/bin/init-firewall.sh` をもう一度実行し、再実行でも成功することを確かめる(自己チェック 4 件が通り `Firewall configured` で終了)。 -3. [x] `docker compose exec -u root web iptables -S NOVISTEPS_OUTPUT` で、`db` の IPv4 アドレス宛て TCP 5432 の許可があり、Docker サブネット全体またはホスト側への無条件の許可がないことを確かめる。`docker compose exec -u root web iptables -S NOVISTEPS_INPUT` では、着信の許可が公開ポート 5173 と 5555、および既存接続とループバックに限られることを確かめる。 -4. [x] `docker compose exec -u root web iptables -S OUTPUT` と `docker compose exec -u root web ip6tables -S OUTPUT` で、それぞれの `NOVISTEPS_*` チェーンへのジャンプが1つずつあり、両方の OUTPUT ポリシーが DROP であることを確かめる。再実行後もジャンプが増えないことを確認する。手順 2 の再実行を済ませてあるので、ここでジャンプが重複していなければ冪等性の確認を兼ねる。 -5. [x] `docker compose exec web node -e "const socket = require('node:net').connect(5432, 'db'); socket.on('connect', () => { console.log('DB TCP OK'); socket.destroy(); }); socket.on('error', (error) => { console.error(error); process.exitCode = 1; });"` が `DB TCP OK` と表示することを確かめる(手順 2 の再実行の前後どちらも接続できた)。 - [x] アプリを起動している場合はホストから 5173 番ポートにアクセスできることも確認する。 -6. [x] `docker compose exec web curl -I --connect-timeout 5 --max-time 8 https://api.github.com` が通信でき、`docker compose exec web curl -sS --connect-timeout 5 --max-time 8 https://example.com` は接続拒否で終了することを確かめる。これは初期化スクリプト自身の許可先・遮断先チェックの再確認である。