Claude Code: Dev Containers and How-to Work Securely with AI Agents

Claude Code: Dev Containers and How-to Work Securely with AI Agents

Updated
This post is from the "Claude Code Setup" series
A collection of articles focused on setting up dev environment, configuring commands and CLAUDE.md for a spec-driven development workflow.
  1. Dev Containers and How-to Work Securely with AI Agents
  2. System prompting and commands
  3. Spec-Driven Development (SDD)

Intro

I’ve been using AI for 3 years now, but most recently I’ve been coding almost exclusively with Claude Code. This blog is one of the first projects that I completed using it.

I built my blog in a day using a very raw approach to software development. Basically, I had a conversation with the bot and asked him to change various things as I progressed.

That’s good and all for experiments and prototyping, but as your projects grow bigger, certain patterns begin to emerge and it’s hard to describe everything that one has to do in order to have a steady, viable development flow with AI agents.

This article is about setting up devcontainers in order to have a secure dev environment while working with AI agents like Claude Code.

Basic environment setup for AI assisted development

Here’re several things that I do nowadays (about a month after I’ve created the blog and I’m more than actively using Claude Code to produce production ready applications. At the time of writing this, the blog itself has a rather “outdated” codebase, in terms of what one can refer to as “AI derrived technical debt”.),

When setting up a new project now, I’d go through the following checklist:

  1. I create a general .devcontainer for my application. Since I’m building from scratch, I begin with monolithic approach and I split out code when absolutely necessary for scalability. So my .devcontainer has the capacity to run all the applications I’m working with, plus whatever is required for Claude Code to run steadily.

    I do this to isolate my PC from Claude to achieve security and data integrity. I can restore missing code with version control and I can live with 1 commit being deleted accidentally, but the rest of my system is exposed, since Claude is running with the same privileges, as my local user is and it runs shell commands. This is not just something to be careful about, it’s an active vector for malicious attacks and command injection.

  2. After, my environment is secure and capable of running all my apps in dev mode, I would try to setup my primary agent entry - CLAUDE.md (AGENTS.md is a more general entry that is also viable option, but in since I’m using Claude code, I’m sticking to the named file format).

    For a new project, I’d reuse an existing CLAUDE.md from a recent similar project. If I have nothing good to reuse, I’ll continue to the next step and come back to CLAUDE.md a bit later, when I have some context for the app.

  3. I will add common Claude commands. These would include commands for the most common actions that I take, when trying to write better and more consistent code:

    • spec commands - used to manage spec files: create, update, verify, apply, refactor with. The idea is to able to persist reusable context long-term. That’s why specs are keept it in the repo, to be close to the code and to be able to do ad-hoc changes on the fly with predefined prompts. Specs are usually kept in the repo as specs/**/*.md.

    • test commands - working with multiple apps, requires different type of tests and approaches to testing. One can verify that web frontend and a service written in Elixir would have different testing concepts. Test commands are context specific and require a specific instruction set to be considered, when generating code for coverage

    • git assist - I don’t ask my agent to push code for me, but I do ask it to commit staged code for me. It would generate a smart message and run the husky pre-commit hook and fix issues, if any.

    One might need to grant their agents read/write permissions when running a multi-agent pipeline of sorts, which goes and produces some kind of code end-to-end, where pushing changes to the commit tree trigger actions for other other agents. Still, if you work with an AI agent on a machine that uses your personal git access tokens, in my humble opinion, push and pull should be reserved only to the host machine.

    Given unrestricted access to your repository, the agent could modify your commit tree and force push changes, potentially erasing commit history. In addition, in the case of GitHub for example, if a token by any chance provides further credentials, it could potentially modify the repository settings, to grant itself capabilities previously unavailable (like disabling branch restrictions on main to allow --force push).

Secure-first approach to working with AI agents

The benefits of using devcontainers vary from:

  • improving onboarding of new team members
  • setting up CI pipelines
  • eliminating “runs on my machine”
  • etc.

Nowadays there’s an open-source devcontainer toolchain that integrates almost natively with VS Code.

My personal reason to quickly integrate devcontainers for my AI assited dev environment is quite more simplistic, yet serious.

Reddit Post

View on Reddit

