Skip to content
Level 5 · Agentic EngineerLabPart 25 · page 9 of 1160 minSXMN 8 GB
60Minutes
2Tools
15Sources
All fourTracks
Tools used on this page2

Lab: Sandbox Your Agent: Containers, Permissions and Secrets

Validated on: written from the documentation cited above; not yet validated on hardware on any track. The engine versions, image digests and per-track results belong here once the validation pass has run this lab on real machines.

By the end of this lab an agent on your machine will be able to see exactly one project directory, reach exactly one network address, and read none of your credentials — and you will have a script that proves all three and fails loudly when one of them stops being true.

The deliverable is not the container. It is the passing boundary test, kept beside the container, run again after every change to it. A sandbox nobody tests is a sandbox nobody has.

Everything in this part so far has had a switch that turns confirmation off: Codex CLI’s --dangerously-bypass-approvals-and-sandbox, OpenCode’s --auto, Cline’s mode named after a slogan, Aider’s --yes-always, Goose’s automatic mode. Each of them is genuinely useful and each is only safe inside a boundary that is not the tool’s own permission list.

The reason is one sentence from Docker’s own security documentation, and it applies to permission lists in general as much as to containers: a control enforced by the program you are running protects you from that program’s mistakes, and from nothing else. An agent’s deny list is a guard rail. A filesystem that contains nothing to leak is a boundary.

Four boundaries, from the weakest to the strongest

  1. The model's instructionsA prompt asking it not to touch other files. Not a control at all: the model is the thing you are constrainingzero enforcement
  2. The agent's permission listDeny rules, approval modes, auto-approve checkboxes. Enforced by the agent, in your account, with your privilegesstops accidents
  3. A separate user accountEnforced by the operating system. The agent cannot read what its user cannot reada real boundary
  4. A container with one mount and no routeEnforced by the kernel. The agent cannot read what is not in its filesystem, and cannot reach what has no routewhat this lab builds
The top two are worth having and are not what people think they have. This lab builds the bottom one, and on the track where containers are awkward it builds the third instead.

The Part 9 gateway running, a project directory you are willing to let a program rewrite, and a container engine or a spare user account. Sixty minutes, all of it attended.

Track S — NVIDIA DGX Spark

Docker Engine with the Compose plugin, which Part 5 installed. Everything in this lab works as written. If you have not enabled user-namespace remapping on this machine, task 5 is the place to consider it.

Track X — AMD Ryzen AI Max+ 395

Docker Engine or Podman. Podman’s documentation says a familiar CLI is the point — “Most users can simply alias Docker to Podman (alias docker=podman) without any problems” — and its rootless mode gives you something Docker’s default does not, which task 5 covers. If you are on a distribution with SELinux enabled, add the :z or :Z suffix to the bind mounts; the documentation describes Z as labelling “the content with a private unshared label. Only the current container can use a private volume”, which is what a single-agent sandbox wants.

Track M — Apple siliconPartial

Containers on macOS run inside a Linux virtual machine through Docker Desktop, which changes the performance of bind mounts but not the boundary. The native alternative, sandbox-exec, is documented as deprecated, so the second path here is a dedicated user account.

Two routes, and you should pick deliberately.

Containers through Docker Desktop. The whole lab works. Docker’s installation page states that Docker Desktop “is supported on the current and two previous major macOS releases” and lists a 4 GB memory minimum, and on Apple silicon it recommends installing Rosetta 2 for a few command-line tools. Bind mounts cross a virtual-machine boundary, which makes a large test suite slower than it is natively; that is a performance note, not a security one.

A dedicated user account. The alternative if you would rather not run Docker Desktop. Apple’s own guidance describes the account type you want: “Standard users are set up by an administrator. Standard users can install apps and change their own settings, but can’t add other users or change other users’ settings.” Create a standard account for agent work, give it the project directory and nothing else, and run the agent while logged in as that user or through su. Task 6 covers what this does and does not achieve.

Do not reach for sandbox-exec. Its manual page states “The sandbox-exec command is DEPRECATED. Developers who wish to sandbox an app should instead adopt the App Sandbox feature described in the App Sandbox Design Guide”, and App Sandbox is documented as applying to macOS apps through entitlements, with Apple noting that “To distribute a macOS app through the Mac App Store, you must enable the App Sandbox capability”. Neither is a supported route for confining an arbitrary command-line agent.

Track N — NVIDIA desktop or laptop

Docker Engine with the Compose plugin, or Podman. Nothing in this lab needs the GPU: the agent talks HTTP to your gateway and the weights stay outside the container, which is itself a useful property — the sandbox costs you no accelerator memory.

Working directory and terminal roles

Prepare the course execution workspace once before this procedure. It includes this part's scripts, data and shared Python helpers. In the client or training terminal, select this directory:

RunnableAll tracks

