Skip to content
Open
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
99 changes: 99 additions & 0 deletions container-addon/README.md
Original file line number Diff line number Diff line change
@@ -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:<v>` | Add to the `features` block of `devcontainer.json`. Installs `python3` + `curl` best-effort. |
| `COPY --from` add-on | `ghcr.io/websentry-ai/setup/addon:<v>` | `COPY --from=…/addon:<v> / /` 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:<v> / /
```

## 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.
25 changes: 25 additions & 0 deletions container-addon/consumer.Dockerfile.example
Original file line number Diff line number Diff line change
@@ -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 <v> 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; \
Comment on lines +16 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The apt-get branch cleans up package lists (rm -rf /var/lib/apt/lists/*), but the dnf, microdnf, and yum branches leave their cache in the image layer. On RHEL/Fedora/CentOS bases this can add tens of MBs to the final image. The same omission exists in the matching snippet in container-addon/README.md.

Suggested change
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; \
elif command -v dnf >/dev/null 2>&1; then dnf install -y python3 curl && dnf clean all; \
elif command -v microdnf >/dev/null 2>&1; then microdnf install -y python3 curl && microdnf clean all; \
elif command -v yum >/dev/null 2>&1; then yum install -y python3 curl && yum clean all; \

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:<v> / /

# Credentials are resolved at runtime: set UNBOUND_CLAUDE_API_KEY or mount
# ~/.unbound/config.json. See container-addon/README.md for the mount patterns.
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
21 changes: 17 additions & 4 deletions devcontainer-feature/src/unbound-hooks/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The python3 apt-get block uses apt-get update 2>&1 (verbose) while the existing curl block on line 40 uses apt-get update -qq (quiet). On a Debian/Ubuntu base that lacks python3, the update output streams to the build log at full verbosity, inconsistent with the curl install below. Aligning to -qq keeps build noise uniform and matches the established pattern.

Suggested change
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
if command -v apt-get >/dev/null 2>&1; then apt-get update -qq && apt-get install -y -qq --no-install-recommends python3 2>&1 || true

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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

Expand Down