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:
2026-06-16 20:18:24 +00:00
commit f283a371bb
253 changed files with 15975 additions and 0 deletions

8
sandbox/.env.sample Normal file
View 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
View File

@ -0,0 +1,4 @@
.env
__pycache__
target
.container-abra

154
sandbox/Dockerfile Normal file
View 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
View 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
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
docker build -t sandbox .

104
sandbox/claude.py Executable file
View 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()

View 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
View 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
View 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()