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
14 changes: 0 additions & 14 deletions .devcontainer/Dockerfile

This file was deleted.

63 changes: 63 additions & 0 deletions .devcontainer/Dockerfile.app
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
FROM mcr.microsoft.com/devcontainers/base:debian

# ca-certificates, curl, git are already in the devcontainers base image.
# fd-find: fast file finder (aliased to fd below)
# fzf: fuzzy finder for files and command history
# gh: GitHub CLI
# gosu: drops privileges in the entrypoint
# jq: JSON processor
# ripgrep: fast recursive grep (rg)
# tmux: terminal multiplexer
# vim: text editor
RUN apt-get update \
&& apt-get install -y --no-install-recommends fd-find fzf gh gosu jq ripgrep tmux vim \
&& ln -s $(which fdfind) /usr/local/bin/fd \
&& rm -rf /var/lib/apt/lists/*

COPY --chmod=755 sandcat/scripts/app-init.sh /usr/local/bin/app-init.sh
COPY --chmod=755 sandcat/scripts/app-user-init.sh /usr/local/bin/app-user-init.sh
COPY --chown=vscode:vscode sandcat/tmux.conf /home/vscode/.tmux.conf

USER vscode

# Install Claude Code (native binary — no Node.js required).
RUN curl -fsSL https://claude.ai/install.sh | bash

# Install mise (SDK manager) for language toolchains.
RUN curl https://mise.run | sh
# Make mise available in login shells (su - vscode) and Docker CMD/RUN.
RUN echo 'export PATH="/home/vscode/.local/bin:/home/vscode/.local/share/mise/shims:$PATH"' >> /home/vscode/.profile
ENV PATH="/home/vscode/.local/bin:/home/vscode/.local/share/mise/shims:$PATH"

# Development stacks (managed by sandcat init --stacks):
RUN mise use -g java@lts
RUN mise use -g scala@latest && mise use -g sbt@latest
# END STACKS

# If Java was installed above, bake JAVA_HOME and JAVA_TOOL_OPTIONS into
# .bashrc so VS Code's env probe picks them up before the entrypoint runs.
# Without JAVA_HOME, JVM tooling like Metals fails to find the JDK.
# JAVA_TOOL_OPTIONS points to a trust store copy that the entrypoint will
# populate with the mitmproxy CA at runtime; until then it holds the default
# Java CAs (harmless — equivalent to not setting it at all).
# A version-independent symlink is used so .bashrc doesn't need updating
# when the Java version changes — only the symlink target is updated.
RUN if MISE_JAVA=$(mise where java 2>/dev/null); then \
dir="$HOME/.local/share/sandcat"; mkdir -p "$dir"; \
ln -sfn "$MISE_JAVA" "$dir/java-home"; \
cp "$MISE_JAVA/lib/security/cacerts" "$dir/cacerts" 2>/dev/null || true; \
{ echo ''; \
echo '# sandcat-java-env'; \
echo '[ -L "$HOME/.local/share/sandcat/java-home" ] && export JAVA_HOME="$HOME/.local/share/sandcat/java-home"'; \
echo '[ -f "$HOME/.local/share/sandcat/cacerts" ] && export JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=$HOME/.local/share/sandcat/cacerts -Djavax.net.ssl.trustStorePassword=changeit"'; \
} >> "$HOME/.bashrc"; \
fi

# Pre-create ~/.claude so Docker bind-mounts (CLAUDE.md, agents/, commands/)
# don't cause it to be created as root-owned.
RUN mkdir -p /home/vscode/.claude

RUN echo 'alias claude-yolo="claude --dangerously-skip-permissions"' >> /home/vscode/.bashrc

USER root
ENTRYPOINT ["/usr/local/bin/app-init.sh"]
49 changes: 49 additions & 0 deletions .devcontainer/compose-all.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: tapir-sandbox
include:
- path: sandcat/compose-proxy.yml
services:
agent:
build:
context: .
dockerfile: Dockerfile.app
# Share wg-client's network namespace so all traffic goes through its
# WireGuard tunnel. The app container has no NET_ADMIN capability,
# so processes inside cannot modify routing, iptables, or the tunnel.
network_mode: "service:wg-client"
volumes:
# Named volume for the home directory so Claude Code auth state,
# shell history, and other user-level config persist across rebuilds.
- app-home:/home/vscode
# Shared volume from mitmproxy containing the CA cert and
# sandcat.env (env vars + secret placeholders). Read-only — app
# containers should never write to this.
- mitmproxy-config:/mitmproxy-config:ro
# Mount the project's code
- ..:/workspaces/tapir-sandbox
# Read-only devcontainer directory
- ../.devcontainer:/workspaces/tapir-sandbox/.devcontainer:ro
# Read-only settings directory
- ../.sandcat:/workspaces/tapir-sandbox/.sandcat:ro
# Host Claude config (optional)
- ${HOME}/.claude/CLAUDE.md:/home/vscode/.claude/CLAUDE.md:ro
- ${HOME}/.claude/agents:/home/vscode/.claude/agents:ro
- ${HOME}/.claude/commands:/home/vscode/.claude/commands:ro
# Read-only Git directory
# - ../.git:/workspace/.git:ro
# Read-only IntelliJ IDEA project directory
# - ../.idea:/workspace/.idea:ro
command: sleep infinity
environment:
- CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
depends_on:
wg-client:
condition: service_healthy
working_dir: /workspaces/tapir-sandbox
mitmproxy:
volumes:
- ../.sandcat:/config/project:ro
# Project-level settings (.sandcat/ directory). If the directory does
# not exist on the host, Docker creates an empty one and the addon
# simply finds no files — no error.
volumes:
app-home:
97 changes: 52 additions & 45 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,46 +1,53 @@
{
"name": "tapir",
"build": {
"dockerfile": "Dockerfile"
},
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "24"
},
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
"ghcr.io/devcontainers/features/github-cli:1": {
"installDirectlyFromGitHubRelease": true,
"version": "latest"
}
},
"customizations": {
"vscode": {
"extensions": [
"anthropic.claude-code",
"scalameta.metals",
"scala-lang.scala",
"github.vscode-pull-request-github"
],
"settings": {
"terminal.integrated.defaultProfile.linux": "bash",
"claudeCode.allowDangerouslySkipPermissions": true,
"claudeCode.initialPermissionMode": "bypassPermissions",
"claudeCode.selectedModel": "opus"
}
}
},
"postCreateCommand": "bash .devcontainer/post-create.sh",
"postStartCommand": "bash .devcontainer/post-start.sh",
"mounts": [
"type=volume,source=tapir-vscode-home,target=/home/vscode",
"type=bind,source=${localEnv:HOME}/.claude/CLAUDE.md,target=/home/vscode/.claude/CLAUDE.md,readonly",
"type=bind,source=${localEnv:HOME}/.claude/commands,target=/home/vscode/.claude/commands,readonly",
"type=bind,source=${localEnv:HOME}/.claude/agents,target=/home/vscode/.claude/agents,readonly"
],
"containerEnv": {
"SSH_AUTH_SOCK": ""
},
"runArgs": [
"--env-file=${localWorkspaceFolder}/../dev-container-oss.env"
]
}
"name": "tapir-sandbox",
"dockerComposeFile": "compose-all.yml",
"service": "agent",
"workspaceFolder": "/workspaces/tapir-sandbox",
Comment on lines +2 to +5
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

The PR title/description focus on AWS Lambda interpreters, but this change switches the devcontainer to a new multi-container “sandcat” setup and adds a large number of new scripts/configs under .devcontainer/. If these dev-environment changes are intentional, they should be called out explicitly in the PR description; otherwise, consider splitting them into a separate PR to keep the Lambda interpreter change reviewable.

Copilot uses AI. Check for mistakes.
// Remove credential sockets that VS Code forwards into the container
// (SSH agent, git credential helper). Clearing env vars alone only
// hides the paths — the socket files in /tmp can still be discovered
// by scanning. The post-start script deletes them as best-effort
// hardening.
"postStartCommand": "bash /workspaces/tapir-sandbox/.devcontainer/sandcat/scripts/app-post-start.sh",
// VS Code forwards host credential sockets into containers by default.
// Clear them so container code cannot use host SSH keys, GPG signing,
// or VS Code's git credential helpers to authenticate as the host user.
"remoteEnv": {
"SSH_AUTH_SOCK": "",
"GPG_AGENT_INFO": "",
"GIT_ASKPASS": ""
},
"customizations": {
"vscode": {
"extensions": [
"anthropic.claude-code",
"github.vscode-pull-request-github",
"redhat.java",
"scalameta.metals",
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

devcontainer.json contains a trailing comma after the last entry in the extensions array. Unless this file is guaranteed to be parsed as JSONC everywhere, this makes it invalid JSON and can break tooling that expects strict JSON. Removing trailing commas (and keeping JSONC-specific features only when needed) will improve compatibility.

Suggested change
"scalameta.metals",
"scalameta.metals"

Copilot uses AI. Check for mistakes.
],
"settings": {
// Prevent VS Code from copying host .gitconfig into the
// container, which can leak credential helpers and signing
// key references.
"dev.containers.copyGitConfig": false,
// Prompt before trusting workspace settings, which container
// code could modify via the bind-mounted project folder.
"security.workspace.trust.enabled": true,
// Block container extensions from opening a host-side
// terminal, which would bypass the WireGuard tunnel entirely.
// For maximum protection, also set this in your host user
// settings (workspace settings could theoretically override it).
"terminal.integrated.allowLocalTerminal": false,
// Sandcat provides the security boundary (network isolation,
// secret substitution, iptables kill-switch), so permission
// prompts inside the container add friction without meaningful
// security benefit. Remove these if you prefer interactive
// permission approval.
"claudeCode.allowDangerouslySkipPermissions": true,
"claudeCode.initialPermissionMode": "bypassPermissions",
// Optional: override the default Claude model.
"claudeCode.selectedModel": "opus"
}
}
}
}
21 changes: 0 additions & 21 deletions .devcontainer/post-create.sh

This file was deleted.

4 changes: 0 additions & 4 deletions .devcontainer/post-start.sh

This file was deleted.

17 changes: 17 additions & 0 deletions .devcontainer/sandcat/Dockerfile.wg-client
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
FROM debian:trixie-slim

# Dependencies for the WireGuard tunnel and iptables kill switch:
# wireguard-tools - `wg` command to configure WireGuard interfaces
# iproute2 - `ip` command for interface, address, route, and rule management
# iptables - firewall rules used as a kill switch (blocks traffic if tunnel drops)
# jq - parse mitmproxy's wireguard.conf JSON to extract key pairs
# openresolv - `resolvconf` command to configure DNS through the tunnel
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
wireguard-tools iproute2 iptables jq openresolv \
&& rm -rf /var/lib/apt/lists/*

COPY scripts/wg-client-init.sh /usr/local/bin/wg-client-init.sh
RUN chmod +x /usr/local/bin/wg-client-init.sh

ENTRYPOINT ["/usr/local/bin/wg-client-init.sh"]
40 changes: 40 additions & 0 deletions .devcontainer/sandcat/compose-proxy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
services:
# Dedicated networking container that manages the WireGuard tunnel to
# mitmproxy. Only this container has NET_ADMIN — no user code runs here.
wg-client:
build:
context: .
dockerfile: Dockerfile.wg-client
volumes:
- mitmproxy-config:/mitmproxy-config:ro
cap_add:
- NET_ADMIN # required for WireGuard interface and iptables setup
sysctls:
- net.ipv4.conf.all.src_valid_mark=1 # required by WireGuard fwmark routing; can't be set inside the container (/proc/sys is read-only)
command: sleep infinity
depends_on:
mitmproxy:
condition: service_healthy
healthcheck:
test: ["CMD", "test", "-f", "/tmp/wg-ready"]
interval: 2s
timeout: 2s
retries: 15
mitmproxy:
image: ghcr.io/virtuslab/sandcat-mitmproxy-op:latest
command: mitmweb --mode wireguard --web-host 0.0.0.0 --set web_password=mitmproxy -s /scripts/mitmproxy_addon.py
ports:
- "8081" # mitmweb UI; host port assigned dynamically to avoid conflicts
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

In Docker Compose, the short port syntax "8081" maps host port 8081 to container port 8081 (equivalent to 8081:8081); it does not assign a random host port. Either update the comment or use a syntax that requests an ephemeral published port if avoiding conflicts is the goal.

Suggested change
- "8081" # mitmweb UI; host port assigned dynamically to avoid conflicts
- target: 8081
published: 0
protocol: tcp
mode: host # mitmweb UI; host port assigned dynamically to avoid conflicts

Copilot uses AI. Check for mistakes.
volumes:
- mitmproxy-config:/home/mitmproxy/.mitmproxy
- ./scripts/mitmproxy_addon.py:/scripts/mitmproxy_addon.py:ro
- ~/.config/sandcat/settings.json:/config/settings.json:ro
healthcheck:
test: ["CMD", "test", "-f", "/home/mitmproxy/.mitmproxy/wireguard.conf"]
interval: 2s
timeout: 2s
retries: 15
environment:
- OP_SERVICE_ACCOUNT_TOKEN
volumes:
mitmproxy-config:
Binary file not shown.
74 changes: 74 additions & 0 deletions .devcontainer/sandcat/scripts/app-init.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/bin/bash
#
# Entrypoint for containers that share the wg-client's network namespace.
# Installs the mitmproxy CA cert, disables commit signing, loads env vars
# and secret placeholders from sandcat.env, runs vscode-user setup (git
# identity, Java trust store, Claude Code update), then drops to vscode
# and exec's the container's main command.
#
set -e

CA_CERT="/mitmproxy-config/mitmproxy-ca-cert.pem"

# The CA cert is guaranteed to exist: app depends_on wg-client (healthy),
# which depends_on mitmproxy (healthy), whose healthcheck requires the
# WireGuard config — generated after the CA.
if [ ! -f "$CA_CERT" ]; then
echo "mitmproxy CA cert not found at $CA_CERT" >&2
exit 1
fi

cp "$CA_CERT" /usr/local/share/ca-certificates/mitmproxy.crt
update-ca-certificates

# Node.js ignores the system trust store and bundles its own CA certs.
# Point it at the mitmproxy CA so TLS verification works for Node-based
# tools (e.g. Anthropic SDK).
export NODE_EXTRA_CA_CERTS="$CA_CERT"
echo "export NODE_EXTRA_CA_CERTS=\"$CA_CERT\"" > /etc/profile.d/sandcat-node-ca.sh

# GPG keys are not forwarded into the container (credential isolation),
# so commit signing would always fail. Git env vars have the highest
# precedence, overriding system/global/local/worktree config files.
export GIT_CONFIG_COUNT=1
export GIT_CONFIG_KEY_0="commit.gpgsign"
export GIT_CONFIG_VALUE_0="false"
cat > /etc/profile.d/sandcat-git.sh << 'GITEOF'
export GIT_CONFIG_COUNT=1
export GIT_CONFIG_KEY_0="commit.gpgsign"
export GIT_CONFIG_VALUE_0="false"
GITEOF

# Source env vars and secret placeholders (if available)
SANDCAT_ENV="/mitmproxy-config/sandcat.env"
if [ -f "$SANDCAT_ENV" ]; then
. "$SANDCAT_ENV"
# Make vars available to new shells (e.g. VS Code terminals in dev
# containers) that won't inherit the entrypoint's environment.
cp "$SANDCAT_ENV" /etc/profile.d/sandcat-env.sh
count=$(grep -c '^export ' "$SANDCAT_ENV" 2>/dev/null || echo 0)
echo "Loaded $count env var(s) from $SANDCAT_ENV"
grep '^export ' "$SANDCAT_ENV" | sed 's/=.*//' | sed 's/^export / /'
else
echo "No $SANDCAT_ENV found — env vars and secret substitution disabled"
fi

# Run vscode-user tasks: git identity, Java trust store, Claude Code update.
su - vscode -c /usr/local/bin/app-user-init.sh

# Source all sandcat profile.d scripts from /etc/bash.bashrc so env vars
# are available in non-login shells (e.g. VS Code integrated terminals).
# Guard with a marker to avoid duplicating on container restart.
BASHRC_MARKER="# sandcat-profile-source"
if ! grep -q "$BASHRC_MARKER" /etc/bash.bashrc 2>/dev/null; then
cat >> /etc/bash.bashrc << 'BASHRC_EOF'

# sandcat-profile-source
for _f in /etc/profile.d/sandcat-*.sh; do
[ -r "$_f" ] && . "$_f"
done
unset _f
BASHRC_EOF
fi

exec gosu vscode "$@"
Loading
Loading