Adventures in Declarative Agent Security, Part 1
YAML shapes everything else; why not agent security?
When you inspect an application running in Kubernetes, you typically start by looking at its YAML manifests. Continue tracing the deployment backwards and you’ll likely find the GitHub or GitLab CI pipeline that ran it, itself defined in YAML. The pipeline might have even wrapped it up in a YAML Helm chart, which itself may have been wrapped in a Helmfile YAML configuration. If you make it all the way down the Russian nesting doll scheme into the application, you’ll likely find it has still more YAML files configuring dependencies. Not to mention it likely communicates over HTTP using JSON: a subset of YAML!
All of this is to say that YAML has become a common way to express complex systems as flexible, declarative configuration. Agent security, specifically the management of permissions for filesystem and network access, presents a similar problem. My ideal solution would:
- Prevent nightmare scenarios:
- An agent running amok on my local filesystem and making unwanted changes.
- An agent making network requests that leak sensitive information, download malware, or attack third-party systems ( OpenAI incident report , Anthropic incident report ).
- Preserve a productive developer experience by letting me define simple, fine-grained YAML policies for filesystem and network access.
Initially I looked for an out-of-the-box solution to this problem. The only one I found that satisfied my other criteria (maintained by a reputable org, works across Linux and macOS (Apple Silicon), quick to set up and get running) was NVIDIA OpenShell. However, I was disappointed to discover that OpenShell’s official Linux installer requires root privileges, which was a dealbreaker for me.
Unable to find an existing solution, I decided to roll my own! The simplest solution I came up with was to use Docker Compose. The agent would run in one container, while all of its outbound network traffic would pass through a separate proxy container that controls which requests are allowed.
The first step was setting up a minimal Dockerfile. I started with the latest Ubuntu LTS and tacked on Vim so I could easily poke around inside the container.
FROM ubuntu:26.04
RUN apt-get update && apt-get install -y vim
CMD ["bash"]The next step was adding a minimal compose.yaml file:
services:
agent:
build: .
stdin_open: true
tty: trueWith that in place:
Build and start the container:
docker compose up --detach --buildOpen a shell inside it:
docker compose exec agent bashVerify Vim is installed:
vim --version | head -n 1This should output something like
VIM - Vi IMproved 9.x.Confirm DNS resolution works before we disable networking:
getent hosts google.comThis should return one or more addresses.
Run
exitto leave the container, then shut it down:docker compose down
Next, I added network_mode: none to the agent service in compose.yaml to disable networking:
services:
agent:
build: .
stdin_open: true
tty: true
network_mode: noneI spun the container back up, reran getent hosts google.com, and verified that it returned no results!
Then add codex installation to Dockerfile:
RUN curl -fsSL https://chatgpt.com/codex/install.sh \
| env \
CODEX_INSTALL_DIR=/usr/local/bin \
CODEX_HOME=/opt/codex \
CODEX_NON_INTERACTIVE=1 \
shNext, I set up an internal network that the agent would eventually use to reach the proxy. I replaced network_mode: none in the agent service with:
networks:
- agent-networkThen I added the network to the top-level networks section in compose.yaml:
networks:
agent-network:
internal: trueSetting internal: true allows containers on agent-network to communicate with each other but prevents them from reaching the internet. I spun the agent container back up and verified that it still couldn’t access the internet.
Next, I added a Squid proxy to the top-level services section. The proxy connects to both the internal agent network and an internet-facing network:
services:
proxy:
image: ubuntu/squid:7.2-26.04_edge
networks:
- agent-network
- internetThen I added the internet-facing network to the top-level networks section:
networks:
internet:At this point, the proxy could reach the internet, but the agent still couldn’t because it was connected only to agent-network. I spun the containers back up and verified that the agent still couldn’t access the internet.
Before routing the agent’s traffic through the proxy, I created a squid.conf file that denied every request by default:
http_port 3128
http_access deny allThen I mounted the configuration read-only into the proxy service:
services:
proxy:
volumes:
- ./squid.conf:/etc/squid/squid.conf:roThis gave the proxy a deny-all policy before the agent began using it.
Next, I configured the agent service to send HTTP and HTTPS requests through the proxy:
services:
agent:
environment:
HTTP_PROXY: http://proxy:3128
HTTPS_PROXY: http://proxy:3128I recreated the containers and verified that an internet request from the agent was rejected.
Next, I updated squid.conf to allow HTTPS connections only to OpenAI and ChatGPT domains:
http_port 3128
acl CONNECT method CONNECT
acl codex_domains dstdomain auth.openai.com .chatgpt.com
http_access allow CONNECT codex_domains
http_access deny allThen I restarted the proxy and verified that Google was blocked while ChatGPT was reachable:
docker compose restart proxy
docker compose exec agent curl -I https://google.com
docker compose exec agent curl -I https://chatgpt.com | head -n 1The second command should return 403 Forbidden from Squid, while the final command should output HTTP/1.1 200 Connection established.
Finally, with device code login enabled in my ChatGPT security settings, I logged in to Codex:
docker compose exec agent codex login --device-authAfter following the terminal instructions, I saw Successfully logged in. The login persisted for the life of the container.
To avoid logging in every time I recreated the agent container, I added a named volume to compose.yaml:
services:
agent:
volumes:
- codex-data:/root/.codex
volumes:
codex-data:After recreating the containers, I logged in again using the same command as before. This time, the login persisted after bringing the containers down and back up. I verified it with:
docker compose exec agent codex login statusFinally, I tested Codex inside the container:
docker compose exec agent \
codex exec --skip-git-repo-check \
"Reply with exactly: Codex network works"The final response should be Codex network works.
I’ve now mostly accomplished my initial goal: I have an agent running in a sandbox, but its security is not governed entirely by YAML because the network policy still lives in squid.conf. Eventually, I’ll add a conversion layer that translates simpler YAML policies into the resulting proxy configuration.
Security caveat
I am not a cybersecurity expert, and this project should not be treated as a hardened security boundary.
Its primary goal is to reduce the risk of accidental data exposure or unintended agent behavior. For example, it can prevent an agent working on a private project from searching the public internet with sensitive project details. It is not designed to contain a malicious agent or defend against sophisticated attacks.
The final setup is available on GitHub
. The version used in this tutorial is tagged 1.0.0
.
In Part 2, coming soon, I’ll add Claude Code with models from Amazon Bedrock to this setup!
