sandbox Claude Code image with minor dev environments
  • Shell 90.2%
  • Dockerfile 9.8%
Find a file
ci-bot 6918042305
Some checks failed
release / tag (push) Successful in 7s
build-and-push / test-1 (push) Successful in 25s
build-and-push / test (push) Successful in 0s
build-and-push / build (push) Failing after 12m3s
deps: daily auto-bump (#69)
2026-06-22 22:36:54 +02:00
.forgejo/workflows change daily bot to 22:35 CET 2026-05-13 22:31:59 +02:00
images deps: daily auto-bump (#69) 2026-06-22 22:36:54 +02:00
installer multi-arch images + Linux/Intel-Mac host + project rename to scc 2026-04-29 18:25:54 +02:00
lib refactor: inline claude-args helpers into scc.sh; drop lib/claude-args.sh (fix also broken installer issue on 1.1.1 with missing claude-args.sh file) 2026-05-10 02:31:21 +02:00
tests feat(launcher): per-project Claude volume override (#55) 2026-05-26 17:01:56 +02:00
.gitignore .scc to .gitignore 2026-05-04 19:28:14 +02:00
.pre-commit-config.yaml feat: pass-through claude args via --ca / project config + test suite 2026-05-10 01:34:18 +02:00
build.sh build.sh: add per-flavor :<flavor>-latest rolling tags 2026-05-03 22:20:29 +02:00
bump-deps.sh feat(images): rename android-flutter → android-flutter-ios + add CocoaPods (#58) 2026-05-29 16:13:31 +02:00
install.sh install.zsh → install.sh: portable bash, macOS + Linux 2026-04-29 19:09:16 +02:00
README.md feat(images): rename android-flutter → android-flutter-ios + add CocoaPods (#58) 2026-05-29 16:13:31 +02:00
scc.sh fix(entrypoint): locate Claude binary by path (Claude 2.1+ native install) (#56) 2026-05-26 17:14:20 +02:00
scc.sh.version fix(entrypoint): locate Claude binary by path (Claude 2.1+ native install) (#56) 2026-05-26 17:14:20 +02:00

scc

⚠️ Not affiliated with or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic, PBC. This is an unofficial third-party wrapper.

Sandboxed Docker environment for running Claude Code against a workspace without giving it access to the host. Installed CLI command: scc.

Supply chain: published images are built without provenance or SBOM attestations (--provenance=false --sbom=false) — there is no signed proof of where or how they were built. If that matters to you, build locally from this repo (./build.sh) instead of pulling the published tags. Will be implemented soon.

Quick Start

Install (macOS or Linux):

curl -fsSL https://forge.stacktop.network/openstacktop/scc/raw/branch/main/installer/i.sh | bash

Then run it against any project:

cd /your/project
scc

See Running for all flags and options.

Migrating from sclaude? State now lives in ~/.scc/. The launcher auto-renames ~/.sclaude/ on first run if present.

Local Setup Instructions

./install.sh                 # link scc into ~/bin
./build.sh                   # build the default flavor (Fedora + Claude + Python/Rust/Go)
scc .                        # run Claude Code with $PWD mounted at /workspace

Claude state (~/.claude, .claude.json) lives in ~/.scc/ on the host and survives container restarts.

Testing

Tests use bats-core and live under tests/unit/ (fast, isolated) and tests/feature/ (end-to-end, slower).

Run the whole suite locally:

bats --recursive tests/

Just the unit tests (what pre-commit runs):

bats tests/unit/

Pre-commit hooks

The repo ships a pre-commit config that runs shellcheck + the unit-test suite on every git commit. Install once per clone:

pip install pre-commit
pre-commit install

pre-commit run --all-files re-runs everything against the whole tree.

CI

Tests run automatically on every push to dev and on every pull request via .forgejo/workflows/test.yml. The release/build pipeline (build.yml) declares needs: test, so a tag push won't produce published images unless the test suite is green.

Passing args to claude

Anything you'd normally pass to claude on the host can be forwarded into the container with --ca (alias --claude-args):

scc --ca "--print 'review this CHANGELOG'"            # one-shot non-interactive run
scc --ca "--model sonnet" --ca "--output-format json" # repeat to accumulate args
scc --ca="--debug" .                                  # also accepts --ca=VALUE
scc -c . -- --some-claude-flag                        # `--` appends literally, no tokenization

Tokenization is shell-style: single/double quotes group, backslashes escape. $VAR and $(…) are NOT expanded — that's a security property, not an oversight, since project configs feed the same parser.

Project-pinned defaults live in .scc/config.ini under claude_args = … (see Project configs below). Project args are prepended before any --ca you type on the CLI, so claude's own arg parsing makes the CLI override.

Denylist (security gate)

A small set of risky flags are rejected before reaching claude:

Flag Reason
--dangerously-skip-permissions bypasses claude's tool-use confirmations
--mcp-config, --mcp-server loads arbitrary code into claude's tool surface
--add-dir expands filesystem access beyond /workspace
--proxy, --proxy-anyway exfiltration channel

Both bare (--flag) and =value (--flag=foo) forms are caught. There is no y/N prompt — the denylist is a hard reject. To override, set SCC_ALLOW_DANGEROUS_FLAGS=1 for that one invocation:

SCC_ALLOW_DANGEROUS_FLAGS=1 scc --ca "--dangerously-skip-permissions"

The override is per-invocation by design — it forces you to type the env var into your shell history, which is harder to do absent-mindedly than clicking through a prompt.

Hard limits

  • max 64 total args
  • max 8 KB total bytes

Either limit hit → reject with rc=2. Bounds the blast radius of a runaway config or any future argv-parsing bug in claude itself.

Image flavors

Each flavor lives under images/<distro>/<flavor>/ and ships:

  • Dockerfile
  • config.ini — declares this flavor's tag and revision

The default flavor is selected by the images/default/default symlink:

images/
├── default/default              → ../fedora/python-pip-rust-golang   (symlink)
├── alpine/
│   └── alpine/
│       ├── Dockerfile
│       └── config.ini
└── fedora/
    └── python-pip-rust-golang/
        ├── Dockerfile
        └── config.ini

config.ini example:

[image]
tag = python-rust-go-fedora
arches = linux/amd64,linux/arm64

tag is the human-readable flavor token used in image tags. arches is the csv list of platforms this flavor can be built for; build.sh -a intersects with it and skips flavors with no overlap. The version part of the image tag is project-wide and resolved by build.sh (see Versioning below), not per-flavor.

Adding a flavor

cp -r images/fedora/python-pip-rust-golang images/fedora/<new-flavor>
# edit images/fedora/<new-flavor>/Dockerfile
# edit images/fedora/<new-flavor>/config.ini  (set a new tag)

ln -sfn ../fedora/<new-flavor> images/default/default   # optional: make it the default

Versioning

build.sh owns the project version (independent of the Claude binary's version, since the binary is sideloaded at runtime). Both image tags and git tags use bare semver (no v prefix). Resolution priority:

  1. -V X.Y.Z flag (a leading v is silently stripped)
  2. $SCC_VERSION env var
  3. The exact-match git tag at HEAD (e.g. when CI runs on a tag push)
  4. Auto-increment the patch from the latest semver git tag (e.g. 1.0.51.0.6)
  5. Default to 1.0.0 if no tags exist

So a fresh ./build.sh on main after 1.0.5 produces 1.0.6 automatically; explicit overrides win when needed.

Tag scheme

Every build produces two tags per flavor (an exact pin and a rolling "latest of this flavor" pointer). Builds of the default flavor produce two extra short aliases on top:

scc:<version>-<flavor>     # always       — e.g. scc:1.0.6-alpine
scc:<flavor>-latest        # always       — e.g. scc:alpine-latest
scc:<version>              # default only — e.g. scc:1.0.6
scc:latest                 # default only

Pick by intent:

  • Reproducible build inputs → use the exact scc:<version>-<flavor> pin (e.g. scc:1.0.6-alpine). Pinned to one image digest forever.
  • Track the latest of one flavor → use scc:<flavor>-latest (e.g. scc:alpine-latest). New releases overwrite this tag.
  • Track the default flavor → use scc:latest or scc:<version>.

Switching the images/default/default symlink changes which flavor owns the :latest and short tags.

Claude binary handling

The Claude Code binary is not baked into the published image. The container entrypoint downloads it from claude.ai/install.sh on first run and stores it in a dedicated Docker named volume (scc-claude, mounted at /opt/scc-claude). Subsequent scc invocations detect the cached binary in the volume and skip the install.

Why a named volume instead of a host bind mount: Docker manages it, there's no host filesystem clutter under ~/.scc/, and the volume layout is independent of the host OS. Login state (.credentials.json, recent projects, etc.) still lives on the host bind at ~/.scc/dot-claude/ so it remains visible.

  • First scc after install: ~236 MB download (3 s on gigabit, 2030 s on typical home wifi), then runs.
  • Every subsequent run: zero download, instant start.
  • Parallel containers: safe — flock in the entrypoint serializes the one-time install if two cold-cache containers race.
  • Force a refresh (e.g. when a new Claude version drops): SCC_FORCE_REINSTALL=1 scc .
  • Wipe the cached binary: docker volume rm scc-claude
  • Override volume name: SCC_CLAUDE_VOLUME=my-vol scc .
  • Fresh state (scc -c): doesn't mount the volume either, so Claude is re-installed every run.

Daily Claude update check

scc.sh checks once a day whether a newer Claude Code release is published on npm (@anthropic-ai/claude-code/latest, ~1 KB JSON, 1 s timeout) and compares it against the version cached in your scc-claude volume. When the upstream is newer:

  ⚠  Claude Code update available: 1.0.122 → 1.0.135
     refresh on this run with:    SCC_FORCE_REINSTALL=1 scc
     auto-refresh on daily check: export SCC_AUTO_UPDATE_CLAUDE=1
     silence this check:          export SCC_NO_CLAUDE_UPDATE_CHECK=1

  Reinstall Claude on this run? [y/N]

How it knows the installed version: the entrypoint writes claude --version to /opt/scc-claude/version and mirrors it to ~/.scc/volume-meta/claude_version (bind-mounted from the host) on every install. The launcher reads that mirror — no docker-exec roundtrip on the check.

Controls:

  • SCC_AUTO_UPDATE_CLAUDE=1 — when newer detected, auto-set SCC_FORCE_REINSTALL=1 for this run; no prompt.
  • SCC_NO_CLAUDE_UPDATE_CHECK=1 — skip the daily probe entirely.
  • Non-TTY runs (CI, pipes) skip the prompt and continue with the cached binary — same fail-closed semantics as the scc.sh self-update check.
  • The user's "no" decision is cached per upstream version: once you've said no to 1.0.135, you won't be re-prompted until npm publishes a newer one. Cache lives at ~/.scc/claude-update-check.

Architectures

scc is a thin wrapper around Claude Code, so the supported arches are exactly the arches Anthropic publishes the Claude binary for: linux/amd64 and linux/arm64 (glibc and musl variants resolve automatically at install time).

Flavor linux/amd64 linux/arm64
fedora/python-pip-rust-golang
fedora/python-pip-openjdk-maven-springboot
fedora/android-flutter-ios
alpine/alpine

fedora/android-flutter-ios is linux/amd64 only because Google ships no linux/arm64 Android SDK channel. Apple Silicon hosts run it under Rosetta (OrbStack automatic; Docker Desktop via Settings → General → "Use Rosetta for x86_64/amd64 emulation"). Rosetta is fast enough — it's the same path Android Studio uses on macOS. CocoaPods is bundled so pod install resolves iOS dependencies inside the container; the actual flutter build ipa step still requires macOS + Xcode and must run on a Mac.

Host install (via installer/i.sh) likewise accepts macOS (Apple Silicon + Intel) and Linux on x86_64 / aarch64. Other host arches are rejected — there's no Claude binary that would run inside the resulting container.

Building & publishing

./build.sh                                            # auto-version + default flavor for host arch
./build.sh -V 1.2.3                                   # explicit version
./build.sh -i fedora/python-pip-rust-golang           # specific flavor
./build.sh -i all                                     # every flavor
./build.sh -a linux/amd64,linux/arm64 -i all -p       # multi-arch, push manifest list
./build.sh -k 5                                       # keep 5 old versioned tags besides :latest
./build.sh -p                                         # push the resulting tags
./build.sh -p -r forge.example/ns/scc-other           # push to a different registry/repo
./build.sh -h                                         # full help

Default push target: forge.stacktop.network/openstacktop/scc. Run docker login forge.stacktop.network once before the first push.

By default build.sh builds for the host arch and --loads into the local Docker daemon. With -a listing more than one platform, the build switches to a multi-arch manifest and must --push — Docker's local image store can't hold a manifest list. CI uses this mode to ship the linux/amd64 + linux/arm64 manifest behind a single tag.

build.sh -i all walks images/*/*, skips symlinked aliases (so default/default isn't built twice), and dedupes by realpath. Pruning and pushing happen once at the end of the run, not per-build.

Running

scc                       # mount $PWD and ~/.scc state
scc ~/code/myproj         # mount a specific dir
scc -c                    # workspace only, fresh Claude state (no .claude mount)
scc -w                    # Claude state only, /workspace empty (no workspace mount)
scc -cw                   # fully sandboxed: no workspace, fresh state
scc -h                    # full help

Pick a non-default flavor via SCC_IMAGE. Two options:

# Pinned: this exact build forever
SCC_IMAGE=scc:1.0.6-alpine scc .

# Rolling: always the latest alpine flavor
SCC_IMAGE=scc:alpine-latest scc .

Force the entrypoint to re-download Claude (cache busting):

SCC_FORCE_REINSTALL=1 scc .

Updating scc.sh

The launcher checks once a day (HTTPS, 1 s timeout) whether a newer scc.sh is published on main. When one is, scc prints the recommended installer command and a link to the source view, then prompts before continuing:

  ⚠  scc.sh update available: 1.0.0 → 1.1.0
     review the new launcher first:
       https://forge.stacktop.network/openstacktop/scc/src/branch/main/scc.sh
     then update with:
       curl -fsSL https://.../installer/i.sh | bash

⚠️ Updating runs remote code on your machine. Updating scc is on your responsibility — review the diff in the source view before pasting the installer one-liner. Never paste a curl-pipe-bash command without reading what you're executing.

The check never auto-runs the installer. To suppress the daily check entirely: SCC_NO_UPDATE_CHECK=1 scc. To force a fresh check next run: rm ~/.scc/update-check.

Note

: scc.sh has its own version (scc.sh.version at the repo root) independent of the image version. The launcher only changes when the launcher itself does — most image releases (scc:1.0.5scc:1.0.6 etc.) don't bump it. So expect scc.sh.version to lag behind the image's :latest tag, and don't worry if your launcher reports an older version than the image you're running.

Project configs

Drop a .scc/ directory inside any workspace to opt into per-project settings. Bootstrap one with scc init (interactive), or create the files by hand — both paths produce the same layout. Three pieces, each independent:

your-project/
└── .scc/
    ├── config.ini          # optional — pins the image for this project
    ├── dot-claude/         # optional — per-project /root/.claude bind
    └── dot-claude.json     # optional — per-project /root/.claude.json

Bootstrap (scc init)

cd /your/project
scc init

Prompts for:

  1. Image — Enter to inherit the default forge.stacktop.network/openstacktop/scc:latest, or type a specific tag. A config.ini is written only when you override the default — keeps the .scc/ directory minimal. Tag styles:
    • scc:1.0.6-alpine — pin to an exact build (reproducible).
    • scc:alpine-latest — track the latest alpine flavor (rolling).
    • scc:latest — track the default flavor.
  2. Isolate Claude state?y creates .scc/dot-claude/ + .scc/dot-claude.json so login + sessions are per-project.
  3. Copy login credentials? — only asked when isolation is selected and ~/.scc/dot-claude/.credentials.json exists. y copies just the credential file (auth tokens), leaving session/conversation history fresh in the new isolated state.

Run scc init /path/to/project to bootstrap a different directory.

Schema

.scc/config.ini:

[scc]
# Pinned to a specific release — every checkout of this repo gets the
# same image bytes, regardless of when scc was run.
image       = forge.stacktop.network/openstacktop/scc:1.0.6-alpine

# Default args forwarded to claude on every `scc` in this workspace.
# Same shell-style tokenizer as --ca; no $VAR / $(…) expansion.
claude_args = --model sonnet --output-format json

# Docker --platform pin. Use when the project's image only ships one arch
# and your host is the other — typically Apple Silicon (arm64) running an
# amd64-only image under Rosetta-for-Linux. Empty / omitted lets docker
# pick from the image manifest.
platform    = linux/amd64

# Per-project Docker named volume for the Claude install. By default every
# scc invocation shares `scc-claude` across all flavors; pin a separate
# volume here when an image's libc / runtime layout differs enough that
# the shared binary won't run under it. The volume holds only the Claude
# binary cache (~/.scc/dot-claude still holds login state), so the extra
# disk cost is one binary per pinned volume (~236 MB).
volume      = scc-claude-android-flutter

All four fields are optional and independent — pin any subset. Image rolling form, if you prefer to track the newest build of a flavor:

[scc]
image = forge.stacktop.network/openstacktop/scc:alpine-latest

Resolution priority:

  • imageSCC_IMAGE env > .scc/config.ini > default
  • platformSCC_PLATFORM env > .scc/config.ini > (omit, docker chooses)
  • volumeSCC_CLAUDE_VOLUME env > .scc/config.ini > scc-claude
  • claude_args — always read (and prepended before any --ca from the CLI)

Trust prompt — the first time a project pins a non-default image, any claude_args, OR a platform, scc asks before running it (a malicious .scc/config.ini could otherwise route you to any registry, pin dangerous flags, or force-emulate a different arch):

  ⚠  .scc/config.ini wants a non-default configuration:

       workspace:   /Users/you/code/myproj
       image:       forge.stacktop.network/openstacktop/scc:1.0.6-alpine
       claude_args: --model sonnet --output-format json
       platform:    linux/amd64

    Trust this configuration for this workspace? [y/N]

Accept once and the decision is cached at ~/.scc/trusted/<sha256(workspace)> keyed by the (image, claude_args, platform) triple. If the project later changes any field, you're re-prompted with an OLD → NEW diff so you can see what changed:

  ⚠  .scc/config.ini for this workspace changed since last trust:

       workspace:   /Users/you/code/myproj
       image:       forge.stacktop.network/openstacktop/scc:1.0.6-alpine
       claude_args: --model sonnet
                  → --model sonnet --dangerously-skip-permissions

Bypass:

  • SCC_IMAGE=… scc — env wins for image, no prompt for image (claude_args still gated).
  • SCC_PLATFORM=… scc — env wins for platform; no prompt for that field.
  • SCC_ALLOW_PROJECT_CONFIG=1 scc — auto-trust this run + cache.
  • SCC_ALLOW_PROJECT_IMAGE=1 — legacy alias for the above; still accepted for one release.
  • Non-TTY runs (CI, pipes) fail closed — set SCC_ALLOW_PROJECT_CONFIG=1.

Note that even an accepted trust prompt won't let project-pinned dangerous flags through — those still need SCC_ALLOW_DANGEROUS_FLAGS=1 per invocation (see Passing args to claude). Two independent gates.

Wipe trust: rm ~/.scc/trusted/<sha> for one workspace, or rm -rf ~/.scc/trusted for all.

Per-project Claude state — drop a .scc/dot-claude/ directory and the launcher binds it to /root/.claude instead of the host-global ~/.scc/dot-claude. Same idea for the adjacent .scc/dot-claude.json. Login state, conversation history, and MCP config become per-project.

The Claude binary still lives in the shared Docker volume scc-claude — no point reinstalling per project.

Recommended .gitignore for repos using a .scc/dot-claude/:

/.scc/dot-claude/
/.scc/dot-claude.json

.scc/config.ini itself is fine to commit; the trust prompt is the safety net.

Files

Path Purpose
scc.sh Launcher: resolves workspace, conditionally mounts state, runs the image
build.sh Builds + tags (per-flavor and default aliases), prunes, optional push
install.sh Symlinks the launcher onto PATH for local clones (macOS + Linux)
installer/ One-line curl | bash installers (e.g. installer/i.sh for macOS/Linux)
images/ Per-flavor Docker build contexts (Dockerfile + config.ini)
<workspace>/.scc/ Optional per-project overrides (image pin, isolated Claude state)