Debian ships venv's ensurepip in a separate python3-venv package. Without
it, `python3 -m venv` exits 1, so mason cannot build the venv it installs
basedpyright into, and mason-lspconfig reports a failed install on every
startup.
Add python3-venv to the apt package list, and guard the basedpyright entry
in ensure_installed the same way the npm-sourced servers are already
guarded, so a machine missing it gets one warning instead of a failed
install every time.
Pasting multi-line text into a tmux session reached over ssh from inside
another tmux replaced every newline with a literal CSI 27;5;106~, i.e.
C-j -- the pasted newline -- rendered in modifyOtherKeys form rather than
sent as a newline.
tmux 3.5 rewrote extended-key handling and encodes keys inside a
bracketed paste; the layer further in passes those bytes through verbatim
instead of decoding them, so the application receives the escape as text.
A hop only corrupts when whatever sits downstream of it has asked for
modifyOtherKeys mode 2. A nested tmux always asks, because of the
"*:extkeys" terminal-feature above it; an application running directly
under screen-256color generally does not. That is why one tmux in the
chain was fine and two were not.
Confirmed by building 3.2a, 3.3a, 3.4, 3.5a, 3.6b and 3.7c and driving a
real bracketed paste through a pty into every outer/inner combination:
only 3.5 and 3.5a corrupt, and only on the requesting hop. Fixed upstream
in 3.6, so upgrading is the real answer where it is available.
For hosts pinned to a distro tmux, disable extended keys on exactly those
two versions. Nothing narrower works: extended-keys-format csi-u only
changes which escape lands in the text, and withholding the extkeys
terminal-feature stops a tmux asking its own parent for extended keys
without stopping it encoding them for its pane. With the guard in place
all sixteen version combinations paste correctly, and extended keys stay
on everywhere except 3.5/3.5a.
The guard tests the version with an awk END block rather than a bare
exit, so that empty input -- a tmux -V that failed for any reason --
leaves extended keys on instead of silently disabling them.
Also corrects the comment that attributed this to a Claude Code paste
tokenizer bug. It reproduces just as readily pasting into neovim.
The auto-start left the Claude terminal only as a narrow right split. Follow it with a tab split so the same terminal buffer also gets a full-screen tab, while the original tab keeps the side-by-side layout with the editor.
Many concurrent sessions with many panes each make it hard to track what is
running where, and moving a pane between windows means join-pane with a
hand-typed target, so it rarely gets done.
tmux-fzf gives a fuzzy-searchable list of every session, window and pane with
a live preview of each, and implements break/join/swap as pane actions --
which is the pane-movement gap. fzf is already in apt.global, arch.global and
brew.global, so no new packages are needed.
Checked out as a submodule rather than left for TPM to clone on first
prefix+I: ~/.tmux symlinks to config/tmux, so a TPM-installed plugin lands
inside this repo as untracked files. The submodule pins a version and
installs via the existing "git submodule update --init". TPM still sources it
from the @plugin line and can still update it.
prefix+F is the plugin's own top-level menu. prefix+P/W/S skip straight to
the pane, window and session panels. TMUX_FZF_CLIENT pins each action to the
client that opened it, as the plugin's own binding does -- run-shell has no
client context, so tmux otherwise targets the most recently active client,
which is wrong when a second client is attached to the same session.
No env-var configuration was needed: preview is already on by default, and
the TMUX_FZF_* variables are read from the environment only, so the README's
suggestion to set them in .tmux.conf is not valid tmux syntax.
Tested against tmux 3.5a and fzf 0.38.0: config parses with no errors, all
three bindings register, existing bindings are unaffected, TPM sources the
plugin and binds prefix+F, the three scripts pass bash -n, and the plugin's
fzf-version detection resolves to fzf-tmux with preview enabled. The
interactive picker itself needs a real terminal and is left to manual check.
vim.g.clipboard pointed unconditionally at ~/dotfiles/bin/clipboard-copy,
which isn't there on every machine this config runs on now - claude-sandbox
mounts in only the nvim config directory itself, not the rest of dotfiles.
Without the guard, every yank errored inside the sandbox. Same
gracefully-degrade approach the colorscheme fallback already uses elsewhere
in this config.
Second-round review follow-up. Security notes was written as the
canonical list of residual risks but never mentioned the new
SANDBOX_SSH_KEY mechanism, which has the same "signs for any repo it can
reach" shape as the forwarded agent (already documented) - add a bullet
covering it and the /root-only mount.
Also nothing enforced that SANDBOX_SSH_KEY actually be a dedicated
low-privilege key rather than a real personal identity, which is the
whole basis for accepting it as passphrase-less and left loaded for an
entire unattended session. bin/claude-sandbox now warns (non-fatal) if
it looks like a default personal keyfile (id_rsa, id_ed25519, etc).
The headless-SSH-key feature claimed the plaintext key was "deleted
immediately after being loaded into the agent, so nothing readable is
left on disk" - but entrypoint.sh only deleted the copy it made; the
original bind-mounted source at $SANDBOX_HOME/.ssh-sandbox-key-src stayed
mounted, readable, for the container's whole life. Combined with the
sandbox user's uid now matching the host user's, that file was directly
readable by the very process the sandbox exists to constrain, letting a
compromised session exfiltrate the raw key instead of being limited to
signing through the local agent socket.
Move the mount to /root/.ssh-sandbox-key-src (root:root, 700 by default)
so only entrypoint.sh's root-run startup code can ever reach it; the
sandbox user has no path to that directory at all. Update the matching
static/runtime tests (the runtime one now asserts the sandbox user
actually gets permission denied reading the source) and correct the
README's description of the mechanism.
Installs playwright + a matching Chromium build at image build time
(system deps as root via install-deps, npm package + browser binary as
node so they land in node's own npm prefix/cache), so a session never
needs network access for either. NODE_PATH makes require("playwright")
work from any script, not just one with its own local node_modules -
global npm install alone only puts the CLI on PATH.
Testing against a project's own local dev server needs no firewall
changes: localhost traffic never goes through the egress firewall.
Verified the firewall does apply to the browser's own traffic for
anything else, the same as curl/git - a non-allowlisted domain gets
blocked from inside Playwright too, not silently bypassed, while an
allowlisted one still works.
Chromium's own internal sandbox is documented honestly rather than
assumed either way: it launches without --no-sandbox and without error,
but its setuid helper isn't actually setuid here, and this host has the
same unprivileged-userns restriction that blocks bubblewrap - so it's
likely not providing real isolation, just not hard-failing about it the
way bubblewrap does. Not treated as a security boundary regardless: this
container is already the boundary, and a compromised session already has
full node-level bash access that a Chromium exploit couldn't exceed.
Test suite grew to 29: Playwright is installed and require()-able, it
actually launches and renders (not just installs), and the firewall
applies to its traffic.
Git commits inside the sandbox need a user.name/user.email. Carries over
a throwaway copy of the host's ~/.gitconfig, generated by bin/claude-sandbox
and mounted as its own directory (not the real file, and not a single-file
bind mount at ~/.gitconfig) with GIT_CONFIG_GLOBAL pointing git at the file
inside it.
That took two iterations to get right, both caught by actually testing gh
auth setup-git (the thing that makes GitHub read-only from in here) rather
than just checking the identity resolved:
- Mounting the real file read-only broke gh auth setup-git outright, which
needs to *write* a credential-helper entry into the global gitconfig
("could not write config file: Device or resource busy").
- Mounting a read-write *copy* of the file directly at ~/.gitconfig hit the
exact same error - a single-file bind mount can't have its backing inode
replaced, and git config writes via the usual write-temp-then-rename
pattern. Only a directory mount, with git pointed at a file inside it,
actually supports that.
Verified end to end this time: gh auth setup-git completes cleanly, the
credential-helper entries land in the sandbox's copy, user.name/email
resolve correctly, and the real host ~/.gitconfig is untouched throughout.
Also has bin/claude-sandbox warn (non-fatally) at launch if ssh-agent has
zero identities loaded - the forwarded socket still connects fine either
way, but git-over-ssh silently fails until a key's been ssh-add'ed, which
otherwise only surfaces confusingly deep into a session as a failed push.
Confirmed the forwarding mechanism itself works correctly by testing with
a throwaway key - the reported failure was the host agent having no
identities loaded, not a bug in the forwarding.
Test suite grew to 26 with a check for the gitconfig mount shape.
Installs the latest stable Go release, fetched and verified the same way
as gosu: go.dev's own release JSON gives both the download URL and a
SHA256 per file, checked before extracting since this runs as root.
Also allowlists proxy.golang.org and sum.golang.org (Go's module proxy and
checksum database) - go build/go mod download need them reachable at
runtime, same as registry.npmjs.org/pypi.org for npm/pip.
Verified end to end: go version, and a real `go mod tidy` + `go run`
against an actual module (rsc.io/quote, pulling in transitive
dependencies) through the real firewall - not just that the binary
installs. All 25 existing tests still pass.
Claude Code's own per-command sandboxing (~/.claude/settings.json's
"sandbox" block) is redundant on top of this container - which is already
a real, working sandbox on its own - and it couldn't even function here
anyway (bwrap can't create a namespace on this host; even CAP_SYS_ADMIN
only got it to a mount-propagation error, not further). Rather than
leaving it half-broken, disable it properly: settings.json is mounted
read-only specifically so a session can't tamper with it, so it can't be
patched from inside - bin/claude-sandbox now generates a sandbox-specific
copy (real settings.json with sandbox.enabled forced to false) on the
host and mounts that instead. bubblewrap/socat (added for this last
commit, never actually working) are removed - nothing needs them now.
Also fixes a real bug found in the same session: sandbox-context.sh told
agents to worktree via `git worktree add ../<branch>`, a sibling of the
mounted project directory. That path isn't backed by real host storage -
only the exact bind-mounted directory is - so a worktree created there
(observed landing under /home/node/<branch> in practice) silently
disappears, along with whatever work went into it, when the container
exits. Now instructs `.worktrees/<branch>` under the project root instead,
plus a .git/info/exclude entry so it doesn't clutter the user's real
checkout's git status.
Test suite extended to 25: sandbox.enabled=false generation, no
bubblewrap/socat, and no worktree instruction pointing outside the mounted
project. All pass.
~/.claude/CLAUDE.md (already carried into the container) tells agents to
write specs to ~/specs/ instead of a project-local path - that instruction
was already in effect inside the sandbox, but nothing mounted the
directory it points at, so an agent following it would write into a path
that vanishes with the container. Bind-mounted read-write at the fixed
host path ~/specs (auto-created if missing), same pattern as the shared
scratch space but not configurable - it's tied to the CLAUDE.md
convention, not a per-run choice.
Also adds bubblewrap/socat, which Claude Code checks for before offering
its own internal per-command sandboxing and /remote-control. This clears
the "Sandbox disabled: dependencies are missing" warning, but doesn't
actually fix /remote-control: bwrap still can't create a namespace on this
host ("kernel does not allow non-privileged user namespaces"), and even
adding CAP_SYS_ADMIN to the container only got further to a mount-
propagation permission error, not further progress. Making it actually
work would need privilege escalation approaching full --privileged mode,
which would undo most of this session's hardening - not pursued. Kept the
packages anyway since they at least make the startup warning accurate
rather than noise, in case a future decision revisits this tradeoff.
All 22 existing tests still pass.
Addresses the "no regression protection" gap called out in Security notes:
everything there was hand-verified at a point in time, with nothing to
catch a future change silently breaking one of those properties again -
which had already happened once (the ipset -exist bug hit twice, on two
different ipsets, before being caught).
entrypoint.sh gets a SANDBOX_TEST_MODE hook: run the real DNS-proxy/
firewall/logging setup exactly as normal, then exec `sleep infinity` as
node instead of nvim (which needs a real tty). Lets the suite attach to a
live, fully-initialized container and assert against it, rather than
reimplementing the setup sequence in the test file where it could drift
out of sync with the real thing.
test/static.bats: fast, no Docker required - sudo/sudoers absence, every
ipset add is duplicate-safe, no docker.sock/host-credential mounts, the
~/.claude read-only overlay lines are present, claude-sandbox-allow's
input validation.
test/runtime.bats: builds the image, boots a container in test mode,
asserts IPv6 is blocked, DNS/IP allowlist enforcement in both directions,
GitHub read-only, no working sudo/iptables/init-firewall.sh as node,
capabilities are actually stripped (not just absent because docker exec
doesn't have them to begin with), ulogd2 runs unprivileged, and the
dynamic-ipset CDN-rotation fix actually re-syncs on a fresh DNS answer.
Container/network names are derived from bats' own BATS_FILE_TMPDIR rather
than $$ (caught via a real failure: bats reruns each file's top-level code
in a fresh subshell per test, so $$ differs across setup_file/tests/
teardown_file, silently pointing them at different, nonexistent
containers - several tests "passed" only because asserting failure against
a missing container also happens to return nonzero). BATS_FILE_TMPDIR is
guaranteed stable across the whole file's run, verified directly before
relying on it.
All 22 tests pass cleanly against the current sandbox.
Adds an explicit threat-model framing to Security notes: this reduces
blast radius for a compromised/prompt-injected session, it isn't a hard
adversarial boundary. Names the gaps that weren't written down anywhere -
the mounted project has no write protection (hooks are fast-fail regex
guards, not a security boundary), allowlisting a domain doesn't stop
exfiltration via that service's own request surface, this is container-
level isolation sharing the host kernel rather than VM-level, and none of
this is regression-tested going forward, only hand-verified at a point in
time.
Also fixes a real internal LAN IP (the Loki example) that had ended up in
this public repo as a "for instance" - swapped for a placeholder hostname.
cowsay/cowthink/fortune install to /usr/games on Debian, not on this image's
PATH by default - added the packages and extended PATH via ENV so nvim's
start-screen header (plugins/ui.lua) can actually find them as non-login
subprocesses. That surfaced a real, pre-existing bug in ui.lua itself
(unrelated to the sandbox): `cowsay -l` wraps cow names across multiple
space-separated lines after a header line, but the code treated each whole
line as a single cow name, so a wrapped line got passed to cowthink -f as
one bogus multi-word argument. Fixed to parse every name from every line.
Verified directly against the real cowsay/fortune installed in the image.
The sandbox now opens claudecode.nvim's terminal on startup (CLAUDE_SANDBOX-
gated, so this doesn't affect normal host nvim use) instead of requiring
<leader>ac - starting a Claude session is the point of being in there.
:ClaudeCode is only defined once the plugin loads (it's lazy-loaded on that
keymap, no `cmd` in its spec), so the autocmd force-loads it first.
Also adds an optional read-write scratch volume (~/shared in the container,
$SANDBOX_SHARE_DIR on the host, auto-created) separate from the project
mount, for passing files back and forth with a sandboxed session without
touching the actual project checkout. Mentioned in sandbox-context.sh so a
sandboxed agent knows it's there.
Same class of bug as 718f6b7, on the newer github-domains ipset that
didn't exist yet at the time of that fix: aggregate -q dedupes overlapping
ranges within each of GitHub meta's web/api/git lists individually, but not
necessarily across all three combined once merged, so ipset add without
-exist could hit a duplicate and error - which set -e turns into the whole
firewall, and thus the whole container, failing to start with no clear
error surfaced to the user. Reproduced against live GitHub meta data
(exactly what was seen), fixed with -exist, and reran the same firewall
init end to end to confirm it now completes.
init-firewall.sh's ipset population was a one-time snapshot at container
start: an already-allowed domain could still start failing to connect
later if its CDN edge rotated to an IP that was never added
(pypi.org/files.pythonhosted.org/GitHub's release-assets CDN all do this),
and allowing a domain like crates.io didn't help traffic that actually
goes to a different hostname under it (index.crates.io/static.crates.io).
dns-proxy.sh's dnsmasq now also uses --ipset= so the resolved IP gets added
on every fresh answer it serves for the life of the session, not just once
at boot - verified this actually repopulates after a real (non-cached)
re-resolution, and that dnsmasq tolerates the target ipset not existing yet
when its config is generated (it runs before init-firewall.sh creates it).
Also batches log-shipper.sh's Loki pushes (up to 20 lines or 2s, whichever
first) instead of one HTTP request per line - a burst of activity (e.g.
npm install resolving/connecting to many hosts quickly) would otherwise
spam Loki with one request per line. Verified via the sandbox's own
connection logging: 15 rapid lines produced 2 requests instead of 15.
DNS resolution was previously unrestricted - only the resulting IP
connections were firewalled - so a compromised session could still
exfiltrate data by encoding it into query names to an attacker-controlled
domain. dns-proxy.sh now runs a local dnsmasq with no fallback resolver;
only allowlisted names (shared with init-firewall.sh via fixed-domains.sh)
get forwarded, everything else is refused outright.
IPv6 is blocked outright via ip6tables DROP-all, since there was no IPv6
equivalent of the IPv4 allowlist.
If LOKI_URL is set, every DNS query and every new outbound connection
attempt (allowed or rejected, via NFLOG + ulogd2) ships to Loki as JSON,
for visibility in the existing Grafana/Prometheus setup.
Also fixes two bugs found along the way:
- init-firewall.sh's script-wide IFS (set for stricter splitting elsewhere)
silently broke EXTRA_ALLOWED_DOMAINS whenever it held more than one
space-separated entry, collapsing it into a single bogus token.
- bin/claude-sandbox-allow interpolated the domain argument directly into a
bash -c string executed via docker exec - a command injection path,
since sandbox-context.sh tells a sandboxed agent to hand this exact
command to the user to copy-paste. Fixed with upfront domain validation
and positional-argument passing instead of string interpolation.
Nvim isn't a single process attached to the terminal: the process that
actually spawns jobs (clipboard-copy included) is a separate `nvim --embed`
core process with no tty of its own (ps shows `?`); the real pty belongs to
a distinct parent UI process. Checking only the immediate parent's tty (as
the previous fix did) found the embed process's `?` and fell through to the
broken /dev/tty path every time in real interactive use, even though it
looked fine in headless testing. Walk up the process tree past any tty-less
ancestors instead.
Verified against the real docker-compose path in actual (non-headless) TUI
mode, with real keystrokes fed through a pty: the OSC 52 sequence now
reaches the terminal with no error, where it previously still failed.
- clipboard-copy: nvim's job-spawned clipboard provider process doesn't get
its own controlling terminal, so /dev/tty (ENXIO) fails. Resolve the real
tty from the parent (nvim)'s recorded tty via ps instead.
- entrypoint.sh: chown the allocated pty to node before dropping privileges.
A normal login/ssh session always does this; this entrypoint skipped it,
leaving the tty root-owned so node couldn't write to it directly even
after resolving the right path.
- init-firewall.sh/README: allow release-assets.githubusercontent.com.
GitHub release assets (e.g. mason-registry's own index) redirect here,
a separate CDN from github.com's published IP ranges - without it mason
can't even look up a package's install spec, regardless of PyPI access.
All three verified against a rebuilt image: OSC 52 write reaches the real
pty with no error, and mason's registry refresh succeeds through the
firewall.
pypi.org and files.pythonhosted.org share Fastly edge IPs, so the second
domain's ipset add hit a duplicate-element error. Without -exist, that
error plus set -e killed the whole firewall init (and thus the container)
before nvim ever started. Reported by the user as a silent hang after a
successful build.
- Bind-mount dotfiles at node's own $HOME/dotfiles too, not just the
host-path mount needed for the ~/.claude/hooks symlink: node's $HOME is
/home/node, which broke clipboard-copy and anything else in this repo
assuming ~/dotfiles.
- Forward the host's real TERM into the container instead of letting
Docker's tty allocation default to a generic xterm, which was breaking
the Ctrl-H/Backspace disambiguation the tmux config sets up.
- Add pypi.org/files.pythonhosted.org to the firewall's default allowlist
so mason's pip-installed Python tools (ruff, basedpyright, debugpy) can
actually install; verified with a real venv pip install.