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:
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ require_force() {
|
|||||||
fail "no invocation of '$pattern' found; has it been renamed?"
|
fail "no invocation of '$pattern' found; has it been renamed?"
|
||||||
}
|
}
|
||||||
|
|
||||||
require_force 'sbx secret set'
|
require_force 'sbx secret set '
|
||||||
require_force 'sbx skills import'
|
require_force 'sbx skills import'
|
||||||
require_force 'sbx rm '
|
require_force 'sbx rm '
|
||||||
|
|
||||||
|
|||||||
Executable
+77
@@ -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'
|
||||||
Reference in New Issue
Block a user