Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c62c43f
fix(claude-sandbox): re-blank VS Code askpass vars at claude invocation
gilesknap Apr 29, 2026
1dff94f
docs(claude-sandbox): note planned move of node/gh/glab to base image
gilesknap Apr 29, 2026
dbd0ec7
fix(devcontainer): drop forwardPorts/autoForwardPorts override
gilesknap Apr 29, 2026
19099d9
refactor(devcontainer): always render postCreate.sh
gilesknap Apr 29, 2026
aed82f9
fix(devcontainer): only init missing submodules on container start
gilesknap Apr 29, 2026
0f2ea37
fix tests
gilesknap Apr 29, 2026
297e25b
fix(readme): restore Claude sandbox link with absolute URL
gilesknap Apr 29, 2026
d886d20
fix(readme): use raw HTML for README-CLAUDE link
gilesknap Apr 29, 2026
5cf9c82
refactor(copier): drop install_gh / install_glab questions
gilesknap Apr 29, 2026
19e9846
fix(postCreate): fail fast with a clear message when .git is missing
gilesknap Apr 29, 2026
457d06b
move claude install to end of postCreate for earlier checks on uv
gilesknap Apr 30, 2026
5eed878
add pr-squash claude command
gilesknap Apr 30, 2026
5be632f
sandbox-check: point env-var failures at "just claude"; widen copier-…
gilesknap Apr 30, 2026
2ad3a5b
sandbox: enforce VS Code git credential lockdown via devcontainer set…
gilesknap Apr 30, 2026
e5e5bbb
switch sandboxing to use unshare and unique gitconfig for claude
gilesknap Apr 30, 2026
2402977
add claude to main repo
gilesknap Apr 30, 2026
e0c906d
remove copier-derived skill
gilesknap Apr 30, 2026
ca40850
add in etc/gitconfig to unshare sandboxing
gilesknap Apr 30, 2026
944ba53
sandbox-check: catch dropped /root/.gitconfig and /etc/gitconfig binds
gilesknap Apr 30, 2026
7fc8035
fix(template): drop trailing blank from rendered postCreate.sh
gilesknap Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .claude/commands/memo.md
Original file line number Diff line number Diff line change
@@ -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/<name>/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.
88 changes: 88 additions & 0 deletions .claude/commands/pr-squash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# PR Squash

Create a clean PR by grouping the current branch's commits into logical squashed
commits on a new branch, then opening a pull request.

## Instructions

1. **Determine the base branch.** Use `$ARGUMENTS` if provided, otherwise detect
the repo's default branch (`main` or `master`) via `gh repo view --json
defaultBranchRef -q .defaultBranchRef.name`.

2. **Collect the commit history.** Run:
```
git log --oneline --reverse <base>..<current-branch>
```
These are the commits to be grouped.

3. **Analyse and group the commits.** Read the diffs for each commit
(`git show --stat <sha>` and `git show <sha>` for ambiguous cases).
Group commits into logical units:
- Each group should represent one cohesive change (a feature, a fix, a
refactor, a config change, etc.).
- Iterative fix-up commits ("fix typo", "try again", "wip") belong with the
feature they relate to.
- Keep genuinely independent changes in separate groups.
- Preserve chronological order between groups where possible.

4. **Decide: one PR or multiple PRs.** If the groups fall into distinct,
unrelated topics (e.g. "developer tooling" vs "production feature"), plan
to create **separate PRs** — one per topic. Each PR gets its own squash
branch (`<current-branch>-squash-1`, `-squash-2`, etc.) and contains only
the groups for that topic. Groups that are closely related (e.g. a feature
and its config) stay in the same PR as separate squashed commits.

Rule of thumb: if a reviewer would reasonably want to merge one topic
without the other, they belong in separate PRs.

5. **Present the grouping plan.** Show the user a numbered list like:
```
PR 1: "Devcontainer hardening and tooling"
Group 1: "harden devcontainer and add Just task runner"
- abc1234 add security settings
- def5678 replace tox with just

