Launch a tmux workspace and carry dotfiles into the sandbox

AI_SBX_LAUNCH=tmux (or run --launch tmux) attaches to a three-window tmux
session - agent, edit, shell - instead of the bare agent. The launcher is
vendored at tasks/ai/workspace and installed into the sandbox, so a custom
image and this task cannot drift. It is invoked through a login shell because
/etc/sandbox-persistent.sh is where PATH, the mise shims, the AWS credentials
and every secret placeholder live, and the tmux server hands that environment
to all three windows.

AI_SBX_DOTFILES=chezmoi renders the host chezmoi target state and unpacks it
into the sandbox, so no dotfiles repository, decryption key or network access
is needed inside. chezmoi archive decrypts as it renders, so the target list
is an allowlist, encrypted files resolving inside it are refused, and the
rendered archive is scanned for credential shapes before it enters the
sandbox.

Both default to off; with neither set, run behaves exactly as before.
This commit is contained in:
2026-08-03 13:49:24 -05:00
parent ad2b33f984
commit 14165503d5
7 changed files with 842 additions and 9 deletions
+212 -3
View File
@@ -9,6 +9,20 @@ DEFAULT_TOKEN_DAYS="${AI_SBX_TOKEN_DAYS:-30}"
DEFAULT_TEMPLATE="${AI_SBX_TEMPLATE:-}"
DEFAULT_TOOLS="${AI_SBX_TOOLS:-bun}"
DEFAULT_NETWORK="${AI_SBX_NETWORK:-}"
DEFAULT_LAUNCH="${AI_SBX_LAUNCH:-agent}"
DEFAULT_DOTFILES="${AI_SBX_DOTFILES:-}"
# Rendered dotfiles are checked for these before the archive enters the sandbox.
# chezmoi archive decrypts as it renders, so this is the last line of defence
# behind the allowlist rather than the first.
DOTFILES_CREDENTIAL_PATTERNS=(
'gh[pousr]_[A-Za-z0-9]{16,}'
'github_pat_[A-Za-z0-9_]{20,}'
'-----BEGIN [A-Z ]*PRIVATE KEY-----'
'AKIA[0-9A-Z]{16}'
'_authToken[[:space:]]*='
'aws_secret_access_key'
)
# VAR|host[,host...]|requirement. Provisioned for every repository when the
# variable is present in the host environment. "docker" skips the entry on a
@@ -65,7 +79,7 @@ Usage:
mise run ai:sbx -- token
mise run ai:sbx -- refresh
mise run ai:sbx -- config
mise run ai:sbx -- run [-- agent arguments...]
mise run ai:sbx -- run [--launch agent|tmux] [-- agent arguments...]
mise run ai:sbx -- status
mise run ai:sbx -- remove
@@ -91,6 +105,16 @@ 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_LAUNCH selects what "run" attaches to: "agent" starts the agent alone
and is the default, "tmux" attaches to a three-window workspace (agent, edit,
shell) that survives detaching. --launch overrides it for one run. Agent
arguments apply to "agent" only.
AI_SBX_DOTFILES=chezmoi renders the host's chezmoi dotfiles into the sandbox.
It requires an allowlist of target paths, one per line, in the user's config
directory as a "dotfiles" file. chezmoi decrypts as it renders, so setup
refuses to run when an encrypted file resolves inside the allowlist.
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
empty string to install none.
@@ -1031,6 +1055,154 @@ EOF
'
}
validate_launch_mode() {
case "$1" in
agent | tmux) ;;
*)
die "Unknown launch mode: $1 (expected agent or tmux)"
;;
esac
}
# The image may already carry the launcher. Its copy is built from this same
# file, so the two cannot drift, and skipping keeps a custom image authoritative
# about its own contents.
install_sandbox_workspace() {
local launcher
launcher="$(dirname "${BASH_SOURCE[0]}")/workspace"
[[ -f "$launcher" ]] ||
die "Workspace launcher is missing: $launcher"
if sbx exec "$SANDBOX_NAME" \
bash -c '[[ -x /usr/local/bin/ai-sbx-workspace ]]' \
</dev/null >/dev/null 2>&1; then
return 0
fi
local sandbox_home
# shellcheck disable=SC2016
sandbox_home="$(sbx exec "$SANDBOX_NAME" bash -c 'printf %s "$HOME"')"
[[ -n "$sandbox_home" ]] ||
die "Could not determine the sandbox home directory."
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" bash -c 'mkdir -p "$HOME/.local/bin"'
sbx cp "$launcher" "$SANDBOX_NAME:$sandbox_home/.local/bin/ai-sbx-workspace"
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" bash -c 'chmod 755 "$HOME/.local/bin/ai-sbx-workspace"'
}
# One target path per line, relative to the home directory. Comments and blank
# lines are ignored.
read_dotfiles_allowlist() {
local file="$CONFIG_ROOT/dotfiles"
[[ -f "$file" ]] ||
die "AI_SBX_DOTFILES is set but $file does not exist. List one target path per line, for example .config/nvim"
local line
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"
line="$(printf '%s' "$line" | xargs)"
[[ -n "$line" ]] || continue
printf '%s\n' "$line"
done <"$file"
}
# chezmoi archive decrypts as it renders, so an encrypted file inside the
# allowlist would arrive in the sandbox as plaintext credentials - defeating the
# proxy-injected GitHub token in a single step. A denylist would rot as new
# encrypted files appear, and the failure mode is silent, so refuse instead.
assert_no_encrypted_targets() {
local source_dir
source_dir="$(chezmoi source-path)" ||
die "Could not determine the chezmoi source directory."
local encrypted target allowed
while IFS= read -r encrypted; do
[[ -n "$encrypted" ]] || continue
target="$(chezmoi target-path "$encrypted" 2>/dev/null)" || continue
target="${target#"$HOME/"}"
for allowed in "$@"; do
[[ "$target" == "$allowed" || "$target" == "$allowed"/* ]] ||
continue
die "Dotfiles allowlist entry $allowed contains the encrypted file $target, which chezmoi would render in plaintext. Narrow $CONFIG_ROOT/dotfiles."
done
done < <(find "$source_dir" -type f -name '*encrypted_*' 2>/dev/null)
}
scan_dotfiles_archive() {
local archive="$1" pattern
for pattern in "${DOTFILES_CREDENTIAL_PATTERNS[@]}"; do
grep -aEq -- "$pattern" "$archive" ||
continue
die "The rendered dotfiles archive contains something shaped like a credential (matching /$pattern/). Narrow $CONFIG_ROOT/dotfiles."
done
}
# Rendered on the host, where the age identity already lives, and copied in as a
# tar. The sandbox needs no dotfiles repository, no key and no network for this.
install_sandbox_dotfiles() {
[[ -n "$DEFAULT_DOTFILES" ]] || return 0
[[ "$DEFAULT_DOTFILES" == chezmoi ]] ||
die "Unknown AI_SBX_DOTFILES value: $DEFAULT_DOTFILES (expected chezmoi)"
require_command chezmoi
local -a targets
mapfile -t targets < <(read_dotfiles_allowlist)
((${#targets[@]})) ||
die "The dotfiles allowlist $CONFIG_ROOT/dotfiles is empty."
assert_no_encrypted_targets "${targets[@]}"
local -a target_paths=()
local target
for target in "${targets[@]}"; do
target_paths+=("$HOME/$target")
done
local archive
archive="$(mktemp)"
trap 'rm -f "$archive"' RETURN
# DEV_CONTAINER=1 is required, not cosmetic: .chezmoi.toml.tmpl branches on
# it to disable git.autoCommit and git.autoPush, and without it an agent in
# the sandbox could push to the dotfiles repository.
DEV_CONTAINER=1 chezmoi archive --format tar "${target_paths[@]}" >"$archive" ||
die "chezmoi could not render the dotfiles archive."
scan_dotfiles_archive "$archive"
local sandbox_home
# shellcheck disable=SC2016
sandbox_home="$(sbx exec "$SANDBOX_NAME" bash -c 'printf %s "$HOME"')"
[[ -n "$sandbox_home" ]] ||
die "Could not determine the sandbox home directory."
sbx exec -i "$SANDBOX_NAME" tar -x -C "$sandbox_home" <"$archive" ||
die "Could not unpack the dotfiles archive in $SANDBOX_NAME."
rm -f "$archive"
trap - RETURN
printf 'Installed dotfiles into %s: %s\n' "$SANDBOX_NAME" "${targets[*]}"
}
create_sandbox() {
load_config
@@ -1155,6 +1327,8 @@ setup_command() {
install_sandbox_claude_config
install_sandbox_dotfiles
install_sandbox_network
install_sandbox_secrets
@@ -1205,12 +1379,41 @@ config_command() {
install_sandbox_claude_config
install_sandbox_dotfiles
install_sandbox_network
install_sandbox_secrets
}
run_command() {
local launch="$DEFAULT_LAUNCH"
# Everything after -- belongs to the agent, including anything that looks
# like an option of this task.
while (($#)); do
case "$1" in
--launch)
(($# >= 2)) || die "--launch requires a value"
launch="$2"
shift 2
;;
--)
shift
break
;;
*)
break
;;
esac
done
validate_launch_mode "$launch"
if [[ "$launch" == tmux ]] && (($#)); then
die "Agent arguments are only supported with --launch agent; got: $*"
fi
load_config
sandbox_exists ||
@@ -1224,8 +1427,14 @@ run_command() {
# runs every time rather than only at setup.
install_sandbox_mise
if (($#)) && [[ "$1" == "--" ]]; then
shift
if [[ "$launch" == tmux ]]; then
install_sandbox_workspace
# bash -lc is mandatory: /etc/sandbox-persistent.sh is where PATH, the
# mise shims, the AWS credentials and every secret placeholder live, and
# the tmux server inherits its environment from this shell.
exec sbx exec -it -w "$REPO_ROOT" "$SANDBOX_NAME" \
bash -lc "ai-sbx-workspace $(printf '%q' "$CONFIG_AGENT")"
fi
if (($#)); then