Author SHA1 Message Date
branderson 621bc28f7a claude-sandbox: document SANDBOX_SSH_KEY in Security notes; warn on default-looking personal keys
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).
2026-07-22 13:03:30 -07:00
branderson 5fef8dcf90 claude-sandbox: mount the dedicated SSH key source under /root, not $HOME
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.
2026-07-22 12:36:40 -07:00
branderson 6b833bd222 claude-sandbox: support a dedicated in-container SSH agent for headless git push 2026-07-21 23:59:52 -07:00
branderson 075f4b2ba4 claude-sandbox: build the container image tagged and configured for the real host user, fixing plugin/skill resolution 2026-07-21 21:42:57 -07:00
branderson 46f57624e6 claude-sandbox: parametrize the container user, defaulting to today's node/1000/1000//home/node 2026-07-21 21:17:41 -07:00
branderson df3399b0d3 Add Playwright/Chromium for testing frontend changes
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.
2026-07-20 17:32:07 -07:00
branderson 7267e1ca77 Carry git identity into the sandbox; warn on an empty ssh-agent
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.
2026-07-20 01:03:20 -07:00
branderson 7249da8fc3 Add a Go toolchain to the sandbox
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.
2026-07-20 00:19:27 -07:00
branderson 0c3c3aacad Disable Claude Code's internal sandbox in claude-sandbox; fix worktree location
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.
2026-07-20 00:06:15 -07:00
branderson 0b7857eb3f Mount ~/specs read-write; add bubblewrap/socat (partial fix)
~/.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.
2026-07-19 23:45:01 -07:00
branderson 832aaef3d8 Add a bats test suite for the sandbox's security invariants
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.
2026-07-19 22:08:15 -07:00
branderson d7858d9579 Document sandbox threat model and tradeoffs honestly in README
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.
2026-07-19 20:46:52 -07:00
branderson 0f3e2c8362 Add cowsay/fortune, auto-open Claude Code, and a shared scratch volume to sandbox
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.
2026-07-19 19:11:12 -07:00
branderson a86fefc663 Fix firewall init crash on overlapping GitHub CIDR ranges
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.
2026-07-19 18:16:40 -07:00
branderson 5294e72a32 Keep sandbox firewall ipsets fresh and batch Loki log shipping
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.
2026-07-19 18:09:16 -07:00
branderson 07926638d4 Add ulogd user 2026-07-19 17:58:24 -07:00
branderson 04c1b1e1f4 Add DNS allowlisting, IPv6 block, and network logging to sandbox
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.
2026-07-19 17:16:55 -07:00
branderson b4d22b97c0 Add quick allowlist for domains in sandbox 2026-07-19 16:26:58 -07:00
branderson 4551dc5a59 Limit push privileges 2026-07-19 16:10:47 -07:00
branderson ca76b46750 Lock down .claude in sandbox 2026-07-19 13:45:35 -07:00
branderson 373655963a Merge branch 'master' of gitbox.bradina.localdomain:branderson/dotfiles 2026-07-19 13:07:03 -07:00
branderson fae504043e Merge branch 'master' of gitbox.bradina.localdomain:branderson/dotfiles 2026-07-19 13:03:55 -07:00
branderson bdb8c8b149 Fix profile scope issue 2026-07-19 13:03:32 -07:00
branderson d76d2f4b3a Fix clipboard OSC 52 fallback: walk past nvim's tty-less embed process
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.
2026-07-19 11:46:31 -07:00
branderson 08a2ae52f1 Fix clipboard OSC 52 write and mason registry access in the sandbox
- 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.
2026-07-19 04:08:14 -07:00
branderson 718f6b7600 Fix firewall init crash on overlapping IPs across allowlisted domains
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.
2026-07-19 03:31:50 -07:00
branderson 18e111b00c Fix sandbox dotfiles path, terminal type, and PyPI access
- 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.
2026-07-19 03:23:11 -07:00
branderson a2be0e5678 Sandbox hardening 2026-07-19 02:43:17 -07:00
branderson b5e18a1b29 Fix mount path 2026-07-19 01:50:38 -07:00
branderson e0ce0e79fc Fix allowlist of domains 2026-07-19 01:33:31 -07:00
branderson bcc0d4ab14 Fix permissions issue 2026-07-19 01:01:41 -07:00
branderson 37e77a4c30 Add claude code container sandbox 2026-07-19 00:30:05 -07:00
branderson 93b93b30b8 Fix Ctrl+J in claude code 2026-07-18 17:22:48 -07:00
branderson 49c638cda8 Re-enable extended keys 2026-07-18 17:15:46 -07:00
branderson efd1ddebd9 Fix corruption in copy-paste over nested tmux 2026-07-18 16:03:19 -07:00
branderson ea5a2b77d4 Fix some stale comments 2026-07-17 16:34:37 -07:00
branderson bd4d579a90 Fix remote tmux copy 2026-07-17 16:02:43 -07:00
branderson 0ba29f8d89 Fix clipboard copy across terminal emulator -> tmux -> SSH -> tmux -> nvim 2026-07-17 15:13:40 -07:00
branderson 25dcfc6802 Merge branch 'master' of gitbox.bradina.localdomain:branderson/dotfiles 2026-07-17 14:05:13 -07:00
branderson a56e04a0aa Make alacritty's highlight copy to system clipboard 2026-07-17 14:05:00 -07:00
asphodel 1ec5e0258d Add htop and jq to apt globals 2026-07-17 13:15:53 -07:00
branderson 1caa1be1d2 Support <Shift><Enter> through multiple nested tmux's and alacritty 2026-07-17 12:51:53 -07:00
branderson aa71eebf0b Fix nvim errors when editing html 2026-07-16 14:39:28 -07:00
branderson 9987dcacd8 Merge branch 'master' of gitbox.bradina.localdomain:branderson/dotfiles 2026-07-14 21:12:17 -07:00
branderson 58a3512c28 Disable claude-code systemd limits 2026-07-14 21:11:35 -07:00
branderson 7b61fde6e9 Merge branch 'master' of gitbox.bradina.localdomain:branderson/dotfiles 2026-07-14 19:45:30 -07:00
branderson db267226a8 Enable sixel for iTerm2 2026-07-14 18:39:48 -07:00
branderson 521c1b1c4d Merge branch 'master' of gitbox.bradina.localdomain:branderson/dotfiles 2026-07-14 18:08:06 -07:00
branderson a94e283e98 Add sandboxing tools to global install 2026-07-14 18:07:59 -07:00
branderson b26e564672 Use cargo build instead of prebuilt binary for tree-sitter-cli manual install 2026-07-14 14:23:06 -07:00
branderson 1deb25008f Make nvim installation helpers platform-aware and add local install helper for tree-sitter-cli 2026-07-14 14:11:42 -07:00
branderson 9ffe754410 Add claude gruvbox theme, fix nvim errors with custom npm 2026-07-14 13:48:36 -07:00
branderson d0a4cb0db7 Add authorship disclaimer to LLM generated PR summaries 2026-07-13 18:28:08 -07:00
branderson 46ca53e0c0 Prevent runaway claude-code 2026-07-13 18:24:22 -07:00
branderson ddd7f6bf2c Merge pull request 'gitea-pr: token-file auth + skip duplicate PR creation' (#2) from worktree-gitea-pr-review-workflow into master
Reviewed-on: #2
2026-07-12 22:15:01 -07:00
branderson 06e60bda8c Match existing-PR check on base branch too, not just head
The lookup was matching on head.ref alone, so running create with a
different --base for the same branch would silently return an
unrelated PR's URL instead of opening a new one. Resolve base before
the check and filter on both head.ref and base.ref.
2026-07-12 22:14:07 -07:00
branderson 567d0a1718 Skip creating a duplicate PR when one is already open for the branch
gitea-pr create now checks for an existing open PR on the current
branch first and returns its URL instead of creating a new one. Only
matches open PRs by design -- once merged, new commits on the same
branch correctly get a new PR.
2026-07-12 22:14:07 -07:00
branderson eec71b5f82 Add gitea-pr script and PR review workflow (#1)
Adds bin/gitea-pr script for gitea PR workflow.
Reviewed-on: #1
Co-authored-by: Brad Anderson <brad@branderson.io>
Co-committed-by: Brad Anderson <brad@branderson.io>
2026-07-12 18:49:36 -07:00
branderson 4e8c3c6269 Add keybind to fix missing cursor after SSH timeout in tmux 2026-07-11 13:11:14 -07:00
branderson 8badfdb9b8 Fix copy-paste in tmux 2026-07-10 19:10:41 -07:00
branderson 1eb4ee50d8 Merge branch 'master' of gitbox.bradina.localdomain:branderson/dotfiles 2026-07-10 18:36:42 -07:00
branderson 5bf98d5b1d Make ,, work in terminal mode nvim 2026-07-10 18:36:30 -07:00
branderson 90d67c5a4a Fix file modification issue in non-modifiable nvim buffers 2026-07-10 17:16:28 -07:00
branderson ad9a5072d3 Handle shellcheck cleanly in vim 2026-07-10 16:26:09 -07:00
branderson c6c51c81ef Improve <leader>? keybind search 2026-07-10 16:21:34 -07:00
branderson f318cd953f Merge branch 'master' of gitbox.bradina.localdomain:branderson/dotfiles 2026-07-10 16:04:27 -07:00
branderson f75adf42ae Add nvim debugging for python 2026-07-10 16:04:12 -07:00
branderson 192b457c61 Add new nvim deps to apt.global 2026-07-10 15:43:45 -07:00
branderson 0d3260dab1 Fix issues when nvim plugin deps aren't available and make system clipboard more consistent 2026-07-10 15:38:33 -07:00
branderson e8f6eeb8fb Fix a few nvim issues 2026-07-09 22:27:07 -07:00
branderson 3f420bf740 Merge branch 'master' of gitbox.bradina.localdomain:branderson/dotfiles 2026-07-09 21:17:17 -07:00
branderson 30effbb474 Port neovim configs from vimscript to lua 2026-07-09 21:15:47 -07:00
branderson cbda5efd3c Add tmux selection highlight for nested sessions 2026-07-03 17:34:22 -07:00
branderson baf24a5198 Merge branch 'master' of https://gitbox.branderson.io/branderson/dotfiles 2026-07-03 15:26:07 -07:00
branderson 69a3dfed30 Modernize and clean up tmux configs 2026-07-03 15:25:56 -07:00
branderson b650051b81 Add Claude Code plugin to nvim 2026-07-02 13:51:49 -07:00
Brad Anderson a0b6b93831 Merge branch 'master' of https://gitbox.branderson.io/branderson/dotfiles 2026-07-01 15:55:48 -07:00
Brad Anderson e4a76e28e9 Fix issue with profile leading to '1' files being created 2026-07-01 15:50:50 -07:00
branderson a6a9b44784 Fix picom.conf deprecated settings 2026-06-01 21:51:59 -07:00
branderson 535b6dc8f9 Add VSCodium config 2026-06-01 21:50:17 -07:00
branderson 99b90050ae Remove picom configuration which produces warnings 2026-03-19 15:05:14 -07:00
branderson 3e40b07e76 Fix Arch linux corrupted keys issues 2026-03-19 13:24:38 -07:00
branderson f849865294 Fix wallpaper updater monitor order to apply wallpapers to correct monitors 2026-01-16 17:49:47 -08:00
branderson b4ee1e28ef Add ideavimrc 2026-01-12 17:10:18 -08:00
branderson 2e3f837cbb Merge branch 'master' of gitbox.bradina.localdomain:branderson/dotfiles 2025-12-15 15:36:16 -08:00
branderson 54fee7544b Allow disabling loading SSH keys 2025-12-15 15:36:05 -08:00
branderson 1e121a3ece Backup instead of skipping existing dotfiles and disable installing templates for local dotfiles 2025-12-13 14:36:24 -08:00
Brad Anderson d1bbe70964 Fix local templates being linked and add markdown preview plugin to nvim 2025-10-21 13:32:42 -07:00
branderson 4bc636f494 Merge branch 'master' of gitbox.bradina.localdomain:branderson/dotfiles 2025-10-12 15:54:29 -07:00
branderson 340d9b725a Add better support for pyenv, set up GTK themes, and add (manually linkable for now) application shortcuts 2025-10-12 15:53:30 -07:00
branderson 80ced6b8f0 Merge branch 'master' of gitbox.bradina.localdomain:branderson/dotfiles 2025-10-09 00:54:02 -07:00
branderson 54494ca8ac Add some helper scripts for working with docker postgres 2025-10-09 00:43:15 -07:00
branderson 40b2710bad Fix automount for CIFS shares on Debian not mounting on boot 2025-10-08 18:25:58 -07:00
branderson cc7c6227d0 Change keybindings for new workspace and clipboard history 2025-10-07 19:28:25 -07:00
branderson 93a8c07a5a Merge branch 'master' of gitbox.bradina.localdomain:branderson/dotfiles 2025-10-06 01:12:28 -07:00
branderson dfa080ebb4 Add shortcut to move container to new empty workspace for i3 2025-10-06 01:12:19 -07:00
branderson 94fdec8f41 Make wallpaper autostart in i3 work 2025-10-05 17:15:01 -07:00
branderson ddf6a180cd Merge branch 'master' of gitbox.bradina.localdomain:branderson/dotfiles 2025-10-05 16:40:08 -07:00
branderson e638cd74ae Disable ohmyzsh corrections because it's more annoying than anything 2025-10-05 16:39:59 -07:00
branderson 72022f3940 Remove .profile messing with SSH_AUTH_SOCK to fix error 2025-10-05 16:31:42 -07:00
branderson ff607b428e Only use ssh-add if SSH_AUTH_SOCK set 2025-10-05 16:12:55 -07:00
branderson 0e8cfb9670 Only check for SSH keys if ssh-add available 2025-10-05 16:06:37 -07:00
branderson 4d2082994c Fix bug in AUR installer 2025-10-04 22:14:00 -07:00
branderson 2ad1035ddd Add some docker shortcut scripts to PATH 2025-10-03 23:50:01 -07:00
branderson 2a4453ac35 Create .ssh if it doesn't exist 2025-10-03 18:34:29 -07:00
branderson ac59b7b305 Make default selections clear 2025-10-03 14:03:50 -07:00
branderson f58a2ac4d3 Make /mnt/ dirs for SMB if they don't exist 2025-10-02 21:41:44 -07:00
branderson 9927dc903e Install the correct powerline through apt 2025-10-02 20:45:27 -07:00
branderson 5409a9140f Fix issue with setting ZSH as default 2025-10-02 20:21:11 -07:00
branderson 6f929ee958 Merge branch 'master' of gitbox.bradina.localdomain:branderson/dotfiles 2025-10-02 20:13:07 -07:00
branderson 4fe6c591f2 Add zsh to global apt packages 2025-10-02 20:11:40 -07:00
branderson 39fd88d207 Add fstab management for SMB configuration 2025-10-02 19:45:33 -07:00
branderson 9cedb773a6 Fix some issues in install complete 2025-10-02 18:02:09 -07:00
branderson a2a5666d75 Clean up bin and add better i3 output control 2025-10-01 21:10:47 -07:00
branderson b47a893e4b Fix firewalld reload when installing gpu-monitoring 2025-09-29 18:51:06 -07:00
branderson fb1eea31ae Merge branch 'master' of gitbox.bradina.localdomain:branderson/dotfiles 2025-09-28 20:36:47 -07:00
branderson 372ebf45a1 Add dotfiles-install option to install Nvidia GPU monitoring 2025-09-28 20:36:37 -07:00
branderson 7134b55487 Fix powerline in non-default dotfiles directory 2025-09-27 14:05:18 -07:00
branderson cb5e2ced19 Fix wallpapers in i3 and add some functions 2025-09-27 00:29:40 -07:00
branderson 7cc0ece887 Output missing dependencies in a single line 2025-09-22 18:14:54 -07:00
branderson 874db62c5f Fix infinite loop in interactive run 2025-09-22 17:33:04 -07:00
branderson b6c656ce94 Improve interface consistency and support positional argument command 2025-09-20 02:48:19 -07:00
branderson 3381b9e54d Fix issue with using env variable before it's set 2025-09-18 01:41:32 -07:00
branderson 481bf28d44 .dotfiles-dir -> .dotfiles-config 2025-09-18 01:39:35 -07:00
branderson ca2f0e18b4 Always update submodules 2025-09-18 00:31:42 -07:00
branderson d7c60e2f7b Put installer script in PATH 2025-09-17 23:37:34 -07:00
branderson 8f2cbb62aa Use POWERLINE_ROOT without additional lookups if set and valid 2025-09-17 20:02:49 -07:00
branderson eb6633fc7e Add thefuck shell initialization 2025-09-17 13:52:42 -07:00
branderson 1ea4bcc0e4 Fix zsh powerline 2025-09-17 00:58:21 -07:00
branderson bf7621b151 Optimize installs and add support for apt powerline 2025-09-17 00:55:47 -07:00
branderson 46d487fe32 Add support for apt powerline 2025-09-17 00:24:36 -07:00
branderson 7d31673399 Fix local-dotfiles clone initial branch 2025-09-16 23:55:54 -07:00
branderson 13d19cb8fc Fix powerline bash setup to support bash 3.2 without global array support 2025-09-16 17:34:19 -07:00
Brad Anderson ff49aba967 Make powerline support python virtualenvs without breaking prompt 2025-09-16 17:12:05 -07:00
branderson ec7c0acadc Fixing some dotfiles-local output 2025-09-16 12:26:47 -07:00
branderson 0ec2d1f6ce Use return values rather than output for bash functions 2025-09-16 12:12:36 -07:00
branderson 54db5b59be Fix SSH agent issues 2025-09-16 11:11:22 -07:00
branderson 388ec2ef7f Make function returns comply to POSIX and differ output if existing dotfiles are links or files/dirs 2025-09-16 10:35:52 -07:00
branderson e6444f9f86 Merge branch 'master' of https://gitbox.branderson.io/branderson/dotfiles 2025-09-16 08:40:52 -07:00
branderson 91318a32ad Add tmux and nvim local configurations 2025-09-16 08:40:33 -07:00
branderson 2df4cc4c30 Remove dotfiles-local submodule for real 2025-09-15 23:35:44 -07:00
branderson 095da7e302 Fix some issues 2025-09-15 23:31:40 -07:00
branderson 2dda647ba6 Generalize public keys to check for 2025-09-15 22:02:59 -07:00
branderson d82f92d52c Make dotfiles-local not a submodule and reorganize zshrc and profile 2025-09-15 21:10:03 -07:00
Brad Anderson 6b91a5053b Fix bash dependencies 2025-09-15 17:44:12 -07:00
branderson f9890fb2bd Support mac SSH key loading in .profile and skip ssh-add if no public keys found 2025-09-15 13:09:44 -07:00
branderson d2c73a5b48 Set correct default dotfiles-local branch 2025-09-15 12:57:08 -07:00
branderson 5f8d9c1047 Add fzf dependency, bash powerline, only ssh-add if not in keyring already 2025-09-14 01:40:55 -07:00
branderson a6d895dedf Clean up and reorganize zshrc 2025-09-13 21:24:32 -07:00
branderson cde4f66571 Fix dotfiles-local empty spaces in output 2025-09-13 20:25:58 -07:00
branderson 233378d20c Fix issue with submodule initialization in non-interactive run 2025-09-13 20:12:37 -07:00
branderson 7217ce8a41 Fix non-interactive dotfiles-local and skip pushing branch if no local changes 2025-09-13 18:43:55 -07:00
branderson 6d8f3594d7 Remove fixed TODO 2025-09-12 23:32:39 -07:00
branderson 48111eecbd Make sure dotfiles-local branch off main branch initially 2025-09-12 23:25:01 -07:00
branderson cced83f09b Fix issues with dotfiles-local 2025-09-12 12:59:57 -07:00
branderson c20eeab2cc Add local dotfiles management system 2025-09-12 01:20:00 -07:00
branderson bb1c607a29 Merge branch 'master' of gitbox.bradina.localdomain:branderson/dotfiles 2025-09-09 19:14:45 -07:00
branderson 6dc3dc7b2d Install coder option 2025-09-09 19:14:35 -07:00
branderson ca009bc794 Add support for mac 2025-09-09 17:48:49 -07:00
Brad Anderson 72d1fbcd16 Add bashrc and bash_profile 2025-09-08 14:31:12 -07:00
branderson 0a75d65f01 Fix setting wallpaper on startup 2025-09-06 18:00:19 -07:00
branderson 8fc175077c Update default config and respect config 2025-08-31 07:36:34 +00:00
branderson dd60653474 Move zshrc PATH assignment up-front for commands 2025-08-31 00:31:16 -07:00
branderson 8382c423f0 Silence output from git fetch 2025-08-31 00:26:07 -07:00
branderson a26151086d Extend PATH 2025-08-31 00:24:34 -07:00
branderson cd8aaae817 Fix sh issue 2025-08-31 00:16:28 -07:00
branderson b434313676 Add toggles for zsh startup behavior with sane defaults 2025-08-31 00:11:59 -07:00
branderson 8b79f58be4 Fix zsh configs when ~/.dotfiles-dir not set 2025-08-30 23:36:48 -07:00
branderson caa3d4a286 Automatic configuration of dotfile-dir on install 2025-08-31 03:12:03 +00:00
branderson 6d43be3029 Add initial git submodule update in zshrc 2025-08-30 19:47:42 -07:00
branderson ba6d9aeefd Add dotfiles-dir environment variable support and dotfile 2025-08-30 19:43:16 -07:00
branderson fdcb5de550 Fix local-templates install 2025-08-30 19:21:58 -07:00
branderson e4421bb2f0 Make powerline config sourcing automatic 2025-08-31 02:15:39 +00:00
branderson 27296ea69f Fix local template install 2025-08-30 14:33:06 -07:00
branderson 0d854ac137 Fix vim-plugged plugin directory 2025-08-29 14:55:53 -07:00
branderson a7101ebab8 Fix non-interactive run and add additional apt packages 2025-08-29 14:48:15 -07:00
branderson c9f242178b Fix some issues 2025-08-26 22:06:16 -07:00
branderson 7c765d0cf0 Support non-interactive install 2025-08-26 21:57:46 -07:00
branderson 0d9eafcaab Automatically set wallpapers on display change and at login 2025-08-08 19:49:51 -07:00
branderson 2a8051c68b Update wallpaper script to detect display layout and set wallpapers appropriately 2025-08-08 15:20:44 -07:00
branderson 60b126a4d1 Pipx support and changes from laptop 2025-06-29 15:51:47 -07:00
branderson 85c649acab Fix for gnome-keyring in i3 2025-06-29 15:25:36 -07:00
branderson a7d521917a Various fixes, wallpaper script, fix notebook 2025-05-06 13:43:08 -07:00
branderson 506a85843d Merge branch 'master' of https://github.com/branderson/dotfiles 2024-09-09 20:19:38 -07:00
branderson a31646fcd5 Fix enabling ssh-agent for root instead of user 2024-09-09 20:19:13 -07:00
111 changed files with 6741 additions and 741 deletions
+6
View File
@@ -0,0 +1,6 @@
# The claude-sandbox build context is this whole repo (so it can COPY
# bin/gitea-pr into the image), but it only needs a couple of paths from it.
**
!bin/gitea-pr
!docker/claude-sandbox/**
!config/nvim/**
+4
View File
@@ -1,6 +1,7 @@
existing
minimal
install_local.sh
bin/dotfiles-install
config/i3/config.de
config/i3/config.local
config/gtk-3.0/bookmarks
@@ -8,4 +9,7 @@ config/xfce4/xfconf/xfce-perchannel-xml
config/xfce4/terminal
config/themes
config/icons
config/VSCodium
dependencies/dotfiles-local
packages/*.local
.claude
+6
View File
@@ -16,3 +16,9 @@
[submodule "dependencies/gruvbox"]
path = dependencies/gruvbox
url = https://github.com/morhetz/gruvbox
[submodule "config/tmux/plugins/tpm"]
path = config/tmux/plugins/tpm
url = https://github.com/tmux-plugins/tpm
[submodule "dependencies/fzf"]
path = dependencies/fzf
url = https://github.com/junegunn/fzf.git
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Update all locally existing PGP keys in pacman's gnupg keyring, that are
# relevant for Arch Linux packaging using the distribution's Web Key Directory
# (WKD).
# This ensures, that new signatures on already existing keys are fetched before
# a new version of archlinux-keyring is installed. Fetching signatures early
# prevents marginal trust issues with packages that are signed by keys which
# only gain full trust when updating to a new version of archlinux-keyring in
# that same system upgrade action.
set -eu
readonly main_key_domain_match="@master-key.archlinux.org$"
readonly packager_domain_match="@archlinux.org$"
readonly homedir="$(pacman-conf GPGDir)"
domain_match=""
uid=""
gpg_locate_external=(
# force update a key using WKD
gpg
--homedir
"$homedir"
--quiet
--no-permission-warning
--auto-key-locate
"clear,nodefault,wkd"
--locate-external-keys
)
# a list of <fingerprint> <mbox> tuples of all keys in the keyring
# e.g.:
# C7E7849466FE2358343588377258734B41C31549 dvzrv@archlinux.org
# 8FC15A064950A99DD1BD14DD39E4B877E62EB915 svenstaro@gmail.com
fingerprint_mboxes="$(
gpg --homedir "$homedir" --no-permission-warning --list-keys --list-options show-only-fpr-mbox
)"
# a list of <fingerprints> of all revoked keys and keys that have no valid main
# key signatures
old_fingerprints="$(
gpg --homedir "$homedir" --no-permission-warning --list-keys --with-colons |
awk -F: '$1 == "pub" && $2 ~ /-|q|r/ { getline; print $10 }'
)"
if (( EUID != 0 )); then
printf "This script must be run as root.\n" >&2
exit 1
fi
# first update the main signing keys, then the packager keys
for domain_match in "$main_key_domain_match" "$packager_domain_match"; do
while read -ra fpr_email; do
if [[ ${fpr_email[1]} =~ $domain_match && ! "$old_fingerprints" =~ ${fpr_email[0]} ]]; then
printf "Refreshing key %s with UID %s...\n" "${fpr_email[0]}" "${fpr_email[1]}"
"${gpg_locate_external[@]}" "${fpr_email[1]}"
else
printf "Skipping key %s with UID %s...\n" "${fpr_email[0]}" "${fpr_email[1]}"
fi
done <<< "$fingerprint_mboxes"
done
+165
View File
@@ -0,0 +1,165 @@
#!/bin/bash
# Launch nvim inside the claude-sandbox container (see docker/claude-sandbox/),
# with a project directory bind-mounted at its real host path. Any `claude`
# invocation inside (a shell, or claudecode.nvim's terminal) runs with
# --dangerously-skip-permissions.
#
# Usage: claude-sandbox [path-to-project] [-- nvim args...]
# claude-sandbox # sandbox the current directory
# claude-sandbox ~/repos/myproject
# claude-sandbox ~/repos/myproject src/foo.py # open a specific file
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="$SCRIPT_DIR/../docker/claude-sandbox/docker-compose.yml"
WORKSPACE_DIR="${1:-$(pwd)}"
if [ ! -d "$WORKSPACE_DIR" ]; then
echo "Not a directory: $WORKSPACE_DIR" >&2
exit 1
fi
shift || true
if [ "${1:-}" = "--" ]; then
shift
fi
WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
SANDBOX_SSH_KEY="${SANDBOX_SSH_KEY:-}"
if [ -n "$SANDBOX_SSH_KEY" ] && [ ! -f "$SANDBOX_SSH_KEY" ]; then
echo "SANDBOX_SSH_KEY is set but not a file: $SANDBOX_SSH_KEY" >&2
exit 1
fi
if [ -n "$SANDBOX_SSH_KEY" ]; then
key_perms=$(stat -c '%a' "$SANDBOX_SSH_KEY")
if [ "${key_perms: -2}" != "00" ]; then
echo "Warning: $SANDBOX_SSH_KEY is readable by group/other (mode $key_perms)." >&2
echo "Recommended: chmod 600 $SANDBOX_SSH_KEY" >&2
fi
# SANDBOX_SSH_KEY is meant to be a dedicated, low-privilege deploy key
# (see README "Headless git push"), not your real identity - it stays
# loaded in an in-container agent for the whole session, unattended.
# Nothing enforces that convention, so at least catch the common case
# of pointing it at a default personal keyfile by mistake.
case "$(basename -- "$SANDBOX_SSH_KEY")" in
id_rsa|id_ecdsa|id_ed25519|id_dsa|id_ecdsa_sk|id_ed25519_sk)
echo "Warning: $SANDBOX_SSH_KEY looks like a default personal SSH key," \
"not a dedicated one." >&2
echo "It will be loaded into the sandbox for the whole unattended session -" \
"see README 'Headless git push'." >&2
;;
esac
fi
have_forwarded_agent=1
if [ -z "${SSH_AUTH_SOCK:-}" ] || [ ! -S "${SSH_AUTH_SOCK}" ]; then
have_forwarded_agent=0
fi
if [ "$have_forwarded_agent" = 0 ] && [ -z "$SANDBOX_SSH_KEY" ]; then
echo "SSH_AUTH_SOCK is not set or not a valid socket, and SANDBOX_SSH_KEY is not set." >&2
echo "Either start ssh-agent and run ssh-add, or set SANDBOX_SSH_KEY to a dedicated" >&2
echo "key for headless use - see README 'Headless git push' section." >&2
exit 1
fi
# A valid forwarded socket with zero identities loaded forwards fine (the
# container sees a live, working agent) but is useless for git-over-ssh -
# warn now rather than have that surface confusingly deep into a session as
# a failed push. Non-fatal: a task might not need git-over-ssh at all, and
# SANDBOX_SSH_KEY (if set) provides its own identity independently of this.
if [ "$have_forwarded_agent" = 1 ] && ! ssh-add -l >/dev/null 2>&1; then
echo "Warning: ssh-agent has no identities loaded (ssh-add -l shows none)." >&2
echo "git-over-ssh (e.g. pushing to Gitea) will fail until you ssh-add a key," >&2
echo "unless SANDBOX_SSH_KEY is also set." >&2
fi
if [ -z "${GITEA_TOKEN:-}" ]; then
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
if [ -f "$GITEA_TOKEN_FILE" ]; then
GITEA_TOKEN="$(<"$GITEA_TOKEN_FILE")"
fi
fi
if [ -z "${GH_TOKEN:-}" ]; then
# Deliberately not `gh auth token` / gh's own hosts.yml: a normal `gh
# auth login` session is write-capable, which would defeat the point.
# This should be a dedicated read-only fine-grained PAT (Contents +
# Metadata: read-only), generated separately for this sandbox.
GH_TOKEN_FILE="${GH_TOKEN_FILE:-$HOME/.config/gh/sandbox-token}"
if [ -f "$GH_TOKEN_FILE" ]; then
GH_TOKEN="$(<"$GH_TOKEN_FILE")"
fi
fi
# Read-write scratch space, separate from WORKSPACE_DIR, for passing files
# (screenshots, temp scripts) back and forth with a sandboxed session without
# touching the actual project checkout. Persists across sessions (it's a
# real host directory, not a container volume) since the point is sharing
# things with the host, not just scratch space for one run.
SANDBOX_SHARE_DIR="${SANDBOX_SHARE_DIR:-$HOME/.local/share/claude-sandbox/shared}"
mkdir -p "$SANDBOX_SHARE_DIR"
# ~/.claude/CLAUDE.md (carried into the container) tells agents to write
# specs to ~/specs/ rather than a project-local path - make sure that
# actually exists before it's bind-mounted, for a fresh machine that hasn't
# used the convention yet.
mkdir -p "$HOME/specs"
# Claude Code's own internal per-command sandboxing (~/.claude/settings.json's
# "sandbox" block) is redundant here - it can't even function inside this
# container (bwrap can't create a namespace, and getting it to would need
# privilege escalation approaching --privileged, undoing this container's own
# hardening), and this container is already a real, working sandbox on its
# own. settings.json is mounted read-only precisely so a session can't
# tamper with it, so it can't be patched from inside - generate a sandbox-
# specific copy here instead and mount that in place of the real one.
# Everything else in it (hooks, permissions, plugins) carries over unchanged.
SANDBOX_SETTINGS_FILE="$(mktemp "${TMPDIR:-/tmp}/claude-sandbox-settings.XXXXXX.json")"
jq '.sandbox.enabled = false' "$HOME/.claude/settings.json" > "$SANDBOX_SETTINGS_FILE"
cleanup() { rm -f "$SANDBOX_SETTINGS_FILE"; rm -rf "$SANDBOX_GITCONFIG_DIR"; }
trap cleanup EXIT
# Git commits inside the sandbox need a user.name/user.email - carry over a
# copy of the host's global gitconfig, read-write: entrypoint.sh's
# `gh auth setup-git` (what makes GitHub read-only from in here - see
# README) needs to *write* a credential-helper entry into it.
#
# A whole directory, not a single file bind-mounted at ~/.gitconfig: `git
# config` writes via the usual write-temp-then-rename-over-original pattern,
# which fails with "Device or resource busy" on a single-file bind mount -
# Linux won't let that mount point's backing inode be replaced, only a file
# *within* a mounted directory can be freely renamed over. Point git at the
# copy via GIT_CONFIG_GLOBAL instead of relying on it being at ~/.gitconfig.
# Nothing written in here ever reaches the user's real ~/.gitconfig either
# way, since it's a copy in its own directory, not the real file/parent.
SANDBOX_GITCONFIG_DIR="$(mktemp -d "${TMPDIR:-/tmp}/claude-sandbox-gitconfig.XXXXXX")"
if [ -f "$HOME/.gitconfig" ]; then
cp "$HOME/.gitconfig" "$SANDBOX_GITCONFIG_DIR/.gitconfig"
else
touch "$SANDBOX_GITCONFIG_DIR/.gitconfig"
fi
export WORKSPACE_DIR
# The image's user is built to match this uid/gid/username exactly (see
# Dockerfile) - this is what makes $HOME-based absolute paths (dotfiles,
# ~/.claude's plugin bookkeeping) resolve identically inside the container.
export SANDBOX_USER="$(id -un)"
export SANDBOX_UID="$(id -u)"
export SANDBOX_GID="$(id -g)"
export SANDBOX_SHARE_DIR
export SANDBOX_SETTINGS_FILE
export SANDBOX_GITCONFIG_DIR
export SSH_AUTH_SOCK
export SANDBOX_SSH_KEY_FILE="$SANDBOX_SSH_KEY"
export SSH_KNOWN_HOSTS="${SSH_KNOWN_HOSTS:-$HOME/.ssh/known_hosts}"
export GITEA_TOKEN="${GITEA_TOKEN:-}"
export GITEA_SSH_HOST="${GITEA_SSH_HOST:-}"
export GITEA_API="${GITEA_API:-}"
export GH_TOKEN="${GH_TOKEN:-}"
export TZ="${TZ:-$(readlink -f /etc/localtime 2>/dev/null | sed 's#.*/zoneinfo/##')}"
# Not `exec`: the EXIT trap above needs this shell to still be around
# afterward to actually clean up the generated settings file.
docker compose -f "$COMPOSE_FILE" run --rm --build claude "$@"
+109
View File
@@ -0,0 +1,109 @@
#!/bin/bash
# Add a domain to the egress firewall allowlist of a running claude-sandbox
# container, from outside it. The sandbox itself has no privilege to do this
# (node has no sudo/setuid path back to root - see docker/claude-sandbox's
# security notes); if a sandboxed agent hits a blocked domain it actually
# needs, it should tell you the exact command to run here rather than try
# to work around the firewall.
#
# Usage: claude-sandbox-allow <domain> [--global] [--container <name>]
# claude-sandbox-allow crates.io # this running session only
# claude-sandbox-allow crates.io --global # + persist for future sessions
set -euo pipefail
domain=""
global=0
container=""
while [ "$#" -gt 0 ]; do
case "$1" in
--global) global=1; shift ;;
--container) container="$2"; shift 2 ;;
-*) echo "Unknown option: $1" >&2; exit 1 ;;
*)
if [ -n "$domain" ]; then
echo "Usage: claude-sandbox-allow <domain> [--global] [--container <name>]" >&2
exit 1
fi
domain="$1"
shift
;;
esac
done
if [ -z "$domain" ]; then
echo "Usage: claude-sandbox-allow <domain> [--global] [--container <name>]" >&2
exit 1
fi
# Validated up front, not just passed as a positional arg below: this also
# has to be a well-formed hostname to be a valid dnsmasq server=/domain/...
# line (e.g. no embedded "/"), on top of ruling out shell metacharacters. A
# sandboxed agent can suggest this exact command to the user to copy-paste
# (see sandbox-context.sh), so a prompt-injected agent handing back a
# malicious "domain" is a real path here, not just a theoretical one.
if [[ ! "$domain" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
echo "Not a valid domain: $domain" >&2
exit 1
fi
if [ -z "$container" ]; then
containers=$(docker ps --filter "label=com.docker.compose.project=claude-sandbox" --filter "status=running" --format '{{.Names}}')
count=$(echo "$containers" | grep -c . || true)
if [ "$count" -eq 0 ]; then
echo "No running claude-sandbox container found." >&2
exit 1
elif [ "$count" -gt 1 ]; then
echo "Multiple running claude-sandbox containers found; specify one with --container:" >&2
echo "$containers" >&2
exit 1
fi
container="$containers"
fi
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
# this has to happen *before* resolving the domain below - otherwise the dig
# right after this would itself get blocked. Append a server= line to
# dnsmasq's servers-file (idempotent - skip if already present from an
# earlier call) and SIGHUP it to reload just that file, no restart needed.
# $domain is passed as a positional arg to the inner script (`$1`, inside a
# single-quoted heredoc) rather than interpolated into the script text - it's
# validated above, but this keeps it that way even if the validation regex
# above is ever loosened later.
docker exec -u root "$container" bash -c '
domain="$1"
entry="server=/${domain}/127.0.0.11"
grep -qxF "$entry" /etc/dnsmasq-sandbox-servers.conf 2>/dev/null || echo "$entry" >> /etc/dnsmasq-sandbox-servers.conf
kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"
' -- "$domain"
echo "Resolving $domain inside $container..."
ips=$(docker exec "$container" dig +short A "$domain" | grep -E '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || true)
if [ -z "$ips" ]; then
echo "Failed to resolve $domain inside the container." >&2
exit 1
fi
for ip in $ips; do
echo "Allowing $ip ($domain)"
docker exec -u root "$container" ipset add allowed-domains "$ip" -exist
done
echo "$domain allowed for the running session ($container)."
if [ "$global" -eq 1 ]; then
profile_file="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/config/profile"
if [ -f "$profile_file" ] && grep -q '^export ALLOWED_DOMAINS=' "$profile_file"; then
current=$(sed -nE 's/^export ALLOWED_DOMAINS="(.*)"$/\1/p' "$profile_file")
if [[ " $current " == *" $domain "* ]]; then
echo "$domain already in the global ALLOWED_DOMAINS (config/profile)."
else
new="${current:+$current }$domain"
sed -i "s|^export ALLOWED_DOMAINS=.*|export ALLOWED_DOMAINS=\"$new\"|" "$profile_file"
echo "Added $domain to config/profile's ALLOWED_DOMAINS (source your shell, or open a new one, for future sessions to pick it up)."
fi
else
echo "Couldn't find 'export ALLOWED_DOMAINS=' in $profile_file to update." >&2
exit 1
fi
fi
+45
View File
@@ -0,0 +1,45 @@
#!/bin/sh
# Copy stdin to the system clipboard, regardless of platform or how deep in
# ssh/tmux this shell is: prefer a local clipboard tool for the current
# display server, and fall back to a single plain OSC 52 write when no local
# tool is available (e.g. a headless remote host). Any tmux hops in between
# here and the real terminal relay that onward themselves, recursively, via
# the pane-set-clipboard hook in tmux.conf -- this doesn't need to know or
# guess how many hops there are.
if command -v pbcopy >/dev/null 2>&1; then
exec pbcopy
elif [ -n "$WAYLAND_DISPLAY" ] && command -v wl-copy >/dev/null 2>&1; then
# wl-copy/xclip both hold the selection by staying alive as a background
# process (X11/Wayland selections have no central store -- the owner has
# to serve paste requests live). setsid -f detaches that process from
# our session so tmux's copy-pipe job cleanup can't kill it right after.
setsid -f wl-copy
elif [ -n "$DISPLAY" ] && command -v xclip >/dev/null 2>&1; then
setsid -f xclip -selection clipboard
else
data=$(base64 | tr -d '\n')
# /dev/tty needs this process's own session to have a controlling
# terminal, but a job spawned by nvim's clipboard provider doesn't get
# one (nvim's job-spawning doesn't establish a ctty for children). Nvim
# itself also isn't a single process: the one actually spawning jobs is
# a `nvim --embed` core process with no tty of its own (shown as `?` in
# ps) -- the real terminal belongs to a separate parent UI process. Walk
# up the process tree past any tty-less ancestors to find it.
real_tty=""
pid="$PPID"
i=0
while [ "$i" -lt 10 ] && [ -n "$pid" ] && [ "$pid" != "0" ]; do
t=$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ')
if [ -n "$t" ] && [ "$t" != "?" ]; then
real_tty="/dev/$t"
break
fi
pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')
i=$((i + 1))
done
if [ -n "$real_tty" ] && [ -w "$real_tty" ]; then
printf '\033]52;c;%s\a' "$data" > "$real_tty"
else
printf '\033]52;c;%s\a' "$data" > /dev/tty
fi
fi
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
docker compose pull
docker compose down
docker compose up -d
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
docker compose down
docker compose up -d
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
find . -type f -name "docker-compose.y*ml" -exec dirname {} \; | while read dir
do
if [[ "$dir" != *"DEPRECATED"* ]] && [[ "$dir" != *"MIGRATED"* ]] ; then
pushd "$dir"
docker compose down
docker compose pull
docker compose up -d
popd
fi
done
Executable
+209
View File
@@ -0,0 +1,209 @@
#!/bin/bash
set -euo pipefail
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
function usage() {
echo "Usage: gitea-pr create [--title TITLE] [--body BODY] [--base BRANCH]"
echo " gitea-pr comments PR_NUMBER"
echo " gitea-pr merge PR_NUMBER"
echo ""
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
"set for this invocation only)."
}
function require_config() {
if [ -z "${GITEA_SSH_HOST:-}" ]; then
echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST=<gitea ssh hostname>'" \
"(e.g. gitbox.bradina.localdomain) to ~/.profile.local" >&2
exit 1
fi
if [ -z "${GITEA_API:-}" ]; then
echo "GITEA_API is not set. Add 'export GITEA_API=https://<gitea host>/api/v1'" \
"(e.g. https://gitbox.branderson.io/api/v1) to ~/.profile.local" >&2
exit 1
fi
}
function require_token() {
if [ -z "${GITEA_TOKEN:-}" ] && [ -f "$GITEA_TOKEN_FILE" ]; then
GITEA_TOKEN=$(<"$GITEA_TOKEN_FILE")
fi
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "No Gitea token found. Generate one in the Gitea web UI" \
"(Settings > Applications, scopes write:repository + write:issue)" \
"and save it to $GITEA_TOKEN_FILE (chmod 600), or set GITEA_TOKEN" \
"in the environment for this invocation only." >&2
exit 1
fi
}
function gitea_owner_repo() {
local remote_url host owner_repo
remote_url=$(git remote get-url origin 2>/dev/null) || {
echo "No 'origin' remote found in this repository" >&2
exit 1
}
host=$(echo "$remote_url" | sed -E 's#^[^@]+@([^:]+):.*#\1#')
if [ "$host" != "$GITEA_SSH_HOST" ]; then
echo "origin remote ($remote_url) is not on $GITEA_SSH_HOST; gitea-pr only supports this host" >&2
exit 1
fi
owner_repo=$(echo "$remote_url" | sed -E 's#^[^@]+@[^:]+:##; s#\.git$##')
echo "$owner_repo"
}
if [ "$#" -lt 1 ]; then
usage
exit 1
fi
subcommand="$1"
shift
case "$subcommand" in
create|comments|merge)
require_config
require_token
owner_repo=$(gitea_owner_repo)
owner="${owner_repo%%/*}"
repo="${owner_repo##*/}"
;;
*)
usage
exit 1
;;
esac
case "$subcommand" in
create)
title=""
body=""
base=""
while [ "$#" -gt 0 ]; do
case "$1" in
--title) title="$2"; shift 2 ;;
--body) body="$2"; shift 2 ;;
--base) base="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
body="**Summary generated by LLM agent**
${body}"
current_branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$current_branch" = "HEAD" ]; then
echo "Detached HEAD: check out the feature branch before creating a PR" >&2
exit 1
fi
if [ -z "$base" ]; then
base=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's#refs/remotes/origin/##' || true)
base="${base:-main}"
fi
response=$(curl -sS -w '\n%{http_code}' "${GITEA_API}/repos/${owner}/${repo}/pulls?state=open" \
-H "Authorization: token ${GITEA_TOKEN}")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to check for an existing PR (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
existing_url=$(echo "$json" | jq -r --arg head "$current_branch" --arg base "$base" \
'.[] | select(.head.ref == $head and .base.ref == $base) | .html_url' | head -1)
if [ -n "$existing_url" ]; then
echo "$existing_url"
exit 0
fi
if [ -z "$title" ]; then
title="$current_branch"
fi
response=$(curl -sS -w '\n%{http_code}' -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg head "$current_branch" --arg base "$base" --arg title "$title" --arg body "$body" \
'{head: $head, base: $base, title: $title, body: $body}')")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to create PR (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
echo "$json" | jq -r '.html_url'
;;
comments)
pr_number="${1:-}"
if [ -z "$pr_number" ]; then
echo "Usage: gitea-pr comments PR_NUMBER" >&2
exit 1
fi
echo "=== Issue comments ==="
response=$(curl -sS -w '\n%{http_code}' "${GITEA_API}/repos/${owner}/${repo}/issues/${pr_number}/comments" \
-H "Authorization: token ${GITEA_TOKEN}")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to fetch issue comments (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'
echo "=== Reviews ==="
response=$(curl -sS -w '\n%{http_code}' "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews" \
-H "Authorization: token ${GITEA_TOKEN}")
http_code=$(echo "$response" | tail -1)
reviews_json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to fetch reviews (HTTP $http_code):" >&2
echo "$reviews_json" >&2
exit 1
fi
echo "$reviews_json" | jq -r '.[] | select(.body != "") | "[\(.user.login) @ \(.submitted_at) — \(.state)]\n\(.body)\n"'
echo "=== Inline review comments ==="
review_ids=$(echo "$reviews_json" | jq -r '.[] | select(.comments_count > 0) | .id')
for review_id in $review_ids; do
response=$(curl -sS -w '\n%{http_code}' \
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews/${review_id}/comments" \
-H "Authorization: token ${GITEA_TOKEN}")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to fetch comments for review ${review_id} (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.path):\(.line // "?")]\n\(.body)\n"'
done
;;
merge)
pr_number="${1:-}"
if [ -z "$pr_number" ]; then
echo "Usage: gitea-pr merge PR_NUMBER" >&2
exit 1
fi
response=$(curl -sS -w '\n%{http_code}' -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/merge" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"Do": "merge"}')
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to merge PR #${pr_number} (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
echo "Merged PR #${pr_number}"
;;
esac
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
function usage() {
echo "Usage: postgres-docker-backup container_name db_user dump_file"
}
if [[ ! "$#" == 3 ]]; then
usage
exit 1
fi
container_name="$1"
db_user="$2"
dump_file="$3"
if [ -z "$container_name" ] || [ -z "$db_user" ] || [ -z "$dump_file" ]; then
usage
exit 1
fi
docker exec "$container_name" '/bin/sh' -c "pg_dumpall -U $db_user" > $dump_file
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
function usage() {
echo "Usage: postgres-docker-restore container_name db_user"
}
if [[ ! "$#" == 2 ]]; then
usage
exit 1
fi
container_name="$1"
db_user="$2"
if [ -z "$container_name" ] || [ -z "$db_user" ]; then
usage
exit 1
fi
docker exec -it "$container_name" '/bin/sh' -c "psql -U $db_user"
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
function usage() {
echo "Usage: postgres-docker-restore container_name db_user dump_file"
echo " Note that dump_file is relative to docker filesystem and must be mounted"
}
if [[ ! "$#" == 3 ]]; then
usage
exit 1
fi
container_name="$1"
db_user="$2"
dump_file="$3"
if [ -z "$container_name" ] || [ -z "$db_user" ] || [ -z "$dump_file" ]; then
usage
exit 1
fi
docker exec -it "$container_name" '/bin/sh' -c "psql -U $db_user < $dump_file"
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# Read clipboard content on stdin (e.g. from `tmux save-buffer -`) and write
# it as a raw OSC 52 escape sequence directly to the given tty device path.
# Intended to be called from tmux's pane-set-clipboard hook as:
# tmux save-buffer - | tmux-clipboard-relay "#{client_tty}"
tty_path="$1"
data=$(base64 | tr -d '\n')
printf '\033]52;c;%s\a' "$data" > "$tty_path"
+77
View File
@@ -0,0 +1,77 @@
#!/bin/bash
# TODO: Dotfiles may be in a different place
if [ ! -f "$HOME/.dotfiles-config" ]; then
echo "$HOME/.dotfiles-config not found, please run dotfiles/install.sh to create it"
exit 1
fi
source "$HOME/.dotfiles-config"
source "$DOTFILES_DIR/functions.sh"
# XAUTHORITY file may be in /tmp or it may be ~/.XAUTHORITY
if [ -f "$HOME/.Xauthority" ]; then
export XAUTHORITY="$HOME/.Xauthority"
else
auth_path="$(find /tmp -maxdepth 1 -name "xauth_*")"
if [ ! -z "$auth_path" ]; then
export XAUTHORITY="$auth_path"
else
echo "No Xauthority file found. Exiting"
exit 1
fi
fi
wallpapers_path="$HOME/wallpapers"
wallpapers_vertical_path="$HOME/wallpapers-vertical"
if [ ! -d "$wallpapers_path" ]; then
echo "ERROR: Wallpaper directory not found!"
echo "Please create wallpapers directory in $wallpapers_path"
exit 1
fi
if program_installed xrandr; then
display_count="$(xrandr | grep ' connected ' | wc -l)"
display_order="$(xrandr --listmonitors | tail -n +2 | sed 's/[\+\*]//g' | awk '{ print $2}')"
# Build feh command
feh_args=("--bg-fill")
for i in $(seq 1 "$display_count"); do
display_name="$(echo "$display_order" | sed "${i}q;d")"
# Get information about the current display, need verbose to always return rotation
# and remove 'primary' for consistent column placement
display_info="$(xrandr --query --verbose | grep "$display_name" | sed 's/primary //')"
# Get the selected resolution for the display
resolution="$(echo "$display_info" | awk '{ print $3 }' | sed 's/\+.*//')"
# Get the rotation for each display
rotation="$(echo "$display_info" | awk '{ print $5 }')"
path="$wallpapers_path"
resolution_segment=""
if [ "$rotation" != "normal" ] && [ "$rotation" != "inverted" ]; then
if [ ! -d "$wallpapers_vertical_path" ]; then
echo "$rotation"
echo "ERROR: Vertical display detected, but vertical wallpapers directory not found!"
echo "Please create vertical wallpapers directory in $wallpapers_vertical_path"
exit 1
fi
path="$wallpapers_vertical_path"
fi
# If we have a resolution-specific path for the display, use it
if [ -d "$path/1080p" ]; then
resolution_segment="/1080p"
fi
if [ -d "$path/4k" ]; then
if [ "$resolution" = "3840x2160" ] || [ "$resolution" = "2160x3840" ]; then
resolution_segment="/4k"
fi
fi
display_wallpaper="$(shuf -en1 "$path$resolution_segment"/*)"
feh_args+=("$display_wallpaper")
done
echo "feh ${feh_args[@]}"
feh "${feh_args[@]}"
else
# Default to ~/wallpapers/1080p or ~/wallpapers for first display if no xrandr
if [ -d "$path/1080p" ]; then
feh --bg-fill "$(shuf -en1 $wallpapers_path/1080p/*)"
else
feh --bg-fill "$(shuf -en1 $wallpapers_path/*)"
fi
fi
+59
View File
@@ -0,0 +1,59 @@
{
"workbench.colorTheme": "Gruvbox Dark Medium",
"vim.camelCaseMotion.enable": true,
"vim.easymotion": true,
"vim.enableNeovim": true,
"vim.handleKeys": {
"<C-a>": false,
"<C-d>": true,
"<C-f>": false,
"<C-s>": false,
"<C-z>": false
},
"vim.insertModeKeyBindingsNonRecursive": [
{
"before": [",", ","],
"after": ["<Esc>"]
}
],
"vim.normalModeKeyBindingsNonRecursive": [
{
"before": ["<leader>", "d"],
"after": ["d", "d"]
},
{
"before": ["<leader>", "d"],
"after": ["d", "d"]
},
{
"before": [",", ",", "<Esc>"],
"commands": [":nohl"]
},
{
"before": ["<leader>", "q"],
"commands": [":bp"]
},
{
"before": ["<leader>", "w"],
"commands": [":bn"]
},
{
"before": ["<leader>", "r", "r"],
"commands": [":action "]
},
{
"before": ["<leader>", "<leader>", "c"],
"commands": [":Blose"],
"silent": true
}
],
"vim.leader": ",",
// To improve performance
"extensions.experimental.affinity": {
"vscodevim.vim": 1
},
"vim.neovimUseConfigFile": true,
"editor.fontFamily": "'Source Code Pro', regular",
"vim.normalModeKeyBindings": [],
"python.venvPath": "~/.pyenv"
}
+27 -3
View File
@@ -1,6 +1,3 @@
import = [
"~/.config/alacritty/themes/themes/gruvbox_dark.toml"
]
[font]
size = 9.0
@@ -20,3 +17,30 @@ style = "Italic"
[font.normal]
family = "Source Code Pro for Powerline"
style = "Regular"
[general]
import = [
"~/.config/alacritty/themes/themes/gruvbox_dark.toml"
]
[selection]
# X11 always sets PRIMARY (middle-click paste) on text selection; that part
# needs no config. CLIPBOARD (Ctrl+V / Ctrl+Shift+V) is a separate buffer
# that nothing populates without this, so mirror every selection into it
# too. This is handled by Alacritty itself, so it covers anything visible
# on screen -- local shell, nested tmux, SSH'd remote shells -- regardless
# of whether the app underneath knows how to talk to the clipboard.
save_to_clipboard = true
[keyboard]
# Alacritty doesn't implement the CSI-u/Kitty keyboard protocol that lets a
# terminal report Shift+Enter as distinct from plain Enter, so it needs an
# explicit binding. Sending a raw line feed makes Shift+Enter equivalent to
# Ctrl+J, which Claude Code (and most other terminal apps) already treats
# as "insert newline" with zero further configuration -- this works
# unchanged through any depth of nested tmux, since it's just a literal
# byte rather than an escape sequence that needs to be interpreted along
# the way.
bindings = [
{ key = "Return", mods = "Shift", chars = "\n" },
]
+7
View File
@@ -0,0 +1,7 @@
[Desktop Entry]
Name=USDB Syncer
Comment=Download karaoke songs
Exec=usdb_syncer
Terminal=false
Type=Application
Categories=Software;
+7
View File
@@ -21,6 +21,13 @@ exec --no-startup-id dex --autostart --environment i3
# applications #
################
# set wallpaper
exec --no-startup-id sleep 1 && ~/dotfiles/bin/update-wallpaper
# exec --no-startup-id sleep 1 && systemctl start update-wallpaper.service
# start keyring
exec --no-startup-id dbus-update-activation-environment --all
#exec_always --no-startup-id conky -c ~/.config/conky/conkyrc-stats
#exec --no-startup-id sleep 7 && firefox https://todo.bradina.haus/
+31 -18
View File
@@ -10,6 +10,8 @@ bar {
font pango: Source Code Pro for Powerline 10
status_command i3blocks -c ~/.config/i3/i3blocks.conf
position bottom
output DP-0
output DP-2
#tray_padding 0
# When strip_workspace_numbers is set to yes,
@@ -37,21 +39,32 @@ bar {
# https://gist.github.com/lopspower/03fb1cc0ac9f32ef38f4
# #08052be6 --> e6=90%
# bar {
# font pango: Noto Sans Regular 10
# status_command i3blocks -c ~/.config/i3/i3blocks-2.conf
# i3bar_command i3bar --transparency
# output HDMI-0
# position bottom
#
# colors {
# separator $purple
# background $darkbluetrans
# statusline $white
# border bg txt indicator
# focused_workspace $lighterblue $lighterblue $darkblue $purple
# active_workspace $lightdblue $lightdblue $darkblue $purple
# inactive_workspace $darkblue $darkblue $lightdblue $purple
# urgent_workspace $urgentred $urgentred $white $purple
# }
#}
bar {
font pango: Noto Sans Regular 10
status_command i3blocks -c ~/.config/i3/i3blocks-2.conf
i3bar_command i3bar --transparency
output HDMI-0
position bottom
colors {
# gruvbox colors
background $bg
statusline $yellow
# workspaces section
# border bg txt
focused_workspace $aqua $aqua $darkgray
inactive_workspace $darkgray $darkgray $yellow
active_workspace $darkgray $darkgray $yellow
urgent_workspace $red $red $bg
}
# colors {
# separator $purple
# background $darkbluetrans
# statusline $white
# border bg txt indicator
# focused_workspace $lighterblue $lighterblue $darkblue $purple
# active_workspace $lightdblue $lightdblue $darkblue $purple
# inactive_workspace $darkblue $darkblue $lightdblue $purple
# urgent_workspace $urgentred $urgentred $white $purple
# }
}
+142
View File
@@ -0,0 +1,142 @@
function program_installed {
local return_=0
type $1 >/dev/null 2>&1 || { local return_=1; }
return "$return_"
}
function list_processes() {
# List all processes matching the given process name string
ps aux | grep $(whoami) | grep -i "$1" | grep -v grep
}
function kill_all() {
# Kill all processes matching the given process name string
if [ ! "$1" ]; then
echo "kill_all requires a process name to kill all instances of"
return 1
fi
kill_flag=15
if [ "$15" ]; then
kill_flag="$15"
fi
count=$(list_processes "$1" | wc -l)
echo "Killing $count running processes matching $1"
kill -"$kill_flag" $(list_processes "$1" | awk '{print $2}')
count=$(list_processes "$1" | wc -l)
if [ $count -gt 0 ]; then
echo "$count processes could not be killed"
fi
}
function ranger-cd() {
# Make shell cd to ranger directory
tempfile=$(mktemp)
ranger --choosedir="$tempfile" "${@:-$(pwd)}" < $TTY
test -f "$tempfile" &&
if [ "$(cat -- "$tempfile")" != "$(echo -n `pwd`)" ]; then
cd $(cat $tempfile)
fi
zle reset-prompt
printf '\n'
rm -f -- "$tempfile"
}
function terminal-clock() {
tty-clock -tcb < $TTY
zle reset-prompt
printf '\n'
}
function check_dotfiles_version {
# Check if the version of the dotfiles directory is the latest version
# Return 0 if repo is present and the latest version
# Return 1 if the repo is not present
# Return 2 if the repo is behind the remote
# Return 3 if the repo is ahead of the remote
# Return 4 if the repo has diverged from the remote
# Return 5 if DOTFILES_DIR is not set
if [ -z $DOTFILES_DIR ]; then
return 5
fi
if [ ! -d $DOTFILES_DIR ]; then
return 2
fi
pushd $DOTFILES_DIR > /dev/null
# TODO: This may be gitbox
git fetch origin > /dev/null
UPSTREAM=${1:-'@{u}'}
LOCAL=$(git rev-parse @)
REMOTE=$(git rev-parse "$UPSTREAM")
BASE=$(git merge-base @ "$UPSTREAM")
popd > /dev/null
if [[ $LOCAL = $REMOTE ]]; then
return 0
elif [[ $LOCAL = $BASE ]]; then
return 2
elif [[ $REMOTE = $BASE ]]; then
return 3
else
return 4
fi
}
function python_roots {
if [ -d $HOME/.local/lib/ ]; then
for directory in $(echo $HOME/.local/lib/python*); do
echo "$directory"
done
fi
if [ -d $HOME/.local/share/pipx/venvs/powerline-status/lib/ ]; then
for directory in $(echo $HOME/.local/share/pipx/venvs/powerline-status/lib/python*); do
echo "$directory"
done
fi
if [ -d $HOME/.local/pipx/venvs/powerline-status/lib/ ]; then
for directory in $(echo $HOME/.local/pipx/venvs/powerline-status/lib/python*); do
echo "$directory"
done
fi
if [ -d /usr/lib/ ]; then
for directory in $(echo /usr/lib/python*); do
echo "$directory"
done
fi
if [ -d /usr/local/lib/ ]; then
# TODO: Make this not print warnings when the directory doesn't exit
for directory in $(echo /usr/local/lib/python*); do
echo "$directory"
done
fi
}
function powerline_bash_sources {
# To support bash 3.2 on macOS, can't use declare -ag so workaround with capturing output
PYTHON_ROOTS=( $(python_roots) )
# Powerline plugin from distribution agnostic install directory
## Assemble list of possible sources
if [[ -a $POWERLINE_ROOT/bindings/bash/powerline.sh ]]; then
# Prefer user-set powerline directory
echo "$POWERLINE_ROOT/bindings/bash/powerline.sh"
fi
if [ -d /usr/share/powerline ]; then
# Apt installs here
echo "/usr/share/powerline/bindings/bash/powerline.sh"
fi
# If $POWERLINE_ROOT not set or set incorrectly, try default locations
for directory in "${PYTHON_ROOTS[@]}"; do
if [ -d "$directory" ]; then
echo "$directory"/site-packages/powerline/bindings/bash/powerline.sh
echo "$directory"/dist-packages/powerline/bindings/bash/powerline.sh
fi
done
}
+6
View File
@@ -0,0 +1,6 @@
if [ -n "$BASH_VERSION" ]; then
# include .bashrc if it exists
if [ -f "$HOME/.bashrc" ]; then
. "$HOME/.bashrc"
fi
fi
+84
View File
@@ -0,0 +1,84 @@
#PATHs
if [ -d "$HOME/.local/bin" ]; then
export PATH="$HOME/.local/bin:$PATH"
fi
if [ -f "$HOME/.bash_functions" ]; then
source "$HOME/.bash_functions"
fi
# User profiles
source $HOME/.profile
# Define $POWERLINE_ROOT
if [ -f $HOME/.profile.local ]; then
set -a
source $HOME/.profile.local
set +a
fi
if [ -n "$DOTFILES_DIR" ] && [ -d "$DOTFILES_DIR" ]; then
# Gruvbox colors
[[ -f "$DOTFILES_DIR/dependencies/gruvbox/gruvbox_256palette.sh" ]] && source "$DOTFILES_DIR/dependencies/gruvbox/gruvbox_256palette.sh"
fi
# Italic support
if [ -f $HOME/.xterm-256color-italic.terminfo ]; then
tic $HOME/.xterm-256color-italic.terminfo
fi
if [ -f $HOME/.screen-256color-italic.terminfo ]; then
tic $HOME/.screen-256color-italic.terminfo
fi
# Use neovim where available
if program_installed neovim; then
alias vim='nvim'
fi
# fzf fuzzy finder
if program_installed fzf; then
# TODO: This is broken when fzf is old, should include a local copy
eval "$(fzf --bash)"
fi
# thefuck command correction
if program_installed fuck; then
eval "$(thefuck --alias)"
fi
# aactivator
if program_installed aactivator; then
eval "$(aactivator init)"
fi
# Preferred editor for local and remote sessions
export EDITOR='vim'
# Powerline
if program_installed powerline; then
if [ -n "$POWERLINE_ROOT" ] && \
[ -f "$POWERLINE_ROOT" ] && \
[ -f "$POWERLINE_ROOT/bindings/bash/powerline.sh" ]; then
# Skip lookup if location is known
source "$POWERLINE_ROOT/bindings/bash/powerline.sh"
else
POWERLINE_BASH_SOURCES=( $(powerline_bash_sources) )
found=0
for file in "${POWERLINE_BASH_SOURCES[@]}"; do
if [[ -a $file ]]; then
# TODO: Store the POWERLINE_ROOT in .profile.local if found
source $file
found=1
break
fi
done
if [ $found -eq 0 ]; then
echo "Powerline binding not found."
echo "Please set \$POWERLINE_ROOT in .profile.local"
fi
fi
fi
# Local overrides
if [ -f $HOME/.bashrc_local ]; then
source $HOME/.bashrc_local
fi
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# PreToolUse hook: block obviously destructive Bash commands regardless of permission mode.
# Exit 2 blocks the tool call and returns stderr to Claude as feedback.
command=$(jq -r '.tool_input.command // empty')
deny_patterns=(
'rm[[:space:]]+(-[a-zA-Z]*[rR][a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*[rR])[[:space:]]+/($|[[:space:]])'
'rm[[:space:]]+(-[a-zA-Z]*[rR][a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*[rR])[[:space:]]+(--no-preserve-root|~($|/)|\*)'
':\(\)[[:space:]]*\{[[:space:]]*:.*\|.*:.*&.*\}[[:space:]]*;[[:space:]]*:'
'dd[[:space:]].*of=/dev/(sd|nvme|vd|hd)'
'mkfs(\.[a-zA-Z0-9]+)?[[:space:]]'
'>[[:space:]]*/dev/(sd|nvme|vd|hd)[a-z0-9]*($|[[:space:]])'
'chmod[[:space:]]+-R[[:space:]]+[0-7]{3,4}[[:space:]]+/($|[[:space:]])'
'chown[[:space:]]+-R[[:space:]].*[[:space:]]+/($|[[:space:]])'
'(^|[[:space:]])(shutdown|reboot|poweroff|halt)([[:space:]]|$)'
'wipefs[[:space:]]'
'shred[[:space:]].*/dev/'
'curl[[:space:]].*\|[[:space:]]*(sudo[[:space:]]+)?(sh|bash|zsh)($|[[:space:]])'
'wget[[:space:]].*-O-.*\|[[:space:]]*(sudo[[:space:]]+)?(sh|bash|zsh)($|[[:space:]])'
)
for pattern in "${deny_patterns[@]}"; do
if [[ "$command" =~ $pattern ]]; then
echo "Blocked by local denylist hook (~/.claude/hooks/deny-dangerous-bash.sh): command matches pattern '$pattern'" >&2
exit 2
fi
done
exit 0
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# PreToolUse hook: inside claude-sandbox, only allow `git push` to the Gitea
# host (GITEA_SSH_HOST / the GITEA_API host), and never directly to
# main/master there - the sandbox workflow is feature branch + `gitea-pr
# create`, never a direct push, and never to GitHub at all (read-only there).
# This is a fast-fail guard against an overeager agent, not a hard security
# boundary - a sandbox session has full shell access and could in principle
# edit .git/config to route around it. No-op outside the sandbox
# (CLAUDE_SANDBOX unset), since this restriction doesn't apply to normal host
# usage.
if [ -z "${CLAUDE_SANDBOX:-}" ]; then
exit 0
fi
input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // empty')
cwd=$(echo "$input" | jq -r '.cwd // empty')
# Only care about `git push` invocations.
if ! [[ "$command" =~ (^|[[:space:]&\|;])git([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+push([[:space:]]|$) ]]; then
exit 0
fi
repo_dir="$cwd"
if [[ "$command" =~ git[[:space:]]+-C[[:space:]]+([^[:space:]]+) ]]; then
repo_dir="${BASH_REMATCH[1]}"
fi
# Positionally parse everything after "push": first non-flag token is the
# remote, second (if any) is the refspec. Word-splitting rather than a full
# shell parse, so quoting edge cases aren't handled - same pragmatic
# tradeoff as the sibling deny-dangerous-bash.sh denylist.
after_push="${command#*push}"
read -ra tokens <<< "$after_push"
positional=()
for tok in "${tokens[@]}"; do
case "$tok" in
-*) continue ;;
*) positional+=("$tok") ;;
esac
done
remote="origin"
refspec=""
if [ "${#positional[@]}" -ge 1 ]; then
remote="${positional[0]}"
fi
if [ "${#positional[@]}" -ge 2 ]; then
refspec="${positional[1]}"
fi
remote_url=$(git -C "$repo_dir" remote get-url "$remote" 2>/dev/null)
if [ -z "$remote_url" ]; then
# Can't resolve a remote from here - let git produce the real error
# rather than block or guess.
exit 0
fi
host=$(echo "$remote_url" | sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')
gitea_api_host=""
if [ -n "${GITEA_API:-}" ]; then
gitea_api_host=$(echo "$GITEA_API" | sed -E 's#^[a-zA-Z]+://([^/]+).*#\1#')
fi
if [ "$host" != "${GITEA_SSH_HOST:-}" ] && [ "$host" != "$gitea_api_host" ]; then
echo "Blocked by git-push-guard.sh: this sandbox may only push to the Gitea host (${GITEA_SSH_HOST:-<GITEA_SSH_HOST not set>}), not '$host'. Reading from GitHub is fine; pushing there is not." >&2
exit 2
fi
# Resolve the target branch: a src:dst refspec's dst, a bare branch name, or
# (if no refspec was given at all, or it's HEAD) the current branch - `git
# push origin` and `git push origin HEAD` both push the current branch.
if [[ "$refspec" == *:* ]]; then
branch="${refspec#*:}"
elif [ -n "$refspec" ] && [ "$refspec" != "HEAD" ]; then
branch="$refspec"
else
branch=$(git -C "$repo_dir" branch --show-current 2>/dev/null)
fi
branch="${branch#refs/heads/}"
if [ "$branch" = "main" ] || [ "$branch" = "master" ]; then
echo "Blocked by git-push-guard.sh: direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and run 'gitea-pr create' instead." >&2
exit 2
fi
exit 0
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# SessionStart hook: when running inside the claude-sandbox Docker container
# (see ~/dotfiles/docker/claude-sandbox), inject a reminder of the sandbox's
# constraints and how to escape it. No-op outside the container.
if [ -z "${CLAUDE_SANDBOX:-}" ]; then
exit 0
fi
session_id=$(jq -r '.session_id // empty')
resume_id="${session_id:-<unknown-session-id>}"
context=$(cat <<EOF
You are running inside the claude-sandbox Docker container
(~/dotfiles/docker/claude-sandbox), not directly on the user's machine, with
--dangerously-skip-permissions active (no tool-use confirmations).
What this means:
- The workspace directory is bind-mounted read-write and is the real
project. ~/shared is also a real, persistent host directory (not part of
the project) - use it to hand the user a file (a screenshot, a temp
script) without touching the project checkout, or to pick up something
they left there for you. Everything else in the container besides
~/.claude is disposable: installed system packages, anything outside the
mounted paths, and the container itself go away when this run ends.
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
registry, GitHub read-only, the configured Gitea host, plus anything set
via ALLOWED_DOMAINS). Requests to anything else will simply fail to
connect. If a task needs one more domain and nothing else, that's a quick
fix, not a reason to stop: tell the user to run
\`bin/claude-sandbox-allow <domain>\` (add \`--global\` to persist it past
this session) from a terminal outside the sandbox, then continue.
- Stay within the task you were given. Since there are no permission
prompts here, be more conservative than usual about anything destructive,
irreversible, or outside the project directory.
Git workflow - do this even though nothing stops you from doing otherwise:
- Do new work in a git worktree for the feature branch, not by switching
branches in place in the mounted checkout - that's the user's actual
working copy, and changing its branch out from under them is disruptive
even if you switch it back.
- The worktree MUST live under the mounted project directory itself, e.g.
\`git worktree add .worktrees/<branch> -b <branch>\` run from the project
root - never \`../<branch>\` or any other path outside it, including
anywhere under \$HOME. Only the exact directory bind-mounted into this
container is backed by real storage on the host; everything else,
including what looks like the project's own parent directory, is
container-local and silently disappears when the session ends - a
worktree created there is real work that just gets thrown away. Add
\`.worktrees/\` to \`.git/info/exclude\` (not \`.gitignore\` - this is
sandbox-local, not something to commit) so it doesn't clutter \`git
status\` for the user's real checkout.
- Push that branch to the Gitea remote, then run \`gitea-pr create\` from
the worktree. Never push to GitHub (read-only, and not reachable for
push regardless), and never push directly to main/master, on any remote.
A \`git-push-guard.sh\` hook enforces both as a backstop, but treat them
as the actual workflow to follow, not just a filter to route around.
If a task genuinely needs something else the sandbox can't provide -
broader network access than a domain or two, host-level or system changes,
GUI/hardware access - stop and explain clearly to the user what you need
and why, instead of trying to work around the firewall or sandbox yourself.
They can continue this exact session outside the container by running on
the host:
claude --resume $resume_id
(session history is shared via the bind-mounted ~/.claude, so it will pick
up right where this left off).
EOF
)
jq -n --arg context "$context" '{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": $context
}
}'
+30
View File
@@ -0,0 +1,30 @@
{
"name": "Gruvbox Dark",
"base": "dark",
"overrides": {
"claude": "#d3869b",
"text": "#ebdbb2",
"inactive": "#665c54",
"subtle": "#7c6f64",
"muted": "#928374",
"success": "#b8bb26",
"error": "#fb4934",
"warning": "#fabd2f",
"link": "#83a598",
"highlight": "#fabd2f",
"selection": "#504945",
"diffAdded": "#b8bb26",
"diffRemoved": "#fb4934",
"promptBorder": "#504945",
"bashBorder": "#504945",
"mcpBorder": "#504945",
"planMode": "#83a598",
"spinner": "#d3869b",
"header": "#8ec07c",
"separator": "#3c3836",
"badge": "#fe8019",
"permission": "#fe8019",
"suggestion": "#8ec07c",
"fastMode": "#fabd2f"
}
}
+84
View File
@@ -0,0 +1,84 @@
@define-color borders_breeze #4c4e51;
@define-color content_view_bg_breeze #141618;
@define-color error_color_backdrop_breeze #da4453;
@define-color error_color_breeze #da4453;
@define-color error_color_insensitive_backdrop_breeze #55242b;
@define-color error_color_insensitive_breeze #55242b;
@define-color insensitive_base_color_breeze #131517;
@define-color insensitive_base_fg_color_breeze #606263;
@define-color insensitive_bg_color_breeze #1f2124;
@define-color insensitive_borders_breeze #2d3033;
@define-color insensitive_fg_color_breeze #686a6c;
@define-color insensitive_selected_bg_color_breeze #1f2124;
@define-color insensitive_selected_fg_color_breeze #686a6c;
@define-color insensitive_unfocused_bg_color_breeze #1f2124;
@define-color insensitive_unfocused_fg_color_breeze #686a6c;
@define-color insensitive_unfocused_selected_bg_color_breeze #1f2124;
@define-color insensitive_unfocused_selected_fg_color_breeze #686a6c;
@define-color link_color_breeze #926ee4;
@define-color link_visited_color_breeze #9b59b6;
@define-color success_color_backdrop_breeze #27ae60;
@define-color success_color_breeze #27ae60;
@define-color success_color_insensitive_backdrop_breeze #19482f;
@define-color success_color_insensitive_breeze #19482f;
@define-color theme_base_color_breeze #141618;
@define-color theme_bg_color_breeze #202326;
@define-color theme_button_background_backdrop_breeze #292c30;
@define-color theme_button_background_backdrop_insensitive_breeze #272a2e;
@define-color theme_button_background_insensitive_breeze #272a2e;
@define-color theme_button_background_normal_breeze #292c30;
@define-color theme_button_decoration_focus_backdrop_breeze #926ee4;
@define-color theme_button_decoration_focus_backdrop_insensitive_breeze #4a406a;
@define-color theme_button_decoration_focus_breeze #926ee4;
@define-color theme_button_decoration_focus_insensitive_breeze #4a406a;
@define-color theme_button_decoration_hover_backdrop_breeze #926ee4;
@define-color theme_button_decoration_hover_backdrop_insensitive_breeze #4a406a;
@define-color theme_button_decoration_hover_breeze #926ee4;
@define-color theme_button_decoration_hover_insensitive_breeze #4a406a;
@define-color theme_button_foreground_active_backdrop_breeze #fcfcfc;
@define-color theme_button_foreground_active_backdrop_insensitive_breeze #686a6c;
@define-color theme_button_foreground_active_breeze #ffffff;
@define-color theme_button_foreground_active_insensitive_breeze #686a6c;
@define-color theme_button_foreground_backdrop_breeze #fcfcfc;
@define-color theme_button_foreground_backdrop_insensitive_breeze #6d6f72;
@define-color theme_button_foreground_insensitive_breeze #6d6f72;
@define-color theme_button_foreground_normal_breeze #fcfcfc;
@define-color theme_fg_color_breeze #fcfcfc;
@define-color theme_header_background_backdrop_breeze #202326;
@define-color theme_header_background_breeze #292c30;
@define-color theme_header_background_light_breeze #202326;
@define-color theme_header_foreground_backdrop_breeze #fcfcfc;
@define-color theme_header_foreground_breeze #fcfcfc;
@define-color theme_header_foreground_insensitive_backdrop_breeze #fcfcfc;
@define-color theme_header_foreground_insensitive_breeze #fcfcfc;
@define-color theme_hovering_selected_bg_color_breeze #926ee4;
@define-color theme_selected_bg_color_breeze #6c53a6;
@define-color theme_selected_fg_color_breeze #ffffff;
@define-color theme_text_color_breeze #fcfcfc;
@define-color theme_titlebar_background_backdrop_breeze #202326;
@define-color theme_titlebar_background_breeze #292c30;
@define-color theme_titlebar_background_light_breeze #202326;
@define-color theme_titlebar_foreground_backdrop_breeze #fcfcfc;
@define-color theme_titlebar_foreground_breeze #fcfcfc;
@define-color theme_titlebar_foreground_insensitive_backdrop_breeze #fcfcfc;
@define-color theme_titlebar_foreground_insensitive_breeze #fcfcfc;
@define-color theme_unfocused_base_color_breeze #141618;
@define-color theme_unfocused_bg_color_breeze #202326;
@define-color theme_unfocused_fg_color_breeze #fcfcfc;
@define-color theme_unfocused_selected_bg_color_alt_breeze #362c50;
@define-color theme_unfocused_selected_bg_color_breeze #362c50;
@define-color theme_unfocused_selected_fg_color_breeze #fcfcfc;
@define-color theme_unfocused_text_color_breeze #fcfcfc;
@define-color theme_unfocused_view_bg_color_breeze #131517;
@define-color theme_unfocused_view_text_color_breeze #606263;
@define-color theme_view_active_decoration_color_breeze #926ee4;
@define-color theme_view_hover_decoration_color_breeze #926ee4;
@define-color tooltip_background_breeze #292c30;
@define-color tooltip_border_breeze #535659;
@define-color tooltip_text_breeze #fcfcfc;
@define-color unfocused_borders_breeze #4c4e51;
@define-color unfocused_insensitive_borders_breeze #2d3033;
@define-color warning_color_backdrop_breeze #f67400;
@define-color warning_color_breeze #f67400;
@define-color warning_color_insensitive_backdrop_breeze #5e340f;
@define-color warning_color_insensitive_breeze #5e340f;
+1
View File
@@ -1,3 +1,4 @@
VteTerminal, vte-terminal {
padding: 13px;
}
@import 'colors.css';
+18 -9
View File
@@ -1,16 +1,25 @@
[Settings]
gtk-font-name=Liberation Sans Regular 10
gtk-theme-name=Gruvbox-Dark-BL
gtk-icon-theme-name=Gruvbox_Dark
gtk-cursor-theme-name=Gruvbox-cursors
gtk-cursor-theme-size=16
gtk-toolbar-style=GTK_TOOLBAR_BOTH_HORIZ
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
gtk-button-images=1
gtk-menu-images=1
gtk-application-prefer-dark-theme=true
gtk-button-images=true
gtk-cursor-blink=true
gtk-cursor-blink-time=1000
gtk-cursor-theme-name=breeze_cursors
gtk-cursor-theme-size=24
gtk-decoration-layout=icon:minimize,maximize,close
gtk-enable-animations=true
gtk-enable-event-sounds=0
gtk-enable-input-feedback-sounds=0
gtk-font-name=Noto Sans, 10
gtk-icon-theme-name=breeze-dark
gtk-menu-images=true
gtk-modules=colorreload-gtk-module
gtk-primary-button-warps-slider=true
gtk-sound-theme-name=ocean
gtk-theme-name=Gruvbox-Dark-BL
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
gtk-toolbar-style=3
gtk-xft-antialias=1
gtk-xft-dpi=122880
gtk-xft-hinting=1
gtk-xft-hintstyle=hintmedium
gtk-xft-rgba=none
+9
View File
@@ -22,6 +22,15 @@ set $ws7 "7"
set $ws8 "8"
set $ws9 "9"
set $ws10 "10"
set $ws11 "11"
set $ws12 "12"
set $ws13 "13"
set $ws14 "14"
set $ws15 "15"
set $ws16 "16"
set $ws17 "17"
set $ws18 "18"
set $ws19 "19"
# Define colors variables:
# gruvbox colors
+10 -9
View File
@@ -12,20 +12,19 @@ exec --no-startup-id numlockx on
# use arandr to setup displays and save the file as default:
exec --no-startup-id ~/.screenlayout/default.sh
# set wallpaper
exec --no-startup-id sleep 1 && feh --bg-fill --randomize ~/wallpapers
# set powersavings for display if not handled by DE:
# Usage: xset dpms {standby_time} {suspend_time} {off_time}
# exec --no-startup-id xset dpms 600 0 0
#exec --no-startup-id xset dpms 600 0 0
# disable power saving (for example if using xscreensaver)
#exec --no-startup-id xset -dpms
exec --no-startup-id xset -dpms
# use xautolock to use autosuspend rules for mobile devices
# https://wiki.archlinux.org/title/Session_lock#xautolock
#exec --no-startup-id xautolock -time 60 -locker "systemctl suspend"
# set wallpaper(s) on i3 start/restart
exec_always --no-startup-id "[ -f ~/.dotfiles-config ] && source ~/.dotfiles-config && $DOTFILES_DIR/bin/update-wallpaper"
# xscreensaver
# https://www.jwz.org/xscreensaver
@@ -45,15 +44,17 @@ exec --no-startup-id /usr/bin/dunst
# Scratchpad with notes
# `tmuxinator open notes` first for this to work
exec --no-startup-id alacritty --title notebook -e zsh -c 'tmuxinator start notes'
for_window [class="^Alacritty$" title="notebook"] move scratchpad, scratchpad hide
for_window [class="^Alacritty$" title="notebook"] move scratchpad
#, scratchpad hide
# Performance monitor
exec --no-startup-id alacritty --title monitoring -e zsh -c 'htop'
for_window [class="^Alacritty$" title="monitoring"] move scratchpad, scratchpad hide
for_window [class="^Alacritty$" title="monitoring"] move scratchpad
#, scratchpad hide
# Remote connections
exec --no-startup-id alacritty --title servers -e zsh -c 'tmuxinator start servers'
for_window [class="^Alacritty$" title="servers"] move scratchpad, scratchpad hide
# exec --no-startup-id alacritty --title servers -e zsh -c 'tmuxinator start servers'
# for_window [class="^Alacritty$" title="servers"] move scratchpad, scratchpad hide
###############
# system tray #
+31 -5
View File
@@ -7,10 +7,15 @@ floating_modifier $mod
# switch/iterate between workspaces
bindsym $mod+Tab workspace next
bindsym $mod+Shift+Tab workspace prev
bindsym $mod+c exec --no-startup-id ~/.config/i3/scripts/empty_workspace
bindsym $mod+Shift+c exec --no-startup-id ~/.config/i3/scripts/move_container_empty_workspace
bindsym $mod+slash [class="^Alacritty$" title="notebook"] scratchpad show
# bindsym $mod+slash scratchpad show
bindsym $mod+m [class="^Alacritty$" title="monitoring"] scratchpad show
bindsym $mod+Shift+colon [class="^Alacritty$" title="servers"] scratchpad show
# bindsym $mod+Shift+colon [class="^Alacritty$" title="servers"] scratchpad show
bindsym $mod+Shift+slash move scratchpad
bindsym $mod+Control+slash scratchpad show
# switch to workspace
bindsym $mod+1 workspace $ws1
@@ -162,7 +167,8 @@ mode "$mode_gaps_outer" {
######################################
# start a terminal
bindsym $mod+Return exec --no-startup-id alacritty
bindsym $mod+Return exec --no-startup-id alacritty -e sh -c "tmux new-session"
# bindsym $mod+Return exec --no-startup-id alacritty
# kill focused window
bindsym $mod+q kill
@@ -171,7 +177,7 @@ bindsym $mod+q kill
#bindsym --release button2 kill
# reload the configuration file
bindsym $mod+Shift+c reload
bindsym $mod+Control+r reload
# restart i3 inplace (preserves your layout/session, can be used to update i3)
bindsym $mod+Shift+r restart
@@ -206,7 +212,19 @@ bindsym $mod+Shift+Down move down
bindsym $mod+Shift+Up move up
bindsym $mod+Shift+Right move right
# splij in vertical orientation
# move focused workspace
bindsym $mod+Control+h move workspace to output left
bindsym $mod+Control+j move workspace to output down
bindsym $mod+Control+k move workspace to output up
bindsym $mod+Control+l move workspace to output right
# alternatively, you can use the cursor keys:
bindsym $mod+Control+Left move workspace to output left
bindsym $mod+Control+Down move workspace to output down
bindsym $mod+Control+Up move workspace to output up
bindsym $mod+Control+Right move workspace to output right
# split in vertical orientation
bindsym $mod+minus split v
bindsym $mod+KP_Subtract split v
@@ -254,6 +272,7 @@ bindsym $mod+p exec --no-startup-id /usr/local/bin/switch-audio-port
## App shortcuts
bindsym $mod+w exec /usr/bin/firefox
bindsym Print exec scrot ~/Pictures/Screenshots/%Y-%m-%d-%T-screenshot.png && notify-send "Screenshot saved to ~/Pictures/Screenshots/$(date +"%Y-%m-%d-%T")-screenshot.png"
#bindsym Print exec spectactle --output ~/Pictures/Screenshots/%Y-%m-%d-%T-screenshot.png
# Power Profiles menu switcher (rofi)
bindsym $mod+Shift+p exec --no-startup-id ~/.config/i3/scripts/power-profiles
@@ -272,5 +291,12 @@ bindsym $mod+t exec --no-startup-id rofi -show window \
## rofi bindings to manage clipboard (install rofi-greenclip from the AUR)
exec --no-startup-id greenclip daemon>/dev/null
bindsym $mod+c exec --no-startup-id rofi -modi "clipboard:greenclip print" -show clipboard \
bindsym $mod+b exec --no-startup-id rofi -modi "clipboard:greenclip print" -show clipboard \
-config ~/.config/rofi/rofidmenu.rasi
## autoclicker x 100
bindsym $mod+Shift+o exec --no-startup-id xdotool click --repeat 1000 --delay 10 1
bindsym $mod+Shift+i exec --no-startup-id xdotool click --repeat 10000 --delay 10 1
# calculator
bindsym XF86Calculator exec --no-startup-id galculator
+1 -1
View File
@@ -8,7 +8,7 @@ font pango: Liberation Sans Regular 10
default_border pixel 5
# thin borders
# hide_edge_borders both
hide_edge_borders smart
# Set inner/outer gaps
gaps inner 10
-13
View File
@@ -13,16 +13,3 @@ workspace_layout default
# Each new window will open fullscreen as a tab (left to right)
#workspace_layout tabbed
##########################################
# configuration for workspace behaviour: #
##########################################
# use workspaces on different displays:
# where you have to replace VGA-0/HDMI-0 with the names for your displays
# you can get from xrandr command
#workspace $ws1 output DP-2
#workspace $ws3 output DP-2
#workspace $ws5 output DP-2
#workspace $ws2 output DP-4
#workspace $ws4 output DP-4
#workspace $ws6 output DP-4
+102
View File
@@ -0,0 +1,102 @@
# i3blocks config file changed for EndeavourOS-i3 setup
# source is available here:
# https://raw.githubusercontent.com/endeavouros-team/endeavouros-i3wm-setup/main/.config/i3/i3blocks.conf
# Maintainer: joekamprad [joekamprad //a_t// endeavouros.com]
# Former Visual Designer: Florent Valetti [@FLVAL EndeavourOS]
# created for i3wm setup on EndeavourOS
# https://endeavouros.com
# cheatsheet for icon fonts used on the block-bar:
# https://fontawesome.com/v4.7/cheatsheet/
# --> to update this run the following command:
# wget --backups=1 https://raw.githubusercontent.com/endeavouros-team/endeavouros-i3wm-setup/main/.config/i3/i3blocks.conf -P ~/.config/i3/
# Please see man i3blocks for a complete reference!
# The man page is also hosted at http://vivien.github.io/i3blocks
# List of valid properties:
#
# align
# color
# command
# full_text
# instance
# interval
# label
# min_width
# name
# separator
# separator_block_width
# short_text
# signal
# urgent
# Global properties
#
# The top properties below are applied to every block, but can be overridden.
separator=false
markup=pango
separator_block_width=15
#[Weather]
#command=~/.config/i3/scripts/openweather
# or:
#command=~/.config/i3/scripts/openweather-city
#interval=1800
#color=#7275b3
separator_block_width=10
[spacer]
separator_block_width=10
full_text=
color=#b16286
[pavucontrol]
separator_block_width=10
full_text=
command=pavucontrol
color=#689d6a
[volume-pulseaudio]
command=~/.config/i3/scripts/volume
instance=Master
color=#689d6a
interval=1
separator_block_width=10
[spacer]
separator_block_width=10
full_text=
color=#b16286
[time]
label=
command=date '+%a %b %d %I:%M:%S %p'
color=#98971a
interval=1
separator_block_width=10
[spacer]
separator_block_width=10
full_text=
color=#b16286
[keybindings]
full_text=
command=~/.config/i3/scripts/keyhint
[sleep_screen]
full_text=🖵
command=~/.config/i3/scripts/blur-lock
[shutdown_menu]
full_text=
command=~/.config/i3/scripts/powermenu
separator_block_width=15
[simple-2]
full_text=
color=#b16286
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
MAX_DESKTOPS=20
WORKSPACES=$(seq -s '\n' 1 1 ${MAX_DESKTOPS})
EMPTY_WORKSPACE=$( (i3-msg -t get_workspaces | tr ',' '\n' | grep num | awk -F: '{print int($2)}' ; \
echo -e ${WORKSPACES} ) | sort -n | uniq -u | head -n 1)
i3-msg move container to workspace ${EMPTY_WORKSPACE}
+63
View File
@@ -0,0 +1,63 @@
" .ideavimrc is a configuration file for IdeaVim plugin. It uses
" the same commands as the original .vimrc configuration.
" You can find a list of commands here: https://jb.gg/h38q75
" Find more examples here: https://jb.gg/share-ideavimrc
"" -- Suggested options --
" Show a few lines of context around the cursor. Note that this makes the
" text scroll if you mouse-click near the start or end of the window.
set scrolloff=5
" Do incremental searching.
set incsearch
" Don't use Ex mode, use Q for formatting.
map Q gq
" --- Enable IdeaVim plugins https://jb.gg/ideavim-plugins
" Highlight copied text
Plug 'machakann/vim-highlightedyank'
" Commentary plugin
Plug 'tpope/vim-commentary'
"" -- Map IDE actions to IdeaVim -- https://jb.gg/abva4t
"" Map \r to the Reformat Code action
"map \r <Action>(ReformatCode)
"" Map <leader>d to start debug
"map <leader>d <Action>(Debug)
"" Map \b to toggle the breakpoint on the current line
"map \b <Action>(ToggleLineBreakpoint)
inoremap ,, <ESC>
cnoremap ,, <ESC><ESC>
nmap j gj
nmap k gk
" Only set on first run or it overwrites YCM behavior
" if has('vim_starting')
" imap <tab> <C-X><C-O>
" endif
let mapleader=","
let maplocalleader="\\"
" Close buffer
map <leader><leader>c :Bclose<CR>
" Prev/next buffer
map <leader>q :bp<CR>
map <leader>w :bn<CR>
" System clipboard cut copy paste
map <leader>y "+y
map <leader>Y "+Y
map <leader>p "+p
map <leader>P "+P
map <leader>x "+x
map <leader>dd "+dd
" Update ideavimrc
nnoremap <leader>v :e ~/.ideavimrc<CR>;
nnoremap <leader>rr :action IdeaVim.ReloadVimRc.reload<CR>;
+4 -1
View File
@@ -1 +1,4 @@
POWERLINE_ROOT=/usr/share/powerline
NO_NEOFETCH=1
NO_GIT_CHECK=1
NO_ZSH_COWSAY=1
LOAD_SSH_KEYS=0
+143
View File
@@ -0,0 +1,143 @@
# nvim config map
Lua config managed by [lazy.nvim](https://github.com/folke/lazy.nvim). Leader is `,`, local leader is `\`.
## Layout
- `init.lua` — bootstraps lazy.nvim, loads `config/` then `plugins/`
- `lua/config/options.lua` — `vim.opt` settings
- `lua/config/keymaps.lua` — keymaps not tied to a specific plugin
- `lua/config/autocmds.lua` — autocommands
- `lua/plugins/*.lua` — one lazy.nvim spec file per topic (each file returns a plugin spec table; lazy.nvim loads every file in the directory automatically)
- `ftplugin/*.lua` — filetype-specific settings (Python, vimwiki)
- `lua/util.lua` — small helper functions (`ranger()`, `delete_trailing_ws()`) used from keymaps
## Plugins
### Navigation & editing (`plugins/editor.lua`)
- **[neo-tree.nvim](https://github.com/nvim-neo-tree/neo-tree.nvim)** — file explorer sidebar
- `<leader>t` — toggle
- `<leader>cd` — cd to current file's directory and open
- **[telescope.nvim](https://github.com/nvim-telescope/telescope.nvim)** — fuzzy finder for files/buffers/grep/LSP symbols
- `<leader>.` — jump to symbol
- `<leader>;` — jump to buffer
- `<leader>be` — jump to buffer, sorted by most recently used
- `<leader>ag` — live grep
- `<leader>?` — fuzzy-search all keybindings
- **[flash.nvim](https://github.com/folke/flash.nvim)** — jump to any visible location via search-like labels
- `<leader>/` — jump
- `<leader>s` — search jump
- `<leader><leader>s` — treesitter jump
- `<leader>f` — forward jump
- **[nvim-surround](https://github.com/kylechui/nvim-surround)** — add/change/delete surrounding pairs (quotes, tags, brackets)
- `ys`, `cs`, `ds` — plugin defaults
- **[nvim-autopairs](https://github.com/windwp/nvim-autopairs)** — auto-close brackets/quotes, automatic
- **[nvim-treesitter-context](https://github.com/nvim-treesitter/nvim-treesitter-context)** — sticky header showing enclosing function/class while scrolling, automatic
- **[mini.align](https://github.com/echasnovski/mini.nvim)** — align text on a character
- `ga=`, `ga:`
- **[mini.bufremove](https://github.com/echasnovski/mini.nvim)** — close a buffer without closing its window/split
- `<leader><leader>c`
- **[vim-visual-multi](https://github.com/mg979/vim-visual-multi)** — multiple cursors
- `<C-n>` — add cursor on word
- **[vim-tmux-navigator](https://github.com/christoomey/vim-tmux-navigator)** — move between nvim splits and tmux panes; also works from inside `:terminal` buffers (e.g. the Claude Code terminal)
- `<C-h/j/k/l>` — move left/down/up/right
- **[emmet-vim](https://github.com/mattn/emmet-vim)** — Emmet expansion, HTML/CSS files only
### UI (`plugins/ui.lua`)
- **[gruvbox.nvim](https://github.com/ellisonleao/gruvbox.nvim)** — colorscheme
- **[lualine.nvim](https://github.com/nvim-lualine/lualine.nvim)** — statusline
- **[bufferline.nvim](https://github.com/akinsho/bufferline.nvim)** — buffer tabs along the top
- **[indent-blankline.nvim](https://github.com/lukas-reineke/indent-blankline.nvim)** — indent guides
- **[rainbow-delimiters.nvim](https://github.com/HiPhish/rainbow-delimiters.nvim)** — rainbow-colored matching brackets
- **[nvim-web-devicons](https://github.com/nvim-tree/nvim-web-devicons)** — filetype icons (dependency for several plugins above)
- **[alpha-nvim](https://github.com/goolord/alpha-nvim)** — start screen shown on launch, see [Dashboard](#dashboard) below
- `<leader><leader>o` — reopen
### Completion & LSP
- **[blink.cmp](https://github.com/Saghen/blink.cmp)** — completion engine (LSP, path, buffer sources) — `plugins/completion.lua`
- **[mason.nvim](https://github.com/mason-org/mason.nvim)** / **[mason-lspconfig.nvim](https://github.com/mason-org/mason-lspconfig.nvim)** — installs/manages LSP servers — `plugins/lsp.lua`
- **[nvim-lspconfig](https://github.com/neovim/nvim-lspconfig)** — LSP client config for `ts_ls`, `svelte`, `html`, `cssls`, `basedpyright`, `ruff`, `bashls` — `plugins/lsp.lua`
- **[rustaceanvim](https://github.com/mrcjkb/rustaceanvim)** — Rust LSP setup (`rust_analyzer`), replaces the standard lspconfig flow for Rust — `plugins/rust.lua`
- `<localleader>rr` — cargo run
LSP buffer keymaps (set on `LspAttach`, see `plugins/lsp.lua`):
- `K` — hover
- `gd` — definition
- `gy` — type definition
- `gi` — implementation
- `gr` — references
- `rn` — rename
- `ff` (visual) — format selection
### Debugging (`plugins/dap.lua`)
- **[nvim-dap](https://github.com/mfussenegger/nvim-dap)** — Debug Adapter Protocol client
- `<leader>db` — toggle breakpoint
- `<leader>dc` — continue / start debugging
- `<leader>di` / `<leader>do` / `<leader>dO` — step into / over / out
- `<leader>dr` — toggle REPL
- `<leader>dq` — terminate session
- **[nvim-dap-python](https://github.com/mfussenegger/nvim-dap-python)** — configures nvim-dap for Python using `debugpy`. Requires `:MasonInstall debugpy`; without it, debugging is skipped with a warning.
### Formatting & linting (`plugins/format.lua`)
- **[conform.nvim](https://github.com/stevearc/conform.nvim)** — format-on-save (prettier, ruff_format, rustfmt, stylua per filetype)
- **[nvim-lint](https://github.com/mfussenegger/nvim-lint)** — async linting on save/insert-leave (eslint_d, shellcheck)
### Treesitter (`plugins/treesitter.lua`)
**[nvim-treesitter](https://github.com/nvim-treesitter/nvim-treesitter)** — syntax highlighting, folding, and indentation for the installed parser list. Uses core Neovim's built-in `vim.treesitter.start()`/foldexpr rather than the old treesitter modules.
### Git (`plugins/git.lua`)
- **[gitsigns.nvim](https://github.com/lewis6991/gitsigns.nvim)** — git change signs in the gutter, hunk stage/preview/blame
- **[vim-fugitive](https://github.com/tpope/vim-fugitive)** — Git commands
- `:Git`, `:Gdiffsplit`, `:Gvdiffsplit`, `:Gread`, `:Gwrite`, `:Gclog`
### AI (`plugins/ai.lua`)
- **[snacks.nvim](https://github.com/folke/snacks.nvim)** — utility library (terminal provider etc.), dependency for claudecode.nvim
- **[claudecode.nvim](https://github.com/coder/claudecode.nvim)** — Claude Code terminal integration. Diffs render as a single unified (VS Code-style, red/green interleaved) buffer in one new split next to your active pane, and keyboard focus stays in the Claude terminal instead of jumping to the diff.
- `<leader>ac` — toggle
- `<leader>af` — focus
- `<leader>ab` — add current buffer
- `<leader>as` (visual) — send selection
- `<leader>aa` / `<leader>ad` — accept/deny diff
### Wiki / notes (`plugins/vimwiki.lua`)
- **[vimwiki](https://github.com/vimwiki/vimwiki)** — personal wiki across 4 notebooks (bradwiki, work, private, shared under `~/synced-notebooks/`)
- `<leader>m...` — wiki keymaps (`vim.g.vimwiki_map_prefix`)
- **[vim-zettel](https://github.com/michal-h21/vim-zettel)** — Zettelkasten-style note linking on top of vimwiki
- **[vim-table-mode](https://github.com/dhruvasagar/vim-table-mode)** — markdown/wiki table formatting
- `:TableModeToggle`, or `<leader><leader>t...` prefix
- **[markdown-preview.nvim](https://github.com/iamcco/markdown-preview.nvim)** — live browser preview for markdown/vimwiki
- `<localleader>v` — toggle preview
### Misc (`plugins/misc.lua`)
- **[aerial.nvim](https://github.com/stevearc/aerial.nvim)** — code outline/symbol sidebar
- `<leader>ct` — toggle
- **[undotree](https://github.com/jiaoshijie/undotree)** — visual undo history tree
- `<leader>u` — toggle
- **[persistence.nvim](https://github.com/folke/persistence.nvim)** — session save/restore per project
- restore from the dashboard's `s` button
- **[which-key.nvim](https://github.com/folke/which-key.nvim)** — shows available keybindings as you type a prefix (e.g. pause after `<leader>`), automatic
## Dashboard
The start screen (shown on launch, reopen with `<leader><leader>o`) is [alpha-nvim](https://github.com/goolord/alpha-nvim), configured in `plugins/ui.lua`. It shows a random `fortune | cowthink` header plus buttons:
- `e` — new file
- `f` — find file (Telescope)
- `g` — live grep (Telescope)
- `s` — restore last session (persistence.nvim)
- `q` — quit
## Buffer navigation
`<leader>be` fuzzy-searches open buffers via Telescope, sorted by most recently used, excluding the current buffer. `<leader>;` does the same but in default Telescope order. Both are fuzzy-filterable as you type.
@@ -7,8 +7,12 @@ source $HOME/.config/nvim/config/bindings.vimrc
source $HOME/.config/nvim/config/functions.vimrc
" Python dependencies
let g:python_host_prog=expand('$HOME/.pyenv/versions/neovim2/bin/python')
let g:python3_host_prog=expand('$HOME/.pyenv/versions/neovim3/bin/python')
if isdirectory(expand($HOME.'/.pyenv/versions/neovim2'))
let g:python_host_prog=expand($HOME.'/.pyenv/versions/neovim2/bin/python')
endif
if isdirectory(expand($HOME.'/.pyenv/versions/neovim3'))
let g:python3_host_prog=expand($HOME.'/.pyenv/versions/neovim3/bin/python')
endif
" Local overrides
let $LOCALFILE=expand("$HOME/.nvim_local.vimrc")
+12 -1
View File
@@ -161,6 +161,16 @@ function! MapPlugins()
nnoremap <leader>u :GundoToggle<CR>
endif
if exists('g:loaded_claudecode')
" Claude Code
nnoremap <leader>ac :ClaudeCode<CR>
nnoremap <leader>af :ClaudeCodeFocus<CR>
nnoremap <leader>ab :ClaudeCodeAdd %<CR>
vnoremap <leader>as :ClaudeCodeSend<CR>
nnoremap <leader>aa :ClaudeCodeDiffAccept<CR>
nnoremap <leader>ad :ClaudeCodeDiffDeny<CR>
endif
" --- Language Specific ---
" - General
" COC
@@ -178,7 +188,8 @@ function! MapPlugins()
map <localleader>rr :RustRun<CR>
" - Markdown -
map <localleader>v :LivedownToggle<CR>
" map <localleader>v :LivedownToggle<CR>
nmap <localleader>v <Plug>MarkdownPreviewToggle
endfunction
augroup Plugin_Mappings
+10 -2
View File
@@ -1,5 +1,5 @@
let g:vim_plug_install_dir = "~/.local/share/nvim/site/autoload"
let g:plugin_install_dir = "~/.local/share/nvim/plugged"
let g:plugin_install_dir = "~/.vim/plugged"
" Install vim-plug if not present
if empty(glob(g:vim_plug_install_dir . "/plug.vim"))
@@ -116,7 +116,7 @@ Plug 'xolox/vim-reload'
" Note taking and wiki managment
Plug 'vimwiki/vimwiki'
" TODO Why can't these go in Search?
Plug 'junegunn/fzf'
Plug 'junegunn/fzf', { 'do': { -> fzf#install() } }
Plug 'junegunn/fzf.vim'
Plug 'michal-h21/vim-zettel'
" TODO Make this pull on buffer open / load
@@ -173,6 +173,14 @@ Plug 'sophacles/vim-processing'
" - Markdown -
" Plug 'shime/vim-livedown'
" Plug 'iamcco/markdown-preview.nvim', { 'do': 'cd app && npx --yes yarn install' }
Plug 'iamcco/markdown-preview.nvim', { 'do': { -> mkdp#util#install() }, 'for': ['markdown', 'vim-plug']}
" --- AI ---
" Floating/split terminal windows used by claudecode.nvim
Plug 'folke/snacks.nvim'
" Claude Code integration
Plug 'coder/claudecode.nvim'
" --- Icons ---
+18
View File
@@ -322,4 +322,22 @@ let g:rust_fold = 2
let g:processing_fold = 1
" - Markdown -
" Livedown
let g:livedown_open = 1
" Markdown Preview
" --- AI ---
" Claude Code
" plugin/claudecode.lua auto-calls require('claudecode').setup(g:claudecode_auto_setup)
" once the plugin loads (during plug#end() below), so this must be a plain
" vimscript dict rather than a lua block -- see the guarded keymaps in
" bindings.vimrc for the commands this enables.
" Full option list: https://github.com/coder/claudecode.nvim#configuration-options
let g:claudecode_auto_setup = {
\ 'terminal': {
\ 'provider': 'snacks',
\ 'split_side': 'right',
\ 'split_width_percentage': 0.30,
\ },
\ }
+4
View File
@@ -0,0 +1,4 @@
vim.bo.textwidth = 79
if vim.bo.modifiable then
vim.bo.fileformat = "unix"
end
+36
View File
@@ -0,0 +1,36 @@
local map = function(mode, lhs, rhs)
vim.keymap.set(mode, lhs, rhs, { buffer = true })
end
map("n", "<leader>mm", "<Plug>VimwikiIndex")
map("n", "<leader>mw", "<Plug>VimwikiUISelect")
map("n", "<leader>mhelp", ":verbose map ,m<CR>")
map("n", "<leader>mg", "<Plug>VimwikiGoto")
map("n", "<leader>mf", "<Plug>VimwikiUISelect1<CR>")
map("n", "<leader>m<Space>", "<Plug>VimwikiUISelect2<CR>")
map("n", "<leader>mp", "<Plug>VimwikiUISelect3<CR>")
map("n", "<leader>ms", "<Plug>VimwikiUISelect4<CR>")
map("n", "<leader>m-", "<Plug>VimwikiSplitLink")
map("n", "<leader>m<Bar>", "<Plug>VimwikiVSplitLink")
map("n", "<leader>mnt", "<Plug>VimwikiNextTask")
map("n", "<leader>mnl", "<Plug>VimwikiNextLink")
map("n", "<leader>mNl", "<Plug>VimwikiPrevLink")
map({ "n", "i", "v" }, "<Tab>", "<Plug>VimwikiIncreaseLvlSingleItem")
map({ "n", "i", "v" }, "<S-Tab>", "<Plug>VimwikiDecreaseLvlSingleItem")
map("i", "<C-Space>", "<Esc><Plug>VimwikiToggleListItemA")
map("n", "<C-d><C-Space>", "<Plug>VimwikiRemoveSingleCB")
map("i", "<C-d><C-Space>", "<Esc><Plug>VimwikiRemoveSingleCBa")
map("n", "<leader>mb", "<Plug>VimwikiBackLinks")
map("n", "<leader>mqtoc", "<Plug>VimwikiTOC")
map("n", "<leader>mqgtl", "<Plug>VimwikiRebuildTags<CR><Plug>VimwikiGenerateTagLinks<CR>")
map("v", "<leader>mk", 'c[]<Esc>Pea()<Esc>"+P')
map("i", "<leader>mk", '<Esc>viW<C-k>ea')
map("n", "<leader>mk", 'ciw[]<Esc>Pea()<Esc>"+P')
map("n", "<leader>mag", ":ZettelOpen<CR>")
map("n", "<leader>mat", ":ZettelOpen<CR>title:")
+91
View File
@@ -0,0 +1,91 @@
-- Version guard: native LSP config (vim.lsp.config/vim.lsp.enable, added in
-- 0.11) and the pinned nvim-treesitter (hard-requires 0.12 in its own health
-- check) both need a current Neovim. Older versions -- e.g. Debian's apt
-- package, which lags upstream by a long way even fully updated -- don't
-- fail here; they fail later with a confusing cascade of lspconfig and
-- mason-lspconfig errors. Check up front instead, before anything else in
-- this file runs (some of it, like vim.uv below, doesn't exist pre-0.10).
if vim.fn.has("nvim-0.12") == 0 then
local ok, v = pcall(vim.version)
local current = ok and string.format("%d.%d.%d", v.major, v.minor, v.patch) or "an older version"
-- vim.loop (not vim.uv -- that alias doesn't exist pre-0.10) to detect the
-- machine's arch, since Neovim ships separate release assets per arch and
-- the install command below needs to name the right one.
local machine = (vim.loop.os_uname() or {}).machine or ""
local asset_arch = ({ x86_64 = "x86_64", amd64 = "x86_64", aarch64 = "arm64", arm64 = "arm64" })[machine]
if not asset_arch then
vim.api.nvim_echo({
{
"This config requires Neovim 0.12.0 or newer (found "
.. current
.. "). No prebuilt Neovim release is published for this machine's architecture ("
.. (machine ~= "" and machine or "unknown")
.. "); check https://github.com/neovim/neovim/releases/latest for a build, or install from source.\n",
"ErrorMsg",
},
}, true, {})
vim.fn.getchar()
os.exit(1)
end
local sysname = ((vim.loop.os_uname() or {}).sysname or ""):lower()
local is_mac = sysname == "darwin"
local asset = (is_mac and "nvim-macos-" or "nvim-linux-") .. asset_arch
local lines = {
{ "This config requires Neovim 0.12.0 or newer (found " .. current .. ", " .. machine .. ").\n\n", "ErrorMsg" },
{ "Install a current build into ~/.local (no root needed):\n", "None" },
{ " curl -LO https://github.com/neovim/neovim/releases/latest/download/" .. asset .. ".tar.gz\n", "None" },
{ " mkdir -p ~/.local && tar xzf " .. asset .. ".tar.gz --strip-components=1 -C ~/.local\n", "None" },
{ " rm " .. asset .. ".tar.gz\n", "None" },
{ " # then make sure ~/.local/bin is on your PATH\n\n", "Comment" },
}
if not is_mac then
vim.list_extend(lines, {
{ "Or system-wide (needs root):\n", "None" },
{ " curl -LO https://github.com/neovim/neovim/releases/latest/download/" .. asset .. ".tar.gz\n", "None" },
{ " sudo mkdir -p /opt/nvim && sudo tar xzf " .. asset .. ".tar.gz --strip-components=1 -C /opt/nvim\n", "None" },
{ " sudo ln -sf /opt/nvim/bin/nvim /usr/local/bin/nvim\n", "None" },
{ " rm " .. asset .. ".tar.gz\n", "None" },
})
end
vim.api.nvim_echo(lines, true, {})
vim.fn.getchar()
os.exit(1)
end
-- Leader keys MUST be set before lazy.setup() so lazy-loaded `keys = {...}`
-- specs resolve <leader> correctly when they register their keymaps.
vim.g.mapleader = ","
vim.g.maplocalleader = "\\"
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.uv.fs_stat(lazypath) then
local out = vim.fn.system({
"git",
"clone",
"--filter=blob:none",
"https://github.com/folke/lazy.nvim.git",
"--branch=stable",
lazypath,
})
if vim.v.shell_error ~= 0 then
vim.api.nvim_echo({
{ "Failed to clone lazy.nvim:\n", "ErrorMsg" },
{ out, "WarningMsg" },
}, true, {})
vim.fn.getchar()
os.exit(1)
end
end
vim.opt.rtp:prepend(lazypath)
require("config.options")
require("config.keymaps")
require("config.autocmds")
require("lazy").setup("plugins", {
change_detection = { notify = false },
})
+47
View File
@@ -0,0 +1,47 @@
{
"aerial.nvim": { "branch": "master", "commit": "28fe6e822ae344544c379d60fcb13c9519a1f08a" },
"alpha-nvim": { "branch": "main", "commit": "6c6a89d5b068b5251c8bdf0dd57bb921bcfeeb09" },
"blink.cmp": { "branch": "main", "commit": "78336bc89ee5365633bcf754d93df01678b5c08f" },
"blink.lib": { "branch": "main", "commit": "5876dd95deeb70aadbe9f1c0b7117a135061cdac" },
"bufferline.nvim": { "branch": "main", "commit": "655133c3b4c3e5e05ec549b9f8cc2894ac6f51b3" },
"claudecode.nvim": { "branch": "main", "commit": "2390c6e45c4789072c293ac69de051d169668b29" },
"conform.nvim": { "branch": "master", "commit": "619363c30309d29ffa631e67c8183f2a72caa373" },
"emmet-vim": { "branch": "master", "commit": "92ef2f74f4093edc99db5e9e4cf7e40116a85bd6" },
"flash.nvim": { "branch": "main", "commit": "fcea7ff883235d9024dc41e638f164a450c14ca2" },
"gitsigns.nvim": { "branch": "main", "commit": "eb60cc7b94c46005237fd34170d76f3a089a90aa" },
"gruvbox.nvim": { "branch": "main", "commit": "154eb5ff5b96d0641307113fa385eaf0d36d9796" },
"indent-blankline.nvim": { "branch": "master", "commit": "d28a3f70721c79e3c5f6693057ae929f3d9c0a03" },
"lazy.nvim": { "branch": "main", "commit": "85c7ff3711b730b4030d03144f6db6375044ae82" },
"lualine.nvim": { "branch": "master", "commit": "221ce6b2d999187044529f49da6554a92f740a96" },
"markdown-preview.nvim": { "branch": "master", "commit": "a923f5fc5ba36a3b17e289dc35dc17f66d0548ee" },
"mason-lspconfig.nvim": { "branch": "main", "commit": "47059d71b42d74b0a1e9f61c1d99d301039c3b5b" },
"mason.nvim": { "branch": "main", "commit": "2a6940af80375532e5e9e7c1f2fc6319a1b7a69d" },
"mini.align": { "branch": "main", "commit": "8cebe89d82f20e6d21b27346bcde4b4b6244a9f1" },
"mini.bufremove": { "branch": "main", "commit": "4cfbb201b2760da79ca6e1a00e9f67cbc99cbb8f" },
"neo-tree.nvim": { "branch": "v3.x", "commit": "ebd66767191714e008ce73b769518a763ff31bdc" },
"nui.nvim": { "branch": "main", "commit": "de740991c12411b663994b2860f1a4fd0937c130" },
"nvim-autopairs": { "branch": "master", "commit": "7b9923abad60b903ece7c52940e1321d39eccc79" },
"nvim-dap": { "branch": "master", "commit": "9e848e09a697ee95302a3ef2dd43fd6eb709e570" },
"nvim-dap-python": { "branch": "master", "commit": "1808458eba2b18f178f990e01376941a42c7f93b" },
"nvim-lint": { "branch": "master", "commit": "a219b2c9e5b4765e5c845aba119dad55806fcaf1" },
"nvim-lspconfig": { "branch": "master", "commit": "d224a1920728ba129880efc700d4a0180ac4ecbb" },
"nvim-surround": { "branch": "main", "commit": "8b47db616ef658b8fc27e61db2896aa2f40134de" },
"nvim-treesitter": { "branch": "main", "commit": "4916d6592ede8c07973490d9322f187e07dfefac" },
"nvim-treesitter-context": { "branch": "master", "commit": "b311b30818951d01f7b4bf650521b868b3fece16" },
"nvim-web-devicons": { "branch": "master", "commit": "dad71387de386a946b123079d0e53f23028f3abd" },
"persistence.nvim": { "branch": "main", "commit": "b20b2a7887bd39c1a356980b45e03250f3dce49c" },
"plenary.nvim": { "branch": "master", "commit": "74b06c6c75e4eeb3108ec01852001636d85a932b" },
"rainbow-delimiters.nvim": { "branch": "master", "commit": "a798325b7f36acc62741d1029930a7b96d4dd4bf" },
"rustaceanvim": { "branch": "main", "commit": "88575b98bb9937fb9983ddec5e532b67e75ce677" },
"snacks.nvim": { "branch": "main", "commit": "882c996cf28183f4d63640de0b4c02ec886d01f2" },
"telescope-fzf-native.nvim": { "branch": "main", "commit": "b25b749b9db64d375d782094e2b9dce53ad53a40" },
"telescope.nvim": { "branch": "master", "commit": "427b576c16792edad01a92b89721d923c19ad60f" },
"undotree": { "branch": "main", "commit": "02b69aed427b848c4dca483fc5e9524b6019c296" },
"vim-fugitive": { "branch": "master", "commit": "3b753cf8c6a4dcde6edee8827d464ba9b8c4a6f0" },
"vim-table-mode": { "branch": "master", "commit": "bb025308a45c67c7c8f0763ba37bc2ee3f534df0" },
"vim-tmux-navigator": { "branch": "master", "commit": "e41c431a0c7b7388ae7ba341f01a0d217eb3a432" },
"vim-visual-multi": { "branch": "master", "commit": "a6975e7c1ee157615bbc80fc25e4392f71c344d4" },
"vim-zettel": { "branch": "master", "commit": "554d16bf65b04ae86b92db08a44b4fbcbc31023f" },
"vimwiki": { "branch": "dev", "commit": "a54a3002e229c4b43d69ced170ff77663a5b2c40" },
"which-key.nvim": { "branch": "main", "commit": "3aab2147e74890957785941f0c1ad87d0a44c15a" }
}
+53
View File
@@ -0,0 +1,53 @@
-- autoread (options.lua) only reloads a buffer once Vim checks the file's
-- timestamp, which by default only happens on a handful of internal
-- triggers. Force that check on focus/buffer-switch/idle so external edits
-- (e.g. from claudecode.nvim) show up immediately instead of surfacing the
-- stale-buffer prompt later.
vim.api.nvim_create_autocmd({ "FocusGained", "BufEnter", "CursorHold", "CursorHoldI" }, {
group = vim.api.nvim_create_augroup("CheckTimeOnFocus", { clear = true }),
callback = function()
if vim.fn.getcmdwintype() == "" then
vim.cmd("checktime")
end
end,
})
local augroup = vim.api.nvim_create_augroup("EnterBuffer", { clear = true })
vim.api.nvim_create_autocmd({ "BufNewFile", "BufReadPre", "FileReadPre", "BufEnter" }, {
group = augroup,
pattern = "Makefile",
callback = function()
vim.bo.expandtab = false
vim.bo.softtabstop = 0
vim.bo.shiftwidth = 8
end,
})
vim.api.nvim_create_autocmd({ "BufNewFile", "BufReadPre", "FileReadPre", "BufEnter" }, {
group = augroup,
pattern = { "*.asm", "*.S" },
callback = function()
vim.bo.softtabstop = 8
vim.bo.shiftwidth = 8
end,
})
-- claude-sandbox (docker/claude-sandbox) sets CLAUDE_SANDBOX, never present
-- on a normal host session, so this only fires inside the sandbox: open
-- claudecode.nvim's terminal on startup rather than requiring <leader>ac,
-- since starting a Claude session is the entire point of being in there.
-- :ClaudeCode is defined by the plugin itself, so it doesn't exist until
-- claudecode.nvim has actually loaded - it's lazy-loaded on the <leader>ac
-- keymap only (no `cmd` in its lazy.nvim spec), so force that load first.
if vim.env.CLAUDE_SANDBOX then
vim.api.nvim_create_autocmd("VimEnter", {
once = true,
callback = function()
vim.schedule(function()
require("lazy").load({ plugins = { "claudecode.nvim" } })
vim.cmd("ClaudeCode")
end)
end,
})
end
+59
View File
@@ -0,0 +1,59 @@
local map = vim.keymap.set
-- Insert/command-mode escape via ,,
map("i", ",,", "<Esc>", { desc = "Escape insert mode" })
map("c", ",,", "<Esc><Esc>", { desc = "Escape command mode" })
-- Terminal mode is a separate mode from Insert, so the above doesn't cover
-- it (e.g. the Claude Code terminal). <C-\><C-n> is the real escape back to
-- Normal mode there.
map("t", ",,", "<C-\\><C-n>", { desc = "Escape terminal mode" })
-- Move by display line, not physical line
map("n", "j", "gj", { desc = "Move down by display line" })
map("n", "k", "gk", { desc = "Move up by display line" })
-- Clear search highlight
map("n", "<leader><leader><Esc>", ":nohl<CR>", { desc = "Clear search highlight" })
-- Hex mode
map("n", "<leader>\\he", ":%!xxd<CR>", { desc = "Hex dump buffer" })
map("n", "<leader>\\hd", ":%!xxd -r<CR>", { desc = "Undo hex dump" })
-- Toggle whitespace display
map("n", "<leader><leader>lw", ":set list!<CR>", { desc = "Toggle whitespace display" })
-- List registers / marks
map("n", "<leader><leader>lr", ":reg<CR>", { desc = "List registers" })
map("n", "<leader><leader>lm", ":marks<CR>", { desc = "List marks" })
-- Prev/next buffer
map("n", "<leader>q", ":bp<CR>", { desc = "Previous buffer" })
map("n", "<leader>w", ":bn<CR>", { desc = "Next buffer" })
-- System clipboard
map({ "n", "v" }, "<leader>y", '"+y', { desc = "Yank to system clipboard" })
map({ "n", "v" }, "<leader>Y", '"+Y', { desc = "Yank line to system clipboard" })
map({ "n", "v" }, "<leader>p", '"+p', { desc = "Paste from system clipboard" })
map({ "n", "v" }, "<leader>P", '"+P', { desc = "Paste before from system clipboard" })
map({ "n", "v" }, "<leader>x", '"+x', { desc = "Cut to system clipboard" })
map("n", "<leader>dd", '"+dd', { desc = "Delete line to system clipboard" })
-- Make split
map("n", "<leader>-", ":sp<Space>", { desc = "Horizontal split" })
map("n", "<leader><Bar>", ":vsp<Space>", { desc = "Vertical split" })
-- Open init.lua
map("n", "<leader>v", ":e $MYVIMRC<CR>", { desc = "Edit init.lua" })
-- Low-light / daylight mode
map("n", "<leader><leader>ll", ":set background=dark<CR>", { desc = "Dark background" })
map("n", "<leader><leader>LL", ":set background=light<CR>", { desc = "Light background" })
-- Ranger / trailing whitespace utilities
map("n", "<leader>ra", function() require("util").ranger() end, { desc = "Open ranger" })
map("n", "<leader>rw", function() require("util").delete_trailing_ws() end, { desc = "Delete trailing whitespace" })
-- Terminal-mode Ctrl-Z otherwise goes straight to the embedded process (e.g.
-- suspending the claude CLI inside claudecode.nvim's terminal with no way to
-- fg it back) instead of suspending Neovim itself, like Ctrl-Z does by
-- default in Normal mode.
map("t", "<C-z>", "<C-\\><C-n><C-z>", { desc = "Suspend Neovim" })
+87
View File
@@ -0,0 +1,87 @@
local opt = vim.opt
-- Completion / buffers
opt.omnifunc = "syntaxcomplete#Complete"
opt.hidden = true
opt.autoread = true
-- No swap/backup files
opt.swapfile = false
opt.backup = false
opt.number = true
-- matchit is built into Neovim's runtime already; no `runtime macros/matchit.vim` needed
opt.encoding = "utf-8"
opt.lazyredraw = true
opt.backspace = { "indent", "eol", "start" }
opt.history = 700
opt.incsearch = true
opt.ignorecase = true
opt.smartcase = true
opt.scrolloff = 15
opt.wildmode = { "longest", "list", "full" }
opt.wildmenu = true
opt.wildcharm = vim.api.nvim_replace_termcodes("<C-Z>", true, true, true):byte()
opt.mouse = "a"
-- Tabs
opt.tabstop = 4
opt.softtabstop = 4
opt.expandtab = true
opt.shiftwidth = 4
opt.wrap = true
opt.linebreak = true
opt.list = false
opt.ruler = true
opt.showmatch = true
opt.viewoptions:remove("options")
opt.shortmess:append("I")
opt.laststatus = 2
opt.showmode = false
opt.termguicolors = true
vim.g.enable_bold_font = 1
-- Have vrm extend background color to full terminal screen
vim.cmd("set t_ut=")
opt.splitbelow = true
opt.splitright = true
opt.updatetime = 300
opt.shortmess:append("c")
-- Clipboard: copy-only, via dotfiles/bin/clipboard-copy. This is a small
-- script (rather than nvim's built-in raw-OSC-52-only provider) because
-- reaching the real clipboard needs more than a raw OSC 52 write: it picks
-- pbcopy/xclip/wl-copy when nvim is running locally, and when none of those
-- are available (nvim on a remote host with no local display) sends a
-- single plain OSC 52 write. Any tmux hops between here and the real
-- terminal relay that onward themselves via the pane-set-clipboard hook in
-- tmux.conf, so this works whether nvim is local or several ssh/tmux hops
-- deep, on any machine sharing this dotfiles repo, without needing to know
-- how many hops there are. Paste is intentionally left as a no-op: OSC 52
-- read support is inconsistent across terminals and can hang waiting for a
-- reply, so use the terminal's own native paste (e.g. Ctrl-Shift-V)
-- instead.
local clipboard_copy = vim.fn.expand("~/dotfiles/bin/clipboard-copy")
vim.g.clipboard = {
name = "clipboard-copy (copy-only)",
copy = {
["+"] = { clipboard_copy },
["*"] = { clipboard_copy },
},
paste = {
["+"] = function() return {} end,
["*"] = function() return {} end,
},
}
+66
View File
@@ -0,0 +1,66 @@
return {
{ "folke/snacks.nvim", lazy = false },
{
"coder/claudecode.nvim",
dependencies = { "folke/snacks.nvim" },
opts = {
terminal = {
provider = "snacks",
split_side = "right",
split_width_percentage = 0.30,
},
diff_opts = {
keep_terminal_focus = true,
-- Unified (single-buffer, VS Code-style) diff instead of side-by-side
-- splits: it only ever opens one new window, so it can't add a second
-- split and squish the rest of the layout the way the side-by-side
-- diff sometimes did.
layout = "unified",
},
},
config = function(_, opts)
require("claudecode").setup(opts)
-- claudecode picks a window for the diff by scanning all windows for the
-- first non-sidebar/non-terminal one, not the one you're actually
-- focused on, so the diff could land away from your active pane. Prefer
-- the current window when it's a normal editing window.
local diff = require("claudecode.diff")
local default_find_main_editor_window = diff._find_main_editor_window
diff._find_main_editor_window = function()
local win = vim.api.nvim_get_current_win()
local buf = vim.api.nvim_win_get_buf(win)
local buftype = vim.api.nvim_buf_get_option(buf, "buftype")
local win_config = vim.api.nvim_win_get_config(win)
local is_floating = win_config.relative and win_config.relative ~= ""
local is_diff_win = vim.api.nvim_win_get_option(win, "diff")
if not is_floating and not is_diff_win and buftype ~= "terminal" and buftype ~= "prompt" then
return win
end
return default_find_main_editor_window()
end
-- claudecode's unified diff buffers pick up treesitter's foldexpr via
-- the normal FileType autocmd, so they otherwise inherit the global
-- foldlevelstart and open partially collapsed. Fully expand folds in
-- these specifically instead of changing the default for all buffers.
vim.api.nvim_create_autocmd("BufWinEnter", {
group = vim.api.nvim_create_augroup("ClaudeCodeDiffUnfold", { clear = true }),
callback = function(args)
if vim.b[args.buf].claudecode_inline_diff then
vim.wo.foldlevel = 99
end
end,
})
end,
keys = {
{ "<leader>ac", "<cmd>ClaudeCode<CR>", desc = "Toggle Claude Code" },
{ "<leader>af", "<cmd>ClaudeCodeFocus<CR>", desc = "Focus Claude Code" },
{ "<leader>ab", "<cmd>ClaudeCodeAdd %<CR>", desc = "Add buffer to Claude Code" },
{ "<leader>as", "<cmd>ClaudeCodeSend<CR>", mode = "v", desc = "Send selection to Claude Code" },
{ "<leader>aa", "<cmd>ClaudeCodeDiffAccept<CR>", desc = "Accept Claude Code diff" },
{ "<leader>ad", "<cmd>ClaudeCodeDiffDeny<CR>", desc = "Deny Claude Code diff" },
},
},
}
+10
View File
@@ -0,0 +1,10 @@
return {
"saghen/blink.cmp",
version = "*",
opts = {
keymap = { preset = "default" },
appearance = { nerd_font_variant = "mono" },
completion = { documentation = { auto_show = true } },
sources = { default = { "lsp", "path", "buffer" } },
},
}
+33
View File
@@ -0,0 +1,33 @@
return {
{
"mfussenegger/nvim-dap",
keys = {
{ "<leader>db", function() require("dap").toggle_breakpoint() end, desc = "Toggle breakpoint" },
{ "<leader>dc", function() require("dap").continue() end, desc = "Debug: continue" },
{ "<leader>di", function() require("dap").step_into() end, desc = "Debug: step into" },
{ "<leader>do", function() require("dap").step_over() end, desc = "Debug: step over" },
{ "<leader>dO", function() require("dap").step_out() end, desc = "Debug: step out" },
{ "<leader>dr", function() require("dap").repl.toggle() end, desc = "Debug: toggle REPL" },
{ "<leader>dq", function() require("dap").terminate() end, desc = "Debug: terminate" },
},
},
{
-- Configures nvim-dap for Python using debugpy, installed via Mason
-- (:MasonInstall debugpy) so it lives in an isolated venv rather than
-- needing debugpy in every project's own environment.
"mfussenegger/nvim-dap-python",
dependencies = { "mfussenegger/nvim-dap" },
ft = "python",
config = function()
local python = vim.fn.stdpath("data") .. "/mason/packages/debugpy/venv/bin/python"
if vim.fn.executable(python) == 0 then
vim.notify(
"debugpy not installed: run :MasonInstall debugpy to enable Python debugging",
vim.log.levels.WARN
)
return
end
require("dap-python").setup(python)
end,
},
}
+137
View File
@@ -0,0 +1,137 @@
return {
{
"nvim-neo-tree/neo-tree.nvim",
branch = "v3.x",
dependencies = {
"nvim-lua/plenary.nvim",
"nvim-tree/nvim-web-devicons",
"MunifTanjim/nui.nvim",
},
keys = {
{ "<leader>t", ":Neotree toggle<CR>", desc = "Toggle file explorer" },
{
"<leader>cd",
function()
vim.cmd("cd " .. vim.fn.fnameescape(vim.fn.expand("%:p:h")))
vim.cmd("pwd")
vim.cmd("Neotree dir=" .. vim.fn.fnameescape(vim.fn.getcwd()))
end,
desc = "cd to current file's directory and open explorer",
},
},
opts = {},
},
{
"nvim-telescope/telescope.nvim",
dependencies = {
"nvim-lua/plenary.nvim",
{ "nvim-telescope/telescope-fzf-native.nvim", build = "make" },
},
cmd = "Telescope",
keys = {
{ "<leader>.", "<cmd>Telescope lsp_document_symbols<CR>", desc = "Jump to symbol" },
{ "<leader>;", "<cmd>Telescope buffers<CR>", desc = "Jump to buffer" },
{
"<leader>be",
"<cmd>Telescope buffers sort_mru=true ignore_current_buffer=true<CR>",
desc = "Jump to buffer (MRU)",
},
{ "<leader>ag", "<cmd>Telescope live_grep<CR>", desc = "Grep" },
{ "<leader>?", "<cmd>Telescope keymaps<CR>", desc = "Search keybindings" },
},
config = function()
local telescope = require("telescope")
telescope.setup({})
telescope.load_extension("fzf")
end,
},
{
"folke/flash.nvim",
event = "VeryLazy",
opts = {},
keys = {
{ "<leader>/", function() require("flash").jump() end, mode = { "n", "x", "o" }, desc = "Flash jump" },
{ "<leader>s", function() require("flash").jump({ search = { mode = "search" } }) end, mode = { "n", "x", "o" }, desc = "Flash search jump" },
{ "<leader><leader>s", function() require("flash").treesitter() end, mode = { "n", "x", "o" }, desc = "Flash treesitter" },
{ "<leader>f", function() require("flash").jump({ search = { forward = true, wrap = false } }) end, mode = { "n", "x", "o" }, desc = "Flash forward" },
},
},
{ "kylechui/nvim-surround", event = { "BufReadPost", "BufNewFile" }, opts = {} },
{ "windwp/nvim-autopairs", event = "InsertEnter", opts = {} },
{
"nvim-treesitter/nvim-treesitter-context",
event = { "BufReadPost", "BufNewFile" },
dependencies = { "nvim-treesitter/nvim-treesitter" },
opts = {},
},
{
"echasnovski/mini.align",
version = "*",
keys = {
{ "ga=", mode = { "n", "x" }, desc = "Align on =" },
{ "ga:", mode = { "n", "x" }, desc = "Align on :" },
},
config = function()
require("mini.align").setup({
mappings = { start = "ga", start_with_preview = "" },
})
end,
},
{ "echasnovski/mini.bufremove", version = "*", keys = {
{ "<leader><leader>c", function() require("mini.bufremove").delete(0, false) end, desc = "Close buffer" },
} },
{
"mg979/vim-visual-multi",
branch = "master",
keys = { "<C-n>" },
init = function()
vim.g.VM_maps = {
["Find Under"] = "<C-n>",
["Find Subword Under"] = "<C-n>",
}
end,
},
{
"christoomey/vim-tmux-navigator",
lazy = false,
-- Plugin's own default mappings rely on a Vim-only <C-w> terminal escape
-- that Neovim doesn't support, so :terminal buffers (e.g. claudecode.nvim)
-- leak the raw keystrokes to the embedded process. Disable them and
-- define our own, using Neovim's actual terminal-mode escape.
init = function()
vim.g.tmux_navigator_no_mappings = 1
end,
keys = {
{ "<C-h>", "<cmd>TmuxNavigateLeft<CR>" },
{ "<C-j>", "<cmd>TmuxNavigateDown<CR>" },
{ "<C-k>", "<cmd>TmuxNavigateUp<CR>" },
{ "<C-l>", "<cmd>TmuxNavigateRight<CR>" },
{ "<C-h>", "<C-\\><C-n><cmd>TmuxNavigateLeft<CR>", mode = "t" },
{ "<C-j>", "<C-\\><C-n><cmd>TmuxNavigateDown<CR>", mode = "t" },
{ "<C-k>", "<C-\\><C-n><cmd>TmuxNavigateUp<CR>", mode = "t" },
{ "<C-l>", "<C-\\><C-n><cmd>TmuxNavigateRight<CR>", mode = "t" },
},
},
{
"mattn/emmet-vim",
ft = { "html", "css" },
init = function()
vim.g.user_emmet_install_global = 0
end,
config = function()
vim.api.nvim_create_autocmd("FileType", {
pattern = { "html", "css" },
callback = function() vim.cmd("EmmetInstall") end,
})
end,
},
}
+55
View File
@@ -0,0 +1,55 @@
return {
{
"stevearc/conform.nvim",
event = { "BufWritePre" },
cmd = { "ConformInfo" },
opts = {
formatters_by_ft = {
css = { "prettier" },
svelte = { "prettier" },
pcss = { "prettier" },
html = { "prettier" },
typescript = { "prettier" },
typescriptreact = { "prettier" },
javascript = { "prettier" },
javascriptreact = { "prettier" },
json = { "prettier" },
yaml = { "prettier" },
markdown = { "prettier" },
python = { "ruff_format" },
rust = { "rustfmt" },
lua = { "stylua" },
},
format_on_save = {
lsp_format = "fallback",
timeout_ms = 2000,
},
},
},
{
"mfussenegger/nvim-lint",
event = { "BufWritePost", "BufReadPost", "InsertLeave" },
config = function()
require("lint").linters_by_ft = {
javascript = { "eslint_d" },
typescript = { "eslint_d" },
javascriptreact = { "eslint_d" },
typescriptreact = { "eslint_d" },
svelte = { "eslint_d" },
}
if vim.fn.executable("shellcheck") == 1 then
require("lint").linters_by_ft.sh = { "shellcheck" }
else
vim.notify("shellcheck not found: skipping shell linting", vim.log.levels.WARN)
end
vim.api.nvim_create_autocmd({ "BufWritePost", "BufReadPost", "InsertLeave" }, {
callback = function()
require("lint").try_lint()
end,
})
end,
},
}
+4
View File
@@ -0,0 +1,4 @@
return {
{ "lewis6991/gitsigns.nvim", event = { "BufReadPre", "BufNewFile" }, opts = {} },
{ "tpope/vim-fugitive", cmd = { "Git", "Gdiffsplit", "Gvdiffsplit", "Gread", "Gwrite", "Gclog" } },
}
+59
View File
@@ -0,0 +1,59 @@
local servers = { "basedpyright", "ruff" }
local npm_servers = { "ts_ls", "svelte", "html", "cssls", "bashls" }
local function has_public_npm()
if vim.fn.executable("npm") ~= 1 then
return false
end
local result = vim.fn.system("npm config get registry")
return result:find("registry.npmjs.org") ~= nil
end
if has_public_npm() then
vim.list_extend(servers, npm_servers)
else
vim.notify(
"public npm registry unavailable: skipping LSP servers " .. table.concat(npm_servers, ", "),
vim.log.levels.WARN
)
end
return {
{
"mason-org/mason.nvim",
opts = {},
},
{
"mason-org/mason-lspconfig.nvim",
dependencies = { "mason-org/mason.nvim" },
opts = {
ensure_installed = servers,
},
},
{
"neovim/nvim-lspconfig",
dependencies = { "mason-org/mason-lspconfig.nvim", "saghen/blink.cmp", "saghen/blink.lib" },
config = function()
local capabilities = require("blink.cmp").get_lsp_capabilities()
vim.api.nvim_create_autocmd("LspAttach", {
group = vim.api.nvim_create_augroup("LspKeymaps", { clear = true }),
callback = function(args)
local opts = { buffer = args.buf }
vim.keymap.set("n", "K", vim.lsp.buf.hover, opts)
vim.keymap.set("n", "gd", vim.lsp.buf.definition, opts)
vim.keymap.set("n", "gy", vim.lsp.buf.type_definition, opts)
vim.keymap.set("n", "gi", vim.lsp.buf.implementation, opts)
vim.keymap.set("n", "gr", vim.lsp.buf.references, opts)
vim.keymap.set("n", "rn", vim.lsp.buf.rename, opts)
vim.keymap.set("v", "ff", function() vim.lsp.buf.format({ async = true }) end, opts)
end,
})
for _, server in ipairs(servers) do
vim.lsp.config(server, { capabilities = capabilities })
vim.lsp.enable(server)
end
end,
},
}
+31
View File
@@ -0,0 +1,31 @@
return {
{
"stevearc/aerial.nvim",
dependencies = { "nvim-treesitter/nvim-treesitter", "nvim-tree/nvim-web-devicons" },
keys = {
{ "<leader>ct", "<cmd>AerialToggle<CR>", desc = "Toggle symbol outline" },
},
opts = {},
},
{
"jiaoshijie/undotree",
dependencies = { "nvim-lua/plenary.nvim" },
keys = {
{ "<leader>u", function() require("undotree").toggle() end, desc = "Toggle undo tree" },
},
opts = {},
},
{
"folke/persistence.nvim",
event = "BufReadPre",
opts = {},
},
{
"folke/which-key.nvim",
event = "VeryLazy",
opts = {},
},
}
+25
View File
@@ -0,0 +1,25 @@
return {
{
"mrcjkb/rustaceanvim",
version = "^6",
lazy = false,
init = function()
vim.g.rustaceanvim = {
tools = {},
server = {
on_attach = function(_, bufnr)
vim.keymap.set("n", "<localleader>rr", function()
vim.cmd.RustLsp("run")
end, { buffer = bufnr })
end,
},
}
end,
},
{
"mason-org/mason-lspconfig.nvim",
opts = {
automatic_enable = { exclude = { "rust_analyzer" } },
},
},
}
+100
View File
@@ -0,0 +1,100 @@
return {
"nvim-treesitter/nvim-treesitter",
lazy = false,
build = ":TSUpdate",
config = function()
local parsers = {
"javascript",
"typescript",
"tsx",
"svelte",
"html",
"css",
"scss",
"json",
"yaml",
"python",
"rust",
"bash",
"lua",
"vim",
"vimdoc",
"query",
"markdown",
"markdown_inline",
"regex",
}
-- Install parsers (skip if the tree-sitter CLI isn't available; parsers
-- already cached from a previous run on this machine still work fine)
if vim.fn.executable("tree-sitter") == 1 then
require("nvim-treesitter").install(parsers)
else
local uname = vim.uv.os_uname()
local sysname = (uname.sysname or ""):lower()
local machine = uname.machine or ""
local is_mac = sysname == "darwin"
local install_cmd
if is_mac then
local arch_part = ({ x86_64 = "x64", amd64 = "x64", aarch64 = "arm64", arm64 = "arm64" })[machine] or "x64"
local asset = "tree-sitter-macos-" .. arch_part
install_cmd = " curl -L https://github.com/tree-sitter/tree-sitter/releases/latest/download/"
.. asset
.. ".gz | gunzip > ~/.local/bin/tree-sitter && chmod +x ~/.local/bin/tree-sitter"
else
install_cmd = " cargo install --root ~/.local tree-sitter-cli"
end
vim.notify(
"tree-sitter CLI not found: skipping install of missing parsers.\n"
.. "Install into ~/.local/bin:\n"
.. install_cmd,
vim.log.levels.WARN
)
end
-- Enable treesitter highlighting, folding, and indentation for
-- filetypes with an installed parser. Note some filetype names differ
-- from their parser names (e.g. "bash" parser -> "sh" filetype).
vim.api.nvim_create_autocmd("FileType", {
pattern = {
"javascript",
"typescript",
"typescriptreact",
"javascriptreact",
"svelte",
"html",
"css",
"scss",
"json",
"yaml",
"python",
"rust",
"sh",
"lua",
"vim",
"help",
"query",
"markdown",
},
callback = function()
-- Highlighting, provided by Neovim. Falls back to Vim's legacy
-- syntax/indent if no parser is installed for this filetype.
if not pcall(vim.treesitter.start) then
return
end
-- Folding, provided by Neovim
vim.wo.foldexpr = "v:lua.vim.treesitter.foldexpr()"
vim.wo.foldmethod = "expr"
-- Indentation, provided by nvim-treesitter
vim.bo.indentexpr = "v:lua.require'nvim-treesitter'.indentexpr()"
end,
})
-- General fold display settings (not treesitter-specific)
vim.opt.foldlevel = 2
vim.opt.foldlevelstart = 2
vim.opt.foldcolumn = "5"
end,
}
+101
View File
@@ -0,0 +1,101 @@
return {
{
"ellisonleao/gruvbox.nvim",
priority = 1000,
opts = {
italic = { strings = true, comments = true, operators = false, folds = true },
invert_selection = true,
invert_signs = false,
},
config = function(_, opts)
require("gruvbox").setup(opts)
vim.o.background = "dark"
local ok = pcall(vim.cmd.colorscheme, "gruvbox")
if not ok then
vim.cmd.colorscheme("desert")
end
end,
},
{
"nvim-lualine/lualine.nvim",
dependencies = { "nvim-tree/nvim-web-devicons" },
event = "VeryLazy",
opts = {
options = { theme = "gruvbox" },
},
},
{
"akinsho/bufferline.nvim",
dependencies = { "nvim-tree/nvim-web-devicons" },
event = "VeryLazy",
opts = {},
},
{
"lukas-reineke/indent-blankline.nvim",
main = "ibl",
event = { "BufReadPost", "BufNewFile" },
opts = {},
},
{
"HiPhish/rainbow-delimiters.nvim",
event = { "BufReadPost", "BufNewFile" },
},
{ "nvim-tree/nvim-web-devicons", opts = {} },
{
"goolord/alpha-nvim",
dependencies = { "nvim-tree/nvim-web-devicons" },
event = "VimEnter",
config = function()
local alpha = require("alpha")
local dashboard = require("alpha.themes.dashboard")
local function fortune_header()
-- cowsay -l prints a header line, then wraps every cow name across
-- several space-separated lines (not one name per line), so a name
-- has to be picked from all of them split on whitespace, not by
-- treating a whole (possibly multi-name) line as one name.
local raw = vim.fn.systemlist("cowsay -l")
local cow = "default"
local names = {}
for i = 2, #raw do
for name in raw[i]:gmatch("%S+") do
table.insert(names, name)
end
end
if #names > 0 then
cow = names[math.random(#names)]
end
local moods = { "b", "d", "g", "p", "s", "t", "w", "y" }
local mood = moods[math.random(#moods)]
local cmd = string.format(
"fortune -a -s | fmt -80 -s | cowthink -%s -f %s -n",
mood,
cow
)
local ok, lines = pcall(vim.fn.systemlist, cmd)
if not ok or #lines == 0 then
return { "Welcome to Neovim" }
end
return lines
end
dashboard.section.header.val = fortune_header()
dashboard.section.buttons.val = {
dashboard.button("e", " New file", ":enew<CR>"),
dashboard.button("f", " Find file", ":Telescope find_files<CR>"),
dashboard.button("g", " Live grep", ":Telescope live_grep<CR>"),
dashboard.button("s", " Restore session", ":lua require('persistence').load()<CR>"),
dashboard.button("q", " Quit", ":qa<CR>"),
}
alpha.setup(dashboard.opts)
vim.keymap.set("n", "<leader><leader>o", ":Alpha<CR>")
end,
},
}
+64
View File
@@ -0,0 +1,64 @@
vim.g.vimwiki_list = {
{
path = "~/synced-notebooks/bradwiki/",
syntax = "markdown",
ext = ".md",
diary_rel_path = "daily_notes/",
diary_index = "index",
diary_header = "Daily Notes",
auto_tags = 1,
auto_diary_index = 1,
auto_generate_links = 1,
auto_generate_tags = 1,
},
{
path = "~/synced-notebooks/work_notebook/",
syntax = "markdown",
ext = ".md",
diary_rel_path = "daily_notes/",
diary_index = "index",
diary_header = "Daily Notes",
auto_tags = 1,
auto_diary_index = 1,
auto_generate_links = 1,
auto_generate_tags = 1,
},
{ path = "~/synced-notebooks/privatewiki/", syntax = "markdown", ext = ".md" },
{ path = "~/synced-notebooks/sharedwiki/", syntax = "markdown", ext = ".md" },
}
vim.g.vimwiki_map_prefix = "<Leader>m"
vim.g.vimwiki_folding = "list"
vim.g.markdown_folding = 1
vim.g.zettel_options = {
{},
{
front_matter = { { "tags", "" }, { "type", "note" } },
template = "~/work_notebook/templates/daily_note.tpl",
},
}
vim.g.table_mode_map_prefix = "<Leader><Leader>t"
vim.api.nvim_create_autocmd("FileType", {
group = vim.api.nvim_create_augroup("VimwikiFiletype", { clear = true }),
pattern = "vimwiki",
callback = function()
vim.opt_local.breakindent = true
vim.opt_local.breakindentopt = "shift:2"
end,
})
return {
{ "vimwiki/vimwiki", lazy = false },
{ "michal-h21/vim-zettel", dependencies = { "vimwiki/vimwiki" }, lazy = false },
{ "dhruvasagar/vim-table-mode", cmd = "TableModeToggle" },
{
"iamcco/markdown-preview.nvim",
build = function() vim.fn["mkdp#util#install"]() end,
ft = { "markdown", "vimwiki" },
keys = {
{ "<localleader>v", "<Plug>MarkdownPreviewToggle", ft = { "markdown", "vimwiki" }, desc = "Toggle markdown preview" },
},
},
}
+28
View File
@@ -0,0 +1,28 @@
local M = {}
-- Open ranger from within nvim; if it writes a chosen path to its return
-- file, open that file. Mirrors the old vimscript Ranger() function.
function M.ranger()
if vim.fn.executable("ranger") == 0 then
vim.notify("ranger is not installed", vim.log.levels.WARN)
return
end
local tmpfile = vim.fn.system("mktemp -u"):gsub("\n", "")
vim.cmd(("silent !RANGER_RETURN_FILE=%s ranger"):format(tmpfile))
if vim.fn.filereadable(tmpfile) == 1 then
local filetoedit = vim.fn.system("cat " .. tmpfile)
vim.cmd("edit " .. filetoedit)
vim.fn.delete(tmpfile)
end
vim.cmd("redraw!")
end
-- Delete trailing whitespace on the current buffer, preserving cursor
-- position. Mirrors the old vimscript DeleteTrailingWS() function.
function M.delete_trailing_ws()
local view = vim.fn.winsaveview()
vim.cmd([[silent! %s/\s\+$//ge]])
vim.fn.winrestview(view)
end
return M
+4 -3
View File
@@ -22,7 +22,6 @@ shadow-exclude = [
"class_g = 'lattedock'",
"_GTK_FRAME_EXTENTS@:c"
]
xinerama-shadow-crop = true;
### Blur
blur:{
@@ -52,8 +51,6 @@ backend = "glx";
#backend = "xrender";
vsync = true;
glx-no-stencil = true;
glx-no-rebind-pixmap = true;
xrender-sync-fence = true;
dbus=true;
@@ -74,3 +71,7 @@ mark-wmwin-focused = true;
mark-ovredir-focused = true;
use-ewmh-active-win = true;
detect-client-leader = true;
## Fixes graphical issues (jitter/flickering) in Jetbrains IDEs
## TODO: This doesn't fully fix the issue
unredir-if-possible = false;
@@ -1,5 +1,5 @@
{
"groups": {
"stack_depth": { "fg": "gb:light1", "bg": "gb:light4", "attrs": ["bold"] }
"stack_depth": { "fg": "gray1", "bg": "gray10", "attrs": ["bold"] }
}
}
@@ -0,0 +1,5 @@
{
"groups": {
"stack_depth": { "fg": "gb:light1", "bg": "gb:light4", "attrs": ["bold"] }
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
}
},
"pdb": {
"colorscheme": "default",
"colorscheme": "gruvbox",
"theme": "default"
},
"shell": {
+129 -5
View File
@@ -1,6 +1,130 @@
export TERMINAL=alacritty
export EDITOR=nvim
export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/ssh-agent.socket"
alias vim=nvim
XDG_DATA_DIRS="/var/lib/flatpak/exports/share:$XDG_DATA_DIRS"
# Override in .profile.local for custom repo
DOTFILES_LOCAL_REPOSITORY="https://gitbox.branderson.io/branderson/dotfiles-local"
# Programs expected to be installed by tools
# These are the name on $PATH, not the package name
dependencies=(
tree
nvim
ag
fzf
node
cowsay
fortune
)
# Load dotfiles directory into DOTFILES_DIR
# Dotfiles directory should be in environment or ~/.dotfiles-config
if [ -f "$HOME/.dotfiles-config" ]; then
source "$HOME/.dotfiles-config"
fi
# PATHs
## Earlier paths in PATH checked first
if [ -d /usr/games ]; then
export PATH="/usr/games:$PATH"
fi
if [ -d /opt/homebrew/opt/coreutils/libexec/gnubin ]; then
export PATH="/opt/homebrew/opt/coreutils/libexec/gnubin:$PATH"
fi
if [ -d /opt/homebrew/opt/python@3.13/libexec/bin ]; then
export PATH="/opt/homebrew/opt/python@3.13/libexec/bin:$PATH"
fi
if [ -d /opt/homebrew/bin ]; then
export PATH="/opt/homebrew/bin:$PATH"
fi
## Keep user bin directories first in PATH so they're preferred
if [ -d "$DOTFILES_DIR/bin" ]; then
export PATH="$DOTFILES_DIR/bin:$PATH"
fi
## OS-specific binaries
if [[ $OSTYPE == 'darwin'* ]]; then
if [[ -d "$DOTFILES_DIR/macbin" ]]; then
export PATH="$DOTFILES_DIR/macbin:$PATH"
fi
elif [[ $OSTYPE == 'linux'* ]]; then
if [[ -d "$DOTFILES_DIR/linbin" ]]; then
export PATH="$DOTFILES_DIR/linbin:$PATH"
fi
fi
if [ -d "$HOME/.local/bin" ]; then
export PATH="$HOME/.local/bin:$PATH"
fi
if [ -d "$HOME/bin" ]; then
export PATH="$HOME/bin:$PATH"
fi
source "$HOME/.bash_functions"
# Set up brew if on macOS
if [[ $OSTYPE == 'darwin'* ]]; then
if [ -f /opt/homebrew/bin/brew ]; then
eval "$(/opt/homebrew/bin/brew shellenv)"
fi
fi
# Prefer alacritty terminal if installed
if program_installed alacritty; then
export TERMINAL=alacritty
fi
# Prefer neovim, if installed
if program_installed nvim; then
export EDITOR=nvim
alias vim=nvim
fi
if program_installed powerline; then
# Make powerline support Python virtualenvs
export POWERLINE_COMMAND="$DOTFILES_DIR/scripts/powerline-wrapper.sh"
fi
# alias make='nocorrect make'
# alias git='nocorrect git'
# Crontab
#alias crontab-e='vim ~/.crontab && crontab ~/.crontab'
#alias sl="sl -laF"
# Put flatpak apps in XDG
if program_installed flatpak; then
export XDG_DATA_DIRS="/var/lib/flatpak/exports/share:$XDG_DATA_DIRS"
fi
# pyenv paths
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
# Make sure ssh-agent is available
if [ -n "$LOAD_SSH_KEYS" ] && [ "$LOAD_SSH_KEYS" -eq 1 ]; then
if program_installed ssh-add && [ ! -z "$SSH_AUTH_SOCK" ] && [ -d "$SSH_AUTH_SOCK" ] > /dev/null 2>&1 ; then
# Add SSH keys to agent if not already added
## Find existing keys, supporting one layer of folder organization
public_keys=($(find "$HOME/.ssh/" -maxdepth 2 -name "*.pub"))
if [ ${#public_keys[@]} -gt 0 ]; then
for public_key in "${public_keys[@]}"; do
if [[ $(uname -s) == Linux* ]]; then
# Add SSH keys only if not already present
ssh-add -l | grep -q `ssh-keygen -lf "$public_key" | awk '{print $2}'` || ssh-add -k
break
elif [[ $(uname -s) == Darwin* ]]; then
# Add SSH keys only if not already present
ssh-add -l | grep -q `ssh-keygen -lf "$public_key" | awk '{print $2}'` || ssh-add --apple-use-keychain
break
fi
done
fi
fi
fi
if program_installed i3; then
# Set QT environment variables
export QT_QPA_PLATFORMTHEME="qt5ct"
fi
# Extra domains claude-sandbox's firewall should allow beyond its defaults
export ALLOWED_DOMAINS=""
# Gitea host, used by bin/gitea-pr and claude-sandbox's git-push-guard hook
export GITEA_SSH_HOST="gitbox.bradina.localdomain"
export GITEA_API="https://gitbox.branderson.io/api/v1"
@@ -0,0 +1,37 @@
[Unit]
After=network-online.target nss-lookup.target
ConditionPathIsDirectory=/etc/pacman.d/gnupg/
ConditionPathIsReadWrite=/etc/pacman.d/gnupg/
ConditionFileIsExecutable=/usr/bin/archlinux-keyring-wkd-sync
Description=Refresh existing keys of archlinux-keyring
Wants=network-online.target
[Service]
ExecStart=/usr/bin/archlinux-keyring-wkd-sync
CapabilityBoundingSet=
DeviceAllow=
LockPersonality=true
MemoryDenyWriteExecute=true
NoNewPrivileges=true
PrivateDevices=true
PrivateTmp=true
ProtectClock=true
ProtectControlGroups=true
ProtectHome=true
ProtectHostname=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectProc=noaccess
ProtectSystem=strict
ReadWritePaths=/etc/pacman.d/gnupg
RemoveIPC=true
RestrictAddressFamilies=~AF_PACKET AF_NETLINK
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@resources
@@ -0,0 +1,11 @@
[Unit]
Description=Refresh existing PGP keys of archlinux-keyring regularly
[Timer]
OnCalendar=weekly
Persistent=true
RandomizedDelaySec=12h
[Install]
WantedBy=timers.target
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=Update wallpapers when display change detected
[Service]
Type=simple
User=brad
Environment=DISPLAY=:0
ExecStartPre=/bin/sleep 3
# TODO: The file might not be here if ~/.dotfiles-config set to somewhere else
ExecStart=/home/brad/dotfiles/bin/update-wallpaper
[Install]
WantedBy=multi-user.target
+163 -2
View File
@@ -1,2 +1,163 @@
# run-shell "~/.tmux/verify_tmux_version.sh"
source-file "~/.tmux/tmux_3.1c.conf"
# ----------------------------------------------------------------------------
# Prefix
# ----------------------------------------------------------------------------
set -g prefix C-a
unbind C-b
bind C-a send-prefix
bind Tab last-window
# ----------------------------------------------------------------------------
# General behavior
# ----------------------------------------------------------------------------
set-option -g set-titles on
set -g default-terminal "screen-256color"
set -g history-limit 10000
set -sg escape-time 1
set-option -g base-index 1
setw -g pane-base-index 1
setw -g aggressive-resize on
# ----------------------------------------------------------------------------
# Windows and panes
# ----------------------------------------------------------------------------
bind T swap-window -t 1
bind | split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"
bind c new-window -c "#{pane_current_path}"
bind h select-pane -L
bind j select-pane -D
bind k select-pane -U
bind l select-pane -R
# Smart pane switching with awareness of vim splits
is_vim='echo "#{pane_current_command}" | grep -iqE "(^|\/)g?(view|n?vim?)(diff)?$"'
# Claude Code also wants its own use of C-j (insert newline), same as vim
# wants C-h/j/k/l for its own window navigation -- forward it raw instead of
# letting tmux swallow it for pane-switching.
is_vim_or_claude='echo "#{pane_current_command}" | grep -iqE "(^|\/)(g?(view|n?vim?)(diff)?|claude)$"'
bind -n C-h if-shell "$is_vim" "send-keys C-h" "select-pane -L"
bind -n C-j if-shell "$is_vim_or_claude" "send-keys C-j" "select-pane -D"
bind -n C-k if-shell "$is_vim" "send-keys C-k" "select-pane -U"
bind -n C-l if-shell "$is_vim" "send-keys C-l" "select-pane -R"
bind -n 'C-\' if-shell "$is_vim" "send-keys 'C-\\'" "select-pane -l"
bind -r C-h select-window -t :-
bind -r C-l select-window -t :+
bind a setw synchronize-panes
# ----------------------------------------------------------------------------
# Copy mode and mouse
# ----------------------------------------------------------------------------
setw -g mode-keys vi
set-option -g -q mouse on
bind y run 'tmux save-buffer - | $HOME/dotfiles/bin/clipboard-copy'
bind C-y run 'tmux save-buffer - | $HOME/dotfiles/bin/clipboard-copy'
# Let escape sequences a pane explicitly DCS-wraps for passthrough (e.g.
# sixel image data, see the iTerm2 terminal-features line below) reach the
# real terminal, even when nested behind ssh/tmux layers. Not used for the
# clipboard -- that has its own relay via the pane-set-clipboard hook above.
# allow-passthrough was added in tmux 3.3; older tmux doesn't understand the
# option, so only set it when the running tmux is new enough.
if-shell "tmux -V | awk '{print $2}' | awk -F. '{if ($1*1000+$2 >= 3003) exit 0; else exit 1}'" 'set -g allow-passthrough on'
# Let tmux tell Shift+Enter apart from plain Enter and forward that
# distinction inward, so Claude Code's Shift+Enter-inserts-newline shortcut
# works. extended-keys/terminal-features need tmux >= 3.2. The terminal
# name is matched with '*' rather than the commonly-suggested 'xterm*',
# because a nested tmux's "terminal" is the outer tmux, which reports
# itself using default-terminal above (screen-256color) -- 'xterm*' would
# never match that, breaking the nested case this is meant to support.
#
# Investigated as a suspect for multi-line paste getting corrupted with
# literal CSI 27;<mod>;<key>~ garbage in place of newlines (see Claude Code
# issues #43169 and #30239) -- turning this off did not fix that paste
# corruption, so it's an upstream Claude Code paste-tokenizer bug, not
# something caused by this setting. Restored.
if-shell "tmux -V | awk '{print $2}' | awk -F. '{if ($1*1000+$2 >= 3002) exit 0; else exit 1}'" 'set -s extended-keys on; set -as terminal-features "*:extkeys"'
# Accept pane-originated OSC 52 clipboard-set attempts into a real tmux
# buffer. Only on-vs-off matters here -- off would refuse to even look at
# these escapes; "on" (rather than "external") is needed so the pane-set-
# clipboard hook below has an actual buffer to read.
set -g set-clipboard on
# tmux's own mechanism for forwarding an accepted clipboard-set (or its own
# copy-mode copies) to the real terminal is generating a fresh OSC 52 write
# via the "Ms" terminfo capability -- confirmed unreliable here (both
# pane-forwarded and tmux-generated OSC 52 silently failed to reach the
# clipboard even with Ms correctly registered against Alacritty). Relay it
# ourselves instead: whenever any pane sets the clipboard, grab what was
# just captured above and write it straight to this client's own terminal
# device. If that's a real terminal, this is the final hop and we're done.
# If it's actually another tmux one layer further out (e.g. this tmux is
# itself running inside an outer tmux over ssh), that data becomes pane
# output there, and the *same* hook -- present there too, since it's the
# same dotfiles -- fires again. That recurses cleanly through however many
# tmux layers are actually in the chain, without any of them needing to
# know the total count in advance.
set-hook -g pane-set-clipboard 'run-shell "tmux save-buffer - | $HOME/dotfiles/bin/tmux-clipboard-relay #{client_tty}"'
# The default/no-argument form of copy-pipe (used by double/triple-click and
# a couple of default bindings) pipes to this option instead of a literal
# command -- route it through the same working script as the explicit
# copy-pipe bindings in tmux/modern.conf.
set -g copy-command '$HOME/dotfiles/bin/clipboard-copy'
# iTerm2 supports SIXEL images (since 3.3.0) but doesn't set a distinct TERM
# to identify itself, so this matches on its usual "xterm-256color" report --
# any other client reporting that same TERM will also be treated as
# sixel-capable. Alacritty is deliberately left off: upstream Alacritty has
# no sixel support, so telling tmux otherwise would replace today's clean
# text placeholder with garbled escape data.
set -as terminal-features ",xterm-256color:sixel"
# tmux renamed the copy-mode key table (vi-copy -> copy-mode-vi) and changed
# bind-key's table flag (-t -> -T) in 2.4. Source the file matching the
# running tmux so old tmux never parses syntax it doesn't understand.
if-shell "tmux -V | awk '{print $2}' | awk -F. '{if ($1*1000+$2 < 2004) exit 0; else exit 1}'" 'source-file ~/.tmux/legacy.conf' 'source-file ~/.tmux/modern.conf'
# ----------------------------------------------------------------------------
# Misc bindings
# ----------------------------------------------------------------------------
bind-key C run-shell "cal $(date +%Y)"
bind r source-file ~/.tmux.conf \; display "Reloaded config"
# A pane whose connection died mid-redraw (e.g. an SSH session killed by the
# laptop sleeping) can get stuck with tmux thinking the cursor is hidden,
# since that's tracked from the pane's own output and nothing ever sent the
# matching show-cursor sequence. Re-run it in the active pane to fix it.
bind R send-keys "printf '\\033[?25h'" Enter
# ----------------------------------------------------------------------------
# Powerline (guarded so machines without it don't error on load)
# ----------------------------------------------------------------------------
run-shell "command -v powerline-daemon >/dev/null 2>&1 && powerline-daemon -q; true"
run-shell "command -v powerline-config >/dev/null 2>&1 && powerline-config tmux setup; true"
# ----------------------------------------------------------------------------
# Status bar
# ----------------------------------------------------------------------------
# Flash the status bar when this client's prefix key is armed, so nested
# tmux sessions (e.g. laptop -> ssh -> devbox tmux) make it obvious which
# layer is about to receive the next keystroke. Falls back to whatever
# status-style powerline set above (captured here) the rest of the time.
# Colors match gruvbox's own visual/selection convention (bg0 on orange).
set -g @status_style_armed "bg=#fe8019,fg=#282828"
run-shell 'tmux set-option -g @status_style_normal "$(tmux show-options -gv status-style)"'
set -g status-style "#{?client_prefix,#{@status_style_armed},#{@status_style_normal}}"
# ----------------------------------------------------------------------------
# Plugins (TPM)
# ----------------------------------------------------------------------------
set -g @plugin 'tmux-plugins/tpm'
set -g @plugin 'tmux-plugins/tmux-sensible'
# Local overrides
if-shell "[ -f ~/.tmux_local.conf ]" 'source ~/.tmux_local.conf'
# Initialize TPM (must be the last line)
run '~/.tmux/plugins/tpm/tpm'
+16
View File
@@ -0,0 +1,16 @@
# tmux < 2.4 copy-mode key table (vi-copy, old -t flag) and mouse-wheel/
# PageUp passthrough. Sourced conditionally from tmux.conf; see
# tmux/modern.conf for tmux >= 2.4.
bind-key -t vi-copy PPage page-up
bind-key -t vi-copy NPage page-down
bind-key -t vi-copy WheelUpPane halfpage-up
bind-key -t vi-copy WheelDownPane halfpage-down
bind-key -t vi-copy v begin-selection
bind-key -t vi-copy y copy-pipe "$HOME/dotfiles/bin/clipboard-copy"
unbind-key -t vi-copy Enter
bind-key -t vi-copy Enter copy-pipe "$HOME/dotfiles/bin/clipboard-copy"
bind-key -n PPage if-shell -F "#{alternate_on}" "send-keys PPage" "copy-mode -e; send-keys PPage"
bind-key -n WheelUpPane if-shell -F -t = "#{alternate_on}" "send-keys -M" "select-pane -t =; copy-mode -e; send-keys -M"
bind-key -n WheelDownPane if-shell -F -t = "#{alternate_on}" "send-keys -M" "select-pane -t =; send-keys -M"
+24
View File
@@ -0,0 +1,24 @@
# tmux >= 2.4 copy-mode key table and mouse-wheel/PageUp passthrough.
# Sourced conditionally from tmux.conf; see tmux/legacy.conf for older tmux.
bind-key -T copy-mode-vi PPage send-keys -X page-up
bind-key -T copy-mode-vi NPage send-keys -X page-down
bind-key -T copy-mode-vi WheelUpPane send-keys -X halfpage-up
bind-key -T copy-mode-vi WheelDownPane send-keys -X halfpage-down
bind-key -T copy-mode-vi v send-keys -X begin-selection
bind-key -T copy-mode-vi y send-keys -X copy-pipe "$HOME/dotfiles/bin/clipboard-copy"
unbind-key -T copy-mode-vi Enter
bind-key -T copy-mode-vi Enter send-keys -X copy-pipe "$HOME/dotfiles/bin/clipboard-copy"
# The default MouseDragEnd1Pane binding (copy-selection-and-cancel) relies on
# tmux's own OSC 52 write via the Ms terminfo capability, which doesn't
# reliably reach every terminal (confirmed broken against Alacritty here).
# Route mouse-drag copies through the same clipboard-copy script as keyboard
# yanks instead, so both use the mechanism that's actually proven to work.
bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "$HOME/dotfiles/bin/clipboard-copy"
# Let mouse wheel / PageUp pass through to apps using the alternate screen
# (vim, less, etc.) instead of always entering copy-mode
bind-key -T root PPage if-shell -F "#{alternate_on}" "send-keys PPage" "copy-mode -e; send-keys PPage"
bind-key -T root WheelUpPane if-shell -F -t = "#{alternate_on}" "send-keys -M" "select-pane -t =; copy-mode -e; send-keys -M"
bind-key -T root WheelDownPane if-shell -F -t = "#{alternate_on}" "send-keys -M" "select-pane -t =; send-keys -M"
-37
View File
@@ -1,37 +0,0 @@
# PREFIX | splits horizontally and PREFIX - splits vertically
bind | split-window -h
bind - split-window -v
# mouse support
set -g mode-mouse on # scrolling
set -g mouse-select-pane on # clicking into panes
set -g mouse-resize-pane on # resizing panes
set -g mouse-select-window on # select windows
# toggle mouse support
bind m \
set -g mode-mouse on \;\
set -g mouse-resize-pane on \;\
set -g mouse-select-pane on \;\
set -g mouse-select-window on \;\
display 'Mouse: ON'
bind M \
set -g mode-mouse off \;\
set -g mouse-resize-pane off \;\
set -g mouse-select-pane off \;\
set -g mouse-select-window off \;\
display 'Mouse: OFF'
# ----------------------
# set some pretty colors
# ----------------------
# set pane colors - hilight the active pane
# set-option -g pane-border-fg colour235 #base02
# set-option -g pane-active-border-fg colour240 #base01
# # colorize messages in the command line
# set-option -g message-bg black #base02
# set-option -g message-fg brightred #orange
source "$HOME/.tmux/tmux_shared.conf"
-2
View File
@@ -1,2 +0,0 @@
source "$HOME/.tmux/tmux_shared.conf"
-48
View File
@@ -1,48 +0,0 @@
# PREFIX | splits horizontally and PREFIX - splits vertically
bind | split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"
# open new window with pwd
bind c new-window -c "#{pane_current_path}"
# PREFIX T makes current window index 1
bind T swap-window -t 1
# use vim movement for panes
bind h select-pane -L
bind j select-pane -D
bind k select-pane -U
bind l select-pane -R
# mouse support
set-option -g -q mouse on
# Copy Mode
bind-key -t root PPage if-shell -F "#{alternate_on}" "send-keys PPage" "copy-mode -e; send-keys PPage"
bind-key -t vi-copy PPage page-up
bind-key -t vi-copy NPage page-down
# Mouse Wheel
bind-key -t root WheelUpPane if-shell -F -t = "#{alternate_on}" "send-keys -M" "select-pane -t =; copy-mode -e; send-keys -M"
bind-key -t root WheelDownPane if-shell -F -t = "#{alternate_on}" "send-keys -M" "select-pane -t =; send-keys -M"
bind-key -t vi-copy WheelUpPane halfpage-up
bind-key -t vi-copy WheelDownPane halfpage-down
# Make sure the client is always using the correct environment
# eval $(tmux switch-client \; show-environment -s)
# Copy/Paste
#
# provide access to clipboard
# set-option -g default-command "reattach-to-user-namespace -l zsh"
# set-window-option -g automatic-rename on
# use vim keybindings in copy mode
setw -g mode-keys vi
# v selection
bind-key -t vi-copy v begin-selection
bind-key -t vi-copy y copy-pipe "reattach-to-user-namespace pbcopy"
# make enter use copy pipe
unbind -t vi-copy Enter
bind-key -t vi-copy Enter copy-pipe "reattach-to-user-namespace pbcopy"
-42
View File
@@ -1,42 +0,0 @@
# PREFIX | splits horizontally and PREFIX - splits vertically
bind | split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"
# open new window with pwd
bind c new-window -c "#{pane_current_path}"
# use vim keybindings in copy mode
setw -g mode-keys vi
# Copy Mode
bind-key -T copy-mode-vi PPage send-keys -X page-up
bind-key -T copy-mode-vi NPage send-keys -X page-down
# Mouse Wheel
bind-key -T copy-mode-vi WheelUpPane send-keys -X halfpage-up
bind-key -T copy-mode-vi WheelDownPane send-keys -X halfpage-down
# v selection
bind-key -T copy-mode-vi v send-keys -X begin-selection
bind-key -T copy-mode-vi y send-keys -X copy-pipe "reattach-to-user-namespace pbcopy"
# make enter use copy pipe
unbind -T copy-mode-vi Enter
bind-key -T copy-mode-vi Enter send-keys -X copy-pipe "reattach-to-user-namespace pbcopy"
set-option -g -q mouse on
set-option -g -q mouse-resize-pane on
set-option -g -q mouse-select-pane on
set-option -g -q mouse-select-window on
# Enable middle click in urxvt
set-option -ga terminal-override ',rxvt-uni*:XT:Ms=\E]52;%p1%s;%p2%s\007'
# Copy Mode
bind-key -T root PPage if-shell -F "#{alternate_on}" "send-keys PPage" "copy-mode -e; send-keys PPage"
# Mouse Wheel
bind-key -T root WheelUpPane if-shell -F -t = "#{alternate_on}" "send-keys -M" "select-pane -t =; copy-mode -e; send-keys -M"
bind-key -T root WheelDownPane if-shell -F -t = "#{alternate_on}" "send-keys -M" "select-pane -t =; send-keys -M"
source ~/.tmux/tmux_shared.conf
-42
View File
@@ -1,42 +0,0 @@
# PREFIX | splits horizontally and PREFIX - splits vertically
bind | split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"
# open new window with pwd
bind c new-window -c "#{pane_current_path}"
# use vim keybindings in copy mode
setw -g mode-keys vi
# Copy Mode
bind-key -T copy-mode-vi PPage send-keys -X page-up
bind-key -T copy-mode-vi NPage send-keys -X page-down
# Mouse Wheel
bind-key -T copy-mode-vi WheelUpPane send-keys -X halfpage-up
bind-key -T copy-mode-vi WheelDownPane send-keys -X halfpage-down
# v selection
bind-key -T copy-mode-vi v send-keys -X begin-selection
bind-key -T copy-mode-vi y send-keys -X copy-pipe "reattach-to-user-namespace pbcopy"
# make enter use copy pipe
unbind -T copy-mode-vi Enter
bind-key -T copy-mode-vi Enter send-keys -X copy-pipe "reattach-to-user-namespace pbcopy"
set-option -g -q mouse on
set-option -g -q mouse-resize-pane on
set-option -g -q mouse-select-pane on
set-option -g -q mouse-select-window on
# Enable middle click in urxvt
set-option -ga terminal-override ',rxvt-uni*:XT:Ms=\E]52;%p1%s;%p2%s\007'
# Copy Mode
bind-key -T root PPage if-shell -F "#{alternate_on}" "send-keys PPage" "copy-mode -e; send-keys PPage"
# Mouse Wheel
bind-key -T root WheelUpPane if-shell -F -t = "#{alternate_on}" "send-keys -M" "select-pane -t =; copy-mode -e; send-keys -M"
bind-key -T root WheelDownPane if-shell -F -t = "#{alternate_on}" "send-keys -M" "select-pane -t =; send-keys -M"
source ~/.tmux/tmux_shared.conf
-106
View File
@@ -1,106 +0,0 @@
# use UTF8
# set -g utf8
# set-window-option -g utf8 on
# 256 color mode
set -g default-terminal "screen-256color"
# set-option -sa terminal-overrides ',screen*:RGB'
# set-option -ga terminal-overrides ',screen*:Tc'
# set-option -sa terminal-overrides ',xterm*:RGB'
# set-option -ga terminal-overrides ',xterm*:Tc'
# set-option -sa terminal-overrides ',tmux*:RGB'
# set-option -ga terminal-overrides ',tmux*:Tc'
# set-option -sa terminal-overrides ',rxvt*:RGB'
# set-option -ga terminal-overrides ',rxvt*:Tc'
# Properly handle ssh agent forwarding when reconnecting to session
# set-environment -g 'SSH_AUTH_SOCK' ~/.ssh/ssh_auth_sock
set-option -g set-titles on
# Tmux attach will create a new session if one doesn't exist
# new-session -n $HOST
# set scrollback history
set -g history-limit 10000
# set Ctrl-a as prefix and unbind Ctrl-b
set -g prefix C-a
unbind C-b
# last active window
# bind-key C-a last-window
bind Tab last-window
# pass prefix through to application
bind C-a send-prefix
# don't constrain size unless smaller client looking at window
setw -g aggressive-resize on
# set command delay
set -sg escape-time 1
# set base index to 1 rather than 0
set-option -g base-index 1
setw -g pane-base-index 1
# use PREFIX-r to reload config
bind r source-file ~/.tmux/tmux_shared.conf \; display "Reloaded config"
# PREFIX T makes current window index 1
bind T swap-window -t 1
# use vim movement for panes
bind h select-pane -L
bind j select-pane -D
bind k select-pane -U
bind l select-pane -R
# Smart pane switching with awareness of vim splits
is_vim='echo "#{pane_current_command}" | grep -iqE "(^|\/)g?(view|n?vim?)(diff)?$"'
bind -n C-h if-shell "$is_vim" "send-keys C-h" "select-pane -L"
bind -n C-j if-shell "$is_vim" "send-keys C-j" "select-pane -D"
bind -n C-k if-shell "$is_vim" "send-keys C-k" "select-pane -U"
bind -n C-l if-shell "$is_vim" "send-keys C-l" "select-pane -R"
bind -n 'C-\' if-shell "$is_vim" "send-keys 'C-\\'" "select-pane -l"
# bind C-h and C-l to cycle through panes
bind -r C-h select-window -t :-
bind -r C-l select-window -t :+
# Synchonize keyboard input across panes
bind a setw synchronize-panes
# open calendar
bind-key C run-shell "cal $(date +%Y)"
# Make sure the client is always using the correct environment
# eval $(tmux switch-client \; show-environment -s)
bind y run 'tmux save-buffer - | reattach-to-user-namespace pbcopy '
bind C-y run 'tmux save-buffer - | reattach-to-user-namespace pbcopy '1
# ----------------------
# set some pretty colors
# ----------------------
# set pane colors - hilight the active pane
# set-option -g pane-border-fg colour235 #base02
# set-option -g pane-active-border-fg colour240 #base01
# Show active pane
# set-window-option -g window-style 'bg=#181818'
# set-window-option -g window-active-style 'bg=black'
# set-window-option -g pane-active-border-style ''
# set-window-option -g pane-active-border-style 'fg=#181818'
# colorize messages in the command line
# set-option -g message-bg black #base02
# set-option -g message-fg brightred #orange
# Powerline
run-shell "powerline-daemon -q"
run-shell "powerline-config tmux setup"
# source-file $POWERLINE_ROOT/bindings/tmux/powerline.conf
# Local overrides
if-shell "[ -f ~/.tmux_local.conf ]" 'source ~/.tmux_local.conf'
-27
View File
@@ -1,27 +0,0 @@
#!/bin/bash
verify_tmux_version() {
tmux_home=~/.tmux
tmux_version="$(tmux -V | cut -c 6-)"
# This is necessary because 3.1c gives a syntax error in bc
# TODO Fix this it will break with the next tmux version
if [[ $tmux_version -eq "3.1c"]] ; then
tmux source-file $tmux_home/tmux_3.1c.conf
exit
if [[ $(echo "$tmux_version >= 2.5" | bc) -eq 1 ]] ; then
tmux source-file $tmux_home/tmux_3.1c.conf
exit
elif [[ $(echo "$tmux_version >= 2.1" | bc) -eq 1 ]] ; then
tmux source-file $tmux_home/tmux_2.1_to_2.5.conf
exit
elif [[ $(echo "$tmux_version >= 1.9" | bc) -eq 1 ]] ; then
tmux source-file $tmux_home/tmux_1.9_to_2.1.conf
exit
else
tmux source-file $tmux_home/tmux_1.9_down.conf
exit
fi
}
verify_tmux_version
+1
View File
@@ -0,0 +1 @@
ACTION=="change", SUBSYSTEM=="drm", RUN+="/usr/bin/systemctl start --no-block update-wallpapers.service"
+90 -3
View File
@@ -1,3 +1,30 @@
# Returns 0 if program is installed and 1 otherwise
function program_installed {
local return_=0
type $1 >/dev/null 2>&1 || { local return_=1; }
return "$return_"
}
function cd {
builtin cd "$@"
if [[ -z "$VIRTUAL_ENV" ]] ; then
# If env folder is found then activate the vitualenv
if [[ -d ./venv ]] ; then
source ./venv/bin/activate
fi
else
# If no longer within virtualenv, deactivate
parentdir="$(dirname "$VIRTUAL_ENV")"
if [[ "$PWD"/ != "$parentdir"/* ]] ; then
deactivate
fi
fi
}
function list_processes() {
# List all processes matching the given process name string
ps aux | grep $(whoami) | grep -i "$1" | grep -v grep
@@ -51,13 +78,19 @@ function check_dotfiles_version {
# Return 2 if the repo is behind the remote
# Return 3 if the repo is ahead of the remote
# Return 4 if the repo has diverged from the remote
if [ ! -d $HOME/dotfiles ]; then
# Return 5 if DOTFILES_DIR is not set
if [ -z $DOTFILES_DIR ]; then
return 5
fi
if [ ! -d $DOTFILES_DIR ]; then
return 2
fi
pushd $HOME/dotfiles > /dev/null
pushd $DOTFILES_DIR > /dev/null
git fetch origin
# TODO: This may be gitbox
git fetch origin > /dev/null
UPSTREAM=${1:-'@{u}'}
LOCAL=$(git rev-parse @)
REMOTE=$(git rev-parse "$UPSTREAM")
@@ -75,3 +108,57 @@ function check_dotfiles_version {
return 4
fi
}
function set_python_roots {
PYTHON_ROOTS=()
if [ -d $HOME/.local/lib/ ]; then
for directory in $(echo $HOME/.local/lib/python*(N)); do
PYTHON_ROOTS+=$directory
done
fi
if [ -d $HOME/.local/share/pipx/venvs/powerline-status/lib/ ]; then
for directory in $(echo $HOME/.local/share/pipx/venvs/powerline-status/lib/python*(N)); do
PYTHON_ROOTS+=$directory
done
fi
if [ -d $HOME/.local/pipx/venvs/powerline-status/lib/ ]; then
for directory in $(echo $HOME/.local/pipx/venvs/powerline-status/lib/python*(N)); do
PYTHON_ROOTS+=$directory
done
fi
if [ -d /usr/lib/ ]; then
for directory in $(echo /usr/lib/python*(N)); do
PYTHON_ROOTS+=$directory
done
fi
if [ -d /usr/local/lib/ ]; then
# TODO: Make this not print warnings when the directory doesn't exit
for directory in $(echo /usr/local/lib/python*(N)); do
PYTHON_ROOTS+=$directory
done
fi
}
function set_powerline_zsh_sources {
set_python_roots
# Powerline plugin from distribution agnostic install directory
## Assemble list of possible sources
declare -ag POWERLINE_ZSH_SOURCES=()
if [[ -a $POWERLINE_ROOT/bindings/zsh/powerline.zsh ]]; then
# Prefer user-set powerline directory
POWERLINE_ZSH_SOURCES+=$POWERLINE_ROOT/bindings/zsh/powerline.zsh
fi
if [ -d /usr/share/powerline ]; then
# Apt installs here
POWERLINE_ZSH_SOURCES+="/usr/share/powerline/bindings/zsh/powerline.zsh"
fi
# If $POWERLINE_ROOT not set or set incorrectly, try default locations
for directory in $PYTHON_ROOTS; do
#for directory in $(echo $template); do
if [ -d "$directory" ]; then
POWERLINE_ZSH_SOURCES+="$directory"/site-packages/powerline/bindings/zsh/powerline.zsh
POWERLINE_ZSH_SOURCES+="$directory"/dist-packages/powerline/bindings/zsh/powerline.zsh
fi
# done
done
}
+196 -190
View File
@@ -1,92 +1,91 @@
# Gruvbox colors
# source "$HOME/.vim/bundle/gruvbox/gruvbox_256palette.sh"
[[ -f "$HOME/dotfiles/dependencies/gruvbox/gruvbox_256palette.sh" ]] && source "$HOME/dotfiles/dependencies/gruvbox/gruvbox_256palette.sh"
# ZSH-specific environment variables
# Override in .profile.local
# Custom functions
source "$HOME/.zsh_functions"
# Path to your oh-my-zsh installation.
export ZSH=$HOME/dotfiles/dependencies/ohmyzsh
#export RUST_SRC_PATH=/usr/local/src/rust/src
# Z
# . $HOME/dotfiles/z/z.sh
# Set name of the theme to load.
# # Look in ~/.oh-my-zsh/themes/
# # Optionally, if you set this to "random", it'll load a random theme each
# # time that oh-my-zsh is loaded.
## oh-my-zsh
# Automatically update oh-my-zsh without asking
DISABLE_UPDATE_PROMPT=true
# Disable oh-my-zsh auto-update checks
DISABLE_AUTO_UPDATE=false
# How often to check for oh-my-zsh updates
UPDATE_ZSH_DAYS=30
# Disable cowsay on login by default.
NO_ZSHRC_COWSAY=1
# Theme to load [~/.oh-my-zsh/themes/]
ZSH_THEME="agnoster"
# Base16 Shell
# BASE16_SHELL="$HOME/.config/base16-shell/base16-tomorrow.dark.sh"
# [[ -s $BASE16_SHELL ]] && source $BASE16_SHELL
# BASE16_SHELL="$HOME/.config/base16-shell/base16-ocean.dark.sh"
# [[ -s $BASE16_SHELL ]] && source $BASE16_SHELL
#
# # Uncomment the following line to use case-sensitive completion.
# # CASE_SENSITIVE="true"
#
# # Uncomment the following line to disable bi-weekly auto-update checks.
# # DISABLE_AUTO_UPDATE="true"
#
# # Uncomment the following line to change how often to auto-update (in days).
# # export UPDATE_ZSH_DAYS=13
#
# # Uncomment the following line to disable colors in ls.
# # DISABLE_LS_COLORS="true"
#
# # Uncomment the following line to disable auto-setting terminal title.
# # DISABLE_AUTO_TITLE="true"
#
# # Uncomment the following line to enable command auto-correction.
# # ENABLE_CORRECTION="true"
#
# # Uncomment the following line to display red dots whilst waiting for completion.
# # COMPLETION_WAITING_DOTS="true"
#
# # Uncomment the following line if you want to disable marking untracked files
# # under VCS as dirty. This makes repository status check for large repositories
# # much, much faster.
# # DISABLE_UNTRACKED_FILES_DIRTY="true"
#
# # Uncomment the following line if you want to change the command execution time
# # stamp shown in the history command output.
# # The optional three formats: "mm/dd/yyyy"|"dd.mm.yyyy"|"yyyy-mm-dd"
# # HIST_STAMPS="mm/dd/yyyy"
#
# # Would you like to use another custom folder than $ZSH/custom?
# # ZSH_CUSTOM=/path/to/new-custom-folder
# Automatic window title setting
DISABLE_AUTO_TITLE="true"
# Command correction support
ENABLE_CORRECTION="false"
# Case-sensitive completion
CASE_SENSITIVE="false"
# ls colors
DISABLE_LS_COLORS="false"
# Red ... on completion waiting
COMPLETION_WAITING_DOTS="true"
# # zsh-syntax-highlighting must come last
plugins=(archlinux colorize command-not-found cp git github bower grunt npm macos)
# tmux
zmodload zsh/zpty
# # User configuration
# Enable environment variables as CD directories
setopt cdablevars
# Enable correction for mistyped commands
unsetopt correctall
# alias make='nocorrect make'
# alias git='nocorrect git'
#PATHs
#export PATH=$HOME/bin:/usr/local/bin:$PATH
#export MANPATH="/usr/local/man:$MANPATH"
#PATH="$(ruby -e 'puts Gem.user_dir')/bin:$PATH"
# Add user cowpath to COWPATH
#COWPATH="$COWPATH:$HOME/dotfiles/cowfiles"
# Make a random (cow?) with a random face say something
# fortune -a | fmt -80 -s | cowthink -$(shuf -n 1 -e b d g p s t w y) -f $(shuf -n 1 -e $(cowsay -l | tail -n +2)) -n
# Uncomment the following line if you want to disable marking untracked files
# under VCS as dirty. This makes repository status check for large repositories
# much, much faster.
# DISABLE_UNTRACKED_FILES_DIRTY="true"
#
# Uncomment the following line if you want to change the command execution time
# stamp shown in the history command output.
# The optional three formats: "mm/dd/yyyy"|"dd.mm.yyyy"|"yyyy-mm-dd"
# HIST_STAMPS="mm/dd/yyyy"
# User profiles
source $HOME/.profile
# Define $POWERLINE_ROOT
if [ -f $HOME/.profile.local ]; then
set -a
source $HOME/.profile.local
set +a
fi
source $ZSH/oh-my-zsh.sh
# Custom functions
source "$HOME/.zsh_functions"
if [ -n "$DOTFILES_DIR" ] && [ -d "$DOTFILES_DIR" ]; then
# Load dependencies
## oh-my-zsh
if [ ! -f "$DOTFILES_DIR/dependencies/ohmyzsh/oh-my-zsh.sh" ]; then
cd "$DOTFILES_DIR"
git submodule update --init
cd -
fi
export ZSH=$DOTFILES_DIR/dependencies/ohmyzsh
## Gruvbox colors
[[ -f "$DOTFILES_DIR/dependencies/gruvbox/gruvbox_256palette.sh" ]] && source "$DOTFILES_DIR/dependencies/gruvbox/gruvbox_256palette.sh"
fi
missing_dependencies=()
for dependency in "${dependencies[@]}"; do
if ! program_installed $dependency; then
missing_dependencies+="$dependency"
fi
done
if [ "${#missing_dependencies[@]}" -gt 0 ]; then
echo "Missing dependencies: ${missing_dependencies[@]}"
fi
# Dircolors
# eval $(dircolors ~/.dircolors)
# tmux
zmodload zsh/zpty
# Enable environment variables as CD directories
setopt cdablevars
# Enable correction for mistyped commands
unsetopt correctall
setopt appendhistory autocd extendedglob
unsetopt beep
alias -s txt=vim
alias -s .c=vim
if [ -d $ZSH ] && [ ! -z $ZSH ]; then
source $ZSH/oh-my-zsh.sh
fi
# Italic support
if [ -f $HOME/.xterm-256color-italic.terminfo ]; then
@@ -96,114 +95,19 @@ if [ -f $HOME/.screen-256color-italic.terminfo ]; then
tic $HOME/.screen-256color-italic.terminfo
fi
# TODO: Remove this
# Use neovim where available
if type nvim > /dev/null 2>&1; then
alias vim='nvim'
fi
# if type nvim > /dev/null 2>&1; then
# alias vim='nvim'
# fi
# # Preferred editor for local and remote sessions
# # if [[ -n $SSH_CONNECTION ]]; then
export EDITOR='vim'
# export EDITOR='vim'
# export TERM=xterm-256color
# TODO ====
# # else
# # export EDITOR='mvim'
# # fi
#
# # Compilation flags
# # export ARCHFLAGS="-arch x86_64"
#
# # ssh
# # export SSH_KEY_PATH="~/.ssh/dsa_id"
#
# # Set personal aliases, overriding those provided by oh-my-zsh libs,
# # plugins, and themes. Aliases can be placed here, though oh-my-zsh
# # users are encouraged to define aliases within the ZSH_CUSTOM folder.
# # For a full list of active aliases, run `alias`.
# #
# # Example aliases
# # alias zshconfig="mate ~/.zshrc"
# # alias ohmyzsh="mate ~/.oh-my-zsh"
alias -s txt=vim
alias -s .c=vim
# Crontab
#alias crontab-e='vim ~/.crontab && crontab ~/.crontab'
# Lines configured by zsh-newuser-install
setopt appendhistory autocd extendedglob
unsetopt beep
bindkey -v
# End of lines configured by zsh-newuser-install
# The following lines were added by compinstall
zstyle :compinstall filename '~/.zshrc'
autoload -Uz compinit
compinit
# End of lines added by compinstall
DEFAULT_USER=brad
# Syntax highlighting configuration
# ZSH_HIGHLIGHT_STYLES[path]='bold'
# ZSH_HIGHLIGHT_STYLES[path_prefix]=none
# ZSH_HIGHLIGHT_STYLES[path_approx]='fg=yellow'
# ZSH_HIGHLIGHT_STYLES[globbing]='fg=yellow'
# Source files
#typeset -ga sources
#sources+=$HOME/dotfiles/shortcuts.txt
# Powerline plugin from distribution agnostic install directory
# pip install --user powerline-status &>/dev/null
if [[ -a $POWERLINE_ROOT/bindings/zsh/powerline.zsh ]]; then
# Prefer user-set powerline directory
sources+=$POWERLINE_ROOT/bindings/zsh/powerline.zsh
else
echo "Attempting to source Powerline bindings from default locations.\nPlease set \$POWERLINE_ROOT in .profile.local"
# If $POWERLINE_ROOT not set or set incorrectly, try default locations
sources+=$HOME/.local/lib/python2.6/site-packages/powerline/bindings/zsh/powerline.zsh
sources+=$HOME/.local/lib/python2.6/dist-packages/powerline/bindings/zsh/powerline.zsh
sources+=$HOME/.local/lib/python2.7/site-packages/powerline/bindings/zsh/powerline.zsh
sources+=$HOME/.local/lib/python2.7/dist-packages/powerline/bindings/zsh/powerline.zsh
sources+=$HOME/.local/lib/python3.6/site-packages/powerline/bindings/zsh/powerline.zsh
sources+=$HOME/.local/lib/python3.6/dist-packages/powerline/bindings/zsh/powerline.zsh
sources+=/usr/lib/python2.6/site-packages/powerline/bindings/zsh/powerline.zsh
sources+=/usr/lib/python2.6/dist-packages/powerline/bindings/zsh/powerline.zsh
sources+=/usr/local/lib/python2.6/site-packages/powerline/bindings/zsh/powerline.zsh
sources+=/usr/local/lib/python2.6/dist-packages/powerline/bindings/zsh/powerline.zsh
sources+=/usr/lib/python2.7/site-packages/powerline/bindings/zsh/powerline.zsh
sources+=/usr/lib/python2.7/dist-packages/powerline/bindings/zsh/powerline.zsh
sources+=/usr/local/lib/python2.7/site-packages/powerline/bindings/zsh/powerline.zsh
sources+=/usr/local/lib/python2.7/dist-packages/powerline/bindings/zsh/powerline.zsh
sources+=/usr/lib/python3.6/site-packages/powerline/bindings/zsh/powerline.zsh
sources+=/usr/lib/python3.6/dist-packages/powerline/bindings/zsh/powerline.zsh
sources+=/usr/local/lib/python3.6/site-packages/powerline/bindings/zsh/powerline.zsh
sources+=/usr/local/lib/python3.6/dist-packages/powerline/bindings/zsh/powerline.zsh
fi
foreach file (`echo $sources`)
if [[ -a $file ]]; then
source $file
fi
end
# Check dotfiles version
check_dotfiles_version
dotfiles_version=$?
if [[ $dotfiles_version = 1 ]]; then
echo "Dotfiles repository not present at $HOME/dotfiles"
elif [[ $dotfiles_version = 2 ]]; then
echo "Dotfiles repository at $HOME/dotfiles is outdated"
elif [[ $dotfiles_version = 3 ]]; then
echo "Dotfiles repository at $HOME/dotfiles is ahead of origin"
elif [[ $dotfiles_version = 4 ]]; then
echo "Dotfiles repository at $HOME/dotfiles has diverged from origin"
fi
# TMuxinator Completion
# source ~/bin/tmuxinator.zsh
# Dircolors
# eval $(dircolors ~/.dircolors)
# Keybindings
bindkey -M vicmd 'K' run-help
@@ -215,26 +119,128 @@ bindkey -M viins ',,' vi-cmd-mode
#bindkey '^o' ranger-cd
#zle -N terminal-clock
#bindkey '^t' terminal-clock
# TMuxinator Completion
# source ~/bin/tmuxinator.zsh
#alias sl="sl -laF"
zstyle :compinstall filename '~/.zshrc'
autoload -Uz compinit
compinit
DEFAULT_USER="$USER"
# Syntax highlighting configuration
# ZSH_HIGHLIGHT_STYLES[path]='bold'
# ZSH_HIGHLIGHT_STYLES[path_prefix]=none
# ZSH_HIGHLIGHT_STYLES[path_approx]='fg=yellow'
# ZSH_HIGHLIGHT_STYLES[globbing]='fg=yellow'
# Source files
#typeset -ga sources
#sources+=$DOTFILES_DIR/shortcuts.txt
# Check dotfiles version
if [ -n "$NO_GIT_CHECK" ] && [ ! "$NO_GIT_CHECK" -eq 1 ]; then
# 5 second timeout to avoid delaying
timeout -k 5 5s $(which zsh) -c 'source ~/.zsh_functions && check_dotfiles_version'
dotfiles_version=$?
if [[ $dotfiles_version = 1 ]]; then
echo "Dotfiles repository not present at $DOTFILES_DIR"
elif [[ $dotfiles_version = 2 ]]; then
echo "Dotfiles repository at $DOTFILES_DIR is outdated"
elif [[ $dotfiles_version = 3 ]]; then
echo "Dotfiles repository at $DOTFILES_DIR is ahead of origin"
elif [[ $dotfiles_version = 4 ]]; then
echo "Dotfiles repository at $DOTFILES_DIR has diverged from origin"
elif [[ $dotfiles_version = 5 ]]; then
echo "Dotfiles repository not set in $HOME/.dotfiles-config"
echo "Please run ./install.sh in dotfiles repository and exit to create ~/.dotfiles-config"
echo "The following will not work until this is set:"
echo " - oh-my-zsh will not be started"
echo " - gruvbox color palette will not be loaded"
echo ""
fi
fi
# Powerline
if program_installed powerline; then
if [ -n "$POWERLINE_ROOT" ] && \
[ -f "$POWERLINE_ROOT" ] && \
[ -f "$POWERLINE_ROOT/bindings/zsh/powerline.zsh" ]; then
# Skip lookup if location is known
source "$POWERLINE_ROOT/bindings/zsh/powerline.zsh"
else
found=0
set_powerline_zsh_sources
for file in $POWERLINE_ZSH_SOURCES; do
if [[ -a $file ]]; then
# TODO: Store the POWERLINE_ROOT in .profile.local if found
source $file
found=1
break
fi
done
if [ $found -eq 0 ]; then
echo "Powerline binding not found.\nPlease set \$POWERLINE_ROOT in .profile.local"
fi
fi
fi
# Tool options
export SHELLCHECK_OPTS="-e SC2029 -e SC2155"
# Limit claude-code: cap CPU/memory/process count
# Adjust the -p values if these limits are ever too tight.
# if program_installed claude && program_installed systemd-run; then
# claude() {
# systemd-run --user --scope --quiet \
# -p CPUQuota=50% \
# -p MemoryMax=6G \
# -p TasksMax=512 \
# -- command claude "$@"
# }
# fi
# Syntax highlighting
if [ -f /usr/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh ]; then
source /usr/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
fi
# fzf fuzzy finder
if program_installed fzf; then
# TODO: This is broken on debian fzf (no --zsh)
source <(fzf --zsh)
fi
# thefuck command correction
if program_installed fuck; then
eval "$(thefuck --alias)"
fi
# pyenv-virtualenv (for managing virtualenvs for different python versions)
if program_installed pyenv; then
export PYENV_ROOT="$HOME/.pyenv"
export PYENV_VIRTUALENV_DISABLE_PROMPT=1
[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init - zsh)"
# eval "$(pyenv virtualenv-init -)"
fi
# oh-my-zsh plugins (must come after all initialization above)
plugins=(archlinux colored-man-pages colorize command-not-found cp docker docker-compose fzf git history history-substring-search nmap npm pip poetry pyenv macos rsync snap ssh sudo systemd terraform thefuck tmux tmuxinator vi-mode virtualenv yarn zsh-interactive-cd zsh-navigation-tools)
# Local overrides
if [ -f $HOME/.zshrc_local ]; then
source $HOME/.zshrc_local
fi
if [ -f ~/.fzf.zsh ]; then
source ~/.fzf.zsh
# Fun stupid opt-in stuff on new shell. Enable these in .profile.local
if program_installed neofetch; then
if [ ! -n "$NO_NEOFETCH" ] || [ "$NO_NEOFETCH" -eq 1 ]; then
else
neofetch
fi
fi
if [ -f /usr/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh ]; then
source /usr/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
if [ -n "$NO_ZSHRC_COWSAY" ] && [ ! "$NO_ZSHRC_COWSAY" -eq 1 ]; then
# Make a random (cow?) with a random face say something
fortune -a | fmt -80 -s | cowthink -$(shuf -n 1 -e b d g p s t w y) -f $(shuf -n 1 -e $(cowsay -l | tail -n +2)) -n
fi
# pyenv-virtualenv (for managing virtualenvs for different python versions)
#export PYENV_VIRTUALENV_DISABLE_PROMPT=1
#eval "$(pyenv init -)"
#eval "$(pyenv virtualenv-init -)"
+261
View File
@@ -0,0 +1,261 @@
# Based on the official Anthropic reference dev container:
# https://github.com/anthropics/claude-code/tree/main/.devcontainer
FROM node:22
ARG TZ
ENV TZ="$TZ"
ARG CLAUDE_CODE_VERSION=latest
# Install basic development tools, iptables/ipset (for the egress firewall),
# openssh-client (for git-over-ssh and agent forwarding), and the toolchain
# nvim's plugin config (config/nvim, telescope/treesitter/mason) expects.
#
# Deliberately NOT installing bubblewrap/socat: those exist to let Claude
# Code's own internal per-command sandboxing (~/.claude/settings.json's
# "sandbox" block) and /remote-control run, but that inner sandbox is
# redundant on top of this container - it can't even function inside it
# anyway (bwrap can't create a namespace here, and fixing that would need
# privilege escalation approaching --privileged, undoing this container's
# own hardening). bin/claude-sandbox mounts a settings.json copy with
# sandbox.enabled forced to false, so there's nothing here to satisfy.
RUN apt-get update && apt-get install -y --no-install-recommends \
less \
git \
openssh-client \
procps \
fzf \
zsh \
man-db \
unzip \
gnupg2 \
gh \
iptables \
ipset \
iproute2 \
dnsutils \
aggregate \
jq \
dnsmasq \
ulogd2 \
ulogd2-json \
nano \
vim \
ripgrep \
fd-find \
build-essential \
python3 \
python3-pip \
python3-venv \
xz-utils \
cowsay \
fortune-mod \
fortunes-min \
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
&& ln -s "$(command -v fdfind)" /usr/local/bin/fd
# cowsay/cowthink/fortune install to /usr/games on Debian, which isn't on
# PATH by default - config/nvim's start-screen header (plugins/ui.lua) shells
# out to them by bare name. Set via ENV (not a profile.d script) so it's
# picked up by every process unconditionally, including nvim's non-login
# subprocess spawns, which wouldn't source /etc/profile.d.
ENV PATH=/usr/games:$PATH
# Replace the base image's built-in `node` user (uid/gid 1000,
# $HOME=/home/node) with one matching the host user who runs
# bin/claude-sandbox - so absolute paths under $HOME (dotfiles, and
# ~/.claude/plugins/installed_plugins.json's own installPath bookkeeping,
# which Claude Code resolves as a literal host-absolute string rather than
# relative to whatever $HOME happens to be) resolve identically inside and
# outside the container, instead of needing a one-off dual-mount for every
# individual thing that assumes the real host path. Defaults below
# reproduce today's node/1000/1000//home/node exactly, so a build that
# never overrides them (bin/claude-sandbox always does; the test suite
# never does) exercises this same rename path rather than a separate,
# untested one.
ARG SANDBOX_USER=node
ARG SANDBOX_UID=1000
ARG SANDBOX_GID=1000
ARG SANDBOX_HOME=/home/node
RUN userdel -r node 2>/dev/null; \
groupdel node 2>/dev/null; \
groupadd -g "$SANDBOX_GID" "$SANDBOX_USER" && \
useradd -u "$SANDBOX_UID" -g "$SANDBOX_GID" -m -d "$SANDBOX_HOME" -s /bin/bash "$SANDBOX_USER"
ENV SANDBOX_USER=$SANDBOX_USER
ENV SANDBOX_HOME=$SANDBOX_HOME
# Ensure the sandbox user has access to /usr/local/share
RUN mkdir -p /usr/local/share/npm-global && \
chown -R $SANDBOX_USER:$SANDBOX_USER /usr/local/share
# Persist bash history.
RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
&& mkdir /commandhistory \
&& touch /commandhistory/.bash_history \
&& chown -R $SANDBOX_USER /commandhistory
# Set `DEVCONTAINER` environment variable to help with orientation
ENV DEVCONTAINER=true
# Disable non-essential network traffic (telemetry/error reporting) so the
# firewall allowlist can stay small.
ENV CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
# Lets Claude Code (and the SessionStart hook in ~/.claude/hooks) know it's
# running inside this container rather than directly on the host.
ENV CLAUDE_SANDBOX=1
# Dedicated unprivileged user for ulogd2 (see entrypoint.sh) - unlike
# dnsmasq, the ulogd2 Debian package doesn't create one of its own. ulogd2
# opens its log/output files and the NFLOG netlink socket before dropping to
# this user, so it doesn't need to own anything itself.
RUN useradd -r -s /usr/sbin/nologin ulogd
# Create the ssh directory and set permissions. The project directory itself
# is bind-mounted at runtime at its real host path (see docker-compose.yml),
# not a fixed /workspace, so it can share ~/.claude project/session history
# with the host. ~/.claude is bind-mounted too, not baked in here.
RUN mkdir -p $SANDBOX_HOME/.ssh && \
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh && \
chmod 700 $SANDBOX_HOME/.ssh
# Pre-create the nvim data/state/cache dirs owned by the sandbox user,
# since they're mounted as named volumes in docker-compose.yml: Docker
# seeds a fresh named volume from whatever already exists at the mount
# path in the image, ownership included, so this is what keeps those
# volumes writable by that user instead of defaulting to root.
RUN mkdir -p $SANDBOX_HOME/.local/share/nvim $SANDBOX_HOME/.local/state/nvim $SANDBOX_HOME/.cache/nvim && \
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.local $SANDBOX_HOME/.cache
ARG GIT_DELTA_VERSION=0.18.2
RUN ARCH=$(dpkg --print-architecture) && \
wget "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
# gosu lets entrypoint.sh (root) drop to the node user for the actual
# session after setting up the firewall, without leaving any setuid/sudo
# escalation path available to node afterward. Verified against the
# published SHA256SUMS since it runs as root.
ARG GOSU_VERSION=1.19
RUN ARCH=$(dpkg --print-architecture) && \
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-${ARCH}" && \
wget -O /tmp/gosu.sha256sums "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/SHA256SUMS" && \
grep -E "gosu-${ARCH}\$" /tmp/gosu.sha256sums | sed "s#gosu-${ARCH}#/usr/local/bin/gosu#" | sha256sum -c - && \
rm /tmp/gosu.sha256sums && \
chmod +x /usr/local/bin/gosu
# Install the latest stable Neovim release. Debian's packaged version is too
# old for this config's plugins (blink.cmp etc. need a recent Neovim), so
# fetch the current release from GitHub directly rather than pinning a
# version that may go stale.
RUN asset_url=$(curl -fsSL https://api.github.com/repos/neovim/neovim/releases/latest \
| jq -r '[.assets[] | select(.name | test("linux.*x86_64.*\\.tar\\.gz$"))][0].browser_download_url') && \
wget -O nvim.tar.gz "$asset_url" && \
tar -C /usr/local --strip-components=1 -xzf nvim.tar.gz && \
rm nvim.tar.gz
# Install the latest stable Go toolchain. go.dev's own release JSON gives
# both the download URL and a SHA256 per file, verified below since this
# runs as root - same pattern as gosu above. `go build`/`go mod download`
# need proxy.golang.org and sum.golang.org reachable at runtime - see
# fixed-domains.sh.
RUN ARCH=$(dpkg --print-architecture) && \
release_json=$(curl -fsSL 'https://go.dev/dl/?mode=json') && \
filename=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .filename') && \
sha256=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .sha256') && \
wget -O go.tar.gz "https://go.dev/dl/${filename}" && \
echo "${sha256} go.tar.gz" | sha256sum -c - && \
tar -C /usr/local -xzf go.tar.gz && \
rm go.tar.gz
ENV PATH=$PATH:/usr/local/go/bin
# System libraries Chromium needs (fonts, libnss, libatk, etc.) for
# Playwright, installed here since it needs apt/root - the actual
# `playwright` package and browser binary install as node, further down,
# so they land in node's own npm prefix/cache correctly.
ARG PLAYWRIGHT_VERSION=1.61.1
RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
# Set up non-root user
USER $SANDBOX_USER
# Install global packages
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
# A global npm install only puts a package's CLI on PATH, not its module on
# Node's require() resolution path - NODE_PATH is what makes `require
# ("playwright")` work from any script anywhere, not just the project's own
# node_modules (which it likely doesn't have without its own npm install).
ENV NODE_PATH=/usr/local/share/npm-global/lib/node_modules
# Set the default shell to zsh rather than sh
ENV SHELL=/bin/zsh
# Set the default editor and visual
ENV EDITOR=nano
ENV VISUAL=nano
# Default powerline10k theme
ARG ZSH_IN_DOCKER_VERSION=1.2.0
RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh)" -- \
-p git \
-p fzf \
-a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \
-a "source /usr/share/doc/fzf/examples/completion.zsh" \
-a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
-x
# Install Claude Code
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
# Install the tree-sitter CLI, which nvim-treesitter (plugins/treesitter.lua)
# needs to install/update parsers. Pinned below latest: newer tree-sitter-cli
# prebuilds require glibc >=2.39, which this (Debian 12 bookworm) image
# doesn't have.
RUN npm install -g tree-sitter-cli@0.24.7
# Playwright, for testing frontend changes against a locally-running dev
# server - localhost traffic never goes through the egress firewall, so
# this needs no allowlist changes for that. System deps were already
# installed as root above; this installs the npm package and downloads the
# matching Chromium build into node's own cache, both at build time so a
# session never needs network access for either.
#
# Chromium's own internal process sandbox is questionable here: it launches
# without --no-sandbox and without error, but its setuid sandbox helper
# (chrome_sandbox) isn't actually setuid-root in this image, and this host
# has the same unprivileged-userns restriction that broke bubblewrap - so
# its namespace-based isolation almost certainly isn't active either, it's
# just not hard-failing the way bubblewrap did. Not treated as a real
# security boundary either way: this container is already the boundary that
# matters, and a compromised session already has full `node`-level bash
# access, which a Chromium exploit couldn't exceed regardless of whichever
# of its own internal sandbox layers happen to be active.
RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
npx playwright install chromium
# Copy and set up firewall + DNS-proxy + entrypoint scripts, the gitea-pr
# helper from this repo's bin/ directory, the claude wrapper that pins
# --dangerously-skip-permissions, and this user's real nvim config.
COPY docker/claude-sandbox/fixed-domains.sh /usr/local/bin/
COPY docker/claude-sandbox/dns-proxy.sh /usr/local/bin/
COPY docker/claude-sandbox/init-firewall.sh /usr/local/bin/
COPY docker/claude-sandbox/log-shipper.sh /usr/local/bin/
COPY docker/claude-sandbox/ulogd.conf /etc/ulogd-sandbox.conf
COPY docker/claude-sandbox/entrypoint.sh /usr/local/bin/
COPY docker/claude-sandbox/claude-wrapper.sh /usr/local/bin/claude
COPY bin/gitea-pr /usr/local/bin/gitea-pr
COPY config/nvim $SANDBOX_HOME/.config/nvim
USER root
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/gitea-pr /usr/local/bin/claude && \
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.config/nvim && \
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
# Entrypoint runs as root (no USER node here) so it can set up the firewall
# directly, with no sudo/setuid escalation path available to node at any
# point afterward. It drops to node itself via gosu before execing nvim.
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+516
View File
@@ -0,0 +1,516 @@
# claude-sandbox
Runs Claude Code inside a Docker container with `--dangerously-skip-permissions`
(no permission prompts, all tool use auto-accepted) against a project mounted
from the host. Based on
[Anthropic's reference dev container](https://github.com/anthropics/claude-code/tree/main/.devcontainer):
same base image, non-root user, and egress-firewall approach, adapted to run
via `docker compose` directly instead of through a VS Code dev container, and
extended with this user's own nvim config and a `gitea-pr` helper for
opening/iterating on PRs.
This directory (`docker/claude-sandbox/`) only holds the tooling. It is
**not** where any project lives — the project you point it at is bind-mounted
into the container at runtime from wherever it actually sits on disk.
## Prerequisites
- Docker with the `docker compose` v2 plugin.
- `ssh-agent` running with the key for your Gitea remote(s) added
(`ssh-add -l` should list it). The container never sees your private key,
only a forwarded agent socket. `bin/claude-sandbox` warns (non-fatally) at
launch if the agent has zero identities loaded - the socket still forwards
fine either way, but git-over-ssh will fail until you `ssh-add` a key. That
takes effect immediately in an already-running session too (it's a live
reference to the same host agent process, not a snapshot) - no need to
restart the sandbox after `ssh-add`ing.
For a sandbox that needs to keep working unattended after your client
disconnects, see "Headless git push without agent forwarding" below —
`SANDBOX_SSH_KEY` is an alternative to this forwarded-agent requirement,
not just an addition to it.
- `GITEA_SSH_HOST` and `GITEA_API` exported in your shell (same variables
`bin/gitea-pr` already needs — see its `require_config`), and a token
available either via `GITEA_TOKEN` in the environment or at
`~/.config/gitea/token`.
- Optional: a **read-only** fine-grained GitHub PAT (GitHub → Settings →
Developer settings → Fine-grained tokens → generate, with Contents and
Metadata set to read-only, no write scopes) for `gh`/private-repo HTTPS
access, available via `GH_TOKEN` in the environment or at
`~/.config/gh/sandbox-token`. Don't use a normal `gh auth login` session
here — that's write-capable, which defeats the point (see "GitHub is
read-only" below). Without it, public GitHub repos still clone/fetch fine
anonymously over HTTPS; only private repos and `gh` API calls need it.
- Optional: `LOKI_URL` (a base URL, e.g. `http://loki.internal:3100`) to ship
DNS-query and firewall allow/reject logs to a Loki push endpoint - see
"Network request logging" below. Logging is skipped entirely if unset.
## Usage
```sh
bin/claude-sandbox # sandbox the current directory
bin/claude-sandbox ~/repos/some-project
bin/claude-sandbox ~/repos/some-project src/foo.py # open a specific file
```
The entry point is nvim, opened on the bind-mounted project directory, using
this user's real nvim config (`config/nvim`, including
[claudecode.nvim](https://github.com/coder/claudecode.nvim) — `<leader>ac`
toggles a Claude Code terminal). `claude` is also on `PATH` directly for use
from any shell/terminal inside the container. Either way it always runs with
`--dangerously-skip-permissions`, regardless of how it's invoked.
Typical loop: open the project, ask Claude to implement something and open a
PR (it has `gitea-pr` on `PATH`); from the host, `gitea-pr comments <PR#>` to
pull review feedback, feed it back in, repeat.
## Config parity with your host Claude Code setup
`~/.claude` and `~/.claude.json` are bind-mounted read-write from the host
into the container, so global `CLAUDE.md`, settings, hooks, plugins, MCP
server config, and your existing login/credentials all carry over
automatically — the container is a faithful copy of this setup, not a
separate identity. There's no separate first-run login.
The project directory is mounted at the *same absolute path* inside the
container as on the host (not a fixed `/workspace`), so Claude Code's
project/session-history keying lines up between host and container. A
session started in the sandbox can be resumed on the host afterward with
`claude --resume <session-id>`.
The container's own user is built to match yours exactly — same username,
uid, gid, and home directory (`bin/claude-sandbox` passes these as Docker
build args; see `Dockerfile`). This is what makes `$HOME`-relative
assumptions actually work inside the container instead of just superficially
resembling your setup — your dotfiles, and Claude Code's own plugin
bookkeeping (which records install locations as literal host-absolute
paths, not relative to whatever `$HOME` happens to be), resolve to the same
paths on both sides. The build defaults to `node`/1000/1000/`/home/node` if
these aren't supplied, so building the image directly (bypassing
`bin/claude-sandbox`) still works, just without this parity. On a shared
machine, the built image is tagged per-user (`claude-sandbox:<username>`) so
two accounts building concurrently don't clobber each other's image.
Caveat: MCP servers or hooks that shell out to host-specific binaries (e.g. a
browser for Playwright, a language runtime not installed in this image) may
not work inside the container unless that tooling is also added to the
`Dockerfile`.
Your global `~/.gitconfig` (`user.name`/`user.email` and the like) is also
carried over, so commits made inside the sandbox have the right identity
without any extra setup - falls back to an empty config if you don't have
one, it's not a hard requirement. It's a throwaway copy rather than the
real file, in its own directory rather than mounted directly at
`~/.gitconfig` (`GIT_CONFIG_GLOBAL` points git at it instead) - `gh auth
setup-git` needs to write a credential-helper entry into it, and both a
read-only mount and a single-file bind mount at `~/.gitconfig` break that
write in different ways. Nothing written to it during a session reaches
your real `~/.gitconfig`.
### Headless git push without agent forwarding
Agent forwarding (the default — see Prerequisites) ties the sandbox's SSH
access to whichever client's agent is currently forwarded. On a devbox
reached over SSH+tmux, that agent belongs to whichever client connected —
when that client disconnects, the forwarded socket dies with it, breaking
git-over-ssh for anything still running headless in the sandbox.
For sandboxes meant to keep running unattended, generate a dedicated,
purpose-built keypair instead — not your personal key:
```sh
ssh-keygen -t ed25519 -N '' -f ~/.ssh/claude-sandbox_ed25519
```
Passphrase-less is deliberate: headless operation has no human to answer a
prompt, and this is meant to be a low-privilege credential that only ever
lives inside the container, not your personal identity. Add the public
half as a Gitea deploy key scoped to only the repos that actually need push
access, then:
```sh
SANDBOX_SSH_KEY=~/.ssh/claude-sandbox_ed25519 bin/claude-sandbox ~/repos/some-project
```
The key is bind-mounted read-only at `/root/.ssh-sandbox-key-src` — under
`/root`, not `$SANDBOX_HOME` — so only entrypoint.sh's root-run startup code
can ever read it; the sandbox user (which the session actually runs as) has
no path to that directory at all. At container start, `entrypoint.sh` loads
it into a fresh `ssh-agent` running inside the container itself —
independent of the forwarded socket — copies it out just long enough to do
that, and deletes the copy immediately afterward. A compromised session can
use the agent's socket to sign git-over-ssh operations (bounded by whatever
the deploy key has push access to), but can't read the raw key material
itself. When `SANDBOX_SSH_KEY` is set, a live forwarded agent is no longer
required to start the sandbox at all.
## Shared scratch space
`~/shared` inside the container is a read-write bind mount of a real host
directory (`$SANDBOX_SHARE_DIR`, default `~/.local/share/claude-sandbox/shared`,
created automatically) - separate from the project mount, for passing files
(screenshots, temp scripts) back and forth with a sandboxed session without
touching the actual project checkout. It persists across sessions since it's
a real directory, not a container volume. Set `SANDBOX_SHARE_DIR` to point
it somewhere else.
`~/specs` is also bind-mounted read-write, at the fixed host path `~/specs`
(created automatically if it doesn't exist yet) - not configurable like
`SANDBOX_SHARE_DIR`, since it exists to make the global `CLAUDE.md`
convention of writing specs to `~/specs/` (also carried into the container)
actually work there instead of writing into a path that vanishes with the
session.
## Testing frontend changes
Playwright (Chromium) is pre-installed, browser included - `require
("playwright")` works from any script, anywhere in the container, not just
a project with its own `npm install playwright`. Both the npm package and
the matching Chromium build are baked in at image build time, so a session
never needs network access for either.
Testing against a project's own local dev server needs no firewall changes
at all - `localhost`/loopback traffic never goes through the egress
firewall in the first place. Testing against a real external site works
the same as any other network use: the domain needs to be on the allowlist
(`ALLOWED_DOMAINS`, or `bin/claude-sandbox-allow <domain>` for a running
session) - verified the firewall applies to the browser's own traffic
exactly like everything else (a non-allowlisted domain gets blocked from
inside the browser too, not just from `curl`/`git`).
If a project has its *own* pinned `playwright`/`@playwright/test`
devDependency at a different version than the one baked in here, its
`npm install` may still try to download a matching Chromium build from
Playwright's own CDN, which isn't on the default allowlist - allow it the
same way as any other one-off domain need if that comes up.
Chromium's own internal process sandbox is best-effort here, not something
to rely on: it launches without erroring, but its setuid sandbox helper
isn't actually setuid in this image, and this host has the same
unprivileged-userns restriction that keeps Claude Code's own internal
sandboxing (see Security notes) from working either - so its namespace-
based isolation almost certainly isn't active, Chromium just doesn't
hard-fail the way `bubblewrap` does when it's missing. Not treated as a
real boundary: this container is already the boundary that matters, and a
compromised session already has full bash access as the container's own
user regardless, which a Chromium exploit couldn't exceed.
## Sandbox self-awareness
A `SessionStart` hook (`~/.claude/hooks/sandbox-context.sh`, registered in
`~/.claude/settings.json`) checks for the `CLAUDE_SANDBOX` environment
variable this container sets, and when present, injects a reminder that the
session is running in a disposable, firewalled container and should stay
within the task at hand — and that if a task genuinely needs to break out of
the sandbox (broader network access, host-level changes, GUI/hardware), it
should stop and tell the user rather than trying to work around the
firewall, since the same session can be resumed unrestricted on the host via
`claude --resume`. It's a no-op outside the container. This is on top of the
existing `deny-dangerous-bash.sh` `PreToolUse` hook, which also applies here
since `~/.claude` is shared.
`git-push-guard.sh` (also `PreToolUse`/`Bash`, also `CLAUDE_SANDBOX`-gated)
blocks any `git push` whose resolved remote isn't the Gitea host, and any
push to `main`/`master` even there — the sandbox workflow is feature branch
+ `gitea-pr create`, never a direct push. Like the other hooks, this is a
fast-fail guard against an overeager agent, not a hard security boundary: a
sandbox session has full shell access and could in principle edit
`.git/config` to route around it. For "never pushes to GitHub" specifically,
the firewall backs this up with real enforcement (see below); for "never
pushes to main/master", the authoritative version of that is branch
protection on the Gitea repo itself, which this doesn't set up for you.
sandbox-context.sh also tells the agent the actual workflow to follow
proactively, not just what's blocked: do new work in a `git worktree`
rather than switching branches in the mounted checkout in place (that's the
user's real working copy), then push the feature branch and run
`gitea-pr create` from the worktree. The worktree has to live *under* the
mounted project directory (e.g. `.worktrees/<branch>`, not `../<branch>`) -
only the exact bind-mounted path is backed by real host storage, so a
worktree created as a sibling of the project (or anywhere under `$HOME`)
looks fine but silently disappears with the container, worktree and all.
## Network egress firewall
On start, the container runs an allowlist-only `iptables`/`ipset` firewall
(`init-firewall.sh`), matching the reference dev container. Allowed by
default: DNS resolution via a local DNS proxy (see "DNS is allowlisted too"
below), the Docker host itself (not its whole subnet), GitHub's published IP
ranges (port 443 only - see "GitHub is read-only" below),
`release-assets.githubusercontent.com` (GitHub release assets - e.g. mason's
own registry index - redirect here, a separate CDN from github.com's own
IPs), `registry.npmjs.org`, `pypi.org` and `files.pythonhosted.org` (mason's
Python-based LSP tools - `ruff`, `basedpyright`, `debugpy` - install via
pip), `proxy.golang.org` and `sum.golang.org` (`go build`/`go mod download`'s
module proxy and checksum database), `api.anthropic.com`, and whatever hosts
`GITEA_SSH_HOST`/`GITEA_API`
resolve to — that last group isn't port-restricted, so git-over-ssh to Gitea
works without a separate blanket "port 22 to anywhere" rule. Set
`ALLOWED_DOMAINS` (space-separated) in the environment to allow more (e.g.
`crates.io` for a Rust project needing more than what mason/npm already
cover). Everything else is rejected. Set `ENABLE_FIREWALL=0` to disable it
entirely (e.g. for debugging a blocked domain).
IPv6 is blocked outright (`ip6tables` policy DROP on INPUT/OUTPUT/FORWARD,
and dnsmasq strips AAAA answers so nothing wastes a connection attempt on an
address that could never be reached) - there's no IPv6 equivalent of the
allowlist above, so leaving it open would bypass the entire firewall on any
host with IPv6 egress enabled.
If your Gitea host is only resolvable via a LAN-local DNS server, make sure
the Docker host itself can resolve it — containers inherit the host's
resolver by default.
### DNS is allowlisted too
Resolution itself is restricted, not just the IP connections made
afterward: `dns-proxy.sh` runs a local `dnsmasq` bound to 127.0.0.1 with no
fallback resolver, and `/etc/resolv.conf` is repointed at it. Only names
matching the same allowlist as above (plus `github.com`/
`githubusercontent.com`/`githubassets.com`, needed since GitHub is
allowlisted by IP range rather than by resolving those names - see
`fixed-domains.sh`) get forwarded to Docker's real resolver; anything else
gets no upstream configured at all and is refused outright. Without this, a
compromised session could still exfiltrate data by encoding it into query
names to an attacker-controlled domain even though it could never open a
direct connection there - IP-based filtering alone doesn't stop that, since
the DNS query itself was never restricted.
### Adding a domain to a running session
`ALLOWED_DOMAINS` only takes effect at container start. For a domain a
*running* session needs right now, run from the host (not inside the
sandbox — it has no privilege to do this itself):
```sh
bin/claude-sandbox-allow crates.io # this session only
bin/claude-sandbox-allow crates.io --global # + persist in config/profile
```
It first adds the domain to the running dnsmasq's allowlist (appends a
`server=` line to its servers-file and sends `SIGHUP` to reload just that
file - no restart), then resolves it through the now-unblocked container
resolver and adds the IP(s) to the running firewall's `ipset`, all via
`docker exec -u root`. `--global` also appends the domain to
`ALLOWED_DOMAINS` in `config/profile` for future sessions; source your shell
(or open a new one) for that part to take effect. If more than one
claude-sandbox container is running, pass `--container <name>` to
disambiguate (`docker ps` to find it).
The sandbox-context.sh `SessionStart` hook tells the agent to ask for this
by name when a task is blocked on a single missing domain, rather than
stopping the whole session over it.
## Network request logging
If `LOKI_URL` is set (see Prerequisites), every DNS query and every new
outbound connection attempt - allowed or rejected - is shipped to that Loki
endpoint. Two sources, both tailed and pushed by `log-shipper.sh`:
- `dns-proxy.sh`'s dnsmasq logs every query and whether it was forwarded
(allowed) or refused (not on the allowlist) - labeled `job=claude-sandbox-dns`.
- `init-firewall.sh` tags its terminal ACCEPT/REJECT rules with `NFLOG`
(group 1 for allowed, group 2 for rejected), which `ulogd2`
(`ulogd.conf`) turns into JSON lines with source/dest IP, port, and an
`action` field - labeled `job=claude-sandbox-net`. Only new connection
attempts are logged, not every packet: the firewall's existing
ESTABLISHED,RELATED rule short-circuits the rest of a connection before it
ever reaches the tagged rules.
This is visibility on top of the firewall/DNS-proxy enforcement above, not a
substitute for it - nothing about logging changes what's actually allowed to
resolve or connect.
### Viewing logs in Grafana
Nothing sandbox-specific needed on the Grafana side beyond a Loki data
source pointed at wherever `LOKI_URL` resolves to (Connections → Data
sources → Loki, if one isn't already configured against that instance) -
`log-shipper.sh` pushes directly to Loki's HTTP API, there's no Prometheus
scrape target or exporter involved.
In Grafana's Explore view (or a dashboard panel with a Loki query), both
streams are selectable by their `job` label:
```logql
{job="claude-sandbox-dns"} # every DNS query + verdict
{job="claude-sandbox-net"} # every connection attempt, as JSON
```
Useful filters on top of those:
```logql
# DNS lookups that got refused (not on the allowlist)
{job="claude-sandbox-dns"} |= "REFUSED"
# Rejected connections only (parses ulogd2's JSON, filters on its action field)
{job="claude-sandbox-net"} | json | action="blocked"
# Both streams together, e.g. to watch a live session across sources
{source="claude-sandbox"}
```
`{job="claude-sandbox-net"}` lines are one full ulogd2 JSON object per
connection attempt (`| json` in a query unpacks fields like `dest_ip`,
`dest_port`, `action`) - Loki's Explore view shows these expandable/
pretty-printed by default, no extra parsing setup needed.
## GitHub is read-only
The sandbox can read from GitHub (clone, fetch, `gh` API calls) but can
never push there, and this is enforced at the network layer, not just by the
`git-push-guard.sh` hook above: GitHub's IP ranges are only allowlisted on
port 443. Port 22 is unreachable, so SSH-based push (which could otherwise
use the forwarded agent's real key - agent forwarding doesn't distinguish
"push" from "pull") has no transport to use at all, regardless of what any
hook does or doesn't catch. HTTPS access itself has no write-capable
credential either: `gh auth setup-git` (run at container start if `GH_TOKEN`
is set - see Prerequisites) points git's github.com credential helper at
`gh`, which only ever holds whatever token `GH_TOKEN` provides. Use a
read-only PAT there and even a push attempt over HTTPS gets rejected by
GitHub itself (403), not just blocked locally.
## Security notes
**Threat model, honestly stated:** this reduces the blast radius of a
misbehaving or prompt-injected coding agent - it does not make it safe to
point at arbitrary untrusted or adversarial input, and it does not turn
`--dangerously-skip-permissions` into something without real risk. Point it
at repositories you already trust. Everything below is about how bad a
compromised session *inside* that trust boundary can get, not a claim that
one can't happen.
- `--dangerously-skip-permissions` removes your chance to review tool calls
before they run. Only point this at trusted repositories.
- The mounted project itself has **no write protection**. `deny-dangerous-
bash.sh` and `git-push-guard.sh` are regex pattern-matching on the literal
Bash command string - a fast-fail guard against an overeager agent doing
something obviously destructive, not a boundary against one that's
actively trying to get around them (a different scripting language, an
encoded command, editing `.git/config` directly all sail right through).
Nothing here stops a compromised or badly-instructed session from
modifying or deleting the real working copy it's mounted against.
- Allowing egress to a domain at all creates a channel for exfiltrating data
through *that service's own request surface* (query strings, lookups,
etc.), not just to a literal attacker-controlled destination. Domain-level
allowlisting closes off reaching somewhere new; it can't inspect or
restrict what gets sent to somewhere already trusted for normal operation
(installing packages, calling the model API). That's a structural limit of
allowlist-only firewalls in general, not something specific to this setup,
and nothing here addresses it.
- This is container-level isolation (Linux namespaces plus a small
capability set), not VM-level isolation - no gVisor/Firecracker or
similar. It shares the host kernel, so a container-escape vulnerability
would defeat every other control described here at once.
- Everything in this document has been verified by hand, at a point in
time - there's no automated test suite re-checking these properties on
every change, so a future edit could silently reintroduce a gap. This has
already happened once in this file's own history (a missing `ipset`
dedup flag broke firewall startup entirely, twice, on two different
ipsets, before being caught).
- Because `~/.claude` (including `.credentials.json`) is bind-mounted in,
anything reachable from inside the container (the allowed domains above)
is a potential exfiltration path for those credentials if a malicious repo
manages to manipulate a session. The firewall limits this to
Anthropic/npm/GitHub/your Gitea host, not the open internet.
- `settings.json`, `CLAUDE.md`, and `plugins/` under `~/.claude` are
layered back read-only on top of the (otherwise read-write) `~/.claude`
mount. Hooks execute automatically with no separate approval step, so
without this a compromised session could plant a backdoored hook, rewrite
`CLAUDE.md` with injected instructions, or tamper with plugin code -
something that would then run **unsandboxed on the host** the next time
Claude Code starts there, turning a single compromised sandbox session
into persistent host compromise. Everything else under `~/.claude`
(session/project history, credentials refresh, caches) stays read-write,
since it's runtime state Claude Code needs to update.
`~/.claude.json` is a single file mixing that same kind of live state with
config (e.g. `mcpServers`, if ever set) and can't be split at the mount
level, so it's left fully read-write - accepted as a residual risk rather
than solved.
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" above),
not just the IP connections made afterward, closing off exfiltration via
encoding data into query names to an attacker-controlled domain - verified
a non-allowlisted domain gets refused outright (`status: REFUSED`) rather
than resolving and then only failing to connect.
- Don't mount other host secrets (`~/.ssh` private keys, cloud credential
files) into the container. Git push auth goes through the forwarded SSH
agent instead, which never exposes the key material itself - though note
the agent will sign for *any* Gitea repo your key has access to, not just
the mounted project, and the firewall's Gitea allowlist is host-based
rather than repo-scoped, so a compromised session can still reach (and
push to) other repos on that host you have access to. This doesn't extend
to GitHub: port 22 there is blocked outright, so the forwarded agent has
no transport to use for it at all (see "GitHub is read-only" above).
- `SANDBOX_SSH_KEY` (see "Headless git push without agent forwarding") is a
second, independent credential-loading path with the same "signs for any
Gitea repo it has access to" shape as the forwarded agent above - the
mitigation is that it's meant to be a dedicated deploy key scoped to only
the repos that actually need push access, not your personal identity.
That scoping is a convention, not something enforced here: nothing checks
what `SANDBOX_SSH_KEY` actually points at, so pointing it at your real
`~/.ssh/id_ed25519`/`id_rsa` instead of a dedicated key would load your
full personal identity into the container for the whole session (`bin/
claude-sandbox` warns if this looks like it might be happening - see
below - but doesn't block it). The key source itself is mounted read-only
at `/root/...`, not under the sandbox user's own home, specifically so
that user (which the session actually runs as) has no path to read the
raw key material - only the agent socket it's loaded into.
- The sandbox's own user (matching your host user — see "Config parity"
above) has no `sudo`/setuid path back to root once the container is up:
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
that user via `gosu` before execing `nvim`. A compromised session can't
re-run `init-firewall.sh` with a wider allowlist to open its own
exfiltration path. Verified: capabilities (`NET_ADMIN`/`NET_RAW`) are
cleared from the effective/permitted sets on the `gosu` drop, and that
user gets a permission error running `iptables` directly.
## Tests
`test/` has a [bats](https://github.com/bats-core/bats-core) suite covering
the security invariants above - not a substitute for reading this document,
but a way to actually catch a future change silently breaking one of them
instead of finding out the hard way:
```sh
bats docker/claude-sandbox/test/ # everything
bats docker/claude-sandbox/test/static.bats # fast, no Docker/build needed
SKIP_BUILD=1 bats docker/claude-sandbox/test/runtime.bats # reuse the current image
```
`static.bats` greps the repo's own files - no sudo in the `Dockerfile`, every
`ipset add` is duplicate-safe, no `docker.sock`/host-credential mounts, the
`~/.claude` read-only overlay lines are present, `bin/claude-sandbox-allow`
rejects a malicious domain argument. `runtime.bats` builds the image, boots
a real container running the actual `entrypoint.sh` (a `SANDBOX_TEST_MODE`
env var makes it stop just short of execing `nvim`, so the suite can attach
to a fully-initialized container instead of reimplementing its own copy of
the setup sequence), and asserts against it: IPv6 blocked, DNS/IP allowlist
enforcement in both directions, GitHub read-only, no working `sudo`/
`iptables`/`init-firewall.sh` as `node`, capabilities actually stripped
(not just absent from a `docker exec` that doesn't have them to begin
with), `ulogd2` unprivileged, and the dynamic-ipset CDN-rotation fix.
Every mutation in `runtime.bats` (firewall rules, `ipset` entries) happens
inside the test container's own network namespace, same as any other
container - none of it touches the host. Container/network names are
derived from bats' own per-run temp directory, not anything guessable or
likely to collide with a real container you have running.
## Files
| File | Purpose |
| --- | --- |
| `Dockerfile` | Base image, dev tools, nvim, Claude Code + wrapper, `gitea-pr` |
| `fixed-domains.sh` | Shared domain list sourced by both `init-firewall.sh` and `dns-proxy.sh` |
| `dns-proxy.sh` | Starts the DNS-allowlisting `dnsmasq`, repoints `resolv.conf` at it |
| `init-firewall.sh` | IPv4/IPv6 egress allowlist firewall, run at container start |
| `ulogd.conf` | `ulogd2` config: turns `init-firewall.sh`'s NFLOG-tagged packets into JSON |
| `log-shipper.sh` | Tails the dnsmasq/ulogd2 logs and ships them to `$LOKI_URL` |
| `entrypoint.sh` | Runs as root: DNS proxy, firewall, logging, drops to `node` via `gosu`, execs `nvim` |
| `claude-wrapper.sh` | Shadows `claude` on `PATH` to always add `--dangerously-skip-permissions` |
| `docker-compose.yml` | Wires up mounts/env; invoked via `bin/claude-sandbox`, not directly |
`bin/claude-sandbox` (repo root `bin/`) is the entry point — it resolves the
project path, checks for a live `ssh-agent`, loads `GITEA_TOKEN`/`GH_TOKEN`
if not already set, and runs `docker compose run`. `bin/claude-sandbox-allow`
adds a domain to a running session's firewall from the host - see "Adding a
domain to a running session" above.
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# Shadows the real `claude` binary (installed under npm-global/bin, later on
# PATH) so every invocation - interactive shell, or claudecode.nvim's
# terminal - runs with --dangerously-skip-permissions, without callers
# needing to know that.
exec /usr/local/share/npm-global/bin/claude --dangerously-skip-permissions "$@"
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
# Restricts DNS resolution itself to an allowlist, rather than only
# filtering the IP connections made afterward: without this, Docker's
# embedded resolver at 127.0.0.11 will still resolve *any* domain even once
# the firewall is locked down (verified: `dig` on a non-allowlisted domain
# resolves fine, only the subsequent connect gets blocked), so a compromised
# session could exfiltrate data by encoding it into query names to an
# attacker-controlled domain. Runs a local dnsmasq bound to 127.0.0.1 with
# no fallback resolver, forwarding only allowlisted names to Docker's real
# resolver; everything else gets no upstream configured at all, so it fails
# outright instead of leaking the query. Must run before init-firewall.sh so
# that script's own dig calls (and everything afterward) go through it.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=fixed-domains.sh
source "$SCRIPT_DIR/fixed-domains.sh"
CONF_FILE=/etc/dnsmasq-sandbox.conf
SERVERS_FILE=/etc/dnsmasq-sandbox-servers.conf
LOG_FILE=/var/log/dnsmasq.log
touch "$LOG_FILE"
chown dnsmasq:dnsmasq "$LOG_FILE" 2>/dev/null || true
# server=/domain/upstream lines live in their own file (rather than the main
# conf) since dnsmasq only re-reads a --servers-file on SIGHUP - lets
# bin/claude-sandbox-allow add a domain to a running session by appending a
# line here and signaling the container's dnsmasq, no restart needed.
{
for domain in "${FIXED_DOMAINS[@]}" "${GITHUB_DNS_SUFFIXES[@]}" ${EXTRA_ALLOWED_DOMAINS:-}; do
# Literal IPs (e.g. a monitoring host given as an IP, not a hostname)
# don't need a DNS entry - there's nothing to resolve.
if [[ "$domain" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
continue
fi
echo "server=/$domain/127.0.0.11"
done
} > "$SERVERS_FILE"
{
cat <<EOF
no-resolv
no-hosts
bind-interfaces
# IPv6 is blocked outright at the firewall (see init-firewall.sh's
# ip6tables policies) - strip AAAA answers too so nothing wastes a
# connection attempt on an address that can never actually be reached.
filter-AAAA
listen-address=127.0.0.1
port=53
servers-file=$SERVERS_FILE
log-queries
log-facility=$LOG_FILE
user=dnsmasq
pid-file=/var/run/dnsmasq-sandbox.pid
EOF
# init-firewall.sh's ipset population is a one-time snapshot at
# container start; a domain that's been allowed can still start failing
# to connect later if its CDN edge rotates to an IP that was never added
# (pypi.org/files.pythonhosted.org/GitHub's release-assets CDN all do
# this), and a domain like crates.io being allowed doesn't help
# index.crates.io/static.crates.io, which real traffic actually hits.
# These lines keep the ipsets topped up for the life of the session:
# every time dnsmasq actually answers a query for one of these domains,
# the resolved IP gets added too, on top of the upfront snapshot -
# dnsmasq tolerates the target ipset not existing yet at this point
# (verified: it logs the attempt and still answers the query
# normally), so this is safe even though it's generated before
# init-firewall.sh creates the ipsets moments later.
#
# This only covers domains known at container start (FIXED_DOMAINS,
# GITHUB_DNS_SUFFIXES, EXTRA_ALLOWED_DOMAINS) - a domain added mid-session
# via bin/claude-sandbox-allow only gets the one-time add it already
# does, not ongoing tracking, since --ipset= can't live in the
# SIGHUP-reloadable servers-file (dnsmasq only allows --server/
# --rev-server there).
for domain in "${FIXED_DOMAINS[@]}" ${EXTRA_ALLOWED_DOMAINS:-}; do
if [[ "$domain" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
continue
fi
echo "ipset=/$domain/allowed-domains"
done
for domain in "${GITHUB_DNS_SUFFIXES[@]}"; do
echo "ipset=/$domain/github-domains"
done
} > "$CONF_FILE"
dnsmasq --conf-file="$CONF_FILE"
# dnsmasq daemonizes and returns almost immediately, but give it a moment to
# actually be answering before repointing resolv.conf at it.
ready=0
for _ in $(seq 1 20); do
if dig +time=1 +tries=1 @127.0.0.1 api.anthropic.com >/dev/null 2>&1; then
ready=1
break
fi
sleep 0.2
done
if [ "$ready" -ne 1 ]; then
echo "ERROR: dnsmasq did not come up in time" >&2
exit 1
fi
echo "nameserver 127.0.0.1" > /etc/resolv.conf
+145
View File
@@ -0,0 +1,145 @@
# Not meant to be run directly with plain `docker compose up`: WORKSPACE_DIR,
# SSH_AUTH_SOCK, etc. must point at real host paths for a specific project.
# Use `bin/claude-sandbox <path-to-project>` instead, which sets these and
# invokes this file.
services:
claude:
build:
context: ../..
dockerfile: docker/claude-sandbox/Dockerfile
args:
TZ: ${TZ:-UTC}
# Matches the container's own user to the real host user - see
# Dockerfile. Defaults reproduce today's node/1000/1000//home/node
# exactly if these aren't set (e.g. building directly, bypassing
# bin/claude-sandbox).
SANDBOX_USER: ${SANDBOX_USER:-node}
SANDBOX_UID: ${SANDBOX_UID:-1000}
SANDBOX_GID: ${SANDBOX_GID:-1000}
SANDBOX_HOME: ${HOME:-/home/node}
# Tagged per-user, not a fixed :latest - bin/claude-sandbox always
# passes --build, and two different accounts on a shared devbox
# building from their own checkouts would otherwise silently overwrite
# each other's image under the same tag.
image: claude-sandbox:${SANDBOX_USER:-node}
cap_add:
- NET_ADMIN
- NET_RAW
environment:
GITEA_SSH_HOST: ${GITEA_SSH_HOST:-}
GITEA_API: ${GITEA_API:-}
GITEA_TOKEN: ${GITEA_TOKEN:-}
# Read-only fine-grained GitHub PAT (Contents + Metadata: read-only,
# no write scopes). Used by `gh` and, via `gh auth setup-git`, by git
# itself for github.com over HTTPS - see the firewall/Dockerfile for
# why that's the only path GitHub is reachable through at all.
GH_TOKEN: ${GH_TOKEN:-}
# Points git at the gitconfig copy mounted below instead of the
# default ~/.gitconfig - see that mount's comment for why it can't
# just live at ~/.gitconfig directly.
GIT_CONFIG_GLOBAL: ${HOME}/.gitconfig-sandbox/.gitconfig
ALLOWED_DOMAINS: ${ALLOWED_DOMAINS:-}
# Base URL of a Loki push endpoint (e.g. http://10.0.30.11:3100) to
# ship DNS-query and firewall allow/reject logs to. Its host is
# automatically firewall-allowlisted (see entrypoint.sh). Optional -
# logging is skipped entirely if unset.
LOKI_URL: ${LOKI_URL:-}
SSH_AUTH_SOCK: /ssh-agent
ENABLE_FIREWALL: ${ENABLE_FIREWALL:-1}
# Docker's own tty allocation defaults to a generic TERM (e.g. plain
# "xterm", dropping 256-color and any extended-keys terminfo bits),
# not whatever the host's actual terminal/tmux session is using.
# Forward the real value so nvim negotiates key sequences (e.g. the
# Ctrl-H/Backspace disambiguation config/tmux.conf sets up) against
# the terminfo entry the rest of the chain actually expects.
TERM: ${TERM:-xterm-256color}
volumes:
# Mounted at the same absolute path as on the host (rather than a
# fixed /workspace) so Claude Code's project/session-history keying
# (based on cwd) lines up with the host, letting a session started
# here be resumed on the host with `claude --resume`.
- ${WORKSPACE_DIR:?set WORKSPACE_DIR to the project directory to mount}:${WORKSPACE_DIR}
- ${HOME}/.claude:${HOME}/.claude
# settings.json/CLAUDE.md/plugins are config and hook/plugin *code*,
# not runtime state - hooks run automatically with no approval gate,
# so a compromised session writing to them would plant something that
# then executes unsandboxed on the host next time Claude Code starts
# there. Layer them back read-only on top of the read-write parent
# mount above (Docker applies more specific mounts on top of broader
# ones); everything else under ~/.claude (session/project history,
# credentials refresh, caches, etc.) still needs to stay read-write.
#
# settings.json specifically comes from $SANDBOX_SETTINGS_FILE (a
# bin/claude-sandbox-generated copy with sandbox.enabled forced to
# false), not the real file directly - Claude Code's own internal
# per-command sandboxing is redundant on top of this container (and
# can't actually function inside it anyway), and mounting it read-only
# means it can't be patched from inside, so the substitution has to
# happen on the host before the container ever starts.
- ${SANDBOX_SETTINGS_FILE:?set by bin/claude-sandbox}:${HOME}/.claude/settings.json:ro
- ${HOME}/.claude/CLAUDE.md:${HOME}/.claude/CLAUDE.md:ro
- ${HOME}/.claude/plugins:${HOME}/.claude/plugins:ro
# ~/.claude.json is a single file mixing live app state (project
# registry, onboarding flags, caches) with config (e.g. mcpServers, if
# ever set) that can't be split at the mount level - left read-write
# since most of its content must stay writable for normal operation.
# See README Security notes for this residual risk.
- ${HOME}/.claude.json:${HOME}/.claude.json
# ~/.claude/CLAUDE.md (mounted above) tells agents to write specs to
# ~/specs/ rather than a project-local path - that instruction is
# already in effect inside the container (CLAUDE.md carries over), so
# the directory it points at needs to actually exist and be writable
# here too, or an agent following it just writes into an ephemeral
# path inside the container that vanishes with the session.
- ${HOME}/specs:${HOME}/specs
# Global git identity (user.name/user.email) - a throwaway copy of it,
# in its own directory rather than a single file bind-mounted at
# ~/.gitconfig: entrypoint.sh's `gh auth setup-git` writes a
# credential-helper entry into it via git's usual write-temp-then-
# rename pattern, which fails on a single-file mount ("Device or
# resource busy" - Linux won't let that mount point's backing inode be
# replaced). GIT_CONFIG_GLOBAL above points git at the file inside
# this directory instead. See bin/claude-sandbox - nothing written
# here ever reaches the user's real ~/.gitconfig.
- ${SANDBOX_GITCONFIG_DIR:?set by bin/claude-sandbox}:${HOME}/.gitconfig-sandbox
# ~/.claude/hooks is a symlink into ~/dotfiles/config/claude/hooks (an
# absolute host path); mount dotfiles at the same path so it resolves
# in the container too. Read-only: hooks are only ever read/executed
# from here, never written. Only one mount is needed now (unlike
# before) - the container's own $HOME equals the host's $HOME, so
# there's no separate node-specific path to mirror it at.
- ${HOME}/dotfiles:${HOME}/dotfiles:ro
- claude-code-bashhistory:/commandhistory
- nvim-data:${HOME}/.local/share/nvim
- nvim-state:${HOME}/.local/state/nvim
- nvim-cache:${HOME}/.cache/nvim
# Required only if SANDBOX_SSH_KEY isn't set - bin/claude-sandbox
# itself enforces that at least one of the two is present.
- ${SSH_AUTH_SOCK:-/dev/null}:/ssh-agent
- ${SSH_KNOWN_HOSTS:-/dev/null}:${HOME}/.ssh/known_hosts:ro
# Dedicated, purpose-built key for headless git push - see README
# "Headless git push without agent forwarding". Read-only single-file
# mount is fine here (unlike the gitconfig mount above): nothing in
# the container ever writes to this file. Mounted under /root (700,
# root:root by default), not under $SANDBOX_HOME: the sandbox user's
# UID now matches the host user's (see Dockerfile), so a mount here
# under their own home would leave the raw key readable by that user
# for the container's whole life, even after entrypoint.sh's copy is
# deleted - only root (which loads it into the agent, then never
# touches it again) can reach this path.
- ${SANDBOX_SSH_KEY_FILE:-/dev/null}:/root/.ssh-sandbox-key-src:ro
# Read-write scratch space for passing files back and forth with the
# sandboxed session (screenshots, temp scripts) without touching the
# actual project checkout - separate from WORKSPACE_DIR above, and a
# real host directory (not a named volume) so it's actually visible on
# both sides, persisting across sessions.
- ${SANDBOX_SHARE_DIR}:${HOME}/shared
working_dir: ${WORKSPACE_DIR}
stdin_open: true
tty: true
volumes:
claude-code-bashhistory:
nvim-data:
nvim-state:
nvim-cache:
+103
View File
@@ -0,0 +1,103 @@
#!/bin/bash
set -euo pipefail
# Derive the Gitea API hostname from GITEA_API (a full URL) so it can be
# firewall-allowlisted alongside GITEA_SSH_HOST.
gitea_api_host=""
if [ -n "${GITEA_API:-}" ]; then
gitea_api_host=$(echo "$GITEA_API" | sed -E 's#^[a-zA-Z]+://([^/]+).*#\1#')
fi
# Same for LOKI_URL (also a full URL) - Loki's host needs to be reachable
# for log-shipper.sh to actually deliver anything.
loki_host=""
if [ -n "${LOKI_URL:-}" ]; then
loki_host=$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')
fi
extra_domains="${GITEA_SSH_HOST:-} ${gitea_api_host} ${loki_host} ${ALLOWED_DOMAINS:-}"
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
# dns-proxy.sh must run first: it repoints /etc/resolv.conf at a local
# dnsmasq that only resolves allowlisted names, so init-firewall.sh's own
# dig calls (and everything afterward) go through the same restriction.
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/dns-proxy.sh
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/init-firewall.sh
# NFLOG groups only exist once init-firewall.sh has run, so ulogd2 (the
# NFLOG consumer) and the Loki shipper start after it. Both are
# best-effort visibility on top of the enforcement above, not required
# for it - log-shipper.sh itself no-ops if LOKI_URL isn't set. Runs as
# its own unprivileged `ulogd` user (opens the NFLOG socket and output
# files first, then drops), not root - it's parsing headers from
# attacker-influenceable packets (whatever a compromised session sends),
# so a bug in it shouldn't be a path back to root in the container.
#
# `ulogd -d` daemonizes immediately and always exits 0 from this shell's
# point of view, regardless of whether the daemon itself goes on to
# start successfully - so `set -e` above won't catch a failure here (nor
# should it: logging is best-effort, not something worth failing the
# whole container over). Check separately so a failure is at least
# visible instead of silently producing no logs.
/usr/sbin/ulogd -d -u ulogd -c /etc/ulogd-sandbox.conf
sleep 0.5
if ! pgrep -x ulogd >/dev/null; then
echo "WARNING: ulogd2 failed to start - connection logging will be unavailable (see /var/log/ulogd.log)" >&2
fi
/usr/local/bin/log-shipper.sh &
fi
# A normal login/ssh session always chowns the allocated pty to the logging
# in user; this container's entrypoint skips that (there's no login/sshd
# involved), leaving the tty root-owned. nvim's clipboard fallback needs to
# write to it directly as the sandbox user, so fix the ownership up here,
# before dropping privileges.
if [ -t 1 ]; then
chown "$SANDBOX_USER" "$(tty)" 2>/dev/null || true
fi
# Point git's github.com credential helper at `gh`, which reads GH_TOKEN -
# only possible now, not at image build time, since that's a runtime
# secret. Combined with the firewall restricting github.com to port 443
# only, this is what makes GitHub read-only from inside the sandbox: no
# write-capable credential for it, and SSH (which could use the forwarded
# agent's real key) can't reach it at all. Skipped if GH_TOKEN isn't set -
# github.com HTTPS still works anonymously for public repos either way.
if [ -n "${GH_TOKEN:-}" ]; then
gosu "$SANDBOX_USER" gh auth setup-git
fi
# Dedicated headless SSH identity (optional - see bin/claude-sandbox's
# SANDBOX_SSH_KEY and README "Headless git push"): a purpose-built key
# mounted read-only, independent of whatever forwarded agent socket the
# SSH_AUTH_SOCK mount above provides. Loaded into a fresh agent owned by
# the sandbox user rather than left forwarded, so it keeps working for the
# rest of this container's life even after whatever client connection
# forwarded the *other* agent goes away.
#
# Mounted at /root (not under $SANDBOX_HOME) so only this root-run block
# can ever read it - copied out, loaded into the agent, and the copy
# deleted, all before dropping to the sandbox user below.
SANDBOX_SSH_KEY_SRC="/root/.ssh-sandbox-key-src"
if [ -s "$SANDBOX_SSH_KEY_SRC" ]; then
install -o "$SANDBOX_USER" -g "$SANDBOX_USER" -m 600 "$SANDBOX_SSH_KEY_SRC" "$SANDBOX_HOME/.ssh/sandbox-key"
eval "$(gosu "$SANDBOX_USER" ssh-agent -s)" >/dev/null
gosu "$SANDBOX_USER" ssh-add "$SANDBOX_HOME/.ssh/sandbox-key"
rm -f "$SANDBOX_HOME/.ssh/sandbox-key"
export SSH_AUTH_SOCK
fi
# Test harness hook (docker/claude-sandbox/test/): everything above this
# point is the real startup sequence - DNS proxy, firewall, IPv6 block,
# logging, privilege-drop prep. Skip execing nvim (which needs a real tty)
# so the suite can `docker exec` into a live, fully-initialized container
# and assert against it, exercising the actual code path instead of a
# reimplementation of it that could drift out of sync.
if [ "${SANDBOX_TEST_MODE:-0}" = "1" ]; then
exec gosu "$SANDBOX_USER" sleep infinity
fi
# Drop root -> the sandbox user for the actual session; that user has no
# sudo/setuid path back to root after this, so it can't re-run
# init-firewall.sh itself to widen the allowlist mid-session.
exec gosu "$SANDBOX_USER" nvim "$@"
+26
View File
@@ -0,0 +1,26 @@
# Sourced by both init-firewall.sh (IP-based ipset allowlist) and
# dns-proxy.sh (name-based DNS allowlist) so the two can't drift apart.
FIXED_DOMAINS=(
"registry.npmjs.org"
"api.anthropic.com"
"gitlab.com"
"pypi.org"
"files.pythonhosted.org"
"release-assets.githubusercontent.com"
"proxy.golang.org"
"sum.golang.org"
)
# GitHub itself is allowlisted by IP range (see github-domains ipset,
# populated from api.github.com/meta), never by resolving these names to
# specific IPs - but the container's own tools (git, gh, curl) still need to
# resolve these names to *something* before connecting, so the DNS proxy
# needs to know they're allowed even though init-firewall.sh never digs them.
# Suffix matching means these also cover their actual subdomains in use
# (api.github.com, codeload.github.com, objects.githubusercontent.com, etc.)
# without enumerating each one.
GITHUB_DNS_SUFFIXES=(
"github.com"
"githubusercontent.com"
"githubassets.com"
)
+224
View File
@@ -0,0 +1,224 @@
#!/bin/bash
# Based on the official Anthropic reference dev container:
# https://github.com/anthropics/claude-code/tree/main/.devcontainer
set -euo pipefail # Exit on error, undefined vars, and pipeline failures
IFS=$'\n\t' # Stricter word splitting
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=fixed-domains.sh
source "$SCRIPT_DIR/fixed-domains.sh"
# 1. Extract Docker DNS info BEFORE any flushing
DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
# Flush existing rules and delete existing ipsets
iptables -F
iptables -X
iptables -t nat -F
iptables -t nat -X
iptables -t mangle -F
iptables -t mangle -X
ipset destroy allowed-domains 2>/dev/null || true
ipset destroy github-domains 2>/dev/null || true
# Block IPv6 outright - there's no IPv6 equivalent of the allowlist below, so
# without this, any host with IPv6 egress enabled would let a container
# bypass the entire IPv4-based firewall via an AAAA-resolved address. `||
# true` throughout since ip6tables may simply be unavailable on hosts with
# IPv6 fully disabled at the kernel level, which is an equally fine outcome.
ip6tables -F 2>/dev/null || true
ip6tables -X 2>/dev/null || true
ip6tables -P INPUT DROP 2>/dev/null || true
ip6tables -P OUTPUT DROP 2>/dev/null || true
ip6tables -P FORWARD DROP 2>/dev/null || true
# 2. Selectively restore ONLY internal Docker DNS resolution
if [ -n "$DOCKER_DNS_RULES" ]; then
echo "Restoring Docker DNS rules..."
iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
echo "$DOCKER_DNS_RULES" | xargs -L 1 iptables -t nat
else
echo "No Docker DNS rules to restore"
fi
# Allow localhost. DNS resolution goes entirely through Docker's embedded
# resolver at 127.0.0.11 (see resolv.conf), which stays on loopback, so this
# also covers DNS - no need for a separate "UDP/53 to anywhere" rule, which
# would just be an unrestricted-destination exfil channel.
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
# Create ipsets with CIDR support. GitHub's ranges get their own set,
# restricted below to port 443 only: the sandbox is meant to be able to read
# from GitHub (clone/fetch/gh over HTTPS) but never push there, and blocking
# SSH (port 22) to it entirely means that's not just policy but physically
# enforced - there's no transport left for the forwarded ssh-agent's real
# key to authenticate a push with, regardless of what a hook does or
# doesn't catch.
ipset create allowed-domains hash:net
ipset create github-domains hash:net
# Fetch GitHub meta information and aggregate + add their IP ranges
echo "Fetching GitHub IP ranges..."
gh_ranges=$(curl -s https://api.github.com/meta)
if [ -z "$gh_ranges" ]; then
echo "ERROR: Failed to fetch GitHub IP ranges"
exit 1
fi
if ! echo "$gh_ranges" | jq -e '.web and .api and .git' >/dev/null; then
echo "ERROR: GitHub API response missing required fields"
exit 1
fi
echo "Processing GitHub IPs..."
while read -r cidr; do
if [[ ! "$cidr" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then
echo "ERROR: Invalid CIDR range from GitHub meta: $cidr"
exit 1
fi
echo "Adding GitHub range $cidr"
# -exist: aggregate -q collapses overlapping ranges within each of
# web/api/git, but not necessarily across all three combined - the same
# crash this already caused once on the allowed-domains ipset (a
# duplicate add errors, and set -e turns that into the whole firewall,
# and thus the whole container, failing to start).
ipset add github-domains "$cidr" -exist
done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q)
# Resolve and add other allowed domains (shared with dns-proxy.sh's name
# allowlist via fixed-domains.sh, so the two can't drift apart)
for domain in "${FIXED_DOMAINS[@]}"; do
echo "Resolving $domain..."
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
if [ -z "$ips" ]; then
echo "ERROR: Failed to resolve $domain"
exit 1
fi
while read -r ip; do
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
echo "ERROR: Invalid IP from DNS for $domain: $ip"
exit 1
fi
echo "Adding $ip for $domain"
# -exist: two domains can resolve to the same IP (e.g. pypi.org and
# files.pythonhosted.org share Fastly edge IPs), and ipset otherwise
# errors on a duplicate add, which -e/set -e turns into the whole
# firewall (and thus the container) failing to start.
ipset add allowed-domains "$ip" -exist
done < <(echo "$ips")
done
# Resolve and add caller-supplied extra domains (e.g. a Gitea host), if any.
# Unlike the fixed domains above, failures here are non-fatal: a project may
# be run without any Gitea config set, or a host may be transiently
# unresolvable, and that shouldn't prevent the firewall from coming up.
if [ -n "${EXTRA_ALLOWED_DOMAINS:-}" ]; then
# The script-wide IFS above drops plain space to tighten word splitting
# elsewhere, but EXTRA_ALLOWED_DOMAINS is a space-separated list by
# construction (entrypoint.sh joins GITEA_SSH_HOST/GITEA_API host/
# LOKI_URL host/ALLOWED_DOMAINS with spaces) - restore it just for this
# loop, or a multi-entry value silently collapses into one bogus token.
IFS=' '
for domain in $EXTRA_ALLOWED_DOMAINS; do
# A literal IP (e.g. a monitoring host given as an IP, not a
# hostname) needs no resolution - add it directly.
if [[ "$domain" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
echo "Adding $domain (extra, literal IP)"
ipset add allowed-domains "$domain" -exist
continue
fi
echo "Resolving $domain (extra)..."
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
if [ -z "$ips" ]; then
echo "WARNING: Failed to resolve extra domain $domain, skipping"
continue
fi
while read -r ip; do
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
echo "WARNING: Invalid IP from DNS for $domain: $ip, skipping"
continue
fi
echo "Adding $ip for $domain"
ipset add allowed-domains "$ip" 2>/dev/null || true
done < <(echo "$ips")
done
fi
# Get host IP from default route
HOST_IP=$(ip route | grep default | cut -d" " -f3)
if [ -z "$HOST_IP" ]; then
echo "ERROR: Failed to detect host IP"
exit 1
fi
echo "Host detected as: $HOST_IP"
# Only the host itself, not its whole subnet - other machines on the same
# LAN/VPC have no reason to be reachable from this container.
iptables -A INPUT -s "$HOST_IP" -j ACCEPT
iptables -A OUTPUT -d "$HOST_IP" -j NFLOG --nflog-group 1 --nflog-prefix "allow-host"
iptables -A OUTPUT -d "$HOST_IP" -j ACCEPT
# Set default policies to DROP first
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT DROP
# First allow established connections for already approved traffic
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Then allow only specific outbound traffic to allowed domains. This isn't
# port-restricted, so it also covers git-over-ssh (port 22) to
# GITEA_SSH_HOST/GITEA_API, which are already in the ipset above - no
# separate "TCP/22 to anywhere" rule needed (that would allow SSH, or any
# raw TCP posing as it, to any host on the internet).
#
# Each terminal ACCEPT/REJECT decision below is also tagged with NFLOG so
# ulogd2 can capture every new outbound connection attempt and its verdict
# (log-shipper.sh ships these to Loki alongside dns-proxy.sh's query log).
# Only new connections reach these rules at all - the ESTABLISHED,RELATED
# rule above already short-circuits the rest of a connection's packets, so
# this logs once per attempt, not once per packet.
iptables -A OUTPUT -m set --match-set allowed-domains dst -j NFLOG --nflog-group 1 --nflog-prefix "allow"
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
# GitHub gets port 443 only - read access (clone/fetch/gh over HTTPS) without
# port 22, so SSH push there (which could use the forwarded agent's real
# key) isn't just discouraged, it's unreachable.
iptables -A OUTPUT -p tcp --dport 443 -m set --match-set github-domains dst -j NFLOG --nflog-group 1 --nflog-prefix "allow-gh"
iptables -A OUTPUT -p tcp --dport 443 -m set --match-set github-domains dst -j ACCEPT
# Explicitly REJECT all other outbound traffic for immediate feedback
iptables -A OUTPUT -j NFLOG --nflog-group 2 --nflog-prefix "reject"
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
echo "Firewall configuration complete"
echo "Verifying firewall rules..."
if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then
echo "ERROR: Firewall verification failed - was able to reach https://example.com"
exit 1
else
echo "Firewall verification passed - unable to reach https://example.com as expected"
fi
# Verify GitHub API access
if ! curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1; then
echo "ERROR: Firewall verification failed - unable to reach https://api.github.com"
exit 1
else
echo "Firewall verification passed - able to reach https://api.github.com as expected"
fi
# Verify GitHub SSH (port 22) is NOT reachable - this is what makes GitHub
# actually read-only from here, not just discouraged.
if timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22" 2>/dev/null; then
echo "ERROR: Firewall verification failed - was able to reach github.com on port 22"
exit 1
else
echo "Firewall verification passed - unable to reach github.com:22 as expected"
fi
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
# Tails dns-proxy.sh's dnsmasq query log and ulogd2's NFLOG-derived JSON
# output, shipping them to Loki as raw log streams. Lets DNS lookups and
# firewall allow/reject decisions from inside the sandbox show up alongside
# the rest of the Grafana/Loki setup, labeled by job so they're easy to
# filter. No-op if LOKI_URL isn't set - logging is a bonus on top of the
# firewall/DNS-proxy enforcement, not required for either to work.
#
# Batches rather than pushing one line at a time: a burst of activity (e.g.
# npm install resolving/connecting to dozens of hosts in quick succession)
# would otherwise fire one HTTP request per line, which is both wasteful and
# a good way to get rate-limited/throttled by Loki.
set -uo pipefail # not -e: one bad line or a Loki hiccup shouldn't kill the tailer
LOKI_URL="${LOKI_URL:-}"
if [ -z "$LOKI_URL" ]; then
exit 0
fi
BATCH_MAX_LINES=20
BATCH_MAX_SECONDS=2
ship_stream() {
local job="$1" file="$2"
local -a batch=() batch_ts=()
local last_flush now line
flush() {
[ "${#batch[@]}" -eq 0 ] && return
local i raw=""
for i in "${!batch[@]}"; do
raw+="${batch_ts[$i]}"$'\t'"${batch[$i]}"$'\n'
done
local values payload
values=$(printf '%s' "$raw" | jq -R -s '
split("\n") | map(select(length > 0))
| map(split("\t") | [.[0], (.[1:] | join("\t"))])
')
payload=$(jq -n --arg job "$job" --argjson values "$values" \
'{streams: [{stream: {job: $job, source: "claude-sandbox"}, values: $values}]}')
curl -s -o /dev/null --max-time 5 -H "Content-Type: application/json" \
-XPOST "$LOKI_URL/loki/api/v1/push" -d "$payload" || true
batch=()
batch_ts=()
last_flush=$(date +%s)
}
last_flush=$(date +%s)
tail -F -n0 "$file" 2>/dev/null | while true; do
if IFS= read -r -t "$BATCH_MAX_SECONDS" line; then
batch+=("$line")
batch_ts+=("$(date +%s%N)")
fi
now=$(date +%s)
if [ "${#batch[@]}" -ge "$BATCH_MAX_LINES" ] || [ $((now - last_flush)) -ge "$BATCH_MAX_SECONDS" ]; then
flush
fi
done
}
ship_stream "claude-sandbox-dns" /var/log/dnsmasq.log &
ship_stream "claude-sandbox-net" /var/log/ulogd.json &
wait
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env bats
bats_require_minimum_version 1.5.0
# Behavioral checks against a live container running the real entrypoint.sh
# (SANDBOX_TEST_MODE=1 - see entrypoint.sh - runs the actual DNS
# proxy/firewall/logging setup, then execs `sleep infinity` as node instead
# of nvim so the suite can attach). One container is shared across the whole
# file (setup_file/teardown_file, not setup/teardown) since spinning up a
# fresh one per test would make this suite too slow to actually get run.
#
# Needs Docker. Set SKIP_BUILD=1 to reuse whatever claude-sandbox:latest is
# already built, for fast iteration - the default is to always rebuild, so a
# stale image can't produce a false pass.
#
# Safety: every iptables/ipset/ip6tables mutation below happens inside the
# test container's own network namespace (no --network host, no bind mounts
# of host paths) - none of it touches the host's real firewall state, same
# as any other container. Container/network names are derived from
# BATS_FILE_TMPDIR (a directory bats itself guarantees is unique per run and
# stable across setup_file/every test/teardown_file in this file), so a
# stray real container or network can never collide with, and get torn down
# by, this suite. Don't derive these from $$ instead - bats reruns this
# file's top-level code in a fresh subshell per test, so $$ (unlike
# BATS_FILE_TMPDIR) is a *different* value in every test, setup_file, and
# teardown_file, silently pointing them at containers that don't match.
IMAGE=claude-sandbox:latest
container_name() { echo "claude-sandbox-test-$(basename "$BATS_FILE_TMPDIR")"; }
network_name() { echo "claude-sandbox-test-net-$(basename "$BATS_FILE_TMPDIR")"; }
setup_file() {
SANDBOX_DIR="$BATS_TEST_DIRNAME/.."
REPO_ROOT="$BATS_TEST_DIRNAME/../../.."
local container network
container=$(container_name)
network=$(network_name)
if [ "${SKIP_BUILD:-0}" != "1" ]; then
docker build -q -t "$IMAGE" -f "$SANDBOX_DIR/Dockerfile" "$REPO_ROOT" >&2
fi
docker rm -f "$container" >/dev/null 2>&1 || true
docker network rm "$network" >/dev/null 2>&1 || true
docker network create "$network" >/dev/null
# No GITEA_*/GH_TOKEN/LOKI_URL: this suite only asserts the default
# allowlist and enforcement, which shouldn't depend on any of them.
docker run -d --name "$container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$network" \
-e SANDBOX_TEST_MODE=1 \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
for _ in $(seq 1 60); do
# pgrep -x sleep also transiently matches entrypoint.sh's own `sleep
# 0.5` (used for its ulogd readiness check) before the real, final
# `sleep infinity` - harmless here, since by the time *either* sleep
# runs, dns-proxy.sh/init-firewall.sh have already fully completed
# (they're earlier, sequential steps under set -e).
if docker exec "$container" pgrep -x sleep >/dev/null 2>&1; then
return
fi
sleep 1
done
echo "container never reached the post-entrypoint sleep - startup log:" >&2
docker logs "$container" >&2
return 1
}
teardown_file() {
docker rm -f "$(container_name)" >/dev/null 2>&1 || true
docker network rm "$(network_name)" >/dev/null 2>&1 || true
}
teardown() {
docker rm -f "claude-sandbox-test-override-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rmi "claude-sandbox-test-override:$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "claude-sandbox-test-sshkey-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
}
dexec() {
docker exec "$(container_name)" "$@"
}
dexec_node() {
docker exec -u node "$(container_name)" "$@"
}
@test "the container's user actually matches overridden SANDBOX_USER/UID/GID/HOME build args, not just the defaults" {
local test_image="claude-sandbox-test-override:$(basename "$BATS_FILE_TMPDIR")"
local test_container="claude-sandbox-test-override-$(basename "$BATS_FILE_TMPDIR")"
local sandbox_dir="$BATS_TEST_DIRNAME/.."
local repo_root="$BATS_TEST_DIRNAME/../../.."
docker build -q -t "$test_image" \
--build-arg SANDBOX_USER=testuser \
--build-arg SANDBOX_UID=1234 \
--build-arg SANDBOX_GID=1234 \
--build-arg SANDBOX_HOME=/home/testuser \
-f "$sandbox_dir/Dockerfile" "$repo_root" >&2
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
-e SANDBOX_TEST_MODE=1 \
--entrypoint /usr/local/bin/entrypoint.sh \
"$test_image" >/dev/null
for _ in $(seq 1 60); do
if docker exec "$test_container" pgrep -x sleep >/dev/null 2>&1; then break; fi
sleep 1
done
run docker exec -u testuser "$test_container" whoami
[ "$status" -eq 0 ]
[ "$output" = "testuser" ]
run docker exec -u testuser "$test_container" printenv HOME
[ "$status" -eq 0 ]
[ "$output" = "/home/testuser" ]
run docker exec -u testuser "$test_container" id -u
[ "$output" = "1234" ]
}
@test "a dedicated SANDBOX_SSH_KEY loads into its own in-container agent with no forwarded socket at all" {
local key_dir test_container
key_dir="$BATS_TEST_TMPDIR"
ssh-keygen -t ed25519 -N '' -f "$key_dir/testkey" >/dev/null
test_container="claude-sandbox-test-sshkey-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-v "$key_dir/testkey:/root/.ssh-sandbox-key-src:ro" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
for _ in $(seq 1 60); do
if docker exec "$test_container" pgrep -x sleep >/dev/null 2>&1; then break; fi
sleep 1
done
# A fresh `docker exec` is a sibling of PID 1, not a descendant of the
# exec chain entrypoint.sh set SSH_AUTH_SOCK in, so it doesn't inherit
# that export the way a real session's nvim/terminal children would
# (nvim itself *is* that same process, continued via exec, not a
# separate one) - read the value back out of PID 1's own environment
# instead of assuming docker exec sees it.
run docker exec -u node "$test_container" sh -c '
export SSH_AUTH_SOCK=$(cat /proc/1/environ | tr "\0" "\n" | grep ^SSH_AUTH_SOCK= | cut -d= -f2-)
ssh-add -l
'
[ "$status" -eq 0 ]
[ -n "$output" ]
run docker exec -u node "$test_container" test -e /home/node/.ssh/sandbox-key
[ "$status" -ne 0 ]
# The regression this guards against: /root/.ssh-sandbox-key-src is the
# bind-mounted original, still present for the container's whole life
# (unlike the deleted copy above, this can't be rm'd from inside). If
# it were reachable by the sandbox user, that user's uid matching the
# host's (see Dockerfile) would make the raw key directly readable,
# defeating the point of only ever exposing it via the agent socket.
run docker exec -u node "$test_container" cat /root/.ssh-sandbox-key-src
[ "$status" -ne 0 ]
}
@test "IPv6 is fully blocked" {
run dexec ip6tables -L OUTPUT -n
[[ "$output" == *"policy DROP"* ]]
}
@test "a non-allowlisted domain is refused by the DNS proxy, not just left to fail later" {
run dexec dig definitely-not-on-the-allowlist.example.org
[[ "$output" == *"status: REFUSED"* ]]
}
@test "an allowlisted domain resolves via the DNS proxy" {
run dexec dig +short api.anthropic.com
[ -n "$output" ]
}
@test "a non-allowlisted destination is unreachable" {
run dexec curl --connect-timeout 5 -s -o /dev/null https://example.com
[ "$status" -ne 0 ]
}
@test "GitHub is reachable on port 443" {
run dexec curl --connect-timeout 5 -s -o /dev/null -w "%{http_code}" https://api.github.com/zen
[ "$status" -eq 0 ]
}
@test "GitHub is NOT reachable on port 22 (push transport is physically absent)" {
run dexec timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22"
[ "$status" -ne 0 ]
}
@test "node cannot run iptables directly" {
run dexec_node iptables -L
[ "$status" -ne 0 ]
}
@test "node cannot re-run init-firewall.sh to widen the allowlist" {
run dexec_node /usr/local/bin/init-firewall.sh
[ "$status" -ne 0 ]
}
@test "node has no working sudo" {
# `run !` rather than checking $status: this fails with 127 (command
# not found) today since sudo isn't installed at all, not some other
# nonzero code - both are "no working sudo," and pinning to one exact
# code would make the test brittle to which specific way that stays true.
run ! dexec_node sudo -n true
}
@test "the real running node process has NET_ADMIN/NET_RAW stripped, not just the bounding set" {
run dexec cat /proc/1/status
[ "$status" -eq 0 ]
cap_eff=$(echo "$output" | awk '/^CapEff:/ {print $2}')
[ "$cap_eff" = "0000000000000000" ]
}
@test "ulogd2 runs as its own unprivileged user, not root" {
run dexec ps -eo user,comm
[[ "$output" == *"ulogd ulogd"* ]] || [[ "$output" == *$'\nulogd '* ]]
! [[ "$output" == *$'\nroot ulogd'* ]]
}
@test "the ipset stays in sync with fresh (non-cached) DNS answers, not just the boot-time snapshot" {
ip=$(dexec dig +short pypi.org | head -1)
[ -n "$ip" ]
dexec ipset del allowed-domains "$ip"
run dexec ipset test allowed-domains "$ip"
[ "$status" -ne 0 ]
# SIGHUP clears dnsmasq's answer cache, forcing a genuine upstream
# re-forward on the next query instead of a cache hit - only a fresh
# forward triggers dnsmasq's --ipset= tracking (see dns-proxy.sh).
dexec bash -c 'kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"'
sleep 0.5
dexec dig +short pypi.org >/dev/null
sleep 0.5
run dexec ipset test allowed-domains "$ip"
[ "$status" -eq 0 ]
}
@test "Playwright/Chromium actually launches and renders, not just installs" {
dexec_node bash -c 'cat > /tmp/pw-launch-test.js <<'"'"'EOF'"'"'
const { chromium } = require("playwright");
(async () => {
const browser = await chromium.launch({});
const page = await browser.newPage();
await page.setContent("<h1>ok</h1>");
const text = await page.textContent("h1");
if (text !== "ok") throw new Error("unexpected content: " + text);
await browser.close();
console.log("PLAYWRIGHT_LAUNCH_OK");
})();
EOF'
run dexec_node node /tmp/pw-launch-test.js
[ "$status" -eq 0 ]
[[ "$output" == *"PLAYWRIGHT_LAUNCH_OK"* ]]
}
@test "the firewall applies to Playwright's browser traffic, not just curl/git" {
# The whole point of pre-baking the browser at build time is that a
# session never needs new network access for it - confirm the egress
# allowlist actually governs what the browser itself can reach, the same
# as every other tool in the container.
dexec_node bash -c 'cat > /tmp/pw-firewall-test.js <<'"'"'EOF'"'"'
const { chromium } = require("playwright");
(async () => {
const browser = await chromium.launch({});
const page = await browser.newPage();
try {
await page.goto("http://example.com", { timeout: 5000 });
console.log("REACHED_BLOCKED_DOMAIN");
} catch (e) {
console.log("BLOCKED_AS_EXPECTED");
}
await browser.close();
})();
EOF'
run dexec_node node /tmp/pw-firewall-test.js
[[ "$output" == *"BLOCKED_AS_EXPECTED"* ]]
! [[ "$output" == *"REACHED_BLOCKED_DOMAIN"* ]]
}
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env bats
# Fast checks that don't need Docker: catch regressions in the security
# invariants that are visible directly in the repo's files, without paying
# for an image build + container boot. See runtime.bats for everything that
# actually needs a live container.
setup() {
SANDBOX_DIR="$BATS_TEST_DIRNAME/.."
REPO_ROOT="$BATS_TEST_DIRNAME/../../.."
}
@test "Dockerfile never installs sudo" {
run grep -E '^\s*sudo\s*\\?\s*$' "$SANDBOX_DIR/Dockerfile"
[ "$status" -ne 0 ]
}
@test "Dockerfile never creates a sudoers entry" {
run grep -i "sudoers" "$SANDBOX_DIR/Dockerfile"
[ "$status" -ne 0 ]
}
@test "Dockerfile creates a SANDBOX_USER matching the host user, not a hardcoded node" {
grep -qE '^ARG SANDBOX_USER=node\s*$' "$SANDBOX_DIR/Dockerfile"
grep -qE '^ARG SANDBOX_UID=1000\s*$' "$SANDBOX_DIR/Dockerfile"
grep -qE '^ARG SANDBOX_GID=1000\s*$' "$SANDBOX_DIR/Dockerfile"
grep -qE '^ARG SANDBOX_HOME=/home/node\s*$' "$SANDBOX_DIR/Dockerfile"
grep -q 'useradd -u "$SANDBOX_UID" -g "$SANDBOX_GID" -m -d "$SANDBOX_HOME"' "$SANDBOX_DIR/Dockerfile"
grep -qE '^ENV SANDBOX_USER=\$SANDBOX_USER\s*$' "$SANDBOX_DIR/Dockerfile"
grep -qE '^ENV SANDBOX_HOME=\$SANDBOX_HOME\s*$' "$SANDBOX_DIR/Dockerfile"
# No literal "USER node" anywhere - the mid-build USER switch must use
# the parametrized variable, not the old hardcoded name.
run grep -E '^USER\s+node\s*$' "$SANDBOX_DIR/Dockerfile"
[ "$status" -ne 0 ]
grep -qE '^USER\s+\$SANDBOX_USER\s*$' "$SANDBOX_DIR/Dockerfile"
}
@test "entrypoint.sh drops privilege via SANDBOX_USER, not a literal node" {
run grep -E 'gosu node\b' "$SANDBOX_DIR/entrypoint.sh"
[ "$status" -ne 0 ]
grep -q 'gosu "$SANDBOX_USER"' "$SANDBOX_DIR/entrypoint.sh"
}
@test "Playwright is installed and require()-able globally" {
# A global npm install only puts the CLI on PATH, not the module on
# Node's require() resolution path - NODE_PATH is what makes
# require("playwright") work from any script, not just one with its own
# local node_modules (see runtime.bats for the actual launch test).
grep -q 'npm install -g playwright' "$SANDBOX_DIR/Dockerfile"
grep -qE '^\s*ENV\s+NODE_PATH=' "$SANDBOX_DIR/Dockerfile"
}
@test "every ipset add in init-firewall.sh is duplicate-safe" {
# Same bug class that broke firewall startup twice (718f6b7, a86fefc):
# ipset add without -exist errors on a duplicate entry, and set -e turns
# that into the whole firewall - and thus the whole container - failing
# to start with no clear error surfaced to the user. Every "ipset add"
# line must either pass -exist or already be wrapped so a failure can't
# propagate (e.g. `|| true`).
while IFS= read -r line; do
case "$line" in
*-exist*) continue ;;
*"|| true"*) continue ;;
esac
echo "unsafe ipset add: $line"
return 1
done < <(grep "ipset add" "$SANDBOX_DIR/init-firewall.sh")
}
@test "docker-compose.yml never mounts the Docker socket" {
run grep -i "docker.sock" "$SANDBOX_DIR/docker-compose.yml"
[ "$status" -ne 0 ]
}
@test "docker-compose.yml never mounts host SSH/cloud credential directories" {
# Anchored to the *source* side of a mount (right after the leading
# "- ") - the container's own $HOME legitimately contains .ssh/known_hosts
# as a *destination* path now that it mirrors the host's $HOME, which
# isn't the same thing as mounting the host's real ~/.ssh directory in.
run grep -E '^\s*-\s*\$\{HOME\}/\.(ssh|aws|gcloud|azure)(/|:)' "$SANDBOX_DIR/docker-compose.yml"
[ "$status" -ne 0 ]
}
@test "~/.claude settings/CLAUDE.md/plugins are layered read-only over the read-write parent mount" {
grep -qE '^\s*-\s*\$\{HOME\}/\.claude:\$\{HOME\}/\.claude\s*$' "$SANDBOX_DIR/docker-compose.yml"
# settings.json specifically comes from a generated $SANDBOX_SETTINGS_FILE
# (see below), not the real file directly - it still has to be read-only
# in the container, just from a different, sandbox-specific source.
grep -qE '^\s*-\s*\$\{SANDBOX_SETTINGS_FILE.*\}:\$\{HOME\}/\.claude/settings\.json:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/CLAUDE\.md:.*:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/plugins:.*:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
}
@test "gitconfig is a throwaway copy in its own directory, not a single-file mount" {
# entrypoint.sh's `gh auth setup-git` writes a credential-helper entry
# via git's write-temp-then-rename pattern, which fails with "Device or
# resource busy" on a single-file bind mount at ~/.gitconfig directly -
# a real regression this guards against (Linux won't let a bind mount's
# backing inode be replaced, only a file *within* a mounted directory
# can be freely renamed over). Must be a directory mount, with
# GIT_CONFIG_GLOBAL pointing git at the file inside it.
grep -qE '^\s*-\s*\$\{SANDBOX_GITCONFIG_DIR.*\}:\$\{HOME\}/\.gitconfig-sandbox\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*GIT_CONFIG_GLOBAL:\s*\$\{HOME\}/\.gitconfig-sandbox/\.gitconfig\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -q 'mktemp -d.*gitconfig' "$REPO_ROOT/bin/claude-sandbox"
}
@test "docker-compose.yml never hardcodes /home/node - the container home is host-parametrized" {
# ${HOME:-/home/node} (the SANDBOX_HOME build arg's own default value,
# mirroring the Dockerfile's default) is the one legitimate exception -
# it's a fallback expression, not a hardcoded container path.
run bash -c "grep -v '^\s*#' '$SANDBOX_DIR/docker-compose.yml' | grep -v '\${HOME:-/home/node}' | grep '/home/node'"
[ "$status" -ne 0 ]
}
@test "docker-compose.yml builds with a per-user image tag and passes the host identity as build args" {
grep -qE '^\s*image:\s*claude-sandbox:\$\{SANDBOX_USER:-node\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*SANDBOX_USER:\s*\$\{SANDBOX_USER:-node\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*SANDBOX_UID:\s*\$\{SANDBOX_UID:-1000\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*SANDBOX_GID:\s*\$\{SANDBOX_GID:-1000\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*SANDBOX_HOME:\s*\$\{HOME:-/home/node\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
}
@test "bin/claude-sandbox exports the host user identity for the build" {
grep -q 'SANDBOX_USER="$(id -un)"' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'SANDBOX_UID="$(id -u)"' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'SANDBOX_GID="$(id -g)"' "$REPO_ROOT/bin/claude-sandbox"
}
@test "dotfiles is mounted once now that the container home matches the host home" {
count=$(grep -c '\${HOME}/dotfiles:' "$SANDBOX_DIR/docker-compose.yml")
[ "$count" -eq 1 ]
}
@test "bin/claude-sandbox forces sandbox.enabled to false in the generated settings.json" {
# Claude Code's own internal per-command sandboxing is redundant on top
# of this container and can't actually function inside it (bwrap can't
# create a namespace here) - settings.json is read-only precisely so it
# can't be patched from inside, so this has to happen in the generation
# step on the host, before the container starts.
grep -qE '\.sandbox\.enabled\s*=\s*false' "$REPO_ROOT/bin/claude-sandbox"
}
@test "Dockerfile never installs bubblewrap or socat" {
# Nothing needs them now that sandbox.enabled is forced off - if they
# come back, it's worth asking why before just re-adding them.
run grep -E '^\s*(bubblewrap|socat)\s*\\?\s*$' "$SANDBOX_DIR/Dockerfile"
[ "$status" -ne 0 ]
}
@test "sandbox-context.sh never tells the agent to worktree outside the mounted project" {
# ../<branch> (a sibling of the mounted project) or anything under $HOME
# lands in container-local ephemeral storage, not real host storage -
# the worktree, and whatever work went into it, silently vanishes when
# the session ends. Regression check for exactly this bug.
run grep -E '(worktree add|git worktree)[^\\n]*\.\./' "$REPO_ROOT/config/claude/hooks/sandbox-context.sh"
[ "$status" -ne 0 ]
grep -q '.worktrees/' "$REPO_ROOT/config/claude/hooks/sandbox-context.sh"
}
@test "entrypoint runs as root and drops to node via gosu, not a baked-in USER node" {
# The whole privilege-drop model depends on entrypoint.sh itself starting
# as root (to set up the firewall) - a stray `USER node` before
# ENTRYPOINT would silently break that.
run tail -n 5 "$SANDBOX_DIR/Dockerfile"
[[ "$output" == *"ENTRYPOINT"* ]]
! grep -qE '^\s*USER\s+node\s*$' <(tail -n 8 "$SANDBOX_DIR/Dockerfile")
}
@test "bin/claude-sandbox-allow rejects a domain with shell metacharacters" {
run "$REPO_ROOT/bin/claude-sandbox-allow" 'evil.com;touch /tmp/pwned'
[ "$status" -ne 0 ]
[[ "$output" == *"Not a valid domain"* ]]
}
@test "bin/claude-sandbox-allow rejects a domain with a slash (dnsmasq server= delimiter)" {
run "$REPO_ROOT/bin/claude-sandbox-allow" 'evil.com/../etc'
[ "$status" -ne 0 ]
[[ "$output" == *"Not a valid domain"* ]]
}
@test "bin/claude-sandbox-allow accepts a well-formed domain (passes validation)" {
# --container points at a name that can't exist, so this can *never*
# reach a real running claude-sandbox session (and mutate its live
# firewall) even if the machine running this test happens to have one -
# it must fail at "container not found", not at validation.
run "$REPO_ROOT/bin/claude-sandbox-allow" 'crates.io' --container claude-sandbox-test-suite-nonexistent-target
[ "$status" -ne 0 ]
[[ "$output" != *"Not a valid domain"* ]]
}
@test "the dedicated SSH key mount is optional, not a hard requirement" {
grep -qE '^\s*-\s*\$\{SANDBOX_SSH_KEY_FILE:-/dev/null\}:/root/\.ssh-sandbox-key-src:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{SSH_AUTH_SOCK:-/dev/null\}:/ssh-agent\s*$' "$SANDBOX_DIR/docker-compose.yml"
}
@test "the dedicated SSH key source is mounted under /root, not the sandbox user's home" {
# A mount under \$SANDBOX_HOME would be readable by the sandbox user
# directly, since its uid now matches the host user's (see Dockerfile) -
# /root is only reachable by root, which is all that should ever read
# this file (see entrypoint.sh).
run grep -E '\.ssh-sandbox-key-src' "$SANDBOX_DIR/docker-compose.yml"
[[ "$output" != *'${HOME}'* ]]
run grep -F 'SANDBOX_SSH_KEY_SRC=' "$SANDBOX_DIR/entrypoint.sh"
[[ "$output" != *'SANDBOX_HOME'* ]]
grep -qF 'SANDBOX_SSH_KEY_SRC="/root/.ssh-sandbox-key-src"' "$SANDBOX_DIR/entrypoint.sh"
}
@test "entrypoint.sh deletes the plaintext dedicated-key copy after loading it into the agent" {
grep -q 'gosu "$SANDBOX_USER" ssh-add' "$SANDBOX_DIR/entrypoint.sh"
grep -q 'rm -f "$SANDBOX_HOME/.ssh/sandbox-key"' "$SANDBOX_DIR/entrypoint.sh"
ssh_add_line=$(grep -n 'gosu "$SANDBOX_USER" ssh-add' "$SANDBOX_DIR/entrypoint.sh" | head -1 | cut -d: -f1)
rm_line=$(grep -n 'rm -f "$SANDBOX_HOME/.ssh/sandbox-key"' "$SANDBOX_DIR/entrypoint.sh" | head -1 | cut -d: -f1)
[ "$rm_line" -gt "$ssh_add_line" ]
}
@test "bin/claude-sandbox allows a dedicated SANDBOX_SSH_KEY in place of a forwarded agent" {
grep -q 'SANDBOX_SSH_KEY' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'export SANDBOX_SSH_KEY_FILE' "$REPO_ROOT/bin/claude-sandbox"
}
@test "bin/claude-sandbox warns when SANDBOX_SSH_KEY looks like a default personal key" {
grep -q 'looks like a default personal SSH key' "$REPO_ROOT/bin/claude-sandbox"
run grep -E '^\s*id_rsa\|id_ecdsa\|id_ed25519\|id_dsa\|id_ecdsa_sk\|id_ed25519_sk\)\s*$' "$REPO_ROOT/bin/claude-sandbox"
[ "$status" -eq 0 ]
}

Some files were not shown because too many files have changed in this diff Show More