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
+40
View File
@@ -397,6 +397,46 @@ It is copied to the workspace root on every run. This matters under `--clone`, w
the agent gets a fresh git clone and an untracked `mise.local.toml` on the host would the agent gets a fresh git clone and an untracked `mise.local.toml` on the host would
not reach it. Repositories with no mise configuration are left alone. not reach it. Repositories with no mise configuration are left alone.
## Registry credentials
Repositories often need registry tokens — npm, NuGet — to install dependencies. The
task resolves them on the host, where 1Password and your keychain live, and provisions
them so the value never enters the sandbox.
Declare them per repository in your **own** config, not the repository's:
```text
~/.config/ai-sbx/repos/<digest>/secrets
```
```text
# VAR | host[,host...] | command printing the value, run from the repository root
FONTAWESOME_API_KEY | npm.fontawesome.com | scripts/npm-auth.sh print FONTAWESOME_API_KEY
PROGET_NPM_TOKEN | proget.careevolution.com | scripts/npm-auth.sh print PROGET_NPM_TOKEN
```
The `<digest>` is the suffix of the sandbox name, so read it off
`mise run ai:sbx -- status`.
**The value is never in the sandbox.** Each entry becomes an `sbx` custom secret: the
sandbox environment variable is set to a *placeholder*, and the proxy substitutes the
real secret into outbound request headers for the listed hosts. A committed `.npmrc`
using `${FONTAWESOME_API_KEY}` interpolation therefore works unchanged, while an agent
reading the variable sees only `sbx-cs-…`.
The declaration lives in user config for the same reason AWS profile approval does: a
repository must not be able to choose which host commands run or which credentials get
resolved. The command runs on your host, with your credentials.
Point it at whatever the repository already uses. A resolver that collapses several
sources into one `print <VAR>` interface is ideal, because the search order stays in
the repository where it belongs. If resolution fails, the command's own stderr is
surfaced — it names the variable and where to obtain it — and the remaining secrets
still provision.
Placeholders are derived from the repository and variable name, so re-running `setup`
does not invalidate a value already exported inside a running sandbox.
## AWS profile naming ## AWS profile naming
Only a trailing `-readonly` is removed. Everything else passes through: Only a trailing `-readonly` is removed. Everything else passes through:
+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 marketplaces and plugins your host has enabled. Credentials, transcripts and
history are never copied. Use "config" to re-apply after the host changes. 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 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.
@@ -732,6 +737,112 @@ install_sandbox_claude_plugins() {
done < <(host_enabled_plugins "$settings") 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 # 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 # 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. # 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_claude_config
install_sandbox_secrets
install_sandbox_mise install_sandbox_mise
cat <<EOF cat <<EOF
@@ -1007,6 +1120,8 @@ config_command() {
die "Sandbox does not exist. Run: mise run ai:sbx -- setup" die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
install_sandbox_claude_config install_sandbox_claude_config
install_sandbox_secrets
} }
run_command() { run_command() {
+77
View File
@@ -0,0 +1,77 @@
#!/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
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
failures=$((failures + 1))
}
REPOSITORY="CareEvolution/api-portal"
REPO_CONFIG_DIR="$work"
cat >"$work/secrets" <<'EOF'
# Registry credentials for this repository
FONTAWESOME_API_KEY | npm.fontawesome.com | scripts/npm-auth.sh print FONTAWESOME_API_KEY
PROGET_NPM_TOKEN|proget.careevolution.com|scripts/npm-auth.sh print PROGET_NPM_TOKEN
MULTI | a.example.com,b.example.com | echo hi # trailing comment
MISSING_COMMAND | host.example.com
NO_HOST || echo hi
EOF
mapfile -t lines < <(read_secret_declarations 2>/dev/null)
((${#lines[@]} == 3)) ||
fail "expected 3 valid declarations, got ${#lines[@]}: ${lines[*]}"
IFS='|' read -r var hosts command <<<"${lines[0]}"
[[ "$var" == "FONTAWESOME_API_KEY" ]] || fail "var mis-parsed: '$var'"
[[ "$hosts" == "npm.fontawesome.com" ]] || fail "hosts mis-parsed: '$hosts'"
[[ "$command" == "scripts/npm-auth.sh print FONTAWESOME_API_KEY" ]] ||
fail "command mis-parsed: '$command'"
IFS='|' read -r var hosts command <<<"${lines[1]}"
[[ "$var" == "PROGET_NPM_TOKEN" ]] || fail "unpadded var mis-parsed: '$var'"
[[ "$hosts" == "proget.careevolution.com" ]] || fail "unpadded host mis-parsed: '$hosts'"
IFS='|' read -r var hosts command <<<"${lines[2]}"
[[ "$hosts" == "a.example.com,b.example.com" ]] || fail "multi-host mis-parsed: '$hosts'"
[[ "$command" == "echo hi" ]] || fail "trailing comment not stripped: '$command'"
printf '%s\n' "${lines[@]}" | grep -q MISSING_COMMAND &&
fail "a declaration without a command was accepted"
printf '%s\n' "${lines[@]}" | grep -q NO_HOST &&
fail "a declaration without a host was accepted"
REPO_CONFIG_DIR="$work/nonexistent"
mapfile -t none < <(read_secret_declarations 2>/dev/null)
((${#none[@]} == 0)) || fail "absent file produced ${#none[@]} declarations"
first="$(secret_placeholder FONTAWESOME_API_KEY)"
second="$(secret_placeholder FONTAWESOME_API_KEY)"
[[ "$first" == "$second" ]] || fail "placeholder is not stable: $first vs $second"
[[ "$first" == sbx-cs-* ]] || fail "placeholder lacks the sbx-cs- prefix: $first"
[[ "$(secret_placeholder PROGET_NPM_TOKEN)" != "$first" ]] ||
fail "two variables share one placeholder"
REPOSITORY="other/repo"
[[ "$(secret_placeholder FONTAWESOME_API_KEY)" != "$first" ]] ||
fail "placeholder does not vary by repository"
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All secret declaration assertions passed.\n'