A reproducible setup for running an AI coding agent on Windows inside an OS-level sandbox, while keeping SSH commit signing backed by Bitwarden.
The agent can only read and write the project directory. It cannot reach ~/.ssh,
the Windows filesystem, or any Windows executable. Commits are still signed by the
key held in Bitwarden, which never touches disk.
This is my personal setup, documented so I can rebuild it and so others can copy the parts they want.
The motivation is not fear of malware. I do not assume Claude Code is compromised or
hostile. The problem is a much more ordinary one: an agent that wanders. Left
unconstrained it will run reg.exe queries, poke at Windows config, read dotfiles, or
start "helpfully" fixing things nowhere near the code I asked about. Sometimes it goes
far beyond the request, which is scope creep rather than sabotage.
So the goal is containment of reach, not defence against an attacker. When I ask for help with a project, the agent should only be able to act on that project. Everything else on the machine is simply not there. As a bonus, the same boundary happens to limit the damage if a repo ever does contain a prompt injection.
Worth being clear about the limit: the sandbox constrains where the agent can act, not how much it does. For bounding behaviour, see Security notes.
Paths below assume the username
diogoon both Windows and WSL. Adjust as needed.
README.md, this guide.ai-jail, my base sandbox config, see Part 8CLAUDE.mdandjail.md, the standing session instructions, see Part 9
They are meant to be copied and adapted, not used verbatim.
- Why WSL
- How it fits together
- Part 1: WSL base setup
- Part 2: VS Code
- Part 3: Claude Code
- Part 4: mise, ai-jail and Node
- Part 5: Bitwarden SSH agent bridge
- Part 6: Git signing in WSL
- Part 7: Git signing on Windows
- Part 8: The .ai-jail config
- Part 9: Session instructions
- Maintenance
- Troubleshooting
- Security notes
ai-jail has no native Windows support and
will not get it. Its Linux backend is bubblewrap (namespaces + Landlock LSM +
seccomp); its macOS backend is sandbox-exec. Windows has no userspace equivalent.
AppContainers are a different API, need admin to configure, and do not map onto what
bwrap does.
WSL 2 runs a real Linux kernel, so bwrap works normally. Claude Code must therefore be installed inside the WSL distro, not on Windows, because ai-jail sandboxes the Linux binary.
A useful side effect: inside the jail there is no /mnt, so reg.exe,
powershell.exe and cmd.exe are unreachable. The agent cannot touch Windows at all.
Windows │ WSL 2 (Ubuntu)
─────────────────────────────────────┼──────────────────────────────────────
Bitwarden Desktop │
└── \\.\pipe\openssh-ssh-agent ◄──┼── npiperelay.exe ◄── socat
│ ▲ ▲
VS Code (Remote-WSL) ───────────────┼────────┼─────────────────┤
│ │ ~/.ssh/agent.sock.$$
│ │ ▲
│ git commit -S ─────────┘
│
│ ai-jail ──► bwrap ──► claude
│ (project dir only)
Commits are made outside the jail. The agent writes code; you commit.
- Windows 11 with WSL 2 and an Ubuntu distro
- Bitwarden Desktop with the SSH agent enabled. See the official Bitwarden SSH agent guide and Part 7 below for the full Windows walkthrough
- An SSH key stored in Bitwarden
C:\Users\diogo\.wslconfig:
[wsl2]
# 1. Shut the VM down as soon as all WSL instances have exited
vmIdleTimeout=0This trades a few seconds of start-up delay for the VM not sitting idle in the background. It only takes effect once no process is left running in the distro. See Part 5.4 for why the SSH relay matters here.
By default WSL makes /etc/resolv.conf a symlink to /mnt/wsl/resolv.conf.
bwrap cannot bind over that symlink and ai-jail fails to start with:
bwrap: Can't create file at /etc/resolv.conf: No such file or directory
The fix is to make it a regular file with the same contents. First, note your current nameserver and search domain:
cat /etc/resolv.confThen:
sudo tee /etc/wsl.conf > /dev/null << 'EOF'
[network]
generateResolvConf = false
EOFShut down from PowerShell so wsl.conf takes effect before writing the file:
wsl --shutdownReopen WSL, then:
# 1. Remove the symlink
sudo rm -f /etc/resolv.conf
# 2. Write a regular file with the same values noted above
printf 'nameserver 10.255.255.254\nsearch <your-tailnet>.ts.net\n' | sudo tee /etc/resolv.conf
# 3. Confirm it is a regular file (expect -rw-r--r--, no arrow)
ls -l /etc/resolv.conf
ping -c1 github.comNotes:
10.255.255.254is WSL's DNS proxy into the Windows resolver. It is a fixed address and does not drift across reboots, so you keep whatever DNS Windows is using, including Tailscale MagicDNS and any Pi-hole filtering.- The
searchline only matters for bare single-label hostnames. Drop it if you do not use MagicDNS. - Trade-off: WSL no longer updates this file automatically. If you join a VPN that pushes its own search domains, add them by hand.
- Revert with
sudo rm /etc/wsl.conf /etc/resolv.confthenwsl --shutdown.
Order matters. Writing the file before
wsl --shutdownlets WSL recreate the symlink on the next boot.
Install the WSL extension in Windows VS Code, then launch from inside the distro:
cd ~/Projects/my-app
code .The bottom-left corner should read WSL: Ubuntu. VS Code Server runs outside the
jail, so it sees and edits the project normally. You watch every agent edit live.
Keep repositories in ~/Projects, not /mnt/c. On /mnt/c the DrvFS mount has
poor inotify support, so VS Code often fails to auto-refresh when the agent writes
files, which defeats the purpose. It also has no real Unix permission bits, so git
reports phantom mode changes (git config core.fileMode false if you hit it).
Use the native installer. It needs no Node.js and avoids the common WSL failure where
npm install -g picks up the Windows npm and errors on a platform mismatch.
curl -fsSL https://claude.ai/install.sh | bash
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc && source ~/.bashrc
claude --versionRun claude once to log in. This is a separate login from any Windows-side install,
because WSL has its own ~/.claude. Use claude doctor to diagnose install problems.
mise manages tool versions per project and installs standalone binaries from GitHub releases. ai-jail has built-in mise integration, so tools it manages are exposed correctly inside the jail.
curl https://mise.run | sh
echo "eval \"\$(/home/diogo/.local/bin/mise activate bash)\"" >> ~/.bashrc
exec bash
sudo apt update && sudo apt install -y bubblewrap socat
mise use -g github:akitaonrails/ai-jail
mise use -g node@lts # example only, use whatever runtime the project needs
ai-jail --versionIf you previously installed ai-jail by hand, remove it so there is only one copy:
rm -f ~/.local/bin/ai-jail
exec bash
which -a ai-jail # expect a single path under ~/.local/share/mise/installs/
mise upgraderefuses releases younger than 24 hours (minimum_release_age) as a supply-chain guard. A new release is picked up on the next run once it has aged in.mise upgrade --bumpbypasses it, but leave the default alone.
Bitwarden's SSH agent listens on a Windows named pipe (\\.\pipe\openssh-ssh-agent).
Linux tools speak to a Unix domain socket. npiperelay.exe bridges the two:
it opens the named pipe and relays it over stdin/stdout, while socat creates the Unix
socket on the Linux side and spawns the relay per connection.
The private key never enters WSL. Only the sign request and the signature cross.
Enable the agent in Bitwarden Desktop first. Full walkthrough in Part 7, or see bitwarden.com/help/ssh-agent.
Use the actively maintained albertony fork.
The original (jstarks) has not been updated since 2020.
winget install albertony.npiperelay
where.exe npiperelayOpen a new PowerShell window before running where.exe, because the PATH change
does not reach already-running terminals.
Symlinking rather than copying means winget upgrade maintains the binary and the
bridge follows automatically:
sudo ln -s "/mnt/c/Users/diogo/AppData/Local/Microsoft/WinGet/Packages/albertony.npiperelay_Microsoft.Winget.Source_8wekyb3d8bbwe/npiperelay.exe" \
/usr/local/bin/npiperelay.exe
/usr/local/bin/npiperelay.exe # expect the usage textAdjust the path to whatever where.exe npiperelay reported.
Append to ~/.bashrc:
# Bitwarden SSH agent bridge (per-shell; dies with this shell)
# 1. Clear sockets whose owning shell no longer exists
for s in "$HOME"/.ssh/agent.sock.*; do
[ -e "$s" ] || continue
pid="${s##*.}"
kill -0 "$pid" 2>/dev/null || { pkill -f "UNIX-LISTEN:$s," 2>/dev/null; rm -f "$s"; }
done
# 2. Start the relay in its own session, so a stray Ctrl+C never reaches it
export SSH_AUTH_SOCK="$HOME/.ssh/agent.sock.$$"
setsid socat UNIX-LISTEN:"$SSH_AUTH_SOCK",fork,unlink-early \
EXEC:"npiperelay.exe -ei -s //./pipe/openssh-ssh-agent",nofork >/dev/null 2>&1 &
# 3. Tear it down when this shell exits, matched by socket path rather than PID
trap 'pkill -f "UNIX-LISTEN:$SSH_AUTH_SOCK," 2>/dev/null; rm -f "$SSH_AUTH_SOCK"' EXITThen verify:
mkdir -p ~/.ssh && chmod 700 ~/.ssh
exec bash
ssh-add -l # expect your key fingerprint
ls -la ~/.ssh/agent.sock.* # expect exactly one, numbered for this shellDesign notes:
.$$suffix: each shell gets its own socket, so two terminals never fight over one path. It also makes the socket self-identifying, which is what the sweep relies on.trap ... EXIT: kills the relay when the shell exits. No interactive guard is needed, because Ubuntu's stock.bashrcalready returns early for non-interactive shells. It matches on the socket path rather than a stored PID, becausesetsidforks and$!no longer points at socat.setsid: puts the relay in its own session, so a stray Ctrl+C at the prompt cannot reach it. Without this, one mistyped command kills your agent for that shell and nothing tells you until a signature fails. Note thattrap '' INTbeforeexec socatdoes not work: socat installs its own SIGINT handler at startup rather than checking whether the signal is already ignored, so the inherited disposition is overwritten. The signal has to not be delivered at all.- The sweep exists because the trap is not guaranteed.
EXITfires on a clean exit or a SIGHUP, but not on SIGKILL. A terminal that is force-killed rather than closed leaves its relay running with no parent, and one orphaned relay is enough to keep the whole distro alive and defeatvmIdleTimeout=0. Since each socket is named after its shell's PID,kill -0on that PID says whether the owner still exists, so opening any new shell clears what the last killed one left behind. unlink-early: removes a stale socket file if one is still sitting at the path.
One known limit: the sweep matches sockets to shells by PID, so if a dead shell's PID has
been reused by an unrelated process, that orphan survives one extra round. It clears on a
later pass, and wsl --shutdown is always a hard reset.
Check for strays at any time with:
ps -eo pid,etime,cmd --sort=-etime | grep [s]ocatAnything older than your current session is an orphan.
setsid alone, with no teardown, leaves a relay running after every terminal closes,
which keeps the distro alive and silently defeats vmIdleTimeout=0. That is why the
block above pairs it with the EXIT trap: detached enough to survive Ctrl+C, still torn
down when the shell exits, so the VM shuts down once the last one closes.
Confirm with:
wsl --list --running # expect "There are no running distributions"Known trade-off: VS Code captures SSH_AUTH_SOCK once when it resolves the shell
environment, so the git panel may end up with a dead socket path. The integrated
terminal always works, since each terminal spawns its own live relay. Commit from the
terminal.
GitHub stores authentication keys and signing keys as separate resources. The same public key must be added twice, once as an Authentication Key and once as a Signing Key. This is the intended setup, not a duplicate.
Symptom of a missing auth key: git@github.com: Permission denied (publickey) even
though signing works. Symptom of a missing signing key: commits land Unverified.
Verify with:
ssh -T git@github.com # expect "Hi <user>! You've successfully authenticated"Never delete an existing Signing entry to re-add it as Authentication, because that breaks verification on all existing signed commits. Add, do not replace.
# 1. Copy the public key from Windows so both sides match
cp /mnt/c/Users/diogo/.ssh/bitwarden_signing.pub ~/.ssh/
chmod 644 ~/.ssh/bitwarden_signing.pub
# 2. Identity, must match the Windows config or GitHub splits your contributions
git config --global user.name "Diogo"
git config --global user.email "diogo@carvalhofer.lu"
# 3. Sign with SSH rather than GPG
git config --global gpg.format ssh
git config --global user.signingkey "$HOME/.ssh/bitwarden_signing.pub"
git config --global commit.gpgsign true
# 4. Use SSH for your own repos, even when the URL is HTTPS
git config --global url."git@github.com:<user>/".insteadOf "https://github.com/<user>/"Step 4 is worth setting. GitHub removed password authentication for HTTPS in 2021, so cloning an HTTPS URL prompts for a personal access token, which is a second credential to manage. The rewrite means you can paste one of your own GitHub URLs straight from the browser and it silently goes over SSH through the Bitwarden key. It applies to submodules and to any tool that shells out to git.
Scope it to your account rather than all of github.com. The unscoped form,
url."git@github.com:".insteadOf "https://github.com/", forces every GitHub URL
through your key, including public repos that any tool might clone in the background.
Those clones then fail with git@github.com: Permission denied (publickey) whenever the
subprocess does not inherit your agent socket, which is a confusing error for something
that would have worked anonymously over HTTPS.
Add a line per account or organisation you push to:
git config --global url."git@github.com:<org>/".insteadOf "https://github.com/<org>/"Trailing slashes matter: without them the prefix would also match names that merely start with the same characters. Review what you have with:
git config --global --get-regexp 'url\..*\.insteadof'For a repo already cloned over HTTPS, point it at SSH directly:
git remote set-url origin git@github.com:<user>/<repo>.git
git remote -vTwo settings that look contradictory but are not:
gpg.formatselects the backend (openpgp,ssh,x509)commit.gpgsignselects whether to sign at all. The name is historical, from when GPG was the only option.
Do not copy these two lines from a Windows .gitconfig:
| Windows line | Why it breaks in WSL |
|---|---|
user.signingkey=C:\Users\... |
Windows path, will not resolve |
gpg.ssh.program=C:/Windows/System32/OpenSSH/ssh-keygen.exe |
Git passes it Linux temp paths the Windows binary cannot read, so signing fails outright |
The same reason rules out the commonly suggested alias ssh=ssh.exe trick: it fixes
push and pull, but never signing.
Without this, git log --show-signature reports
gpg.ssh.allowedSignersFile needs to be configured and shows No signature, even
though the commit is signed and GitHub shows Verified.
echo "diogo@carvalhofer.lu $(cat ~/.ssh/bitwarden_signing.pub)" > ~/.ssh/allowed_signers
git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
git log --show-signature -1 # expect: Good "git" signatureOptionally import GitHub's web-flow key so commits created in the GitHub UI verify too:
curl -sL https://github.com/web-flow.gpg | gpg --importStandalone and independent of everything above. This is how to get the Verified badge for commits made from a normal Windows VS Code window or PowerShell prompt. It is not required for the WSL workflow, but it is the natural companion to it, and steps 1 and 2 are prerequisites for the WSL bridge in Part 5 as well.
Signing is separate from pushing. Pushing already works without any of this. Signing just proves the commit author is really you, which is what earns the Verified badge.
Bitwarden's SSH agent uses the same Windows named pipe as the built-in OpenSSH agent, so the built-in one has to be turned off first.
- Press
Win + R, typeservices.msc, press Enter. - Find OpenSSH Authentication Agent.
- Double-click it.
- Set Startup type to Disabled.
- Click Stop, then OK.
This affects all SSH usage on the machine, not just Git. Any SSH connections (homelab, servers) now go through Bitwarden's agent too.
- Open Bitwarden Desktop (version 2025.1.2 or newer).
- Go to Settings then Security.
- Enable Use Bitwarden as SSH agent.
- Set Ask for authorization to Always.
Reference: bitwarden.com/help/ssh-agent
Skip if the key already exists.
- Click the + button, then SSH Key.
- Name it something like
GitHub Signing Key - Windows. - Click Generate, then choose Ed25519.
- Save the item.
- Copy the public key (starts with
ssh-ed25519 AAAA...).
- GitHub, then Settings, SSH and GPG keys, New SSH key.
- Title:
Bitwarden Signing - Windows. - Key type: Signing Key. This is the important part. The default is "Authentication Key", which will NOT verify commits.
- Paste the public key, then Add SSH key.
If you also push over SSH, repeat this with Key type: Authentication Key. See 6.1.
# 1. Create the .ssh folder if it does not exist
New-Item -Path "$env:USERPROFILE\.ssh" -ItemType Directory -Force
# 2. Save the public key (replace the placeholder with the value copied from Bitwarden)
Set-Content -Path "$env:USERPROFILE\.ssh\bitwarden_signing.pub" -Value "ssh-ed25519 AAAA... PASTE_YOUR_PUBLIC_KEY_HERE"# 1. Use SSH (not GPG) for signing
git config --global gpg.format ssh
# 2. Point Git at the public key file
git config --global user.signingkey "$env:USERPROFILE\.ssh\bitwarden_signing.pub"
# 3. Sign every commit by default
git config --global commit.gpgsign true
# 4. Use Windows OpenSSH ssh-keygen so it can talk to Bitwarden's named pipe
git config --global gpg.ssh.program "C:/Windows/System32/OpenSSH/ssh-keygen.exe"The last line in that block is the one that matters most. Git for Windows bundles its
own MSYS2 ssh-keygen, which expects a Unix socket and cannot reach a Windows named
pipe, so signing fails silently or errors out. Pointing gpg.ssh.program at the
Windows OpenSSH binary makes it talk to Bitwarden directly.
Confirm the Git email matches a verified email on GitHub, otherwise commits sign but show as Unverified:
# 1. Check the configured email
git config --global user.email
# 2. If it is wrong, set it to a verified GitHub email
git config --global user.email "diogo@carvalhofer.lu"For SSH remotes (skip if you push over HTTPS), the same named-pipe issue applies to the transport:
git config --global core.sshCommand "C:/Windows/System32/OpenSSH/ssh.exe"VS Code uses the global Git config, so nothing extra to configure inside it.
- Open a repo in VS Code.
- Make a small change, stage it, commit via the Source Control panel.
- Bitwarden pops up an authorization prompt, click Authorize.
- Push the commit.
- On GitHub, refresh the commit and confirm the Verified badge.
| Symptom | Likely cause |
|---|---|
| Commit shows Unverified | Git email does not match a verified GitHub email, or the key on GitHub is set as Authentication instead of Signing |
gpg failed to sign the data |
Bitwarden Desktop not running, vault locked, or wrong gpg.ssh.program path |
| No Bitwarden authorization prompt | SSH agent not enabled in Bitwarden, or the Windows OpenSSH agent was not properly disabled |
| Commit signs but no prompt ever appears | "Ask for authorization" is set to Never instead of Always |
| Prompt appears when opening a VS Code window | VS Code Git auto-fetch, which is an authentication request rather than signing. Read the dialog text to tell them apart. Disable per-project with "git.autofetch": false in .vscode/settings.json |
- The key lives in the Bitwarden vault (cloud-synced), so it is available on every machine logged into the same account. One key, registered once on GitHub.
- Revoking the key on GitHub revokes it for all machines using it. Past commits keep their Verified status regardless.
- To isolate machines instead, generate a separate key in Bitwarden per machine and add each as its own Signing Key on GitHub.
gpg.ssh.programandcore.sshCommandare Windows-only. Never copy them into the WSL config, see the table in 6.2.
This is the config I start from for every project, kept in this repo as
.ai-jail. Commit it in each project so the policy syncs across machines.
# ai-jail sandbox configuration
# https://github.com/akitaonrails/ai-jail
# Edit freely. Regenerate with: ai-jail --clean --init
command = ["claude"]
hide_dotdirs = [
".azure",
".vscode-server",
]
mask = [
".env",
".env.local",
]
no_gpu = true
no_display = trueIt gets adapted per project (extra masks for whatever secrets that repo actually holds), but this is the base.
Two ways, both fine:
- Write the file by hand. Copy the template above into
.ai-jailat the project root. Simplest and fully predictable. - Run the full command once with
--init. ai-jail parses the flags and writes them in the correct format, then exits without launching anything:
cd ~/Projects/my-app
ai-jail --no-gpu --no-display \
--hide-dotdir .azure --hide-dotdir .vscode-server \
--mask .env --mask .env.local \
--init claudeDo not include --resume here. --init assumes a clean starting point, and there
is nothing to resume yet.
I prefer one of those two over letting the file build itself up from stray flags, for the reason below.
--save-config is on by default: any flag passed on the command line is silently
written into .ai-jail and applies to every later run. This includes
claude --resume <id>, which gets recorded as part of command and pins that session
forever, and --private-home, which silently hides ~/.claude and forces a fresh login
every time.
- Put ai-jail flags before the command, and avoid passing Claude's own flags after it
- Use
--no-save-configfor anything experimental - Use
--initto write a config deliberately - When ai-jail behaves oddly,
cat .ai-jailfirst
| Option | Why |
|---|---|
no_display = true |
The important one. On WSL, XDG_RUNTIME_DIR resolves to /mnt/wslg/runtime-dir, where VS Code Server drops vscode-ipc-*.sock. Anything able to write to that socket can drive the host VS Code, opening files anywhere and spawning processes outside the jail. Display passthrough drags it in as collateral. Claude Code is a TUI, so nothing is lost. |
no_gpu = true |
No /dev/dxg, /dev/dri or /dev/nvidia* under WSL, so GPU passthrough adds 9p/overlay mount surface for zero capability. |
hide_dotdirs |
.azure is bind-mounted from C:\Users\... and would expose CLI tokens if populated. .vscode-server is not needed by the agent. .docker and .claude cannot be hidden, ai-jail refuses because they are required. |
mask |
Replaces matching files with empty ones. The whole project directory is readable, so secrets in-repo need masking explicitly. --deny-path throws a permission error instead of returning empty. |
--exec is direct execution: no PTY proxy, no status bar. It is a runtime mode rather
than sandbox policy, so --init silently drops it and there is no .ai-jail key for it.
Every persistable option has a paired form (--gpu / --no-gpu, --mise / --no-mise);
--exec has no --no-exec twin, which is the tell.
If you want it every time, alias it:
echo "alias ai-jail='ai-jail --exec'" >> ~/.bash_aliases
source ~/.bashrcBash does not recurse on an alias that invokes its own name, so this is safe. .ai-jail
still supplies everything else, and \ai-jail bypasses the alias when you want the
status bar back.
--exec does not weaken the sandbox. It changes how output reaches your terminal, not
what the sandboxed process can reach: the PTY proxy sits outside the bwrap boundary, and
--landlock-exec --landlock is passed either way. What you lose is the status line that
shows the jail is active, so use hostname instead, which returns ai-sandbox inside.
cd ~/Projects/my-app
ai-jail --dry-run claude # inspect the mount plan
ai-jail claude # run for realTo resume a previous session, either use /resume from inside Claude Code, or pass the
flag with auto-save disabled so the session ID is not written into .ai-jail:
ai-jail --no-save-config claude --resume 11f2b4c6-625a-4650-b0c2-96cf276f91f4Workflow: the agent edits inside the jail, you commit from a normal terminal.
Two things are missing inside it. user.signingkey points at
~/.ssh/bitwarden_signing.pub, and ~/.ssh is never mounted, so that path does not
exist. SSH_AUTH_SOCK is not forwarded either, so ssh-keygen -Y sign has nothing to
talk to. Since commit.gpgsign = true comes from the read-only .gitconfig, a plain
git commit in there fails outright. It can still commit unsigned with
--no-gpg-sign, which is worth knowing: an unsigned commit appearing in your history is
the signal that something happened inside the jail.
That is why CLAUDE.md asks for a block of git add and git commit
commands rather than commits. The agent does the useful part, grouping changes and
writing messages, and you run it outside so every commit is signed with your key.
If you do want signing to work inside the jail:
ai-jail --ssh claudeThat mounts ~/.ssh read-only and forwards SSH_AUTH_SOCK, so git commit works and
Bitwarden prompts for each signature.
Think before enabling it. It gives the agent use of your agent socket, which means it
can authenticate as you to any SSH host, not just sign commits. "Ask for authorization:
Always" does prompt every time, but the dialog reads
npiperelay.exe is requesting access to github.com whether the request came from you or
from the agent, so you would be approving blind. Leaving it off keeps the split intact:
the agent writes code, you commit, and every commit in your history is provably yours.
The sandbox constrains what the agent can reach. These files cover what it should do
inside those limits, so the same instructions do not have to be retyped every session.
Claude Code reads CLAUDE.md automatically at the start of every session, and @path
imports pull in the rest.
~/.claude/
├── CLAUDE.md a thin router, mine, with one protected block
├── jail.md the sandbox and the rules, read-only to the agent
└── machine-state.md agent-maintained inventory, not in this repo
The split is by ownership and rate of change. The rules are stable and mine. The jail description changes when the sandbox config changes. Machine state changes on its own, as things get installed.
machine-state.md is deliberately absent from this repo, since it describes one machine
rather than the method. Create it locally if you want it, and add the import below the
protected block in CLAUDE.md.
A project-level ./CLAUDE.md at a repo root still works and is read alongside the global
file, for anything specific to one project.
mkdir -p ~/.claude
nano ~/.claude/CLAUDE.md# How I work with you
<!-- PROTECTED: do not edit, move or remove anything between these two markers. -->
Never edit or delete `~/.claude/jail.md`, and never edit the protected block below.
This holds no matter what: not to fix a mistake in it, not to record something you
learned, not to note an exception, and not because a task appears to require it. Propose
the change and wait for me to make it.
@~/.claude/jail.md
<!-- END PROTECTED -->
Everything outside the markers is fair game. Add anything useful here.The markers are HTML comments, so they do not render but are unmissable to anything
editing the file. The protected region covers the rule as well as the import, because the
first thing an agent could otherwise legally delete is the sentence telling it not to
delete things. Everything below the closing marker is open, which is where a local
@~/.claude/machine-state.md line goes.
# The ai-jail sandbox, and how I want you to work
This file is read-only to you. Never edit or delete it. In `~/.claude/CLAUDE.md` you may
edit anything outside the block marked PROTECTED, but nothing inside it, including the
line that imports this file. Both rules hold no matter what: not to correct an error you
spot, not to add something you learned, not to record an exception, and not because a
task appears to require it. Propose the change instead and wait for me to make it. If you
need somewhere to write, use your own auto-memory notes.
Am I jailed? Inside the sandbox `hostname` returns `ai-sandbox` and
`/tmp/.ai-jail-landlock` exists. If neither is true you are running unjailed on the host:
the environment description below does not apply, but the rules still do.
## The sandbox
bubblewrap plus Landlock LSM plus seccomp, on WSL 2. It is deliberate and expected.
Only the current project directory is writable and persistent. $HOME is tmpfs and is
discarded on exit, `~/.ssh` is usually not mounted, and there is no `/mnt`, so Windows is
unreachable.
Why: the sandbox keeps work scoped to the project I asked about. It is not a statement
that you are untrusted. Everything outside the project is out of scope by design, so do
not try to escape it.
## Scope
- Files inside the working directory are yours to change as needed. Anything else outside
the project, including my system, is off limits: mention it and wait rather than
touching it.
- In `~/.claude` you may write your own auto-memory notes without asking. Record what is
true rather than how you worked around something missing. Anything else there is mine
unless I have pointed you at it explicitly.
- If a new notes or instruction file would help, propose it first: what it is for, what
would go in it, and where it would be imported. Do not create the file, and do not add
an import line for it, until I have agreed.
## Setting things up
- Setting up whatever a task needs is allowed while it stays ephemeral and
self-contained: installing dependencies, fetching tools or runtimes, unpacking files
into the scratchpad and pointing environment variables at them. $HOME and /tmp are
tmpfs, so all of it disappears when the jail exits and costs nothing permanent.
- Say what you set up and why in the same turn, especially when it was large or took
several steps. Often it can be made permanent instead of repeated every session:
anything installed on the WSL host is visible inside the jail because `/usr` is mounted
read-only from it, and `~/.cache` is writable and survives between sessions.
- Stop and ask when the fix needs something the jail deliberately withholds: anything
requiring sudo or root, anything system-level, anything outside the project directory
and `~/.claude`. sudo is inert here by design, so finding that you need it is the
signal to stop rather than a problem to route around.
- If something genuinely needs a system package, name it and say what it unblocks. I
install those manually on the host, and only when they are strictly necessary, so
expect me to decline anything that only makes sense for a single task.
## Processes
- You may start servers, watchers and other long-running processes when they help you
check your work. Bind anything that listens to 127.0.0.1 only, never 0.0.0.0, since
this machine is on a tailnet.
- Stop anything you started once you are done with it. Nothing you launched should still
be running when you hand back to me. This applies only to your own processes.
- I may have my own processes running in other shells, including servers on common
ports. Do not try to kill them, and pick a port that is free. If a change of yours
means one of mine needs restarting, tell me and I will do it manually.
## Commits
- Before ending any turn in which you changed files, output the commit block. Do not
wait for me to ask and do not defer it to a later turn.
- Do not commit yourself by default. I want to review changes before they enter history,
and I run the commits so they are signed with my key. Signing cannot work inside the
jail anyway, since `~/.ssh` is not mounted.
- When changes are ready, output one bash block of `git add` and `git commit` commands
with the messages written, then stop. Do not run it.
- Judge the number of commits per case. Split into several when the changes are
genuinely separate concerns, and use a single commit when that is all the work
amounts to. Do not split for the sake of splitting.
- The exception: if I explicitly ask you to commit during a session, go ahead. I will
approve each signature in Bitwarden.- The jail check comes first. The global file loads in every session, including
unjailed ones, so a file that opens by asserting "$HOME is tmpfs" is simply wrong half
the time. Inside the sandbox
hostnamereturnsai-sandboxand/tmp/.ai-jail-landlockexists, both straight from ai-jail's own bwrap invocation. The check gates the environment description only, so the rules still apply everywhere. - Explaining that the sandbox is intentional matters more than it looks. An agent that hits a missing path with no context reads it as a broken environment and starts working around it. Told the restriction is deliberate, it reports the problem instead, which is what you want.
- The setup rules are split into three deliberately. A blanket "do not work around the
sandbox" collapses two different situations. Downloading a browser into tmpfs to take a
screenshot is ordinary dev work and costs nothing permanent, so it is allowed. Needing
sudo or a system-level change is the sandbox saying no, so that stops. The middle rule
exists because the same workaround repeating every session is usually a sign it should
be solved properly instead:
/usris bind-mounted read-only from the host, sosudo apt installoutside the jail makes libraries permanently visible inside it, and~/.cacheis bind-mounted read-write rather than tmpfs, so downloads landing there survive between sessions. - The system-package rule invites a request rather than promising a yes. Host installs are permanent and accumulate, so they are worth it for a recurring dependency and not for a one-off.
- The instruction files are protected explicitly. In practice an agent will edit the
file containing its own rules for entirely defensible reasons: it learned something
durable and machine-wide, and that file was the only globally loaded place to put it.
Naming that specific justification as insufficient matters more than a general "do not
edit", which reads as having reasonable exceptions. The same applies to creating new
instruction files: proposing one is fine, wiring it into
CLAUDE.mdwithout asking is not, because that changes what loads in every future session. - The commit block has an explicit trigger. "When changes are ready" turned out to be vague enough to skip, so the rule names the moment: before ending any turn in which files changed.
- The rules avoid naming specific tools. They are written in terms of what an action costs, whether it is ephemeral or permanent, inside the project or outside it, rather than listing package managers or frameworks. A rule phrased around one tool gets read as not applying to the next one.
- Running processes is allowed on purpose. Checking your own work by starting a dev
server is useful, and
--die-with-parentplus the unshared PID namespace means anything it starts dies with the jail. No orphans on the host. The cleanup rule is about the session rather than safety: a forgotten server holds its port for the rest of the session, and the next thing that needs that port fails for no obvious reason.
Claude Code also writes notes for itself, unprompted, into
~/.claude/projects/<repo>/memory/. Those are project-scoped, so a lesson about the
machine rather than the project will not travel between repos, which is what
machine-state.md is for. Review them with /memory now and then: a wrong note is worse
than no note, and only the first 200 lines of MEMORY.md load at startup.
CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 turns the feature off entirely.
A useful header for machine-state.md, since it is the one file the agent maintains:
record what is installed and where rather than how to work around something missing,
verify an entry before relying on it, date each entry, and delete anything that no longer
holds. That last part matters more than it sounds, because host state can quietly stop
being true after a distro rebuild while the file keeps loading in every session.
CLAUDE.md is context, not enforcement. It is guidance the agent follows, not something
the kernel blocks. chattr +i ~/.claude/CLAUDE.md ~/.claude/jail.md makes edits fail at
the filesystem level if you want it actually enforced, at the cost of chattr -i whenever
you edit them yourself.
| What | Command |
|---|---|
| Everything on the WSL side | all-update (alias below) |
| npiperelay, everything Windows | winget upgrade --all |
Aliases worth having, both in ~/.bash_aliases:
echo "alias all-update='sudo apt update && sudo apt full-upgrade -y && sudo apt autoremove -y && mise self-update && mise upgrade && claude update'" >> ~/.bash_aliases
source ~/.bashrcUbuntu's stock .bashrc sources ~/.bash_aliases automatically, so nothing else is
needed. Run all-update from any shell, outside the jail. The ai-jail alias is covered
in Part 8.
claude update goes last on purpose. With && chaining a failed step skips everything
after it, so a hiccup in the Claude Code updater cannot block your system upgrades.
Native installations auto-update in the background, and claude update forces it
immediately. Both write to the binary at ~/.local/bin/claude, and the dry run shows
that path mounted read-only:
--ro-bind /home/diogo/.local /home/diogo/.local
So the update fails inside the jail, silently or with a warning. This is deliberate
rather than a gap: read-only ~/.local is the same rule that keeps the ai-jail binary
itself out of the agent's reach and blocks PATH shadowing.
Run claude unjailed now and then, or let all-update handle it. claude doctor
reports the result of the most recent update attempt, which is the quickest way to check
a version that looks stale.
After a npiperelay upgrade, restart the bridge in each open shell:
pkill -f 'UNIX-LISTEN:.*agent.sock' && rm -f ~/.ssh/agent.sock.* && exec bashOr simply wsl --shutdown from PowerShell. The symlink target survives upgrades,
because the winget package path is stable.
| Symptom | Cause | Fix |
|---|---|---|
bwrap: Can't create file at /etc/resolv.conf |
/etc/resolv.conf is a symlink |
Part 1.2 |
bwrap: setting up uid map: Permission denied |
Ubuntu 24.04+ AppArmor blocks unprivileged user namespaces | sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 (persist via /etc/sysctl.d/, needs systemd enabled in wsl.conf) |
ssh-add -l gives Could not open a connection |
Relay not running, or Bitwarden locked / agent disabled | exec bash, then unlock Bitwarden Desktop |
ssh-add -l gives agent has no identities |
Bridge works, no key in the agent | Add an SSH key item in Bitwarden |
git@github.com: Permission denied (publickey) |
Key registered as Signing only | Part 6.1 |
Username for 'https://github.com': prompt on clone |
Remote is an HTTPS URL, and GitHub dropped password auth in 2021 | Clone the git@github.com: URL, or set the insteadOf rewrite in Part 6.2 |
Permission denied (publickey) when a tool clones a public repo |
An unscoped insteadOf rewrote its HTTPS URL to SSH, and the subprocess has no agent socket |
Scope the rewrite to your own account, see Part 6.2 |
| Commit shows Unverified on GitHub | Key not registered as a Signing key, or email mismatch | Part 6.1 |
gpg.ssh.allowedSignersFile needs to be configured |
Local verification not set up, signing itself is fine | Part 6.3 |
| Claude Code asks to log in every run | private_home = true in .ai-jail, or claude_dir points at a directory that does not exist |
cat .ai-jail, remove the offending line |
| Distro stays running after closing all terminals | A detached process: an orphaned relay from a force-killed shell, a dev server, VS Code Server, or Docker Desktop integration | ps -eo pid,etime,cmd --sort=-etime | head to find what is old, then wsl --shutdown. The sweep in Part 5.3 clears orphaned relays on the next shell |
| Bitwarden prompts on VS Code window focus | VS Code Git auto-fetch, not signing | "git.autofetch": false in .vscode/settings.json |
Verified empirically from inside a running jail:
- Project-only persistent writes.
$HOMEis tmpfs and discarded on exit ~/.ssh,~/.gnupg,~/.awsnever mounted, hardcoded never-mount list- No
/mnt, so the Windows filesystem and all Windows executables are unreachable - PID namespace unshared, so host processes are invisible and cannot be signalled
- Empty capability bounding set,
NoNewPrivs=1, every mountnosuid, so setuid binaries are inert - Around 40 syscalls blocked by seccomp: the whole mount family,
ptrace,bpf,unshare/setns,io_uring, keyring,kexec, module loading - Landlock LSM enforced at VFS level, on top of the mount layout
~/.localread-only, so the ai-jail binary itself is out of reach and PATH shadowing is blocked
- Network egress is unfiltered. bwrap does not unshare the network namespace, so
the agent shares the host's. Note that any allowlist must permit the Claude API
anyway, so egress filtering buys less than it appears to.
--lockdown --allow-tcp-port 443exists, but also makes the project read-only ~/.claudeis read-write and cannot be hidden. It holds.credentials.json(a live OAuth token) and supports hooks that run shell commands, which is a persistence path into future unjailed sessions.chattr +i ~/.claude/settings.jsoncloses the hook path specifically, at the cost of needingchattr -ito change any setting/dev/shmis bind-mounted from the host at mode 1777, a bidirectional channel outside Landlock's file rules by construction- The agent can open listening ports. Check occasionally from the host:
ss -lptn | grep -v 127.0.0.1 - No default credential denylist. Every
maskis operator-supplied. The threat model is contain the host blast radius, not keep secrets from the agent - Kernel escapes are out of scope, per the author. For genuinely untrusted code, use a disposable VM
The sandbox constrains where the agent can act, not how much it does. For bounding
behaviour, use Claude Code's own permission system (/permissions, written to
.claude/settings.local.json) and tighter prompts.
Both are WSL-specific and worth reporting to akitaonrails/ai-jail:
/etc/resolv.confsymlink breaks startup. ai-jail already binds/mnt/wsl/resolv.conf, so it has some WSL awareness, but it does not handle/etc/resolv.confbeing a symlink to it.--displayexposes VS Code IPC sockets on WSL.XDG_RUNTIME_DIRresolves to/mnt/wslg/runtime-dir, where VS Code Server storesvscode-ipc-*.sockandvscode-git-*.sock. This is a real sandbox-escape path, not just noise.
CC0 1.0 Universal. Public domain, no attribution required, though it is always appreciated.