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:
+212
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user