select this part’s execution directory
export LABS_ROOT="${LABS_ROOT:-$HOME/llm-course/labs}"
export LAB_DIR="$LABS_ROOT/part-25-coding-agents"
cd "$LAB_DIR"
pwd
test -f "agent-sandbox.Dockerfile"

Expected result: pwd ends in part-25-coding-agents and the file check returns successfully. If it does not, finish workspace preparation before continuing. Activate the environment in the requirements for your track. Bare script and data filenames below are relative to this directory; paths to earlier experiments must point at the artefacts you actually retained.

Keep each foreground server in a separate terminal and send requests from this terminal. Reapply lesson-specific environment variables in each new shell. Stop at the first failed checkpoint and retain its output; the execution guide explains how to distinguish missing files, endpoint failures and capacity problems.

1. Plant a secret, so the test has something to fail on

Section titled “1. Plant a secret, so the test has something to fail on”

A boundary test that has nothing to find proves nothing. Put a file somewhere the agent must not reach, and remember where.

RunnableAll tracks

something the agent must never read
mkdir -p ~/agent-lab
printf 'if an agent can read this line, the boundary leaked\n' > ~/agent-lab-secret.txt
chmod 600 ~/agent-lab-secret.txt

That file is outside the project directory and outside anything this lab mounts. If it ever appears inside the container, you have found a real hole rather than a theoretical one.

RunnableAll tracks

agent-sandbox.Dockerfile
# Purpose: an image for running a coding agent with nothing in it except the agent, a
# Python toolchain and the project you mount. No credentials, no host home
# directory, no container socket, and a non-root user by default.
# Platform: spark, strix, nvidia (Docker Engine or Podman); mac through Docker Desktop
# Minimum memory: 8 GB; the model runs outside this container, on the host or elsewhere
# Assumes: built with `docker build -f agent-sandbox.Dockerfile -t agent-sandbox .` and
# run through compose-sandbox.yaml, which supplies the mounts, the network policy
# and the resource limits. Nothing here needs a GPU: the agent talks to your
# gateway over HTTP and the weights never enter this image.
FROM python:3.12-slim
# Build-time network access is used here and nowhere else. Everything the agent needs is
# installed now, so that the running container can be cut off from the internet entirely.
RUN apt-get update \
&& apt-get install --no-install-recommends --yes \
git \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# The test toolchain the task uses, plus one agent. Pin what you install: an agent that
# updates itself inside a sandbox is a sandbox whose contents you did not choose.
RUN pip install --no-cache-dir \
"pytest==8.3.4" \
"aider-chat==0.86.0"
# A non-root user with a home directory that is not a host path. Docker's own guidance is
# that the best way to prevent privilege escalation from inside a container is to run the
# application as an unprivileged user, and this is the cheapest half of that.
RUN useradd --create-home --home-dir /agent --shell /bin/bash --uid 10001 agent
# The only directory the agent is expected to write to. compose-sandbox.yaml mounts your
# project here and mounts nothing else.
WORKDIR /work
# Two habits that make the boundary visible from inside. HOME is not a host path, and the
# shell history goes to a tmpfs that disappears with the container.
ENV HOME=/agent \
HISTFILE=/tmp/.bash_history \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PYTHONDONTWRITEBYTECODE=1
USER agent
CMD ["/bin/bash"]

Download agent-sandbox.Dockerfile45 lines

RunnableAll tracks

