The egress boundary from post 2 has a git-shaped hole in it that I obviously had to deal with as a first order of business when using the Claude container in the real world.
The easy route here would be to just set the git credentials to read-only and include them in the container (via read-only mount), but this would also expose them to exfiltration, and there's no good reason for an agent or program to have direct access to them.
When Squid sees a CONNECT github.com:443 it would wave it through, or not, and that'd be its
entire vocabulary. It notably couldn't distinguish a git clone from a git push, and it
certainly cannot tell a push of your bugfix from a push of a branch containing every .env
under /workspace. The tunnel is opaque by design.
That was tolerable while the container had no credentials to push with, but the moment you want
the agent to open a PR, you have to give it something, and the obvious move — mount
~/.ssh/ and get on with your life — undoes a good chunk of the previous five
posts. The threat model here is of persistence: that an exfiltrated key keeps working long after
the container is gone.
So: how do you let something use a credential without letting it have one?
Pulling up another sidecar
The proxy container already solved a similar problem, sitting on both caged and
egress, it was the only way out, and was constrained in this way so that the Claude Code
container cannot reconfigure it. Because the rules live in a container the agent has no access to,
it keeps the Claude Code environment 'sheltered', managed.
The git gateway is the same trick in a different protocol:
Internet
▲
┌───────────┴───────────┐
│ │
┌─────┴─────┐ ┌──────┴──────┐
│ proxy │ │ git-gateway │ holds the real key
│ (squid) │ │ (sshd) │ speaks git only
└─────▲─────┘ └──────▲──────┘
│ │
┌───────┴───────────────────────┴───────┐
│ caged internal: true │
│ Claude Code │
└───────────────────────────────────────┘
This time your SSH key is mounted into that right-hand box and nowhere else. There's no volume, socket, nor env var available in the workspace, and instead a sort of SSH relay through which the Claude Code box carries out its git ops.
The workspace gets a different key, a throwaway ed25519 pair generated fresh for every run, whose
public half is written into the gateway's authorized_keys and whose private half is deleted
when the run ends. It authenticates just the one hop over this internal network and is worth nil
beyond it.
The gateway needs an identity of its own too, and the runner generates that as well, for the
same reason in reverse. An image that builds its own host key leaves the workspace with no way
to know what it ought to expect — which is the usual reason people end up telling SSH not to
bother checking. Generating it outside the container means the fingerprint is known before the
container exists, so it can be written into the workspace's known_hosts up front: nothing to
trust on first connection, nothing kept between runs. sshd is then given exactly one host key,
because it offers every key it holds, and a client that had pinned one of three would reject
the other two with a warning that reads exactly like an attack.
Then a nifty little URL rewrite points git at the sidecar, so nothing in your repo needs to change:
[url "git-gateway:"]
insteadOf = git@github.com:
insteadOf = ssh://git@github.com/
insteadOf = https://github.com/
git push connects to git-gateway over the internal network, and the sidecar makes its own,
separate connection out to GitHub using the credential you never handed over (the host's SSH creds,
kept safe across this gap).
The interesting part is what falls out of terminating the connection rather than tunnelling
it. Git names its operation in the SSH exec request — git-upload-pack is a fetch,
git-receive-pack is a push — so the sidecar can simply read what is being asked for.
Alternatives would require more sus routes like TLS interception, fake certificate authority, or mitmproxy.
The workspace is talking to its real endpoint; said endpoint just gets to be opinionated.
| key in the workspace | the sidecar | |
|---|---|---|
| agent can read the key | yes | no |
| key still works if leaked | yes | no |
| fetch and push distinguishable | no | yes |
| restrictable to one repository | no | yes |
| the log says | "a tunnel existed" | ALLOW read git-upload-pack lmmx/repo |
The gate
Authenticating to the sidecar does not get you a shell on it, which matters rather a lot
given what the sidecar possesses. sshd there runs with ForceCommand, so every session
(whatever was actually requested) is replaced by one program:
/usr/local/bin/git-policy
and the authorized_keys entry carries restrict,command="..." as well, so a replaced key
file cannot yield a session that runs anything else either. Forwarding of every flavour (TCP,
stream-local, agent, X11), tunnels, TTYs and user environment are all off. An SSH channel is
a general-purpose network pivot unless you turn all of that off explicitly.
git-policy is a hardened shell script, with no evaluated inputs.
It accepts three verbs (git-upload-pack and git-upload-archive as reads,
git-receive-pack as a write), requires the remainder to be exactly one single-quoted
argument, and rejects any path containing anything outside [A-Za-z0-9._/-].
The restricted character list is important here. Consider '/a' rm -rf '/b' — that
starts with a quote, ends with a quote, and is two arguments. What kills it is that no space
or quote survives the path check, so a second word cannot exist.
And then the part I think is the actual design decision: it does not forward the string it validated. It rebuilds the command from the two values it parsed out:
exec ssh -i "$KEY" \
-o IdentitiesOnly=yes -o BatchMode=yes \
-o StrictHostKeyChecking=yes -o UserKnownHostsFile="$KNOWN_HOSTS" \
"$UPSTREAM" "$op '$repo'"
Validate-then-forward leaves you one parser disagreement away from a bad day.
Validate-then-reconstruct does not. There are 31 tests over the gate, the injection cases
among them — trailing commands, second arguments, backticks, $(...), newlines, unquoted
paths, traversal — and they run under plain sh with no container and no network, which meant I
(or rather the CC agent) could run them from inside the sandbox while debugging it.
Using it
There are now four ways to run it:
claude-container ~/repo # no git at all
claude-container --git ~/repo # local commits, no network
claude-container --github ~/repo # clone and fetch
claude-container --github=push ~/repo # and push
--github is deliberately one flag for the full capability: it implies --git for the
identity, --allow github for the hostnames, and it starts the sidecar.
Two flags narrow what is opened:
--git-repo lmmx/one-repo # only this repository (repeatable)
--git-key ~/.ssh/deploy_key # this key rather than ~/.ssh/id_ed25519
--git-repo is the one to reach for by default alongside =push. It is the difference
between "may push" and "may push here", and a GitHub deploy key scoped to a single
repository is narrower still.
Everything the run is permitted to do gets printed at startup, which I have come to think is non-negotiable for this kind of tool — a boundary you cannot see is a boundary you will misconfigure:
ALLOW: github (in addition to api.anthropic.com)
GIT: mounting /home/louis/.gitconfig read-only
GIT GATEWAY: /home/louis/.ssh/id_ed25519 mounted into the sidecar only; the workspace cannot read it
fetch, clone and PUSH permitted
repositories: lmmx/scratch-repo-pub
and a refusal comes back in words rather than as a mysterious network fault:
claude-container: push refused: this run started without --github=push
claude-container: refused: lmmx/other is not among the repositories this run may reach
Seven things wrong with one container
That is the design. Getting it to actually pass a single byte took most of an evening, and the failures are worth recording for posterity, because they were all variations on a theme.
The container is cap_drop: [ALL], and is rootless. These two features
break an astonishing number of things that root normally just does. In order:
1. sshd could not open a socket.
Bind to port 22 on 0.0.0.0 failed: Permission denied.
Port 22 is privileged, binding it needs CAP_NET_BIND_SERVICE, and cap_drop: [ALL] takes
that away from root as thoroughly as from anyone else. Moved to 2222 rather than handing the
capability back.
2. The account was locked. Alpine's adduser -D writes ! into the shadow password
field, and OpenSSH reads a leading ! as a locked account — and refuses before it looks at
any key at all:
User gitgw not allowed because account is locked
Which meant every Permission denied (publickey) I had been chasing was reported against a
key sshd never got as far as opening. The fix is to write * instead, which means "no
password will ever match" without also meaning "locked".
3. authorized_keys was unreadable. sshd lowers itself to the target user before opening
AuthorizedKeysFile — the same behaviour that makes root-squashed NFS home directories fail
to authenticate. Rootless podman maps me to root inside the container, so a file I wrote at
0600 arrived owned by root, and gitgw could not read it. It holds a public key. 0644.
4. The forced command could not run. Now authenticating cleanly, and:
This account is not available
which is /sbin/nologin talking. sshd runs ForceCommand through the user's login shell —
$SHELL -c git-policy — so a nologin shell doesn't harden the gate, it makes the gate
unreachable. This one stung a bit, because giving the account a real shell reads like a
downgrade. It isn't: ForceCommand and restrict,command= both replace whatever was asked
for, and the shell is only ever the thing that execs the replacement. But it does mean the
containment now rests on two mechanisms where it previously had a third by accident.
5. The policy never arrived. The gate reads GIT_GATEWAY_PUSH and GIT_GATEWAY_REPOS,
set as compose environment variables on the container. Except sshd builds a session a fresh
environment rather than passing its own — only TZ and the SSH_* variables it sets itself
survive. So the policy reached pid 1 and stopped dead, and git-policy saw nothing. Now an
entrypoint writes them to a file at 0444 before execing sshd, and the gate reads that.
6. The credential was unreadable, in precisely the same way as #3: mounted at 0600, owned
by root inside the container, read by gitgw. The user's ~/.ssh/id_ed25519 is not the program's to
re-permission on the host, so the entrypoint copies it to a gitgw-owned 0400 file while pid
1 is still root.
7. And then it couldn't do that either.
chmod: /run/git-key: Operation not permitted
chown then chmod — and once root has given the file away, changing its mode needs
CAP_FOWNER, which cap_drop: [ALL] also took. Swap the two calls and the same work needs
no capability at all, because root may always chmod a file it still owns and chown
preserves the mode.
Seven failures, two root causes: capabilities root normally has for free, and rootless uid mapping making every mounted file root-owned. If you build one of these, that's where to look first.
The evidence was being deleted
The worst part of this was that I spent the first few rounds of this convinced it was a networking problem, because the only symptom I could see was:
ssh: Could not resolve hostname git-gateway
I "fixed" the compose networking twice, yet the workspace was already on caged,
which the Squid proxy working had already proved. A hostname failing to resolve on a
podman network means no running container has that name, because aardvark-dns only
registers live containers. It was a startup crash the whole time, mistaken for DNS.
I could not then read the crash, because the runner's teardown ran podman compose down at the
end of every session, which removes the containers and takes their logs with them.
Two changes fixed the debugging loop, and they were worth more than any of the seven fixes above:
- Check the sidecar is still alive after
up -d. Compose reports on whether a container started, not on what it did next, so a container that exits while parsing its config leaves a perfectly successfulupbehind. The runner now waits a beat, checks, and exits with that container's log if it's gone. - Capture the sidecar's log at teardown, alongside squid's, in the same egress log under
the same header, before
downremoves it.
After that, every remaining failure was diagnosed in one round from sshd's own words. Which is the lesson, really: I was not short of hypotheses, I was short of evidence, and the tool was destroying it on exit.
And then it worked: a commit written inside the container, pushed to lmmx/scratch-repo-pub
by an agent that could not read the key that pushed it, with ALLOW write git-receive-pack
lmmx/scratch-repo-pub in the log to say so.
One bonus bug before we go
While fixing #5 I noticed that git-policy applies the repository allowlist like this:
if [ -n "${REPOS:-}" ]; then
# ...check the repo is in the list, deny otherwise
fi
With the environment not surviving sshd, REPOS was empty. An empty allowlist means "no
--git-repo was given, so any repository the key reaches is fine" — so the check was skipped
entirely. --git-repo lmmx/scratch-repo-pub had not been narrowing anything at all.
The same missing variable made the push check deny (PUSH unset → not 1 → refused) while
making the repository check permit. The same absence failed closed in one field and open in
the other. The closed one is what I noticed, immediately and loudly, because my push didn't
work. The open one produced no symptom whatsoever; it just quietly wasn't a boundary.
There is no correct default to pick here, because "no allowlist was configured" and "the allowlist never arrived" are genuinely different situations that both arrive looking like an empty string. So the gate stopped asking the individual fields. The entrypoint now writes an explicit marker saying the policy was delivered at all, and that gets checked before anything else is read:
if [ "$POLICY_OK" != "1" ]; then
deny "no-policy" "refused: this run's policy did not reach the gateway"
fi
Two of the tests now point the gate at a policy file that does not exist and check that an ordinary fetch is refused — the case that would previously have sailed straight through.
What it doesn't buy
A permitted operation is unrestricted in content. With =push to a repository you named,
the agent can commit anything in the workspace, including things you'd rather it didn't, and
the gateway will carry it — a push of secrets and a push of a fix are the same operation. The
gate constrains where, not what.
The API is a different surface. --github also opens api.github.com through squid, with
no credential attached. The sidecar runs git and only git; anything reaching the API is
governed by whether a token is reachable inside the workspace. Which brings us to:
--git mounts your ~/.gitconfig, and if that names a credential helper or carries an
inline token, you have handed over a credential by a different door. The runner warns about
this by line number. A warning is not a boundary.
The proxy is still taken on trust. The gateway's identity is checked now, but the
workspace talks to whatever answers to proxy:3128 without verifying anything at all. There's
nobody else on that network to impersonate it, so this is theoretical rather than urgent, but
it is the last "trusted because it answered" left inside caged.
The gateway is now the thing worth attacking. Moving a credential out of the workspace moved it somewhere. That somewhere is a small Alpine image running OpenSSH and one shell script, under dropped capabilities, on an internal network — which is about as small as I can make it — but it is no longer correct to think of it as a helper container. It's an enclave.
Where this leaves the box
The pattern from post 4 keeps repeating: take something that was one uniform thing and split it. Privileges into held and acquirable. Directories into read and written. Now the network splits into "bytes to a host" and "an operation on a repository", and it turns out the second is a much more useful unit to write policy about than the first.
Squid can tell you that 47KB went to github.com:443. The gateway tells you
ALLOW write git-receive-pack lmmx/scratch-repo-pub. Which route you take makes all the difference.