Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 8 min read

My AI Agent Deploys Code to My Server. Here's What Keeps It in a Box.

My coding agent deploys apps to a server I own. A lot of the time it's shipping code it wrote ten minutes earlier, and I haven't read every line. Usually that's fine. The question I kept coming back to was what happens o

My coding agent deploys apps to a server I own. A lot of the time it's shipping code it wrote ten minutes earlier, and I haven't read every line. Usually that's fine. The question I kept coming back to was what happens on the day it isn't fine: a bad dependency, a confused agent, or an app that starts poking at the machine it runs on.

My answer is a sandbox around every app the agent ships. I built it into agentic-hosting, an open-source tool that turns a Linux server into a place AI agents can deploy apps and databases on their own. This post explains in plain terms how that sandbox works, how to set it up on your server, and how to check for yourself that an app is really inside it.

⭐ If this is useful, a star on GitHub helps other people find it.

πŸ€– Give this to your agent: point it at https://raw.githubusercontent.com/dennisonbertram/agentic-hosting/main/llms.txt and it can sign up and set things up from that one link.

Why a normal container isn't enough

Most apps today ship as containers. A container feels like its own small computer, with its own files and processes. It isn't one. Every container on a machine shares the same Linux kernel with the host and with every other container. The kernel is the core program that controls files, memory, the network, and everything else.

Programs ask the kernel for things through system calls, or syscalls. Opening a file is a syscall. So is sending network data or starting a process. In a normal container, those calls go straight to the host's real kernel. Linux has hundreds of them, and every so often someone finds a bug in one that lets a program break out of its container and reach the host. That's called a container escape.

When I write and deploy the code myself, I can live with that risk. When an agent writes and deploys code all day without me watching, I want another wall.

What gVisor does

gVisor is an open-source project from Google that adds that wall. It gives each container its own kernel, written in Go, that runs as an ordinary program on the host. gVisor calls this kernel the Sentry.

When the app makes a syscall, the Sentry handles it itself instead of passing it to the host kernel. When the app needs files, a separate helper process called the Gofer fetches them. The Sentry itself runs with tight limits on what it can ask the real kernel for. So if the app finds a bug, it's a bug in gVisor's kernel, and getting from there to the host takes a second break-in.

gVisor plugs into Docker as a runtime, which is the piece that actually starts containers. Docker's default runtime is called runc. gVisor's is called runsc. You use the same Docker commands with a different engine underneath.

There are costs, and gVisor's docs are upfront about them. Each syscall takes a longer path, so apps that make a lot of them, like heavy disk or network work, run slower. gVisor also doesn't implement every Linux syscall, so once in a while an app won't run unchanged (see the compatibility notes). For the web apps and APIs I have agents deploy, I'll take that trade.

Setting up gVisor for agentic-hosting

agentic-hosting is a single Go program called ah that runs on your server and takes deploy requests from agents over an HTTP API. The quickstart covers the full install. Here's the gVisor part, on Ubuntu.

Install runsc from gVisor's package repository:

KEYRING=/usr/share/keyrings/gvisor-archive-keyring.gpg
ARCH=$(dpkg --print-architecture)
REPO=https://storage.googleapis.com/gvisor/releases

curl -fsSL https://gvisor.dev/archive.key \
  | sudo gpg --dearmor -o "$KEYRING"
echo "deb [arch=$ARCH signed-by=$KEYRING] $REPO release main" \
  | sudo tee /etc/apt/sources.list.d/gvisor.list
sudo apt-get update && sudo apt-get install -y runsc

Then register it with Docker under the name runsc:

sudo tee /etc/docker/daemon.json <<'JSON'
{
  "runtimes": {
    "runsc": {
      "path": "/usr/bin/runsc"
    }
  },
  "default-address-pools": [{"base":"172.20.0.0/14","size":24}]
}
JSON
sudo systemctl restart docker

The runtimes block is the part that matters. ah asks Docker for a runtime named exactly runsc. The address pool line gives Docker room for a lot of small networks, which ah needs because it creates a private network for each agent and another one for each app.

Check that it worked:

docker info 2>/dev/null | grep runsc
docker run --rm --runtime=runsc hello-world

The first command should show runsc among Docker's runtimes. The second runs a test container inside gVisor.

That's all the gVisor setup there is. ah checks for it every time it starts. In production, if Docker doesn't list runsc, ah exits with this error:

FATAL: gVisor runtime (runsc) not found in Docker daemon. gVisor is required for container isolation in production.

In dev mode (--dev, for trying it on a laptop) it logs a warning instead, but app containers still won't start, because ah always asks for runsc. There's no setting that turns the sandbox off.

What happens when an agent deploys