compose-sandbox.yaml
# Purpose: run a coding agent in a container that can see one project directory, can reach
# the gateway and nothing else on the network, carries none of your credentials,
# and cannot start containers of its own.
# Platform: spark, strix, nvidia (Docker Engine with the Compose plugin, or Podman);
# mac through Docker Desktop
# Minimum memory: 8 GB; the model runs outside this stack
# Assumes: a .env file beside this one, copied from env-example.txt and filled in, and
# agent-sandbox.Dockerfile in the same directory. Start it with:
# docker compose -f compose-sandbox.yaml run --rm agent
# Every key used here is in the Compose file reference read on 2026-09-09.
name: agent-sandbox
services:
agent:
build:
context: .
dockerfile: agent-sandbox.Dockerfile
# An interactive shell inside the sandbox. Change this to a one-shot agent invocation
# once you trust the boundary; see the lab page.
command: ["/bin/bash"]
stdin_open: true
tty: true
# ------------------------------------------------------------------ what it can see
volumes:
# The project, and nothing else. This is the only writable mount, and it is the
# single most important line in this file: the agent's world is what is listed here.
- "${PROJECT_DIR}:/work"
# The task's read-only reference material, mounted read-only because the agent has
# no reason to change it. Docker's bind-mount documentation is explicit that a bind
# mount has write access to the host by default and that ro is how you remove it.
- "${TASK_DIR}:/task:ro"
# No host home directory. No ~/.ssh. No ~/.aws. No ~/.config. No /var/run/docker.sock.
# Those absences are the security model, and the audit script in the challenge that
# follows this lab checks for each of them by name.
# ------------------------------------------------------------------- who it runs as
# The image already creates this user; naming it here means an image change cannot
# silently promote the agent back to root.
user: "10001:10001"
# ------------------------------------------------------------- what it may do at all
read_only: true
tmpfs:
# A read-only root filesystem needs somewhere to write scratch files, and a tmpfs
# gives it one that disappears when the container stops.
- /tmp
- /agent
cap_drop:
- ALL
security_opt:
# Documented as disabling "container processes from gaining new privileges", which
# stops setuid binaries and sudo from doing anything useful inside the container.
- "no-new-privileges=true"
pids_limit: 512
# ------------------------------------------------------------------ what it may use
# An agent that runs your test suite in a loop can use a whole machine. These are not
# security boundaries; they are the difference between a runaway agent slowing your
# editor and a runaway agent making the machine unusable.
mem_limit: "${AGENT_MEMORY_LIMIT}"
cpus: ${AGENT_CPU_LIMIT}
# -------------------------------------------------------------- where it may connect
environment:
# The gateway address, as seen from inside this network. Nothing else in the
# environment: no keys from your shell, no tokens, no cloud credentials. What is
# absent here matters more than what is present.
OPENAI_API_BASE: "${GATEWAY_URL}"
OPENAI_API_KEY: "${AGENT_GATEWAY_KEY}"
AIDER_MODEL: "openai/${MODEL_ALIAS}"
networks:
- sandbox
networks:
sandbox:
# `internal`, per the Compose reference, "lets you create an externally isolated
# network". The agent can reach other containers attached to this network and nothing
# beyond it: no package registry, no model hub, no paste site.
#
# To let it reach your gateway, attach the gateway's container to this network too, or
# set `external: true` and name the Part 9 stack's private network here. The lab page
# walks through both, and boundary-test.sh proves which one you actually got.
internal: true

Download compose-sandbox.yaml86 lines

RunnableAll tracks

env-example.txt
# Purpose: the settings compose-sandbox.yaml reads. Copy this file to `.env` beside it and
# fill in the empty lines. Nothing here is a secret except the gateway key, which
# you generate on the gateway rather than copy from anywhere.
# Platform: all
# Minimum memory: 8 GB
# Assumes: `cp env-example.txt .env` and then an editor. Docker Compose reads `.env`
# automatically from the directory you run it in.
# ------------------------------------------------------------------- what the agent sees
# Absolute path to the one project the agent may edit. This is the whole of its writable
# world, so point it at a directory you would be comfortable letting a program rewrite.
# Do not point it at a parent directory "just in case": every extra directory here is an
# extra thing the boundary does not protect.
PROJECT_DIR=
# Absolute path to the read-only task material, mounted at /task. For the lab this is the
# directory holding task-readme.md, task-app.py and task-tests.py.
TASK_DIR=
# ---------------------------------------------------------------------------- the model
# The gateway's address as seen from inside the sandbox network. When the gateway
# container is attached to the same network, this is its Compose service name and port,
# for example http://gateway:4000/v1 rather than a loopback address, because inside the
# container 127.0.0.1 is the container itself.
GATEWAY_URL=
# The alias the agent should ask for. One of the names published by the Part 9 gateway.
MODEL_ALIAS=local/coder
# A virtual key generated on the gateway for this sandbox alone, scoped to the aliases the
# agent needs and nothing else. Generate it with the gateway project's key-generation
# call. Do not paste a key you use anywhere else: the point of a per-sandbox key is that
# revoking it costs you nothing.
AGENT_GATEWAY_KEY=
# ------------------------------------------------------------------------ resource caps
# Not security boundaries. They are what stops a runaway agent, running your test suite in
# a loop, from making the rest of the machine unusable while it does it. Pick values that
# leave the engine and your desktop room to work.
AGENT_MEMORY_LIMIT=4g
AGENT_CPU_LIMIT=2.0

Download env-example.txt41 lines

Read the Compose file before you run it. Six decisions are encoded in it and each one is a sentence from the documentation.

The mounts are the security model. Docker’s bind-mount page is explicit about the default: “Bind mounts have write access to files on the host by default… you can change the host filesystem via processes running in a container, including creating, modifying, or deleting important system files or directories.” The project is mounted writable because the agent must edit it; the task material is mounted with :ro because it must not.

The user is set to a non-root uid, because Docker’s user-namespace page states the principle directly: “The best way to prevent privilege-escalation attacks from within a container is to configure your container’s applications to run as unprivileged users.”

The root filesystem is read-only, documented as configuring “the service container to be created with a read-only filesystem”, with tmpfs mounts giving the agent somewhere scratch that vanishes.

