Skip to content

About

Run Claude in a container

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

119 Commits

Folders and files

Repository files navigation

hack-in-a-box

One command starts an unrestricted Claude Code session inside a container:

hackinabox <box> [claude-args...]

Claude Code on the host asks permission constantly, and every prompt breaks flow mid-thought. This project runs a session with permission checks disabled entirely — --dangerously-skip-permissions — on the premise that the container, not a permission prompt, is what makes that safe. Read the section below before you rely on that.

What this protects, and what it does not

Protects: the host, and every other project on it. Exactly one host directory is writable per box — the project it was started for — plus whatever read-only mounts and host MCP servers its shape declares. A session cannot write to any host path outside that one directory, with one named exception: a box declaring ssh_agent=yes mounts the forwarded SSH agent socket read-write, because connecting to the agent protocol itself needs write access — that is the mechanism, not a leak of host files, and it is covered fully under the ssh_agent=yes bullet below. A session cannot see another box's files, either: each box's $HOME is its own private named volume, shared with no other box. The launcher always passes --strict-mcp-config; a .mcp.json planted inside the project cannot add a server the shape didn't declare — measured directly. --strict-mcp-config is designed to exclude the authenticated account's own claude.ai connectors the same way; that half is a design intent, not yet an observed outcome — it needs an interactive session and has not been checked.

What a box receives is wider than the shape file, and hackinabox shape is honest about that in its own printed output. A shape declares mounts, host MCP servers, and ssh_agent, but three things reach every box by a route the shape never declares: the generated config directory (settings.json and mcp.json, mounted read-only) is handed to every box regardless of what it declares; when the host has a ~/.gitconfig, a wholesale copy of it is handed to every such box the same way — see "A box's git identity" below for what that copy is and is not, and for what a box gets instead when the host has no ~/.gitconfig; and a box with ssh_agent=yes also receives a copy of the host's entire ~/.ssh/known_hosts — see the "does not protect" bullet below for what that copy contains. Run hackinabox shape <box> to see the box's full, resolved set of openings, undeclared ones included, rather than treating the shape file itself as the complete list. shape labels a third line "not declared" too — the $HOME volume — but that one carries nothing in from the host: it's a Docker named volume holding the box's own state, not host content reaching the box, and it's already covered under "A box's $HOME volume holds its Claude Code credentials" below.

Does not protect: anything handed into the box. The boundary is the container wall, not a sandbox inside it, and inside that wall a box's own $HOME is freely writable — that's what lets a session install a tool in one run and have it resolve in the next. Concretely:

  • A box's $HOME volume holds its Claude Code credentials — auth tokens and conversation history — and anything running inside that box can read them. Removing that state is a deliberate, separately-named action (hackinabox reset), never a side effect of anything else, precisely because it's the one thing in this project capable of destroying it.
  • A box runs with passwordless sudo. Anything running inside it is effectively root inside that box, not merely whatever privileges the invoking user had — a further amplifier of "anything handed into the box."
  • A box that declares ssh_agent=yes gets use of every key the host's SSH agent holds, for as long as the box runs — not a key scoped to the one project it was started for. Only the agent's socket is mounted, never ~/.ssh itself, so as far as the mount list goes no private key material is copied into the box; that has not yet been swept with a key actually loaded in the host agent, so treat it as well-founded rather than measured. Regardless, anything able to run a command in the box can ask that agent to sign with any identity it holds.
  • A box declaring ssh_agent=yes also receives a copy of the host's entire ~/.ssh/known_hosts (see "Host prerequisites" below for the mechanism and why it exists) — measured at 9 entries, 5 distinct hosts, unhashed: plaintext hostnames of machines this Mac has connected to, readable by anything running in any ssh_agent=yes box, including one started for an unrelated project. These are public host keys, not private key material, so the point above still holds — but they are an opening the shape file never declares, which is exactly what this section exists to surface.
  • A declared host MCP server bounds what Claude Code loads, not what the box can reach over the network. --strict-mcp-config stops an undeclared server from being added to the session's tool list. It does not stop the box reaching other host ports: host.docker.internal resolves to the host for any process inside the box, declared or not, and a service bound to 127.0.0.1 on the host answers a box exactly the same as it answers anything else on the host — loopback binding is not a boundary here.
  • Concretely: an unauthenticated host MCP gateway is available to every box, not just the one that declared it. Wrapping a stdio server for a box — say chrome-devtools-mcp behind supergateway on 127.0.0.1:8931 — publishes that capability to all of them. The undeclared box doesn't have the tools in its list, and doesn't need them: it has a shell, curl, and passwordless root. What sits behind that particular gateway is wider than anything a shape declares — evaluate_script runs arbitrary JavaScript in the host's real, logged-in Chrome, upload_file reads host paths, and navigate_page reaches anything on host loopback or the LAN from the host's network position. Running one gateway per box does not fix this: it stops boxes sharing a browser, but every gateway stays reachable from every box, because none of them authenticates and (see above) host_mcp could not declare a token if they did. This is a host-side thing to manage — hackinabox has no position from which to constrain what a process inside a container connects to.

