#!/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()