The capabilities are dropped entirely and no-new-privileges is set, which is documented as disabling “container processes from gaining new privileges”, with the practical consequence that “commands that raise privileges such as su or sudo no longer work”.

The network is internal, which the Compose reference defines as: “By default, Compose provides external connectivity to networks. internal, when set to true, lets you create an externally isolated network.”

The limits on memory and processes are not security. They are what keeps a runaway agent from making the machine unusable while it runs your test suite in a loop.

RunnableAll tracks

the settings, then the image
cd ~/agent-lab
cp env-example.txt .env
docker compose -f compose-sandbox.yaml build

Edit .env first. PROJECT_DIR is the one directory the agent may rewrite; point it at the lab task from the previous page, not at your whole repository collection. GATEWAY_URL is the gateway as seen from inside the container, which is not a loopback address: inside a container, 127.0.0.1 is the container itself.

4. Connect it to the gateway and nothing else

Section titled “4. Connect it to the gateway and nothing else”

Two ways, and both are worth understanding.

Attach the gateway to the sandbox network. Add the sandbox network to the gateway service in the Part 9 stack, so both containers share it. The sandbox keeps internal: true and therefore has no external route, while the gateway keeps its own network as well and does have one.

Or point the sandbox at the gateway’s existing private network. Replace the network block with an external: true entry naming the Part 9 stack’s network. The Compose reference describes external as specifying “that this network’s lifecycle is maintained outside of that of the application”, so Compose will not try to create it and will error if it does not exist.

The first is easier to reason about and is what the shipped file assumes. The second is tidier once you have several sandboxes.

RunnableAll tracks