Nothing here defends against a deliberate container escape, filters egress, or scopes credentials per-project. Those are accepted trade-offs, not oversights — see openspec/changes/ for the reasoning, and docs/adrs/ for the decisions this project treats as binding.

Day to day

A box is a long-lived container tied to one project. First use creates it; every later use attaches to the already-running container in seconds.

A newly created box always interrupts its first launch for an interactive login and the onboarding prompts — see "Budget for a first-run login and onboarding pass" below for what that involves and why it can't be pre-seeded. Separately, first use of any box — new or already authenticated — answers a workspace-trust dialog, once per directory. Because the launcher always opens a session in the box's project directory, a box that has already accepted trust for that directory does not see that dialog again.

hackinabox <box> [claude-args...]   start (or attach to) the box and launch Claude Code in it
hackinabox shell <box>              open a plain interactive shell in the box, alongside a session
hackinabox shape <box>              print the box's resolved shape: declared mounts, host MCP
                                     servers and SSH agent forwarding, anything declared but missing
                                     on the host, and the undeclared openings every box (or every
                                     ssh_agent=yes box) gets regardless of what it declares
hackinabox stop <box>               stop the box's container (state is kept)
hackinabox rebuild <box>            reset the container from its image, keeping the $HOME volume
hackinabox reset <box>              destroy the box's $HOME volume — credentials and history with it
hackinabox list                     list every known box and its container/image state

A box's shape — which directory it can write to, which other paths it can read, which host MCP servers it can reach, and whether the host's SSH agent is forwarded to it — is declared outside the project, in a plain Bash fragment at ~/.hackinabox/boxes/<box>.sh:

project="/path/to/the/project"
readonly_mounts=("/path/to/some/reference/dir")
host_mcp=("xcode=http://host.docker.internal:8765/mcp")
ssh_agent="yes"

That file is never mounted into the box and is not writable from inside one, so a session cannot widen its own shape.

A host_mcp entry must be a Streamable HTTP MCP server, and it cannot carry an Authorization header. The launcher generates exactly {"type": "http", "url": "<url>"} per entry, so a host server that speaks the older SSE transport cannot be declared, and neither can one that authenticates. Two consequences worth knowing before you go looking for the bug elsewhere:

  • The failure mode for a transport mismatch is quiet — the server loads and then contributes no tools, rather than erroring. If a declared server's tools don't appear, check the transport first.
  • Many useful MCP servers are stdio-only and need a host-side HTTP wrapper (supergateway and friends). That works, but read docs/stdio-mcp-servers-over-http.md before doing it: the wrapper is unauthenticated, and the bullet on host MCP servers under "Does not protect" applies to it in full.

