An AI coding agent with shell access can harm the system or my files. Before letting Claude Code run more or less unattended, I wanted a real boundary between it and the rest.

First try: a second OS user

My first version was a dedicated standard user, claude-agent — no admin group, no sudo, with all the code it could touch in one shared folder. Claude ran logged in as that user, and the filesystem kept it out of my home directory.

It works, and the files are protected. But the claude-agent user still has access to system package manager, to apps. It might mess its own tooling and the way to fix it is not the easiest one. Another reason why I started with OS user is that it can re-use tooling with my original user. Good at first look, but if I allow claude-agent update the tooling (and it becomes a requirement fairly fast), my own user’s environment will be polluted. So I started to look for a better solution.

Now: a devcontainer

A devcontainer is the boring, correct answer, and I stopped talking myself out of it. The container is disposable — a bad run is docker rm away.

My .devcontainer/devcontainer.json, trimmed:

{
  "build": { "dockerfile": "Dockerfile", "args": {
    "USER_UID": "${localEnv:UID}", "USER_GID": "${localEnv:GID}"
  }},
  "mounts": [
    "source=/Users/oleg/Repos,target=/Users/oleg/Repos,type=bind",
    "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind"
  ],
  "workspaceFolder": "/Users/oleg/Repos",
  "features": {
    "ghcr.io/devcontainers/features/go:1": {},
    "ghcr.io/devcontainers/features/python:1": {},
    "ghcr.io/devcontainers/features/node:1": {}
  },
  "postCreateCommand": "npm install -g @anthropic-ai/claude-code",
  "remoteUser": "vscode"
}

The Dockerfile just remaps the container vscode user to my host UID/GID so bind-mounted files don’t change owner between host and container. Only two things are mounted in: my ~/Repos tree and ~/.claude (config, hooks, and the shared secret below). Everything else on the Mac — SSH keys, browser profiles, ~/.aws, the rest of $HOME — simply isn’t in the container’s world.

I run it headless, no VS Code:

npm install -g @devcontainers/cli
devcontainer up --workspace-folder ~/Repos
devcontainer exec --workspace-folder ~/Repos zsh   # then: claude

Letting the agent reach back to the host

The catch with a container: the agent loses the small host niceties I’d wired into hooks — say for spoken status, open for a finished preview, a notification banner when it needs me. I didn’t want to punch a hole in the sandbox to get them back, so those live behind a narrow bridge: .claude/scripts/server.

It’s a ~200-line stdlib HTTP server that runs on the host and exposes a fixed set of named actions:

action does
say speak text through the Mac’s say
notify a macOS notification banner
open open a URL, or a path under $HOME / /tmp
focus report the frontmost app + idle time (is my terminal even visible?)

What keeps it safe:

  • Named actions only. The client sends {"action": "say", "text": "..."}. The server maps that to a hard-coded argv and runs it with shell=False. There’s no field for a binary, a flag, or a shell string — the container can’t ask the host to run anything that isn’t already on the list.
  • Shared secret. Every request carries a token from ~/.claude/scripts/server/secret (auto-generated, 0600, git-ignored). The container reads the same file through the ~/.claude mount.
  • Localhost only. The host binds 127.0.0.1:8787; the container reaches it at host.docker.internal:8787. Nothing is exposed to the network.
  • Inputs are validated — lengths capped, control characters rejected, open paths resolved and confined to an allow-list of roots.

On the Mac it installs as a launchd agent

python3 .claude/scripts/server/server.py

and a hook just calls:

python3 .claude/scripts/server/client.py say "tests are green"
python3 .claude/scripts/server/client.py open http://localhost:4000

The client is fail-soft: if the bridge is unreachable it warns and returns nothing, so a missing host never blocks Claude.