Skip to content

Commit e5e5bbb

Browse files
committed
switch sandboxing to use unshare and unique gitconfig for claude
1 parent 2ad3a5b commit e5e5bbb

8 files changed

Lines changed: 257 additions & 137 deletions

template/.devcontainer/devcontainer.json.jinja

Lines changed: 8 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,8 @@
1111
"remoteUser": "root",{% endif %}
1212
"remoteEnv": {
1313
// Allow X11 apps to run inside the container
14-
"DISPLAY": "${localEnv:DISPLAY}",{% if add_claude %}
15-
// Disable SSH agent forwarding — prevents Claude from using host SSH keys.
16-
// No VS Code setting disables this, so the remoteEnv blank is the actual
17-
// defence (the GIT_ASKPASS / VSCODE_GIT_IPC_HANDLE bridge is closed at
18-
// the customizations.vscode.settings level instead — see below).
19-
"SSH_AUTH_SOCK": "",{% endif %}
20-
// Mark this shell as running inside the devcontainer
14+
"DISPLAY": "${localEnv:DISPLAY}",
15+
// Mark this shell as running inside the devcontainer.
2116
"IN_DEVCONTAINER": "1",
2217
// Put things that allow it in the persistent cache
2318
"PRE_COMMIT_HOME": "/cache/pre-commit",
@@ -40,18 +35,7 @@
4035
"python.terminal.activateEnvironment": false,
4136
// Workaround to prevent garbled python REPL in the terminal
4237
// https://github.com/microsoft/vscode-python/issues/25505
43-
"python.terminal.shellIntegration.enabled": false{% if add_claude %},
44-
// Close every VS Code channel that would otherwise forward host git
45-
// credentials into the container. Together these stop the integrated
46-
// terminal from inheriting GIT_ASKPASS / VSCODE_GIT_IPC_HANDLE, stop
47-
// the Dev Containers extension from writing a credential.helper line
48-
// into /etc/gitconfig, and stop the host ~/.gitconfig (with its SSH
49-
// url.insteadOf rewrites and per-host helpers) being copied in.
50-
// postStart.sh and the sandbox-check.sh hook are belt-and-braces
51-
// verifications that these settings actually took effect.
52-
"git.terminalAuthentication": false,
53-
"dev.containers.gitCredentialHelperConfigLocation": "none",
54-
"dev.containers.copyGitConfig": false{% endif %}
38+
"python.terminal.shellIntegration.enabled": false
5539
},
5640
// Add the IDs of extensions you want installed when the container is created.
5741
"extensions": [
@@ -72,7 +56,10 @@
7256
// Allow the container to access the host X11 display and EPICS CA
7357
"--net=host",
7458
// Make sure SELinux does not disable with access to host filesystems like tmp
75-
"--security-opt=label=disable"
59+
"--security-opt=label=disable"{% if add_claude %},
60+
// Required for `unshare -m` in the `just claude` recipe; without it,
61+
// rootless podman blocks mount-namespace creation. See README-CLAUDE.md.
62+
"--cap-add=SYS_ADMIN"{% endif %}
7663
],
7764
"mounts": [
7865
// Mount in the user terminal config folder so it can be edited
@@ -108,10 +95,5 @@
10895
],
10996
// Mount the parent as /workspaces so we can pip install peers as editable
11097
"workspaceMount": "source=${localWorkspaceFolder}/..,target=/workspaces,type=bind",
111-
"postCreateCommand": ".devcontainer/postCreate.sh"{% if add_claude %},
112-
"postStartCommand": ".devcontainer/postStart.sh",
113-
// VS Code's Dev Containers extension re-injects its credential bridge
114-
// when the editor attaches — after postStart has already run. Re-run
115-
// the cleanup at attach so the leak is closed before any git operation.
116-
"postAttachCommand": ".devcontainer/postStart.sh"{% endif %}
98+
"postCreateCommand": ".devcontainer/postCreate.sh"
11799
}

template/.devcontainer/postCreate.sh.jinja

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,14 @@ EOF
2828
exit 1
2929
fi
3030