boundary-test.sh
#!/usr/bin/env bash
# Purpose: prove, from inside the sandbox, that the boundary is where you think it is -
# the host home directory is not visible, the internet is not reachable, a planted
# secret cannot be read, the container socket is absent, the root filesystem is
# read-only, the process is not root, and the gateway IS reachable.
# Platform: all; run inside the agent container, not on the host
# Minimum memory: 8 GB for the stack as a whole; this script needs almost nothing
# Assumes: run as `docker compose -f compose-sandbox.yaml run --rm agent bash /task/boundary-test.sh`
# with python3 available (the sandbox image is based on a Python image), the
# project mounted at /work and the task material at /task. Set GATEWAY_URL to the
# gateway as seen from inside the network to exercise the positive control.
#
# Every check below is written so that FAILURE OF THE ATTEMPT IS THE PASS. A check that
# succeeds in reading, writing or connecting is a hole in the boundary.
set -uo pipefail
WORKDIR="${WORKDIR:-/work}"
GATEWAY_URL="${GATEWAY_URL:-${OPENAI_API_BASE:-}}"
LABBOOK="${LABBOOK:-$WORKDIR/labbook.md}"
SECRET_NAME="${SECRET_NAME:-planted-secret.txt}"
passes=0
failures=0
results=""
record() {
# record <name> <pass|fail> <detail>
local name="$1" verdict="$2" detail="$3"
if [ "$verdict" = "pass" ]; then
passes=$(( passes + 1 ))
printf ' PASS %-34s %s\n' "$name" "$detail"
else
failures=$(( failures + 1 ))
printf ' FAIL %-34s %s\n' "$name" "$detail"
fi
results="${results}${results:+,}\"${name}\":\"${verdict}\""
}
echo "Boundary test, run from inside the container at $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo
# 1. The host home directory must not be visible. A mounted home is the commonest hole,
# and it is the one that makes every other control irrelevant.
if [ -d /host-home ] || [ -d /root/.ssh ] || [ -d "$HOME/.ssh" ] || [ -d "$HOME/.aws" ]; then
record "host-home-not-mounted" "fail" "a credential directory is visible from inside"
else
record "host-home-not-mounted" "pass" "no host credential directory is visible"
fi
# 2. The container socket must not be present. A process that can reach it can start a
# container that mounts the host root, which ends the sandbox.
if [ -S /var/run/docker.sock ] || [ -S /run/docker.sock ] || [ -S /run/podman/podman.sock ]; then
record "container-socket-absent" "fail" "a container socket is mounted into the sandbox"
else
record "container-socket-absent" "pass" "no container socket present"
fi
# 3. The process must not be root. Root inside a container without user-namespace remapping
# is uncomfortably close to root outside it.
if [ "$(id -u)" = "0" ]; then
record "runs-as-non-root" "fail" "running as uid 0"
else
record "runs-as-non-root" "pass" "uid $(id -u)"
fi
# 4. The root filesystem must be read-only, so an agent cannot install itself a new tool
# or leave anything behind outside the project.
probe="/etc/boundary-probe.$$"
if : >"$probe" 2>/dev/null; then
rm -f "$probe"
record "root-filesystem-read-only" "fail" "wrote to /etc"
else
record "root-filesystem-read-only" "pass" "/etc is not writable"
fi
# 5. The project must be writable, or the agent cannot do its job. This is the one check
# where success is the pass.
probe="$WORKDIR/.boundary-probe.$$"
if : >"$probe" 2>/dev/null; then
rm -f "$probe"
record "project-is-writable" "pass" "$WORKDIR is writable"
else
record "project-is-writable" "fail" "$WORKDIR is not writable; the agent cannot work"
fi
# 6. A planted secret outside the project must be unreadable. Plant it on the host before
# running this, in a directory you did NOT mount, and pass its path in SECRET_PATH.
secret_found=false
for candidate in "${SECRET_PATH:-}" "/$SECRET_NAME" "$HOME/$SECRET_NAME" "/host-home/$SECRET_NAME"; do
[ -z "$candidate" ] && continue
if [ -r "$candidate" ]; then
secret_found=true
break
fi
done
if [ "$secret_found" = true ]; then
record "planted-secret-unreadable" "fail" "read a secret that is outside the project"
else
record "planted-secret-unreadable" "pass" "no readable secret outside the project"
fi
# 7. No credential-shaped variables in the environment beyond the one gateway key. An
# agent inherits its environment, and an environment full of tokens is a boundary that
# was never applied.
leaked="$(env | grep -E -i '^[A-Z_]*(TOKEN|SECRET|PASSWORD|API_KEY)=' \
| grep -v -E '^(OPENAI_API_KEY|AGENT_GATEWAY_KEY)=' || true)"
if [ -n "$leaked" ]; then
record "environment-is-clean" "fail" "$(echo "$leaked" | cut -d= -f1 | tr '\n' ' ')"
else
record "environment-is-clean" "pass" "only the sandbox gateway key is present"
fi
# 8. The internet must be unreachable. Two attempts against addresses that would answer
# from any machine with an external route.
internet_reachable="$(python3 - <<'PY'
import socket
reachable = False
for host, port in (("pypi.org", 443), ("github.com", 443)):
try:
socket.setdefaulttimeout(4)
socket.create_connection((host, port), timeout=4).close()
reachable = True
break
except OSError:
continue
print("yes" if reachable else "no")
PY
)"
if [ "$internet_reachable" = "yes" ]; then
record "internet-unreachable" "fail" "opened a connection to a public host"
else
record "internet-unreachable" "pass" "no route to a public host"
fi
# 9. The gateway must be reachable. This is the positive control: without it, a passing
# test sheet might just mean the container has no network at all and no agent can run.
if [ -z "$GATEWAY_URL" ]; then
record "gateway-reachable" "fail" "GATEWAY_URL is unset, so the control was not run"
else
gateway_ok="$(GATEWAY_URL="$GATEWAY_URL" python3 - <<'PY'
import os
import urllib.error
import urllib.request
url = os.environ["GATEWAY_URL"].rstrip("/") + "/models"
try:
with urllib.request.urlopen(url, timeout=5) as response:
print("yes" if response.status < 500 else "no")
except urllib.error.HTTPError:
# An authentication or method error still proves the gateway answered.
print("yes")
except OSError:
print("no")
PY
)"
if [ "$gateway_ok" = "yes" ]; then
record "gateway-reachable" "pass" "the gateway answered"
else
record "gateway-reachable" "fail" "the gateway did not answer; the agent cannot work"
fi
fi
echo
echo "$passes passed, $failures failed."
RESULTS="$results" PASSES="$passes" FAILURES="$failures" LABBOOK="$LABBOOK" python3 - <<'PY'
import datetime
import json
import os
checks = json.loads("{" + os.environ["RESULTS"] + "}")
record = {
"lab": "part-25-sandbox-your-agent",
"recorded": datetime.datetime.now(datetime.timezone.utc)
.replace(microsecond=0)
.isoformat(),
"checks": checks,
"passed": int(os.environ["PASSES"]),
"failed": int(os.environ["FAILURES"]),
"boundary_holds": os.environ["FAILURES"] == "0",
}
try:
with open(os.environ["LABBOOK"], "a", encoding="utf-8") as handle:
handle.write(json.dumps(record) + "\n")
print("recorded in " + os.environ["LABBOOK"])
except OSError as error:
print("could not write the lab notebook: " + str(error))
PY
if [ "$failures" -ne 0 ]; then
echo
echo "The boundary does not hold. Fix the failing check before running an agent here." >&2
exit 1
fi

Download boundary-test.sh196 lines

Every check in it is written so that failure of the attempt is the pass. It tries to see a home directory, tries to reach two public hosts, tries to read the planted secret, looks for a container socket, tries to write outside the project, and checks the environment for credential-shaped variables. Then it does the one thing that must succeed: reach the gateway.