From the agent's side, the sandbox doesn't show up at all. The agent registers, gets an API key, and asks for a service:

curl -s -X POST "$AH_URL/v1/services" \
  -H "Authorization: Bearer $AH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "web",
    "image": "your-dockerhub-user/your-app:1.0",
    "port": 3000
  }'

That request takes a name, an image, a port, and optional environment variables. Nothing else. There's no field for the runtime, extra privileges, or more memory, so the agent can't ask for a looser box even if it wanted one.

Behind that call, ah starts the container with these settings. They're hardcoded in internal/docker/client.go, so you can read them yourself.

  • Runtime runsc. The app runs on gVisor.
  • All capabilities dropped (CapDrop: ALL). Linux splits root's powers into pieces called capabilities, like changing file ownership or reconfiguring the network. The app gets none of them.
  • No new privileges (no-new-privileges). A program inside can't gain more power later, for example through sudo or another setuid program.
  • Read-only root filesystem (ReadonlyRootfs). The app can't change its own files. It gets small in-memory scratch folders instead: /tmp (64 MB) and /run, /var/run, and /var/tmp (16 MB each). They're all mounted noexec, so nothing written there can run as a program.
  • 256 processes max (PidsLimit). A program that keeps spawning copies of itself hits a wall.
  • Memory and CPU caps from the agent's quota, 2048 MB and 2 CPUs by default. The memory-plus-swap limit equals the memory limit, so the app gets no swap.
  • A private network. Each agent's apps sit on their own Docker network, marked internal, with app-to-app traffic turned off. Web traffic comes in through Traefik, the proxy that routes requests, over a separate small network for each app. None of these networks has a route to the internet.

ah also sets the label traefik.enable=false on every app, so an image can't use its own labels to grab another app's traffic. The Postgres and Redis containers it creates for agents run under runsc too.

One practical effect: images that write outside those scratch folders, or that expect to start as root and switch users, will crash here. If an agent's app dies with "Read-only file system" in its logs, that's the reason. The agent can read those logs itself at GET /v1/services/{id}/logs and fix the app.

How to check an app is really sandboxed

I don't like trusting a list of settings in a blog post, including mine. Here's how to check on your own server.

First, ask ah. The detailed health endpoint reports whether gVisor is installed and which version:

curl -s -H "Authorization: Bearer $AH_KEY" \
  "$AH_URL/v1/system/health/detailed"
# {
#   "status": "ok",
#   "docker": {"available": true, ...},
#   "gvisor": {"available": true, "version": "..."},
#   ...
# }

Then look at the container. Get the service's container_id from the API and ask Docker how it was started (this uses jq to pull out the field):

CID=$(curl -s -H "Authorization: Bearer $AH_KEY" \
  "$AH_URL/v1/services/$SERVICE_ID" | jq -r .container_id)

docker inspect --format '{{json .HostConfig}}' "$CID" \
  | jq '{Runtime, ReadonlyRootfs, CapDrop, PidsLimit}'
# {
#   "Runtime": "runsc",
#   "ReadonlyRootfs": true,
#   "CapDrop": [
#     "ALL"
#   ],
#   "PidsLimit": 256
# }

On the host, every gVisor sandbox shows up as its own process:

ps -eo pid,args | grep '[r]unsc-sandbox'

You can also look from inside. If the image has dmesg and touch:

docker exec "$CID" dmesg
# [    0.000000] Starting gVisor...

docker exec "$CID" touch /test
# ... Read-only file system

Under gVisor, dmesg prints gVisor's own made-up boot log instead of the host's kernel log. Some of the lines are jokes, like "Preparing for the zombie uprising...". gVisor's docs point out that this output is easy to fake, so use it as a quick sanity check and trust docker inspect for the real answer.

What the sandbox doesn't cover

gVisor protects the server from the apps. It doesn't solve everything.

Builds are a separate step. When an agent deploys from git, Nixpacks (the tool that turns source code into a container image) builds it with Docker's regular builder, not inside gVisor. The app that comes out of the build runs sandboxed. ah limits where the source can come from: HTTPS URLs on github.com, gitlab.com, bitbucket.org, sr.ht, or codeberg.org.

The API key is the other gap. Anyone holding an agent's key can do anything that agent can, including deploying, deleting, and reading its secrets. They can't reach other agents' apps or register new agents. I give each agent its own key with POST /v1/auth/keys and revoke it when I'm done.

And gVisor is one more wall, not a guarantee. Keep the host patched.

Wrapping up

Every app an agent deploys through agentic-hosting runs inside gVisor, with no extra privileges, a read-only filesystem, capped resources, and a network that only lets web traffic in. The agent can't turn any of that off, and you can check the main settings yourself with one docker inspect command.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.