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.
This commit is contained in:
2026-07-19 19:11:12 -07:00
parent a86fefc663
commit 0f3e2c8362
7 changed files with 107 additions and 6 deletions
+9
View File
@@ -50,7 +50,16 @@ if [ -z "${GH_TOKEN:-}" ]; then
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"
export WORKSPACE_DIR
export SANDBOX_SHARE_DIR
export SSH_AUTH_SOCK
export SSH_KNOWN_HOSTS="${SSH_KNOWN_HOSTS:-$HOME/.ssh/known_hosts}"
export GITEA_TOKEN="${GITEA_TOKEN:-}"
+6 -3
View File
@@ -17,9 +17,12 @@ You are running inside the claude-sandbox Docker container
What this means:
- The workspace directory is bind-mounted read-write and is the real
project, but 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.
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
+19
View File
@@ -32,3 +32,22 @@ vim.api.nvim_create_autocmd({ "BufNewFile", "BufReadPre", "FileReadPre", "BufEnt
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
+13 -3
View File
@@ -56,10 +56,20 @@ return {
local dashboard = require("alpha.themes.dashboard")
local function fortune_header()
local cows = vim.fn.systemlist("cowsay -l")
-- 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"
if #cows > 1 then
cow = cows[math.random(2, #cows)]
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)]
+10
View File
@@ -39,9 +39,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
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
# Ensure default node user has access to /usr/local/share
RUN mkdir -p /usr/local/share/npm-global && \
chown -R node:node /usr/local/share
+44
View File
@@ -73,6 +73,16 @@ 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`.
## 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.
## Sandbox self-awareness
A `SessionStart` hook (`~/.claude/hooks/sandbox-context.sh`, registered in
@@ -193,6 +203,40 @@ 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
+6
View File
@@ -78,6 +78,12 @@ services:
- nvim-cache:/home/node/.cache/nvim
- ${SSH_AUTH_SOCK:?set SSH_AUTH_SOCK to your running ssh-agent socket}:/ssh-agent
- ${SSH_KNOWN_HOSTS:-/dev/null}:/home/node/.ssh/known_hosts: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/node/shared
working_dir: ${WORKSPACE_DIR}
stdin_open: true
tty: true