diff --git a/.claude/settings.json b/.claude/settings.json index 602db4f4e..37229864d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -3,13 +3,7 @@ "enabled": true, "allowUnsandboxedCommands": false, "filesystem": { - "denyRead": [ - "~/.ssh", - "~/.claude/.credentials.json", - "~/.codex/auth.json", - "**/.env", - "**/.env.*" - ] + "denyRead": ["~/.ssh", "~/.claude/.credentials.json", "~/.codex/auth.json", "**/.env"] } }, "permissions": { @@ -30,7 +24,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/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..e4278c3d0 --- /dev/null +++ b/.devcontainer/codex-managed-config.toml @@ -0,0 +1,12 @@ +# 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" + +# 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..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,11 +25,14 @@ // "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", // + "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/empty.env b/.devcontainer/empty.env new file mode 100644 index 000000000..e69de29bb diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh new file mode 100644 index 000000000..efcb7a6f9 --- /dev/null +++ b/.devcontainer/init-firewall.sh @@ -0,0 +1,190 @@ +#!/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: 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 + # CodeRabbit CLI + cli.coderabbit.ai + app.coderabbit.ai + 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 + +cleanup() { + local status="$1" + + 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. +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 +time=2 +tries=1 A "${domain}" | grep -E '^[0-9.]+$')"; then + echo "Failed to resolve ${domain}" >&2 + exit 1 + fi + + while IFS= read -r address; do + ipset add -exist "${temporary_set}" "${address}" + done <<<"${ips}" +done + +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 + +# 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() { + 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 + +echo 'Firewall configured' diff --git a/.devcontainer/setup-devcontainer.sh b/.devcontainer/setup-devcontainer.sh index b54f1a157..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...' @@ -32,5 +38,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/.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..a2c076e81 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 git@github.com:AtCoder-NoviSteps/AtCoderNoviSteps.git` で SSH に切り替えてください。 2. 作業ディレクトリを`AtCoderNovisteps`に変更します。 @@ -129,20 +131,16 @@ 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` -- 開発サーバ(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/) - ホーム画面が起動し、ユーザの登録・ログインができれば、環境構築は完了です。 @@ -160,9 +158,17 @@ 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 のトークンをコンテナに共有しないようにします。 +- コンテナ内の `sudo` はファイアウォール専用です。apt のパッケージや Playwright のブラウザは `Dockerfile` を変更して 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`)を追加します。 -秘密鍵はコンテナに 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` が付かない方が秘密鍵)。 @@ -188,17 +194,13 @@ 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` - 以下のリンクをクリックしてください。 - + - また、開発サーバの起動と同時に新しいブラウザタブでアプリを開くこともできます。 @@ -208,6 +210,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 1264b1b27..4e7effe9a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,13 +7,25 @@ WORKDIR /usr/src/app COPY . /usr/src/app RUN apt-get update \ - && apt-get -y install --no-install-recommends bubblewrap fish \ - && chmod u+s /usr/bin/bwrap \ + && 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 +# 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 + +# 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 33624c0b9..057d8c420 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,18 +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 for .devcontainer/init-firewall.sh. cap_add: - NET_ADMIN - - SETGID - - SETUID - - SYS_ADMIN - - SYS_CHROOT - - SYS_PTRACE - security_opt: - - apparmor=unconfined - - seccomp=unconfined ports: - '5173:5173' - '5555:5555' @@ -20,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/guides/claude-code.md b/docs/guides/claude-code.md index 7ef9cb102..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を外さない。 -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を無効にし、agent自身のlogin情報以外の秘密はcontainerに置かない。SSH秘密鍵はmountせずagent forwardingを使い、agentはpushしない。projectのMCP serverは登録しない。外向き通信は [init-firewall.sh](../../.devcontainer/init-firewall.sh) で制限する。 ## 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..e00bae352 100644 --- a/docs/guides/codex.md +++ b/docs/guides/codex.md @@ -11,22 +11,12 @@ ## 実行権限 -`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も有効にする。 -Linux sandboxには `bubblewrap` を使う。Dockerfileでsetuid付きで導入し、composeのweb serviceにnested sandbox用のcapabilityとseccomp / AppArmorの緩和を設定する。 +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は止まらない。 -`on-request` はsandbox外の操作に対する承認方針であり、sandboxの代替ではない。credentialを保護するため、`danger-full-access` と `--dangerously-bypass-approvals-and-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で止める。 ## 動作確認 -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 }, diff --git a/src/test/init-firewall.test.ts b/src/test/init-firewall.test.ts new file mode 100644 index 000000000..49442d9fb --- /dev/null +++ b/src/test/init-firewall.test.ts @@ -0,0 +1,64 @@ +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)); + +// 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([]); + }); + + 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([]); + }); +});