Safe SSH Key Access from a Docker-Running AI Agent
Problem
When an AI coding agent (e.g. Claude Code, Cursor, Copilot) runs inside a Docker container,
it needs to perform git push / git pull over SSH. The naive solution — bind-mounting your
~/.ssh/ directory into the container — gives the agent direct access to your private key bytes:
# Naive approach — the agent can do this:
cat ~/.ssh/id_ed25519 # read raw key material
cp ~/.ssh/id_ed25519 /tmp/ # exfiltrate itThe agent can extract and exfiltrate your key silently. This is a meaningful risk when you don’t fully control what the agent executes.
Solution: SSH Agent Socket Forwarding
Instead of mounting key files, run an ssh-agent process on the host and forward only its
Unix domain socket into the container. The container can ask the agent to sign challenges
(enabling git operations) but can never read the key bytes — the key never leaves the agent
process.
Host machine
├── ssh-agent (holds private key in memory)
│ └── listens on ~/.ssh_agent/agent.sock
│
└── Docker container
└── /run/ssh-agent/agent.sock ← same socket, bind-mounted
git push → "sign this challenge" → agent signs → git push succeeds
cat private_key → file doesn't exist
Why Unix Sockets Are Not a Network Risk
Unix domain sockets are filesystem objects, not network endpoints:
- No IP address, no port, not reachable from your LAN or the internet
- Access is controlled entirely by filesystem permissions (owner / group / mode)
- Only processes on the same machine can connect — and only if they can traverse the directory path to the socket file
This means locking down the parent directory to chmod 700 is sufficient to prevent other
users on the same host from reaching the socket, even if the socket file itself is chmod 666.
Directory Layout
~/.ssh_agent/ # chmod 700 — only you can enter
├── id_ed25519 # chmod 600 — your private key, NEVER mounted
├── id_ed25519.pub
└── agent.sock # chmod 666 — socket forwarded into container
The chmod 666 on the socket is intentional and safe: no other host user can traverse the
700 parent directory to reach it. It is necessary on Linux native Docker (see below).
Host-Side Setup Script
Add an idempotent ensure_ssh_agent() to your container standup script. Call it before
docker run.
SSH_AGENT_DIR="${HOME}/.ssh_agent"
SSH_AGENT_SOCK_PATH="${SSH_AGENT_DIR}/agent.sock"
SSH_KEY_PATH="${SSH_AGENT_DIR}/id_ed25519"
# UID of the non-root user inside your Docker image (e.g. 'node' in node:22 is 1000).
CONTAINER_USER_UID=1000
# Ensures the SSH agent is running with the fixed socket path and key loaded.
# Idempotent — safe to call on every container standup.
ensure_ssh_agent() {
mkdir -p "$SSH_AGENT_DIR"
chmod 700 "$SSH_AGENT_DIR"
# Detect UID mismatch early. On Linux native Docker there is no UID remapping,
# so a 0600 socket owned by host UID X is inaccessible to container UID Y.
local host_uid
host_uid=$(id -u)
if [[ "$host_uid" != "$CONTAINER_USER_UID" ]]; then
echo "NOTE: host UID=[${host_uid}] != container UID=[${CONTAINER_USER_UID}] — socket will be chmod 666 (safe; parent dir is 700)"
fi
if SSH_AUTH_SOCK="$SSH_AGENT_SOCK_PATH" ssh-add -l &>/dev/null; then
echo "SSH agent already running at [${SSH_AGENT_SOCK_PATH}]"
else
echo "Starting SSH agent at [${SSH_AGENT_SOCK_PATH}]..."
if [[ ! -f "$SSH_KEY_PATH" ]]; then
echo "ERROR: SSH key not found at [${SSH_KEY_PATH}]"
echo "Place your private key there and re-run. The key is never mounted into the container."
exit 1
fi
# Kill any old agent that is alive but has no keys (ssh-add -l exits 1),
# before removing its socket — prevents orphaned agent processes.
SSH_AUTH_SOCK="$SSH_AGENT_SOCK_PATH" ssh-agent -k &>/dev/null || true
rm -f "$SSH_AGENT_SOCK_PATH"
ssh-agent -a "$SSH_AGENT_SOCK_PATH" > /dev/null
SSH_AUTH_SOCK="$SSH_AGENT_SOCK_PATH" ssh-add "$SSH_KEY_PATH"
fi
# [chmod 666 on agent socket]: ssh-agent creates the socket with 0600 (owner-only).
# On Linux native Docker the container user cannot connect if host UID != container UID.
# Widening to 0666 is safe because the parent directory is chmod 700.
# Applied unconditionally so it is correct even when the agent was already running.
chmod 666 "$SSH_AGENT_SOCK_PATH"
}
ensure_ssh_agentDocker Run Flags
# Remove any previous key-file bind mount:
# -v "${HOST_SSH_DIR}:/home/user/.ssh" ← DELETE this
# Add instead:
docker run \
-v "${SSH_AGENT_SOCK_PATH}:/run/ssh-agent/agent.sock" \
-e SSH_AUTH_SOCK=/run/ssh-agent/agent.sock \
-v "${HOME}/.ssh_agent_known_hosts:/home/user/.ssh/known_hosts" \
...known_hosts is extracted to its own file so new hosts can still be learned (read-write mount)
without any key files being present in the same directory.
Dockerfile Changes
The parent directory /run/ssh-agent/ must exist in the image for the socket bind-mount to work:
RUN mkdir -p /home/user/.ssh /run/ssh-agent && \
chmod 700 /home/user/.sshThreat Model
| Capability | Before (key files mounted) | After (socket forwarding) |
|---|---|---|
git push / git pull | ✓ | ✓ |
| Read private key bytes | ✓ (critical risk) | ✗ impossible |
| Exfiltrate key for later use | ✓ | ✗ impossible |
| Sign arbitrary data while container runs | ✓ | ✓ (unavoidable) |
| Persist access after container stops | ✓ | ✗ socket is gone |
The remaining exposure is session-scoped: the agent can make git operations (push to repos you have access to) while the container is live. This is unavoidable if git-over-SSH must work. The critical gain is that the key cannot be extracted and used later — which is what makes key theft a serious incident.
macOS vs Linux Behaviour
| Platform | UID remapping | Notes |
|---|---|---|
| macOS Docker Desktop | Yes (via Linux VM) | Socket often accessible without chmod 666 |
| Linux native Docker | No | chmod 666 on socket is required when host UID ≠ container UID |
The ensure_ssh_agent() script above handles both correctly: it detects the mismatch and
always applies chmod 666, which is harmless on macOS and necessary on Linux.