RunnableAll tracks

prove the boundary, from inside
cd ~/agent-lab
docker compose -f compose-sandbox.yaml run --rm \
-e SECRET_PATH=/agent-lab-secret.txt \
agent bash /task/boundary-test.sh

Output — what you should see

Boundary test, run from inside the container at 2026-09-09T00:00:00Z
PASS host-home-not-mounted no host credential directory is visible
PASS container-socket-absent no container socket present
PASS runs-as-non-root uid 10001
PASS root-filesystem-read-only /etc is not writable
PASS project-is-writable /work is writable
PASS planted-secret-unreadable no readable secret outside the project
PASS environment-is-clean only the sandbox gateway key is present
PASS internet-unreachable no route to a public host
PASS gateway-reachable the gateway answered
9 passed, 0 failed.

Eight of those nine are boundaries. The ninth, gateway-reachable, is the control that stops you from celebrating a container that simply has no network and in which no agent can run.

6. Harden one level further, on the track you are on

Section titled “6. Harden one level further, on the track you are on”

Track S — NVIDIA DGX Spark

Consider user-namespace remapping. The documentation describes what it buys: the remapped user “is assigned a range of UIDs which function within the namespace as normal UIDs from 0 to 65536, but have no privileges on the host machine itself”. It is a daemon-level setting, enabled with the userns-remap key in daemon.json or the --userns-remap flag, and default makes Docker create the user for you.

Fragment — not complete on its own

Terminal window
{
"userns-remap": "default"
}

Read the whole page before you enable it: the same documentation notes that “the whole container filesystem will belong to the user specified in the --userns-remap daemon config”, which can break images that expect to be root, and it applies to every container on the machine rather than only this one.

Track X — AMD Ryzen AI Max+ 395

Try the same stack under rootless Podman, which gives you the property Docker’s default does not. The rootless tutorial states it plainly: “Rootless Podman is not, and will never be, root; it’s not a setuid binary, and gains no privileges when it runs.” It uses a user namespace so that “If your container runs with the root user, then root in the container is actually your user on the host.”

--network none, --read-only, -v ...:ro, --userns and --security-opt no-new-privileges all exist in podman run with the same names, so the whole file translates. Run the boundary test again under Podman and compare the two sheets; the interesting difference is what runs-as-non-root means in each case.

Track M — Apple silicon

If you took the container route, everything above applies. If you took the dedicated-user route, this is where you make it real.

Create a standard user account for agent work. Give it read and write access to the project directory and nothing else, and check the negative case explicitly: log in as that user and try to read a file in your own home directory. Standard macOS permissions should refuse.

Then run the boundary test as that user with WORKDIR set to the project. Three checks will behave differently and you should understand each. runs-as-non-root passes because the account is not an administrator. root-filesystem-read-only fails, because on a real machine the root filesystem is not read-only for anyone, and that is a genuine difference between this route and the container. internet-unreachable fails unless you have added a firewall rule, because a user account is not a network boundary.

Record those three as known gaps rather than pretending the sheet is clean. The dedicated user gives you filesystem isolation from your own data, which is the largest single risk, and gives you neither a network boundary nor an immutable system. That is a defensible position as long as it is a stated one.

Track N — NVIDIA desktop or laptop

Consider user-namespace remapping, as on Track S, and consider whether the sandbox needs a GPU at all. It does not: the agent talks HTTP to the gateway, and the engine holding the weights is outside the container. Leaving the accelerator out of the sandbox costs nothing and removes a whole category of device-access questions from the boundary you are trying to reason about.

7. Now put an agent in it and set its permissions

Section titled “7. Now put an agent in it and set its permissions”

The container is the outer boundary. The agent’s own permission model is the inner one, and both are worth setting, because the inner one is what stops the ordinary accident before it becomes an event.

Fragment — not complete on its own

Terminal window
docker compose -f compose-sandbox.yaml run --rm agent \
aider --model "openai/local/coder" \
--message "Read /task/task-readme.md and make the tests pass." \
--test-cmd "python3 -m pytest -q task-tests.py" \
--auto-test \
--no-auto-commits \
--yes-always \
task-app.py

Inside this container, --yes-always is a reasonable setting: the confirmation prompts were protecting you from a filesystem the container has already removed. That is the whole argument of this lab in one flag. The same command outside the container is a bad idea, and it is exactly the same command.

For the other tools, set the equivalent: Codex CLI’s --sandbox workspace-write with --ask-for-approval never, OpenCode’s --auto or a permission map with bash set to allow, Goose’s automatic mode, Claude Code’s acceptEdits. Write down which you used, because the challenge that follows asks you to reason about a machine where one of them was set to the most permissive value by someone who had not built the container.

The last step, and the one that makes the test worth keeping. Introduce one hole, watch the test catch it, then put it back.

Fragment — not complete on its own