31-
# Install Python dependencies and pre-commit hooks
31+
# Install Python dependencies and pre-commit hooks. `uv venv --clear` wipes
32+
# the venv that lives in /cache (a persistent named volume), so any bash
33+
# hash entries pointing into the old venv (e.g. cached `pre-commit` path)
34+
# are stale. `hash -r` after `uv sync` forces re-resolution against the
35+
# freshly populated venv and against any new `uv` location after a base
36+
# image bump.
3237
uv venv --clear
38+
hash -r
3339
uv sync
3440
pre-commit install --install-hooks
3541

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/bin/bash
2+
# Inner script for `just claude`: runs inside a private mount namespace
3+
# (created by `unshare -m` from the justfile recipe). Mounts tmpfs over
4+
# the locations VS Code uses for host bridges, builds a Claude-only
5+
# /root/.gitconfig, then exec's claude with PR_SET_PDEATHSIG so it dies
6+
# if its parent shell does. Requires CAP_SYS_ADMIN — granted via
7+
# --cap-add=SYS_ADMIN in devcontainer.json's runArgs. See
8+
# README-CLAUDE.md for the full sandbox model.
9+
set -euo pipefail
10+
11+
# VS Code drops IPC sockets (vscode-ipc-*.sock, vscode-git-*.sock,
12+
# vscode-ssh-auth-*.sock, vscode-remote-containers-ipc-*.sock) and the
13+
# vscode-remote-containers-*.js credential shim in /tmp, plus more in
14+
# /run/user/<uid>/. Replacing those directories with tmpfs in Claude's
15+
# namespace makes them invisible. Outside the namespace (the user's
16+
# regular terminal) VS Code keeps using them normally.
17+
mount -t tmpfs tmpfs /tmp
18+
if [ -d /run/user ]; then
19+
mount -t tmpfs tmpfs /run/user
20+
fi
21+
22+
# Mask credential directories the user may bind-mount from the host for
23+
# their own use from non-Claude terminals (e.g. ~/.ssh for SSH-based
24+
# git push). Claude sees an empty tmpfs; the user's regular shell sees
25+
# the originals.
26+
for d in /root/.ssh /root/.gnupg /root/.aws /root/.azure /root/.gcloud /root/.docker; do
27+
if [ -d "$d" ]; then
28+
mount -t tmpfs tmpfs "$d"
29+
fi
30+
done
31+
# .netrc is a single file, not a dir — mask via bind to /dev/null.
32+
if [ -e /root/.netrc ]; then
33+
mount --bind /dev/null /root/.netrc
34+
fi
35+
36+
# Build a Claude-only /root/.gitconfig containing the in-container
37+
# credential helpers (gh / glab) and HTTPS rewrites — and nothing else
38+
# the user has on the host (no SSH url rewrites, no host-specific
39+
# helpers). User identity is read from the original gitconfig BEFORE
40+
# we bind over it, so commits Claude makes are still attributed.
41+
git_name=$(git config --get user.name 2>/dev/null || true)
42+
git_email=$(git config --get user.email 2>/dev/null || true)
43+
gh_path=$(command -v gh || echo /usr/bin/gh)
44+
glab_path=$(command -v glab || echo /usr/local/bin/glab)
45+
cat > /etc/claude-gitconfig <<EOF
46+
[user]
47+
name = $git_name
48+
email = $git_email
49+
[safe]
50+
directory = *
51+
[url "https://github.com/"]
52+
insteadOf = git@github.com:
53+
[url "https://gitlab.diamond.ac.uk/"]
54+
insteadOf = git@gitlab.diamond.ac.uk:
55+
[credential "https://github.com"]
56+
helper =
57+
helper = !$gh_path auth git-credential
58+
[credential "https://gitlab.diamond.ac.uk"]
59+
helper =
60+
helper = !$glab_path auth git-credential
61+
EOF
62+
mount --bind /etc/claude-gitconfig /root/.gitconfig
63+
64+
# IS_SANDBOX=1 is the canary `.claude/hooks/sandbox-check.sh` keys off.
65+
# Env-blanks: SSH_AUTH_SOCK / VSCODE_GIT_IPC_HANDLE / VSCODE_IPC_HOOK_CLI
66+
# all point into /tmp (already tmpfs in this namespace), but blanking
67+
# the vars stops Claude from even *trying* the path. GIT_ASKPASS and
68+
# VSCODE_GIT_ASKPASS_* point under /.vscode-server which the namespace
69+
# does NOT mask — blanking them is the actual defence against Claude
70+
# triggering the VS Code "log in to GitHub" popup. BROWSER points at a
71+
# host helper that opens URLs in the user's browser — blanked so
72+
# Claude cannot drive the user's browser.
73+
exec setpriv --pdeathsig SIGKILL env \
74+
SSH_AUTH_SOCK= \
75+
GIT_ASKPASS= \
76+
VSCODE_GIT_IPC_HANDLE= \
77+
VSCODE_GIT_ASKPASS_NODE= \
78+
VSCODE_GIT_ASKPASS_MAIN= \
79+
VSCODE_IPC_HOOK_CLI= \
80+
BROWSER= \
81+
IS_SANDBOX=1 \
82+
claude --dangerously-skip-permissions

template/.devcontainer/{% if add_claude %}postStart.sh{% endif %}.jinja

