diff --git a/container-addon/README.md b/container-addon/README.md new file mode 100644 index 00000000..7b774089 --- /dev/null +++ b/container-addon/README.md @@ -0,0 +1,99 @@ +# Unbound container add-on + +Installs the canonical Unbound hook plus managed settings so AI coding tools (Claude Code) +enforce Unbound policy inside a container. The hook is sourced directly from this repo's +[`claude-code/hooks/unbound.py`](../claude-code/hooks/unbound.py) — it is not vendored, so the +add-on never drifts from the canonical copy. + +## Two delivery methods + +| Method | Reference | How it's consumed | +|---|---|---| +| devcontainer Feature | `ghcr.io/websentry-ai/setup/unbound-hooks:` | Add to the `features` block of `devcontainer.json`. Installs `python3` + `curl` best-effort. | +| `COPY --from` add-on | `ghcr.io/websentry-ai/setup/addon:` | `COPY --from=…/addon: / /` onto any base image that already has (or installs) `python3` + `curl`. Multi-arch (amd64/arm64). | + +The Feature is the turn-key path: it installs dependencies for you. The `COPY --from` add-on is a +`scratch` payload (hook + managed settings + `link-unbound.sh`) and expects the base image to +provide `python3` + `curl` — install them yourself (see the consumer Dockerfile below). + +## Supported base images + +| Base | `COPY --from` add-on | Feature | +|---|---|---| +| Debian / Ubuntu (apt) | ✅ | ✅ | +| RHEL / Fedora / CentOS (dnf/yum/microdnf) | ✅ | ✅ | +| Alpine (musl, apk) | ✅ (base needs python3+curl) | ✅ | +| Distroless / no package manager | ❌ | ❌ | + +Notes: + +- The hook needs **python3** + **curl**. The Feature installs both best-effort across + apt/apk/dnf/microdnf/yum. For the `COPY --from` add-on you install them yourself. +- Device-serial identity **degrades gracefully** where `dmidecode` is absent (e.g. Alpine, most + rootless containers) — this is non-fatal; the hook still enforces policy. +- The add-on image is **multi-arch** (amd64/arm64), so `COPY --from` works on both. + +## Credentials + +The hook resolves credentials at runtime from `UNBOUND_CLAUDE_API_KEY` in the env **or** a mounted +`~/.unbound/config.json`. To supply the host config, mount it into the container. Two patterns: + +### Single user + +Mount the host config straight into the running user's home: + +```jsonc +// devcontainer.json +"mounts": [ + "source=${localEnv:HOME}/.unbound/config.json,target=${containerEnv:HOME}/.unbound/config.json,type=bind,readonly" +] +``` + +### Any user / su-sudo switches + +If the container switches users (`su`/`sudo`), mount to a shared path and let the bundled +`link-unbound.sh` symlink it into every user's home: + +```jsonc +// devcontainer.json +"mounts": [ + "source=${localEnv:HOME}/.unbound/config.json,target=/usr/local/share/unbound/config.json,type=bind,readonly" +] +``` + +- With the **Feature**, `link-unbound.sh` is auto-run via the Feature's `postStartCommand` — no + extra config needed. +- With the **`COPY --from` add-on**, add the `postStartCommand` yourself: + + ```jsonc + "postStartCommand": "sudo -n sh /usr/local/share/unbound/link-unbound.sh 2>/dev/null || sh /usr/local/share/unbound/link-unbound.sh" + ``` + +**0600-perm caveat:** a `0600` host config is readable by root and the owning uid (so root + the +owner work fine), but a *third*, non-root uid cannot read it through the symlink. For that case, +relax the host file to `0644` or copy the config per-user instead of symlinking. + +## OS-agnostic consumer Dockerfile (`COPY --from` path) + +Install `python3` + `curl` with whatever package manager the base image has, then copy the add-on +payload on top. See [`consumer.Dockerfile.example`](./consumer.Dockerfile.example) for a complete +minimal example. + +```dockerfile +RUN set -eu; \ + if command -v apt-get >/dev/null 2>&1; then apt-get update && apt-get install -y --no-install-recommends python3 curl && rm -rf /var/lib/apt/lists/*; \ + elif command -v apk >/dev/null 2>&1; then apk add --no-cache python3 curl; \ + elif command -v dnf >/dev/null 2>&1; then dnf install -y python3 curl; \ + elif command -v microdnf >/dev/null 2>&1; then microdnf install -y python3 curl; \ + elif command -v yum >/dev/null 2>&1; then yum install -y python3 curl; \ + else echo "need python3 + curl: no supported package manager" >&2; exit 1; fi +COPY --from=ghcr.io/websentry-ai/setup/addon: / / +``` + +## Out of scope + +- **Windows containers** — different managed-settings path (`C:\ProgramData\ClaudeCode\…`), no POSIX + `sh`, and the interpreter is `python` not `python3`. A separate future track. +- **Distroless / no package manager** — `python3` + `curl` cannot be installed, so the hook cannot run. +- **Other AI tools beyond Claude Code** — Cursor/Codex/Copilot hooks exist elsewhere in this repo but + are not packaged into this add-on yet. Future work. diff --git a/container-addon/consumer.Dockerfile.example b/container-addon/consumer.Dockerfile.example new file mode 100644 index 00000000..499a68f2 --- /dev/null +++ b/container-addon/consumer.Dockerfile.example @@ -0,0 +1,25 @@ +# Minimal OS-agnostic example of consuming the Unbound container add-on via COPY --from. +# See container-addon/README.md for the full support matrix, credential-mount patterns, +# and out-of-scope notes. +# +# Replace with the add-on image tag you want to pin (e.g. 0.1.0), and FROM with your +# own base image. The RUN step installs the hook's only runtime deps (python3 + curl) with +# whatever package manager the base provides, then COPY --from layers the add-on payload +# (hook + managed settings + link-unbound.sh) on top. + +FROM debian:bookworm-slim + +# Install python3 + curl best-effort across common package managers (apt/apk/dnf/microdnf/yum). +RUN set -eu; \ + if command -v apt-get >/dev/null 2>&1; then apt-get update && apt-get install -y --no-install-recommends python3 curl && rm -rf /var/lib/apt/lists/*; \ + elif command -v apk >/dev/null 2>&1; then apk add --no-cache python3 curl; \ + elif command -v dnf >/dev/null 2>&1; then dnf install -y python3 curl; \ + elif command -v microdnf >/dev/null 2>&1; then microdnf install -y python3 curl; \ + elif command -v yum >/dev/null 2>&1; then yum install -y python3 curl; \ + else echo "need python3 + curl: no supported package manager" >&2; exit 1; fi + +# Layer the Unbound add-on payload (hook + managed settings) on top. +COPY --from=ghcr.io/websentry-ai/setup/addon: / / + +# Credentials are resolved at runtime: set UNBOUND_CLAUDE_API_KEY or mount +# ~/.unbound/config.json. See container-addon/README.md for the mount patterns. diff --git a/devcontainer-feature/src/unbound-hooks/devcontainer-feature.json b/devcontainer-feature/src/unbound-hooks/devcontainer-feature.json index 0dae6c24..2a1e3aac 100644 --- a/devcontainer-feature/src/unbound-hooks/devcontainer-feature.json +++ b/devcontainer-feature/src/unbound-hooks/devcontainer-feature.json @@ -2,14 +2,8 @@ "id": "unbound-hooks", "version": "0.1.0", "name": "Unbound Container Hooks", - "description": "Installs the canonical Unbound hook (sourced from this repo's claude-code/hooks/unbound.py) plus managed settings so AI coding tools (Claude Code, Cursor) running in this dev container enforce Unbound policy. The hook resolves credentials at runtime via UNBOUND_CLAUDE_API_KEY or a mounted ~/.unbound/config.json (read natively by the hook) and calls the Unbound gateway. Requires python3 (installed via the official python feature) and curl (installed best-effort).", + "description": "Installs the canonical Unbound hook (sourced from this repo's claude-code/hooks/unbound.py) plus managed settings so AI coding tools (Claude Code, Cursor) running in this dev container enforce Unbound policy. The hook resolves credentials at runtime via UNBOUND_CLAUDE_API_KEY or a mounted ~/.unbound/config.json (read natively by the hook) and calls the Unbound gateway. python3 and curl (the hook's only dependencies) are installed best-effort across apt/apk/dnf/microdnf/yum — no external feature dependency, so the Feature stays OS-agnostic.", "documentationURL": "https://github.com/websentry-ai/setup/tree/main/devcontainer-feature/src/unbound-hooks", - "dependsOn": { - "ghcr.io/devcontainers/features/python:1": { - "version": "os-provided", - "installTools": false - } - }, "installsAfter": [ "ghcr.io/devcontainers/features/common-utils" ], diff --git a/devcontainer-feature/src/unbound-hooks/install.sh b/devcontainer-feature/src/unbound-hooks/install.sh index 1d6ed901..90ede5bd 100755 --- a/devcontainer-feature/src/unbound-hooks/install.sh +++ b/devcontainer-feature/src/unbound-hooks/install.sh @@ -7,17 +7,30 @@ # it into this feature dir before publish — see .github/workflows/publish-feature.yml), so # there is no vendored/drifting duplicate. # -# python3 (the hook's only dependency) is installed automatically via the dependsOn the -# official python feature. The hook reads credentials directly: UNBOUND_CLAUDE_API_KEY in +# python3 (the hook's only dependency) is installed best-effort across common package +# managers (apt/apk/dnf/microdnf/yum) so the Feature stays OS-agnostic — no external +# feature dependency. The hook reads credentials directly: UNBOUND_CLAUDE_API_KEY in # the env, or a mounted ~/.unbound/config.json. No shell env-export bridge is installed — # the hook resolves config.json itself. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" -# Safety net: dependsOn should have provided python3 already. +# The hook requires python3. Install it best-effort across common package managers, the +# same way curl is handled below. Guarded with `|| true` so a failed install never aborts +# the build under `set -e` — the safety-net warning below fires only if it truly failed. if ! command -v python3 >/dev/null 2>&1; then - echo "unbound-hooks: WARNING — python3 not found on PATH despite the python dependency;" >&2 + if command -v apt-get >/dev/null 2>&1; then apt-get update 2>&1 && apt-get install -y --no-install-recommends python3 2>&1 || true + elif command -v apk >/dev/null 2>&1; then apk add --no-cache python3 2>&1 || true + elif command -v dnf >/dev/null 2>&1; then dnf install -y python3 2>&1 || true + elif command -v microdnf >/dev/null 2>&1; then microdnf install -y python3 2>&1 || true + elif command -v yum >/dev/null 2>&1; then yum install -y python3 2>&1 || true + fi +fi + +# Safety net: warn (don't fail) if python3 still isn't available after the best-effort install. +if ! command -v python3 >/dev/null 2>&1; then + echo "unbound-hooks: WARNING — python3 not found on PATH and could not be installed;" >&2 echo "unbound-hooks: hooks will fail open (no enforcement) until python3 is available." >&2 fi