Terminal window
docker compose -f compose-sandbox.yaml run --rm \
-v "$HOME:/host-home:ro" \
-e SECRET_PATH=/agent-lab-secret.txt \
agent bash /task/boundary-test.sh

That adds a read-only mount of your home directory, which is the commonest real-world hole and looks harmless because it is read-only. Two checks should fail: host-home-not-mounted, and — depending on where you planted it — planted-secret-unreadable.

Read-only is not the point. A read-only mount of a home directory still exposes every key, token, browser profile and private repository in it to a program you did not read.

Prove the boundary before using a real project

Section titled “Prove the boundary before using a real project”

Use only the lab’s synthetic secret and disposable files. Inspect the container configuration for mounts, user identity, privileges, capabilities, network mode and environment values before launch. The model should reach the gateway but should not receive unrelated credentials or host control.

Run the boundary script directly before starting an agent. Check permitted workspace access, forbidden host-path access, unavailable administrative sockets and the intended outbound-network restriction. A failed boundary probe is a stop condition: repair the configuration and rerun the same probe before giving the model tools.

Then run a harmless agent task and a task that attempts the forbidden read. Distinguish the model declining the request from the operating environment denying it. The latter is the enforcement evidence. Keep the redacted inspection output and pass/fail result for every boundary. When demonstrating a weakened configuration, use a separate disposable container and restore the hardened configuration immediately afterwards. Cleanup removes the synthetic secret and test environment only after the evidence is saved. Do not generalise a container-name or permission-prompt setting into a security claim without the actual denied-access checks.

You are done when all of the following are true:

  • boundary-test.sh reports nine passes and zero failures inside your sandbox;
  • the gateway-reachable control passes, so the sandbox is a working environment and not an empty one;
  • deliberately mounting your home directory makes the test fail, and removing it makes it pass again;
  • the planted secret is outside every mount, and the test confirms it cannot be read;
  • an agent completes the previous lab’s task inside the container, and its edits appear in the project directory on the host and nowhere else;
  • labbook.md contains the boundary-test record, written by the script;
  • on Track M with the dedicated-user route, the three known gaps are written down rather than glossed over.

A container you will reuse for the rest of the course, and a test you run after every change to it.

The properties you should be able to state without looking: the agent’s filesystem contains the project, the read-only task material and a system image; its network reaches the gateway and nothing else; its environment contains one key scoped to one alias; it runs as an unprivileged user on a read-only root; and none of that depends on the agent behaving well.

The build fails with no network. The build needs the internet; the running container does not. internal: true applies to the runtime network, not to the build. If your build is failing, look at proxy settings rather than at the sandbox network.

gateway-reachable fails and everything else passes. The address is wrong or the networks are not joined. From the host, confirm the gateway answers. From inside the container, remember that the gateway is a service name on a shared network, not a loopback address.

The agent cannot write to the project. Check the uid. The container runs as uid 10001 and the files on the host are owned by you; on Linux the mounted directory keeps its host ownership, so the agent needs write permission for that uid. Either relax the directory’s group permissions, or set the user key to your own uid and gid, which is a legitimate choice as long as it is deliberate.

Podman refuses to read the mounted directory on an SELinux system. Add :z or :Z to the volume. The documentation describes Z as labelling the content “with a private unshared label”, which is what a single-container sandbox wants.

On macOS, the test suite is much slower inside the container. Bind mounts cross the virtual machine boundary on Docker Desktop. That is a performance property, not a boundary problem. If it is intolerable, use the dedicated-user route and accept the documented gaps.

Everything passes but the agent behaves as if it can see your home directory. Check for a Compose override file. docker compose config prints the merged configuration, and the merged configuration is what actually ran.

The environment check fails and names a variable you did not set. Compose passes some variables through from your shell. Read the merged configuration, and prefer an explicit environment block to env_file for a sandbox, so that what reaches the container is a list you wrote.

RunnableAll tracks

stop the sandbox and remove the planted secret
cd ~/agent-lab
docker compose -f compose-sandbox.yaml down
rm -f ~/agent-lab-secret.txt

Keep the image; you will use it in the project at the end of this part. Revoke the sandbox’s gateway key if you are not going on immediately.

  • A permission list and a boundary are different things. The first is enforced by the program you are constraining and stops accidents; the second is enforced by the kernel and stops the rest. Every agent in this part has a switch that turns the first one off, and each of those switches is reasonable only inside the second.
  • The mount list is the security model. Everything else in the Compose file is secondary to the question of what appears in the container’s filesystem, and read-only does not make a home directory safe to expose.
  • A boundary test needs a positive control. Without gateway-reachable, a completely broken container passes every check, and you would ship it.
  • Failure of the attempt is the pass. Writing the checks that way is what makes the sheet readable at a glance and what makes a new hole obvious rather than subtle.
  • The container socket is an exit. Docker’s own documentation describes starting a container that can alter the host filesystem without restriction, and membership of the docker group as root-level privilege. A sandbox containing the socket is not a sandbox.
  • Every track can do this, and not identically. On macOS the honest alternative to a container is a dedicated standard user, which gives filesystem isolation and neither network isolation nor an immutable system, and saying so is better than a clean-looking test sheet that measured less than it appeared to.

