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:
@@ -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:-}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user