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
13 changes: 7 additions & 6 deletions .github/workflows/build-and-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,12 @@ jobs:
REPO_NAME=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')

# Run the build script
VERSION=$(./devops/build-image.sh -repo-name $REPO_NAME | tail -n2 | head -n1 | cut -d: -f2)
VERSION=$(./devops/build-image.sh --repo-name $REPO_NAME | tail -n2 | head -n1 | cut -d: -f2)

# Tag the image for GHCR
buildah tag claude-code \
"ghcr.io/$REPO_NAME/claude-code:$VERSION" \
"ghcr.io/$REPO_NAME/claude-code:latest"
buildah tag opencode \
"ghcr.io/$REPO_NAME/opencode:$VERSION" \
"ghcr.io/$REPO_NAME/opencode:latest"

echo "repo_name=$REPO_NAME" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
Expand All @@ -48,6 +48,7 @@ jobs:
run: ./tests/test-image.sh "${{ steps.build.outputs.version }}"

- name: Push to GitHub Container Registry
if: github.event_name != 'pull_request'
run: |
buildah push "ghcr.io/${{ steps.build.outputs.repo_name }}/claude-code:${{ steps.build.outputs.version }}"
buildah push "ghcr.io/${{ steps.build.outputs.repo_name }}/claude-code:latest"
buildah push "ghcr.io/${{ steps.build.outputs.repo_name }}/opencode:${{ steps.build.outputs.version }}"
buildah push "ghcr.io/${{ steps.build.outputs.repo_name }}/opencode:latest"
73 changes: 51 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,66 @@
claude-podman
opencode-podman
====

