From f2bcf25ffeb1d03530d005ca04ed2cc812029fd8 Mon Sep 17 00:00:00 2001 From: Giles Knap Date: Wed, 29 Apr 2026 14:23:39 +0000 Subject: [PATCH] fix(claude-sandbox): close VS Code credential helper leak VS Code's Dev Containers extension re-injects a /tmp credential bridge after postStart runs, allowing host GitHub PATs to leak into the container even with VSCODE_GIT_IPC_HANDLE blanked. Fix by: - Use --unset-all (not =) for credential.helper, so the multi-valued entry VS Code writes is actually cleared. - Remove /tmp/vscode-remote-containers-*.js so the bridge cannot answer even if a stale helper survives. - Pin per-host helpers to command -v gh / glab so a stale host path (/usr/local/bin/gh) doesn't fall through to the next helper. - Re-run cleanup on postAttachCommand because VS Code injects after postStartCommand has already finished. Also: - Install just explicitly when add_claude (recipes need it) - Bump glab to 1.93.0 - Add CLAUDE.md describing sandbox boundaries and intentional exposures (NFS-mounted ~/.claude, /workspaces parent bind, --net=host) - Link CLAUDE.md from README --- .../.devcontainer/devcontainer.json.jinja | 6 +- ...add_claude %}postStart.sh{% endif %}.jinja | 41 ++++++-- template/.gitignore | 4 + template/Dockerfile.jinja | 6 +- template/README.md.jinja | 15 +-- .../commands/memo.md | 43 +++++++++ .../hooks/sandbox-check.sh | 33 +++++++ .../settings.json | 38 ++++++++ .../skills/copier-derived/SKILL.md | 60 ++++++++++++ ...if add_claude %}CLAUDE.md{% endif %}.jinja | 65 +++++++++++++ ...claude %}README-CLAUDE.md{% endif %}.jinja | 93 +++++++++++++++++++ 11 files changed, 388 insertions(+), 16 deletions(-) create mode 100644 template/{% if add_claude %}.claude{% endif %}/commands/memo.md create mode 100755 template/{% if add_claude %}.claude{% endif %}/hooks/sandbox-check.sh create mode 100644 template/{% if add_claude %}.claude{% endif %}/settings.json create mode 100644 template/{% if add_claude %}.claude{% endif %}/skills/copier-derived/SKILL.md create mode 100644 template/{% if add_claude %}CLAUDE.md{% endif %}.jinja create mode 100644 template/{% if add_claude %}README-CLAUDE.md{% endif %}.jinja diff --git a/template/.devcontainer/devcontainer.json.jinja b/template/.devcontainer/devcontainer.json.jinja index ff0b38d8..24f72656 100644 --- a/template/.devcontainer/devcontainer.json.jinja +++ b/template/.devcontainer/devcontainer.json.jinja @@ -109,7 +109,11 @@ // Mount the parent as /workspaces so we can pip install peers as editable "workspaceMount": "source=${localWorkspaceFolder}/..,target=/workspaces,type=bind",{% if add_claude %} "postCreateCommand": ".devcontainer/postCreate.sh", - "postStartCommand": ".devcontainer/postStart.sh"{% else %} + "postStartCommand": ".devcontainer/postStart.sh", + // VS Code's Dev Containers extension re-injects its credential bridge + // when the editor attaches — after postStart has already run. Re-run + // the cleanup at attach so the leak is closed before any git operation. + "postAttachCommand": ".devcontainer/postStart.sh"{% else %} // After the container is created, recreate the venv then make pre-commit first run faster "postCreateCommand": "uv venv --clear && uv sync && pre-commit install --install-hooks"{% endif %} } diff --git a/template/.devcontainer/{% if add_claude %}postStart.sh{% endif %}.jinja b/template/.devcontainer/{% if add_claude %}postStart.sh{% endif %}.jinja index 78275e2a..f49a4a2c 100755 --- a/template/.devcontainer/{% if add_claude %}postStart.sh{% endif %}.jinja +++ b/template/.devcontainer/{% if add_claude %}postStart.sh{% endif %}.jinja @@ -1,12 +1,28 @@ #!/bin/bash set -euo pipefail -# Wipe any credential helpers and SSH URL rewrites injected by VS Code's -# Dev Containers extension when it copies the host gitconfig. An empty-string -# value resets the helper list so only an explicit PAT via `just gh-auth` -# can authenticate to remotes. -git config --global credential.helper '' -git config --global --unset-all url.ssh://git@github.com/.insteadOf 2>/dev/null || true +# Wipe any credential helpers and SSH URL rewrites that VS Code's Dev +# Containers extension injects when it copies the host gitconfig and +# spawns its own credential bridge. We need --unset-all (not =''), +# because VS Code stores the helper as a single multi-valued line that +# `git config ` only replaces if there is a single value. +# IMPORTANT: VS Code writes its credential.helper to /etc/gitconfig +# (system scope), not ~/.gitconfig — so the system scope must also be +# cleared, otherwise the helper still runs. +for scope in --system --global; do + git config $scope --unset-all credential.helper 2>/dev/null || true + git config $scope --unset-all credential.https://github.com.helper 2>/dev/null || true +{%- if install_glab %} + git config $scope --unset-all credential.https://gitlab.diamond.ac.uk.helper 2>/dev/null || true +{%- endif %} + git config $scope --unset-all url.ssh://git@github.com/.insteadOf 2>/dev/null || true +done + +# VS Code drops a Node-based credential bridge in /tmp that talks back +# to the host over a named pipe — even with VSCODE_GIT_IPC_HANDLE blank +# it can still surface host PATs. Remove it so any stale `credential.helper` +# entries cannot fall through to it. +rm -f /tmp/vscode-remote-containers-*.js # Force all SSH-style remotes to use HTTPS so the gh/glab credential helpers # handle auth. This keeps the container SSH-key-free (Claude stays sandboxed) @@ -17,9 +33,22 @@ git config --global url."https://gitlab.diamond.ac.uk/".insteadOf "git@gitlab.di {%- endif %} {% if install_gh -%} +# Pin per-host helper to the in-container gh path. The host gitconfig may +# reference /usr/local/bin/gh which doesn't exist here (apt installs to +# /usr/bin/gh); without this, git falls through to the next helper. +if command -v gh >/dev/null; then + git config --global credential.https://github.com.helper "!$(command -v gh) auth git-credential" +fi + # If gh CLI has cached credentials (survive container rebuild), re-register # its git credential helper so HTTPS remotes authenticate automatically. if gh auth status &>/dev/null; then gh auth setup-git fi {%- endif %} +{% if install_glab %} +# Pin per-host helper to the in-container glab path. +if command -v glab >/dev/null; then + git config --global credential.https://gitlab.diamond.ac.uk.helper "!$(command -v glab) auth git-credential" +fi +{%- endif %} diff --git a/template/.gitignore b/template/.gitignore index 0f33bf29..b57a8a3c 100644 --- a/template/.gitignore +++ b/template/.gitignore @@ -69,3 +69,7 @@ lockfiles/ # ruff cache .ruff_cache/ + +# Claude Code local state (commit settings.json, commands, skills, hooks) +.claude/settings.local.json +.claude/scheduled_tasks.lock diff --git a/template/Dockerfile.jinja b/template/Dockerfile.jinja index 4909f07f..422cfa17 100644 --- a/template/Dockerfile.jinja +++ b/template/Dockerfile.jinja @@ -7,9 +7,11 @@ RUN apt-get update -y && apt-get install -y --no-install-recommends \ graphviz \ && apt-get dist-clean{% if add_claude %} -# Node is required by Claude Code's hook runtime +# Node is required by Claude Code's hook runtime; just powers the +# container's claude/gh-auth/glab-auth recipes in justfile. RUN apt-get update -y && apt-get install -y --no-install-recommends \ nodejs \ + just \ && apt-get dist-clean{% endif %}{% if install_gh %} # GitHub CLI — used by Claude to authenticate to github.com via PAT @@ -23,7 +25,7 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | \ # GitLab CLI — used by Claude to authenticate to gitlab instances via PAT. # No apt repo, so install from the upstream release tarball. -ARG GLAB_VERSION=1.92.1 +ARG GLAB_VERSION=1.93.0 RUN curl -fsSL "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/glab_${GLAB_VERSION}_linux_amd64.tar.gz" \ | tar -xz -C /tmp bin/glab && \ install -m 0755 /tmp/bin/glab /usr/local/bin/glab && \ diff --git a/template/README.md.jinja b/template/README.md.jinja index 722e0419..1365caa9 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -10,13 +10,14 @@ This is where you should write a short paragraph that describes what your module does, how it does it, and why people should use it. -{# #}What | Where -{# #}:---: | :---: -{# #}Source | <{{repo_url}}> -{% if pypi %}PyPI | `pip install {{distribution_name}}` -{% endif %}{% if docker %}Docker | `docker run ghcr.io/{{github_org | lower}}/{{repo_name}}:latest` -{% endif %}{% if sphinx %}Documentation | <{{docs_url}}> -{% endif %}Releases | <{{repo_url}}/releases> +{# #}What | Where +{# #}:---: | :---: +{# #}Source | <{{repo_url}}> +{% if pypi %}PyPI | `pip install {{distribution_name}}` +{% endif %}{% if docker %}Docker | `docker run ghcr.io/{{github_org | lower}}/{{repo_name}}:latest` +{% endif %}{% if sphinx %}Documentation | <{{docs_url}}> +{% endif %}{% if add_claude %}Claude sandbox | [README-CLAUDE.md](./README-CLAUDE.md) +{% endif %}Releases | <{{repo_url}}/releases> This is where you should put some images or code snippets that illustrate some relevant examples. If it is a library then you might put some diff --git a/template/{% if add_claude %}.claude{% endif %}/commands/memo.md b/template/{% if add_claude %}.claude{% endif %}/commands/memo.md new file mode 100644 index 00000000..fb47a417 --- /dev/null +++ b/template/{% if add_claude %}.claude{% endif %}/commands/memo.md @@ -0,0 +1,43 @@ +--- +description: Save current task state to auto-memory, then promote reusable lessons to skills and trim memory. +--- + +# Memo + +Save a snapshot of current work to persistent memory, then clean up. + +## Step 1 — Save current state + +Write a concise summary of in-progress or recently completed work to the +auto-memory `MEMORY.md` for this project. Include: + +- What was done (feature, bug, refactor, area of code) +- Current status (completed, blocked, in-progress) +- Key decisions or outcomes worth remembering across conversations + +Do not duplicate information already in skills, CLAUDE.md, or README-CLAUDE.md. + +## Step 2 — Promote to skills + +Review the memory file for items that represent **reusable patterns or +lessons** — things that would help future sessions on this project. For +each such item: + +1. Identify which skill file it belongs in (or create a new one under + `.claude/skills//SKILL.md`). +2. Add it to the appropriate skill. +3. Remove it from memory (it now lives in the skill). + +Examples of promotable items: +- A non-obvious convention specific to this project +- A "foot-gun" pattern worth warning future-you about +- A reusable recipe (test invocation, deploy command, debugging trick) + +## Step 3 — Trim memory + +Remove from memory anything that is: +- Already captured in skills, CLAUDE.md, or README-CLAUDE.md +- Too specific to a single completed task to be useful again +- Stale or superseded by later work + +Keep memory concise — ideally under 30 lines. diff --git a/template/{% if add_claude %}.claude{% endif %}/hooks/sandbox-check.sh b/template/{% if add_claude %}.claude{% endif %}/hooks/sandbox-check.sh new file mode 100755 index 00000000..2fe82937 --- /dev/null +++ b/template/{% if add_claude %}.claude{% endif %}/hooks/sandbox-check.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# UserPromptSubmit hook: verify the Claude sandbox is intact before +# executing any prompt. Exit code 2 blocks the prompt and shows the +# message to the user. See README-CLAUDE.md for the full sandbox model. + +fail() { echo "BLOCKED: $1" >&2; exit 2; } + +# Are we in the devcontainer at all? +[ -n "${IN_DEVCONTAINER:-}" ] || \ + fail "not in the devcontainer (IN_DEVCONTAINER unset). Reopen the project in the devcontainer." + +# Host SSH agent must not be reachable. +[ -z "${SSH_AUTH_SOCK:-}" ] || \ + fail "SSH_AUTH_SOCK is set ($SSH_AUTH_SOCK) — host SSH agent is reachable." + +# VS Code git credential bridge must be silenced. +[ -z "${VSCODE_GIT_IPC_HANDLE:-}" ] || \ + fail "VSCODE_GIT_IPC_HANDLE is set — VS Code credential bridge is reachable." +[ -z "${GIT_ASKPASS:-}" ] || \ + fail "GIT_ASKPASS is set — VS Code askpass is injected." + +# The /tmp credential helper script VS Code drops in must have been removed. +if compgen -G '/tmp/vscode-remote-containers-*.js' >/dev/null; then + fail "/tmp/vscode-remote-containers-*.js bridge present — re-run .devcontainer/postStart.sh." +fi + +# system-scope credential.helper is where VS Code injects; if anything +# is set there git will use it before our per-host helpers. +if git config --system --get credential.helper >/dev/null 2>&1; then + fail "system credential.helper is still set — re-run .devcontainer/postStart.sh." +fi + +exit 0 diff --git a/template/{% if add_claude %}.claude{% endif %}/settings.json b/template/{% if add_claude %}.claude{% endif %}/settings.json new file mode 100644 index 00000000..6c32c5ea --- /dev/null +++ b/template/{% if add_claude %}.claude{% endif %}/settings.json @@ -0,0 +1,38 @@ +{ + "permissions": { + "allow": [ + "Edit(/workspaces/**)", + "Write(/workspaces/**)", + "Read(/workspaces/**)", + "Bash(*)" + ], + "deny": [ + "Bash(git push --force *)", + "Bash(git reset --hard*)", + "Bash(ssh *)", + "Bash(ssh-agent *)", + "Bash(*ssh-agent*)", + "Bash(scp *)", + "Bash(rsync *)", + "Bash(sftp *)", + "Bash(telnet *)", + "Bash(mail *)", + "Bash(sendmail *)" + ], + "additionalDirectories": [ + "/workspaces/**" + ] + }, + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/sandbox-check.sh" + } + ] + } + ] + } +} diff --git a/template/{% if add_claude %}.claude{% endif %}/skills/copier-derived/SKILL.md b/template/{% if add_claude %}.claude{% endif %}/skills/copier-derived/SKILL.md new file mode 100644 index 00000000..e6965e62 --- /dev/null +++ b/template/{% if add_claude %}.claude{% endif %}/skills/copier-derived/SKILL.md @@ -0,0 +1,60 @@ +--- +name: copier-derived +description: This project was generated from python-copier-template. Use when editing devcontainer / Dockerfile / .github / pre-commit / justfile / .gitleaks / renovate config, or when the user asks about updating from the template, resolving copier conflicts, or why a config looks the way it does. +--- + +# Copier-template-derived project + +This project was generated from +[python-copier-template](https://github.com/diamondlightsource/python-copier-template). +The template is recorded in `.copier-answers.yml`: + +```bash +grep _src_path .copier-answers.yml # template source +grep _commit .copier-answers.yml # version applied +``` + +## Template-managed files + +`copier update` overwrites these from the template. Local edits will +either merge cleanly (good) or produce `.rej` / inline conflicts. +**Prefer editing the upstream template** for any change that should +apply to all projects — otherwise the next update reverts it. + +- `.devcontainer/**` +- `Dockerfile` +- `.github/workflows/*.yml`, `.github/CONTRIBUTING.md`, + `.github/ISSUE_TEMPLATE/`, `.github/PULL_REQUEST_TEMPLATE/` +- `.pre-commit-config.yaml`, `.gitleaks.toml`, `renovate.json` +- `justfile` +- `pyproject.toml` — top-level metadata, build-system, ruff/pyright/mypy + config, tox config (project deps and scripts are project-owned) +- `tests/conftest.py`, `tests/test_cli.py` +- `CLAUDE.md`, `README-CLAUDE.md`, `.claude/**` + +## Project-owned files + +Edit freely; never overwritten by `copier update`: + +- `src//**` +- New tests under `tests/` (other than the seeded `test_cli.py`) +- `README.md` (rendered once with placeholders, then yours) +- `.copier-answers.yml` answers (only `_commit` / `_src_path` are bumped + by `copier update`) + +## When the user asks to change a template-managed file + +1. Make the requested change in this project so it works now. +2. **Tell the user** the file is template-managed, and offer to also + update the upstream template if they have it checked out (commonly + at `/workspaces/python-copier-template`). Phrase as a choice — they + may want a project-only patch. +3. If both edits are made, the project edit can be reverted on the + next `copier update` once the template change reaches a release. + +## Running `copier update` + +The user runs this themselves (it touches many files); only run it +yourself if explicitly asked. Always pass `--trust`. After update, +resolve any conflicts (look for `<<<<<<<` markers and `.rej` files) +before committing. diff --git a/template/{% if add_claude %}CLAUDE.md{% endif %}.jinja b/template/{% if add_claude %}CLAUDE.md{% endif %}.jinja new file mode 100644 index 00000000..daced9bd --- /dev/null +++ b/template/{% if add_claude %}CLAUDE.md{% endif %}.jinja @@ -0,0 +1,65 @@ +# CLAUDE.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. diff --git a/template/{% if add_claude %}README-CLAUDE.md{% endif %}.jinja b/template/{% if add_claude %}README-CLAUDE.md{% endif %}.jinja new file mode 100644 index 00000000..73f14aa9 --- /dev/null +++ b/template/{% if add_claude %}README-CLAUDE.md{% endif %}.jinja @@ -0,0 +1,93 @@ +# Claude sandbox + +This project's devcontainer is configured to run Claude Code with +`--dangerously-skip-permissions` (see `justfile`'s `claude` recipe). To make +that safe, the container is set up as a sandbox: Claude can use the project +toolchain, push/pull through PATs it owns, and persist its own settings — +but it cannot reach back to the host's identity or shared resources. + +This file documents what's locked down, what's deliberately left exposed, +and how to verify the sandbox is intact. + +## What's locked down + +- **No host SSH keys.** `SSH_AUTH_SOCK` is unset in `remoteEnv`, so any + SSH-agent forwarded by the host is invisible inside the container. No + private keys are mounted into `/root/.ssh` either — only `known_hosts`. +- **No VS Code git credential injection.** `GIT_ASKPASS`, + `VSCODE_GIT_IPC_HANDLE`, `VSCODE_GIT_ASKPASS_*` are all blanked, and + `postStart.sh` aggressively unsets `credential.helper` and per-host + helpers in BOTH `--system` (`/etc/gitconfig`) and `--global` scopes — + VS Code writes the helper into the *system* gitconfig, so a + global-only cleanup leaves the leak open. The script also removes the + `/tmp/vscode-remote-containers-*.js` bridge that VS Code drops in. + The cleanup re-runs on `postAttachCommand` because VS Code re-injects + the helper after `postStartCommand`. +- **Per-host helpers point at the in-container CLI.** The host gitconfig + often references `/usr/local/bin/gh`; here `gh` is at `/usr/bin/gh`. We + rewrite the helper to `command -v gh` / `command -v glab` so it doesn't + fall through to a stale entry. +- **All git remotes forced to HTTPS.** `url..insteadOf` rewrites + `git@github.com:` and `git@gitlab.diamond.ac.uk:` so push/pull always + uses the gh/glab credential helper rather than SSH. +- **Auth is per-repo.** `gh-auth-${repo}` and `glab-auth-${repo}` are + named volumes, not bind mounts — each project gets its own scoped PAT + via `just gh-auth` / `just glab-auth`. Authenticate once per repo and + the token survives container rebuilds. + +## What's deliberately exposed (and why) + +- **`/root/.claude` is bind-mounted from the host's `~/.claude`.** Claude's + settings, memory, hooks, and skills are shared between the host and the + container — that's the whole point. Anything Claude writes to its own + config persists to the host home directory. Treat `~/.claude` on the + host as part of the sandbox boundary, not outside it. +- **`/workspaces` is the parent of the project, not the project itself.** + The `workspaceMount` source is `${localWorkspaceFolder}/..`, so all + sibling repos in the same parent directory are visible inside the + container. This is intentional — it lets `pip install -e ../peer-repo` + work and lets Claude read across related projects when asked. If you + keep unrelated work in the same parent dir, Claude can see it. +- **`--net=host` shares the host's network namespace.** The container's + hostname will match the host's, and any service bound to `localhost` on + the host is reachable from inside. This is needed for X11, EPICS CA, + and to avoid devcontainer port-forwarding hassles. It also means the + container can talk to anything the host can talk to on its LAN. +- **`/cache` is a shared named volume across all devcontainers** built + from this template — uv cache, pre-commit cache, and the project venv + live there. Faster rebuilds; the trade-off is that a poisoned cache + affects every project sharing the volume. + +## Verifying the sandbox + +From inside the container: + +```bash +# Should be empty / unset +echo "SSH_AUTH_SOCK='${SSH_AUTH_SOCK:-}'" +ssh-add -l # "Could not open a connection..." +ls /root/.ssh # only known_hosts + +# Should NOT return a host PAT +printf 'protocol=https\nhost=github.com\n\n' | git credential fill + +# Should show only gh/glab helpers (no /tmp/vscode-remote-containers-*.js) +git config --global --list | grep -i credential +``` + +If `git credential fill` returns a `password=gho_...` for github.com when +you have not run `just gh-auth`, the sandbox is leaking — open an issue +against the python-copier-template. + +## Authenticating + +```bash +just gh-auth # paste a github.com PAT (repo + workflow scope is enough) +just glab-auth # gitlab.com (pass a hostname arg for self-hosted instances) +``` + +## Starting Claude + +```bash +just claude # runs `claude --dangerously-skip-permissions` with SSH_AUTH_SOCK blanked +```