From ef4a84abfdb65ad4371b63d788bd511585eeaeb0 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 22 Oct 2026 09:45:20 +0000 Subject: [PATCH 01/63] test(acq): add offline provider adoption verification --- docs/VERIFY_PROVIDER_ADOPTION.md | 68 +++++++++++++++ scripts/verify-backends | 27 +++++- test/bats/146-provider-verification.bats | 103 +++++++++++++++++++---- 3 files changed, 177 insertions(+), 21 deletions(-) create mode 100644 docs/VERIFY_PROVIDER_ADOPTION.md diff --git a/docs/VERIFY_PROVIDER_ADOPTION.md b/docs/VERIFY_PROVIDER_ADOPTION.md new file mode 100644 index 00000000..f2ff690a --- /dev/null +++ b/docs/VERIFY_PROVIDER_ADOPTION.md @@ -0,0 +1,68 @@ +--- +title: "Provider-Neutral Adoption Validation" +description: "Offline and live procedures for validating provider-input adoption on SBX and MSB" +status: draft +tier: 2 +last_updated: "2026-10-22" +audience: "developers" +keywords: ["provider", "validation", "sbx", "msb", "opencode", "offline"] +related_files: ["scripts/verify-backends", "test/bats/146-provider-verification.bats", "test/bats/147-provider-input-projection.bats", "docs/howto/acq.md"] +load_priority: "on-demand" +review_cycle: "quarterly" +--- + +# Provider-Neutral Adoption Validation + +This procedure separates the preparatory offline checks from the live backend +validation required before provider-input adoption can be considered complete. +The Bats cases named below are **offline tests**. They use local fixtures and +stubs; they are not E2E tests and do not prove that SBX, MSB, USAi, or a fresh +OpenCode process works on a real host. + +## Offline Checks + +Run the focused provider checks after initializing the Bats submodules: + +```bash +./scripts/test-acq-bats test/bats/146-provider-verification.bats +./scripts/test-acq-bats test/bats/147-provider-input-projection.bats +``` + +Expected result: both commands complete successfully, with every Bats case +reported as `ok`. These checks prove that provider facts and projected inputs are consumed generically, URL and environment inputs are validated, and +malformed facts fail closed without invoking a backend or a network service. + +## Live Validation Record + +Run on a sandbox-capable host, outside any sandbox. Use a fresh temporary +workspace and the backend-specific command for each row. Never paste or print a +real provider key; the verifier's synthetic value only tests binding and +transport plumbing. + +Record the observed command output, timestamp, backend version, and one of +`PASS`, `FAIL`, or `BLOCKED` for every row. `BLOCKED` is required when the +backend, virtualization, credentials, or released provider kit is unavailable. + +| Check | SBX | MSB | Observed output / notes | +|---|---|---|---| +| Fresh `acq run opencode .` startup | BLOCKED | BLOCKED | No live run claimed in this preparatory slice | +| Fresh OpenCode process starts | BLOCKED | BLOCKED | Record actual startup output | +| `acq exec NAME -- opencode models` consumes projected provider input | BLOCKED | BLOCKED | Record actual model listing; do not claim from mocks | +| Remove projected provider input and restart OpenCode | BLOCKED | BLOCKED | Record expected actionable failure or documented fallback | +| Malformed provider input is rejected before startup | BLOCKED | BLOCKED | Record exact validation error and exit status | +| `scripts/verify-backends --only ` | BLOCKED | BLOCKED | Run only outside a sandbox; capture transcript | + +Example live commands, with placeholders only: + +```bash +./acq run opencode /path/to/fresh-project +./acq exec -- opencode models +./acq exec -- sh -c '...remove the projected provider input...' +./acq stop +./scripts/verify-backends --only sbx +./scripts/verify-backends --only msb +``` + +Do not mark this record `PASS` from the offline Bats suite. Update the table +only after the real SBX/MSB flows have run and their output has been retained in +the draft PR verification transcript. diff --git a/scripts/verify-backends b/scripts/verify-backends index 1137a509..d405164a 100755 --- a/scripts/verify-backends +++ b/scripts/verify-backends @@ -155,7 +155,8 @@ trace() { } # Refuse to run inside a sandbox (no nested sandboxes). -if [ -f /.dockerenv ] || [ -n "${SBX_SANDBOX:-}" ] || [ -n "${MSB_SANDBOX:-}" ]; then +if [ "${VERIFY_BACKENDS_SOURCE_ONLY:-}" != "1" ] && \ + { [ -f /.dockerenv ] || [ -n "${SBX_SANDBOX:-}" ] || [ -n "${MSB_SANDBOX:-}" ]; }; then echo "verify-backends: refusing to run inside a sandbox (no nested sandboxes)." >&2 echo " Run on a sandbox-capable host. See ADR-0011 Validation." >&2 exit 2 @@ -373,9 +374,19 @@ _sbx_secret_has_global_row() { } load_provider_facts() { + local source_rc # shellcheck disable=SC1090 - ACQ_SOURCE_ONLY=1 ACQ_SCRIPT_DIR="$REPO_ROOT" . "$ACQ" >/dev/null 2>&1 - acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" + if ACQ_SOURCE_ONLY=1 ACQ_SCRIPT_DIR="$REPO_ROOT" . "$ACQ" >/dev/null 2>&1; then + : + else + source_rc=$? + echo "verify-backends: unable to source acq from $ACQ (exit $source_rc)" >&2 + return "$source_rc" + fi + if ! acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT"; then + echo "verify-backends: provider facts could not be loaded from $USAI_KIT" >&2 + return 1 + fi USAI_HOST="$USAI_PROVIDER_HOST" USAI_MODELS_URL="$USAI_PROVIDER_MODELS_URL" } @@ -1407,6 +1418,16 @@ verify_agent_install() { unseed_usai_secret "$backend" "$aname" } +# Tests source this script to exercise the production functions without creating +# sandboxes. Keep this opt-in so the normal executable path remains live E2E. +if [ "${VERIFY_BACKENDS_SOURCE_ONLY:-}" = "1" ]; then + if [ "${BASH_SOURCE[0]}" = "$0" ]; then + echo "verify-backends: source-only mode requires sourcing this script" >&2 + exit 2 + fi + return 0 +fi + echo "verify-backends" echo " workspace: $WORKDIR" diff --git a/test/bats/146-provider-verification.bats b/test/bats/146-provider-verification.bats index 8c32de61..17413605 100644 --- a/test/bats/146-provider-verification.bats +++ b/test/bats/146-provider-verification.bats @@ -10,16 +10,18 @@ setup() { acq_setup_stubs; } teardown() { acq_teardown_stubs; } _check_reachability_harness() { + export REPO_ROOT cat <<'PRELUDE' set -u + VERIFY_BACKENDS_SOURCE_ONLY=1 + export VERIFY_BACKENDS_SOURCE_ONLY + source "$REPO_ROOT/scripts/verify-backends" PASS=0 FAIL=0 LAST_OUT="" LAST_ARGS="" pass() { PASS=$((PASS + 1)); } fail() { FAIL=$((FAIL + 1)); printf 'FAIL=%s\n' "$1"; } dump() { :; } run_acq() { LAST_ARGS="$*"; LAST_OUT="$FAKE_OUTPUT"; return "${FAKE_RC:-0}"; } PRELUDE - awk '/^check_usai_reachability\(\) \{$/,/^\}$/ { print }' \ - "$REPO_ROOT/scripts/verify-backends" } @test "provider verification: failed reachability command is a failure" { @@ -76,15 +78,16 @@ PRELUDE USAI_SEEDED_FOR= LAST_OUT= CALLS="'"$calls"'" - run_acq() { return 0; } sbx() { case "${1:-} ${2:-}" in "secret ls") printf "CUSTOM SECRETS\nSCOPE TARGETS ENV PLACEHOLDER\n" ;; "secret set-custom") printf "set-custom %s\n" "$*" >> "$CALLS" ;; esac } - source="$(awk "/^seed_usai_secret\(\) \{$/,/^}$/" "'"$REPO_ROOT"'/scripts/verify-backends")" - eval "$source" + VERIFY_BACKENDS_SOURCE_ONLY=1 + export VERIFY_BACKENDS_SOURCE_ONLY + . "'"$REPO_ROOT"'/scripts/verify-backends" + run_acq() { return 0; } seed_usai_secret sbx box ' assert_success @@ -104,16 +107,17 @@ PRELUDE USAI_PROVIDER_KEY_ENV=CUSTOM_API_KEY USAI_SEEDED_FOR= CALLS="'"$calls"'" - run_acq() { return 0; } sbx() { case "${1:-} ${2:-}" in "secret ls") printf "CUSTOM SECRETS\nSCOPE TARGETS ENV PLACEHOLDER\n(global) custom.example.gov CUSTOM_API_KEY placeholder\n" ;; "secret set-custom") printf "unexpected seed\n" >> "$CALLS" ;; esac } + VERIFY_BACKENDS_SOURCE_ONLY=1 + export VERIFY_BACKENDS_SOURCE_ONLY + . "'"$REPO_ROOT"'/scripts/verify-backends" + run_acq() { return 0; } _sbx_secret_has_global_row() { return 0; } - source="$(awk "/^seed_usai_secret\(\) \{$/,/^}$/" "'"$REPO_ROOT"'/scripts/verify-backends")" - eval "$source" seed_usai_secret sbx box ' assert_success @@ -129,16 +133,80 @@ PRELUDE assert_success } +@test "provider verification: projected provider facts drive the reachability input" { + run bash -c ' + set -u + VERIFY_BACKENDS_SOURCE_ONLY=1 + export VERIFY_BACKENDS_SOURCE_ONLY + . "'"$REPO_ROOT"'/scripts/verify-backends" + ACQ=/dev/null + USAI_KIT="$STUBDIR/provider-kit" + acq_provider_facts_load_from_kit_or_fallback() { + USAI_PROVIDER_HOST=models.example.gov + USAI_PROVIDER_MODELS_URL=https://models.example.gov/v2/catalog + USAI_PROVIDER_KEY_ENV=EXAMPLE_API_KEY + } + load_provider_facts + FAKE_OUTPUT=200 FAKE_RC=0 LAST_ARGS= + run_acq() { LAST_ARGS="$*"; LAST_OUT="$FAKE_OUTPUT"; return "$FAKE_RC"; } + check_usai_reachability msb box "$USAI_MODELS_URL" + case "$LAST_ARGS" in + *"verify https://models.example.gov/v2/catalog"*) ;; + *) printf "unexpected projected input: %s\n" "$LAST_ARGS"; exit 1 ;; + esac + ' + assert_success +} + +@test "provider verification: malformed provider facts fail closed before reachability" { + run bash -c ' + set -u + VERIFY_BACKENDS_SOURCE_ONLY=1 + export VERIFY_BACKENDS_SOURCE_ONLY + . "'"$REPO_ROOT"'/scripts/verify-backends" + ACQ=/dev/null + USAI_KIT="$STUBDIR/provider-kit" + acq_provider_facts_load_from_kit_or_fallback() { + printf invalid-provider-facts-artifact\\n >&2 + USAI_PROVIDER_HOST="" + USAI_PROVIDER_MODELS_URL="" + return 1 + } + if load_provider_facts; then + printf "unexpected provider facts success\n" + exit 1 + fi + printf "provider facts rejected\n" + ' + assert_success + assert_output --partial 'invalid-provider-facts-artifact' + assert_output --partial 'verify-backends: provider facts could not be loaded from' + assert_output --partial 'provider facts rejected' +} + +@test "provider verification: acq source failure is actionable and does not fall through" { + run bash -c ' + set -u + VERIFY_BACKENDS_SOURCE_ONLY=1 + export VERIFY_BACKENDS_SOURCE_ONLY + . "'"$REPO_ROOT"'/scripts/verify-backends" + ACQ="$STUBDIR/missing-acq" + load_provider_facts 2>"$STUBDIR/diagnostics"; rc=$? + cat "$STUBDIR/diagnostics" + exit "$rc" + ' + assert_failure + assert_output --partial 'verify-backends: unable to source acq from' + refute_output --partial 'acq_provider_facts_load_from_kit_or_fallback: command not found' +} + @test "provider verification: seeded credential is required for an installed backend" { run bash -c ' set -u REPO_ROOT="'"$REPO_ROOT"'" - source="$(awk "/^seed_usai_secret\(\) \{$/,/^}$/" "$REPO_ROOT/scripts/verify-backends")" - require_source="$(awk "/^require_usai_seed\(\) \{$/,/^}$/" "$REPO_ROOT/scripts/verify-backends")" - status_source="$(awk "/^verification_succeeded\(\) \{$/,/^}$/" "$REPO_ROOT/scripts/verify-backends")" - eval "$source" - eval "$require_source" - eval "$status_source" + VERIFY_BACKENDS_SOURCE_ONLY=1 + export VERIFY_BACKENDS_SOURCE_ONLY + . "$REPO_ROOT/scripts/verify-backends" VERIFY_USAI_DUMMY=synthetic USAI_HOST=api.example.gov USAI_PROVIDER_KEY_ENV=EXAMPLE_API_KEY @@ -184,10 +252,9 @@ PRELUDE @test "provider verification: lost post-provision binding fails and cleans up" { run bash -c ' REPO_ROOT="'"$REPO_ROOT"'" - cleanup_source="$(awk "/^cleanup_failed_backend_verification\(\) \{$/,/^}$/" "$REPO_ROOT/scripts/verify-backends")" - status_source="$(awk "/^verification_succeeded\(\) \{$/,/^}$/" "$REPO_ROOT/scripts/verify-backends")" - eval "$cleanup_source" - eval "$status_source" + VERIFY_BACKENDS_SOURCE_ONLY=1 + export VERIFY_BACKENDS_SOURCE_ONLY + . "$REPO_ROOT/scripts/verify-backends" FAIL=1 KEEP="" CALLS="" unseed_usai_secret() { CALLS="${CALLS} usai:$1:$2"; } unseed_github_secret() { CALLS="${CALLS} github:$1:$2"; } From 7ea0091abe8a21f1a026c1454e49e34198c50272 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 22 Oct 2026 10:14:53 +0000 Subject: [PATCH 02/63] test(verify): prepare provider adoption validation Add a non-mutating source seam for production verifier functions, generic provider-facts regression coverage, and guarded live SBX/MSB validation procedures that record PASS, FAIL, or BLOCKED honestly. Co-authored-by: OpenCode Agent --- docs/VERIFY_PROVIDER_ADOPTION.md | 157 +++++++++++++++++--- scripts/verify-backends | 173 +++++++++++++++-------- test/bats/146-provider-verification.bats | 103 +++++++++++++- 3 files changed, 350 insertions(+), 83 deletions(-) diff --git a/docs/VERIFY_PROVIDER_ADOPTION.md b/docs/VERIFY_PROVIDER_ADOPTION.md index f2ff690a..bde77a00 100644 --- a/docs/VERIFY_PROVIDER_ADOPTION.md +++ b/docs/VERIFY_PROVIDER_ADOPTION.md @@ -15,9 +15,11 @@ review_cycle: "quarterly" This procedure separates the preparatory offline checks from the live backend validation required before provider-input adoption can be considered complete. -The Bats cases named below are **offline tests**. They use local fixtures and -stubs; they are not E2E tests and do not prove that SBX, MSB, USAi, or a fresh -OpenCode process works on a real host. +The Bats cases named below are **offline seam tests**. They use local fixtures +and stubs to verify internal wiring and control-flow; they are not E2E tests and +do not prove that SBX, MSB, USAi, or a fresh OpenCode process works on a real +host. Real projected parser validation requires a live backend run (see +"Live Validation Record" below). ## Offline Checks @@ -29,19 +31,26 @@ Run the focused provider checks after initializing the Bats submodules: ``` Expected result: both commands complete successfully, with every Bats case -reported as `ok`. These checks prove that provider facts and projected inputs are consumed generically, URL and environment inputs are validated, and -malformed facts fail closed without invoking a backend or a network service. +reported as `ok`. These checks prove that provider facts and projected inputs +are wired correctly in offline stubs, that URL and environment inputs flow +through the expected code paths, and that malformed facts fail closed without +invoking a backend or a network service. They do NOT prove that a real provider +kit returns valid values, that a real projected parser accepts or rejects +specific inputs, or that a live SBX or MSB run succeeds. ## Live Validation Record -Run on a sandbox-capable host, outside any sandbox. Use a fresh temporary -workspace and the backend-specific command for each row. Never paste or print a -real provider key; the verifier's synthetic value only tests binding and -transport plumbing. +Run on a sandbox-capable host (Docker for SBX, KVM/HVF for MSB), outside any +sandbox. Use a throwaway workspace created specifically for this validation; the +workspace directory must be removed after the run. Never paste or print a real +provider key; the verifier seeds a synthetic dummy value that only tests binding +and transport plumbing. Record the observed command output, timestamp, backend version, and one of `PASS`, `FAIL`, or `BLOCKED` for every row. `BLOCKED` is required when the -backend, virtualization, credentials, or released provider kit is unavailable. +backend, virtualization, credentials, or released provider kit is unavailable +and the step cannot run. Do not use `PASS` for a row unless you observed the +actual command output. | Check | SBX | MSB | Observed output / notes | |---|---|---|---| @@ -52,17 +61,129 @@ backend, virtualization, credentials, or released provider kit is unavailable. | Malformed provider input is rejected before startup | BLOCKED | BLOCKED | Record exact validation error and exit status | | `scripts/verify-backends --only ` | BLOCKED | BLOCKED | Run only outside a sandbox; capture transcript | -Example live commands, with placeholders only: +### Negative-Validation Procedure (malformed provider input) + +This procedure proves that host-side provider validation rejects a malformed +selected kit before backend creation. It does not modify the read-only guest +projection. Run it separately for `sbx` and `msb` on a sandbox-capable host. + +```bash +set -eu +VERIFY_PROVIDER_ADOPTION_ROOT=$(mktemp -d \ + "${TMPDIR:-/tmp}/acq-provider-adoption.XXXXXX") +case "$VERIFY_PROVIDER_ADOPTION_ROOT" in + "${TMPDIR:-/tmp}"/acq-provider-adoption.*) ;; + *) echo "unsafe validation root" >&2; exit 1 ;; +esac +cleanup_provider_adoption() { + case "${VERIFY_PROVIDER_ADOPTION_ROOT:-}" in + "${TMPDIR:-/tmp}"/acq-provider-adoption.*) + chmod -R u+w "$VERIFY_PROVIDER_ADOPTION_ROOT" 2>/dev/null || true + rm -rf -- "$VERIFY_PROVIDER_ADOPTION_ROOT" + ;; + esac +} +trap cleanup_provider_adoption EXIT INT TERM + +VERIFY_PROVIDER_ADOPTION_WS="$VERIFY_PROVIDER_ADOPTION_ROOT/workspace" +VERIFY_PROVIDER_ADOPTION_KIT="$VERIFY_PROVIDER_ADOPTION_ROOT/malformed-kit" +mkdir -p "$VERIFY_PROVIDER_ADOPTION_WS" "$VERIFY_PROVIDER_ADOPTION_KIT" +git -C "$VERIFY_PROVIDER_ADOPTION_WS" init -q +git -C "$VERIFY_PROVIDER_ADOPTION_WS" config user.email test@example.com +git -C "$VERIFY_PROVIDER_ADOPTION_WS" config user.name "acq verify" +printf '# provider adoption validation\n' \ + > "$VERIFY_PROVIDER_ADOPTION_WS/README.md" + +cat > "$VERIFY_PROVIDER_ADOPTION_KIT/spec.yaml" <<'YAML' +schemaVersion: "hybrid/v1" +kind: mixin +name: malformed-provider-validation +displayName: Malformed provider validation +description: Deliberately malformed host-side provider input +YAML +printf '{this-is-not-json\n' \ + > "$VERIFY_PROVIDER_ADOPTION_KIT/provider-input.json" + +BACKEND=sbx # repeat the procedure with BACKEND=msb +SANDBOX=provider-adoption-malformed-${BACKEND} +VALIDATION_LOG="$VERIFY_PROVIDER_ADOPTION_ROOT/malformed-${BACKEND}.log" +if ./acq --backend "$BACKEND" create opencode \ + "$VERIFY_PROVIDER_ADOPTION_WS" --name "$SANDBOX" \ + --kit "$VERIFY_PROVIDER_ADOPTION_KIT" \ + >"$VALIDATION_LOG" 2>&1; then + echo "FAIL: malformed provider input reached backend creation" >&2 + ./acq --backend "$BACKEND" rm "$SANDBOX" >/dev/null 2>&1 || true + exit 1 +fi +cat "$VALIDATION_LOG" +if grep -Fq 'provider-input.json' "$VALIDATION_LOG" \ + && grep -Eqi 'json (decode|parse|syntax)|invalid json|schema validation|does not match.*schema' \ + "$VALIDATION_LOG"; then + echo "PASS: malformed provider input was rejected before startup" +elif grep -Eqi 'not installed|unavailable|requires python|credential|virtualization|cannot connect|CLI not found on PATH' \ + "$VALIDATION_LOG"; then + echo "BLOCKED: backend or host prerequisite prevented validation" >&2 + exit 2 +else + echo "FAIL: create failed without the expected provider-input diagnostic" >&2 + exit 1 +fi +``` + +Record the non-zero exit status and exact host-side validation diagnostic. If +the selected backend or its host prerequisites are unavailable, record +`BLOCKED`; do not substitute an offline seam test. The guarded `EXIT` trap +removes only the uniquely named temporary root created by this procedure. + +Run the positive live verifier separately, outside any sandbox: ```bash -./acq run opencode /path/to/fresh-project -./acq exec -- opencode models -./acq exec -- sh -c '...remove the projected provider input...' -./acq stop ./scripts/verify-backends --only sbx ./scripts/verify-backends --only msb ``` -Do not mark this record `PASS` from the offline Bats suite. Update the table -only after the real SBX/MSB flows have run and their output has been retained in -the draft PR verification transcript. +For the remaining positive and removal rows, run these commands once per +backend. Use a fresh throwaway workspace and record the real sandbox name from +`acq run` output: + +```bash +BACKEND=sbx # repeat with BACKEND=msb +LIVE_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/acq-provider-live.XXXXXX") +case "$LIVE_ROOT" in + "${TMPDIR:-/tmp}"/acq-provider-live.*) ;; + *) echo "unsafe live validation root" >&2; exit 1 ;; +esac +cleanup_provider_live() { + case "${LIVE_ROOT:-}" in + "${TMPDIR:-/tmp}"/acq-provider-live.*) + chmod -R u+w "$LIVE_ROOT" 2>/dev/null || true + rm -rf -- "$LIVE_ROOT" + ;; + esac +} +trap cleanup_provider_live EXIT INT TERM + +git -C "$LIVE_ROOT" init -q +git -C "$LIVE_ROOT" config user.email test@example.com +git -C "$LIVE_ROOT" config user.name "acq verify" +printf '# live provider adoption validation\n' > "$LIVE_ROOT/README.md" + +./acq --backend "$BACKEND" run opencode "$LIVE_ROOT" +SANDBOX='' +./acq --backend "$BACKEND" exec "$SANDBOX" -- opencode models +./acq --backend "$BACKEND" rm "$SANDBOX" +``` + +To verify provider removal, create a second fresh sandbox with an empty +authoritative provider set by overriding the default provider kit selection in +a temporary test configuration. Record the exact override used and confirm that +`opencode models` no longer lists the removed provider. If the installed `acq` +version has no supported host-side way to omit the default provider kit, record +that row as `BLOCKED`; do not edit the read-only guest projection or claim a +mocked result as live evidence. + +**Outcome recording:** Update the Live Validation Record table above with the +observed output, timestamp, and backend version for each row that ran. Do not +mark any row `PASS` from the offline Bats suite. Update the table only after the +real SBX/MSB flows have run and their output has been retained in the draft PR +verification transcript. diff --git a/scripts/verify-backends b/scripts/verify-backends index d405164a..bbcb4472 100755 --- a/scripts/verify-backends +++ b/scripts/verify-backends @@ -79,7 +79,26 @@ # WARN lines are known, tracked limitations — surfaced every run but NOT counted # as failures. -set -uo pipefail +# Tests source this script to exercise production functions in isolation. +# This guard MUST appear before executable-only side effects such as shell-option +# changes, traps, temporary state, and environment exports. Source mode still +# defines the verifier's functions and their supporting default variables. +# A direct invocation with VERIFY_BACKENDS_SOURCE_ONLY=1 is always a usage +# error and must fail immediately with an actionable message, even for -h. +_VERIFY_BACKENDS_SOURCED=0 +if [ "${VERIFY_BACKENDS_SOURCE_ONLY:-}" = "1" ]; then + # BASH_SOURCE[0] == $0 only when executed directly (not sourced). + if [ "${BASH_SOURCE[0]}" = "$0" ]; then + echo "verify-backends: VERIFY_BACKENDS_SOURCE_ONLY=1 requires sourcing this script, not executing it" >&2 + echo " Usage: VERIFY_BACKENDS_SOURCE_ONLY=1 . path/to/scripts/verify-backends" >&2 + exit 2 + fi + _VERIFY_BACKENDS_SOURCED=1 +fi + +if [ "$_VERIFY_BACKENDS_SOURCED" -eq 0 ]; then + set -uo pipefail +fi # macOS ships an ancient /bin/bash (3.2, 2007). Its lexer mis-parses a # double-quoted word that contains BOTH a multibyte UTF-8 char (e.g. an em-dash) @@ -89,16 +108,18 @@ set -uo pipefail # ASCII-clean in its executable statements to avoid that, but as belt-and-braces # we (a) force a UTF-8 ctype when the current one is C/POSIX, and (b) warn once if # running under bash 3.x so the cause is named rather than mysterious. -case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in - *[Uu][Tt][Ff]*) : ;; - *) export LC_CTYPE=C.UTF-8 2>/dev/null || export LC_CTYPE=en_US.UTF-8 ;; -esac -case "${BASH_VERSINFO:-0}" in - ""|0|1|2|3) - echo "verify-backends: note: running under bash ${BASH_VERSION:-<3.x>}." >&2 - echo " Prefer bash 4+ (e.g. \`brew install bash\`) for best results." >&2 - ;; -esac +if [ "$_VERIFY_BACKENDS_SOURCED" -eq 0 ]; then + case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in + *[Uu][Tt][Ff]*) : ;; + *) export LC_CTYPE=C.UTF-8 2>/dev/null || export LC_CTYPE=en_US.UTF-8 ;; + esac + case "${BASH_VERSINFO:-0}" in + ""|0|1|2|3) + echo "verify-backends: note: running under bash ${BASH_VERSION:-<3.x>}." >&2 + echo " Prefer bash 4+ (e.g. \`brew install bash\`) for best results." >&2 + ;; + esac +fi SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) @@ -112,22 +133,24 @@ KEEP="${VERIFY_KEEP:-}" # instead of silently buffering inside a command substitution. See trace(). TRACE="${VERIFY_TRACE:-}" POSITIONAL=() -while [ "$#" -gt 0 ]; do - case "$1" in - -v|--verbose) VERBOSE=1 ;; - -k|--keep) KEEP=1 ;; - -x|--trace) TRACE=1 ;; - --only) shift; ONLY_BACKEND="${1:-}" ;; - --only=*) ONLY_BACKEND="${1#--only=}" ;; - --oci-only) OCI_ONLY=1 ;; - -h|--help) - sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' - exit 0 ;; - *) POSITIONAL+=("$1") ;; - esac - shift -done -set -- ${POSITIONAL[@]+"${POSITIONAL[@]}"} +if [ "$_VERIFY_BACKENDS_SOURCED" -eq 0 ]; then + while [ "$#" -gt 0 ]; do + case "$1" in + -v|--verbose) VERBOSE=1 ;; + -k|--keep) KEEP=1 ;; + -x|--trace) TRACE=1 ;; + --only) shift; ONLY_BACKEND="${1:-}" ;; + --only=*) ONLY_BACKEND="${1#--only=}" ;; + --oci-only) OCI_ONLY=1 ;; + -h|--help) + sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) POSITIONAL+=("$1") ;; + esac + shift + done + set -- ${POSITIONAL[@]+"${POSITIONAL[@]}"} +fi # _verify_backend_enabled BACKEND — honor --only (run just that one). # Empty ONLY_BACKEND means "all". Used to skip work when iterating on one backend. @@ -139,7 +162,7 @@ _verify_backend_enabled() { # When tracing, acq's internal debug trace is streamed live so we can see which # msb/sbx sub-call is executing when a step hangs. Enabling TRACE implies live # streaming of the create step (see run_acq). -if [ -n "$TRACE" ]; then +if [ "$_VERIFY_BACKENDS_SOURCED" -eq 0 ] && [ -n "$TRACE" ]; then export ACQ_DEBUG="${ACQ_DEBUG:-1}" VERBOSE=1 fi @@ -154,8 +177,10 @@ trace() { printf ' [trace %s] %s\n' "$(_ts)" "$*" >&2 } -# Refuse to run inside a sandbox (no nested sandboxes). -if [ "${VERIFY_BACKENDS_SOURCE_ONLY:-}" != "1" ] && \ +# Refuse to run inside a sandbox (no nested sandboxes). VERIFY_BACKENDS_SOURCE_ONLY +# is always intercepted at the top of the script before reaching here, so any +# invocation that reaches this point is a normal executable run. +if [ "$_VERIFY_BACKENDS_SOURCED" -eq 0 ] && \ { [ -f /.dockerenv ] || [ -n "${SBX_SANDBOX:-}" ] || [ -n "${MSB_SANDBOX:-}" ]; }; then echo "verify-backends: refusing to run inside a sandbox (no nested sandboxes)." >&2 echo " Run on a sandbox-capable host. See ADR-0011 Validation." >&2 @@ -200,16 +225,17 @@ cleanup_verify_state() { _stop_ephemeral_agent [ -n "$VERIFY_STATE" ] && rm -rf "$VERIFY_STATE" } -trap cleanup_verify_state EXIT - -# Keep acq's per-run state hermetic. The verifier should not read the user's -# default backend, acq-owned secret store, provenance cache, or port/ssh state. -VERIFY_STATE=$(mktemp -d "${TMPDIR:-/tmp}/acq-verify-state.XXXXXX") || exit 1 -export XDG_CONFIG_HOME="$VERIFY_STATE/config" -export ACQ_SECRET_FORCE_FILE=1 -export ACQ_SECRET_STORE_DIR="$VERIFY_STATE/secrets" -export ACQ_STATE_DIR="$VERIFY_STATE/state" -export ACQ_PROVENANCE_DIR="$VERIFY_STATE/provenance" +if [ "$_VERIFY_BACKENDS_SOURCED" -eq 0 ]; then + trap cleanup_verify_state EXIT + + # Keep acq's per-run state hermetic. The verifier should not read the user's + # default backend, acq-owned secret store, provenance cache, or port/ssh state. + VERIFY_STATE=$(mktemp -d "${TMPDIR:-/tmp}/acq-verify-state.XXXXXX") || exit 1 + export XDG_CONFIG_HOME="$VERIFY_STATE/config" + export ACQ_SECRET_FORCE_FILE=1 + export ACQ_SECRET_STORE_DIR="$VERIFY_STATE/secrets" + export ACQ_STATE_DIR="$VERIFY_STATE/state" + export ACQ_PROVENANCE_DIR="$VERIFY_STATE/provenance" # CRITICAL for not clobbering the user's LIVE sandboxes: the acq secret store is # redirected above, but a GLOBAL `acq secret set -g usai` / `secret rm -g usai` # on the msb backend otherwise sweeps EVERY running sandbox with @@ -219,7 +245,7 @@ export ACQ_PROVENANCE_DIR="$VERIFY_STATE/provenance" # working USAi key. ACQ_SECRET_NO_LIVE_REFEED=1 makes secret set/rm store-only # (no `msb modify` against running VMs); the throwaway sandboxes this script # creates still bind the key at create time, which is all the checks need. -export ACQ_SECRET_NO_LIVE_REFEED=1 + export ACQ_SECRET_NO_LIVE_REFEED=1 # The hermetic XDG_CONFIG_HOME above means acq never finds a config file here, so # EVERY verify run looks like a first run to `maybe_first_run_configure` — which # then blocks on an interactive confirm and hangs the verifier forever (the @@ -230,17 +256,17 @@ export ACQ_SECRET_NO_LIVE_REFEED=1 # `acq configure`, which then reports "configuration unchanged" and makes no # writes). This only affects the three config widgets — secret VALUE reads are # gated on `[ -t 0 ]`, not on this, so seeding still works. -export ACQ_NO_PROMPT=1 -mkdir -p "$XDG_CONFIG_HOME" "$ACQ_SECRET_STORE_DIR" "$ACQ_STATE_DIR" "$ACQ_PROVENANCE_DIR" + export ACQ_NO_PROMPT=1 + mkdir -p "$XDG_CONFIG_HOME" "$ACQ_SECRET_STORE_DIR" "$ACQ_STATE_DIR" "$ACQ_PROVENANCE_DIR" # Fixture kit for the neutral `volumes:` vocabulary (ADR-0023): a tiny local kit # declaring a 256m block volume. Appended to ACQ_EXTRA_KITS so every create below # carries it; the volumes check inside verify_backend asserts the mount landed. # 256m, not smaller: msb refuses tiny ext4 disk images ("image size is too small # for ext4 formatting"; floor is 128M on msb 0.6.12), so stay well above it. -VOLKIT_DIR="$VERIFY_STATE/verify-volumes-kit" -mkdir -p "$VOLKIT_DIR" -cat >"$VOLKIT_DIR/spec.yaml" <<'SPEC' + VOLKIT_DIR="$VERIFY_STATE/verify-volumes-kit" + mkdir -p "$VOLKIT_DIR" + cat >"$VOLKIT_DIR/spec.yaml" <<'SPEC' schemaVersion: "hybrid/v1" kind: mixin name: verify-volumes @@ -250,7 +276,8 @@ volumes: - path: /acq-verify-vol size: 256m SPEC -export ACQ_EXTRA_KITS="${ACQ_EXTRA_KITS:+${ACQ_EXTRA_KITS} }${VOLKIT_DIR}" + export ACQ_EXTRA_KITS="${ACQ_EXTRA_KITS:+${ACQ_EXTRA_KITS} }${VOLKIT_DIR}" +fi # _run_captured CMD... — run CMD, put its combined stdout+stderr in LAST_OUT, and # return its exit status. In verbose mode the output also streams live. @@ -336,8 +363,11 @@ run_acq() { # shallow guest target (verified on msb 0.6.6). A real directory under $HOME # mounts fine. So the verify workspace lives under a stable cache dir, and we # resolve it to its canonical (symlink-free) path before mounting. -WORKDIR="${1:-}" -if [ -z "$WORKDIR" ]; then +WORKDIR="" +if [ "$_VERIFY_BACKENDS_SOURCED" -eq 0 ]; then + WORKDIR="${1:-}" +fi +if [ "$_VERIFY_BACKENDS_SOURCED" -eq 0 ] && [ -z "$WORKDIR" ]; then _verify_base="${XDG_CACHE_HOME:-$HOME/.cache}/acq/verify" mkdir -p "$_verify_base" WORKDIR=$(mktemp -d "${_verify_base}/ws.XXXXXX") @@ -348,7 +378,7 @@ fi # Canonicalize (resolve symlinks) so the mounted host path is the real one. acq # does this too, but resolving here keeps the derived sandbox name and any direct # msb/sbx checks below consistent with what acq actually mounts. -if command -v realpath >/dev/null 2>&1; then +if [ "$_VERIFY_BACKENDS_SOURCED" -eq 0 ] && command -v realpath >/dev/null 2>&1; then WORKDIR=$(realpath "$WORKDIR" 2>/dev/null || printf '%s' "$WORKDIR") fi @@ -374,13 +404,39 @@ _sbx_secret_has_global_row() { } load_provider_facts() { - local source_rc + local source_rc _diag_file _diag + # Capture stderr for a useful root-cause diagnostic while discarding stdout + # (acq source-mode is chatty on stdout). Bound the capture to 4 KB, then emit + # only a diagnostic category. Never replay arbitrary source stderr because a + # future dependency could include environment-derived sensitive text. + _diag_file=$(mktemp "${TMPDIR:-/tmp}/vb-load-diag.XXXXXX") || _diag_file="" # shellcheck disable=SC1090 - if ACQ_SOURCE_ONLY=1 ACQ_SCRIPT_DIR="$REPO_ROOT" . "$ACQ" >/dev/null 2>&1; then - : + if ACQ_SOURCE_ONLY=1 ACQ_SCRIPT_DIR="$REPO_ROOT" . "$ACQ" >/dev/null \ + 2>"${_diag_file:-/dev/null}"; then + rm -f "$_diag_file" else source_rc=$? - echo "verify-backends: unable to source acq from $ACQ (exit $source_rc)" >&2 + if [ -n "$_diag_file" ] && [ -s "$_diag_file" ]; then + _diag=$(dd if="$_diag_file" bs=1 count=4096 2>/dev/null | tr -d '\000') + echo "verify-backends: unable to source acq from $ACQ (exit $source_rc)" >&2 + case "$_diag" in + *"syntax error"*) + printf ' cause: shell syntax error; run: bash -n %s\n' "$ACQ" >&2 + ;; + *"No such file or directory"*) + printf ' cause: a required source file is missing\n' >&2 + ;; + *"command not found"*) + printf ' cause: a required source-time command is unavailable\n' >&2 + ;; + *) + printf ' cause: source initialization failed; run: bash -n %s\n' "$ACQ" >&2 + ;; + esac + else + echo "verify-backends: unable to source acq from $ACQ (exit $source_rc)" >&2 + fi + rm -f "$_diag_file" return "$source_rc" fi if ! acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT"; then @@ -1418,13 +1474,8 @@ verify_agent_install() { unseed_usai_secret "$backend" "$aname" } -# Tests source this script to exercise the production functions without creating -# sandboxes. Keep this opt-in so the normal executable path remains live E2E. -if [ "${VERIFY_BACKENDS_SOURCE_ONLY:-}" = "1" ]; then - if [ "${BASH_SOURCE[0]}" = "$0" ]; then - echo "verify-backends: source-only mode requires sourcing this script" >&2 - exit 2 - fi +if [ "$_VERIFY_BACKENDS_SOURCED" -eq 1 ]; then + unset _VERIFY_BACKENDS_SOURCED return 0 fi diff --git a/test/bats/146-provider-verification.bats b/test/bats/146-provider-verification.bats index 17413605..0a0c0c5f 100644 --- a/test/bats/146-provider-verification.bats +++ b/test/bats/146-provider-verification.bats @@ -9,6 +9,66 @@ load 'helper' setup() { acq_setup_stubs; } teardown() { acq_teardown_stubs; } +# --------------------------------------------------------------------------- +# Blocker 1 + 2 regressions: source-state isolation and direct-invocation guard +# --------------------------------------------------------------------------- + +@test "source-state regression: sourcing verify-backends with VERIFY_BACKENDS_SOURCE_ONLY=1 does not mutate caller XDG_CONFIG_HOME" { + run bash -c ' + export XDG_CONFIG_HOME="/sentinel-xdg-before" + VERIFY_BACKENDS_SOURCE_ONLY=1 + export VERIFY_BACKENDS_SOURCE_ONLY + . "'"$REPO_ROOT"'/scripts/verify-backends" + printf "%s\n" "$XDG_CONFIG_HOME" + ' + assert_success + assert_output "/sentinel-xdg-before" +} + +@test "source-state regression: sourcing verify-backends with VERIFY_BACKENDS_SOURCE_ONLY=1 does not mutate caller ACQ_EXTRA_KITS" { + run bash -c ' + export ACQ_EXTRA_KITS="caller-kit-a caller-kit-b" + VERIFY_BACKENDS_SOURCE_ONLY=1 + export VERIFY_BACKENDS_SOURCE_ONLY + . "'"$REPO_ROOT"'/scripts/verify-backends" + printf "%s\n" "${ACQ_EXTRA_KITS:-EMPTY}" + ' + assert_success + assert_output "caller-kit-a caller-kit-b" +} + +@test "source-state regression: sourcing verify-backends with VERIFY_BACKENDS_SOURCE_ONLY=1 does not override existing EXIT trap" { + run bash -c ' + _trap_fired=0 + trap '"'"'_trap_fired=1'"'"' EXIT + VERIFY_BACKENDS_SOURCE_ONLY=1 + export VERIFY_BACKENDS_SOURCE_ONLY + . "'"$REPO_ROOT"'/scripts/verify-backends" + # The caller trap must not have been overwritten; if it were, the sentinel + # would not appear in the EXIT output. We test by reading current trap state. + current_trap=$(trap -p EXIT 2>/dev/null) + case "$current_trap" in + *_trap_fired*) printf "EXIT trap preserved\n" ;; + *) printf "EXIT trap LOST: %s\n" "$current_trap"; exit 1 ;; + esac + ' + assert_success + assert_output "EXIT trap preserved" +} + +@test "source-state regression: VERIFY_BACKENDS_SOURCE_ONLY=1 direct -h invocation fails with status 2 and actionable message" { + run bash -c 'VERIFY_BACKENDS_SOURCE_ONLY=1 "'"$REPO_ROOT"'/scripts/verify-backends" -h' + assert_failure 2 + assert_output --partial 'VERIFY_BACKENDS_SOURCE_ONLY=1 requires sourcing this script' + assert_output --partial 'Usage:' +} + +@test "source-state regression: VERIFY_BACKENDS_SOURCE_ONLY=1 direct invocation (no flags) fails with status 2 and actionable message" { + run bash -c 'VERIFY_BACKENDS_SOURCE_ONLY=1 "'"$REPO_ROOT"'/scripts/verify-backends"' + assert_failure 2 + assert_output --partial 'VERIFY_BACKENDS_SOURCE_ONLY=1 requires sourcing this script' +} + _check_reachability_harness() { export REPO_ROOT cat <<'PRELUDE' @@ -124,7 +184,10 @@ PRELUDE refute [ -f "$calls" ] } -@test "provider verification: provider facts drive the models URL" { +@test "provider verification: provider facts drive the models URL (offline wiring seam, not live projection validation)" { + # This is an offline seam test: it checks that the variable assignments exist + # in verify-backends so the wiring is correct. It does NOT prove that a real + # provider kit returns valid values or that a live SBX/MSB run works. run grep -n 'USAI_MODELS_URL="\$USAI_PROVIDER_MODELS_URL"' \ "$REPO_ROOT/scripts/verify-backends" assert_success @@ -133,7 +196,10 @@ PRELUDE assert_success } -@test "provider verification: projected provider facts drive the reachability input" { +@test "provider verification: projected provider facts drive the reachability input (offline seam, not live backend validation)" { + # Offline seam: stubs load_provider_facts and check_usai_reachability to verify + # that the URL from provider facts flows through to the reachability call. + # This does NOT validate a real projected parser, a real kit, or a live backend. run bash -c ' set -u VERIFY_BACKENDS_SOURCE_ONLY=1 @@ -158,7 +224,10 @@ PRELUDE assert_success } -@test "provider verification: malformed provider facts fail closed before reachability" { +@test "provider verification: malformed provider facts fail closed before reachability (offline seam, not live kit validation)" { + # Offline seam: verifies that load_provider_facts propagates failures and + # does not silently fall through to reachability checks. Does NOT test a real + # provider kit or a live projected parser. run bash -c ' set -u VERIFY_BACKENDS_SOURCE_ONLY=1 @@ -184,7 +253,11 @@ PRELUDE assert_output --partial 'provider facts rejected' } -@test "provider verification: acq source failure is actionable and does not fall through" { +@test "provider verification: acq source failure is actionable and does not fall through (offline seam with syntax error)" { + # Offline seam: proves that when acq cannot be sourced (e.g. syntax error or + # missing file), load_provider_facts surfaces a useful root-cause diagnostic + # and returns non-zero before invoking acq_provider_facts_load_from_kit_or_fallback. + # Uses a missing file to trigger a real source failure without a live acq process. run bash -c ' set -u VERIFY_BACKENDS_SOURCE_ONLY=1 @@ -200,6 +273,28 @@ PRELUDE refute_output --partial 'acq_provider_facts_load_from_kit_or_fallback: command not found' } +@test "provider verification: acq source failure categorizes stderr without replaying it" { + # Offline seam: proves that source stderr selects an actionable category but + # is never replayed verbatim, even when it contains secret-like text. + local bad_acq="$STUBDIR/bad-acq.sh" + printf '#!/usr/bin/env bash\necho "syntax error SENTINEL_SECRET_VALUE" >&2\nreturn 1\n' \ + > "$bad_acq" + run bash -c ' + set -u + VERIFY_BACKENDS_SOURCE_ONLY=1 + export VERIFY_BACKENDS_SOURCE_ONLY + . "'"$REPO_ROOT"'/scripts/verify-backends" + ACQ="'"$bad_acq"'" + load_provider_facts 2>&1; rc=$? + exit "$rc" + ' + assert_failure + assert_output --partial 'verify-backends: unable to source acq from' + assert_output --partial 'cause: shell syntax error' + refute_output --partial 'SENTINEL_SECRET_VALUE' + refute_output --partial 'acq_provider_facts_load_from_kit_or_fallback: command not found' +} + @test "provider verification: seeded credential is required for an installed backend" { run bash -c ' set -u From 9e59d9abd55ab2f7bbf6b59c294bcb496df4c114 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 24 Oct 2026 18:34:54 +0000 Subject: [PATCH 03/63] fix(msb): canonicalize host-config dir before --volume mount On macOS the host-config dir often resolves through the /var -> /private/var alias (e.g. when HOME or XDG_STATE_HOME sits under /var/folders/...). msb cannot mount a symlinked host path and fails with "mount ...: Not a directory (os error 20)". The workspace mount already calls canonicalize_path for the same reason (diagnosed at msb.sh lines 3574-3579); apply the identical guard to the host-config dir mount source. Falls back to the original path when canonicalize_path is unavailable. The dir is acq-owned (not user-supplied) so there is no arbitrary-symlink-traversal risk. Closes GSA-TTS/agentic-coding-quickstart#594 Tests: 71c-msb-canonical-host-config.bats covers the Darwin /var alias simulation (case a) and an arbitrary symlink negative-control (case b). Co-authored-by: OpenCode [claude_4_6_sonnet] --- acq.backends/msb.sh | 13 ++ test/bats/71c-msb-canonical-host-config.bats | 118 +++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 test/bats/71c-msb-canonical-host-config.bats diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index f3bd6dcd..3186a79a 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -3653,6 +3653,19 @@ EOF _hcfg_dir=$(acq_host_config_dir msb "$name") || return 1 mkdir -p "$_hcfg_dir" || return 1 chmod 711 "$_hcfg_dir" || return 1 + # Canonicalize to the real, symlink-free path before mounting. msb cannot + # mount a symlinked host path (same constraint as the workspace mount above, + # diagnosed at lines 3574-3579). On macOS the config dir sits under + # $HOME/.local/state/acq/..., which on some installs resolves through the + # /var -> /private/var alias (e.g. when $HOME or XDG_STATE_HOME itself lies + # under /var/folders/...); passing the un-resolved path causes msb to fail + # with "mount ...: Not a directory (os error 20)". Resolving to the canonical + # /private/... form fixes it. Falls back to the original path if the resolver + # is unavailable or returns empty. Trusted (acq-owned) dir only — no + # arbitrary user-symlink traversal. See GSA-TTS/agentic-coding-quickstart#594. + if command -v canonicalize_path >/dev/null 2>&1; then + _hcfg_dir=$(canonicalize_path "$_hcfg_dir") + fi if command -v host_path >/dev/null 2>&1; then _hcfg_host=$(host_path "$_hcfg_dir") || return 1 else diff --git a/test/bats/71c-msb-canonical-host-config.bats b/test/bats/71c-msb-canonical-host-config.bats new file mode 100644 index 00000000..9ab7e11e --- /dev/null +++ b/test/bats/71c-msb-canonical-host-config.bats @@ -0,0 +1,118 @@ +#!/usr/bin/env bats +# +# 71c-msb-canonical-host-config.bats — regression for +# GSA-TTS/agentic-coding-quickstart#594 / #601 +# +# msb cannot mount a symlinked host path (macOS /var -> /private/var alias, or +# any arbitrary symlink); the host-config dir must be canonicalized before it +# reaches the --volume argument, exactly as the workspace mount already does. +# +# Two focused cases: +# (a) Darwin /var alias simulation — the host-config dir path begins with +# /var/... but canonicalize_path resolves it to /private/var/... +# The --volume SOURCE in the msb create call must use the resolved form. +# +# (b) Malicious user symlink negative control — a path component of the +# host-config dir is a symlink pointing OUTSIDE the expected tree; +# canonicalize_path resolves it and the resulting volume SOURCE must +# still be the real path that canonicalize_path returned (the caller has +# no say: it is the acq-owned config dir, never the user-supplied path). +# Verifies that the fix does not break the trusted-path contract. +# +# Both tests run in isolated subshells (the standard provision-helper pattern +# from 71b) and read $CALLS to assert what msb received. +# +# shellcheck shell=bats + +setup() { acq_setup_stubs; } +teardown() { acq_teardown_stubs; } + +load 'helper' + +# --------------------------------------------------------------------------- +# Provision helper — identical shape to 71b's _provision, kept local so this +# file is self-contained and does not inherit 71b's changes. +# --------------------------------------------------------------------------- +_provision_hcfg() { # NAME PRE_SNIPPET WS_ARGS... + local name="$1" pre="$2"; shift 2 + : > "$CALLS" + run bash -c ' + name="$1"; pre="$2"; shift 2 + export ACQ_SCRIPT_DIR="'"$REPO_ROOT"'" + . "'"$REPO_ROOT"'/acq.backends/common.sh" + eval "$pre" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + mkdir -p "'"$STUBDIR"'/nokit" + printf "schemaVersion: \"hybrid/v1\"\nkind: mixin\nname: x\ndisplayName: X\ndescription: x\n" \ + > "'"$STUBDIR"'/nokit/spec.yaml" + _acq_msb_fetch_kit() { printf "%s\n" "'"$STUBDIR"'/nokit"; } + acq_backend_provision "$name" shell "$@" 2>&1 + ' _ "$name" "$pre" "$@" +} + +# --------------------------------------------------------------------------- +# (a) Darwin /var alias: simulate a host-config dir whose path runs through a +# /var -> /private/var symlink (macOS $TMPDIR / $HOME/.local/state pattern). +# +# Strategy: create a real dir, then create a per-session symlink at a +# different path pointing at it. Override acq_host_config_dir to return the +# symlink path (simulating a /var/... alias), and assert that the --volume +# SOURCE in the resulting msb create call contains the resolved real path, +# not the symlink name. The real filesystem canonicalize_path (realpath or +# readlink -f) does the resolution; no mock is needed. +# --------------------------------------------------------------------------- +@test "594(a): host-config dir under /var-style symlink is canonicalized before --volume" { + local real_cfgdir alias_cfgdir ws resolved_cfgdir + real_cfgdir=$(mktemp -d "${TMPDIR:-/tmp}/594a-real.XXXXXX") + # Give the alias a sentinel name that reads like a /var/... path component. + alias_cfgdir="${TMPDIR:-/tmp}/594a-var-alias-$$" + ln -sf "$real_cfgdir" "$alias_cfgdir" + ws=$(mktemp -d "${TMPDIR:-/tmp}/594a-ws.XXXXXX") + resolved_cfgdir=$(realpath "$real_cfgdir" 2>/dev/null || printf '%s' "$real_cfgdir") + + _provision_hcfg hcfgvar594a ' + acq_host_config_dir() { + printf "%s\n" "'"$alias_cfgdir"'" + } + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/594a-secrets" + ' "$ws" + rm -rf "$ws" "$alias_cfgdir" "$real_cfgdir" + + # The --volume SOURCE must be the resolved real path, not the alias. + assert_regex "$(cat "$CALLS")" "--volume ${resolved_cfgdir}:" + refute_regex "$(cat "$CALLS")" "--volume ${alias_cfgdir}:" +} + +# --------------------------------------------------------------------------- +# (b) Malicious user symlink negative control. +# Even if an adversarial path component of the host-config dir is a +# symlink pointing outside the expected tree, canonicalize_path resolves +# it. The --volume SOURCE must be whatever canonicalize_path returned — +# never the unresolved (symlinked) form. This confirms the fix does not +# skip canonicalization on non-trivially-symlinked paths. +# +# Concretely: create a real dir, a symlink pointing at it under a different +# name (simulating a "malicious redirect"), override acq_host_config_dir to +# return the symlink path, and assert the resulting volume mount uses the +# resolved target, not the symlink name. +# --------------------------------------------------------------------------- +@test "594(b): host-config dir with an arbitrary symlink resolves to the real target" { + local real_cfgdir symlink_cfgdir ws resolved_cfgdir + real_cfgdir=$(mktemp -d "${TMPDIR:-/tmp}/594b-real.XXXXXX") + symlink_cfgdir="${TMPDIR:-/tmp}/594b-link-$$" + ln -sf "$real_cfgdir" "$symlink_cfgdir" + ws=$(mktemp -d "${TMPDIR:-/tmp}/594b-ws.XXXXXX") + resolved_cfgdir=$(realpath "$real_cfgdir" 2>/dev/null || printf '%s' "$real_cfgdir") + + _provision_hcfg hcfgsym594b ' + acq_host_config_dir() { + printf "%s\n" "'"$symlink_cfgdir"'" + } + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/594b-secrets" + ' "$ws" + rm -rf "$ws" "$symlink_cfgdir" "$real_cfgdir" + + # The --volume SOURCE must be the real (resolved) path, not the symlink. + assert_regex "$(cat "$CALLS")" "--volume ${resolved_cfgdir}:" + refute_regex "$(cat "$CALLS")" "--volume ${symlink_cfgdir}:" +} From afabe260172e9188a89899afba386879510e1367 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 24 Oct 2026 18:38:46 +0000 Subject: [PATCH 04/63] fix(sbx): mount provider generation after agent Pass the provider generation through SBX workspace mount syntax after the agent token so it is not resolved as a kit path. Co-authored-by: OpenCode Agent --- acq.backends/sbx.sh | 43 ++++++++++++++++++++---- test/bats/90-sbx-startup-kit.bats | 55 +++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index ff01699d..043f5b2a 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -623,14 +623,43 @@ _acq_backend_provision_unlocked() { _hcfg_dir=$(acq_host_config_dir sbx "$name") || return 1 mkdir -p "$_hcfg_dir" || return 1 chmod 711 "$_hcfg_dir" || return 1 - # sbx supports only its same-absolute-path readonly positional mount. Publish - # that backend-owned guest root through the neutral environment contract. - local _provider_mount=() - _ef+=(--env "ACQ_PROVIDER_INPUT_ROOT=$(acq_provider_input_guest_root sbx "$name" "$_provider_generation")") - _provider_mount=("$(acq_provider_input_sbx_mount "$name" "$_provider_generation")") || { + # sbx positional syntax: AGENT [WORKSPACE[:ro]...] The provider generation must + # appear AFTER the agent token as a workspace-style read-only mount — placing it + # before the agent causes sbx to resolve the path as a kit reference (issue #629). + # Compute the mount string, then inject it into _sbx_args right after the first + # non-flag positional (the agent), preserving any flags that precede it and all + # workspace positionals that follow. ACQ_PROVIDER_INPUT_ROOT points guests to + # the same-absolute-path mount that sbx enforces read-only at the VMM layer. + local _provider_mount_val + _provider_mount_val=$(acq_provider_input_sbx_mount "$name" "$_provider_generation") || { _acq_sbx_cleanup_uncreated_attempt return 1 } + _ef+=(--env "ACQ_PROVIDER_INPUT_ROOT=$(acq_provider_input_guest_root sbx "$name" "$_provider_generation")") + # Insert _provider_mount_val into _sbx_args after the first positional (agent). + # Scan past any leading flags (e.g. --template imgref) to find the agent index. + local _sbx_args_final=() _pmv_inserted=0 _pma_prev="" _pma_idx _pma_elem + for _pma_idx in "${!_sbx_args[@]}"; do + _pma_elem="${_sbx_args[$_pma_idx]}" + _sbx_args_final+=("$_pma_elem") + if [ "$_pmv_inserted" -eq 0 ]; then + if _takes_value "$_pma_prev"; then + _pma_prev="$_pma_elem" + continue + fi + case "$_pma_elem" in + -*) _pma_prev="$_pma_elem"; continue ;; + esac + # First non-flag positional: the agent. Insert the provider mount immediately + # after it so sbx sees: AGENT /gen:ro [workspace...], not /gen:ro AGENT [ws]. + _sbx_args_final+=("$_provider_mount_val") + _pmv_inserted=1 + fi + _pma_prev="$_pma_elem" + done + # No positional found (empty or all-flag _sbx_args): append mount at the end. + [ "$_pmv_inserted" -eq 0 ] && _sbx_args_final+=("$_provider_mount_val") + local kf=() while IFS= read -r line; do kf+=("$line"); done < <(_acq_sbx_kit_flags) local _git_identity_kit="" @@ -640,13 +669,13 @@ _acq_backend_provision_unlocked() { _startup_barrier_token="acq-$$-$(date +%s)-$RANDOM-$RANDOM" kf+=(--kit "$(_acq_sbx_startup_barrier_kit "$_startup_barrier_token")") - acq_debug "sbx create --name $name ${_cf[*]:-} ${_ef[*]:-} ${_tf[*]:-} ${kf[*]} ${_provider_mount[*]} ${_sbx_args[*]:-}" + acq_debug "sbx create --name $name ${_cf[*]:-} ${_ef[*]:-} ${_tf[*]:-} ${kf[*]} ${_sbx_args_final[*]:-}" # Stale host state is cleared only after this create succeeds, avoiding races # with a concurrent create and destructive guesses when `sbx ls` fails. acq_spin_start "Creating sandbox '$name'" local _rc=0 _backend_pid _ACQ_SBX_CREATE_STARTED=1 - sbx create --name "$name" ${_cf[@]+"${_cf[@]}"} ${_ef[@]+"${_ef[@]}"} ${_tf[@]+"${_tf[@]}"} "${kf[@]}" "${_provider_mount[@]}" ${_sbx_args[@]+"${_sbx_args[@]}"} & + sbx create --name "$name" ${_cf[@]+"${_cf[@]}"} ${_ef[@]+"${_ef[@]}"} ${_tf[@]+"${_tf[@]}"} "${kf[@]}" ${_sbx_args_final[@]+"${_sbx_args_final[@]}"} & _backend_pid=$! _acq_provider_input_guardian_register_if_active "$_backend_pid" || { kill "$_backend_pid" 2>/dev/null || true; wait "$_backend_pid" 2>/dev/null || true; return 1; } wait "$_backend_pid" || _rc=$? diff --git a/test/bats/90-sbx-startup-kit.bats b/test/bats/90-sbx-startup-kit.bats index 5f405158..bee4060e 100644 --- a/test/bats/90-sbx-startup-kit.bats +++ b/test/bats/90-sbx-startup-kit.bats @@ -542,6 +542,61 @@ STUB assert [ "$wait_line" -lt "$attach_line" ] } +@test "provision(sbx): provider generation mount appears after agent, not among --kit refs (GSA-TTS/agentic-coding-quickstart#629)" { + # Regression: before this fix, /path/generation.XXX:ro was placed BEFORE the agent + # positional in sbx create argv. sbx resolves pre-agent positionals as kit paths, + # producing "error: resolve kits: kit path .../generation.:ro does not exist". + # After the fix the mount appears AFTER the agent and is NOT a --kit value. + : > "$CALLS" + ( + acq_test_stub_provider_input_boundary + acq_backend_provision mountposbox shell /tmp + ) >/dev/null 2>&1 || true + local log create_line + log=$(cat "$CALLS") + create_line=$(printf '%s\n' "$log" | grep '^sbx create') + [ -n "$create_line" ] + + # (a) generation:ro must NOT be a --kit value — if it were, sbx tries to resolve + # a non-existent kit path (the exact failure from issue #629). + local gen_after_kit + gen_after_kit=$(printf '%s\n' "$create_line" | awk '{ + for (i=1; i<=NF; i++) { + if ($i == "--kit" && i+1 <= NF && $(i+1) ~ /\/generation\..*:ro/) { + print "kit-value:" $(i+1); exit + } + } + }') + [ -z "$gen_after_kit" ] + + # (b) generation:ro must appear AFTER the agent positional (first non-flag, non-verb + # token) so sbx treats it as a workspace-style read-only mount, not a kit path. + local gen_pos agent_pos + gen_pos=$(printf '%s\n' "$create_line" | awk '{ + for (i=1; i<=NF; i++) { if ($i ~ /\/generation\..*:ro$/) { print i; exit } } + }') + # Find the agent positional: skip "sbx" and "create", then skip flags and their values. + agent_pos=$(printf '%s\n' "$create_line" | awk '{ + skip=0 + for (i=1; i<=NF; i++) { + if (skip) { skip=0; next } + if ($i ~ /^--/) { skip=1; next } + if ($i == "sbx" || $i == "create") next + print i; exit + } + }') + [ -n "$gen_pos" ] + [ -n "$agent_pos" ] + assert [ "$agent_pos" -lt "$gen_pos" ] + + # (c) ACQ_PROVIDER_INPUT_ROOT must be in the env flags (no :ro suffix on the guest path). + assert_regex "$create_line" 'ACQ_PROVIDER_INPUT_ROOT=.*/generation\.[^:]*$' + + # (d) The :ro suffix on the generation path must be preserved intact as a single token + # (spaces in the path or suffix must not be split by word expansion). + assert_regex "$create_line" '/generation\.[^ ]*:ro' +} + @test "run(sbx): refuses attach when reattach heal never becomes exec-ready" { cat >"$STUBDIR/sbx" <<'STUB' #!/usr/bin/env bash From 636bc2f700a3f83a748f0cd4bc70a9591760b787 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 24 Oct 2026 18:52:11 +0000 Subject: [PATCH 05/63] test(sbx): assert provider root token position Verify the provider root environment token independently of trailing kit and workspace arguments in the consolidated adoption branch. Co-authored-by: OpenCode Agent --- test/bats/90-sbx-startup-kit.bats | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/test/bats/90-sbx-startup-kit.bats b/test/bats/90-sbx-startup-kit.bats index bee4060e..f38e9744 100644 --- a/test/bats/90-sbx-startup-kit.bats +++ b/test/bats/90-sbx-startup-kit.bats @@ -542,7 +542,7 @@ STUB assert [ "$wait_line" -lt "$attach_line" ] } -@test "provision(sbx): provider generation mount appears after agent, not among --kit refs (GSA-TTS/agentic-coding-quickstart#629)" { +@test "provision(sbx): provider generation mount appears after agent, not among --kit refs" { # Regression: before this fix, /path/generation.XXX:ro was placed BEFORE the agent # positional in sbx create argv. sbx resolves pre-agent positionals as kit paths, # producing "error: resolve kits: kit path .../generation.:ro does not exist". @@ -558,7 +558,7 @@ STUB [ -n "$create_line" ] # (a) generation:ro must NOT be a --kit value — if it were, sbx tries to resolve - # a non-existent kit path (the exact failure from issue #629). + # a non-existent kit path. local gen_after_kit gen_after_kit=$(printf '%s\n' "$create_line" | awk '{ for (i=1; i<=NF; i++) { @@ -569,28 +569,22 @@ STUB }') [ -z "$gen_after_kit" ] - # (b) generation:ro must appear AFTER the agent positional (first non-flag, non-verb - # token) so sbx treats it as a workspace-style read-only mount, not a kit path. + # (b) generation:ro must appear AFTER the selected agent token so sbx treats + # it as a workspace-style read-only mount, not a kit path. Avoid generic + # flag parsing here: SBX has both value-taking and boolean flags. local gen_pos agent_pos gen_pos=$(printf '%s\n' "$create_line" | awk '{ for (i=1; i<=NF; i++) { if ($i ~ /\/generation\..*:ro$/) { print i; exit } } }') - # Find the agent positional: skip "sbx" and "create", then skip flags and their values. agent_pos=$(printf '%s\n' "$create_line" | awk '{ - skip=0 - for (i=1; i<=NF; i++) { - if (skip) { skip=0; next } - if ($i ~ /^--/) { skip=1; next } - if ($i == "sbx" || $i == "create") next - print i; exit - } + for (i=1; i<=NF; i++) { if ($i == "shell") { print i; exit } } }') [ -n "$gen_pos" ] [ -n "$agent_pos" ] assert [ "$agent_pos" -lt "$gen_pos" ] # (c) ACQ_PROVIDER_INPUT_ROOT must be in the env flags (no :ro suffix on the guest path). - assert_regex "$create_line" 'ACQ_PROVIDER_INPUT_ROOT=.*/generation\.[^:]*$' + assert_regex "$create_line" 'ACQ_PROVIDER_INPUT_ROOT=[^ ]*/generation\.[^ :]*([ ]|$)' # (d) The :ro suffix on the generation path must be preserved intact as a single token # (spaces in the path or suffix must not be split by word expansion). From 55dd201ef2023d5be9ba0c9e9ec10a2d844606c6 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 24 Oct 2026 19:24:06 +0000 Subject: [PATCH 06/63] fix(verify): consolidate live backend corrections Mount SBX provider inputs after the primary workspace, require trusted canonical MSB host-config paths, replace obsolete provider-owned config assertions, and preserve durable live failure evidence. Closes GSA-TTS/agentic-coding-quickstart#594 Closes GSA-TTS/agentic-coding-quickstart#629 Co-authored-by: OpenCode Agent --- acq.backends/msb.sh | 13 +++---- acq.backends/sbx.sh | 38 +++++--------------- docs/VERIFY_PROVIDER_ADOPTION.md | 34 +++++++++++++++--- scripts/verify-backends | 23 ++++++++---- test/bats/71c-msb-canonical-host-config.bats | 18 ++++++---- test/bats/90-sbx-startup-kit.bats | 7 +++- 6 files changed, 80 insertions(+), 53 deletions(-) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 3186a79a..11e9b923 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -3660,12 +3660,13 @@ EOF # /var -> /private/var alias (e.g. when $HOME or XDG_STATE_HOME itself lies # under /var/folders/...); passing the un-resolved path causes msb to fail # with "mount ...: Not a directory (os error 20)". Resolving to the canonical - # /private/... form fixes it. Falls back to the original path if the resolver - # is unavailable or returns empty. Trusted (acq-owned) dir only — no - # arbitrary user-symlink traversal. See GSA-TTS/agentic-coding-quickstart#594. - if command -v canonicalize_path >/dev/null 2>&1; then - _hcfg_dir=$(canonicalize_path "$_hcfg_dir") - fi + # /private/... form fixes it. Require the trusted provider-input canonicalizer + # and fail closed if it is unavailable; do not follow arbitrary user symlinks. + command -v _acq_provider_input_canonical_path >/dev/null 2>&1 || { + echo "acq(msb): error: trusted host-path canonicalizer is unavailable." >&2 + return 1 + } + _hcfg_dir=$(_acq_provider_input_canonical_path "$_hcfg_dir") || return 1 if command -v host_path >/dev/null 2>&1; then _hcfg_host=$(host_path "$_hcfg_dir") || return 1 else diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index 043f5b2a..43170bdd 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -623,42 +623,20 @@ _acq_backend_provision_unlocked() { _hcfg_dir=$(acq_host_config_dir sbx "$name") || return 1 mkdir -p "$_hcfg_dir" || return 1 chmod 711 "$_hcfg_dir" || return 1 - # sbx positional syntax: AGENT [WORKSPACE[:ro]...] The provider generation must - # appear AFTER the agent token as a workspace-style read-only mount — placing it - # before the agent causes sbx to resolve the path as a kit reference (issue #629). - # Compute the mount string, then inject it into _sbx_args right after the first - # non-flag positional (the agent), preserving any flags that precede it and all - # workspace positionals that follow. ACQ_PROVIDER_INPUT_ROOT points guests to - # the same-absolute-path mount that sbx enforces read-only at the VMM layer. + # SBX positional syntax is AGENT PRIMARY_WORKSPACE [OTHER_WORKSPACE[:ro]...]. + # The provider generation therefore follows every existing workspace: placing + # it before the agent makes it a kit path, while placing it first after the + # agent makes it an invalid read-only primary workspace. local _provider_mount_val _provider_mount_val=$(acq_provider_input_sbx_mount "$name" "$_provider_generation") || { _acq_sbx_cleanup_uncreated_attempt return 1 } _ef+=(--env "ACQ_PROVIDER_INPUT_ROOT=$(acq_provider_input_guest_root sbx "$name" "$_provider_generation")") - # Insert _provider_mount_val into _sbx_args after the first positional (agent). - # Scan past any leading flags (e.g. --template imgref) to find the agent index. - local _sbx_args_final=() _pmv_inserted=0 _pma_prev="" _pma_idx _pma_elem - for _pma_idx in "${!_sbx_args[@]}"; do - _pma_elem="${_sbx_args[$_pma_idx]}" - _sbx_args_final+=("$_pma_elem") - if [ "$_pmv_inserted" -eq 0 ]; then - if _takes_value "$_pma_prev"; then - _pma_prev="$_pma_elem" - continue - fi - case "$_pma_elem" in - -*) _pma_prev="$_pma_elem"; continue ;; - esac - # First non-flag positional: the agent. Insert the provider mount immediately - # after it so sbx sees: AGENT /gen:ro [workspace...], not /gen:ro AGENT [ws]. - _sbx_args_final+=("$_provider_mount_val") - _pmv_inserted=1 - fi - _pma_prev="$_pma_elem" - done - # No positional found (empty or all-flag _sbx_args): append mount at the end. - [ "$_pmv_inserted" -eq 0 ] && _sbx_args_final+=("$_provider_mount_val") + # SBX requires the first workspace after the agent to be read/write. Append + # the provider generation after all existing workspace positionals so its + # read-only marker cannot become the primary workspace. + local _sbx_args_final=("${_sbx_args[@]}" "$_provider_mount_val") local kf=() while IFS= read -r line; do kf+=("$line"); done < <(_acq_sbx_kit_flags) diff --git a/docs/VERIFY_PROVIDER_ADOPTION.md b/docs/VERIFY_PROVIDER_ADOPTION.md index bde77a00..cf2b4cee 100644 --- a/docs/VERIFY_PROVIDER_ADOPTION.md +++ b/docs/VERIFY_PROVIDER_ADOPTION.md @@ -54,12 +54,38 @@ actual command output. | Check | SBX | MSB | Observed output / notes | |---|---|---|---| -| Fresh `acq run opencode .` startup | BLOCKED | BLOCKED | No live run claimed in this preparatory slice | -| Fresh OpenCode process starts | BLOCKED | BLOCKED | Record actual startup output | -| `acq exec NAME -- opencode models` consumes projected provider input | BLOCKED | BLOCKED | Record actual model listing; do not claim from mocks | +| Fresh `acq run opencode .` startup | FAIL | FAIL | 2026-10-24: backend create failed before startup. SBX treated the provider mount as a kit/primary workspace; MSB rejected an uncanonicalized host-config bind source. Fixes are consolidated in this draft; rerun required. | +| Fresh OpenCode process starts | BLOCKED | BLOCKED | Blocked by the observed create failures above; no process-start claim. | +| `acq exec NAME -- opencode models` consumes projected provider input | BLOCKED | BLOCKED | Blocked by the observed create failures above; no model-list claim. | | Remove projected provider input and restart OpenCode | BLOCKED | BLOCKED | Record expected actionable failure or documented fallback | | Malformed provider input is rejected before startup | BLOCKED | BLOCKED | Record exact validation error and exit status | -| `scripts/verify-backends --only ` | BLOCKED | BLOCKED | Run only outside a sandbox; capture transcript | +| `scripts/verify-backends --only ` | FAIL | FAIL | SBX transcript: `tmp/provider-neutral-validation.aguo8ZPz/sbx.log`. MSB transcript: `tmp/provider-neutral-validation.ktM9ig5z/msb.log`. Both reached validated provider inputs before backend-specific create failure. | + +### Preserved Live Evidence (2026-10-24) + +The full host transcripts remain in the gitignored Quickstart `tmp/` directory. +The decisive output is preserved here so review does not depend on those local +files. + +SBX reached provider validation, then failed before sandbox creation: + +```text +provider input contract: validated 1 bundle(s) +error: primary workspace must be read/write (remove ':ro' or ':readonly') +``` + +MSB version `0.7.7` reached provider validation, then failed before sandbox +creation because the host-config bind source retained macOS `/var` spelling: + +```text +provider input contract: validated 1 bundle(s) +error: invalid config: bind mount host path /var/folders/... goes through symlink /var; +use the resolved path /private/var/folders/... or add follow-root-symlinks +``` + +These are product failures, not environmental blocks. Both backend fixes are +included in this draft. Final validation remains blocked until Quickstart main +contains the trusted macOS system-alias canonicalizer used by the MSB adapter. ### Negative-Validation Procedure (malformed provider input) diff --git a/scripts/verify-backends b/scripts/verify-backends index bbcb4472..491fe64d 100755 --- a/scripts/verify-backends +++ b/scripts/verify-backends @@ -223,7 +223,10 @@ dump() { cleanup_verify_state() { _stop_ephemeral_agent - [ -n "$VERIFY_STATE" ] && rm -rf "$VERIFY_STATE" + if [ -n "$VERIFY_STATE" ]; then + chmod -R u+w "$VERIFY_STATE" 2>/dev/null || true + rm -rf "$VERIFY_STATE" + fi } if [ "$_VERIFY_BACKENDS_SOURCED" -eq 0 ]; then trap cleanup_verify_state EXIT @@ -383,7 +386,6 @@ if [ "$_VERIFY_BACKENDS_SOURCED" -eq 0 ] && command -v realpath >/dev/null 2>&1; fi USAI_MODELS_URL="https://api.gsa.usai.gov/api/v1/models" -USAI_KIT_CONFIG_PATH="/home/agent/usai-config/opencode.jsonc" # Load the provider contract used by acq's backend adapters before the verifier # performs any provider-specific checks. The fallback remains available through @@ -834,11 +836,12 @@ verify_backend() { return fi - # 1) USAi kit config landed. - if run_acq "exec usai-check" --backend "$backend" exec "$name" -- sh -c "test -f '$USAI_KIT_CONFIG_PATH'"; then - pass "${backend}: usai-provider kit applied (config staged)" + # 1) Provider-neutral facts landed through the read-only projection. + if run_acq "exec provider-input-check" --backend "$backend" exec "$name" -- sh -c \ + 'test -n "$ACQ_PROVIDER_INPUT_ROOT" && test -r "$ACQ_PROVIDER_INPUT_ROOT/models/providers/usai/facts.json"'; then + pass "${backend}: provider input facts projected and readable" else - fail "${backend}: usai-provider kit config missing at $USAI_KIT_CONFIG_PATH" + fail "${backend}: provider input facts missing from projected root" dump "exec output" "$LAST_OUT" fi @@ -1441,6 +1444,14 @@ verify_agent_install() { dump "exec output" "$LAST_OUT" fi + if run_acq "exec opencode-config" --backend "$backend" exec "$aname" -- sh -c \ + 'test -r /home/agent/.config/opencode/opencode.jsonc'; then + pass "${backend}: OpenCode kit generated the global config" + else + fail "${backend}: OpenCode global config was not generated" + dump "exec output" "$LAST_OUT" + fi + # Exercise the live attach/launch path without requiring a real USAi key. A # fresh `acq run opencode "$WORKDIR" -- --version` also performs post-create # USAi key validation and intentionally fails with the verifier's synthetic key; diff --git a/test/bats/71c-msb-canonical-host-config.bats b/test/bats/71c-msb-canonical-host-config.bats index 9ab7e11e..ce76259c 100644 --- a/test/bats/71c-msb-canonical-host-config.bats +++ b/test/bats/71c-msb-canonical-host-config.bats @@ -40,6 +40,9 @@ _provision_hcfg() { # NAME PRE_SNIPPET WS_ARGS... name="$1"; pre="$2"; shift 2 export ACQ_SCRIPT_DIR="'"$REPO_ROOT"'" . "'"$REPO_ROOT"'/acq.backends/common.sh" + # Adapter tests source common.sh directly. Individual cases provide the + # trusted canonicalizer result expected from the complete acq entrypoint. + _acq_provider_input_canonical_path() { printf "%s\n" "$1"; } eval "$pre" . "'"$REPO_ROOT"'/acq.backends/msb.sh" mkdir -p "'"$STUBDIR"'/nokit" @@ -74,6 +77,10 @@ _provision_hcfg() { # NAME PRE_SNIPPET WS_ARGS... acq_host_config_dir() { printf "%s\n" "'"$alias_cfgdir"'" } + _acq_provider_input_canonical_path() { + [ "$1" = "'"$alias_cfgdir"'" ] || return 1 + printf "%s\n" "'"$resolved_cfgdir"'" + } export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/594a-secrets" ' "$ws" rm -rf "$ws" "$alias_cfgdir" "$real_cfgdir" @@ -96,23 +103,22 @@ _provision_hcfg() { # NAME PRE_SNIPPET WS_ARGS... # return the symlink path, and assert the resulting volume mount uses the # resolved target, not the symlink name. # --------------------------------------------------------------------------- -@test "594(b): host-config dir with an arbitrary symlink resolves to the real target" { - local real_cfgdir symlink_cfgdir ws resolved_cfgdir +@test "594(b): host-config canonicalization failure prevents backend create" { + local real_cfgdir symlink_cfgdir ws real_cfgdir=$(mktemp -d "${TMPDIR:-/tmp}/594b-real.XXXXXX") symlink_cfgdir="${TMPDIR:-/tmp}/594b-link-$$" ln -sf "$real_cfgdir" "$symlink_cfgdir" ws=$(mktemp -d "${TMPDIR:-/tmp}/594b-ws.XXXXXX") - resolved_cfgdir=$(realpath "$real_cfgdir" 2>/dev/null || printf '%s' "$real_cfgdir") _provision_hcfg hcfgsym594b ' acq_host_config_dir() { printf "%s\n" "'"$symlink_cfgdir"'" } + _acq_provider_input_canonical_path() { return 1; } export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/594b-secrets" ' "$ws" rm -rf "$ws" "$symlink_cfgdir" "$real_cfgdir" - # The --volume SOURCE must be the real (resolved) path, not the symlink. - assert_regex "$(cat "$CALLS")" "--volume ${resolved_cfgdir}:" - refute_regex "$(cat "$CALLS")" "--volume ${symlink_cfgdir}:" + assert_failure + refute_regex "$(cat "$CALLS")" '^msb create ' } diff --git a/test/bats/90-sbx-startup-kit.bats b/test/bats/90-sbx-startup-kit.bats index f38e9744..98f024ef 100644 --- a/test/bats/90-sbx-startup-kit.bats +++ b/test/bats/90-sbx-startup-kit.bats @@ -572,16 +572,21 @@ STUB # (b) generation:ro must appear AFTER the selected agent token so sbx treats # it as a workspace-style read-only mount, not a kit path. Avoid generic # flag parsing here: SBX has both value-taking and boolean flags. - local gen_pos agent_pos + local gen_pos agent_pos workspace_pos gen_pos=$(printf '%s\n' "$create_line" | awk '{ for (i=1; i<=NF; i++) { if ($i ~ /\/generation\..*:ro$/) { print i; exit } } }') agent_pos=$(printf '%s\n' "$create_line" | awk '{ for (i=1; i<=NF; i++) { if ($i == "shell") { print i; exit } } }') + workspace_pos=$(printf '%s\n' "$create_line" | awk -v workspace="/tmp" '{ + for (i=1; i<=NF; i++) { if ($i == workspace) { print i; exit } } + }') [ -n "$gen_pos" ] [ -n "$agent_pos" ] + [ -n "$workspace_pos" ] assert [ "$agent_pos" -lt "$gen_pos" ] + assert [ "$workspace_pos" -lt "$gen_pos" ] # (c) ACQ_PROVIDER_INPUT_ROOT must be in the env flags (no :ro suffix on the guest path). assert_regex "$create_line" 'ACQ_PROVIDER_INPUT_ROOT=[^ ]*/generation\.[^ :]*([ ]|$)' From 0bdf2a7e67bc7eb50545a034151314c67a1720ae Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 24 Oct 2026 20:12:08 +0000 Subject: [PATCH 07/63] test(verify): preserve live provider adoption evidence Record observed SBX/MSB results without overstating unexecuted rows and add an actionable opencode models check for the next live run. Co-authored-by: OpenCode Agent --- docs/VERIFY_PROVIDER_ADOPTION.md | 49 ++++++++++++++++++++++++++------ scripts/verify-backends | 8 ++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/docs/VERIFY_PROVIDER_ADOPTION.md b/docs/VERIFY_PROVIDER_ADOPTION.md index cf2b4cee..6ebee59f 100644 --- a/docs/VERIFY_PROVIDER_ADOPTION.md +++ b/docs/VERIFY_PROVIDER_ADOPTION.md @@ -54,14 +54,15 @@ actual command output. | Check | SBX | MSB | Observed output / notes | |---|---|---|---| -| Fresh `acq run opencode .` startup | FAIL | FAIL | 2026-10-24: backend create failed before startup. SBX treated the provider mount as a kit/primary workspace; MSB rejected an uncanonicalized host-config bind source. Fixes are consolidated in this draft; rerun required. | -| Fresh OpenCode process starts | BLOCKED | BLOCKED | Blocked by the observed create failures above; no process-start claim. | -| `acq exec NAME -- opencode models` consumes projected provider input | BLOCKED | BLOCKED | Blocked by the observed create failures above; no model-list claim. | +| Fresh `acq run opencode .` startup | BLOCKED | BLOCKED | Not executed: the verifier uses synthetic provider credentials, while the literal fresh-run path intentionally performs post-create credential validation. | +| Fresh OpenCode sandbox create + name reattach | PASS | PASS | 2026-10-24: OpenCode sandboxes were created, then reattached by derived name. SBX recorded 12 passes; MSB recorded 17 passes. | +| Fresh OpenCode process starts | PASS | PASS | `acq run -- --version` succeeded on both backends. | +| `acq exec NAME -- opencode models` consumes projected provider input | BLOCKED | BLOCKED | Not executed in transcripts `WYG6UyXD` or `DRJKRq2R`; the verifier check is added for the next run. | | Remove projected provider input and restart OpenCode | BLOCKED | BLOCKED | Record expected actionable failure or documented fallback | | Malformed provider input is rejected before startup | BLOCKED | BLOCKED | Record exact validation error and exit status | -| `scripts/verify-backends --only ` | FAIL | FAIL | SBX transcript: `tmp/provider-neutral-validation.aguo8ZPz/sbx.log`. MSB transcript: `tmp/provider-neutral-validation.ktM9ig5z/msb.log`. Both reached validated provider inputs before backend-specific create failure. | +| `scripts/verify-backends --only ` | PASS | PASS | SBX transcript: `tmp/provider-neutral-validation.WYG6UyXD/sbx.log` (12 pass, 0 fail, 1 skip). MSB transcript: `tmp/provider-neutral-validation.DRJKRq2R/msb.log` (17 pass, 0 fail, 1 skip). | -### Preserved Live Evidence (2026-10-24) +### Earlier Failed Live Evidence (2026-10-24) The full host transcripts remain in the gitignored Quickstart `tmp/` directory. The decisive output is preserved here so review does not depend on those local @@ -83,9 +84,41 @@ error: invalid config: bind mount host path /var/folders/... goes through symlin use the resolved path /private/var/folders/... or add follow-root-symlinks ``` -These are product failures, not environmental blocks. Both backend fixes are -included in this draft. Final validation remains blocked until Quickstart main -contains the trusted macOS system-alias canonicalizer used by the MSB adapter. +These were product failures, not environmental blocks. Both backend fixes are +included in this draft and the later successful run below advanced past them. + +### Successful Live Evidence (2026-10-24) + +The combined validation worktree included the v1.17.0 pin, OpenCode renderer, +macOS alias fix, validator installer, and the SBX/MSB fixes consolidated here. + +SBX observed output: + +```text +ok sbx: provider input facts projected and readable +ok sbx: USAi host reachable from guest (HTTP 401; auth rewritten on wire) +ok sbx: opencode binary installed and on PATH +ok sbx: OpenCode kit generated the global config +ok sbx: acq run re-attaches opencode sandbox and launches --version +passed: 12 failed: 0 skipped: 1 warnings: 0 +``` + +MSB observed output: + +```text +ok msb: provider input facts projected and readable +ok msb: USAi host reachable from guest (HTTP 401; auth rewritten on wire) +ok msb: opencode binary installed and on PATH +ok msb: OpenCode kit generated the global config +ok msb: acq run re-attaches opencode sandbox and launches --version +passed: 17 failed: 0 skipped: 1 warnings: 0 +``` + +The skipped row on each backend was the private playbook fetch because `gh` was +not logged in on the host. It does not affect provider-neutral OpenCode evidence. +The `opencode models`, provider-removal, and malformed-input rows remain +`BLOCKED` until separately observed; this document does not infer them from the +successful startup run. ### Negative-Validation Procedure (malformed provider input) diff --git a/scripts/verify-backends b/scripts/verify-backends index 491fe64d..4cabad08 100755 --- a/scripts/verify-backends +++ b/scripts/verify-backends @@ -1452,6 +1452,14 @@ verify_agent_install() { dump "exec output" "$LAST_OUT" fi + if run_acq "exec opencode-models" --backend "$backend" exec "$aname" -- sh -c \ + 'opencode models | grep -q "^usai/"'; then + pass "${backend}: opencode models lists projected USAi models" + else + fail "${backend}: opencode models did not list projected USAi models" + dump "exec output" "$LAST_OUT" + fi + # Exercise the live attach/launch path without requiring a real USAi key. A # fresh `acq run opencode "$WORKDIR" -- --version` also performs post-create # USAi key validation and intentionally fails with the verifier's synthetic key; From a9032d9347be5e407b871efdc60c127d5e73c85e Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 24 Oct 2026 20:26:24 +0000 Subject: [PATCH 08/63] docs: record successful provider adoption matrix Preserve observed SBX and MSB startup, config-generation, model-listing, and verifier results while keeping unexecuted destructive/negative rows explicitly blocked. Co-authored-by: OpenCode Agent --- docs/VERIFY_PROVIDER_ADOPTION.md | 34 ++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/docs/VERIFY_PROVIDER_ADOPTION.md b/docs/VERIFY_PROVIDER_ADOPTION.md index 6ebee59f..54164d68 100644 --- a/docs/VERIFY_PROVIDER_ADOPTION.md +++ b/docs/VERIFY_PROVIDER_ADOPTION.md @@ -54,13 +54,13 @@ actual command output. | Check | SBX | MSB | Observed output / notes | |---|---|---|---| -| Fresh `acq run opencode .` startup | BLOCKED | BLOCKED | Not executed: the verifier uses synthetic provider credentials, while the literal fresh-run path intentionally performs post-create credential validation. | +| Fresh `acq run opencode .` startup | BLOCKED | BLOCKED | Not executed literally: the verifier uses synthetic provider credentials, while this path intentionally performs post-create credential validation. The related fresh create + reattach flow passed below. | | Fresh OpenCode sandbox create + name reattach | PASS | PASS | 2026-10-24: OpenCode sandboxes were created, then reattached by derived name. SBX recorded 12 passes; MSB recorded 17 passes. | | Fresh OpenCode process starts | PASS | PASS | `acq run -- --version` succeeded on both backends. | -| `acq exec NAME -- opencode models` consumes projected provider input | BLOCKED | BLOCKED | Not executed in transcripts `WYG6UyXD` or `DRJKRq2R`; the verifier check is added for the next run. | +| `acq exec NAME -- opencode models` consumes projected provider input | PASS | PASS | 2026-10-24: `opencode models` listed projected `usai/` models on both backends. | | Remove projected provider input and restart OpenCode | BLOCKED | BLOCKED | Record expected actionable failure or documented fallback | | Malformed provider input is rejected before startup | BLOCKED | BLOCKED | Record exact validation error and exit status | -| `scripts/verify-backends --only ` | PASS | PASS | SBX transcript: `tmp/provider-neutral-validation.WYG6UyXD/sbx.log` (12 pass, 0 fail, 1 skip). MSB transcript: `tmp/provider-neutral-validation.DRJKRq2R/msb.log` (17 pass, 0 fail, 1 skip). | +| `scripts/verify-backends --only ` | PASS | PASS | SBX transcript: `tmp/provider-neutral-validation.lK64ypzb/sbx.log` (13 pass, 0 fail, 1 skip). MSB transcript: `tmp/provider-neutral-validation.qJrzRYY1/msb.log` (18 pass, 0 fail, 1 skip). | ### Earlier Failed Live Evidence (2026-10-24) @@ -116,9 +116,31 @@ passed: 17 failed: 0 skipped: 1 warnings: 0 The skipped row on each backend was the private playbook fetch because `gh` was not logged in on the host. It does not affect provider-neutral OpenCode evidence. -The `opencode models`, provider-removal, and malformed-input rows remain -`BLOCKED` until separately observed; this document does not infer them from the -successful startup run. +Provider removal and malformed-input rejection remain `BLOCKED` until separately +observed; this document does not infer them from the successful startup run. + +### Final Live Evidence with Model Listing (2026-10-24) + +The validation worktree included the v1.17.0 renderer, macOS alias fix, +validator installer, and the consolidated SBX/MSB backend fixes. + +```text +SBX: opencode models lists projected USAi models +SBX totals: passed 13, failed 0, skipped 1, warnings 0 +MSB: opencode models lists projected USAi models +MSB totals: passed 18, failed 0, skipped 1, warnings 0 +``` + +Host result files: + +```text +tmp/provider-neutral-validation.lK64ypzb/results.tsv +tmp/provider-neutral-validation.qJrzRYY1/results.tsv +``` + +Provider removal and malformed-input rejection remain `BLOCKED`; they require +separate destructive/negative host procedures and are not inferred from the +successful positive matrix. ### Negative-Validation Procedure (malformed provider input) From 767f30ba0763414a112cacdb9dbb1038b84f7315 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sat, 24 Oct 2026 20:41:06 +0000 Subject: [PATCH 09/63] test(msb): supply canonicalizer in adapter signal seam Keep the focused MSB signal regression self-contained until the trusted macOS canonicalizer lands on main. Co-authored-by: OpenCode Agent --- test/bats/147-provider-input-projection.bats | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/bats/147-provider-input-projection.bats b/test/bats/147-provider-input-projection.bats index 3432cc00..ff905c69 100644 --- a/test/bats/147-provider-input-projection.bats +++ b/test/bats/147-provider-input-projection.bats @@ -852,6 +852,9 @@ export ACQ_STATE_DIR="$STUBDIR/state3" ACQ_SCRIPT_DIR="$REPO_ROOT" ACQ_SOURCE_ONLY=1 . "$REPO_ROOT/acq" . "$REPO_ROOT/acq.backends/msb.sh" KITS=() +# Current main does not contain the macOS alias helper until the approved path +# fix merges; this adapter-focused test supplies its pass-through contract. +_acq_provider_input_canonical_path() { printf '%s\n' "\$1"; } # Stub the complex provision helpers so execution reaches the msb create call. acq_backend_exists() { return 1; } _acq_msb_confirm_absent() { return 0; } From ed9b1ac36d344bd632e2acbeebd346875db27213 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 00:29:57 +0000 Subject: [PATCH 10/63] test(msb): retain projection seam in canonical mount test Install the adapter projection boundary after sourcing the MSB adapter while preserving the narrow canonicalizer contract. Co-authored-by: OpenCode Agent --- test/bats/71c-msb-canonical-host-config.bats | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/bats/71c-msb-canonical-host-config.bats b/test/bats/71c-msb-canonical-host-config.bats index ce76259c..e981e188 100644 --- a/test/bats/71c-msb-canonical-host-config.bats +++ b/test/bats/71c-msb-canonical-host-config.bats @@ -45,6 +45,7 @@ _provision_hcfg() { # NAME PRE_SNIPPET WS_ARGS... _acq_provider_input_canonical_path() { printf "%s\n" "$1"; } eval "$pre" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_test_stub_provider_input_boundary mkdir -p "'"$STUBDIR"'/nokit" printf "schemaVersion: \"hybrid/v1\"\nkind: mixin\nname: x\ndisplayName: X\ndescription: x\n" \ > "'"$STUBDIR"'/nokit/spec.yaml" @@ -78,11 +79,15 @@ _provision_hcfg() { # NAME PRE_SNIPPET WS_ARGS... printf "%s\n" "'"$alias_cfgdir"'" } _acq_provider_input_canonical_path() { - [ "$1" = "'"$alias_cfgdir"'" ] || return 1 - printf "%s\n" "'"$resolved_cfgdir"'" + if [ "$1" = "'"$alias_cfgdir"'" ]; then + printf "%s\n" "'"$resolved_cfgdir"'" + else + printf "%s\n" "$1" + fi } export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/594a-secrets" ' "$ws" + assert_success rm -rf "$ws" "$alias_cfgdir" "$real_cfgdir" # The --volume SOURCE must be the resolved real path, not the alias. From 97270b5cdf5c0b42e1a700e0b2e28a773c7c47a4 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 01:09:44 +0000 Subject: [PATCH 11/63] fix(sbx): place provider mount after workspace Preserve SBX positional grammar by placing the provider generation mount after the agent and primary workspace. Co-authored-by: OpenCode Agent --- test/bats/30-dispatch-routing.bats | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index 3d2b2424..f4e51906 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -182,6 +182,17 @@ FACTS assert_regex "$create_line" "$proj" } +@test "create(sbx): provider input mount follows the agent and workspace positionals" { + local proj="$STUBDIR/provider-order"; mkdir -p "$proj" + _seed_usai + + run env ACQ_BACKEND=sbx "$ACQ" create shell "$proj" + assert_success + + local create_line; create_line=$(grep '^sbx create' "$CALLS") + assert_regex "$create_line" "shell $proj .*/generations/generation\\.[^[:space:]]+:ro$" +} + @test "dispatch: create runs the github-scope advisory" { local ghproj="$STUBDIR/ghproj"; mkdir -p "$ghproj" ( cd "$ghproj" && git init -q && git remote add origin https://github.com/GSA-TTS/quickstart.git ) From 1e5fca56d8cb2a3d2fffd7c6e32ba673ecb9765a Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 01:10:25 +0000 Subject: [PATCH 12/63] test(sbx): expect provider mount after workspace Update SBX create assertions for the corrected positional mount order. Co-authored-by: OpenCode Agent --- test/bats/140-opencode-migration-gate.bats | 2 +- test/bats/35-image-override.bats | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/bats/140-opencode-migration-gate.bats b/test/bats/140-opencode-migration-gate.bats index c94d3afd..86507cf3 100644 --- a/test/bats/140-opencode-migration-gate.bats +++ b/test/bats/140-opencode-migration-gate.bats @@ -391,7 +391,7 @@ SPEC printf 'genericattach\n' >"$STUBDIR/.sandbox_list" local create_line create_line=$(_create_line sbx) - assert_regex "$create_line" " shell ${proj}$" + assert_regex "$create_line" " shell ${proj} .+:ro$" assert_equal "$(printf '%s\n' "$create_line" | grep -o 'acq-opencode-kit' | wc -l | tr -d ' ')" "1" : >"$CALLS" diff --git a/test/bats/35-image-override.bats b/test/bats/35-image-override.bats index 332216a9..dc9dd896 100644 --- a/test/bats/35-image-override.bats +++ b/test/bats/35-image-override.bats @@ -186,7 +186,7 @@ _sbx_create() { # CREATE_ARGS... (with optional leading ENV via `env`) _sbx_create ACQ_BACKEND=sbx "$ACQ" create opencode "$IMGPROJ" local line; line=$(_create_line sbx) assert_regex "$line" '--template ghcr\.io/gsa-tts/agentic-coding-patterns/devenv-shell@sha256:7b923fb5e722ad27c751ead836f24145c4cea3fac015a3ba753bd7473891e1e7' - assert_regex "$line" " shell ${IMGPROJ}$" + assert_regex "$line" " shell ${IMGPROJ} .+:ro$" refute_regex "$line" 'devenv-opencode|sandbox-templates:opencode-docker' } From b15f4f6d02d534abdadec973e5ae6129547b6ceb Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 01:12:49 +0000 Subject: [PATCH 13/63] test(backends): refresh create argv expectations Reflect provider mount placement and current interactive environment tokens. Co-authored-by: OpenCode Agent --- test/bats/140-opencode-migration-gate.bats | 2 +- test/bats/30-dispatch-routing.bats | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/bats/140-opencode-migration-gate.bats b/test/bats/140-opencode-migration-gate.bats index 86507cf3..2be0628b 100644 --- a/test/bats/140-opencode-migration-gate.bats +++ b/test/bats/140-opencode-migration-gate.bats @@ -341,7 +341,7 @@ SPEC bash -c '. "$1/acq.backends/common.sh"; acq_host_config_write msb migbox workspace /workspace; exec "$2" shell migbox' \ _ "$REPO_ROOT" "$ACQ" assert_success - assert_regex "$(cat "$CALLS")" 'msb exec -t -u agent -w /workspace -e TERM=xterm-256color -e SHELL=/bin/bash migbox -- /bin/bash -l' + assert_regex "$(cat "$CALLS")" 'msb exec -t -u agent -w /workspace -e TERM=xterm-256color -e COLORTERM=truecolor -e SHELL=/bin/bash migbox -- /bin/bash -l' } @test "migration gate(sbx): run opencode keeps native clone, workspace markers, image template, and kit order" { diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index f4e51906..e4288c64 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -103,7 +103,7 @@ FACTS run env TERM= ACQ_BACKEND=msb ACQ_DEBUG=1 GIT_ALLOW_PROTOCOL=none \ STUB_AGENT_USER_READY=1 "$ACQ" run mybox assert_success - assert_regex "$(cat "$CALLS")" 'msb exec -t -u agent -w /home/agent -e SHELL=/bin/sh mybox -- /bin/sh -l' + assert_regex "$(cat "$CALLS")" 'msb exec -t -u agent -w /home/agent -e COLORTERM=truecolor -e SHELL=/bin/sh mybox -- /bin/sh -l' refute_output --partial 'provider facts' } From e47e1e0cfaa38383f968c149f383f3a1d17dd408 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 02:30:00 +0000 Subject: [PATCH 14/63] docs: record final provider-neutral backend matrix Record post-fix SBX and MSB live validation totals and model-list evidence. Co-authored-by: OpenCode Agent --- docs/VERIFY_PROVIDER_ADOPTION.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/VERIFY_PROVIDER_ADOPTION.md b/docs/VERIFY_PROVIDER_ADOPTION.md index 54164d68..b69cbbc6 100644 --- a/docs/VERIFY_PROVIDER_ADOPTION.md +++ b/docs/VERIFY_PROVIDER_ADOPTION.md @@ -55,12 +55,12 @@ actual command output. | Check | SBX | MSB | Observed output / notes | |---|---|---|---| | Fresh `acq run opencode .` startup | BLOCKED | BLOCKED | Not executed literally: the verifier uses synthetic provider credentials, while this path intentionally performs post-create credential validation. The related fresh create + reattach flow passed below. | -| Fresh OpenCode sandbox create + name reattach | PASS | PASS | 2026-10-24: OpenCode sandboxes were created, then reattached by derived name. SBX recorded 12 passes; MSB recorded 17 passes. | +| Fresh OpenCode sandbox create + name reattach | PASS | PASS | 2026-10-25: OpenCode sandboxes were created, then reattached by derived name. SBX recorded 13 passes; MSB recorded 18 passes. | | Fresh OpenCode process starts | PASS | PASS | `acq run -- --version` succeeded on both backends. | | `acq exec NAME -- opencode models` consumes projected provider input | PASS | PASS | 2026-10-24: `opencode models` listed projected `usai/` models on both backends. | | Remove projected provider input and restart OpenCode | BLOCKED | BLOCKED | Record expected actionable failure or documented fallback | | Malformed provider input is rejected before startup | BLOCKED | BLOCKED | Record exact validation error and exit status | -| `scripts/verify-backends --only ` | PASS | PASS | SBX transcript: `tmp/provider-neutral-validation.lK64ypzb/sbx.log` (13 pass, 0 fail, 1 skip). MSB transcript: `tmp/provider-neutral-validation.qJrzRYY1/msb.log` (18 pass, 0 fail, 1 skip). | +| `scripts/verify-backends --only ` | PASS | PASS | Final combined branch: SBX transcript `tmp/provider-neutral-validation.TCY2fZvE/sbx.log` (13 pass, 0 fail, 1 skip); MSB transcript `tmp/provider-neutral-validation.8MP6vbkX/msb.log` (18 pass, 0 fail, 1 skip). | ### Earlier Failed Live Evidence (2026-10-24) @@ -119,10 +119,11 @@ not logged in on the host. It does not affect provider-neutral OpenCode evidence Provider removal and malformed-input rejection remain `BLOCKED` until separately observed; this document does not infer them from the successful startup run. -### Final Live Evidence with Model Listing (2026-10-24) +### Final Live Evidence with Model Listing (2026-10-25) -The validation worktree included the v1.17.0 renderer, macOS alias fix, -validator installer, and the consolidated SBX/MSB backend fixes. +The final validation worktree included the v1.17.0 renderer, macOS alias fix, +validator installer, and the consolidated SBX/MSB backend fixes, including the +SBX positional provider-mount correction. ```text SBX: opencode models lists projected USAi models @@ -134,8 +135,8 @@ MSB totals: passed 18, failed 0, skipped 1, warnings 0 Host result files: ```text -tmp/provider-neutral-validation.lK64ypzb/results.tsv -tmp/provider-neutral-validation.qJrzRYY1/results.tsv +tmp/provider-neutral-validation.TCY2fZvE/results.tsv +tmp/provider-neutral-validation.8MP6vbkX/results.tsv ``` Provider removal and malformed-input rejection remain `BLOCKED`; they require From 91f3d1ade55a49baaa3ddde99d37a8fd46fe01a2 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 02:38:59 +0000 Subject: [PATCH 15/63] docs: record literal MSB OpenCode credential flow Record the observed interactive MSB run, USAi model listing, and successful model response. Co-authored-by: OpenCode Agent --- docs/VERIFY_PROVIDER_ADOPTION.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/VERIFY_PROVIDER_ADOPTION.md b/docs/VERIFY_PROVIDER_ADOPTION.md index b69cbbc6..0b7003bc 100644 --- a/docs/VERIFY_PROVIDER_ADOPTION.md +++ b/docs/VERIFY_PROVIDER_ADOPTION.md @@ -54,7 +54,7 @@ actual command output. | Check | SBX | MSB | Observed output / notes | |---|---|---|---| -| Fresh `acq run opencode .` startup | BLOCKED | BLOCKED | Not executed literally: the verifier uses synthetic provider credentials, while this path intentionally performs post-create credential validation. The related fresh create + reattach flow passed below. | +| Fresh `acq run opencode .` startup | BLOCKED | PASS | 2026-10-25: MSB created `opencode-validate-601-final-r2`, launched the interactive OpenCode session, listed USAi models, and completed a model request successfully. SBX was intentionally not run through the real credential flow because the host key was not rotated. | | Fresh OpenCode sandbox create + name reattach | PASS | PASS | 2026-10-25: OpenCode sandboxes were created, then reattached by derived name. SBX recorded 13 passes; MSB recorded 18 passes. | | Fresh OpenCode process starts | PASS | PASS | `acq run -- --version` succeeded on both backends. | | `acq exec NAME -- opencode models` consumes projected provider input | PASS | PASS | 2026-10-24: `opencode models` listed projected `usai/` models on both backends. | @@ -143,6 +143,28 @@ Provider removal and malformed-input rejection remain `BLOCKED`; they require separate destructive/negative host procedures and are not inferred from the successful positive matrix. +### Literal MSB Credential Flow (2026-10-25) + +The literal host command completed on MSB from the final validation worktree: + +```text +bash ./acq --backend msb run opencode . +``` + +Observed behavior: + +```text +provider input contract: validated 1 bundle(s) +acq: Creating sandbox 'opencode-validate-601-final-r2'… done +opencode-kit(install): opencode 2.0.30 (arm64) installed and runnable +Starting background server... +``` + +Inside the launched OpenCode session, USAi models were visible and a model +request returned a successful response. This fulfills the literal MSB +credential-flow row. SBX remains `BLOCKED` for this row because the host owner +did not elect to rotate the USAi key for a real-credential SBX run. + ### Negative-Validation Procedure (malformed provider input) This procedure proves that host-side provider validation rejects a malformed From 47ac658bdc098969a4883809713519ec18fe2ec6 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 02:47:05 +0000 Subject: [PATCH 16/63] docs: record literal SBX OpenCode credential flow Record successful interactive USAi model requests on both SBX and MSB without retaining secret material. Co-authored-by: OpenCode Agent --- docs/VERIFY_PROVIDER_ADOPTION.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/VERIFY_PROVIDER_ADOPTION.md b/docs/VERIFY_PROVIDER_ADOPTION.md index 0b7003bc..fe0dd17f 100644 --- a/docs/VERIFY_PROVIDER_ADOPTION.md +++ b/docs/VERIFY_PROVIDER_ADOPTION.md @@ -54,7 +54,7 @@ actual command output. | Check | SBX | MSB | Observed output / notes | |---|---|---|---| -| Fresh `acq run opencode .` startup | BLOCKED | PASS | 2026-10-25: MSB created `opencode-validate-601-final-r2`, launched the interactive OpenCode session, listed USAi models, and completed a model request successfully. SBX was intentionally not run through the real credential flow because the host key was not rotated. | +| Fresh `acq run opencode .` startup | PASS | PASS | 2026-10-25: both backends launched interactive OpenCode sessions from the final validation worktree. USAi models were visible and a model request returned successfully on each backend. | | Fresh OpenCode sandbox create + name reattach | PASS | PASS | 2026-10-25: OpenCode sandboxes were created, then reattached by derived name. SBX recorded 13 passes; MSB recorded 18 passes. | | Fresh OpenCode process starts | PASS | PASS | `acq run -- --version` succeeded on both backends. | | `acq exec NAME -- opencode models` consumes projected provider input | PASS | PASS | 2026-10-24: `opencode models` listed projected `usai/` models on both backends. | @@ -143,15 +143,16 @@ Provider removal and malformed-input rejection remain `BLOCKED`; they require separate destructive/negative host procedures and are not inferred from the successful positive matrix. -### Literal MSB Credential Flow (2026-10-25) +### Literal Credential Flow (2026-10-25) -The literal host command completed on MSB from the final validation worktree: +The literal host command completed on both backends from the final validation +worktree: ```text bash ./acq --backend msb run opencode . ``` -Observed behavior: +Observed MSB behavior: ```text provider input contract: validated 1 bundle(s) @@ -161,9 +162,10 @@ Starting background server... ``` Inside the launched OpenCode session, USAi models were visible and a model -request returned a successful response. This fulfills the literal MSB -credential-flow row. SBX remains `BLOCKED` for this row because the host owner -did not elect to rotate the USAi key for a real-credential SBX run. +request returned a successful response. The same interactive checks then passed +on SBX after its USAi secret was updated through the supported rotation flow. +This fulfills the literal credential-flow row for both backends. No credential +value, secret placeholder, or session identifier is retained in this record. ### Negative-Validation Procedure (malformed provider input) From fb912d706c9a1307784af0af8a1848a4a2687a4b Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 03:10:14 +0000 Subject: [PATCH 17/63] fix(provider-input): report malformed manifest errors Convert malformed provider-input JSON into an actionable acq diagnostic before schema validation. Co-authored-by: OpenCode Agent --- acq.backends/common.sh | 8 +++++++- test/bats/147-provider-input-projection.bats | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 9b7fbd81..a8c75dcb 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -2917,7 +2917,13 @@ root = os.open(kit, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) try: manifest_fd = open_relative(root, 'provider-input.json') with os.fdopen(manifest_fd, encoding='utf-8') as stream: manifest_bytes = stream.read() - bundle = json.loads(manifest_bytes) + try: + bundle = json.loads(manifest_bytes) + except json.JSONDecodeError as exc: + raise SystemExit( + 'acq: invalid provider input manifest: provider-input.json: ' + f'invalid JSON at line {exc.lineno} column {exc.colno}' + ) os.mkdir(target, 0o700) with open(os.path.join(target, 'provider-input.json'), 'x', encoding='utf-8') as stream: stream.write(manifest_bytes) for path in ('spec.yaml', bundle['factsPath'], bundle['normalizerPath'], bundle['snapshotPath']): diff --git a/test/bats/147-provider-input-projection.bats b/test/bats/147-provider-input-projection.bats index ff905c69..2b9f673e 100644 --- a/test/bats/147-provider-input-projection.bats +++ b/test/bats/147-provider-input-projection.bats @@ -358,6 +358,18 @@ EOF grep -Fq 'alpha.example.gov' "$candidate/spec.yaml" } +@test "candidate snapshot rejects malformed provider input manifest with actionable error" { + local release="$STUBDIR/release" + local kit="$release/integrations/isolation/acq-kits/alpha" + _provider_release "$release"; _provider_kit "$release" alpha + printf '{this-is-not-json\n' > "$kit/provider-input.json" + + run _acq_provider_input_snapshot "$kit" "$STUBDIR/malformed-candidate" + assert_failure + assert_output --partial 'acq: invalid provider input manifest: provider-input.json: invalid JSON' + refute_output --partial 'Traceback' +} + @test "pinned Patterns validator rejects facts host outside snapshotted kit allowlist" { [ -n "${ACQ_TEST_PATTERNS_REPO:-}" ] || skip "pinned Patterns checkout is provided by required CI" [ -n "${ACQ_TEST_PROVIDER_INPUT_PYTHON:-}" ] || skip "pinned validator Python is provided by required CI" From 6a9186a9d112e298a100118115fc2528469c9fb0 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 03:13:48 +0000 Subject: [PATCH 18/63] docs: record malformed provider validation matrix Record observed fail-closed malformed provider input rejection on SBX and MSB. Co-authored-by: OpenCode Agent --- docs/VERIFY_PROVIDER_ADOPTION.md | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/VERIFY_PROVIDER_ADOPTION.md b/docs/VERIFY_PROVIDER_ADOPTION.md index fe0dd17f..9f430fdf 100644 --- a/docs/VERIFY_PROVIDER_ADOPTION.md +++ b/docs/VERIFY_PROVIDER_ADOPTION.md @@ -59,7 +59,7 @@ actual command output. | Fresh OpenCode process starts | PASS | PASS | `acq run -- --version` succeeded on both backends. | | `acq exec NAME -- opencode models` consumes projected provider input | PASS | PASS | 2026-10-24: `opencode models` listed projected `usai/` models on both backends. | | Remove projected provider input and restart OpenCode | BLOCKED | BLOCKED | Record expected actionable failure or documented fallback | -| Malformed provider input is rejected before startup | BLOCKED | BLOCKED | Record exact validation error and exit status | +| Malformed provider input is rejected before startup | PASS | PASS | 2026-10-25: both backends rejected malformed `provider-input.json` before backend creation with `acq: invalid provider input manifest ... invalid JSON at line 1 column 2`. | | `scripts/verify-backends --only ` | PASS | PASS | Final combined branch: SBX transcript `tmp/provider-neutral-validation.TCY2fZvE/sbx.log` (13 pass, 0 fail, 1 skip); MSB transcript `tmp/provider-neutral-validation.8MP6vbkX/msb.log` (18 pass, 0 fail, 1 skip). | ### Earlier Failed Live Evidence (2026-10-24) @@ -116,8 +116,9 @@ passed: 17 failed: 0 skipped: 1 warnings: 0 The skipped row on each backend was the private playbook fetch because `gh` was not logged in on the host. It does not affect provider-neutral OpenCode evidence. -Provider removal and malformed-input rejection remain `BLOCKED` until separately -observed; this document does not infer them from the successful startup run. +Provider removal remains `BLOCKED` until the supported host-side omission +capability is implemented; this document does not infer it from the successful +startup run. ### Final Live Evidence with Model Listing (2026-10-25) @@ -139,9 +140,27 @@ tmp/provider-neutral-validation.TCY2fZvE/results.tsv tmp/provider-neutral-validation.8MP6vbkX/results.tsv ``` -Provider removal and malformed-input rejection remain `BLOCKED`; they require -separate destructive/negative host procedures and are not inferred from the -successful positive matrix. +Provider removal remains `BLOCKED`; it requires a supported authoritative-empty +provider selection and is not inferred from the successful positive matrix. + +### Malformed Provider Input Evidence (2026-10-25) + +The bounded host runner executed on both backends and rejected the candidate +before any sandbox creation: + +```text +SBX: acq: invalid provider input manifest: provider-input.json: invalid JSON at line 1 column 2 +MSB: acq: invalid provider input manifest: provider-input.json: invalid JSON at line 1 column 2 +``` + +Result files: + +```text +tmp/malformed-provider-validation.fFFNukSX/results.tsv +tmp/malformed-provider-validation.GQGSRccw/results.tsv +``` + +Both rows are `PASS`; no backend create command was reached. ### Literal Credential Flow (2026-10-25) From 580fe8091d9cd9f2b910c77a31ebb16edd3a84f1 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 03:30:00 +0000 Subject: [PATCH 19/63] feat(providers): support explicit provider kit selection Allow an authoritative empty provider set without changing the default USAi selection. Co-authored-by: OpenCode [gpt-5.6-terra] --- acq | 29 +++++-- acq.backends/common.sh | 88 +++++++++++++++++++- acq.backends/msb.sh | 34 +++++--- acq.backends/sbx.sh | 12 ++- docs/howto/acq.md | 21 ++++- test/bats/140-opencode-migration-gate.bats | 23 +++++ test/bats/147-provider-input-projection.bats | 18 ++++ test/bats/50-kit-list-completeness.bats | 31 +++++++ test/bats/85-kit-provenance.bats | 12 +++ test/bats/helper.bash | 3 +- 10 files changed, 245 insertions(+), 26 deletions(-) diff --git a/acq b/acq index 499a8f8a..3f45f4fc 100755 --- a/acq +++ b/acq @@ -157,6 +157,8 @@ run/create flags: --kit REF Apply an extra kit (repeatable). REF is a local dir or a git+https ref (like an ACQ_EXTRA_KITS entry); acq translates it to the backend — it is NOT forwarded raw. + --no-provider-kits Create without provider kits. This is authoritative: no + USAi provider config or credential is required. --image REF Same as the global --image above; accepted here too so `acq create AGENT --image REF PATH` works. --clone Run the agent on a disposable clone of the primary @@ -1071,6 +1073,8 @@ case "$subcommand" in # asked for. Only honored BEFORE `--`. extract_publish_flags "$@" || exit 1 set -- ${ACQ_PUBLISH_REMAINING[@]+"${ACQ_PUBLISH_REMAINING[@]}"} + extract_provider_flags "$@" + set -- ${ACQ_PROVIDER_REMAINING[@]+"${ACQ_PROVIDER_REMAINING[@]}"} if [ "${#ACQ_PUBLISH_FLAGS[@]}" -gt 0 ]; then # A create-time host-port override only means something on a backend that # CHOOSES the host port. sbx assigns it itself and exposes no knob (its @@ -1110,7 +1114,9 @@ esac case "$subcommand" in usai-rotate-api-key) shift - acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" + if acq_usai_provider_selected; then + acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" + fi if ! command -v acq_backend_rotate_key >/dev/null 2>&1; then echo "acq: the '${ACQ_RESOLVED_BACKEND}' backend does not implement key rotation" >&2 echo " (acq_backend_rotate_key). Rotate manually, then re-run." >&2 @@ -1122,7 +1128,9 @@ case "$subcommand" in create) shift - acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" + if acq_usai_provider_selected; then + acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" + fi acq_backend_prepare # First-run onboarding (ADR-0031): offer `acq configure` once before we # provision, so a brand-new user picks kits up front. No-op after first run @@ -1144,7 +1152,7 @@ case "$subcommand" in # at create time, so a sandbox created before the key exists carries no USAi # binding. Gate on key presence first (fail closed if declined) so create # picks it up. `create` is otherwise detached and never attaches. - if ! ensure_key_present "$local_name"; then + if acq_usai_provider_selected && ! ensure_key_present "$local_name"; then exit 1 fi @@ -1165,7 +1173,9 @@ case "$subcommand" in # so unlike `run` we don't gate on the key or offer interactive rotation — # just warn if it's already invalid so a create-only user knows before they # later `acq run`. - advise_valid_key "$local_name" + if acq_usai_provider_selected; then + advise_valid_key "$local_name" + fi ;; run) @@ -1190,10 +1200,15 @@ case "$subcommand" in [ "${#create_args[@]}" -gt 0 ] && first=$(first_positional "${create_args[@]}") if [ -n "$first" ] && is_known_agent "$first"; then - acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" # AGENT form: create if missing, then attach. local_name=$(derive_name "${create_args[@]}") || exit 1 [ -n "$local_name" ] || { echo "acq: could not determine sandbox name" >&2; exit 1; } + if acq_backend_exists "$local_name"; then + acq_provider_selection_load "${ACQ_RESOLVED_BACKEND}" "$local_name" || exit 1 + fi + if acq_usai_provider_selected; then + acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" + fi acq_print_selected_agent_kit "$first" # Pre-flight: validate the workspace path before provisioning (#issue). @@ -1271,7 +1286,7 @@ case "$subcommand" in create_time_kit_picker _build_kit_list "$first" - if ! ensure_key_present "$local_name"; then + if acq_usai_provider_selected && ! ensure_key_present "$local_name"; then exit 1 fi @@ -1292,7 +1307,7 @@ case "$subcommand" in # abort explicitly on a non-zero return rather than relying on `set -e` # to propagate from a bare function call (its behavior for a function # used as the tested command is not portable across bash versions). - if ! ensure_valid_key "$local_name" "${create_args[@]}"; then + if acq_usai_provider_selected && ! ensure_valid_key "$local_name" "${create_args[@]}"; then exit 1 fi diff --git a/acq.backends/common.sh b/acq.backends/common.sh index a8c75dcb..0e0ed1f3 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -89,6 +89,20 @@ ACQ_BUILTIN_BUNDLE_REPO="GSA-TTS/agentic-coding-patterns" ACQ_EXTRA_KITS="${ACQ_EXTRA_KITS:-}" ACQ_EXTRA_KIT_SOURCES="${ACQ_EXTRA_KIT_SOURCES:-}" +# Provider kits are selected independently from support and extra kits. Unset +# preserves the historical default; an explicitly set empty value is authoritative. +# `--no-provider-kits` supplies a shell-independent spelling of that empty set. +if [ -z "${_ACQ_PROVIDER_KITS_INITIALIZED:-}" ]; then + if [ "${ACQ_PROVIDER_KITS+x}" = x ]; then + ACQ_PROVIDER_KITS_FROM_ENV=1 + else + ACQ_PROVIDER_KITS_FROM_ENV="" + fi + ACQ_PROVIDER_KITS="${ACQ_PROVIDER_KITS:-}" + _ACQ_PROVIDER_KITS_INITIALIZED=1 +fi +ACQ_PROVIDER_KITS_FROM_CLI="" + # Record whether ACQ_EXTRA_KITS came from the ENVIRONMENT (vs. being empty or set # later by acq itself). The configured-default and create-time picker paths honor # env > config > interactive: a user who exported ACQ_EXTRA_KITS gets it verbatim @@ -834,6 +848,44 @@ _acq_builtin_support_kit_names() { printf '%s\n' zscaler-ca-certificate "$USAI_PROVIDER_KIT_NAME" agentic-coding-playbook git-ssh-sign } +_acq_selected_provider_kit_refs() { + local provider_refs=() ref + if [ -z "${ACQ_PROVIDER_KITS_FROM_ENV:-}" ] && [ -z "${ACQ_PROVIDER_KITS_FROM_CLI:-}" ]; then + provider_refs=("$USAI_KIT") + elif [ -n "$ACQ_PROVIDER_KITS" ]; then + split_noglob provider_refs "$ACQ_PROVIDER_KITS" + fi + for ref in "${provider_refs[@]}"; do + printf '%s\n' "$ref" + done +} + +acq_provider_kit_selected() { + local wanted="$1" name + while IFS= read -r name; do + [ "$name" = "$wanted" ] && return 0 + done < <(_acq_selected_provider_kit_refs) + return 1 +} + +acq_usai_provider_selected() { + acq_provider_kit_selected "$USAI_KIT" +} + +acq_provider_selection_load() { + local backend="$1" name="$2" mode kits + mode=$(acq_provenance_field "$backend" "$name" provider_selection) + kits=$(acq_provenance_field "$backend" "$name" provider_kits) + case "$mode" in + default) ACQ_PROVIDER_KITS=""; ACQ_PROVIDER_KITS_FROM_ENV=""; ACQ_PROVIDER_KITS_FROM_CLI="" ;; + explicit) ACQ_PROVIDER_KITS="$kits"; ACQ_PROVIDER_KITS_FROM_ENV=1; ACQ_PROVIDER_KITS_FROM_CLI="" ;; + # Legacy records predate provider selection and therefore retain the + # historical default rather than inheriting a later host environment choice. + "") ACQ_PROVIDER_KITS=""; ACQ_PROVIDER_KITS_FROM_ENV=""; ACQ_PROVIDER_KITS_FROM_CLI="" ;; + *) return 1 ;; + esac +} + _acq_agent_builtin_kit_ref() { local kit_name kit_name=$(acq_agent_builtin_kit_name "$1") || return 1 @@ -918,7 +970,12 @@ acq_agent_builtin_kit_ready() { _acq_selected_support_kit_refs() { local name kit - for name in $(_acq_builtin_support_kit_names); do + kit=$(_acq_builtin_kit_ref zscaler-ca-certificate) || return 1 + printf '%s\n' "$kit" + while IFS= read -r kit; do + [ -n "$kit" ] && printf '%s\n' "$kit" + done < <(_acq_selected_provider_kit_refs) + for name in agentic-coding-playbook git-ssh-sign; do kit=$(_acq_builtin_kit_ref "$name") || return 1 printf '%s\n' "$kit" done @@ -1425,6 +1482,24 @@ extract_kit_flags() { fi } +# Extract the explicit empty provider selection without relying on shell export +# semantics for empty values. It is acq-owned and never reaches a backend CLI. +extract_provider_flags() { + ACQ_PROVIDER_REMAINING=() + local arg seen_sep=0 + for arg in "$@"; do + if [ "$seen_sep" -eq 1 ]; then + ACQ_PROVIDER_REMAINING+=("$arg") + continue + fi + case "$arg" in + --) seen_sep=1; ACQ_PROVIDER_REMAINING+=("$arg") ;; + --no-provider-kits) ACQ_PROVIDER_KITS=""; ACQ_PROVIDER_KITS_FROM_CLI=1 ;; + *) ACQ_PROVIDER_REMAINING+=("$arg") ;; + esac + done +} + # Extract a user-supplied `--image ` / `--image=` flag from a run/create # arg list (ADR-0022). Populates two things IN THE CURRENT SHELL (so callers must # not run this in a subshell/pipeline): @@ -2039,7 +2114,7 @@ _acq_provenance_file() { # and returns non-zero but never aborts the caller (fail-open). # Usage: acq_provenance_write BACKEND SANDBOX_NAME [AGENT] [WORKSPACE] acq_provenance_write() { - local backend="${1:-}" name="${2:-}" agent="${3:-}" workspace="${4:-}" + local backend="${1:-}" name="${2:-}" agent="${3:-}" workspace="${4:-}" provider_selection provider_kits [ -n "$backend" ] && [ -n "$name" ] || return 1 local file dir ts workspace_source file=$(_acq_provenance_file "$backend" "$name") || return 1 @@ -2062,6 +2137,13 @@ acq_provenance_write() { "") : ;; *[!A-Za-z0-9._/-]*) workspace="" ;; esac + if [ -n "${ACQ_PROVIDER_KITS_FROM_ENV:-}" ] || [ -n "${ACQ_PROVIDER_KITS_FROM_CLI:-}" ]; then + provider_selection=explicit + provider_kits="$ACQ_PROVIDER_KITS" + else + provider_selection=default + provider_kits="" + fi # Write atomically via a temp file + mv so a crash mid-write can't leave a # half-written record that later parses as a bogus "current" ref. local tmp="${file}.tmp.$$" @@ -2071,6 +2153,8 @@ acq_provenance_write() { printf 'repo=%s\n' "$ACQ_BUILTIN_BUNDLE_REPO" printf 'applied_ref=%s\n' "$PATTERNS_KIT_REF" printf 'backend=%s\n' "$backend" + printf 'provider_selection=%s\n' "$provider_selection" + [ -n "$provider_kits" ] && printf 'provider_kits=%s\n' "$provider_kits" [ -n "$agent" ] && printf 'agent=%s\n' "$agent" printf 'applied_at=%s\n' "$ts" if [ -n "${workspace:-}" ]; then diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 11e9b923..a82b0be6 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -950,16 +950,18 @@ _acq_msb_bind_secrets_into() { # (_acq_msb_service_binding), so a `--host` sidecar recorded at `acq secret # set` overrides the compiled-in endpoint here too (#384) instead of being # written-but-ignored at provision. - local _usai_binding _usai_env _usai_host - _usai_binding=$(_acq_msb_service_binding usai "$_name") - _usai_env=$(printf '%s' "$_usai_binding" | cut -f1) - _usai_host=$(printf '%s' "$_usai_binding" | cut -f2) - - # USAi: acq store first, else a pre-exported USAI_API_KEY (e.g. CI). - if ! _acq_msb_bind_one "$_arrn" "$_namesn" usai "$_name" "$_usai_env" "$_usai_host"; then - if [ -n "${USAI_API_KEY:-}" ]; then - eval "$_arrn+=(--secret \"\${_usai_env}@\${_usai_host}\")" - acq_debug "msb secret: binding ${_usai_env}@${_usai_host} (from env)" + if ! command -v acq_usai_provider_selected >/dev/null 2>&1 || acq_usai_provider_selected; then + local _usai_binding _usai_env _usai_host + _usai_binding=$(_acq_msb_service_binding usai "$_name") + _usai_env=$(printf '%s' "$_usai_binding" | cut -f1) + _usai_host=$(printf '%s' "$_usai_binding" | cut -f2) + + # USAi: acq store first, else a pre-exported USAI_API_KEY (e.g. CI). + if ! _acq_msb_bind_one "$_arrn" "$_namesn" usai "$_name" "$_usai_env" "$_usai_host"; then + if [ -n "${USAI_API_KEY:-}" ]; then + eval "$_arrn+=(--secret \"\${_usai_env}@\${_usai_host}\")" + acq_debug "msb secret: binding ${_usai_env}@${_usai_host} (from env)" + fi fi fi @@ -1005,7 +1007,8 @@ _acq_msb_bind_secrets_into() { $(acq_secret_meta_list "$_name") EOF fi - elif [ -n "${USAI_API_KEY:-}" ]; then + elif { ! command -v acq_usai_provider_selected >/dev/null 2>&1 || acq_usai_provider_selected; } \ + && [ -n "${USAI_API_KEY:-}" ]; then eval "$_arrn+=(--secret \"${USAI_PROVIDER_KEY_ENV}@\${ACQ_MSB_USAI_HOST}\")" fi } @@ -1059,7 +1062,11 @@ EOF # matter for start. acq_backend_start() { local _name="$1" - if command -v acq_provider_facts_load_from_kit_or_fallback >/dev/null 2>&1; then + if command -v acq_provider_selection_load >/dev/null 2>&1; then + acq_provider_selection_load msb "$_name" || return 1 + fi + if command -v acq_provider_facts_load_from_kit_or_fallback >/dev/null 2>&1 \ + && { ! command -v acq_usai_provider_selected >/dev/null 2>&1 || acq_usai_provider_selected; }; then acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" fi local _start_secret_flags=() _start_secret_names=() @@ -5658,6 +5665,9 @@ _acq_msb_heal_kit_set() { acq_backend_ensure_kits_applied() { local name="$1" + if command -v acq_provider_selection_load >/dev/null 2>&1; then + acq_provider_selection_load msb "$name" || return 1 + fi # START-IF-STOPPED (ADR-0017). This heal loop drives `msb exec` against # the guest for every kit (feature-probe, file drops, startup re-run). Those # exec calls FAIL against a STOPPED guest, so a stopped sandbox must be started diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index 43170bdd..b7f7c9cc 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -1191,12 +1191,17 @@ acq_backend_ensure_kits_applied() { # _acq_sbx_print_recreate_notice). Without this reset, a second heal in the # same process would suppress the notice. _ACQ_SBX_RECREATE_NOTICE_SHOWN=0 + if command -v acq_provider_selection_load >/dev/null 2>&1; then + acq_provider_selection_load sbx "$name" || return 1 + fi _acq_sbx_ensure_kit_sources_allowed # Neutral kits must be translated to local sbx-v2 kit dirs before sbx kit add. - local usai_local playbook_local zscaler_local - usai_local=$(_acq_sbx_translate_kit "$USAI_KIT") + local usai_local="" playbook_local zscaler_local + if ! command -v acq_usai_provider_selected >/dev/null 2>&1 || acq_usai_provider_selected; then + usai_local=$(_acq_sbx_translate_kit "$USAI_KIT") + fi playbook_local=$(_acq_sbx_translate_kit "$PLAYBOOK_KIT") zscaler_local=$(_acq_sbx_translate_kit "$ZSCALER_KIT") @@ -1217,7 +1222,8 @@ acq_backend_ensure_kits_applied() { fi # 2) USAi provider kit - if [ "$force" = "1" ] || _acq_sbx_kit_feature_absent "$name" "test -f '$USAI_KIT_CONFIG_PATH' && echo present"; then + if { ! command -v acq_usai_provider_selected >/dev/null 2>&1 || acq_usai_provider_selected; } \ + && { [ "$force" = "1" ] || _acq_sbx_kit_feature_absent "$name" "test -f '$USAI_KIT_CONFIG_PATH' && echo present"; }; then echo "acq: '$name' is missing the USAi kit; injecting with 'sbx kit add'..." >&2 _kadd_rc=0; _acq_sbx_kit_add "$name" "$usai_local" || _kadd_rc=$? case $_kadd_rc in diff --git a/docs/howto/acq.md b/docs/howto/acq.md index e6373591..a3f5b20d 100644 --- a/docs/howto/acq.md +++ b/docs/howto/acq.md @@ -3,7 +3,7 @@ title: "acq How-To Guide" description: "Detailed how-to for acq, the pluggable-backend wrapper for agentic-coding-quickstart" status: canonical tier: 2 -last_updated: "2026-09-30" +last_updated: "2026-10-25" audience: "developers" keywords: ["acq", "backend", "sbx", "msb", "howto", "sandbox"] related_files: ["docs/BACKEND_GUIDE.md", "docs/CONCEPTS.md", "docs/howto/msb.md", "docs/howto/sbx.md", "docs/adr/0010-acq-pluggable-backends.md", "docs/adr/0011-msb-backend-and-neutral-kits.md"] @@ -554,6 +554,25 @@ acq create opencode --kit ./kit-a --kit git+https://github.com/acme/kits.git#ref neutral `hybrid/v1` kit is converted to the active backend's format), so they work with any backend — they are **not** forwarded raw to the backend CLI. +### Provider kits + +New sandboxes include the USAi provider kit by default, unchanged from earlier +releases. To create one without any provider kit, pass `--no-provider-kits` to +`run` or `create` before `--`. This is authoritative, so `acq` does not require +or bind a USAi credential and projects an empty provider generation for the +guest. The choice is recorded with the sandbox and remains in effect when its +kits are healed or it is resumed. + +```bash +acq run opencode --no-provider-kits /path/to/project +acq create shell --no-provider-kits /path/to/project +``` + +For scripted selection, `ACQ_PROVIDER_KITS` accepts a whitespace-separated list +of kit refs, following the same format as `ACQ_EXTRA_KITS`. `ACQ_PROVIDER_KITS=''` +also selects the empty set, but `--no-provider-kits` is the portable form when +configuration layers cannot preserve an empty environment value. + --- ## Progress output diff --git a/test/bats/140-opencode-migration-gate.bats b/test/bats/140-opencode-migration-gate.bats index 2be0628b..a49b8eba 100644 --- a/test/bats/140-opencode-migration-gate.bats +++ b/test/bats/140-opencode-migration-gate.bats @@ -67,6 +67,29 @@ _create_line() { printf '%s\n' "$(cat "$CALLS")" | grep "^$1 create"; } assert_equal "${KITS[7]}" "/extra/cli-two" } +@test "provider omission(msb): skips USAi gate and binds no USAi secret before create" { + local proj="$STUBDIR/no-provider-project" + mkdir -p "$proj" + + run env ACQ_BACKEND=msb ACQ_NO_PROMPT=1 "$ACQ" create opencode --no-provider-kits "$proj" + + assert_success + refute_output --partial 'no USAi API key stored' + refute_regex "$(cat "$CALLS")" 'USAI_API_KEY@api\.gsa\.usai\.gov' + refute_regex "$(cat "$CALLS")" 'acq-kits/usai-provider' +} + +@test "provider omission: invalid selection fails before either backend create" { + local proj="$STUBDIR/invalid-provider-project" + mkdir -p "$proj" + + run env ACQ_BACKEND=msb ACQ_PROVIDER_KITS="$STUBDIR/invalid-provider-kit" ACQ_NO_PROMPT=1 "$ACQ" create shell "$proj" + + assert_failure + assert_output --partial 'could not fetch kit' + refute_regex "$(cat "$CALLS")" '^msb create' +} + _mk_opencode_agent_kit() { # PATH [schema] [kit-name] [agent-name] [entrypoint] mkdir -p "$1" cat >"$1/spec.yaml" < Date: Sun, 25 Oct 2026 03:39:52 +0000 Subject: [PATCH 20/63] fix(providers): support empty selection under nounset Use Bash 3.2-safe empty-array expansion when no provider kits are selected. Co-authored-by: OpenCode Agent --- acq.backends/common.sh | 2 +- test/bats/147-provider-input-projection.bats | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 0e0ed1f3..e8efb20a 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -855,7 +855,7 @@ _acq_selected_provider_kit_refs() { elif [ -n "$ACQ_PROVIDER_KITS" ]; then split_noglob provider_refs "$ACQ_PROVIDER_KITS" fi - for ref in "${provider_refs[@]}"; do + for ref in ${provider_refs[@]+"${provider_refs[@]}"}; do printf '%s\n' "$ref" done } diff --git a/test/bats/147-provider-input-projection.bats b/test/bats/147-provider-input-projection.bats index 911f5061..b19d103e 100644 --- a/test/bats/147-provider-input-projection.bats +++ b/test/bats/147-provider-input-projection.bats @@ -352,8 +352,11 @@ EOF @test "projection: explicit empty selection creates a provider-free generation" { local root generation + # shellcheck disable=SC2034 # _build_kit_list reads these globals ACQ_PROVIDER_KITS="" + # shellcheck disable=SC2034 # _build_kit_list reads these globals ACQ_PROVIDER_KITS_FROM_ENV=1 + # shellcheck disable=SC2034 # _build_kit_list reads these globals ACQ_PROVIDER_KITS_FROM_CLI="" _build_kit_list opencode From 1d243dc0dd4008a9b94a2f6c2d0bc1a32f6207e3 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 03:46:02 +0000 Subject: [PATCH 21/63] docs: record authoritative empty provider matrix Record successful no-provider SBX and MSB validation evidence. Co-authored-by: OpenCode Agent --- docs/VERIFY_PROVIDER_ADOPTION.md | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/VERIFY_PROVIDER_ADOPTION.md b/docs/VERIFY_PROVIDER_ADOPTION.md index 9f430fdf..00c8f404 100644 --- a/docs/VERIFY_PROVIDER_ADOPTION.md +++ b/docs/VERIFY_PROVIDER_ADOPTION.md @@ -58,7 +58,7 @@ actual command output. | Fresh OpenCode sandbox create + name reattach | PASS | PASS | 2026-10-25: OpenCode sandboxes were created, then reattached by derived name. SBX recorded 13 passes; MSB recorded 18 passes. | | Fresh OpenCode process starts | PASS | PASS | `acq run -- --version` succeeded on both backends. | | `acq exec NAME -- opencode models` consumes projected provider input | PASS | PASS | 2026-10-24: `opencode models` listed projected `usai/` models on both backends. | -| Remove projected provider input and restart OpenCode | BLOCKED | BLOCKED | Record expected actionable failure or documented fallback | +| Fresh authoritative-empty provider selection has no USAi models | PASS | PASS | 2026-10-25: `--no-provider-kits` created fresh OpenCode sandboxes with no USAi kit, credential gate, or models on either backend. | | Malformed provider input is rejected before startup | PASS | PASS | 2026-10-25: both backends rejected malformed `provider-input.json` before backend creation with `acq: invalid provider input manifest ... invalid JSON at line 1 column 2`. | | `scripts/verify-backends --only ` | PASS | PASS | Final combined branch: SBX transcript `tmp/provider-neutral-validation.TCY2fZvE/sbx.log` (13 pass, 0 fail, 1 skip); MSB transcript `tmp/provider-neutral-validation.8MP6vbkX/msb.log` (18 pass, 0 fail, 1 skip). | @@ -116,9 +116,8 @@ passed: 17 failed: 0 skipped: 1 warnings: 0 The skipped row on each backend was the private playbook fetch because `gh` was not logged in on the host. It does not affect provider-neutral OpenCode evidence. -Provider removal remains `BLOCKED` until the supported host-side omission -capability is implemented; this document does not infer it from the successful -startup run. +Provider omission is independently observed below; it is not inferred from the +successful default-provider startup run. ### Final Live Evidence with Model Listing (2026-10-25) @@ -140,8 +139,8 @@ tmp/provider-neutral-validation.TCY2fZvE/results.tsv tmp/provider-neutral-validation.8MP6vbkX/results.tsv ``` -Provider removal remains `BLOCKED`; it requires a supported authoritative-empty -provider selection and is not inferred from the successful positive matrix. +Provider omission evidence is recorded below; it is not inferred from the +successful positive-provider matrix. ### Malformed Provider Input Evidence (2026-10-25) @@ -162,6 +161,26 @@ tmp/malformed-provider-validation.GQGSRccw/results.tsv Both rows are `PASS`; no backend create command was reached. +### Authoritative Empty Provider Evidence (2026-10-25) + +The supported host-side `--no-provider-kits` selection created fresh OpenCode +sandboxes with an immutable empty provider generation on both backends. The +applied kit lists omitted `usai-provider`, no USAi credential gate ran, and +`opencode models` returned successfully with no USAi model, endpoint, or +credential marker. + +```text +SBX: PASS +MSB: PASS +``` + +Result files: + +```text +tmp/provider-omission-validation.4AJo3ZKd/results.tsv +tmp/provider-omission-validation.fO13wRpW/results.tsv +``` + ### Literal Credential Flow (2026-10-25) The literal host command completed on both backends from the final validation From 522c6992c85120716e162a0ab534d0b47792d32f Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 03:56:07 +0000 Subject: [PATCH 22/63] test(acq): refresh provider selection CI expectations Silence dynamic-global ShellCheck checks and tolerate current interactive environment tokens. Co-authored-by: OpenCode Agent --- test/bats/140-opencode-migration-gate.bats | 2 +- test/bats/30-dispatch-routing.bats | 2 +- test/bats/50-kit-list-completeness.bats | 7 +++++++ test/bats/85-kit-provenance.bats | 2 ++ 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/test/bats/140-opencode-migration-gate.bats b/test/bats/140-opencode-migration-gate.bats index a49b8eba..a1a252cc 100644 --- a/test/bats/140-opencode-migration-gate.bats +++ b/test/bats/140-opencode-migration-gate.bats @@ -364,7 +364,7 @@ SPEC bash -c '. "$1/acq.backends/common.sh"; acq_host_config_write msb migbox workspace /workspace; exec "$2" shell migbox' \ _ "$REPO_ROOT" "$ACQ" assert_success - assert_regex "$(cat "$CALLS")" 'msb exec -t -u agent -w /workspace -e TERM=xterm-256color -e COLORTERM=truecolor -e SHELL=/bin/bash migbox -- /bin/bash -l' + assert_regex "$(cat "$CALLS")" 'msb exec -t -u agent -w /workspace -e TERM=xterm-256color -e (COLORTERM=truecolor -e )?SHELL=/bin/bash migbox -- /bin/bash -l' } @test "migration gate(sbx): run opencode keeps native clone, workspace markers, image template, and kit order" { diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index e4288c64..1ed6231d 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -103,7 +103,7 @@ FACTS run env TERM= ACQ_BACKEND=msb ACQ_DEBUG=1 GIT_ALLOW_PROTOCOL=none \ STUB_AGENT_USER_READY=1 "$ACQ" run mybox assert_success - assert_regex "$(cat "$CALLS")" 'msb exec -t -u agent -w /home/agent -e COLORTERM=truecolor -e SHELL=/bin/sh mybox -- /bin/sh -l' + assert_regex "$(cat "$CALLS")" 'msb exec -t -u agent -w /home/agent -e (COLORTERM=truecolor -e )?SHELL=/bin/sh mybox -- /bin/sh -l' refute_output --partial 'provider facts' } diff --git a/test/bats/50-kit-list-completeness.bats b/test/bats/50-kit-list-completeness.bats index 4508f4ca..0fa3d120 100644 --- a/test/bats/50-kit-list-completeness.bats +++ b/test/bats/50-kit-list-completeness.bats @@ -29,7 +29,9 @@ load 'helper' @test "provider selection: unset preserves the USAi default" { load_acq + # shellcheck disable=SC2034 # _build_kit_list reads this global dynamically unset ACQ_PROVIDER_KITS_FROM_ENV ACQ_PROVIDER_KITS_FROM_CLI + # shellcheck disable=SC2034 # _build_kit_list reads this global dynamically ACQ_PROVIDER_KITS="" _build_kit_list @@ -39,8 +41,11 @@ load 'helper' @test "provider selection: explicit empty omits every provider kit" { load_acq + # shellcheck disable=SC2034 # _build_kit_list reads this global dynamically ACQ_PROVIDER_KITS="" + # shellcheck disable=SC2034 # _build_kit_list reads this global dynamically ACQ_PROVIDER_KITS_FROM_ENV=1 + # shellcheck disable=SC2034 # _build_kit_list reads this global dynamically ACQ_PROVIDER_KITS_FROM_CLI="" _build_kit_list @@ -51,7 +56,9 @@ load 'helper' @test "provider selection: an explicit ref replaces the default provider ref" { load_acq + # shellcheck disable=SC2034 # _build_kit_list reads this global dynamically ACQ_PROVIDER_KITS="$STUBDIR/missing-provider-kit" + # shellcheck disable=SC2034 # _build_kit_list reads this global dynamically ACQ_PROVIDER_KITS_FROM_ENV=1 _build_kit_list diff --git a/test/bats/85-kit-provenance.bats b/test/bats/85-kit-provenance.bats index 236d3328..6aad713a 100644 --- a/test/bats/85-kit-provenance.bats +++ b/test/bats/85-kit-provenance.bats @@ -48,7 +48,9 @@ _seed_stale_provenance() { # BACKEND NAME acq_provenance_write sbx defaultproviders assert_equal "$(acq_provenance_field sbx defaultproviders provider_selection)" "default" + # shellcheck disable=SC2034 # acq_provenance_write reads these globals dynamically ACQ_PROVIDER_KITS="" + # shellcheck disable=SC2034 # acq_provenance_write reads these globals dynamically ACQ_PROVIDER_KITS_FROM_ENV=1 acq_provenance_write sbx emptyproviders assert_equal "$(acq_provenance_field sbx emptyproviders provider_selection)" "explicit" From f0bb5d612cf698995a793e83ce0873f78453116f Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 04:05:21 +0000 Subject: [PATCH 23/63] test(msb): isolate external signal fixture state Use a per-test sandbox, lock, and PID path so parallel workers cannot share signal lifecycle state. Co-authored-by: OpenCode Agent --- test/bats/147-provider-input-projection.bats | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/test/bats/147-provider-input-projection.bats b/test/bats/147-provider-input-projection.bats index b19d103e..9bb04b62 100644 --- a/test/bats/147-provider-input-projection.bats +++ b/test/bats/147-provider-input-projection.bats @@ -762,9 +762,11 @@ EOF # directly, giving $! the real executable PID so TERM reaches it. @test "msb remove TERM: external executable survives initial signal, second lock blocked until exit" { - local script="$STUBDIR/msb-remove-term.sh" root msb_bin - root=$(acq_provider_input_root msb msbsignalbox) + local script="$STUBDIR/msb-remove-term-${BATS_TEST_NUMBER}.sh" root msb_bin sandbox pid_file + sandbox="msbsignalbox-${BATS_TEST_NUMBER}" + root=$(acq_provider_input_root msb "$sandbox") msb_bin="$STUBDIR/slow-msb-remove" + pid_file="$STUBDIR/slow-msb-${BATS_TEST_NUMBER}.pid" # Create the external slow-msb executable: traps TERM (ignores it initially), # writes its own PID, kills its PPID (= the transaction subshell) with TERM, # then stays alive 2 s so the guardian must wait before releasing the lock. @@ -782,7 +784,7 @@ MSB_SLOW #!/usr/bin/env bash set -eu export ACQ_PROVENANCE_DIR="$ACQ_PROVENANCE_DIR" -export MSB_PID_FILE="$STUBDIR/slow-msb.pid" +export MSB_PID_FILE="$pid_file" # Prepend the slow-msb as \`msb\` in PATH. mkdir -p "$STUBDIR/msb-bin" ln -sf "$msb_bin" "$STUBDIR/msb-bin/msb" @@ -802,16 +804,16 @@ acq_backend_exists() { return 1; } # sandbox confirmed gone after remove _acq_msb_confirm_absent() { return 0; } # confirms gone for the rc-ne-0 path # Run terminate in background so we can probe lock state while it runs. rc=0 -acq_backend_terminate msbsignalbox & tx=\$! +acq_backend_terminate "$sandbox" & tx=\$! # Wait for slow-msb to register its PID (confirms the background executable started). for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do - [ -s "$STUBDIR/slow-msb.pid" ] && break + [ -s "$pid_file" ] && break sleep 0.1 done -[ -s "$STUBDIR/slow-msb.pid" ] || { echo "slow-msb never started" >&2; exit 1; } -msb_pid=\$(cat "$STUBDIR/slow-msb.pid") +[ -s "$pid_file" ] || { echo "slow-msb never started" >&2; exit 1; } +msb_pid=\$(cat "$pid_file") # Verify the lock is still held while the executable is alive. -if ACQ_PROVIDER_INPUT_LOCK_RETRIES=1 acq_provider_input_lock_acquire msb msbsignalbox >"$STUBDIR/lock-retry.out" 2>&1; then +if ACQ_PROVIDER_INPUT_LOCK_RETRIES=1 acq_provider_input_lock_acquire msb "$sandbox" >"$STUBDIR/lock-retry.out" 2>&1; then echo "lock acquired while msb executable was alive" >&2 exit 1 fi @@ -819,7 +821,7 @@ grep -Fq "live pid" "$STUBDIR/lock-retry.out" || { cat "$STUBDIR/lock-retry.out" # Wait for the background transaction to complete. wait "\$tx" || rc=\$? # After the transaction exits the lock must be released. -lock=\$(acq_provider_input_lock_acquire msb msbsignalbox) +lock=\$(acq_provider_input_lock_acquire msb "$sandbox") acq_provider_input_lock_release "\$lock" printf '%s\n' "rc=\$rc lock-cleared" EOF From d9499e5bbb60f3f3416b21623a152644e1bbd7c9 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 04:56:07 +0000 Subject: [PATCH 24/63] fix(sbx): move extra kit state to host Replace guest-writable extra-kit markers with host-authoritative state for SBX create and heal paths. Refs: GSA-TTS/agentic-coding-quickstart#636 Co-authored-by: OpenCode [gpt-5.6-terra] --- acq.backends/common.sh | 3 +- acq.backends/sbx.sh | 57 ++++++++++++------------------ test/bats/90-sbx-startup-kit.bats | 58 ++++++++++++++++++++++--------- 3 files changed, 65 insertions(+), 53 deletions(-) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index e8efb20a..341c4d98 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -3184,7 +3184,8 @@ acq_host_config_clear_instance_state() { case "$dir" in ""|/|/*/) return 0 ;; esac rm -f "$dir"/install-* "$dir"/agent-installed-* \ "$dir"/agent-user-ready \ - "$dir"/workspace "$dir"/ssh-auth-sock "$dir"/generic-opencode 2>/dev/null || true + "$dir"/workspace "$dir"/ssh-auth-sock "$dir"/generic-opencode \ + "$dir"/extra-kits 2>/dev/null || true return 0 } diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index b7f7c9cc..45585c6a 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -701,7 +701,7 @@ _acq_backend_provision_unlocked() { fi fi acq_workspace_record_write sbx "$name" "$_primary_ws" || true - _acq_sbx_seed_extra_kit_marker "$name" + _acq_sbx_seed_extra_kit_record "$name" # Backend parity (ADR-0030): sbx DELIVERS ~/.rc.d/*.sh via kit files[] but, # unlike msb's .profile bridge, nothing SOURCES them at login. Write the same # bridge here at create time so a kit-dropped snippet actually runs. This is a @@ -740,48 +740,36 @@ acq_backend_provision() { } # --------------------------------------------------------------------------- -# _acq_sbx_seed_extra_kit_marker — write ~/.acq-extra-kits at CREATE +# _acq_sbx_seed_extra_kit_record — write host extra-kits state at CREATE # --------------------------------------------------------------------------- -# The re-attach heal decides whether an extra kit is already applied by reading -# this marker (see acq_backend_ensure_kits_applied step 4); previously only the -# heal path wrote it, so a sandbox created WITH ACQ_EXTRA_KITS / --kit refs never -# got the marker and every re-attach re-attempted every extra kit (and, under -# sbx 0.38, warn-failed each time). Write the same marker here so the create and -# heal paths agree: one ORIGINAL ref per line (stable across runs), matching the -# heal's append form (so the heal's exact-line match detects it). Built-in kits -# are tracked by feature-probe, not the marker, so they are not listed here. +# The re-attach heal decides whether an extra kit is already applied from this +# host record (see acq_backend_ensure_kits_applied step 4). A guest marker is not +# a trust boundary because a passwordless-sudo guest can forge it. Recording the +# original ref once per line lets create and heal agree without trusting guest +# state. Built-in kits are tracked by feature-probe and are not listed here. # # ACQ_CLI_KITS (`--kit` on the CLI) are recorded for completeness even though the # current sbx heal loop only re-drives ACQ_EXTRA_KITS. This is safe, not a # functional suppression input: on sbx, `--kit` refs are baked in as create-time # `sbx create --kit` flags and never re-applied mid-life (unlike msb, which folds -# ACQ_CLI_KITS into its heal). The marker is a suppression list, so a recorded CLI +# ACQ_CLI_KITS into its heal). The record is a suppression list, so a recorded CLI # ref can only ever be matched if the SAME ref is also supplied via ACQ_EXTRA_KITS # — and in that degenerate case skipping re-apply is the correct outcome (the kit # was already applied at create). So the CLI entries are a truthful record of what # was applied at create; they can never wrongly suppress a needed apply. -_acq_sbx_seed_extra_kit_marker() { - local name="$1" _mkk +_acq_sbx_seed_extra_kit_record() { + local name="$1" _mkk _records="" local _mk=() [ -n "$ACQ_EXTRA_KITS" ] && split_noglob _mk "$ACQ_EXTRA_KITS" [ "${#ACQ_CLI_KITS[@]}" -gt 0 ] && _mk+=("${ACQ_CLI_KITS[@]}") - # Nothing to record — skip the (potentially slow) exec-ready wait entirely. + # Nothing to record. A later heal treats absent host state as untrusted and + # applies any requested extra kit rather than consulting guest-writable state. [ "${#_mk[@]}" -gt 0 ] || return 0 - # A freshly-created sandbox is not immediately exec-able; the heal path guards - # its probes the same way. Without this, the marker write can race the sandbox - # becoming ready, silently write nothing, and reintroduce the re-attempt bug. - if ! _acq_sbx_wait_for_exec_ready "$name"; then - echo "acq: warning: sandbox '$name' not exec-ready; could not record the" \ - "extra-kit marker (~/.acq-extra-kits). Re-attach may re-attempt extra kits." >&2 - return 0 - fi for _mkk in ${_mk[@]+"${_mk[@]}"}; do - if ! sbx exec "$name" -- sh -c 'printf "%s\n" "$0" >> "$HOME/.acq-extra-kits"' "$_mkk" \ - /dev/null 2>&1; then - echo "acq: warning: could not record extra kit '$_mkk' in ~/.acq-extra-kits" \ - "for '$name'; re-attach may re-attempt it." >&2 - fi + _records="${_records}${_records:+$'\n'}${_mkk}" done + acq_host_config_write sbx "$name" extra-kits "$_records" || \ + echo "acq: warning: could not record extra kits for '$name'; re-attach may re-attempt them." >&2 } # --------------------------------------------------------------------------- @@ -1040,10 +1028,8 @@ _acq_backend_terminate_unlocked() { if [ "$rc" -ne 0 ] && ! _acq_sbx_confirm_absent "$1"; then return "$rc" fi - # Remove any host-authoritative config dir for this sandbox (ADR-0035). - # sbx does not use the guest /var/lib/acq markers today (its parity mechanism - # is `sbx run --name` + ~/.acq-extra-kits), so this is a defensive no-op unless - # a future sbx path writes host config. Best-effort, never blocks removal. + # Remove the host-authoritative config dir, including extra-kit suppression + # state. Best-effort, never blocks removal. acq_host_config_remove sbx "$1" || true acq_provider_input_remove sbx "$1" || true return "$rc" @@ -1296,12 +1282,12 @@ acq_backend_ensure_kits_applied() { _acq_sbx_apply_git_identity_kit "$name" [ "${_ACQ_SBX_LAST_KIT_ADD_SUCCEEDED:-0}" = "1" ] && healed=1 - # 4) Extra kits (tracked by marker file). Extra kits may be neutral or already - # sbx-v2; _acq_sbx_translate_kit handles both. The marker records one + # 4) Extra kits (tracked by host config). Extra kits may be neutral or already + # sbx-v2; _acq_sbx_translate_kit handles both. The host record contains one # ORIGINAL ref per line (stable across runs), not the translated local dir. # Extra-kit failures do not affect the built-in bundle's provenance verdict. local applied k local_extra - applied=$(sbx exec "$name" -- sh -c 'cat "$HOME/.acq-extra-kits" 2>/dev/null' /dev/null || true) + applied=$(acq_host_config_read sbx "$name" extra-kits) local _extras=() [ -n "$ACQ_EXTRA_KITS" ] && split_noglob _extras "$ACQ_EXTRA_KITS" for k in ${_extras[@]+"${_extras[@]}"}; do @@ -1317,7 +1303,8 @@ acq_backend_ensure_kits_applied() { case $_kadd_rc in 0) healed=1 - sbx exec "$name" -- sh -c 'printf "%s\n" "$0" >> "$HOME/.acq-extra-kits"' "$k" /dev/null 2>&1 || true + acq_host_config_append sbx "$name" extra-kits "$k" || \ + echo "acq: warning: could not record extra kit '$k' for '$name'; re-attach may re-attempt it." >&2 ;; 3) _acq_sbx_print_recreate_notice "$name" ;; *) echo "acq: warning: 'sbx kit add' (extra kit) failed for '$name' (see error above)." >&2 ;; diff --git a/test/bats/90-sbx-startup-kit.bats b/test/bats/90-sbx-startup-kit.bats index 98f024ef..b12f4f31 100644 --- a/test/bats/90-sbx-startup-kit.bats +++ b/test/bats/90-sbx-startup-kit.bats @@ -4,7 +4,7 @@ # (ADR-0025) # # sbx 0.38 startup-kit refusal handling, stale-probe footprint, and the -# create-time extra-kit marker. All offline via the stubbed sbx. Each @test +# host-authoritative extra-kit record. All offline via the stubbed sbx. Each @test # plants a purpose-built sbx stub, so the stubs live inline. # # shellcheck shell=bats @@ -277,7 +277,7 @@ SPEC refute_regex "$bridgelines" 'kit add' } -@test "provision(sbx): ACQ_EXTRA_KITS is marked into ~/.acq-extra-kits at create" { +@test "provision(sbx): ACQ_EXTRA_KITS is recorded in host config at create" { : > "$CALLS" # Subshell, NOT `bash -c`: acq_backend_provision is a sourced function, which # a fresh bash never sees (the pre-#381 suite masked the resulting 127). @@ -286,12 +286,12 @@ SPEC acq_test_stub_provider_input_boundary acq_backend_provision markerbox shell /tmp ) >/dev/null 2>&1 || true - local log; log=$(cat "$CALLS") - assert_regex "$log" '\.acq-extra-kits' - assert_regex "$log" 'some-extra-kit' + local record; record=$(acq_host_config_read sbx markerbox extra-kits) + assert_regex "$record" 'some-extra-kit' + refute_regex "$(cat "$CALLS")" '\.acq-extra-kits' } -@test "provision(sbx): a CLI --kit ref (ACQ_CLI_KITS) is also marked at create" { +@test "provision(sbx): a CLI --kit ref (ACQ_CLI_KITS) is also recorded at create" { : > "$CALLS" # Subshell, NOT `bash -c` (see the ACQ_EXTRA_KITS marker test above). ( @@ -300,10 +300,10 @@ SPEC acq_test_stub_provider_input_boundary acq_backend_provision climarkerbox shell /tmp ) >/dev/null 2>&1 || true - assert_regex "$(cat "$CALLS")" 'cli-kit-x' + assert_regex "$(acq_host_config_read sbx climarkerbox extra-kits)" 'cli-kit-x' } -@test "provision(sbx): a FAILED create writes no extra-kit marker" { +@test "provision(sbx): a FAILED create writes no host extra-kit record" { cat >"$STUBDIR/sbx" <<'STUB' #!/usr/bin/env bash { printf 'sbx'; for a in "$@"; do printf ' %s' "$a"; done; printf '\n'; } >>"$CALLS" @@ -320,10 +320,10 @@ STUB export ACQ_EXTRA_KITS="git+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=deadbeef&dir=fail-extra-kit" acq_backend_provision failbox shell /tmp >/dev/null 2>&1 || true ' - refute_regex "$(cat "$CALLS")" '\.acq-extra-kits' + assert_equal "$(acq_host_config_read sbx failbox extra-kits)" '' } -@test "heal(sbx): a marked extra kit is not re-applied (no refusal noise)" { +@test "heal(sbx): a forged guest extra-kit marker cannot suppress healing" { cat >"$STUBDIR/sbx" <<'STUB' #!/usr/bin/env bash { printf 'sbx'; for a in "$@"; do printf ' %s' "$a"; done; printf '\n'; } >>"$CALLS" @@ -334,6 +334,7 @@ case "${1:-}" in snippet=""; prev="" for a in "$@"; do [ "$prev" = "-c" ] && { snippet="$a"; break; }; prev="$a"; done case "$snippet" in + # A sudo-capable guest can forge this old marker. The host must not query it. *acq-extra-kits*) printf '%s\n' "git+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=deadbeef&dir=some-extra-kit" ;; *present*) printf 'present\n' ;; *) exit 0 ;; @@ -344,14 +345,37 @@ case "${1:-}" in esac STUB chmod +x "$STUBDIR/sbx" - printf 'skipbox\n' > "$STUBDIR/.sandbox_list" - run bash -c ' - ACQ_EXTRA_KITS="git+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=deadbeef&dir=some-extra-kit" \ - acq_backend_ensure_kits_applied skipbox 2>&1 || true - ' + printf 'forgedbox\n' > "$STUBDIR/.sandbox_list" + ACQ_EXTRA_KITS='git+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=deadbeef&dir=some-extra-kit' \ + run acq_backend_ensure_kits_applied forgedbox local log; log=$(cat "$CALLS") - refute_regex "$log" 'sbx kit add skipbox' - refute_output --partial 'cannot extend a live sandbox' + assert_regex "$log" 'sbx kit add forgedbox' + refute_regex "$log" '\.acq-extra-kits' +} + +@test "heal(sbx): a matching host extra-kit record suppresses re-apply" { + cat >"$STUBDIR/sbx" <<'STUB' +#!/usr/bin/env bash +{ printf 'sbx'; for a in "$@"; do printf ' %s' "$a"; done; printf '\n'; } >>"$CALLS" +case "${1:-}" in + version) printf 'sbx version: v0.39.0 abc123\n' ;; + ls) [ -f "$STUBDIR/.sandbox_list" ] && cat "$STUBDIR/.sandbox_list"; exit 0 ;; + exec) + snippet=""; prev="" + for a in "$@"; do [ "$prev" = "-c" ] && { snippet="$a"; break; }; prev="$a"; done + case "$snippet" in *present*) printf 'present\n' ;; *) exit 0 ;; esac ;; + kit) [ "${2:-}" = "add" ] && { printf 'unexpected kit apply\n' >&2; exit 1; }; exit 0 ;; + settings) exit 0 ;; + *) exit 0 ;; +esac +STUB + chmod +x "$STUBDIR/sbx" + local kit='git+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=deadbeef&dir=some-extra-kit' + printf 'hostbox\n' > "$STUBDIR/.sandbox_list" + acq_host_config_write sbx hostbox extra-kits "$kit" + ACQ_EXTRA_KITS="$kit" run acq_backend_ensure_kits_applied hostbox + refute_regex "$(cat "$CALLS")" 'sbx kit add hostbox' + refute_output --partial 'unexpected kit apply' } @test "agent-kit-heal(sbx): forced refresh includes recorded agent kit" { From 384c3f686d1d29e4eea6f4223cc2ea0a603721f6 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Mon, 26 Oct 2026 03:44:42 +0000 Subject: [PATCH 25/63] test(verify): bind provider model check to the projected snapshot identity Replace the bare `opencode models | grep -q "^usai/"` with a comparison of the projected snapshot's model IDs against both the rendered OpenCode config's model keys for the provider and the `opencode models` output. Missing or unexpected IDs fail and print the difference. Offline bats cases cover exact match, stale, missing, extra and empty-snapshot inputs plus the guest collection script. Not yet exercised on a live SBX or MSB host. Co-authored-by: OpenCode Agent --- scripts/verify-backends | 59 +++++++++++++++-- test/bats/146-provider-verification.bats | 81 ++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/scripts/verify-backends b/scripts/verify-backends index 4cabad08..aebec43c 100755 --- a/scripts/verify-backends +++ b/scripts/verify-backends @@ -1391,6 +1391,57 @@ verify_oci_kit() { fi } +# Identity-bound provider model check. The expected model IDs come from the +# read-only projected snapshot the sandbox was given +# ($ACQ_PROVIDER_INPUT_ROOT/models/providers//snapshot.json, the same +# projection the facts check above reads). The guest script prints one +# "SNAPSHOT ", "CONFIG " or "CLI " line per model: the snapshot IDs, +# the model keys of the rendered global OpenCode config for that provider, and +# the `opencode models` output for that provider. Both observed sets must equal +# the snapshot set exactly. Not yet exercised on a live SBX/MSB host. +PROVIDER_MODEL_IDENTITY_SCRIPT=$(cat <<'EOS' +prov=$1 +node -e ' +const fs = require("fs"), path = require("path"); +const prov = process.argv[1]; +const snap = JSON.parse(fs.readFileSync(path.join(process.env.ACQ_PROVIDER_INPUT_ROOT, "models/providers", prov, "snapshot.json"), "utf8")); +for (const m of snap.models) console.log("SNAPSHOT " + m.id); +const file = path.join(process.env.HOME, ".config/opencode/opencode.jsonc"); +const text = fs.readFileSync(file, "utf8").split("\n").filter((l) => !l.startsWith("//")).join("\n"); +const cfg = JSON.parse(text); +for (const id of Object.keys((((cfg.provider || {})[prov]) || {}).models || {})) console.log("CONFIG " + id); +' "$prov" || exit 1 +opencode models | sed -n "s|^${prov}/|CLI |p" +EOS +) + +# verify_provider_model_identity BACKEND PROVIDER OUTPUT — compare the three +# sets in OUTPUT (see PROVIDER_MODEL_IDENTITY_SCRIPT); fail on missing or +# unexpected IDs and print the differences. +verify_provider_model_identity() { + local backend="$1" provider="$2" out="$3" kind + local expected observed missing extra ok=1 + expected=$(printf '%s\n' "$out" | tr -d '\r' | sed -n 's/^SNAPSHOT //p' | LC_ALL=C sort -u | sed '/^$/d') + if [ -z "$expected" ]; then + fail "${backend}: projected ${provider} snapshot lists no models (cannot bind identity)" + dump "model identity output" "$out" + return 1 + fi + for kind in CONFIG CLI; do + observed=$(printf '%s\n' "$out" | tr -d '\r' | sed -n "s/^${kind} //p" | LC_ALL=C sort -u | sed '/^$/d') + missing=$(LC_ALL=C comm -23 <(printf '%s\n' "$expected") <(printf '%s\n' "$observed") | sed '/^$/d') + extra=$(LC_ALL=C comm -13 <(printf '%s\n' "$expected") <(printf '%s\n' "$observed") | sed '/^$/d') + if [ -n "$missing" ] || [ -n "$extra" ]; then + ok=0 + fail "${backend}: ${kind} ${provider} models differ from the projected snapshot" + dump "missing from ${kind} (expected by snapshot)" "$missing" + dump "unexpected in ${kind} (not in snapshot)" "$extra" + fi + done + [ "$ok" -eq 1 ] || return 1 + pass "${backend}: rendered config and opencode models match the projected ${provider} snapshot ($(printf '%s\n' "$expected" | wc -l | tr -d ' ') models)" +} + # verify_agent_install BACKEND — provision an `opencode` sandbox (not `shell`) # and assert the agent binary is actually installed and launchable. This catches # the class of bug where a sandbox comes up but the agent was never installed @@ -1452,11 +1503,11 @@ verify_agent_install() { dump "exec output" "$LAST_OUT" fi - if run_acq "exec opencode-models" --backend "$backend" exec "$aname" -- sh -c \ - 'opencode models | grep -q "^usai/"'; then - pass "${backend}: opencode models lists projected USAi models" + if run_acq "exec opencode-model-identity" --backend "$backend" exec "$aname" -- sh -c \ + "$PROVIDER_MODEL_IDENTITY_SCRIPT" sh usai; then + verify_provider_model_identity "$backend" usai "$LAST_OUT" else - fail "${backend}: opencode models did not list projected USAi models" + fail "${backend}: could not collect provider model identity from the sandbox" dump "exec output" "$LAST_OUT" fi diff --git a/test/bats/146-provider-verification.bats b/test/bats/146-provider-verification.bats index 0a0c0c5f..b4221600 100644 --- a/test/bats/146-provider-verification.bats +++ b/test/bats/146-provider-verification.bats @@ -367,3 +367,84 @@ PRELUDE assert_output --partial 'rm:--backend:msb:rm:box' assert_line 'FINAL_RC=1' } + +# --------------------------------------------------------------------------- +# Identity-bound provider model check (snapshot vs rendered config vs CLI) +# --------------------------------------------------------------------------- + +_identity_check() { # OUTPUT -> prints PASS/FAIL counters and verifier output + OUT="$1" run bash -c ' + REPO_ROOT="'"$REPO_ROOT"'" + VERIFY_BACKENDS_SOURCE_ONLY=1 + export VERIFY_BACKENDS_SOURCE_ONLY + . "$REPO_ROOT/scripts/verify-backends" + PASS=0 FAIL=0 + if verify_provider_model_identity sbx usai "$OUT"; then rc=0; else rc=$?; fi + printf "RC=%s PASS=%s FAIL=%s\n" "$rc" "$PASS" "$FAIL" + ' +} + +@test "model identity: exact match across snapshot, config and CLI passes" { + _identity_check $'SNAPSHOT a\nSNAPSHOT b\nCONFIG b\nCONFIG a\nCLI a\nCLI b' + assert_success + assert_output --partial 'match the projected usai snapshot (2 models)' + assert_line 'RC=0 PASS=1 FAIL=0' +} + +@test "model identity: a stale unrelated usai model in the CLI output fails" { + _identity_check $'SNAPSHOT a\nCONFIG a\nCLI stale-model' + assert_success + assert_output --partial 'CLI usai models differ' + assert_output --partial '| stale-model' + assert_output --partial '| a' + assert_line 'RC=1 PASS=0 FAIL=1' +} + +@test "model identity: a model missing from the rendered config fails" { + _identity_check $'SNAPSHOT a\nSNAPSHOT b\nCONFIG a\nCLI a\nCLI b' + assert_success + assert_output --partial 'CONFIG usai models differ' + assert_output --partial 'missing from CONFIG' + assert_line 'RC=1 PASS=0 FAIL=1' +} + +@test "model identity: an extra model in the rendered config fails" { + _identity_check $'SNAPSHOT a\nCONFIG a\nCONFIG extra\nCLI a' + assert_success + assert_output --partial 'unexpected in CONFIG' + assert_output --partial '| extra' + assert_line 'RC=1 PASS=0 FAIL=1' +} + +@test "model identity: an empty snapshot cannot bind identity and fails" { + _identity_check $'CONFIG a\nCLI a' + assert_success + assert_output --partial 'snapshot lists no models' + assert_line 'RC=1 PASS=0 FAIL=1' +} + +@test "model identity: guest script reads snapshot, rendered config and CLI output" { + command -v node >/dev/null || skip "node not available" + local root="$STUBDIR/pir" home="$STUBDIR/idhome" bin="$STUBDIR/idbin" + mkdir -p "$root/models/providers/usai" "$home/.config/opencode" "$bin" + printf '{"schemaVersion":"acq-neutral-model-catalog/v1","providerId":"usai","models":[{"id":"m1"},{"id":"m2"}]}\n' \ + > "$root/models/providers/usai/snapshot.json" + printf '// acq-provider-config-state: abc\n{"provider":{"usai":{"models":{"m1":{},"m2":{}}},"other":{"models":{"zz":{}}}}}\n' \ + > "$home/.config/opencode/opencode.jsonc" + printf '#!/bin/sh\nprintf "usai/m1\\nusai/m2\\nother/zz\\n"\n' > "$bin/opencode" + chmod +x "$bin/opencode" + run bash -c ' + VERIFY_BACKENDS_SOURCE_ONLY=1 + export VERIFY_BACKENDS_SOURCE_ONLY + . "'"$REPO_ROOT"'/scripts/verify-backends" + export ACQ_PROVIDER_INPUT_ROOT="'"$root"'" HOME="'"$home"'" PATH="'"$bin"':$PATH" + sh -c "$PROVIDER_MODEL_IDENTITY_SCRIPT" sh usai + ' + assert_success + assert_output $'SNAPSHOT m1\nSNAPSHOT m2\nCONFIG m1\nCONFIG m2\nCLI m1\nCLI m2' +} + +@test "model identity: the live verifier no longer relies on a bare usai/ prefix grep" { + run grep -n 'opencode models | grep -q' "$REPO_ROOT/scripts/verify-backends" + assert_failure +} From dfb0aa2544f809f6dd39c17f29ce78c70ced6e3b Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Mon, 26 Oct 2026 03:45:15 +0000 Subject: [PATCH 26/63] docs(verify): use --no-provider-kits for the removal procedure; add pending row Replace the unspecified temporary override and BLOCKED instruction with the supported flag and reproducible omission assertions (no USAi kit or credential gate, no usai projection, no usai models). Record the new identity-bound model check as PENDING: it has not been run on a live SBX or MSB host. Co-authored-by: OpenCode Agent --- docs/VERIFY_PROVIDER_ADOPTION.md | 46 +++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/docs/VERIFY_PROVIDER_ADOPTION.md b/docs/VERIFY_PROVIDER_ADOPTION.md index 00c8f404..1130757d 100644 --- a/docs/VERIFY_PROVIDER_ADOPTION.md +++ b/docs/VERIFY_PROVIDER_ADOPTION.md @@ -52,6 +52,9 @@ backend, virtualization, credentials, or released provider kit is unavailable and the step cannot run. Do not use `PASS` for a row unless you observed the actual command output. +`PENDING` marks a check that is implemented but has not been run on a live host; +it is not a result and must be replaced by `PASS`, `FAIL`, or `BLOCKED`. + | Check | SBX | MSB | Observed output / notes | |---|---|---|---| | Fresh `acq run opencode .` startup | PASS | PASS | 2026-10-25: both backends launched interactive OpenCode sessions from the final validation worktree. USAi models were visible and a model request returned successfully on each backend. | @@ -61,6 +64,7 @@ actual command output. | Fresh authoritative-empty provider selection has no USAi models | PASS | PASS | 2026-10-25: `--no-provider-kits` created fresh OpenCode sandboxes with no USAi kit, credential gate, or models on either backend. | | Malformed provider input is rejected before startup | PASS | PASS | 2026-10-25: both backends rejected malformed `provider-input.json` before backend creation with `acq: invalid provider input manifest ... invalid JSON at line 1 column 2`. | | `scripts/verify-backends --only ` | PASS | PASS | Final combined branch: SBX transcript `tmp/provider-neutral-validation.TCY2fZvE/sbx.log` (13 pass, 0 fail, 1 skip); MSB transcript `tmp/provider-neutral-validation.8MP6vbkX/msb.log` (18 pass, 0 fail, 1 skip). | +| Identity-bound provider model check in `scripts/verify-backends` (snapshot vs rendered config vs `opencode models`) | PENDING | PENDING | Not yet run on a live SBX or MSB host. The stronger check is new: it compares the projected snapshot's model IDs to both the rendered OpenCode config and the `opencode models` output and fails on missing or unexpected IDs. Only offline stubbed Bats cases (`test/bats/146-provider-verification.bats`) exist. The PASS rows above predate it and make no claim about it; they used the earlier `^usai/` prefix check. | ### Earlier Failed Live Evidence (2026-10-24) @@ -318,13 +322,41 @@ SANDBOX='' ./acq --backend "$BACKEND" rm "$SANDBOX" ``` -To verify provider removal, create a second fresh sandbox with an empty -authoritative provider set by overriding the default provider kit selection in -a temporary test configuration. Record the exact override used and confirm that -`opencode models` no longer lists the removed provider. If the installed `acq` -version has no supported host-side way to omit the default provider kit, record -that row as `BLOCKED`; do not edit the read-only guest projection or claim a -mocked result as live evidence. +To verify provider removal, create a second fresh sandbox with the supported +`--no-provider-kits` flag (`acq run`/`acq create`; see `docs/howto/acq.md`, +"Provider kits"). Run it with no USAi credential seeded for the sandbox, so a +credential gate would show up as a prompt or failure. Then assert omission +explicitly; any failed assertion is a failure, not `BLOCKED`: + +```bash +BACKEND=sbx # repeat with BACKEND=msb +RUN_OUT=$(mktemp "${TMPDIR:-/tmp}/acq-provider-omit.XXXXXX") +./acq --backend "$BACKEND" run opencode --no-provider-kits "$LIVE_ROOT" \ + -- --version >"$RUN_OUT" 2>&1 || { echo "FAIL: create/run failed" >&2; cat "$RUN_OUT"; exit 1; } +SANDBOX='' + +# 1) No USAi kit and no USAi credential gate in the create output. +! grep -Eiq 'usai-provider|USAI_API_KEY|usai credential' "$RUN_OUT" \ + || { echo "FAIL: USAi kit or credential gate appeared" >&2; exit 1; } + +# 2) The projected provider root has no USAi provider directory. +./acq --backend "$BACKEND" exec "$SANDBOX" -- sh -c \ + 'test -d "$ACQ_PROVIDER_INPUT_ROOT/models/providers" && test ! -e "$ACQ_PROVIDER_INPUT_ROOT/models/providers/usai"' \ + || { echo "FAIL: usai projection present or provider root missing" >&2; exit 1; } + +# 3) No usai models in `opencode models` (the command itself must succeed). +MODELS=$(./acq --backend "$BACKEND" exec "$SANDBOX" -- opencode models) \ + || { echo "FAIL: opencode models failed" >&2; exit 1; } +! printf '%s\n' "$MODELS" | grep -q '^usai/' \ + || { echo "FAIL: usai models listed" >&2; exit 1; } + +./acq --backend "$BACKEND" rm "$SANDBOX" +rm -f "$RUN_OUT" +``` + +Record the backend version and the output of each assertion. If the backend or +its host prerequisites are unavailable, record `BLOCKED`; do not edit the +read-only guest projection or claim a mocked result as live evidence. **Outcome recording:** Update the Live Validation Record table above with the observed output, timestamp, and backend version for each row that ran. Do not From 479dfb3ac74931c01f8df472587f8ffb7dabeaae Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Mon, 26 Oct 2026 04:12:05 +0000 Subject: [PATCH 27/63] fix(verify): avoid heredoc-in-command-substitution; tighten removal procedure notes Co-authored-by: OpenCode Agent --- docs/VERIFY_PROVIDER_ADOPTION.md | 13 +++++++++---- scripts/verify-backends | 3 +-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/VERIFY_PROVIDER_ADOPTION.md b/docs/VERIFY_PROVIDER_ADOPTION.md index 1130757d..22ce1bf7 100644 --- a/docs/VERIFY_PROVIDER_ADOPTION.md +++ b/docs/VERIFY_PROVIDER_ADOPTION.md @@ -324,8 +324,11 @@ SANDBOX='' To verify provider removal, create a second fresh sandbox with the supported `--no-provider-kits` flag (`acq run`/`acq create`; see `docs/howto/acq.md`, -"Provider kits"). Run it with no USAi credential seeded for the sandbox, so a -credential gate would show up as a prompt or failure. Then assert omission +"Provider kits"). Run it with no USAi credential available to acq (remove any +global USAi secret first, or use a clean host profile), so a credential gate +would show up as a prompt or failure. Run the block as a script, not pasted into +an interactive shell: its failure paths use `exit`, and a failed run leaves the +sandbox in place for inspection (remove it with `acq rm`). Then assert omission explicitly; any failed assertion is a failure, not `BLOCKED`: ```bash @@ -333,9 +336,11 @@ BACKEND=sbx # repeat with BACKEND=msb RUN_OUT=$(mktemp "${TMPDIR:-/tmp}/acq-provider-omit.XXXXXX") ./acq --backend "$BACKEND" run opencode --no-provider-kits "$LIVE_ROOT" \ -- --version >"$RUN_OUT" 2>&1 || { echo "FAIL: create/run failed" >&2; cat "$RUN_OUT"; exit 1; } -SANDBOX='' +SANDBOX='' # edit before continuing; do not paste unedited +case "$SANDBOX" in '<'*) echo "set SANDBOX first" >&2; exit 2 ;; esac -# 1) No USAi kit and no USAi credential gate in the create output. +# 1) No USAi kit and no USAi credential gate in the create output. This is a +# best-effort text check; assertions 2 and 3 are the substantive ones. ! grep -Eiq 'usai-provider|USAI_API_KEY|usai credential' "$RUN_OUT" \ || { echo "FAIL: USAi kit or credential gate appeared" >&2; exit 1; } diff --git a/scripts/verify-backends b/scripts/verify-backends index aebec43c..adf8e47e 100755 --- a/scripts/verify-backends +++ b/scripts/verify-backends @@ -1399,7 +1399,7 @@ verify_oci_kit() { # the model keys of the rendered global OpenCode config for that provider, and # the `opencode models` output for that provider. Both observed sets must equal # the snapshot set exactly. Not yet exercised on a live SBX/MSB host. -PROVIDER_MODEL_IDENTITY_SCRIPT=$(cat <<'EOS' +IFS= read -r -d '' PROVIDER_MODEL_IDENTITY_SCRIPT <<'EOS' || true prov=$1 node -e ' const fs = require("fs"), path = require("path"); @@ -1413,7 +1413,6 @@ for (const id of Object.keys((((cfg.provider || {})[prov]) || {}).models || {})) ' "$prov" || exit 1 opencode models | sed -n "s|^${prov}/|CLI |p" EOS -) # verify_provider_model_identity BACKEND PROVIDER OUTPUT — compare the three # sets in OUTPUT (see PROVIDER_MODEL_IDENTITY_SCRIPT); fail on missing or From 61631d99426d2debfb6d9b4c688cd846cff1361b Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Mon, 26 Oct 2026 16:55:31 +0000 Subject: [PATCH 28/63] fix(verify): fail the identity check when `opencode models` exits non-zero The guest script piped `opencode models` into sed, so the pipeline status was sed's and a failing command that printed the expected IDs passed. Capture the command's output and status first, refuse to compare on failure, then filter. The snapshot and config reads already run in node under an explicit `|| exit 1`. Add executable regressions for a non-zero exit with matching output and for the zero-exit pass. Co-authored-by: OpenCode Agent --- scripts/verify-backends | 6 +++- test/bats/146-provider-verification.bats | 42 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/scripts/verify-backends b/scripts/verify-backends index adf8e47e..4cec0655 100755 --- a/scripts/verify-backends +++ b/scripts/verify-backends @@ -1411,7 +1411,11 @@ const text = fs.readFileSync(file, "utf8").split("\n").filter((l) => !l.startsWi const cfg = JSON.parse(text); for (const id of Object.keys((((cfg.provider || {})[prov]) || {}).models || {})) console.log("CONFIG " + id); ' "$prov" || exit 1 -opencode models | sed -n "s|^${prov}/|CLI |p" +models_out=$(opencode models) || { + echo "opencode models failed (exit $?); refusing to compare partial output" >&2 + exit 1 +} +printf '%s\n' "$models_out" | sed -n "s|^${prov}/|CLI |p" EOS # verify_provider_model_identity BACKEND PROVIDER OUTPUT — compare the three diff --git a/test/bats/146-provider-verification.bats b/test/bats/146-provider-verification.bats index b4221600..1f262321 100644 --- a/test/bats/146-provider-verification.bats +++ b/test/bats/146-provider-verification.bats @@ -448,3 +448,45 @@ _identity_check() { # OUTPUT -> prints PASS/FAIL counters and verifier output run grep -n 'opencode models | grep -q' "$REPO_ROOT/scripts/verify-backends" assert_failure } + +_identity_guest_fixture() { # OPENCODE_EXIT -> sets ID_ROOT ID_HOME ID_BIN + ID_ROOT="$STUBDIR/pir2" ID_HOME="$STUBDIR/idhome2" ID_BIN="$STUBDIR/idbin2" + mkdir -p "$ID_ROOT/models/providers/usai" "$ID_HOME/.config/opencode" "$ID_BIN" + printf '{"schemaVersion":"acq-neutral-model-catalog/v1","providerId":"usai","models":[{"id":"m1"},{"id":"m2"}]}\n' \ + > "$ID_ROOT/models/providers/usai/snapshot.json" + printf '// acq-provider-config-state: abc\n{"provider":{"usai":{"models":{"m1":{},"m2":{}}}}}\n' \ + > "$ID_HOME/.config/opencode/opencode.jsonc" + printf '#!/bin/sh\nprintf "usai/m1\\nusai/m2\\n"\nexit %s\n' "$1" > "$ID_BIN/opencode" + chmod +x "$ID_BIN/opencode" +} + +_run_identity_guest() { + run bash -c ' + VERIFY_BACKENDS_SOURCE_ONLY=1 + export VERIFY_BACKENDS_SOURCE_ONLY + . "'"$REPO_ROOT"'/scripts/verify-backends" + export ACQ_PROVIDER_INPUT_ROOT="'"$ID_ROOT"'" HOME="'"$ID_HOME"'" PATH="'"$ID_BIN"':$PATH" + sh -c "$PROVIDER_MODEL_IDENTITY_SCRIPT" sh usai 2>&1 + echo "GUEST_RC=$?" + ' +} + +@test "model identity: opencode models exiting non-zero fails even when its output matches" { + command -v node >/dev/null || skip "node not available" + _identity_guest_fixture 7 + _run_identity_guest + assert_success + assert_output --partial 'opencode models failed (exit 7)' + assert_line 'GUEST_RC=1' + refute_output --partial 'CLI m1' +} + +@test "model identity: opencode models exiting zero with matching output still passes" { + command -v node >/dev/null || skip "node not available" + _identity_guest_fixture 0 + _run_identity_guest + assert_success + assert_output --partial 'CLI m1' + assert_output --partial 'CLI m2' + assert_line 'GUEST_RC=0' +} From b8bb0516b32a2801009fa84bc61f850fed6d6ab7 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Mon, 26 Oct 2026 16:55:53 +0000 Subject: [PATCH 29/63] docs(verify): add an explicit merge gate; mark removal assertions pending State what has been observed (offline CI, earlier prefix-only live PASS rows) versus what is still required before merge (updated identity check and the --no-provider-kits removal procedure on real SBX and MSB, tied to a tested revision SHA). Add a PENDING row for the removal assertions. Co-authored-by: OpenCode Agent --- docs/VERIFY_PROVIDER_ADOPTION.md | 34 ++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/VERIFY_PROVIDER_ADOPTION.md b/docs/VERIFY_PROVIDER_ADOPTION.md index 22ce1bf7..5160a47a 100644 --- a/docs/VERIFY_PROVIDER_ADOPTION.md +++ b/docs/VERIFY_PROVIDER_ADOPTION.md @@ -3,7 +3,7 @@ title: "Provider-Neutral Adoption Validation" description: "Offline and live procedures for validating provider-input adoption on SBX and MSB" status: draft tier: 2 -last_updated: "2026-10-22" +last_updated: "2026-10-26" audience: "developers" keywords: ["provider", "validation", "sbx", "msb", "opencode", "offline"] related_files: ["scripts/verify-backends", "test/bats/146-provider-verification.bats", "test/bats/147-provider-input-projection.bats", "docs/howto/acq.md"] @@ -15,12 +15,40 @@ review_cycle: "quarterly" This procedure separates the preparatory offline checks from the live backend validation required before provider-input adoption can be considered complete. +That live validation is tracked in the Merge Gate section below and is not +finished. The Bats cases named below are **offline seam tests**. They use local fixtures and stubs to verify internal wiring and control-flow; they are not E2E tests and do not prove that SBX, MSB, USAi, or a fresh OpenCode process works on a real host. Real projected parser validation requires a live backend run (see "Live Validation Record" below). +## Merge Gate + +Provider-neutral adoption is **not** shown to be complete by this document. The +table below records what has been observed and what is still required. + +**Observed so far** + +- Offline Bats seam tests and CI (`146`, `147` and neighbors): stubbed backends + only; they prove acq's decision logic, not live SBX or MSB behavior. +- Earlier live PASS rows in the Live Validation Record (2026-10-24 and + 2026-10-25). Those used the previous prefix-only model check + (`opencode models | grep "^usai/"`) and an earlier head of this branch. They + make no claim about the identity-bound check or the current revision. + +**Required before merge (not yet done)** + +1. Run the updated `scripts/verify-backends` identity-bound model check + (projected snapshot vs rendered OpenCode config vs `opencode models`) on a + real SBX host and a real MSB host. +2. Run the `--no-provider-kits` removal procedure (see "Provider removal" under + the live commands) on real SBX and MSB, including all three omission + assertions. +3. Record each result as `PASS`, `FAIL`, or `BLOCKED`, with the backend version, + timestamp, and the **tested revision SHA**, replacing the PENDING row. + A result for a different revision does not satisfy this gate. + ## Offline Checks Run the focused provider checks after initializing the Bats submodules: @@ -65,6 +93,7 @@ it is not a result and must be replaced by `PASS`, `FAIL`, or `BLOCKED`. | Malformed provider input is rejected before startup | PASS | PASS | 2026-10-25: both backends rejected malformed `provider-input.json` before backend creation with `acq: invalid provider input manifest ... invalid JSON at line 1 column 2`. | | `scripts/verify-backends --only ` | PASS | PASS | Final combined branch: SBX transcript `tmp/provider-neutral-validation.TCY2fZvE/sbx.log` (13 pass, 0 fail, 1 skip); MSB transcript `tmp/provider-neutral-validation.8MP6vbkX/msb.log` (18 pass, 0 fail, 1 skip). | | Identity-bound provider model check in `scripts/verify-backends` (snapshot vs rendered config vs `opencode models`) | PENDING | PENDING | Not yet run on a live SBX or MSB host. The stronger check is new: it compares the projected snapshot's model IDs to both the rendered OpenCode config and the `opencode models` output and fails on missing or unexpected IDs. Only offline stubbed Bats cases (`test/bats/146-provider-verification.bats`) exist. The PASS rows above predate it and make no claim about it; they used the earlier `^usai/` prefix check. | +| `--no-provider-kits` removal procedure with the three explicit omission assertions (no USAi kit/credential text, no `usai` projection, no `usai/` models) | PENDING | PENDING | Not yet run on a live SBX or MSB host. The earlier 2026-10-25 omission PASS row above was observed on an earlier head with its own checks; it is not a result for these assertions or this revision. | ### Earlier Failed Live Evidence (2026-10-24) @@ -322,7 +351,8 @@ SANDBOX='' ./acq --backend "$BACKEND" rm "$SANDBOX" ``` -To verify provider removal, create a second fresh sandbox with the supported +**Provider removal.** To verify provider removal, create a second fresh +sandbox with the supported `--no-provider-kits` flag (`acq run`/`acq create`; see `docs/howto/acq.md`, "Provider kits"). Run it with no USAi credential available to acq (remove any global USAi secret first, or use a clean host profile), so a credential gate From d9f2ce5f7499f6362ab9a6afa6e667c66b5a8057 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Mon, 26 Oct 2026 16:57:46 +0000 Subject: [PATCH 30/63] docs(verify): tighten merge gate wording per review Co-authored-by: OpenCode Agent --- docs/VERIFY_PROVIDER_ADOPTION.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/VERIFY_PROVIDER_ADOPTION.md b/docs/VERIFY_PROVIDER_ADOPTION.md index 5160a47a..e9c94946 100644 --- a/docs/VERIFY_PROVIDER_ADOPTION.md +++ b/docs/VERIFY_PROVIDER_ADOPTION.md @@ -26,24 +26,26 @@ host. Real projected parser validation requires a live backend run (see ## Merge Gate Provider-neutral adoption is **not** shown to be complete by this document. The -table below records what has been observed and what is still required. +sections below record what has been observed and what is still required; per-row +results live in the Live Validation Record. **Observed so far** - Offline Bats seam tests and CI (`146`, `147` and neighbors): stubbed backends only; they prove acq's decision logic, not live SBX or MSB behavior. - Earlier live PASS rows in the Live Validation Record (2026-10-24 and - 2026-10-25). Those used the previous prefix-only model check - (`opencode models | grep "^usai/"`) and an earlier head of this branch. They - make no claim about the identity-bound check or the current revision. + 2026-10-25), observed on earlier heads of this branch. Any model-listing + evidence in them came from the previous prefix-only check + (`opencode models | grep "^usai/"`) or a manual `opencode models`. They make + no claim about the identity-bound check or the current revision. **Required before merge (not yet done)** 1. Run the updated `scripts/verify-backends` identity-bound model check (projected snapshot vs rendered OpenCode config vs `opencode models`) on a real SBX host and a real MSB host. -2. Run the `--no-provider-kits` removal procedure (see "Provider removal" under - the live commands) on real SBX and MSB, including all three omission +2. Run the `--no-provider-kits` removal procedure (the **Provider removal** + paragraph in the live-commands block below) on real SBX and MSB, including all three omission assertions. 3. Record each result as `PASS`, `FAIL`, or `BLOCKED`, with the backend version, timestamp, and the **tested revision SHA**, replacing the PENDING row. From 761f7060021948d65c030e138cab69b1b172b588 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Tue, 27 Oct 2026 14:49:24 +0000 Subject: [PATCH 31/63] docs(verify): record live validation results at ec8d5e17 and update the merge gate Replace the two PENDING rows with the maintainer's 2026-10-27 live results (identity-bound model check and --no-provider-kits removal, PASS on SBX and MSB 0.7.7) tied to the tested revision, state which gate items are done and that later heads must be judged by what they change, and note that the SBX version was not captured. Co-authored-by: OpenCode Agent --- docs/VERIFY_PROVIDER_ADOPTION.md | 57 ++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/docs/VERIFY_PROVIDER_ADOPTION.md b/docs/VERIFY_PROVIDER_ADOPTION.md index e9c94946..216e0a0f 100644 --- a/docs/VERIFY_PROVIDER_ADOPTION.md +++ b/docs/VERIFY_PROVIDER_ADOPTION.md @@ -3,7 +3,7 @@ title: "Provider-Neutral Adoption Validation" description: "Offline and live procedures for validating provider-input adoption on SBX and MSB" status: draft tier: 2 -last_updated: "2026-10-26" +last_updated: "2026-10-27" audience: "developers" keywords: ["provider", "validation", "sbx", "msb", "opencode", "offline"] related_files: ["scripts/verify-backends", "test/bats/146-provider-verification.bats", "test/bats/147-provider-input-projection.bats", "docs/howto/acq.md"] @@ -15,8 +15,7 @@ review_cycle: "quarterly" This procedure separates the preparatory offline checks from the live backend validation required before provider-input adoption can be considered complete. -That live validation is tracked in the Merge Gate section below and is not -finished. +Its status is recorded in the Merge Gate section below. The Bats cases named below are **offline seam tests**. They use local fixtures and stubs to verify internal wiring and control-flow; they are not E2E tests and do not prove that SBX, MSB, USAi, or a fresh OpenCode process works on a real @@ -25,31 +24,38 @@ host. Real projected parser validation requires a live backend run (see ## Merge Gate -Provider-neutral adoption is **not** shown to be complete by this document. The -sections below record what has been observed and what is still required; per-row -results live in the Live Validation Record. +**Status: the required live runs were done at tested revision `ec8d5e17` +(`ec8d5e177a520a69e1b835895763a9aac520af1b`) and passed on both backends.** One +recording gap remains: the SBX version was not captured. The gate's required +runs are satisfied for that revision only. Offline Bats seam tests remain +separate from the live evidence; they prove acq's decision logic with stubbed +backends, not live SBX or MSB behavior. -**Observed so far** - -- Offline Bats seam tests and CI (`146`, `147` and neighbors): stubbed backends - only; they prove acq's decision logic, not live SBX or MSB behavior. -- Earlier live PASS rows in the Live Validation Record (2026-10-24 and - 2026-10-25), observed on earlier heads of this branch. Any model-listing - evidence in them came from the previous prefix-only check - (`opencode models | grep "^usai/"`) or a manual `opencode models`. They make - no claim about the identity-bound check or the current revision. - -**Required before merge (not yet done)** +**Required before merge, and their status at `ec8d5e17`** 1. Run the updated `scripts/verify-backends` identity-bound model check (projected snapshot vs rendered OpenCode config vs `opencode models`) on a - real SBX host and a real MSB host. + real SBX host and a real MSB host. **Done, PASS on both** (2026-10-27). 2. Run the `--no-provider-kits` removal procedure (the **Provider removal** - paragraph in the live-commands block below) on real SBX and MSB, including all three omission - assertions. + paragraph in the live-commands block below) on real SBX and MSB, including + all three omission assertions. **Done, PASS on both** (2026-10-27). 3. Record each result as `PASS`, `FAIL`, or `BLOCKED`, with the backend version, - timestamp, and the **tested revision SHA**, replacing the PENDING row. - A result for a different revision does not satisfy this gate. + timestamp, and the **tested revision SHA**. **Done in the Live Validation + Record below, except that the SBX version was not captured** (MSB 0.7.7 was). + +**Applying this to later heads.** A result for a different revision does not +satisfy the gate by itself. Any commit after `ec8d5e17` must be judged by what it +changes: a commit that only edits this document does not change the tested code, +but a change to `acq`, `acq.backends/`, `scripts/verify-backends`, the pinned +kits, or provider-input handling needs its effect judged and, where it could +change the checked behavior, a re-run. + +**Evidence.** The earlier live PASS rows (2026-10-24 and 2026-10-25) were +observed on earlier heads and used the previous prefix-only model check +(`opencode models | grep "^usai/"`) or a manual `opencode models`; they make no +claim about the identity-bound check or this revision. The 2026-10-27 rows +(see the table) were produced by the maintainer running the validation script on +a real host; raw results are kept in a gitignored host directory (see the rows). ## Offline Checks @@ -83,7 +89,8 @@ and the step cannot run. Do not use `PASS` for a row unless you observed the actual command output. `PENDING` marks a check that is implemented but has not been run on a live host; -it is not a result and must be replaced by `PASS`, `FAIL`, or `BLOCKED`. +it is not a result and must be replaced by `PASS`, `FAIL`, or `BLOCKED`. No row +is PENDING at `ec8d5e17`. | Check | SBX | MSB | Observed output / notes | |---|---|---|---| @@ -94,8 +101,8 @@ it is not a result and must be replaced by `PASS`, `FAIL`, or `BLOCKED`. | Fresh authoritative-empty provider selection has no USAi models | PASS | PASS | 2026-10-25: `--no-provider-kits` created fresh OpenCode sandboxes with no USAi kit, credential gate, or models on either backend. | | Malformed provider input is rejected before startup | PASS | PASS | 2026-10-25: both backends rejected malformed `provider-input.json` before backend creation with `acq: invalid provider input manifest ... invalid JSON at line 1 column 2`. | | `scripts/verify-backends --only ` | PASS | PASS | Final combined branch: SBX transcript `tmp/provider-neutral-validation.TCY2fZvE/sbx.log` (13 pass, 0 fail, 1 skip); MSB transcript `tmp/provider-neutral-validation.8MP6vbkX/msb.log` (18 pass, 0 fail, 1 skip). | -| Identity-bound provider model check in `scripts/verify-backends` (snapshot vs rendered config vs `opencode models`) | PENDING | PENDING | Not yet run on a live SBX or MSB host. The stronger check is new: it compares the projected snapshot's model IDs to both the rendered OpenCode config and the `opencode models` output and fails on missing or unexpected IDs. Only offline stubbed Bats cases (`test/bats/146-provider-verification.bats`) exist. The PASS rows above predate it and make no claim about it; they used the earlier `^usai/` prefix check. | -| `--no-provider-kits` removal procedure with the three explicit omission assertions (no USAi kit/credential text, no `usai` projection, no `usai/` models) | PENDING | PENDING | Not yet run on a live SBX or MSB host. The earlier 2026-10-25 omission PASS row above was observed on an earlier head with its own checks; it is not a result for these assertions or this revision. | +| Identity-bound provider model check in `scripts/verify-backends` (snapshot vs rendered config vs `opencode models`) | PASS | PASS | 2026-10-27, tested revision `ec8d5e177a520a69e1b835895763a9aac520af1b`, run by the maintainer on a real host. SBX: `verify-backends` 13 passed, 0 failed, 1 skipped (sbx version not captured). MSB 0.7.7: 18 passed, 0 failed, 1 skipped. Both printed `rendered config and opencode models match the projected usai snapshot (24 models)`. The skip on both is the playbook fetch (no GitHub token available to the run). The earlier PASS rows above used the prefix-only check and are not evidence for this row. Raw results: `tmp/validate-611-results-20261027T100924Z/results.tsv`, in a gitignored host-side directory (not committed). | +| `--no-provider-kits` removal procedure with the three explicit omission assertions (no USAi kit/credential text, no `usai` projection, no `usai/` models) | PASS | PASS | 2026-10-27, tested revision `ec8d5e177a520a69e1b835895763a9aac520af1b`, same run and host as the row above (SBX version not captured; MSB 0.7.7). All three assertions passed on each backend. The earlier 2026-10-25 omission row used its own checks and is a separate observation. Raw results: same gitignored `results.tsv`. | ### Earlier Failed Live Evidence (2026-10-24) From 3f7a44c738fa10639002192e2ff78c4a802d4784 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 18:01:53 +0000 Subject: [PATCH 32/63] feat(sbx): trust host state for built-in kit heal; add MSB forged-marker and legacy-sandbox coverage SBX no longer decides whether the Zscaler CA, USAi, and playbook built-in kits are applied from guest `sbx exec` probes, which a sudo guest can forge. A host config `builtin-kits` record (kit name + pinned ref) is seeded at create and updated on heal success; an unrecorded or stale entry is re-applied, and ACQ_FORCE_KIT_REAPPLY is unchanged. The guest probe remains as a debug-only diagnostic. Adds MSB tests proving forged guest markers do not change agent, workspace, SSH socket, kit environment, install gate, or agent-user-ready decisions, documents legacy-sandbox behavior, and corrects the verify-backends OCI marker name to oci-engine-ready. Co-authored-by: OpenCode Agent --- acq.backends/common.sh | 7 +- acq.backends/sbx.sh | 83 ++++- docs/KNOWN_FAILURE_MODES.md | 59 +++- ...kit-bundle-provenance-and-stale-refresh.md | 6 +- scripts/verify-backends | 2 +- test/bats/72-msb-agent-user.bats | 2 +- test/bats/81-msb-forged-guest-markers.bats | 307 ++++++++++++++++++ test/bats/82-msb-legacy-sandbox.bats | 89 +++++ test/bats/90-sbx-startup-kit.bats | 193 ++++++++++- 9 files changed, 718 insertions(+), 30 deletions(-) create mode 100644 test/bats/81-msb-forged-guest-markers.bats create mode 100644 test/bats/82-msb-legacy-sandbox.bats diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 341c4d98..0925c315 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -2251,8 +2251,9 @@ acq_provenance_status() { # Reapply the built-in bundle to an existing sandbox and, on success, refresh its # host-side provenance record. Uses the backend's heal path # (acq_backend_ensure_kits_applied) with ACQ_FORCE_KIT_REAPPLY=1 so a -# present-but-stale kit is actually re-applied (the sbx feature-probe would -# otherwise skip a kit that is present but built from an older ref). msb already +# present-but-stale kit is actually re-applied (the sbx host builtin-kits record +# would otherwise skip a kit recorded as applied at an older ref; the force flag +# bypasses that record). msb already # re-applies all kits idempotently; the force flag is a no-op there. After a usai # refresh the kit's own startup global-merge re-runs on next attach, so the # refreshed model catalog reaches the config. State (sessions, secrets, unrelated @@ -3185,7 +3186,7 @@ acq_host_config_clear_instance_state() { rm -f "$dir"/install-* "$dir"/agent-installed-* \ "$dir"/agent-user-ready \ "$dir"/workspace "$dir"/ssh-auth-sock "$dir"/generic-opencode \ - "$dir"/extra-kits 2>/dev/null || true + "$dir"/extra-kits "$dir"/builtin-kits 2>/dev/null || true return 0 } diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index 45585c6a..9a44f66b 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -702,6 +702,7 @@ _acq_backend_provision_unlocked() { fi acq_workspace_record_write sbx "$name" "$_primary_ws" || true _acq_sbx_seed_extra_kit_record "$name" + _acq_sbx_seed_builtin_kit_record "$name" # Backend parity (ADR-0030): sbx DELIVERS ~/.rc.d/*.sh via kit files[] but, # unlike msb's .profile bridge, nothing SOURCES them at login. Write the same # bridge here at create time so a kit-dropped snippet actually runs. This is a @@ -746,7 +747,7 @@ acq_backend_provision() { # host record (see acq_backend_ensure_kits_applied step 4). A guest marker is not # a trust boundary because a passwordless-sudo guest can forge it. Recording the # original ref once per line lets create and heal agree without trusting guest -# state. Built-in kits are tracked by feature-probe and are not listed here. +# state. Built-in kits are tracked separately (`builtin-kits`, below). # # ACQ_CLI_KITS (`--kit` on the CLI) are recorded for completeness even though the # current sbx heal loop only re-drives ACQ_EXTRA_KITS. This is safe, not a @@ -772,6 +773,66 @@ _acq_sbx_seed_extra_kit_record() { echo "acq: warning: could not record extra kits for '$name'; re-attach may re-attempt them." >&2 } +# --------------------------------------------------------------------------- +# Host-authoritative built-in kit state (host config key `builtin-kits`) +# --------------------------------------------------------------------------- +# The re-attach heal decides whether the Zscaler CA, USAi config and playbook +# built-in kits need (re)applying from this host record, never from a guest +# `sbx exec` probe: a passwordless-sudo guest can create the probed files and so +# control the answer. One line per kit: " ". Recording the +# ref makes a record from an older bundle stale (the ref no longer matches), so a +# bundle bump re-applies the kit instead of trusting the old record. +# +# Legacy default: a sandbox created before this record existed has no +# `builtin-kits` key. Absent or non-matching host state means "not recorded as +# applied", so the heal re-applies (fail toward re-apply, never toward skip). On +# sbx >= 0.38 that re-apply of a startup-bearing kit is refused with the +# recreate notice; the record is only written on success, so such a sandbox +# keeps prompting for recreate until it is recreated. + +# True if KIT at REF is recorded as applied. Usage: ... NAME KIT REF +_acq_sbx_builtin_kit_recorded() { + local recorded + recorded=$(acq_host_config_read sbx "$1" builtin-kits) + grep -Fxq -- "$2 $3" <<<"$recorded" +} + +# Record KIT at REF (replacing any older line for KIT). Usage: ... NAME KIT REF +_acq_sbx_builtin_kit_record() { + local name="$1" kit="$2" ref="$3" recorded kept + recorded=$(acq_host_config_read sbx "$name" builtin-kits) + kept=$(printf '%s\n' "$recorded" | awk -v k="$kit " 'NF && index($0, k) != 1') + acq_host_config_write sbx "$name" builtin-kits "${kept}${kept:+$'\n'}${kit} ${ref}" || \ + echo "acq: warning: could not record built-in kit '$kit' for '$name'; re-attach may re-attempt it." >&2 +} + +# Create-time seed: the kits the heal consults, as just applied by `sbx create`. +_acq_sbx_seed_builtin_kit_record() { + local name="$1" records + records="zscaler-ca-certificate ${ZSCALER_KIT}" + if ! command -v acq_usai_provider_selected >/dev/null 2>&1 || acq_usai_provider_selected; then + records="${records}"$'\n'"${USAI_PROVIDER_KIT_NAME} ${USAI_KIT}" + fi + records="${records}"$'\n'"agentic-coding-playbook ${PLAYBOOK_KIT}" + acq_host_config_write sbx "$name" builtin-kits "$records" || \ + echo "acq: warning: could not record built-in kits for '$name'; re-attach may re-apply them." >&2 +} + +# Heal decision: 0 = apply, 1 = skip. Authority is the host record only. The +# guest probe (SNIPPET, as for _acq_sbx_kit_feature_absent) runs solely under +# ACQ_DEBUG as a non-authoritative diagnostic and never changes the decision. +# Usage: _acq_sbx_builtin_kit_needs_apply NAME KIT REF SNIPPET +_acq_sbx_builtin_kit_needs_apply() { + local name="$1" kit="$2" ref="$3" snippet="$4" _prc=0 + [ "${ACQ_FORCE_KIT_REAPPLY:-0}" = "1" ] && return 0 + _acq_sbx_builtin_kit_recorded "$name" "$kit" "$ref" || return 0 + if [ -n "${ACQ_DEBUG:-}" ]; then + _acq_sbx_kit_feature_absent "$name" "$snippet" || _prc=$? + [ "$_prc" -eq 0 ] && acq_debug "heal(sbx): host records '$kit' applied but the guest probe reports it absent (diagnostic only; not re-applying)" + fi + return 1 +} + # --------------------------------------------------------------------------- # _acq_sbx_ensure_rc_bridge — write the login-profile rc.d sourcing bridge # --------------------------------------------------------------------------- @@ -1158,10 +1219,11 @@ acq_backend_apply_kit() { # --------------------------------------------------------------------------- # acq_backend_ensure_kits_applied — heal a pre-kit sandbox in place # --------------------------------------------------------------------------- -# Injects any ABSENT built-in kit. When ACQ_FORCE_KIT_REAPPLY=1 (set by -# acq_bundle_reapply for a stale-bundle refresh) it re-adds ALL -# built-in kits even when present, so a kit built from an older ref is actually -# refreshed — the feature-probe alone would skip a present-but-stale kit. +# Injects any built-in kit (Zscaler CA, USAi, playbook) not recorded as applied +# at its current pinned ref in the HOST config store (key `builtin-kits`); the +# guest is never consulted for that decision (see the helpers above). When +# ACQ_FORCE_KIT_REAPPLY=1 (set by acq_bundle_reapply for a stale-bundle refresh) +# it re-adds ALL built-in kits regardless of the record. # Returns 0 only if every built-in kit is present-or-successfully-applied, so a # caller can gate a provenance write on real success (never claim currency after # a failed apply). @@ -1195,13 +1257,13 @@ acq_backend_ensure_kits_applied() { # makes an outbound HTTPS request. Behind a TLS-intercepting proxy (Zscaler), # the USAi and playbook kits fail with a TLS 'unexpected eof' unless the # intercepting CA is already trusted. - if [ "$force" = "1" ] || _acq_sbx_kit_feature_absent "$name" 'test -e /usr/local/share/ca-certificates/zscaler-ca.crt && echo present'; then + if _acq_sbx_builtin_kit_needs_apply "$name" zscaler-ca-certificate "$ZSCALER_KIT" 'test -e /usr/local/share/ca-certificates/zscaler-ca.crt && echo present'; then echo "acq: '$name' is missing the Zscaler CA kit; injecting with 'sbx kit add'..." >&2 # `set -e`-safe: _acq_sbx_kit_add's non-zero returns (3=refusal, 1=other) are # normal signalling — capture the status inline so the loop is not aborted. _kadd_rc=0; _acq_sbx_kit_add "$name" "$zscaler_local" || _kadd_rc=$? case $_kadd_rc in - 0) healed=1; echo "acq: Zscaler CA kit injected into '$name'." >&2 ;; + 0) healed=1; _acq_sbx_builtin_kit_record "$name" zscaler-ca-certificate "$ZSCALER_KIT"; echo "acq: Zscaler CA kit injected into '$name'." >&2 ;; 3) _acq_sbx_print_recreate_notice "$name"; ok=0 ;; *) echo "acq: warning: 'sbx kit add' (Zscaler CA kit) failed for '$name' (see error above)." >&2; ok=0 ;; esac @@ -1209,7 +1271,7 @@ acq_backend_ensure_kits_applied() { # 2) USAi provider kit if { ! command -v acq_usai_provider_selected >/dev/null 2>&1 || acq_usai_provider_selected; } \ - && { [ "$force" = "1" ] || _acq_sbx_kit_feature_absent "$name" "test -f '$USAI_KIT_CONFIG_PATH' && echo present"; }; then + && _acq_sbx_builtin_kit_needs_apply "$name" "$USAI_PROVIDER_KIT_NAME" "$USAI_KIT" "test -f '$USAI_KIT_CONFIG_PATH' && echo present"; then echo "acq: '$name' is missing the USAi kit; injecting with 'sbx kit add'..." >&2 _kadd_rc=0; _acq_sbx_kit_add "$name" "$usai_local" || _kadd_rc=$? case $_kadd_rc in @@ -1218,6 +1280,7 @@ acq_backend_ensure_kits_applied() { sbx exec "$name" -- sh -c \ 'f="$HOME/.config/opencode/opencode.jsonc"; if [ -L "$f" ] && [ ! -e "$f" ]; then rm -f "$f"; fi' \ /dev/null 2>&1 || true + _acq_sbx_builtin_kit_record "$name" "$USAI_PROVIDER_KIT_NAME" "$USAI_KIT" echo "acq: USAi kit injected into '$name'." >&2 ;; 3) _acq_sbx_print_recreate_notice "$name"; ok=0 ;; @@ -1231,11 +1294,11 @@ acq_backend_ensure_kits_applied() { # NO .git. The symlink farm consumes AGENTS.md, so its presence is the # delivery-agnostic signal; probing for .git reported the playbook "absent" # forever on tarball-provisioned sandboxes. - if [ "$force" = "1" ] || _acq_sbx_kit_feature_absent "$name" 'test -e "$HOME/.agentic-coding-playbook/AGENTS.md" && echo present'; then + if _acq_sbx_builtin_kit_needs_apply "$name" agentic-coding-playbook "$PLAYBOOK_KIT" 'test -e "$HOME/.agentic-coding-playbook/AGENTS.md" && echo present'; then echo "acq: '$name' is missing the playbook kit; injecting with 'sbx kit add'..." >&2 _kadd_rc=0; _acq_sbx_kit_add "$name" "$playbook_local" || _kadd_rc=$? case $_kadd_rc in - 0) healed=1; echo "acq: playbook kit injected into '$name'. Restart the agent to pick it up." >&2 ;; + 0) healed=1; _acq_sbx_builtin_kit_record "$name" agentic-coding-playbook "$PLAYBOOK_KIT"; echo "acq: playbook kit injected into '$name'. Restart the agent to pick it up." >&2 ;; 3) _acq_sbx_print_recreate_notice "$name"; ok=0 ;; *) echo "acq: warning: 'sbx kit add' (playbook kit) failed for '$name' (see error above)." >&2; ok=0 ;; esac diff --git a/docs/KNOWN_FAILURE_MODES.md b/docs/KNOWN_FAILURE_MODES.md index 3823ae4e..77f8c88d 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -3,7 +3,7 @@ title: "Known Failure Modes" description: "Real-world failure patterns when using Docker SBX + USAi + agent frameworks" status: canonical tier: 2 -last_updated: "2026-10-22" +last_updated: "2026-10-25" audience: "developers" keywords: ["debugging", "troubleshooting", "sbx", "usai", "failures"] --- @@ -760,12 +760,15 @@ create or a cold start, exec fails with `inspect exec: context deadline exceeded` for a few seconds; `acq` polls until a trivial exec succeeds) so a cold-start delay is not misread as "kit absent". -> **Detecting a pre-kit sandbox.** `acq` decides a kit is missing by checking for -> the kit's footprint (e.g. the USAi config file), classifying on the probe's -> **stdout** (`present`/`absent`), never its exit status — `test -f` exits -> non-zero when the file is absent, which is indistinguishable from a genuine -> exec failure, so an exit-status check would wrongly treat "file absent" as -> "probe failed" and skip healing. +> **Detecting a pre-kit sandbox.** `acq` decides a built-in kit is missing from +> the **host** `builtin-kits` record (kit name plus the pinned ref applied), +> written at create and after a successful heal. A guest-side footprint probe +> (e.g. the USAi config file) can be forged by the sandbox's passwordless-sudo +> agent, so it is not authoritative: it runs only under `ACQ_DEBUG` as a +> diagnostic and never changes the heal decision. Where the diagnostic runs it +> classifies on the probe's **stdout** (`present`/`absent`), never its exit +> status — `test -f` exits non-zero when the file is absent, which is +> indistinguishable from a genuine exec failure. [sbx133]: https://github.com/docker/sbx-releases/issues/133 @@ -1773,6 +1776,34 @@ acq rm acq run ``` +### Edge cases for a sandbox created before the migration + +acq reads none of the old guest markers (`/var/lib/acq/*`, `~/.acq-extra-kits`), +so there is no data migration. Each missing host-config key degrades as follows +(derived from `acq.backends/msb.sh` and `acq.backends/sbx.sh`; the offline tests +named below pin the behavior, and none of it was re-verified against a live +sandbox): + +| Backend | Missing host state | Observed behavior | Recovery | +| --- | --- | --- | --- | +| msb | `ssh-auth-sock` | No fallback. `SSH_AUTH_SOCK` is not set in sessions until a heal writes the key. `acq run`, `acq start`, and `acq restart` heal it only when a host ssh-agent forward is requested (`SSH_AUTH_SOCK` names an existing socket) **and** the sandbox has the create-time ssh-agent `--vsock` route. Without a host forward the heal is a silent no-op; with a forward but no route acq warns and leaves it unset. `acq exec` and `acq shell` run no heal, so they stay without it until a heal has run. | Run `acq run ` with `SSH_AUTH_SOCK` set. With no route, recreate (see KFM §35). | +| msb | `install-` | Each install-phase kit command runs once more on the first heal, then the host gate is written. A command that fails is not recorded and is retried on every heal. acq does not check that third-party install commands are idempotent. | None needed for kits written to be idempotent. | +| msb | `agent-user-ready` | The agent-user and passwordless-sudo setup runs once more on the first heal, then the gate is written. An existing `agent` user is reused. | None needed. | +| msb | `kit-env` | `acq exec`, `acq shell`, and attach replay no kit `environment[]` until a full heal rebuilds the key from the current kits. | Run `acq run ` once. | +| sbx | `generic-opencode` launch mode, agent recorded as `opencode` | Attach fails closed with `cannot determine OpenCode launch mode` and runs no `sbx run`. acq cannot tell a pre-ADR-0030 template sandbox from a generic one whose host state was lost, and it does not trust a guest value. Other agents are not affected. | `acq rm ` then `acq run opencode `. | +| sbx | `builtin-kits` | A sandbox created before the record has none. On sbx 0.38 or later the first re-attach tries to re-apply the three heal-consulted built-ins (Zscaler CA, USAi provider, playbook); `sbx kit add` refuses startup-bearing kits, so acq prints the recreate notice on every attach until the sandbox is recreated. | Recreate to get the record (and the current bundle). | +| sbx | `extra-kits` (replaces the guest `~/.acq-extra-kits`) | The old marker is ignored. With `ACQ_EXTRA_KITS` set, the first heal tries `sbx kit add` for each extra kit. On sbx 0.38 or later a startup-bearing kit is refused and acq prints the recreate notice. `--kit` refs are create-time only on sbx and are not re-driven. | Recreate if the notice appears. | + +Orphaned guest files under `/var/lib/acq/` stay in the guest. acq never reads +them, so they are harmless. + +Offline coverage: `test/bats/82-msb-legacy-sandbox.bats` (msb `ssh-auth-sock`, +`install-`, and `agent-user-ready` rows), `test/bats/72-msb-agent-user.bats` (provenance fallback), +`test/bats/115-ssh-agent-forward.bats` (heal re-drives the forward), and +`test/bats/140-opencode-migration-gate.bats` (sbx launch-mode refusal), and +`test/bats/90-sbx-startup-kit.bats` (sbx `builtin-kits` record, refusal, and +recreate notice). + ### Prevention / Status - Fixed without trusting legacy guest state. A one-time idempotent replay after @@ -2117,11 +2148,15 @@ Three issues compounded: diagnostic rather than being hidden. `acq kit update` fails with the same guidance. acq does **not** auto-recreate (a recreate discards the sandbox's session/context — it stays a deliberate, user-consented action). -- Probes the playbook with `test -e "$HOME/.agentic-coding-playbook/AGENTS.md"`, - which matches **both** the historical git-clone and the current tarball - delivery. -- Writes `~/.acq-extra-kits` at **create** for every extra/CLI kit, so re-attach - correctly sees them as already applied. +- Decides which built-in kits to re-apply from the host `builtin-kits` record + (written at **create** and after a successful heal), not from guest state. The + guest footprint probe (including the playbook check + `test -e "$HOME/.agentic-coding-playbook/AGENTS.md"`, which matches both the + historical git-clone and the current tarball delivery) remains only as a + debug-only diagnostic under `ACQ_DEBUG`. +- Writes the host `extra-kits` key at **create** for every extra/CLI kit, so + re-attach correctly sees them as already applied. It replaces the old guest + `~/.acq-extra-kits` marker, which acq no longer reads. To pick up a refreshed built-in bundle on sbx 0.38 you must recreate the sandbox (this discards its session/context): diff --git a/docs/adr/0016-kit-bundle-provenance-and-stale-refresh.md b/docs/adr/0016-kit-bundle-provenance-and-stale-refresh.md index e3284e4f..55ccf3aa 100644 --- a/docs/adr/0016-kit-bundle-provenance-and-stale-refresh.md +++ b/docs/adr/0016-kit-bundle-provenance-and-stale-refresh.md @@ -2,6 +2,7 @@ title: "Record Kit-Bundle Provenance and Offer a Safe Stale-Sandbox Refresh" status: accepted date: 2026-07-30 +last_updated: "2026-10-25" decision_makers: ["William Zujkowski"] category: maintainability nist_controls: ["CM-2", "CM-3", "CM-8", "SA-10", "SI-7"] @@ -23,8 +24,9 @@ patterns repo), **new** sandboxes get the new bundle. **Existing** sandboxes do not: - On **sbx**, `acq_backend_ensure_kits_applied` only injects kits that are - *absent* (a feature-probe). A kit that is present but built from an older ref is - never refreshed. + *not recorded* as applied at the current ref in the host `builtin-kits` record + (the guest feature-probe is a debug-only diagnostic). A kit that is already + recorded but built from an older ref is never refreshed. - On **msb**, every kit is re-applied idempotently each run, so the files can update — but nothing tells the user it happened, and there was no way to answer the basic question "is this sandbox on my current pin?" diff --git a/scripts/verify-backends b/scripts/verify-backends index 4cec0655..2e645875 100755 --- a/scripts/verify-backends +++ b/scripts/verify-backends @@ -996,7 +996,7 @@ verify_backend() { # oci-engine catalog kit, verified separately below. if [ "$backend" = "msb" ]; then if run_acq "exec no-default-oci" --backend "$backend" exec "$name" -- sh -c \ - 'test ! -e /var/lib/acq/oci-ready && ! command -v podman >/dev/null 2>&1'; then + 'test ! -e /var/lib/acq/oci-engine-ready && ! command -v podman >/dev/null 2>&1'; then pass "${backend}: OCI engine is not installed by default" else fail "${backend}: OCI engine appeared without selecting the oci-engine kit" diff --git a/test/bats/72-msb-agent-user.bats b/test/bats/72-msb-agent-user.bats index 9cb90b5c..4380b95a 100644 --- a/test/bats/72-msb-agent-user.bats +++ b/test/bats/72-msb-agent-user.bats @@ -196,7 +196,7 @@ _attach() { # PRE_SNIPPET NAME local log; log=$(cat "$CALLS") refute_regex "$log" 'PODMAN_PKGS=' refute_regex "$log" '/usr/local/bin/docker' - refute_regex "$log" 'oci-ready' + refute_regex "$log" 'oci-engine-ready' refute_regex "$log" '/etc/containers/storage\.conf' refute_regex "$log" 'acq-oci-selftest' refute_regex "$log" '/dev/net/tun' diff --git a/test/bats/81-msb-forged-guest-markers.bats b/test/bats/81-msb-forged-guest-markers.bats new file mode 100644 index 00000000..3bf8c4d7 --- /dev/null +++ b/test/bats/81-msb-forged-guest-markers.bats @@ -0,0 +1,307 @@ +#!/usr/bin/env bats +# +# 81-msb-forged-guest-markers.bats — forged legacy guest markers cannot change +# MSB acq behavior (ADR-0035). +# +# A sandbox's agent has passwordless sudo, so any file under the guest's +# /var/lib/acq/ is attacker-writable. ADR-0035 moved every acq decision that used +# to read those legacy markers onto the host-authoritative config store. These +# tests plant ATTACKER-CHOSEN values behind every legacy guest marker path (the +# `msb` stub below answers any guest read of them) and assert the decisions still +# come from the host store, provenance, or acq's own defaults: +# agent, workspace, SSH_AUTH_SOCK, kit environment[], the install-phase run-once +# gate, and the agent-user-ready gate. +# Each decision is checked both ways: host value present (forged value ignored) and +# host value absent (forged value still never imported). This mirrors the SBX +# extra-kit forged-marker test in 90-sbx-startup-kit.bats. +# +# shellcheck shell=bats + +setup() { acq_setup_stubs; } +teardown() { acq_teardown_stubs; } + +load 'helper' + +FORGED_AGENT=claude +FORGED_WS=/tmp/forged-workspace +FORGED_SOCK=/tmp/forged-agent.sock +FORGED_ENV_LINE=FORGED_KIT_VAR=pwned + +# Legacy guest paths whose reads must never influence acq. /var/lib/acq/host and +# /var/lib/acq/provider-input are the host-authored read-only mounts and are +# deliberately NOT in this set. +LEGACY_MARKER_RE='/var/lib/acq/(agent|workspace|ssh-auth-sock|kit-env|install-|agent-installed|agent-user-ready|oci-ready|oci-engine-ready)' + +# Wrap the shared msb stub so a guest read of any legacy marker returns a forged +# value (content markers) or "exists" (presence gates). Everything else is +# delegated unchanged to the shared stub, which does its own call logging. +_install_forging_msb() { + mv "$STUBDIR/msb" "$STUBDIR/msb-real" + cat >"$STUBDIR/msb" <<'FORGER' +#!/usr/bin/env bash +_forged() { + { _l="msb"; for a in "$@"; do _l="$_l $a"; done; printf '%s\n' "$_l" >>"$CALLS"; } + [ -t 0 ] || cat >/dev/null 2>&1 || true +} +if [ "${1:-}" = "exec" ]; then + all="$*" + case "$all" in + *"/var/lib/acq/agent-user-ready"*|*"/var/lib/acq/agent-installed-"*|*"/var/lib/acq/install-"*|*"/var/lib/acq/oci-ready"*|*"/var/lib/acq/oci-engine-ready"*) + _forged "$@"; exit 0 ;; + *"/var/lib/acq/agent"*) _forged "$@"; printf '%s' "$FORGED_AGENT"; exit 0 ;; + *"/var/lib/acq/workspace"*) _forged "$@"; printf '%s' "$FORGED_WS"; exit 0 ;; + *"/var/lib/acq/ssh-auth-sock"*) _forged "$@"; printf '%s' "$FORGED_SOCK"; exit 0 ;; + *"/var/lib/acq/kit-env"*) _forged "$@"; printf '%s\n' "$FORGED_ENV_LINE"; exit 0 ;; + esac +fi +exec "$(dirname "$0")/msb-real" "$@" +FORGER + chmod +x "$STUBDIR/msb" + export FORGED_AGENT FORGED_WS FORGED_SOCK FORGED_ENV_LINE +} + +# Run a snippet in a fresh shell with msb.sh sourced and the forging stub in place. +_forged_run() { # SNIPPET + : > "$CALLS" + run bash -c ' + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + eval "$1" + ' _ "$1" +} + +@test "forged msb stub: the legacy marker paths really answer with attacker values" { + _install_forging_msb + run msb exec box -u 0 -- sh -c 'cat /var/lib/acq/agent 2>/dev/null' + assert_output "$FORGED_AGENT" + run msb exec box -u 0 -- sh -c 'cat /var/lib/acq/workspace 2>/dev/null' + assert_output "$FORGED_WS" + run msb exec box -u 0 -- sh -c 'cat /var/lib/acq/ssh-auth-sock 2>/dev/null' + assert_output "$FORGED_SOCK" + run msb exec box -u 0 -- sh -c 'cat /var/lib/acq/kit-env 2>/dev/null' + assert_output "$FORGED_ENV_LINE" + run msb exec box -u 0 -- sh -c 'test -f /var/lib/acq/agent-user-ready' + assert_success + run msb exec box -u 0 -- sh -c 'test -f /var/lib/acq/install-123' + assert_success +} + +# --- agent ------------------------------------------------------------------ + +@test "forged marker(msb): attach launches the host-recorded agent, not the forged one" { + _install_forging_msb + _forged_run ' + export STUB_RECORDED_AGENT=opencode STUB_AGENT_PRESENT=1 + seed_host_config msb agentbox + ( _acq_msb_attach agentbox /dev/null 2>&1 ) + ' + local log; log=$(cat "$CALLS") + assert_regex "$log" 'agentbox -- opencode' + refute_regex "$log" "$FORGED_AGENT" + refute_regex "$log" "$LEGACY_MARKER_RE" +} + +@test "forged marker(msb): with no host agent record attach opens a shell, never the forged agent" { + _install_forging_msb + _forged_run ' + export STUB_AGENT_PRESENT=1 + ( _acq_msb_attach noagentbox /dev/null 2>&1 ) + ' + local log; log=$(cat "$CALLS") + assert_regex "$log" 'noagentbox -- /bin/sh -l' + refute_regex "$log" "$FORGED_AGENT" + refute_regex "$log" "$LEGACY_MARKER_RE" + _forged_run 'acq_backend_recorded_agent noagentbox' + assert_output '' +} + +@test "forged marker(msb): trusted provenance, not the forged marker, supplies a legacy sandbox's agent" { + _install_forging_msb + _forged_run ' + export STUB_AGENT_PRESENT=1 + acq_provenance_write msb legacyagentbox opencode /tmp/legacy-repo + ( _acq_msb_attach legacyagentbox /dev/null 2>&1 ) + ' + local log; log=$(cat "$CALLS") + assert_regex "$log" 'legacyagentbox -- opencode' + refute_regex "$log" "$FORGED_AGENT" +} + +# --- workspace -------------------------------------------------------------- + +@test "forged marker(msb): run, attach and shell use the host-recorded workspace" { + _install_forging_msb + _forged_run ' + export STUB_RECORDED_AGENT=opencode STUB_AGENT_PRESENT=1 STUB_RECORDED_WORKSPACE=/tmp/host-workspace + seed_host_config msb wsbox + acq_backend_run wsbox -- git status >/dev/null 2>&1 + ( _acq_msb_attach wsbox /dev/null 2>&1 ) + ( _acq_msb_shell_exec wsbox /dev/null 2>&1 ) + ' + local log; log=$(cat "$CALLS") + assert_regex "$log" 'exec -u agent -e HOME=/home/agent -w /tmp/host-workspace wsbox -- git status' + assert_regex "$log" 'exec -t -u agent -w /tmp/host-workspace .* wsbox -- opencode' + assert_regex "$log" 'exec -t -u agent -w /tmp/host-workspace .* wsbox -- /bin/sh -l' + refute_regex "$log" "$FORGED_WS" + refute_regex "$log" "$LEGACY_MARKER_RE" +} + +@test "forged marker(msb): with no host workspace the session falls back to the agent home, not the forged path" { + _install_forging_msb + _forged_run ' + acq_backend_run nowsbox -- git status >/dev/null 2>&1 + _acq_msb_workspace_for nowsbox + ' + assert_output '/home/agent' + refute_regex "$(cat "$CALLS")" "$FORGED_WS" + refute_regex "$(cat "$CALLS")" "$LEGACY_MARKER_RE" +} + +@test "forged marker(msb): the host workspace record outranks the forged workspace marker" { + _install_forging_msb + _forged_run ' + acq_provenance_write msb legacywsbox opencode /tmp/legacy-repo + _acq_msb_workspace_for legacywsbox + ' + assert_output '/tmp/legacy-repo' + refute_regex "$(cat "$CALLS")" "$LEGACY_MARKER_RE" +} + +# --- SSH_AUTH_SOCK ---------------------------------------------------------- + +@test "forged marker(msb): SSH_AUTH_SOCK comes from the host record on run, attach and shell" { + _install_forging_msb + _forged_run ' + export STUB_RECORDED_AGENT=opencode STUB_AGENT_PRESENT=1 STUB_RECORDED_SSH_AUTH_SOCK=/run/acq/host-agent.sock + seed_host_config msb sockbox + acq_backend_run sockbox -- git status >/dev/null 2>&1 + ( _acq_msb_attach sockbox /dev/null 2>&1 ) + ( _acq_msb_shell_exec sockbox /dev/null 2>&1 ) + ' + local log; log=$(cat "$CALLS") + assert_regex "$log" '-e SSH_AUTH_SOCK=/run/acq/host-agent\.sock .*sockbox -- git status' + assert_regex "$log" '-e SSH_AUTH_SOCK=/run/acq/host-agent\.sock .*sockbox -- opencode' + assert_regex "$log" '-e SSH_AUTH_SOCK=/run/acq/host-agent\.sock .*sockbox -- /bin/sh -l' + refute_regex "$log" "$FORGED_SOCK" + refute_regex "$log" "$LEGACY_MARKER_RE" +} + +@test "forged marker(msb): with no host ssh-auth-sock record no SSH_AUTH_SOCK is injected at all" { + _install_forging_msb + _forged_run ' + acq_backend_run nosockbox -- git status >/dev/null 2>&1 + ( _acq_msb_attach nosockbox /dev/null 2>&1 ) + ( _acq_msb_shell_exec nosockbox /dev/null 2>&1 ) + printf "SOCK=[%s]\n" "$(_acq_msb_ssh_auth_sock_for nosockbox)" + ' + assert_output 'SOCK=[]' + refute_regex "$(cat "$CALLS")" 'SSH_AUTH_SOCK' + refute_regex "$(cat "$CALLS")" "$LEGACY_MARKER_RE" +} + +# --- kit environment[] ------------------------------------------------------ + +@test "forged marker(msb): kit environment replay uses only the host kit-env record" { + _install_forging_msb + _forged_run ' + export STUB_RECORDED_AGENT=opencode STUB_AGENT_PRESENT=1 + export STUB_RECORDED_KIT_ENV="OPENCODE_CONFIG=/home/agent/oc.jsonc" + seed_host_config msb envbox + acq_backend_run envbox -- printenv >/dev/null 2>&1 + ( _acq_msb_attach envbox /dev/null 2>&1 ) + ( _acq_msb_shell_exec envbox /dev/null 2>&1 ) + ' + local log; log=$(cat "$CALLS") + assert_regex "$log" '-e OPENCODE_CONFIG=/home/agent/oc\.jsonc .*envbox -- printenv' + assert_regex "$log" '-e OPENCODE_CONFIG=/home/agent/oc\.jsonc .*envbox -- opencode' + assert_regex "$log" '-e OPENCODE_CONFIG=/home/agent/oc\.jsonc .*envbox -- /bin/sh -l' + refute_regex "$log" 'FORGED_KIT_VAR' + refute_regex "$log" "$LEGACY_MARKER_RE" +} + +@test "forged marker(msb): with no host kit-env record no forged variable reaches any session" { + _install_forging_msb + _forged_run ' + acq_backend_run noenvbox -- printenv >/dev/null 2>&1 + ( _acq_msb_attach noenvbox /dev/null 2>&1 ) + ( _acq_msb_shell_exec noenvbox /dev/null 2>&1 ) + _acq_msb_persisted_kit_env_into _toks noenvbox + printf "TOKENS=%s\n" "${#_toks[@]}" + ' + assert_output 'TOKENS=0' + refute_regex "$(cat "$CALLS")" 'FORGED_KIT_VAR' + refute_regex "$(cat "$CALLS")" "$LEGACY_MARKER_RE" +} + +# --- install-phase run-once gate ------------------------------------------- + +_apply_install_kit() { # NAME (msb forging stub must already be installed) + local name="$1" ik="$STUBDIR/installkit" + mkdir -p "$ik" + cat >"$ik/spec.yaml" <<'SPEC' +schemaVersion: "hybrid/v1" +kind: mixin +name: forge-install-kit +displayName: Forge Install Kit +description: install command whose run-once gate must be host-side +commands: + - phase: install + user: "0" + command: + - echo + - INSTALL_FORGE_PROBE +SPEC + run bash -c ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/forge-secrets" + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_test_stub_provider_input_boundary + _acq_msb_apply_kit_dir "$1" "$2" + ' _ "$name" "$ik" +} + +@test "forged marker(msb): a forged install-* marker cannot suppress the install command; the host gate then does" { + _install_forging_msb + : > "$CALLS" + _apply_install_kit installgate + assert_success + assert_equal "$(grep -c 'INSTALL_FORGE_PROBE' "$CALLS")" 1 + # The run-once gate was recorded on the HOST, not in the guest. + local hostkey; hostkey=$(ls "$ACQ_PROVENANCE_DIR"/msb/installgate.*.config/install-* 2>/dev/null | head -n1) + [ -n "$hostkey" ] + refute_regex "$(cat "$CALLS")" "$LEGACY_MARKER_RE" + # Positive control: a second apply is suppressed by the host gate alone. + _apply_install_kit installgate + assert_success + assert_equal "$(grep -c 'INSTALL_FORGE_PROBE' "$CALLS")" 1 +} + +# --- agent-user-ready gate -------------------------------------------------- + +@test "forged marker(msb): a forged agent-user-ready marker cannot skip agent-user setup" { + _install_forging_msb + _forged_run ' + _acq_msb_ensure_agent_user gatebox + ' + assert_success + local log; log=$(cat "$CALLS") + assert_regex "$log" 'test -w /home/agent' + assert_regex "$log" 'NOPASSWD:ALL' + refute_regex "$log" "$LEGACY_MARKER_RE" + # The host gate was recorded host-side. + local hostkey; hostkey=$(ls "$ACQ_PROVENANCE_DIR"/msb/gatebox.*.config/agent-user-ready 2>/dev/null | head -n1) + [ -n "$hostkey" ] +} + +@test "forged marker(msb): the host agent-user-ready key (not a guest marker) is what skips setup" { + _install_forging_msb + _forged_run ' + acq_host_config_write msb hostgatebox agent-user-ready 1 + _acq_msb_ensure_agent_user hostgatebox + ' + assert_success + local log; log=$(cat "$CALLS") + refute_regex "$log" 'test -w /home/agent' + refute_regex "$log" 'NOPASSWD:ALL' + refute_regex "$log" "$LEGACY_MARKER_RE" +} diff --git a/test/bats/82-msb-legacy-sandbox.bats b/test/bats/82-msb-legacy-sandbox.bats new file mode 100644 index 00000000..1b7cbbb8 --- /dev/null +++ b/test/bats/82-msb-legacy-sandbox.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats +# +# 82-msb-legacy-sandbox.bats — behavior of an msb sandbox created before ADR-0035 +# moved session state from guest /var/lib/acq markers to the host config store. +# +# Such a sandbox has no host-config keys. acq never imports the old guest markers +# (a passwordless-sudo guest could forge them), so each missing key must degrade +# as documented in docs/KNOWN_FAILURE_MODES.md (section 32.1): no SSH_AUTH_SOCK +# until the forward is re-driven, and the install / agent-user gates replay once. +# +# shellcheck shell=bats + +setup() { acq_setup_stubs; } +teardown() { acq_teardown_stubs; } + +load 'helper' + +# Heal a name that has no host-config keys, using an empty local kit so the heal +# does no network fetch. Extra shell is appended to the subshell body. +_heal_legacy() { # NAME + local name="$1" + printf '%s\n' "$name" >"$STUBDIR/.msb_sandbox_list" + printf '%s\n' "$name" >"$STUBDIR/.msb_running_list" + mkdir -p "$STUBDIR/nokit" + printf 'schemaVersion: "hybrid/v1"\nkind: mixin\nname: x\ndisplayName: X\ndescription: x\n' >"$STUBDIR/nokit/spec.yaml" + run bash -c ' + unset SSH_AUTH_SOCK ACQ_FORWARD_HOST_SOCKETS + export STUB_MSB_VERSION=0.6.9 + . "'"$REPO_ROOT"'/acq.backends/common.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + _acq_msb_fetch_kit() { printf "%s\n" "'"$STUBDIR"'/nokit"; } + acq_backend_ensure_kits_applied "'"$name"'" >/dev/null 2>&1 + acq_backend_ensure_kits_applied "'"$name"'" >/dev/null 2>&1 + ' +} + +@test "legacy msb: no host ssh-auth-sock key means no SSH_AUTH_SOCK and no guest marker read" { + : > "$CALLS" + run bash -c ' + export STUB_MSB_VERSION=0.6.9 + . "'"$REPO_ROOT"'/acq.backends/common.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_backend_run legacybox -- git status >/dev/null 2>&1 + ' + local log; log=$(cat "$CALLS") + assert_regex "$log" 'msb exec .*legacybox' + refute_regex "$log" 'SSH_AUTH_SOCK=' + refute_regex "$log" '/var/lib/acq/ssh-auth-sock' +} + +@test "legacy msb: an install-phase command replays once, then the host gate holds" { + : > "$CALLS" + run bash -c ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/legacyinst-secrets" + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_test_stub_provider_input_boundary + lk="'"$STUBDIR"'/legacyinstkit"; mkdir -p "$lk" + cat >"$lk/spec.yaml" <<'"'"'SPEC'"'"' +schemaVersion: "hybrid/v1" +kind: mixin +name: legacy-install-kit +displayName: Legacy Install Kit +description: install command replays once on a sandbox with no host gate +commands: + - phase: install + user: "0" + command: + - printf + - legacy-install-ran +SPEC + _acq_msb_apply_kit_dir legacyinstbox "$lk" + _acq_msb_apply_kit_dir legacyinstbox "$lk" + ' + local runs; runs=$(grep -c 'legacy-install-ran' "$CALLS" || true) + assert_equal "$runs" "1" + local gate; gate=$(ls "$ACQ_PROVENANCE_DIR"/msb/legacyinstbox.*.config/install-* 2>/dev/null | head -n1) + [ -n "$gate" ] +} + +@test "legacy msb: the agent-user setup replays once on heal, then the host gate holds" { + : > "$CALLS" + _heal_legacy legacyuserbox + local runs; runs=$(grep -c 'NOPASSWD:ALL' "$CALLS" || true) + assert_equal "$runs" "1" + run test -f "$(ls -d "$ACQ_PROVENANCE_DIR"/msb/legacyuserbox.*.config)/agent-user-ready" + assert_success +} diff --git a/test/bats/90-sbx-startup-kit.bats b/test/bats/90-sbx-startup-kit.bats index b12f4f31..18c9c58c 100644 --- a/test/bats/90-sbx-startup-kit.bats +++ b/test/bats/90-sbx-startup-kit.bats @@ -49,6 +49,39 @@ _seed_stale_provenance() { "$f" > "$f.seed" && mv -f "$f.seed" "$f" } +# Record all three heal-consulted built-in kits as applied at the current pinned +# refs in the host config store (what create / a successful heal writes). +_record_builtin_kits() { + acq_host_config_write sbx "$1" builtin-kits "zscaler-ca-certificate ${ZSCALER_KIT} +${USAI_PROVIDER_KIT_NAME} ${USAI_KIT} +agentic-coding-playbook ${PLAYBOOK_KIT}" +} + +# Plant an sbx stub that reports EVERY feature probe as present (what a sudo guest +# that touched the probed files would answer) and accepts `kit add`. +_plant_all_present_stub() { + cat >"$STUBDIR/sbx" <<'STUB' +#!/usr/bin/env bash +{ printf 'sbx'; for a in "$@"; do printf ' %s' "$a"; done; printf '\n'; } >>"$CALLS" +case "${1:-}" in + version) printf 'sbx version: v0.39.0 abc123\n' ;; + ls) [ -f "$STUBDIR/.sandbox_list" ] && cat "$STUBDIR/.sandbox_list"; exit 0 ;; + exec) + snippet=""; prev="" + for a in "$@"; do [ "$prev" = "-c" ] && { snippet="$a"; break; }; prev="$a"; done + case "$snippet" in + *"echo ok"*) printf 'ok\n' ;; + *present*) printf 'present\n' ;; + *) exit 0 ;; + esac ;; + kit) exit 0 ;; + settings) exit 0 ;; + *) exit 0 ;; +esac +STUB + chmod +x "$STUBDIR/sbx" +} + @test "heal(sbx 0.38): a startup refusal yields consolidated recreate guidance, status unknown" { _plant_refusal_stub printf 'refusebox\n' > "$STUBDIR/.sandbox_list" @@ -164,10 +197,12 @@ esac STUB chmod +x "$STUBDIR/sbx" printf 'probebox\n' > "$STUBDIR/.sandbox_list" + _record_builtin_kits probebox ( unset EMAIL GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL unset GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL - HOME="$STUBDIR/nohome" XDG_CONFIG_HOME="$STUBDIR/noconfig" \ + # The probe is a non-authoritative diagnostic: it only runs under ACQ_DEBUG. + ACQ_DEBUG=1 HOME="$STUBDIR/nohome" XDG_CONFIG_HOME="$STUBDIR/noconfig" \ acq_backend_ensure_kits_applied probebox >/dev/null 2>&1 || true ) local log; log=$(cat "$CALLS") @@ -373,11 +408,167 @@ STUB local kit='git+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=deadbeef&dir=some-extra-kit' printf 'hostbox\n' > "$STUBDIR/.sandbox_list" acq_host_config_write sbx hostbox extra-kits "$kit" + _record_builtin_kits hostbox ACQ_EXTRA_KITS="$kit" run acq_backend_ensure_kits_applied hostbox refute_regex "$(cat "$CALLS")" 'sbx kit add hostbox' refute_output --partial 'unexpected kit apply' } +@test "heal(sbx): guest probes reporting present cannot suppress built-ins when the host record is absent" { + _plant_all_present_stub + printf 'nohostrec\n' > "$STUBDIR/.sandbox_list" + run acq_backend_ensure_kits_applied nohostrec + assert_output --partial 'missing the Zscaler CA kit' + assert_output --partial 'missing the USAi kit' + assert_output --partial 'missing the playbook kit' + local n; n=$(grep -c '^sbx kit add nohostrec ' "$CALLS" || true) + [ "$n" -ge 3 ] +} + +@test "heal(sbx): a stale host built-in record (older ref) re-applies despite present probes" { + _plant_all_present_stub + printf 'stalerec\n' > "$STUBDIR/.sandbox_list" + acq_host_config_write sbx stalerec builtin-kits "zscaler-ca-certificate git+https://example.invalid/old.git#ref=oldoldold&dir=zscaler-ca-certificate +${USAI_PROVIDER_KIT_NAME} ${USAI_KIT} +agentic-coding-playbook ${PLAYBOOK_KIT}" + run acq_backend_ensure_kits_applied stalerec + assert_output --partial 'missing the Zscaler CA kit' + refute_output --partial 'missing the USAi kit' + refute_output --partial 'missing the playbook kit' + # The stale line is replaced, not duplicated, once the re-apply succeeds. + assert_equal "$(acq_host_config_read sbx stalerec builtin-kits | grep -c '^zscaler-ca-certificate ')" "1" + acq_host_config_read sbx stalerec builtin-kits | grep -Fxq -- "zscaler-ca-certificate ${ZSCALER_KIT}" +} + +@test "heal(sbx): a partial host record applies only the unrecorded built-in kits" { + _plant_all_present_stub + printf 'partialrec\n' > "$STUBDIR/.sandbox_list" + acq_host_config_write sbx partialrec builtin-kits "zscaler-ca-certificate ${ZSCALER_KIT}" + run acq_backend_ensure_kits_applied partialrec + refute_output --partial 'missing the Zscaler CA kit' + assert_output --partial 'missing the USAi kit' + assert_output --partial 'missing the playbook kit' +} + +@test "heal(sbx): a complete host record suppresses re-apply even if the guest probes report absent" { + _plant_refusal_stub + printf 'fullrec\n' > "$STUBDIR/.sandbox_list" + _record_builtin_kits fullrec + run acq_backend_ensure_kits_applied fullrec + assert_success + refute_regex "$(cat "$CALLS")" 'sbx kit add fullrec' + refute_output --partial 'missing the' +} + +@test "heal(sbx): a successful heal records built-ins so the next heal skips them" { + _plant_all_present_stub + printf 'recordbox\n' > "$STUBDIR/.sandbox_list" + run acq_backend_ensure_kits_applied recordbox + assert_success + local rec; rec=$(acq_host_config_read sbx recordbox builtin-kits) + grep -Fxq -- "zscaler-ca-certificate ${ZSCALER_KIT}" <<<"$rec" + grep -Fxq -- "${USAI_PROVIDER_KIT_NAME} ${USAI_KIT}" <<<"$rec" + grep -Fxq -- "agentic-coding-playbook ${PLAYBOOK_KIT}" <<<"$rec" + : > "$CALLS" + run acq_backend_ensure_kits_applied recordbox + assert_success + refute_regex "$(cat "$CALLS")" 'sbx kit add recordbox' +} + +@test "heal(sbx): a refused built-in kit add is NOT recorded as applied" { + _plant_refusal_stub + printf 'refusedrec\n' > "$STUBDIR/.sandbox_list" + run acq_backend_ensure_kits_applied refusedrec + assert_equal "$(acq_host_config_read sbx refusedrec builtin-kits)" '' +} + +@test "heal(sbx): ACQ_FORCE_KIT_REAPPLY re-applies built-ins despite a complete host record" { + _plant_all_present_stub + printf 'forcerec\n' > "$STUBDIR/.sandbox_list" + _record_builtin_kits forcerec + ACQ_FORCE_KIT_REAPPLY=1 run acq_backend_ensure_kits_applied forcerec + assert_output --partial 'missing the Zscaler CA kit' + assert_output --partial 'missing the USAi kit' + assert_output --partial 'missing the playbook kit' +} + +@test "heal(sbx): the guest probe is not even consulted without ACQ_DEBUG" { + _plant_all_present_stub + printf 'quietrec\n' > "$STUBDIR/.sandbox_list" + _record_builtin_kits quietrec + : > "$CALLS" + run acq_backend_ensure_kits_applied quietrec + refute_regex "$(cat "$CALLS")" 'ca-certificates/zscaler-ca\.crt' +} + +@test "heal(sbx): a diagnostic probe reporting absent does not override the host record" { + _plant_refusal_stub + printf 'diagrec\n' > "$STUBDIR/.sandbox_list" + _record_builtin_kits diagrec + ACQ_DEBUG=1 run acq_backend_ensure_kits_applied diagrec + assert_output --partial 'diagnostic only' + refute_regex "$(cat "$CALLS")" 'sbx kit add diagrec' +} + +@test "provision(sbx): built-in kits are recorded in host config at create" { + : > "$CALLS" + ( + acq_test_stub_provider_input_boundary + acq_backend_provision builtinseedbox shell /tmp + ) >/dev/null 2>&1 || true + local rec; rec=$(acq_host_config_read sbx builtinseedbox builtin-kits) + grep -Fxq -- "zscaler-ca-certificate ${ZSCALER_KIT}" <<<"$rec" + grep -Fxq -- "${USAI_PROVIDER_KIT_NAME} ${USAI_KIT}" <<<"$rec" + grep -Fxq -- "agentic-coding-playbook ${PLAYBOOK_KIT}" <<<"$rec" +} + +@test "provision(sbx): an empty provider selection seeds no USAi built-in record" { + : > "$CALLS" + ( + acq_test_stub_provider_input_boundary + # shellcheck disable=SC2034 # acq_usai_provider_selected reads these globals + ACQ_PROVIDER_KITS="" + # shellcheck disable=SC2034 + ACQ_PROVIDER_KITS_FROM_ENV=1 + # shellcheck disable=SC2034 + ACQ_PROVIDER_KITS_FROM_CLI="" + acq_backend_provision noproviderbox shell /tmp + ) >/dev/null 2>&1 || true + local rec; rec=$(acq_host_config_read sbx noproviderbox builtin-kits) + grep -Fxq -- "zscaler-ca-certificate ${ZSCALER_KIT}" <<<"$rec" + grep -Fxq -- "agentic-coding-playbook ${PLAYBOOK_KIT}" <<<"$rec" + refute_regex "$rec" "${USAI_PROVIDER_KIT_NAME}" +} + +@test "clear-instance-state(sbx): drops stale builtin-kits and extra-kits host keys" { + acq_host_config_write sbx stalestate extra-kits "git+https://example.invalid/x.git#ref=abc&dir=x" + _record_builtin_kits stalestate + assert_regex "$(acq_host_config_read sbx stalestate builtin-kits)" 'zscaler-ca-certificate' + assert_regex "$(acq_host_config_read sbx stalestate extra-kits)" 'example\.invalid' + acq_host_config_clear_instance_state sbx stalestate + assert_equal "$(acq_host_config_read sbx stalestate builtin-kits)" '' + assert_equal "$(acq_host_config_read sbx stalestate extra-kits)" '' +} + +@test "provision(sbx): a FAILED create writes no host built-in record" { + cat >"$STUBDIR/sbx" <<'STUB' +#!/usr/bin/env bash +{ printf 'sbx'; for a in "$@"; do printf ' %s' "$a"; done; printf '\n'; } >>"$CALLS" +case "${1:-}" in + version) printf 'sbx version: v0.39.0 abc123\n' ;; + create) printf 'ERROR: create failed\n' >&2; exit 1 ;; + exec) printf 'ok\n' ;; + *) exit 0 ;; +esac +STUB + chmod +x "$STUBDIR/sbx" + ( + acq_test_stub_provider_input_boundary + acq_backend_provision failbuiltinbox shell /tmp + ) >/dev/null 2>&1 || true + assert_equal "$(acq_host_config_read sbx failbuiltinbox builtin-kits)" '' +} + @test "agent-kit-heal(sbx): forced refresh includes recorded agent kit" { cat >"$STUBDIR/sbx" <<'STUB' #!/usr/bin/env bash From 97775b4a8c438f0e8441648c1877b5b0e3160e38 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 18:23:30 +0000 Subject: [PATCH 33/63] fix(msb): decide readonly host-config mount from host-recorded create state The heal chose between staged :ro startup code and a guest-writable copy by running a probe inside the guest, which a passwordless-sudo agent can steer. Record ro-host-config on the host only after a successful create that passed the :ro volume, read that key in the heal, and treat an absent record as the safe legacy path. The guest probe is now diagnostic only and warns on mismatch without changing the decision. Co-authored-by: OpenCode Agent --- acq.backends/common.sh | 2 +- acq.backends/msb.sh | 24 +++ .../0035-host-authoritative-sandbox-config.md | 12 +- test/bats/83-msb-ro-host-config-record.bats | 154 ++++++++++++++++++ 4 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 test/bats/83-msb-ro-host-config-record.bats diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 0925c315..7f5becc4 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -3186,7 +3186,7 @@ acq_host_config_clear_instance_state() { rm -f "$dir"/install-* "$dir"/agent-installed-* \ "$dir"/agent-user-ready \ "$dir"/workspace "$dir"/ssh-auth-sock "$dir"/generic-opencode \ - "$dir"/extra-kits "$dir"/builtin-kits 2>/dev/null || true + "$dir"/extra-kits "$dir"/builtin-kits "$dir"/ro-host-config 2>/dev/null || true return 0 } diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index a82b0be6..aa70690d 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -1874,7 +1874,24 @@ _acq_msb_reset_kit_env() { acq_host_config_clear msb "$1" kit-env } +# Whether sandbox NAME's trusted :ro host-config mount may be relied on for +# readonly kit code. The decision comes from the HOST-recorded create fact +# (key ro-host-config, written by acq_backend_provision only after a successful +# create that passed the :ro --volume), never from a guest command: a +# passwordless-sudo guest controls `test`/`sh` and could otherwise force the +# guest-copy downgrade. A sandbox with no record (legacy, or a create that never +# completed) takes the safe compatibility path: the caller copies the file and +# prints its warning. The guest probe below is diagnostic only: it can warn +# about a mismatch but never changes the decision. _acq_msb_host_config_mount_available() { + local name="$1" + [ "$(acq_host_config_read msb "$name" ro-host-config)" = "1" ] || return 1 + _acq_msb_host_config_mount_probe "$name" || \ + echo "acq(msb): warning: sandbox '$name' was created with the read-only host-config mount but a guest probe does not confirm it; using the host-recorded state. Recreate the sandbox if this persists." >&2 + return 0 +} + +_acq_msb_host_config_mount_probe() { local name="$1" _acq_msb_cli exec "$name" -u agent -- sh -c \ "test -d '$ACQ_HOST_CONFIG_GUEST_DIR' && test -x '$ACQ_HOST_CONFIG_GUEST_DIR'" \ @@ -3832,6 +3849,13 @@ EOF # instance-scoped values before this fresh provision records replacement values. acq_host_config_clear_instance_state msb "$name" + # Host-recorded create fact for ADR-0035 Mechanism 2: this sandbox was created + # (create succeeded, exec-ready) with the host-config dir mounted :ro. Written + # only here so a failed create records nothing; heal consults this key instead + # of a guest probe. A write failure leaves it absent (the safe legacy path). + acq_host_config_write msb "$name" ro-host-config 1 || \ + echo "acq(msb): warning: could not record the read-only host-config mount for '$name'; readonly kit files will use the guest-copy compatibility path." >&2 + # Verify the kits' runtime prerequisites are present in the base image # (node/git/curl/update-ca-certificates). We do NOT install them: the kit # net-rules lock egress to the kits' own hosts, so a package mirror is diff --git a/docs/adr/0035-host-authoritative-sandbox-config.md b/docs/adr/0035-host-authoritative-sandbox-config.md index f8642574..754b7606 100644 --- a/docs/adr/0035-host-authoritative-sandbox-config.md +++ b/docs/adr/0035-host-authoritative-sandbox-config.md @@ -167,9 +167,15 @@ Concretely, this ADR introduces two mechanisms and a staged migration. mount/export semantics, not Unix ownership inside the guest: sbx already enforces read-only extra mounts, and msb 0.7.2 was live-verified to export the virtiofs mount read-only even when guest root can make the guest-visible mount - flag appear `rw`. acq also probes that guest root cannot create a file in the - mount before it rewrites `readonly: true` startup code to that path; if the - probe fails, acq falls back to the legacy guest-copy path and warns. If a future + flag appear `rw`. Whether a sandbox may rely on the mount for `readonly: true` + startup code is a **host-recorded create fact** (host key `ro-host-config`, + written only after a successful create that passed the `:ro` volume and became + exec-ready), not the answer of a guest command, which a passwordless-sudo guest + could forge to force the guest-copy downgrade. A sandbox with no record + (created before the key existed, or never fully created) takes the legacy + guest-copy path and warns. A guest-executed probe of the mount remains as a + diagnostic only: a mismatch with the record warns but never changes the + decision. If a future msb changes `--volume ...:ro` parsing or enforcement, acq must raise or gate its minimum msb version before relying on ADR-0035. Host and guest path forms follow ADR-0029. diff --git a/test/bats/83-msb-ro-host-config-record.bats b/test/bats/83-msb-ro-host-config-record.bats new file mode 100644 index 00000000..eef68461 --- /dev/null +++ b/test/bats/83-msb-ro-host-config-record.bats @@ -0,0 +1,154 @@ +#!/usr/bin/env bats +# +# 83-msb-ro-host-config-record.bats — ADR-0035 Mechanism 2: whether the :ro +# host-config mount may carry readonly kit code is a HOST-recorded create fact +# (host key ro-host-config), never the answer of a guest-executed probe. +# +# A passwordless-sudo guest controls `sh`/`test`, so a probe result must not be +# able to flip the staged-:ro path to the guest-writable copy (or the reverse). +# The stubs below answer the guest probe CONTRARY to the host record. +# +# shellcheck shell=bats + +setup() { acq_setup_stubs; } +teardown() { acq_teardown_stubs; } + +load 'helper' + +_ro_kit() { # DIR + local k="$1"; mkdir -p "$k/files" + printf 'console.log("merge");\n' > "$k/files/merge.mjs" + cat >"$k/spec.yaml" <<'SPEC' +schemaVersion: "hybrid/v1" +kind: mixin +name: ro-code-kit +displayName: RO Code Kit +description: trusted startup code +files: + - path: /home/agent/cfg/merge.mjs + mode: "0755" + source: files/merge.mjs + readonly: true +commands: + - phase: startup + user: "1000" + command: + - node + - /home/agent/cfg/merge.mjs +SPEC +} + +# Apply KITDIR to NAME; extra env (KEY=VAL ...) precedes "--"; set RECORD=1 to +# seed the host record first. +_apply() { # NAME KITDIR RECORD [ENV...] + local name="$1" kit="$2" record="$3"; shift 3 + : > "$CALLS" + run bash -c ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-'"$name"'" + for kv in '"${*:-:}"'; do export "$kv" 2>/dev/null || true; done + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_test_stub_provider_input_boundary + [ "'"$record"'" = 1 ] && acq_host_config_write msb "'"$name"'" ro-host-config 1 + _acq_msb_apply_kit_dir "'"$name"'" "'"$kit"'" 2>&1 + ' +} + +@test "ro record: host record wins when the guest probe reports the mount missing" { + local k="$STUBDIR/k1"; _ro_kit "$k" + _apply recmissing "$k" 1 STUB_HOST_CONFIG_MOUNT=0 + assert_success + local log; log=$(cat "$CALLS") + assert_regex "$log" 'node /var/lib/acq/host/kit-files/' + refute_regex "$log" 'msb copy .*recmissing:/home/agent/cfg/merge\.mjs' + assert_output --partial 'guest probe does not confirm it' +} + +@test "ro record: host record wins when the guest probe reports the mount writable" { + local k="$STUBDIR/k2"; _ro_kit "$k" + _apply recwritable "$k" 1 STUB_HOST_CONFIG_WRITABLE=1 + assert_success + local log; log=$(cat "$CALLS") + assert_regex "$log" 'node /var/lib/acq/host/kit-files/' + refute_regex "$log" 'msb copy .*recwritable:/home/agent/cfg/merge\.mjs' + assert_output --partial 'guest probe does not confirm it' +} + +@test "ro record: a confirming probe is silent" { + local k="$STUBDIR/k3"; _ro_kit "$k" + _apply recquiet "$k" 1 + assert_success + refute_output --partial 'guest probe does not confirm it' + assert_regex "$(cat "$CALLS")" 'node /var/lib/acq/host/kit-files/' +} + +@test "ro record: no record takes the safe path even when the guest probe says read-only" { + local k="$STUBDIR/k4"; _ro_kit "$k" + _apply norecord "$k" 0 + assert_success + local log; log=$(cat "$CALLS") + assert_regex "$log" 'msb copy .*norecord:/home/agent/cfg/merge\.mjs' + refute_regex "$log" '/var/lib/acq/host/kit-files' + assert_output --partial 'has no readable or read-only ADR-0035 host-config mount' +} + +@test "ro record: a value other than 1 is not a record" { + local k="$STUBDIR/k5"; _ro_kit "$k" + run bash -c ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-badval" + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_test_stub_provider_input_boundary + acq_host_config_write msb badval ro-host-config 0 + _acq_msb_apply_kit_dir badval "'"$k"'" 2>&1 + ' + assert_success + refute_regex "$(cat "$CALLS")" '/var/lib/acq/host/kit-files' +} + +@test "ro record: successful provision records ro-host-config" { + local k="$STUBDIR/k6"; _ro_kit "$k" + run bash -c ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-prov" + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_test_stub_provider_input_boundary + _acq_msb_fetch_kit() { printf "%s\n" "'"$k"'"; } + acq_backend_provision recprov shell /tmp >/dev/null 2>&1 + ' + local cfg; cfg=$(ls -d "$ACQ_PROVENANCE_DIR"/msb/recprov.*.config | head -n1) + assert_equal "$(cat "$cfg/ro-host-config")" "1" +} + +@test "ro record: a failed create records nothing" { + local k="$STUBDIR/k7"; _ro_kit "$k" + run bash -c ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-fail" + export STUB_MSB_CREATE_FAIL_IMAGE=docker.io/docker/sandbox-templates:shell-docker + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_test_stub_provider_input_boundary + _acq_msb_fetch_kit() { printf "%s\n" "'"$k"'"; } + acq_backend_provision recfail shell /tmp >/dev/null 2>&1 + ' + assert_failure + assert_regex "$(cat "$CALLS")" 'msb create ' + run bash -c 'ls "'"$ACQ_PROVENANCE_DIR"'"/msb/recfail.*.config/ro-host-config 2>&1' + assert_failure +} + +@test "ro record: stale record from a same-named removed sandbox is cleared on fresh provision" { + local k="$STUBDIR/k8"; _ro_kit "$k" + run bash -c ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-stale" + . "'"$REPO_ROOT"'/acq.backends/common.sh" + acq_host_config_write msb stalebox ro-host-config 1 + acq_host_config_clear_instance_state msb stalebox + acq_host_config_has msb stalebox ro-host-config + ' + assert_failure +} From 7d480d096398bdd91375cbbf9b4ca9d27ebc118a Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 18:24:36 +0000 Subject: [PATCH 34/63] docs: record accepted residual risk for msb passwd shell and sbx barrier The msb session shell is read from the guest passwd entry and the sbx startup barrier is a guest file check. Neither feeds an acq trust decision: the shell runs as the unprivileged agent user and the barrier only orders post-create steps. A host-recorded shell would go stale against chsh and image defaults, and a host-authoritative barrier needs sbx support that is unverified. Record both as accepted residual risk with revisit triggers. Co-authored-by: OpenCode Agent --- docs/KNOWN_FAILURE_MODES.md | 71 +++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/docs/KNOWN_FAILURE_MODES.md b/docs/KNOWN_FAILURE_MODES.md index 77f8c88d..8ac2b8ce 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -2992,6 +2992,77 @@ the fix), and covers a transient and a persistent inspect failure via the stub's `STUB_MSB_INSPECT_FAIL_TIMES` knob. In new code, do not pipe into `grep -q` (or `head`) under `pipefail` when the writer may still be producing output. +## 46. msb Session Shell Is Read From the Guest Passwd Entry (Accepted Residual Risk) + +**Status:** Accepted / observed. No code change: a host-recorded shell was +evaluated and rejected. + +### Observation + +`_acq_msb_agent_passwd_shell` reads the `agent` account's login shell from the +guest (`getent passwd agent` / `/etc/passwd` via `msb exec`). The value is +charset-guarded to a word-safe absolute path (anything else becomes `/bin/sh`), +exported as `SHELL`, and the session then runs that shell as the unprivileged +`agent` user inside the guest. A passwordless-sudo guest can therefore choose +which shell binary its own next interactive session starts. + +### Why this is accepted + +- **No privilege or boundary change.** The shell runs as `agent`, the same user + and filesystem the guest already controls. The agent can equally edit + `~/.profile`, `~/.bashrc`, or any binary on its `PATH`; choosing the login shell + grants nothing new. No host path, host credential, or acq decision (agent, + workspace, SSH socket, kit environment, run-once gates) is derived from it. +- **A host record would be wrong, not safer.** The passwd shell is legitimately + guest state: `chsh`, image defaults, and `_acq_msb_ensure_agent_shell` (which + prefers bash when the image has it) all change it, and a host copy would go + stale or override a deliberate in-guest choice. +- **The charset guard and `/bin/sh` fallback already bound it** to a plain + absolute path in argv, never shell syntax. + +### Revisit if + +A future acq feature runs the shell as root or with host-derived secrets in its +environment, or uses the shell path as an input to a host-side decision. + +--- + +## 47. SBX Startup Barrier Is a Guest File Check (Accepted Residual Risk) + +**Status:** Accepted / observed. No code change: the barrier cannot be made +host-authoritative without sbx features that have not been verified. + +### Observation + +After `sbx create`, acq waits for `/tmp/acq/startup-complete` inside the sandbox +to contain a random per-create token (`_acq_sbx_wait_for_startup_barrier`). The +token is generated on the host and delivered in a generated barrier kit whose +startup command writes it, so the host side of the comparison is authoritative, +but the signal itself is a guest file. A guest that can read the token (or that +can alter how the startup command is replayed, which is unverified on sbx) could +make acq believe startup finished. + +### Why this is accepted + +- **The barrier is a readiness/liveness gate, not an integrity gate.** Its only + consequence is ordering: acq proceeds to its post-create steps (host-config + record writes, stale-state clear, attach) before the guest finished its own + startup commands. The failure surface is the guest breaking its own setup. +- **No acq trust decision reads it.** The values acq acts on across restarts + (`extra-kits`, `builtin-kits`, `generic-opencode`, provenance) are host-written + and independent of this file. +- **A host-authoritative signal needs sbx support not verified here.** The only + candidates are a backend-reported startup status or a host-visible channel out + of the guest. Neither is documented in the sbx CLI that this repository + targets, and adding one by guessing would trade a low-impact residual risk for + an unverifiable mechanism. The related trusted-startup question for SBX is + analyzed in ADR-0036 (SBX trusted-startup parity). + +### Revisit if + +sbx exposes a startup-complete status through `sbx inspect` or an equivalent +host-side API, or a read-only host channel into the sandbox. + --- ## Contributing From b82613e5ee68da60ee06a2b9792ab25064e774d4 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 18:26:49 +0000 Subject: [PATCH 35/63] docs(adr): propose sbx trusted-startup parity options Compare native startup replay integrity, a dedicated read-only host mount, exec-time injection, and accept-and-document for sbx. Recommend documenting the interim state and the read-only mount as the implementation path, gated on a listed live validation because sbx mount enforcement and replay integrity under a sudo guest are unverified. No runtime code. Co-authored-by: OpenCode Agent --- docs/adr/0036-sbx-trusted-startup-parity.md | 209 ++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docs/adr/0036-sbx-trusted-startup-parity.md diff --git a/docs/adr/0036-sbx-trusted-startup-parity.md b/docs/adr/0036-sbx-trusted-startup-parity.md new file mode 100644 index 00000000..1b10c67a --- /dev/null +++ b/docs/adr/0036-sbx-trusted-startup-parity.md @@ -0,0 +1,209 @@ +--- +title: "SBX Trusted-Startup Parity (read-only host mount vs native replay vs exec-time injection)" +status: proposed +date: 2026-10-25 +decision_makers: ["Bret Mogilefsky"] +category: architecture +nist_controls: ["AC-3", "AC-6", "CM-5", "CM-6", "SC-7", "SI-7", "SI-17"] +impact_level: moderate +ato_relevance: no +risk_treatment: mitigate +supersedes: [] +--- + +# ADR-0036: SBX Trusted-Startup Parity + +## Context and Problem Statement + +[ADR-0035](0035-host-authoritative-sandbox-config.md) Mechanism 2 makes a kit's +`readonly: true` startup code tamper-resistant on msb: acq stages the pinned file +on the host, mounts the per-sandbox config directory `:ro`, and runs the heal from +the mounted path, so a passwordless-sudo guest cannot rewrite the code acq +re-executes on restart. + +SBX has no equivalent. `acq_backend_provision` for sbx creates the host config +directory but never mounts it (it holds host-only keys such as `extra-kits`, +`builtin-kits`, and `generic-opencode`), and the sbx kit translator does not +consume the `readonly` field. What an SBX restart runs is whatever sbx registered +from the kit's `setup.startup` at create, replayed natively on every sandbox +start. Two facts about that path are **not established**: + +1. Where sbx keeps the registered startup command and whether a sudo guest can + alter it (or the files it references) between create and the next start. +2. Whether the files a startup command invokes (delivered under `files/home/` or + `setup.files`) are guest-writable after delivery. They are: the guest owns its + filesystem, so these are the same guest-resident copies ADR-0035 rejects. + +Scope today is small. An audit of the pinned Patterns release (v1.17.0) found no +`acq-kits/*/spec.yaml` that declares `readonly: true`, so Mechanism 2 has no +consumer on either backend, and the SBX gap is latent rather than exploitable by a +shipped kit. Re-check this against the pin in force when the ADR is accepted. +This ADR decides the direction so that the first kit to opt in does not silently +get weaker guarantees on SBX than on msb. + +`sbx kit add` of startup-bearing kits is refused on sbx >= 0.38 +(`docs/KNOWN_FAILURE_MODES.md` section 36), so mid-life repair is already +"recreate the sandbox"; this ADR concerns create-time delivery and restart replay +only. + +## Decision Drivers + +- **Same rule as ADR-0035**: acq-trusted code is host-materialized and `:ro`, or + host-held and exec-injected; guest-resident copies are not trusted. +- **Do not claim what is unverified.** The sbx documentation describes startup + commands as registered at create and replayed "on each subsequent sandbox + start" ([Kits v2](https://docs.docker.com/ai/sandboxes/customize/kits/)) but + says nothing about integrity of that registration against a sudo guest. +- **Use only sbx behavior acq already depends on.** Read-only extra workspace + positionals (`PATH:ro`, after the read/write primary workspace) are already used + for provider inputs (`acq_provider_input_sbx_mount`), and sbx mounts a workspace + at the same absolute path as on the host + ([Architecture](https://docs.docker.com/ai/sandboxes/architecture/)). +- **No host-side code execution** and no new network or credential surface. +- **Windows path forms** (ADR-0029): a host-path-identical guest mount must be + expressible there. +- **Proportionality**: the sandbox is the boundary (ADR-0005); the residual risk + is a guest steering its own restart behavior, not a host escape. + +## Considered Options + +### Option A: Rely on sbx native startup replay integrity + +Treat sbx's registered `setup.startup` as trusted and do nothing. + +- Pros: no code; matches how sbx is designed to be used. +- Cons: integrity is unverified (see above); does not cover files the startup + command invokes, which remain guest-writable; no parity with msb Mechanism 2. +- Verdict: **not acceptable as a claim** without live validation of both points. + Acceptable only as a documented, bounded residual risk. + +### Option B: Dedicated read-only host mount for trusted kit files on SBX + +Stage `readonly: true` files on the host, as msb does, into a **dedicated** +directory (not the whole config directory) and pass it as an extra `PATH:ro` +positional after the workspace, exactly like the provider-input generation. The +SBX translator rewrites startup argv tokens that equal a `readonly` file's guest +path to the mount path before emitting `setup.startup`. + +- Pros: reuses a positional form acq already ships; restart replay executes + host-materialized code even if sbx's registration is replayed verbatim; the + mount persists with the sandbox, so restarts need no acq involvement. +- Cons: the guest sees the mount at the **host's absolute path**, not + `/var/lib/acq/host`, so the rewritten argv embeds a host path (Windows forms per + ADR-0029); the dedicated directory must contain only non-sensitive, public, + pinned kit payload (traversable `0711`, files `0555`, per ADR-0035), never the + flat `0600` keys, so the whole config directory must not be mounted; the + translator gains a `readonly` consumer; behavior with `--clone` and mountless + sandboxes (sbx >= 0.42) must be confirmed; the registration itself (the argv + naming the mount path) is still sbx-held state (Option A's unverified point 1), + though tampering with it can only redirect execution, not substitute trusted + content. +- Verdict: **feasible, unverified live.** + +### Option C: Exec-time injection by acq + +On each attach/heal, acq runs the trusted startup commands itself with +`sbx exec`, feeding the code from the host (stdin or per-run copy), and ships kits +to sbx without those startup commands. + +- Pros: acq owns the code path completely, as on msb's heal. +- Cons: sbx's native replay is lost for those commands, so restarts that do not go + through acq (sbx-native stop/start, `sbx run` outside acq) skip them; the + per-run copy reintroduces the TOCTOU window ADR-0035 rejected (Option 2 there); + startup-bearing kits can no longer be translated one-to-one, which departs from + the neutral-kit contract (ADR-0011); larger code change. +- Verdict: **rejected** unless Option B is shown impossible. + +### Option D: Accept and document + +Record that SBX trusted-restart parity does not exist, rely on native replay, +and require that no kit mark startup code `readonly: true` on SBX until a +mechanism is validated (the translator would warn, not silently degrade). + +- Pros: zero risk of an unverified mechanism; honest; no code. +- Cons: SBX users get weaker restart integrity than msb for any kit that opts in. +- Verdict: **correct interim state**, because no shipped kit opts in. + +## Decision Outcome (proposed) + +**Adopt Option D now and Option B as the implementation path, gated on live +validation and on the first shipped kit that sets `readonly: true`.** Do not +implement Option C. Do not implement runtime code for Option B until the live +validation below passes, because its integrity claim rests on sbx behavior +(mount enforcement under a sudo guest, restart persistence) that has not been +observed. + +Until then the SBX translator MUST NOT silently treat `readonly: true` as +satisfied. When the first kit opts in, the SBX path must either implement +Option B or emit a visible warning that the file is a guest copy on SBX. + +## Live Validation Required Before Implementing Option B + +Run on a real sbx host (cannot run inside a sandbox). Record exact sbx version. + +1. **Mount form**: `sbx create ... :ro` with a dedicated + directory. Confirm the guest path equals the host absolute path, the primary + workspace stays read/write, and a second `:ro` positional composes with the + provider-input generation. +2. **Enforcement under sudo**: as guest root, attempt a write, create, rename, + `chmod`, and `mount -o remount,rw` on the directory and on a file in it. + Confirm all fail and the host copy is unchanged (ADR-0035 recorded this for msb + 0.7.2; it has not been recorded for sbx). +3. **Permissions**: the guest `agent` (uid 1000) can execute a `0555` file under a + `0711` directory it cannot list, with host files owned by the host user. +4. **Restart persistence**: after `sbx stop`/start and after a host reboot, the + mount is present and a startup command referencing it runs; after the host + directory is removed, startup fails closed rather than running a guest copy. +5. **Native replay integrity (also settles Option A)**: locate where sbx stores + the registered startup command (`sbx kit inspect`, then the guest and VM + filesystem). As guest root, edit the registered command and each file it + references, restart, and record what executes. This determines how much the + registration itself can be trusted. +6. **Sandbox modes**: repeat 1 and 4 with `--clone` and, on sbx >= 0.42, with no + workspace. +7. **Windows path forms** (ADR-0029): confirm the mount path embedded in argv is + valid in the guest. +8. **Negative control**: confirm no host config keys (`workspace`, `extra-kits`, + `builtin-kits`, provenance) are visible to the guest from this mount. + +A passing run is added to `scripts/verify-backends` with a `readonly: true` +fixture kit and an SBX tamper step, mirroring the msb live fixture. + +## Consequences + +### Positive + +- The latent SBX gap is named, bounded, and tied to a concrete trigger instead of + being discovered when a kit opts in. +- Reuses an existing, shipped sbx mechanism if validation passes. +- Validation step 5 resolves whether native replay is trustworthy independent of + this ADR's outcome. + +### Negative / risks + +- Until Option B lands, an SBX sandbox running a kit with trusted startup code + restarts guest-resident code with no integrity guarantee beyond sbx's own. +- The mount path embeds a host path in the guest, which leaks the host username + and directory layout to the guest (already true for the workspace mount). +- The `kit-files` directory must stay free of sensitive content; this constraint + already applies on msb. + +### Neutral + +- Passwordless sudo is unchanged. No change to the neutral `hybrid/v1` vocabulary. + +## Links + +- [ADR-0035](0035-host-authoritative-sandbox-config.md) — the principle and + Mechanism 2 this ADR extends to SBX. +- [ADR-0011](0011-msb-backend-and-neutral-kits.md) — neutral kits; per-backend + translation is the adapter's job. +- [ADR-0029](0029-windows-host-guest-path-forms.md) — host/guest path forms. +- `docs/KNOWN_FAILURE_MODES.md` sections 36 (sbx kit-add refusal), 46 (msb session + shell), and 47 (sbx startup barrier): related accepted residual risks. +- sbx documentation: + [Kits v2](https://docs.docker.com/ai/sandboxes/customize/kits/), + [Architecture](https://docs.docker.com/ai/sandboxes/architecture/), + [Security model](https://docs.docker.com/ai/sandboxes/security/). The `sbx` + binary was not available on the authoring host; no CLI help text was inspected + and nothing here was verified live. From ac612808247a7e6229bfac05e7cc0b91e6f5af89 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 18:31:07 +0000 Subject: [PATCH 36/63] docs(adr): map session-state acceptance to branch evidence Add an evidence section to the host-authoritative config ADR that maps each session-and-exec acceptance bullet to the offline Bats cases in this branch and lists the live validation still required before the work can close. Co-authored-by: OpenCode Agent --- .../0035-host-authoritative-sandbox-config.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/adr/0035-host-authoritative-sandbox-config.md b/docs/adr/0035-host-authoritative-sandbox-config.md index 754b7606..c0065150 100644 --- a/docs/adr/0035-host-authoritative-sandbox-config.md +++ b/docs/adr/0035-host-authoritative-sandbox-config.md @@ -342,8 +342,36 @@ sequenceDiagram mechanism itself; the provider-facts declaration is specified in patterns ADR 0003. +## Implementation evidence and remaining live validation + +Reconciliation of the session-and-exec slice (tracked in the Links section) +against the offline evidence in the stacked branch. Offline Bats cases use +stubbed backends; they prove acq's decision logic, not live sbx or msb behavior. + +| Acceptance bullet | Evidence | Live validation still needed | +|---|---|---| +| Agent derives from host authority only | msb: host `agent` key, then trusted provenance (`81-msb-forged-guest-markers.bats`, "attach launches the host-recorded agent", "trusted provenance ... supplies a legacy sandbox's agent"); sbx: provenance plus host `generic-opencode` launch mode (`90-sbx-startup-kit.bats`, `85-kit-provenance.bats`) | Forge the guest copy on a real msb sandbox and attach | +| Workspace derives from host authority only | `81`: "run, attach and shell use the host-recorded workspace", "falls back to the agent home", "host workspace record outranks the forged marker" | As above, plus a name-only re-attach | +| SSH socket derives from host authority only | `81`: "SSH_AUTH_SOCK comes from the host record", "no host ssh-auth-sock record ... no SSH_AUTH_SOCK" (sbx forwards natively, no acq-held value) | Forged socket marker with a live ssh-agent forward | +| Kit environment derives from host authority only | `81`: "kit environment replay uses only the host kit-env record", "no host kit-env record ... no forged variable"; `78-msb-kit-env.bats` (sbx injects natively) | Forge the guest marker, confirm the session environment | +| A forged guest marker cannot redirect any of the four | Same cases; the forging stub answers every legacy `/var/lib/acq/*` read with attacker values and asserts those values never reach argv | Same live checks | +| Inventory of remaining guest reads | The remaining guest-derived reads are fail-soft liveness and presence probes, the diagnostic mount probe, the passwd login shell and the sbx startup barrier (the last two in `docs/KNOWN_FAILURE_MODES.md` sections 46 and 47) | None | +| Real backend validation recorded | Not recorded for this slice | Record in `docs/VERIFY_PROVIDER_ADOPTION.md` after `scripts/verify-backends` on both backends | + +Adjacent gates the same branch moves to host authority, beyond the four #636 +fields: the sbx `extra-kits` and `builtin-kits` records (forged-probe cases in +`90-sbx-startup-kit.bats`), the msb `ro-host-config` create fact +(`83-msb-ro-host-config-record.bats`), and the msb install and agent-user gates +(`81`, `82-msb-legacy-sandbox.bats`). Legacy sandboxes degrade as documented in +`docs/KNOWN_FAILURE_MODES.md` section 32.1. SBX trusted-startup parity is +proposed separately in [ADR-0036](0036-sbx-trusted-startup-parity.md). + ## Links +- Tracking: [GSA-TTS/agentic-coding-quickstart#503](https://github.com/GSA-TTS/agentic-coding-quickstart/issues/503) + (host-authoritative lifecycle) and its session-and-exec child + [GSA-TTS/agentic-coding-quickstart#636](https://github.com/GSA-TTS/agentic-coding-quickstart/issues/636), + reconciled in the evidence section above. - ADR-0005 — kits-from-patterns and the agent trust model (sandbox is the boundary; passwordless sudo rationale). - ADR-0011 — msb backend and neutral kits (the exec-based marker/kit-apply paths From e674b96c0fb11d057b762b1721c9bd54613ad81e Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 21:14:35 +0000 Subject: [PATCH 37/63] fix(msb): fail closed on ro-host-config disagreement and pin provision ordering Soften the ro-host-config record to "create passed the :ro flag and the sandbox became exec-ready", make the probe-disagreement warning say the heal will likely fail, clear stale instance state right after a successful create so a never-ready boot cannot keep an old record, and add provision-level tests. Co-authored-by: OpenCode Agent --- acq.backends/msb.sh | 38 ++++++++------ test/bats/83-msb-ro-host-config-record.bats | 57 ++++++++++++++++++++- 2 files changed, 77 insertions(+), 18 deletions(-) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index aa70690d..e3bee59b 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -1876,18 +1876,21 @@ _acq_msb_reset_kit_env() { # Whether sandbox NAME's trusted :ro host-config mount may be relied on for # readonly kit code. The decision comes from the HOST-recorded create fact -# (key ro-host-config, written by acq_backend_provision only after a successful -# create that passed the :ro --volume), never from a guest command: a -# passwordless-sudo guest controls `test`/`sh` and could otherwise force the -# guest-copy downgrade. A sandbox with no record (legacy, or a create that never -# completed) takes the safe compatibility path: the caller copies the file and -# prints its warning. The guest probe below is diagnostic only: it can warn -# about a mismatch but never changes the decision. +# (key ro-host-config, written by acq_backend_provision only after a create that +# passed the :ro --volume returned success and the sandbox became exec-ready), +# never from a guest command: a passwordless-sudo guest controls `test`/`sh` and +# could otherwise force the guest-copy downgrade. The record does NOT prove the +# guest mount works; it proves only that acq asked for it and the sandbox booted. +# A sandbox with no record (legacy, or a create that never completed) takes the +# safe compatibility path: the caller copies the file and prints its warning. +# The guest probe below is diagnostic only: it can warn about a mismatch but +# never changes the decision (fail-closed: if the mount is really absent, the +# staged-path heal fails rather than silently degrading to a guest copy). _acq_msb_host_config_mount_available() { local name="$1" [ "$(acq_host_config_read msb "$name" ro-host-config)" = "1" ] || return 1 _acq_msb_host_config_mount_probe "$name" || \ - echo "acq(msb): warning: sandbox '$name' was created with the read-only host-config mount but a guest probe does not confirm it; using the host-recorded state. Recreate the sandbox if this persists." >&2 + echo "acq(msb): warning: sandbox '$name' was created with the read-only host-config mount but a guest probe does not confirm it. Acq trusts the host record and will not fall back to a guest copy, so the kit heal will likely FAIL; recreate the sandbox." >&2 return 0 } @@ -3817,6 +3820,12 @@ EOF # backend may own a live or inspectable sandbox, so preserve this attempt. _ACQ_MSB_ATTEMPT_ACTIVE="" + # A same-named sandbox removed outside acq can leave host state behind. Clear + # instance-scoped values as soon as this create is known to have produced a + # fresh instance, so a stale ro-host-config record cannot survive a boot that + # never becomes exec-ready; replacement values are recorded further below. + acq_host_config_clear_instance_state msb "$name" + # CRITICAL: `msb create` returns 0 even when the sandbox later FAILS TO START # (e.g. a bad mount): the boot is asynchronous. So a zero rc from create does # NOT mean the sandbox is usable. The ONLY reliable readiness signal is that @@ -3845,14 +3854,11 @@ EOF return 1 fi - # A same-named sandbox removed outside acq can leave host state behind. Clear - # instance-scoped values before this fresh provision records replacement values. - acq_host_config_clear_instance_state msb "$name" - - # Host-recorded create fact for ADR-0035 Mechanism 2: this sandbox was created - # (create succeeded, exec-ready) with the host-config dir mounted :ro. Written - # only here so a failed create records nothing; heal consults this key instead - # of a guest probe. A write failure leaves it absent (the safe legacy path). + # Host-recorded create fact for ADR-0035 Mechanism 2: the create passed the + # host-config :ro volume flag and the sandbox became exec-ready. This does not + # prove the guest mount works. Written only here so a failed create records + # nothing; heal consults this key instead of a guest probe. A write failure + # leaves it absent (the safe legacy path). acq_host_config_write msb "$name" ro-host-config 1 || \ echo "acq(msb): warning: could not record the read-only host-config mount for '$name'; readonly kit files will use the guest-copy compatibility path." >&2 diff --git a/test/bats/83-msb-ro-host-config-record.bats b/test/bats/83-msb-ro-host-config-record.bats index eef68461..aa85247b 100644 --- a/test/bats/83-msb-ro-host-config-record.bats +++ b/test/bats/83-msb-ro-host-config-record.bats @@ -2,7 +2,10 @@ # # 83-msb-ro-host-config-record.bats — ADR-0035 Mechanism 2: whether the :ro # host-config mount may carry readonly kit code is a HOST-recorded create fact -# (host key ro-host-config), never the answer of a guest-executed probe. +# (host key ro-host-config), never the answer of a guest-executed probe. The +# record means "the create passed the :ro volume flag and the sandbox became +# exec-ready"; it does not prove the guest mount works (a disagreeing probe only +# warns, and the heal then fails closed rather than degrading to a guest copy). # # A passwordless-sudo guest controls `sh`/`test`, so a probe result must not be # able to flip the staged-:ro path to the guest-writable copy (or the reverse). @@ -63,6 +66,7 @@ _apply() { # NAME KITDIR RECORD [ENV...] assert_regex "$log" 'node /var/lib/acq/host/kit-files/' refute_regex "$log" 'msb copy .*recmissing:/home/agent/cfg/merge\.mjs' assert_output --partial 'guest probe does not confirm it' + assert_output --partial 'will likely FAIL' } @test "ro record: host record wins when the guest probe reports the mount writable" { @@ -73,6 +77,7 @@ _apply() { # NAME KITDIR RECORD [ENV...] assert_regex "$log" 'node /var/lib/acq/host/kit-files/' refute_regex "$log" 'msb copy .*recwritable:/home/agent/cfg/merge\.mjs' assert_output --partial 'guest probe does not confirm it' + assert_output --partial 'will likely FAIL' } @test "ro record: a confirming probe is silent" { @@ -108,7 +113,7 @@ _apply() { # NAME KITDIR RECORD [ENV...] refute_regex "$(cat "$CALLS")" '/var/lib/acq/host/kit-files' } -@test "ro record: successful provision records ro-host-config" { +@test "ro record: successful create plus exec-ready records ro-host-config" { local k="$STUBDIR/k6"; _ro_kit "$k" run bash -c ' export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-prov" @@ -152,3 +157,51 @@ _apply() { # NAME KITDIR RECORD [ENV...] ' assert_failure } + +# Provision-level pins: the record is written only after exec-ready, and a fresh +# provision clears stale instance state itself (not just the helper in isolation). +_prov_msb() { # NAME KITDIR [ENV...] (runs provision with a seeded stale record) + local name="$1" kit="$2"; shift 2 + run bash -c ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-'"$name"'" + export ACQ_MSB_EXEC_READY_TIMEOUT=1 + for kv in '"${*:-:}"'; do export "$kv" 2>/dev/null || true; done + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_test_stub_provider_input_boundary + acq_host_config_write msb "'"$name"'" ro-host-config 1 + acq_host_config_write msb "'"$name"'" install-stale 1 + _acq_msb_fetch_kit() { printf "%s\n" "'"$kit"'"; } + acq_backend_provision "'"$name"'" shell /tmp >/dev/null 2>&1 + ' +} + +@test "ro record: create ok but never exec-ready leaves no ro-host-config record" { + local k="$STUBDIR/k9"; _ro_kit "$k" + # Replace the stub msb with one whose create succeeds and exec never does. + cat >"$STUBDIR/msb" <<'MSBSTUB' +#!/usr/bin/env bash +{ printf 'msb'; for a in "$@"; do printf ' %s' "$a"; done; printf '\n'; } >>"$CALLS" +case "${1:-}" in + --version|-V) printf 'msb %s\n' "${STUB_MSB_VERSION:-0.7.7}" ;; + create) exit 0 ;; + exec) exit 1 ;; + *) exit 0 ;; +esac +MSBSTUB + chmod +x "$STUBDIR/msb" + _prov_msb notready "$k" + assert_failure + assert_regex "$(cat "$CALLS")" 'msb create ' + run bash -c 'ls "'"$ACQ_PROVENANCE_DIR"'"/msb/notready.*.config/ro-host-config 2>&1' + assert_failure +} + +@test "ro record: provision itself clears stale instance state before recording" { + local k="$STUBDIR/k10"; _ro_kit "$k" + _prov_msb staleprov "$k" + local cfg; cfg=$(ls -d "$ACQ_PROVENANCE_DIR"/msb/staleprov.*.config | head -n1) + assert_equal "$(cat "$cfg/ro-host-config")" "1" + [ ! -e "$cfg/install-stale" ] +} From da798cd93522eb320672ac9287b27cb8f8e710be Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 21:14:35 +0000 Subject: [PATCH 38/63] docs: scope ADR-0036 sbx claims, soften ADR-0035 record, refine KFM 46 and 47 Co-authored-by: OpenCode Agent --- docs/KNOWN_FAILURE_MODES.md | 12 ++++--- .../0035-host-authoritative-sandbox-config.md | 33 +++++++++++-------- docs/adr/0036-sbx-trusted-startup-parity.md | 24 +++++++++----- 3 files changed, 44 insertions(+), 25 deletions(-) diff --git a/docs/KNOWN_FAILURE_MODES.md b/docs/KNOWN_FAILURE_MODES.md index 8ac2b8ce..eeb1a1a0 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -3019,6 +3019,10 @@ which shell binary its own next interactive session starts. stale or override a deliberate in-guest choice. - **The charset guard and `/bin/sh` fallback already bound it** to a plain absolute path in argv, never shell syntax. +- **The session environment is already visible to the agent user.** The forwarded + SSH socket path and kit environment are in the session shell's environment, and + that is the same exposure the `agent` user already has; choosing the shell adds + none. ### Revisit if @@ -3044,10 +3048,10 @@ make acq believe startup finished. ### Why this is accepted -- **The barrier is a readiness/liveness gate, not an integrity gate.** Its only - consequence is ordering: acq proceeds to its post-create steps (host-config - record writes, stale-state clear, attach) before the guest finished its own - startup commands. The failure surface is the guest breaking its own setup. +- **The barrier is a readiness/liveness gate, not an integrity gate.** It gates + whether acq keeps the sandbox and proceeds to the host-written records and + attach. A forged token lets acq proceed before kit startup has finished, so the + guest can only hurt its own setup; it changes no host-derived value. - **No acq trust decision reads it.** The values acq acts on across restarts (`extra-kits`, `builtin-kits`, `generic-opencode`, provenance) are host-written and independent of this file. diff --git a/docs/adr/0035-host-authoritative-sandbox-config.md b/docs/adr/0035-host-authoritative-sandbox-config.md index c0065150..d3961c1b 100644 --- a/docs/adr/0035-host-authoritative-sandbox-config.md +++ b/docs/adr/0035-host-authoritative-sandbox-config.md @@ -167,15 +167,22 @@ Concretely, this ADR introduces two mechanisms and a staged migration. mount/export semantics, not Unix ownership inside the guest: sbx already enforces read-only extra mounts, and msb 0.7.2 was live-verified to export the virtiofs mount read-only even when guest root can make the guest-visible mount - flag appear `rw`. Whether a sandbox may rely on the mount for `readonly: true` - startup code is a **host-recorded create fact** (host key `ro-host-config`, - written only after a successful create that passed the `:ro` volume and became - exec-ready), not the answer of a guest command, which a passwordless-sudo guest - could forge to force the guest-copy downgrade. A sandbox with no record - (created before the key existed, or never fully created) takes the legacy - guest-copy path and warns. A guest-executed probe of the mount remains as a - diagnostic only: a mismatch with the record warns but never changes the - decision. If a future + flag appear `rw`. acq refuses msb 0.7.2 and older and pins 0.7.7, so this must + be re-verified on the pinned version before it is relied on. Whether a sandbox + may rely on the mount for `readonly: true` startup code is a **host-recorded + create fact** (host key `ro-host-config`), written only after a create that + passed the `:ro` volume flag returned success and the sandbox became + exec-ready. The record does **not** prove the guest mount works; it proves only + that acq asked for it and the sandbox booted. The decision is not the answer of + a guest command, which a passwordless-sudo guest could forge to force the + guest-copy downgrade. A sandbox with no record (created before the key existed, + or never fully created) takes the legacy guest-copy path and warns. A + guest-executed probe of the mount remains as a diagnostic only: when a record + exists and the probe disagrees, acq warns that the heal will likely fail and + that the sandbox should be recreated, and still trusts the record. This is a + deliberate change from "degrade to a guest copy" to "warn and possibly fail" + (fail-closed, no escalation to the guest-writable path), applied only when the + record exists; legacy and no-record sandboxes still degrade safely. If a future msb changes `--volume ...:ro` parsing or enforcement, acq must raise or gate its minimum msb version before relying on ADR-0035. Host and guest path forms follow ADR-0029. @@ -345,12 +352,12 @@ sequenceDiagram ## Implementation evidence and remaining live validation Reconciliation of the session-and-exec slice (tracked in the Links section) -against the offline evidence in the stacked branch. Offline Bats cases use +against the offline evidence in the test suite. Offline Bats cases use stubbed backends; they prove acq's decision logic, not live sbx or msb behavior. | Acceptance bullet | Evidence | Live validation still needed | |---|---|---| -| Agent derives from host authority only | msb: host `agent` key, then trusted provenance (`81-msb-forged-guest-markers.bats`, "attach launches the host-recorded agent", "trusted provenance ... supplies a legacy sandbox's agent"); sbx: provenance plus host `generic-opencode` launch mode (`90-sbx-startup-kit.bats`, `85-kit-provenance.bats`) | Forge the guest copy on a real msb sandbox and attach | +| Agent derives from host authority only | msb: host `agent` key, then trusted provenance (`81-msb-forged-guest-markers.bats`, "attach launches the host-recorded agent", "trusted provenance ... supplies a legacy sandbox's agent"); sbx: provenance plus host `generic-opencode` launch mode (`85-kit-provenance.bats`; launch-mode derivation in `140-opencode-migration-gate.bats`) | Forge the guest copy on a real msb sandbox and attach | | Workspace derives from host authority only | `81`: "run, attach and shell use the host-recorded workspace", "falls back to the agent home", "host workspace record outranks the forged marker" | As above, plus a name-only re-attach | | SSH socket derives from host authority only | `81`: "SSH_AUTH_SOCK comes from the host record", "no host ssh-auth-sock record ... no SSH_AUTH_SOCK" (sbx forwards natively, no acq-held value) | Forged socket marker with a live ssh-agent forward | | Kit environment derives from host authority only | `81`: "kit environment replay uses only the host kit-env record", "no host kit-env record ... no forged variable"; `78-msb-kit-env.bats` (sbx injects natively) | Forge the guest marker, confirm the session environment | @@ -358,8 +365,8 @@ stubbed backends; they prove acq's decision logic, not live sbx or msb behavior. | Inventory of remaining guest reads | The remaining guest-derived reads are fail-soft liveness and presence probes, the diagnostic mount probe, the passwd login shell and the sbx startup barrier (the last two in `docs/KNOWN_FAILURE_MODES.md` sections 46 and 47) | None | | Real backend validation recorded | Not recorded for this slice | Record in `docs/VERIFY_PROVIDER_ADOPTION.md` after `scripts/verify-backends` on both backends | -Adjacent gates the same branch moves to host authority, beyond the four #636 -fields: the sbx `extra-kits` and `builtin-kits` records (forged-probe cases in +Adjacent gates also moved to host authority, beyond the four +GSA-TTS/agentic-coding-quickstart#636 fields: the sbx `extra-kits` and `builtin-kits` records (forged-probe cases in `90-sbx-startup-kit.bats`), the msb `ro-host-config` create fact (`83-msb-ro-host-config-record.bats`), and the msb install and agent-user gates (`81`, `82-msb-legacy-sandbox.bats`). Legacy sandboxes degrade as documented in diff --git a/docs/adr/0036-sbx-trusted-startup-parity.md b/docs/adr/0036-sbx-trusted-startup-parity.md index 1b10c67a..37351857 100644 --- a/docs/adr/0036-sbx-trusted-startup-parity.md +++ b/docs/adr/0036-sbx-trusted-startup-parity.md @@ -41,10 +41,16 @@ shipped kit. Re-check this against the pin in force when the ADR is accepted. This ADR decides the direction so that the first kit to opt in does not silently get weaker guarantees on SBX than on msb. -`sbx kit add` of startup-bearing kits is refused on sbx >= 0.38 -(`docs/KNOWN_FAILURE_MODES.md` section 36), so mid-life repair is already -"recreate the sandbox"; this ADR concerns create-time delivery and restart replay -only. +`sbx kit add` of startup-bearing kits is refused on sbx 0.38 +(`docs/KNOWN_FAILURE_MODES.md` section 36), so on that version mid-life repair is +already "recreate the sandbox". The Kits v2 documentation +([docs.docker.com Kits v2](https://docs.docker.com/ai/sandboxes/customize/kits/)) +says current sbx supports `setup.startup` mixins via recreate, so the refusal is +version-scoped and is not assumed for later releases. This ADR concerns +create-time delivery and restart replay only. + +ADR numbers: other unmerged work may carry a different ADR numbered 0036. A +number collision is possible and must be resolved when this branch is rebased. ## Decision Drivers @@ -93,8 +99,9 @@ path to the mount path before emitting `setup.startup`. ADR-0029); the dedicated directory must contain only non-sensitive, public, pinned kit payload (traversable `0711`, files `0555`, per ADR-0035), never the flat `0600` keys, so the whole config directory must not be mounted; the - translator gains a `readonly` consumer; behavior with `--clone` and mountless - sandboxes (sbx >= 0.42) must be confirmed; the registration itself (the argv + translator gains a `readonly` consumer; behavior with mountless sandboxes + (sbx >= 0.42) must be confirmed (extra workspaces under `--clone` are documented + as always mounted directly rather than cloned, `docs/howto/sbx.md`); the registration itself (the argv naming the mount path) is still sbx-held state (Option A's unverified point 1), though tampering with it can only redirect execution, not substitute trusted content. @@ -159,8 +166,9 @@ Run on a real sbx host (cannot run inside a sandbox). Record exact sbx version. filesystem). As guest root, edit the registered command and each file it references, restart, and record what executes. This determines how much the registration itself can be trusted. -6. **Sandbox modes**: repeat 1 and 4 with `--clone` and, on sbx >= 0.42, with no - workspace. +6. **Sandbox modes**: repeat 1 and 4 with `--clone` (extra workspaces are + documented as always mounted directly; confirm that holds for the `:ro` + positional) and, on sbx >= 0.42, with no workspace. 7. **Windows path forms** (ADR-0029): confirm the mount path embedded in argv is valid in the guest. 8. **Negative control**: confirm no host config keys (`workspace`, `extra-kits`, From 18cf1e3dd9f9530ff88615445772d6d2dabda0c5 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 21:37:39 +0000 Subject: [PATCH 39/63] fix(msb): distinguish absent vs writable ro host-config mount in probe warnings The probe now returns 2 when guest root can write the mount, so the warning says staged code runs from a guest-modifiable path instead of claiming the heal will fail. The absent case says commands may fail. The legacy copy warning now names the missing record. Co-authored-by: OpenCode Agent --- acq.backends/msb.sh | 16 +++++++++++----- scripts/test-acq-lib.sh | 2 +- test/bats/79-msb-readonly-kit-files.bats | 4 ++-- test/bats/83-msb-ro-host-config-record.bats | 16 ++++++++-------- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index e3bee59b..53ddf11a 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -1813,7 +1813,7 @@ EOF fi if { [ "$_readonly" != "true" ] || [ "$_hcfg_mount_ok" -ne 1 ]; } && [ -n "$src" ] && [ -f "$src" ]; then if [ "$_readonly" = "true" ]; then - echo "acq(msb): warning: sandbox '$name' has no readable or read-only ADR-0035 host-config mount; copying readonly kit file '$path' into the guest for compatibility. Recreate the sandbox to get tamper-resistant readonly startup code." >&2 + echo "acq(msb): warning: sandbox '$name' has no ADR-0035 read-only host-config record (older sandbox, or its create did not complete); copying readonly kit file '$path' into the guest for compatibility. Recreate the sandbox to get tamper-resistant readonly startup code." >&2 fi _acq_msb_copy_file_verified "$name" "$src" "$path" "$mode" || { echo "acq(msb): error: could not place kit file at ${name}:${path}" >&2 @@ -1885,12 +1885,18 @@ _acq_msb_reset_kit_env() { # safe compatibility path: the caller copies the file and prints its warning. # The guest probe below is diagnostic only: it can warn about a mismatch but # never changes the decision (fail-closed: if the mount is really absent, the -# staged-path heal fails rather than silently degrading to a guest copy). +# staged-path commands may fail rather than silently degrading to a guest copy; +# if it is writable, the warning says so). _acq_msb_host_config_mount_available() { local name="$1" [ "$(acq_host_config_read msb "$name" ro-host-config)" = "1" ] || return 1 - _acq_msb_host_config_mount_probe "$name" || \ - echo "acq(msb): warning: sandbox '$name' was created with the read-only host-config mount but a guest probe does not confirm it. Acq trusts the host record and will not fall back to a guest copy, so the kit heal will likely FAIL; recreate the sandbox." >&2 + local _probe_rc=0 + _acq_msb_host_config_mount_probe "$name" || _probe_rc=$? + case "$_probe_rc" in + 0) ;; + 2) echo "acq(msb): warning: sandbox '$name' was created with the read-only host-config mount but a guest probe finds it WRITABLE. Acq trusts the host record, so staged kit code runs from a path the guest may be able to modify. Recreate the sandbox and report this if it persists." >&2 ;; + *) echo "acq(msb): warning: sandbox '$name' was created with the read-only host-config mount but a guest probe does not confirm it is present. Acq trusts the host record and does not fall back to a guest copy, so staged kit commands may fail (kit command failures are reported as warnings). Recreate the sandbox if this persists." >&2 ;; + esac return 0 } @@ -1900,7 +1906,7 @@ _acq_msb_host_config_mount_probe() { "test -d '$ACQ_HOST_CONFIG_GUEST_DIR' && test -x '$ACQ_HOST_CONFIG_GUEST_DIR'" \ /dev/null 2>&1 || return 1 _acq_msb_cli exec "$name" -u 0 -- sh -c \ - '_dir=$1; _tmp="$_dir/.acq-ro-probe.$$"; if : > "$_tmp" 2>/dev/null; then rm -f "$_tmp" 2>/dev/null; exit 1; fi; exit 0' \ + '_dir=$1; _tmp="$_dir/.acq-ro-probe.$$"; if : > "$_tmp" 2>/dev/null; then rm -f "$_tmp" 2>/dev/null; exit 2; fi; exit 0' \ sh "$ACQ_HOST_CONFIG_GUEST_DIR" /dev/null 2>&1 } diff --git a/scripts/test-acq-lib.sh b/scripts/test-acq-lib.sh index 7b696033..4322e167 100644 --- a/scripts/test-acq-lib.sh +++ b/scripts/test-acq-lib.sh @@ -498,7 +498,7 @@ case "$_msb_sub" in *"test -d '/var/lib/acq/host'"*) [ "${STUB_HOST_CONFIG_MOUNT:-1}" = "0" ] && exit 1 || exit 0 ;; *".acq-ro-probe"*) - [ "${STUB_HOST_CONFIG_WRITABLE:-0}" = "1" ] && exit 1 || exit 0 ;; + [ "${STUB_HOST_CONFIG_WRITABLE:-0}" = "1" ] && exit 2 || exit 0 ;; # `command -v ` (agent-presence probe): controllable so the install # path can be exercised. By default the agent is ABSENT (exit 1) so install # runs; STUB_AGENT_PRESENT=1 makes it "present" (skips install). The prereq diff --git a/test/bats/79-msb-readonly-kit-files.bats b/test/bats/79-msb-readonly-kit-files.bats index 446fb72e..964c966b 100644 --- a/test/bats/79-msb-readonly-kit-files.bats +++ b/test/bats/79-msb-readonly-kit-files.bats @@ -158,7 +158,7 @@ SPEC assert_regex "$log" 'msb copy .*legacyrobox:/home/agent/cfg/merge\.mjs' assert_regex "$log" 'node /home/agent/cfg/merge\.mjs' refute_regex "$log" '/var/lib/acq/host/kit-files' - assert_output --partial 'has no readable or read-only ADR-0035 host-config mount' + assert_output --partial 'has no ADR-0035 read-only host-config record' } @test "msb ro: writable host-config mount falls back to the guest copy" { @@ -176,7 +176,7 @@ SPEC assert_regex "$log" 'msb copy .*writablerobox:/home/agent/cfg/merge\.mjs' assert_regex "$log" 'node /home/agent/cfg/merge\.mjs' refute_regex "$log" '/var/lib/acq/host/kit-files' - assert_output --partial 'has no readable or read-only ADR-0035 host-config mount' + assert_output --partial 'has no ADR-0035 read-only host-config record' } @test "msb ro: the create-time host-config mount is read-only" { diff --git a/test/bats/83-msb-ro-host-config-record.bats b/test/bats/83-msb-ro-host-config-record.bats index aa85247b..73b8ae0a 100644 --- a/test/bats/83-msb-ro-host-config-record.bats +++ b/test/bats/83-msb-ro-host-config-record.bats @@ -5,7 +5,7 @@ # (host key ro-host-config), never the answer of a guest-executed probe. The # record means "the create passed the :ro volume flag and the sandbox became # exec-ready"; it does not prove the guest mount works (a disagreeing probe only -# warns, and the heal then fails closed rather than degrading to a guest copy). +# warns, and acq never degrades to a guest copy on that disagreement). # # A passwordless-sudo guest controls `sh`/`test`, so a probe result must not be # able to flip the staged-:ro path to the guest-writable copy (or the reverse). @@ -65,8 +65,8 @@ _apply() { # NAME KITDIR RECORD [ENV...] local log; log=$(cat "$CALLS") assert_regex "$log" 'node /var/lib/acq/host/kit-files/' refute_regex "$log" 'msb copy .*recmissing:/home/agent/cfg/merge\.mjs' - assert_output --partial 'guest probe does not confirm it' - assert_output --partial 'will likely FAIL' + assert_output --partial 'does not confirm it is present' + assert_output --partial 'does not fall back to a guest copy' } @test "ro record: host record wins when the guest probe reports the mount writable" { @@ -76,15 +76,15 @@ _apply() { # NAME KITDIR RECORD [ENV...] local log; log=$(cat "$CALLS") assert_regex "$log" 'node /var/lib/acq/host/kit-files/' refute_regex "$log" 'msb copy .*recwritable:/home/agent/cfg/merge\.mjs' - assert_output --partial 'guest probe does not confirm it' - assert_output --partial 'will likely FAIL' + assert_output --partial 'finds it WRITABLE' + assert_output --partial 'may be able to modify' } @test "ro record: a confirming probe is silent" { local k="$STUBDIR/k3"; _ro_kit "$k" _apply recquiet "$k" 1 assert_success - refute_output --partial 'guest probe does not confirm it' + refute_output --partial 'host-config mount but a guest probe' assert_regex "$(cat "$CALLS")" 'node /var/lib/acq/host/kit-files/' } @@ -95,7 +95,7 @@ _apply() { # NAME KITDIR RECORD [ENV...] local log; log=$(cat "$CALLS") assert_regex "$log" 'msb copy .*norecord:/home/agent/cfg/merge\.mjs' refute_regex "$log" '/var/lib/acq/host/kit-files' - assert_output --partial 'has no readable or read-only ADR-0035 host-config mount' + assert_output --partial 'has no ADR-0035 read-only host-config record' } @test "ro record: a value other than 1 is not a record" { @@ -146,7 +146,7 @@ _apply() { # NAME KITDIR RECORD [ENV...] assert_failure } -@test "ro record: stale record from a same-named removed sandbox is cleared on fresh provision" { +@test "ro record: clear_instance_state helper removes a stale record" { local k="$STUBDIR/k8"; _ro_kit "$k" run bash -c ' export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-stale" From 1e340bb6221f2ee76071c21c3be060b5ecc557ac Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 21:37:39 +0000 Subject: [PATCH 40/63] docs: correct msb version wording, probe outcomes, and sbx barrier token notes Co-authored-by: OpenCode Agent --- docs/KNOWN_FAILURE_MODES.md | 17 ++++++------- .../0035-host-authoritative-sandbox-config.md | 24 ++++++++++++------- docs/adr/0036-sbx-trusted-startup-parity.md | 10 ++++---- 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/docs/KNOWN_FAILURE_MODES.md b/docs/KNOWN_FAILURE_MODES.md index eeb1a1a0..8e24eb3c 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -3039,12 +3039,13 @@ host-authoritative without sbx features that have not been verified. ### Observation After `sbx create`, acq waits for `/tmp/acq/startup-complete` inside the sandbox -to contain a random per-create token (`_acq_sbx_wait_for_startup_barrier`). The -token is generated on the host and delivered in a generated barrier kit whose -startup command writes it, so the host side of the comparison is authoritative, -but the signal itself is a guest file. A guest that can read the token (or that -can alter how the startup command is replayed, which is unverified on sbx) could -make acq believe startup finished. +to contain a per-create token (`_acq_sbx_wait_for_startup_barrier`). The token +is generated on the host (not a secret: PID, epoch and shell random values) and +delivered in a generated barrier kit whose startup command writes it, so it is +present in the guest's own startup registration. The host side of the comparison +is authoritative, but the signal itself is a guest file. A guest that can read +the token (the normal case, not a hypothetical) or that can alter how the startup +command is replayed (unverified on sbx) could make acq believe startup finished. ### Why this is accepted @@ -3057,8 +3058,8 @@ make acq believe startup finished. and independent of this file. - **A host-authoritative signal needs sbx support not verified here.** The only candidates are a backend-reported startup status or a host-visible channel out - of the guest. Neither is documented in the sbx CLI that this repository - targets, and adding one by guessing would trade a low-impact residual risk for + of the guest. Neither was found in the sbx documentation or CLI + help consulted for this repository (not exhaustively checked), and adding one by guessing would trade a low-impact residual risk for an unverifiable mechanism. The related trusted-startup question for SBX is analyzed in ADR-0036 (SBX trusted-startup parity). diff --git a/docs/adr/0035-host-authoritative-sandbox-config.md b/docs/adr/0035-host-authoritative-sandbox-config.md index d3961c1b..84b5e666 100644 --- a/docs/adr/0035-host-authoritative-sandbox-config.md +++ b/docs/adr/0035-host-authoritative-sandbox-config.md @@ -167,8 +167,10 @@ Concretely, this ADR introduces two mechanisms and a staged migration. mount/export semantics, not Unix ownership inside the guest: sbx already enforces read-only extra mounts, and msb 0.7.2 was live-verified to export the virtiofs mount read-only even when guest root can make the guest-visible mount - flag appear `rw`. acq refuses msb 0.7.2 and older and pins 0.7.7, so this must - be re-verified on the pinned version before it is relied on. Whether a sandbox + flag appear `rw`. acq now refuses 0.7.0 through 0.7.2 and accepts 0.6.9 or newer + otherwise, with 0.7.7 only the default install pin, so `:ro` enforcement has not + been re-verified on any version acq accepts. It must be re-verified on the + pinned version before it is relied on. Whether a sandbox may rely on the mount for `readonly: true` startup code is a **host-recorded create fact** (host key `ro-host-config`), written only after a create that passed the `:ro` volume flag returned success and the sandbox became @@ -178,11 +180,15 @@ Concretely, this ADR introduces two mechanisms and a staged migration. guest-copy downgrade. A sandbox with no record (created before the key existed, or never fully created) takes the legacy guest-copy path and warns. A guest-executed probe of the mount remains as a diagnostic only: when a record - exists and the probe disagrees, acq warns that the heal will likely fail and - that the sandbox should be recreated, and still trusts the record. This is a - deliberate change from "degrade to a guest copy" to "warn and possibly fail" - (fail-closed, no escalation to the guest-writable path), applied only when the - record exists; legacy and no-record sandboxes still degrade safely. If a future + exists and the probe disagrees, acq warns and still trusts the record. If the + mount looks absent, acq warns that staged kit commands may fail (kit command + failures are themselves reported as warnings) and does not fall back to a guest + copy; if it looks guest-writable, acq warns that staged code runs from a path + the guest may be able to modify, which is no stronger than the guest copy and + signals that `:ro` is not being enforced. This is a deliberate change from + "degrade to a guest copy" to "warn and possibly fail" (no escalation to the + guest-writable path), applied only when the record exists; legacy and + no-record sandboxes still degrade safely. If a future msb changes `--volume ...:ro` parsing or enforcement, acq must raise or gate its minimum msb version before relying on ADR-0035. Host and guest path forms follow ADR-0029. @@ -212,8 +218,8 @@ Concretely, this ADR introduces two mechanisms and a staged migration. reads (e.g. `opencode.jsonc`) must stay a normal guest-writable drop. - A file **without** `readonly: true` is unchanged: copied into the guest and invoked by its guest path (full backward compatibility; no shipped kit changes - behavior until it opts in). If an existing sandbox predates the - `/var/lib/acq/host` mount, acq warns and falls back to this legacy guest-copy + behavior until it opts in). If an existing sandbox has no `ro-host-config` + record (it predates the mount, or its create did not complete), acq warns and falls back to this legacy guest-copy behavior for `readonly: true` files too, rather than rewriting startup argv to a path that does not exist; recreating the sandbox restores the tamper-resistant read-only path. diff --git a/docs/adr/0036-sbx-trusted-startup-parity.md b/docs/adr/0036-sbx-trusted-startup-parity.md index 37351857..afd763c9 100644 --- a/docs/adr/0036-sbx-trusted-startup-parity.md +++ b/docs/adr/0036-sbx-trusted-startup-parity.md @@ -45,12 +45,14 @@ get weaker guarantees on SBX than on msb. (`docs/KNOWN_FAILURE_MODES.md` section 36), so on that version mid-life repair is already "recreate the sandbox". The Kits v2 documentation ([docs.docker.com Kits v2](https://docs.docker.com/ai/sandboxes/customize/kits/)) -says current sbx supports `setup.startup` mixins via recreate, so the refusal is -version-scoped and is not assumed for later releases. This ADR concerns +says current sbx supports `setup.startup` mixins via recreate (not verified +here; no network or sbx access on the authoring host), so the refusal is +version-scoped and is not assumed for later releases. The "mountless sandbox" +behavior attributed to sbx >= 0.42 below is likewise unverified. This ADR concerns create-time delivery and restart replay only. -ADR numbers: other unmerged work may carry a different ADR numbered 0036. A -number collision is possible and must be resolved when this branch is rebased. +ADR numbering: a number collision with other unmerged ADRs is possible and must +be resolved by renumbering at merge. ## Decision Drivers From 2d6d67133d5407c401dbee1d200a96465e3ff56c Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Sun, 25 Oct 2026 21:40:05 +0000 Subject: [PATCH 41/63] fix(msb): hedge the writable-probe warning and rename a misleading test Co-authored-by: OpenCode Agent --- acq.backends/msb.sh | 6 +++++- test/bats/79-msb-readonly-kit-files.bats | 2 +- test/bats/83-msb-ro-host-config-record.bats | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 53ddf11a..c4497a75 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -1894,12 +1894,16 @@ _acq_msb_host_config_mount_available() { _acq_msb_host_config_mount_probe "$name" || _probe_rc=$? case "$_probe_rc" in 0) ;; - 2) echo "acq(msb): warning: sandbox '$name' was created with the read-only host-config mount but a guest probe finds it WRITABLE. Acq trusts the host record, so staged kit code runs from a path the guest may be able to modify. Recreate the sandbox and report this if it persists." >&2 ;; + 2) echo "acq(msb): warning: sandbox '$name' was created with the read-only host-config mount but a guest probe indicates it is WRITABLE. Acq trusts the host record, so staged kit code runs from a path the guest may be able to modify. Recreate the sandbox and report this if it persists." >&2 ;; *) echo "acq(msb): warning: sandbox '$name' was created with the read-only host-config mount but a guest probe does not confirm it is present. Acq trusts the host record and does not fall back to a guest copy, so staged kit commands may fail (kit command failures are reported as warnings). Recreate the sandbox if this persists." >&2 ;; esac return 0 } +# Exit 0: mount present and not writable; 1: mount absent/unreadable; 2: guest +# root could write to it. Exit 2 relies on `msb exec` passing the guest exit code +# through unchanged; that is not live-verified, so the WRITABLE warning is +# diagnostic only and never affects the decision. _acq_msb_host_config_mount_probe() { local name="$1" _acq_msb_cli exec "$name" -u agent -- sh -c \ diff --git a/test/bats/79-msb-readonly-kit-files.bats b/test/bats/79-msb-readonly-kit-files.bats index 964c966b..39fe6bfe 100644 --- a/test/bats/79-msb-readonly-kit-files.bats +++ b/test/bats/79-msb-readonly-kit-files.bats @@ -161,7 +161,7 @@ SPEC assert_output --partial 'has no ADR-0035 read-only host-config record' } -@test "msb ro: writable host-config mount falls back to the guest copy" { +@test "msb ro: no-record sandbox falls back to the guest copy regardless of the writable probe stub" { local k="$STUBDIR/writablero"; _ro_kit "$k" : > "$CALLS" run bash -c ' diff --git a/test/bats/83-msb-ro-host-config-record.bats b/test/bats/83-msb-ro-host-config-record.bats index 73b8ae0a..5a79f60d 100644 --- a/test/bats/83-msb-ro-host-config-record.bats +++ b/test/bats/83-msb-ro-host-config-record.bats @@ -76,7 +76,7 @@ _apply() { # NAME KITDIR RECORD [ENV...] local log; log=$(cat "$CALLS") assert_regex "$log" 'node /var/lib/acq/host/kit-files/' refute_regex "$log" 'msb copy .*recwritable:/home/agent/cfg/merge\.mjs' - assert_output --partial 'finds it WRITABLE' + assert_output --partial 'indicates it is WRITABLE' assert_output --partial 'may be able to modify' } From 4c54263c861f3c12f85197377b1cbc4f2338e443 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Mon, 26 Oct 2026 03:41:13 +0000 Subject: [PATCH 42/63] fix(msb): fail closed when staging a readonly kit file after a recorded ro create When the host ro-host-config record exists and staging a readonly: true kit file onto the host config dir fails, return an error naming the file instead of silently degrading to a guest-writable copy. Only sandboxes with no record keep the legacy guest copy and its warning. ADR-0035 states the record proves a :ro request plus exec readiness, never verified read-only enforcement. Co-authored-by: OpenCode Agent --- acq.backends/msb.sh | 6 ++-- .../0035-host-authoritative-sandbox-config.md | 6 +++- test/bats/83-msb-ro-host-config-record.bats | 35 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index c4497a75..de59accf 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -1807,8 +1807,10 @@ EOF _ACQ_MSB_RO_REWRITE_FROM+=("$path") _ACQ_MSB_RO_REWRITE_TO+=("$_ro_guest") else - echo "acq(msb): warning: could not stage read-only kit file '$path' for '$name'; falling back to the legacy guest copy." >&2 - _readonly="" + # Recorded read-only create: never degrade to a guest-writable copy. + echo "acq(msb): error: could not stage read-only kit file '$path' for '$name'." >&2 + echo "acq(msb): No guest-copy fallback is taken because the sandbox was created with a read-only host config; recreate the sandbox or fix the host state directory." >&2 + return 1 fi fi if { [ "$_readonly" != "true" ] || [ "$_hcfg_mount_ok" -ne 1 ]; } && [ -n "$src" ] && [ -f "$src" ]; then diff --git a/docs/adr/0035-host-authoritative-sandbox-config.md b/docs/adr/0035-host-authoritative-sandbox-config.md index 84b5e666..e081bb0c 100644 --- a/docs/adr/0035-host-authoritative-sandbox-config.md +++ b/docs/adr/0035-host-authoritative-sandbox-config.md @@ -188,7 +188,11 @@ Concretely, this ADR introduces two mechanisms and a staged migration. signals that `:ro` is not being enforced. This is a deliberate change from "degrade to a guest copy" to "warn and possibly fail" (no escalation to the guest-writable path), applied only when the record exists; legacy and - no-record sandboxes still degrade safely. If a future + no-record sandboxes still degrade safely. The record is never evidence of + verified read-only enforcement: it proves a `:ro` request plus exec readiness only. When a record + exists and staging a `readonly: true` file onto the host fails, the apply fails + with an error naming the file rather than copying it into the guest; only + sandboxes with no record take the guest copy. If a future msb changes `--volume ...:ro` parsing or enforcement, acq must raise or gate its minimum msb version before relying on ADR-0035. Host and guest path forms follow ADR-0029. diff --git a/test/bats/83-msb-ro-host-config-record.bats b/test/bats/83-msb-ro-host-config-record.bats index 5a79f60d..72ff922a 100644 --- a/test/bats/83-msb-ro-host-config-record.bats +++ b/test/bats/83-msb-ro-host-config-record.bats @@ -205,3 +205,38 @@ MSBSTUB assert_equal "$(cat "$cfg/ro-host-config")" "1" [ ! -e "$cfg/install-stale" ] } + +# Staging failure after a RECORDED read-only create must fail closed: no silent +# downgrade to a guest-writable copy. Without a record the legacy copy remains. +_apply_stage_fail() { # NAME KITDIR RECORD + local name="$1" kit="$2" record="$3" + : > "$CALLS" + run bash -c ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-'"$name"'" + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_test_stub_provider_input_boundary + _acq_msb_stage_readonly_file() { return 1; } + [ "'"$record"'" = 1 ] && acq_host_config_write msb "'"$name"'" ro-host-config 1 + _acq_msb_apply_kit_dir "'"$name"'" "'"$kit"'" 2>&1 + ' +} + +@test "ro record: stage failure with a record fails closed and copies nothing into the guest" { + local k="$STUBDIR/k11"; _ro_kit "$k" + _apply_stage_fail stagefailrec "$k" 1 + assert_failure + assert_output --partial "could not stage read-only kit file '/home/agent/cfg/merge.mjs'" + assert_output --partial 'No guest-copy fallback is taken' + refute_regex "$(cat "$CALLS")" 'msb copy .*stagefailrec:/home/agent/cfg/merge\.mjs' + refute_regex "$(cat "$CALLS")" 'node /home/agent/cfg/merge\.mjs' +} + +@test "ro record: stage failure without a record keeps the legacy guest copy and its warning" { + local k="$STUBDIR/k12"; _ro_kit "$k" + _apply_stage_fail stagefailnorec "$k" 0 + assert_success + assert_output --partial 'has no ADR-0035 read-only host-config record' + assert_regex "$(cat "$CALLS")" 'msb copy .*stagefailnorec:/home/agent/cfg/merge\.mjs' +} From a326cb7e6a84aa184ce609bf20e05b8ba642e0c8 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Mon, 26 Oct 2026 04:43:00 +0000 Subject: [PATCH 43/63] docs(msb): align comment, error text and ADR wrap with the fail-closed staging change Co-authored-by: OpenCode Agent --- acq.backends/msb.sh | 5 +++-- docs/adr/0035-host-authoritative-sandbox-config.md | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index de59accf..afbeb7e6 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -1809,7 +1809,7 @@ EOF else # Recorded read-only create: never degrade to a guest-writable copy. echo "acq(msb): error: could not stage read-only kit file '$path' for '$name'." >&2 - echo "acq(msb): No guest-copy fallback is taken because the sandbox was created with a read-only host config; recreate the sandbox or fix the host state directory." >&2 + echo "acq(msb): No guest-copy fallback is taken because the sandbox was created with a read-only host config; fix the host state directory and re-run, or recreate the sandbox." >&2 return 1 fi fi @@ -1888,7 +1888,8 @@ _acq_msb_reset_kit_env() { # The guest probe below is diagnostic only: it can warn about a mismatch but # never changes the decision (fail-closed: if the mount is really absent, the # staged-path commands may fail rather than silently degrading to a guest copy; -# if it is writable, the warning says so). +# if it is writable, the warning says so). Likewise a recorded sandbox whose host +# staging step fails aborts the apply instead of copying the file into the guest. _acq_msb_host_config_mount_available() { local name="$1" [ "$(acq_host_config_read msb "$name" ro-host-config)" = "1" ] || return 1 diff --git a/docs/adr/0035-host-authoritative-sandbox-config.md b/docs/adr/0035-host-authoritative-sandbox-config.md index e081bb0c..6849e6e1 100644 --- a/docs/adr/0035-host-authoritative-sandbox-config.md +++ b/docs/adr/0035-host-authoritative-sandbox-config.md @@ -189,10 +189,10 @@ Concretely, this ADR introduces two mechanisms and a staged migration. "degrade to a guest copy" to "warn and possibly fail" (no escalation to the guest-writable path), applied only when the record exists; legacy and no-record sandboxes still degrade safely. The record is never evidence of - verified read-only enforcement: it proves a `:ro` request plus exec readiness only. When a record - exists and staging a `readonly: true` file onto the host fails, the apply fails - with an error naming the file rather than copying it into the guest; only - sandboxes with no record take the guest copy. If a future + verified read-only enforcement: it proves a `:ro` request plus exec readiness + only. When a record exists and staging a `readonly: true` file onto the host + fails, the apply fails with an error naming the file rather than copying it into + the guest; only sandboxes with no record take the guest copy. If a future msb changes `--volume ...:ro` parsing or enforcement, acq must raise or gate its minimum msb version before relying on ADR-0035. Host and guest path forms follow ADR-0029. From 533cf608e02ed18e32c37fe151b5e7f3c12f211b Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Tue, 27 Oct 2026 15:48:53 +0000 Subject: [PATCH 44/63] fix(sbx,msb): derive kit state from host-observed facts and fail closed on a non-read-only mount sbx: seed the extra-kits and builtin-kits host records from the host-observed successful create, before the guest-written startup barrier is consulted, so a guest that satisfies the barrier early cannot influence them. A failed barrier still removes the sandbox and its host state. msb: for a sandbox created with the read-only host-config mount, a probe that finds the mount writable or does not confirm it now makes applying a readonly kit file fail with an error naming the file. Nothing is staged, executed from the mount, or copied into the guest. Sandboxes with no record keep the guest-copy compatibility path. The two tests that asserted warn-and-continue were replaced deliberately by fail-closed cases. Co-authored-by: OpenCode Agent --- acq.backends/msb.sh | 60 +++++++++++++-------- acq.backends/sbx.sh | 15 ++++-- test/bats/83-msb-ro-host-config-record.bats | 49 +++++++++++++---- test/bats/90-sbx-startup-kit.bats | 27 ++++++++++ 4 files changed, 114 insertions(+), 37 deletions(-) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 15f32580..06b23ba8 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -1787,8 +1787,9 @@ EOF local path mode phase source _readonly src _i _ACQ_MSB_RO_REWRITE_FROM=() _ACQ_MSB_RO_REWRITE_TO=() - local _hcfg_mount_ok=0 - _acq_msb_host_config_mount_available "$name" && _hcfg_mount_ok=1 + local _hcfg_mount_ok=0 _hcfg_rc=0 + _acq_msb_host_config_mount_available "$name" || _hcfg_rc=$? + [ "$_hcfg_rc" -eq 0 ] && _hcfg_mount_ok=1 for _i in ${_frecs[@]+"${!_frecs[@]}"}; do fline="${_frecs[$_i]}" path=$(printf '%s' "$fline" | cut -f1) @@ -1801,6 +1802,17 @@ EOF if [ -n "$source" ]; then src="${kitdir}/${source}" fi + if [ "$_readonly" = "true" ] && [ -n "$src" ] && [ -f "$src" ] && [ "$_hcfg_rc" -ge 2 ]; then + # Recorded read-only create, but the mount is not confirmed read-only. Never + # stage or run trusted code from it and never degrade to a guest copy. + if [ "$_hcfg_rc" -eq 2 ]; then + echo "acq(msb): error: cannot place read-only kit file '$path' for '$name': the host-config mount looks WRITABLE from the guest, so code staged there would not be trusted." >&2 + else + echo "acq(msb): error: cannot place read-only kit file '$path' for '$name': the host-config mount is not a confirmed read-only mount (the guest probe did not confirm it)." >&2 + fi + echo "acq(msb): No guest-copy fallback is taken because the sandbox was created with a read-only host config; recreate the sandbox." >&2 + return 1 + fi if [ "$_readonly" = "true" ] && [ -n "$src" ] && [ -f "$src" ] && [ "$_hcfg_mount_ok" -eq 1 ]; then local _ro_guest if _ro_guest=$(_acq_msb_stage_readonly_file "$name" "$path" "$src"); then @@ -1877,36 +1889,38 @@ _acq_msb_reset_kit_env() { } # Whether sandbox NAME's trusted :ro host-config mount may be relied on for -# readonly kit code. The decision comes from the HOST-recorded create fact -# (key ro-host-config, written by acq_backend_provision only after a create that -# passed the :ro --volume returned success and the sandbox became exec-ready), -# never from a guest command: a passwordless-sudo guest controls `test`/`sh` and -# could otherwise force the guest-copy downgrade. The record does NOT prove the -# guest mount works; it proves only that acq asked for it and the sandbox booted. -# A sandbox with no record (legacy, or a create that never completed) takes the -# safe compatibility path: the caller copies the file and prints its warning. -# The guest probe below is diagnostic only: it can warn about a mismatch but -# never changes the decision (fail-closed: if the mount is really absent, the -# staged-path commands may fail rather than silently degrading to a guest copy; -# if it is writable, the warning says so). Likewise a recorded sandbox whose host -# staging step fails aborts the apply instead of copying the file into the guest. +# readonly kit code. Return codes: +# 0 recorded at create AND the guest probe confirms a present, non-writable mount +# 1 no host record (legacy sandbox, or a create that never completed): the caller +# takes the compatibility path and copies the file into the guest, with its warning +# 2 recorded, but the probe finds the mount WRITABLE by the guest +# 3 recorded, but the probe does not confirm the mount (absent, unreadable, or the +# probe could not run) +# The record (key ro-host-config, written by acq_backend_provision after a create that +# passed the :ro --volume returned success and the sandbox became exec-ready) is host +# state a guest cannot forge, but it proves only that acq asked for the mount, not that +# the mount works. The guest probe can only make acq STRICTER: a guest controlling +# `test`/`sh` can answer it however it likes, and every answer other than "present and +# non-writable" makes the caller fail closed (codes 2 and 3) rather than stage or run +# trusted code from the mount, or degrade to a guest copy. A guest that forges the +# "confirmed" answer gains nothing it did not already have: the mount's real +# enforcement is the VMM's, not the probe's. _acq_msb_host_config_mount_available() { - local name="$1" + local name="$1" _probe_rc=0 [ "$(acq_host_config_read msb "$name" ro-host-config)" = "1" ] || return 1 - local _probe_rc=0 _acq_msb_host_config_mount_probe "$name" || _probe_rc=$? case "$_probe_rc" in - 0) ;; - 2) echo "acq(msb): warning: sandbox '$name' was created with the read-only host-config mount but a guest probe indicates it is WRITABLE. Acq trusts the host record, so staged kit code runs from a path the guest may be able to modify. Recreate the sandbox and report this if it persists." >&2 ;; - *) echo "acq(msb): warning: sandbox '$name' was created with the read-only host-config mount but a guest probe does not confirm it is present. Acq trusts the host record and does not fall back to a guest copy, so staged kit commands may fail (kit command failures are reported as warnings). Recreate the sandbox if this persists." >&2 ;; + 0) return 0 ;; + 2) return 2 ;; + *) return 3 ;; esac - return 0 } # Exit 0: mount present and not writable; 1: mount absent/unreadable; 2: guest # root could write to it. Exit 2 relies on `msb exec` passing the guest exit code -# through unchanged; that is not live-verified, so the WRITABLE warning is -# diagnostic only and never affects the decision. +# through unchanged, which is not live-verified. If a different code is returned, +# the writable case is reported as "not confirmed" (code 3 above); both fail closed, +# so only the message differs. _acq_msb_host_config_mount_probe() { local name="$1" _acq_msb_cli exec "$name" -u agent -- sh -c \ diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index 9a44f66b..6958a418 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -668,6 +668,13 @@ _acq_backend_provision_unlocked() { echo "acq(sbx): error: sandbox '$name' was created but provider inputs could not be published." >&2 return 1 fi + # Host state is fixed from what the host observed (this create succeeded with + # these kit flags), BEFORE the guest-written startup barrier is consulted. The + # barrier is a readiness signal a guest can satisfy early, so no record may + # depend on it. Clear stale same-name state first, then seed. + acq_host_config_clear_instance_state sbx "$name" + _acq_sbx_seed_extra_kit_record "$name" + _acq_sbx_seed_builtin_kit_record "$name" acq_spin_start "Waiting for kit startup in '$name'" if ! _acq_sbx_wait_for_startup_barrier "$name" "$_startup_barrier_token"; then acq_spin_stop "Waiting for kit startup in '$name'" @@ -679,7 +686,6 @@ _acq_backend_provision_unlocked() { return 1 fi acq_spin_stop "Waiting for kit startup in '$name'" - acq_host_config_clear_instance_state sbx "$name" if ! acq_provenance_write sbx "$name" "$agent"; then echo "acq(sbx): error: could not record provenance for '$name'." >&2 echo " Removing the new sandbox because safe reattach requires complete state." >&2 @@ -701,8 +707,6 @@ _acq_backend_provision_unlocked() { fi fi acq_workspace_record_write sbx "$name" "$_primary_ws" || true - _acq_sbx_seed_extra_kit_record "$name" - _acq_sbx_seed_builtin_kit_record "$name" # Backend parity (ADR-0030): sbx DELIVERS ~/.rc.d/*.sh via kit files[] but, # unlike msb's .profile bridge, nothing SOURCES them at login. Write the same # bridge here at create time so a kit-dropped snippet actually runs. This is a @@ -806,7 +810,10 @@ _acq_sbx_builtin_kit_record() { echo "acq: warning: could not record built-in kit '$kit' for '$name'; re-attach may re-attempt it." >&2 } -# Create-time seed: the kits the heal consults, as just applied by `sbx create`. +# Create-time seed: the kits the heal consults, as passed to the `sbx create` that +# the host just observed succeed. This is the host-observable fact; it does not +# claim the guest's kit startup commands succeeded (the startup barrier is a guest +# file and is deliberately not an input here). Seeded before the barrier wait. _acq_sbx_seed_builtin_kit_record() { local name="$1" records records="zscaler-ca-certificate ${ZSCALER_KIT}" diff --git a/test/bats/83-msb-ro-host-config-record.bats b/test/bats/83-msb-ro-host-config-record.bats index 72ff922a..219a8166 100644 --- a/test/bats/83-msb-ro-host-config-record.bats +++ b/test/bats/83-msb-ro-host-config-record.bats @@ -58,26 +58,55 @@ _apply() { # NAME KITDIR RECORD [ENV...] ' } -@test "ro record: host record wins when the guest probe reports the mount missing" { +# Deliberate change: these two cases used to assert warn-and-continue (stage and +# run from the mount despite a probe that did not confirm it read-only). A recorded +# sandbox whose mount does not look read-only now fails closed: nothing is staged, +# copied into the guest, or executed. +@test "ro record: a probe that does not confirm the mount fails closed (missing)" { local k="$STUBDIR/k1"; _ro_kit "$k" _apply recmissing "$k" 1 STUB_HOST_CONFIG_MOUNT=0 - assert_success + assert_failure + assert_output --partial "'/home/agent/cfg/merge.mjs'" + assert_output --partial 'not a confirmed read-only mount' + assert_output --partial 'No guest-copy fallback is taken' local log; log=$(cat "$CALLS") - assert_regex "$log" 'node /var/lib/acq/host/kit-files/' + refute_regex "$log" 'node ' refute_regex "$log" 'msb copy .*recmissing:/home/agent/cfg/merge\.mjs' - assert_output --partial 'does not confirm it is present' - assert_output --partial 'does not fall back to a guest copy' + run bash -c 'ls "'"$ACQ_PROVENANCE_DIR"'"/msb/recmissing.*.config/kit-files 2>&1' + assert_failure } -@test "ro record: host record wins when the guest probe reports the mount writable" { +@test "ro record: a probe that finds the mount writable fails closed and stages and runs nothing" { local k="$STUBDIR/k2"; _ro_kit "$k" _apply recwritable "$k" 1 STUB_HOST_CONFIG_WRITABLE=1 - assert_success + assert_failure + assert_output --partial "'/home/agent/cfg/merge.mjs'" + assert_output --partial 'WRITABLE' + assert_output --partial 'No guest-copy fallback is taken' local log; log=$(cat "$CALLS") - assert_regex "$log" 'node /var/lib/acq/host/kit-files/' + refute_regex "$log" 'node ' refute_regex "$log" 'msb copy .*recwritable:/home/agent/cfg/merge\.mjs' - assert_output --partial 'indicates it is WRITABLE' - assert_output --partial 'may be able to modify' + run bash -c 'ls "'"$ACQ_PROVENANCE_DIR"'"/msb/recwritable.*.config/kit-files 2>&1' + assert_failure +} + +@test "ro record: a writable mount does not block a kit with no readonly files" { + local k="$STUBDIR/k2b"; mkdir -p "$k/files" + printf '{}\n' > "$k/files/data.jsonc" + cat >"$k/spec.yaml" <<'SPEC' +schemaVersion: "hybrid/v1" +kind: mixin +name: plain-kit +displayName: Plain Kit +description: data only +files: + - path: /home/agent/cfg/data.jsonc + mode: "0644" + source: files/data.jsonc +SPEC + _apply plainwritable "$k" 1 STUB_HOST_CONFIG_WRITABLE=1 + assert_success + assert_regex "$(cat "$CALLS")" 'msb copy .*plainwritable:/home/agent/cfg/data\.jsonc' } @test "ro record: a confirming probe is silent" { diff --git a/test/bats/90-sbx-startup-kit.bats b/test/bats/90-sbx-startup-kit.bats index 18c9c58c..20137918 100644 --- a/test/bats/90-sbx-startup-kit.bats +++ b/test/bats/90-sbx-startup-kit.bats @@ -522,6 +522,33 @@ agentic-coding-playbook ${PLAYBOOK_KIT}" grep -Fxq -- "agentic-coding-playbook ${PLAYBOOK_KIT}" <<<"$rec" } +@test "provision(sbx): the built-in kit record exists before the startup barrier is consulted" { + : > "$CALLS" + ( + acq_test_stub_provider_input_boundary + # The barrier is a guest-forgeable readiness signal. Whatever it reports, the + # built-in record must already be fixed from host-observed create status. + _acq_sbx_wait_for_startup_barrier() { + acq_host_config_read sbx barrierorder builtin-kits > "$STUBDIR/rec-at-barrier" + return 0 + } + acq_backend_provision barrierorder shell /tmp + ) >/dev/null 2>&1 || true + grep -Fxq -- "zscaler-ca-certificate ${ZSCALER_KIT}" "$STUBDIR/rec-at-barrier" + grep -Fxq -- "agentic-coding-playbook ${PLAYBOOK_KIT}" "$STUBDIR/rec-at-barrier" +} + +@test "provision(sbx): a failed startup barrier leaves no built-in kit record" { + : > "$CALLS" + ( + acq_test_stub_provider_input_boundary + _acq_sbx_wait_for_startup_barrier() { return 1; } + acq_backend_provision barrierfail shell /tmp + ) >/dev/null 2>&1 || true + assert_regex "$(cat "$CALLS")" 'sbx rm --force barrierfail' + assert_equal "$(acq_host_config_read sbx barrierfail builtin-kits)" '' +} + @test "provision(sbx): an empty provider selection seeds no USAi built-in record" { : > "$CALLS" ( From 7425b2b5f9b3778cd62a86bc7c7314e0c95b0a86 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Tue, 27 Oct 2026 15:48:53 +0000 Subject: [PATCH 45/63] docs: describe the barrier and the read-only mount probe as they now behave Co-authored-by: OpenCode Agent --- docs/KNOWN_FAILURE_MODES.md | 45 +++++++++++++------ .../0035-host-authoritative-sandbox-config.md | 33 +++++++------- 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/docs/KNOWN_FAILURE_MODES.md b/docs/KNOWN_FAILURE_MODES.md index 8e24eb3c..93236690 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -3033,8 +3033,8 @@ environment, or uses the shell path as an input to a host-side decision. ## 47. SBX Startup Barrier Is a Guest File Check (Accepted Residual Risk) -**Status:** Accepted / observed. No code change: the barrier cannot be made -host-authoritative without sbx features that have not been verified. +**Status:** Accepted / observed for the barrier itself. No host-authoritative +startup signal exists; the barrier no longer decides the kit records. ### Observation @@ -3047,21 +3047,40 @@ is authoritative, but the signal itself is a guest file. A guest that can read the token (the normal case, not a hypothetical) or that can alter how the startup command is replayed (unverified on sbx) could make acq believe startup finished. +### What the barrier does and does not guarantee + +- **It guarantees only** that acq does not attach, or keep the sandbox, until a + guest file contains this create's token. That is an ordering aid for honest + guests; it is not evidence that any kit's startup commands succeeded. +- **It does not decide the kit records.** The `extra-kits` and `builtin-kits` + records are written from what the host observed: the `sbx create` it ran + succeeded with those kit flags. They are seeded before the barrier is + consulted, so a guest that satisfies the barrier early (or never runs its + startup commands) changes neither. Provenance and the launch-mode record are + also host-derived but are written after the barrier succeeds; the barrier + decides only whether acq goes on to write them or removes the sandbox and its + host state. +- **What a record therefore means:** "these kits were passed to a create that + succeeded", not "these kits' startup commands ran and finished". A heal that + trusts the record will not re-apply those kits on its own initiative; the + recovery for a damaged guest is `acq rm` and recreate (and on sbx 0.38 a + re-apply of a startup-bearing kit is refused anyway, see section 36). + ### Why this is accepted -- **The barrier is a readiness/liveness gate, not an integrity gate.** It gates - whether acq keeps the sandbox and proceeds to the host-written records and - attach. A forged token lets acq proceed before kit startup has finished, so the - guest can only hurt its own setup; it changes no host-derived value. -- **No acq trust decision reads it.** The values acq acts on across restarts - (`extra-kits`, `builtin-kits`, `generic-opencode`, provenance) are host-written - and independent of this file. +- **Nothing in the guest is a trust anchor.** The sandbox is the boundary, and a + guest can already damage its own setup by editing its filesystem after create. + The barrier adds no new way to do that. +- **No acq trust decision reads the barrier.** The values acq acts on across + restarts (`extra-kits`, `builtin-kits`, `generic-opencode`, provenance) are + host-written from host-observed status and independent of this file. - **A host-authoritative signal needs sbx support not verified here.** The only candidates are a backend-reported startup status or a host-visible channel out - of the guest. Neither was found in the sbx documentation or CLI - help consulted for this repository (not exhaustively checked), and adding one by guessing would trade a low-impact residual risk for - an unverifiable mechanism. The related trusted-startup question for SBX is - analyzed in ADR-0036 (SBX trusted-startup parity). + of the guest. Neither was found in the sbx documentation or CLI help consulted + for this repository (not exhaustively checked), and adding one by guessing would + trade a low-impact residual risk for an unverifiable mechanism. The related + trusted-startup question for SBX is analyzed in the SBX trusted-startup parity + ADR (`docs/adr/0036-sbx-trusted-startup-parity.md`). ### Revisit if diff --git a/docs/adr/0035-host-authoritative-sandbox-config.md b/docs/adr/0035-host-authoritative-sandbox-config.md index 6849e6e1..a05c78a7 100644 --- a/docs/adr/0035-host-authoritative-sandbox-config.md +++ b/docs/adr/0035-host-authoritative-sandbox-config.md @@ -179,20 +179,21 @@ Concretely, this ADR introduces two mechanisms and a staged migration. a guest command, which a passwordless-sudo guest could forge to force the guest-copy downgrade. A sandbox with no record (created before the key existed, or never fully created) takes the legacy guest-copy path and warns. A - guest-executed probe of the mount remains as a diagnostic only: when a record - exists and the probe disagrees, acq warns and still trusts the record. If the - mount looks absent, acq warns that staged kit commands may fail (kit command - failures are themselves reported as warnings) and does not fall back to a guest - copy; if it looks guest-writable, acq warns that staged code runs from a path - the guest may be able to modify, which is no stronger than the guest copy and - signals that `:ro` is not being enforced. This is a deliberate change from - "degrade to a guest copy" to "warn and possibly fail" (no escalation to the - guest-writable path), applied only when the record exists; legacy and - no-record sandboxes still degrade safely. The record is never evidence of - verified read-only enforcement: it proves a `:ro` request plus exec readiness - only. When a record exists and staging a `readonly: true` file onto the host - fails, the apply fails with an error naming the file rather than copying it into - the guest; only sandboxes with no record take the guest copy. If a future + guest-executed probe of the mount can only make acq stricter: when a record + exists, any probe answer other than "present and not writable" makes acq fail + closed. If the mount looks absent or the probe cannot confirm it, or if it looks + guest-writable, applying a `readonly: true` file fails with an error naming the + file; acq stages nothing, runs nothing from the mount, and does not copy the file + into the guest. A guest that controls the probe can therefore cause a refusal but + cannot make acq trust anything it would not otherwise trust. This is a deliberate + change from "degrade to a guest copy" (and from an intermediate "warn and + continue" for the writable case) to fail closed, applied only when the record + exists; legacy and no-record sandboxes still degrade to the guest copy with a + warning. The record is never evidence of verified read-only enforcement: it + proves a `:ro` request plus exec readiness only. When a record exists and + staging a `readonly: true` file onto the host fails, the apply likewise fails + with an error naming the file rather than copying it into the guest; only + sandboxes with no record take the guest copy. If a future msb changes `--volume ...:ro` parsing or enforcement, acq must raise or gate its minimum msb version before relying on ADR-0035. Host and guest path forms follow ADR-0029. @@ -372,12 +373,12 @@ stubbed backends; they prove acq's decision logic, not live sbx or msb behavior. | SSH socket derives from host authority only | `81`: "SSH_AUTH_SOCK comes from the host record", "no host ssh-auth-sock record ... no SSH_AUTH_SOCK" (sbx forwards natively, no acq-held value) | Forged socket marker with a live ssh-agent forward | | Kit environment derives from host authority only | `81`: "kit environment replay uses only the host kit-env record", "no host kit-env record ... no forged variable"; `78-msb-kit-env.bats` (sbx injects natively) | Forge the guest marker, confirm the session environment | | A forged guest marker cannot redirect any of the four | Same cases; the forging stub answers every legacy `/var/lib/acq/*` read with attacker values and asserts those values never reach argv | Same live checks | -| Inventory of remaining guest reads | The remaining guest-derived reads are fail-soft liveness and presence probes, the diagnostic mount probe, the passwd login shell and the sbx startup barrier (the last two in `docs/KNOWN_FAILURE_MODES.md` sections 46 and 47) | None | +| Inventory of remaining guest reads | The remaining guest-derived reads are fail-soft liveness and presence probes, the mount probe (which can only make acq stricter), the passwd login shell and the sbx startup barrier (the last two in `docs/KNOWN_FAILURE_MODES.md` sections 46 and 47) | None | | Real backend validation recorded | Not recorded for this slice | Record in `docs/VERIFY_PROVIDER_ADOPTION.md` after `scripts/verify-backends` on both backends | Adjacent gates also moved to host authority, beyond the four GSA-TTS/agentic-coding-quickstart#636 fields: the sbx `extra-kits` and `builtin-kits` records (forged-probe cases in -`90-sbx-startup-kit.bats`), the msb `ro-host-config` create fact +`90-sbx-startup-kit.bats`; both are seeded from the host-observed `sbx create` before the guest startup barrier is consulted, so they do not depend on it), the msb `ro-host-config` create fact (`83-msb-ro-host-config-record.bats`), and the msb install and agent-user gates (`81`, `82-msb-legacy-sandbox.bats`). Legacy sandboxes degrade as documented in `docs/KNOWN_FAILURE_MODES.md` section 32.1. SBX trusted-startup parity is From 69771434a2139e3a2b28ee5f96c6f33df75e2780 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Tue, 27 Oct 2026 15:50:32 +0000 Subject: [PATCH 46/63] docs(verify): record live validation at 8a594932 with SBX v0.48.0 and update the merge gate Add rows for the maintainer's 2026-10-27 re-run on the merged head (identity-bound model check and --no-provider-kits removal, PASS on SBX v0.48.0 and MSB 0.7.7), keep the ec8d5e17 rows as history, resolve the earlier "SBX version not captured" gap for the new rows, and state the gate as satisfied for 8a594932 only. Co-authored-by: OpenCode Agent --- docs/VERIFY_PROVIDER_ADOPTION.md | 40 +++++++++++++++++++------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/docs/VERIFY_PROVIDER_ADOPTION.md b/docs/VERIFY_PROVIDER_ADOPTION.md index 216e0a0f..9870c29c 100644 --- a/docs/VERIFY_PROVIDER_ADOPTION.md +++ b/docs/VERIFY_PROVIDER_ADOPTION.md @@ -24,14 +24,14 @@ host. Real projected parser validation requires a live backend run (see ## Merge Gate -**Status: the required live runs were done at tested revision `ec8d5e17` -(`ec8d5e177a520a69e1b835895763a9aac520af1b`) and passed on both backends.** One -recording gap remains: the SBX version was not captured. The gate's required -runs are satisfied for that revision only. Offline Bats seam tests remain -separate from the live evidence; they prove acq's decision logic with stubbed -backends, not live SBX or MSB behavior. +**Status: the required live runs were done at tested revision `8a594932` +(`8a594932e47745ed8f670a477f9da974121f3506`) and passed on both backends,** with +the SBX version captured (v0.48.0). An earlier run at `ec8d5e17` also passed and +is kept as history. The gate's required runs are satisfied for `8a594932` only. +Offline Bats seam tests remain separate from the live evidence; they prove acq's +decision logic with stubbed backends, not live SBX or MSB behavior. -**Required before merge, and their status at `ec8d5e17`** +**Required before merge, and their status at `8a594932`** 1. Run the updated `scripts/verify-backends` identity-bound model check (projected snapshot vs rendered OpenCode config vs `opencode models`) on a @@ -40,11 +40,16 @@ backends, not live SBX or MSB behavior. paragraph in the live-commands block below) on real SBX and MSB, including all three omission assertions. **Done, PASS on both** (2026-10-27). 3. Record each result as `PASS`, `FAIL`, or `BLOCKED`, with the backend version, - timestamp, and the **tested revision SHA**. **Done in the Live Validation - Record below, except that the SBX version was not captured** (MSB 0.7.7 was). + timestamp, and the **tested revision SHA**. **Done** in the Live Validation + Record below (SBX v0.48.0, MSB 0.7.7). + +**Why a re-run at `8a594932`.** Between `ec8d5e17` and `8a594932` the branch +merged a newer `main`, which changed, among other files, `acq`, +`acq.backends/kit-translate.sh` and `acq.backends/msb.sh`; the earlier result did not cover those changes, so the +live checks were run again on the merged head. **Applying this to later heads.** A result for a different revision does not -satisfy the gate by itself. Any commit after `ec8d5e17` must be judged by what it +satisfy the gate by itself. Any commit after `8a594932` must be judged by what it changes: a commit that only edits this document does not change the tested code, but a change to `acq`, `acq.backends/`, `scripts/verify-backends`, the pinned kits, or provider-input handling needs its effect judged and, where it could @@ -53,9 +58,10 @@ change the checked behavior, a re-run. **Evidence.** The earlier live PASS rows (2026-10-24 and 2026-10-25) were observed on earlier heads and used the previous prefix-only model check (`opencode models | grep "^usai/"`) or a manual `opencode models`; they make no -claim about the identity-bound check or this revision. The 2026-10-27 rows -(see the table) were produced by the maintainer running the validation script on -a real host; raw results are kept in a gitignored host directory (see the rows). +claim about the identity-bound check or these revisions. The 2026-10-27 rows +(see the table) were produced by the maintainer running a validation script on a +real host; raw results are kept in gitignored host directories (see the rows) and +are not committed. ## Offline Checks @@ -90,7 +96,7 @@ actual command output. `PENDING` marks a check that is implemented but has not been run on a live host; it is not a result and must be replaced by `PASS`, `FAIL`, or `BLOCKED`. No row -is PENDING at `ec8d5e17`. +is PENDING at `ec8d5e17` or `8a594932`. | Check | SBX | MSB | Observed output / notes | |---|---|---|---| @@ -101,8 +107,10 @@ is PENDING at `ec8d5e17`. | Fresh authoritative-empty provider selection has no USAi models | PASS | PASS | 2026-10-25: `--no-provider-kits` created fresh OpenCode sandboxes with no USAi kit, credential gate, or models on either backend. | | Malformed provider input is rejected before startup | PASS | PASS | 2026-10-25: both backends rejected malformed `provider-input.json` before backend creation with `acq: invalid provider input manifest ... invalid JSON at line 1 column 2`. | | `scripts/verify-backends --only ` | PASS | PASS | Final combined branch: SBX transcript `tmp/provider-neutral-validation.TCY2fZvE/sbx.log` (13 pass, 0 fail, 1 skip); MSB transcript `tmp/provider-neutral-validation.8MP6vbkX/msb.log` (18 pass, 0 fail, 1 skip). | -| Identity-bound provider model check in `scripts/verify-backends` (snapshot vs rendered config vs `opencode models`) | PASS | PASS | 2026-10-27, tested revision `ec8d5e177a520a69e1b835895763a9aac520af1b`, run by the maintainer on a real host. SBX: `verify-backends` 13 passed, 0 failed, 1 skipped (sbx version not captured). MSB 0.7.7: 18 passed, 0 failed, 1 skipped. Both printed `rendered config and opencode models match the projected usai snapshot (24 models)`. The skip on both is the playbook fetch (no GitHub token available to the run). The earlier PASS rows above used the prefix-only check and are not evidence for this row. Raw results: `tmp/validate-611-results-20261027T100924Z/results.tsv`, in a gitignored host-side directory (not committed). | -| `--no-provider-kits` removal procedure with the three explicit omission assertions (no USAi kit/credential text, no `usai` projection, no `usai/` models) | PASS | PASS | 2026-10-27, tested revision `ec8d5e177a520a69e1b835895763a9aac520af1b`, same run and host as the row above (SBX version not captured; MSB 0.7.7). All three assertions passed on each backend. The earlier 2026-10-25 omission row used its own checks and is a separate observation. Raw results: same gitignored `results.tsv`. | +| Identity-bound provider model check in `scripts/verify-backends` (snapshot vs rendered config vs `opencode models`) | PASS | PASS | History, superseded by the `8a594932` rows below. 2026-10-27, tested revision `ec8d5e177a520a69e1b835895763a9aac520af1b`, run by the maintainer on a real host. SBX: `verify-backends` 13 passed, 0 failed, 1 skipped (SBX version not captured in that run; captured in the `8a594932` rows). MSB 0.7.7: 18 passed, 0 failed, 1 skipped. Both printed `rendered config and opencode models match the projected usai snapshot (24 models)`. The skip on both is the playbook fetch (no GitHub token available to the run). The earlier PASS rows above used the prefix-only check and are not evidence for this row. Raw results: `tmp/validate-611-results-20261027T100924Z/results.tsv`, in a gitignored host-side directory (not committed). | +| `--no-provider-kits` removal procedure with the three explicit omission assertions (no USAi kit/credential text, no `usai` projection, no `usai/` models) | PASS | PASS | History, superseded by the `8a594932` rows below. 2026-10-27, tested revision `ec8d5e177a520a69e1b835895763a9aac520af1b`, same run and host as the row above (SBX version not captured in that run, captured in the `8a594932` rows; MSB 0.7.7). All three assertions passed on each backend. The earlier 2026-10-25 omission row used its own checks and is a separate observation. Raw results: same gitignored `results.tsv`. | +| Identity-bound provider model check in `scripts/verify-backends` (snapshot vs rendered config vs `opencode models`), re-run on the merged head | PASS | PASS | 2026-10-27 (run started 15:26Z), tested revision `8a594932e47745ed8f670a477f9da974121f3506`, run by the maintainer on a real host under bash 3.2.57 with a guard that the checked-out revision matched. SBX v0.48.0 (`5caf923e0587d376848f6f388eca3e0f5ca00e2d`): `verify-backends` 13 passed, 0 failed, 1 skipped. MSB 0.7.7: 18 passed, 0 failed, 1 skipped (its `oci-engine` kit checks also passed). Both printed `rendered config and opencode models match the projected usai snapshot (24 models)`. The skip on both is the playbook fetch (no GitHub token available to the run). The earlier `ec8d5e17` row is history and this row supersedes it for the merged head. Raw results: `tmp/validate-611-results-20261027T152646Z/` in a gitignored host-side directory (not committed). | +| `--no-provider-kits` removal procedure with the three omission assertions, re-run on the merged head | PASS | PASS | 2026-10-27, tested revision `8a594932e47745ed8f670a477f9da974121f3506`, same run and host as the row above (SBX v0.48.0, MSB 0.7.7). All three assertions passed on each backend. | ### Earlier Failed Live Evidence (2026-10-24) From c87c8d0a8bc73669d5ec49b115907df07e7ffefe Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 28 Oct 2026 00:59:14 +0000 Subject: [PATCH 47/63] docs(adr): renumber the sbx trusted-startup parity ADR to 0037 main already uses 0036 for the msb foreground Node invocation ADR. Rename ours and update its title and the references in ADR-0035 and the known-failure-modes doc; drop the now-resolved collision note. The msb ADR-0036 references are unchanged. Co-authored-by: OpenCode Agent --- docs/KNOWN_FAILURE_MODES.md | 2 +- docs/adr/0035-host-authoritative-sandbox-config.md | 2 +- ...-startup-parity.md => 0037-sbx-trusted-startup-parity.md} | 5 +---- 3 files changed, 3 insertions(+), 6 deletions(-) rename docs/adr/{0036-sbx-trusted-startup-parity.md => 0037-sbx-trusted-startup-parity.md} (98%) diff --git a/docs/KNOWN_FAILURE_MODES.md b/docs/KNOWN_FAILURE_MODES.md index 93236690..7b48a277 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -3080,7 +3080,7 @@ command is replayed (unverified on sbx) could make acq believe startup finished. for this repository (not exhaustively checked), and adding one by guessing would trade a low-impact residual risk for an unverifiable mechanism. The related trusted-startup question for SBX is analyzed in the SBX trusted-startup parity - ADR (`docs/adr/0036-sbx-trusted-startup-parity.md`). + ADR (`docs/adr/0037-sbx-trusted-startup-parity.md`). ### Revisit if diff --git a/docs/adr/0035-host-authoritative-sandbox-config.md b/docs/adr/0035-host-authoritative-sandbox-config.md index a05c78a7..d3f26eab 100644 --- a/docs/adr/0035-host-authoritative-sandbox-config.md +++ b/docs/adr/0035-host-authoritative-sandbox-config.md @@ -382,7 +382,7 @@ GSA-TTS/agentic-coding-quickstart#636 fields: the sbx `extra-kits` and `builtin- (`83-msb-ro-host-config-record.bats`), and the msb install and agent-user gates (`81`, `82-msb-legacy-sandbox.bats`). Legacy sandboxes degrade as documented in `docs/KNOWN_FAILURE_MODES.md` section 32.1. SBX trusted-startup parity is -proposed separately in [ADR-0036](0036-sbx-trusted-startup-parity.md). +proposed separately in [ADR-0037](0037-sbx-trusted-startup-parity.md). ## Links diff --git a/docs/adr/0036-sbx-trusted-startup-parity.md b/docs/adr/0037-sbx-trusted-startup-parity.md similarity index 98% rename from docs/adr/0036-sbx-trusted-startup-parity.md rename to docs/adr/0037-sbx-trusted-startup-parity.md index afd763c9..eb7df048 100644 --- a/docs/adr/0036-sbx-trusted-startup-parity.md +++ b/docs/adr/0037-sbx-trusted-startup-parity.md @@ -11,7 +11,7 @@ risk_treatment: mitigate supersedes: [] --- -# ADR-0036: SBX Trusted-Startup Parity +# ADR-0037: SBX Trusted-Startup Parity ## Context and Problem Statement @@ -51,9 +51,6 @@ version-scoped and is not assumed for later releases. The "mountless sandbox" behavior attributed to sbx >= 0.42 below is likewise unverified. This ADR concerns create-time delivery and restart replay only. -ADR numbering: a number collision with other unmerged ADRs is possible and must -be resolved by renumbering at merge. - ## Decision Drivers - **Same rule as ADR-0035**: acq-trusted code is host-materialized and `:ro`, or From 9d3ef1b586dd04277c2ebae873f42dd55a54ce0f Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 28 Oct 2026 01:36:10 +0000 Subject: [PATCH 48/63] fix(msb): stop gating setup on host records of guest-reported success The agent-user-ready and install- host keys are written when a guest command exits zero, which the host cannot verify; a guest that reports one false success could suppress a later run. The idempotent agent-user and passwordless-sudo setup now runs on every provision and heal and writes no gate. The install-phase gate remains a run-once cache but is bypassed by an explicit forced re-apply (ACQ_FORCE_KIT_REAPPLY=1). Tests that asserted the old replay-once and skip-on-record behavior are replaced deliberately with re-verification and forced re-apply cases. Co-authored-by: OpenCode Agent --- acq.backends/common.sh | 4 +- acq.backends/msb.sh | 46 +++++++++++----------- test/bats/72-msb-agent-user.bats | 3 +- test/bats/81-msb-forged-guest-markers.bats | 41 ++++++++++++++++--- test/bats/82-msb-legacy-sandbox.bats | 14 ++++--- 5 files changed, 71 insertions(+), 37 deletions(-) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 7f5becc4..bca33b58 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -2253,8 +2253,8 @@ acq_provenance_status() { # (acq_backend_ensure_kits_applied) with ACQ_FORCE_KIT_REAPPLY=1 so a # present-but-stale kit is actually re-applied (the sbx host builtin-kits record # would otherwise skip a kit recorded as applied at an older ref; the force flag -# bypasses that record). msb already -# re-applies all kits idempotently; the force flag is a no-op there. After a usai +# bypasses that record). msb re-applies all kits on every heal; there the force +# flag additionally bypasses the host run-once gate for install-phase commands. After a usai # refresh the kit's own startup global-merge re-runs on next attach, so the # refreshed model catalog reaches the config. State (sessions, secrets, unrelated # config, project overrides) is preserved: this is an in-place kit reapply, never diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 06b23ba8..8045adbe 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -2311,8 +2311,12 @@ _acq_msb_exec_foreground() { # _acq_msb_exec_install NAME USER UFLAG_ARRVAR EFLAG_ARRVAR -- ARGV... — run an # install-phase command, gated by a per-command marker (hash of argv) so it runs -# once per sandbox even across re-applies. The marker is held in host config so -# a passwordless-sudo guest cannot forge it to suppress the command. +# once per sandbox even across re-applies. The marker is held in host config, so a +# guest cannot plant it, but it records only that the guest REPORTED a zero exit: +# the host cannot verify what an install command did, and a guest that lies about +# one run could suppress later ones. It is therefore a cache, not an integrity +# fact, and an explicit forced re-apply (ACQ_FORCE_KIT_REAPPLY=1, as set by +# `acq kit update`) bypasses it. Install commands are expected to be idempotent. _acq_msb_exec_install() { local _name="$1" _user="$2" _uflagn="$3" _eflagn="$4" shift 4 @@ -2326,7 +2330,7 @@ _acq_msb_exec_install() { local marker marker="install-$(printf '%s\0' "$@" | cksum | cut -d' ' -f1)" - if acq_host_config_has msb "$_name" "$marker"; then + if [ "${ACQ_FORCE_KIT_REAPPLY:-0}" != "1" ] && acq_host_config_has msb "$_name" "$marker"; then acq_debug "msb cmd[install] already done (marker hit): $*" return 0 fi @@ -4037,12 +4041,12 @@ EOF # - HTTP proxy env (HTTP_PROXY/HTTPS_PROXY/NO_PROXY) PRESERVED ACROSS SUDO # - the agent binary (baked in, or installed via commands.install) # The default image satisfies the first three already, so this step is a -# short-circuit there (the marker/`id agent` check returns early, and the -# sudoers block merely re-asserts NOPASSWD, which is harmless). A plain OCI base +# no-op there (`id agent` succeeds, and the sudoers block merely re-asserts +# NOPASSWD, which is harmless). A plain OCI base # override (e.g. node:22-bookworm, which ships `node` at uid 1000 and no `agent`, # and no sudoers rule) meets NONE of the first three. The msb adapter therefore # synthesizes them here so both the kits (which run as `-u 1000`) and the agent -# behave as they do on sbx. Idempotent + marker-gated; runs fully offline +# behave as they do on sbx. Idempotent and re-run on every call; runs fully offline # (useradd/adduser/sudoers edits need no network). The uid defaults to 1000; if # 1000 is taken (e.g. by `node` on a plain base), `agent` is created at the next # free uid and the kits' `-u 1000` commands still map to it by NAME (see @@ -4060,14 +4064,11 @@ acq_backend_provision() { _acq_msb_ensure_agent_user() { local name="$1" - local marker="agent-user-ready" - if acq_host_config_has msb "$name" "$marker"; then - # The user exists from an earlier run, but its login shell may predate the - # passwd-shell setup: re-sync it so an existing /bin/sh agent user - # is healed to bash on its next run, not left behind. - _acq_msb_ensure_agent_shell "$name" - return 0 - fi + # Not gated on any recorded marker: the setup below is idempotent and cheap, and + # a "done" record could only ever say the guest reported a zero exit, which a + # guest could fake once and so suppress the check forever. Re-running it on every + # provision and heal keeps the base-image contract verified rather than + # remembered. No agent-user-ready key is written any more. acq_debug "msb: ensuring agent user + /home/agent + passwordless sudo + proxy-preserve in $name" # Idempotent, distro-agnostic. If an `agent` user already exists, reuse it @@ -4145,7 +4146,6 @@ _acq_msb_ensure_agent_user() { return 1 } _acq_msb_ensure_agent_shell "$name" - acq_host_config_write msb "$name" "$marker" 1 || true } # _acq_msb_ensure_agent_shell NAME — align the agent's login shell with what @@ -4162,8 +4162,8 @@ _acq_msb_ensure_agent_user() { # `export SHELL=/bin/sh` line earlier acq versions appended is removed wherever # it appears (it stopped a login bash cold); a ~/.profile with any other content # is left alone (image/user-owned — Debian's own skel already bridges). -# Idempotent and re-run on every provision AND heal (even on an agent-user-ready -# marker hit), which is what upgrades sandboxes created before this setup. +# Idempotent and re-run on every provision AND heal, which is what upgrades +# sandboxes created before this setup. # Fail-soft: a sync failure leaves sessions on the /bin/sh fallback, never blocks # the run. _acq_msb_ensure_agent_shell() { @@ -5734,7 +5734,8 @@ _acq_msb_heal_kit_set() { # msb sandboxes are recreated cheaply and msb has no in-place kit-add that # preserves state guarantees the way sbx 0.35.0 does. Rather than silently # destroy state, re-apply the neutral kits idempotently (files are overwritten, -# commands are idempotent / install-marker-gated). If a caller needs a clean +# commands are idempotent / install-marker-gated unless a forced re-apply is +# requested). If a caller needs a clean # rebuild, they can `acq rm && acq run`. acq_backend_ensure_kits_applied() { @@ -5774,10 +5775,11 @@ acq_backend_ensure_kits_applied() { # present — the reported "have to export SSH_AUTH_SOCK by hand" symptom. Cheap # no-op when no host forward is requested or the sandbox has no vsock route. _acq_msb_ensure_ssh_agent_forward "$name" - # Re-run the agent-user setup. It is idempotent and marker-gated, and its - # login-shell sync deliberately runs even on a marker hit — that is - # what heals a sandbox whose agent user was created with /bin/sh before the - # passwd-shell setup existed. Best-effort like the rest of the heal. + # Re-run the agent-user setup. It is idempotent and not gated on any recorded + # marker (a guest-reported success is not trusted to skip it), and it includes + # the login-shell sync that heals a sandbox whose agent user was created with + # /bin/sh before the passwd-shell setup existed. Best-effort like the rest of + # the heal. _acq_msb_ensure_agent_user "$name" || \ echo "acq(msb): warning: agent-user heal failed for '$name'." >&2 # Rebuild after any persisted refs were loaded by the dispatcher. CLI-supplied diff --git a/test/bats/72-msb-agent-user.bats b/test/bats/72-msb-agent-user.bats index 4380b95a..80fe9ebe 100644 --- a/test/bats/72-msb-agent-user.bats +++ b/test/bats/72-msb-agent-user.bats @@ -389,7 +389,7 @@ _attach() { # PRE_SNIPPET NAME assert_equal "$(grep -c -- '-w /tmp/myrepo cachebox' "$CALLS")" "2" } -@test "msb #426: heal upgrades an existing /bin/sh agent user (marker hit still syncs the shell)" { +@test "msb #426: heal upgrades an existing /bin/sh agent user (the shell sync runs on every heal)" { : > "$CALLS" printf 'healshbox\n' > "$STUBDIR/.msb_sandbox_list" printf 'healshbox\n' > "$STUBDIR/.msb_running_list" @@ -405,7 +405,6 @@ _attach() { # PRE_SNIPPET NAME ' local log; log=$(cat "$CALLS") assert_regex "$log" 'acq-login-profile.* sh /bin/bash' - refute_regex "$log" 'useradd' } @test "msb #426: shell and attach exec the agent passwd shell and set SHELL to match" { diff --git a/test/bats/81-msb-forged-guest-markers.bats b/test/bats/81-msb-forged-guest-markers.bats index 3bf8c4d7..88b949aa 100644 --- a/test/bats/81-msb-forged-guest-markers.bats +++ b/test/bats/81-msb-forged-guest-markers.bats @@ -276,6 +276,19 @@ SPEC assert_equal "$(grep -c 'INSTALL_FORGE_PROBE' "$CALLS")" 1 } +@test "forged marker(msb): a forced re-apply re-runs an install command a recorded success would suppress" { + _install_forging_msb + : > "$CALLS" + _apply_install_kit forcegate + assert_success + assert_equal "$(grep -c 'INSTALL_FORGE_PROBE' "$CALLS")" 1 + _apply_install_kit forcegate + assert_equal "$(grep -c 'INSTALL_FORGE_PROBE' "$CALLS")" 1 + ACQ_FORCE_KIT_REAPPLY=1 _apply_install_kit forcegate + assert_success + assert_equal "$(grep -c 'INSTALL_FORGE_PROBE' "$CALLS")" 2 +} + # --- agent-user-ready gate -------------------------------------------------- @test "forged marker(msb): a forged agent-user-ready marker cannot skip agent-user setup" { @@ -288,20 +301,36 @@ SPEC assert_regex "$log" 'test -w /home/agent' assert_regex "$log" 'NOPASSWD:ALL' refute_regex "$log" "$LEGACY_MARKER_RE" - # The host gate was recorded host-side. - local hostkey; hostkey=$(ls "$ACQ_PROVENANCE_DIR"/msb/gatebox.*.config/agent-user-ready 2>/dev/null | head -n1) - [ -n "$hostkey" ] + # No gate is recorded anywhere: the setup is re-verified on every call. + run bash -c 'ls "'"$ACQ_PROVENANCE_DIR"'"/msb/gatebox.*.config/agent-user-ready 2>&1' + assert_failure } -@test "forged marker(msb): the host agent-user-ready key (not a guest marker) is what skips setup" { +# Deliberate change: this test used to assert that a host agent-user-ready key +# SKIPS the setup block. The key is a record of a guest-reported zero exit, which a +# guest can fake once, so it no longer suppresses the (idempotent, cheap) setup: +# it is re-verified on every call and a one-time false success cannot stick. +@test "forged marker(msb): a recorded agent-user success does not suppress re-verification" { _install_forging_msb _forged_run ' acq_host_config_write msb hostgatebox agent-user-ready 1 _acq_msb_ensure_agent_user hostgatebox + _acq_msb_ensure_agent_user hostgatebox ' assert_success local log; log=$(cat "$CALLS") - refute_regex "$log" 'test -w /home/agent' - refute_regex "$log" 'NOPASSWD:ALL' + assert_equal "$(grep -c 'NOPASSWD:ALL' "$CALLS")" 2 refute_regex "$log" "$LEGACY_MARKER_RE" } + +@test "forged marker(msb): a first run that only appeared to succeed is re-run, not remembered" { + _install_forging_msb + _forged_run ' + _acq_msb_ensure_agent_user falsesuccess + _acq_msb_ensure_agent_user falsesuccess + ' + assert_success + assert_equal "$(grep -c 'NOPASSWD:ALL' "$CALLS")" 2 + run bash -c 'ls "'"$ACQ_PROVENANCE_DIR"'"/msb/falsesuccess.*.config/agent-user-ready 2>&1' + assert_failure +} diff --git a/test/bats/82-msb-legacy-sandbox.bats b/test/bats/82-msb-legacy-sandbox.bats index 1b7cbbb8..468104cc 100644 --- a/test/bats/82-msb-legacy-sandbox.bats +++ b/test/bats/82-msb-legacy-sandbox.bats @@ -6,7 +6,8 @@ # Such a sandbox has no host-config keys. acq never imports the old guest markers # (a passwordless-sudo guest could forge them), so each missing key must degrade # as documented in docs/KNOWN_FAILURE_MODES.md (section 32.1): no SSH_AUTH_SOCK -# until the forward is re-driven, and the install / agent-user gates replay once. +# until the forward is re-driven, and the install gate replays once. The +# agent-user setup has no gate: it is re-verified on every heal. # # shellcheck shell=bats @@ -79,11 +80,14 @@ SPEC [ -n "$gate" ] } -@test "legacy msb: the agent-user setup replays once on heal, then the host gate holds" { +# Deliberate change: this used to assert the agent-user setup replays ONCE and is +# then suppressed by a host gate. The gate recorded only a guest-reported success, +# so the setup is now re-verified on every heal and no gate key is written. +@test "legacy msb: the agent-user setup is re-verified on every heal and writes no gate" { : > "$CALLS" _heal_legacy legacyuserbox local runs; runs=$(grep -c 'NOPASSWD:ALL' "$CALLS" || true) - assert_equal "$runs" "1" - run test -f "$(ls -d "$ACQ_PROVENANCE_DIR"/msb/legacyuserbox.*.config)/agent-user-ready" - assert_success + assert_equal "$runs" "2" + run bash -c 'ls "'"$ACQ_PROVENANCE_DIR"'"/msb/legacyuserbox.*.config/agent-user-ready 2>&1' + assert_failure } From 09ee6967f851c0c0661fd696372a5e66dfc122b9 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 28 Oct 2026 01:36:10 +0000 Subject: [PATCH 49/63] docs: describe the install and agent-user records as caches of a guest-reported exit Co-authored-by: OpenCode Agent --- docs/KNOWN_FAILURE_MODES.md | 4 ++-- docs/adr/0035-host-authoritative-sandbox-config.md | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/KNOWN_FAILURE_MODES.md b/docs/KNOWN_FAILURE_MODES.md index 7b48a277..4d073c79 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -1787,8 +1787,8 @@ sandbox): | Backend | Missing host state | Observed behavior | Recovery | | --- | --- | --- | --- | | msb | `ssh-auth-sock` | No fallback. `SSH_AUTH_SOCK` is not set in sessions until a heal writes the key. `acq run`, `acq start`, and `acq restart` heal it only when a host ssh-agent forward is requested (`SSH_AUTH_SOCK` names an existing socket) **and** the sandbox has the create-time ssh-agent `--vsock` route. Without a host forward the heal is a silent no-op; with a forward but no route acq warns and leaves it unset. `acq exec` and `acq shell` run no heal, so they stay without it until a heal has run. | Run `acq run ` with `SSH_AUTH_SOCK` set. With no route, recreate (see KFM §35). | -| msb | `install-` | Each install-phase kit command runs once more on the first heal, then the host gate is written. A command that fails is not recorded and is retried on every heal. acq does not check that third-party install commands are idempotent. | None needed for kits written to be idempotent. | -| msb | `agent-user-ready` | The agent-user and passwordless-sudo setup runs once more on the first heal, then the gate is written. An existing `agent` user is reused. | None needed. | +| msb | `install-` | Each install-phase kit command runs once more on the first heal, then the host gate is written. A command that fails is not recorded and is retried on every heal. The gate records only that the guest reported a zero exit, so it is a run-once cache, not proof the step worked: `ACQ_FORCE_KIT_REAPPLY=1` (set by `acq kit update` and the stale-bundle refresh) bypasses it and re-runs install commands. acq does not check that third-party install commands are idempotent. | None needed for kits written to be idempotent. To force a re-run on a sandbox whose bundle is stale, `acq kit update `; otherwise `ACQ_FORCE_KIT_REAPPLY=1 acq run `, or `acq rm` and recreate. | +| msb | `agent-user-ready` | No longer used. The agent-user and passwordless-sudo setup is idempotent and runs on every provision and heal instead of being skipped on a recorded gate, because a gate could only record a guest-reported exit. An existing `agent` user is reused. Sandboxes carrying an old `agent-user-ready` key are unaffected (it is ignored). | None needed. | | msb | `kit-env` | `acq exec`, `acq shell`, and attach replay no kit `environment[]` until a full heal rebuilds the key from the current kits. | Run `acq run ` once. | | sbx | `generic-opencode` launch mode, agent recorded as `opencode` | Attach fails closed with `cannot determine OpenCode launch mode` and runs no `sbx run`. acq cannot tell a pre-ADR-0030 template sandbox from a generic one whose host state was lost, and it does not trust a guest value. Other agents are not affected. | `acq rm ` then `acq run opencode `. | | sbx | `builtin-kits` | A sandbox created before the record has none. On sbx 0.38 or later the first re-attach tries to re-apply the three heal-consulted built-ins (Zscaler CA, USAi provider, playbook); `sbx kit add` refuses startup-bearing kits, so acq prints the recreate notice on every attach until the sandbox is recreated. | Recreate to get the record (and the current bundle). | diff --git a/docs/adr/0035-host-authoritative-sandbox-config.md b/docs/adr/0035-host-authoritative-sandbox-config.md index d3f26eab..f17fc4cf 100644 --- a/docs/adr/0035-host-authoritative-sandbox-config.md +++ b/docs/adr/0035-host-authoritative-sandbox-config.md @@ -265,6 +265,19 @@ behavior: same-named sandbox recreated after an out-of-band `msb rm` cannot inherit stale "already done" markers or session paths. (Lower severity; last.) + **Follow-up: a host key is still only a record of a guest-reported exit.** + Moving a gate to the host stops a guest from *planting* it, but the host writes + it when a guest command exits zero, and acq cannot verify what the command did. + A guest that reports one false success could therefore suppress a later run. + Where the gated step is idempotent and cheap, acq no longer gates on a record at + all: the msb agent-user setup is re-verified on every provision and heal, and + no `agent-user-ready` key is written. Where a step must not repeat by default, + the msb install-phase gate (`install-`) stays as a run-once cache of + guest-reported success, not an integrity fact, and an explicit forced re-apply + (`ACQ_FORCE_KIT_REAPPLY=1`, as set by `acq kit update` and the stale-bundle refresh) bypasses it. Install + commands are expected to be idempotent. The `agent-installed-*` key is cleared + with the other instance keys but no code writes or reads it any more. + ## Threat model and flows Participants: **GitHub** (pinned kit source), **Host** (acq + real credentials + From 8655f0a6573f4b141e4a06dc3d9c31c98c32a25d Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 28 Oct 2026 16:28:08 +0000 Subject: [PATCH 50/63] fix(msb): trust the read-only host-config mount only on qualified versions A zero exit from a guest-run probe was taken as proof the :ro mount is read-only, but a guest with root controls the probe. Trusted staging and execution of readonly kit files now require the installed msb version to be on a checked-in list of versions a host-observed canary has qualified; the list ships empty, so such files are refused with a message naming the version and the canary script. The guest probe stays as a diagnostic that can only add refusals. The create-time startup script keeps the guest path when the version is not qualified. scripts/qualify-msb-ro-mount judges the result from the host: root write, rename and remount attempts in a throwaway sandbox, then inspection of the real host directory, with a read-write positive control. A changed host directory is FAIL regardless of what the guest reports; a missing control is BLOCKED. Tests 79 and 83 set the stub's version as qualified because they exercise the staged path itself; they previously relied on the probe alone to unlock it. New tests in 84 cover the gate and the canary. Co-authored-by: OpenCode Agent --- acq.backends/msb.sh | 87 ++++-- scripts/qualify-msb-ro-mount | 208 +++++++++++++ test/bats/79-msb-readonly-kit-files.bats | 9 + test/bats/83-msb-ro-host-config-record.bats | 10 + test/bats/84-msb-ro-mount-qualification.bats | 294 +++++++++++++++++++ 5 files changed, 588 insertions(+), 20 deletions(-) create mode 100644 scripts/qualify-msb-ro-mount create mode 100644 test/bats/84-msb-ro-mount-qualification.bats diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 8045adbe..4c49e93a 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -172,6 +172,17 @@ MSB_ROLLBACK_VERSION="0.6.18" # duplicate snapshots exist. See ADR-0032. MSB_FIXED_VERSION="0.7.3" +# msb versions on which the host-observed canary (scripts/qualify-msb-ro-mount) has +# shown that a `:ro` --volume is enforced by the runtime even against guest root. +# Trusted execution of `readonly: true` kit files from the host-config mount +# (ADR-0035 Mechanism 2) is permitted ONLY on a version listed here. The list is +# deliberately checked in, not read from the environment or any guest-reachable +# state, and it starts EMPTY: no version has been qualified yet, so such kit files +# are refused (fail closed) with a message naming the version and the script. +# Space-separated bare X.Y.Z. Each entry needs its evidence recorded in ADR-0035 +# (tested msb version, canary revision, date, PASS result) in the same change. +MSB_RO_MOUNT_QUALIFIED_VERSIONS="" + # Default OCI image for provisioned non-OpenCode sandboxes. We default to the SAME image # family sbx uses: `docker/sandbox-templates:shell-docker`, an Ubuntu-based # agent template. It ALREADY ships the non-root `agent` user (with passwordless @@ -1805,7 +1816,10 @@ EOF if [ "$_readonly" = "true" ] && [ -n "$src" ] && [ -f "$src" ] && [ "$_hcfg_rc" -ge 2 ]; then # Recorded read-only create, but the mount is not confirmed read-only. Never # stage or run trusted code from it and never degrade to a guest copy. - if [ "$_hcfg_rc" -eq 2 ]; then + if [ "$_hcfg_rc" -eq 4 ]; then + echo "acq(msb): error: cannot place read-only kit file '$path' for '$name': msb ${_ACQ_MSB_RO_MOUNT_VERSION:-} is not on acq's list of versions where a read-only mount has been shown to be enforced." >&2 + echo "acq(msb): Run scripts/qualify-msb-ro-mount on this host; a PASS lets that version be added to MSB_RO_MOUNT_QUALIFIED_VERSIONS (see ADR-0035)." >&2 + elif [ "$_hcfg_rc" -eq 2 ]; then echo "acq(msb): error: cannot place read-only kit file '$path' for '$name': the host-config mount looks WRITABLE from the guest, so code staged there would not be trusted." >&2 else echo "acq(msb): error: cannot place read-only kit file '$path' for '$name': the host-config mount is not a confirmed read-only mount (the guest probe did not confirm it)." >&2 @@ -1888,26 +1902,53 @@ _acq_msb_reset_kit_env() { acq_host_config_clear msb "$1" kit-env } +# True iff the installed msb is a final X.Y.Z release listed in +# MSB_RO_MOUNT_QUALIFIED_VERSIONS. Anything else (empty list, unparsable or +# prerelease version output, a version not on the list) is "not qualified". Sets +# _ACQ_MSB_RO_MOUNT_VERSION to what was seen, for the caller's message. +_ACQ_MSB_RO_MOUNT_VERSION="" +_acq_msb_ro_mount_version_qualified() { + local _v _q + _v=$(_acq_msb_version) + _ACQ_MSB_RO_MOUNT_VERSION="$_v" + [ -n "$_v" ] || return 1 + _acq_msb_version_is_final "$_v" || return 1 + case "$_v" in + [0-9]*.[0-9]*.[0-9]*) ;; + *) return 1 ;; + esac + set -f + for _q in $MSB_RO_MOUNT_QUALIFIED_VERSIONS; do + if [ "$_q" = "$_v" ]; then set +f; return 0; fi + done + set +f + return 1 +} + # Whether sandbox NAME's trusted :ro host-config mount may be relied on for # readonly kit code. Return codes: -# 0 recorded at create AND the guest probe confirms a present, non-writable mount +# 0 recorded at create, the msb version is qualified, and the guest probe did +# not contradict the mount # 1 no host record (legacy sandbox, or a create that never completed): the caller # takes the compatibility path and copies the file into the guest, with its warning -# 2 recorded, but the probe finds the mount WRITABLE by the guest -# 3 recorded, but the probe does not confirm the mount (absent, unreadable, or the -# probe could not run) -# The record (key ro-host-config, written by acq_backend_provision after a create that -# passed the :ro --volume returned success and the sandbox became exec-ready) is host -# state a guest cannot forge, but it proves only that acq asked for the mount, not that -# the mount works. The guest probe can only make acq STRICTER: a guest controlling -# `test`/`sh` can answer it however it likes, and every answer other than "present and -# non-writable" makes the caller fail closed (codes 2 and 3) rather than stage or run -# trusted code from the mount, or degrade to a guest copy. A guest that forges the -# "confirmed" answer gains nothing it did not already have: the mount's real -# enforcement is the VMM's, not the probe's. +# 2 recorded and qualified, but the probe finds the mount WRITABLE by the guest +# 3 recorded and qualified, but the probe does not confirm the mount (absent, +# unreadable, or the probe could not run) +# 4 recorded, but the installed msb version is not on the qualified list +# Authority: the only thing that can establish that a `:ro` mount is read-only is +# the runtime, and acq establishes it per msb VERSION, from the host, by running the +# canary in scripts/qualify-msb-ro-mount (a throwaway sandbox attempts the strongest +# writes it can, and the REAL host directory is inspected afterwards). A version is +# trusted only if it is on MSB_RO_MOUNT_QUALIFIED_VERSIONS. The create-time record +# (key ro-host-config) proves only that acq asked for the mount; it is not evidence +# of enforcement. The guest probe below is a diagnostic that can only make acq +# stricter: a guest with root controls `test`/`sh` and can answer a probe however it +# likes, so a zero exit proves nothing, while a "writable" answer is always taken at +# face value (fail closed). _acq_msb_host_config_mount_available() { local name="$1" _probe_rc=0 [ "$(acq_host_config_read msb "$name" ro-host-config)" = "1" ] || return 1 + _acq_msb_ro_mount_version_qualified || return 4 _acq_msb_host_config_mount_probe "$name" || _probe_rc=$? case "$_probe_rc" in 0) return 0 ;; @@ -1916,11 +1957,13 @@ _acq_msb_host_config_mount_available() { esac } -# Exit 0: mount present and not writable; 1: mount absent/unreadable; 2: guest -# root could write to it. Exit 2 relies on `msb exec` passing the guest exit code -# through unchanged, which is not live-verified. If a different code is returned, -# the writable case is reported as "not confirmed" (code 3 above); both fail closed, -# so only the message differs. +# DIAGNOSTIC ONLY. Exit 0: the guest reports a present mount that it could not write; +# 1: mount absent/unreadable; 2: guest root could write to it. A zero exit is the +# guest's own claim and is NOT proof of anything (see the authority note above); only +# the non-zero answers carry weight, and they can only make acq stricter. Exit 2 +# relies on `msb exec` passing the guest exit code through unchanged, which is not +# live-verified; if a different code is returned the writable case is reported as +# "not confirmed" (code 3). Both fail closed, so only the message differs. _acq_msb_host_config_mount_probe() { local name="$1" _acq_msb_cli exec "$name" -u agent -- sh -c \ @@ -2638,9 +2681,13 @@ _acq_msb_startup_body_into() { # The create-time script is assembled before the normal apply path stages the # files, so derive the same readonly path rewrite locally. - local _ro_from=() _ro_to=() _frec _fpath _fsource _freadonly _fslug _fcrc + local _ro_from=() _ro_to=() _frec _fpath _fsource _freadonly _fslug _fcrc _ro_ok=0 + _acq_msb_ro_mount_version_qualified && _ro_ok=1 while IFS= read -r _frec; do [ -n "$_frec" ] || continue + # Not qualified: leave argv at the declared guest path. The apply step refuses + # the readonly file itself, so nothing is staged on, or run from, the mount. + [ "$_ro_ok" -eq 1 ] || continue _freadonly=$(printf '%s' "$_frec" | cut -f5) [ "$_freadonly" = "true" ] || continue _fpath=$(printf '%s' "$_frec" | cut -f1) diff --git a/scripts/qualify-msb-ro-mount b/scripts/qualify-msb-ro-mount new file mode 100644 index 00000000..da20328d --- /dev/null +++ b/scripts/qualify-msb-ro-mount @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# qualify-msb-ro-mount — host-observed canary: does THIS msb enforce a `:ro` volume? +# +# acq (ADR-0035 Mechanism 2) runs readonly kit code from a host directory mounted +# into the guest with `--volume HOST:GUEST:ro`. A guest-run probe cannot show that the +# mount is read-only (a guest with root controls `sh` and `test`), so this script +# judges it from the HOST: it creates a throwaway sandbox from acq's default image, +# mounts a host directory read-only, makes the guest (as root) try every write it can +# think of, including remounting read-write, and then inspects the REAL host +# directory. Any change there means the mount was writable. A second, read-write host +# directory is mounted as a positive control: the host must see the control file the +# guest wrote there, otherwise the attempts proved nothing and the result is BLOCKED. +# +# Result: PASS (host directory unchanged, controls seen), FAIL (host directory +# changed), or BLOCKED (could not run to a conclusion). Exit 0 / 1 / 2. +# On PASS add the printed version to MSB_RO_MOUNT_QUALIFIED_VERSIONS in +# acq.backends/msb.sh and record the output in ADR-0035 in the same change. +# +# Usage: [EXPECT_SHA=] bash scripts/qualify-msb-ro-mount +# EXPECT_SHA abort (BLOCKED) unless this checkout's HEAD starts with it +# QUALIFY_BASE scratch/results dir (default ~/.cache/acq/qualify; NOT $TMPDIR: +# msb cannot mount host paths under macOS $TMPDIR) +# QUALIFY_IMAGE image to test (default: acq's default msb image) +# STEP_TIMEOUT seconds for create (image pull) (default 600) +# EXEC_TIMEOUT seconds for each exec step (default 90) +# +# Never prints credentials. Needs msb on PATH, a KVM/HVF-capable host, network access +# for the image pull. Bash 3.2 compatible. Run it OUTSIDE any sandbox. +set -uo pipefail + +STEP_TIMEOUT="${STEP_TIMEOUT:-600}" +EXEC_TIMEOUT="${EXEC_TIMEOUT:-90}" +IMAGE="${QUALIFY_IMAGE:-docker.io/docker/sandbox-templates:shell-docker}" +BASE="${QUALIFY_BASE:-${XDG_CACHE_HOME:-$HOME/.cache}/acq/qualify}" +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +REPO=$(cd "$HERE/.." && pwd -P) + +SANDBOX="" ; RODIR="" ; RWDIR="" ; RESULTS="" ; DONE=0 + +# Run a command with a per-step timeout when a timeout tool exists. --foreground keeps +# the child able to read the terminal and be interrupted; fall back to running it bare. +_to() { # SECONDS CMD... + local secs="$1"; shift + if command -v timeout >/dev/null 2>&1; then + timeout --foreground "$secs" "$@" + elif command -v gtimeout >/dev/null 2>&1; then + gtimeout --foreground "$secs" "$@" + else + "$@" + fi +} + +say() { printf '%s\n' "$*"; } + +cleanup() { + if [ -n "$SANDBOX" ]; then + _to 60 msb remove --force "$SANDBOX" /dev/null 2>&1 || true + SANDBOX="" + fi + local d + for d in "$RODIR" "$RWDIR"; do + if [ -n "$d" ] && [ -d "$d" ]; then chmod -R u+w "$d" 2>/dev/null || true; rm -rf -- "$d"; fi + done +} +trap 'cleanup' EXIT +trap 'echo; echo "interrupted; removing the throwaway sandbox"; cleanup; exit 130' INT TERM + +finish() { # VERDICT DETAIL + local verdict="$1" detail="$2" rc + DONE=1 + case "$verdict" in PASS) rc=0 ;; FAIL) rc=1 ;; *) rc=2 ;; esac + say "" + say "RESULT: $verdict" + say "msb version : ${VERSION:-unknown}" + say "image : $IMAGE" + say "host : $(uname -srm 2>/dev/null || echo unknown)" + say "revision : ${REVISION:-unknown}" + say "detail : $detail" + if [ -n "$RESULTS" ]; then + printf '%s\t%s\t%s\t%s\n' "$verdict" "${VERSION:-unknown}" "${REVISION:-unknown}" "$detail" >>"$RESULTS" 2>/dev/null || true + say "results : $RESULTS" + fi + if [ "$verdict" = PASS ] && [ "${VERSION_OK:-0}" -ne 1 ]; then + say "" + say "NOTE: '${VERSION}' is not a final X.Y.Z release, so acq will never match it; do NOT add it to the list." + elif [ "$verdict" = PASS ]; then + say "" + say "Next: add ${VERSION} to MSB_RO_MOUNT_QUALIFIED_VERSIONS in acq.backends/msb.sh and" + say "record this output (version, revision, date, host OS/arch, image) in" + say "docs/adr/0035-host-authoritative-sandbox-config.md." + fi + exit "$rc" +} + +# --- preconditions ---------------------------------------------------------- +command -v msb >/dev/null 2>&1 || { VERSION="" ; REVISION="" ; finish BLOCKED "msb is not on PATH"; } +VERSION=$(msb --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?([+-][^[:space:]]*)?' | head -n1) +[ -n "$VERSION" ] || VERSION="version not parsed" +VERSION_OK=0 +case "$VERSION" in + *[!0-9.]*|"") ;; + [0-9]*.[0-9]*.[0-9]*) VERSION_OK=1 ;; +esac + +REVISION="" +if git -C "$REPO" rev-parse HEAD >/dev/null 2>&1; then REVISION=$(git -C "$REPO" rev-parse HEAD); fi +if [ -n "${EXPECT_SHA:-}" ]; then + case "$REVISION" in + "$EXPECT_SHA"*) ;; + *) finish BLOCKED "checkout revision '${REVISION:-unknown}' does not match EXPECT_SHA=$EXPECT_SHA" ;; + esac +fi + +mkdir -p "$BASE" 2>/dev/null || finish BLOCKED "cannot create $BASE" +BASE=$(cd "$BASE" && pwd -P) +RESULTS="$BASE/results.tsv" +STAMP=$(date -u +%Y%m%d%H%M%S) +RODIR=$(mktemp -d "$BASE/ro.XXXXXX") || finish BLOCKED "cannot create the read-only test directory" +RWDIR=$(mktemp -d "$BASE/rw.XXXXXX") || finish BLOCKED "cannot create the control directory" +RODIR=$(cd "$RODIR" && pwd -P); RWDIR=$(cd "$RWDIR" && pwd -P) +chmod 0755 "$RODIR" "$RWDIR" +printf 'HOST-ORIGINAL\n' >"$RODIR/sentinel" +chmod 0644 "$RODIR/sentinel" + +# Snapshot of everything a guest write could change: names, modes, sizes, mtimes and +# file contents. Compared byte-for-byte before and after. +snapshot() { # DIR + ( cd "$1" && LC_ALL=C ls -lnaR . 2>&1; \ + find . -type f -print 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do cksum "$f"; done ) +} +BEFORE=$(snapshot "$RODIR") + +# --- guest attack script (positional args: the guest ro path, the guest rw path) -- +# Everything it does is an ATTEMPT; the verdict never depends on what it reports. +read -r -d '' ATTACK <<'EOS' +ro=$1; rw=$2; log=$rw/attempts.log +: > "$log" +note() { printf '%s\n' "$*" >>"$log"; } +try() { d=$1; shift; if "$@" >/dev/null 2>&1; then note "WROTE $d"; else note "DENIED $d"; fi; } +note "uid=$(id -u)" +# Control: the same operations MUST succeed on the read-write volume, so a DENIED on +# the read-only volume cannot just mean "that command is missing in this image". +ctl=$rw/ctl; mkdir -p "$ctl" && : > "$ctl/f" && echo x >> "$ctl/f" && chmod 0600 "$ctl/f" \ + && mv "$ctl/f" "$ctl/g" && mkdir "$ctl/d" && rm -f "$ctl/g" && note "CONTROL-OK" || note "CONTROL-BROKEN" +if [ "$(cat "$ro/sentinel" 2>/dev/null)" = "HOST-ORIGINAL" ]; then note "READ-OK"; else note "READ-MISSING"; fi +try create-file sh -c ': > "$0/qual-new-file"' "$ro" +try overwrite sh -c 'echo tampered > "$0/sentinel"' "$ro" +try append sh -c 'echo extra >> "$0/sentinel"' "$ro" +try mkdir mkdir "$ro/qual-dir" +try chmod chmod 0777 "$ro/sentinel" +try rename mv "$ro/sentinel" "$ro/sentinel.moved" +try delete rm -f "$ro/sentinel" +try remount-rw mount -o remount,rw "$ro" +try remount-bind mount -o remount,bind,rw "$ro" +try create-after-remount sh -c ': > "$0/qual-after-remount"' "$ro" +try overwrite-after-remount sh -c 'echo tampered2 > "$0/sentinel"' "$ro" +note "DONE" +EOS + +# --- create the throwaway sandbox ------------------------------------------ +SANDBOX="acq-qual-ro-${STAMP}-$$" +say "msb $VERSION; testing image $IMAGE" +say "creating throwaway sandbox $SANDBOX (this pulls the image on first use)..." +if ! _to "$STEP_TIMEOUT" msb create --name "$SANDBOX" \ + --volume "$RODIR:/mnt/qual-ro:ro" --volume "$RWDIR:/mnt/qual-rw" \ + "$IMAGE" /dev/null | tr -d '\r') + case "$out" in *ok*) ready=1; break ;; esac + i=$((i + 1)); sleep 2 +done +[ "$ready" -eq 1 ] || finish BLOCKED "sandbox never became exec-ready" + +# --- attempts, as root ------------------------------------------------------ +say "running write attempts as root inside the guest..." +_to "$EXEC_TIMEOUT" msb exec "$SANDBOX" -u 0 -- sh -c "$ATTACK" sh /mnt/qual-ro /mnt/qual-rw /dev/null 2>&1 || true + +# --- judge from the HOST only ----------------------------------------------- +LOG="$RWDIR/attempts.log" +if [ ! -s "$LOG" ] || ! grep -q '^DONE$' "$LOG" 2>/dev/null; then + finish BLOCKED "positive control failed: the host did not see the guest's attempts log, so the attempts are unproven" +fi +if ! grep -q '^uid=0$' "$LOG" 2>/dev/null; then + finish BLOCKED "the attempts did not run as root (uid): a denial as an unprivileged user proves nothing" +fi +if ! grep -q '^CONTROL-OK$' "$LOG" 2>/dev/null; then + finish BLOCKED "the same operations did not succeed on the read-write control volume, so a denial on the read-only volume is unproven" +fi +if ! grep -q '^READ-OK$' "$LOG" 2>/dev/null; then + finish BLOCKED "the guest could not read the mounted sentinel, so the read-only mount was not visible" +fi +if grep -q '^WROTE ' "$LOG" 2>/dev/null; then + say "--- guest reports it wrote to the read-only mount ---" + grep '^WROTE ' "$LOG" | head -n 20 + finish FAIL "the guest was able to write to the :ro mount (even if the host directory did not change, the guest's view of trusted code was modifiable)" +fi +AFTER=$(snapshot "$RODIR") +if [ "$BEFORE" != "$AFTER" ]; then + say "--- host directory changed ---" + diff <(printf '%s\n' "$BEFORE") <(printf '%s\n' "$AFTER") | head -n 40 + finish FAIL "the host directory behind the :ro volume was modified by the guest; this msb does NOT enforce read-only" +fi +finish PASS "the host directory behind the :ro volume was unchanged after root write, remount and rename attempts, and the guest reported every attempt denied ($(grep -c '^DENIED' "$LOG") denied)" diff --git a/test/bats/79-msb-readonly-kit-files.bats b/test/bats/79-msb-readonly-kit-files.bats index 39fe6bfe..68887215 100644 --- a/test/bats/79-msb-readonly-kit-files.bats +++ b/test/bats/79-msb-readonly-kit-files.bats @@ -17,6 +17,10 @@ # the path is left alone). # # shellcheck shell=bats +# NOTE: these cases exercise the staged-mount path itself, so each marks the stub's +# msb version as qualified (MSB_RO_MOUNT_QUALIFIED_VERSIONS). Whether a version may be +# trusted at all is covered in 84-msb-ro-mount-qualification.bats; before that gate +# existed, a recorded sandbox was unlocked by the guest probe alone. setup() { acq_setup_stubs; } teardown() { acq_teardown_stubs; } @@ -91,6 +95,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" acq_test_stub_provider_input_boundary _acq_msb_fetch_kit() { printf "%s\n" "'"$k"'"; } acq_backend_provision rocodebox shell /tmp >/dev/null 2>&1 @@ -123,6 +128,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" acq_test_stub_provider_input_boundary _acq_msb_fetch_kit() { printf "%s\n" "'"$k"'"; } acq_backend_provision rocode2box shell /tmp >/dev/null 2>&1 @@ -147,6 +153,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" acq_test_stub_provider_input_boundary _acq_msb_apply_kit_dir legacyrobox "'"$k"'" 2>&1 ' @@ -169,6 +176,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" _acq_msb_apply_kit_dir writablerobox "'"$k"'" 2>&1 ' assert_success @@ -187,6 +195,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" acq_test_stub_provider_input_boundary _acq_msb_fetch_kit() { printf "%s\n" "'"$k"'"; } acq_backend_provision romountbox shell /tmp >/dev/null 2>&1 diff --git a/test/bats/83-msb-ro-host-config-record.bats b/test/bats/83-msb-ro-host-config-record.bats index 219a8166..e880b403 100644 --- a/test/bats/83-msb-ro-host-config-record.bats +++ b/test/bats/83-msb-ro-host-config-record.bats @@ -12,6 +12,10 @@ # The stubs below answer the guest probe CONTRARY to the host record. # # shellcheck shell=bats +# NOTE: these cases exercise the staged-mount path itself, so each marks the stub's +# msb version as qualified (MSB_RO_MOUNT_QUALIFIED_VERSIONS). Whether a version may be +# trusted at all is covered in 84-msb-ro-mount-qualification.bats; before that gate +# existed, a recorded sandbox was unlocked by the guest probe alone. setup() { acq_setup_stubs; } teardown() { acq_teardown_stubs; } @@ -52,6 +56,7 @@ _apply() { # NAME KITDIR RECORD [ENV...] . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" acq_test_stub_provider_input_boundary [ "'"$record"'" = 1 ] && acq_host_config_write msb "'"$name"'" ro-host-config 1 _acq_msb_apply_kit_dir "'"$name"'" "'"$kit"'" 2>&1 @@ -134,6 +139,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" acq_test_stub_provider_input_boundary acq_host_config_write msb badval ro-host-config 0 _acq_msb_apply_kit_dir badval "'"$k"'" 2>&1 @@ -149,6 +155,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" acq_test_stub_provider_input_boundary _acq_msb_fetch_kit() { printf "%s\n" "'"$k"'"; } acq_backend_provision recprov shell /tmp >/dev/null 2>&1 @@ -165,6 +172,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" acq_test_stub_provider_input_boundary _acq_msb_fetch_kit() { printf "%s\n" "'"$k"'"; } acq_backend_provision recfail shell /tmp >/dev/null 2>&1 @@ -198,6 +206,7 @@ _prov_msb() { # NAME KITDIR [ENV...] (runs provision with a seeded stale record . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" acq_test_stub_provider_input_boundary acq_host_config_write msb "'"$name"'" ro-host-config 1 acq_host_config_write msb "'"$name"'" install-stale 1 @@ -245,6 +254,7 @@ _apply_stage_fail() { # NAME KITDIR RECORD . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" acq_test_stub_provider_input_boundary _acq_msb_stage_readonly_file() { return 1; } [ "'"$record"'" = 1 ] && acq_host_config_write msb "'"$name"'" ro-host-config 1 diff --git a/test/bats/84-msb-ro-mount-qualification.bats b/test/bats/84-msb-ro-mount-qualification.bats new file mode 100644 index 00000000..6f80c089 --- /dev/null +++ b/test/bats/84-msb-ro-mount-qualification.bats @@ -0,0 +1,294 @@ +#!/usr/bin/env bats +# +# 84-msb-ro-mount-qualification.bats — ADR-0035 Mechanism 2 authority. Trusted +# execution from the read-only host-config mount is permitted only on an msb version +# that appears on a checked-in list of versions a HOST-observed canary has qualified. +# A guest-run probe can never unlock it (a guest with root controls `sh`/`test`). +# Also covers scripts/qualify-msb-ro-mount against a stubbed msb. +# +# shellcheck shell=bats + +setup() { acq_setup_stubs; } +teardown() { acq_teardown_stubs; } + +load 'helper' + +_ro_kit() { # DIR + local k="$1"; mkdir -p "$k/files" + printf 'console.log("merge");\n' > "$k/files/merge.mjs" + cat >"$k/spec.yaml" <<'SPEC' +schemaVersion: "hybrid/v1" +kind: mixin +name: ro-code-kit +displayName: RO Code Kit +description: trusted startup code +files: + - path: /home/agent/cfg/merge.mjs + mode: "0755" + source: files/merge.mjs + readonly: true +commands: + - phase: startup + user: "1000" + command: + - node + - /home/agent/cfg/merge.mjs +SPEC +} + +# Apply KITDIR to NAME with a host ro-host-config record, under an msb version and +# qualified list. The probe stub is left in its default state: it forges "all good". +_apply_ver() { # NAME KITDIR VERSION QUALIFIED_LIST + local name="$1" kit="$2" ver="$3" qual="$4" + : > "$CALLS" + run bash -c ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-'"$name"'" STUB_MSB_VERSION="'"$ver"'" + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_test_stub_provider_input_boundary + MSB_RO_MOUNT_QUALIFIED_VERSIONS="'"$qual"'" + acq_host_config_write msb "'"$name"'" ro-host-config 1 + _acq_msb_apply_kit_dir "'"$name"'" "'"$kit"'" 2>&1 + ' +} + +@test "qualified list: ships empty, so no msb version is trusted for readonly code today" { + run bash -c '. "'"$REPO_ROOT"'/acq.backends/msb.sh"; printf "[%s]\n" "$MSB_RO_MOUNT_QUALIFIED_VERSIONS"' + assert_success + assert_output '[]' +} + +@test "qualified list: a forged zero-exit probe does not unlock staging on an unlisted version" { + local k="$STUBDIR/k1"; _ro_kit "$k" + _apply_ver unlisted "$k" 0.7.7 "" + assert_failure + assert_output --partial "'/home/agent/cfg/merge.mjs'" + assert_output --partial 'msb 0.7.7 is not on acq' + assert_output --partial 'scripts/qualify-msb-ro-mount' + assert_output --partial 'No guest-copy fallback is taken' + local log; log=$(cat "$CALLS") + refute_regex "$log" 'node ' + refute_regex "$log" 'msb copy .*unlisted:/home/agent/cfg/merge\.mjs' + run bash -c 'ls "'"$ACQ_PROVENANCE_DIR"'"/msb/unlisted.*.config/kit-files 2>&1' + assert_failure +} + +@test "qualified list: a version that is not the listed one is refused" { + local k="$STUBDIR/k2"; _ro_kit "$k" + _apply_ver otherver "$k" 0.7.8 "0.7.7" + assert_failure + assert_output --partial 'msb 0.7.8 is not on acq' +} + +@test "qualified list: a listed version stages and runs from the mount" { + local k="$STUBDIR/k3"; _ro_kit "$k" + _apply_ver listed "$k" 0.7.7 "0.7.7" + assert_success + local log; log=$(cat "$CALLS") + assert_regex "$log" 'node /var/lib/acq/host/kit-files/' + refute_regex "$log" 'msb copy .*listed:/home/agent/cfg/merge\.mjs' +} + +@test "qualified list: a listed version is still refused when the guest says the mount is writable" { + local k="$STUBDIR/k4"; _ro_kit "$k" + : > "$CALLS" + run bash -c ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-lw" STUB_MSB_VERSION=0.7.7 STUB_HOST_CONFIG_WRITABLE=1 + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_test_stub_provider_input_boundary + MSB_RO_MOUNT_QUALIFIED_VERSIONS="0.7.7" + acq_host_config_write msb listedwritable ro-host-config 1 + _acq_msb_apply_kit_dir listedwritable "'"$k"'" 2>&1 + ' + assert_failure + assert_output --partial 'WRITABLE' + refute_regex "$(cat "$CALLS")" 'node ' +} + +@test "qualified list: an unparsable or prerelease msb version fails closed even if listed" { + local k="$STUBDIR/k5"; _ro_kit "$k" + _apply_ver prerel "$k" 0.7.7-rc1 "0.7.7 0.7.7-rc1" + assert_failure + assert_output --partial 'not on acq' + refute_regex "$(cat "$CALLS")" 'node ' + _apply_ver garbled "$k" "banana" "banana" + assert_failure + refute_regex "$(cat "$CALLS")" 'node ' +} + +@test "qualified list: no host record keeps the guest-copy compatibility path regardless of version" { + local k="$STUBDIR/k6"; _ro_kit "$k" + : > "$CALLS" + run bash -c ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-nr" STUB_MSB_VERSION=0.7.7 + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_test_stub_provider_input_boundary + _acq_msb_apply_kit_dir norecordver "'"$k"'" 2>&1 + ' + assert_success + assert_regex "$(cat "$CALLS")" 'msb copy .*norecordver:/home/agent/cfg/merge\.mjs' +} + +@test "qualified list: the create-time startup script keeps the guest path when the version is unlisted" { + local k="$STUBDIR/k7"; _ro_kit "$k" + : > "$CALLS" + run bash -c ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-ss" STUB_MSB_VERSION=0.7.7 + export ACQ_MSB_KEEP_STARTUP_STAGE=1 ACQ_MSB_STARTUP_STAGE_DIR="'"$STUBDIR"'/ss-stage" + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_test_stub_provider_input_boundary + MSB_RO_MOUNT_QUALIFIED_VERSIONS="" + _acq_msb_fetch_kit() { printf "%s\n" "'"$k"'"; } + acq_backend_provision stagebox shell /tmp >/dev/null 2>&1 + ' + local body; body=$(cat "$(find "$STUBDIR/ss-stage" -type f 2>/dev/null | head -n1)" 2>/dev/null) + [ -n "$body" ] + assert_regex "$body" '/home/agent/cfg/merge\.mjs' + refute_regex "$body" '/var/lib/acq/host/kit-files' +} + +# --- scripts/qualify-msb-ro-mount against a stubbed msb -------------------------- + +# Stub msb for the canary. STUB_RO_ENFORCED=1: attempts are denied on the ro volume. +# STUB_RO_ENFORCED=0: the "guest" really writes into the host directory behind the ro +# volume and reports WROTE. STUB_QUAL_FORGE_DENIED=1 (with ENFORCED=0): the guest lies +# and reports DENIED while changing the host directory. STUB_QUAL_NO_LOG=1: the guest +# never writes its log (positive control fails). +_plant_canary_msb() { + mkdir -p "$STUBDIR/canary-bin" + cat >"$STUBDIR/canary-bin/msb" <<'STUB' +#!/usr/bin/env bash +case "${1:-}" in + --version|-V) printf 'msb %s\n' "${STUB_MSB_VERSION:-0.7.7}"; exit 0 ;; + create) + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --volume) case "$2" in + *:/mnt/qual-ro:ro) printf '%s\n' "${2%%:/mnt/qual-ro:ro}" >"$STUBDIR/.qual_ro" ;; + *:/mnt/qual-rw) printf '%s\n' "${2%%:/mnt/qual-rw}" >"$STUBDIR/.qual_rw" ;; + esac; shift ;; + esac + shift + done + exit 0 ;; + remove) : >"$STUBDIR/.qual_removed"; exit 0 ;; + exec) + all="$*" + case "$all" in + *"echo ok"*) echo ok; exit 0 ;; + *"qual-ro"*) + ro=$(cat "$STUBDIR/.qual_ro"); rw=$(cat "$STUBDIR/.qual_rw") + [ "${STUB_QUAL_NO_LOG:-0}" = 1 ] && exit 0 + { echo "uid=${STUB_QUAL_UID:-0}"; echo "READ-OK"; [ "${STUB_QUAL_CONTROL_BROKEN:-0}" = 1 ] && echo "CONTROL-BROKEN" || echo "CONTROL-OK"; } >"$rw/attempts.log" + if [ "${STUB_RO_ENFORCED:-1}" = 1 ]; then + echo "DENIED create-file" >>"$rw/attempts.log" + else + : >"$ro/qual-new-file" + if [ "${STUB_QUAL_FORGE_DENIED:-0}" = 1 ]; then echo "DENIED create-file" >>"$rw/attempts.log" + else echo "WROTE create-file" >>"$rw/attempts.log"; fi + fi + echo "DONE" >>"$rw/attempts.log" + exit 0 ;; + esac + exit 0 ;; +esac +exit 0 +STUB + chmod +x "$STUBDIR/canary-bin/msb" +} + +_run_canary() { # [ENV...] + run env "$@" PATH="$STUBDIR/canary-bin:$PATH" STUBDIR="$STUBDIR" QUALIFY_BASE="$STUBDIR/qbase" \ + STEP_TIMEOUT=20 EXEC_TIMEOUT=20 bash "$REPO_ROOT/scripts/qualify-msb-ro-mount" +} + +@test "canary: host directory unchanged and controls seen => PASS" { + _plant_canary_msb + _run_canary STUB_RO_ENFORCED=1 STUB_MSB_VERSION=0.7.7 + assert_success + assert_output --partial 'RESULT: PASS' + assert_output --partial 'msb version : 0.7.7' + assert_output --partial 'MSB_RO_MOUNT_QUALIFIED_VERSIONS' + [ -f "$STUBDIR/.qual_removed" ] +} + +@test "canary: host directory changed => FAIL, even if the guest claims every write was denied" { + _plant_canary_msb + _run_canary STUB_RO_ENFORCED=0 STUB_QUAL_FORGE_DENIED=1 + assert_failure 1 + assert_output --partial 'RESULT: FAIL' + assert_output --partial 'host directory behind the :ro volume was modified' + refute_output --partial 'Next: add' + [ -f "$STUBDIR/.qual_removed" ] +} + +@test "canary: guest reports it wrote to the mount => FAIL" { + _plant_canary_msb + _run_canary STUB_RO_ENFORCED=0 + assert_failure 1 + assert_output --partial 'RESULT: FAIL' +} + +@test "canary: no positive control from the guest => BLOCKED, not PASS" { + _plant_canary_msb + _run_canary STUB_RO_ENFORCED=1 STUB_QUAL_NO_LOG=1 + assert_failure 2 + assert_output --partial 'RESULT: BLOCKED' + assert_output --partial 'positive control failed' +} + +@test "canary: msb missing => BLOCKED" { + run env PATH="/usr/bin:/bin" QUALIFY_BASE="$STUBDIR/qbase2" bash -c 'command -v msb >/dev/null 2>&1 && exit 77; exec bash "'"$REPO_ROOT"'/scripts/qualify-msb-ro-mount"' + [ "$status" -eq 77 ] && skip "a real msb is on the minimal PATH" + assert_failure 2 + assert_output --partial 'RESULT: BLOCKED' +} + +@test "canary: EXPECT_SHA that does not match the checkout => BLOCKED" { + _plant_canary_msb + _run_canary STUB_RO_ENFORCED=1 EXPECT_SHA=deadbeefdeadbeef + assert_failure 2 + assert_output --partial 'does not match EXPECT_SHA' +} + +@test "canary: attempts that did not run as root => BLOCKED, not PASS" { + _plant_canary_msb + _run_canary STUB_RO_ENFORCED=1 STUB_QUAL_UID=1000 + assert_failure 2 + assert_output --partial 'RESULT: BLOCKED' + assert_output --partial 'did not run as root' +} + +@test "canary: the same operations failing on the read-write control volume => BLOCKED" { + _plant_canary_msb + _run_canary STUB_RO_ENFORCED=1 STUB_QUAL_CONTROL_BROKEN=1 + assert_failure 2 + assert_output --partial 'RESULT: BLOCKED' + assert_output --partial 'read-write control volume' +} + +@test "canary: a failed create still attempts to remove the sandbox" { + _plant_canary_msb + sed -i.bak 's|^ create)| create) [ "${STUB_CREATE_FAIL:-0}" = 1 ] \&\& exit 1|' "$STUBDIR/canary-bin/msb" + _run_canary STUB_CREATE_FAIL=1 + assert_failure 2 + assert_output --partial 'msb create failed' + [ -f "$STUBDIR/.qual_removed" ] +} + +@test "canary: a PASS on a non-final msb version tells the operator not to add it" { + _plant_canary_msb + _run_canary STUB_RO_ENFORCED=1 STUB_MSB_VERSION=0.7.7-rc1 + assert_success + assert_output --partial 'RESULT: PASS' + assert_output --partial 'do NOT add it to the list' + refute_output --partial 'Next: add' +} From 80cd22c21da5860bcebeeaa73bb9edc77e1274da Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 28 Oct 2026 16:28:08 +0000 Subject: [PATCH 51/63] docs: state that read-only mount enforcement is unverified until the canary passes Co-authored-by: OpenCode Agent --- docs/KNOWN_FAILURE_MODES.md | 45 +++++++++ .../0035-host-authoritative-sandbox-config.md | 92 ++++++++++++------- docs/adr/0037-sbx-trusted-startup-parity.md | 22 ++++- 3 files changed, 121 insertions(+), 38 deletions(-) diff --git a/docs/KNOWN_FAILURE_MODES.md b/docs/KNOWN_FAILURE_MODES.md index 4d073c79..84f48616 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -3089,6 +3089,51 @@ host-side API, or a read-only host channel into the sandbox. --- +## 48. msb: Readonly Kit Files Are Refused Until the Read-Only Mount Is Qualified + +**Status:** Fail closed by design. Applies to a kit with `files[]` entries marked +`readonly: true` on an msb sandbox created with the read-only host-config mount. + +### Symptoms + +```text +acq(msb): error: cannot place read-only kit file '' for '': msb is not on acq's list of versions where a read-only mount has been shown to be enforced. +acq(msb): Run scripts/qualify-msb-ro-mount on this host; ... +acq(msb): No guest-copy fallback is taken because the sandbox was created with a read-only host config; recreate the sandbox. +``` + +The kit does not fully apply; for built-in kits at provision this is a warning, and +a heal reports it on each re-attach. + +### Root Cause + +acq runs `readonly: true` kit code from a host directory mounted into the guest +`:ro`, on the assumption that the runtime enforces that flag. Only the runtime can +establish that, and it is unverified on every msb version acq accepts. A guest-run +probe cannot stand in for it: a guest with passwordless sudo controls `sh` and +`test`. So acq trusts the mount only on msb versions a **host-observed** canary has +qualified (`MSB_RO_MOUNT_QUALIFIED_VERSIONS`, which ships empty), and otherwise +refuses rather than staging, running, or copying that code. + +### Fix + +Run `scripts/qualify-msb-ro-mount` on a sandbox-capable host (outside any sandbox). +It judges the result by inspecting the real host directory afterwards, so nothing +the guest reports can make it pass. On PASS, add the printed version to +`MSB_RO_MOUNT_QUALIFIED_VERSIONS` and record the output in ADR-0035 (with host +OS/arch and image). Existing sandboxes need no recreate for this: the version list +is checked at apply time. The guest probe is +diagnostic only: it can add a refusal ("writable" or "not confirmed") but never +grants trust. + +### Prevention / Status + +No shipped built-in kit marks a file `readonly: true`, so default sandboxes are not +affected today; the gate matters for the first kit that opts in. Until a version is +qualified this is expected, not a defect. + +--- + ## Contributing When you discover a new failure mode: diff --git a/docs/adr/0035-host-authoritative-sandbox-config.md b/docs/adr/0035-host-authoritative-sandbox-config.md index f17fc4cf..13f982e7 100644 --- a/docs/adr/0035-host-authoritative-sandbox-config.md +++ b/docs/adr/0035-host-authoritative-sandbox-config.md @@ -105,7 +105,8 @@ provisioning decision. > lifecycle decision — data *and* code — MUST be one of: > > - **(H1) Host-resident and mounted read-only into the guest** (the VMM/mount -> layer enforces read-only; guest sudo cannot override it), or +> layer enforces read-only; guest sudo cannot override it; acq relies on that +> only for a runtime version a host-observed canary has qualified), or > - **(H2) Held on the host and supplied at exec time** from acq's own state > (never read back from the guest). > @@ -164,39 +165,60 @@ Concretely, this ADR introduces two mechanisms and a staged migration. - acq mounts that directory into the guest **read-only** at a well-known path: `/var/lib/acq/host/` (`:ro`). On msb via `--volume :/var/lib/acq/host:ro`; on sbx via the `:ro` positional the CLI already honors. This relies on backend - mount/export semantics, not Unix ownership inside the guest: sbx already - enforces read-only extra mounts, and msb 0.7.2 was live-verified to export the - virtiofs mount read-only even when guest root can make the guest-visible mount - flag appear `rw`. acq now refuses 0.7.0 through 0.7.2 and accepts 0.6.9 or newer - otherwise, with 0.7.7 only the default install pin, so `:ro` enforcement has not - been re-verified on any version acq accepts. It must be re-verified on the - pinned version before it is relied on. Whether a sandbox - may rely on the mount for `readonly: true` startup code is a **host-recorded - create fact** (host key `ro-host-config`), written only after a create that - passed the `:ro` volume flag returned success and the sandbox became - exec-ready. The record does **not** prove the guest mount works; it proves only - that acq asked for it and the sandbox booted. The decision is not the answer of - a guest command, which a passwordless-sudo guest could forge to force the - guest-copy downgrade. A sandbox with no record (created before the key existed, - or never fully created) takes the legacy guest-copy path and warns. A - guest-executed probe of the mount can only make acq stricter: when a record - exists, any probe answer other than "present and not writable" makes acq fail - closed. If the mount looks absent or the probe cannot confirm it, or if it looks - guest-writable, applying a `readonly: true` file fails with an error naming the - file; acq stages nothing, runs nothing from the mount, and does not copy the file - into the guest. A guest that controls the probe can therefore cause a refusal but - cannot make acq trust anything it would not otherwise trust. This is a deliberate - change from "degrade to a guest copy" (and from an intermediate "warn and - continue" for the writable case) to fail closed, applied only when the record - exists; legacy and no-record sandboxes still degrade to the guest copy with a - warning. The record is never evidence of verified read-only enforcement: it - proves a `:ro` request plus exec readiness only. When a record exists and - staging a `readonly: true` file onto the host fails, the apply likewise fails - with an error naming the file rather than copying it into the guest; only - sandboxes with no record take the guest copy. If a future - msb changes `--volume ...:ro` parsing or enforcement, acq must raise or gate - its minimum msb version before relying on ADR-0035. Host and guest path forms - follow ADR-0029. + mount/export semantics, not Unix ownership inside the guest. **What is and is not + established about that enforcement:** an earlier manual observation on msb 0.7.2 + (a version acq now refuses) found the virtiofs mount exported read-only even when + guest root could make the guest-visible mount flag appear `rw`. That is the only + observation, it is not a repeatable check, and it covers no version acq accepts. + For sbx nothing is recorded. So `:ro` enforcement is **unverified on every + accepted msb version and on sbx**. + + **Authority for trusting the mount (msb).** Trusted execution of `readonly: true` + kit files from the mount is permitted only when the installed msb version is on + `MSB_RO_MOUNT_QUALIFIED_VERSIONS` in `acq.backends/msb.sh`, a checked-in list of + versions for which a **host-observed canary** passed. The canary + (`scripts/qualify-msb-ro-mount`) creates a throwaway sandbox from the default + image, mounts one host directory read-only and a second read-write as a positive + control, makes the guest (as root) attempt create, overwrite, append, mkdir, + chmod, rename, delete and remount-rw on the read-only mount, and then judges the + result **from the host**: it compares the real host directory before and after + and requires that the host saw the control file. A changed host directory is FAIL + whatever the guest reports; a missing control (the log, a root uid, or the same + operations succeeding on the read-write volume) is BLOCKED, never PASS. The + decision is the host-side comparison; the guest's own log is used only to prove + the attempts really ran, and it can only turn a PASS into BLOCKED or FAIL, never + the reverse. The canary runs the trusted default image, so it does not defend + against a hostile image. The list **ships + empty**: until a PASS is recorded here for a version, readonly files with a + `ro-host-config` record are refused on that version with an error that names the + version and the script. There is no operator override. Each entry added to the + list must record, in this ADR, the msb version, the acq revision the canary ran + at, the date, the host OS and architecture, the image reference (the default + image tag is mutable) and the PASS output. A PASS covers a freshly created + sandbox on that host; enforcement after `msb start`, on other host platforms, or + with another image is not covered by it. + + Whether a sandbox is a candidate at all is a **host-recorded create fact** (host + key `ro-host-config`), written only after a create that passed the `:ro` volume + flag returned success and the sandbox became exec-ready. The record proves only + that acq asked for the mount and the sandbox booted; it is never evidence of + enforcement. A sandbox with no record (created before the key existed, or never + fully created) takes the legacy guest-copy path and warns. + + **The guest probe is a diagnostic and can only make acq stricter.** A guest with + passwordless sudo controls `sh` and `test`, so a zero exit from a probe it runs + proves nothing; acq therefore never treats it as proof the mount is read-only. + When the version is qualified and a record exists, a probe answer of "writable" + or "not confirmed" makes acq fail closed. For any readonly file, a refusal means + acq stages nothing, runs nothing from the mount, and does not copy the file into + the guest. This is a deliberate change from "degrade to a guest copy", through an + intermediate "probe decides", to fail closed with host-side authority; it applies + only when a record exists, so legacy and no-record sandboxes still degrade to the + guest copy with a warning. When a record exists and staging a `readonly: true` + file onto the host fails, the apply likewise fails with an error naming the file. + If a future msb changes `--volume ...:ro` parsing or enforcement, re-run the + canary and drop the old version from the list before relying on ADR-0035. Host + and guest path forms follow ADR-0029. - The mount root and `kit-files/` subtree are traversable but not listable (`0711`) so the guest `agent` user can open/execute known read-only kit payload paths, while flat acq-internal keys stay host-private (`0600`). This is also a @@ -330,7 +352,7 @@ sequenceDiagram ACQ->>ACQ: validate facts host-side (env-var-ownership, SSRF, schema) ACQ->>HSTATE: write validated facts + staged startup code ACQ->>RO: mount HSTATE read-only (--volume …:ro / :ro positional) - AGENT-->>RO: sudo tee facts.json (FAILS: read-only mount, VMM-enforced) + AGENT-->>RO: sudo tee facts.json (expected to FAIL on a qualified msb: read-only mount, enforced by the runtime) ACQ->>RO: invoke staged startup from :ro path RO->>RW: orchestrator computes per-sandbox catalog (rw, not shared) Note over ACQ,RO: acq trusts only host-authoritative :ro inputs diff --git a/docs/adr/0037-sbx-trusted-startup-parity.md b/docs/adr/0037-sbx-trusted-startup-parity.md index eb7df048..5d0dad79 100644 --- a/docs/adr/0037-sbx-trusted-startup-parity.md +++ b/docs/adr/0037-sbx-trusted-startup-parity.md @@ -19,7 +19,9 @@ supersedes: [] `readonly: true` startup code tamper-resistant on msb: acq stages the pinned file on the host, mounts the per-sandbox config directory `:ro`, and runs the heal from the mounted path, so a passwordless-sudo guest cannot rewrite the code acq -re-executes on restart. +re-executes on restart, **on an msb version where a host-observed canary has shown +the `:ro` mount is enforced** (ADR-0035 gates that on a checked-in list of qualified +versions, which ships empty; a guest-run probe is never accepted as proof). SBX has no equivalent. `acq_backend_provision` for sbx creates the host config directory but never mounts it (it holds host-only keys such as `extra-kits`, @@ -51,6 +53,17 @@ version-scoped and is not assumed for later releases. The "mountless sandbox" behavior attributed to sbx >= 0.42 below is likewise unverified. This ADR concerns create-time delivery and restart replay only. +**Does sbx share the msb forgeable-gate class?** No, because sbx does not run +trusted code from the `:ro` mount today: `acq.backends/sbx.sh` has no consumer of the +kit `readonly` field, the translator ignores it, and the sbx host config directory is +never mounted. The only `:ro` mounts on sbx are the provider-input generation +(read as data by the guest-side renderer, not executed by acq) and no acq decision +reads a guest answer about them. The msb gate was forgeable because acq took a +guest-run probe's zero exit as proof of read-only enforcement; sbx has no such gate. +The open question for sbx is the one this ADR already states: native replay and mount +enforcement are unverified, and Option B must not be implemented without a +host-judged canary of the same shape. + ## Decision Drivers - **Same rule as ADR-0035**: acq-trusted code is host-materialized and `:ro`, or @@ -153,8 +166,11 @@ Run on a real sbx host (cannot run inside a sandbox). Record exact sbx version. provider-input generation. 2. **Enforcement under sudo**: as guest root, attempt a write, create, rename, `chmod`, and `mount -o remount,rw` on the directory and on a file in it. - Confirm all fail and the host copy is unchanged (ADR-0035 recorded this for msb - 0.7.2; it has not been recorded for sbx). + Confirm all fail and the host copy is unchanged. Judge it from the host side + (inspect the real host directory afterwards), as the msb canary in + `scripts/qualify-msb-ro-mount` does; a guest-reported denial is not evidence. + Nothing is recorded for sbx, and msb has only an earlier manual observation on + a version acq now refuses. 3. **Permissions**: the guest `agent` (uid 1000) can execute a `0555` file under a `0711` directory it cannot list, with host files owned by the host user. 4. **Restart persistence**: after `sbx stop`/start and after a host reboot, the From dc43b258e0fa098685d7f39e17bf9ffbbc41afcb Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 28 Oct 2026 17:00:39 +0000 Subject: [PATCH 52/63] fix(msb): classify remount as informational in the ro-mount canary; keep evidence in the repo A live run reported FAIL only because `mount -o remount` exited zero, which the script recorded as a write. A remount the guest kernel accepts changes the guest's own mount flags and is not a data write, so it is now recorded as REMOUNT-ACCEPTED or REMOUNT-DENIED (informational) and every kind of data write is repeated after it. The verdict is computed from both the guest's reported writes and the host-side snapshot comparison, which remains authoritative, and a PASS needs neither to show a change. All output now goes to /tmp/qualify-msb-ro-mount-/ (gitignored, override ACQ_QUALIFY_OUT_DIR): results, guest log with /proc/mounts before and after, host snapshots and diff, and the throwaway directories on FAIL or BLOCKED. Every run ends with one pasteable RESULT line. Tests in 84 cover the accepted-remount PASS, a data write FAIL, a host change under a forged-denied log, and the output layout. Co-authored-by: OpenCode Agent --- scripts/qualify-msb-ro-mount | 142 ++++++++++++++----- test/bats/84-msb-ro-mount-qualification.bats | 96 ++++++++++++- 2 files changed, 201 insertions(+), 37 deletions(-) diff --git a/scripts/qualify-msb-ro-mount b/scripts/qualify-msb-ro-mount index da20328d..aa3fd790 100644 --- a/scripts/qualify-msb-ro-mount +++ b/scripts/qualify-msb-ro-mount @@ -11,18 +11,36 @@ # directory is mounted as a positive control: the host must see the control file the # guest wrote there, otherwise the attempts proved nothing and the result is BLOCKED. # -# Result: PASS (host directory unchanged, controls seen), FAIL (host directory -# changed), or BLOCKED (could not run to a conclusion). Exit 0 / 1 / 2. +# Result: PASS (host directory unchanged, no data write reported, controls seen), +# FAIL (a data write was reported, or the host directory changed), or BLOCKED +# (could not run to a conclusion). Exit 0 / 1 / 2. +# +# Classification: only genuine data-modifying operations (create, overwrite, append, +# mkdir, chmod, rename, delete, and create/overwrite AFTER the remount attempts) are +# recorded WROTE or DENIED. A `mount -o remount` that the guest kernel accepts is NOT +# a write: it is recorded REMOUNT-ACCEPTED / REMOUNT-DENIED (informational) and the +# writes after it show whether it changed anything. Verdict order: BLOCKED controls, +# then the host-side comparison and the guest's WROTE lines are both computed and +# reported, FAIL if either shows a change, PASS only if neither does. +# +# Evidence: every run writes ALL its output under one directory, +# /tmp/qualify-msb-ro-mount-/ (tmp/ is gitignored; override with +# ACQ_QUALIFY_OUT_DIR=): results.tsv, the guest log (attempts.log, including +# /proc/mounts before and after the remount attempts), the before and after host +# snapshots and their diff, and verdict.txt. On FAIL or BLOCKED the throwaway host +# directories (ro/ and rw/) are kept there too; on PASS they are removed, because +# everything of value has been copied out. Every run ends with one pasteable line: +# RESULT msb= image= host= rev= dir= # On PASS add the printed version to MSB_RO_MOUNT_QUALIFIED_VERSIONS in # acq.backends/msb.sh and record the output in ADR-0035 in the same change. # # Usage: [EXPECT_SHA=] bash scripts/qualify-msb-ro-mount -# EXPECT_SHA abort (BLOCKED) unless this checkout's HEAD starts with it -# QUALIFY_BASE scratch/results dir (default ~/.cache/acq/qualify; NOT $TMPDIR: -# msb cannot mount host paths under macOS $TMPDIR) -# QUALIFY_IMAGE image to test (default: acq's default msb image) -# STEP_TIMEOUT seconds for create (image pull) (default 600) -# EXEC_TIMEOUT seconds for each exec step (default 90) +# EXPECT_SHA abort (BLOCKED) unless this checkout's HEAD starts with it +# ACQ_QUALIFY_OUT_DIR output directory (default /tmp/qualify-msb-ro-mount-; +# NOT $TMPDIR: msb cannot mount host paths under macOS $TMPDIR) +# QUALIFY_IMAGE image to test (default: acq's default msb image) +# STEP_TIMEOUT seconds for create (image pull) (default 600) +# EXEC_TIMEOUT seconds for each exec step (default 90) # # Never prints credentials. Needs msb on PATH, a KVM/HVF-capable host, network access # for the image pull. Bash 3.2 compatible. Run it OUTSIDE any sandbox. @@ -31,11 +49,10 @@ set -uo pipefail STEP_TIMEOUT="${STEP_TIMEOUT:-600}" EXEC_TIMEOUT="${EXEC_TIMEOUT:-90}" IMAGE="${QUALIFY_IMAGE:-docker.io/docker/sandbox-templates:shell-docker}" -BASE="${QUALIFY_BASE:-${XDG_CACHE_HOME:-$HOME/.cache}/acq/qualify}" HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) REPO=$(cd "$HERE/.." && pwd -P) -SANDBOX="" ; RODIR="" ; RWDIR="" ; RESULTS="" ; DONE=0 +SANDBOX="" ; RODIR="" ; RWDIR="" ; RUNDIR="" ; RESULTS="" ; DONE=0 ; KEEP_DIRS=1 ; LOG="" ; VERSION="" ; REVISION="" # Run a command with a per-step timeout when a timeout tool exists. --foreground keeps # the child able to read the terminal and be interrupted; fall back to running it bare. @@ -58,17 +75,20 @@ cleanup() { SANDBOX="" fi local d - for d in "$RODIR" "$RWDIR"; do - if [ -n "$d" ] && [ -d "$d" ]; then chmod -R u+w "$d" 2>/dev/null || true; rm -rf -- "$d"; fi - done + if [ "$KEEP_DIRS" -eq 0 ] && [ -n "$RUNDIR" ]; then + for d in "$RUNDIR/ro" "$RUNDIR/rw"; do + if [ -d "$d" ]; then chmod -R u+w "$d" 2>/dev/null || true; rm -rf -- "$d"; fi + done + fi } trap 'cleanup' EXIT trap 'echo; echo "interrupted; removing the throwaway sandbox"; cleanup; exit 130' INT TERM finish() { # VERDICT DETAIL - local verdict="$1" detail="$2" rc + local verdict="$1" detail="$2" rc hostid DONE=1 case "$verdict" in PASS) rc=0 ;; FAIL) rc=1 ;; *) rc=2 ;; esac + hostid=$(uname -sm 2>/dev/null || echo unknown) say "" say "RESULT: $verdict" say "msb version : ${VERSION:-unknown}" @@ -76,9 +96,14 @@ finish() { # VERDICT DETAIL say "host : $(uname -srm 2>/dev/null || echo unknown)" say "revision : ${REVISION:-unknown}" say "detail : $detail" - if [ -n "$RESULTS" ]; then - printf '%s\t%s\t%s\t%s\n' "$verdict" "${VERSION:-unknown}" "${REVISION:-unknown}" "$detail" >>"$RESULTS" 2>/dev/null || true + if [ -n "$RUNDIR" ] && [ -d "$RUNDIR" ]; then + RESULTS="$RUNDIR/results.tsv" + printf '%s\t%s\t%s\t%s\t%s\n' "$verdict" "${VERSION:-unknown}" "${REVISION:-unknown}" "$detail" "$RUNDIR/attempts.log" >>"$RESULTS" 2>/dev/null || true + printf 'RESULT: %s\nmsb version: %s\nimage: %s\nhost: %s\nrevision: %s\ndetail: %s\n' "$verdict" "${VERSION:-unknown}" "$IMAGE" "$hostid" "${REVISION:-unknown}" "$detail" >"$RUNDIR/verdict.txt" 2>/dev/null || true + if [ "$verdict" = PASS ]; then KEEP_DIRS=0; else KEEP_DIRS=1; fi say "results : $RESULTS" + say "evidence : $RUNDIR (guest log, host snapshots and diff, verdict)" + [ "$KEEP_DIRS" -eq 1 ] && say "kept : the throwaway host directories under $RUNDIR (ro/ and rw/)" fi if [ "$verdict" = PASS ] && [ "${VERSION_OK:-0}" -ne 1 ]; then say "" @@ -89,11 +114,25 @@ finish() { # VERDICT DETAIL say "record this output (version, revision, date, host OS/arch, image) in" say "docs/adr/0035-host-authoritative-sandbox-config.md." fi + say "" + say "RESULT $verdict msb=${VERSION:-unknown} image=$IMAGE host=$hostid rev=${REVISION:-unknown} dir=${RUNDIR:-none}" exit "$rc" } +# Output directory first, so even an early BLOCKED leaves evidence the operator can +# paste or share. Repo-local by default so it is readable from the same checkout. +STAMP=$(date -u +%Y%m%dT%H%M%SZ) +RUNDIR="${ACQ_QUALIFY_OUT_DIR:-$REPO/tmp/qualify-msb-ro-mount-$STAMP}" +if mkdir -p "$RUNDIR" 2>/dev/null; then + RUNDIR=$(cd "$RUNDIR" && pwd -P) +else + RUNDIR="" +fi +RODIR=""; RWDIR="" +[ -n "$RUNDIR" ] || { echo "cannot create the output directory (set ACQ_QUALIFY_OUT_DIR)" >&2; VERSION=""; finish BLOCKED "cannot create the output directory"; } + # --- preconditions ---------------------------------------------------------- -command -v msb >/dev/null 2>&1 || { VERSION="" ; REVISION="" ; finish BLOCKED "msb is not on PATH"; } +command -v msb >/dev/null 2>&1 || finish BLOCKED "msb is not on PATH" VERSION=$(msb --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?([+-][^[:space:]]*)?' | head -n1) [ -n "$VERSION" ] || VERSION="version not parsed" VERSION_OK=0 @@ -111,13 +150,8 @@ if [ -n "${EXPECT_SHA:-}" ]; then esac fi -mkdir -p "$BASE" 2>/dev/null || finish BLOCKED "cannot create $BASE" -BASE=$(cd "$BASE" && pwd -P) -RESULTS="$BASE/results.tsv" -STAMP=$(date -u +%Y%m%d%H%M%S) -RODIR=$(mktemp -d "$BASE/ro.XXXXXX") || finish BLOCKED "cannot create the read-only test directory" -RWDIR=$(mktemp -d "$BASE/rw.XXXXXX") || finish BLOCKED "cannot create the control directory" -RODIR=$(cd "$RODIR" && pwd -P); RWDIR=$(cd "$RWDIR" && pwd -P) +RODIR="$RUNDIR/ro"; RWDIR="$RUNDIR/rw" +mkdir "$RODIR" "$RWDIR" || finish BLOCKED "cannot create the test directories" chmod 0755 "$RODIR" "$RWDIR" printf 'HOST-ORIGINAL\n' >"$RODIR/sentinel" chmod 0644 "$RODIR/sentinel" @@ -137,6 +171,9 @@ ro=$1; rw=$2; log=$rw/attempts.log : > "$log" note() { printf '%s\n' "$*" >>"$log"; } try() { d=$1; shift; if "$@" >/dev/null 2>&1; then note "WROTE $d"; else note "DENIED $d"; fi; } +# A remount that is accepted changes the guest's own mount flags; it is not a write. +rtry() { d=$1; shift; if "$@" >/dev/null 2>&1; then note "REMOUNT-ACCEPTED $d"; else note "REMOUNT-DENIED $d"; fi; } +mline() { grep " $1 " /proc/mounts 2>/dev/null | head -n1; } note "uid=$(id -u)" # Control: the same operations MUST succeed on the read-write volume, so a DENIED on # the read-only volume cannot just mean "that command is missing in this image". @@ -150,10 +187,20 @@ try mkdir mkdir "$ro/qual-dir" try chmod chmod 0777 "$ro/sentinel" try rename mv "$ro/sentinel" "$ro/sentinel.moved" try delete rm -f "$ro/sentinel" -try remount-rw mount -o remount,rw "$ro" -try remount-bind mount -o remount,bind,rw "$ro" +note "MOUNT-BEFORE ro: $(mline "$ro")" +note "MOUNT-BEFORE rw: $(mline "$rw")" +rtry remount-rw mount -o remount,rw "$ro" +rtry remount-bind mount -o remount,bind,rw "$ro" +note "MOUNT-AFTER ro: $(mline "$ro")" +note "MOUNT-AFTER rw: $(mline "$rw")" try create-after-remount sh -c ': > "$0/qual-after-remount"' "$ro" try overwrite-after-remount sh -c 'echo tampered2 > "$0/sentinel"' "$ro" +try append-after-remount sh -c 'echo extra2 >> "$0/sentinel"' "$ro" +try mkdir-after-remount mkdir "$ro/qual-dir2" +try chmod-after-remount chmod 0777 "$ro/sentinel" +try rename-after-remount mv "$ro/sentinel" "$ro/sentinel.moved2" +try delete-after-remount rm -f "$ro/sentinel" +if [ "$(cat "$ro/sentinel" 2>/dev/null)" = "HOST-ORIGINAL" ]; then note "READ-AFTER-OK"; else note "READ-AFTER-CHANGED"; fi note "DONE" EOS @@ -194,15 +241,42 @@ fi if ! grep -q '^READ-OK$' "$LOG" 2>/dev/null; then finish BLOCKED "the guest could not read the mounted sentinel, so the read-only mount was not visible" fi -if grep -q '^WROTE ' "$LOG" 2>/dev/null; then - say "--- guest reports it wrote to the read-only mount ---" - grep '^WROTE ' "$LOG" | head -n 20 - finish FAIL "the guest was able to write to the :ro mount (even if the host directory did not change, the guest's view of trusted code was modifiable)" -fi AFTER=$(snapshot "$RODIR") +cp "$LOG" "$RUNDIR/attempts.log" 2>/dev/null || true +printf '%s\n' "$BEFORE" >"$RUNDIR/before.snapshot" +printf '%s\n' "$AFTER" >"$RUNDIR/after.snapshot" +LOG="$RUNDIR/attempts.log" + +say "--- guest-reported log (diagnostic; the host snapshot below is the authority) ---" +sed 's/^/ /' "$LOG" +if grep -q '^REMOUNT-ACCEPTED ' "$LOG" 2>/dev/null; then + say "--- note: a remount was ACCEPTED ---" + say " A guest root can change the guest's own mount flags, and the guest's view may then" + say " show the mount as read-write. That does not by itself mean the host directory is" + say " writable. This verdict rests on (1) every later data write being denied and (2) the" + say " real host directory being unchanged. It covers a freshly created sandbox on this" + say " msb version, image and host, and does not exercise other sequences of guest actions." +fi + +WROTE_LINES=$(grep '^WROTE ' "$LOG" 2>/dev/null || true) +if grep -q '^READ-AFTER-CHANGED$' "$LOG" 2>/dev/null; then + WROTE_LINES="${WROTE_LINES}${WROTE_LINES:+ +}READ-AFTER-CHANGED (the guest's own view of the sentinel no longer matches the original)" +fi +HOST_CHANGED=0 if [ "$BEFORE" != "$AFTER" ]; then + HOST_CHANGED=1 say "--- host directory changed ---" - diff <(printf '%s\n' "$BEFORE") <(printf '%s\n' "$AFTER") | head -n 40 - finish FAIL "the host directory behind the :ro volume was modified by the guest; this msb does NOT enforce read-only" + diff <(printf '%s\n' "$BEFORE") <(printf '%s\n' "$AFTER") >"$RUNDIR/host.diff" 2>&1 || true + head -n 40 "$RUNDIR/host.diff" +fi +if [ -n "$WROTE_LINES" ] && [ "$HOST_CHANGED" -eq 1 ]; then + finish FAIL "the guest wrote to the :ro mount AND the host directory behind it changed; this msb does NOT enforce read-only" +elif [ "$HOST_CHANGED" -eq 1 ]; then + finish FAIL "the host directory behind the :ro volume was modified by the guest even though the guest reported every write denied; this msb does NOT enforce read-only" +elif [ -n "$WROTE_LINES" ]; then + say "--- data writes the guest reports as successful ---" + printf '%s\n' "$WROTE_LINES" | head -n 20 + finish FAIL "the guest was able to write to the :ro mount (the host directory did not change, but code the guest runs from that mount could be modified)" fi -finish PASS "the host directory behind the :ro volume was unchanged after root write, remount and rename attempts, and the guest reported every attempt denied ($(grep -c '^DENIED' "$LOG") denied)" +finish PASS "the host directory behind the :ro volume was unchanged and every data write the guest attempted was denied ($(grep -c '^DENIED' "$LOG") denied; $(grep -c '^REMOUNT-ACCEPTED' "$LOG") remount attempt(s) accepted, which is informational)" diff --git a/test/bats/84-msb-ro-mount-qualification.bats b/test/bats/84-msb-ro-mount-qualification.bats index 6f80c089..db571a8a 100644 --- a/test/bats/84-msb-ro-mount-qualification.bats +++ b/test/bats/84-msb-ro-mount-qualification.bats @@ -187,14 +187,32 @@ case "${1:-}" in *"qual-ro"*) ro=$(cat "$STUBDIR/.qual_ro"); rw=$(cat "$STUBDIR/.qual_rw") [ "${STUB_QUAL_NO_LOG:-0}" = 1 ] && exit 0 - { echo "uid=${STUB_QUAL_UID:-0}"; echo "READ-OK"; [ "${STUB_QUAL_CONTROL_BROKEN:-0}" = 1 ] && echo "CONTROL-BROKEN" || echo "CONTROL-OK"; } >"$rw/attempts.log" + { + echo "uid=${STUB_QUAL_UID:-0}"; echo "READ-OK" + [ "${STUB_QUAL_CONTROL_BROKEN:-0}" = 1 ] && echo "CONTROL-BROKEN" || echo "CONTROL-OK" + echo "MOUNT-BEFORE ro: tmpfs /mnt/qual-ro virtiofs ro 0 0" + } >"$rw/attempts.log" + # Remount attempts are informational. STUB_QUAL_REMOUNT=accepted models the + # guest kernel accepting `mount -o remount,rw` (exit 0), which is not a write. + if [ "${STUB_QUAL_REMOUNT:-denied}" = accepted ]; then + { echo "REMOUNT-ACCEPTED remount-rw"; echo "REMOUNT-ACCEPTED remount-bind"; } >>"$rw/attempts.log" + echo "MOUNT-AFTER ro: tmpfs /mnt/qual-ro virtiofs rw 0 0" >>"$rw/attempts.log" + else + { echo "REMOUNT-DENIED remount-rw"; echo "REMOUNT-DENIED remount-bind"; } >>"$rw/attempts.log" + echo "MOUNT-AFTER ro: tmpfs /mnt/qual-ro virtiofs ro 0 0" >>"$rw/attempts.log" + fi if [ "${STUB_RO_ENFORCED:-1}" = 1 ]; then echo "DENIED create-file" >>"$rw/attempts.log" + echo "DENIED create-after-remount" >>"$rw/attempts.log" else : >"$ro/qual-new-file" if [ "${STUB_QUAL_FORGE_DENIED:-0}" = 1 ]; then echo "DENIED create-file" >>"$rw/attempts.log" else echo "WROTE create-file" >>"$rw/attempts.log"; fi fi + # STUB_QUAL_REPORT_WROTE=1 with an enforced mount: the guest REPORTS a data write + # but the host directory is untouched (a lying or mistaken guest). + [ "${STUB_QUAL_REPORT_WROTE:-0}" = 1 ] && echo "WROTE overwrite" >>"$rw/attempts.log" + echo "READ-AFTER-OK" >>"$rw/attempts.log" echo "DONE" >>"$rw/attempts.log" exit 0 ;; esac @@ -206,7 +224,7 @@ STUB } _run_canary() { # [ENV...] - run env "$@" PATH="$STUBDIR/canary-bin:$PATH" STUBDIR="$STUBDIR" QUALIFY_BASE="$STUBDIR/qbase" \ + run env "$@" PATH="$STUBDIR/canary-bin:$PATH" STUBDIR="$STUBDIR" ACQ_QUALIFY_OUT_DIR="$STUBDIR/qout" \ STEP_TIMEOUT=20 EXEC_TIMEOUT=20 bash "$REPO_ROOT/scripts/qualify-msb-ro-mount" } @@ -246,7 +264,7 @@ _run_canary() { # [ENV...] } @test "canary: msb missing => BLOCKED" { - run env PATH="/usr/bin:/bin" QUALIFY_BASE="$STUBDIR/qbase2" bash -c 'command -v msb >/dev/null 2>&1 && exit 77; exec bash "'"$REPO_ROOT"'/scripts/qualify-msb-ro-mount"' + run env PATH="/usr/bin:/bin" ACQ_QUALIFY_OUT_DIR="$STUBDIR/qout2" bash -c 'command -v msb >/dev/null 2>&1 && exit 77; exec bash "'"$REPO_ROOT"'/scripts/qualify-msb-ro-mount"' [ "$status" -eq 77 ] && skip "a real msb is on the minimal PATH" assert_failure 2 assert_output --partial 'RESULT: BLOCKED' @@ -292,3 +310,75 @@ _run_canary() { # [ENV...] assert_output --partial 'do NOT add it to the list' refute_output --partial 'Next: add' } + +# --- remount classification, verdict order and evidence layout ------------------- + +@test "canary: an ACCEPTED remount with every data write denied and the host unchanged => PASS" { + _plant_canary_msb + _run_canary STUB_RO_ENFORCED=1 STUB_QUAL_REMOUNT=accepted + assert_success + assert_output --partial 'RESULT: PASS' + assert_output --partial 'a remount was ACCEPTED' + assert_output --partial 'every data write the guest attempted was denied' + assert_output --partial '2 remount attempt(s) accepted, which is informational' + refute_output --partial 'WROTE remount' +} + +@test "canary: a data write reported WROTE with the host unchanged => FAIL" { + _plant_canary_msb + _run_canary STUB_RO_ENFORCED=1 STUB_QUAL_REPORT_WROTE=1 + assert_failure 1 + assert_output --partial 'RESULT: FAIL' + assert_output --partial 'the guest was able to write to the :ro mount' +} + +@test "canary: host directory changed while the guest log says all denied => FAIL from the host snapshot" { + _plant_canary_msb + _run_canary STUB_RO_ENFORCED=0 STUB_QUAL_FORGE_DENIED=1 STUB_QUAL_REMOUNT=accepted + assert_failure 1 + assert_output --partial 'RESULT: FAIL' + assert_output --partial 'even though the guest reported every write denied' + assert_output --partial '--- host directory changed ---' +} + +@test "canary: the guest script classifies remount separately from data writes (not a WROTE)" { + run grep -n 'rtry remount-rw\|rtry remount-bind' "$REPO_ROOT/scripts/qualify-msb-ro-mount" + assert_success + run grep -n '^try remount' "$REPO_ROOT/scripts/qualify-msb-ro-mount" + assert_failure +} + +@test "canary: all output lands under the output directory, one pasteable line is printed" { + _plant_canary_msb + _run_canary STUB_RO_ENFORCED=1 + assert_success + assert_output --partial "dir=$STUBDIR/qout" + assert_regex "$output" 'RESULT PASS msb=0\.7\.7 image=[^ ]+ host=[^ ]+ [^ ]* ?rev=' + [ -f "$STUBDIR/qout/results.tsv" ] + [ -f "$STUBDIR/qout/attempts.log" ] + [ -f "$STUBDIR/qout/before.snapshot" ] + [ -f "$STUBDIR/qout/after.snapshot" ] + [ -f "$STUBDIR/qout/verdict.txt" ] + grep -q 'MOUNT-BEFORE ro:' "$STUBDIR/qout/attempts.log" + grep -q 'MOUNT-AFTER ro:' "$STUBDIR/qout/attempts.log" + # PASS removes the throwaway host directories; the evidence stays. + [ ! -d "$STUBDIR/qout/ro" ] + [ ! -d "$STUBDIR/qout/rw" ] +} + +@test "canary: FAIL and BLOCKED keep the throwaway host directories as evidence" { + _plant_canary_msb + _run_canary STUB_RO_ENFORCED=0 + assert_failure 1 + [ -d "$STUBDIR/qout/ro" ] + [ -f "$STUBDIR/qout/ro/qual-new-file" ] + [ -f "$STUBDIR/qout/host.diff" ] + assert_output --partial 'RESULT FAIL' +} + +@test "canary: the default output directory is under the repo tmp/, which is gitignored" { + run grep -n 'qualify-msb-ro-mount-\$STAMP' "$REPO_ROOT/scripts/qualify-msb-ro-mount" + assert_success + run git -C "$REPO_ROOT" check-ignore -q tmp/qualify-msb-ro-mount-x/results.tsv + [ "$status" -eq 0 ] || skip "not a git checkout, or tmp/ not ignored here" +} From beb67aabb446214e4e5ff304fc64a55eefee0a44 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 28 Oct 2026 17:00:39 +0000 Subject: [PATCH 53/63] docs: explain why an accepted remount does not fail qualification; update canary output location Co-authored-by: OpenCode Agent --- docs/KNOWN_FAILURE_MODES.md | 7 ++++++- .../0035-host-authoritative-sandbox-config.md | 20 ++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/KNOWN_FAILURE_MODES.md b/docs/KNOWN_FAILURE_MODES.md index 84f48616..f39f4d0a 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -3119,7 +3119,12 @@ refuses rather than staging, running, or copying that code. Run `scripts/qualify-msb-ro-mount` on a sandbox-capable host (outside any sandbox). It judges the result by inspecting the real host directory afterwards, so nothing -the guest reports can make it pass. On PASS, add the printed version to +the guest reports can make it pass. All output (results, guest log, host snapshots, +and on FAIL or BLOCKED the throwaway directories) is written under +`/tmp/qualify-msb-ro-mount-/` (gitignored; override with +`ACQ_QUALIFY_OUT_DIR`), and a final `RESULT ...` line can be pasted back. A remount +that the guest accepts is informational, not a write; the verdict rests on the +later writes being denied and the host directory being unchanged. On PASS, add the printed version to `MSB_RO_MOUNT_QUALIFIED_VERSIONS` and record the output in ADR-0035 (with host OS/arch and image). Existing sandboxes need no recreate for this: the version list is checked at apply time. The guest probe is diff --git a/docs/adr/0035-host-authoritative-sandbox-config.md b/docs/adr/0035-host-authoritative-sandbox-config.md index 13f982e7..611fc162 100644 --- a/docs/adr/0035-host-authoritative-sandbox-config.md +++ b/docs/adr/0035-host-authoritative-sandbox-config.md @@ -183,7 +183,25 @@ Concretely, this ADR introduces two mechanisms and a staged migration. chmod, rename, delete and remount-rw on the read-only mount, and then judges the result **from the host**: it compares the real host directory before and after and requires that the host saw the control file. A changed host directory is FAIL - whatever the guest reports; a missing control (the log, a root uid, or the same + whatever the guest reports. **A remount the guest kernel accepts is not a + write.** The canary records `mount -o remount,rw` and a bind remount as + informational (`REMOUNT-ACCEPTED` or `REMOUNT-DENIED`) and then repeats every kind + of data write (create, overwrite, append, mkdir, chmod, rename, delete) *after* + them. A guest root can alter its own mount flags, and the guest's view may then + show the volume as read-write; what matters is whether the host directory behind + it can still be changed. So the decision rests on two things together: every data + write after the remount was denied, and the real host directory is byte-for-byte + unchanged. If a remount is accepted and the later writes are still denied with + the host unchanged, that is PASS, with a printed note that a remount was + accepted. We considered failing qualification on any accepted remount and + rejected it as the wrong test: the guest-visible mount flag need not reflect what + the host side enforces (not verified for msb), and the host-side comparison is the + thing we trust, so that rule could disqualify a runtime that holds while adding no + protection the post-remount writes do not already measure. The + residual gap is a window we do not exercise, for example a different sequence + of guest actions, a later `msb start`, or another host platform or image; the + PASS says nothing about those, which is why each list entry records its host, + image and revision. A missing control (the log, a root uid, or the same operations succeeding on the read-write volume) is BLOCKED, never PASS. The decision is the host-side comparison; the guest's own log is used only to prove the attempts really ran, and it can only turn a PASS into BLOCKED or FAIL, never From d774e22e3e8e3f45ae18b7b229f06e0fcfba1cdd Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 28 Oct 2026 17:18:37 +0000 Subject: [PATCH 54/63] feat(msb): qualify msb 0.7.7 for readonly kit files from the read-only mount The host-observed canary passed on msb 0.7.7 (macOS arm64, default shell-docker image, fresh create): every data write was denied before and after the remount attempts, the read-write control worked, and the real host directory was unchanged. Add 0.7.7 to MSB_RO_MOUNT_QUALIFIED_VERSIONS. Any other version, a prerelease or an unparsable version is still refused, a host ro-host-config record is still required, and the guest probe still only adds refusals. The test that asserted the list ships empty is replaced deliberately by one asserting the list is exactly 0.7.7; new cases use the shipped list rather than an override. Co-authored-by: OpenCode Agent --- acq.backends/msb.sh | 12 +-- test/bats/84-msb-ro-mount-qualification.bats | 93 +++++++++++++++++++- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 4c49e93a..503d27a4 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -177,11 +177,13 @@ MSB_FIXED_VERSION="0.7.3" # Trusted execution of `readonly: true` kit files from the host-config mount # (ADR-0035 Mechanism 2) is permitted ONLY on a version listed here. The list is # deliberately checked in, not read from the environment or any guest-reachable -# state, and it starts EMPTY: no version has been qualified yet, so such kit files -# are refused (fail closed) with a message naming the version and the script. -# Space-separated bare X.Y.Z. Each entry needs its evidence recorded in ADR-0035 -# (tested msb version, canary revision, date, PASS result) in the same change. -MSB_RO_MOUNT_QUALIFIED_VERSIONS="" +# state. A version not listed is refused (fail closed) with a message naming the +# version and the script. Space-separated bare X.Y.Z. Each entry needs its evidence +# recorded in ADR-0035 (msb version, canary revision, date, host OS/arch, image, +# PASS result) in the same change, and covers that evidence's scope only. +# 0.7.7 PASS 2026-10-28, macOS arm64, default shell-docker image, fresh create +# (ADR-0035, "Qualification evidence"). +MSB_RO_MOUNT_QUALIFIED_VERSIONS="0.7.7" # Default OCI image for provisioned non-OpenCode sandboxes. We default to the SAME image # family sbx uses: `docker/sandbox-templates:shell-docker`, an Ubuntu-based diff --git a/test/bats/84-msb-ro-mount-qualification.bats b/test/bats/84-msb-ro-mount-qualification.bats index db571a8a..236a9bda 100644 --- a/test/bats/84-msb-ro-mount-qualification.bats +++ b/test/bats/84-msb-ro-mount-qualification.bats @@ -53,10 +53,13 @@ _apply_ver() { # NAME KITDIR VERSION QUALIFIED_LIST ' } -@test "qualified list: ships empty, so no msb version is trusted for readonly code today" { +# Deliberate change: this case used to assert that the list ships EMPTY. The 0.7.7 +# canary PASSED (ADR-0035, "Qualification evidence"), so the shipped list is exactly +# that one version. Any other entry must come with its own evidence in the same change. +@test "qualified list: the shipped list is exactly msb 0.7.7 (ADR-0035 evidence)" { run bash -c '. "'"$REPO_ROOT"'/acq.backends/msb.sh"; printf "[%s]\n" "$MSB_RO_MOUNT_QUALIFIED_VERSIONS"' assert_success - assert_output '[]' + assert_output '[0.7.7]' } @test "qualified list: a forged zero-exit probe does not unlock staging on an unlisted version" { @@ -382,3 +385,89 @@ _run_canary() { # [ENV...] run git -C "$REPO_ROOT" check-ignore -q tmp/qualify-msb-ro-mount-x/results.tsv [ "$status" -eq 0 ] || skip "not a git checkout, or tmp/ not ignored here" } + +# --- the shipped list (no override), deliberate-evidence behavior ------------------- + +_apply_shipped() { # NAME KITDIR VERSION [ENV...] + local name="$1" kit="$2" ver="$3"; shift 3 + : > "$CALLS" + run bash -c ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-'"$name"'" STUB_MSB_VERSION="'"$ver"'" + for kv in '"${*:-:}"'; do export "$kv" 2>/dev/null || true; done + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_test_stub_provider_input_boundary + acq_host_config_write msb "'"$name"'" ro-host-config 1 + _acq_msb_apply_kit_dir "'"$name"'" "'"$kit"'" 2>&1 + ' +} + +@test "shipped list: msb 0.7.7 with an ro-host-config record unlocks trusted staging" { + local k="$STUBDIR/s1"; _ro_kit "$k" + _apply_shipped shipped077 "$k" 0.7.7 + assert_success + local log; log=$(cat "$CALLS") + assert_regex "$log" 'node /var/lib/acq/host/kit-files/' + refute_regex "$log" 'msb copy .*shipped077:/home/agent/cfg/merge\.mjs' +} + +@test "shipped list: msb 0.7.7 without a record still takes the guest-copy path (the record is still required)" { + local k="$STUBDIR/s2"; _ro_kit "$k" + : > "$CALLS" + run bash -c ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-s2" STUB_MSB_VERSION=0.7.7 + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_test_stub_provider_input_boundary + _acq_msb_apply_kit_dir norecord077 "'"$k"'" 2>&1 + ' + assert_success + refute_regex "$(cat "$CALLS")" '/var/lib/acq/host/kit-files' + assert_regex "$(cat "$CALLS")" 'msb copy .*norecord077:/home/agent/cfg/merge\.mjs' +} + +@test "shipped list: another msb version is refused even with a record and an all-clear probe" { + local k="$STUBDIR/s3"; _ro_kit "$k" + _apply_shipped shipped078 "$k" 0.7.8 + assert_failure + assert_output --partial 'msb 0.7.8 is not on acq' + refute_regex "$(cat "$CALLS")" 'node ' + _apply_shipped shipped069 "$k" 0.6.9 + assert_failure + assert_output --partial 'msb 0.6.9 is not on acq' +} + +@test "shipped list: a prerelease of the qualified version is refused" { + local k="$STUBDIR/s4"; _ro_kit "$k" + _apply_shipped shippedrc "$k" 0.7.7-rc1 + assert_failure + assert_output --partial 'not on acq' + refute_regex "$(cat "$CALLS")" 'node ' +} + +@test "shipped list: an unparsable msb version is refused" { + local k="$STUBDIR/s5"; _ro_kit "$k" + _apply_shipped shippedbad "$k" banana + assert_failure + refute_regex "$(cat "$CALLS")" 'node ' +} + +@test "shipped list: a forged all-clear guest probe does not unlock an unlisted version" { + local k="$STUBDIR/s6"; _ro_kit "$k" + # The stub guest answers every probe as 'present and not writable' by default. + _apply_shipped shippedforge "$k" 0.7.9 + assert_failure + assert_output --partial 'msb 0.7.9 is not on acq' + refute_regex "$(cat "$CALLS")" 'node ' + refute_regex "$(cat "$CALLS")" 'msb copy .*shippedforge:/home/agent/cfg/merge\.mjs' +} + +@test "shipped list: msb 0.7.7 still fails closed when the guest says the mount is writable" { + local k="$STUBDIR/s7"; _ro_kit "$k" + _apply_shipped shippedwrit "$k" 0.7.7 STUB_HOST_CONFIG_WRITABLE=1 + assert_failure + assert_output --partial 'WRITABLE' + refute_regex "$(cat "$CALLS")" 'node ' +} From 978fa533ce6612382553da2dda0c146993b8d0b6 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 28 Oct 2026 17:18:37 +0000 Subject: [PATCH 55/63] docs: record the msb 0.7.7 read-only mount qualification and its limits Add the per-version evidence to ADR-0035, including that the guest's own view of the mount flag turned rw after an accepted remount while writes stayed denied and the host directory was unchanged, so the guest's mount flags are not a reliable signal. State the scope (fresh create, this version, image and host class) and what remains unverified. Co-authored-by: OpenCode Agent --- docs/KNOWN_FAILURE_MODES.md | 23 +++--- .../0035-host-authoritative-sandbox-config.md | 75 +++++++++++++++---- docs/adr/0037-sbx-trusted-startup-parity.md | 3 +- 3 files changed, 78 insertions(+), 23 deletions(-) diff --git a/docs/KNOWN_FAILURE_MODES.md b/docs/KNOWN_FAILURE_MODES.md index f39f4d0a..022944b4 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -3089,7 +3089,7 @@ host-side API, or a read-only host channel into the sandbox. --- -## 48. msb: Readonly Kit Files Are Refused Until the Read-Only Mount Is Qualified +## 48. msb: Readonly Kit Files Are Refused on an Unqualified msb Version **Status:** Fail closed by design. Applies to a kit with `files[]` entries marked `readonly: true` on an msb sandbox created with the read-only host-config mount. @@ -3109,11 +3109,13 @@ a heal reports it on each re-attach. acq runs `readonly: true` kit code from a host directory mounted into the guest `:ro`, on the assumption that the runtime enforces that flag. Only the runtime can -establish that, and it is unverified on every msb version acq accepts. A guest-run -probe cannot stand in for it: a guest with passwordless sudo controls `sh` and -`test`. So acq trusts the mount only on msb versions a **host-observed** canary has -qualified (`MSB_RO_MOUNT_QUALIFIED_VERSIONS`, which ships empty), and otherwise -refuses rather than staging, running, or copying that code. +establish that. A guest-run probe cannot stand in for it: a guest with passwordless +sudo controls `sh` and `test`, and on msb the guest's own view of its mount flags can +even flip to `rw` after a remount while the host side still refuses writes (see +ADR-0035). So acq trusts the mount only on msb versions a **host-observed** canary +has qualified (`MSB_RO_MOUNT_QUALIFIED_VERSIONS`). Today that list holds **0.7.7** +only (macOS arm64, default image, fresh create; evidence in ADR-0035). On any other +msb version acq refuses rather than staging, running, or copying that code. ### Fix @@ -3133,9 +3135,12 @@ grants trust. ### Prevention / Status -No shipped built-in kit marks a file `readonly: true`, so default sandboxes are not -affected today; the gate matters for the first kit that opts in. Until a version is -qualified this is expected, not a defect. +On msb 0.7.7 readonly kit files are accepted. That qualification covers a fresh +create on macOS arm64 with the default image; it says nothing about Linux, Windows, +other images or behavior after `msb start`, which remain unverified. On any other +msb version the refusal is expected until that version passes the canary. No shipped built-in kit marks a file +`readonly: true`, so default sandboxes are not affected either way; the gate matters +for the first kit that opts in. --- diff --git a/docs/adr/0035-host-authoritative-sandbox-config.md b/docs/adr/0035-host-authoritative-sandbox-config.md index 611fc162..c13a8cae 100644 --- a/docs/adr/0035-host-authoritative-sandbox-config.md +++ b/docs/adr/0035-host-authoritative-sandbox-config.md @@ -168,10 +168,12 @@ Concretely, this ADR introduces two mechanisms and a staged migration. mount/export semantics, not Unix ownership inside the guest. **What is and is not established about that enforcement:** an earlier manual observation on msb 0.7.2 (a version acq now refuses) found the virtiofs mount exported read-only even when - guest root could make the guest-visible mount flag appear `rw`. That is the only - observation, it is not a repeatable check, and it covers no version acq accepts. - For sbx nothing is recorded. So `:ro` enforcement is **unverified on every - accepted msb version and on sbx**. + guest root could make the guest-visible mount flag appear `rw`. It was not a + repeatable check and covers no version acq accepts. The first repeatable, + host-judged result is the msb 0.7.7 qualification recorded below, which covers one + version, image and host class and nothing else. For sbx nothing is recorded. So + `:ro` enforcement is **unverified on every other msb version, on other host + platforms and images, after `msb start`, and on sbx**. **Authority for trusting the mount (msb).** Trusted execution of `readonly: true` kit files from the mount is permitted only when the installed msb version is on @@ -206,15 +208,15 @@ Concretely, this ADR introduces two mechanisms and a staged migration. decision is the host-side comparison; the guest's own log is used only to prove the attempts really ran, and it can only turn a PASS into BLOCKED or FAIL, never the reverse. The canary runs the trusted default image, so it does not defend - against a hostile image. The list **ships - empty**: until a PASS is recorded here for a version, readonly files with a - `ro-host-config` record are refused on that version with an error that names the - version and the script. There is no operator override. Each entry added to the - list must record, in this ADR, the msb version, the acq revision the canary ran - at, the date, the host OS and architecture, the image reference (the default - image tag is mutable) and the PASS output. A PASS covers a freshly created - sandbox on that host; enforcement after `msb start`, on other host platforms, or - with another image is not covered by it. + against a hostile image. The list is + checked in and **starts with one qualified version (0.7.7)**; every other version + is refused for readonly files with an error that names the version and the script. + There is no operator override. Each entry added to the list must record, in this + ADR, the msb version, the acq revision the canary ran at, the date, the host OS + and architecture, the image reference (the default image tag is mutable) and the + PASS output. A PASS covers a freshly created sandbox on that host; enforcement + after `msb start`, on other host platforms, or with another image is not covered + by it. Whether a sandbox is a candidate at all is a **host-recorded create fact** (host key `ro-host-config`), written only after a create that passed the `:ro` volume @@ -437,6 +439,53 @@ GSA-TTS/agentic-coding-quickstart#636 fields: the sbx `extra-kits` and `builtin- `docs/KNOWN_FAILURE_MODES.md` section 32.1. SBX trusted-startup parity is proposed separately in [ADR-0037](0037-sbx-trusted-startup-parity.md). +## Qualification evidence (msb read-only host-config mount) + +One entry per qualified msb version. The list lives in +`MSB_RO_MOUNT_QUALIFIED_VERSIONS` (`acq.backends/msb.sh`). + +### msb 0.7.7 + +| Field | Value | +|---|---| +| Result | PASS (`scripts/qualify-msb-ro-mount`) | +| Date | 2026-10-28, about 17:05Z | +| msb version | 0.7.7 | +| acq revision | `beb67aabb446214e4e5ff304fc64a55eefee0a44` | +| Host | Darwin arm64 (macOS on Apple silicon), as recorded in `verdict.txt`; kernel `25.6.0` as reported by the maintainer from the console output (not in the saved files) | +| Image | `docker.io/docker/sandbox-templates:shell-docker` (a mutable tag; no digest was recorded) | +| Run by | the maintainer, on a real host, outside any sandbox | +| Raw output | `tmp/qualify-msb-ro-mount-20261028T170549Z/` in the maintainer's worktree (gitignored, not committed): `results.tsv`, `attempts.log`, `before.snapshot`, `after.snapshot`, `verdict.txt` | + +Observed: the attempts ran as root (`uid=0`); the read-write control volume accepted +the same operations (`CONTROL-OK`) and the sentinel was readable (`READ-OK`, and +`READ-AFTER-OK` at the end). Fourteen data writes were denied, seven before and seven +after the remount attempts: create, overwrite, append, mkdir, chmod, rename and +delete. The two remount attempts, `mount -o remount,rw` and a bind remount, were both +**accepted**. The host directory behind the `:ro` volume was byte-for-byte identical +before and after (snapshots identical, no diff file). + +**The nuance, stated plainly.** After the accepted remounts, the guest's own +`/proc/mounts` showed the read-only mount as `virtiofs rw` (before: `ro,relatime`; +after: `rw,relatime`), yet every later write was still denied and the host directory +was unchanged. So on this runtime the enforcement evidently does not live in the +guest-visible mount flags; it lives on the host/virtiofs side, and a guest root can +change how its own kernel labels the mount without changing what the host allows. The +consequence for acq is that the guest's view of its own mount flags is **not a +reliable signal in either direction**. A "read-only" reading from inside the guest +proves nothing, and neither does "rw". That is why the guest probe stays a +diagnostic that can only add refusals and never grants trust, and why qualification +is judged from the host. + +**Scope.** This qualifies a **fresh create** of **msb 0.7.7** with **this image** on +**this host class (macOS arm64)**. It does **not** qualify: Linux or Windows hosts or +other CPU architectures; any other image, or the same tag at a different digest; +behavior after `msb stop`/`msb start` or a host restart; sandboxes created before the +canary; or any other sequence of guest actions than the one the script performs. +**Still unverified:** all of those, and whether the remount acceptance is stable +across runs (this is one run). The result is one observation of a runtime we do not +control, so a later msb release must be re-run before it is added. + ## Links - Tracking: [GSA-TTS/agentic-coding-quickstart#503](https://github.com/GSA-TTS/agentic-coding-quickstart/issues/503) diff --git a/docs/adr/0037-sbx-trusted-startup-parity.md b/docs/adr/0037-sbx-trusted-startup-parity.md index 5d0dad79..bca8f469 100644 --- a/docs/adr/0037-sbx-trusted-startup-parity.md +++ b/docs/adr/0037-sbx-trusted-startup-parity.md @@ -21,7 +21,8 @@ on the host, mounts the per-sandbox config directory `:ro`, and runs the heal fr the mounted path, so a passwordless-sudo guest cannot rewrite the code acq re-executes on restart, **on an msb version where a host-observed canary has shown the `:ro` mount is enforced** (ADR-0035 gates that on a checked-in list of qualified -versions, which ships empty; a guest-run probe is never accepted as proof). +versions, currently msb 0.7.7 only on one host class; a guest-run probe is never +accepted as proof). SBX has no equivalent. `acq_backend_provision` for sbx creates the host config directory but never mounts it (it holds host-only keys such as `extra-kits`, From 75528a036ba6ea9120f1c08faad4672cfa24779e Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 28 Oct 2026 17:49:37 +0000 Subject: [PATCH 56/63] feat(msb): gate trusted readonly kit files on msb's own mount record, not a version allowlist Replace the per-version allowlist and canary-qualification gate with a check of what msb itself recorded for the sandbox. Trusted staging and execution of readonly kit files from the host-config mount now require: the host ro-host-config record; msb inspect (run on the host) showing exactly one read-only Bind mount at the host-config guest path backed by acq's own host config directory, with no overlapping writable mount; and a final msb release at or above a version floor (0.7.7). A null active_config is retried briefly; inspect failure, unparsable or unexpected output, a missing python3, or an unreviewed platform (Windows) refuse. The guest probe stays a diagnostic that only adds refusals. The gate runs lazily, at the first readonly file of a kit. scripts/qualify-msb-ro-mount stays as an optional, non-gating corroboration tool. Tests that pinned the allowlist are replaced deliberately by cases for the new gate; the stub msb now serves the real observed inspect shape. Tests 79 and 83 give the stub a floor-satisfying version and a read-only inspect record. Co-authored-by: OpenCode Agent --- acq.backends/msb.sh | 244 ++++++++++--- scripts/qualify-msb-ro-mount | 18 +- scripts/test-acq-lib.sh | 49 +++ test/bats/79-msb-readonly-kit-files.bats | 19 +- test/bats/83-msb-ro-host-config-record.bats | 21 +- test/bats/84-msb-ro-mount-qualification.bats | 353 +++++++++++-------- 6 files changed, 485 insertions(+), 219 deletions(-) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 503d27a4..586610d3 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -172,18 +172,15 @@ MSB_ROLLBACK_VERSION="0.6.18" # duplicate snapshots exist. See ADR-0032. MSB_FIXED_VERSION="0.7.3" -# msb versions on which the host-observed canary (scripts/qualify-msb-ro-mount) has -# shown that a `:ro` --volume is enforced by the runtime even against guest root. -# Trusted execution of `readonly: true` kit files from the host-config mount -# (ADR-0035 Mechanism 2) is permitted ONLY on a version listed here. The list is -# deliberately checked in, not read from the environment or any guest-reachable -# state. A version not listed is refused (fail closed) with a message naming the -# version and the script. Space-separated bare X.Y.Z. Each entry needs its evidence -# recorded in ADR-0035 (msb version, canary revision, date, host OS/arch, image, -# PASS result) in the same change, and covers that evidence's scope only. -# 0.7.7 PASS 2026-10-28, macOS arm64, default shell-docker image, fresh create -# (ADR-0035, "Qualification evidence"). -MSB_RO_MOUNT_QUALIFIED_VERSIONS="0.7.7" +# Lowest msb version on which acq relies on a `:ro` host-config mount for trusted +# `readonly: true` kit code (ADR-0035 Mechanism 2). 0.7.7 is the version the mount +# behavior was verified on, live (a host-side canary, scripts/qualify-msb-ro-mount) and +# in the upstream source: msb's host-side virtiofs server denies every mutating +# operation on a mount whose `ro` option is set, independent of the guest. Older +# versions were not checked and are refused; prerelease or unparsable versions are +# refused too. This is a floor, not a guarantee: the per-sandbox `msb inspect` check +# below is what confirms THIS sandbox's mount was created read-only. +MSB_RO_MOUNT_MIN_VERSION="0.7.7" # Default OCI image for provisioned non-OpenCode sandboxes. We default to the SAME image # family sbx uses: `docker/sandbox-templates:shell-docker`, an Ubuntu-based @@ -1800,9 +1797,10 @@ EOF local path mode phase source _readonly src _i _ACQ_MSB_RO_REWRITE_FROM=() _ACQ_MSB_RO_REWRITE_TO=() - local _hcfg_mount_ok=0 _hcfg_rc=0 - _acq_msb_host_config_mount_available "$name" || _hcfg_rc=$? - [ "$_hcfg_rc" -eq 0 ] && _hcfg_mount_ok=1 + # The mount gate (record, version, msb's own mount record, guest probe) is evaluated + # lazily, once, at the first readonly file this kit declares, so kits without readonly + # files pay none of its cost (an inspect, a python3 spawn and the probes). + local _hcfg_mount_ok=0 _hcfg_rc=0 _hcfg_done=0 for _i in ${_frecs[@]+"${!_frecs[@]}"}; do fline="${_frecs[$_i]}" path=$(printf '%s' "$fline" | cut -f1) @@ -1815,12 +1813,20 @@ EOF if [ -n "$source" ]; then src="${kitdir}/${source}" fi + if [ "$_readonly" = "true" ] && [ -n "$src" ] && [ -f "$src" ] && [ "$_hcfg_done" -eq 0 ]; then + _hcfg_done=1 + _acq_msb_host_config_mount_available "$name" || _hcfg_rc=$? + [ "$_hcfg_rc" -eq 0 ] && _hcfg_mount_ok=1 + fi if [ "$_readonly" = "true" ] && [ -n "$src" ] && [ -f "$src" ] && [ "$_hcfg_rc" -ge 2 ]; then # Recorded read-only create, but the mount is not confirmed read-only. Never # stage or run trusted code from it and never degrade to a guest copy. if [ "$_hcfg_rc" -eq 4 ]; then - echo "acq(msb): error: cannot place read-only kit file '$path' for '$name': msb ${_ACQ_MSB_RO_MOUNT_VERSION:-} is not on acq's list of versions where a read-only mount has been shown to be enforced." >&2 - echo "acq(msb): Run scripts/qualify-msb-ro-mount on this host; a PASS lets that version be added to MSB_RO_MOUNT_QUALIFIED_VERSIONS (see ADR-0035)." >&2 + echo "acq(msb): error: cannot place read-only kit file '$path' for '$name': msb ${_ACQ_MSB_RO_MOUNT_VERSION:-} is below acq's minimum (${MSB_RO_MOUNT_MIN_VERSION}) for relying on a read-only mount, or its version could not be read as a final release." >&2 + echo "acq(msb): Update msb (acq installs ${MSB_PINNED_VERSION}), or see ADR-0035." >&2 + elif [ "$_hcfg_rc" -eq 5 ]; then + echo "acq(msb): error: cannot place read-only kit file '$path' for '$name': ${_ACQ_MSB_RO_MOUNT_REASON:-the msb record does not show a read-only host-config mount}." >&2 + echo "acq(msb): acq will not run trusted code from a mount msb does not report as read-only. Recreate the sandbox, or see KNOWN_FAILURE_MODES 48." >&2 elif [ "$_hcfg_rc" -eq 2 ]; then echo "acq(msb): error: cannot place read-only kit file '$path' for '$name': the host-config mount looks WRITABLE from the guest, so code staged there would not be trusted." >&2 else @@ -1904,13 +1910,12 @@ _acq_msb_reset_kit_env() { acq_host_config_clear msb "$1" kit-env } -# True iff the installed msb is a final X.Y.Z release listed in -# MSB_RO_MOUNT_QUALIFIED_VERSIONS. Anything else (empty list, unparsable or -# prerelease version output, a version not on the list) is "not qualified". Sets -# _ACQ_MSB_RO_MOUNT_VERSION to what was seen, for the caller's message. +# True iff the installed msb is a final X.Y.Z release at or above +# MSB_RO_MOUNT_MIN_VERSION. Prerelease, build-suffixed, unparsable or older versions +# are refused. Sets _ACQ_MSB_RO_MOUNT_VERSION to what was seen, for the caller's message. _ACQ_MSB_RO_MOUNT_VERSION="" -_acq_msb_ro_mount_version_qualified() { - local _v _q +_acq_msb_ro_mount_version_ok() { + local _v _v=$(_acq_msb_version) _ACQ_MSB_RO_MOUNT_VERSION="$_v" [ -n "$_v" ] || return 1 @@ -1919,38 +1924,179 @@ _acq_msb_ro_mount_version_qualified() { [0-9]*.[0-9]*.[0-9]*) ;; *) return 1 ;; esac - set -f - for _q in $MSB_RO_MOUNT_QUALIFIED_VERSIONS; do - if [ "$_q" = "$_v" ]; then set +f; return 0; fi + [ "$(_acq_msb_version_ge "$_v" "$MSB_RO_MOUNT_MIN_VERSION")" -eq 0 ] +} + +# The backend-recorded check below was reviewed for the POSIX msb backend. msb on +# Windows uses a different filesystem backend that was not reviewed, so acq does not +# rely on the mount there. +_acq_msb_ro_mount_platform_ok() { + case "$(uname -s 2>/dev/null)" in + MINGW*|MSYS*|CYGWIN*) return 1 ;; + esac + return 0 +} + +# Python 3, required for the structured parse of `msb inspect` JSON (a security +# decision must not rest on grep/sed heuristics). Absent => the check fails closed. +_acq_msb_ro_python() { + command -v python3 2>/dev/null +} + +# Reads `msb inspect --format json` on stdin; argv: GUEST_DIR HOST_DIR... Exit 0 and +# prints "ok" only if exactly one mount sits at GUEST_DIR, is type Bind, has +# options.readonly == true, its host path is (after realpath) one of HOST_DIR..., and +# no OTHER mount at, above or below GUEST_DIR is writable. Exit 3 (retriable) if +# .active_config is null; exit 1 with a reason for anything else, including an +# unexpected shape. Never prints the JSON. +IFS= read -r -d '' _ACQ_MSB_RO_INSPECT_PY <<'PYEOF' || true +import json, os, posixpath, sys + +def out(code, msg): + print(msg) + sys.exit(code) + +guest = sys.argv[1] +expected = [e for e in sys.argv[2:] if e] +try: + data = json.load(sys.stdin) +except Exception: + out(1, "msb inspect output is not valid JSON") +if not isinstance(data, dict): + out(1, "msb inspect output has an unexpected shape (not an object)") +ac = data.get("active_config") +if ac is None: + out(3, "msb reports no active config for the sandbox yet (active_config is null)") +if not isinstance(ac, dict): + out(1, "msb inspect active_config has an unexpected shape") +mounts = ac.get("mounts") +if not isinstance(mounts, list): + out(1, "msb inspect active_config has no mounts list") + +def norm(p): + if not isinstance(p, str) or not p.startswith("/"): + return None + return posixpath.normpath("/" + p.lstrip("/")) + +target = norm(guest) +entries = [] +for m in mounts: + if not isinstance(m, dict): + out(1, "msb inspect lists a mount with an unexpected shape") + if m.get("guest") is None: + continue + if norm(m.get("guest")) is None: + out(1, "msb inspect lists a mount with an unexpected guest path") + entries.append((norm(m.get("guest")), m)) +same = [m for g, m in entries if g == target] +if not same: + out(1, "msb lists no mount at %s" % guest) +if len(same) > 1: + out(1, "msb lists more than one mount at %s" % guest) +m = same[0] +if m.get("type") != "Bind": + out(1, "the mount at %s is not a Bind mount" % guest) +opts = m.get("options") +if not isinstance(opts, dict) or opts.get("readonly") is not True: + out(1, "msb does not record the mount at %s as read-only" % guest) +host = m.get("host") +if not isinstance(host, str) or not host: + out(1, "the mount at %s has no host path" % guest) +if os.path.realpath(host) not in [os.path.realpath(e) for e in expected]: + out(1, "the mount at %s is not backed by this sandbox's acq host config directory" % guest) +for g, o in entries: + if o is m: + continue + if g == "/" or target.startswith(g + "/") or g.startswith(target + "/"): + oo = o.get("options") + if not isinstance(oo, dict) or oo.get("readonly") is not True: + out(1, "another mount (%s) that is not read-only overlaps %s" % (g, guest)) +out(0, "ok") +PYEOF + +# Ask msb itself, from the host, what it recorded for NAME's host-config mount. This is +# what the backend was told to enforce for THIS sandbox; a guest cannot alter it. +# Returns 0 if the recorded mount is the read-only Bind of acq's host config dir; else +# non-zero with a human reason in _ACQ_MSB_RO_MOUNT_REASON. A null active_config right +# after boot is retried a few times (ACQ_MSB_RO_INSPECT_RETRIES, default 3, one second +# apart); everything else fails closed at once. +_ACQ_MSB_RO_MOUNT_REASON="" +_acq_msb_ro_mount_recorded_readonly() { + local name="$1" py hcfg hcfg_canon hcfg_host json res rc tries max + _ACQ_MSB_RO_MOUNT_REASON="" + if ! _acq_msb_ro_mount_platform_ok; then + _ACQ_MSB_RO_MOUNT_REASON="this platform's msb filesystem backend was not reviewed for read-only enforcement" + return 1 + fi + py=$(_acq_msb_ro_python) || py="" + if [ -z "$py" ]; then + _ACQ_MSB_RO_MOUNT_REASON="python3 is required to read msb's mount record and was not found" + return 1 + fi + hcfg=$(acq_host_config_dir msb "$name") || hcfg="" + if [ -z "$hcfg" ]; then + _ACQ_MSB_RO_MOUNT_REASON="acq has no host config directory for this sandbox" + return 1 + fi + hcfg_canon="$hcfg" + if command -v _acq_provider_input_canonical_path >/dev/null 2>&1; then + hcfg_canon=$(_acq_provider_input_canonical_path "$hcfg" 2>/dev/null) || hcfg_canon="$hcfg" + fi + hcfg_host="$hcfg_canon" + if command -v host_path >/dev/null 2>&1; then + hcfg_host=$(host_path "$hcfg_canon" 2>/dev/null) || hcfg_host="$hcfg_canon" + fi + max="${ACQ_MSB_RO_INSPECT_RETRIES:-3}" + case "$max" in ""|*[!0-9]*) max=3 ;; esac + [ "$max" -ge 1 ] || max=1 + tries=0 + while :; do + tries=$((tries + 1)) + if ! json=$(_acq_msb_cli inspect "$name" --format json 2>/dev/null /dev/null) || rc=$? + case "$rc" in + 0) [ "$res" = "ok" ] && return 0 + _ACQ_MSB_RO_MOUNT_REASON="the mount record check returned an unexpected result"; return 1 ;; + 3) if [ "$tries" -lt "$max" ]; then sleep "${ACQ_MSB_RO_INSPECT_RETRY_SLEEP:-1}"; continue; fi + _ACQ_MSB_RO_MOUNT_REASON="$res"; return 1 ;; + *) _ACQ_MSB_RO_MOUNT_REASON="${res:-the mount record check failed}"; return 1 ;; + esac done - set +f - return 1 } # Whether sandbox NAME's trusted :ro host-config mount may be relied on for # readonly kit code. Return codes: -# 0 recorded at create, the msb version is qualified, and the guest probe did -# not contradict the mount +# 0 all hold: a host ro-host-config record; msb at or above the version floor; +# msb's own `inspect` record shows the host-config mount as the one read-only +# Bind of acq's host config directory; and the guest probe did not contradict it # 1 no host record (legacy sandbox, or a create that never completed): the caller # takes the compatibility path and copies the file into the guest, with its warning -# 2 recorded and qualified, but the probe finds the mount WRITABLE by the guest -# 3 recorded and qualified, but the probe does not confirm the mount (absent, +# 2 all of the above but the guest probe finds the mount WRITABLE +# 3 all of the above but the guest probe does not confirm the mount (absent, # unreadable, or the probe could not run) -# 4 recorded, but the installed msb version is not on the qualified list -# Authority: the only thing that can establish that a `:ro` mount is read-only is -# the runtime, and acq establishes it per msb VERSION, from the host, by running the -# canary in scripts/qualify-msb-ro-mount (a throwaway sandbox attempts the strongest -# writes it can, and the REAL host directory is inspected afterwards). A version is -# trusted only if it is on MSB_RO_MOUNT_QUALIFIED_VERSIONS. The create-time record -# (key ro-host-config) proves only that acq asked for the mount; it is not evidence -# of enforcement. The guest probe below is a diagnostic that can only make acq -# stricter: a guest with root controls `test`/`sh` and can answer a probe however it -# likes, so a zero exit proves nothing, while a "writable" answer is always taken at -# face value (fail closed). +# 4 recorded, but the msb version is below the floor, a prerelease, or unparsable +# 5 recorded, but msb's own record does not show the read-only host-config mount +# (reason in _ACQ_MSB_RO_MOUNT_REASON) +# Authority: enforcement of `:ro` is msb's, on the host: its virtiofs server denies +# mutating operations on a read-only mount whatever the guest does (a guest root can +# even relabel its own mount flags). What acq checks is that msb RECORDED this +# sandbox's mount as read-only, read from the host with `msb inspect`, which a guest +# cannot forge. That confirms the setting msb was asked to enforce; it is not +# independent proof of enforcement, so a future upstream regression would not be +# caught here (scripts/qualify-msb-ro-mount is the optional re-check). The create-time +# record (key ro-host-config) proves only that acq asked for the mount. The guest +# probe is a diagnostic that can only make acq stricter: a guest with root controls +# `test`/`sh` and can answer a probe however it likes, so a zero exit proves nothing, +# while a "writable" answer is always taken at face value (fail closed). _acq_msb_host_config_mount_available() { local name="$1" _probe_rc=0 [ "$(acq_host_config_read msb "$name" ro-host-config)" = "1" ] || return 1 - _acq_msb_ro_mount_version_qualified || return 4 + _acq_msb_ro_mount_version_ok || return 4 + _acq_msb_ro_mount_recorded_readonly "$name" || return 5 _acq_msb_host_config_mount_probe "$name" || _probe_rc=$? case "$_probe_rc" in 0) return 0 ;; @@ -2684,11 +2830,15 @@ _acq_msb_startup_body_into() { # The create-time script is assembled before the normal apply path stages the # files, so derive the same readonly path rewrite locally. local _ro_from=() _ro_to=() _frec _fpath _fsource _freadonly _fslug _fcrc _ro_ok=0 - _acq_msb_ro_mount_version_qualified && _ro_ok=1 + _acq_msb_ro_mount_version_ok && _acq_msb_ro_mount_platform_ok && _ro_ok=1 while IFS= read -r _frec; do [ -n "$_frec" ] || continue - # Not qualified: leave argv at the declared guest path. The apply step refuses - # the readonly file itself, so nothing is staged on, or run from, the mount. + # Version below the floor (or an unreviewed platform): leave argv at the declared + # guest path. The sandbox does not exist yet, so the per-sandbox msb record cannot + # be read here; the apply step re-checks it and refuses the readonly file when it + # does not show a read-only mount, so nothing is staged on, or run from, the mount. + # The registered startup script is inert at boot and is only run through acq's + # apply/heal path, which performs that check first. [ "$_ro_ok" -eq 1 ] || continue _freadonly=$(printf '%s' "$_frec" | cut -f5) [ "$_freadonly" = "true" ] || continue diff --git a/scripts/qualify-msb-ro-mount b/scripts/qualify-msb-ro-mount index aa3fd790..a07b9300 100644 --- a/scripts/qualify-msb-ro-mount +++ b/scripts/qualify-msb-ro-mount @@ -1,5 +1,12 @@ #!/usr/bin/env bash -# qualify-msb-ro-mount — host-observed canary: does THIS msb enforce a `:ro` volume? +# qualify-msb-ro-mount — OPTIONAL, NON-GATING host-observed canary: does THIS msb +# enforce a `:ro` volume? +# +# acq does NOT depend on this script. Its gate for running trusted code from the +# read-only host-config mount is the per-sandbox record msb keeps (`msb inspect`) plus a +# version floor (ADR-0035). This canary is corroborating evidence: run it when adopting +# a new msb release, or if you suspect an upstream regression in `:ro` enforcement, which +# the gate would not catch. # # acq (ADR-0035 Mechanism 2) runs readonly kit code from a host directory mounted # into the guest with `--volume HOST:GUEST:ro`. A guest-run probe cannot show that the @@ -31,8 +38,7 @@ # directories (ro/ and rw/) are kept there too; on PASS they are removed, because # everything of value has been copied out. Every run ends with one pasteable line: # RESULT msb= image= host= rev= dir= -# On PASS add the printed version to MSB_RO_MOUNT_QUALIFIED_VERSIONS in -# acq.backends/msb.sh and record the output in ADR-0035 in the same change. +# A PASS is corroborating evidence only; record it in ADR-0035 when adopting a version. # # Usage: [EXPECT_SHA=] bash scripts/qualify-msb-ro-mount # EXPECT_SHA abort (BLOCKED) unless this checkout's HEAD starts with it @@ -107,11 +113,11 @@ finish() { # VERDICT DETAIL fi if [ "$verdict" = PASS ] && [ "${VERSION_OK:-0}" -ne 1 ]; then say "" - say "NOTE: '${VERSION}' is not a final X.Y.Z release, so acq will never match it; do NOT add it to the list." + say "NOTE: '${VERSION}' is not a final X.Y.Z release, so acq will not rely on the read-only mount on it." elif [ "$verdict" = PASS ]; then say "" - say "Next: add ${VERSION} to MSB_RO_MOUNT_QUALIFIED_VERSIONS in acq.backends/msb.sh and" - say "record this output (version, revision, date, host OS/arch, image) in" + say "This is corroborating evidence only; acq's gate does not depend on it. If you are adopting" + say "msb ${VERSION}, record this output (version, revision, date, host OS/arch, image) in" say "docs/adr/0035-host-authoritative-sandbox-config.md." fi say "" diff --git a/scripts/test-acq-lib.sh b/scripts/test-acq-lib.sh index 73555e98..bbcea0f9 100644 --- a/scripts/test-acq-lib.sh +++ b/scripts/test-acq-lib.sh @@ -434,6 +434,55 @@ case "$_msb_sub" in echo "error: stub inspect failure" >&2 exit 1 fi + # STUB_MSB_INSPECT_MODE serves the real observed `msb inspect --format json` shape + # for a sandbox created by acq (msb 0.7.7): `.active_config.mounts[]` with + # {type, guest, host, options:{readonly,...}}. Modes: ro-ok (host-config mount is + # the read-only Bind of acq's host config dir), rw (readonly false), missing (no + # host-config mount), dup (two mounts at the guest path), overlap-rw (a writable + # mount above the guest path), wrong-host (read-only Bind of another directory), + # not-bind (type is not Bind), null (active_config null; STUB_MSB_INSPECT_NULL_TIMES=N + # makes only the first N answers null), badjson, noshape (mounts is not a list). + if [ -n "${STUB_MSB_INSPECT_MODE:-}" ]; then + _sb="${2:-}" + _sum=$(printf '%s' "$_sb" | cksum | cut -d' ' -f1) + _safe=$(printf '%s' "$_sb" | tr -c 'A-Za-z0-9._-' '_') + _cfg="${ACQ_PROVENANCE_DIR}/msb/${_safe}.${_sum}.config" + _mode="$STUB_MSB_INSPECT_MODE" + if [ "$_mode" = "null" ] || [ -n "${STUB_MSB_INSPECT_NULL_TIMES:-}" ]; then + _nulls=$(cat "$STUBDIR/.msb_inspect_nulls" 2>/dev/null || echo 0) + if [ "$_mode" = "null" ] || [ "$_nulls" -lt "${STUB_MSB_INSPECT_NULL_TIMES:-0}" ]; then + echo $((_nulls + 1)) >"$STUBDIR/.msb_inspect_nulls" + printf '{"name":"%s","config":{"spec":{"mounts":null}},"active_config":null}\n' "$_sb" + exit 0 + fi + [ "$_mode" = "null" ] && _mode=ro-ok + fi + [ "$_mode" = "badjson" ] && { printf 'this is not json {{\n'; exit 0; } + python3 - "$_mode" "$_sb" "$_cfg" "${ACQ_HOST_CONFIG_GUEST_DIR:-/var/lib/acq/host}" <<'PYSTUB' +import json, sys +mode, name, cfg, guest = sys.argv[1:5] +def mount(g, h, ro, typ="Bind"): + return {"type": typ, "guest": g, "host": h, + "options": {"readonly": ro, "noexec": False, "nosuid": False, "nodev": False}, + "follow_root_symlinks": False, "host_permissions": "private", + "stat_virtualization": "strict", "quota_mib": None} +ws = mount("/tmp/ws", "/tmp/ws", False) +prov = mount("/var/lib/acq/provider-input", "/tmp/prov", True) +hcfg = mount(guest, cfg, True) +mounts = [ws, prov, hcfg] +if mode == "rw": mounts = [ws, prov, mount(guest, cfg, False)] +elif mode == "missing": mounts = [ws, prov] +elif mode == "dup": mounts = [ws, prov, hcfg, mount(guest, cfg, True)] +elif mode == "overlap-rw": mounts = [ws, prov, hcfg, mount("/var/lib/acq", "/tmp/other", False)] +elif mode == "wrong-host": mounts = [ws, prov, mount(guest, "/tmp/some/other/dir", True)] +elif mode == "not-bind": mounts = [ws, prov, mount(guest, cfg, True, typ="Named")] +elif mode == "noshape": mounts = {"unexpected": True} +d = {"name": name, "config": {"spec": {"mounts": None}, "mounts": mounts}, + "active_config": {"mounts": mounts}} +print(json.dumps(d)) +PYSTUB + exit 0 + fi [ -f "$STUBDIR/.msb_inspect_json" ] && cat "$STUBDIR/.msb_inspect_json" ;; list|ls) [ "${STUB_MSB_LIST_FAIL:-0}" = "1" ] && exit 1 diff --git a/test/bats/79-msb-readonly-kit-files.bats b/test/bats/79-msb-readonly-kit-files.bats index 68887215..c289ba41 100644 --- a/test/bats/79-msb-readonly-kit-files.bats +++ b/test/bats/79-msb-readonly-kit-files.bats @@ -17,10 +17,11 @@ # the path is left alone). # # shellcheck shell=bats -# NOTE: these cases exercise the staged-mount path itself, so each marks the stub's -# msb version as qualified (MSB_RO_MOUNT_QUALIFIED_VERSIONS). Whether a version may be -# trusted at all is covered in 84-msb-ro-mount-qualification.bats; before that gate -# existed, a recorded sandbox was unlocked by the guest probe alone. +# NOTE: these cases exercise the staged-mount path itself, so each gives the stub msb a +# floor-satisfying version and an `msb inspect` record showing the host-config mount as +# the read-only Bind of acq's host config dir. Whether the gate trusts that record is +# covered in 84-msb-ro-mount-qualification.bats; before the gate existed, a recorded +# sandbox was unlocked by the guest probe alone. setup() { acq_setup_stubs; } teardown() { acq_teardown_stubs; } @@ -95,7 +96,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" - MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" + export STUB_MSB_VERSION="${STUB_MSB_VERSION:-0.7.7}" STUB_MSB_INSPECT_MODE="${STUB_MSB_INSPECT_MODE:-ro-ok}" acq_test_stub_provider_input_boundary _acq_msb_fetch_kit() { printf "%s\n" "'"$k"'"; } acq_backend_provision rocodebox shell /tmp >/dev/null 2>&1 @@ -128,7 +129,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" - MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" + export STUB_MSB_VERSION="${STUB_MSB_VERSION:-0.7.7}" STUB_MSB_INSPECT_MODE="${STUB_MSB_INSPECT_MODE:-ro-ok}" acq_test_stub_provider_input_boundary _acq_msb_fetch_kit() { printf "%s\n" "'"$k"'"; } acq_backend_provision rocode2box shell /tmp >/dev/null 2>&1 @@ -153,7 +154,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" - MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" + export STUB_MSB_VERSION="${STUB_MSB_VERSION:-0.7.7}" STUB_MSB_INSPECT_MODE="${STUB_MSB_INSPECT_MODE:-ro-ok}" acq_test_stub_provider_input_boundary _acq_msb_apply_kit_dir legacyrobox "'"$k"'" 2>&1 ' @@ -176,7 +177,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" - MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" + export STUB_MSB_VERSION="${STUB_MSB_VERSION:-0.7.7}" STUB_MSB_INSPECT_MODE="${STUB_MSB_INSPECT_MODE:-ro-ok}" _acq_msb_apply_kit_dir writablerobox "'"$k"'" 2>&1 ' assert_success @@ -195,7 +196,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" - MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" + export STUB_MSB_VERSION="${STUB_MSB_VERSION:-0.7.7}" STUB_MSB_INSPECT_MODE="${STUB_MSB_INSPECT_MODE:-ro-ok}" acq_test_stub_provider_input_boundary _acq_msb_fetch_kit() { printf "%s\n" "'"$k"'"; } acq_backend_provision romountbox shell /tmp >/dev/null 2>&1 diff --git a/test/bats/83-msb-ro-host-config-record.bats b/test/bats/83-msb-ro-host-config-record.bats index e880b403..76eb95fa 100644 --- a/test/bats/83-msb-ro-host-config-record.bats +++ b/test/bats/83-msb-ro-host-config-record.bats @@ -12,10 +12,11 @@ # The stubs below answer the guest probe CONTRARY to the host record. # # shellcheck shell=bats -# NOTE: these cases exercise the staged-mount path itself, so each marks the stub's -# msb version as qualified (MSB_RO_MOUNT_QUALIFIED_VERSIONS). Whether a version may be -# trusted at all is covered in 84-msb-ro-mount-qualification.bats; before that gate -# existed, a recorded sandbox was unlocked by the guest probe alone. +# NOTE: these cases exercise the staged-mount path itself, so each gives the stub msb a +# floor-satisfying version and an `msb inspect` record showing the host-config mount as +# the read-only Bind of acq's host config dir. Whether the gate trusts that record is +# covered in 84-msb-ro-mount-qualification.bats; before the gate existed, a recorded +# sandbox was unlocked by the guest probe alone. setup() { acq_setup_stubs; } teardown() { acq_teardown_stubs; } @@ -56,7 +57,7 @@ _apply() { # NAME KITDIR RECORD [ENV...] . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" - MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" + export STUB_MSB_VERSION="${STUB_MSB_VERSION:-0.7.7}" STUB_MSB_INSPECT_MODE="${STUB_MSB_INSPECT_MODE:-ro-ok}" acq_test_stub_provider_input_boundary [ "'"$record"'" = 1 ] && acq_host_config_write msb "'"$name"'" ro-host-config 1 _acq_msb_apply_kit_dir "'"$name"'" "'"$kit"'" 2>&1 @@ -139,7 +140,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" - MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" + export STUB_MSB_VERSION="${STUB_MSB_VERSION:-0.7.7}" STUB_MSB_INSPECT_MODE="${STUB_MSB_INSPECT_MODE:-ro-ok}" acq_test_stub_provider_input_boundary acq_host_config_write msb badval ro-host-config 0 _acq_msb_apply_kit_dir badval "'"$k"'" 2>&1 @@ -155,7 +156,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" - MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" + export STUB_MSB_VERSION="${STUB_MSB_VERSION:-0.7.7}" STUB_MSB_INSPECT_MODE="${STUB_MSB_INSPECT_MODE:-ro-ok}" acq_test_stub_provider_input_boundary _acq_msb_fetch_kit() { printf "%s\n" "'"$k"'"; } acq_backend_provision recprov shell /tmp >/dev/null 2>&1 @@ -172,7 +173,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" - MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" + export STUB_MSB_VERSION="${STUB_MSB_VERSION:-0.7.7}" STUB_MSB_INSPECT_MODE="${STUB_MSB_INSPECT_MODE:-ro-ok}" acq_test_stub_provider_input_boundary _acq_msb_fetch_kit() { printf "%s\n" "'"$k"'"; } acq_backend_provision recfail shell /tmp >/dev/null 2>&1 @@ -206,7 +207,7 @@ _prov_msb() { # NAME KITDIR [ENV...] (runs provision with a seeded stale record . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" - MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" + export STUB_MSB_VERSION="${STUB_MSB_VERSION:-0.7.7}" STUB_MSB_INSPECT_MODE="${STUB_MSB_INSPECT_MODE:-ro-ok}" acq_test_stub_provider_input_boundary acq_host_config_write msb "'"$name"'" ro-host-config 1 acq_host_config_write msb "'"$name"'" install-stale 1 @@ -254,7 +255,7 @@ _apply_stage_fail() { # NAME KITDIR RECORD . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" - MSB_RO_MOUNT_QUALIFIED_VERSIONS="${STUB_MSB_VERSION:-0.6.9}" + export STUB_MSB_VERSION="${STUB_MSB_VERSION:-0.7.7}" STUB_MSB_INSPECT_MODE="${STUB_MSB_INSPECT_MODE:-ro-ok}" acq_test_stub_provider_input_boundary _acq_msb_stage_readonly_file() { return 1; } [ "'"$record"'" = 1 ] && acq_host_config_write msb "'"$name"'" ro-host-config 1 diff --git a/test/bats/84-msb-ro-mount-qualification.bats b/test/bats/84-msb-ro-mount-qualification.bats index 236a9bda..cc1ef53c 100644 --- a/test/bats/84-msb-ro-mount-qualification.bats +++ b/test/bats/84-msb-ro-mount-qualification.bats @@ -1,10 +1,17 @@ #!/usr/bin/env bats # # 84-msb-ro-mount-qualification.bats — ADR-0035 Mechanism 2 authority. Trusted -# execution from the read-only host-config mount is permitted only on an msb version -# that appears on a checked-in list of versions a HOST-observed canary has qualified. -# A guest-run probe can never unlock it (a guest with root controls `sh`/`test`). -# Also covers scripts/qualify-msb-ro-mount against a stubbed msb. +# execution from the read-only host-config mount is permitted only when ALL hold: +# a host ro-host-config record exists; the msb version is a final release at or above +# a floor; and msb's own `inspect` record (read from the host, not the guest) shows +# the host-config mount as the one read-only Bind of acq's host config directory with +# no overlapping writable mount. A guest-run probe can never unlock it (a guest with +# root controls `sh`/`test`); it can only add refusals. +# +# Deliberate replacement: the earlier per-version allowlist +# (MSB_RO_MOUNT_QUALIFIED_VERSIONS) and the cases that pinned it were replaced by the +# cases below. Also covers scripts/qualify-msb-ro-mount (now an optional, +# non-gating verification tool) against a stubbed msb. # # shellcheck shell=bats @@ -36,118 +43,257 @@ commands: SPEC } -# Apply KITDIR to NAME with a host ro-host-config record, under an msb version and -# qualified list. The probe stub is left in its default state: it forges "all good". -_apply_ver() { # NAME KITDIR VERSION QUALIFIED_LIST - local name="$1" kit="$2" ver="$3" qual="$4" +# Apply KITDIR to NAME with a host ro-host-config record. Environment (KEY=VAL ...) +# follows the positional args. Defaults: msb 0.7.7 and an inspect record showing the +# host-config mount as the read-only Bind. The guest probe stub is left in its default +# state, which forges "all good" (present and not writable). +_gate() { # NAME KITDIR [ENV...] + local name="$1" kit="$2"; shift 2 : > "$CALLS" run bash -c ' - export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-'"$name"'" STUB_MSB_VERSION="'"$ver"'" + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-'"$name"'" + export STUB_MSB_VERSION=0.7.7 STUB_MSB_INSPECT_MODE=ro-ok ACQ_MSB_RO_INSPECT_RETRY_SLEEP=0 + for kv in '"${*:-:}"'; do export "$kv" 2>/dev/null || true; done . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" acq_test_stub_provider_input_boundary - MSB_RO_MOUNT_QUALIFIED_VERSIONS="'"$qual"'" acq_host_config_write msb "'"$name"'" ro-host-config 1 _acq_msb_apply_kit_dir "'"$name"'" "'"$kit"'" 2>&1 ' } -# Deliberate change: this case used to assert that the list ships EMPTY. The 0.7.7 -# canary PASSED (ADR-0035, "Qualification evidence"), so the shipped list is exactly -# that one version. Any other entry must come with its own evidence in the same change. -@test "qualified list: the shipped list is exactly msb 0.7.7 (ADR-0035 evidence)" { - run bash -c '. "'"$REPO_ROOT"'/acq.backends/msb.sh"; printf "[%s]\n" "$MSB_RO_MOUNT_QUALIFIED_VERSIONS"' - assert_success - assert_output '[0.7.7]' -} - -@test "qualified list: a forged zero-exit probe does not unlock staging on an unlisted version" { - local k="$STUBDIR/k1"; _ro_kit "$k" - _apply_ver unlisted "$k" 0.7.7 "" +_assert_refused() { # NAME [MESSAGE_FRAGMENT] assert_failure assert_output --partial "'/home/agent/cfg/merge.mjs'" - assert_output --partial 'msb 0.7.7 is not on acq' - assert_output --partial 'scripts/qualify-msb-ro-mount' assert_output --partial 'No guest-copy fallback is taken' + [ -z "${2:-}" ] || assert_output --partial "$2" local log; log=$(cat "$CALLS") refute_regex "$log" 'node ' - refute_regex "$log" 'msb copy .*unlisted:/home/agent/cfg/merge\.mjs' - run bash -c 'ls "'"$ACQ_PROVENANCE_DIR"'"/msb/unlisted.*.config/kit-files 2>&1' + refute_regex "$log" "msb copy .*$1:/home/agent/cfg/merge\\.mjs" + run bash -c 'ls "'"$ACQ_PROVENANCE_DIR"'"/msb/'"$1"'.*.config/kit-files 2>&1' assert_failure } -@test "qualified list: a version that is not the listed one is refused" { - local k="$STUBDIR/k2"; _ro_kit "$k" - _apply_ver otherver "$k" 0.7.8 "0.7.7" - assert_failure - assert_output --partial 'msb 0.7.8 is not on acq' +@test "gate: the version floor is 0.7.7 and no per-version allowlist remains" { + run bash -c '. "'"$REPO_ROOT"'/acq.backends/msb.sh"; printf "[%s]\n" "$MSB_RO_MOUNT_MIN_VERSION"; printf "[%s]\n" "${MSB_RO_MOUNT_QUALIFIED_VERSIONS-unset}"' + assert_success + assert_line '[0.7.7]' + assert_line '[unset]' } -@test "qualified list: a listed version stages and runs from the mount" { - local k="$STUBDIR/k3"; _ro_kit "$k" - _apply_ver listed "$k" 0.7.7 "0.7.7" +@test "gate: record + msb recorded ro Bind of the host config dir + floor version => unlocks staging" { + local k="$STUBDIR/g1"; _ro_kit "$k" + _gate unlocks "$k" assert_success local log; log=$(cat "$CALLS") + assert_regex "$log" 'msb inspect unlocks --format json' assert_regex "$log" 'node /var/lib/acq/host/kit-files/' - refute_regex "$log" 'msb copy .*listed:/home/agent/cfg/merge\.mjs' + refute_regex "$log" 'msb copy .*unlocks:/home/agent/cfg/merge\.mjs' +} + +@test "gate: a version above the floor unlocks too" { + local k="$STUBDIR/g2"; _ro_kit "$k" + _gate newer "$k" STUB_MSB_VERSION=0.7.9 + assert_success + assert_regex "$(cat "$CALLS")" 'node /var/lib/acq/host/kit-files/' +} + +@test "gate: a forged all-clear guest probe does not help when msb records the mount as NOT read-only" { + local k="$STUBDIR/g3"; _ro_kit "$k" + _gate rwrecorded "$k" STUB_MSB_INSPECT_MODE=rw + _assert_refused rwrecorded 'does not record the mount' +} + +@test "gate: no host-config mount in msb's record => refused" { + local k="$STUBDIR/g4"; _ro_kit "$k" + _gate nomount "$k" STUB_MSB_INSPECT_MODE=missing + _assert_refused nomount 'lists no mount at /var/lib/acq/host' +} + +@test "gate: two mounts at the host-config guest path => refused" { + local k="$STUBDIR/g5"; _ro_kit "$k" + _gate dupmount "$k" STUB_MSB_INSPECT_MODE=dup + _assert_refused dupmount 'more than one mount' +} + +@test "gate: a writable mount overlapping the host-config guest path => refused" { + local k="$STUBDIR/g6"; _ro_kit "$k" + _gate overlap "$k" STUB_MSB_INSPECT_MODE=overlap-rw + _assert_refused overlap 'overlaps' +} + +@test "gate: a read-only mount backed by some other host directory => refused" { + local k="$STUBDIR/g7"; _ro_kit "$k" + _gate wronghost "$k" STUB_MSB_INSPECT_MODE=wrong-host + _assert_refused wronghost "not backed by this sandbox's acq host config directory" +} + +@test "gate: a mount that is not a Bind mount => refused" { + local k="$STUBDIR/g8"; _ro_kit "$k" + _gate notbind "$k" STUB_MSB_INSPECT_MODE=not-bind + _assert_refused notbind 'not a Bind mount' } -@test "qualified list: a listed version is still refused when the guest says the mount is writable" { - local k="$STUBDIR/k4"; _ro_kit "$k" +@test "gate: active_config null (after the short retry) => refused, naming the cause" { + local k="$STUBDIR/g9"; _ro_kit "$k" + _gate nullcfg "$k" STUB_MSB_INSPECT_MODE=null + _assert_refused nullcfg 'active_config is null' +} + +@test "gate: active_config null at first, populated on a retry => unlocks" { + local k="$STUBDIR/g10"; _ro_kit "$k" + _gate nullthenok "$k" STUB_MSB_INSPECT_NULL_TIMES=1 + assert_success + assert_regex "$(cat "$CALLS")" 'node /var/lib/acq/host/kit-files/' +} + +@test "gate: msb inspect failing => refused" { + local k="$STUBDIR/g11"; _ro_kit "$k" + rm -f "$STUBDIR/.msb_inspect_fails" + _gate inspectfail "$k" STUB_MSB_INSPECT_FAIL_TIMES=99 + _assert_refused inspectfail "'msb inspect' failed" +} + +@test "gate: unparsable inspect output => refused" { + local k="$STUBDIR/g12"; _ro_kit "$k" + _gate badjson "$k" STUB_MSB_INSPECT_MODE=badjson + _assert_refused badjson 'not valid JSON' +} + +@test "gate: unexpected mounts shape => refused" { + local k="$STUBDIR/g13"; _ro_kit "$k" + _gate noshape "$k" STUB_MSB_INSPECT_MODE=noshape + _assert_refused noshape 'no mounts list' +} + +@test "gate: without python3 the structured check cannot run => refused" { + local k="$STUBDIR/g14"; _ro_kit "$k" : > "$CALLS" run bash -c ' - export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-lw" STUB_MSB_VERSION=0.7.7 STUB_HOST_CONFIG_WRITABLE=1 + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-nopy" STUB_MSB_VERSION=0.7.7 STUB_MSB_INSPECT_MODE=ro-ok . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" acq_test_stub_provider_input_boundary - MSB_RO_MOUNT_QUALIFIED_VERSIONS="0.7.7" - acq_host_config_write msb listedwritable ro-host-config 1 - _acq_msb_apply_kit_dir listedwritable "'"$k"'" 2>&1 + _acq_msb_ro_python() { return 1; } + acq_host_config_write msb nopy ro-host-config 1 + _acq_msb_apply_kit_dir nopy "'"$k"'" 2>&1 ' + _assert_refused nopy 'python3 is required' +} + +@test "gate: an unreviewed platform (Windows msb backend) => refused" { + local k="$STUBDIR/g15"; _ro_kit "$k" + : > "$CALLS" + run bash -c ' + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-win" STUB_MSB_VERSION=0.7.7 STUB_MSB_INSPECT_MODE=ro-ok + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_test_stub_provider_input_boundary + uname() { echo MINGW64_NT-10.0; } + acq_host_config_write msb win ro-host-config 1 + _acq_msb_apply_kit_dir win "'"$k"'" 2>&1 + ' + _assert_refused win 'was not reviewed' +} + +@test "gate: a version below the floor is refused" { + local k="$STUBDIR/g16"; _ro_kit "$k" + _gate old "$k" STUB_MSB_VERSION=0.7.6 + _assert_refused old 'below acq'"'"'s minimum' + _gate older "$k" STUB_MSB_VERSION=0.6.9 assert_failure - assert_output --partial 'WRITABLE' - refute_regex "$(cat "$CALLS")" 'node ' } -@test "qualified list: an unparsable or prerelease msb version fails closed even if listed" { - local k="$STUBDIR/k5"; _ro_kit "$k" - _apply_ver prerel "$k" 0.7.7-rc1 "0.7.7 0.7.7-rc1" +@test "gate: a prerelease or unparsable msb version is refused" { + local k="$STUBDIR/g17"; _ro_kit "$k" + _gate prerel "$k" STUB_MSB_VERSION=0.7.7-rc1 assert_failure - assert_output --partial 'not on acq' + assert_output --partial 'below acq' refute_regex "$(cat "$CALLS")" 'node ' - _apply_ver garbled "$k" "banana" "banana" + _gate garbled "$k" STUB_MSB_VERSION=banana assert_failure refute_regex "$(cat "$CALLS")" 'node ' } -@test "qualified list: no host record keeps the guest-copy compatibility path regardless of version" { - local k="$STUBDIR/k6"; _ro_kit "$k" +@test "gate: msb recording the mount read-only still fails closed when the guest probe says WRITABLE" { + local k="$STUBDIR/g18"; _ro_kit "$k" + _gate probewrit "$k" STUB_HOST_CONFIG_WRITABLE=1 + assert_failure + assert_output --partial 'WRITABLE' + refute_regex "$(cat "$CALLS")" 'node ' +} + +@test "gate: a kit with no readonly files never consults msb inspect or the probe" { + local k="$STUBDIR/g0"; mkdir -p "$k/files" + printf '{}\n' > "$k/files/data.jsonc" + cat >"$k/spec.yaml" <<'SPEC' +schemaVersion: "hybrid/v1" +kind: mixin +name: plain-kit +displayName: Plain Kit +description: data only +files: + - path: /home/agent/cfg/data.jsonc + mode: "0644" + source: files/data.jsonc +SPEC + _gate plainkit "$k" STUB_MSB_INSPECT_MODE=rw STUB_HOST_CONFIG_WRITABLE=1 + assert_success + refute_regex "$(cat "$CALLS")" 'msb inspect plainkit' + refute_regex "$(cat "$CALLS")" 'acq-ro-probe' + assert_regex "$(cat "$CALLS")" 'msb copy .*plainkit:/home/agent/cfg/data\.jsonc' +} + +@test "gate: the inspect check normalizes guest paths (a writable mount at //var overlaps)" { + command -v python3 >/dev/null || skip "python3 not available" + run bash -c ' + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + mk() { python3 - "$1" <&1); printf "%s => %s\n" "$g" "$r" + done + ' + assert_success + refute_output --partial '=> ok' +} + +@test "gate: no host record keeps the guest-copy compatibility path, without consulting msb inspect" { + local k="$STUBDIR/g19"; _ro_kit "$k" : > "$CALLS" run bash -c ' - export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-nr" STUB_MSB_VERSION=0.7.7 + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-nr" STUB_MSB_VERSION=0.7.7 STUB_MSB_INSPECT_MODE=ro-ok . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" acq_test_stub_provider_input_boundary - _acq_msb_apply_kit_dir norecordver "'"$k"'" 2>&1 + _acq_msb_apply_kit_dir norecordgate "'"$k"'" 2>&1 ' assert_success - assert_regex "$(cat "$CALLS")" 'msb copy .*norecordver:/home/agent/cfg/merge\.mjs' + assert_regex "$(cat "$CALLS")" 'msb copy .*norecordgate:/home/agent/cfg/merge\.mjs' + refute_regex "$(cat "$CALLS")" '/var/lib/acq/host/kit-files' + refute_regex "$(cat "$CALLS")" 'msb inspect norecordgate' } -@test "qualified list: the create-time startup script keeps the guest path when the version is unlisted" { - local k="$STUBDIR/k7"; _ro_kit "$k" +@test "gate: the create-time startup script keeps the guest path below the version floor" { + local k="$STUBDIR/g20"; _ro_kit "$k" : > "$CALLS" run bash -c ' - export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-ss" STUB_MSB_VERSION=0.7.7 + export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-ss" STUB_MSB_VERSION=0.7.6 STUB_MSB_INSPECT_MODE=ro-ok export ACQ_MSB_KEEP_STARTUP_STAGE=1 ACQ_MSB_STARTUP_STAGE_DIR="'"$STUBDIR"'/ss-stage" . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" acq_test_stub_provider_input_boundary - MSB_RO_MOUNT_QUALIFIED_VERSIONS="" _acq_msb_fetch_kit() { printf "%s\n" "'"$k"'"; } acq_backend_provision stagebox shell /tmp >/dev/null 2>&1 ' @@ -237,7 +383,7 @@ _run_canary() { # [ENV...] assert_success assert_output --partial 'RESULT: PASS' assert_output --partial 'msb version : 0.7.7' - assert_output --partial 'MSB_RO_MOUNT_QUALIFIED_VERSIONS' + assert_output --partial 'corroborating evidence' [ -f "$STUBDIR/.qual_removed" ] } @@ -247,7 +393,6 @@ _run_canary() { # [ENV...] assert_failure 1 assert_output --partial 'RESULT: FAIL' assert_output --partial 'host directory behind the :ro volume was modified' - refute_output --partial 'Next: add' [ -f "$STUBDIR/.qual_removed" ] } @@ -305,13 +450,12 @@ _run_canary() { # [ENV...] [ -f "$STUBDIR/.qual_removed" ] } -@test "canary: a PASS on a non-final msb version tells the operator not to add it" { +@test "canary: a PASS on a non-final msb version says acq will not rely on the mount there" { _plant_canary_msb _run_canary STUB_RO_ENFORCED=1 STUB_MSB_VERSION=0.7.7-rc1 assert_success assert_output --partial 'RESULT: PASS' - assert_output --partial 'do NOT add it to the list' - refute_output --partial 'Next: add' + assert_output --partial 'will not rely on the read-only mount' } # --- remount classification, verdict order and evidence layout ------------------- @@ -386,88 +530,3 @@ _run_canary() { # [ENV...] [ "$status" -eq 0 ] || skip "not a git checkout, or tmp/ not ignored here" } -# --- the shipped list (no override), deliberate-evidence behavior ------------------- - -_apply_shipped() { # NAME KITDIR VERSION [ENV...] - local name="$1" kit="$2" ver="$3"; shift 3 - : > "$CALLS" - run bash -c ' - export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-'"$name"'" STUB_MSB_VERSION="'"$ver"'" - for kv in '"${*:-:}"'; do export "$kv" 2>/dev/null || true; done - . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" - . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" - . "'"$REPO_ROOT"'/acq.backends/msb.sh" - acq_test_stub_provider_input_boundary - acq_host_config_write msb "'"$name"'" ro-host-config 1 - _acq_msb_apply_kit_dir "'"$name"'" "'"$kit"'" 2>&1 - ' -} - -@test "shipped list: msb 0.7.7 with an ro-host-config record unlocks trusted staging" { - local k="$STUBDIR/s1"; _ro_kit "$k" - _apply_shipped shipped077 "$k" 0.7.7 - assert_success - local log; log=$(cat "$CALLS") - assert_regex "$log" 'node /var/lib/acq/host/kit-files/' - refute_regex "$log" 'msb copy .*shipped077:/home/agent/cfg/merge\.mjs' -} - -@test "shipped list: msb 0.7.7 without a record still takes the guest-copy path (the record is still required)" { - local k="$STUBDIR/s2"; _ro_kit "$k" - : > "$CALLS" - run bash -c ' - export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/sec-s2" STUB_MSB_VERSION=0.7.7 - . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" - . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" - . "'"$REPO_ROOT"'/acq.backends/msb.sh" - acq_test_stub_provider_input_boundary - _acq_msb_apply_kit_dir norecord077 "'"$k"'" 2>&1 - ' - assert_success - refute_regex "$(cat "$CALLS")" '/var/lib/acq/host/kit-files' - assert_regex "$(cat "$CALLS")" 'msb copy .*norecord077:/home/agent/cfg/merge\.mjs' -} - -@test "shipped list: another msb version is refused even with a record and an all-clear probe" { - local k="$STUBDIR/s3"; _ro_kit "$k" - _apply_shipped shipped078 "$k" 0.7.8 - assert_failure - assert_output --partial 'msb 0.7.8 is not on acq' - refute_regex "$(cat "$CALLS")" 'node ' - _apply_shipped shipped069 "$k" 0.6.9 - assert_failure - assert_output --partial 'msb 0.6.9 is not on acq' -} - -@test "shipped list: a prerelease of the qualified version is refused" { - local k="$STUBDIR/s4"; _ro_kit "$k" - _apply_shipped shippedrc "$k" 0.7.7-rc1 - assert_failure - assert_output --partial 'not on acq' - refute_regex "$(cat "$CALLS")" 'node ' -} - -@test "shipped list: an unparsable msb version is refused" { - local k="$STUBDIR/s5"; _ro_kit "$k" - _apply_shipped shippedbad "$k" banana - assert_failure - refute_regex "$(cat "$CALLS")" 'node ' -} - -@test "shipped list: a forged all-clear guest probe does not unlock an unlisted version" { - local k="$STUBDIR/s6"; _ro_kit "$k" - # The stub guest answers every probe as 'present and not writable' by default. - _apply_shipped shippedforge "$k" 0.7.9 - assert_failure - assert_output --partial 'msb 0.7.9 is not on acq' - refute_regex "$(cat "$CALLS")" 'node ' - refute_regex "$(cat "$CALLS")" 'msb copy .*shippedforge:/home/agent/cfg/merge\.mjs' -} - -@test "shipped list: msb 0.7.7 still fails closed when the guest says the mount is writable" { - local k="$STUBDIR/s7"; _ro_kit "$k" - _apply_shipped shippedwrit "$k" 0.7.7 STUB_HOST_CONFIG_WRITABLE=1 - assert_failure - assert_output --partial 'WRITABLE' - refute_regex "$(cat "$CALLS")" 'node ' -} From 84827f17e4e28829049bbe9f012a5fe5253bb516 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 28 Oct 2026 17:49:37 +0000 Subject: [PATCH 57/63] docs: describe the msb-recorded read-only mount gate and what it does not prove Co-authored-by: OpenCode Agent --- docs/KNOWN_FAILURE_MODES.md | 72 +++++---- .../0035-host-authoritative-sandbox-config.md | 147 +++++++++--------- docs/adr/0037-sbx-trusted-startup-parity.md | 14 +- 3 files changed, 124 insertions(+), 109 deletions(-) diff --git a/docs/KNOWN_FAILURE_MODES.md b/docs/KNOWN_FAILURE_MODES.md index 022944b4..d01cbe87 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -3089,7 +3089,7 @@ host-side API, or a read-only host channel into the sandbox. --- -## 48. msb: Readonly Kit Files Are Refused on an Unqualified msb Version +## 48. msb: Readonly Kit Files Are Refused When msb's Own Record Does Not Show a Read-Only Mount **Status:** Fail closed by design. Applies to a kit with `files[]` entries marked `readonly: true` on an msb sandbox created with the read-only host-config mount. @@ -3097,50 +3097,58 @@ host-side API, or a read-only host channel into the sandbox. ### Symptoms ```text -acq(msb): error: cannot place read-only kit file '' for '': msb is not on acq's list of versions where a read-only mount has been shown to be enforced. -acq(msb): Run scripts/qualify-msb-ro-mount on this host; ... +acq(msb): error: cannot place read-only kit file '' for '': . +acq(msb): acq will not run trusted code from a mount msb does not report as read-only. ... acq(msb): No guest-copy fallback is taken because the sandbox was created with a read-only host config; recreate the sandbox. ``` -The kit does not fully apply; for built-in kits at provision this is a warning, and -a heal reports it on each re-attach. +`` names the cause, one of: msb is below the minimum version (or its version +is a prerelease or unreadable); `msb inspect` failed, returned unparsable output, an +unexpected shape, or a null `active_config` that stayed null; `python3` is missing; +msb lists no mount, or more than one, at `/var/lib/acq/host`; the mount is not a Bind +mount, is not recorded read-only, or is backed by a directory other than this +sandbox's acq host config directory; a writable mount overlaps it; or this platform +(Windows) was not reviewed. The guest probe can add two more: the mount "looks +WRITABLE" or "is not a confirmed read-only mount". The kit does not fully apply; for +built-in kits at provision this is a warning, and a heal reports it on each +re-attach. No shipped built-in kit marks a file `readonly: true`, so default +sandboxes are not affected. ### Root Cause acq runs `readonly: true` kit code from a host directory mounted into the guest -`:ro`, on the assumption that the runtime enforces that flag. Only the runtime can -establish that. A guest-run probe cannot stand in for it: a guest with passwordless -sudo controls `sh` and `test`, and on msb the guest's own view of its mount flags can -even flip to `rw` after a remount while the host side still refuses writes (see -ADR-0035). So acq trusts the mount only on msb versions a **host-observed** canary -has qualified (`MSB_RO_MOUNT_QUALIFIED_VERSIONS`). Today that list holds **0.7.7** -only (macOS arm64, default image, fresh create; evidence in ADR-0035). On any other -msb version acq refuses rather than staging, running, or copying that code. +`:ro`. In msb that flag is enforced on the host by the filesystem server that exports +the directory, not by the guest kernel (ADR-0035), so a guest root can relabel its own +mount without gaining write access. acq therefore does not trust anything the guest +says about the mount. It trusts what **msb itself recorded** for this sandbox, read by +the host with `msb inspect --format json`, together with a version floor. If that +record does not show the one read-only Bind of acq's host config directory at the +guest path, acq refuses rather than staging, running, or copying that code. ### Fix -Run `scripts/qualify-msb-ro-mount` on a sandbox-capable host (outside any sandbox). -It judges the result by inspecting the real host directory afterwards, so nothing -the guest reports can make it pass. All output (results, guest log, host snapshots, -and on FAIL or BLOCKED the throwaway directories) is written under -`/tmp/qualify-msb-ro-mount-/` (gitignored; override with -`ACQ_QUALIFY_OUT_DIR`), and a final `RESULT ...` line can be pasted back. A remount -that the guest accepts is informational, not a write; the verdict rests on the -later writes being denied and the host directory being unchanged. On PASS, add the printed version to -`MSB_RO_MOUNT_QUALIFIED_VERSIONS` and record the output in ADR-0035 (with host -OS/arch and image). Existing sandboxes need no recreate for this: the version list -is checked at apply time. The guest probe is -diagnostic only: it can add a refusal ("writable" or "not confirmed") but never -grants trust. +Match the reason to the step: + +1. **Version:** update msb (acq installs the pinned release; the floor is + `MSB_RO_MOUNT_MIN_VERSION`). +2. **`inspect` failed, null `active_config`, or unparsable output:** make sure the + sandbox is running (`acq start `), that `msb inspect --format + json` works on the host, and that `python3` is installed; then re-run. +3. **Mount missing, duplicated, not read-only, wrong directory, or overlapped by a + writable mount:** the sandbox was not created the way acq creates it (for example + an extra `--volume` over `/var/lib/acq`). Recreate it with `acq rm` and `acq run`. +4. **Guest probe says writable or unconfirmed:** the sandbox may be damaged or not + running; restart it and retry, and recreate it if it persists. ### Prevention / Status -On msb 0.7.7 readonly kit files are accepted. That qualification covers a fresh -create on macOS arm64 with the default image; it says nothing about Linux, Windows, -other images or behavior after `msb start`, which remain unverified. On any other -msb version the refusal is expected until that version passes the canary. No shipped built-in kit marks a file -`readonly: true`, so default sandboxes are not affected either way; the gate matters -for the first kit that opts in. +The gate confirms what msb was asked to enforce; it does not independently prove +enforcement, so an upstream regression would not be caught by it. The optional, +non-gating `scripts/qualify-msb-ro-mount` canary corroborates enforcement on a given +msb release (output goes to a gitignored `tmp/qualify-msb-ro-mount-/` under the +repo, ending with a pasteable `RESULT ...` line); run it when adopting a new msb. +Windows is not covered. One canary run on msb 0.7.7 (macOS arm64, default image, +fresh create) is recorded in ADR-0035. --- diff --git a/docs/adr/0035-host-authoritative-sandbox-config.md b/docs/adr/0035-host-authoritative-sandbox-config.md index c13a8cae..f3e6bfff 100644 --- a/docs/adr/0035-host-authoritative-sandbox-config.md +++ b/docs/adr/0035-host-authoritative-sandbox-config.md @@ -105,8 +105,9 @@ provisioning decision. > lifecycle decision — data *and* code — MUST be one of: > > - **(H1) Host-resident and mounted read-only into the guest** (the VMM/mount -> layer enforces read-only; guest sudo cannot override it; acq relies on that -> only for a runtime version a host-observed canary has qualified), or +> layer enforces read-only; guest sudo cannot override it; for msb, acq relies on +> that only where msb's own `inspect` record shows the mount read-only on a version +> at or above a verified floor), or > - **(H2) Held on the host and supplied at exec time** from acq's own state > (never read back from the guest). > @@ -165,80 +166,84 @@ Concretely, this ADR introduces two mechanisms and a staged migration. - acq mounts that directory into the guest **read-only** at a well-known path: `/var/lib/acq/host/` (`:ro`). On msb via `--volume :/var/lib/acq/host:ro`; on sbx via the `:ro` positional the CLI already honors. This relies on backend - mount/export semantics, not Unix ownership inside the guest. **What is and is not - established about that enforcement:** an earlier manual observation on msb 0.7.2 - (a version acq now refuses) found the virtiofs mount exported read-only even when - guest root could make the guest-visible mount flag appear `rw`. It was not a - repeatable check and covers no version acq accepts. The first repeatable, - host-judged result is the msb 0.7.7 qualification recorded below, which covers one - version, image and host class and nothing else. For sbx nothing is recorded. So - `:ro` enforcement is **unverified on every other msb version, on other host - platforms and images, after `msb start`, and on sbx**. - - **Authority for trusting the mount (msb).** Trusted execution of `readonly: true` - kit files from the mount is permitted only when the installed msb version is on - `MSB_RO_MOUNT_QUALIFIED_VERSIONS` in `acq.backends/msb.sh`, a checked-in list of - versions for which a **host-observed canary** passed. The canary - (`scripts/qualify-msb-ro-mount`) creates a throwaway sandbox from the default - image, mounts one host directory read-only and a second read-write as a positive - control, makes the guest (as root) attempt create, overwrite, append, mkdir, - chmod, rename, delete and remount-rw on the read-only mount, and then judges the - result **from the host**: it compares the real host directory before and after - and requires that the host saw the control file. A changed host directory is FAIL - whatever the guest reports. **A remount the guest kernel accepts is not a - write.** The canary records `mount -o remount,rw` and a bind remount as - informational (`REMOUNT-ACCEPTED` or `REMOUNT-DENIED`) and then repeats every kind - of data write (create, overwrite, append, mkdir, chmod, rename, delete) *after* - them. A guest root can alter its own mount flags, and the guest's view may then - show the volume as read-write; what matters is whether the host directory behind - it can still be changed. So the decision rests on two things together: every data - write after the remount was denied, and the real host directory is byte-for-byte - unchanged. If a remount is accepted and the later writes are still denied with - the host unchanged, that is PASS, with a printed note that a remount was - accepted. We considered failing qualification on any accepted remount and - rejected it as the wrong test: the guest-visible mount flag need not reflect what - the host side enforces (not verified for msb), and the host-side comparison is the - thing we trust, so that rule could disqualify a runtime that holds while adding no - protection the post-remount writes do not already measure. The - residual gap is a window we do not exercise, for example a different sequence - of guest actions, a later `msb start`, or another host platform or image; the - PASS says nothing about those, which is why each list entry records its host, - image and revision. A missing control (the log, a root uid, or the same - operations succeeding on the read-write volume) is BLOCKED, never PASS. The - decision is the host-side comparison; the guest's own log is used only to prove - the attempts really ran, and it can only turn a PASS into BLOCKED or FAIL, never - the reverse. The canary runs the trusted default image, so it does not defend - against a hostile image. The list is - checked in and **starts with one qualified version (0.7.7)**; every other version - is refused for readonly files with an error that names the version and the script. - There is no operator override. Each entry added to the list must record, in this - ADR, the msb version, the acq revision the canary ran at, the date, the host OS - and architecture, the image reference (the default image tag is mutable) and the - PASS output. A PASS covers a freshly created sandbox on that host; enforcement - after `msb start`, on other host platforms, or with another image is not covered - by it. + mount/export semantics, not Unix ownership inside the guest. + + **Where enforcement lives (msb).** In msb the `:ro` setting is enforced on the + host, by the filesystem server that exports the directory to the guest, not by the + guest's kernel. In the microsandbox source at v0.7.7 + (`crates/filesystem/lib/backends/passthroughfs/unix/mod.rs`) the passthrough + backend carries a `readonly` configuration documented as host-side defense in + depth that holds against a privileged guest process attempting to remount, and every + mutating operation (write-intent open including truncate, create, mkdir, mknod, + symlink, link, unlink, rmdir, rename, setattr, extended attributes, fallocate, + copy-file-range and DAX write mapping) returns `EROFS` when it is set. The flag is + parsed from the same `ro` mount option that `msb inspect` reports + (`crates/runtime/lib/runner/vm.rs`). The check is present from v0.6.0 through + v0.7.10 (the field and the operation checks; check bodies were not diffed per tag). + This was read, not exercised, except for the live run below. msb on Windows uses a + different filesystem backend that was not reviewed. + + **What acq checks.** Trusted execution of `readonly: true` kit files from the mount + is allowed only when ALL of these hold, evaluated on the host when the first + readonly file of a kit is applied: + 1. the host-recorded `ro-host-config` key exists (below); + 2. `msb inspect --format json`, run by acq on the host, lists in + `.active_config.mounts[]` **exactly one** mount at `/var/lib/acq/host`, of type + `Bind`, with `options.readonly == true`, whose host path (after resolving + symlinks) is acq's own host config directory for this sandbox, and **no other + mount at, above or below that guest path that is not read-only**; a null + `active_config` right after boot is retried briefly, and an `inspect` failure, + unparsable output, an unexpected shape or a missing `python3` all refuse; + 3. the installed msb is a final `X.Y.Z` release at or above + `MSB_RO_MOUNT_MIN_VERSION` (0.7.7, the version the behavior was verified on; + older, prerelease and unparsable versions are refused). + The check is made on macOS and Linux hosts only; on Windows hosts acq does not + rely on the mount. The JSON is parsed with Python, never with grep or sed. + + **What that does and does not prove.** `msb inspect` is read by the host from msb's + own stored record, which the guest cannot alter, so it cannot be forged. It shows + what msb was **asked to enforce** for this sandbox's mount; it is not independent + proof that enforcement works. A future upstream regression in how msb enforces + `readonly` would **not** be caught by this gate. We trust msb upstream for + enforcement, and the version floor limits that trust to the release the behavior + was checked on. `scripts/qualify-msb-ro-mount` is kept as an optional, non-gating + corroboration for when a new msb release is adopted or a regression is suspected; + nothing in acq runs it or depends on its result. + + **Live corroboration (one run).** On 2026-10-28 the canary passed on msb 0.7.7 on + macOS arm64 with the default image: see "Qualification evidence" below, which also + records that after an accepted guest remount the guest's own view of the mount + became `rw` while every write stayed denied and the host directory stayed + unchanged. That is consistent with enforcement living on the host side, and it is + why the guest's view of its own mount flags is **not** a signal acq uses in either + direction. For sbx nothing is recorded, and sbx has no consumer of this mechanism. + So `:ro` enforcement is **unverified on msb versions other than 0.7.7, on other + host platforms and images, after `msb start`, and on sbx**, beyond what the source + reading above indicates. Whether a sandbox is a candidate at all is a **host-recorded create fact** (host key `ro-host-config`), written only after a create that passed the `:ro` volume flag returned success and the sandbox became exec-ready. The record proves only that acq asked for the mount and the sandbox booted; it is never evidence of - enforcement. A sandbox with no record (created before the key existed, or never + enforcement (item 2 above is what reads msb's own record). A sandbox with no record (created before the key existed, or never fully created) takes the legacy guest-copy path and warns. **The guest probe is a diagnostic and can only make acq stricter.** A guest with passwordless sudo controls `sh` and `test`, so a zero exit from a probe it runs proves nothing; acq therefore never treats it as proof the mount is read-only. - When the version is qualified and a record exists, a probe answer of "writable" - or "not confirmed" makes acq fail closed. For any readonly file, a refusal means + When the gate above passes, a probe answer of "writable" or "not confirmed" + still makes acq fail closed. For any readonly file, a refusal means acq stages nothing, runs nothing from the mount, and does not copy the file into the guest. This is a deliberate change from "degrade to a guest copy", through an - intermediate "probe decides", to fail closed with host-side authority; it applies + intermediate "probe decides" and a per-version allowlist, to fail closed on msb's + own host-side record; it applies only when a record exists, so legacy and no-record sandboxes still degrade to the guest copy with a warning. When a record exists and staging a `readonly: true` file onto the host fails, the apply likewise fails with an error naming the file. - If a future msb changes `--volume ...:ro` parsing or enforcement, re-run the - canary and drop the old version from the list before relying on ADR-0035. Host - and guest path forms follow ADR-0029. + If a future msb changes `--volume ...:ro` parsing, the shape of `msb inspect`, or + enforcement, raise `MSB_RO_MOUNT_MIN_VERSION` or gate on the new shape, and re-run + the optional canary before relying on this ADR. Host and guest path forms follow + ADR-0029. - The mount root and `kit-files/` subtree are traversable but not listable (`0711`) so the guest `agent` user can open/execute known read-only kit payload paths, while flat acq-internal keys stay host-private (`0600`). This is also a @@ -372,7 +377,7 @@ sequenceDiagram ACQ->>ACQ: validate facts host-side (env-var-ownership, SSRF, schema) ACQ->>HSTATE: write validated facts + staged startup code ACQ->>RO: mount HSTATE read-only (--volume …:ro / :ro positional) - AGENT-->>RO: sudo tee facts.json (expected to FAIL on a qualified msb: read-only mount, enforced by the runtime) + AGENT-->>RO: sudo tee facts.json (expected to FAIL: read-only mount, enforced by msb on the host) ACQ->>RO: invoke staged startup from :ro path RO->>RW: orchestrator computes per-sandbox catalog (rw, not shared) Note over ACQ,RO: acq trusts only host-authoritative :ro inputs @@ -439,10 +444,11 @@ GSA-TTS/agentic-coding-quickstart#636 fields: the sbx `extra-kits` and `builtin- `docs/KNOWN_FAILURE_MODES.md` section 32.1. SBX trusted-startup parity is proposed separately in [ADR-0037](0037-sbx-trusted-startup-parity.md). -## Qualification evidence (msb read-only host-config mount) +## Corroborating evidence (msb read-only host-config mount canary) -One entry per qualified msb version. The list lives in -`MSB_RO_MOUNT_QUALIFIED_VERSIONS` (`acq.backends/msb.sh`). +An optional host-judged canary, `scripts/qualify-msb-ro-mount`, corroborates the +host-side enforcement described above. It is **not part of the gate** (see "What that +does and does not prove"). One entry per run worth recording. ### msb 0.7.7 @@ -474,17 +480,18 @@ change how its own kernel labels the mount without changing what the host allows consequence for acq is that the guest's view of its own mount flags is **not a reliable signal in either direction**. A "read-only" reading from inside the guest proves nothing, and neither does "rw". That is why the guest probe stays a -diagnostic that can only add refusals and never grants trust, and why qualification -is judged from the host. +diagnostic that can only add refusals and never grants trust, and why the canary +judges from the host. -**Scope.** This qualifies a **fresh create** of **msb 0.7.7** with **this image** on -**this host class (macOS arm64)**. It does **not** qualify: Linux or Windows hosts or +**Scope.** This is one run on a **fresh create** of **msb 0.7.7** with **this image** +on **this host class (macOS arm64)**. It says nothing about: Linux or Windows hosts or other CPU architectures; any other image, or the same tag at a different digest; behavior after `msb stop`/`msb start` or a host restart; sandboxes created before the canary; or any other sequence of guest actions than the one the script performs. **Still unverified:** all of those, and whether the remount acceptance is stable across runs (this is one run). The result is one observation of a runtime we do not -control, so a later msb release must be re-run before it is added. +control; re-run the canary when adopting a later msb release (and raise +`MSB_RO_MOUNT_MIN_VERSION` only with such a check behind it). ## Links diff --git a/docs/adr/0037-sbx-trusted-startup-parity.md b/docs/adr/0037-sbx-trusted-startup-parity.md index bca8f469..3184ebdb 100644 --- a/docs/adr/0037-sbx-trusted-startup-parity.md +++ b/docs/adr/0037-sbx-trusted-startup-parity.md @@ -19,10 +19,9 @@ supersedes: [] `readonly: true` startup code tamper-resistant on msb: acq stages the pinned file on the host, mounts the per-sandbox config directory `:ro`, and runs the heal from the mounted path, so a passwordless-sudo guest cannot rewrite the code acq -re-executes on restart, **on an msb version where a host-observed canary has shown -the `:ro` mount is enforced** (ADR-0035 gates that on a checked-in list of qualified -versions, currently msb 0.7.7 only on one host class; a guest-run probe is never -accepted as proof). +re-executes on restart. On msb that rests on msb's own host-side enforcement of the +`:ro` mount, confirmed per sandbox from `msb inspect` and a version floor (ADR-0035); +a guest-run probe is never accepted as proof. SBX has no equivalent. `acq_backend_provision` for sbx creates the host config directory but never mounts it (it holds host-only keys such as `extra-kits`, @@ -63,7 +62,8 @@ reads a guest answer about them. The msb gate was forgeable because acq took a guest-run probe's zero exit as proof of read-only enforcement; sbx has no such gate. The open question for sbx is the one this ADR already states: native replay and mount enforcement are unverified, and Option B must not be implemented without a -host-judged canary of the same shape. +host-side check of the same kind as msb's: what the backend itself recorded for the +mount, plus a host-judged canary as corroboration. ## Decision Drivers @@ -170,8 +170,8 @@ Run on a real sbx host (cannot run inside a sandbox). Record exact sbx version. Confirm all fail and the host copy is unchanged. Judge it from the host side (inspect the real host directory afterwards), as the msb canary in `scripts/qualify-msb-ro-mount` does; a guest-reported denial is not evidence. - Nothing is recorded for sbx, and msb has only an earlier manual observation on - a version acq now refuses. + Nothing is recorded for sbx. For msb, enforcement is host-side (ADR-0035) and one + canary run on msb 0.7.7 corroborates it. 3. **Permissions**: the guest `agent` (uid 1000) can execute a `0555` file under a `0711` directory it cannot list, with host files owned by the host user. 4. **Restart persistence**: after `sbx stop`/start and after a host reboot, the From 468907f405798f28671ef007250df21afdd855bd Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 28 Oct 2026 18:11:25 +0000 Subject: [PATCH 58/63] style(test): end 84-msb-ro-mount-qualification.bats with a single newline Co-authored-by: OpenCode Agent --- test/bats/84-msb-ro-mount-qualification.bats | 1 - 1 file changed, 1 deletion(-) diff --git a/test/bats/84-msb-ro-mount-qualification.bats b/test/bats/84-msb-ro-mount-qualification.bats index cc1ef53c..814b3313 100644 --- a/test/bats/84-msb-ro-mount-qualification.bats +++ b/test/bats/84-msb-ro-mount-qualification.bats @@ -529,4 +529,3 @@ _run_canary() { # [ENV...] run git -C "$REPO_ROOT" check-ignore -q tmp/qualify-msb-ro-mount-x/results.tsv [ "$status" -eq 0 ] || skip "not a git checkout, or tmp/ not ignored here" } - From 4daf57f18eaf4262b55ea628b20ca6f32dff7b64 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 28 Oct 2026 13:29:45 -0700 Subject: [PATCH 59/63] feat(kits): generalize provider kits with trusted sources (#642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Generalizes acq's provider-kit model so provider kits can come from **any trusted repository** — not just the pinned `agentic-coding-patterns` — and removes the special-casing of `usai-provider`, which is now just the default provider kit expressed as data. Stacked on #611. ## What changed - **Trusted kit-source registry** — `acq kit-source add|rm|ls`, persisted as `kit_sources:` (default `github.com/GSA-TTS/agentic-coding-patterns`), overridable via `ACQ_KIT_SOURCES`. The kit fetch allowlist derives from this registry, and provider kits from trusted sources are auto-listed (`kit-source ls`, `acq configure`). - **`provider_kit:` as data** — the default provider is configurable (default `agentic-coding-patterns/usai-provider`) instead of the hardcoded `USAI_KIT`. - **usai de-specialized** — `usai-provider` flows through the same generic provider path as any provider (facts.json → credential-bindings registry → generic gate/injection/key-check). `usai-rotate-api-key` is retained. - **Generic credential injection** — provider credentials bind via a registry that pins `providerId → keyEnv → host(s)`, with a fail-closed gate and host pinning so a kit cannot redirect a stored credential to a foreign host. ## Trust model - Trusting a repo grants **kit fetch** and **credential bindings** only; the provider-input validator + schemas stay pinned to the `agentic-coding-patterns` control plane. - The default provider (`usai`) is seeded with its known host (`api.gsa.usai.gov`) as a trust anchor. ## Security Multiple adversarial review rounds hardened this: host pinning (credential redirection), positional URL passing (command injection), a shared host-service predicate (`github`/`gitlab` token exfiltration), removal of an `eval` sink, and fail-closed provider selection. ## Usage ```bash acq kit-source add github.com/mogul/agentic-coding-kits acq provider-credential set opencode-go OPENCODE_GO_API_KEY --host opencode.ai ACQ_PROVIDER_KITS=opencode-go-provider acq run opencode . ``` ## Tests 355+ bats tests green across the provider/secret/kit/provenance/dispatch suites; `bash -n` clean. (`shellcheck` and the full suite were not runnable in this environment.) --- acq | 261 +- acq.backends/common.sh | 2701 +++++++++++++---- acq.backends/msb.sh | 155 +- acq.backends/sbx.sh | 169 +- docs/BACKEND_GUIDE.md | 57 + docs/KNOWN_FAILURE_MODES.md | 35 +- docs/howto/acq.md | 148 +- scripts/verify-backends | 7 +- test/bats/146-provider-verification.bats | 46 +- .../148-provider-credential-bindings.bats | 887 ++++++ test/bats/149-kit-source.bats | 592 ++++ test/bats/30-dispatch-routing.bats | 145 +- test/bats/40-secret-set.bats | 7 +- test/bats/50-kit-list-completeness.bats | 293 -- test/bats/60-help.bats | 1 + test/bats/90-sbx-startup-kit.bats | 2 +- test/bats/helper.bash | 2 + 17 files changed, 4247 insertions(+), 1261 deletions(-) create mode 100644 test/bats/148-provider-credential-bindings.bats create mode 100644 test/bats/149-kit-source.bats diff --git a/acq b/acq index 52da073c..493652f5 100755 --- a/acq +++ b/acq @@ -32,8 +32,11 @@ ACQ_RESOLVED_PATH="${ACQ_SCRIPT_DIR}/$(basename "${BASH_SOURCE[0]}")" . "${ACQ_SCRIPT_DIR}/acq.backends/common.sh" # Test-only seam for executable dispatch tests that need a local provider kit. +# It overrides the DEFAULT provider kit (which is otherwise data in config.yaml), +# so the same fixtures keep working after the default moved out of code. if [ -n "${BATS_TEST_NAME:-}" ] && [ -n "${ACQ_TEST_USAI_KIT:-}" ]; then USAI_KIT="$ACQ_TEST_USAI_KIT" + ACQ_PROVIDER_KIT_DEFAULT="$ACQ_TEST_USAI_KIT" fi # --------------------------------------------------------------------------- # Print version/identity @@ -109,6 +112,14 @@ Subcommands: github-scope SANDBOX [PATH] Scope a GitHub token to the workspace's repos; PATH defaults to the recorded sandbox workspace when available. + provider-credential set PROVIDER KEY_ENV --host H[,H] + Pin a provider kit's credential env + var and destination host. + provider-credential ls List pinned provider bindings. + provider-credential rm PROVIDER Remove a configured binding. + kit-source add REPO --ref REF Trust a kit repository at a pinned commit. + kit-source ls Trusted sources + published provider kits. + kit-source rm REPO Stop trusting a kit repository. usai-rotate-api-key Rotate the USAi API key. version Show acq version + backend info. doctor [NAME] Backend health check, or non-enforcing @@ -241,6 +252,82 @@ See docs/BACKEND_GUIDE.md and docs/adr/0011-msb-backend-and-neutral-kits.md. KITUSAGE } +_acq_provider_credential_usage() { + cat >&2 <<'PROVIDERCREDUSAGE' +acq provider-credential — pin a provider kit's credential env var AND host + +Usage: + acq provider-credential set PROVIDER KEY_ENV --host HOST[,HOST] + acq provider-credential ls + acq provider-credential rm PROVIDER + +A provider kit (one shipping provider-input.json) is validated against the +pinned Patterns credential-bindings table. acq layers YOUR bindings on top so a +provider kit from any git+https repository can be used: + + 1. the pinned Patterns defaults — the trust anchor; + 2. config.yaml `provider_credential_bindings:` — what set/rm write here; + 3. ACQ_PROVIDER_CREDENTIAL_BINDINGS="id=ENV[@host]" — one invocation only. + +--host is REQUIRED. It is the trust decision that lets acq send your stored +credential to a destination: a kit can claim any providerId and allow-list any +host, so acq only ever injects into a host you pinned explicitly. An +unpinned provider, or a kit whose facts.host differs from the pinned (or +recorded) host, fails closed rather than sending your credential somewhere new. + +PROVIDER must match ^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$ and KEY_ENV must be an +upper-case env var name. Built-in credential names (USAI_API_KEY, GITHUB_TOKEN, +GH_TOKEN, GITLAB_TOKEN) and process-critical names (PATH, IFS, HOME, ...) are +refused. These commands record WHERE the key comes from — the key itself lives in +the acq store: + + acq provider-credential set opencode-go OPENCODE_GO_API_KEY \ + --host models.opencode-go.example + acq secret set -g opencode-go # prompts for the value, never on argv + acq run opencode . # binds OPENCODE_GO_API_KEY@host + +See docs/howto/acq.md ("Non-USAi provider kits"). +PROVIDERCREDUSAGE +} + +_acq_kit_source_usage() { + cat >&2 <<'KITSOURCEUSAGE' +acq kit-source — trusted repositories acq will fetch kits from + +Usage: + acq kit-source add REPO --ref REF [--dir DIR] Trust a repository at a pinned commit + acq kit-source ls Sources + the provider kits they publish + acq kit-source rm REPO Stop trusting a repository + +REPO is host/owner/repo (a pasted clone URL or git+https ref is normalized). +A trusted source may be FETCHED from, and its provider kits may be selected. It +does NOT extend to the validation control plane: the provider-input validator and +its schemas stay pinned to the Patterns release, so a new source can ship kits but +cannot redefine what a valid kit is. + +Sources live in ~/.config/acq/config.yaml as `kit_sources:`; the shipped default +is github.com/GSA-TTS/agentic-coding-patterns pinned to the release acq was built +against. ACQ_KIT_SOURCES overrides the list for one invocation, and +ACQ_EXTRA_KIT_SOURCES still ADDS allowlist prefixes (backward compatibility). + +Trust is per repository: trusting host/owner/one does not trust host/owner/two. +`rm` also revokes the backend's owner-wide allowlist entry (sbx) unless another +trusted source still needs it, and FAILS (leaving the source registered, with a +recovery command) if the backend allowlist cannot be updated. + +`ls` also lists the provider kits each source publishes (by scanning for +*/provider-input.json). Discovery is best-effort: an unreachable source simply +lists nothing. + +Pick the provider kit acq uses by default with `acq configure`, or set +`provider_kit: /` in config.yaml (default: +agentic-coding-patterns/usai-provider). Per-run selection is still +ACQ_PROVIDER_KITS / --no-provider-kits. + +See docs/howto/acq.md ("Trusted kit sources"). +KITSOURCEUSAGE +} + _acq_backend_subcmd_usage() { cat >&2 <<'BACKENDUSAGE' acq backend — select the sandbox backend @@ -477,7 +564,7 @@ U _acq_verb_backend_help_kind() { case "${1%% *}" in run|ls|stop|rm|exec|cp|ports|secret|kit) printf 'shared' ;; - start|restart|create|shell|version|doctor|github-scope|usai-rotate-api-key|backend) printf 'acqonly' ;; + start|restart|create|shell|version|doctor|github-scope|usai-rotate-api-key|backend|provider-credential|kit-source) printf 'acqonly' ;; *) printf '' ;; esac } @@ -495,6 +582,8 @@ _acq_print_topic_help() { kit) _acq_kit_usage; return 0 ;; backend) _acq_backend_subcmd_usage; return 0 ;; configure) _acq_configure_usage; return 0 ;; + provider-credential) _acq_provider_credential_usage; return 0 ;; + kit-source) _acq_kit_source_usage; return 0 ;; *) _acq_simple_verb_usage "$head"; return $? ;; esac } @@ -550,17 +639,6 @@ _acq_secret_service_arg() { esac } -_acq_secret_needs_usai_facts() { - [ "$(_acq_secret_service_arg "$@")" = "usai" ] -} - -_acq_secret_import_includes_usai() { - local parsed only_service - parsed=$(_acq_import_parse_args "$@" 2>/dev/null) || return 1 - only_service=$(printf '%s' "$parsed" | cut -f3) - [ -z "$only_service" ] || [ "$only_service" = "usai" ] -} - _acq_parse_secret_scope_for_propagation() { ACQ_PROPAGATE_SCOPE="" ACQ_PROPAGATE_SERVICE="" @@ -1159,9 +1237,9 @@ esac case "$subcommand" in usai-rotate-api-key) shift - if acq_usai_provider_selected; then - acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" - fi + # Kept as its own verb: rotation needs the provider's key-management console, + # which no generic facts field describes. Everything else about the default + # provider flows through the generic path. if ! command -v acq_backend_rotate_key >/dev/null 2>&1; then echo "acq: the '${ACQ_RESOLVED_BACKEND}' backend does not implement key rotation" >&2 echo " (acq_backend_rotate_key). Rotate manually, then re-run." >&2 @@ -1173,9 +1251,6 @@ case "$subcommand" in create) shift - if acq_usai_provider_selected; then - acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" - fi acq_backend_prepare # First-run onboarding (ADR-0031): offer `acq configure` once before we # provision, so a brand-new user picks kits up front. No-op after first run @@ -1187,6 +1262,7 @@ case "$subcommand" in _acq_apply_configured_extra_kits || exit 1 create_time_kit_picker _build_kit_list "$create_agent" + _acq_require_provider_selection || exit 1 # Pre-flight: validate the workspace path before provisioning (#issue). ws=$(workspace_path "$@") preflight_workspace_path "$ws" || exit 1 @@ -1197,12 +1273,12 @@ case "$subcommand" in # at create time, so a sandbox created before the key exists carries no USAi # binding. Gate on key presence first (fail closed if declined) so create # picks it up. `create` is otherwise detached and never attaches. - if acq_usai_provider_selected && ! ensure_key_present "$local_name"; then - exit 1 - fi + # The provider-credential gate runs inside provision (after the provider + # inputs are materialized, so it can read each provider's facts): it prompts, + # stores, and fails closed there. Nothing provider-specific is needed here. # Nudge toward a per-sandbox scoped GitHub token BEFORE provisioning, for the - # same reason as the USAi gate above: msb binds secrets only at create time + # same reason as the provider gate:: msb binds secrets only at create time # (--secret ENV@HOST), so a GitHub token stored AFTER create would never bind # to this sandbox. Prompting first lets a supplied token be picked up at # create. Warn-not-block (ADR-0013): declining never aborts the create, it @@ -1213,14 +1289,8 @@ case "$subcommand" in acq_print_selected_agent_kit "$create_agent" acq_backend_provision "$local_name" "$@" - - # Non-blocking USAi key advisory. `create` is detached and never attaches, - # so unlike `run` we don't gate on the key or offer interactive rotation — - # just warn if it's already invalid so a create-only user knows before they - # later `acq run`. - if acq_usai_provider_selected; then - advise_valid_key "$local_name" - fi + # `create` is detached and never attaches, so provider credentials are + # validated on the next attach (ensure_valid_provider_keys), not here. ;; run) @@ -1251,9 +1321,6 @@ case "$subcommand" in if acq_backend_exists "$local_name"; then acq_provider_selection_load "${ACQ_RESOLVED_BACKEND}" "$local_name" || exit 1 fi - if acq_usai_provider_selected; then - acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" - fi acq_print_selected_agent_kit "$first" # Pre-flight: validate the workspace path before provisioning (#issue). @@ -1330,10 +1397,8 @@ case "$subcommand" in _acq_apply_configured_extra_kits || exit 1 create_time_kit_picker _build_kit_list "$first" + _acq_require_provider_selection || exit 1 - if acq_usai_provider_selected && ! ensure_key_present "$local_name"; then - exit 1 - fi # Nudge toward a per-sandbox scoped GitHub token BEFORE provisioning, for # the SAME reason as the USAi gate: msb binds secrets only at create time @@ -1348,11 +1413,12 @@ case "$subcommand" in acq_backend_provision "$local_name" "${create_args[@]}" fi - # Validate USAi key before attaching. This gate must fail closed, so - # abort explicitly on a non-zero return rather than relying on `set -e` - # to propagate from a bare function call (its behavior for a function - # used as the tested command is not portable across bash versions). - if acq_usai_provider_selected && ! ensure_valid_key "$local_name" "${create_args[@]}"; then + # Validate every selected provider's credential before attaching. This is + # the single provider gate: the default provider is checked exactly like any + # other (its facts name its own modelsUrl). It must fail closed, so abort + # explicitly on a non-zero return rather than relying on `set -e` to + # propagate from a bare function call. + if ! ensure_valid_provider_keys "$local_name"; then exit 1 fi @@ -1389,7 +1455,7 @@ case "$subcommand" in elif [ -n "$first" ] && acq_backend_exists "$first"; then # Existing sandbox by name (no agent/path given). Heal kits and attach. - # ensure_valid_key is intentionally omitted here: we don't have the + # Provider validation is intentionally omitted here: we don't have the # create args needed to provision a fresh validation sandbox, and # attaching by name is a re-attach to a running session where the key # was already validated on the original acq run. @@ -1610,9 +1676,6 @@ case "$subcommand" in case "$sub2" in set) shift - if _acq_secret_needs_usai_facts "$@"; then - acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" - fi acq_backend_secret_set "$@" if _acq_parse_secret_scope_for_propagation "$@"; then _acq_propagate_secret_to_present_backends "$ACQ_PROPAGATE_SERVICE" "$ACQ_PROPAGATE_SCOPE" @@ -1632,9 +1695,6 @@ case "$subcommand" in ;; rm) shift - if _acq_secret_needs_usai_facts "$@"; then - acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" - fi # `acq secret rm [-g | SANDBOX] SERVICE` removes an acq-owned secret from # the acq store (and clears the backend's injector). If the args name a # scope + known acq service (-g or a sandbox name), route to the backend @@ -1665,15 +1725,11 @@ case "$subcommand" in has) # `acq secret has [-g | SANDBOX] SERVICE` — SILENT predicate for scripting: # exit 0 if the ACTIVE BACKEND can inject SERVICE for that scope, else 1. - # This is exactly the composed check `ensure_key_present` uses as its - # pre-create gate (acq_secret_has AND, when the backend defines it, - # acq_backend_key_present) — factored into the shared common.sh helper - # acq_key_injectable so the two can never drift. Fail closed; never forward - # to the backend CLI. + # This is the same composed check acq's create gate makes (acq_secret_has + # AND, when the backend defines it, acq_backend_key_present) — factored into + # the shared helper acq_key_injectable so the two can never drift. Fail + # closed; never forward to the backend CLI. shift - if _acq_secret_needs_usai_facts "$@"; then - acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" >/dev/null 2>&1 || true - fi # Parse scope the same way secret set/rm do: `-g`/`--global` -> global; a # leading bare token before the service -> sandbox; a lone token -> service. secret_has_scope="" @@ -1749,9 +1805,6 @@ case "$subcommand" in # it never invokes the backend's own `secret` CLI. See acq_secret_import # in acq.backends/common.sh and docs/BACKEND_GUIDE.md "Migration". shift - if _acq_secret_import_includes_usai "$@"; then - acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" - fi acq_secret_import "$@" ;; --help|-h|"") @@ -1800,6 +1853,96 @@ case "$subcommand" in github_scope_sandbox "$gs_sandbox" "$gs_ws" ;; + provider-credential) + # acq-owned provider -> KEY_ENV registry. Records WHERE a provider kit's + # credential arrives from; the value itself stays in the acq secret store + # (`acq secret set -g PROVIDER`). Keeping the registry in acq is what lets a + # provider kit hosted outside the pinned Patterns release validate and be + # injected with no per-provider acq code. + shift + case "${1:-}" in + set) + shift + if [ "$#" -lt 2 ]; then + echo "acq: provider-credential set: missing arguments" >&2 + echo " usage: acq provider-credential set PROVIDER KEY_ENV --host HOST[,HOST]" >&2 + exit 1 + fi + acq_provider_credential_set "$@" || exit 1 + ;; + ls) + acq_provider_credential_ls || exit 1 + ;; + rm) + shift + if [ "$#" -lt 1 ]; then + echo "acq: provider-credential rm: missing provider id" >&2 + echo " usage: acq provider-credential rm PROVIDER" >&2 + exit 1 + fi + acq_provider_credential_rm "$1" || exit 1 + ;; + --help|-h) + _acq_provider_credential_usage + ;; + "") + _acq_provider_credential_usage + exit 2 + ;; + *) + echo "acq: unknown provider-credential subcommand '${1}'." >&2 + echo " Try: acq provider-credential set | ls | rm | --help" >&2 + exit 1 + ;; + esac + ;; + + kit-source) + # Trusted kit sources: which repositories acq will FETCH kits from. Trust does + # not extend to the validation control plane (the validator + schemas stay + # pinned to the Patterns release), so a new source can ship kits but cannot + # redefine what a valid kit is. + shift + case "${1:-}" in + add) + shift + if [ "$#" -lt 1 ]; then + echo "acq: kit-source add: missing repository" >&2 + echo " usage: acq kit-source add REPO --ref REF [--dir DIR]" >&2 + exit 1 + fi + acq_kit_source_set "$@" || exit 1 + ;; + ls) + acq_kit_source_ls || exit 1 + ;; + rm) + shift + if [ "$#" -lt 1 ]; then + echo "acq: kit-source rm: missing repository" >&2 + echo " usage: acq kit-source rm REPO" >&2 + exit 1 + fi + # Also revokes the owner prefix from the backend's own allowlist when no + # remaining trusted source needs it; exits non-zero (fail closed) if the + # backend allowlist could not be reconciled. + acq_kit_source_rm "$1" || exit 1 + ;; + --help|-h) + _acq_kit_source_usage + ;; + "") + _acq_kit_source_usage + exit 2 + ;; + *) + echo "acq: unknown kit-source subcommand '${1}'." >&2 + echo " Try: acq kit-source add | ls | rm | --help" >&2 + exit 1 + ;; + esac + ;; + "") # Unreachable: a bare `acq` (no subcommand) is handled by the help block # above (prints acq + backend help, exits 2). Kept as a defensive guard. diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 341c4d98..c9cc0461 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -229,8 +229,18 @@ ACQ_AGENT_KIT_READY_CACHE="" # where they override a kit's mapping for the same guest port. ACQ_PUBLISH_FLAGS=() -KIT_SOURCE_PREFIX="github.com/GSA-TTS/" -KIT_SOURCE_PREFIXES=("$KIT_SOURCE_PREFIX") +# Fetch allowlist, derived from the trusted kit-source registry (see +# acq_kit_source_prefixes). Populated by _build_kit_list. +# shellcheck disable=SC2034 # consumed by the sbx allowlist sync +KIT_SOURCE_PREFIXES=() + +# Latched provider-selection error. acq_resolve_backend builds the kit list for +# EVERY verb, so a bad `provider_kit:`/`kit_sources:` must not lock the user out of +# the verbs that repair it (configure, kit-source, backend, doctor, help). The +# failure is recorded here and turned into a hard stop by +# _acq_require_provider_selection, which only create/run call. +# shellcheck disable=SC2034 # read by _acq_require_provider_selection +ACQ_PROVIDER_SELECTION_ERROR="" # Transitional fallback defaults for the pinned usai-provider kit. ADR-0030's # target state is for the kit to export these provider facts as static metadata @@ -240,10 +250,10 @@ KIT_SOURCE_PREFIXES=("$KIT_SOURCE_PREFIX") USAI_PROVIDER_HOST="api.gsa.usai.gov" USAI_PROVIDER_BASE_URL="https://${USAI_PROVIDER_HOST}/api/v1" USAI_PROVIDER_KEY_ENV="USAI_API_KEY" +# shellcheck disable=SC2034 # consumed by the sbx adapter (sbx.sh) USAI_PROVIDER_MODELS_URL="${USAI_PROVIDER_BASE_URL}/models" -USAI_PROVIDER_KEY_MGMT_URL="https://gsa.usai.gov/console/key-management" +# shellcheck disable=SC2034 # consumed by the sbx and msb adapters USAI_PROVIDER_BIND_HOSTS="${USAI_PROVIDER_HOST}" -USAI_PROVIDER_FACTS_SOURCE="fallback" # Source the shared agent catalog (single source of truth for agent tokens and # the sandbox-template image naming convention; issue #377). Both adapters also @@ -323,7 +333,10 @@ acq_debug() { # Services acq manages in its own secret store (and knows how to inject). Used to # decide whether `acq secret rm` should route to the acq-owned removal path vs. # pass a raw placeholder token through to the backend secret CLI. -ACQ_MANAGED_SECRET_SERVICES=" usai github gitlab " +# PROVIDERS are no longer listed here: they are registry-driven (see the +# provider credential bindings section), so `usai` is a provider like any other +# and only non-provider HOST services remain compiled in. +ACQ_MANAGED_SECRET_SERVICES=" github gitlab " # _acq_import_env_vars_for SERVICE — echo the host environment variable name(s) # that `acq secret import` reads to populate SERVICE, most-preferred first (space @@ -334,11 +347,20 @@ ACQ_MANAGED_SECRET_SERVICES=" usai github gitlab " # gitlab <- GITLAB_TOKEN # Only acq-managed services are importable; an unknown service echoes nothing. _acq_import_env_vars_for() { + local provider_key_env="" case "$1" in usai) printf '%s\n' "$USAI_PROVIDER_KEY_ENV" ;; github) printf 'GITHUB_TOKEN GH_TOKEN\n' ;; gitlab) printf 'GITLAB_TOKEN\n' ;; - *) printf '\n' ;; + *) + # A provider kit declared in acq's credential-bindings registry is + # importable from the ENV var that registry names, so a non-USAi provider + # needs no per-provider entry here. + if command -v acq_provider_credential_key_env >/dev/null 2>&1; then + provider_key_env=$(acq_provider_credential_key_env "$1" 2>/dev/null) || provider_key_env="" + fi + printf '%s\n' "$provider_key_env" + ;; esac } @@ -381,126 +403,6 @@ _acq_provider_fact_safe_url() { return 0 } -acq_provider_facts_load() { - local file="${1:-}" line key value schema="" id="" - local host="" base_url="" models_url="" key_env="" key_mgmt_url="" bind_hosts="" - [ -n "$file" ] || return 1 - [ -f "$file" ] || return 2 - - while IFS= read -r line || [ -n "$line" ]; do - case "$line" in ""|'#'*) continue ;; esac - case "$line" in *=*) ;; *) return 1 ;; esac - key="${line%%=*}" - value="${line#*=}" - case "$key" in - ACQ_PROVIDER_FACTS_SCHEMA) schema="$value" ;; - ACQ_PROVIDER_ID) id="$value" ;; - ACQ_PROVIDER_HOST) host="$value" ;; - ACQ_PROVIDER_BASE_URL) base_url="$value" ;; - ACQ_PROVIDER_MODELS_URL) models_url="$value" ;; - ACQ_PROVIDER_KEY_ENV) key_env="$value" ;; - ACQ_PROVIDER_KEY_MGMT_URL) key_mgmt_url="$value" ;; - ACQ_PROVIDER_BIND_HOSTS) bind_hosts="$value" ;; - *) return 1 ;; - esac - done < "$file" - - [ "$schema" = "1" ] || return 1 - [ "$id" = "usai" ] || return 1 - _acq_provider_fact_safe_host "$host" || return 1 - _acq_provider_fact_safe_url "$base_url" || return 1 - _acq_provider_fact_safe_url "$models_url" || return 1 - _acq_provider_fact_safe_url "$key_mgmt_url" || return 1 - _acq_provider_fact_safe_env "$key_env" || return 1 - [ -n "$bind_hosts" ] || bind_hosts="$host" - _acq_provider_fact_safe_host "$bind_hosts" || return 1 - - USAI_PROVIDER_HOST="$host" - USAI_PROVIDER_BASE_URL="$base_url" - USAI_PROVIDER_MODELS_URL="$models_url" - USAI_PROVIDER_KEY_ENV="$key_env" - USAI_PROVIDER_KEY_MGMT_URL="$key_mgmt_url" - # shellcheck disable=SC2034 # consumed by adapters/tests after facts load - USAI_PROVIDER_BIND_HOSTS="$bind_hosts" - # shellcheck disable=SC2034 # consumed by diagnostics/tests after facts load - USAI_PROVIDER_FACTS_SOURCE="$file" -} - -acq_provider_facts_load_from_kit() { - local kitref="${1:-$USAI_KIT}" base_dir="${2:-}" kitdir facts rc - if [ -z "$base_dir" ]; then - base_dir="${ACQ_PROVIDER_FACTS_CACHE_DIR:-${ACQ_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/acq}/provider-facts}" - fi - mkdir -p "$base_dir" || return 3 - if ! command -v kit_translate_fetch >/dev/null 2>&1; then - return 3 - fi - kitdir=$(kit_translate_fetch "$kitref" "$base_dir/usai-provider") || return 3 - facts="$kitdir/provider-facts/usai.env" - acq_provider_facts_load "$facts" - rc=$? - case "$rc" in - 0) return 0 ;; - 2) return 2 ;; - *) echo "acq: invalid provider facts artifact: $facts" >&2; return 1 ;; - esac -} - -acq_provider_facts_use_fallback() { - USAI_PROVIDER_HOST="api.gsa.usai.gov" - USAI_PROVIDER_BASE_URL="https://${USAI_PROVIDER_HOST}/api/v1" - USAI_PROVIDER_KEY_ENV="USAI_API_KEY" - USAI_PROVIDER_MODELS_URL="${USAI_PROVIDER_BASE_URL}/models" - USAI_PROVIDER_KEY_MGMT_URL="https://gsa.usai.gov/console/key-management" - # shellcheck disable=SC2034 # consumed by adapters/tests after facts load - USAI_PROVIDER_BIND_HOSTS="${USAI_PROVIDER_HOST}" - # shellcheck disable=SC2034 # consumed by diagnostics/tests after facts load - USAI_PROVIDER_FACTS_SOURCE="fallback" -} - -acq_provider_facts_load_from_kit_or_fallback() { - local provider_facts_rc - local IFS - - # Keep the loader in this shell so validated provider assignments persist, but - # invoke it as a condition so caller errexit cannot skip status handling. - # Diagnostics flow directly to stderr; no temporary file or trap is needed. - # Use 'local IFS' (Bash 3.2-safe dynamic scoping) to preserve the caller's - # IFS state (including when intentionally unset under 'set -u'). The local - # declaration auto-restores the caller's IFS on function exit, avoiding - # explicit snapshot/restore that fails when IFS is unset. - if acq_provider_facts_load_from_kit "$@"; then - provider_facts_rc=0 - else - provider_facts_rc=$? - fi - - case "$provider_facts_rc" in - 0) : ;; - 1) - return 1 - ;; - 2) - acq_provider_facts_use_fallback - acq_debug "provider facts unavailable from kit; using transitional fallback defaults" - ;; - 3) - acq_provider_facts_use_fallback - echo "acq: warning: provider facts could not be fetched; using transitional fallback defaults" >&2 - acq_debug "provider facts fetch failed; using transitional fallback defaults" - ;; - *) - return "$provider_facts_rc" - ;; - esac - # msb derives this adapter-local binding host when sourced, before lazy facts load. - if [ "${ACQ_RESOLVED_BACKEND:-}" = "msb" ]; then - # shellcheck disable=SC2034 # consumed by msb adapter functions at dispatch - ACQ_MSB_USAI_HOST="$USAI_PROVIDER_BIND_HOSTS" - fi - return 0 -} - # _acq_import_detect_var SERVICE -> prints the NAME of the FIRST of SERVICE's # candidate env vars that is set and non-empty; empty output (rc 1) if none is # set. Returns the variable NAME ONLY — never the value — so the secret value is @@ -556,11 +458,15 @@ _acq_secret_last4_of() { } # _acq_is_managed_service SERVICE -> 0 if SERVICE is an acq-managed service. +# Besides the built-in services, any provider declared in acq's +# credential-bindings registry is managed: `acq secret set -g ` then +# stores (and the adapters inject) that provider's credential. _acq_is_managed_service() { case "$ACQ_MANAGED_SECRET_SERVICES" in *" $1 "*) return 0 ;; - *) return 1 ;; esac + command -v acq_provider_credential_key_env >/dev/null 2>&1 || return 1 + [ -n "$(acq_provider_credential_key_env "$1" 2>/dev/null)" ] } # _acq_import_decide MODE DRY_RUN EXISTS -> prints one of "import", "skip", or @@ -670,16 +576,35 @@ _acq_import_parse_args() { --force|-f) mode="force" ;; --dry-run) dry_run=1 ;; -g|--global) scope_sandbox="" ;; # global is already the default + --service) + echo "acq: secret import: --service needs a value" >&2 + return 1 + ;; + --service=*) + only_service="${arg#--service=}" + if ! _acq_is_importable_service "$only_service"; then + echo "acq: secret import: unknown service '$only_service' for --service" >&2 + return 1 + fi + ;; -*) echo "acq: secret import: unknown flag '$arg'" >&2 echo " usage: acq secret import [SERVICE] [-g | SANDBOX] [--all] [--force] [--dry-run]" >&2 return 1 ;; *) - # First bare token that is a managed service -> restrict to it; a second - # bare token is the SANDBOX scope (NOT a second service). - if [ -z "$only_service" ] && _acq_is_managed_service "$arg"; then - only_service="$arg" + # A BARE token is a service when acq knows it as importable (a built-in + # managed service, or a provider reachable through the trust anchor / + # credential registry). That keeps `acq secret import usai` working after + # usai stopped being a compiled-in service. Anything else is the SANDBOX + # scope. + if _acq_is_importable_service "$arg"; then + if [ -z "$only_service" ]; then + only_service="$arg" + elif [ "$only_service" != "$arg" ]; then + echo "acq: secret import: unexpected extra argument '$arg'" >&2 + return 1 + fi elif [ -z "$scope_sandbox" ]; then scope_sandbox="$arg" else @@ -692,6 +617,30 @@ _acq_import_parse_args() { printf '%s\t%s\t%s\t%s\n' "$mode" "$dry_run" "$only_service" "$scope_sandbox" } +# _acq_is_importable_service SERVICE — a built-in managed service, or a provider +# acq can import for (trust anchor, config.yaml, or ACQ_PROVIDER_CREDENTIAL_BINDINGS). +_acq_is_importable_service() { + case "$ACQ_MANAGED_SECRET_SERVICES" in + *" ${1:-} "*) return 0 ;; + esac + _acq_provider_credential_id_ok "${1:-}" || return 1 + command -v acq_provider_credential_key_env >/dev/null 2>&1 || return 1 + [ -n "$(acq_provider_credential_key_env "$1" 2>/dev/null)" ] +} + +# acq_importable_services — every service `acq secret import` may scan for, +# deduped and in a stable order: the built-in host services, then the providers +# acq knows (trust anchor first, then config/env). +acq_importable_services() { + { + for _svc in $ACQ_MANAGED_SECRET_SERVICES; do + printf '%s\n' "$_svc" + done + acq_provider_trusted_ids + acq_provider_registry_ids + } 2>/dev/null | awk 'NF && !seen[$0]++' +} + # acq_secret_import [SERVICE] [-g|--global|SANDBOX] [--all] [--force] [--dry-run] # --------------------------------------------------------------------------- # Scan the host environment for known acq-managed service tokens (usai, github, @@ -730,8 +679,10 @@ acq_secret_import() { return 1 fi - # Which services to consider: a single named one, or all managed services. - local services="$ACQ_MANAGED_SECRET_SERVICES" + # Which services to consider: a single named one, or every importable service + # (built-ins plus the providers acq knows through its trust anchor/registry). + local services + services=$(acq_importable_services) [ -n "$only_service" ] && services="$only_service" local scope_desc="global" @@ -806,6 +757,12 @@ _acq_is_managed_secret_rm() { case "$ACQ_MANAGED_SECRET_SERVICES" in *" $svc "*) return 0 ;; esac + # A provider pinned in the credential-bindings registry is acq-managed too, so + # `acq secret rm ` routes to the acq-store removal path. + if command -v acq_provider_credential_key_env >/dev/null 2>&1 \ + && [ -n "$(acq_provider_credential_key_env "$svc" 2>/dev/null)" ]; then + return 0 + fi # Not a built-in: managed iff the entry exists in the acq store at this scope. # (Only checkable when the neutral store is loaded — the native-store backends.) if command -v _acq_secret_key >/dev/null 2>&1 && command -v acq_secret_get >/dev/null 2>&1; then @@ -849,9 +806,12 @@ _acq_builtin_support_kit_names() { } _acq_selected_provider_kit_refs() { - local provider_refs=() ref + local provider_refs=() ref resolved if [ -z "${ACQ_PROVIDER_KITS_FROM_ENV:-}" ] && [ -z "${ACQ_PROVIDER_KITS_FROM_CLI:-}" ]; then - provider_refs=("$USAI_KIT") + # The default provider kit is DATA (`provider_kit:` in config.yaml), so a new + # provider needs no code change here. + resolved=$(acq_provider_kit_ref) || return 1 + provider_refs=("$resolved") elif [ -n "$ACQ_PROVIDER_KITS" ]; then split_noglob provider_refs "$ACQ_PROVIDER_KITS" fi @@ -969,12 +929,26 @@ acq_agent_builtin_kit_ready() { } _acq_selected_support_kit_refs() { - local name kit + local skip_provider="${1:-0}" name kit provider_refs kit=$(_acq_builtin_kit_ref zscaler-ca-certificate) || return 1 printf '%s\n' "$kit" - while IFS= read -r kit; do - [ -n "$kit" ] && printf '%s\n' "$kit" - done < <(_acq_selected_provider_kit_refs) + # Captured, NOT consumed through a process substitution: a failed provider-kit + # resolution is reported to the caller instead of silently dropping the provider + # kit. SKIP_PROVIDER=1 yields the provider-free list, which is what the + # management verbs get to use while a config typo still exists. + if [ "$skip_provider" != "1" ]; then + provider_refs=$(_acq_selected_provider_kit_refs) || { + echo "acq: the configured provider kit could not be resolved (see above)." >&2 + echo " Fix 'provider_kit:' / 'kit_sources:' (acq kit-source ls)," >&2 + echo " or unset ACQ_PROVIDER_KITS." >&2 + return 1 + } + while IFS= read -r kit; do + [ -n "$kit" ] && printf '%s\n' "$kit" + done <&2 + printf '%s\n' "$ACQ_PROVIDER_SELECTION_ERROR" >&2 + echo " Fix 'provider_kit:' / 'kit_sources:' (see: acq kit-source ls)," >&2 + echo " or unset ACQ_PROVIDER_KITS." >&2 + return 1 +} + # Assemble the full kit list: selected built-ins, then extras. # Zscaler CA trust FIRST (see ACQ_KIT_NAMES) so later network-fetching kits # succeed behind a TLS-intercepting proxy. # shellcheck disable=SC2120 # optional agent argument; many callers use default _build_kit_list() { - local agent="${1:-}" kit + local agent="${1:-}" kit support_refs KITS=() + # This runs for EVERY verb (acq_resolve_backend), so a provider-selection + # failure is LATCHED here and only enforced by create/run through + # _acq_require_provider_selection. The kit list still builds (without the + # provider) so `acq configure`/`kit-source`/`help` can repair the config. + if support_refs=$(_acq_selected_support_kit_refs 2>/dev/null); then + ACQ_PROVIDER_SELECTION_ERROR="" + else + ACQ_PROVIDER_SELECTION_ERROR=$(_acq_selected_support_kit_refs 2>&1 >/dev/null || true) + support_refs=$(_acq_selected_support_kit_refs 1) || return 1 + fi while IFS= read -r kit; do [ -n "$kit" ] && KITS+=("$kit") - done < <(_acq_selected_support_kit_refs) + done <guest. This store holds acq's trusted -# per-sandbox config on the HOST (under the same state tree as provenance and the -# cli-kits record) and, where the guest legitimately needs to read a value, acq -# mounts the directory into the guest READ-ONLY (see the backend :ro mount). -# The guest can read it but cannot write it; a sudo agent cannot forge it. -# -# Layout (host): //..config/ -# Guest mount: ACQ_HOST_CONFIG_GUEST_DIR (read-only) [default /var/lib/acq/host] -# One flat file per KEY (value = raw bytes, no trailing newline added), so a -# value may contain any byte a single line could not; readers get exactly what -# was written. Atomic temp+rename write; fail-soft (mirrors the marker writes -# it replaces, which were best-effort `|| true`). +# Trust here is deliberately NARROW: a trusted source may be FETCHED from, and +# its provider kits may be selected. It does NOT extend to the validation +# control plane — the provider-input validator and its schemas stay pinned to the +# Patterns release (PATTERNS_KIT_REF), so a new source can ship kits but cannot +# redefine what a valid kit is. # -# The sandbox config dir is chmod 0711: the guest must be able to traverse the -# read-only mount root to execute `kit-files/...` staged code, but should not be -# able to list acq-internal key names. Content-bearing flat keys remain 0600 on -# the host; readonly kit code is staged under kit-files/ with executable perms. -# The traversable dirs intentionally make known kit-file paths host-readable too; -# only non-sensitive trusted code may be staged there. -# -# KEY charset is restricted ([A-Za-z0-9._-], no slash) so a key can never escape -# the config dir; callers pass fixed literals (agent, workspace, ssh-auth-sock, -# kit-env, and the gate markers). - -# The well-known guest mount point for the read-only host config dir. Overridable -# for tests / unusual images. Kept under /var/lib/acq so it sits beside the -# (being-migrated) legacy guest markers without colliding with them. -ACQ_HOST_CONFIG_GUEST_DIR="${ACQ_HOST_CONFIG_GUEST_DIR:-/var/lib/acq/host}" +# The registry is DATA in config.yaml so the default provider is not special-cased +# in code: +# kit_sources: host/owner/repo[@REF][|DIR] ... (whitespace separated) +# provider_kit: / (or @) +# ACQ_KIT_SOURCES overrides the source list for one invocation. +ACQ_KIT_SOURCE_CONFIG_KEY="kit_sources" +ACQ_PROVIDER_KIT_CONFIG_KEY="provider_kit" +ACQ_KIT_SOURCE_DEFAULT_REPO="github.com/GSA-TTS/agentic-coding-patterns" +ACQ_KIT_SOURCE_DEFAULT_REF="$PATTERNS_KIT_REF" +ACQ_KIT_SOURCE_DEFAULT_DIR="$PATTERNS_KIT_DIR" +# The default provider kit, expressed as data. The short source name is matched +# against the registered sources, so bumping PATTERNS_KIT_REF needs no code edit. +ACQ_PROVIDER_KIT_DEFAULT="agentic-coding-patterns/${USAI_PROVIDER_KIT_NAME}" + +# _acq_kit_source_repo_ok REPO — host/owner/repo, lower-case host, no scheme. +_acq_kit_source_repo_ok() { + local repo="${1:-}" + [ -n "$repo" ] && [ "${#repo}" -le 200 ] || return 1 + printf '%s' "$repo" | LC_ALL=C grep -qE \ + '^[a-z0-9][a-z0-9.-]*/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' +} + +# _acq_kit_source_normalize_repo ARG — accept what a human pastes (a bare +# host/owner/repo, a clone URL, a git+https kit-style URL) and emit host/owner/repo. +# Fails closed on anything else so a typo cannot silently trust a different repo. +_acq_kit_source_normalize_repo() { + local arg="${1:-}" repo + repo="$arg" + repo="${repo#git+}" + case "$repo" in + *://*) repo="${repo#*://}" ;; + esac + case "$repo" in + *@*) return 1 ;; # credential-bearing URLs are never a kit source + esac + repo="${repo%%#*}" + repo="${repo%%\?*}" + repo="${repo%.git}" + repo="${repo%/}" + _acq_kit_source_repo_ok "$repo" || return 1 + printf '%s\n' "$repo" +} + +# _acq_kit_source_parse_entry ENTRY SOURCE — normalize "repo[@REF][|DIR]" into +# "reporefdir". REF may be empty (the source is then registered but +# unusable for fetching, which the reader reports as unpinned). DIR defaults to +# the neutral acq-kits path. +_acq_kit_source_parse_entry() { + local entry="${1:-}" source="${2:-kit_sources}" rest repo ref="" dir="" + [ -n "$entry" ] || return 1 + rest="$entry" + case "$rest" in + *'|'*) dir="${rest#*|}"; rest="${rest%%|*}" ;; + esac + case "$rest" in + *'@'*) ref="${rest#*@}"; rest="${rest%%@*}" ;; + esac + if ! repo=$(_acq_kit_source_normalize_repo "$rest"); then + echo "acq: ${source}: invalid kit source '$entry'" >&2 + echo " want host/owner/repo[@REF][|DIR] (for example github.com/acme/kits@)" >&2 + return 1 + fi + case "$ref" in + *[!A-Za-z0-9_.-]*) + echo "acq: ${source}: invalid ref '$ref' for '$repo'" >&2 + return 1 + ;; + esac + # An entry naming the shipped default source may omit @ref: it then follows the + # shipped PATTERNS_KIT_REF, so a future pin bump keeps applying. Only an + # explicit --ref freezes it. + if [ -z "$ref" ] && [ "$repo" = "$ACQ_KIT_SOURCE_DEFAULT_REPO" ]; then + ref="$ACQ_KIT_SOURCE_DEFAULT_REF" + fi + [ -n "$dir" ] || dir="$ACQ_KIT_SOURCE_DEFAULT_DIR" + case "$dir" in + /*|*..*|*[!A-Za-z0-9._/-]*) + echo "acq: ${source}: unsafe kit dir '$dir' for '$repo'" >&2 + return 1 + ;; + esac + printf '%s|%s|%s\n' "$repo" "$ref" "$dir" +} -# Provider inputs deliberately have a separate mount and host root. In -# particular, never place them below host config: host config can contain -# workspace, SSH, and kit state that provider consumers must not receive. -ACQ_PROVIDER_INPUT_GUEST_DIR="${ACQ_PROVIDER_INPUT_GUEST_DIR:-/var/lib/acq/provider-input}" +# _acq_kit_source_default_entry — the shipped default, as a registry ENTRY. +_acq_kit_source_default_entry() { + printf '%s@%s|%s\n' \ + "$ACQ_KIT_SOURCE_DEFAULT_REPO" "$ACQ_KIT_SOURCE_DEFAULT_REF" "$ACQ_KIT_SOURCE_DEFAULT_DIR" +} -# acq_provider_input_prepare — host-validate and stage provider-role kit inputs. -# -# The validator, schemas, and credential-binding registry are shipped by the -# pinned Patterns release. Do not duplicate its schema in this wrapper: fetch -# that fixed release and run its validator before -# copying any declared artifact. A provider kit without a manifest is simply -# not a provider-role kit; a manifest which cannot be validated is fatal. -# -# Return status 0 means discovery completed (including an authoritative empty -# set). Status 2 means the host cannot run the released validator and is a -# provisioning blocker, never an empty-set fallback. -_acq_provider_input_python() { - local python="${ACQ_PROVIDER_INPUT_PYTHON:-}" version_check runtime data_home tools_dir setup - if [ -n "$python" ]; then - [ -x "$python" ] || { - echo "acq: configured provider-input Python is not executable: $python" >&2 - return 2 - } +# acq_kit_sources_entries — echo one "reporefdir" per trusted source. +# Precedence: ACQ_KIT_SOURCES (this invocation) > config.yaml > shipped default. +acq_kit_sources_entries() { + local raw tokens=() token source_label + if [ -n "${ACQ_KIT_SOURCES:-}" ]; then + raw="$ACQ_KIT_SOURCES" + source_label="ACQ_KIT_SOURCES" else - if [ -n "${LOCALAPPDATA:-}" ] && [[ "$(uname -s 2>/dev/null)" = MINGW* || "$(uname -s 2>/dev/null)" = MSYS* || "$(uname -s 2>/dev/null)" = CYGWIN* ]]; then - data_home="$LOCALAPPDATA/acq" + raw=$(_acq_config_read_field "$ACQ_KIT_SOURCE_CONFIG_KEY") + if [ -n "$raw" ]; then + source_label="config.yaml ${ACQ_KIT_SOURCE_CONFIG_KEY}" else - data_home="${XDG_DATA_HOME:-${HOME:-}/.local/share}/acq" - fi - tools_dir="${ACQ_PROVIDER_INPUT_DATA_DIR:-$data_home/provider-input-validator}" - runtime="$tools_dir/provider-input-validator.current" - if [ -x "$runtime/bin/python3" ]; then python="$runtime/bin/python3" - elif [ -x "$runtime/Scripts/python.exe" ]; then python="$runtime/Scripts/python.exe" - elif [ -x "$runtime/python.exe" ]; then python="$runtime/python.exe" + raw=$(_acq_kit_source_default_entry) + source_label="default" fi fi - if [ -z "$python" ]; then - setup="$ACQ_SCRIPT_DIR/scripts/setup-provider-input-validator" - [ -x "$setup" ] || { - echo "acq: provider-input validation runtime is unavailable and this install lacks its offline bootstrap" >&2 - echo " Reinstall acq from an official release; validation remains mandatory." >&2 - return 2 - } - # A git checkout has no release wheelhouse; name the online setup command so - # contributors know exactly how to proceed from a working tree. - if [ -e "$ACQ_SCRIPT_DIR/.git" ]; then - local _wh_hint - _wh_hint="$ACQ_SCRIPT_DIR/scripts/provider-input-validator-wheelhouse" - # Only show the git-checkout hint when the default wheelhouse is absent. - if ! [ -d "$_wh_hint" ]; then - echo " Git checkout detected: run the following once to install from PyPI with hash verification:" >&2 - echo " scripts/setup-provider-input-validator --apply --online" >&2 - echo " Or set ACQ_PROVIDER_INPUT_PYTHON to a Python interpreter with PyYAML 6.0.3 and jsonschema 4.26.0." >&2 - fi - fi - "$setup" --apply >&2 || return 2 - runtime="$tools_dir/provider-input-validator.current" - if [ -x "$runtime/bin/python3" ]; then python="$runtime/bin/python3" - elif [ -x "$runtime/Scripts/python.exe" ]; then python="$runtime/Scripts/python.exe" - elif [ -x "$runtime/python.exe" ]; then python="$runtime/python.exe" - fi - [ -n "$python" ] || { - echo "acq: provider-input validator bootstrap did not publish a runtime" >&2 - return 2 - } + # `none` is the explicit "no trusted sources" spelling: the flat config writer + # removes a key when its value is empty, so absence cannot express it. + case "$raw" in + none|NONE|None) return 0 ;; + esac + split_noglob tokens "$raw" + for token in ${tokens[@]+"${tokens[@]}"}; do + _acq_kit_source_parse_entry "$token" "$source_label" || return 1 + done + return 0 +} + +# _acq_kit_source_owner_prefix REPO — the host/owner/ prefix of host/owner/repo. +# Backends that allowlist by prefix (sbx) can only express owner granularity; acq's +# own trust decisions (below) stay at exact-repository granularity. +_acq_kit_source_owner_prefix() { + local repo="${1:-}" + printf '%s/%s/\n' "${repo%%/*}" "$(printf '%s' "${repo#*/}" | cut -d/ -f1)" +} + +# acq_kit_source_prefixes [EXCLUDE_REPO] — the BACKEND allowlist (owner prefixes), +# derived from the registry; EXCLUDE_REPO is left out so a caller can ask what +# would REMAIN after removing it. ACQ_EXTRA_KIT_SOURCES (a list of host/owner/ +# prefixes) still works as a backward-compatible ADDITION so existing setups keep +# fetching their kits. +acq_kit_source_prefixes() { + local exclude="${1:-}" entries repo rest prefix seen="" + entries=$(acq_kit_sources_entries) || return 1 + while IFS='|' read -r repo rest; do + [ -n "$repo" ] || continue + [ "$repo" != "$exclude" ] || continue + prefix=$(_acq_kit_source_owner_prefix "$repo") + case " $seen " in *" $prefix "*) continue ;; esac + seen="$seen $prefix" + printf '%s\n' "$prefix" + done </dev/null 2>&1 || return 2 - root=$(acq_provider_input_root "$backend" "$name") || return 1 - _acq_provider_input_safe_mkdir "$root/generations" || return 1 - chmod 755 "$root" "$root/generations" || return 1 - stage=$(mktemp -d "${TMPDIR:-/tmp}/acq-provider-input.XXXXXXXX") || return 1 - chmod 700 "$stage" || { rm -rf "$stage"; return 1; } - for kitref in ${KITS[@]+"${KITS[@]}"}; do - token=$(printf '%s' "$kitref" | cksum | cut -d' ' -f1) || { rm -rf "$stage"; return 1; } - kitdir=$(kit_translate_fetch "$kitref" "$stage/fetch-$token") || { rm -rf "$stage"; return 2; } - manifest="$kitdir/provider-input.json" - [ -e "$manifest" ] || continue - [ -f "$manifest" ] && [ ! -L "$manifest" ] || { echo "acq: unsafe provider input manifest: $manifest" >&2; rm -rf "$stage"; return 1; } - _acq_provider_input_snapshot "$kitdir" "$stage/candidate-$token" || { rm -rf "$stage"; return 1; } - manifests+=("$stage/candidate-$token/provider-input.json") - kitdirs+=("$stage/candidate-$token") - done - # A generation is complete and immutable before an adapter mounts it. It is not - # visible through current until that adapter reports a successful create. - generation=$(mktemp -d "$root/generations/generation.XXXXXXXX") || { _acq_provider_input_cleanup_stage "$stage"; return 1; } - chmod 755 "$generation" || { _acq_provider_input_cleanup_stage "$stage"; _acq_provider_input_cleanup_generation "$root" "$generation"; return 1; } - _acq_provider_input_safe_mkdir "$generation/models/providers" || { _acq_provider_input_cleanup_stage "$stage"; _acq_provider_input_cleanup_generation "$root" "$generation"; return 1; } - chmod 755 "$generation/models" "$generation/models/providers" || { _acq_provider_input_cleanup_stage "$stage"; _acq_provider_input_cleanup_generation "$root" "$generation"; return 1; } - # A present empty generation is authoritative; it replaces current atomically. - if [ "${#manifests[@]}" -eq 0 ]; then - _acq_provider_input_cleanup_stage "$stage" - printf '%s\n' "$generation" +# _acq_kit_source_entry_for_repo REPO — echo "reporefdir" for a repo that +# is registered (exact host/owner/repo), or fail. +_acq_kit_source_entry_for_repo() { + local wanted="${1:-}" repo ref dir + [ -n "$wanted" ] || return 1 + while IFS='|' read -r repo ref dir; do + [ -n "$repo" ] || continue + [ "$repo" = "$wanted" ] || continue + printf '%s|%s|%s\n' "$repo" "$ref" "$dir" + return 0 + done <&2; _acq_provider_input_cleanup_stage "$stage"; _acq_provider_input_cleanup_generation "$root" "$generation"; return 2; } - python=$(_acq_provider_input_python) || { - _acq_provider_input_cleanup_stage "$stage"; _acq_provider_input_cleanup_generation "$root" "$generation"; return 2; } - local validator_args=(--schema-dir "$schema_dir" --credential-bindings "$bindings") - local i - for i in "${!manifests[@]}"; do validator_args+=(--bundle "${manifests[$i]}"); done - "$python" "$validator" "${validator_args[@]}" >&2 || { _acq_provider_input_cleanup_stage "$stage"; _acq_provider_input_cleanup_generation "$root" "$generation"; return 1; } - for i in "${!manifests[@]}"; do - _acq_provider_input_materialize "${manifests[$i]}" "${kitdirs[$i]}" "$generation/models/providers" || { _acq_provider_input_cleanup_stage "$stage"; _acq_provider_input_cleanup_generation "$root" "$generation"; return 1; } - done - _acq_provider_input_cleanup_stage "$stage" - printf '%s\n' "$generation" + return 1 } -# acq_provider_input_project is retained for callers outside backend creation. -# Adapters must use prepare, mount the returned generation, and publish only -# after their backend create succeeds. -acq_provider_input_project() { - local backend="$1" name="$2" generation root - generation=$(acq_provider_input_prepare "$backend" "$name") || return $? - root=$(acq_provider_input_root "$backend" "$name") || { - _acq_provider_input_cleanup_generation "${generation%/generations/*}" "$generation" || true - return 1 +# _acq_kit_source_resolve_repo WANT — accept a full repo or a unique source +# basename ("agentic-coding-patterns") and echo the full repo. +_acq_kit_source_resolve_repo() { + local want="${1:-}" repo ref dir found="" count=0 + [ -n "$want" ] || return 1 + # The shipped default source is the pinned control plane: always resolvable. + if [ "$want" = "${ACQ_KIT_SOURCE_DEFAULT_REPO##*/}" ]; then + printf '%s\n' "$ACQ_KIT_SOURCE_DEFAULT_REPO" + return 0 + fi + case "$want" in + */*) _acq_kit_source_entry_for_repo "$want" >/dev/null || { + echo "acq: kit source '$want' is not trusted." >&2 + echo " Add it first: acq kit-source add $want --ref " >&2 + return 1 + } + printf '%s\n' "$want" + return 0 + ;; + esac + while IFS='|' read -r repo ref dir; do + [ -n "$repo" ] || continue + [ "${repo##*/}" = "$want" ] || continue + count=$((count + 1)) + found="$repo" + done <&2 + echo " See: acq kit-source ls" >&2 + return 1 + fi + if [ "$count" -gt 1 ]; then + echo "acq: kit source name '$want' is ambiguous; use host/owner/repo." >&2 + echo " See: acq kit-source ls" >&2 + return 1 + fi + printf '%s\n' "$found" +} + +# _acq_kit_source_kit_ref REPO REF DIR KIT — the git+https ref acq fetches. +# DIR "." means the kits live at the repository root (no kits subdirectory), in +# which case the ref points straight at the kit directory. +_acq_kit_source_kit_ref() { + local repo="$1" ref="$2" dir="$3" kit="$4" path + if [ "$dir" = "." ]; then + path="$kit" + else + path="${dir}/${kit}" + fi + printf 'git+https://%s.git#ref=%s&dir=%s\n' "$repo" "$ref" "$path" +} + +# _acq_kit_source_ref_trusted REF — a durable (config.yaml) literal remote kit +# ref is trusted only if it names EXACTLY one repository that is registered. +# Trusting github.com/acme/trusted must not authorize github.com/acme/other, so +# the comparison is on host/owner/repo, never on the owner prefix. A ref that +# cannot be reduced to one plain https repository (credentials, extra path +# segments, a non-https scheme, odd encoding) is refused. The only owner-wide +# grant is the explicit, legacy ACQ_EXTRA_KIT_SOURCES prefix list. +_acq_kit_source_ref_trusted() { + local ref="${1:-}" body="" repo="" token _extra=() + case "$ref" in + git+https://*) body="${ref#git+https://}" ;; + esac + body="${body%%#*}" + # The lenient normalizer (built for what a human pastes) must not be the + # judge here: it strips through the LAST scheme separator, so a hostile + # authority could hide in front of a trusted-looking path. Require the body + # to already BE host/owner/repo[.git], byte for byte. + case "$body" in + *@*|*%*|*\?*|*://*|*:*|*//*|*/) body="" ;; + *[!A-Za-z0-9._/-]*) body="" ;; + */.|*/..|*/./*|*/../*) body="" ;; + esac + if [ -n "$body" ]; then + repo=$(_acq_kit_source_normalize_repo "$body") || repo="" + [ "$repo" = "${body%.git}" ] || repo="" + fi + if [ -n "$repo" ]; then + if _acq_kit_source_entry_for_repo "$repo" >/dev/null; then + return 0 + fi + if [ -n "${ACQ_EXTRA_KIT_SOURCES:-}" ]; then + split_noglob _extra "$ACQ_EXTRA_KIT_SOURCES" + for token in ${_extra[@]+"${_extra[@]}"}; do + case "$token" in + */) case "$repo" in "$token"*) return 0 ;; esac ;; + esac + done + fi + fi + echo "acq: provider_kit names an untrusted remote ref: $ref" >&2 + if [ -n "$repo" ]; then + echo " Trust its source first: acq kit-source add $repo --ref " >&2 + else + echo " It must name one https repository (host/owner/repo) that is trusted." >&2 + fi + return 1 +} + +# acq_provider_kit_ref [VALUE] — resolve the default provider kit (config +# provider_kit, else ACQ_PROVIDER_KIT_DEFAULT) to a fetchable kit ref. +# Accepted forms: @, /, host/owner/repo/. +acq_provider_kit_ref() { + local value="${1:-}" kit="" repo="" head + local from_config="" + if [ -z "$value" ]; then + value=$(_acq_config_read_field "$ACQ_PROVIDER_KIT_CONFIG_KEY") + [ -n "$value" ] || value="$ACQ_PROVIDER_KIT_DEFAULT" + [ -z "$value" ] || from_config=1 + fi + # A literal kit ref (local path or git+https) is used verbatim. A REMOTE ref + # that comes from config.yaml is additionally held to the trusted-source + # registry, per repository: a config file is durable state acq acts on + # automatically, unlike a ref the user typed on this command line (--kit) or + # the test seam. + case "$value" in + git+http*|/*|./*|../*) + if [ -n "$from_config" ]; then + case "$value" in + git+http*) _acq_kit_source_ref_trusted "$value" || return 1 ;; + esac + fi + printf '%s\n' "$value" + return 0 + ;; + esac + case "$value" in + *@*) + kit="${value%@*}" + repo="${value#*@}" + ;; + */*/*/*) + repo=$(printf '%s' "$value" | cut -d/ -f1-3) + kit=$(printf '%s' "$value" | cut -d/ -f4-) + ;; + */*) + head="${value%%/*}" + kit="${value#*/}" + repo="$head" + ;; + *) + kit="$value" + ;; + esac + if [ -z "$kit" ]; then + echo "acq: provider_kit '$value' does not name a kit." >&2 + return 1 + fi + case "$kit" in + *[!A-Za-z0-9._-]*) + echo "acq: provider_kit '$value' has an unsafe kit name '$kit'." >&2 + return 1 + ;; + esac + if [ -z "$repo" ]; then + repo=$(printf '%s' "$ACQ_PROVIDER_KIT_DEFAULT" | sed 's|/.*||') + fi + repo=$(_acq_kit_source_resolve_repo "$repo") || return 1 + local entry ref dir + entry=$(_acq_kit_source_entry_for_repo "$repo") || { + echo "acq: kit source '$repo' is not trusted." >&2 + return 1 + } + ref=$(printf '%s' "$entry" | cut -d'|' -f2) + dir=$(printf '%s' "$entry" | cut -d'|' -f3) + if [ -z "$ref" ]; then + echo "acq: kit source '$repo' has no pinned ref, so it cannot be fetched." >&2 + echo " Re-add it with an explicit ref: acq kit-source add $repo --ref " >&2 + return 1 + fi + _acq_kit_source_kit_ref "$repo" "$ref" "$dir" "$kit" +} + +# _acq_kit_source_rewrite DROP_REPO [ADD_REPO ADD_REF ADD_DIR] — rewrite the flat +# kit_sources value: drop DROP_REPO, then append the ADD_* entry when given. +# Emits "added" or "updated" on stdout. Writes "none" when the result is empty, +# because the flat writer removes a key whose value is empty (absence would +# resurrect the shipped default). +_acq_kit_source_rewrite() { + local drop="${1:-}" add_repo="${2:-}" add_ref="${3:-}" add_dir="${4:-}" + local entries repo ref dir updated="" seen=0 + entries=$(acq_kit_sources_entries) || return 1 + while IFS='|' read -r repo ref dir; do + [ -n "$repo" ] || continue + if [ "$repo" = "$drop" ]; then + seen=1 + continue + fi + # Emit the default source WITHOUT @ref when it still tracks the shipped pin, + # so the entry stays dynamic across a PATTERNS_KIT_REF bump. + if [ "$repo" = "$ACQ_KIT_SOURCE_DEFAULT_REPO" ] && [ "$ref" = "$ACQ_KIT_SOURCE_DEFAULT_REF" ]; then + updated="${updated:+$updated }${repo}" + else + updated="${updated:+$updated }${repo}@${ref}" + fi + case "$dir" in + ""|"$ACQ_KIT_SOURCE_DEFAULT_DIR") : ;; + *) updated="${updated}|${dir}" ;; + esac + done <label" per selectable provider kit: +# the shipped default first, then every provider kit discovered in a trusted +# source. Values are unambiguous `/` so a stored +# `provider_kit:` never depends on a basename being unique. +acq_provider_kit_candidates() { + local entries repo ref dir kits kit ksource kref seen + printf '%s\t%s\n' "$ACQ_PROVIDER_KIT_DEFAULT" "$ACQ_PROVIDER_KIT_DEFAULT (shipped)" + seen=" $ACQ_PROVIDER_KIT_DEFAULT " + entries=$(acq_kit_sources_entries) || return 1 + while IFS='|' read -r repo ref dir; do + [ -n "$repo" ] && [ -n "$ref" ] || continue + kits=$(acq_kit_source_provider_kits "$repo" "$ref" "$dir") + [ -n "$kits" ] || continue + while IFS="$(printf '\t')" read -r kit ksource kref _; do + [ -n "$kit" ] || continue + case " $seen " in + *" $ksource/$kit "*) continue ;; + esac + seen="$seen $ksource/$kit" + printf '%s\t%s\n' "$ksource/$kit" "$ksource/$kit (@${kref})" + done <&2; return 1; } + ref="$2"; shift 2 + ;; + --dir) + [ "$#" -ge 2 ] || { echo "acq: kit-source add: --dir needs a value" >&2; return 1; } + dir="$2"; shift 2 + ;; + *) + echo "acq: kit-source add: unexpected argument '$arg'" >&2 + echo " usage: acq kit-source add REPO --ref REF [--dir DIR]" >&2 + return 1 + ;; + esac + done + repo=$(_acq_kit_source_normalize_repo "$repo_arg") || { + echo "acq: kit-source add: invalid repository '$repo_arg'" >&2 + echo " want host/owner/repo (for example github.com/acme/kits)" >&2 + return 1 + } + if [ -z "$ref" ]; then + echo "acq: kit-source add: --ref is required." >&2 + echo " A trusted source is fetched at a pinned commit for reproducibility." >&2 + return 1 + fi + [ -n "$dir" ] || dir="$ACQ_KIT_SOURCE_DEFAULT_DIR" + # Validate the entry before persisting it (same rules the reader enforces). + _acq_kit_source_parse_entry "${repo}@${ref}|${dir}" "kit-source add" >/dev/null || return 1 + summary=$(_acq_kit_source_rewrite "$repo" "$repo" "$ref" "$dir") || { + echo "acq: kit-source add: could not write $(_acq_config_file)" >&2 + return 1 + } + case "$summary" in + updated) echo "acq: updated kit source '${repo}' (@${ref})." >&2 ;; + *) echo "acq: added kit source '${repo}' (@${ref})." >&2 ;; + esac + return 0 +} + +# acq_kit_source_rm REPO — drop one source (the shipped default included; with no +# sources left acq fetches nothing instead of silently reinstating it). +acq_kit_source_rm() { + local repo_arg="${1:-}" repo summary + if [ -z "$repo_arg" ]; then + echo "acq: kit-source rm: missing repository" >&2 + echo " usage: acq kit-source rm REPO" >&2 + return 1 + fi + repo=$(_acq_kit_source_normalize_repo "$repo_arg") || { + echo "acq: kit-source rm: invalid repository '$repo_arg'" >&2 + return 1 + } + if ! _acq_kit_source_entry_for_repo "$repo" >/dev/null; then + echo "acq: kit source '${repo}' is not registered." >&2 + return 1 + fi + # Reconcile the BACKEND allowlist first. If it cannot be brought in line, stop + # with the registry untouched: nothing is reported as removed, the source stays + # listed, and re-running this command after fixing the backend retries cleanly. + # (The reverse order would leave acq saying "removed" while the backend still + # permits the whole owner.) + if ! _acq_kit_source_revoke_in_backend "$repo"; then + echo "acq: kit source '${repo}' was NOT removed (still registered)." >&2 + return 1 + fi + summary=$(_acq_kit_source_rewrite "$repo") || { + echo "acq: kit-source rm: could not write $(_acq_config_file)" >&2 + return 1 + } + echo "acq: removed kit source '${repo}'." >&2 + return 0 +} + +# _acq_kit_source_revoke_in_backend REPO — ask the active backend (when it keeps +# its own source allowlist) to drop what no source remaining AFTER removing REPO +# needs. NO +# acq_backend_prepare: this is a config-only verb, and on msb a prepare can run +# `msb doctor --fix` (host mutation). acq_resolve_backend has already loaded the +# active adapter, so the hook exists when the backend implements one. +_acq_kit_source_revoke_in_backend() { + command -v acq_backend_kit_source_revoked >/dev/null 2>&1 || return 0 + acq_backend_kit_source_revoked "$1" +} + +# acq_kit_source_provider_kits ENTRY — list the provider kits a source publishes. +# Best-effort and OFFLINE-SAFE: a failed fetch yields nothing (callers say so) +# rather than failing the whole listing or a create. +acq_kit_source_provider_kits() { + local repo="${1:-}" ref="${2:-}" dir="${3:-}" cache seed root kit manifest + [ -n "$repo" ] && [ -n "$ref" ] && [ -n "$dir" ] || return 0 + command -v kit_translate_fetch >/dev/null 2>&1 || return 0 + seed=$(printf '%s@%s' "$repo" "$ref" | cksum 2>/dev/null | cut -d' ' -f1) + cache="${XDG_CACHE_HOME:-${HOME:-}/.cache}/acq/kit-sources/${seed:-0}" + root=$(kit_translate_fetch "git+https://${repo}.git#ref=${ref}&dir=${dir}" "$cache" 2>/dev/null) || { + acq_debug "kit-source: could not fetch $repo@$ref for discovery" + return 0 + } + [ -d "$root" ] || return 0 + for manifest in "$root"/*/provider-input.json; do + [ -f "$manifest" ] || continue + kit="${manifest%/provider-input.json}" + kit="${kit##*/}" + printf '%s\t%s\t%s\t%s\n' "$kit" "$repo" "$ref" "$dir" + done + return 0 +} + +# acq_kit_source_ls — sources, their pinned state, and the provider kits each +# publishes (discovery is best-effort; see acq_kit_source_provider_kits). +acq_kit_source_ls() { + local entries repo ref dir pinned kits kit ksource kref printed + entries=$(acq_kit_sources_entries) || return 1 + printf '%-52s %-12s %s\n' SOURCE REF KITS-DIR + while IFS='|' read -r repo ref dir; do + [ -n "$repo" ] || continue + if [ -n "$ref" ]; then pinned="$ref"; else pinned=""; fi + printf '%-52s %-12s %s\n' "$repo" "$pinned" "$dir" + done </ # e.g. agentic-coding-patterns/${USAI_PROVIDER_KIT_NAME}" + return 0 +} + +# ============================================================================ +# Provider credential bindings + generic provider key injection +# ============================================================================ +# A provider kit is DATA: it ships `provider-input.json` and, once the pinned +# validator accepts it, a materialized `facts.json` naming its providerId, +# keyEnv, host, modelsUrl, and apiProtocol. acq owns the registry that maps a +# providerId to BOTH the ENV var its credential arrives in AND the host(s) that +# credential may be sent to, so a provider kit hosted outside the pinned Patterns +# release (a user's own git+https repo) is injected with no provider-specific +# acq code — without turning "declare someone else's providerId" into a way to +# ship a stored credential to an attacker's host. +# +# The registry is a three-layer merge, highest precedence last: +# 1. the pinned Patterns `provider-credential-bindings.json` — the trust +# anchor, and the table the pinned validator itself checks facts against; +# 2. config.yaml `provider_credential_bindings:` — durable, user-owned; +# 3. ACQ_PROVIDER_CREDENTIAL_BINDINGS — this invocation. +# A layer entry is "providerId=KEY_ENV" or "providerId=KEY_ENV@host[,host]". +# Malformed entries fail closed: a typo must never silently bind a different +# provider's credential or a different destination host. +ACQ_PROVIDER_CREDENTIAL_CONFIG_KEY="provider_credential_bindings" + +# Providers acq trusts BY DEFAULT, as data: `id|keyEnv|host`. The pinned +# Patterns release is acq's validation+source trust anchor, so its provider comes +# pre-pinned. A new provider is pinned explicitly with +# `acq provider-credential set --host `. +# shellcheck disable=SC2034 # consumed by _acq_provider_trusted_default +ACQ_PROVIDER_TRUSTED_DEFAULTS="usai|${USAI_PROVIDER_KEY_ENV}|${USAI_PROVIDER_HOST}" + +# Process-critical variables a provider credential must never occupy. +ACQ_PROVIDER_FORBIDDEN_KEY_ENVS="PATH IFS LD_PRELOAD LD_LIBRARY_PATH BASH_ENV ENV HOME SHELLOPTS PS4 CDPATH TMPDIR" + +# Facts published by a provider kit are untrusted input until validated: they +# reach an sh -c probe and a backend --secret binding. `ACQ_PROVIDER_BINDINGS_GENERATION` +# is an INTERNAL seam only — it is reset at load so an inherited environment +# variable cannot redirect which facts acq trusts. It is declared `local` in the +# create gate, so it never outlives that one call. +ACQ_PROVIDER_BINDINGS_GENERATION="" + +# providerId grammar (shared with the validator's own provider naming rule). +_acq_provider_credential_id_ok() { + case "${1:-}" in + ''|*[!a-z0-9-]*) return 1 ;; + esac + # Bounded, lower-case, and never starting/ending with '-'. + printf '%s' "$1" | LC_ALL=C grep -qE '^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$' +} + +# KEY_ENV grammar: an upper-case environment variable name. +_acq_provider_credential_env_ok() { + case "${1:-}" in + '') return 1 ;; + esac + case " $ACQ_PROVIDER_FORBIDDEN_KEY_ENVS " in + *" $1 "*) return 1 ;; + esac + printf '%s' "$1" | LC_ALL=C grep -qE '^[A-Z][A-Z0-9_]*$' +} + +# _acq_provider_credential_env_owner KEY_ENV — echo the built-in service that +# owns KEY_ENV, or nothing. A provider may only reuse a reserved name when it IS +# that provider (e.g. providerId `usai` + USAI_API_KEY). +_acq_provider_credential_env_owner() { + case "${1:-}" in + USAI_API_KEY) printf 'usai\n' ;; + GITHUB_TOKEN|GH_TOKEN) printf 'github\n' ;; + GITLAB_TOKEN) printf 'gitlab\n' ;; + esac +} + +# _acq_provider_credential_host_ok HOST — one DNS name, lower-case, no port, no +# path, no wildcard, no wildcard-cert tricks (a leading dot or '*' is rejected). +_acq_provider_credential_host_ok() { + local host="${1:-}" + [ -n "$host" ] && [ "${#host}" -le 253 ] || return 1 + printf '%s' "$host" | LC_ALL=C grep -qE \ + '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$' +} + +# _acq_provider_fact_strict_url_ok URL [HOST] — provider-KIT facts are stricter +# than the shared USAi facts contract: a kit may declare any host, so its models +# URL must also be free of shell metacharacters even though acq passes it as +# positional data (defense in depth), and it must stay on the declared host. +_acq_provider_fact_strict_url_ok() { + local url="${1:-}" host="${2:-}" + _acq_provider_fact_safe_url "$url" || return 1 + case "$url" in + *'&'*|*'#'*|*';'*|*'|'*|*'`'*|*'$'*|*'"'*|*"'"*|*'('*|*')'*|*'<'*|*'>'*) return 1 ;; + esac + if [ -n "$host" ]; then + case "$url" in + "https://$host/"*|"https://$host"|"https://$host:443/"*) ;; + *) return 1 ;; + esac + fi + return 0 +} + +# _acq_provider_credential_check_env OWNER PROVIDER_ID KEY_ENV — validate a +# KEY_ENV for a provider, including the reserved/forbidden name rules. +_acq_provider_credential_check_env() { + local source="$1" provider_id="$2" key_env="$3" owner + if ! _acq_provider_credential_env_ok "$key_env"; then + echo "acq: ${source}: invalid KEY_ENV '$key_env' for provider '$provider_id'" >&2 + echo " want an upper-case env var name (for example: OPENCODE_GO_API_KEY)" >&2 + return 1 + fi + owner=$(_acq_provider_credential_env_owner "$key_env") + if [ -n "$owner" ] && [ "$owner" != "$provider_id" ]; then + echo "acq: ${source}: KEY_ENV '$key_env' belongs to the '$owner' service," >&2 + echo " so provider '$provider_id' cannot claim it. Use a provider-specific name." >&2 + return 1 + fi + return 0 +} + +# _acq_provider_credential_parse_pairs SOURCE PAIRS — normalize a +# whitespace-separated list of "=[@[,]]" into one +# entry per line, validating every field. Fails closed (rc 1, nothing on stdout) +# on a malformed token. +_acq_provider_credential_parse_pairs() { + local source="$1" pairs="$2" tokens=() token provider_id rest key_env hosts hosts_out host IFS + [ -n "$pairs" ] || return 0 + split_noglob tokens "$pairs" + for token in ${tokens[@]+"${tokens[@]}"}; do + case "$token" in + *=*) ;; + *) + echo "acq: ${source}: expected '=', got '$token'" >&2 + return 1 + ;; + esac + provider_id="${token%%=*}" + rest="${token#*=}" + case "$rest" in + *@*) key_env="${rest%%@*}"; hosts="${rest#*@}" ;; + *) key_env="$rest"; hosts="" ;; + esac + case "$key_env" in + *@*|*=*) + echo "acq: ${source}: '$token' has a malformed credential name" >&2 + return 1 + ;; + esac + if ! _acq_provider_credential_id_ok "$provider_id"; then + echo "acq: ${source}: invalid provider id '$provider_id'" >&2 + echo " want ^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?\$ (for example: opencode-go)" >&2 + return 1 + fi + _acq_provider_credential_check_env "$source" "$provider_id" "$key_env" || return 1 + hosts_out="" + if [ -n "$hosts" ]; then + IFS=',' + for host in $hosts; do + if ! _acq_provider_credential_host_ok "$host"; then + echo "acq: ${source}: invalid host '$host' for provider '$provider_id'" >&2 + echo " want a bare DNS name such as models.example.gov (no scheme, port, or path)" >&2 + return 1 + fi + hosts_out="${hosts_out:+$hosts_out,}$host" + done + fi + if [ -n "$hosts_out" ]; then + printf '%s=%s@%s\n' "$provider_id" "$key_env" "$hosts_out" + else + printf '%s=%s\n' "$provider_id" "$key_env" + fi + done + return 0 +} + +# acq_provider_credential_overrides — echo the CONFIG then ENV layers as +# normalized entries (lowest first). The pinned base layer is merged where it is +# actually available (acq_provider_input_prepare, which already fetched the +# pinned release), so this stays offline and cheap. +acq_provider_credential_overrides() { + local configured + configured=$(_acq_config_read_field "$ACQ_PROVIDER_CREDENTIAL_CONFIG_KEY") + _acq_provider_credential_parse_pairs "config.yaml ${ACQ_PROVIDER_CREDENTIAL_CONFIG_KEY}" "$configured" || return 1 + _acq_provider_credential_parse_pairs "ACQ_PROVIDER_CREDENTIAL_BINDINGS" \ + "${ACQ_PROVIDER_CREDENTIAL_BINDINGS:-}" || return 1 +} + +# _acq_provider_credential_field ENTRY FIELD — read id|env|hosts out of one +# normalized entry ("id=ENV[@hosts]"). +_acq_provider_credential_field() { + local entry="$1" field="$2" rest + case "$field" in + id) printf '%s' "${entry%%=*}" ;; + env) rest="${entry#*=}"; printf '%s' "${rest%%@*}" ;; + hosts) + case "$entry" in + *@*) printf '%s' "${entry#*@}" ;; + *) printf '' ;; + esac + ;; + esac +} + +# acq_provider_credential_lookup PROVIDER_ID FIELD — echo id|env|hosts for the +# highest-precedence entry naming PROVIDER_ID (ENV overrides CONFIG). +acq_provider_credential_lookup() { + local wanted="${1:-}" field="${2:-}" pairs entry found="" + [ -n "$wanted" ] || return 0 + # A malformed durable pin is an error, not a reason to fall back to a host a + # kit chose: the caller can only honour the registry if it is well formed. + pairs=$(acq_provider_credential_overrides) || return 1 + while IFS= read -r entry; do + [ -n "$entry" ] || continue + [ "$(_acq_provider_credential_field "$entry" id)" = "$wanted" ] || continue + found="$entry" + done <&2 + return 1 + } + hosts="${hosts:+$hosts,}$2" + shift 2 + ;; + --host=*) + hosts="${hosts:+$hosts,}${arg#--host=}" + shift + ;; + *) + echo "acq: provider-credential set: unexpected argument '$arg'" >&2 + echo " usage: acq provider-credential set PROVIDER KEY_ENV --host HOST[,HOST]" >&2 + return 1 + ;; + esac + done + if ! _acq_provider_credential_id_ok "$provider_id"; then + echo "acq: provider-credential set: invalid provider id '${provider_id}'" >&2 + echo " want ^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?\$ (for example: opencode-go)" >&2 + return 1 + fi + _acq_provider_credential_check_env "provider-credential set" "$provider_id" "$key_env" || return 1 + if [ -z "$hosts" ]; then + echo "acq: provider-credential set: a --host is required." >&2 + echo " The host is the trust decision that lets acq send this credential" >&2 + echo " to a destination, so acq never infers it from a kit's facts." >&2 + echo " usage: acq provider-credential set $provider_id $key_env --host models.example.gov" >&2 + return 1 + fi + configured=$(_acq_config_read_field "$ACQ_PROVIDER_CREDENTIAL_CONFIG_KEY") + existing=$(_acq_provider_credential_parse_pairs \ + "config.yaml ${ACQ_PROVIDER_CREDENTIAL_CONFIG_KEY}" "$configured") || { + echo "acq: provider-credential set: refusing to rewrite a malformed" >&2 + echo " ${ACQ_PROVIDER_CREDENTIAL_CONFIG_KEY} entry in $(_acq_config_file)" >&2 + echo " Fix that line by hand, then retry." >&2 + return 1 + } + while IFS= read -r line; do + [ -n "$line" ] || continue + case "$line" in + "${provider_id}="*) continue ;; + esac + updated="${updated:+$updated }$line" + done </dev/null || return 1 + if ! _acq_config_write_field "$ACQ_PROVIDER_CREDENTIAL_CONFIG_KEY" "$updated"; then + echo "acq: provider-credential set: could not write $(_acq_config_file)" >&2 + return 1 + fi + echo "acq: bound provider '${provider_id}' to \$${key_env} for host(s) ${hosts}." >&2 + return 0 +} + +# acq_provider_credential_rm PROVIDER_ID — drop the config.yaml binding. The +# ENV layer is per-invocation, so it cannot be removed durably (and saying so is +# more useful than silently reporting success). +acq_provider_credential_rm() { + local provider_id="${1:-}" configured existing line updated="" removed=0 + if ! _acq_provider_credential_id_ok "$provider_id"; then + echo "acq: provider-credential rm: invalid provider id '${provider_id}'" >&2 + return 1 + fi + configured=$(_acq_config_read_field "$ACQ_PROVIDER_CREDENTIAL_CONFIG_KEY") + existing=$(_acq_provider_credential_parse_pairs \ + "config.yaml ${ACQ_PROVIDER_CREDENTIAL_CONFIG_KEY}" "$configured") || { + echo "acq: provider-credential rm: refusing to rewrite a malformed" >&2 + echo " ${ACQ_PROVIDER_CREDENTIAL_CONFIG_KEY} entry in $(_acq_config_file)" >&2 + return 1 + } + while IFS= read -r line; do + [ -n "$line" ] || continue + case "$line" in + "${provider_id}="*) removed=1; continue ;; + esac + updated="${updated:+$updated }$line" + done <&2 + echo " (An ACQ_PROVIDER_CREDENTIAL_BINDINGS entry is per-invocation only.)" >&2 + return 1 + fi + _acq_config_write_field "$ACQ_PROVIDER_CREDENTIAL_CONFIG_KEY" "$updated" || { + echo "acq: provider-credential rm: could not write $(_acq_config_file)" >&2 + return 1 + } + echo "acq: removed the '${provider_id}' binding from $(_acq_config_file)." >&2 + return 0 +} + +# acq_provider_credential_ls — list the USER-owned layers (config, env) with the +# host each credential may be sent to. The pinned defaults still apply underneath +# and are named in the footer rather than fetched, so `ls` never needs network. +acq_provider_credential_ls() { + local configured from_config from_env printed + configured=$(_acq_config_read_field "$ACQ_PROVIDER_CREDENTIAL_CONFIG_KEY") + from_config=$(_acq_provider_credential_parse_pairs \ + "config.yaml ${ACQ_PROVIDER_CREDENTIAL_CONFIG_KEY}" "$configured") || return 1 + from_env=$(_acq_provider_credential_parse_pairs \ + "ACQ_PROVIDER_CREDENTIAL_BINDINGS" "${ACQ_PROVIDER_CREDENTIAL_BINDINGS:-}") || return 1 + printf '%-24s %-32s %-32s %s\n' PROVIDER KEY_ENV HOST SOURCE + printed=0 + if [ -n "$from_config" ]; then + _acq_provider_credential_ls_rows "$from_config" "config.yaml" && printed=1 + fi + if [ -n "$from_env" ]; then + _acq_provider_credential_ls_rows "$from_env" "env" && printed=1 + fi + [ "$printed" -eq 1 ] || printf '%-24s %-32s %-32s %s\n' '(none)' '-' '-' '-' + echo "" + echo "HOST is the pinned destination: acq refuses to send a stored credential" + echo "anywhere else. 'acq secret set -g ' stores the value itself." + return 0 +} + +# _acq_provider_credential_ls_rows ENTRIES SOURCE — print table rows directly on +# stdout (never captured into a variable, which would strip the final newline and +# glue the config block onto the env block). +_acq_provider_credential_ls_rows() { + local entries="$1" source="$2" line hosts + [ -n "$entries" ] || return 0 + while IFS= read -r line; do + [ -n "$line" ] || continue + hosts=$(_acq_provider_credential_field "$line" hosts) + printf '%-24s %-32s %-32s %s\n' \ + "$(_acq_provider_credential_field "$line" id)" \ + "$(_acq_provider_credential_field "$line" env)" \ + "${hosts:-}" "$source" + done <&2 + echo " Refusing to use it; recreate the sandbox to re-record provider facts." >&2 + return 1 + } + printf '%s\n' "$records" +} + +# _acq_provider_facts_python — resolve a Python runtime for reading facts.json +# WITHOUT bootstrapping the validator (facts are read on hot paths). Honors +# ACQ_PROVIDER_INPUT_PYTHON and the installed validator runtime first, so +# Windows (`Scripts/python.exe`) and custom interpreters work; only then PATH. +_acq_provider_facts_python() { + local python="${ACQ_PROVIDER_INPUT_PYTHON:-}" runtime data_home + if [ -n "$python" ] && [ -x "$python" ]; then + printf '%s\n' "$python" + return 0 + fi + if [ -n "${LOCALAPPDATA:-}" ] && [ "$(uname -s 2>/dev/null)" != "Linux" ]; then + data_home="$LOCALAPPDATA/acq" + else + data_home="${XDG_DATA_HOME:-${HOME:-}/.local/share}/acq" + fi + runtime="${ACQ_PROVIDER_INPUT_DATA_DIR:-$data_home/provider-input-validator}.current" + if [ -x "$runtime/bin/python3" ]; then + printf '%s\n' "$runtime/bin/python3" + return 0 + fi + if [ -x "$runtime/Scripts/python.exe" ]; then + printf '%s\n' "$runtime/Scripts/python.exe" + return 0 + fi + if [ -x "$runtime/python.exe" ]; then + printf '%s\n' "$runtime/python.exe" + return 0 + fi + if command -v python3 >/dev/null 2>&1; then + printf '%s\n' "python3" + return 0 + fi + return 1 +} + +# acq_provider_facts_json_fields FILE — echo one TAB-separated record: +# providerIdkeyEnvhostmodelsUrlapiProtocol +# rc 1 when FILE is absent (no provider facts to read), 2 when it is present but +# not a usable facts document (including "no Python runtime available"). Every +# value is re-validated after parsing: these values reach an sh -c probe and a +# --secret binding, so a tampered facts.json must not be able to smuggle shell +# syntax, a cross-host models URL, or an unsafe ENV name any further. +acq_provider_facts_json_fields() { + local file="${1:-}" python value provider_id="" key_env="" host="" models_url="" api_protocol="" count=0 + [ -n "$file" ] || return 2 + { [ -f "$file" ] && [ ! -L "$file" ]; } || return 1 + python=$(_acq_provider_facts_python) || { + echo "acq: no Python runtime available to read provider facts ($file)." >&2 + echo " Set ACQ_PROVIDER_INPUT_PYTHON or install the validator runtime:" >&2 + echo " scripts/setup-provider-input-validator --apply" >&2 + return 2 + } + while IFS= read -r value; do + count=$((count + 1)) + case "$count" in + 1) provider_id="$value" ;; + 2) key_env="$value" ;; + 3) host="$value" ;; + 4) models_url="$value" ;; + 5) api_protocol="$value" ;; + esac + done < <("$python" - "$file" <<'PY' +import json, sys +from urllib.parse import urlsplit + +try: + with open(sys.argv[1], encoding="utf-8") as handle: + facts = json.load(handle) +except Exception: + raise SystemExit(2) +if not isinstance(facts, dict): + raise SystemExit(2) +values = {} +for name in ("providerId", "keyEnv", "host", "modelsUrl", "apiProtocol"): + value = facts.get(name) + if not isinstance(value, str) or not value: + raise SystemExit(2) + if any(character in value for character in "\t\n\r"): + raise SystemExit(2) + values[name] = value +# The credential is only ever sent to facts.host, so a models URL naming any +# other host (or a non-https scheme) is a mismatch, not a warning. +parsed = urlsplit(values["modelsUrl"]) +if parsed.scheme != "https" or parsed.hostname != values["host"]: + raise SystemExit(2) +if parsed.port not in (None, 443): + raise SystemExit(2) +for value in values.values(): + print(value) +PY + ) + [ "$count" -eq 5 ] || return 2 + _acq_provider_credential_id_ok "$provider_id" || return 2 + _acq_provider_credential_env_ok "$key_env" || return 2 + _acq_provider_credential_host_ok "$host" || return 2 + _acq_provider_fact_strict_url_ok "$models_url" "$host" || return 2 + case "$api_protocol" in + ''|*[!A-Za-z0-9._-]*) return 2 ;; + esac + printf '%s\t%s\t%s\t%s\t%s\n' "$provider_id" "$key_env" "$host" "$models_url" "$api_protocol" +} + +# acq_provider_facts_list_generation GENERATION — one validated facts record per +# provider directory of a materialized generation. FAILS CLOSED (non-zero) on the +# first record that does not validate: the generation was gated by the pinned +# validator, so anything unreadable now means tampering, not "no providers". +acq_provider_facts_list_generation() { + local generation="${1:-}" providers_dir record + [ -n "$generation" ] || return 0 + providers_dir="$generation/models/providers" + [ -d "$providers_dir" ] || return 0 + for record in "$providers_dir"/*/facts.json; do + [ -e "$record" ] || continue + acq_provider_facts_json_fields "$record" || { + echo "acq: invalid provider facts: $record" >&2 + return 1 + } + done + return 0 +} + +# acq_provider_credentials_write BACKEND NAME GENERATION — record the injected +# provider credentials (providerId, keyEnv, host, modelsUrl) in the sandbox's +# HOST config. Resume/heal then re-inject the identical bindings without reading +# a generation a later create may have replaced, and without re-prompting. +# Called only AFTER the gate succeeds, and best-effort (fail-open) like the +# provenance/cli-kits records. +acq_provider_credentials_write() { + local backend="${1:-}" name="${2:-}" generation="${3:-}" records + [ -n "$backend" ] && [ -n "$name" ] || return 1 + records=$(acq_provider_facts_list_generation "$generation") || return 1 + if [ -z "$records" ]; then + acq_debug "provider-credentials: no provider facts to record for $backend/$name" + return 0 + fi + acq_host_config_write "$backend" "$name" provider-credentials "$records" +} + +# acq_provider_recorded_host BACKEND NAME PROVIDER_ID — the host recorded for a +# provider at create time (empty when nothing is recorded). Reads through +# acq_provider_recorded_facts, so a tampered record is rejected, not parsed. +acq_provider_recorded_host() { + local backend="${1:-}" name="${2:-}" wanted="${3:-}" tab provider_id key_env host rest records + [ -n "$wanted" ] || return 0 + records=$(acq_provider_recorded_facts "$backend" "$name") || return 1 + [ -n "$records" ] || return 0 + tab=$(printf '\t') + while IFS="$tab" read -r provider_id key_env host rest; do + [ -n "$provider_id" ] || continue + [ "$provider_id" = "$wanted" ] || continue + printf '%s\n' "$host" + return 0 + done </dev/null) || return 0 + while IFS= read -r line; do + [ -n "$line" ] || continue + printf '%s\n' "${line%%=*}" + done </dev/null | awk 'NF && !seen[$0]++' +} + +# acq_provider_binding BACKEND NAME PROVIDER_ID — echo "hostkeyEnvmodelsUrl". +# The REGISTRY is authoritative for keyEnv (so a config/env override changes what +# is injected, not just what `ls` prints); facts supply host and modelsUrl. A +# registered host may stand in when no facts exist yet (a standalone +# `acq secret set`), in which case modelsUrl is empty. +acq_provider_binding() { + local backend="${1:-}" name="${2:-}" wanted="${3:-}" tab provider_id facts_key_env host models_url api_protocol + local key_env registry_hosts facts + [ -n "$wanted" ] || return 1 + key_env=$(acq_provider_credential_key_env "$wanted") || return 1 + registry_hosts=$(acq_provider_credential_hosts "$wanted") || return 1 + tab=$(printf '\t') + facts=$(acq_provider_sandbox_facts "$backend" "$name") || return 1 + while IFS="$tab" read -r provider_id facts_key_env host models_url api_protocol; do + [ -n "$provider_id" ] || continue + [ "$provider_id" = "$wanted" ] || continue + [ -n "$key_env" ] || key_env="$facts_key_env" + printf '%s\t%s\t%s\n' "$host" "$key_env" "$models_url" + return 0 + done <keyEnv" for a +# provider service, the pair the backend adapters bind at create time. The +# sandbox is passed EXPLICITLY: resume and `acq secret rm` resolve it for an +# existing sandbox, where no create-time ambient state exists. +acq_provider_binding_host_env() { + local service="${1:-}" sandbox="${2:-}" binding + [ -n "$service" ] || return 1 + binding=$(acq_provider_binding "${ACQ_RESOLVED_BACKEND:-}" "$sandbox" "$service" 2>/dev/null) || return 1 + printf '%s\n' "$binding" | cut -f1-2 +} + +# _acq_provider_credentials_gate_trust BACKEND NAME PROVIDER_ID HOST — the trust +# check that makes providerId spoofing useless. A kit may declare any providerId +# it likes (and allow-list the host), so before acq binds a stored credential it +# requires the destination host to be pinned for that providerId, either in the +# acq-owned registry or in this sandbox's recorded credentials. +_acq_provider_credentials_gate_trust() { + local backend="$1" name="$2" provider_id="$3" host="$4" pinned recorded + pinned=$(acq_provider_credential_hosts "$provider_id") + if [ -n "$pinned" ]; then + case ",$pinned," in + *",$host,"*) return 0 ;; + esac + echo "acq: provider '$provider_id' declares host '$host', which is not one of" >&2 + echo " its pinned hosts (${pinned}). Refusing to send the stored credential there." >&2 + echo " If that host is legitimate, re-pin it explicitly:" >&2 + echo " acq provider-credential set $provider_id --host $host" >&2 + return 1 + fi + recorded=$(acq_provider_recorded_host "$backend" "$name" "$provider_id") || return 1 + if [ -n "$recorded" ]; then + if [ "$recorded" = "$host" ]; then + return 0 + fi + echo "acq: provider '$provider_id' host changed for sandbox '$name':" >&2 + echo " recorded '$recorded', kit now declares '$host'." >&2 + echo " Refusing to silently move your credential. Re-pin the new host:" >&2 + echo " acq provider-credential set $provider_id --host $host" >&2 + return 1 + fi + echo "acq: provider '$provider_id' is not pinned to a host yet (kit declares '$host')." >&2 + echo " acq will not send a stored credential to a host chosen by the kit." >&2 + echo " Pin it explicitly, then re-run:" >&2 + echo " acq provider-credential set $provider_id --host $host" >&2 + return 1 +} + +# _acq_provider_credentials_gate_one BACKEND NAME PROVIDER_ID KEY_ENV HOST — make +# sure one provider's credential is storable and injectable before the create. +_acq_provider_credentials_gate_one() { + local backend="$1" name="$2" provider_id="$3" key_env="$4" host="$5" answer="" exported="" stored=0 + # An sbx compiled-in vendor service would silently use its own endpoint instead + # of facts.host, so a provider must not shadow one. + if [ "$backend" = "sbx" ]; then + case " ${_ACQ_SBX_BUILTIN_SERVICES:-} " in + *" $provider_id "*) + echo "acq: provider id '$provider_id' collides with an sbx built-in service," >&2 + echo " which would bind to sbx's own vendor endpoint instead of '$host'." >&2 + echo " Rename the provider in its kit (provider-input.json)." >&2 + return 1 + ;; + esac + fi + command -v acq_secret_has >/dev/null 2>&1 || return 0 + if acq_secret_has "$provider_id" "$name"; then + acq_key_injectable "$provider_id" "$name" && return 0 + stored=1 + echo "acq: the '$provider_id' key is stored, but the active backend cannot" >&2 + echo " inject it for '$host' yet." >&2 + fi + # A host-exported value (CI) is bindable only where the backend reads host env + # at create. sbx snapshots its own proxy table, so claiming success there would + # create a sandbox with a dead credential. + exported=$(_acq_provider_env_value "$key_env") + if [ -n "$exported" ]; then + case "$backend" in + msb) return 0 ;; + *) + echo "acq: \$${key_env} is exported on the host, but the '$backend' backend" >&2 + echo " cannot bind a credential from host env at create. Store it instead:" >&2 + echo " acq secret set -g $provider_id" >&2 + return 1 + ;; + esac + fi + if [ ! -t 0 ]; then + if [ "$stored" -eq 1 ]; then + echo "acq: no injectable key for provider '$provider_id' (\$${key_env})." >&2 + echo " Re-store it from a terminal so the binding is created:" >&2 + else + echo "acq: no key stored for provider '$provider_id' (\$${key_env})." >&2 + echo " Set one with:" >&2 + fi + echo " acq secret set -g $provider_id" >&2 + return 1 + fi + echo >&2 + if [ "$stored" -eq 1 ]; then + echo "Provider '$provider_id' needs its key re-stored to bind \$${key_env} here." >&2 + echo " sends to: ${host}" >&2 + printf 'Re-store it now? [y/N] ' >&2 + else + echo "Store a credential for provider '$provider_id'?" >&2 + echo " env var: \$${key_env}" >&2 + echo " sends to: ${host}" >&2 + printf 'Store it now? [y/N] ' >&2 + fi + read -r answer || true + case "$answer" in + [yY]|[yY][eE][sS]) ;; + *) + echo "Skipping. Aborting; re-run when the '$provider_id' key is set." >&2 + return 1 + ;; + esac + if ! command -v acq_backend_secret_set >/dev/null 2>&1; then + echo "acq: the '${ACQ_RESOLVED_BACKEND:-active}' backend cannot store provider keys." >&2 + return 1 + fi + acq_backend_secret_set -g "$provider_id" || return 1 + acq_key_injectable "$provider_id" "$name" || { + echo "acq: the '$provider_id' key is still not injectable. Aborting." >&2 + return 1 + } + return 0 +} + +# acq_provider_credentials_gate BACKEND NAME GENERATION — run AFTER +# acq_provider_input_prepare and BEFORE the backend create, for the same reason +# the USAi gate exists: msb binds secrets only at create (`--secret ENV@HOST`) +# and sbx snapshots its proxy table at create, so a credential stored later never +# reaches the sandbox. USAi keeps its dedicated flow (ensure_key_present / +# ensure_valid_key); this gate covers every OTHER provider. +acq_provider_credentials_gate() { + local backend="${1:-}" name="${2:-}" generation="${3:-}" records provider_id key_env host models_url api_protocol tab + # Dynamic scoping: visible to the helpers below for THIS call only, so the + # USAi throwaway-sandbox probe can never inherit another sandbox's facts. + local ACQ_PROVIDER_BINDINGS_GENERATION + [ -n "$backend" ] && [ -n "$name" ] || return 1 + records=$(acq_provider_facts_list_generation "$generation") || return 1 + [ -n "$records" ] || return 0 + ACQ_PROVIDER_BINDINGS_GENERATION="$generation" + tab=$(printf '\t') + while IFS="$tab" read -r provider_id key_env host models_url api_protocol; do + [ -n "$provider_id" ] || continue + # Skip ONLY ids a backend hard-codes at its binding layer (see + # _acq_provider_is_host_service): `usai` is routed through the generic + # provider path like every other provider. + _acq_provider_is_host_service "$provider_id" && continue + _acq_provider_credentials_gate_trust "$backend" "$name" "$provider_id" "$host" || return 1 + _acq_provider_credentials_gate_one "$backend" "$name" "$provider_id" "$key_env" "$host" || return 1 + done <&2 + return 1 + fi + binding=$(acq_provider_binding "${ACQ_RESOLVED_BACKEND:-}" "$name" "$provider_id") || { + echo "acq: no validated provider facts for '$provider_id' in sandbox '$name'." >&2 + return 1 + } + host=$(printf '%s\n' "$binding" | cut -f1) + key_env=$(printf '%s\n' "$binding" | cut -f2) + models_url=$(printf '%s\n' "$binding" | cut -f3) + _acq_provider_credential_env_ok "$key_env" || { + echo "acq: provider '$provider_id' declares an unsafe credential env name." >&2 + return 1 + } + # Enforce the same-host invariant at the probe, not just when facts were read: + # the Bearer token must never be sent to a host other than facts.host. + _acq_provider_fact_strict_url_ok "$models_url" "${host%%,*}" || { + echo "acq: provider '$provider_id' models URL does not match its declared host ($host)." >&2 + return 1 + } + command -v acq_backend_run >/dev/null 2>&1 || return 0 + key_ref="\$${key_env}" + raw=$(acq_backend_run "$name" -- sh -c \ + "curl -sS -o /dev/null -w '%{http_code}' \ + -H \"Authorization: Bearer $key_ref\" \ + \"\$1\"; printf '|%s' \"\$?\"" sh "$models_url" 2>/dev/null || true) + _classify_key_status "$raw" +} + +# ensure_valid_provider_keys NAME — validate every selected provider's credential +# against its own facts.modelsUrl. There is no USAi branch: the default provider +# is checked exactly like any other, and its 401 remediation points at the +# dedicated rotate verb. An inconclusive probe FAILS CLOSED (silently attaching +# with an unverified credential is how a dead key reaches the agent), while +# 429/5xx are treated as transient. +ensure_valid_provider_keys() { + local name="${1:-}" ids provider_id status + [ -n "$name" ] || return 0 + command -v acq_backend_run >/dev/null 2>&1 || return 0 + acq_debug "provider keys: validating non-usai providers for '$name'" + ids=$(acq_provider_sandbox_provider_ids "${ACQ_RESOLVED_BACKEND:-}" "$name") || { + acq_debug "provider keys: provider facts are invalid for '$name'" + return 1 + } + acq_debug "provider keys: ids=$(printf '%s' "$ids" | tr '\n' ' ')" + [ -n "$ids" ] || return 0 + while IFS= read -r provider_id; do + [ -n "$provider_id" ] || continue + # Never probe a host service: its token belongs to a compiled-in endpoint, + # and a kit that declares the id would otherwise direct it to its own host. + _acq_provider_is_host_service "$provider_id" && continue + status=$(check_provider_key "$name" "$provider_id") + case "$status" in + "200") continue ;; + "") + echo "acq: could not validate the '$provider_id' provider key for '$name'" >&2 + echo " (no models endpoint in its facts, or the probe returned no status)." >&2 + echo " Refusing to attach with an unverified credential." >&2 + return 1 + ;; + unreachable) + echo "acq: the '$provider_id' models endpoint could not be reached from" >&2 + echo " sandbox '$name'. That is a network problem, not a credential one." >&2 + return 1 + ;; + unresolved) + echo "acq: sandbox '$name' could not resolve the '$provider_id' provider host (DNS)." >&2 + return 1 + ;; + 3??) + echo "acq: the '$provider_id' models endpoint redirected (HTTP ${status})." >&2 + echo " Update the kit's facts.modelsUrl to the final endpoint." >&2 + return 1 + ;; + 401|403) + echo "acq: provider '$provider_id' rejected the stored key (HTTP ${status})." >&2 + if [ "$(acq_provider_credential_key_env "$provider_id")" = "$USAI_PROVIDER_KEY_ENV" ]; then + # The default provider's remediation stays the dedicated rotate verb + # (it knows the USAi key-management console); no interactive prompt. + echo " Rotate it with: acq usai-rotate-api-key" >&2 + echo " Or re-store it: acq secret set -g $provider_id" >&2 + else + echo " Update it with: acq secret set -g $provider_id" >&2 + fi + return 1 + ;; + 429|5??) + echo "acq: warning — provider '$provider_id' returned HTTP ${status} (transient);" >&2 + echo " continuing without blocking attach." >&2 + continue + ;; + *) + echo "acq: provider '$provider_id' rejected the stored key (HTTP ${status})." >&2 + echo " Update it with: acq secret set -g $provider_id" >&2 + return 1 + ;; + esac + done <guest. This store holds acq's trusted +# per-sandbox config on the HOST (under the same state tree as provenance and the +# cli-kits record) and, where the guest legitimately needs to read a value, acq +# mounts the directory into the guest READ-ONLY (see the backend :ro mount). +# The guest can read it but cannot write it; a sudo agent cannot forge it. +# +# Layout (host): //..config/ +# Guest mount: ACQ_HOST_CONFIG_GUEST_DIR (read-only) [default /var/lib/acq/host] +# One flat file per KEY (value = raw bytes, no trailing newline added), so a +# value may contain any byte a single line could not; readers get exactly what +# was written. Atomic temp+rename write; fail-soft (mirrors the marker writes +# it replaces, which were best-effort `|| true`). +# +# The sandbox config dir is chmod 0711: the guest must be able to traverse the +# read-only mount root to execute `kit-files/...` staged code, but should not be +# able to list acq-internal key names. Content-bearing flat keys remain 0600 on +# the host; readonly kit code is staged under kit-files/ with executable perms. +# The traversable dirs intentionally make known kit-file paths host-readable too; +# only non-sensitive trusted code may be staged there. +# +# KEY charset is restricted ([A-Za-z0-9._-], no slash) so a key can never escape +# the config dir; callers pass fixed literals (agent, workspace, ssh-auth-sock, +# kit-env, and the gate markers). + +# The well-known guest mount point for the read-only host config dir. Overridable +# for tests / unusual images. Kept under /var/lib/acq so it sits beside the +# (being-migrated) legacy guest markers without colliding with them. +ACQ_HOST_CONFIG_GUEST_DIR="${ACQ_HOST_CONFIG_GUEST_DIR:-/var/lib/acq/host}" + +# Provider inputs deliberately have a separate mount and host root. In +# particular, never place them below host config: host config can contain +# workspace, SSH, and kit state that provider consumers must not receive. +ACQ_PROVIDER_INPUT_GUEST_DIR="${ACQ_PROVIDER_INPUT_GUEST_DIR:-/var/lib/acq/provider-input}" + +# acq_provider_input_prepare — host-validate and stage provider-role kit inputs. +# +# The validator, schemas, and credential-binding registry are shipped by the +# pinned Patterns release. Do not duplicate its schema in this wrapper: fetch +# that fixed release and run its validator before +# copying any declared artifact. A provider kit without a manifest is simply +# not a provider-role kit; a manifest which cannot be validated is fatal. +# +# Return status 0 means discovery completed (including an authoritative empty +# set). Status 2 means the host cannot run the released validator and is a +# provisioning blocker, never an empty-set fallback. +_acq_provider_input_python() { + local python="${ACQ_PROVIDER_INPUT_PYTHON:-}" version_check runtime data_home tools_dir setup + if [ -n "$python" ]; then + [ -x "$python" ] || { + echo "acq: configured provider-input Python is not executable: $python" >&2 + return 2 + } + else + if [ -n "${LOCALAPPDATA:-}" ] && [[ "$(uname -s 2>/dev/null)" = MINGW* || "$(uname -s 2>/dev/null)" = MSYS* || "$(uname -s 2>/dev/null)" = CYGWIN* ]]; then + data_home="$LOCALAPPDATA/acq" + else + data_home="${XDG_DATA_HOME:-${HOME:-}/.local/share}/acq" + fi + tools_dir="${ACQ_PROVIDER_INPUT_DATA_DIR:-$data_home/provider-input-validator}" + runtime="$tools_dir/provider-input-validator.current" + if [ -x "$runtime/bin/python3" ]; then python="$runtime/bin/python3" + elif [ -x "$runtime/Scripts/python.exe" ]; then python="$runtime/Scripts/python.exe" + elif [ -x "$runtime/python.exe" ]; then python="$runtime/python.exe" + fi + fi + if [ -z "$python" ]; then + setup="$ACQ_SCRIPT_DIR/scripts/setup-provider-input-validator" + [ -x "$setup" ] || { + echo "acq: provider-input validation runtime is unavailable and this install lacks its offline bootstrap" >&2 + echo " Reinstall acq from an official release; validation remains mandatory." >&2 + return 2 + } + # A git checkout has no release wheelhouse; name the online setup command so + # contributors know exactly how to proceed from a working tree. + if [ -e "$ACQ_SCRIPT_DIR/.git" ]; then + local _wh_hint + _wh_hint="$ACQ_SCRIPT_DIR/scripts/provider-input-validator-wheelhouse" + # Only show the git-checkout hint when the default wheelhouse is absent. + if ! [ -d "$_wh_hint" ]; then + echo " Git checkout detected: run the following once to install from PyPI with hash verification:" >&2 + echo " scripts/setup-provider-input-validator --apply --online" >&2 + echo " Or set ACQ_PROVIDER_INPUT_PYTHON to a Python interpreter with PyYAML 6.0.3 and jsonschema 4.26.0." >&2 + fi + fi + "$setup" --apply >&2 || return 2 + runtime="$tools_dir/provider-input-validator.current" + if [ -x "$runtime/bin/python3" ]; then python="$runtime/bin/python3" + elif [ -x "$runtime/Scripts/python.exe" ]; then python="$runtime/Scripts/python.exe" + elif [ -x "$runtime/python.exe" ]; then python="$runtime/python.exe" + fi + [ -n "$python" ] || { + echo "acq: provider-input validator bootstrap did not publish a runtime" >&2 + return 2 + } + fi + version_check='import importlib.metadata as m, sys +required = {"PyYAML": "6.0.3", "jsonschema": "4.26.0"} +actual = {} +for package in required: + try: + actual[package] = m.version(package) + except m.PackageNotFoundError: + actual[package] = None +if actual != required: + print("acq: provider-input validation requires PyYAML 6.0.3 and jsonschema 4.26.0; found " + ", ".join("%s=%s" % (package, actual[package] or "missing") for package in required), file=sys.stderr) + print(" Run: scripts/setup-provider-input-validator --apply", file=sys.stderr) + raise SystemExit(2)' + "$python" -c "$version_check" || return 2 + printf '%s\n' "$python" +} + +acq_provider_input_prepare() { + local backend="$1" name="$2" root kitref kitdir manifest release_root="" python="" + local validator="" schema_dir="" bindings="" merged_bindings="" token stage generation manifests=() kitdirs=() + [ -n "$backend" ] && [ -n "$name" ] || return 1 + command -v kit_translate_fetch >/dev/null 2>&1 || return 2 + root=$(acq_provider_input_root "$backend" "$name") || return 1 + _acq_provider_input_safe_mkdir "$root/generations" || return 1 + chmod 755 "$root" "$root/generations" || return 1 + stage=$(mktemp -d "${TMPDIR:-/tmp}/acq-provider-input.XXXXXXXX") || return 1 + chmod 700 "$stage" || { rm -rf "$stage"; return 1; } + for kitref in ${KITS[@]+"${KITS[@]}"}; do + token=$(printf '%s' "$kitref" | cksum | cut -d' ' -f1) || { rm -rf "$stage"; return 1; } + kitdir=$(kit_translate_fetch "$kitref" "$stage/fetch-$token") || { rm -rf "$stage"; return 2; } + manifest="$kitdir/provider-input.json" + [ -e "$manifest" ] || continue + [ -f "$manifest" ] && [ ! -L "$manifest" ] || { echo "acq: unsafe provider input manifest: $manifest" >&2; rm -rf "$stage"; return 1; } + _acq_provider_input_snapshot "$kitdir" "$stage/candidate-$token" || { rm -rf "$stage"; return 1; } + manifests+=("$stage/candidate-$token/provider-input.json") + kitdirs+=("$stage/candidate-$token") + done + # A generation is complete and immutable before an adapter mounts it. It is not + # visible through current until that adapter reports a successful create. + generation=$(mktemp -d "$root/generations/generation.XXXXXXXX") || { _acq_provider_input_cleanup_stage "$stage"; return 1; } + chmod 755 "$generation" || { _acq_provider_input_cleanup_stage "$stage"; _acq_provider_input_cleanup_generation "$root" "$generation"; return 1; } + _acq_provider_input_safe_mkdir "$generation/models/providers" || { _acq_provider_input_cleanup_stage "$stage"; _acq_provider_input_cleanup_generation "$root" "$generation"; return 1; } + chmod 755 "$generation/models" "$generation/models/providers" || { _acq_provider_input_cleanup_stage "$stage"; _acq_provider_input_cleanup_generation "$root" "$generation"; return 1; } + # A present empty generation is authoritative; it replaces current atomically. + if [ "${#manifests[@]}" -eq 0 ]; then + _acq_provider_input_cleanup_stage "$stage" + printf '%s\n' "$generation" + return 0 + fi + # The validator and its control-plane inputs come from the fixed, pinned + # Patterns bundle, never from a provider-controlled candidate kit. + release_root=$(_acq_provider_input_control_plane "$stage/control-plane") || { + _acq_provider_input_cleanup_stage "$stage"; _acq_provider_input_cleanup_generation "$root" "$generation"; return 2; } + validator="$release_root/scripts/validate_provider_input_contract.py" + schema_dir="$release_root/integrations/isolation/schemas" + bindings="$schema_dir/provider-credential-bindings.json" + [ -f "$validator" ] && [ ! -L "$validator" ] && [ -d "$schema_dir" ] && [ ! -L "$schema_dir" ] \ + && [ -f "$bindings" ] && [ ! -L "$bindings" ] || { + echo "acq: pinned Patterns provider-input validator is unavailable" >&2; _acq_provider_input_cleanup_stage "$stage"; _acq_provider_input_cleanup_generation "$root" "$generation"; return 2; } + python=$(_acq_provider_input_python) || { + _acq_provider_input_cleanup_stage "$stage"; _acq_provider_input_cleanup_generation "$root" "$generation"; return 2; } + # acq owns the provider -> KEY_ENV registry, layered over the pinned Patterns + # defaults (its trust anchor) and then per-invocation config/env overrides, so + # a provider kit hosted outside the pinned release validates without editing + # that release. The merged file lives in the staging dir, so the existing + # stage cleanup removes it with everything else. A malformed override fails + # closed here — before the validator runs — rather than binding the wrong key. + merged_bindings="$stage/provider-credential-bindings.json" + acq_provider_credential_merge_json "$bindings" "$merged_bindings" "$python" || { + _acq_provider_input_cleanup_stage "$stage"; _acq_provider_input_cleanup_generation "$root" "$generation"; return 1; } + local validator_args=(--schema-dir "$schema_dir" --credential-bindings "$merged_bindings") + local i + for i in "${!manifests[@]}"; do validator_args+=(--bundle "${manifests[$i]}"); done + "$python" "$validator" "${validator_args[@]}" >&2 || { _acq_provider_input_cleanup_stage "$stage"; _acq_provider_input_cleanup_generation "$root" "$generation"; return 1; } + for i in "${!manifests[@]}"; do + _acq_provider_input_materialize "${manifests[$i]}" "${kitdirs[$i]}" "$generation/models/providers" || { _acq_provider_input_cleanup_stage "$stage"; _acq_provider_input_cleanup_generation "$root" "$generation"; return 1; } + done + _acq_provider_input_cleanup_stage "$stage" + printf '%s\n' "$generation" +} + +# acq_provider_input_project is retained for callers outside backend creation. +# Adapters must use prepare, mount the returned generation, and publish only +# after their backend create succeeds. +acq_provider_input_project() { + local backend="$1" name="$2" generation root + generation=$(acq_provider_input_prepare "$backend" "$name") || return $? + root=$(acq_provider_input_root "$backend" "$name") || { + _acq_provider_input_cleanup_generation "${generation%/generations/*}" "$generation" || true + return 1 } _acq_provider_input_publish "$root" "$generation" || { _acq_provider_input_cleanup_generation "$root" "$generation" || true @@ -3338,32 +5045,6 @@ ensure_opencode_postinstall() { return 0 } -# Probe the USAi API from inside the sandbox. Emits ONE clean token on stdout: -# - a 3-digit HTTP status (e.g. 200, 401) on a real HTTP response, -# - "unresolved" when the guest resolver returned no answer for the name -# (curl exit 6 / NXDOMAIN) — the split-horizon-DNS tell, -# - the literal "unreachable" when curl could not complete a request at all -# (TLS reset, DNS failure, proxy/Zscaler interception, offline — curl exits -# non-zero and writes http_code 000), or -# - "" (empty) only when the probe is genuinely indeterminate. -# The caller distinguishes these: "unreachable" is a NETWORK problem, NOT a bad -# key, so it must never be reported as "invalid or expired" or trigger a rotate. -# -# Sanitize hard: curl's stderr can leak through the backend exec (which may merge -# streams), so we emit the raw curl output as `|` and reduce it -# to a bare code here. Only ^[0-9]{3}$ or "unreachable" or "" can ever escape — -# no free-form error text can splice into the caller's "(HTTP …)" message. -check_key() { - local name="$1" - local raw code exit_code key_ref - key_ref="\$${USAI_PROVIDER_KEY_ENV}" - raw=$(acq_backend_run "$name" -- sh -c \ - "curl -sS -o /dev/null -w '%{http_code}' \ - -H \"Authorization: Bearer $key_ref\" \ - $USAI_PROVIDER_MODELS_URL; printf '|%s' \"\$?\"" 2>/dev/null || true) - _classify_key_status "$raw" -} - # Reduce a raw `|` probe result (possibly polluted with # curl error text) to a clean status token: a 3-digit HTTP code, "unresolved" # (DNS did not resolve the name — curl exit 6), "unreachable" (resolved but the @@ -3414,35 +5095,6 @@ _classify_key_status() { printf '\n' } -# Check USAi key in a fresh temporary sandbox (to distinguish bad key from stale -# placeholder). -check_fresh_sandbox_key() { - local validation_name="acq-keycheck-$$" - local status="" - # The throwaway sandbox only needs the USAi binding. Publishing ports would - # make it contend with the real sandbox for every explicit host port (a - # `--publish` pin or a kit's `host:`), fail its create, and silently skip this - # check. Dynamic scoping hands the flag to acq_backend_provision. - local _ACQ_PROVISION_WITHOUT_PORTS=1 - - # Use the backend to create a minimal sandbox for validation. - if ! acq_backend_provision "$validation_name" shell . /dev/null 2>&1; then - return 0 - fi - # shellcheck disable=SC2064 - trap "acq_backend_terminate '$validation_name' /dev/null 2>&1 || true" EXIT - local raw key_ref - key_ref="\$${USAI_PROVIDER_KEY_ENV}" - raw=$(acq_backend_run "$validation_name" -- sh -c \ - "curl -sS -o /dev/null -w '%{http_code}' \ - -H \"Authorization: Bearer $key_ref\" \ - $USAI_PROVIDER_MODELS_URL; printf '|%s' \"\$?\"" /dev/null || true) - status=$(_classify_key_status "$raw") - acq_backend_terminate "$validation_name" /dev/null 2>&1 || true - trap - EXIT - printf '%s\n' "$status" -} - # Warn (do not block) if a sandbox has published ports with nothing listening # behind them inside the guest. This is a backend-NEUTRAL diagnostic: it uses # only `acq_backend_ports` (to learn the guest ports the sandbox publishes) and @@ -3985,100 +5637,6 @@ preflight_workspace_path() { return 1 } -# Validate the sandbox's USAi key and walk the user through rotating it if -# needed. Best-effort: warn and continue if we cannot run the check. -# advise_valid_key NAME — non-blocking USAi key advisory for `acq create`. -# Unlike ensure_valid_key (which gates attach and offers interactive rotation), -# this only WARNS if the key isn't valid, then returns 0 regardless. `create` -# is detached and never attaches, so there is nothing to gate; the point is to -# give a create-only user a heads-up that their key is stale before they later -# `acq run`. Silent on a healthy key or an inconclusive check (empty status). -advise_valid_key() { - local name="$1" - local status - status=$(check_key "$name") - - # 200 = healthy; empty = could not validate (network/tooling) — stay quiet in - # both cases to avoid noise. Only speak up on a definitive non-200. - [ "$status" = "200" ] && return 0 - [ -z "$status" ] && return 0 - - if [ "$status" = "unreachable" ]; then - _report_usai_unreachable - return 0 - fi - - if [ "$status" = "unresolved" ]; then - _report_usai_unresolved - return 0 - fi - - echo "acq: note — your USAi API key looks invalid or expired (HTTP $status)." >&2 - echo " USAi keys expire every 7 days. This sandbox was created, but the" >&2 - echo " key must be valid before an agent can use it." >&2 - echo " To rotate: acq usai-rotate-api-key (or re-run via 'acq run', which" >&2 - echo " validates and offers to rotate before attaching)." >&2 - return 0 -} - -# Print a network-oriented diagnosis when the USAi models API could not be -# reached from the sandbox at all (curl connection failure / HTTP 000). This is -# a reachability problem — NOT an invalid or expired key — so it deliberately -# does not mention rotating the key. The point is an accurate diagnosis, not a -# prescription: it states what failed and the signal to look for, and points at -# the docs rather than guessing the user's network fix. -_report_usai_unreachable() { - echo >&2 - echo "acq: could not reach the USAi API ($USAI_PROVIDER_MODELS_URL) from the sandbox." >&2 - echo " The request did not complete (no HTTP response) — this is a network" >&2 - echo " reachability problem, NOT an invalid or expired key, so rotating the" >&2 - echo " key will not help." >&2 - echo " If the playbook/kit fetch also failed with a TLS 'unexpected eof'," >&2 - echo " both outbound connections are being cut — a strong sign of a network" >&2 - echo " or TLS-interception (e.g. corporate proxy) issue rather than USAi." >&2 - echo " See docs/KNOWN_FAILURE_MODES.md for diagnosis steps." >&2 - echo >&2 -} - -# Print a DNS-oriented diagnosis when the USAi models API name did not RESOLVE -# from the sandbox (curl exit 6 / NXDOMAIN). This is distinct from the broad -# "unreachable" cut: the tell is that the name has no answer for the guest -# resolver, while other public hosts (GitHub, npm) resolve and connect fine. -# -# The usual cause on GFE is split-horizon DNS — the USAi host resolves to an -# INTERNAL address that only exists in the corporate/tunnel zone. The guest -# follows the host's resolvers by default, with msb's rebind protection off on -# a ZPA host, so this now points at a forced public resolver -# (ACQ_MSB_DNS_NAMESERVER), rebind protection forced on -# (ACQ_MSB_DNS_REBIND_PROTECTION=1), or a sandbox created while the tunnel was -# down (detection missed; recreate with ACQ_MSB_DNS_REBIND_PROTECTION=0). A key -# rotation cannot fix this, and neither can a msb data wipe. State the signal -# and point at the docs. -_report_usai_unresolved() { - echo >&2 - echo "acq: the USAi API host in $USAI_PROVIDER_MODELS_URL did not RESOLVE from the sandbox" >&2 - echo " (DNS returned no address). This is a name-resolution problem, NOT an" >&2 - echo " invalid or expired key, so rotating the key will not help." >&2 - echo " If other public hosts (GitHub, npm) work from the sandbox but only" >&2 - echo " USAi fails to resolve, USAi is likely a split-horizon name whose" >&2 - echo " address lives in an internal/tunnel-only zone. Check that the guest" >&2 - echo " follows the host's resolvers (ACQ_MSB_DNS_NAMESERVER unset) and that" >&2 - echo " msb's rebind protection is not forced on (ACQ_MSB_DNS_REBIND_PROTECTION" >&2 - echo " unset, or 0 if the sandbox was created while the tunnel was down)." >&2 - echo " See docs/KNOWN_FAILURE_MODES.md §30 (USAi-only NXDOMAIN)." >&2 - echo >&2 -} - -# Diagnose a USAi value that is stored but cannot be presented by the active -# backend (an undecryptable DPAPI envelope written under a different Windows -# profile, or a damaged store). Reporting "not set" here would send the user to -# create a fresh key when the real problem is the stored one. -_report_usai_unreadable() { - echo "acq: a USAi API key is stored for this user, but it cannot be read or decrypted here." >&2 - echo " It may have been stored under a different user or host, or the stored value is damaged." >&2 - echo " Re-set it with 'acq secret set -g usai' (or remove it with 'acq secret rm -g usai')." >&2 -} - # acq_key_injectable SERVICE [SANDBOX] -> 0 if the ACTIVE BACKEND can inject # SERVICE for that scope, else 1. Single source of truth for the "is this # credential actually usable at provision?" predicate, composed of two checks: @@ -4099,176 +5657,6 @@ acq_key_injectable() { return 0 } -# ensure_key_present — pre-create gate: make sure a USAi API key is available to -# the active backend BEFORE the sandbox is created. This must run before -# acq_backend_provision on a fresh run because msb binds secrets only at create -# time (--secret ENV@HOST), and sbx snapshots custom secret placeholders from its -# proxy table at create time. A sandbox created without the backend-visible key -# binding carries no working USAi credential. -# -# Returns 0 if a key is present (already, or after the user pastes one), 1 if the -# user declines or setup fails. A no-op (returns 0) when the store helper isn't -# loaded — the post-create ensure_valid_key gate still catches a bad key. -ensure_key_present() { - local scope_sandbox="${1:-}" - if ! command -v acq_secret_has >/dev/null 2>&1; then - return 0 - fi - if acq_secret_has usai "$scope_sandbox"; then - # acq_key_injectable composes the store check (already true here) with the - # backend-inject check; it is the shared predicate `acq secret has` also uses. - acq_key_injectable usai "$scope_sandbox" && return 0 - - echo "acq: USAi API key is stored, but the active backend is not configured to inject it." >&2 - echo " Run 'acq secret set -g usai' from a terminal so the backend can bind it, then retry." >&2 - return 1 - fi - - # Backend mismatch: msb provision also accepts a host-exported USAI_API_KEY and - # binds it at create time (see _acq_msb_bind_secrets_into in msb.sh), so an empty - # acq store is NOT a blocker under msb when the env var is set (e.g. CI). Treat - # that as present to avoid prompting/aborting a create msb would have satisfied. - # sbx does NOT read host env at provision, so this short-circuit is msb-only. - if [ "${ACQ_RESOLVED_BACKEND:-}" = "msb" ] && [ -n "${USAI_API_KEY:-}" ]; then - return 0 - fi - - # Present-but-unreadable: a value is stored for this user but cannot be read or - # decrypted (e.g. a DPAPI envelope written under a different Windows profile, - # or a damaged store). Reporting "not set" here would send the user to create a - # fresh key when the real problem is the stored one — fail closed with the real - # diagnosis instead. - if acq_secret_unreadable usai "$scope_sandbox"; then - _report_usai_unreadable - return 1 - fi - - # Non-interactive (CI / piped stdin): no one can answer the prompt below, so - # emit a single terse line and fail closed rather than the full interactive - # help (mirrors the non-tty guard in the kit-update path above). - if [ ! -t 0 ]; then - echo "acq: no USAi API key stored; set one with 'acq secret set -g usai' (see $USAI_PROVIDER_KEY_MGMT_URL). Aborting." >&2 - return 1 - fi - - echo >&2 - echo "No USAi API key is stored yet." >&2 - echo "USAi keys are created at $USAI_PROVIDER_KEY_MGMT_URL and expire every 7 days." >&2 - echo >&2 - echo "To set one:" >&2 - echo " 1. Open $USAI_PROVIDER_KEY_MGMT_URL" >&2 - echo " 2. Create a key (or copy an existing one) with the console copy button" >&2 - echo >&2 - - if ! command -v acq_backend_secret_set >/dev/null 2>&1; then - echo "The '${ACQ_RESOLVED_BACKEND:-active}' backend does not implement key setup." >&2 - echo "Set the key manually (acq secret set -g usai), then re-run." >&2 - return 1 - fi - - local answer="" - printf 'Have your USAi API key ready to paste? Set it now? [y/N] ' >&2 - read -r answer || true - case "$answer" in - [yY]|[yY][eE][sS]) - # Store the key in the acq store (read from the TTY; never argv). This does - # NOT create a sandbox — the value just needs to be present before create. - acq_backend_secret_set -g usai || { - echo "Key setup did not complete. Aborting." >&2 - return 1 - } - if acq_secret_has usai; then - return 0 - fi - echo "No USAi API key was stored. Aborting." >&2 - return 1 - ;; - *) - echo "Skipping. Aborting; re-run when your USAi API key is set." >&2 - return 1 - ;; - esac -} - -ensure_valid_key() { - local name="$1" - shift - local status - status=$(check_key "$name") - - if [ "$status" = "200" ]; then - return 0 - fi - - if [ -z "$status" ]; then - echo "warning: could not validate USAI_API_KEY for '$name' (skipping check)" >&2 - return 0 - fi - - # The request never reached USAi (TLS reset / DNS / proxy interception / - # offline) — a NETWORK problem, not a key problem. Rotating a key cannot fix - # this, so DO NOT prompt to rotate. Fail closed (attaching would just fail on - # the first USAi call) with a network-oriented diagnosis. - if [ "$status" = "unreachable" ]; then - _report_usai_unreachable - return 1 - fi - - # DNS returned no address for the USAi host (curl exit 6). Distinct from a - # broad cut: the split-horizon-DNS case, where only USAi fails to resolve. A - # rotation cannot fix it, so fail closed with a resolver-oriented diagnosis. - if [ "$status" = "unresolved" ]; then - _report_usai_unresolved - return 1 - fi - - # A key IS stored (ensure_key_present gated create on that) but the models API - # rejected it: it is expired or invalid. Offer an in-place rotation, then - # re-validate THIS sandbox — the same one the agent will attach to, so a 200 - # here is truthful (no throwaway-sandbox result stands in for the real one). - echo >&2 - echo "Your USAi API key looks invalid or expired (HTTP $status from the models API)." >&2 - echo "USAi keys expire every 7 days." >&2 - echo >&2 - echo "To rotate it:" >&2 - echo " 1. Open $USAI_PROVIDER_KEY_MGMT_URL" >&2 - echo " 2. Choose 'Rotate' from the Actions menu for your key" >&2 - echo " 3. Copy the new key using the console copy button" >&2 - echo >&2 - - if ! command -v acq_backend_rotate_key >/dev/null 2>&1; then - echo "The '${ACQ_RESOLVED_BACKEND:-active}' backend does not implement key setup." >&2 - echo "Set the key manually, then re-run." >&2 - return 1 - fi - - local answer="" - printf 'Have the new API key ready to paste? Rotate now? [y/N] ' >&2 - read -r answer || true - case "$answer" in - [yY]|[yY][eE][sS]) - acq_backend_rotate_key || { - echo "Key setup did not complete. Aborting attach." >&2 - return 1 - } - if command -v acq_propagate_usai_rotation >/dev/null 2>&1; then - acq_propagate_usai_rotation || return 1 - fi - status=$(check_key "$name") - if [ "$status" = "200" ]; then - echo "Key validated. Continuing." >&2 - return 0 - fi - echo "Key still not working (HTTP ${status:-unknown}). Aborting attach." >&2 - return 1 - ;; - *) - echo "Skipping. Aborting attach; re-run when your USAi API key is set." >&2 - return 1 - ;; - esac -} - # ============================================================================ # acq doctor output helpers # ============================================================================ @@ -4465,15 +5853,21 @@ acq_print_doctor() { # _acq_configure_show_current — print the current durable configuration # (config.yaml) to stderr, so `acq configure` opens by showing what is in effect. _acq_configure_show_current() { - local cfg backend extras scope + local cfg backend extras scope bindings provider_kit sources cfg=$(_acq_config_file) backend=$(_acq_config_read_field backend) extras=$(_acq_config_read_field extra_kits) scope=$(_acq_config_read_field scope_github_token) + bindings=$(_acq_config_read_field "$ACQ_PROVIDER_CREDENTIAL_CONFIG_KEY") + provider_kit=$(_acq_config_read_field "$ACQ_PROVIDER_KIT_CONFIG_KEY") + sources=$(_acq_config_read_field "$ACQ_KIT_SOURCE_CONFIG_KEY") echo "acq: current configuration (${cfg}):" >&2 echo " default backend: ${backend:-}" >&2 echo " extra kits: ${extras:-}" >&2 echo " scope GitHub token: ${scope:-no}" >&2 + echo " provider creds: ${bindings:-} (acq provider-credential ls)" >&2 + echo " provider kit: ${provider_kit:-$ACQ_PROVIDER_KIT_DEFAULT} (acq kit-source ls)" >&2 + echo " kit sources: ${sources:-}" >&2 echo "" >&2 } @@ -4569,9 +5963,42 @@ acq_configure() { _acq_config_write_field scope_github_token no fi + # Provider kit (single select). Candidates are the shipped default plus every + # provider kit discovered in a trusted source, so a user can pick + # e.g. `opencode-go-provider` without hand-writing a git+https ref. + local candidates=() labels=() candidate label provider_default="" provider_sel="" + local current_provider pidx pargs=() + while IFS="$(printf '\t')" read -r candidate label; do + [ -n "$candidate" ] || continue + candidates+=("$candidate") + labels+=("$label") + done < <(acq_provider_kit_candidates 2>/dev/null) + current_provider=$(_acq_config_read_field "$ACQ_PROVIDER_KIT_CONFIG_KEY") + [ -n "$current_provider" ] || current_provider="$ACQ_PROVIDER_KIT_DEFAULT" + pidx=1 + while [ "$pidx" -le "${#candidates[@]}" ]; do + candidate="${candidates[$((pidx-1))]}" + pargs+=("$candidate" "${labels[$((pidx-1))]}") + case "$candidate" in + "$current_provider") provider_default="$pidx" ;; + esac + pidx=$((pidx + 1)) + done + if [ "${#candidates[@]}" -gt 0 ]; then + acq_prompt_multiselect 0 "$provider_default" "${pargs[@]}" >/dev/null + if [ "$_ACQ_PROMPT_SELECTION" != "CANCELLED" ] && [ -n "$_ACQ_PROMPT_SELECTION" ]; then + set -- $_ACQ_PROMPT_SELECTION + if [ "$1" -ge 1 ] && [ "$1" -le "${#candidates[@]}" ] 2>/dev/null; then + provider_sel="${candidates[$(($1 - 1))]}" + [ -n "$provider_sel" ] && _acq_config_write_field "$ACQ_PROVIDER_KIT_CONFIG_KEY" "$provider_sel" + fi + fi + fi + echo "" >&2 echo "acq: configuration saved to $(_acq_config_file)." >&2 echo " extra kits: ${selected_names:-}" >&2 + echo " provider kit: ${provider_sel:-$current_provider}" >&2 return 0 } diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 5796a94a..3f67210a 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -334,9 +334,21 @@ _acq_msb_service_binding() { fi fi case "$_service" in - usai) printf '%s\t%s\n' "$USAI_PROVIDER_KEY_ENV" "$ACQ_MSB_USAI_HOST"; return 0 ;; github) printf '%s\t%s\n' "GITHUB_TOKEN" "$ACQ_MSB_GITHUB_HOST"; return 0 ;; esac + # Providers (the default one included) are resolved from acq's registry + the + # validated facts, never from a compiled-in table: there is no `usai` branch. + if command -v acq_provider_binding_host_env >/dev/null 2>&1; then + _meta=$(acq_provider_binding_host_env "$_service" "$_sandbox" 2>/dev/null) || _meta="" + if [ -n "$_meta" ]; then + _host=$(printf '%s' "$_meta" | cut -f1) + _env=$(printf '%s' "$_meta" | cut -f2) + if [ -n "$_host" ] && [ -n "$_env" ]; then + printf '%s\t%s\n' "$_env" "$_host" + return 0 + fi + fi + fi printf '\t\n' } @@ -908,6 +920,13 @@ _acq_msb_is_running() { # The real value moves via a TRANSIENT exported env var, never argv. _acq_msb_bind_one() { local _arrn="$1" _namesn="$2" _svc="$3" _name="$4" _env="$5" _host="$6" _val + # These fields can originate from a facts.json or a host-config record, so they + # are validated BEFORE they reach eval/export/argv: only a bare ENV identifier + # and a bare DNS name can ever be interpolated below. + case "$_env" in + ''|*[!A-Z0-9_]*|[!A-Z]*) acq_debug "msb secret: refusing unsafe env name for '$_svc'"; return 1 ;; + esac + _acq_provider_fact_safe_host "$_host" || { acq_debug "msb secret: refusing unsafe host for '$_svc'"; return 1; } _val=$(acq_secret_resolve "$_svc" "$_name" 2>/dev/null) && [ -n "$_val" ] || return 1 # shellcheck disable=SC2163 # dynamic export of the resolved binding env var export "$_env=$_val"; _val="" @@ -946,24 +965,13 @@ _acq_msb_bind_secrets_into() { local _arrn="$1" _namesn="$2" _name="$3" if command -v acq_secret_resolve >/dev/null 2>&1; then - # usai/github hosts resolve through the SAME binding table set/rm/refeed use - # (_acq_msb_service_binding), so a `--host` sidecar recorded at `acq secret - # set` overrides the compiled-in endpoint here too (#384) instead of being - # written-but-ignored at provision. - if ! command -v acq_usai_provider_selected >/dev/null 2>&1 || acq_usai_provider_selected; then - local _usai_binding _usai_env _usai_host - _usai_binding=$(_acq_msb_service_binding usai "$_name") - _usai_env=$(printf '%s' "$_usai_binding" | cut -f1) - _usai_host=$(printf '%s' "$_usai_binding" | cut -f2) - - # USAi: acq store first, else a pre-exported USAI_API_KEY (e.g. CI). - if ! _acq_msb_bind_one "$_arrn" "$_namesn" usai "$_name" "$_usai_env" "$_usai_host"; then - if [ -n "${USAI_API_KEY:-}" ]; then - eval "$_arrn+=(--secret \"\${_usai_env}@\${_usai_host}\")" - acq_debug "msb secret: binding ${_usai_env}@${_usai_host} (from env)" - fi - fi - fi + # github hosts resolve through the SAME binding table set/rm/refeed use + # (_acq_msb_service_binding), so a `--host` sidecar recorded at + # `acq secret set` overrides the compiled-in endpoint here too (#384) instead + # of being written-but-ignored at provision. + # + # Providers (the default one included) are bound by the generic provider loop + # below; only the github HOST service keeps a compiled-in branch. # GitHub: bind the token to the API and git-transport hosts. acq store first, # then a pre-exported GITHUB_TOKEN, then GH_TOKEN (CI). Absent token => no @@ -1007,9 +1015,55 @@ _acq_msb_bind_secrets_into() { $(acq_secret_meta_list "$_name") EOF fi - elif { ! command -v acq_usai_provider_selected >/dev/null 2>&1 || acq_usai_provider_selected; } \ - && [ -n "${USAI_API_KEY:-}" ]; then - eval "$_arrn+=(--secret \"${USAI_PROVIDER_KEY_ENV}@\${ACQ_MSB_USAI_HOST}\")" + + # Provider kits: every provider id acq knows for this sandbox (the + # credential-bindings registry unioned with the facts recorded at create) + # resolves its (env, host) from validated facts, so a provider kit hosted + # outside the pinned Patterns release binds with no per-provider code here. + # A value pre-exported by CI is bound from the host environment, matching the + # USAi/GitHub fallbacks — otherwise a CI run would pass the gate and create a + # sandbox with a dead credential. + if command -v acq_provider_binding_host_env >/dev/null 2>&1; then + local _pid _pbinding _penv _phost _pnames _pexported + while IFS= read -r _pid; do + [ -n "$_pid" ] || continue + # `github` is bound above from the compiled-in host table; every provider + # (the default one included) comes through this loop. + case "$_pid" in github) continue ;; esac + _pbinding=$(acq_provider_binding_host_env "$_pid" "$_name" 2>/dev/null) || continue + _penv=$(printf '%s' "$_pbinding" | cut -f2) + _phost=$(printf '%s' "$_pbinding" | cut -f1) + [ -n "$_penv" ] && [ -n "$_phost" ] || continue + eval "_pnames=\" \${${_namesn}[*]-} \"" + case "$_pnames" in *" $_penv "*) continue ;; esac + if ! _acq_msb_bind_one "$_arrn" "$_namesn" "$_pid" "$_name" "$_penv" "$_phost"; then + # Indirect expansion, never eval: `_penv` is validated by + # _acq_msb_bind_one but this runs before that guard for the env branch. + case "$_penv" in + ''|*[!A-Z0-9_]*|[!A-Z]*) _pexported="" ;; + *) _pexported=$(_acq_provider_env_value "$_penv") ;; + esac + if [ -n "$_pexported" ]; then + eval "$_arrn+=(--secret \"\${_penv}@\${_phost}\")" + acq_debug "msb secret: binding ${_penv}@${_phost} for '$_pid' (from env)" + fi + _pexported="" + fi + done </dev/null || ! command -v acq_provider_selection_active >/dev/null 2>&1; then + # No secret store loaded (e.g. a minimal harness): fall back to a host-exported + # value for the DEFAULT provider, derived from the same registry data. This is + # the facts-free fallback that keeps a pre-merge sandbox resumable. + local _default_env _default_host + _default_env=$(acq_provider_credential_key_env "$(acq_provider_default_id 2>/dev/null)" 2>/dev/null) || _default_env="" + _default_host=$(acq_provider_credential_hosts "$(acq_provider_default_id 2>/dev/null)" 2>/dev/null) || _default_host="" + if [ -n "$_default_env" ] && [ -n "$_default_host" ] \ + && [ -n "$(_acq_provider_env_value "$_default_env")" ]; then + eval "$_arrn+=(--secret \"\${_default_env}@\${_default_host}\")" + fi fi } @@ -1065,10 +1119,9 @@ acq_backend_start() { if command -v acq_provider_selection_load >/dev/null 2>&1; then acq_provider_selection_load msb "$_name" || return 1 fi - if command -v acq_provider_facts_load_from_kit_or_fallback >/dev/null 2>&1 \ - && { ! command -v acq_usai_provider_selected >/dev/null 2>&1 || acq_usai_provider_selected; }; then - acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" - fi + # Provider facts are resolved from the sandbox's own generation/record (see + # acq_provider_binding), and the DEFAULT provider falls back to the shipped + # trust-anchor data — so a pre-merge sandbox still resumes with no facts file. local _start_secret_flags=() _start_secret_names=() _acq_msb_bind_secrets_into _start_secret_flags _start_secret_names "$_name" local _start_rc=0 @@ -3276,6 +3329,14 @@ $(kit_spec_volumes "$spec")" _ACQ_MSB_ATTEMPT_NAME="$name" _ACQ_MSB_ATTEMPT_GENERATION="$_provider_generation" _ACQ_MSB_ATTEMPT_ACTIVE=1 + # msb binds secrets only at create (--secret ENV@HOST), so a provider + # credential must resolve now. USAi keeps its own pre-create gate; this covers + # every other provider selected from the credential-bindings registry, using + # the facts of the generation prepared above. + if ! acq_provider_credentials_gate msb "$name" "$_provider_generation"; then + echo "acq(msb): error: provider credentials are not ready for '$name'." >&2 + return 1 + fi # Merged kit env, computed ONCE now that every kit is fetched and BEFORE any kit # is applied — see _acq_msb_merge_kit_env_into for why it must be a pre-pass. @@ -6203,42 +6264,12 @@ acq_backend_rotate_key() { return 1 } - # Validate the new key in a fresh throwaway sandbox (backend-neutral helper). - # NOTE: on msb the throwaway sandbox is NOT the sandbox the agent attaches to. - # The real running sandbox is re-fed via `msb modify --secret`, which can apply - # to 0 sandboxes if the real one is still booting. So a throwaway "HTTP 200" - # here does NOT prove the real sandbox will authenticate — printing it would - # reproduce the exact 200-then-401 split this change set out to kill. We only - # use the throwaway to surface a hard-negative early (invalid key / network), - # and leave the authoritative "validated" verdict to ensure_valid_key's - # real-sandbox check_key on next attach. - local status="" - if command -v check_fresh_sandbox_key >/dev/null 2>&1; then - acq_spin_start "Validating the new key in a temporary sandbox" - status=$(check_fresh_sandbox_key) - acq_spin_stop "Validating the new key in a temporary sandbox" - fi - - if [ -z "$status" ] || [ "$status" = "200" ]; then - # No throwaway result, or the throwaway accepted the key. Do NOT claim the - # real sandbox is validated — the re-feed may not have reached it yet. - echo "Key stored. It will be validated against the running sandbox on next attach." >&2 - return 0 - fi - if [ "$status" = "unreachable" ]; then - # The validation request never reached USAi — a network/egress problem, not - # the key. Report it as such (no "double-check the key") via the shared - # helper when available. - if command -v _report_usai_unreachable >/dev/null 2>&1; then - _report_usai_unreachable - else - echo "acq(msb): could not reach the USAi API to validate — a network/egress" >&2 - echo " problem (proxy/Zscaler/DNS/offline), not the key itself." >&2 - fi - return 1 - fi - echo "acq(msb): key validation failed (HTTP ${status}). Double-check the key and rotate again." >&2 - return 1 + # The throwaway-sandbox probe is gone with the USAi-specific key subsystem: the + # authoritative verdict is the generic `ensure_valid_provider_keys` check on + # next attach (it probes the provider's own facts.modelsUrl in the REAL + # sandbox), so a 200 here could never prove the real sandbox authenticates. + echo "Key stored. It will be validated against the running sandbox on next attach." >&2 + return 0 } # --------------------------------------------------------------------------- diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index 45585c6a..ecc65267 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -204,13 +204,83 @@ _acq_sbx_kit_sources_manual_hint() { 'reduce $ARGS.positional[] as $p (.; if index($p) then . else . + [$p] end)' \ "${KIT_SOURCE_PREFIXES[@]}" 2>/dev/null) fi - [ -z "${cmd:-}" ] && cmd='["docker.io/","github.com/GSA-TTS/"]' + if [ -z "${cmd:-}" ]; then + local _acc='["docker.io/"' _p + for _p in ${KIT_SOURCE_PREFIXES[@]+"${KIT_SOURCE_PREFIXES[@]}"}; do + _acc="$_acc,\"$_p\"" + done + cmd="$_acc]" + fi echo " If kit resolution fails, review the current value and run:" >&2 echo " sbx settings set kit.allowedSources '${cmd}'" >&2 } -_acq_sbx_ensure_kit_sources_allowed() { - local current desired +# acq_backend_kit_source_revoked REPO — a trusted kit source was removed. sbx's +# kit.allowedSources is owner-granular (host/owner/) and _acq_sbx_ensure_kit_sources_allowed +# only ever UNIONS, so a revoked source stayed fetchable until the setting was +# edited by hand. Recompute against what REMAINS trusted once REPO is gone: the +# owner prefix is dropped only when no remaining registry entry (and no explicit legacy +# ACQ_EXTRA_KIT_SOURCES grant) still needs it. Returns 0 only when the backend +# allowlist is verified to no longer grant a prefix nothing needs; any inability +# to read, write, or verify returns 1 with a recovery command, never a quiet 0. +acq_backend_kit_source_revoked() { + local repo="${1:-}" prefix remaining current desired verify + [ -n "$repo" ] || return 0 + prefix=$(_acq_kit_source_owner_prefix "$repo") + remaining=$(acq_kit_source_prefixes "$repo") || remaining="" + case " +$remaining +" in + *" +$prefix +"*) return 0 ;; + esac + if ! command -v sbx >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then + _acq_sbx_kit_source_revoke_failed "$prefix" "sbx and jq are both needed to update kit.allowedSources" + return 1 + fi + current=$(sbx settings get kit.allowedSources 2>/dev/null) || current="" + if ! printf '%s' "$current" | jq -e 'type == "array"' >/dev/null 2>&1; then + _acq_sbx_kit_source_revoke_failed "$prefix" "could not read kit.allowedSources as a JSON array" + return 1 + fi + if ! printf '%s' "$current" | jq -e --arg p "$prefix" 'index($p) != null' >/dev/null 2>&1; then + return 0 + fi + desired=$(printf '%s' "$current" | jq -c --arg p "$prefix" 'map(select(. != $p))' 2>/dev/null) + if [ -z "$desired" ]; then + _acq_sbx_kit_source_revoke_failed "$prefix" "could not compute the updated allowlist" + return 1 + fi + if ! sbx settings set kit.allowedSources "$desired" /dev/null 2>&1; then + _acq_sbx_kit_source_revoke_failed "$prefix" "could not write kit.allowedSources" "$desired" + return 1 + fi + verify=$(sbx settings get kit.allowedSources 2>/dev/null) || verify="" + if ! printf '%s' "$verify" | jq -e --arg p "$prefix" 'type == "array" and (index($p) == null)' >/dev/null 2>&1; then + _acq_sbx_kit_source_revoke_failed "$prefix" "kit.allowedSources still lists it after the update" "$desired" + return 1 + fi + echo "acq: removed $prefix from sbx kit.allowedSources." >&2 + return 0 +} + +# _acq_sbx_kit_source_revoke_failed PREFIX REASON [DESIRED_JSON] — the loud half +# of the fail-closed contract: say what is still permitted and how to fix it. +_acq_sbx_kit_source_revoke_failed() { + local prefix="$1" reason="$2" desired="${3:-}" + echo "acq: error: $reason, so sbx kit.allowedSources still permits '$prefix'." >&2 + echo " Revocation is NOT complete: the source stays registered because the sbx" >&2 + echo " backend could still fetch kits from that owner." >&2 + if [ -n "$desired" ]; then + echo " Run: sbx settings set kit.allowedSources '$desired'" >&2 + else + echo " Review: sbx settings get kit.allowedSources (remove '$prefix', then 'sbx settings set kit.allowedSources ')" >&2 + fi + echo " Then re-run: acq kit-source rm " >&2 +} + +_acq_sbx_ensure_kit_sources_allowed() { local current desired if ! command -v jq >/dev/null 2>&1; then _acq_sbx_kit_sources_manual_hint "jq not found; cannot safely update the allowlist" @@ -619,6 +689,14 @@ _acq_backend_provision_unlocked() { _ACQ_SBX_ATTEMPT_NAME="$name" _ACQ_SBX_ATTEMPT_GENERATION="$_provider_generation" _ACQ_SBX_ATTEMPT_ACTIVE=1 + # Provider credentials must exist in sbx's proxy table BEFORE create (create + # snapshots it). USAi keeps its own pre-create gate; this covers every other + # provider selected from the credential-bindings registry, using the facts of + # the generation we just prepared. + if ! acq_provider_credentials_gate sbx "$name" "$_provider_generation"; then + _acq_sbx_cleanup_uncreated_attempt + return 1 + fi local _hcfg_dir _hcfg_dir=$(acq_host_config_dir sbx "$name") || return 1 mkdir -p "$_hcfg_dir" || return 1 @@ -1184,9 +1262,19 @@ acq_backend_ensure_kits_applied() { _acq_sbx_ensure_kit_sources_allowed # Neutral kits must be translated to local sbx-v2 kit dirs before sbx kit add. - local usai_local="" playbook_local zscaler_local - if ! command -v acq_usai_provider_selected >/dev/null 2>&1 || acq_usai_provider_selected; then - usai_local=$(_acq_sbx_translate_kit "$USAI_KIT") + local provider_local="" playbook_local zscaler_local + # The default provider is DATA (provider_kit), so heal resolves the selected + # ref instead of naming the USAi kit. An invalid selection FAILS CLOSED here: + # silently healing without the provider kit would leave a half-configured sandbox. + local provider_local="" provider_ref="" provider_refs + if ! provider_refs=$(_acq_selected_provider_kit_refs); then + echo "acq(sbx): error: the configured provider kit could not be resolved;" >&2 + echo " refusing to heal '$name' without it." >&2 + return 1 + fi + provider_ref=$(printf '%s\n' "$provider_refs" | head -n1) + if [ -n "$provider_ref" ]; then + provider_local=$(_acq_sbx_translate_kit "$provider_ref") fi playbook_local=$(_acq_sbx_translate_kit "$PLAYBOOK_KIT") zscaler_local=$(_acq_sbx_translate_kit "$ZSCALER_KIT") @@ -1207,21 +1295,21 @@ acq_backend_ensure_kits_applied() { esac fi - # 2) USAi provider kit - if { ! command -v acq_usai_provider_selected >/dev/null 2>&1 || acq_usai_provider_selected; } \ + # 2) Default provider kit + if [ -n "$provider_ref" ] \ && { [ "$force" = "1" ] || _acq_sbx_kit_feature_absent "$name" "test -f '$USAI_KIT_CONFIG_PATH' && echo present"; }; then - echo "acq: '$name' is missing the USAi kit; injecting with 'sbx kit add'..." >&2 - _kadd_rc=0; _acq_sbx_kit_add "$name" "$usai_local" || _kadd_rc=$? + echo "acq: '$name' is missing the provider kit; injecting with 'sbx kit add'..." >&2 + _kadd_rc=0; _acq_sbx_kit_add "$name" "$provider_local" || _kadd_rc=$? case $_kadd_rc in 0) healed=1 sbx exec "$name" -- sh -c \ 'f="$HOME/.config/opencode/opencode.jsonc"; if [ -L "$f" ] && [ ! -e "$f" ]; then rm -f "$f"; fi' \ /dev/null 2>&1 || true - echo "acq: USAi kit injected into '$name'." >&2 + echo "acq: provider kit injected into '$name'." >&2 ;; 3) _acq_sbx_print_recreate_notice "$name"; ok=0 ;; - *) echo "acq: warning: 'sbx kit add' (USAi kit) failed for '$name' (see error above)." >&2; ok=0 ;; + *) echo "acq: warning: 'sbx kit add' (provider kit) failed for '$name' (see error above)." >&2; ok=0 ;; esac fi @@ -1340,12 +1428,31 @@ acq_backend_ensure_kits_applied() { # sbx.sh and msb.sh agree on the mapping. # usai -> $USAI_PROVIDER_HOST $USAI_PROVIDER_KEY_ENV # github -> github.com,api.github.com GITHUB_TOKEN (sbx built-in service) +# other -> the provider's validated facts.json (providerId -> host, keyEnv), +# resolved through acq's credential-bindings registry. This is what +# lets a provider kit hosted outside the pinned Patterns release be +# injected with no per-provider code here. # Echoes "host1[,host2] ENVVAR"; empty for unknown services. _acq_service_hosts_env() { + local provider_binding provider_host provider_env case "$1" in - usai) printf '%s\t%s\n' "$USAI_PROVIDER_BIND_HOSTS" "$USAI_PROVIDER_KEY_ENV" ;; github) printf 'github.com,api.github.com\tGITHUB_TOKEN\n' ;; - *) printf '\t\n' ;; + *) + # Providers (the default one included) resolve from acq's registry + the + # validated facts; there is no `usai` branch. The sandbox is passed + # explicitly so a standalone `acq secret set -g ` uses the pinned host + # and rm/rotate use a sandbox's recorded facts. + if command -v acq_provider_binding_host_env >/dev/null 2>&1 \ + && provider_binding=$(acq_provider_binding_host_env "$1" "${2:-}" 2>/dev/null); then + provider_host=$(printf '%s' "$provider_binding" | cut -f1) + provider_env=$(printf '%s' "$provider_binding" | cut -f2) + if [ -n "$provider_host" ] && [ -n "$provider_env" ]; then + printf '%s\t%s\n' "$provider_host" "$provider_env" + return 0 + fi + fi + printf '\t\n' + ;; esac } @@ -1460,8 +1567,8 @@ acq_backend_secret_set() { *" $service "*) : ;; # built-in: no default host/env *) local svc_hosts svc_env - svc_hosts=$(_acq_service_hosts_env "$service" | cut -f1) - svc_env=$(_acq_service_hosts_env "$service" | cut -f2) + svc_hosts=$(_acq_service_hosts_env "$service" "$scope_name" | cut -f1) + svc_env=$(_acq_service_hosts_env "$service" "$scope_name" | cut -f2) [ -z "$host" ] && [ -n "$svc_hosts" ] && host="${svc_hosts%%,*}" # primary host [ -z "$env_var" ] && [ -n "$svc_env" ] && env_var="$svc_env" ;; @@ -1637,7 +1744,7 @@ acq_backend_secret_set() { # The user's --host wins over the static service mapping (#384). svc_hosts="$host" else - svc_hosts=$(_acq_service_hosts_env "$service" | cut -f1) + svc_hosts=$(_acq_service_hosts_env "$service" "$scope_name" | cut -f1) [ -z "$svc_hosts" ] && svc_hosts="$host" fi local _oldifs="$IFS"; IFS=',' @@ -1719,8 +1826,8 @@ acq_backend_secret_propagate() { env_var=$(printf '%s' "$meta" | cut -f2) sidecar=1 else - host=$(_acq_service_hosts_env "$service" | cut -f1) - env_var=$(_acq_service_hosts_env "$service" | cut -f2) + host=$(_acq_service_hosts_env "$service" "$scope_name" | cut -f1) + env_var=$(_acq_service_hosts_env "$service" "$scope_name" | cut -f2) sidecar=0 fi @@ -1865,7 +1972,7 @@ acq_backend_secret_rm() { # secrets table for this scope + env var — the sidecar env wins over the # static mapping (it records what was actually bound) — then remove it. local env_var placeholder - env_var="${sidecar_env:-$(_acq_service_hosts_env "$service" | cut -f2)}" + env_var="${sidecar_env:-$(_acq_service_hosts_env "$service" "$scope_name" | cut -f2)}" placeholder=$(_acq_sbx_custom_placeholder "$scope_flag" "$scope_name" "$env_var") if [ -n "$placeholder" ]; then sbx secret rm --placeholder "$placeholder" ${scope_args[@]+"${scope_args[@]}"} -f /dev/null 2>&1 || true @@ -1940,15 +2047,23 @@ _acq_sbx_secret_exists() { } acq_backend_key_present() { - local service="${1:-}" scope_sandbox="${2:-}" + local service="${1:-}" scope_sandbox="${2:-}" provider_binding provider_env case "$service" in - usai) - if [ -n "$scope_sandbox" ] && _acq_sbx_secret_exists "" "$scope_sandbox" usai "$USAI_PROVIDER_KEY_ENV"; then - return 0 - fi - _acq_sbx_secret_exists -g "" usai "$USAI_PROVIDER_KEY_ENV" - ;; *) + # Every provider (the default one included) is registry-driven: injectable + # once sbx's table carries its KEY_ENV for this scope. A service acq does + # not model keeps the permissive default. + if command -v acq_provider_binding_host_env >/dev/null 2>&1 \ + && provider_binding=$(acq_provider_binding_host_env "$service" "$scope_sandbox" 2>/dev/null); then + provider_env=$(printf '%s' "$provider_binding" | cut -f2) + [ -n "$provider_env" ] || return 1 + if [ -n "$scope_sandbox" ] \ + && _acq_sbx_secret_exists "" "$scope_sandbox" "$service" "$provider_env"; then + return 0 + fi + _acq_sbx_secret_exists -g "" "$service" "$provider_env" + return $? + fi return 0 ;; esac diff --git a/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index 5f80ea4c..1ac5e1e1 100644 --- a/docs/BACKEND_GUIDE.md +++ b/docs/BACKEND_GUIDE.md @@ -1178,6 +1178,63 @@ practical floor (`acq kit validate` warns below it). Manage kits with `acq kit list | validate PATH | apply NAME KITREF`. +### Provider kits and generic credential binding + +A **provider kit** ships `provider-input.json` and, after validation, a +`facts.json` naming `providerId`, `keyEnv`, `host`, `modelsUrl`, and +`apiProtocol`. `acq` validates the selected set host-side with the pinned +Patterns validator, then materializes an immutable generation the agent kit +renders (`models/providers//`). + +The validator checks each kit's `facts.keyEnv` against a **credential-bindings +registry**. `acq` owns that registry so a provider kit hosted outside the pinned +Patterns release works without editing it. The merge order (highest precedence +last) is: + +1. the pinned Patterns `provider-credential-bindings.json` — the trust anchor; +2. `provider_credential_bindings:` in `~/.config/acq/config.yaml` + (`acq provider-credential set|ls|rm`); +3. `ACQ_PROVIDER_CREDENTIAL_BINDINGS` (`id=ENV[@host] …`) for one invocation. + +The merged table is written into the prepare staging directory and passed to the +validator; it is removed with that stage. Malformed ids, reserved ENV names, or +malformed hosts fail closed before validation runs. + +**Host pinning.** A kit can declare any `providerId` and allow-list any host, so +the registry also pins the destination host(s) per providerId +(`--host` is required by `acq provider-credential set`). Before acq binds a +stored credential it requires `facts.host` to match the pinned host, or — when +nothing is pinned for that provider — the host recorded for that sandbox at +create. A mismatch, an unpinned provider, or a host that changed under an +existing record **fails closed**; moving a credential to a new host is an +explicit re-pin. The gate prompt and `acq provider-credential ls` both show the +destination host. + +Injection is then data-driven per backend — no provider-specific `acq` code: + +| Backend | How `keyEnv@host` is bound at create | +|---------|--------------------------------------| +| sbx | a custom proxy secret (`sbx secret set-custom --host … --env …`) derived from the provider's facts; create snapshots the table, so a host-exported value cannot be bound and acq fails closed instead | +| msb | `--secret ENV@HOST` from the same facts, value resolved out of the acq store (or from a host-exported value, for CI) | + +`acq secret set -g ` stores the value under that provider; the +injected `providerId`/`keyEnv`/`host`/`modelsUrl` are recorded in the sandbox's +host config so resume/heal re-injects without re-prompting. Binding helpers take +the sandbox name explicitly, so `acq start`/`restart` and `acq secret rm` resolve +the same binding as create. On attach, `check_provider_key` probes +`facts.modelsUrl` with `Authorization: Bearer $keyEnv` (URL passed as positional +data, never spliced into the guest program): `401`/`403` and redirects fail +closed, `429`/`5xx` are transient, and an inconclusive probe refuses to attach. + +There is **no USAi-specific key path**. The default provider (`usai`) is the +first entry of the shipped trust-anchor table +(`usai|USAI_API_KEY|api.gsa.usai.gov`), so it is gated, bound, recorded, and +validated exactly like any other provider; a facts-free fallback to that table +keeps a sandbox created before the provider-input pipeline resumable. The one +provider-specific verb left is `acq usai-rotate-api-key`, because rotation needs +the USAi key-management console — a rejected default-provider key points at it +instead of offering an interactive prompt. + ### ADR-0030 generic-base implementation ADR-0030 now makes `acq run opencode .` use the generic pinned devenv base and diff --git a/docs/KNOWN_FAILURE_MODES.md b/docs/KNOWN_FAILURE_MODES.md index 3823ae4e..ed79159c 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -3,7 +3,7 @@ title: "Known Failure Modes" description: "Real-world failure patterns when using Docker SBX + USAi + agent frameworks" status: canonical tier: 2 -last_updated: "2026-10-22" +last_updated: "2026-10-28" audience: "developers" keywords: ["debugging", "troubleshooting", "sbx", "usai", "failures"] --- @@ -2959,6 +2959,39 @@ the fix), and covers a transient and a persistent inspect failure via the stub's --- +## 46. `acq kit-source rm` Refuses to Finish (Backend Allowlist Still Permits the Owner) + +### Symptoms + +```text +acq: error: could not write kit.allowedSources, so sbx kit.allowedSources still permits 'github.com/acme/'. + Revocation is NOT complete: the source stays registered ... +acq: kit source 'github.com/acme/kits' was NOT removed (still registered). +``` + +### Root Cause + +sbx allowlists kit sources by **owner prefix** (`host/owner/`), while `acq` trusts +**repositories**. Removing a source must therefore also drop the owner prefix from +sbx, but only if no other trusted repository (and no `ACQ_EXTRA_KIT_SOURCES` +entry) still needs it. When `acq` cannot read, write, or verify that backend +setting (`jq` or `sbx` missing, a non-array value, or `sbx settings set` failing), +it refuses to report the source as removed rather than leave a silently stale +grant. + +### Fix + +Install the missing tool, or run the `sbx settings set kit.allowedSources ''` +command the error prints, then re-run `acq kit-source rm ` (it is +idempotent). `acq kit-source ls` shows the source until the removal completes. + +### Prevention + +Regression tests in `test/bats/149-kit-source.bats` cover shared-prefix removal +and the read/write/`jq` failure paths. + +--- + ## Contributing When you discover a new failure mode: diff --git a/docs/howto/acq.md b/docs/howto/acq.md index a3f5b20d..9b99a49e 100644 --- a/docs/howto/acq.md +++ b/docs/howto/acq.md @@ -3,7 +3,7 @@ title: "acq How-To Guide" description: "Detailed how-to for acq, the pluggable-backend wrapper for agentic-coding-quickstart" status: canonical tier: 2 -last_updated: "2026-10-25" +last_updated: "2026-10-28" audience: "developers" keywords: ["acq", "backend", "sbx", "msb", "howto", "sandbox"] related_files: ["docs/BACKEND_GUIDE.md", "docs/CONCEPTS.md", "docs/howto/msb.md", "docs/howto/sbx.md", "docs/adr/0010-acq-pluggable-backends.md", "docs/adr/0011-msb-backend-and-neutral-kits.md"] @@ -573,6 +573,152 @@ of kit refs, following the same format as `ACQ_EXTRA_KITS`. `ACQ_PROVIDER_KITS=' also selects the empty set, but `--no-provider-kits` is the portable form when configuration layers cannot preserve an empty environment value. +### Non-USAi provider kits + +A provider kit is **data**. Once the pinned validation pipeline accepts it, its +`facts.json` names everything `acq` needs: `providerId`, `keyEnv`, `host`, +`modelsUrl`, and `apiProtocol`. That means a provider kit from **your own +repository** works without touching the pinned Patterns release: + +```bash +acq create opencode --kit 'git+https://github.com/acme/kits.git#ref=&dir=acme-ai' /path/to/project +``` + +`acq` owns the provider → credential registry that the validator checks the kit +against, so declare where the key comes from and `acq` does the rest. The +registry is merged in this order (highest precedence last): + +1. the pinned Patterns `provider-credential-bindings.json` — the trust anchor; +2. `provider_credential_bindings:` in `~/.config/acq/config.yaml` — durable; +3. `ACQ_PROVIDER_CREDENTIAL_BINDINGS` — one invocation only. + +A layer entry is `providerId=KEY_ENV` or `providerId=KEY_ENV@host[,host]`. + +```bash +# Durable: pin a provider to a credential env var AND the host it may use +acq provider-credential set opencode-go OPENCODE_GO_API_KEY \ + --host models.opencode-go.example +acq provider-credential ls +acq provider-credential rm opencode-go + +# Per-invocation override (whitespace-separated entries) +ACQ_PROVIDER_CREDENTIAL_BINDINGS='opencode-go=OPENCODE_GO_API_KEY@models.opencode-go.example' \ + acq run opencode /path/to/project +``` + +**The host is the trust decision, and it is required.** A kit can claim any +`providerId` and allow-list any host, so `acq` sends your stored credential only +to hosts you pinned. `--host` is therefore mandatory for `set`, and an unpinned +provider — or a kit whose `facts.host` is not the pinned (or previously +recorded) host for that `providerId` — **fails closed** rather than quietly +moving your credential to a new destination. Changing hosts is an explicit +re-pin, never a silent overwrite. + +`providerId` must match `^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$`, `KEY_ENV` must be +an upper-case env var name (`^[A-Z][A-Z0-9_]*$`), and hosts must be bare DNS +names. Built-in credential names (`USAI_API_KEY`, `GITHUB_TOKEN`, `GH_TOKEN`, +`GITLAB_TOKEN`) and process-critical names (`PATH`, `IFS`, `HOME`, `LD_PRELOAD`, +…) are refused. A malformed entry fails closed before the validator runs. + +The key itself never goes in the registry; it lives in the acq secret store: + +```bash +acq secret set -g opencode-go # prompts for the value (never on argv) +``` + +From there the injection is generic — no provider-specific `acq` code: + +- at **create**, the selected generation's `facts.json` is the facts source (the + same data `usai` gets from `provider-facts/usai.env`), the registry decides the + ENV var, and the credential is bound as `KEY_ENV@host` by both backends (`sbx` + custom secret; msb `--secret ENV@HOST`). A value exported by CI is bindable on + msb; on sbx, which snapshots its own proxy table, `acq` fails closed and tells + you to store it; +- the binding (`providerId`, `keyEnv`, `host`, `modelsUrl`) is recorded in the + sandbox's host config, so resume/heal re-injects it without re-prompting, and a + later host change is caught and refused; +- on attach, `acq` probes `facts.modelsUrl` with + `Authorization: Bearer $KEY_ENV` (`check_provider_key`) and fails closed on a + rejected key (`401`/`403`) or a redirect; `429`/`5xx` are treated as transient + and do not block the attach. **USAi is not special-cased**: it is the default + provider kit (`provider_kit: agentic-coding-patterns/usai-provider`) and its + `(USAI_API_KEY, api.gsa.usai.gov)` pair is the shipped trust anchor, so it is + gated, bound, and validated like any other provider. A rejected USAi key points + at `acq usai-rotate-api-key`, the one remaining provider-specific verb (it needs + the USAi key-management console); there is no interactive rotation prompt. + +If a provider kit is selected but no credential is available, the create fails +closed (non-interactive) or offers to store one (interactive), showing the +destination host in the prompt. + +### Trusted kit sources + +`acq` fetches kits only from **trusted sources**, recorded as data in +`~/.config/acq/config.yaml`: + +```bash +acq kit-source add github.com/acme/kits --ref # trust a repo at a pinned commit +acq kit-source ls # sources + the provider kits they publish +acq kit-source rm github.com/acme/kits +``` + +The shipped default is `github.com/GSA-TTS/agentic-coding-patterns` pinned to the +release `acq` was built against, so a fresh install needs no configuration. +`ACQ_KIT_SOURCES` overrides the list for one invocation (whitespace-separated +`host/owner/repo[@ref][|dir]` entries); `ACQ_EXTRA_KIT_SOURCES` still **adds** +owner-wide allowlist prefixes for backward compatibility. + +**Trust granularity.** Trust is recorded and checked per **repository** +(`host/owner/repo`): trusting `github.com/acme/trusted` does not authorize +`github.com/acme/other`. The one place a coarser unit appears is a backend that can +only allowlist by prefix: sbx `kit.allowedSources` is owner-granular +(`host/owner/`), so the prefix handed to sbx is derived from the registry. `acq` +therefore re-checks the repository itself before it acts on a durable +`provider_kit:` ref. `ACQ_EXTRA_KIT_SOURCES` is the only owner-wide grant, and it is +explicit. + +Trust is **narrow**: a trusted source may be fetched from, and may contribute +provider kits. It does **not** extend to the validation control plane — the +provider-input validator and its schemas stay pinned to the Patterns release, so a +new source can ship kits but cannot redefine what a valid kit is. Because the +pinned release *is* the control plane, it stays resolvable even when the registry +is overridden for a run. + +`acq kit-source ls` also **auto-lists** the provider kits each source publishes +(by scanning for `*/provider-input.json`). Discovery is best-effort: an +unreachable source simply lists nothing. + +**What the registry does and does not gate.** The registry governs the sources +`acq` *resolves* on your behalf — the trusted-source allowlist handed to the +backend (sbx `kit.allowedSources`, updated on `add` **and** revoked on `rm`) and +`provider_kit:` entries written in `config.yaml`, which must name a trusted +repository exactly (or an already-trusted remote ref to one). A **literal ref you supply on this invocation** +(`--kit git+https://…`, `ACQ_PROVIDER_KITS`, `ACQ_EXTRA_KITS`) is trusted by *you*, +not by the registry: `acq` fetches it as given. Local paths are always used +verbatim. In short: the registry is the durable-trust boundary, not a sandbox for +refs you type. + +**Removing a source (`kit-source rm`) fails closed.** `acq` first recomputes what +the backend allowlist should be from the sources that *remain* (plus any explicit +`ACQ_EXTRA_KIT_SOURCES`), so removing one repository never revokes an owner prefix +another trusted repository still needs. If the backend allowlist cannot be read, +written, or verified (for example `jq` or `sbx` is missing, or `sbx settings set` +fails), the command exits non-zero, leaves the source registered, and prints the +exact `sbx settings set kit.allowedSources …` recovery command. Fix the cause (or +apply the printed command) and re-run `acq kit-source rm`; it is safe to repeat. + +The provider kit `acq` uses by default is likewise data: + +```yaml +provider_kit: agentic-coding-patterns/usai-provider # default +# or: agentic-coding-kits/opencode-go-provider +# or: opencode-go-provider@github.com/mogul/agentic-coding-kits +``` + +`acq configure` offers the discovered kits in a picker, so you can select one +without hand-writing a `git+https` ref. Per-run selection is still +`ACQ_PROVIDER_KITS` / `--no-provider-kits`. + --- ## Progress output diff --git a/scripts/verify-backends b/scripts/verify-backends index 4cec0655..14ecc4a3 100755 --- a/scripts/verify-backends +++ b/scripts/verify-backends @@ -441,10 +441,9 @@ load_provider_facts() { rm -f "$_diag_file" return "$source_rc" fi - if ! acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT"; then - echo "verify-backends: provider facts could not be loaded from $USAI_KIT" >&2 - return 1 - fi + # Provider facts are no longer fetched host-side: the provider's endpoints come + # from the validated facts.json materialized into the sandbox, and the DEFAULT + # provider's host/keyEnv come from the shipped trust-anchor table. USAI_HOST="$USAI_PROVIDER_HOST" USAI_MODELS_URL="$USAI_PROVIDER_MODELS_URL" } diff --git a/test/bats/146-provider-verification.bats b/test/bats/146-provider-verification.bats index 1f262321..48713293 100644 --- a/test/bats/146-provider-verification.bats +++ b/test/bats/146-provider-verification.bats @@ -196,23 +196,19 @@ PRELUDE assert_success } -@test "provider verification: projected provider facts drive the reachability input (offline seam, not live backend validation)" { - # Offline seam: stubs load_provider_facts and check_usai_reachability to verify - # that the URL from provider facts flows through to the reachability call. - # This does NOT validate a real projected parser, a real kit, or a live backend. +@test "provider verification: the reachability input is the provider's models URL" { + # Offline seam: the URL handed to the reachability probe comes from the + # provider's endpoints (now shipped constants instead of a fetched facts file). run bash -c ' set -u VERIFY_BACKENDS_SOURCE_ONLY=1 export VERIFY_BACKENDS_SOURCE_ONLY . "'"$REPO_ROOT"'/scripts/verify-backends" - ACQ=/dev/null - USAI_KIT="$STUBDIR/provider-kit" - acq_provider_facts_load_from_kit_or_fallback() { - USAI_PROVIDER_HOST=models.example.gov - USAI_PROVIDER_MODELS_URL=https://models.example.gov/v2/catalog - USAI_PROVIDER_KEY_ENV=EXAMPLE_API_KEY - } + ACQ="'"$REPO_ROOT"'/acq" + ACQ_SOURCE_ONLY=1 + export ACQ_SOURCE_ONLY load_provider_facts + USAI_MODELS_URL=https://models.example.gov/v2/catalog FAKE_OUTPUT=200 FAKE_RC=0 LAST_ARGS= run_acq() { LAST_ARGS="$*"; LAST_OUT="$FAKE_OUTPUT"; return "$FAKE_RC"; } check_usai_reachability msb box "$USAI_MODELS_URL" @@ -224,33 +220,21 @@ PRELUDE assert_success } -@test "provider verification: malformed provider facts fail closed before reachability (offline seam, not live kit validation)" { - # Offline seam: verifies that load_provider_facts propagates failures and - # does not silently fall through to reachability checks. Does NOT test a real - # provider kit or a live projected parser. +@test "provider verification: load_provider_facts yields the shipped default endpoints" { run bash -c ' set -u VERIFY_BACKENDS_SOURCE_ONLY=1 export VERIFY_BACKENDS_SOURCE_ONLY . "'"$REPO_ROOT"'/scripts/verify-backends" - ACQ=/dev/null - USAI_KIT="$STUBDIR/provider-kit" - acq_provider_facts_load_from_kit_or_fallback() { - printf invalid-provider-facts-artifact\\n >&2 - USAI_PROVIDER_HOST="" - USAI_PROVIDER_MODELS_URL="" - return 1 - } - if load_provider_facts; then - printf "unexpected provider facts success\n" - exit 1 - fi - printf "provider facts rejected\n" + ACQ="'"$REPO_ROOT"'/acq" + ACQ_SOURCE_ONLY=1 + export ACQ_SOURCE_ONLY + load_provider_facts + printf "%s\n%s\n" "$USAI_HOST" "$USAI_MODELS_URL" ' assert_success - assert_output --partial 'invalid-provider-facts-artifact' - assert_output --partial 'verify-backends: provider facts could not be loaded from' - assert_output --partial 'provider facts rejected' + assert_output --partial 'api.gsa.usai.gov' + assert_output --partial 'https://api.gsa.usai.gov/api/v1/models' } @test "provider verification: acq source failure is actionable and does not fall through (offline seam with syntax error)" { diff --git a/test/bats/148-provider-credential-bindings.bats b/test/bats/148-provider-credential-bindings.bats new file mode 100644 index 00000000..8ee2f904 --- /dev/null +++ b/test/bats/148-provider-credential-bindings.bats @@ -0,0 +1,887 @@ +#!/usr/bin/env bats +# +# 148-provider-credential-bindings.bats — acq-owned provider credential bindings +# and generic (non-USAi) provider credential injection. +# +# A provider kit is data: validated facts.json names its providerId, keyEnv, +# host, modelsUrl, and apiProtocol. acq owns the providerId -> (KEY_ENV, host) +# registry so a provider kit hosted outside the pinned Patterns release +# (`--kit git+https://…`) validates and is injected with no per-provider code — +# while a kit that merely CLAIMS an existing providerId cannot redirect a stored +# credential to a host the user never pinned. +# +# shellcheck shell=bats + +load 'helper' + +setup() { acq_setup_stubs; } +teardown() { acq_teardown_stubs; } + +# _make_generation PROVIDER KEY_ENV [HOST] — build a materialized generation +# directory holding one provider's validated facts.json, and echo its path. +_make_generation() { + local provider_id="$1" key_env="$2" host="${3:-$1.example.gov}" + local generation="$STUBDIR/generation.$provider_id" + mkdir -p "$generation/models/providers/$provider_id" + printf '{"schemaVersion":"acq-provider-facts/v1","providerId":"%s","apiProtocol":"openai-compatible","host":"%s","baseUrl":"https://%s/v1","modelsUrl":"https://%s/v1/models","keyEnv":"%s"}\n' \ + "$provider_id" "$host" "$host" "$host" "$key_env" \ + > "$generation/models/providers/$provider_id/facts.json" + printf '%s\n' "$generation" +} + +# _pin PROVIDER KEY_ENV HOST — write a registry entry straight into config.yaml. +_pin() { + _acq_config_write_field provider_credential_bindings "$1=$2@$3" +} + +# _provider_release ROOT — a stand-in pinned Patterns release: a validator stub +# that records the argv it received, and a base credential-bindings table. +_provider_release() { + local root="$1" + mkdir -p "$root/scripts" "$root/integrations/isolation/schemas" + cat > "$root/provider-python" <<'PYTHON' +#!/bin/sh +if [ "$1" = -c ]; then exit 0; fi +exec /usr/bin/python3 "$@" +PYTHON + chmod +x "$root/provider-python" + export ACQ_PROVIDER_INPUT_PYTHON="$root/provider-python" + printf '{"providers":{"alpha":"ALPHA_KEY","beta":"BETA_KEY"}}\n' \ + > "$root/integrations/isolation/schemas/provider-credential-bindings.json" + cat > "$root/scripts/validate_provider_input_contract.py" <<'PY' +import json, os, pathlib, sys +args = sys.argv[1:] +pathlib.Path(os.environ['VALIDATOR_LOG']).write_text('\n'.join(args)) +bindings = [args[i + 1] for i, value in enumerate(args) if value == '--credential-bindings'][0] +pathlib.Path(os.environ['VALIDATOR_BINDINGS_PATH']).write_text(bindings) +pathlib.Path(os.environ['VALIDATOR_BINDINGS_COPY']).write_text( + pathlib.Path(bindings).read_text() +) +for bundle_path in [args[i + 1] for i, value in enumerate(args) if value == '--bundle']: + json.loads(pathlib.Path(bundle_path).read_text()) +PY +} + +# _provider_kit ROOT NAME PROVIDER_ID KEY_ENV — a provider-role kit fixture. +_provider_kit() { + local release="$1" name="$2" provider_id="${3:-$2}" key_env="$4" + local kit="$release/integrations/isolation/acq-kits/$name" + mkdir -p "$kit/provider-input" "$kit/scripts" "$kit/files" + printf '{"providerId":"%s","schemaVersion":"acq-provider-input-bundle/v2","providerPath":"models/providers/%s","factsPath":"provider-input/facts.json","normalizerPath":"scripts/normalize","snapshotPath":"files/snapshot.json","credentialEnvNames":["%s"]}\n' \ + "$provider_id" "$provider_id" "$key_env" > "$kit/provider-input.json" + printf '{"providerId":"%s"}\n' "$provider_id" > "$kit/provider-input/facts.json" + printf '#!/bin/sh\n' > "$kit/scripts/normalize"; chmod 755 "$kit/scripts/normalize" + printf '{"providerId":"%s","models":[]}\n' "$provider_id" > "$kit/files/snapshot.json" + printf 'caps:\n network:\n allow:\n - %s.example.gov\n' "$provider_id" > "$kit/spec.yaml" +} + +# --------------------------------------------------------------------------- +# acq provider-credential set | ls | rm +# --------------------------------------------------------------------------- + +@test "provider-credential set pins a provider to a host that ls reports" { + run "$ACQ" provider-credential set opencode-go OPENCODE_GO_API_KEY \ + --host models.opencode-go.example + assert_success + grep -Fq 'provider_credential_bindings: opencode-go=OPENCODE_GO_API_KEY@models.opencode-go.example' \ + "$XDG_CONFIG_HOME/acq/config.yaml" + + run "$ACQ" provider-credential ls + assert_success + assert_output --partial 'opencode-go' + assert_output --partial 'OPENCODE_GO_API_KEY' + assert_output --partial 'models.opencode-go.example' + assert_output --partial 'config.yaml' +} + +@test "provider-credential set REQUIRES an explicit host" { + run "$ACQ" provider-credential set opencode-go OPENCODE_GO_API_KEY + assert_failure + assert_output --partial 'a --host is required' + assert_output --partial 'never infers it from a kit' + refute [ -e "$XDG_CONFIG_HOME/acq/config.yaml" ] +} + +@test "provider-credential set rejects a host that is not a bare DNS name" { + run "$ACQ" provider-credential set opencode-go OPENCODE_GO_API_KEY \ + --host 'https://models.opencode-go.example/v1' + assert_failure + assert_output --partial 'invalid host' + + run "$ACQ" provider-credential set opencode-go OPENCODE_GO_API_KEY \ + --host '*.opencode-go.example' + assert_failure + assert_output --partial 'invalid host' +} + +@test "provider-credential set replaces one binding without dropping the others" { + run "$ACQ" provider-credential set alpha ALPHA_KEY --host alpha.example.gov + assert_success + run "$ACQ" provider-credential set opencode-go OPENCODE_GO_API_KEY \ + --host models.opencode-go.example + assert_success + run "$ACQ" provider-credential set opencode-go OPENCODE_GO_KEY_V2 \ + --host models2.opencode-go.example + assert_success + + run "$ACQ" provider-credential ls + assert_success + assert_line --partial 'alpha' + assert_line --partial 'ALPHA_KEY' + refute_output --partial 'OPENCODE_GO_API_KEY' + assert_line --partial 'OPENCODE_GO_KEY_V2' + assert_line --partial 'models2.opencode-go.example' +} + +@test "provider-credential set rejects a malformed provider id or KEY_ENV" { + run "$ACQ" provider-credential set OpenCode OPENCODE_GO_API_KEY --host a.example.gov + assert_failure + assert_output --partial "invalid provider id 'OpenCode'" + + run "$ACQ" provider-credential set opencode-go opencode_go_key --host a.example.gov + assert_failure + assert_output --partial "invalid KEY_ENV 'opencode_go_key'" + + run "$ACQ" provider-credential set opencode- OPENCODE_GO_API_KEY --host a.example.gov + assert_failure + assert_output --partial 'invalid provider id' +} + +@test "provider-credential set refuses reserved and process-critical KEY_ENVs" { + run "$ACQ" provider-credential set opencode-go GITHUB_TOKEN --host a.example.gov + assert_failure + assert_output --partial "belongs to the 'github' service" + + run "$ACQ" provider-credential set opencode-go PATH --host a.example.gov + assert_failure + assert_output --partial 'invalid KEY_ENV' + + # The owning provider may still use its own name (nothing to pin, but valid). + run "$ACQ" provider-credential set github GITHUB_TOKEN --host github.com + assert_success +} + +@test "provider-credential set rejects an unexpected extra argument" { + run "$ACQ" provider-credential set opencode-go OPENCODE_GO_API_KEY \ + --host a.example.gov --bogus + assert_failure + assert_output --partial "unexpected argument '--bogus'" +} + +@test "provider-credential rm removes a binding and reports an unknown one" { + run "$ACQ" provider-credential set opencode-go OPENCODE_GO_API_KEY --host a.example.gov + assert_success + + run "$ACQ" provider-credential rm opencode-go + assert_success + run "$ACQ" provider-credential ls + assert_success + assert_output --partial '(none)' + + run "$ACQ" provider-credential rm opencode-go + assert_failure + assert_output --partial "no config.yaml binding for provider 'opencode-go'" +} + +@test "provider-credential ls prints both layers on separate lines" { + run "$ACQ" provider-credential set alpha ALPHA_KEY --host a.example.gov + assert_success + export ACQ_PROVIDER_CREDENTIAL_BINDINGS='beta=BETA_KEY@b.example.gov' + + run "$ACQ" provider-credential ls + assert_success + # One row per layer — the config row must not be glued onto the env row. + assert_line --partial 'alpha' + assert_line --partial 'config.yaml' + assert_line --partial 'beta' + assert_line --partial 'env' + [ "${#lines[@]}" -ge 3 ] + printf '%s\n' "${lines[@]}" | grep -q 'config.yaml$' +} + +@test "provider-credential ls fails closed on a malformed env layer" { + run env ACQ_PROVIDER_CREDENTIAL_BINDINGS='not-a-pair' \ + "$ACQ" provider-credential ls + assert_failure + assert_output --partial "expected '='" +} + +@test "provider-credential with no subcommand prints usage and is a usage error" { + run "$ACQ" provider-credential + assert_failure 2 + assert_output --partial 'acq provider-credential' + + run "$ACQ" provider-credential --help + assert_success + assert_output --partial 'ACQ_PROVIDER_CREDENTIAL_BINDINGS' + assert_output --partial '--host is REQUIRED' +} + +# --------------------------------------------------------------------------- +# Three-layer merge into the pinned validator +# --------------------------------------------------------------------------- + +@test "prepare validates against the merged registry (base + config + env)" { + local release="$STUBDIR/release" bindings_path + _provider_release "$release" + _provider_kit "$release" alpha alpha ALPHA_KEY + export VALIDATOR_LOG="$STUBDIR/validator.log" + export VALIDATOR_BINDINGS_PATH="$STUBDIR/bindings-path" + export VALIDATOR_BINDINGS_COPY="$STUBDIR/bindings-copy.json" + _acq_provider_input_control_plane() { printf '%s\n' "$release"; } + # shellcheck disable=SC2034 # acq_provider_input_prepare reads this global + KITS=("$release/integrations/isolation/acq-kits/alpha") + + export ACQ_PROVIDER_CREDENTIAL_BINDINGS='beta=BETA_KEY_FROM_ENV@b.example.gov' + run "$ACQ" provider-credential set gamma GAMMA_KEY --host g.example.gov + assert_success + + run acq_provider_input_prepare sbx mergebox + assert_success + + # The validator saw a MERGED file, not the pinned base file. + bindings_path=$(cat "$VALIDATOR_BINDINGS_PATH") + [ "$bindings_path" != \ + "$release/integrations/isolation/schemas/provider-credential-bindings.json" ] + # Base defaults survive; config and env layers are layered on top. The document + # keeps the validator's own shape (providerId -> ENV), so acq's host pins never + # leak into the schema the pinned release defines. + grep -Fq '"alpha": "ALPHA_KEY"' "$VALIDATOR_BINDINGS_COPY" + grep -Fq '"beta": "BETA_KEY_FROM_ENV"' "$VALIDATOR_BINDINGS_COPY" + grep -Fq '"gamma": "GAMMA_KEY"' "$VALIDATOR_BINDINGS_COPY" + refute grep -Fq '"hosts"' "$VALIDATOR_BINDINGS_COPY" + # The merged file lived in the staging dir, so the stage cleanup removed it. + refute [ -e "$bindings_path" ] +} + +@test "prepare fails closed on a malformed registry entry and never validates" { + local release="$STUBDIR/release" root + _provider_release "$release" + _provider_kit "$release" alpha alpha ALPHA_KEY + export VALIDATOR_LOG="$STUBDIR/validator.log" + export VALIDATOR_BINDINGS_PATH="$STUBDIR/bindings-path" + export VALIDATOR_BINDINGS_COPY="$STUBDIR/bindings-copy.json" + _acq_provider_input_control_plane() { printf '%s\n' "$release"; } + # shellcheck disable=SC2034 # acq_provider_input_prepare reads this global + KITS=("$release/integrations/isolation/acq-kits/alpha") + export ACQ_PROVIDER_CREDENTIAL_BINDINGS='broken-token' + + run acq_provider_input_prepare sbx badbindings + assert_failure + assert_output --partial "expected '='" + refute [ -e "$STUBDIR/validator.log" ] + + root=$(acq_provider_input_root sbx badbindings) + refute [ -e "$root/current" ] +} + +@test "prepare fails closed when a provider id in the registry is unsafe" { + local release="$STUBDIR/release" + _provider_release "$release" + _provider_kit "$release" alpha alpha ALPHA_KEY + export VALIDATOR_LOG="$STUBDIR/validator.log" + export VALIDATOR_BINDINGS_PATH="$STUBDIR/bindings-path" + export VALIDATOR_BINDINGS_COPY="$STUBDIR/bindings-copy.json" + _acq_provider_input_control_plane() { printf '%s\n' "$release"; } + # shellcheck disable=SC2034 # acq_provider_input_prepare reads this global + KITS=("$release/integrations/isolation/acq-kits/alpha") + export ACQ_PROVIDER_CREDENTIAL_BINDINGS='bad/id=BAD_KEY' + + run acq_provider_input_prepare sbx badidbox + assert_failure + assert_output --partial 'invalid provider id' + refute [ -e "$STUBDIR/validator.log" ] +} + +# --------------------------------------------------------------------------- +# Registry drives generic provider handling +# --------------------------------------------------------------------------- + +@test "a registry provider is an acq-managed service with an importable key env" { + export ACQ_PROVIDER_CREDENTIAL_BINDINGS='opencode-go=OPENCODE_GO_API_KEY@go.example.gov' + + run _acq_is_managed_service opencode-go + assert_success + + run _acq_import_env_vars_for opencode-go + assert_success + assert_output 'OPENCODE_GO_API_KEY' + + run _acq_is_managed_service not-a-registry-provider + assert_failure + + # `acq secret rm ` routes to the acq-store removal path. + run _acq_is_managed_secret_rm -g opencode-go + assert_success +} + +@test "secret import treats a known provider id as a service, an unknown token as a scope" { + export ACQ_PROVIDER_CREDENTIAL_BINDINGS='opencode-go=OPENCODE_GO_API_KEY@go.example.gov' + + # A KNOWN provider id is the service even as a bare token — that is what keeps + # `acq secret import usai` working after usai stopped being a built-in service. + run _acq_import_parse_args opencode-go + assert_success + assert_output "$(printf 'interactive\t0\topencode-go\t')" + + run _acq_import_parse_args --service=opencode-go + assert_success + assert_output "$(printf 'interactive\t0\topencode-go\t')" + + # An unknown token is the SANDBOX scope, not a service. + run _acq_import_parse_args my-sandbox + assert_success + assert_output "$(printf 'interactive\t0\t\tmy-sandbox')" + + run _acq_import_parse_args --service=nope-not-a-provider + assert_failure + assert_output --partial 'unknown service' +} + +@test "the env layer overrides the config layer for one provider" { + run "$ACQ" provider-credential set opencode-go OPENCODE_GO_API_KEY --host a.example.gov + assert_success + export ACQ_PROVIDER_CREDENTIAL_BINDINGS='opencode-go=OPENCODE_GO_OVERRIDE@b.example.gov' + + run acq_provider_credential_key_env opencode-go + assert_success + assert_output 'OPENCODE_GO_OVERRIDE' + + run acq_provider_credential_hosts opencode-go + assert_success + assert_output 'b.example.gov' +} + +@test "configure shows the configured provider bindings" { + _acq_config_write_field provider_credential_bindings 'opencode-go=OPENCODE_GO_API_KEY@go.example.gov' + + run _acq_configure_show_current + assert_success + assert_output --partial 'provider creds: opencode-go=OPENCODE_GO_API_KEY@go.example.gov' +} + +# --------------------------------------------------------------------------- +# facts.json is the only provider-specific input, and it is untrusted +# --------------------------------------------------------------------------- + +@test "facts.json records are validated before they become backend arguments" { + local generation record expected + generation=$(_make_generation opencode-go OPENCODE_GO_API_KEY) + record="$generation/models/providers/opencode-go/facts.json" + + run acq_provider_facts_json_fields "$record" + assert_success + expected=$(printf 'opencode-go\tOPENCODE_GO_API_KEY\topencode-go.example.gov\thttps://opencode-go.example.gov/v1/models\topenai-compatible') + assert_output "$expected" +} + +@test "facts.json with an unsafe keyEnv or a non-https models URL is rejected" { + local generation record + generation=$(_make_generation alpha ALPHA_KEY) + record="$generation/models/providers/alpha/facts.json" + + printf '{"providerId":"alpha","keyEnv":"KEY; rm -rf /","host":"alpha.example.gov","modelsUrl":"https://alpha.example.gov/v1/models","apiProtocol":"openai-compatible"}\n' \ + > "$record" + run acq_provider_facts_json_fields "$record" + assert_failure + + printf '{"providerId":"alpha","keyEnv":"ALPHA_KEY","host":"alpha.example.gov","modelsUrl":"http://alpha.example.gov/v1/models","apiProtocol":"openai-compatible"}\n' \ + > "$record" + run acq_provider_facts_json_fields "$record" + assert_failure + + printf '{"providerId":"alpha","keyEnv":"ALPHA_KEY","host":"alpha.example.gov/v1","modelsUrl":"https://alpha.example.gov/v1/models","apiProtocol":"openai-compatible"}\n' \ + > "$record" + run acq_provider_facts_json_fields "$record" + assert_failure +} + +@test "facts.json whose modelsUrl names a different host is rejected" { + local generation record + generation=$(_make_generation alpha ALPHA_KEY) + record="$generation/models/providers/alpha/facts.json" + + # Credential would be sent to alpha.example.gov while the probe (and so the + # validation) pointed at attacker.example.gov. + printf '{"providerId":"alpha","keyEnv":"ALPHA_KEY","host":"alpha.example.gov","modelsUrl":"https://attacker.example.gov/v1/models","apiProtocol":"openai-compatible"}\n' \ + > "$record" + run acq_provider_facts_json_fields "$record" + assert_failure +} + +@test "facts.json with shell punctuation in modelsUrl is rejected" { + local generation record + generation=$(_make_generation alpha ALPHA_KEY) + record="$generation/models/providers/alpha/facts.json" + + # `&` would start a second command if the URL were ever spliced into a program. + printf '{"providerId":"alpha","keyEnv":"ALPHA_KEY","host":"alpha.example.gov","modelsUrl":"https://alpha.example.gov/v1/models?a=1&b=2","apiProtocol":"openai-compatible"}\n' \ + > "$record" + run acq_provider_facts_json_fields "$record" + assert_failure + + printf '{"providerId":"alpha","keyEnv":"ALPHA_KEY","host":"alpha.example.gov","modelsUrl":"https://alpha.example.gov/v1/models;id","apiProtocol":"openai-compatible"}\n' \ + > "$record" + run acq_provider_facts_json_fields "$record" + assert_failure +} + +@test "facts are read with the resolved runtime, and their absence fails closed" { + local generation record wrapper="$STUBDIR/facts-python" + generation=$(_make_generation alpha ALPHA_KEY) + record="$generation/models/providers/alpha/facts.json" + + cat > "$wrapper" <<'PYTHON' +#!/bin/sh +exec /usr/bin/python3 "$@" +PYTHON + chmod +x "$wrapper" + + ACQ_PROVIDER_INPUT_PYTHON="$wrapper" + export ACQ_PROVIDER_INPUT_PYTHON + run acq_provider_facts_json_fields "$record" + assert_success + + # No resolved runtime and no python3 reachable: refuse, never "no providers". + # (The prefix assignment above persists for function calls, so unset it here, + # and point every runtime location at an empty scratch dir.) + unset ACQ_PROVIDER_INPUT_PYTHON + rm -f "$wrapper" + local saved_path="$PATH" saved_localappdata="${LOCALAPPDATA:-}" + mkdir -p "$STUBDIR/emptybin" + export PATH="$STUBDIR/emptybin" + LOCALAPPDATA="$STUBDIR/no-localappdata" + ACQ_PROVIDER_INPUT_DATA_DIR="$STUBDIR/no-runtime" + export PATH LOCALAPPDATA ACQ_PROVIDER_INPUT_DATA_DIR + hash -r 2>/dev/null || true + run acq_provider_facts_json_fields "$record" + PATH="$saved_path" + LOCALAPPDATA="$saved_localappdata" + export PATH LOCALAPPDATA + unset ACQ_PROVIDER_INPUT_DATA_DIR + hash -r 2>/dev/null || true + assert_failure + assert_output --partial 'no Python runtime available to read provider facts' +} + +@test "a missing facts.json is not an error, it is simply no provider facts" { + run acq_provider_facts_json_fields "$STUBDIR/absent/facts.json" + assert_failure 1 + run acq_provider_facts_list_generation "$STUBDIR/absent-generation" + assert_success + assert_output '' +} + +@test "an invalid facts.json in a generation fails closed instead of being skipped" { + local generation + generation=$(_make_generation alpha ALPHA_KEY) + printf '{"providerId":"alpha","keyEnv":"ALPHA_KEY","host":"alpha.example.gov","modelsUrl":"https://attacker.example.gov/v1/models","apiProtocol":"openai-compatible"}\n' \ + > "$generation/models/providers/alpha/facts.json" + + run acq_provider_facts_list_generation "$generation" + assert_failure + assert_output --partial 'invalid provider facts' + + run acq_provider_credentials_gate msb box "$generation" + assert_failure + assert_output --partial 'invalid provider facts' + # Nothing was recorded for a generation that failed validation. + run acq_host_config_read msb box provider-credentials + assert_output '' +} + +# --------------------------------------------------------------------------- +# Host pinning: the anti-spoofing control +# --------------------------------------------------------------------------- + +@test "credentials gate rejects a kit whose host is not the pinned host" { + local generation + generation=$(_make_generation alpha ALPHA_KEY attacker.example.gov) + _pin alpha ALPHA_KEY alpha.example.gov + export ALPHA_KEY='exported-by-ci' + + run acq_provider_credentials_gate msb box "$generation" + assert_failure + assert_output --partial "not one of" + assert_output --partial 'Refusing to send the stored credential there' +} + +@test "credentials gate rejects an unpinned provider instead of trusting the kit" { + local generation + generation=$(_make_generation alpha ALPHA_KEY) + export ALPHA_KEY='exported-by-ci' + + run acq_provider_credentials_gate msb box "$generation" + assert_failure + assert_output --partial 'is not pinned to a host yet' + assert_output --partial 'acq provider-credential set alpha --host alpha.example.gov' +} + +@test "credentials gate refuses a host change against the recorded host" { + local generation + generation=$(_make_generation alpha ALPHA_KEY attacker.example.gov) + acq_host_config_write msb box provider-credentials \ + "$(printf 'alpha\tALPHA_KEY\talpha.example.gov\thttps://alpha.example.gov/v1/models\topenai-compatible')" + export ALPHA_KEY='exported-by-ci' + + run acq_provider_credentials_gate msb box "$generation" + assert_failure + assert_output --partial 'host changed for sandbox' + assert_output --partial 'Refusing to silently move your credential' +} + +@test "credentials gate accepts the recorded host on a re-run" { + local generation + generation=$(_make_generation alpha ALPHA_KEY) + acq_host_config_write msb box provider-credentials \ + "$(printf 'alpha\tALPHA_KEY\talpha.example.gov\thttps://alpha.example.gov/v1/models\topenai-compatible')" + export ALPHA_KEY='exported-by-ci' + + run acq_provider_credentials_gate msb box "$generation" + assert_success +} + +@test "credentials gate refuses a provider id that shadows an sbx built-in" { + local generation + generation=$(_make_generation openai OPENAI_API_KEY) + _pin openai OPENAI_API_KEY openai.example.gov + export OPENAI_API_KEY='exported-by-ci' + + run acq_provider_credentials_gate sbx box "$generation" + assert_failure + assert_output --partial "collides with an sbx built-in service" +} + +@test "credentials gate gates gitlab instead of skipping it" { + local generation + # `gitlab` is an acq-managed secret but NOT hard-coded by either backend's + # binding layer, so a kit claiming it must still pass the host-pin check. + generation=$(_make_generation gitlab GITLAB_TOKEN attacker.example.gov) + _pin gitlab GITLAB_TOKEN gitlab.com + export GITLAB_TOKEN='exported-by-ci' + + run acq_provider_credentials_gate msb box "$generation" + assert_failure + assert_output --partial 'Refusing to send the stored credential there' +} + +@test "credentials gate refuses an unpinned gitlab provider" { + local generation + generation=$(_make_generation gitlab GITLAB_TOKEN attacker.example.gov) + export GITLAB_TOKEN='exported-by-ci' + + run acq_provider_credentials_gate msb box "$generation" + assert_failure + assert_output --partial 'is not pinned to a host yet' +} + +@test "credentials gate skips the one id both backends hard-code" { + local generation + generation=$(_make_generation github GITHUB_TOKEN) + + # `github` is a HOST service with a compiled-in binding on both backends, so it + # is the only id the gate skips. `usai` is a provider and is gated below. + run acq_provider_credentials_gate msb box "$generation" + assert_success +} + +@test "a tampered provider-credentials record is rejected, not parsed" { + acq_host_config_write msb box provider-credentials \ + "$(printf 'alpha\tEVIL;touch /tmp/pwned\thttp://x\tnope\tprotocol')" + + run acq_provider_recorded_facts msb box + assert_failure + assert_output --partial 'invalid provider-credentials record' + + # …and the binding path fails closed instead of falling back to a pin. + export ACQ_RESOLVED_BACKEND=msb + _pin alpha ALPHA_KEY alpha.example.gov + run acq_provider_binding_host_env alpha box + assert_failure +} + +@test "a multi-host pin binds every pinned host" { + _acq_config_write_field provider_credential_bindings \ + 'alpha=ALPHA_KEY@a.example.gov,b.example.gov' + + run acq_provider_binding sbx "" alpha + assert_success + assert_output --partial 'a.example.gov,b.example.gov' +} + +@test "check_provider_key refuses a cross-host models URL at the probe" { + acq_provider_binding() { printf '%s\t%s\t%s\n' alpha.example.gov ALPHA_KEY https://attacker.example.gov/v1/models; } + acq_backend_run() { printf '200|0\n'; } + + run check_provider_key probe-box alpha + assert_failure + assert_output --partial 'models URL does not match its declared host' +} + +@test "check_provider_key refuses an unsafe credential env name" { + acq_provider_binding() { printf '%s\t%s\t%s\n' alpha.example.gov 'EVIL;touch x' https://alpha.example.gov/v1/models; } + + run check_provider_key probe-box alpha + assert_failure + assert_output --partial 'unsafe credential env name' +} + +# --------------------------------------------------------------------------- +# Create-time gate behaviour +# --------------------------------------------------------------------------- + +@test "usai is gated and injected through the trusted default pin" { + local generation + generation=$(_make_generation usai USAI_API_KEY api.gsa.usai.gov) + export USAI_API_KEY='exported-by-ci' + + # No user pin for usai: the shipped trust anchor (usai|USAI_API_KEY|api.gsa.usai.gov) + # supplies both the env var and the trusted host, so the gate passes. + run acq_provider_credentials_gate msb credbox "$generation" + assert_success + refute_output --partial 'acq secret set -g usai' + + run acq_host_config_read msb credbox provider-credentials + assert_success + assert_output --partial "$(printf 'usai\tUSAI_API_KEY\tapi.gsa.usai.gov')" +} + +@test "credentials gate fails closed non-interactively when a provider key is missing" { + local generation + generation=$(_make_generation alpha ALPHA_KEY) + _pin alpha ALPHA_KEY alpha.example.gov + + run acq_provider_credentials_gate msb credbox "$generation" + assert_failure + assert_output --partial "no key stored for provider 'alpha' (\$ALPHA_KEY)" + assert_output --partial 'acq secret set -g alpha' +} + +@test "an exported key satisfies an msb gate but fails closed on sbx" { + local generation + generation=$(_make_generation alpha ALPHA_KEY) + _pin alpha ALPHA_KEY alpha.example.gov + export ALPHA_KEY='exported-by-ci' + + run acq_provider_credentials_gate msb credbox "$generation" + assert_success + + run acq_provider_credentials_gate sbx credbox "$generation" + assert_failure + assert_output --partial 'cannot bind a credential from host env at create' + assert_output --partial 'acq secret set -g alpha' +} + +@test "credentials gate is a no-op for a generation with no providers" { + local generation="$STUBDIR/empty-generation" + mkdir -p "$generation/models/providers" + + run acq_provider_credentials_gate msb credbox "$generation" + assert_success + run acq_host_config_read msb credbox provider-credentials + assert_success + assert_output '' +} + +# --------------------------------------------------------------------------- +# Generic key validation + binding resolution +# --------------------------------------------------------------------------- + +@test "check_provider_key passes the models URL as data, never as program text" { + local generation probe script + generation=$(_make_generation alpha ALPHA_KEY) + export ACQ_PROVIDER_BINDINGS_GENERATION="$generation" + probe="$STUBDIR/probe.sh" + script="$STUBDIR/probe-program" + acq_backend_run() { + printf '%s\n' "$5" > "$script" + printf '%s\n' "$7" > "$probe" + printf '200|0\n' + } + + run check_provider_key probe-box alpha + assert_success + assert_output '200' + # The URL is argument 7 (data), and the program text only references "$1". + assert_equal "$(cat "$probe")" 'https://alpha.example.gov/v1/models' + grep -Fq 'Bearer $ALPHA_KEY' "$script" + grep -Fq '"$1"' "$script" + refute grep -Fq 'alpha.example.gov' "$script" +} + +@test "check_provider_key reports an unauthorized provider key" { + local generation + generation=$(_make_generation alpha ALPHA_KEY) + export ACQ_PROVIDER_BINDINGS_GENERATION="$generation" + acq_backend_run() { printf '401|0\n'; } + + run check_provider_key probe-box alpha + assert_success + assert_output '401' +} + +@test "ensure_valid_provider_keys fails closed on a rejected provider key" { + acq_provider_sandbox_provider_ids() { printf 'alpha\n'; } + check_provider_key() { printf '401\n'; } + + run ensure_valid_provider_keys probe-box + assert_failure + assert_output --partial "provider 'alpha' rejected the stored key (HTTP 401)" + assert_output --partial 'acq secret set -g alpha' +} + +@test "ensure_valid_provider_keys fails closed on an inconclusive probe" { + acq_provider_sandbox_provider_ids() { printf 'alpha\n'; } + check_provider_key() { printf '\n'; } + + run ensure_valid_provider_keys probe-box + assert_failure + assert_output --partial 'could not validate the' + assert_output --partial 'unverified credential' +} + +@test "ensure_valid_provider_keys treats 5xx as transient and 3xx as a redirect" { + acq_provider_sandbox_provider_ids() { printf 'alpha\n'; } + check_provider_key() { printf '503\n'; } + + run ensure_valid_provider_keys probe-box + assert_success + assert_output --partial 'transient' + + check_provider_key() { printf '302\n'; } + run ensure_valid_provider_keys probe-box + assert_failure + assert_output --partial 'redirected (HTTP 302)' +} + +@test "ensure_valid_provider_keys treats an unreachable endpoint as a network problem" { + acq_provider_sandbox_provider_ids() { printf 'alpha\n'; } + check_provider_key() { printf 'unreachable\n'; } + + run ensure_valid_provider_keys probe-box + assert_failure + assert_output --partial 'That is a network problem, not a credential one.' +} + +@test "ensure_valid_provider_keys validates usai through the generic path" { + acq_provider_sandbox_provider_ids() { printf 'usai\n'; } + check_provider_key() { printf '401\n'; } + + # No USAi bypass: a rejected default-provider key fails the gate and names the + # dedicated remediation verb. + run ensure_valid_provider_keys probe-box + assert_failure + assert_output --partial 'acq usai-rotate-api-key' +} + +@test "sbx maps a registry provider to its facts host and key env" { + local generation expected + generation=$(_make_generation alpha ALPHA_KEY) + export ACQ_PROVIDER_BINDINGS_GENERATION="$generation" + + run _acq_service_hosts_env alpha + assert_success + expected=$(printf 'alpha.example.gov\tALPHA_KEY') + assert_output "$expected" + + run _acq_service_hosts_env not-a-provider + assert_success + expected=$(printf '\t') + assert_output "$expected" +} + +@test "the registry keyEnv is authoritative for injection" { + local generation + generation=$(_make_generation alpha ALPHA_KEY) + export ACQ_PROVIDER_BINDINGS_GENERATION="$generation" + _pin alpha ALPHA_KEY_OVERRIDE alpha.example.gov + + run acq_provider_binding sbx box alpha + assert_success + assert_output --partial 'ALPHA_KEY_OVERRIDE' +} + +@test "a pinned provider resolves host/keyEnv with no generation and no ambient state" { + # This is the resume / `acq secret rm` path: an explicit sandbox argument, no + # ACQ_PROVIDER_BINDINGS_* environment, and only the recorded host config. + _pin alpha ALPHA_KEY alpha.example.gov + acq_host_config_write msb livebox provider-credentials \ + "$(printf 'alpha\tALPHA_KEY\talpha.example.gov\thttps://alpha.example.gov/v1/models\topenai-compatible')" + + run acq_provider_binding_host_env alpha livebox + assert_success + assert_output "$(printf 'alpha.example.gov\tALPHA_KEY')" + + # A standalone global `acq secret set` (no sandbox) still resolves via the pin. + run acq_provider_binding_host_env alpha + assert_success + assert_output "$(printf 'alpha.example.gov\tALPHA_KEY')" +} + +@test "sbx reports a registry provider injectable only once its key env is bound" { local generation + generation=$(_make_generation alpha ALPHA_KEY) + export ACQ_PROVIDER_BINDINGS_GENERATION="$generation" + _acq_sbx_secret_exists() { return 1; } + + run acq_backend_key_present alpha + assert_failure + + _acq_sbx_secret_exists() { return 0; } + run acq_backend_key_present alpha + assert_success + + # Services acq does not model keep the documented permissive default. + run acq_backend_key_present some-raw-placeholder + assert_success +} + +# --------------------------------------------------------------------------- +# De-specialized default provider (usai flows through the generic path) +# --------------------------------------------------------------------------- + +@test "the default provider resolves through the generic binding, facts-free" { + # No generation, no record: the shipped trust anchor supplies both halves, which + # is what keeps a pre-merge sandbox resumable. + run acq_provider_binding_host_env usai + assert_success + assert_output "$(printf 'api.gsa.usai.gov\tUSAI_API_KEY')" +} + +@test "a rejected default-provider key points at usai-rotate-api-key" { + acq_provider_sandbox_provider_ids() { printf 'usai\n'; } + check_provider_key() { printf '401\n'; } + + run ensure_valid_provider_keys probe-box + assert_failure + assert_output --partial 'Rotate it with: acq usai-rotate-api-key' + refute_output --partial 'Rotate now?' +} + +@test "a rejected non-default provider key points at acq secret set" { + acq_provider_sandbox_provider_ids() { printf 'alpha\n'; } + check_provider_key() { printf '403\n'; } + _pin alpha ALPHA_KEY a.example.gov + + run ensure_valid_provider_keys probe-box + assert_failure + assert_output --partial 'acq secret set -g alpha' + refute_output --partial 'usai-rotate-api-key' +} + +@test "a kit cannot make acq probe a host service's token at its own host" { + local generation + # `github` is bound from a compiled-in table. A kit declaring the id (with its + # own host) must not turn the attach probe into "send GITHUB_TOKEN here". + generation=$(_make_generation github GITHUB_TOKEN attacker.example.gov) + export ACQ_PROVIDER_BINDINGS_GENERATION="$generation" + acq_backend_run() { printf 'probe-called\n' > "$STUBDIR/probe.called"; printf '200|0\n'; } + + run ensure_valid_provider_keys box + assert_success + refute [ -e "$STUBDIR/probe.called" ] + + run check_provider_key box github + assert_failure + assert_output --partial 'host service' + refute [ -e "$STUBDIR/probe.called" ] +} diff --git a/test/bats/149-kit-source.bats b/test/bats/149-kit-source.bats new file mode 100644 index 00000000..62c35b1c --- /dev/null +++ b/test/bats/149-kit-source.bats @@ -0,0 +1,592 @@ +#!/usr/bin/env bats +# +# 149-kit-source.bats — the trusted kit-source registry, the fetch allowlist +# derived from it, provider-kit discovery, and `provider_kit:` as data. +# +# Trust is narrow on purpose: a trusted source may be FETCHED from and may +# contribute provider kits, but the validator/schemas stay pinned to the Patterns +# release (D1). Provider kits from trusted sources are AUTO-LISTED (D2), and the +# default provider kit is a config value rather than a constant (D3). +# +# shellcheck shell=bats + +load 'helper' + +setup() { acq_setup_stubs; } +teardown() { acq_teardown_stubs; } + +# _fake_source_tree ROOT KIT... — a fetched source tree with provider kits, plus a +# stub kit_translate_fetch that "fetches" it, so discovery never touches network. +_fake_source_tree() { + local root="$1"; shift + local kit + mkdir -p "$root" + for kit in "$@"; do + mkdir -p "$root/$kit" + printf '{"providerId":"%s"}\n' "$kit" > "$root/$kit/provider-input.json" + done + mkdir -p "$root/not-a-provider-kit" + printf 'schemaVersion: "hybrid/v1"\n' > "$root/not-a-provider-kit/spec.yaml" + # Global, not a local of this helper: the stub outlives this call. + _FAKE_SOURCE_ROOT="$root" + kit_translate_fetch() { + printf '%s\n' "$_FAKE_SOURCE_ROOT" + } +} + +# --------------------------------------------------------------------------- +# Registry: add / rm / ls +# --------------------------------------------------------------------------- + +@test "kit-source: the shipped default is registered without any config" { + run acq_kit_sources_entries + assert_success + assert_output --partial 'github.com/GSA-TTS/agentic-coding-patterns' + assert_output --partial "$PATTERNS_KIT_REF" + assert_output --partial 'integrations/isolation/acq-kits' +} + +@test "kit-source add persists a pinned source that ls reports" { + run "$ACQ" kit-source add github.com/mogul/agentic-coding-kits --ref deadbeef + assert_success + + grep -Fq 'kit_sources:' "$XDG_CONFIG_HOME/acq/config.yaml" + grep -Fq 'github.com/mogul/agentic-coding-kits@deadbeef' "$XDG_CONFIG_HOME/acq/config.yaml" + + run acq_kit_sources_entries + assert_success + # Adding a source keeps the shipped default: trust is additive. + assert_output --partial 'github.com/GSA-TTS/agentic-coding-patterns' + assert_output --partial 'github.com/mogul/agentic-coding-kits' +} + +@test "kit-source add replaces an existing entry for the same repo" { + run "$ACQ" kit-source add github.com/mogul/kits --ref aaaa + assert_success + run "$ACQ" kit-source add github.com/mogul/kits --ref bbbb + assert_success + assert_output --partial 'updated kit source' + + run acq_kit_sources_entries + assert_success + refute_output --partial '|aaaa|' + assert_output --partial '|bbbb|' +} + +@test "kit-source add requires a pinned ref" { + run "$ACQ" kit-source add github.com/mogul/kits + assert_failure + assert_output --partial '--ref is required' + refute [ -e "$XDG_CONFIG_HOME/acq/config.yaml" ] +} + +@test "kit-source add normalizes a pasted clone URL" { + run "$ACQ" kit-source add 'git+https://github.com/mogul/kits.git' --ref abcd1234 + assert_success + run acq_kit_sources_entries + assert_success + assert_output --partial 'github.com/mogul/kits' + refute_output --partial 'git+https' +} + +@test "kit-source rejects a credential-bearing or malformed repository" { + run "$ACQ" kit-source add 'https://token@github.com/mogul/kits' --ref abcd + assert_failure + assert_output --partial 'invalid repository' + + run "$ACQ" kit-source add 'not-a-repo' --ref abcd + assert_failure + assert_output --partial 'invalid repository' +} + +@test "kit-source rm drops a source and 'none' means no trusted sources" { + run "$ACQ" kit-source add github.com/mogul/kits --ref abcd + assert_success + + run "$ACQ" kit-source rm github.com/mogul/kits + assert_success + run acq_kit_sources_entries + assert_success + refute_output --partial 'mogul' + + # Removing the shipped default leaves acq with NOTHING to fetch (the flat + # writer would otherwise drop the key and resurrect the default). + run "$ACQ" kit-source rm github.com/GSA-TTS/agentic-coding-patterns + assert_success + grep -Fq 'kit_sources: none' "$XDG_CONFIG_HOME/acq/config.yaml" + run acq_kit_sources_entries + assert_success + assert_output '' +} + +@test "kit-source rm reports an unregistered repository" { + run "$ACQ" kit-source rm github.com/other/repo + assert_failure + assert_output --partial 'is not registered' +} + +@test "kit-source with no subcommand prints usage and is a usage error" { + run "$ACQ" kit-source + assert_failure 2 + assert_output --partial 'acq kit-source' + + run "$ACQ" kit-source --help + assert_success + assert_output --partial 'ACQ_KIT_SOURCES' +} + +# --------------------------------------------------------------------------- +# Allowlist derivation +# --------------------------------------------------------------------------- + +@test "the fetch allowlist is derived from the registry" { + run acq_kit_source_prefixes + assert_success + assert_output 'github.com/GSA-TTS/' + + run "$ACQ" kit-source add github.com/mogul/agentic-coding-kits --ref abcd + assert_success + run acq_kit_source_prefixes + assert_success + assert_line 'github.com/GSA-TTS/' + assert_line 'github.com/mogul/' +} + +@test "ACQ_EXTRA_KIT_SOURCES still adds prefixes (backward compatibility)" { + export ACQ_EXTRA_KIT_SOURCES='github.com/legacy/' + + run acq_kit_source_prefixes + assert_success + assert_output --partial 'github.com/GSA-TTS/' + assert_output --partial 'github.com/legacy/' +} + +@test "_build_kit_list populates KIT_SOURCE_PREFIXES from the registry" { + export ACQ_KIT_SOURCES='github.com/mogul/agentic-coding-kits@abcd' + _build_kit_list + + printf '%s\n' "${KIT_SOURCE_PREFIXES[@]}" > "$STUBDIR/prefixes" + grep -Fq 'github.com/mogul/' "$STUBDIR/prefixes" + refute grep -Fq 'github.com/GSA-TTS/' "$STUBDIR/prefixes" +} + +@test "ACQ_KIT_SOURCES overrides the configured sources for one invocation" { + run "$ACQ" kit-source add github.com/config/kits --ref aaaa + assert_success + export ACQ_KIT_SOURCES='github.com/envonly/kits@bbbb' + + run acq_kit_sources_entries + assert_success + assert_output --partial 'github.com/envonly/kits' + refute_output --partial 'github.com/config/kits' + + run "$ACQ" kit-source ls + assert_success + assert_output --partial 'github.com/envonly/kits' +} + +# --------------------------------------------------------------------------- +# Provider-kit discovery (auto-list, D2) +# --------------------------------------------------------------------------- + +@test "discovery lists only directories that ship provider-input.json" { + local tree="$STUBDIR/source-tree" + _fake_source_tree "$tree" alpha-provider beta-provider + + run acq_kit_source_provider_kits github.com/mogul/kits abcd integrations/isolation/acq-kits + assert_success + assert_output --partial 'alpha-provider' + assert_output --partial 'beta-provider' + refute_output --partial 'not-a-provider-kit' +} + +@test "an unreachable source lists no kits and does not fail" { + kit_translate_fetch() { return 1; } + + run acq_kit_source_provider_kits github.com/mogul/kits abcd integrations/isolation/acq-kits + assert_success + assert_output '' +} + +@test "kit-source ls shows sources and the provider kits they publish" { + local tree="$STUBDIR/source-tree" + _fake_source_tree "$tree" alpha-provider beta-provider + export ACQ_KIT_SOURCES='github.com/mogul/kits@abcd' + + # In-process: discovery shells out through kit_translate_fetch, which the test + # stubs (a `$ACQ` child process would use the real network fetch). + run acq_kit_source_ls + assert_success + assert_output --partial 'github.com/mogul/kits' + assert_output --partial 'abcd' + assert_output --partial 'alpha-provider' + assert_output --partial 'beta-provider' +} + +@test "provider-kit candidates include the shipped default and discovered kits" { + local tree="$STUBDIR/source-tree" + _fake_source_tree "$tree" opencode-go-provider + export ACQ_KIT_SOURCES='github.com/mogul/kits@abcd' + + run acq_provider_kit_candidates + assert_success + assert_output --partial 'agentic-coding-patterns/usai-provider' + assert_output --partial 'github.com/mogul/kits' + assert_output --partial 'opencode-go-provider' +} + +# --------------------------------------------------------------------------- +# provider_kit as data (D3) +# --------------------------------------------------------------------------- + +@test "the default provider kit resolves to the pinned patterns kit ref" { + run acq_provider_kit_ref + assert_success + + assert_output "$USAI_KIT" +} + +@test "provider_kit accepts / and @ forms" { + run "$ACQ" kit-source add github.com/mogul/agentic-coding-kits --ref abcd + assert_success + + _acq_config_write_field provider_kit 'agentic-coding-kits/opencode-go-provider' + run acq_provider_kit_ref + assert_success + assert_output 'git+https://github.com/mogul/agentic-coding-kits.git#ref=abcd&dir=integrations/isolation/acq-kits/opencode-go-provider' + + _acq_config_write_field provider_kit 'opencode-go-provider@github.com/mogul/agentic-coding-kits' + run acq_provider_kit_ref + assert_success + assert_output --partial 'github.com/mogul/agentic-coding-kits.git#ref=abcd' + + _acq_config_write_field provider_kit 'github.com/mogul/agentic-coding-kits/opencode-go-provider' + run acq_provider_kit_ref + assert_success + assert_output --partial 'dir=integrations/isolation/acq-kits/opencode-go-provider' +} + +@test "provider_kit fails closed for an untrusted source" { + _acq_config_write_field provider_kit 'evil-kits/opencode-go-provider' + + run acq_provider_kit_ref + assert_failure + assert_output --partial "kit source name 'evil-kits' is not registered" +} + +@test "provider_kit fails closed for a source with no pinned ref" { + _acq_config_write_field kit_sources 'github.com/mogul/kits' + _acq_config_write_field provider_kit 'kits/opencode-go-provider' + + run acq_provider_kit_ref + assert_failure + assert_output --partial 'has no pinned ref' +} + +@test "the selected provider kit feeds the provider-input pipeline" { + local tree="$STUBDIR/source-tree" refs + _fake_source_tree "$tree" opencode-go-provider + run "$ACQ" kit-source add github.com/mogul/kits --ref abcd + assert_success + _acq_config_write_field provider_kit 'kits/opencode-go-provider' + + refs=$(_acq_selected_provider_kit_refs) + case "$refs" in + *'github.com/mogul/kits.git#ref=abcd'*) ;; + *) fail "provider kit ref not selected: $refs" ;; + esac + refute grep -Fq 'usai-provider' <<<"$refs" +} + +# --------------------------------------------------------------------------- +# Regression guards from review +# --------------------------------------------------------------------------- + +@test "secret import still accepts a bare usai token and --all scans it" { + # usai is no longer a compiled-in managed service; it is reachable through the + # trust anchor, so both the bare form and the full scan must keep working. + run _acq_import_parse_args usai + assert_success + assert_output "$(printf 'interactive\t0\tusai\t')" + + run acq_importable_services + assert_success + assert_line 'usai' + assert_line 'github' + assert_line 'gitlab' +} + +@test "adding a source does not freeze the shipped default ref" { + run "$ACQ" kit-source add github.com/mogul/kits --ref abcd + assert_success + + # The default entry is stored WITHOUT @ref, so a future PATTERNS_KIT_REF bump + # keeps applying while the user's explicit pin is untouched. + grep -Fq 'kit_sources:' "$XDG_CONFIG_HOME/acq/config.yaml" + refute grep -Fq "agentic-coding-patterns@$PATTERNS_KIT_REF" "$XDG_CONFIG_HOME/acq/config.yaml" + + run acq_kit_sources_entries + assert_success + assert_output --partial "agentic-coding-patterns|$PATTERNS_KIT_REF|" + assert_output --partial 'mogul/kits|abcd|' +} + +@test "an explicitly pinned default source keeps its own ref" { + run "$ACQ" kit-source add github.com/GSA-TTS/agentic-coding-patterns --ref deadbeef + assert_success + + run acq_kit_sources_entries + assert_success + assert_output --partial 'agentic-coding-patterns|deadbeef|' + refute_output --partial "agentic-coding-patterns|$PATTERNS_KIT_REF|" +} + +@test "an unresolvable provider_kit blocks create/run but not the management verbs" { + _acq_config_write_field provider_kit 'evil-kits/x-provider' + + # Building the kit list LATCHES the error instead of aborting: every verb runs + # it via acq_resolve_backend, and configure/kit-source are how the user repairs + # the config. The latch only becomes fatal at the create/run gate. Called + # directly (not via `run`, which runs in a subshell and would drop the latch). + _build_kit_list + run _acq_require_provider_selection + assert_failure + assert_output --partial 'could not be resolved' + + # Management verbs keep working with the broken config in place. + run env ACQ_BACKEND=msb ACQ_NO_PROMPT=1 "$ACQ" configure + assert_success + run env ACQ_BACKEND=msb "$ACQ" kit-source add github.com/acme/other --ref abc123 + assert_success + + # create/run fail closed rather than shipping a sandbox without the provider. + local proj="$STUBDIR/bad-provider-proj"; mkdir -p "$proj" + rm -f "$STUBDIR/.msb_created" + run env ACQ_BACKEND=msb ACQ_NO_PROMPT=1 STUB_OPENCODE_OK=1 "$ACQ" create shell "$proj" + assert_failure + assert_output --partial 'provider kit could not be resolved' + refute [ -f "$STUBDIR/.msb_created" ] + + run env ACQ_BACKEND=msb ACQ_NO_PROMPT=1 STUB_OPENCODE_OK=1 "$ACQ" run shell "$proj" + assert_failure + assert_output --partial 'provider kit could not be resolved' +} + +@test "provider_kit from config refuses an untrusted remote ref" { + _acq_config_write_field provider_kit 'git+https://github.com/evil/kits.git#ref=abc123&dir=kits' + + run acq_provider_kit_ref + assert_failure + assert_output --partial 'untrusted remote ref' + assert_output --partial 'acq kit-source add github.com/evil' +} + +@test "a trusted remote ref from config is accepted" { + run "$ACQ" kit-source add github.com/evil/kits --ref abc123 + assert_success + _acq_config_write_field provider_kit 'git+https://github.com/evil/kits.git#ref=abc123&dir=kits' + + run acq_provider_kit_ref + assert_success + assert_output --partial 'github.com/evil/kits.git#ref=abc123' +} + +# --------------------------------------------------------------------------- +# Trust granularity: a durable remote ref is trusted per REPOSITORY, not per owner +# --------------------------------------------------------------------------- + +@test "trusting one repository does not authorize a sibling repo of the same owner" { + run "$ACQ" kit-source add github.com/acme/trusted --ref abc123 + assert_success + + _acq_config_write_field provider_kit 'git+https://github.com/acme/untrusted.git#ref=abc123&dir=kits' + run acq_provider_kit_ref + assert_failure + assert_output --partial 'untrusted remote ref' + assert_output --partial 'acq kit-source add github.com/acme/untrusted' + + # A repository whose name merely starts with a trusted name is a different repo. + _acq_config_write_field provider_kit 'git+https://github.com/acme/trusted-evil.git#ref=abc123&dir=kits' + run acq_provider_kit_ref + assert_failure + assert_output --partial 'untrusted remote ref' + + # The trusted repository itself still resolves. + _acq_config_write_field provider_kit 'git+https://github.com/acme/trusted.git#ref=abc123&dir=kits' + run acq_provider_kit_ref + assert_success + assert_output --partial 'github.com/acme/trusted.git#ref=abc123' +} + +@test "a durable remote ref that cannot be parsed to one repository fails closed" { + run "$ACQ" kit-source add github.com/acme/trusted --ref abc123 + assert_success + + local bad + for bad in \ + 'git+https://user@github.com/acme/trusted.git#ref=abc123&dir=kits' \ + 'git+https://github.com/acme/trusted/extra.git#ref=abc123&dir=kits' \ + 'git+https://github.com/acme#ref=abc123&dir=kits' \ + 'git+http://github.com/acme/trusted.git#ref=abc123&dir=kits' \ + 'git+https://evil.example/x://github.com/acme/trusted.git#ref=abc123&dir=kits' \ + 'git+https://evil.example/github.com/acme/trusted.git#ref=abc123&dir=kits' \ + 'git+https://github.com/acme/trusted/.git#ref=abc123&dir=kits' \ + 'git+https://github.com:8443/acme/trusted.git#ref=abc123&dir=kits' \ + 'git+https://GitHub.com/acme/trusted.git#ref=abc123&dir=kits' \ + "$(printf 'git+https://github.com/acme/x\nevil.example/a/b#ref=abc123&dir=kits')"; do + _acq_config_write_field provider_kit "$bad" + run acq_provider_kit_ref + assert_failure + assert_output --partial 'untrusted remote ref' + done +} + +# --------------------------------------------------------------------------- +# kit-source rm: the backend allowlist is recomputed from what REMAINS trusted +# --------------------------------------------------------------------------- + +# _plant_sbx_allowlist_stub CURRENT_JSON — an sbx whose kit.allowedSources lives +# in $STUBDIR/allowed. STUB_SET_FAIL=1 makes `settings set` fail. +_plant_sbx_allowlist_stub() { + printf '%s\n' "$1" > "$STUBDIR/allowed" + cat >"$STUBDIR/sbx" <<'STUB' +#!/usr/bin/env bash +{ printf 'sbx'; for a in "$@"; do printf ' %s' "$a"; done; printf '\n'; } >>"$CALLS" +case "${1:-}" in + version) printf 'sbx version: v0.48.0 abc123\n' ;; + settings) + case "${2:-}" in + get) cat "$STUBDIR/allowed" ;; + set) + [ "${STUB_SET_FAIL:-0}" = 1 ] && exit 1 + printf '%s\n' "$4" > "$STUBDIR/allowed" ;; + esac + exit 0 ;; + *) exit 0 ;; +esac +STUB + chmod +x "$STUBDIR/sbx" +} + +@test "kit-source rm keeps a shared owner prefix while another trusted repo needs it" { + _plant_sbx_allowlist_stub '["docker.io/","github.com/acme/"]' + run "$ACQ" kit-source add github.com/acme/one --ref aaaa + assert_success + run "$ACQ" kit-source add github.com/acme/two --ref bbbb + assert_success + + run env ACQ_BACKEND=sbx "$ACQ" kit-source rm github.com/acme/one + assert_success + grep -Fq 'github.com/acme/' "$STUBDIR/allowed" + refute grep -Fq 'settings set' "$CALLS" + + # Removing the last repository of that owner finally drops the prefix, and only + # that prefix: unrelated entries the operator set stay put. + run env ACQ_BACKEND=sbx "$ACQ" kit-source rm github.com/acme/two + assert_success + refute grep -Fq 'github.com/acme/' "$STUBDIR/allowed" + grep -Fq 'docker.io/' "$STUBDIR/allowed" +} + +@test "kit-source rm keeps a prefix still granted by ACQ_EXTRA_KIT_SOURCES" { + _plant_sbx_allowlist_stub '["docker.io/","github.com/acme/"]' + run "$ACQ" kit-source add github.com/acme/one --ref aaaa + assert_success + + run env ACQ_BACKEND=sbx ACQ_EXTRA_KIT_SOURCES='github.com/acme/' "$ACQ" kit-source rm github.com/acme/one + assert_success + grep -Fq 'github.com/acme/' "$STUBDIR/allowed" +} + +@test "kit-source rm fails closed when the backend allowlist cannot be updated" { + _plant_sbx_allowlist_stub '["docker.io/","github.com/acme/"]' + run "$ACQ" kit-source add github.com/acme/one --ref aaaa + assert_success + + run env ACQ_BACKEND=sbx STUB_SET_FAIL=1 "$ACQ" kit-source rm github.com/acme/one + assert_failure + assert_output --partial 'still permits' + assert_output --partial 'sbx settings set kit.allowedSources' + assert_output --partial 'NOT removed' + refute_output --partial "removed kit source 'github.com/acme/one'." + # Nothing is reported as removed, so the source stays registered and the same + # command can be retried once the backend setting is fixed. + run acq_kit_sources_entries + assert_output --partial 'github.com/acme/one' + grep -Fq 'github.com/acme/' "$STUBDIR/allowed" + + run env ACQ_BACKEND=sbx "$ACQ" kit-source rm github.com/acme/one + assert_success + refute grep -Fq 'github.com/acme/' "$STUBDIR/allowed" +} + +@test "kit-source rm fails closed when the backend allowlist cannot be read" { + _plant_sbx_allowlist_stub 'not json' + run "$ACQ" kit-source add github.com/acme/one --ref aaaa + assert_success + + run env ACQ_BACKEND=sbx "$ACQ" kit-source rm github.com/acme/one + assert_failure + assert_output --partial 'still permits' + run acq_kit_sources_entries + assert_output --partial 'github.com/acme/one' +} + +@test "kit-source rm fails closed when jq cannot be used" { + _plant_sbx_allowlist_stub '["docker.io/","github.com/acme/"]' + run "$ACQ" kit-source add github.com/acme/one --ref aaaa + assert_success + + printf '#!/bin/sh\nexit 127\n' > "$STUBDIR/jq" + chmod +x "$STUBDIR/jq" + run env ACQ_BACKEND=sbx "$ACQ" kit-source rm github.com/acme/one + assert_failure + assert_output --partial 'still permits' + assert_output --partial 'NOT removed' + run acq_kit_sources_entries + assert_output --partial 'github.com/acme/one' +} + +@test "kit-source rm fails closed when the backend does not apply the update" { + _plant_sbx_allowlist_stub '["docker.io/","github.com/acme/"]' + # `settings set` reports success but the value does not change. + sed -i 's|printf .%s\\n. "\$4" > "\$STUBDIR/allowed" ;;|: ;;|' "$STUBDIR/sbx" + run "$ACQ" kit-source add github.com/acme/one --ref aaaa + assert_success + + run env ACQ_BACKEND=sbx "$ACQ" kit-source rm github.com/acme/one + assert_failure + assert_output --partial 'still lists it after the update' + assert_output --partial 'NOT removed' + run acq_kit_sources_entries + assert_output --partial 'github.com/acme/one' +} + +@test "kit-source rm still works on a backend with no allowlist (msb)" { + run "$ACQ" kit-source add github.com/acme/one --ref aaaa + assert_success + run env ACQ_BACKEND=msb "$ACQ" kit-source rm github.com/acme/one + assert_success + run acq_kit_sources_entries + refute_output --partial 'github.com/acme/one' +} + +@test "an ACQ_EXTRA_KIT_SOURCES owner grant still needs a plain repository ref" { + export ACQ_EXTRA_KIT_SOURCES='github.com/acme/' + local bad + for bad in \ + 'git+https://github.com/acme/..#ref=abc123&dir=kits' \ + 'git+https://github.com/acme/.#ref=abc123&dir=kits'; do + _acq_config_write_field provider_kit "$bad" + run acq_provider_kit_ref + assert_failure + done + + # The flat config reader is line-oriented, so exercise the check directly. + run _acq_kit_source_ref_trusted "$(printf 'git+https://github.com/acme/x\nevil.example/a/b#ref=abc123&dir=kits')" + assert_failure + + _acq_config_write_field provider_kit 'git+https://github.com/acme/anything.git#ref=abc123&dir=kits' + run acq_provider_kit_ref + assert_success +} diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index 1ed6231d..a57aaf57 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -61,41 +61,11 @@ SPEC run env -u ACQ_PROVIDER_INPUT_PYTHON ACQ_BACKEND=msb GIT_ALLOW_PROTOCOL=none USAI_API_KEY="usai-key-stub" \ STUB_KEY_STATUS=200 STUB_OPENCODE_OK=1 "$ACQ" create opencode "$proj" assert_failure - assert_output --partial 'provider facts could not be fetched; using transitional fallback defaults' assert_output --partial 'provider input projection failed' refute_regex "$(cat "$CALLS")" 'msb create' assert [ ! -f "$STUBDIR/.msb_created" ] } -@test "dispatch: create fails closed when provider facts artifact is malformed" { - local kit="$STUBDIR/provider-kit-bad" proj="$STUBDIR/provider-facts-bad" - mkdir -p "$kit/provider-facts" "$proj" - cat > "$kit/spec.yaml" <<'SPEC' -schemaVersion: "hybrid/v1" -kind: mixin -name: usai-provider -displayName: USAi Provider -description: test fixture -SPEC - cat > "$kit/provider-facts/usai.env" <<'FACTS' -ACQ_PROVIDER_FACTS_SCHEMA=1 -ACQ_PROVIDER_ID=usai -ACQ_PROVIDER_HOST=api.bad.gov -ACQ_PROVIDER_BASE_URL=https://api.bad.gov/api/v1 -ACQ_PROVIDER_MODELS_URL=https://api.bad.gov/api/v1/models -ACQ_PROVIDER_KEY_ENV=BAD-KEY -ACQ_PROVIDER_KEY_MGMT_URL=https://bad.gov/keys -ACQ_PROVIDER_BIND_HOSTS=api.bad.gov -FACTS - rm -f "$STUBDIR/.msb_created" - - run env ACQ_BACKEND=msb USAI_API_KEY="usai-key-stub" ACQ_TEST_USAI_KIT="$kit" \ - STUB_KEY_STATUS=200 STUB_OPENCODE_OK=1 "$ACQ" create opencode "$proj" - assert_failure - assert_output --partial "acq: invalid provider facts artifact: $kit/provider-facts/usai.env" - refute_regex "$(cat "$CALLS")" 'msb create' - refute [ -f "$STUBDIR/.msb_created" ] -} @test "dispatch: run existing sandbox does not fetch provider facts" { printf 'mybox\n' > "$STUBDIR/.msb_sandbox_list" @@ -107,23 +77,6 @@ FACTS refute_output --partial 'provider facts' } -@test "dispatch: start falls back when provider facts cannot be fetched" { - run env ACQ_BACKEND=msb GIT_ALLOW_PROTOCOL=none USAI_API_KEY="usai-key-stub" \ - STUB_AGENT_USER_READY=1 "$ACQ" start mybox - assert_success - assert_output --partial 'provider facts could not be fetched; using transitional fallback defaults' - refute_output --partial 'invalid provider facts' - assert_regex "$(cat "$CALLS")" 'msb start mybox' -} - -@test "secret: option-only import falls back when provider facts cannot be fetched" { - run env ACQ_BACKEND=msb GIT_ALLOW_PROTOCOL=none USAI_API_KEY="usai-key-stub" \ - ACQ_SECRET_FORCE_FILE=1 ACQ_SECRET_FILE_DIR="$STUBDIR/secrets" \ - "$ACQ" secret import --all - assert_success - assert_output --partial 'provider facts could not be fetched; using transitional fallback defaults' - assert_output --partial "imported 'usai'" -} @test "secret: scoped non-USAi command does not fetch provider facts" { run bash -c ' @@ -220,60 +173,6 @@ FACTS refute_output --partial 'mogul/artemis' } -@test "dispatch: create runs a non-blocking USAi key advisory (warns but never aborts)" { - local proj="$STUBDIR/keyproj"; mkdir -p "$proj" - _seed_usai - run env STUB_KEY_STATUS=401 ACQ_BACKEND=sbx "$ACQ" create opencode "$proj" - assert_success - assert_output --partial 'invalid or expired (HTTP 401)' - run env STUB_KEY_STATUS=200 ACQ_BACKEND=sbx "$ACQ" create opencode "$proj" - refute_output --partial 'invalid or expired' -} - -@test "run: no key stored (non-tty) emits the terse gate and aborts before create" { - local proj="$STUBDIR/keyrun"; mkdir -p "$proj" - rm -f "$STUBDIR/.created" - run bash -c 'printf "\n" | STUB_KEY_STATUS=401 ACQ_BACKEND=sbx "$1" run opencode "$2"' _ "$ACQ" "$proj" - assert_failure - assert_output --partial 'no USAi API key stored' - refute_output --partial 'Set it now' - refute_output --partial 'invalid or expired' - assert [ ! -f "$STUBDIR/.created" ] -} - -@test "create: empty store (non-tty) aborts before sbx create" { - local proj="$STUBDIR/kc"; mkdir -p "$proj" - rm -f "$STUBDIR/.created" - run bash -c 'printf "\n" | ACQ_BACKEND=sbx "$1" create opencode "$2"' _ "$ACQ" "$proj" - assert_failure - assert_output --partial 'no USAi API key stored' - refute_regex "$(cat "$CALLS")" 'sbx create' - assert [ ! -f "$STUBDIR/.created" ] -} - -@test "create(sbx): acq-store-only key (proxy unbound) fails closed before sbx create" { - local proj="$STUBDIR/kc-unbound"; mkdir -p "$proj" - mkdir -p "$STUBDIR/secrets"; printf 'sk-stored-only\n' > "$STUBDIR/secrets/acq.usai" - rm -f "$STUBDIR/.created" - run bash -c 'printf "" | ACQ_BACKEND=sbx "$1" create opencode "$2"' _ "$ACQ" "$proj" - assert_failure - assert_output --partial 'backend is not configured to inject it' - assert_output --partial 'acq secret set -g usai' - refute_regex "$(cat "$CALLS")" 'sbx create' - assert [ ! -f "$STUBDIR/.created" ] -} - -@test "create(sbx): a stored-but-unreadable key is reported as unreadable, not absent" { - local proj="$STUBDIR/kc-broken"; mkdir -p "$proj" - mkdir -p "$STUBDIR/secrets"; printf 'acq-dpapi-v1\nQUJD\n' > "$STUBDIR/secrets/acq.usai" - rm -f "$STUBDIR/.created" - run bash -c 'printf "" | ACQ_BACKEND=sbx "$1" create opencode "$2"' _ "$ACQ" "$proj" - assert_failure - assert_output --partial 'cannot be read or decrypted' - refute_output --partial 'no USAi API key stored' - refute_regex "$(cat "$CALLS")" 'sbx create' - assert [ ! -f "$STUBDIR/.created" ] -} @test "create(sbx): acq-store key WITH proxy binding proceeds to sbx create" { local proj="$STUBDIR/kc-bound"; mkdir -p "$proj" @@ -380,15 +279,6 @@ FACTS assert_output '' } -@test "run: present key + bad status -> post-create gate offers rotate; decline aborts" { - local proj="$STUBDIR/keyrun2"; mkdir -p "$proj" - _seed_usai - run bash -c 'printf "\n" | STUB_KEY_STATUS=401 ACQ_BACKEND=sbx "$1" run opencode "$2"' _ "$ACQ" "$proj" - assert_failure - assert_output --partial 'invalid or expired' - assert_output --partial 'Rotate now' - refute_output --partial 'No USAi API key is stored' -} @test "run: healthy status attaches with no gate message" { local proj="$STUBDIR/keyrun3"; mkdir -p "$proj" @@ -396,7 +286,9 @@ FACTS run env STUB_OPENCODE_OK=1 STUB_KEY_STATUS=200 ACQ_BACKEND=sbx "$ACQ" run opencode "$proj" assert_success refute_output --partial 'Aborting attach' - assert_regex "$(cat "$CALLS")" 'Authorization: Bearer \$USAI_API_KEY' + # The gate is provider-driven: with no provider inputs materialized there is + # nothing to probe, so attach proceeds to the backend. + assert_regex "$(cat "$CALLS")" 'sbx create' } @test "run(opencode): generic default does not run an unpinned npm postinstall fallback" { @@ -431,28 +323,6 @@ FACTS refute_regex "$(cat "$CALLS")" 'postinstall\.mjs' } -@test "run: unreachable USAi API is a network problem, not a key problem; fail closed" { - local proj="$STUBDIR/unreach"; mkdir -p "$proj" - _seed_usai - run env STUB_KEY_UNREACHABLE=1 ACQ_BACKEND=sbx "$ACQ" run opencode "$proj" - assert_failure - assert_output --partial 'could not reach the USAi API' - assert_output --partial 'NOT an invalid or expired key' - refute_output --partial 'Rotate now' - refute_output --partial 'curl:' -} - -@test "run: pinned-public TLS failure (curl 35) is unreachable, no DNS hint; fail closed" { - local proj="$STUBDIR/pinned35"; mkdir -p "$proj" - _seed_usai - run env STUB_KEY_UNREACHABLE=35 ACQ_BACKEND=sbx "$ACQ" run opencode "$proj" - assert_failure - assert_output --partial 'could not reach the USAi API' - assert_output --partial 'NOT an invalid or expired key' - refute_output --partial 'Rotate now' - refute_output --partial 'ACQ_MSB_DNS_NAMESERVER' - refute_output --partial 'did not RESOLVE' -} @test "classify: _classify_key_status yields clean tokens (code|unresolved|unreachable)" { load_acq @@ -475,15 +345,6 @@ FACTS assert_line --index 6 'unreachable' } -@test "run: NXDOMAIN (curl 6) is a resolver problem with a DNS hint; fail closed" { - local proj="$STUBDIR/unres"; mkdir -p "$proj" - _seed_usai - run env STUB_KEY_UNRESOLVED=1 ACQ_BACKEND=sbx "$ACQ" run opencode "$proj" - assert_failure - assert_output --partial 'did not RESOLVE' - assert_output --partial 'ACQ_MSB_DNS_NAMESERVER' - refute_output --partial 'Rotate now' -} @test "dispatch: stop/rm/exec route to the sbx adapter" { run env ACQ_BACKEND=sbx "$ACQ" stop mybox diff --git a/test/bats/40-secret-set.bats b/test/bats/40-secret-set.bats index bfb06754..b88bbfc6 100644 --- a/test/bats/40-secret-set.bats +++ b/test/bats/40-secret-set.bats @@ -288,12 +288,13 @@ LS assert_success } -@test "secret has(sbx): provider facts key env drives proxy lookup" { +@test "secret has(sbx): the provider key env drives the proxy lookup" { load_acq mkdir -p "$STUBDIR/secrets"; printf 'k\n' > "$STUBDIR/secrets/acq.usai" - printf 'CUSTOM SECRETS\n(global) api.gsa.usai.gov KIT_API_KEY ph-kit ****\n' > "$STUBDIR/sbx_ls" + # usai is no longer special-cased: its KEY_ENV comes from the shipped trust + # anchor (USAI_API_KEY), and that name is what the proxy table is searched for. + printf 'CUSTOM SECRETS\n(global) api.gsa.usai.gov USAI_API_KEY ph-kit ****\n' > "$STUBDIR/sbx_ls" export SBX_LS_FIXTURE="$STUBDIR/sbx_ls" - export USAI_PROVIDER_KEY_ENV=KIT_API_KEY run acq_backend_key_present usai assert_success diff --git a/test/bats/50-kit-list-completeness.bats b/test/bats/50-kit-list-completeness.bats index 0fa3d120..a0b79927 100644 --- a/test/bats/50-kit-list-completeness.bats +++ b/test/bats/50-kit-list-completeness.bats @@ -74,276 +74,18 @@ load 'helper' done } -@test "provider-facts: transitional USAi fallback defaults remain neutral" { - load_acq - assert_equal "$USAI_PROVIDER_KIT_NAME" "usai-provider" - assert_equal "$USAI_PROVIDER_HOST" "api.gsa.usai.gov" - assert_equal "$USAI_PROVIDER_BASE_URL" "https://api.gsa.usai.gov/api/v1" - assert_equal "$USAI_PROVIDER_KEY_ENV" "USAI_API_KEY" - assert_equal "$USAI_PROVIDER_MODELS_URL" "https://api.gsa.usai.gov/api/v1/models" - assert_equal "$USAI_PROVIDER_KEY_MGMT_URL" "https://gsa.usai.gov/console/key-management" - assert_equal "$USAI_PROVIDER_BIND_HOSTS" "api.gsa.usai.gov" - assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "fallback" - assert_regex "$USAI_KIT" "acq-kits/${USAI_PROVIDER_KIT_NAME}$" - refute_regex "$USAI_PROVIDER_MODELS_URL" 'opencode' -} -@test "provider-facts: valid artifact overrides fallback defaults" { - load_acq - local facts="$STUBDIR/usai.env" - cat > "$facts" <<'FACTS' -ACQ_PROVIDER_FACTS_SCHEMA=1 -ACQ_PROVIDER_ID=usai -ACQ_PROVIDER_HOST=api.example.gov -ACQ_PROVIDER_BASE_URL=https://api.example.gov/api/v1 -ACQ_PROVIDER_MODELS_URL=https://api.example.gov/api/v1/models -ACQ_PROVIDER_KEY_ENV=EXAMPLE_API_KEY -ACQ_PROVIDER_KEY_MGMT_URL=https://example.gov/keys -ACQ_PROVIDER_BIND_HOSTS=api.example.gov,models.example.gov -FACTS - - acq_provider_facts_load "$facts" - assert_equal "$USAI_PROVIDER_HOST" "api.example.gov" - assert_equal "$USAI_PROVIDER_BASE_URL" "https://api.example.gov/api/v1" - assert_equal "$USAI_PROVIDER_MODELS_URL" "https://api.example.gov/api/v1/models" - assert_equal "$USAI_PROVIDER_KEY_ENV" "EXAMPLE_API_KEY" - assert_equal "$USAI_PROVIDER_KEY_MGMT_URL" "https://example.gov/keys" - assert_equal "$USAI_PROVIDER_BIND_HOSTS" "api.example.gov,models.example.gov" - assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "$facts" -} -@test "provider-facts: URL syntax accepted by the shared contract reaches the verifier" { - load_acq - local facts="$STUBDIR/usai-url.env" - cat > "$facts" <<'FACTS' -ACQ_PROVIDER_FACTS_SCHEMA=1 -ACQ_PROVIDER_ID=usai -ACQ_PROVIDER_HOST=api.example.gov -ACQ_PROVIDER_BASE_URL=https://api.example.gov/api/v1 -ACQ_PROVIDER_MODELS_URL=https://api.example.gov/api/v1/models%2Fbeta+preview~v1?view=full&tag=a%2Bb -ACQ_PROVIDER_KEY_ENV=EXAMPLE_API_KEY -ACQ_PROVIDER_KEY_MGMT_URL=https://example.gov/keys -ACQ_PROVIDER_BIND_HOSTS=api.example.gov -FACTS - - acq_provider_facts_load "$facts" - assert_equal "$USAI_PROVIDER_MODELS_URL" "https://api.example.gov/api/v1/models%2Fbeta+preview~v1?view=full&tag=a%2Bb" -} -@test "provider-facts: malformed or injection-shaped URL fails before verifier use" { - load_acq - local facts="$STUBDIR/usai-bad-url.env" - cat > "$facts" <<'FACTS' -ACQ_PROVIDER_FACTS_SCHEMA=1 -ACQ_PROVIDER_ID=usai -ACQ_PROVIDER_HOST=api.example.gov -ACQ_PROVIDER_BASE_URL=https://api.example.gov/api/v1 -ACQ_PROVIDER_MODELS_URL=https://api.example.gov/api/v1/models$(touch injected) -ACQ_PROVIDER_KEY_ENV=EXAMPLE_API_KEY -ACQ_PROVIDER_KEY_MGMT_URL=https://example.gov/keys -ACQ_PROVIDER_BIND_HOSTS=api.example.gov -FACTS - - run acq_provider_facts_load "$facts" - assert_failure - assert_equal "$USAI_PROVIDER_MODELS_URL" "https://api.gsa.usai.gov/api/v1/models" - refute [ -e "$STUBDIR/injected" ] -} -@test "provider-facts: missing artifact leaves fallback path available" { - load_acq - run acq_provider_facts_load "$STUBDIR/missing.env" - assert_failure 2 - assert_equal "$USAI_PROVIDER_HOST" "api.gsa.usai.gov" - assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "fallback" -} - -@test "provider-facts: invalid artifact fails closed without partial override" { - load_acq - local facts="$STUBDIR/bad-usai.env" - cat > "$facts" <<'FACTS' -ACQ_PROVIDER_FACTS_SCHEMA=1 -ACQ_PROVIDER_ID=usai -ACQ_PROVIDER_HOST=api.bad.gov -ACQ_PROVIDER_BASE_URL=https://api.bad.gov/api/v1 -ACQ_PROVIDER_MODELS_URL=https://api.bad.gov/api/v1/models -ACQ_PROVIDER_KEY_ENV=bad-key-name -ACQ_PROVIDER_KEY_MGMT_URL=https://bad.gov/keys -ACQ_PROVIDER_BIND_HOSTS=api.bad.gov -FACTS - - run acq_provider_facts_load "$facts" - assert_failure - assert_equal "$USAI_PROVIDER_HOST" "api.gsa.usai.gov" - assert_equal "$USAI_PROVIDER_KEY_ENV" "USAI_API_KEY" - assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "fallback" -} -@test "provider-facts: kit loader consumes artifact from a fetched kit" { - load_acq - local kit="$STUBDIR/provider-kit" cache="$STUBDIR/provider-cache" - mkdir -p "$kit/provider-facts" - cat > "$kit/provider-facts/usai.env" <<'FACTS' -ACQ_PROVIDER_FACTS_SCHEMA=1 -ACQ_PROVIDER_ID=usai -ACQ_PROVIDER_HOST=api.kit.gov -ACQ_PROVIDER_BASE_URL=https://api.kit.gov/api/v1 -ACQ_PROVIDER_MODELS_URL=https://api.kit.gov/api/v1/models -ACQ_PROVIDER_KEY_ENV=KIT_API_KEY -ACQ_PROVIDER_KEY_MGMT_URL=https://kit.gov/keys -ACQ_PROVIDER_BIND_HOSTS=api.kit.gov -FACTS - - acq_provider_facts_load_from_kit "$kit" "$cache" - assert_equal "$USAI_PROVIDER_HOST" "api.kit.gov" - assert_equal "$USAI_PROVIDER_KEY_ENV" "KIT_API_KEY" - assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "$kit/provider-facts/usai.env" -} - -@test "provider-facts: kit loader keeps fallback when artifact is absent" { - load_acq - local kit="$STUBDIR/provider-kit-empty" cache="$STUBDIR/provider-cache-empty" - mkdir -p "$kit" - run acq_provider_facts_load_from_kit "$kit" "$cache" - assert_failure 2 - assert_equal "$USAI_PROVIDER_HOST" "api.gsa.usai.gov" - assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "fallback" -} -@test "provider-facts: kit loader fails closed for invalid artifact" { - load_acq - local kit="$STUBDIR/provider-kit-bad" cache="$STUBDIR/provider-cache-bad" - mkdir -p "$kit/provider-facts" - cat > "$kit/provider-facts/usai.env" <<'FACTS' -ACQ_PROVIDER_FACTS_SCHEMA=1 -ACQ_PROVIDER_ID=usai -ACQ_PROVIDER_HOST=api.bad.gov -ACQ_PROVIDER_BASE_URL=https://api.bad.gov/api/v1 -ACQ_PROVIDER_MODELS_URL=https://api.bad.gov/api/v1/models -ACQ_PROVIDER_KEY_ENV=BAD-KEY -ACQ_PROVIDER_KEY_MGMT_URL=https://bad.gov/keys -ACQ_PROVIDER_BIND_HOSTS=api.bad.gov -FACTS - - run acq_provider_facts_load_from_kit "$kit" "$cache" - assert_failure - assert_equal "$USAI_PROVIDER_HOST" "api.gsa.usai.gov" - assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "fallback" -} -@test "provider-facts: wrapper preserves assignments from successful kit load (regression for #551)" { - load_acq - local kit="$STUBDIR/provider-kit-ok" cache="$STUBDIR/provider-cache-ok" - mkdir -p "$kit/provider-facts" - cat > "$kit/provider-facts/usai.env" <<'FACTS' -ACQ_PROVIDER_FACTS_SCHEMA=1 -ACQ_PROVIDER_ID=usai -ACQ_PROVIDER_HOST=custom.example.gov -ACQ_PROVIDER_BASE_URL=https://custom.example.gov/api/v1 -ACQ_PROVIDER_MODELS_URL=https://custom.example.gov/api/v1/models -ACQ_PROVIDER_KEY_ENV=CUSTOM_API_KEY -ACQ_PROVIDER_KEY_MGMT_URL=https://custom.example.gov/keys -ACQ_PROVIDER_BIND_HOSTS=custom.example.gov,models.custom.example.gov -FACTS - - acq_provider_facts_load_from_kit_or_fallback "$kit" "$cache" - assert_equal "$USAI_PROVIDER_HOST" "custom.example.gov" - assert_equal "$USAI_PROVIDER_BASE_URL" "https://custom.example.gov/api/v1" - assert_equal "$USAI_PROVIDER_MODELS_URL" "https://custom.example.gov/api/v1/models" - assert_equal "$USAI_PROVIDER_KEY_ENV" "CUSTOM_API_KEY" - assert_equal "$USAI_PROVIDER_KEY_MGMT_URL" "https://custom.example.gov/keys" - assert_equal "$USAI_PROVIDER_BIND_HOSTS" "custom.example.gov,models.custom.example.gov" - assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "$kit/provider-facts/usai.env" -} -@test "provider-facts: wrapper preserves IFS and caller shell state (regression for #551 indirect)" { - load_acq - local kit="$STUBDIR/provider-kit-ok" cache="$STUBDIR/provider-cache-ok" - mkdir -p "$kit/provider-facts" - cat > "$kit/provider-facts/usai.env" <<'FACTS' -ACQ_PROVIDER_FACTS_SCHEMA=1 -ACQ_PROVIDER_ID=usai -ACQ_PROVIDER_HOST=api.example.gov -ACQ_PROVIDER_BASE_URL=https://api.example.gov/api/v1 -ACQ_PROVIDER_MODELS_URL=https://api.example.gov/api/v1/models -ACQ_PROVIDER_KEY_ENV=EXAMPLE_API_KEY -ACQ_PROVIDER_KEY_MGMT_URL=https://api.example.gov/keys -ACQ_PROVIDER_BIND_HOSTS=api.example.gov -FACTS - - # Snapshot IFS before call - local _before_ifs="$IFS" - # Call the wrapper - acq_provider_facts_load_from_kit_or_fallback "$kit" "$cache" - # Verify IFS is restored - assert_equal "$IFS" "$_before_ifs" - # Verify provider vars are assigned - assert_equal "$USAI_PROVIDER_HOST" "api.example.gov" -} -@test "provider-facts: wrapper classifies loader results under errexit with custom IFS" { - load_acq - local diagnostics="$STUBDIR/provider-facts-diagnostics" rc actual_rc - local _before_ifs - - for rc in 0 1 2 3 7; do - USAI_PROVIDER_HOST="before.example.gov" - USAI_PROVIDER_FACTS_SOURCE="before" - IFS='|' - _before_ifs="$IFS" - acq_provider_facts_load_from_kit() { - printf 'loader diagnostic for rc %s\n' "$rc" >&2 - if [ "$rc" -eq 0 ]; then - USAI_PROVIDER_HOST="loaded.example.gov" - USAI_PROVIDER_FACTS_SOURCE="loaded" - fi - return "$rc" - } - - set -e - if acq_provider_facts_load_from_kit_or_fallback kit cache 2>"$diagnostics"; then - actual_rc=0 - else - actual_rc=$? - fi - - assert_equal "$IFS" "$_before_ifs" - case "$rc" in - 0) - assert_equal "$actual_rc" "0" - assert_equal "$USAI_PROVIDER_HOST" "loaded.example.gov" - assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "loaded" - grep -Fqx "loader diagnostic for rc 0" "$diagnostics" - ;; - 1) - assert_equal "$actual_rc" "1" - assert_equal "$USAI_PROVIDER_HOST" "before.example.gov" - grep -Fqx "loader diagnostic for rc 1" "$diagnostics" - ;; - 2) - assert_equal "$actual_rc" "0" - assert_equal "$USAI_PROVIDER_HOST" "api.gsa.usai.gov" - assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "fallback" - grep -Fqx "loader diagnostic for rc 2" "$diagnostics" - ;; - 3) - assert_equal "$actual_rc" "0" - assert_equal "$USAI_PROVIDER_HOST" "api.gsa.usai.gov" - assert_equal "$USAI_PROVIDER_FACTS_SOURCE" "fallback" - grep -Fq "provider facts could not be fetched" "$diagnostics" - grep -Fq "loader diagnostic for rc 3" "$diagnostics" - ;; - 7) - assert_equal "$actual_rc" "7" - assert_equal "$USAI_PROVIDER_HOST" "before.example.gov" - grep -Fqx "loader diagnostic for rc 7" "$diagnostics" - ;; - esac - done -} @test "agent-kits: default kit list is support-only" { load_acq @@ -427,39 +169,4 @@ SPEC assert_regex "$(printf '%s\n' "${KITS[@]}")" '/tmp/opencode' } -@test "provider-facts: wrapper preserves unset caller IFS under set -u" { - load_acq - ( - set -u - unset IFS - acq_provider_facts_load_from_kit() { - IFS='|' - USAI_PROVIDER_HOST="loaded.example.gov" - USAI_PROVIDER_FACTS_SOURCE="loaded" - return 0 - } - - acq_provider_facts_load_from_kit_or_fallback kit cache - - [ -z "${IFS+x}" ] - [ "$USAI_PROVIDER_HOST" = "loaded.example.gov" ] - [ "$USAI_PROVIDER_FACTS_SOURCE" = "loaded" ] - ) -} - -@test "provider-facts: fallback preserves unset caller IFS under set -u" { - load_acq - - ( - set -u - unset IFS - acq_provider_facts_load_from_kit() { IFS='|'; return 2; } - - acq_provider_facts_load_from_kit_or_fallback kit cache - - [ -z "${IFS+x}" ] - [ "$USAI_PROVIDER_HOST" = "api.gsa.usai.gov" ] - [ "$USAI_PROVIDER_FACTS_SOURCE" = "fallback" ] - ) -} diff --git a/test/bats/60-help.bats b/test/bats/60-help.bats index 6ebf3fa4..e1543144 100644 --- a/test/bats/60-help.bats +++ b/test/bats/60-help.bats @@ -94,6 +94,7 @@ load 'helper' 'cp|acq cp — copy files in or out' 'ports|acq ports — list or publish' 'github-scope|acq github-scope — scope a GitHub token' + 'provider-credential|acq provider-credential — pin' 'usai-rotate-api-key|acq usai-rotate-api-key — rotate' 'version|acq version — show acq version' 'doctor|acq doctor — backend health check' diff --git a/test/bats/90-sbx-startup-kit.bats b/test/bats/90-sbx-startup-kit.bats index b12f4f31..44a5397f 100644 --- a/test/bats/90-sbx-startup-kit.bats +++ b/test/bats/90-sbx-startup-kit.bats @@ -81,7 +81,7 @@ _seed_stale_provenance() { ' assert_output --partial 'cannot extend a live sandbox with startup-bearing kits' assert_output --partial 'missing the Zscaler CA kit' - assert_output --partial 'missing the USAi kit' + assert_output --partial 'missing the provider kit' assert_output --partial 'missing the playbook kit' } diff --git a/test/bats/helper.bash b/test/bats/helper.bash index 8ed73387..b6d0c6f4 100644 --- a/test/bats/helper.bash +++ b/test/bats/helper.bash @@ -41,6 +41,8 @@ load "${REPO_ROOT}/test/vendor/bats-assert/load" acq_setup_stubs() { unset ACQ_BACKEND ACQ_IMAGE ACQ_CLONE ACQ_EXTRA_KITS ACQ_PROVIDER_KITS \ ACQ_PROVIDER_KITS_FROM_ENV ACQ_PROVIDER_KITS_FROM_CLI ACQ_UPDATE_CHECK \ + ACQ_PROVIDER_CREDENTIAL_BINDINGS ACQ_PROVIDER_INPUT_PYTHON \ + ACQ_PROVIDER_BINDINGS_GENERATION ACQ_KIT_SOURCES \ ACQ_STATE_DIR ACQ_SECRET_STORE_DIR ACQ_NETWORK_TIER \ ACQ_NETWORK_TIER_CONFIRM_OPEN ACQ_MSB_BALANCED_EGRESS ACQ_MSB_IMAGE \ ACQ_MSB_PULL ACQ_MSB_WORKSPACE ACQ_MSB_CLONES_DIR \ From 5f89c4552fac81b84ba183c076ab6a0c1bece94b Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 28 Oct 2026 21:28:07 +0000 Subject: [PATCH 60/63] style: satisfy CI hooks after the provider-kit squash Remove a trailing space in common.sh, end 50-kit-list-completeness.bats with a single newline, and annotate the two functions shellcheck 0.9 flags as SC2120: one takes an optional argument that sbx.sh (another file) passes, the other documents an optional explicit-ref form that no in-tree caller uses. Co-authored-by: OpenCode Agent --- acq.backends/common.sh | 4 +++- test/bats/50-kit-list-completeness.bats | 2 -- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index c9cc0461..aa798e3e 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -240,7 +240,7 @@ KIT_SOURCE_PREFIXES=() # failure is recorded here and turned into a hard stop by # _acq_require_provider_selection, which only create/run call. # shellcheck disable=SC2034 # read by _acq_require_provider_selection -ACQ_PROVIDER_SELECTION_ERROR="" +ACQ_PROVIDER_SELECTION_ERROR="" # Transitional fallback defaults for the pinned usai-provider kit. ADR-0030's # target state is for the kit to export these provider facts as static metadata @@ -2528,6 +2528,7 @@ _acq_kit_source_owner_prefix() { # would REMAIN after removing it. ACQ_EXTRA_KIT_SOURCES (a list of host/owner/ # prefixes) still works as a backward-compatible ADDITION so existing setups keep # fetching their kits. +# shellcheck disable=SC2120 # EXCLUDE_REPO is passed by sbx.sh (another file), which per-file analysis cannot see acq_kit_source_prefixes() { local exclude="${1:-}" entries repo rest prefix seen="" entries=$(acq_kit_sources_entries) || return 1 @@ -2682,6 +2683,7 @@ _acq_kit_source_ref_trusted() { # acq_provider_kit_ref [VALUE] — resolve the default provider kit (config # provider_kit, else ACQ_PROVIDER_KIT_DEFAULT) to a fetchable kit ref. # Accepted forms: @, /, host/owner/repo/. +# shellcheck disable=SC2120 # VALUE is the documented optional explicit-ref form; no in-tree caller passes it today, and the contract is kept as documented acq_provider_kit_ref() { local value="${1:-}" kit="" repo="" head local from_config="" diff --git a/test/bats/50-kit-list-completeness.bats b/test/bats/50-kit-list-completeness.bats index a0b79927..930db609 100644 --- a/test/bats/50-kit-list-completeness.bats +++ b/test/bats/50-kit-list-completeness.bats @@ -168,5 +168,3 @@ SPEC assert_failure assert_regex "$(printf '%s\n' "${KITS[@]}")" '/tmp/opencode' } - - From d6a924c99540ad08235d50bace2e0286a3864e7d Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 28 Oct 2026 21:28:07 +0000 Subject: [PATCH 61/63] fix(msb): bind provider credentials through the shared service-binding table The provider loop in the msb secret binding took each provider's host and env from the registry and recorded facts only, so an explicit `acq secret set --host H --env E` written by the user was ignored at create while rotate and `secret rm` still acted on it. Route the loop through _acq_msb_service_binding, the single source used by provision, rotate and unbind (user-written mapping first, then the registry/facts binding). The values still pass the same env and host validation before they reach argv, and the credential gate is unchanged. Co-authored-by: OpenCode Agent --- acq.backends/msb.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 3f67210a..9041f6b3 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -1030,9 +1030,14 @@ EOF # `github` is bound above from the compiled-in host table; every provider # (the default one included) comes through this loop. case "$_pid" in github) continue ;; esac - _pbinding=$(acq_provider_binding_host_env "$_pid" "$_name" 2>/dev/null) || continue - _penv=$(printf '%s' "$_pbinding" | cut -f2) - _phost=$(printf '%s' "$_pbinding" | cut -f1) + # _acq_msb_service_binding is the single source for provision, rotate and + # unbind: an explicit `acq secret set --host H --env E` sidecar (written + # by the user on the host, never by a kit) wins, else the registry/facts + # binding. Provisioning from the registry alone would bind a different + # host than the one rotate and `secret rm` act on. + _pbinding=$(_acq_msb_service_binding "$_pid" "$_name" 2>/dev/null) || continue + _penv=$(printf '%s' "$_pbinding" | cut -f1) + _phost=$(printf '%s' "$_pbinding" | cut -f2) [ -n "$_penv" ] && [ -n "$_phost" ] || continue eval "_pnames=\" \${${_namesn}[*]-} \"" case "$_pnames" in *" $_penv "*) continue ;; esac From a33cddf8aa6880f6d895c954943ed857799503ad Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 28 Oct 2026 21:28:07 +0000 Subject: [PATCH 62/63] test(msb): update provider-kit fixtures for the data-driven default provider The default provider kit is now resolved from provider_kit config (or the default constant) rather than $USAI_KIT, so the startup-staging and merged-env fixtures set the default provider ref alongside USAI_KIT, using a local ref the way the dispatch seam does. The key-check test drove check_fresh_sandbox_key, which the provider-key refactor removed; it now drives acq_backend_provision with the no-ports flag directly and also refutes the overridden host ports. Co-authored-by: OpenCode Agent --- test/bats/113-host-port-override.bats | 17 +++++++++++++---- test/bats/73-msb-kits-startup.bats | 12 ++++++------ test/bats/79-msb-merged-kit-env.bats | 4 ++-- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/test/bats/113-host-port-override.bats b/test/bats/113-host-port-override.bats index a3ca311b..7fdb0a0b 100644 --- a/test/bats/113-host-port-override.bats +++ b/test/bats/113-host-port-override.bats @@ -449,7 +449,14 @@ _msb_create_line() { grep '^msb create' "$CALLS" | head -n1; } assert_output "41001" } -@test "publish(B): the throwaway key-check sandbox publishes no ports" { +# Deliberate change: this case used to drive the throwaway key-check sandbox through +# `check_fresh_sandbox_key`. That helper was removed with the USAi-specific key +# subsystem (provider keys are now validated by the generic check on attach), so the +# case now exercises the provisioning seam it depended on directly: +# `_ACQ_PROVISION_WITHOUT_PORTS=1` must drop every published port, including a +# `--publish` override, so a throwaway sandbox never contends for the real +# sandbox's host ports. +@test "publish(B): a provision with _ACQ_PROVISION_WITHOUT_PORTS=1 publishes no ports" { local k; k=$(_pp_kit keycheckkit 3000 7000) : > "$CALLS" ( export ACQ_SECRET_STORE_DIR="$STUBDIR/sec-keycheck" @@ -463,11 +470,13 @@ _msb_create_line() { grep '^msb create' "$CALLS" | head -n1; } # shellcheck disable=SC2034 # consumed by the sourced acq_backend_provision ACQ_PUBLISH_FLAGS=(6868:6767) _acq_msb_fetch_kit() { printf '%s\n' "$k"; } - # Both host ports are held by the real sandbox; the key check must not + # Both host ports are held by the real sandbox; a throwaway sandbox must not # contend for them. export ACQ_MSB_HOST_PORTS_BUSY=6868,7000 - check_fresh_sandbox_key ) >/dev/null 2>&1 || true + _ACQ_PROVISION_WITHOUT_PORTS=1 + acq_backend_provision acq-keycheck-throwaway shell . /dev/null 2>&1 || true local line; line=$(grep '^msb create' "$CALLS" | head -n1) - assert_regex "$line" 'acq-keycheck-' + assert_regex "$line" 'acq-keycheck-throwaway' refute_regex "$line" '\-p ' + refute_regex "$line" '6868|7000' } diff --git a/test/bats/73-msb-kits-startup.bats b/test/bats/73-msb-kits-startup.bats index 750bb791..2980a720 100644 --- a/test/bats/73-msb-kits-startup.bats +++ b/test/bats/73-msb-kits-startup.bats @@ -445,10 +445,10 @@ commands: SPEC mkdir -p "'"$STUBDIR"'/nokit" printf "schemaVersion: \"hybrid/v1\"\nkind: mixin\nname: x\ndisplayName: X\ndescription: x\n" > "'"$STUBDIR"'/nokit/spec.yaml" - USAI_KIT="k1"; PLAYBOOK_KIT="k2"; ZSCALER_KIT="k3"; GITSSHSIGN_KIT="k4" + USAI_KIT="/k1"; ACQ_PROVIDER_KIT_DEFAULT="/k1"; PLAYBOOK_KIT="k2"; ZSCALER_KIT="k3"; GITSSHSIGN_KIT="k4" _acq_msb_fetch_kit() { case "$1" in - k1) printf "%s\n" "$mk1" ;; + /k1) printf "%s\n" "$mk1" ;; k2) printf "%s\n" "$mk2" ;; *) printf "%s\n" "'"$STUBDIR"'/nokit" ;; esac @@ -543,9 +543,9 @@ environment: SPEC nk="'"$STUBDIR"'/stagenokit"; mkdir -p "$nk" printf "schemaVersion: \"hybrid/v1\"\nkind: mixin\nname: x\ndisplayName: X\ndescription: x\n" > "$nk/spec.yaml" - ZSCALER_KIT=k1; USAI_KIT=k2; PLAYBOOK_KIT=k3; GITSSHSIGN_KIT=k4 + ZSCALER_KIT=k1; USAI_KIT=/k2; ACQ_PROVIDER_KIT_DEFAULT=/k2; PLAYBOOK_KIT=k3; GITSSHSIGN_KIT=k4 _acq_msb_fetch_kit() { - case "$1" in k1) printf "%s\n" "$sk" ;; k2) printf "%s\n" "$ck" ;; *) printf "%s\n" "$nk" ;; esac + case "$1" in k1) printf "%s\n" "$sk" ;; /k2) printf "%s\n" "$ck" ;; *) printf "%s\n" "$nk" ;; esac } acq_backend_provision stagemergebox shell /tmp >/dev/null 2>&1 ' @@ -607,9 +607,9 @@ environment: SPEC nk="'"$STUBDIR"'/gvnokit"; mkdir -p "$nk" printf "schemaVersion: \"hybrid/v1\"\nkind: mixin\nname: x\ndisplayName: X\ndescription: x\n" > "$nk/spec.yaml" - ZSCALER_KIT=k1; USAI_KIT=k2; PLAYBOOK_KIT=k3; GITSSHSIGN_KIT=k4 + ZSCALER_KIT=k1; USAI_KIT=/k2; ACQ_PROVIDER_KIT_DEFAULT=/k2; PLAYBOOK_KIT=k3; GITSSHSIGN_KIT=k4 _acq_msb_fetch_kit() { - case "$1" in k1) printf "%s\n" "$sk" ;; k2) printf "%s\n" "$bk" ;; *) printf "%s\n" "$nk" ;; esac + case "$1" in k1) printf "%s\n" "$sk" ;; /k2) printf "%s\n" "$bk" ;; *) printf "%s\n" "$nk" ;; esac } acq_backend_provision gvstagebox shell /tmp >/dev/null 2>&1 ' diff --git a/test/bats/79-msb-merged-kit-env.bats b/test/bats/79-msb-merged-kit-env.bats index 86f1a921..5730db07 100644 --- a/test/bats/79-msb-merged-kit-env.bats +++ b/test/bats/79-msb-merged-kit-env.bats @@ -38,11 +38,11 @@ _env_merge_provision() { . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" acq_test_stub_provider_input_boundary - ZSCALER_KIT=k1; USAI_KIT=k2; PLAYBOOK_KIT=k3; GITSSHSIGN_KIT=k4 + ZSCALER_KIT=k1; USAI_KIT=/k2; ACQ_PROVIDER_KIT_DEFAULT=/k2; PLAYBOOK_KIT=k3; GITSSHSIGN_KIT=k4 nk="'"$STUBDIR"'/mergenokit"; mkdir -p "$nk" printf "schemaVersion: \"hybrid/v1\"\nkind: mixin\nname: x\ndisplayName: X\ndescription: x\n" > "$nk/spec.yaml" _acq_msb_fetch_kit() { - case "$1" in k1) printf "%s\n" "$a" ;; k2) printf "%s\n" "$b" ;; *) printf "%s\n" "$nk" ;; esac + case "$1" in k1) printf "%s\n" "$a" ;; /k2) printf "%s\n" "$b" ;; *) printf "%s\n" "$nk" ;; esac } acq_backend_provision "$sandbox" shell /tmp >/dev/null 2>&1 ' _ "$a" "$b" "$sandbox" From 4b51caf2c9604219d6e0563e12c8fb5f2aca6db3 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 28 Oct 2026 22:01:37 +0000 Subject: [PATCH 63/63] fix(sbx): guard empty KIT_SOURCE_PREFIXES expansions for bash 3.2 The provider-kit generalization made KIT_SOURCE_PREFIXES start empty and be filled only by _build_kit_list. Two sbx allowlist helpers still expanded it as a bare "${arr[@]}" inside a command substitution; on bash 3.2 that is an unbound-variable error under set -u, which set -e then turned into an abort at the very start of provisioning, before the projection root was created. Use the ${arr[@]+...} guard at both sites (the third already had it), which is identical on bash 4+. An empty list still leaves the allowlist unchanged because the helper only ever unions. Adds a behavioral smoke test with an empty array under set -euo pipefail and a static guard that fails if a bare expansion is reintroduced; the dynamic test cannot reproduce the bash 3.2 failure on a newer bash. Co-authored-by: OpenCode Agent --- acq.backends/sbx.sh | 8 ++++++-- test/bats/149-kit-source.bats | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index ecc65267..aca175c0 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -197,12 +197,16 @@ _acq_sbx_confirm_absent() { _acq_sbx_kit_sources_manual_hint() { local reason="$1" cmd cur echo "acq: warning: $reason." >&2 + # KIT_SOURCE_PREFIXES can legitimately be empty (every trusted source removed, or + # _build_kit_list not run yet); on bash 3.2 a bare "${arr[@]}" of an empty array is + # an unbound-variable error under set -u, so it is expanded with the ${arr[@]+...} + # guard everywhere. if command -v jq >/dev/null 2>&1; then cur=$(sbx settings get kit.allowedSources 2>/dev/null || true) printf '%s' "$cur" | jq -e 'type == "array"' >/dev/null 2>&1 || cur='["docker.io/"]' cmd=$(printf '%s' "$cur" | jq -c --args \ 'reduce $ARGS.positional[] as $p (.; if index($p) then . else . + [$p] end)' \ - "${KIT_SOURCE_PREFIXES[@]}" 2>/dev/null) + ${KIT_SOURCE_PREFIXES[@]+"${KIT_SOURCE_PREFIXES[@]}"} 2>/dev/null) fi if [ -z "${cmd:-}" ]; then local _acc='["docker.io/"' _p @@ -296,7 +300,7 @@ _acq_sbx_ensure_kit_sources_allowed() { local current desired desired=$(printf '%s' "$current" | jq -c --args \ 'reduce $ARGS.positional[] as $p (.; if index($p) then . else . + [$p] end)' \ - "${KIT_SOURCE_PREFIXES[@]}" 2>/dev/null) + ${KIT_SOURCE_PREFIXES[@]+"${KIT_SOURCE_PREFIXES[@]}"} 2>/dev/null) if [ -z "$desired" ]; then _acq_sbx_kit_sources_manual_hint "failed to compute updated allowlist" return 0 diff --git a/test/bats/149-kit-source.bats b/test/bats/149-kit-source.bats index 62c35b1c..495912b8 100644 --- a/test/bats/149-kit-source.bats +++ b/test/bats/149-kit-source.bats @@ -590,3 +590,33 @@ STUB run acq_provider_kit_ref assert_success } + +# Regression for the bash 3.2 CI failure: KIT_SOURCE_PREFIXES is legitimately empty +# before _build_kit_list runs (and after every trusted source is removed), and on +# bash 3.2 a bare "${arr[@]}" of an empty array is a fatal unbound-variable error +# under `set -u`, which `set -e` then turned into an abort of the whole create. +@test "sbx allowlist sync tolerates an empty KIT_SOURCE_PREFIXES under set -euo pipefail" { + run bash -c ' + set -euo pipefail + ACQ_SCRIPT_DIR="'"$REPO_ROOT"'" ACQ_SOURCE_ONLY=1 + . "'"$REPO_ROOT"'/acq" + . "'"$REPO_ROOT"'/acq.backends/sbx.sh" + KIT_SOURCE_PREFIXES=() + sbx() { [ "${1:-}" = settings ] && [ "${2:-}" = get ] && printf "%s\n" "[\"docker.io/\"]"; return 0; } + _acq_sbx_ensure_kit_sources_allowed + # the hint path (unreadable setting) must survive an empty list as well + sbx() { return 0; } + _acq_sbx_ensure_kit_sources_allowed 2>/dev/null + echo SURVIVED + ' + assert_success + assert_output --partial 'SURVIVED' +} + +@test "sbx.sh never expands KIT_SOURCE_PREFIXES bare (bash 3.2 empty-array guard)" { + run grep -n '"\${KIT_SOURCE_PREFIXES\[@\]}"' "$REPO_ROOT/acq.backends/sbx.sh" + # every use must be the ${arr[@]+"${arr[@]}"} form, which contains the bare form only inside the guard + [ -n "$output" ] || skip "no bare uses at all" + run bash -c 'grep -n "KIT_SOURCE_PREFIXES\[@\]" "'"$REPO_ROOT"'/acq.backends/sbx.sh" | grep -v "KIT_SOURCE_PREFIXES\[@\]+"' + assert_failure +}