Record in the notebook: the boundary-test sheet with its nine results, the engine and version you used, the exact mounts in your final configuration, which permission mode you set in the agent inside the container, and for Track M’s dedicated-user route the three checks that legitimately fail and why.

Check your understanding

Question 1. Why does the boundary test include a check that must succeed rather than fail?
Show the answer and why

Answer: Because a container with no network at all passes every negative check while being useless, so a positive control is what distinguishes a working sandbox from a broken one

Negative checks alone cannot tell "correctly confined" from "completely disconnected". The gateway control is what makes a sheet of passes mean something.

Question 2. A colleague mounts their home directory into an agent container read-only, arguing that read-only makes it safe. What is wrong with that?
Show the answer and why

Answer: Read-only prevents modification but not reading, so every key, token, browser profile and private repository in that directory is exposed to a program nobody read

The risk from an agent is not only that it writes somewhere it should not. Reading is how credentials leave, and a read-only mount does nothing about it. The lab has you introduce exactly this hole and watch the test catch it.

Question 3. What does Docker's documentation say happens when a process can reach the container socket?
Show the answer and why

Answer: It can start a container that mounts the host root directory and alter the host filesystem without restriction, which is why the docker group is described as granting root-level privileges

This is the documented escape route and it is why the audit in the following challenge looks for the socket by name. A container holding the socket is not bounded by that container.

Question 4. Which of these are true of the dedicated-user route on macOS? Select all that apply.
Show the answer and why

Answer: It gives filesystem isolation from your own home directory, sandbox-exec is documented as deprecated, so it is not the supported alternative, App Sandbox is documented as applying to macOS apps through entitlements rather than to arbitrary command-line programs

A user account is an operating-system boundary for files and not for network access. Getting that distinction right, and writing down the gaps, is what makes the Track M route honest rather than a weaker container pretending to be one.

Question 5. Inside the sandbox, GATEWAY_URL set to http://127.0.0.1:4000 fails to connect. Why?
Show the answer and why

Answer: Inside a container, 127.0.0.1 is the container's own loopback interface, so the gateway must be addressed by its service name on a shared network

This is the commonest false alarm in the lab, which is why the boundary test separates it from the genuine network checks. The gateway is a different container; inside this one, loopback is this one.

Sources for this lesson

15 verified · checked 2026-09-09

  1. 01Docker — networking overview§ Network driversdocs.docker.com/engine/network2026-09-09
  2. 02Docker — none network driverdocs.docker.com/engine/network/drivers/none2026-09-09
  3. 03Docker — bind mounts§ Syntax; considerations and constraintsdocs.docker.com/engine/storage/bind-mounts2026-09-09
  4. 04Docker — isolate containers with a user namespacedocs.docker.com/engine/security/userns-remap2026-09-09
  5. 05Docker — runtime options with memory, CPUs and GPUsdocs.docker.com/engine/containers/resource_constraints2026-09-09
  6. 06Docker — security§ Docker daemon attack surfacedocs.docker.com/engine/security2026-09-09
  7. 07Docker — post-installation steps for Linux§ docker group privilegesdocs.docker.com/engine/install/linux-postinstall2026-09-09
  8. 08Compose file reference — networks§ internaldocs.docker.com/reference/compose-file/networks2026-09-09
  9. 09Compose file reference — services§ read_only; tmpfs; user; cap_drop; security_opt; pids_limit; mem_limit; cpusdocs.docker.com/reference/compose-file/services2026-09-09
  10. 10Podman — rootless tutorialgithub.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md2026-09-09
  11. 11Podman documentation — index§ Familiar CLIdocs.podman.io/en/latest/index.html2026-09-09
  12. 12sandbox-exec manual page§ Deprecation noticekeith.github.io/xcode-man-pages/sandbox-exec.1.html2026-09-09
  13. 13Apple — App Sandboxdeveloper.apple.com/documentation/security/app-sandbox2026-09-09
  14. 14Apple — set up other users on your Macsupport.apple.com/guide/mac-help/set-up-other-users-on-your-mac-mtusr001/mac2026-09-09
  15. 15Docker Desktop — install on Macdocs.docker.com/desktop/setup/install/mac-install2026-09-09

Every technical claim on this page was checked against the official documentation of the tool, vendor or model publisher on the date shown, at the version pinned for the course. Where the course disagrees with folklore, the source is how you can tell which one to trust.