Open the network policy for hosts a sandbox actually needs

Sandboxes default to a deny-everything-else policy, so the registry credentials
provisioned as custom secrets were unusable: npm.fontawesome.com,
proget.careevolution.com and localstack.cloud were all denied, and the request
never left the sandbox for the proxy to substitute a token into. The failure
looked like a connection error rather than a policy decision.

Provisioning a secret now allows its hosts in the same step, since a credential
for a denied host cannot be used by definition. AI_SBX_NETWORK declares any
further hosts, comma or space separated, for private registries that back no
secret.

The marketplace loop's inline policy call moves into the shared helper so the
two cannot drift.

The Balanced policy already permits github.com, codeload and the
githubusercontent hosts, registry.npmjs.org, pypi.org, files.pythonhosted.org,
crates.io and the Go proxies, so npm, pip, cargo, go and a plugin-managed
Neovim need nothing declared. Only private hosts do.
This commit is contained in:
2026-08-03 10:42:56 -05:00
parent d2b7a5ef63
commit 2890b1dd98
4 changed files with 448 additions and 3 deletions
+27
View File
@@ -293,6 +293,7 @@ provider "aws" {
| `AI_SBX_TOKEN_DAYS` | `30` | token expiry pre-filled on the form (1–366, or `none`) | | `AI_SBX_TOKEN_DAYS` | `30` | token expiry pre-filled on the form (1–366, or `none`) |
| `AI_SBX_TEMPLATE` | unset | `--template` / `--stock-template` | | `AI_SBX_TEMPLATE` | unset | `--template` / `--stock-template` |
| `AI_SBX_TOOLS` | `bun` | mise tools installed globally in the sandbox; empty installs none | | `AI_SBX_TOOLS` | `bun` | mise tools installed globally in the sandbox; empty installs none |
| `AI_SBX_NETWORK` | unset | hosts to allow through the sandbox network policy, comma or space separated |
## Carrying your Claude configuration into the sandbox ## Carrying your Claude configuration into the sandbox
@@ -437,6 +438,32 @@ still provision.
Placeholders are derived from the repository and variable name, so re-running `setup` Placeholders are derived from the repository and variable name, so re-running `setup`
does not invalidate a value already exported inside a running sandbox. does not invalidate a value already exported inside a running sandbox.
### Network policy
Sandboxes default to Docker's `Balanced` policy: deny everything except common
development hosts. That already covers more than it appears to — `github.com`,
`codeload.github.com`, `raw` and `objects.githubusercontent.com`,
`registry.npmjs.org`, `pypi.org`, `files.pythonhosted.org`, `crates.io`,
`proxy.golang.org` are all permitted, so npm, pip, cargo, go, and a plugin-managed
Neovim config need nothing added.
Private registries do not. Declare them once:
```toml
AI_SBX_NETWORK = "artifactory.example.com, *.internal.example.com"
```
**Hosts backing a provisioned secret are allowed automatically.** A credential for a
denied host is dead weight — the request never leaves the sandbox, so the proxy never
substitutes anything, and the failure looks like a hang or a connection error rather
than a policy decision.
Check any host against the effective policy with:
```bash
sbx policy check network --sandbox <sandbox> npm.fontawesome.com
```
### Host-wide credentials ### Host-wide credentials
Some credentials are worth provisioning everywhere rather than declaring per Some credentials are worth provisioning everywhere rather than declaring per
+321
View File
@@ -0,0 +1,321 @@
# Plan: launch a tmux workspace instead of the bare agent
Give `ai:sbx` an option to attach to a tmux session with three windows — agent, editor,
shell — rather than dropping straight into the agent.
Spans two repositories: the option and launch logic here, the tools and dotfiles in
the base image (`mroberts/claude-sbx`).
## What was established
Measured against `sbx` 0.37.0 and a live sandbox, not assumed:
| Finding | Consequence |
| --- | --- |
| PID 1 is `tini -- sh -c … sleep infinity`; no agent process runs until attach | `sbx run` execs the agent on attach. Launching tmux instead displaces nothing |
| `sbx exec -it SANDBOX CMD` allocates a TTY and keeps stdin open | A tmux session can be attached without a kit or a custom image |
| `sandbox.entrypoint.run` in a `kind: sandbox` kit replaces the image entrypoint | The alternative route: `sbx run` itself opens tmux |
| `tmux`, `nvim`, `vim` are all absent from `claude-code-docker` | Something must supply them |
| mise resolves `tmux` 3.7b (aqua/asdf) and `neovim` 0.12.4 (aqua) | `AI_SBX_TOOLS` can supply both with no image work |
| `files/home/` in a kit maps to `/home/agent/` | Dotfiles can ship without an image rebuild too |
| `/etc/sandbox-persistent.sh` carries PATH, AWS, and secret placeholders | Any window started via a **login** shell inherits the environment |
That last row is load-bearing: tmux windows must start login shells (`bash -l`), or
they lose mise shims, AWS credentials, and every secret placeholder.
## Two decisions
### How to launch
**Recommended: `sbx exec -it`, from `run_command`.**
```bash
exec sbx exec -it -w "$REPO_ROOT" "$SANDBOX_NAME" \
bash -lc 'ai-sbx-workspace'
```
No kit, no custom image, no change to how the sandbox is created. Reversible per run.
`sbx run` remains available untouched for anyone who wants the plain agent.
The alternative — a `kind: sandbox` kit with `entrypoint.run` — makes `sbx run`
itself open tmux, which is tidier semantically. It costs more: the kit must declare
the whole agent (image and entrypoint), the image must satisfy the base image
contract (non-root `agent` at UID 1000, passwordless sudo, proxy variables preserved
across sudo), and the agent's own launch flags must be reproduced. Not worth it for a
launch preference.
### Where tmux and neovim come from
**Recommended: start with `AI_SBX_TOOLS`, move to the image once it settles.**
```toml
AI_SBX_TOOLS = "bun tmux neovim"
```
Already implemented and needs no new code. Costs a per-sandbox install on first
`setup`, which is why the image is the eventual home — but proving the layout is
worth more than saving that minute, and the image cannot be iterated on as quickly.
## Part 1 — changes here
### 1. `AI_SBX_LAUNCH`
Follows the existing `AI_SBX_AGENT` / `AI_SBX_TEMPLATE` / `AI_SBX_TOOLS` pattern:
an environment variable read at the top of the task, overridable per invocation.
| Value | Behaviour |
| --- | --- |
| `agent` | Current behaviour: `sbx run`. **Default** |
| `tmux` | Attach to the workspace session |
Plus `--launch agent|tmux` on `run` for a one-off override. Persisting it per
repository in `save_config` is the wrong call — it is a preference about *this*
session, not a property of the repository, and the environment variable already
covers the durable case.
### 2. The launcher
A script the task installs into the sandbox, not an inline `sbx exec` string. Three
reasons: it must be idempotent, it needs real logic, and quoting a multi-window tmux
invocation through two shells is how mistakes happen.
```bash
#!/usr/bin/env bash
set -euo pipefail
session=ai-sbx
if tmux has-session -t "$session" 2>/dev/null; then
exec tmux attach-session -t "$session"
fi
tmux new-session -d -s "$session" -n agent -c "$PWD"
tmux new-window -t "$session:" -n edit -c "$PWD"
tmux new-window -t "$session:" -n shell -c "$PWD"
tmux send-keys -t "$session:agent" "$AGENT_COMMAND" C-m
tmux send-keys -t "$session:edit" "nvim ." C-m
tmux select-window -t "$session:agent"
exec tmux attach-session -t "$session"
```
Attach-or-create matters: detaching and re-running must land back in the same session
with the agent's context intact, which is most of the point.
Install it the way `mise` already is — copied to `~/.local/bin/` inside the sandbox
during `install_sandbox_mise`, or a sibling `install_sandbox_workspace`.
### 3. Resolve the agent command — do this first
**The one real unknown.** `sbx run` execs the agent with flags this project has never
seen, because the container only sleeps until attach. Claude Code is very likely
started with `--dangerously-skip-permissions` — the Sandboxes FAQ describes a kit
that exists specifically to *drop* that flag — but that is inference, not
observation.
Determine it before writing the launcher:
```bash
sbx run --name <sandbox> &
sbx exec <sandbox> ps -eo args | grep -i claude
```
If it cannot be recovered, fall back to `claude` plain and document the difference,
because silently changing the agent's permission model would be worse than the
inconvenience.
### 4. Tests
Consistent with the existing suite — pure functions and source-level assertions, no
sandbox required:
- `AI_SBX_LAUNCH` defaults to `agent`; `--launch` overrides it; an unknown value is
rejected rather than silently treated as `agent`.
- `run_command` dispatches to `sbx run` for `agent` and `sbx exec -it` for `tmux`,
asserted with a stubbed `sbx`, as `create_sandbox` already is.
- The launcher script is syntax-checked and shellcheck-clean.
- The launcher uses a **login** shell, since a non-login shell loses the whole
environment. Assert `bash -lc` appears.
## Part 2 — what to bake into the base image
Once the layout settles, move it out of `AI_SBX_TOOLS` and into
`mroberts/claude-sbx`, per `docs/HANDOFF.md` there.
### Packages
```dockerfile
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends tmux \
&& rm -rf /var/lib/apt/lists/*
```
Neovim is the awkward one: Ubuntu ships an old version, and a modern config will
expect ≥ 0.10. Prefer the upstream tarball or keep it on mise rather than `apt`.
### Dotfiles
`files/home/` in a kit, or `COPY --chown=agent:agent` in the template:
```
.tmux.conf mouse on, sane scrollback, obvious status line
.config/nvim/ the smallest config that is pleasant on a fresh machine
```
Keep the nvim config deliberately minimal. A plugin-manager bootstrap that fetches
from the network on first launch will hit the default-deny policy, and debugging that
inside a microVM is a bad first experience.
### The launcher
Bake the same script at `/usr/local/bin/ai-sbx-workspace` so the task can call it
without installing anything. Keep the task's copy as the fallback for stock images —
the two must not drift, so it should live in one place here and be copied into the
image build rather than maintained twice.
### Verify
Check what the base image already provides before adding anything:
```bash
docker run --rm docker/sandbox-templates:claude-code-docker \
bash -lc 'for c in tmux nvim vim git node python3; do printf "%-8s %s\n" "$c" "$(command -v $c || echo MISSING)"; done'
```
## Dotfiles (chezmoi)
Short answer: **mise supplies the binary, but not the dotfiles, and baking them into
the image is the wrong home for them.** A third path fits better — render on the host,
copy the result in — which is how `~/.claude` is already handled.
### Why not mise alone
`mise` installs chezmoi fine (`aqua:twpayne/chezmoi`, 2.71.1), so
`AI_SBX_TOOLS = "bun tmux neovim chezmoi"` puts the binary in every sandbox. Getting
the *content* in is where it stops, for two independent reasons:
| Blocker | Detail |
| --- | --- |
| The repo is private | `github.com/mickeyr/DotFiles` returns 404 anonymously. The sandbox's GitHub token is a fine-grained PAT scoped to the repository being worked on, so it cannot clone a personal repo |
| Six files are age-encrypted | The identity lives at `~/.config/chezmoi/key.txt` on the host. `chezmoi apply` in the sandbox would need that private key copied in |
So `chezmoi init --apply github.com/mickeyr/DotFiles` inside a sandbox fails twice
over. Fixing it by copying an age private key into an environment an agent can read is
worse than the problem.
### Why not the image
Dotfiles are personal and change often; the image is shared and slow to rebuild. Every
nvim tweak would mean a rebuild and a registry push. Worse, the image is pushed to a
registry — anything baked in travels with it.
### Recommended: `chezmoi archive` on the host, allowlisted
`chezmoi archive` renders the target state on the host, where the age key already is,
and emits a tar. No repo access, no key, and no network needed inside the sandbox.
```bash
DEV_CONTAINER=1 chezmoi archive --format tar <target>... |
sbx exec -i "$SANDBOX_NAME" tar -x -C "$sandbox_home"
```
Measured: 241 entries, 542 KB for the full set; `chezmoi archive ~/.config/nvim`
scopes it to 62.
**`DEV_CONTAINER=1` is required, not cosmetic.** The existing `.chezmoi.toml.tmpl`
already branches on it, and setting it disables `git.autoCommit` and `git.autoPush` —
without it, an agent operating in the sandbox could push to the dotfiles repo.
### The part that must not be got wrong
`chezmoi archive` **decrypts** as it renders. A full archive therefore contains, in
plaintext:
```text
.config/gh/hosts.yml GitHub CLI auth tokens
.npmrc npm registry tokens
.nuget/NuGet/NuGet.Config NuGet credentials
.ssh/config
.config/fish/conf.d/tokens.fish
.mcp.json
.aider.conf.yml
```
Copying that in would hand the agent the very credentials this project spends its
effort keeping out — the GitHub token is proxy-injected as a placeholder precisely so
it is unreadable, and `.config/gh/hosts.yml` would undo that in one step.
So the copy must be an **allowlist of targets**, matching `CLAUDE_CONFIG_ALLOW`:
```bash
CHEZMOI_TARGET_ALLOW=(
.config/nvim
.config/fish # audit: conf.d/tokens.fish must not be included
.tmux.conf
.gitconfig
)
```
A denylist is not good enough. New encrypted files appear over time, and the failure
mode is silent credential exfiltration into an agent's environment.
Two mechanical checks worth building in, since both are cheap:
- Enumerate encrypted targets and refuse to proceed if any is inside the allowlist:
`chezmoi target-path` resolves each `encrypted_*` source file to its target, and all
six mapped correctly when tested.
- Scan the rendered archive for credential shapes before it enters the sandbox — the
same guard the template build script already uses.
### Where it goes
A `install_sandbox_dotfiles` alongside `install_sandbox_claude_config`, gated on
`AI_SBX_DOTFILES` (unset = off). It runs on the host, so it needs no chezmoi in the
sandbox at all — which makes the mise entry optional, useful only if the agent should
be able to run `chezmoi` itself.
## Risks
**Environment loss.** Any window not started as a login shell loses PATH, AWS
credentials, and secret placeholders. This will look like "npm suddenly cannot
authenticate" rather than anything to do with tmux.
**Detach semantics.** `sbx exec -it` with a detached tmux session leaves the agent
running inside the sandbox after the terminal closes. Desirable, but different from
today, where closing the terminal ends the session. Worth stating in the README.
**Nested tmux.** A developer already inside tmux on the host gets a nested session.
Setting a distinct prefix in the sandbox `.tmux.conf` avoids a confusing fight over
`C-b`.
**Terminal type.** `TERM` must survive into the sandbox or nvim renders badly.
`sbx exec -t` should handle it; confirm rather than assume.
**LazyVim bootstrap — measured, and not a problem.** Every host the config needs is
already permitted by the `Balanced` policy, checked individually with
`sbx policy check network`:
```text
github.com codeload.github.com raw/objects.githubusercontent.com Allowed
registry.npmjs.org pypi.org files.pythonhosted.org Allowed
crates.io static.crates.io proxy.golang.org sum.golang.org Allowed
```
That covers all 93 pinned lazy.nvim plugins and all 55 mason packages (22 npm, 4 pypi,
29 GitHub releases). First launch will be slow, not blocked.
The hosts that *were* denied are the work ones — `npm.fontawesome.com`,
`proget.careevolution.com`, `localstack.cloud` — now handled by `AI_SBX_NETWORK` and
by automatic allowance of any host backing a provisioned secret.
## Acceptance
1. `AI_SBX_LAUNCH` unset → `mise run ai:sbx -- run` behaves exactly as today.
2. `AI_SBX_LAUNCH=tmux` → three windows, agent running in the first, all three in the
repository root.
3. Detach and re-run → reattaches to the same session, agent context intact.
4. Inside the shell window, `npm ci` in `webui/` still authenticates — proving the
environment survived.
5. `--launch agent` overrides the variable for one run.
+41 -3
View File
@@ -8,6 +8,7 @@ DEFAULT_MODE="${AI_SBX_MODE:-clone}"
DEFAULT_TOKEN_DAYS="${AI_SBX_TOKEN_DAYS:-30}" DEFAULT_TOKEN_DAYS="${AI_SBX_TOKEN_DAYS:-30}"
DEFAULT_TEMPLATE="${AI_SBX_TEMPLATE:-}" DEFAULT_TEMPLATE="${AI_SBX_TEMPLATE:-}"
DEFAULT_TOOLS="${AI_SBX_TOOLS:-bun}" DEFAULT_TOOLS="${AI_SBX_TOOLS:-bun}"
DEFAULT_NETWORK="${AI_SBX_NETWORK:-}"
# VAR|host[,host...]|requirement. Provisioned for every repository when the # VAR|host[,host...]|requirement. Provisioned for every repository when the
# variable is present in the host environment. "docker" skips the entry on a # variable is present in the host environment. "docker" skips the entry on a
@@ -86,6 +87,10 @@ sees a placeholder and the proxy substitutes the real value.
LOCALSTACK_AUTH_TOKEN is provisioned for every repository when it is set on LOCALSTACK_AUTH_TOKEN is provisioned for every repository when it is set on
the host and the sandbox has Docker to make use of it. the host and the sandbox has Docker to make use of it.
AI_SBX_NETWORK lists hosts to allow through the sandbox network policy, comma
or space separated. Hosts backing a provisioned secret are allowed
automatically, since a credential for a denied host can never be used.
AI_SBX_TOOLS lists mise tools installed globally in the sandbox, defaulting AI_SBX_TOOLS lists mise tools installed globally in the sandbox, defaulting
to bun because several Claude plugins run their hooks under it. Set it to an to bun because several Claude plugins run their hooks under it. Set it to an
empty string to install none. empty string to install none.
@@ -719,9 +724,7 @@ install_sandbox_claude_plugins() {
host="${host%%/*}" host="${host%%/*}"
if [[ "$host" != "github.com" ]]; then if [[ "$host" != "github.com" ]]; then
sbx policy allow network \ allow_sandbox_host "$host"
--sandbox "$SANDBOX_NAME" "$host" \
</dev/null >/dev/null 2>&1 || true
fi fi
# Without </dev/null sbx exec drains the loop's input and only the # Without </dev/null sbx exec drains the loop's input and only the
@@ -789,6 +792,33 @@ secret_placeholder() {
printf 'sbx-cs-%s' "$digest" printf 'sbx-cs-%s' "$digest"
} }
allow_sandbox_host() {
local host="$1"
[[ -n "$host" ]] || return 0
sbx policy allow network --sandbox "$SANDBOX_NAME" "$host" \
</dev/null >/dev/null 2>&1 || true
}
# The default policy denies everything outside common development hosts, so a
# private registry is unreachable no matter what credential it holds.
install_sandbox_network() {
[[ -n "$DEFAULT_NETWORK" ]] || return 0
local -a allow_hosts
read -r -a allow_hosts <<<"${DEFAULT_NETWORK//,/ }"
((${#allow_hosts[@]})) || return 0
local host
for host in "${allow_hosts[@]}"; do
allow_sandbox_host "$host"
done
printf 'Allowed network access to: %s\n' "${allow_hosts[*]}"
}
sandbox_has_docker() { sandbox_has_docker() {
sbx exec "$SANDBOX_NAME" \ sbx exec "$SANDBOX_NAME" \
bash -lc 'command -v docker >/dev/null' \ bash -lc 'command -v docker >/dev/null' \
@@ -804,6 +834,10 @@ provision_secret() {
local host local host
for host in ${hosts//,/ }; do for host in ${hosts//,/ }; do
host_args+=(--host "$host") host_args+=(--host "$host")
# A credential for a host the policy denies is dead weight: the request
# never leaves the sandbox, so the proxy never substitutes anything.
allow_sandbox_host "$host"
done done
# Piped rather than --value: the secret would otherwise be visible in the # Piped rather than --value: the secret would otherwise be visible in the
@@ -1115,6 +1149,8 @@ setup_command() {
install_sandbox_claude_config install_sandbox_claude_config
install_sandbox_network
install_sandbox_secrets install_sandbox_secrets
install_sandbox_mise install_sandbox_mise
@@ -1163,6 +1199,8 @@ config_command() {
install_sandbox_claude_config install_sandbox_claude_config
install_sandbox_network
install_sandbox_secrets install_sandbox_secrets
} }
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=tasks/ai/sbx
source "$(dirname "${BASH_SOURCE[0]}")/../tasks/ai/sbx"
failures=0
fail() {
printf 'FAIL: %s\n' "$1" >&2
failures=$((failures + 1))
}
SANDBOX_NAME=ai-test
REPOSITORY=owner/repo
allowed=""
sbx() {
if [[ "$1 $2" == "policy allow" ]]; then
allowed+="${*: -1} "
fi
cat >/dev/null 2>&1 || true
}
allowed=""
DEFAULT_NETWORK="a.example.com,b.example.com c.example.com" install_sandbox_network >/dev/null
for host in a.example.com b.example.com c.example.com; do
[[ "$allowed" == *"$host"* ]] ||
fail "install_sandbox_network skipped $host (comma and space must both split)"
done
allowed=""
DEFAULT_NETWORK="" install_sandbox_network >/dev/null
[[ -z "${allowed// /}" ]] ||
fail "an empty AI_SBX_NETWORK should allow nothing, got: $allowed"
allowed=""
provision_secret TOKEN "reg.example.com,*.cdn.example.com" secret-value >/dev/null
[[ "$allowed" == *"reg.example.com"* ]] ||
fail "provision_secret did not allow reg.example.com"
[[ "$allowed" == *"*.cdn.example.com"* ]] ||
fail "provision_secret did not allow the wildcard host"
allowed=""
provision_secret TOKEN '*.localstack.cloud' secret-value >/dev/null
[[ "$allowed" == *'*.localstack.cloud'* ]] ||
fail "wildcard host was mangled: '$allowed'"
allowed=""
allow_sandbox_host "" >/dev/null
[[ -z "${allowed// /}" ]] || fail "an empty host should be ignored"
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All network policy assertions passed.\n'