Docker-backed development sessions with proxy-mediated network policy — driven from a terminal UI.
Harness Hat (hh) is a session manager for running coding agents and dev workflows inside disposable, network-filtered Docker containers. You register a workspace, pick a language template, and get an interactive shell in a sandbox whose every outbound TCP packet is steered through a MITM HTTP/HTTPS proxy governed by a per-workspace allow/deny list.
It's the harness; your container is the hat.
Modern coding agents (claude, codex, antigravity, pi, …) want to read your home directory, install random packages, hit unknown endpoints, and execute arbitrary shell. Giving them an unrestricted shell on your laptop is a bad time.
Harness Hat boxes each session in a container with:
- A real shell, real toolchains, your repo bind-mounted at
/workspace. - A scoped HTTPS proxy that prompts before allowing unknown hosts, persists your decisions to a committed
harness-rules.toml, and refuses anything denied. - Strict-network mode:
tun2proxy+iptablescapture all outbound TCP, so agents can't bypass the proxy by ignoringHTTPS_PROXY. - Per-session seeded mounts for
~/.claude.json-style files that agents rewrite in place, so two concurrent sessions can't corrupt each other. - A bundled MITM CA wired into Node, Python, Go, Ruby, curl, OpenSSL, gRPC, Java (via init keytool), and the system trust store.
If an agent wants to curl evil.example.com/install.sh, it asks first. You see the request. You decide.
cargo install harness-hat # binary is `hh`
hh # will prompt you to create config, or launch the manager if config existsInside the TUI: pick a workspace, pick a template, get a shell. From inside the container, run killme to ask Harness Hat to stop the session.
To attach to an already-running session from a separate terminal:
hh shell # lists running sessions
hh shell <ID> # attaches via `docker exec -it` workspace ─┐
├── session ──> one running container
template ─┘
- Workspace — a fixed host directory (your repo), mounted into the container at
/workspace. - Template — a
[container_profiles.<name>]block referencing a Dockerfile stem underdocker_dir. Sets memory, CPU, mounts, env, starter network allowlist. - Session — one container, one shell, one network policy. Stop it from the TUI or by running
killmeinside it.
The base image is Ubuntu 24.04 with Node 22, bundled agent CLIs (claude, codex, agy, pi), and the shared proxy/CA plumbing. Stacked on top:
| Stem | Toolchain |
|---|---|
default |
Node, pnpm, TypeScript, tsx, Bun |
typescript |
TypeScript, Bun, npm, Node, pnpm, Vite, ESLint, Prettier |
go |
Go, gopls, Delve, staticcheck, golangci-lint |
rust |
Rust stable + rustfmt, clippy, rust-analyzer, nextest, audit, deny |
php |
PHP CLI/dev, Composer, PHPUnit, PHP-CS-Fixer, PHPStan, Pint, Xdebug, PCOV |
Drop your own something.dockerfile under docker_dir and reference it as image = "something".
Each workspace can commit a harness-rules.toml next to its source — Harness Hat reads it on session start and persists any "Allow forever" / "Deny forever" approvals from the TUI back into it.
version = 1
[network]
allowlist = [
"method=GET,POST domain=api.github.com path=/repos/* port=443",
"domain=registry.npmjs.org",
"domain=*.crates.io",
]
denylist = [
"domain=*.evil.example",
]- Deny wins over allow.
- Unknown requests prompt in the TUI (Allow once / Deny once / Allow forever / Deny forever).
- Domain rules support exact (
example.com) and subdomain-only wildcards (*.example.com). - Path rules support exact and
*wildcards; query strings are stripped, percent-decoded,..///collapsed before match. - Hostnames are canonicalized (lowercase, trailing-dot strip, IDNA) before rule matching — case, trailing dots, and punycode can't bypass denies.
Bypass the MITM entirely for specific hosts (e.g., agent API endpoints with strict cert pinning) via [defaults.containers].bypass_proxy or per-template bypass_proxy.
Enabled by default in the example config. When on:
tun2proxyruns inside the container and captures all TCP via a TUN device.iptablesrejects every outbound packet that isn't loopback, Docker DNS, the scoped proxy, the control server, or an explicitlocalhost_forwardstarget.- UDP/QUIC are blocked (except DNS to Docker's embedded resolver).
- IPv6 is rejected wholesale to prevent AAAA/QUIC hangs.
The result: an application that "doesn't honor HTTPS_PROXY" still gets its packets steered through the proxy or dropped. There is no escape hatch.
Requires /dev/net/tun — Docker Desktop usually needs the container started privileged. Harness Hat handles that.
- Bearer-token and proxy-auth comparisons use constant-time equality.
- HTTP parsing rejects
Content-Length+Transfer-Encodingconflicts, duplicateContent-Length, bare-LF terminators, obs-fold continuations, and trailing body bytes. - TLS MITM leaf-cert cache is bounded (LRU 1024) with key generation off the async runtime; concurrent first-misses are de-duplicated.
- CONNECT MITM replays pipelined bytes into the TLS acceptor.
- SNI/CONNECT hosts are validated before becoming cache keys or cert subjects.
- IPv6 SSRF predicate covers NAT64, 6to4, IPv4-translated, and discard-only prefixes.
- Audit log and
log_dirare created0o600/0o700atomically and refuse to follow symlinks. - Control server and proxy default to loopback-only; non-loopback binds require explicit
allow_remote_control = true. - Workspace paths under
~/.ssh,~/.gnupg,/etcare refused. - Mount/container paths reject
:and,to prevent-vargument injection.
version = 1
docker_dir = "/Users/you/.config/harness-hat/docker"
[manager]
global_rules_file = "~/.config/harness-hat/harness-rules.toml"
[defaults.control] # killme + session identity
server_port = 7878
server_host = "127.0.0.1"
token_env_var = "HARNESS_HAT_TOKEN"
[defaults.proxy]
proxy_port = 28781
proxy_host = "127.0.0.1"
strict_network = true
[defaults.containers]
env_passthrough = ["TERM", "COLORTERM", "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]
bypass_proxy = ["api.anthropic.com", "claude.ai", "..."]
[[defaults.containers.mounts]] # shared across all templates
host = "~/.claude.json"
container = "/home/coder/.claude.json"
mode = "rw"
seed = true # per-session copy, not a live bind
[[defaults.containers.mounts]]
host = "~/.claude/.claude.json"
container = "/home/coder/.claude/.claude.json"
mode = "rw"
seed = true
[container_profiles.rust]
image = "rust"
memory = "6g"
cpus = "3"
starter_network_allowlist = [
"domain=crates.io",
"domain=*.crates.io",
"domain=github.com",
]
[[workspaces]]
name = "my-project"
canonical_path = "~/src/my-project"Full example: harness-hat.example.toml.
Each container session runs Claude Code in a fresh environment. Two env vars control how it authenticates — set whichever one you use on the host and they pass through automatically via env_passthrough in the default config above.
API key (recommended for most setups):
- Generate a key at console.anthropic.com → API Keys.
- Export it in your shell profile:
export ANTHROPIC_API_KEY="sk-ant-api03-..."
OAuth token (alternative — stays tied to your Claude account):
- Run once on the host to generate a long-lived token:
claude setup-token
- Export the printed value in your shell profile:
export CLAUDE_CODE_OAUTH_TOKEN="<token>"
Either var bypasses the interactive browser login flow, so new sessions start authenticated immediately. Run /status inside a session to confirm which method is active.
Antigravity CLI (agy) stores settings and history under ~/.gemini/antigravity-cli, but its login tokens live in the OS secure keyring. Harness Hat mounts .gemini for settings and starts a headless Linux Secret Service in each session, backed by ~/.local/share/harness-hat/container-keyrings on the host.
The first agy login should be done inside a Harness Hat session. After that, new sessions reuse the persisted container keyring. A host desktop login is not copied by the .gemini mount alone.
hh # launch interactive workspace manager (default)
hh --config PATH # use a specific config
hh init [PATH] # write a starter config (default: ./harness-hat.toml)
hh shell # list running sessions
hh shell <ID> # docker exec into a running session
MIT — see LICENSE.