Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Comment thread
KATO-Hiro marked this conversation as resolved.
}
},
"permissions": {
Expand All @@ -30,7 +24,6 @@
"Read(~/.claude/.credentials.json)",
"Read(~/.codex/auth.json)",
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/secrets/**)",
"Read(**/config/credentials.json)",
"Read(**/*.pem)",
Expand Down
13 changes: 6 additions & 7 deletions .codex/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions .devcontainer/claude-managed-settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"sandbox": {
"enabled": false
Comment thread
KATO-Hiro marked this conversation as resolved.
}
}
12 changes: 12 additions & 0 deletions .devcontainer/codex-managed-config.toml
Original file line number Diff line number Diff line change
@@ -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"
13 changes: 10 additions & 3 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand All @@ -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": {
Expand Down Expand Up @@ -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.
Expand Down
Empty file added .devcontainer/empty.env
Empty file.
190 changes: 190 additions & 0 deletions .devcontainer/init-firewall.sh
Original file line number Diff line number Diff line change
@@ -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'
11 changes: 11 additions & 0 deletions .devcontainer/setup-devcontainer.sh
Original file line number Diff line number Diff line change
@@ -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...'

Expand Down Expand Up @@ -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
6 changes: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading