recipe-maintainer: public snapshot (secrets + deployment plans removed, single commit)
Sanitized single-commit public mirror of recipe-maintainer. - Removed test-ssh/.testenv (live creds); added test-ssh/.testenv.example placeholders. - Removed plans/ and planned-updates/ (deployment-planning docs) so no client/ deployment domains appear in the public repo. - All other secret stores were already gitignored. - docs.coopcloud.tech retained as a submodule (public upstream).
This commit is contained in:
8
sandbox/.env.sample
Normal file
8
sandbox/.env.sample
Normal file
@ -0,0 +1,8 @@
|
||||
# Optional: Anthropic API key (alternatively, run `claude login` inside the container)
|
||||
# ANTHROPIC_API_KEY=sk-ant-xxxxx
|
||||
|
||||
# Optional: GitHub token for gh CLI (PRs, issues, etc.)
|
||||
# GH_TOKEN=ghp_xxxxx
|
||||
|
||||
# Optional: Tailscale auth key for mesh VPN access
|
||||
# TAILSCALE_AUTHKEY=tskey-xxxxx
|
||||
4
sandbox/.gitignore
vendored
Normal file
4
sandbox/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
.env
|
||||
__pycache__
|
||||
target
|
||||
.container-abra
|
||||
154
sandbox/Dockerfile
Normal file
154
sandbox/Dockerfile
Normal file
@ -0,0 +1,154 @@
|
||||
# Claude Code sandboxed container
|
||||
# Runs Claude Code CLI inside Docker so it can't access your host filesystem
|
||||
# directly — only the directory you mount in.
|
||||
|
||||
FROM debian:12-slim
|
||||
|
||||
# ── Core system packages ────────────────────────────────────────────────
|
||||
RUN apt-get update && apt-get install -y \
|
||||
bash \
|
||||
ca-certificates \
|
||||
cmake \
|
||||
curl \
|
||||
build-essential \
|
||||
fd-find \
|
||||
git \
|
||||
jq \
|
||||
less \
|
||||
libclang-dev \
|
||||
libssl-dev \
|
||||
musl-tools \
|
||||
musl-dev \
|
||||
gosu \
|
||||
sudo \
|
||||
openssh-client \
|
||||
pkg-config \
|
||||
python3 \
|
||||
python3-pip \
|
||||
ripgrep \
|
||||
tar \
|
||||
tree \
|
||||
unzip \
|
||||
zip \
|
||||
nodejs \
|
||||
npm \
|
||||
vim \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install GitHub CLI
|
||||
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||
> /etc/apt/sources.list.d/github-cli.list \
|
||||
&& apt-get update && apt-get install -y gh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Playwright browser dependencies + install
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libnspr4 \
|
||||
libnss3 \
|
||||
libnss3-tools \
|
||||
libatk1.0-0 \
|
||||
libatk-bridge2.0-0 \
|
||||
libcups2 \
|
||||
libdrm2 \
|
||||
libxkbcommon0 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxrandr2 \
|
||||
libgbm1 \
|
||||
libpango-1.0-0 \
|
||||
libcairo2 \
|
||||
libasound2 \
|
||||
libatspi2.0-0 \
|
||||
libwayland-client0 \
|
||||
libxshmfence1 \
|
||||
libglib2.0-0 \
|
||||
libdbus-1-3 \
|
||||
fonts-liberation \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --break-system-packages playwright && \
|
||||
python3 -m playwright install --with-deps chromium
|
||||
|
||||
# Provide fd alias for fd-find (Debian names the binary fdfind)
|
||||
RUN ln -s /usr/bin/fdfind /usr/local/bin/fd
|
||||
|
||||
# ── Claude Code CLI ────────────────────────────────────────────────────
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash && \
|
||||
/root/.local/bin/claude --version
|
||||
|
||||
# ── Optional tools (uncomment what you need) ───────────────────────────
|
||||
# These are included because this project uses them — feel free to remove
|
||||
# any you don't need to speed up the build.
|
||||
|
||||
# Hugo (static site generator)
|
||||
ARG HUGO_VERSION=0.154.5
|
||||
ARG TARGETARCH
|
||||
RUN set -eux; \
|
||||
arch="${TARGETARCH:-amd64}"; \
|
||||
case "$arch" in \
|
||||
amd64) hugo_arch="linux-amd64" ;; \
|
||||
arm64) hugo_arch="linux-arm64" ;; \
|
||||
*) echo "Unsupported arch: $arch"; exit 1 ;; \
|
||||
esac; \
|
||||
curl -fsSL "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_${hugo_arch}.tar.gz" -o /tmp/hugo.tar.gz; \
|
||||
tar -xzf /tmp/hugo.tar.gz -C /tmp; \
|
||||
mv /tmp/hugo /usr/local/bin/hugo; \
|
||||
rm -f /tmp/hugo.tar.gz; \
|
||||
hugo version
|
||||
|
||||
# Terraform
|
||||
ARG TERRAFORM_VERSION=1.11.2
|
||||
RUN set -eux; \
|
||||
arch="${TARGETARCH:-amd64}"; \
|
||||
curl -fsSL "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_${arch}.zip" -o /tmp/terraform.zip; \
|
||||
unzip /tmp/terraform.zip -d /usr/local/bin/; \
|
||||
rm -f /tmp/terraform.zip; \
|
||||
terraform version
|
||||
|
||||
# Rust (rustup + cargo)
|
||||
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y && \
|
||||
/root/.cargo/bin/rustup target add x86_64-unknown-linux-musl
|
||||
|
||||
# abra (Co-op Cloud CLI)
|
||||
RUN curl -fsSL https://install.abra.coopcloud.tech | bash
|
||||
|
||||
# Caddy (web server / reverse proxy)
|
||||
RUN curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/gpg.key \
|
||||
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg && \
|
||||
echo "deb [signed-by=/usr/share/keyrings/caddy-stable-archive-keyring.gpg] https://dl.cloudsmith.io/public/caddy/stable/deb/debian any-version main" \
|
||||
> /etc/apt/sources.list.d/caddy-stable.list && \
|
||||
apt-get update && apt-get install -y caddy && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Tailscale (VPN / mesh networking)
|
||||
RUN curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.noarmor.gpg \
|
||||
-o /usr/share/keyrings/tailscale-archive-keyring.gpg && \
|
||||
curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.tailscale-keyring.list \
|
||||
-o /etc/apt/sources.list.d/tailscale.list && \
|
||||
apt-get update && apt-get install -y tailscale && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── PATH & permissions ─────────────────────────────────────────────────
|
||||
ENV PATH="/root/.local/bin:/root/.claude/bin:/root/.cargo/bin:${PATH}"
|
||||
|
||||
# Make Claude Code + abra binaries readable by non-root users
|
||||
RUN chmod 755 /root && \
|
||||
mkdir -p /root/.claude /root/.local/bin /root/.config && \
|
||||
chmod -R a+rx /root/.claude /root/.local /root/.config && \
|
||||
chmod a+rx /usr/local/bin/abra 2>/dev/null || true
|
||||
|
||||
# Pre-create home directory for the claude user
|
||||
RUN mkdir -p /home/claude/.claude /home/claude/.local/bin /home/claude/.config /home/claude/.abra && \
|
||||
chmod -R 755 /home/claude
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD []
|
||||
147
sandbox/README.md
Normal file
147
sandbox/README.md
Normal file
@ -0,0 +1,147 @@
|
||||
# sandbox
|
||||
|
||||
Run [Claude Code](https://docs.anthropic.com/en/docs/claude-code) inside a Docker container so it only has access to the directory you explicitly mount in — not your whole filesystem.
|
||||
|
||||
## Why?
|
||||
|
||||
Claude Code runs shell commands, edits files, and installs packages. Running it in a container means:
|
||||
|
||||
- It can only see the project directory you mount in (not `~/.ssh`, `~/.gnupg`, etc.)
|
||||
- Sensitive files (`.env`, `secret.json`, etc.) are automatically hidden via `/dev/null` mounts
|
||||
- Sensitive directories (`~/.ssh`, `~/.gnupg`) under the workspace are hidden via tmpfs overlays
|
||||
- Your host system stays untouched — packages Claude installs stay in the container
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# 1. Build the image
|
||||
./build.sh
|
||||
|
||||
# 2. (Optional) Set up environment variables
|
||||
cp .env.sample .env
|
||||
# Edit .env if you want to pre-configure API keys
|
||||
|
||||
# 3. Run Claude Code on a project directory
|
||||
python3 claude.py
|
||||
```
|
||||
|
||||
This drops you into Claude Code with your current working directory mounted at `/workspace` inside the container.
|
||||
|
||||
## How it works
|
||||
|
||||
### `claude.py` — the launcher
|
||||
|
||||
The Python wrapper does three things:
|
||||
|
||||
1. **Scans your project** for sensitive files (`.env`, `.envrc`, `secret.json`, `secrets.json`) and generates a Docker Compose override that mounts `/dev/null` over each one
|
||||
2. **Hides sensitive directories** (`~/.ssh`, `~/.gnupg`) that fall under the mounted workspace using tmpfs overlays
|
||||
3. **Runs `docker compose run`** with your current directory mounted at `/workspace`
|
||||
|
||||
### `entrypoint.sh` — user mapping
|
||||
|
||||
The entrypoint creates a non-root `claude` user inside the container whose UID/GID matches your host user. This means files Claude creates or edits have the right ownership on your host filesystem.
|
||||
|
||||
### `docker-compose.yml` — persistence
|
||||
|
||||
A named Docker volume (`claude_userhome`) persists Claude's settings, auth tokens, and conversation history across runs.
|
||||
|
||||
## Usage
|
||||
|
||||
### Set up a shell alias
|
||||
|
||||
Add this to your `~/.bashrc` or `~/.zshrc` for convenience:
|
||||
|
||||
```bash
|
||||
alias claude='python3 /path/to/sandbox/claude.py'
|
||||
```
|
||||
|
||||
Then from any project directory:
|
||||
|
||||
```bash
|
||||
cd ~/projects/my-app
|
||||
claude # launches Claude Code with my-app mounted
|
||||
claude . # same thing
|
||||
claude --help # pass any flags through to Claude Code
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
You have two options:
|
||||
|
||||
1. **Interactive login** — run `claude login` inside the container. Credentials persist in the `claude_userhome` volume.
|
||||
2. **API key** — copy `.env.sample` to `.env` and set `ANTHROPIC_API_KEY`.
|
||||
|
||||
### Passing extra arguments
|
||||
|
||||
Any arguments to `claude.py` are forwarded to the Claude Code CLI:
|
||||
|
||||
```bash
|
||||
python3 claude.py --help
|
||||
python3 claude.py --model sonnet
|
||||
python3 claude.py -p "explain this codebase"
|
||||
```
|
||||
|
||||
## What's in the image
|
||||
|
||||
The Dockerfile installs:
|
||||
|
||||
- **Core**: git, Node.js, Python 3, ripgrep, fd, gh (GitHub CLI), vim
|
||||
- **Build tools**: cmake, build-essential, pkg-config
|
||||
- **Rust**: rustup + cargo (with musl target)
|
||||
- **Playwright**: Chromium + browser automation deps
|
||||
- **Hugo**: static site generator
|
||||
- **Terraform**: infrastructure as code
|
||||
- **abra**: [Co-op Cloud](https://coopcloud.tech) CLI
|
||||
- **Caddy**: web server / reverse proxy
|
||||
- **Tailscale**: mesh VPN
|
||||
|
||||
The tools beyond core are included because this project (a Co-op Cloud recipe maintainer) uses them. If you're adapting this for your own use, remove or comment out what you don't need in the Dockerfile to speed up the build, and add your own tools:
|
||||
|
||||
```dockerfile
|
||||
# Example: add Go
|
||||
RUN curl -fsSL https://go.dev/dl/go1.22.0.linux-amd64.tar.gz | tar -xz -C /usr/local
|
||||
```
|
||||
|
||||
Then rebuild with `./build.sh`.
|
||||
|
||||
## Sensitive file protection
|
||||
|
||||
The launcher automatically hides files matching these names anywhere in your project tree:
|
||||
|
||||
- `.env`
|
||||
- `.envrc`
|
||||
- `secret.json`
|
||||
- `secrets.json`
|
||||
|
||||
And hides these directories if they exist under the mounted workspace:
|
||||
|
||||
- `~/.ssh`
|
||||
- `~/.gnupg`
|
||||
|
||||
To add more, edit the `SENSITIVE_NAMES` set or `SENSITIVE_DIRS` list in `claude.py`.
|
||||
|
||||
## Running the tests
|
||||
|
||||
The test suite verifies that sensitive files are properly hidden inside the container:
|
||||
|
||||
```bash
|
||||
python3 tests/test_env_hiding.py
|
||||
```
|
||||
|
||||
Requires the `sandbox` image to be built first.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Permission denied" on mounted files** — make sure `HOST_UID` / `HOST_GID` are passed correctly. The `claude.py` launcher handles this automatically; if using `docker compose` directly, set them:
|
||||
|
||||
```bash
|
||||
HOST_UID=$(id -u) HOST_GID=$(id -g) docker compose run --rm claude
|
||||
```
|
||||
|
||||
**Claude Code not found** — rebuild the image (`./build.sh`). The install script fetches the latest version at build time.
|
||||
|
||||
**Persisted state issues** — to reset Claude's saved state, remove the Docker volume:
|
||||
|
||||
```bash
|
||||
docker volume rm sandbox_claude_userhome
|
||||
```
|
||||
3
sandbox/build.sh
Executable file
3
sandbox/build.sh
Executable file
@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
docker build -t sandbox .
|
||||
104
sandbox/claude.py
Executable file
104
sandbox/claude.py
Executable file
@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
SENSITIVE_NAMES = {".env", ".envrc", "secret.json", "secrets.json"}
|
||||
|
||||
SENSITIVE_DIRS = [
|
||||
os.path.expanduser("~/.ssh"),
|
||||
os.path.expanduser("~/.gnupg"),
|
||||
]
|
||||
|
||||
OVERRIDE_HEADER = """\
|
||||
services:
|
||||
claude:
|
||||
volumes:
|
||||
"""
|
||||
|
||||
|
||||
def find_sensitive_files(workspace):
|
||||
"""Walk workspace and return paths to sensitive files."""
|
||||
matches = []
|
||||
for dirpath, _dirnames, filenames in os.walk(workspace):
|
||||
for name in filenames:
|
||||
if name in SENSITIVE_NAMES:
|
||||
matches.append(os.path.join(dirpath, name))
|
||||
return matches
|
||||
|
||||
|
||||
def find_sensitive_dirs(workspace):
|
||||
"""Return SENSITIVE_DIRS entries that exist and fall under workspace."""
|
||||
workspace = os.path.realpath(workspace)
|
||||
matches = []
|
||||
for d in SENSITIVE_DIRS:
|
||||
real = os.path.realpath(d)
|
||||
if real.startswith(workspace + os.sep) and os.path.isdir(real):
|
||||
matches.append(real)
|
||||
return matches
|
||||
|
||||
|
||||
def generate_override(workspace, sensitive_files, sensitive_dirs=None):
|
||||
"""Write a compose override YAML that mounts /dev/null over each file
|
||||
and tmpfs over each sensitive directory."""
|
||||
fd, path = tempfile.mkstemp(prefix="claude-env-override-", suffix=".yml")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(OVERRIDE_HEADER)
|
||||
if sensitive_files:
|
||||
for host_path in sensitive_files:
|
||||
rel = os.path.relpath(host_path, workspace)
|
||||
f.write(f" - /dev/null:/workspace/{rel}:ro\n")
|
||||
else:
|
||||
f.write(" []\n")
|
||||
if sensitive_dirs:
|
||||
f.write(" tmpfs:\n")
|
||||
for dir_path in sensitive_dirs:
|
||||
rel = os.path.relpath(dir_path, workspace)
|
||||
f.write(f" - /workspace/{rel}:ro\n")
|
||||
return path
|
||||
|
||||
|
||||
def main():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
workspace = os.getcwd()
|
||||
|
||||
sensitive_files = find_sensitive_files(workspace)
|
||||
sensitive_dirs = find_sensitive_dirs(workspace)
|
||||
override_file = generate_override(workspace, sensitive_files, sensitive_dirs)
|
||||
atexit.register(lambda: os.unlink(override_file))
|
||||
|
||||
compose_file = os.path.join(script_dir, "docker-compose.yml")
|
||||
uid = os.getuid()
|
||||
gid = os.getgid()
|
||||
|
||||
print(f"++ loading {workspace}")
|
||||
|
||||
cmd = [
|
||||
"docker", "compose",
|
||||
"-f", compose_file,
|
||||
"-f", override_file,
|
||||
"run", "--rm",
|
||||
]
|
||||
|
||||
# Allocate TTY only when a human is at the terminal
|
||||
if not sys.stdin.isatty():
|
||||
cmd.append("-T")
|
||||
|
||||
cmd.extend([
|
||||
"-v", f"{workspace}:/workspace",
|
||||
"-v", f"{workspace}/.container-abra:/home/claude/.abra",
|
||||
"claude",
|
||||
*sys.argv[1:],
|
||||
])
|
||||
|
||||
env = {**os.environ, "HOST_UID": str(uid), "HOST_GID": str(gid)}
|
||||
|
||||
result = subprocess.run(cmd, env=env)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
22
sandbox/docker-compose.yml
Normal file
22
sandbox/docker-compose.yml
Normal file
@ -0,0 +1,22 @@
|
||||
services:
|
||||
claude:
|
||||
image: sandbox
|
||||
env_file:
|
||||
- path: ./.env
|
||||
required: false
|
||||
environment:
|
||||
HOST_UID: "${HOST_UID:-0}"
|
||||
HOST_GID: "${HOST_GID:-0}"
|
||||
volumes:
|
||||
- .:/workspace # default; overridden by claude.py at runtime
|
||||
- claude_target:/workspace/target # persist Rust build cache
|
||||
- claude_userhome:/home/claude # persist Claude settings, auth, history
|
||||
- ./.container-abra:/home/claude/.abra # default; overridden by claude.py at runtime
|
||||
- tailscale_state:/var/lib/tailscale # persist Tailscale auth across restarts
|
||||
tty: true
|
||||
stdin_open: true
|
||||
|
||||
volumes:
|
||||
claude_target:
|
||||
claude_userhome:
|
||||
tailscale_state:
|
||||
73
sandbox/entrypoint.sh
Normal file
73
sandbox/entrypoint.sh
Normal file
@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HOST_UID=${HOST_UID:-0}
|
||||
HOST_GID=${HOST_GID:-0}
|
||||
DEFAULT_UID=${DEFAULT_UID:-1000}
|
||||
DEFAULT_GID=${DEFAULT_GID:-1000}
|
||||
|
||||
CLAUDE_USER=${CLAUDE_USER:-claude}
|
||||
CLAUDE_GROUP=${CLAUDE_GROUP:-claude}
|
||||
CLAUDE_HOME=${CLAUDE_HOME:-/home/${CLAUDE_USER}}
|
||||
|
||||
if [ "$HOST_UID" -eq 0 ] && [ "$HOST_GID" -eq 0 ]; then
|
||||
HOST_UID=$DEFAULT_UID
|
||||
HOST_GID=$DEFAULT_GID
|
||||
fi
|
||||
|
||||
if getent group "$HOST_GID" >/dev/null 2>&1; then
|
||||
CLAUDE_GROUP="$(getent group "$HOST_GID" | cut -d: -f1)"
|
||||
else
|
||||
groupadd -g "$HOST_GID" "$CLAUDE_GROUP"
|
||||
fi
|
||||
|
||||
# Ensure home exists, but don't recreate it
|
||||
if [ ! -d "$CLAUDE_HOME" ]; then
|
||||
mkdir -p "$CLAUDE_HOME"
|
||||
chown "$HOST_UID:$HOST_GID" "$CLAUDE_HOME"
|
||||
fi
|
||||
chown -R "$HOST_UID:$HOST_GID" "$CLAUDE_HOME"
|
||||
|
||||
if id -u "$CLAUDE_USER" >/dev/null 2>&1; then
|
||||
# Do NOT change -d (home) for existing user
|
||||
usermod -u "$HOST_UID" -g "$CLAUDE_GROUP" "$CLAUDE_USER"
|
||||
else
|
||||
# Only use -m when home doesn't already exist
|
||||
useradd -u "$HOST_UID" -g "$CLAUDE_GROUP" -d "$CLAUDE_HOME" -s /bin/bash "$CLAUDE_USER"
|
||||
fi
|
||||
|
||||
# Grant passwordless sudo to claude user
|
||||
echo "$CLAUDE_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/claude
|
||||
chmod 440 /etc/sudoers.d/claude
|
||||
|
||||
install -d -m 0755 -o "$HOST_UID" -g "$HOST_GID" "$CLAUDE_HOME/.local"
|
||||
install -d -m 0755 -o "$HOST_UID" -g "$HOST_GID" "$CLAUDE_HOME/.local/bin"
|
||||
install -d -m 0755 -o "$HOST_UID" -g "$HOST_GID" "$CLAUDE_HOME/.config"
|
||||
install -d -m 0755 -o "$HOST_UID" -g "$HOST_GID" "$CLAUDE_HOME/.claude"
|
||||
install -d -m 0755 -o "$HOST_UID" -g "$HOST_GID" "$CLAUDE_HOME/.abra"
|
||||
|
||||
# Copy Claude binaries to user's local bin (always, to ensure upgrades apply)
|
||||
cp -r /root/.local/bin/* "$CLAUDE_HOME/.local/bin/" 2>/dev/null || true
|
||||
chown -R "$HOST_UID:$HOST_GID" "$CLAUDE_HOME/.local/bin"
|
||||
|
||||
if [ -d /workspace ]; then
|
||||
chown -R "$HOST_UID:$HOST_GID" /workspace 2>/dev/null || true
|
||||
fi
|
||||
if [ -d /workspace/target ]; then
|
||||
chown -R "$HOST_UID:$HOST_GID" /workspace/target 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Set up PATH in user's bashrc to avoid "~/.local/bin not in PATH" warning
|
||||
if ! grep -q 'export PATH="\$HOME/.local/bin:\$PATH"' "$CLAUDE_HOME/.bashrc" 2>/dev/null; then
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$CLAUDE_HOME/.bashrc"
|
||||
chown "$HOST_UID:$HOST_GID" "$CLAUDE_HOME/.bashrc"
|
||||
fi
|
||||
|
||||
export PATH="$CLAUDE_HOME/.local/bin:$CLAUDE_HOME/.cargo/bin:/usr/local/bin:$PATH"
|
||||
|
||||
if [ $# -gt 0 ]; then
|
||||
exec gosu "$CLAUDE_USER" "$@"
|
||||
else
|
||||
exec gosu "$CLAUDE_USER" "$CLAUDE_HOME/.local/bin/claude" --dangerously-skip-permissions
|
||||
fi
|
||||
263
sandbox/tests/test_env_hiding.py
Executable file
263
sandbox/tests/test_env_hiding.py
Executable file
@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test that sensitive files (.env, .envrc, secret.json, secrets.json) and
|
||||
sensitive directories (.ssh) are hidden from the container user via Docker
|
||||
compose overrides (/dev/null mounts for files, tmpfs for directories).
|
||||
|
||||
All test files are created inside tests/fixtures/env-hiding/ to avoid touching
|
||||
any real sensitive files in the repository root.
|
||||
|
||||
Uses the real docker-compose.yml plus a generated override file.
|
||||
|
||||
Usage: python tests/test_env_hiding.py
|
||||
Requires: docker compose, the "sandbox" image already built.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO_ROOT = os.path.dirname(SCRIPT_DIR)
|
||||
FIXTURE_DIR = os.path.join(SCRIPT_DIR, "fixtures", "env-hiding")
|
||||
COMPOSE_FILE = os.path.join(REPO_ROOT, "docker-compose.yml")
|
||||
SERVICE = "claude"
|
||||
CONTAINER_FIXTURE = "/workspace/tests/fixtures/env-hiding"
|
||||
|
||||
# Import helpers from claude.py
|
||||
sys.path.insert(0, REPO_ROOT)
|
||||
from claude import find_sensitive_files, find_sensitive_dirs, generate_override
|
||||
|
||||
# Fixtures: (relative path, content)
|
||||
FIXTURES = [
|
||||
(".env", "SECRET=hunter2"),
|
||||
(".envrc", "ENVRC_SECRET=s3cret"),
|
||||
("subdir/.env", "NESTED_SECRET=deep"),
|
||||
("subdir/.envrc", "NESTED_ENVRC=deeper"),
|
||||
("secret.json", '{"api_key":"top-secret"}'),
|
||||
("secrets.json", '{"tokens":["abc123"]}'),
|
||||
("subdir/secret.json", '{"db_pass":"hidden"}'),
|
||||
("subdir/secrets.json", '{"creds":["xyz789"]}'),
|
||||
]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
|
||||
def assert_eq(desc, expected, actual):
|
||||
global passed, failed
|
||||
if expected == actual:
|
||||
print(f" PASS: {desc}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL: {desc} (expected={expected!r}, got={actual!r})")
|
||||
failed += 1
|
||||
|
||||
|
||||
def assert_contains(desc, needle, haystack):
|
||||
global passed, failed
|
||||
if needle.lower() in haystack.lower():
|
||||
print(f" PASS: {desc}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL: {desc} (expected to contain {needle!r}, got {haystack!r})")
|
||||
failed += 1
|
||||
|
||||
|
||||
def dc_run(override_file, *args):
|
||||
"""Run a command in the container with TTY disabled."""
|
||||
print(" [dc_run] Starting container...")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker", "compose",
|
||||
"-f", COMPOSE_FILE,
|
||||
"-f", override_file,
|
||||
"run", "-T", "--rm", "--no-deps",
|
||||
SERVICE, *args,
|
||||
],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
if result.returncode != 0:
|
||||
print(f" [dc_run] Container exited with code {result.returncode}")
|
||||
for line in output.splitlines():
|
||||
print(f" {line}")
|
||||
else:
|
||||
print(" [dc_run] Container exited successfully.")
|
||||
return output
|
||||
|
||||
|
||||
# .ssh fixture lives at the repo root so the dynamic tmpfs on /workspace/.ssh covers it
|
||||
SSH_FIXTURE_DIR = os.path.join(REPO_ROOT, ".ssh")
|
||||
SSH_FILES = {
|
||||
"id_rsa": "-----BEGIN FAKE KEY-----\nsuper-secret\n-----END FAKE KEY-----",
|
||||
"config": "Host *\n StrictHostKeyChecking no",
|
||||
}
|
||||
|
||||
|
||||
def create_fixtures():
|
||||
"""Create test fixture files and return a dict of path -> (content, octal_perm)."""
|
||||
os.makedirs(os.path.join(FIXTURE_DIR, "subdir"), exist_ok=True)
|
||||
originals = {}
|
||||
for rel_path, content in FIXTURES:
|
||||
full = os.path.join(FIXTURE_DIR, rel_path)
|
||||
with open(full, "w") as f:
|
||||
f.write(content)
|
||||
os.chmod(full, 0o644)
|
||||
originals[rel_path] = (content, oct(os.stat(full).st_mode & 0o777))
|
||||
|
||||
# Create .ssh fixture directory
|
||||
os.makedirs(SSH_FIXTURE_DIR, exist_ok=True)
|
||||
for name, content in SSH_FILES.items():
|
||||
full = os.path.join(SSH_FIXTURE_DIR, name)
|
||||
with open(full, "w") as f:
|
||||
f.write(content)
|
||||
os.chmod(full, 0o600)
|
||||
|
||||
return originals
|
||||
|
||||
|
||||
def cleanup():
|
||||
shutil.rmtree(FIXTURE_DIR, ignore_errors=True)
|
||||
shutil.rmtree(SSH_FIXTURE_DIR, ignore_errors=True)
|
||||
|
||||
|
||||
def main():
|
||||
print("=== Test: sensitive file hiding in Docker container ===\n")
|
||||
|
||||
# --- Setup ---
|
||||
print(f"Setting up test fixtures in {FIXTURE_DIR} ...")
|
||||
originals = create_fixtures()
|
||||
print("Test fixtures created with permission 644.\n")
|
||||
|
||||
# Generate override for sensitive files and directories
|
||||
sensitive = find_sensitive_files(REPO_ROOT)
|
||||
sensitive_dirs = find_sensitive_dirs(REPO_ROOT)
|
||||
# Append SSH fixture dir explicitly (it won't match SENSITIVE_DIRS paths)
|
||||
if SSH_FIXTURE_DIR not in sensitive_dirs:
|
||||
sensitive_dirs.append(SSH_FIXTURE_DIR)
|
||||
override_file = generate_override(REPO_ROOT, sensitive, sensitive_dirs)
|
||||
|
||||
try:
|
||||
with open(override_file) as f:
|
||||
print("Override file contents:")
|
||||
for line in f:
|
||||
print(f" {line}", end="")
|
||||
print()
|
||||
|
||||
# --- Test 1: sensitive files appear empty in container ---
|
||||
print("--- Test 1: sensitive files appear empty to container user (mounted from /dev/null) ---")
|
||||
|
||||
# Build a shell command that cats each fixture and prints markers
|
||||
cat_cmds = []
|
||||
for rel_path, _ in FIXTURES:
|
||||
label = rel_path.replace("/", " ")
|
||||
container_path = f"{CONTAINER_FIXTURE}/{rel_path}"
|
||||
cat_cmds.append(f"echo '--- {label} ---'; cat {container_path} 2>&1")
|
||||
cat_cmds.append("echo '--- END ---'")
|
||||
shell_script = "; ".join(cat_cmds)
|
||||
|
||||
output = dc_run(override_file, "bash", "-c", shell_script)
|
||||
lines = output.splitlines()
|
||||
|
||||
# Parse output between markers — content between each pair should be empty
|
||||
for i, (rel_path, _) in enumerate(FIXTURES):
|
||||
label = rel_path.replace("/", " ")
|
||||
start_marker = f"--- {label} ---"
|
||||
if i + 1 < len(FIXTURES):
|
||||
end_marker = f"--- {FIXTURES[i + 1][0].replace('/', ' ')} ---"
|
||||
else:
|
||||
end_marker = "--- END ---"
|
||||
|
||||
# Extract lines between markers
|
||||
capturing = False
|
||||
content_lines = []
|
||||
for line in lines:
|
||||
if end_marker in line:
|
||||
capturing = False
|
||||
if capturing:
|
||||
content_lines.append(line)
|
||||
if start_marker in line:
|
||||
capturing = True
|
||||
|
||||
actual = "\n".join(content_lines)
|
||||
assert_eq(f"{rel_path} is empty", "", actual)
|
||||
|
||||
print()
|
||||
|
||||
# --- Test 2: host permissions unchanged ---
|
||||
print("--- Test 2: Host permissions unchanged after container stops ---")
|
||||
for rel_path, (_, orig_perm) in originals.items():
|
||||
full = os.path.join(FIXTURE_DIR, rel_path)
|
||||
current_perm = oct(os.stat(full).st_mode & 0o777)
|
||||
assert_eq(f"{rel_path} permissions unchanged", orig_perm, current_perm)
|
||||
|
||||
print()
|
||||
|
||||
# --- Test 3: host file contents intact ---
|
||||
print("--- Test 3: File contents are intact on host ---")
|
||||
for rel_path, (orig_content, _) in originals.items():
|
||||
full = os.path.join(FIXTURE_DIR, rel_path)
|
||||
with open(full) as f:
|
||||
actual = f.read().strip()
|
||||
assert_eq(f"{rel_path} content intact", orig_content, actual)
|
||||
|
||||
print()
|
||||
|
||||
# --- Test 4: non-sensitive files remain accessible ---
|
||||
print("--- Test 4: Non-sensitive files remain accessible in container ---")
|
||||
output = dc_run(
|
||||
override_file, "bash", "-c",
|
||||
"if [ -r /workspace/entrypoint.sh ]; then echo readable; else echo not readable; fi",
|
||||
)
|
||||
assert_contains("entrypoint.sh is readable", "readable", output)
|
||||
|
||||
print()
|
||||
|
||||
# --- Test 5: .ssh directory is hidden via dynamic tmpfs overlay ---
|
||||
print("--- Test 5: .ssh directory is empty in container (tmpfs overlay) ---")
|
||||
container_ssh = "/workspace/.ssh"
|
||||
output = dc_run(
|
||||
override_file, "bash", "-c",
|
||||
f"echo FILES=$(ls -A {container_ssh} 2>&1); cat {container_ssh}/id_rsa 2>&1 || true",
|
||||
)
|
||||
assert_contains(".ssh directory listing is empty", "FILES=", output)
|
||||
# ls -A of an empty tmpfs returns nothing after "FILES="
|
||||
for line in output.splitlines():
|
||||
if line.startswith("FILES="):
|
||||
listing = line[len("FILES="):]
|
||||
assert_eq(".ssh contains no files", "", listing)
|
||||
break
|
||||
assert_contains(".ssh/id_rsa not accessible", "no such file", output)
|
||||
|
||||
print()
|
||||
|
||||
# --- Test 5b: .ssh host contents intact ---
|
||||
print("--- Test 5b: .ssh host contents intact after container stops ---")
|
||||
for name, expected_content in SSH_FILES.items():
|
||||
full = os.path.join(SSH_FIXTURE_DIR, name)
|
||||
with open(full) as f:
|
||||
actual = f.read().strip()
|
||||
assert_eq(f".ssh/{name} content intact on host", expected_content.strip(), actual)
|
||||
|
||||
print()
|
||||
|
||||
finally:
|
||||
os.unlink(override_file)
|
||||
cleanup()
|
||||
|
||||
# --- Summary ---
|
||||
total = passed + failed
|
||||
print("===============================")
|
||||
print(f"Results: {passed}/{total} passed, {failed} failed")
|
||||
print("===============================")
|
||||
|
||||
if failed > 0:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user