PR 2: "Add Dex OIDC authentication"
Group 2: "configure Dex and argocd-monitor"
- jkl3456 add Dex config
- mno7890 fix client secret
- pqr1234 fix audience mismatch
```
If all groups are closely related, show a single PR with multiple groups.
Ask the user to confirm or adjust before proceeding.

6. **Create squash branch(es).** Once approved, for each PR:
```
git checkout -b <branch-name> <base>
```
Use `<current-branch>-squash` for a single PR, or
`<current-branch>-squash-<N>` (or a short descriptive suffix) for multiple.

7. **Cherry-pick and squash each group.** For each group in the PR:
```
git cherry-pick --no-commit <sha1> <sha2> ...
git commit -m "<group message>"
```
Use a well-written conventional commit message for each group. Include a
short body if the group contains non-obvious changes. Preserve any
`Co-Authored-By` trailers from the original commits.

8. **Push and create the PR(s).**
```
git push -u origin <branch-name>
```
Create each PR with `gh pr create` targeting the base branch. The PR body
should summarise its squashed commit group(s).

9. **Switch back** to the original branch so the user's working state is
unchanged.

## Edge cases
- If there are fewer than 3 commits, suggest the user just squash-merge
directly instead — but proceed if they insist.
- If cherry-pick conflicts arise, stop and inform the user rather than
auto-resolving.
- Never force-push or modify the original branch.
- If a `-squash` branch already exists, ask the user before overwriting.
- When merging a PR created by this command, use `gh pr merge --merge`
(not `--squash`) to preserve the curated commit structure.
130 changes: 130 additions & 0 deletions .claude/commands/verify-sandbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
---
description: Verify Claude's mount-namespace sandbox is intact — env canaries, masked credentials, gitconfig bind, and the four VS Code IPC sockets from the Demmel writeup.
---

# Verify sandbox

Run the full sandbox verification described in `README-CLAUDE.md` and
report a PASS/FAIL table. The threat model these checks defend against
is documented in:

- `README-CLAUDE.md` (this repo) — sections **What's locked down** and
**Verifying the sandbox**.
- Daniel Demmel, *Coding agents in secured VS Code dev containers* —
<https://www.danieldemmel.me/blog/coding-agents-in-secured-vscode-dev-containers>
— describes the `vscode-ipc-*.sock`, `vscode-git-*.sock`,
`vscode-ssh-auth-*.sock`, and `vscode-remote-containers-ipc-*.sock`
bridges in `/tmp` that re-appear up to ~60s after window attach. Our
defence is the private mount namespace set up by `just claude`, not a
one-shot sweep.

## How to run

Execute every check below in a single Bash invocation where practical
(parallel them when independent). For each item, report PASS or FAIL
with a one-line reason. Do not skip a check because an earlier one
failed — collect everything, then summarise.

If any check FAILs, end the report with: "Sandbox is leaking — do not
trust `--dangerously-skip-permissions` until fixed. Open an issue
against `gilesknap/python-copier-template`."

## Checks

### 1. Namespace markers

- `IS_SANDBOX` env var must be `1` (set by `claude-sandbox.sh` after
`unshare -m`). If unset, Claude was not launched via `just claude`.
- `IN_DEVCONTAINER` env var must be set.

### 2. Host bridge env vars (must all be unset)

`SSH_AUTH_SOCK`, `GIT_ASKPASS`, `VSCODE_GIT_IPC_HANDLE`,
`VSCODE_GIT_ASKPASS_NODE`, `VSCODE_GIT_ASKPASS_MAIN`,
`VSCODE_IPC_HOOK_CLI`, `BROWSER`.

### 3. SSH agent unreachable

`ssh-add -l` must fail with "Could not open a connection to your
authentication agent." Anything that lists keys is a FAIL.

### 4. `/tmp` and `/run/user` are private tmpfs

- `mount | grep ' on /tmp '` must show a `tmpfs` entry (this confirms
the mount namespace is active for `/tmp`).
- `ls /tmp` must NOT contain any of the four Demmel sockets:
`vscode-ipc-*.sock`, `vscode-git-*.sock`, `vscode-ssh-auth-*.sock`,
`vscode-remote-containers-ipc-*.sock`. Glob each one explicitly.
- `ls /run/user/*/` must NOT contain `vscode-*` entries.

### 5. Host credential dirs masked

Each of these must be empty or absent:
`/root/.ssh`, `/root/.gnupg`, `/root/.aws`, `/root/.azure`,
`/root/.gcloud`, `/root/.docker`, `/root/.netrc`.

A non-empty `/root/.ssh` (containing `id_*` or `authorized_keys`) is a
critical FAIL — the host SSH keys are reachable.

### 6. Gitconfig bind-mount

- `mount | grep '/root/.gitconfig'` must show a bind mount (typically
`fuse-overlayfs` or `bind` from `/etc/claude-gitconfig`).
- `git config --global --list` must contain ONLY:
- `user.name` / `user.email` (host identity, copied through),
- `safe.directory=*`,
- `url.https://github.com/.insteadof=git@github.com:`,
- `url.https://gitlab.diamond.ac.uk/.insteadof=git@gitlab.diamond.ac.uk:`,
- `credential.https://github.com.helper=` then `!/usr/bin/gh auth git-credential`,
- `credential.https://gitlab.diamond.ac.uk.helper=` then `!/usr/local/bin/glab auth git-credential`.
- Any other `credential.*.helper` (especially one pointing at
`/tmp/vscode-remote-containers-*.js` or `/.vscode-server/...`) is a
FAIL.
- `/etc/gitconfig` must be masked (bind-mounted to `/dev/null` or
absent). `mount | grep '/etc/gitconfig'` should show a bind mount
whose source is `/dev/null` (appears as `devtmpfs` with `mode=755`,
inode for major 1 / minor 3), OR `ls /etc/gitconfig` returns
"No such file or directory". A regular file at `/etc/gitconfig` with
any contents is a FAIL — the host's system-scope gitconfig is
reachable and could carry `url.insteadof`, `http.proxy`,
`core.hooksPath`, or credential helpers that bypass /root/.gitconfig.
- System scope must be empty: `git config --system --list` must produce
no output (exit 0 with empty stdout, or exit non-zero). Any line is a
FAIL — broader than just `credential.helper`, since `core.hooksPath`
or `url.insteadof` at system scope are equally dangerous.

### 7. Credential source is gh, not a host bridge

`printf 'protocol=https\nhost=github.com\n\n' | git credential fill`
must return a `password=` line. The token prefix tells you the source:

- `gho_…` or `github_pat_…` from `gh auth git-credential` → PASS.
- Anything else (e.g. a token from a `vscode-git-*.sock` bridge) → FAIL.

Do NOT print the token. Redact with `sed 's/password=.*/password=<REDACTED>/'`.
Skip this check (mark N/A, not FAIL) if `just gh-auth` has not been run
for this repo — the README explicitly carves that out.

