chore: move docker-chat-sandbox under templates/x (#23777)

Adds the experimental `docker-chat-sandbox` example template under
`examples/templates/x/`. It provisions a regular dev agent plus a
chat-designated agent that runs inside bubblewrap with a read-only root,
writable `/home/coder`, and outbound TCP restricted to the Coder
control-plane endpoint via `iptables`.

The chat agent still appears in dashboard and API responses, but the
template reserves it for chatd-managed sessions rather than normal user
interaction. `lint/examples` now walks nested template directories, so
experimental templates can live under `examples/templates/x/` without
treating `x/` itself as a template.
This commit is contained in:
Michael Suchacz
2026-03-30 15:17:55 +02:00
committed by GitHub
parent 6a2f389110
commit cf500b95b9
6 changed files with 664 additions and 8 deletions
+5
View File
@@ -0,0 +1,5 @@
# Experimental templates
Templates in this directory are experimental and may change or be removed without notice.
They are useful for validating new or unstable Coder behaviors before we commit to them as stable example templates.
@@ -0,0 +1,20 @@
FROM codercom/enterprise-base:ubuntu
USER root
# Install bubblewrap and iptables for sandboxed agent execution.
RUN apt-get update && \
apt-get install -y --no-install-recommends bubblewrap iptables && \
rm -rf /var/lib/apt/lists/*
# Wrapper script that starts the agent inside a bwrap sandbox.
# Everything the agent spawns (tool calls, SSH, etc.) inherits
# the restricted namespace.
COPY bwrap-agent.sh /usr/local/bin/bwrap-agent
RUN chmod 755 /usr/local/bin/bwrap-agent
# Run as root so bwrap can create mount namespaces without needing
# user namespace support (which Docker blocks). The bwrap sandbox
# itself provides filesystem isolation (read-only root).
# The coder user home is still /home/coder (writable via bind mount).
ENV HOME=/home/coder
@@ -0,0 +1,123 @@
---
display_name: Docker + Chat Sandbox
description: Two-agent Docker template with a bubblewrap-sandboxed chat agent
icon: ../../../../site/static/icon/docker.png
maintainer_github: coder
tags: [docker, container, chat]
---
> **Experimental**: This template depends on the `-coderd-chat` agent
> naming convention, which is an internal PoC mechanism subject to
> change. Do not rely on this for production workloads.
# Docker + Chat Sandbox
This template provisions a workspace with two agents:
| Agent | Purpose | Visible in UI |
|-------------------|---------------------------------------------------|---------------|
| `dev` | Regular development agent with code-server | Yes |
| `dev-coderd-chat` | AI chat agent running inside a bubblewrap sandbox | Yes |
## How it works
The `dev` agent is a standard workspace agent with code-server and
full filesystem access. Users interact with it normally through the
dashboard, SSH, and Coder Connect.
The `dev-coderd-chat` agent is designated for AI chat sessions via the
`-coderd-chat` naming suffix. Chatd routes chat traffic to this agent
automatically. The dashboard and REST API still expose it like any other
agent, but this template treats it as a chatd-managed sandbox rather
than a normal user interaction surface.
## Bubblewrap sandbox
The chat agent's init script is wrapped with
[bubblewrap](https://github.com/containers/bubblewrap) so the **entire
agent process** runs inside a restricted mount namespace with **all
capabilities dropped**. Every child process the agent spawns (tool calls
via `sh -c`, SSH sessions) inherits the same restrictions.
The Coder agent hardcodes `sh -c` for tool call execution and ignores
the `SHELL` environment variable, so wrapping only the shell would be
ineffective. Wrapping the agent binary means the `/bin/bash`, `python3`,
or any other binary the model invokes is the one inside the read-only
namespace.
### Sandbox policy
- **Read-only root filesystem**: cannot install packages, modify system
config, or tamper with binaries. Enforced by the kernel mount
namespace, applies even to the root user.
- **Read-write /home/coder**: project files are editable (shared with
the dev agent via a Docker volume).
- **Read-write /tmp**: scratch space (the agent binary downloads here
during startup, tool calls can use it).
- **Shared /proc and /dev**: bind-mounted from the container so CLI
tools and the agent work normally.
- **Outbound TCP allowlist**: before entering bwrap, the wrapper
installs `iptables` and `ip6tables` OUTPUT rules that allow loopback,
`ESTABLISHED,RELATED`, and new TCP connections only to the
control-plane host and port used by the agent. All other outbound TCP
is rejected over both IPv4 and IPv6.
- **Near-zero capabilities**: bwrap drops all Linux capabilities
except `CAP_DAC_OVERRIDE` before exec'ing the agent. This prevents
mount escape (`mount --bind`), ptrace, raw network access, and all
other privileged operations. `DAC_OVERRIDE` is retained so the
sandbox process (root) can read/write files owned by uid 1000
(coder) on the shared home volume without changing ownership.
### How the capability lifecycle works
1. Docker starts the container as root with `CAP_SYS_ADMIN`,
`CAP_NET_ADMIN`, and `CAP_DAC_OVERRIDE`.
2. The entrypoint runs `bwrap-agent`, which resolves the control-plane
host and installs the outbound TCP allowlist with `iptables` and
`ip6tables`.
3. bwrap creates the mount namespace using `CAP_SYS_ADMIN`.
4. bwrap drops all capabilities except `DAC_OVERRIDE`.
5. bwrap exec's the agent binary with only `DAC_OVERRIDE`.
6. All tool calls spawned by the agent inherit only `DAC_OVERRIDE`.
After step 4, the process cannot remount filesystems, change ownership,
ptrace other processes, or perform any other privileged operation. It
can read and write files regardless of Unix permissions, which is needed
because the shared home volume is owned by uid 1000 (coder) but the
sandbox runs as root.
### Limitations
- **No PID namespace isolation**: Docker's namespace setup conflicts
with nested PID namespaces (`--unshare-pid`). Processes inside the
sandbox can see other container processes via `/proc`.
- **No user namespace isolation**: Docker blocks nested user namespaces.
The container runs as root uid 0, but with zero capabilities the
effective privilege level is lower than an unprivileged user.
- **Only outbound TCP is filtered**: UDP, ICMP, and inbound traffic
still follow Docker's normal container networking rules. DNS usually
continues to work over UDP, but DNS-over-TCP is blocked unless it uses
the control-plane endpoint.
- **IP resolution at startup**: the outbound allowlist resolves the
control-plane hostname once with `getent ahostsv4` and, when IPv6 is
enabled, `getent ahostsv6`. If those lookups fail, or if the endpoint
later moves to a different IP, the chat container must restart to
refresh the rules.
- **seccomp=unconfined**: Docker's default seccomp profile blocks
`pivot_root`, which bwrap needs. A custom seccomp profile that allows
only `pivot_root` and `mount` would be more restrictive.
Template authors can adjust the sandbox policy in `bwrap-agent.sh` by
adding `--bind` flags for additional writable paths.
## Usage
After starting `./scripts/develop.sh`, push this template:
```bash
cd examples/templates/x/docker-chat-sandbox
coder templates push docker-chat-sandbox \
--var docker_socket="$(docker context inspect --format '{{ .Endpoints.docker.Host }}')"
```
Then create a workspace from it and start a chat session.
@@ -0,0 +1,190 @@
#!/bin/bash
# bwrap-agent.sh: Start the Coder agent inside a bubblewrap sandbox.
#
# This script wraps the agent binary and all its children in a bwrap
# mount namespace with almost all capabilities dropped.
#
# Sandbox policy:
# - Root filesystem is read-only (prevents system modification)
# - /home/coder is read-write (project files, shared with dev agent)
# - /tmp is read-write (scratch space, bind from container /tmp)
# - /proc is bind-mounted from host (needed by CLI tools)
# - /dev is bind-mounted from host (devices)
# - Outbound TCP is restricted to the control-plane endpoint
# over IPv4 and IPv6.
# - All capabilities dropped except DAC_OVERRIDE.
#
# DAC_OVERRIDE is retained so the sandbox process (running as root)
# can read and write files owned by uid 1000 (coder) on the shared
# home volume without chowning them. This preserves correct
# ownership for the dev agent, which runs as the coder user.
#
# The container must run as root with CAP_SYS_ADMIN and CAP_NET_ADMIN
# so bwrap can create the mount namespace and this wrapper can install
# iptables/ip6tables rules. bwrap then drops all caps except
# DAC_OVERRIDE before exec'ing the child process.
set -euo pipefail
fail() {
echo "bwrap-agent: $*" >&2
exit 1
}
discover_control_plane_url() {
if [ -n "${CODER_SANDBOX_CONTROL_PLANE_URL:-}" ]; then
printf '%s\n' "$CODER_SANDBOX_CONTROL_PLANE_URL"
return 0
fi
local arg url
for arg in "$@"; do
if [ -f "$arg" ]; then
url=$(grep -aoE "https?://[^\"'[:space:]]+" "$arg" | head -n1 || true)
if [ -n "$url" ]; then
printf '%s\n' "$url"
return 0
fi
fi
done
return 1
}
parse_control_plane_host_port() {
local url="$1"
local host_port host port
host_port="${url#*://}"
host_port="${host_port%%/*}"
if [ -z "$host_port" ]; then
fail "control-plane URL is missing a host: $url"
fi
case "$host_port" in
\[*\]:*)
host="${host_port#\[}"
host="${host%%\]*}"
port="${host_port##*:}"
;;
\[*\])
host="${host_port#\[}"
host="${host%\]}"
case "$url" in
https://*) port=443 ;;
http://*) port=80 ;;
*) fail "unsupported control-plane URL scheme: $url" ;;
esac
;;
*:*:*)
fail "IPv6 control-plane URLs must use brackets: $url"
;;
*:*)
host="${host_port%%:*}"
port="${host_port##*:}"
;;
*)
host="$host_port"
case "$url" in
https://*) port=443 ;;
http://*) port=80 ;;
*) fail "unsupported control-plane URL scheme: $url" ;;
esac
;;
esac
if [[ -z "$host" || -z "$port" || ! "$port" =~ ^[0-9]+$ ]]; then
fail "failed to parse control-plane host and port from: $url"
fi
printf '%s %s\n' "$host" "$port"
}
ipv6_enabled() {
[ -s /proc/net/if_inet6 ]
}
install_family_tcp_egress_rules() {
local family="$1"
local port="$2"
shift 2
local -a control_plane_ips=("$@")
local chain ip
local -a table_cmd
case "$family" in
ipv4)
chain="CODER_CHAT_SANDBOX_OUT4"
table_cmd=(iptables -w 5)
;;
ipv6)
chain="CODER_CHAT_SANDBOX_OUT6"
table_cmd=(ip6tables -w 5)
;;
*)
fail "unsupported IP family: $family"
;;
esac
"${table_cmd[@]}" -N "$chain" 2>/dev/null || true
"${table_cmd[@]}" -F "$chain"
while "${table_cmd[@]}" -C OUTPUT -j "$chain" >/dev/null 2>&1; do
"${table_cmd[@]}" -D OUTPUT -j "$chain"
done
"${table_cmd[@]}" -I OUTPUT 1 -j "$chain"
"${table_cmd[@]}" -A "$chain" -o lo -j ACCEPT
"${table_cmd[@]}" -A "$chain" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
for ip in "${control_plane_ips[@]}"; do
[ -n "$ip" ] || continue
"${table_cmd[@]}" -A "$chain" -p tcp -d "$ip" --dport "$port" -j ACCEPT
done
"${table_cmd[@]}" -A "$chain" -p tcp -j REJECT --reject-with tcp-reset
"${table_cmd[@]}" -A "$chain" -j RETURN
}
install_tcp_egress_rules() {
local url="$1"
local host port
local -a control_plane_ipv4s=()
local -a control_plane_ipv6s=()
read -r host port < <(parse_control_plane_host_port "$url")
mapfile -t control_plane_ipv4s < <(getent ahostsv4 "$host" | awk '{print $1}' | sort -u)
if ipv6_enabled; then
mapfile -t control_plane_ipv6s < <(getent ahostsv6 "$host" | awk '{print $1}' | sort -u)
fi
if [ "${#control_plane_ipv4s[@]}" -eq 0 ] && [ "${#control_plane_ipv6s[@]}" -eq 0 ]; then
fail "failed to resolve control-plane host: $host"
fi
install_family_tcp_egress_rules ipv4 "$port" "${control_plane_ipv4s[@]}"
if ipv6_enabled; then
install_family_tcp_egress_rules ipv6 "$port" "${control_plane_ipv6s[@]}"
fi
}
command -v bwrap >/dev/null 2>&1 || fail "bubblewrap not found"
command -v getent >/dev/null 2>&1 || fail "getent not found"
command -v iptables >/dev/null 2>&1 || fail "iptables not found"
if ipv6_enabled; then
command -v ip6tables >/dev/null 2>&1 || fail "ip6tables not found"
fi
control_plane_url=$(discover_control_plane_url "$@" || true)
if [ -z "$control_plane_url" ]; then
fail "failed to determine control-plane URL"
fi
install_tcp_egress_rules "$control_plane_url"
exec bwrap \
--ro-bind / / \
--bind /home/coder /home/coder \
--bind /tmp /tmp \
--bind /proc /proc \
--dev-bind /dev /dev \
--die-with-parent \
--cap-drop ALL \
--cap-add cap_dac_override \
"$@"
@@ -0,0 +1,298 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
docker = {
source = "kreuzwerker/docker"
}
}
}
locals {
username = data.coder_workspace_owner.me.name
chat_control_plane_url = replace(data.coder_workspace.me.access_url, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")
}
variable "docker_socket" {
default = ""
description = "(Optional) Docker socket URI"
type = string
}
provider "docker" {
host = var.docker_socket != "" ? var.docker_socket : null
}
data "coder_provisioner" "me" {}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
# -------------------------------------------------------------------
# Agent 1: Regular dev agent (user-facing, appears in the dashboard)
# -------------------------------------------------------------------
resource "coder_agent" "dev" {
arch = data.coder_provisioner.me.arch
os = "linux"
startup_script = <<-EOT
set -e
if [ ! -f ~/.init_done ]; then
cp -rT /etc/skel ~
touch ~/.init_done
fi
EOT
env = {
GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}"
GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}"
}
metadata {
display_name = "CPU Usage"
key = "0_cpu_usage"
script = "coder stat cpu"
interval = 10
timeout = 1
}
metadata {
display_name = "RAM Usage"
key = "1_ram_usage"
script = "coder stat mem"
interval = 10
timeout = 1
}
metadata {
display_name = "Home Disk"
key = "3_home_disk"
script = "coder stat disk --path $${HOME}"
interval = 60
timeout = 1
}
}
# See https://registry.coder.com/modules/coder/code-server
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "~> 1.0"
agent_id = coder_agent.dev.id
order = 1
}
# -------------------------------------------------------------------
# Agent 2: Chat agent (designated for chatd-managed AI chat)
#
# This agent runs inside a bubblewrap (bwrap) sandbox. The entire
# agent process and all its children (tool calls, SSH sessions, etc.)
# execute in a restricted mount namespace. There is no escape path
# because the sandbox wraps the agent binary itself, not just the
# shell.
#
# The agent name "dev-coderd-chat" ends with the -coderd-chat suffix
# that tells chatd to route chats here. The dashboard still shows the
# agent, but the template reserves it for chatd-managed sessions rather
# than normal user interaction.
#
# NOTE: Terraform resource labels cannot contain hyphens, but the
# Coder provisioner uses the label as the agent name (and rejects
# underscores). To work around this, the resource label uses hyphens
# and all references go through the local.chat_agent indirection
# below.
# -------------------------------------------------------------------
# Terraform parses "coder_agent.dev-coderd-chat.X" as subtraction,
# so we capture the agent attributes in locals for clean references.
locals {
# The resource block below uses a hyphenated label so the Coder
# provisioner registers the agent name as "dev-coderd-chat".
# These locals let the rest of the config reference its attributes
# without Terraform misinterpreting the hyphens.
chat_agent_init = replace(coder_agent.dev-coderd-chat.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")
chat_agent_token = coder_agent.dev-coderd-chat.token
}
resource "coder_agent" "dev-coderd-chat" {
arch = data.coder_provisioner.me.arch
os = "linux"
order = 99
startup_script = <<-EOT
set -e
if [ ! -f ~/.init_done ]; then
cp -rT /etc/skel ~
touch ~/.init_done
fi
EOT
env = {
GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}"
GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}"
}
}
# -------------------------------------------------------------------
# Docker image with bubblewrap pre-installed
# -------------------------------------------------------------------
resource "docker_image" "chat_sandbox" {
name = "coder-chat-sandbox:latest"
build {
context = "."
dockerfile = "Dockerfile.chat"
}
}
# -------------------------------------------------------------------
# Shared home volume
# -------------------------------------------------------------------
resource "docker_volume" "home_volume" {
name = "coder-${data.coder_workspace.me.id}-home"
lifecycle {
ignore_changes = all
}
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
labels {
label = "coder.workspace_name_at_creation"
value = data.coder_workspace.me.name
}
}
# -------------------------------------------------------------------
# Container 1: Dev workspace (regular agent, no sandbox)
# -------------------------------------------------------------------
resource "docker_container" "dev" {
count = data.coder_workspace.me.start_count
image = "codercom/enterprise-base:ubuntu"
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
hostname = data.coder_workspace.me.name
entrypoint = [
"sh", "-c",
replace(coder_agent.dev.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")
]
env = ["CODER_AGENT_TOKEN=${coder_agent.dev.token}"]
host {
host = "host.docker.internal"
ip = "host-gateway"
}
volumes {
container_path = "/home/coder"
volume_name = docker_volume.home_volume.name
read_only = false
}
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
labels {
label = "coder.workspace_name"
value = data.coder_workspace.me.name
}
}
# -------------------------------------------------------------------
# Container 2: Chat sandbox (agent runs inside bubblewrap)
#
# The entrypoint pipes the agent init script through bwrap-agent,
# which starts the entire agent binary inside a bwrap namespace.
# Every process the agent spawns (sh -c for tool calls, SSH
# sessions, etc.) inherits the restricted mount namespace:
#
# - Read-only root filesystem (cannot modify system files)
# - Read-write /home/coder (shared project files)
# - Private /tmp (tmpfs scratch space)
# - Shared network namespace with outbound TCP restricted to the
# Coder control-plane endpoint used by the agent over IPv4 and IPv6
#
# Because the agent itself runs inside bwrap, there is no way for
# a tool call to escape the sandbox by invoking /bin/bash or any
# other binary directly. All binaries are inside the same namespace.
# -------------------------------------------------------------------
resource "docker_container" "chat" {
count = data.coder_workspace.me.start_count
image = docker_image.chat_sandbox.image_id
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-chat"
hostname = "${data.coder_workspace.me.name}-chat"
# Capability budget:
# - SYS_ADMIN: bwrap needs this to create mount namespaces.
# - NET_ADMIN: the wrapper needs this to install iptables OUTPUT
# rules before entering bwrap.
# - DAC_OVERRIDE: passed through to the sandbox so the agent
# (running as root) can read/write files owned by uid 1000 on
# the shared home volume without changing ownership.
# - seccomp=unconfined: Docker's default seccomp profile blocks
# pivot_root, which bwrap uses during namespace setup.
capabilities {
add = ["SYS_ADMIN", "NET_ADMIN", "DAC_OVERRIDE"]
drop = ["ALL"]
}
security_opts = ["seccomp=unconfined"]
# Wrap the init script through bwrap-agent so the agent binary
# and all its children run inside the sandbox namespace.
# The init script is base64-encoded to avoid nested shell quoting
# issues, then decoded and executed at container startup.
entrypoint = [
"sh", "-c",
"echo ${base64encode(local.chat_agent_init)} | base64 -d > /tmp/coder-init.sh && chmod +x /tmp/coder-init.sh && exec bwrap-agent sh /tmp/coder-init.sh"
]
env = [
"CODER_AGENT_TOKEN=${local.chat_agent_token}",
"CODER_SANDBOX_CONTROL_PLANE_URL=${local.chat_control_plane_url}",
]
host {
host = "host.docker.internal"
ip = "host-gateway"
}
volumes {
container_path = "/home/coder"
volume_name = docker_volume.home_volume.name
read_only = false
}
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
labels {
label = "coder.workspace_name"
value = data.coder_workspace.me.name
}
}
+28 -8
View File
@@ -49,17 +49,25 @@ func run(lint bool) error {
var paths []string
if lint {
files, err := fs.ReadDir(examplesFS, "templates")
err := fs.WalkDir(examplesFS, "templates", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
return nil
}
if path == "templates" {
return nil
}
if !isTemplateExampleDir(examplesFS, path) {
return nil
}
paths = append(paths, path)
return fs.SkipDir
})
if err != nil {
return err
}
for _, f := range files {
if !f.IsDir() {
continue
}
paths = append(paths, filepath.Join("templates", f.Name()))
}
} else {
for _, comment := range src.Comments {
for _, line := range comment.List {
@@ -102,6 +110,18 @@ func run(lint bool) error {
return enc.Encode(examples)
}
func isTemplateExampleDir(examplesFS fs.FS, name string) bool {
readmePath := path.Join(name, "README.md")
mainTFPath := path.Join(name, "main.tf")
if _, err := fs.Stat(examplesFS, readmePath); err != nil {
return false
}
if _, err := fs.Stat(examplesFS, mainTFPath); err != nil {
return false
}
return true
}
func parseTemplateExample(projectFS, examplesFS fs.FS, name string) (te *codersdk.TemplateExample, err error) {
var errs []error
defer func() {