The entry is split on its first =, so a URL may contain further ones — a server that accepts a query-parameter token (name=http://host…/mcp?token=…) can therefore be declared today, and hackinabox shape will print that URL verbatim.

A box's contents — the toolchains available inside it — come from a Dockerfile in the project itself, built FROM hackinabox-base; a session can edit that file freely, but only a human rebuild (hackinabox rebuild) applies a change to it.

hackinabox rebuild resets / to the image and keeps the $HOME volume, so a session's own installed tools and shell customisations under $HOME survive; hackinabox reset is the only command that removes that volume, and asks for an explicit yes before it does. This is about what a session installs into $HOME at runtime, ad hoc — it is not licence to install a project's toolchains there in the Dockerfile; see "Adding project toolchains" below for why those two are different and why the rule for the latter is the opposite.

A box also prunes superseded Claude Code installs at start, keeping the install currently in use together with the two highest-versioned, so a $HOME volume that has been self-updating for weeks doesn't quietly accumulate hundreds of megabytes of old versions. Nothing else in $HOME is touched — this is not a general cleanup, only the removal of superseded copies of that one tool.

A box's git identity

When the host has a ~/.gitconfig, every box receives a wholesale copy of it in the generated config directory, and the bootstrap delivers it into $HOME/.gitconfig as an [include], above a marker. "Wholesale" is a standing commitment, not a weakening of the boundary around the host: it copies content into a box — whatever is in ~/.gitconfig today, or added tomorrow (another [include], a credential helper, an excludesfile) — it never reads back out of one.

The box's own $HOME/.gitconfig is a real, writable file. git config --global inside a box works and lands below the marker, so a session's own settings sit alongside the host's without editing it. Host edits reach a box at the next hackinabox <box> invocation — not before, and not continuously: nothing inside a box re-reads the host's file between invocations, only the launcher's own regenerated copy. Because hib_box_ensure_running regenerates the config directory before the create/start branch, a second hackinabox <box> against an already-running container refreshes that copy with no restart — a running session's next git call sees the new values — but this is not "live" and not "read from the host at every git invocation"; both of those were considered and are wrong. See docs/architecture-notes.md, "A single-FILE bind mount does not survive mktemp+mv. Mount the directory", for the measurement behind mounting the config as a directory rather than as individual files (0/8 failures over eight trials for the directory mount, versus 4/8 for single-file binds) — that is what makes the propagation property above safe to rely on.

The single most likely thing to be mistaken for a bug: git config --global --list and git config --global <key> do not follow includes, so they will not show the host's values. Plain git config --list and git config <key> do — that is what git itself uses when it resolves an identity, and what a commit actually sees. If a box's git settings look empty, check with the non---global form before assuming the copy failed.

git config --global --unset of a value the host set fails, because that value lives in the read-only included copy, not in the box's own file. Setting it to something else works fine — the override lands below the marker and wins by git's normal last-value precedence — it is only removal outright that the include blocks.

An LFS repo needs git-lfs from the project's own Dockerfile; see "Adding project toolchains" above for the rule this follows. A host config referencing host-only tooling — a macOS credential helper, a signing key — will not work inside a box; that part of the host's config simply won't function there, wholesale copy or not.

A known limit, by design, not a bug: the copy reads ~/.gitconfig only. A host that keeps its git configuration at $XDG_CONFIG_HOME/git/config instead is reported "absent on this host" by hackinabox shape — false about that particular host, but not a launch failure; design.md's decision on this names ~/.gitconfig deliberately. When no ~/.gitconfig is found at that path — whether because the host keeps it elsewhere or has none at all — hib_config_generate copies nothing and removes any stale generated copy left from an earlier start. What that means for the box depends on its history, not on the host's current state. A box that has never been seeded has no $HOME/.gitconfig at all: there is no git identity, and the first commit attempted inside it fails with Author identity unknown (exit 128) until one is set inside the box. A box that was seeded before, whose host file has since gone, still has the [include] it wrote into $HOME/.gitconfig the first time — that stanza now resolves to nothing silently, no error and no warning: the host's values disappear, but everything the session wrote below the marker survives. An identity set inside the box still works, and a commit made with one succeeds.

The host's macOS system git config (credential.helper = osxkeychain, for example) is not copied, and must not be — it lives outside ~/.gitconfig entirely. A box's effective git configuration therefore still differs from the host's even after the copy, by design.

Host prerequisites

  • OrbStack running. hackinabox drives the container runtime through the docker CLI; if the daemon isn't reachable, preflight fails immediately with "the container runtime is not running" and nothing else runs.
  • jq on PATH. Generating a box's settings.json/mcp.json needs it at runtime; preflight checks for it before generation starts and fails with a named "'jq' is required but was not found on PATH" if it's missing, rather than letting generation fail mid-way with a confusing jq: command not found.
  • An SSH agent loaded with the key you intend to use, if the box declares ssh_agent=yes. A running agent is not enough — AddKeysToAgent/UseKeychain are not set by default on macOS, so an agent can be reachable and hold no identities. Starting a session checks this before creating anything: it runs ssh-add -l against the host's own agent and, for an empty or unreachable agent, dies with a message naming the problem and the fix ("run ssh-add" / "is one running?") — no container, volume, or config directory gets created on that path. hackinabox rebuild is the one exception — it deliberately skips this check, because whether the host agent holds an identity is a launchability question, not one that bears on recreating a container from a shape that's already in use; a rebuild on an ssh_agent=yes box with an empty agent still regenerates the config directory and creates the container, with no agent diagnostic.
  • ~/.ssh/known_hosts, if the box declares ssh_agent=yes. A box with ssh_agent=yes receives a copy of the host's whole ~/.ssh/known_hosts, regenerated on every start, so the box's first connection to a host you've already verified from this Mac just works. If the host has no known_hosts file, the launcher writes an empty one silently — this is not a launcher defect requiring a fix, it's the correct behaviour (ssh warns rather than trusts on an unknown host either way) — but it means the first push to a host you haven't connected to from this Mac before will fail inside the box with Host key verification failed., exit 128, and no launcher diagnostic names the cause. If that happens, connect to the host once from the host shell (or otherwise populate ~/.ssh/known_hosts) and start the box again.
  • Budget for a first-run login and onboarding pass, on every new box, even a project you've already authenticated in another box. Claude Code's onboarding state (hasCompletedOnboarding, hasTrustDialogAccepted) lives in ~/.claude/.claude.json inside the box's own $HOME volume, so it cannot be pre-seeded — a freshly created box always interrupts its first launch for an interactive login and the onboarding prompts, regardless of what settings.json was seeded with. Credentials can't be shortcut by copying another box's $HOME either: one OAuth credential does not stay usable in two boxes — a $HOME volume cloned from an already-authenticated box worked at first, then failed minutes later with OAuth session expired and could not be refreshed, while the source box's own credentials were untouched. The exact mechanism wasn't pinned down and doesn't need to be; the practical rule is simpler — log in once per box, and don't try to clone credentials between boxes. This is expected, not something to debug.

Adding project toolchains

A box's contents come from the project's own Dockerfile, FROM hackinabox-base — but where a tool installs inside that image matters. Install project toolchains under /usr/local (or anywhere else outside $HOME), never into $HOME itself. The reason is mechanical, not a style preference: the $HOME volume is created once, the first time a box is created, and seeds from the image only at that moment — every later hackinabox rebuild replaces everything outside $HOME with a fresh copy of the image but leaves the volume untouched. A toolchain installed into $HOME in the Dockerfile is therefore frozen at whatever version was current the day the box was first created: a later Dockerfile change to it never reaches an existing box, no matter how many times you rebuild. Claude Code itself is the deliberate, documented exception — it installs into $HOME precisely so its own self-update mechanism survives a rebuild.

About

Run Claude in a container

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages