Provision repository registry credentials as sandbox secrets

Repositories need registry tokens to install dependencies, and those live
behind 1Password or a keychain that only exists on the host. A secrets file in
the user's per-repository config names each variable, the hosts it
authenticates to, and a command that prints it; the command runs on the host
from the repository root and its output becomes an sbx custom secret.

Custom secrets keep the value out of the sandbox entirely: the environment
variable is set to a placeholder and the proxy substitutes the real secret into
outbound request headers for the declared hosts. A committed .npmrc using
${VAR} interpolation therefore works unchanged while the agent sees only the
placeholder. Placeholders are derived from the repository and variable name so
re-running setup does not invalidate one already exported into a running
sandbox, and the value is piped rather than passed as --value, which would put
it in the process list.

The declaration lives in user config rather than the repository for the same
reason AWS profile approval does: a checkout must not choose which host
commands run or which credentials resolve.

A failed resolver has its own stderr surfaced, since it names where to obtain
the credential, and the remaining secrets still provision.

sbx secret set-custom was measured to overwrite silently and has no --force
flag, so the non-interactive test now matches sbx secret set precisely rather
than by prefix.
This commit is contained in:
2026-07-31 11:42:36 -05:00
parent c195a82b82
commit 93dc61a024
4 changed files with 233 additions and 1 deletions
+115
View File
@@ -72,6 +72,11 @@ AGENTS.md, agents, commands, hooks, skills) into the sandbox and installs the
marketplaces and plugins your host has enabled. Credentials, transcripts and
history are never copied. Use "config" to re-apply after the host changes.
Registry credentials are declared per repository in the user's config
directory as a "secrets" file, one line of VAR|host|command each. The command
runs on the host and its output becomes an sbx custom secret, so the sandbox
sees a placeholder and the proxy substitutes the real value.
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.
@@ -732,6 +737,112 @@ install_sandbox_claude_plugins() {
done < <(host_enabled_plugins "$settings")
}
# A repository declares which registry credentials it needs, but the declaration
# lives in the user's own config rather than the repository, so a checkout can
# never choose which host commands run or which secrets get resolved.
#
# VAR | host[,host...] | command printing the value on stdout
#
# The command runs on the host, from the repository root, where 1Password and
# the developer's keychain are available.
read_secret_declarations() {
local file="$REPO_CONFIG_DIR/secrets"
[[ -f "$file" ]] || return 0
local line var hosts command
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"
[[ -n "${line//[[:space:]]/}" ]] || continue
IFS='|' read -r var hosts command <<<"$line"
var="$(printf '%s' "$var" | xargs)"
hosts="$(printf '%s' "$hosts" | xargs)"
command="$(printf '%s' "$command" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
[[ -n "$var" && -n "$hosts" && -n "$command" ]] || {
printf 'Ignoring malformed secret declaration: %s\n' "$line" >&2
continue
}
printf '%s|%s|%s\n' "$var" "$hosts" "$command"
done <"$file"
}
# Derived rather than random so re-running setup does not invalidate the value
# already exported inside a running sandbox. The placeholder is not a secret;
# it is the stand-in the proxy swaps for one.
secret_placeholder() {
local var="$1" digest
digest="$(printf '%s' "$REPOSITORY/$var" | sha256sum | cut -c1-16)"
printf 'sbx-cs-%s' "$digest"
}
install_sandbox_secrets() {
local declarations
declarations="$(read_secret_declarations)" || return 0
[[ -n "$declarations" ]] || return 0
local var hosts command value placeholder
local -a host_args
while IFS='|' read -r var hosts command; do
[[ -n "$var" ]] || continue
# The resolver prints guidance to stderr naming where to obtain the
# credential, which is more useful than anything this task could add.
if ! value="$(cd "$REPO_ROOT" && eval "$command" 2>&1)"; then
printf 'Could not resolve %s:\n%s\n' "$var" "$value" >&2
continue
fi
[[ -n "$value" ]] || {
printf 'Resolver for %s printed nothing.\n' "$var" >&2
continue
}
placeholder="$(secret_placeholder "$var")"
host_args=()
local host
for host in ${hosts//,/ }; do
host_args+=(--host "$host")
done
# Piped rather than --value: the secret would otherwise be visible in
# the process list to anything running as this user.
if printf '%s' "$value" |
sbx secret set-custom "$SANDBOX_NAME" \
"${host_args[@]}" \
--env "$var" \
--placeholder "$placeholder" >/dev/null 2>&1; then
printf 'Provisioned %s for %s\n' "$var" "${hosts//,/, }"
else
printf 'Could not store %s in the sandbox.\n' "$var" >&2
continue
fi
# A sandbox that already exists keeps whatever environment it was
# created with, so the placeholder is exported explicitly.
sbx exec "$SANDBOX_NAME" bash -c "
persistent=/etc/sandbox-persistent.sh
marker=$(printf '%q' "# ai-sbx secret $var")
grep -Fq \"\$marker\" \"\$persistent\" 2>/dev/null && exit 0
printf '%s\nexport %s=%s\n' \
\"\$marker\" $(printf '%q' "$var") $(printf '%q' "$placeholder") \
>>\"\$persistent\"
" </dev/null >/dev/null 2>&1 || true
unset value
done <<<"$declarations"
}
# Plugins bring their own runtime requirements - claude-mem and others run
# their hooks under bun, which the sandbox image does not carry - and a missing
# one surfaces as a hook error on every prompt rather than at install time.
@@ -962,6 +1073,8 @@ setup_command() {
install_sandbox_claude_config
install_sandbox_secrets
install_sandbox_mise
cat <<EOF
@@ -1007,6 +1120,8 @@ config_command() {
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
install_sandbox_claude_config
install_sandbox_secrets
}
run_command() {