Lines changed: 0 additions & 47 deletions
This file was deleted.

template/{% if add_claude %}.claude{% endif %}/hooks/sandbox-check.sh

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,21 @@ fail() { echo "BLOCKED: $1" >&2; exit 2; }
99
[ -n "${IN_DEVCONTAINER:-}" ] || \
1010
fail "not in the devcontainer (IN_DEVCONTAINER unset). Reopen the project in the devcontainer."
1111

12+
# IS_SANDBOX=1 is set by the inner `just claude` script after it sets up
13+
# the private mount namespace. If it's missing, Claude was launched
14+
# without the namespace and /tmp/vscode-*.sock host bridges are reachable.
15+
[ -n "${IS_SANDBOX:-}" ] || \
16+
fail "IS_SANDBOX unset — Claude was not launched via \"just claude\", so the mount-namespace sandbox is not active."
17+
1218
# Host SSH agent must not be reachable. remoteEnv blanks SSH_AUTH_SOCK and
1319
# `just claude` re-blanks it; if it is set, neither layer applied.
1420
[ -z "${SSH_AUTH_SOCK:-}" ] || \
1521
fail "SSH_AUTH_SOCK is set ($SSH_AUTH_SOCK) — host SSH agent is reachable. run \"just claude\" or rebuild the devcontainer."
1622

17-
# VS Code git credential bridge must be silenced. With
18-
# git.terminalAuthentication=false in devcontainer.json these env vars
19-
# should never be set — if they are, the setting was not applied.
20-
[ -z "${VSCODE_GIT_IPC_HANDLE:-}" ] || \
21-
fail "VSCODE_GIT_IPC_HANDLE is set — VS Code credential bridge is reachable. Rebuild the devcontainer (git.terminalAuthentication should be false)."
22-
[ -z "${GIT_ASKPASS:-}" ] || \
23-
fail "GIT_ASKPASS is set — VS Code askpass is injected. Rebuild the devcontainer (git.terminalAuthentication should be false)."
24-
25-
# The /tmp credential helper script VS Code drops in must have been removed.
26-
if compgen -G '/tmp/vscode-remote-containers-*.js' >/dev/null; then
27-
fail "/tmp/vscode-remote-containers-*.js bridge present — re-run .devcontainer/postStart.sh."
28-
fi
29-
30-
# system-scope credential.helper is where VS Code injects; if anything
31-
# is set there git will use it before our per-host helpers.
32-
if git config --system --get credential.helper >/dev/null 2>&1; then
33-
fail "system credential.helper is still set — re-run .devcontainer/postStart.sh."
34-
fi
23+
# GIT_ASKPASS points at a script under /.vscode-server, which the
24+
# namespace does NOT mask. If the env var is non-empty AND the file is
25+
# reachable, claude-sandbox.sh's exec-line blank failed to apply.
26+
[ ! -e "${GIT_ASKPASS:-}" ] || \
27+
fail "GIT_ASKPASS script ($GIT_ASKPASS) is reachable — claude-sandbox.sh did not blank the env var. Rebuild the devcontainer or re-run \"just claude\"."
3528

3629
exit 0

template/{% if add_claude %}.claude{% endif %}/skills/copier-derived/SKILL.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,27 @@ The user runs this themselves (it touches many files); only run it
5858
yourself if explicitly asked. Always pass `--trust`. After update,
5959
resolve any conflicts (look for `<<<<<<<` markers and `.rej` files)
6060
before committing.
61+
62+
## Verifying template changes without committing
63+
64+
When editing files in `/workspaces/python-copier-template/template/`,
65+
render a throwaway project to confirm the change works for both
66+
branches of every conditional (`add_claude=true` and `false`):
67+
68+
```bash
69+
cd /tmp && rm -rf render-true render-false
70+
git init render-true -b main >/dev/null
71+
uvx copier copy /workspaces/python-copier-template /tmp/render-true \
72+
--data-file /workspaces/python-copier-template/example-answers.yml \
73+
--vcs-ref HEAD --defaults --trust
74+
git init render-false -b main >/dev/null
75+
uvx copier copy /workspaces/python-copier-template /tmp/render-false \
76+
--data-file /workspaces/python-copier-template/example-answers.yml \
77+
--data add_claude=false --vcs-ref HEAD --defaults --trust
78+
```
79+
80+
`--vcs-ref HEAD` makes copier render from the working tree (so
81+
uncommitted edits are picked up). For each render, sanity-check the
82+
key files: `.devcontainer/devcontainer.json` should parse as JSON
83+
(strip `//` comments first), conditional files should appear or
84+
not as expected, and shell scripts should preserve their `755` mode.

0 commit comments

Comments
 (0)