NOTE: If the above Reddit link goes missing - it is a post on Reddit, where a dude got his entire home directory deleted by Claude Code (the bot ran rm -rf tests/ patches/ plan/ ~/, when it was doing a supposedly controlled code cleanup. Note the last ~/, that wiped the contents of the user’s home directory, to which… I don’t know about you, but for me personally, it would be beyond tragic).

Setting up a devcontainer

For a node app, such as this blog, a basic setup go something like that…

Dockerfile

This is a cleaned-up version of the devcontainer Dockerfile that I use for the blog.

NOTE: For my devcontainers I also install zsh with oh-my-zsh for personal shell pleasure. I also set up pnpm and grant sudo privileges to the vscode user. I do this for debugging only, you shouldn’t do this for prod containers!

.devcontainer/Dockerfile

# Development container for Astro.js blog
FROM node:22-bookworm

# Avoid prompts from apt
ENV DEBIAN_FRONTEND=noninteractive

# Install system dependencies
RUN apt-get update && apt-get install -y \
    curl \
    git \
    netcat-openbsd \
    sudo \
    vim \
    wget \
    && rm -rf /var/lib/apt/lists/*

# Install Claude CLI
RUN npm install -g @anthropic-ai/claude-code

# Set working directory
WORKDIR /workspace

# Default command
CMD ["sleep", "infinity"]

docker-compose

I initially started using the devcontainer CLI with a devcontainer.json configuration, however, eventually I had more issues with it and just migrated to using plain docker-compose.yml running my Dockerfile for my apps.

It made my life easier when it came to forwarding ports, using resources within my docker network, etc. In addition, I run my dev container from the terminal and not my text editor. You still have the choice to have a full integration with it (or JetBrains, but outside of that, I’m not sure what’s the support and plugin marketplace for other text editors and IDEs).

.devcontainer/docker-compose.yml

services:
  devcontainer:
    build:
      context: .
      dockerfile: Dockerfile
    image: blog-dev-container:latest
    container_name: blog-dev-container
    ports:
      - 4321:4321
      - 6006:6006
    volumes:
      - ./.home:/home/vscode/

NOTE: the volumes links .devcontainer/.home/ to the current user’s home directory, allowing me to rebuild the image and keep my auth sessions, as well as to keep in the repository configuration and dotfiles such as .claude/settings.json with Claude global hooks, my own .zshrc with custom aliases, oh-my-zsh configuration, etc.

This requires me to add some ugliness to .gitignore, but it’s manageable

# Devcontainer
.devcontainer/.home/*
!.devcontainer/.home
!.devcontainer/.home/.zshrc
!.devcontainer/.home/.claude
.devcontainer/.home/.claude/*
!.devcontainer/.home/.claude/settings.json

Setting up agent notifications

Working with an agent would require your attention once in a while to know when a job has completed or a question by the AI is pending an answer.

Since the docker environment doesn’t have a native way to propagate notifications downstream to the host machine (or at least to my knowledge), I use a bit more complex setup, which relies on:

  • Having a shell script that accepts messages via sockets and runs a command to show notifications on my docker host machine.
  • Exposing the script using socat on port 4545
  • Claude hooks to emit a message to host.docker.internal:4545 using netcat.

Propagating notifications

To propagate messages from the devcontainer downstream to my machine, I pipe messages to the notifier.sh script with SOcket CAT on port 4545.

Start the listner

pgrep -f 'socat TCP-LISTEN:4545' >/dev/null || \
    ( \
        nohup socat TCP-LISTEN:4545,reuseaddr,fork \
        EXEC:.devcontainer/notifier.sh >/tmp/hostcmd.log 2>&1 & \
        echo '🔔 Notifier started.'
    )

Stop the listener

pkill -f 'socat TCP-LISTEN:4545' && \
    echo '🔕 Notifier stopped.'

WARNING: Using socat directly with a shell (e.g. EXEC:/bin/bash) entirely beats the purpose of trying to secure your local environment from arbitrary command injection, you’d effectively give the bot the opportunity to run any commands on your machine with nc messages to socat.

notifier.sh

I use a script wrapping agent-to-host shell functionality as predefeind “commands”, this way I effectively prevent the AI agent from executing dangerious commands on my machine, such as printf 'rm -rf ~' | nc host.docker.internal 4545.

.devcontainer/notifier.sh

#!/bin/bash
read -r CMD ARG

case "$CMD" in
  notify)
    terminal-notifier -title "Claude" -message "$ARG" -open "iterm2://session"
    ;;
  *)
    echo "DENIED"
    ;;
esac

I use terminal-notifier to send clickable notificatinos. In this case they have a title “Claude” and clicking on them focuses my iTerm. This can be further improved to focus a specific iTerm tab, however I cannot achieve it, since I use tmux and it’s terminal session IDs can belong to tabs that have been long gone, because of the way tmux session service works (e.g. they persist through tab closure).

Connecting Claude hooks

Notifications are sent using 2 Claude hooks:

  • Notification/permission_prompt - triggered when the agent is waiting for my response
  • Stop/* - triggered when a task is completed

To send notifications, we use netcat to send a message to the notifier.sh script on port 4545.

.devcontainer/.home/.claude/settings.json

{
  "hooks": {
    "Notification": [
      {
        "matcher": "permission_prompt",
        "hooks": [
          {
            "type": "command",
            "command": "printf 'notify blog (waiting)\n' | nc host.docker.internal 4545"
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "printf 'notify blog (done)\n' | nc host.docker.internal 4545"
          }
        ]
      }
    ]
  }
}

Running devcontainers locally

Basic docker commands

You would start your devcontainer like a standard docker container using docker compose. Here’s an excerpt from this blog’s package.json and the pnpm commands that I’m using at the time of writing this article.

{
  "devcontainer": "docker compose -f .devcontainer/docker-compose.yml",
  "devcontainer:build": "pnpm devcontainer build",
  "devcontainer:rebuild": "pnpm devcontainer build --no-cache",
  "devcontainer:exec": "pnpm devcontainer:up && pnpm devcontainer exec devcontainer",
  "devcontainer:up": "pnpm devcontainer up -d && pnpm devcontainer:notifier:up",
  "devcontainer:notifier:up": "pgrep -f 'socat TCP-LISTEN:4545' >/dev/null || (nohup socat TCP-LISTEN:4545,reuseaddr,fork EXEC:.devcontainer/notifier.sh >/tmp/hostcmd.log 2>&1 & echo '🔔 Notifier started.')",
  "devcontainer:notifier:stop": "pkill -f 'socat TCP-LISTEN:4545' && echo '🔕 Notifier stopped.'",
  "devcontainer:shell": "pnpm devcontainer:exec zsh",
  "devcontainer:stop": "pnpm devcontainer:notifier:stop; pnpm devcontainer down",
  "devcontainer:claude": "pnpm devcontainer:exec sh -lc 'claude -c || claude'"
}

Worth noting

Maybe you’d like this working differently, however I found the following mods quite useful:

  • devcontainer:exec also lifts the devcontainer for ease of use.
  • devcontainer:shell command starts zsh. From here I run tests/builds/dev or directly start claude, if I want to.
  • devcontainer:claude continues the current Claude session or starts a fresh claude, if no previous conversation is found, as the agent exits, if there’s no previous conversation.
  • devcontainer:notifier:* commands are super lengthy and an excellent script candidate, I keep them like that only in order to copy/paste commands across projects.

Rule of thumb

It’s worth mentioning, that my containers have different setup from my local Mac environment. This causes pnpm install ran through the container and locally to behave differently.

In an ideal world, you’d run install and build commands only inside the devcontainer.

This is where Claude would be running builds and tests, so don’t want to rebuild dependencies when you run tests on your local machine, simply run them inside the devcontainer, same goes for starting applications for local dev and possibly CI.

  • Run Claude Code from the devcontainer

  • Run your applications from the devcontainer when possible. Forward relevant application ports and serve from 0.0.0.0 inside the container, to access it on localhost from the docker host machine.

    NOTE: This is required, especially for people coding on ARM CPU with containers running x86. At the same time, by simply using different library versions on host and container (e.g. pnpm, mix, etc.). This way both you and the agent can run/build/test the same code.

  • Commit code from the host, do not expose Claude to your repository. You’ll be effectively sharing the ssh key that you’re using for git with the agent.

Setting up devcontainers is easy and almost effortless. It will help you by creating consistency when running and testing applications with both the agent, CI and other users on your team, and will add an extra layer of security for your environment when working with AI agents by protecting you from arbitrary command injection and sporadic malicious agent behaviour.

Related Posts

Comments