## Output format

Print a single table:

```
CHECK STATUS DETAIL
1. IS_SANDBOX=1 PASS/FAIL ...
2. Host bridge env vars unset PASS/FAIL ...
3. ssh-add -l fails PASS/FAIL ...
4a. /tmp is tmpfs PASS/FAIL ...
4b. No vscode-*.sock in /tmp PASS/FAIL ...
4c. No vscode-* in /run/user PASS/FAIL ...
5. Host credential dirs masked PASS/FAIL ...
6a. /root/.gitconfig bind-mounted PASS/FAIL ...
6b. Gitconfig contents are sandbox-only PASS/FAIL ...
6c. /etc/gitconfig masked PASS/FAIL ...
6d. System-scope gitconfig is empty PASS/FAIL ...
7. git credential fill source is gh PASS/FAIL/N/A ...
```

End with one line: `RESULT: SANDBOX OK` if every check is PASS or N/A,
otherwise `RESULT: SANDBOX LEAKING — see failures above` and the issue
pointer.
44 changes: 44 additions & 0 deletions .claude/hooks/sandbox-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/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."

# IS_SANDBOX=1 is set by the inner `just claude` script after it sets up
# the private mount namespace. If it's missing, Claude was launched
# without the namespace and /tmp/vscode-*.sock host bridges are reachable.
[ -n "${IS_SANDBOX:-}" ] || \
fail "IS_SANDBOX unset — Claude was not launched via \"just claude\", so the mount-namespace sandbox is not active."

# Host SSH agent must not be reachable. remoteEnv blanks SSH_AUTH_SOCK and
# `just claude` re-blanks it; if it is set, neither layer applied.
[ -z "${SSH_AUTH_SOCK:-}" ] || \
fail "SSH_AUTH_SOCK is set ($SSH_AUTH_SOCK) — host SSH agent is reachable. run \"just claude\" or rebuild the devcontainer."

# GIT_ASKPASS points at a script under /.vscode-server, which the
# namespace does NOT mask. If the env var is non-empty AND the file is
# reachable, claude-sandbox.sh's exec-line blank failed to apply.
[ ! -e "${GIT_ASKPASS:-}" ] || \
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\"."

# /root/.gitconfig must be the bind-mounted /etc/claude-gitconfig (gh/glab
# helpers only). VS Code reconnects can drop the bind and re-expose the
# host gitconfig, whose [credential] helper invokes a node script under
# /.vscode-server via /tmp/vscode-remote-containers-*.js — leaking the
# host's git credentials into the sandbox.
! grep -q -e 'vscode-remote-containers' -e '\.vscode-server' /root/.gitconfig 2>/dev/null || \
fail "/root/.gitconfig contains a VS Code credential bridge — the bind on /root/.gitconfig has been dropped (likely by a VS Code reconnect). Exit Claude and re-run \"just claude\"."

# /etc/gitconfig must be masked (bind-mounted to /dev/null by claude-sandbox.sh).
# If the host's system-scope gitconfig is reachable, it can carry url.insteadof,
# core.hooksPath, http.proxy, or credential helpers that bypass /root/.gitconfig.
# `git config --system --list` returning any content means the mask is gone.
[ -z "$(git config --system --list 2>/dev/null)" ] || \
fail "/etc/gitconfig is exposing system-scope settings — the bind-mount mask on /etc/gitconfig has been dropped. Exit Claude and re-run \"just claude\"."

exit 0
38 changes: 38 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
}
Loading
Loading