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.
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
$HOMEvolume 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=yesgets 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~/.sshitself, 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=yesalso 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 anyssh_agent=yesbox, 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-configstops an undeclared server from being added to the session's tool list. It does not stop the box reaching other host ports:host.docker.internalresolves to the host for any process inside the box, declared or not, and a service bound to127.0.0.1on 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-mcpbehindsupergatewayon127.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_scriptruns arbitrary JavaScript in the host's real, logged-in Chrome,upload_filereads host paths, andnavigate_pagereaches 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_mcpcould not declare a token if they did. This is a host-side thing to manage —hackinaboxhas 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.
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 (
supergatewayand friends). That works, but readdocs/stdio-mcp-servers-over-http.mdbefore 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.
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.
- OrbStack running.
hackinaboxdrives the container runtime through thedockerCLI; if the daemon isn't reachable, preflight fails immediately with "the container runtime is not running" and nothing else runs. jqonPATH. Generating a box'ssettings.json/mcp.jsonneeds it at runtime; preflight checks for it before generation starts and fails with a named "'jq' is required but was not found onPATH" if it's missing, rather than letting generation fail mid-way with a confusingjq: 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/UseKeychainare 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 runsssh-add -lagainst the host's own agent and, for an empty or unreachable agent, dies with a message naming the problem and the fix ("runssh-add" / "is one running?") — no container, volume, or config directory gets created on that path.hackinabox rebuildis 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; arebuildon anssh_agent=yesbox with an empty agent still regenerates the config directory and creates the container, with no agent diagnostic. ~/.ssh/known_hosts, if the box declaresssh_agent=yes. A box withssh_agent=yesreceives 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 noknown_hostsfile, 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 withHost 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.jsoninside the box's own$HOMEvolume, 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 whatsettings.jsonwas seeded with. Credentials can't be shortcut by copying another box's$HOMEeither: one OAuth credential does not stay usable in two boxes — a$HOMEvolume cloned from an already-authenticated box worked at first, then failed minutes later withOAuth 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.
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.