Claude for the security-conscious: run [claude-code, the claude cli tool](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview), in a rootless [podman](https://podman.io/) container.
[opencode](https://opencode.ai/) for the security-conscious: run the opencode
terminal agent in a rootless [podman](https://podman.io/) container.

This is a fork of [claude-podman](https://github.com/EvanCarroll/claude-podman)
adapted for opencode.

Installation
----

First, download and install podman. Then install the script with curl. Set
`REPO` to match your fork (e.g. `?????/claude-podman`):
`REPO` to match your fork (e.g. `?????/opencode-podman`):

```sh
REPO=evancarroll/claude-podman
REPO=ingby/opencode-podman
curl --proto '=https' --tlsv1.2 -sSf \
"https://raw.githubusercontent.com/$REPO/refs/heads/main/bin/claude" |
"https://raw.githubusercontent.com/$REPO/refs/heads/main/bin/opencode" |
sed "s|^REPO_NAME=.*|REPO_NAME=$REPO|" |
sudo tee /usr/local/bin/claude-podman > /dev/null
sudo chmod a+x /usr/local/bin/claude-podman
sudo tee /usr/local/bin/opencode-podman > /dev/null
sudo chmod a+x /usr/local/bin/opencode-podman
```

Now you can just run `claude-podman`.
Now you can just run `opencode-podman`.

Benefits
----

This provides the following benefits:

* Claude only gets file access to
* opencode only gets file access to
* Files in the present working directory
* `$HOME/.claude.json`
* `$HOME/.claude`
* Claude can only execute the files that exist in the image.
* `$HOME/.config/opencode` (config)
* `$HOME/.local/share/opencode` (auth, sessions)
* `$HOME/.local/state/opencode` (state)
* opencode can only execute the files that exist in the image.

This image runs in rootless podman, and even inside rootless podman it runs as
a non-root user inside the container. Claude code is maximally locked down and
can't even update itself!
a non-root user inside the container.

Supply chain hardening
----

The image does **not** use opencode's `curl | bash` installer, and opencode
cannot update itself:

* The build downloads a single, explicitly pinned release tarball
(`opencode-linux-*-musl.tar.gz`) from GitHub. The version **and** its
sha256 checksum are committed in `devops/build-image.sh`; the build fails
if the downloaded artifact does not match.
* Autoupdate is disabled three ways:
* `OPENCODE_DISABLE_AUTOUPDATE=true` is baked into the image (authoritative
— it wins even if a mounted config says otherwise).
* The image ships `~/.config/opencode/opencode.json` with
`"autoupdate": false`, and the wrapper writes the same config on the
host if none exists yet.
* The binary lives at `/usr/local/bin/opencode`, owned by root, so the
container user could not overwrite it anyway.

To upgrade opencode: edit `OPENCODE_VERSION` and the two `OPENCODE_SHA256_*`
values in `devops/build-image.sh` (download the new tarballs and run
`sha256sum` on them), then rebuild.

Customizing the runtime
----
Expand All @@ -46,12 +73,13 @@ Need to add packages to the container, or run an init script? no problem
```


For example, let's say you're using kubernetes and you do want claude to be able to troubleshoot it.
For example, let's say you're using kubernetes and you do want opencode to be
able to troubleshoot it.

```sh
claude-podman \
opencode-podman \
--apk-packages kubectl \
--podman-arg "-v $HOME/.kube/config:/home/claude/.kube/config"
--podman-arg "-v $HOME/.kube/config:/home/opencode/.kube/config"
```

Sharing a network with another container
Expand All @@ -64,11 +92,12 @@ container on the same network is then reachable from inside by its container
name, thanks to podman's built-in DNS.

```sh
# Start the service Claude should reach, on a shared network
podman run -d --name myservice --network claude-net some/image
# Start the service opencode should reach, on a shared network
podman run -d --name myservice --network opencode-net some/image

# Run Claude on the same network (creates claude-net if needed)
claude-podman --network claude-net
# Run opencode on the same network (creates opencode-net if needed)
opencode-podman --network opencode-net
```

Inside the container, Claude can now reach the service at `http://myservice:<port>`.
Inside the container, opencode can now reach the service at
`http://myservice:<port>`.
55 changes: 37 additions & 18 deletions bin/claude → bin/opencode
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/bin/bash

# GH Repo - Replace to run a fork
REPO_NAME=evancarroll/claude-podman
REPO_NAME=ingby/opencode-podman
# Default image
IMAGE="ghcr.io/$REPO_NAME/claude-code:latest"
NAME="claudecode-$(uuidgen -r | cut -d- -f1)"
IMAGE="ghcr.io/$REPO_NAME/opencode:latest"
NAME="opencode-$(uuidgen -r | cut -d- -f1)"
INIT_SCRIPT=""
APK_PACKAGES=""
PODMAN_ARGS=""
Expand All @@ -15,33 +15,33 @@ while [[ $# -gt 0 ]]; do
case "$1" in
--help)
cat <<-'EOT'
Usage: claude [OPTIONS]
Usage: opencode-podman [OPTIONS]

Run Claude Code in a Podman container
Run opencode in a Podman container

Options:
--local Use local image 'claude-code:latest' instead of remote
--local Use local image 'opencode:latest' instead of remote
--init-script FILE Execute initialization script
--apk-packages LIST Install additional Alpine packages (comma or space separated)
--network NAME Join a dedicated podman network (created if missing) so
Claude can reach other containers on it by name via DNS
opencode can reach other containers on it by name via DNS
--podman-arg ARG Pass additional argument to podman
--version Show the Claude Code version in the image
--version Show the opencode version in the image
--help Display this help message
EOT
exit 0
;;
--self-update)
curl --proto '=https' --tlsv1.2 -sSf \
"https://raw.githubusercontent.com/$REPO_NAME/refs/heads/main/bin/claude" |
"https://raw.githubusercontent.com/$REPO_NAME/refs/heads/main/bin/opencode" |
sed "s|^REPO_NAME=.*|REPO_NAME=$REPO_NAME|" |
sudo tee /usr/local/bin/claude-podman >/dev/null
sudo chmod a+x /usr/local/bin/claude-podman
sudo tee /usr/local/bin/opencode-podman >/dev/null
sudo chmod a+x /usr/local/bin/opencode-podman
echo "Updated"
exit 0
;;
--local)
IMAGE="localhost/claude-code:latest"
IMAGE="localhost/opencode:latest"
shift
;;
--init-script)
Expand All @@ -63,8 +63,8 @@ while [[ $# -gt 0 ]]; do
--version)
podman run \
--rm \
--user claude \
--userns=keep-id:uid=1001,gid=1001 \
--user opencode \
--userns=keep-id:uid=1000,gid=1000 \
$PODMAN_ARGS \
"$IMAGE" --version
exit 0
Expand All @@ -84,16 +84,35 @@ if [ -n "$NETWORK" ]; then
NETWORK_ARG="--network $NETWORK"
fi

# opencode keeps auth/sessions, config, and state in XDG directories;
# create them on the host so podman doesn't have to
mkdir -p \
"${HOME}/.config/opencode" \
"${HOME}/.local/share/opencode" \
"${HOME}/.local/state/opencode"

# Disable autoupdate in the host config unless one already exists (the
# image also sets OPENCODE_DISABLE_AUTOUPDATE, which wins regardless)
if [ ! -f "${HOME}/.config/opencode/opencode.json" ]; then
cat > "${HOME}/.config/opencode/opencode.json" <<-'EOT'
{
"$schema": "https://opencode.ai/config.json",
"autoupdate": false
}
EOT
fi

podman run \
-v "${HOME}/.claude:/home/claude/.claude" \
-v "${HOME}/.claude.json:/home/claude/.claude.json" \
-v "${HOME}/.config/opencode:/home/opencode/.config/opencode" \
-v "${HOME}/.local/share/opencode:/home/opencode/.local/share/opencode" \
-v "${HOME}/.local/state/opencode:/home/opencode/.local/state/opencode" \
-v "${PWD}:${PWD}" \
-w "$PWD" \
--rm \
--name "$NAME" \
-ti \
--detach \
--user claude \
--user opencode \
--userns=keep-id:uid=1000,gid=1000 \
$NETWORK_ARG \
$PODMAN_ARGS \
Expand All @@ -108,4 +127,4 @@ if [ -n "$INIT_SCRIPT" ]; then
podman exec --user root:root "$NAME" bash /root/init.sh
fi

podman container attach "$NAME"
podman container attach "$NAME"
91 changes: 69 additions & 22 deletions devops/build-image.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
#!/bin/sh
set -eu

CONTAINER=$(buildah from docker.io/alpine:latest)
IMAGE=claude-code
REPO_NAME="EvanCarroll/claude-podman"
# opencode release pin. To upgrade: bump the version, then update both
# checksums from the new release assets (compute with sha256sum after
# downloading from https://github.com/anomalyco/opencode/releases).
OPENCODE_VERSION="1.18.13"
OPENCODE_SHA256_X86_64="93f1506f26ad8b6e867754d144bea43e6c707ec518bac227fdf959048d020d74"
OPENCODE_SHA256_AARCH64="a5b90d6111d2d826fe14952c769d0d4bcf00db7810c0c92d9f4541d3768f03ef"

IMAGE=opencode
REPO_NAME="ingby/opencode-podman"

# Process arguments
while [ "$#" -gt 0 ]; do
Expand All @@ -11,7 +18,7 @@ while [ "$#" -gt 0 ]; do
cat <<-'EOT'
Usage: $0 [OPTIONS]

Build image to run Claude Code in a Podman container
Build image to run opencode in a Podman container

Options:
--repo-name REPO_NAME Use the specified repo name instead of the default "REPO_NAME"
Expand All @@ -29,30 +36,70 @@ while [ "$#" -gt 0 ]; do
esac
done

case "$(uname -m)" in
x86_64)
OPENCODE_ASSET="opencode-linux-x64-musl.tar.gz"
OPENCODE_SHA256="$OPENCODE_SHA256_X86_64"
;;
aarch64 | arm64)
OPENCODE_ASSET="opencode-linux-arm64-musl.tar.gz"
OPENCODE_SHA256="$OPENCODE_SHA256_AARCH64"
;;
*)
echo "Unsupported architecture: $(uname -m)" >&2
exit 1
;;
esac

CONTAINER=$(buildah from docker.io/alpine:latest)

# Install dependencies and create user
buildah run "$CONTAINER" sh <<EOT
apk add --no-cache bash curl libgcc libstdc++ ripgrep
adduser -D claude
set -eu
apk add --no-cache bash curl git libgcc libstdc++ ripgrep
adduser -D opencode
EOT

# Install Claude Code using native installer as the claude user
buildah run --user claude "$CONTAINER" bash -c 'curl -fsSL https://claude.ai/install.sh | bash'
# Install opencode from a pinned GitHub release tarball, verifying its
# sha256 against the checksum committed in this repo. The binary is
# installed root-owned outside the user's home so opencode cannot
# replace itself even if an update were attempted.
buildah run "$CONTAINER" sh <<EOT
set -eu
cd /tmp
curl --proto '=https' --tlsv1.2 -fsSLo "$OPENCODE_ASSET" \
"https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/${OPENCODE_ASSET}"
echo "$OPENCODE_SHA256 $OPENCODE_ASSET" | sha256sum -c -
tar -xzf "$OPENCODE_ASSET" -C /usr/local/bin opencode
chown root:root /usr/local/bin/opencode
chmod 755 /usr/local/bin/opencode
rm -f "$OPENCODE_ASSET"
EOT

# Get the installed version
CLAUDE_VERSION=$(buildah run --user claude "$CONTAINER" /home/claude/.local/bin/claude --version 2>/dev/null | head -1 | awk '{print $1}')
# Bake a default config that disables autoupdate. If the config dir is
# bind-mounted at runtime this file is shadowed, so the
# OPENCODE_DISABLE_AUTOUPDATE env var below is the authoritative switch.
buildah run --user opencode "$CONTAINER" sh <<'EOT'
set -eu
mkdir -p /home/opencode/.config/opencode
cat > /home/opencode/.config/opencode/opencode.json <<'JSON'
{
"$schema": "https://opencode.ai/config.json",
"autoupdate": false
}
JSON
EOT

buildah config \
--author "Evan Carroll" \
--env "PATH=/home/claude/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
--author "Christer Barreholm" \
--env "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
--env "SHELL=/bin/bash" \
--env "DISABLE_TELEMETRY=1" \
--env "DISABLE_AUTOUPDATER=1" \
--env "USE_BUILTIN_RIPGREP=0" \
--env "OPENCODE_DISABLE_AUTOUPDATE=true" \
--cmd "" \
--entrypoint '[ "/home/claude/.local/bin/claude" ]' \
--annotation "org.anthropic.claudecode.version=$CLAUDE_VERSION" \
--annotation "org.opencontainers.image.title=claude-code" \
--annotation "org.opencontainers.image.description=Claude Code on Alpine ready for rootless podman" \
--entrypoint '[ "/usr/local/bin/opencode" ]' \
--annotation "ai.opencode.version=$OPENCODE_VERSION" \
--annotation "org.opencontainers.image.title=opencode" \
--annotation "org.opencontainers.image.description=opencode on Alpine ready for rootless podman" \
--annotation "org.opencontainers.image.url=https://github.com/$REPO_NAME" \
--annotation "org.opencontainers.image.source=https://github.com/$REPO_NAME" \
--annotation "org.opencontainers.image.documentation=https://github.com/$REPO_NAME/blob/main/README.md" \
Expand All @@ -64,8 +111,8 @@ buildah commit \
--rm \
"$CONTAINER" "$IMAGE"

buildah tag "$IMAGE" "$IMAGE:$CLAUDE_VERSION"
buildah tag "$IMAGE" "$IMAGE:$OPENCODE_VERSION"

echo Done!
echo ${IMAGE}:${CLAUDE_VERSION}
echo To use this image run /bin/claude
echo ${IMAGE}:${OPENCODE_VERSION}
echo To use this image run /bin/opencode
Loading