diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 111aa98..008a1d7 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -2274,6 +2274,193 @@ acq_cli_kits_load() { return 0 } +# ============================================================================ +# Host-authoritative per-sandbox config store (ADR-0035) +# ============================================================================ +# acq must NOT rely on guest-generated or guest-tamperable state to configure a +# sandbox: the in-sandbox agent has passwordless sudo, so a root-owned GUEST path +# (e.g. /var/lib/acq/agent) is NOT a trust boundary against a prompt-injected +# agent — it can sudo-rewrite any such file, and acq would then read the tampered +# value back and act on it (which agent to launch, cwd, SSH_AUTH_SOCK, kit env). +# +# The only enforceable boundary is host<->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}" + +# Host directory holding one sandbox's config files. Same keying as the +# provenance/cli-kits records (sanitized name + raw-name checksum) so the three +# sit side by side and never collide across backends or sanitizer-aliased names. +acq_host_config_dir() { + local backend="${1:-}" name="${2:-}" + [ -n "$backend" ] && [ -n "$name" ] || return 1 + local safe_backend safe_name name_sum + safe_backend=$(printf '%s' "$backend" | tr -c 'A-Za-z0-9._-' '_') + safe_name=$(printf '%s' "$name" | tr -c 'A-Za-z0-9._-' '_') + name_sum=$(printf '%s' "$name" | cksum 2>/dev/null | cut -d' ' -f1 2>/dev/null || echo 0) + printf '%s/%s/%s.%s.config\n' "$(_acq_provenance_dir)" "$safe_backend" "$safe_name" "$name_sum" +} + +# Validate a config KEY: fixed-literal callers only, restricted charset, never a +# path separator or traversal. Fail closed (return non-zero, emit nothing). +_acq_host_config_key_ok() { + case "${1:-}" in + ""|.|..) return 1 ;; + *[!A-Za-z0-9._-]*) return 1 ;; + *) return 0 ;; + esac +} + +# Write one KEY's VALUE for a sandbox's host config. Creates/traversabilizes the +# dir 0711 and writes the file 0600 (world-unreadable on the host; the guest sees +# only what it can traverse/read via the :ro mount). +# Atomic temp+rename. Best-effort: warns (debug) and returns non-zero on failure +# but never aborts the caller — preserving the fail-soft posture of the guest +# markers this replaces. +# Usage: acq_host_config_write BACKEND NAME KEY VALUE +acq_host_config_write() { + local backend="${1:-}" name="${2:-}" key="${3:-}" value="${4:-}" + [ -n "$backend" ] && [ -n "$name" ] || return 1 + _acq_host_config_key_ok "$key" || { acq_debug "host-config: refusing unsafe key '$key'"; return 1; } + local dir file + dir=$(acq_host_config_dir "$backend" "$name") || return 1 + if ! mkdir -p "$dir" 2>/dev/null; then + acq_debug "host-config: could not create config dir: $dir" + return 1 + fi + chmod 711 "$dir" 2>/dev/null || true + file="$dir/$key" + local tmp="${file}.tmp.$$" + ( umask 077; printf '%s' "$value" > "$tmp" ) 2>/dev/null || { + acq_debug "host-config: write failed: $tmp"; rm -f "$tmp" 2>/dev/null; return 1; } + chmod 600 "$tmp" 2>/dev/null || true + mv -f "$tmp" "$file" 2>/dev/null || { + acq_debug "host-config: mv failed: $file"; rm -f "$tmp" 2>/dev/null; return 1; } + acq_debug "host-config: wrote $backend/$name/$key" + return 0 +} + +# Read one KEY's VALUE. Echoes the raw stored bytes (empty if absent/unreadable). +# Never fails the caller (returns 0 even when the key is absent) so a reader under +# `set -euo pipefail` behaves like the old failure-guarded guest `cat`. +# Usage: acq_host_config_read BACKEND NAME KEY +acq_host_config_read() { + local backend="${1:-}" name="${2:-}" key="${3:-}" + [ -n "$backend" ] && [ -n "$name" ] || return 0 + _acq_host_config_key_ok "$key" || return 0 + local dir file + dir=$(acq_host_config_dir "$backend" "$name") || return 0 + file="$dir/$key" + [ -f "$file" ] || return 0 + cat "$file" 2>/dev/null || true +} + +# True (0) if a KEY exists for a sandbox. Replaces the guest `test -f` marker +# gates (install-*, agent-*-ready) that a sudo guest could forge. +# Usage: acq_host_config_has BACKEND NAME KEY +acq_host_config_has() { + local backend="${1:-}" name="${2:-}" key="${3:-}" + [ -n "$backend" ] && [ -n "$name" ] || return 1 + _acq_host_config_key_ok "$key" || return 1 + local dir + dir=$(acq_host_config_dir "$backend" "$name") || return 1 + [ -f "$dir/$key" ] +} + +# Append VALUE (one line) to a KEY's file, creating it if needed. Used by the +# kit-env accumulator (multiple kits each contribute lines). Same fail-soft +# contract as acq_host_config_write. +# Usage: acq_host_config_append BACKEND NAME KEY VALUE +acq_host_config_append() { + local backend="${1:-}" name="${2:-}" key="${3:-}" value="${4:-}" + [ -n "$backend" ] && [ -n "$name" ] || return 1 + _acq_host_config_key_ok "$key" || { acq_debug "host-config: refusing unsafe key '$key'"; return 1; } + local dir file + dir=$(acq_host_config_dir "$backend" "$name") || return 1 + if ! mkdir -p "$dir" 2>/dev/null; then + acq_debug "host-config: could not create config dir: $dir"; return 1 + fi + chmod 711 "$dir" 2>/dev/null || true + file="$dir/$key" + if [ ! -f "$file" ]; then + ( umask 077; : > "$file" ) 2>/dev/null || { + acq_debug "host-config: create failed: $file"; return 1; } + fi + printf '%s\n' "$value" >> "$file" 2>/dev/null || { + acq_debug "host-config: append failed: $file"; return 1; } + chmod 600 "$file" 2>/dev/null || true + return 0 +} + +# Remove one KEY (e.g. reset kit-env before a full re-apply). Best-effort. +# Usage: acq_host_config_clear BACKEND NAME KEY +acq_host_config_clear() { + local backend="${1:-}" name="${2:-}" key="${3:-}" + [ -n "$backend" ] && [ -n "$name" ] || return 0 + _acq_host_config_key_ok "$key" || return 0 + local dir + dir=$(acq_host_config_dir "$backend" "$name") || return 0 + rm -f "$dir/$key" 2>/dev/null || true + return 0 +} + +# Remove per-sandbox instance keys before provisioning a freshly-created sandbox. +# The host config dir is keyed by sandbox name, so a sandbox removed outside acq +# can leave stale state behind for a later same-named sandbox. Clear run-once +# gates and conditionally-written content keys before provision rewrites the +# current instance's authoritative values. +# Usage: acq_host_config_clear_instance_state BACKEND NAME +acq_host_config_clear_instance_state() { + local backend="${1:-}" name="${2:-}" + [ -n "$backend" ] && [ -n "$name" ] || return 0 + local dir + dir=$(acq_host_config_dir "$backend" "$name") || return 0 + case "$dir" in ""|/|/*/) return 0 ;; esac + rm -f "$dir"/install-* "$dir"/agent-installed-* \ + "$dir"/agent-user-ready \ + "$dir"/workspace "$dir"/ssh-auth-sock 2>/dev/null || true + return 0 +} + +# Remove a sandbox's entire host config dir (called from terminate). Best-effort. +# Usage: acq_host_config_remove BACKEND NAME +acq_host_config_remove() { + local backend="${1:-}" name="${2:-}" + [ -n "$backend" ] && [ -n "$name" ] || return 0 + local dir + dir=$(acq_host_config_dir "$backend" "$name") || return 0 + # Guard against a pathological empty resolution before rm -rf. + case "$dir" in + ""|/|/*/) return 0 ;; + *.config) rm -rf "$dir" 2>/dev/null || true ;; + esac + return 0 +} + # Automatic stale-sandbox advisory for `acq run` on an EXISTING sandbox. Compares # the sandbox's recorded bundle ref against the local pinned PATTERNS_KIT_REF and, # when they differ (or no record exists), OFFERS an in-place refresh. Contract: diff --git a/acq.backends/kit-translate.sh b/acq.backends/kit-translate.sh index 96f8b5a..c10f725 100644 --- a/acq.backends/kit-translate.sh +++ b/acq.backends/kit-translate.sh @@ -35,7 +35,7 @@ # kit_spec_net_allow SPEC -> one host[:port] per line # kit_spec_published_ports SPEC -> one "guestprotonamehost" per line # kit_spec_volumes SPEC -> one "pathtypesize" per line -# kit_spec_files SPEC -> one "path|mode|phase|source" per line +# kit_spec_files SPEC -> one "path|mode|phase|source|readonly" per line # kit_spec_commands SPEC -> command records (see format below) # kit_spec_env SPEC -> one "NAMEvalue" per line # kit_spec_has_shortcut SPEC BACKEND -> 0 if backend_shortcuts. @@ -499,9 +499,14 @@ _kit_vol_validate() { # kit_spec_files SPEC # --------------------------------------------------------------------------- # Echo one record per files[] entry, tab-separated: -# path mode phase source +# path mode phase source readonly # phase is empty if unspecified (default: written before commands run). # source is empty for inline-content files (not used by the four acq kits). +# readonly is "true" iff the entry declares `readonly: true` — a file whose +# BYTES are trusted CODE acq must execute from a host-authoritative read-only +# mount rather than a guest-writable copy a passwordless-sudo agent could tamper +# (ADR-0035). Empty (treated as false) otherwise, so existing 4-field consumers +# and unflagged files are unaffected. kit_spec_files() { local spec="$1" [ -f "$spec" ] || return 0 @@ -530,9 +535,10 @@ kit_spec_files() { if (k=="mode") cur_mode=v if (k=="phase") cur_phase=v if (k=="source") cur_source=v + if (k=="readonly") cur_readonly=v if (k=="description") cur_desc=v } - function flush() { + function flush( ro) { if (cur_path != "") { # Reject fields that could break out of the shell contexts these values # are later interpolated into. A hostile or mistyped kit spec must not @@ -549,6 +555,7 @@ kit_spec_files() { # kit stages no files at all — a silent, host-dependent failure, because # the warning goes to stderr and the create path treats kit failures as # best-effort. Do NOT "simplify" this back to an interval expression. + ro = (cur_readonly == "true") ? "true" : "" if (cur_mode != "" && cur_mode !~ /^[0-7][0-7][0-7][0-7]?$/) { print "kit-translate: skipping file with invalid mode: " cur_mode > "/dev/stderr" } else if (cur_path !~ /^[A-Za-z0-9._\/-]+$/) { @@ -556,10 +563,10 @@ kit_spec_files() { } else if (cur_source != "" && cur_source !~ /^[A-Za-z0-9._\/-]+$/) { print "kit-translate: skipping file with unsafe source: " cur_source > "/dev/stderr" } else { - printf "%s\t%s\t%s\t%s\n", cur_path, cur_mode, cur_phase, cur_source + printf "%s\t%s\t%s\t%s\t%s\n", cur_path, cur_mode, cur_phase, cur_source, ro } } - cur_path=""; cur_mode=""; cur_phase=""; cur_source=""; cur_desc="" + cur_path=""; cur_mode=""; cur_phase=""; cur_source=""; cur_readonly=""; cur_desc="" } ' "$spec" } diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 2b9e75e..0b7d6eb 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -85,6 +85,21 @@ _acq_msb_select_kits() { fi } +# Host-authoritative config store (ADR-0035). Normally defined in common.sh +# (sourced before this adapter by acq_resolve_backend), but offline session paths +# source this adapter directly in tests. Guard-source it so acq_host_config_* is +# available in either path. +if ! command -v acq_host_config_read >/dev/null 2>&1; then + # shellcheck source=acq.backends/common.sh + . "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/common.sh" +fi + +# Keep the adapter-local validator used by attach while delegating the canonical +# token policy to the shared agent catalog. +_acq_msb_safe_agent_token() { + acq_agent_safe_token "$1" +} + # --------------------------------------------------------------------------- # _acq_msb_cli — invoke the msb CLI with MSYS argument rewriting disabled # --------------------------------------------------------------------------- @@ -568,6 +583,10 @@ ACQ_MSB_CLONES_DIR="${ACQ_MSB_CLONES_DIR:-${ACQ_STATE_DIR}/clones}" # always defined even if provision is not the entry point. _ACQ_MSB_STARTUP_STAGE_FILES=() _ACQ_MSB_STARTUP_STAGED="" +# Per-kit readonly-file rewrite table: declared guest path -> read-only mount +# path. Trusted startup code is never executed from a guest-writable copy. +_ACQ_MSB_RO_REWRITE_FROM=() +_ACQ_MSB_RO_REWRITE_TO=() # SSH user for the serve listener. `msb ssh serve` authorizes a key host-wide; # the login user on the loopback listener defaults to root (override if a # deployment's msb serve expects a different account). @@ -809,6 +828,14 @@ acq_backend_exists() { _acq_msb_cli list -q 2>/dev/null | grep -Fxq -- "$1" } +# Return success only when msb successfully lists sandboxes and NAME is absent. +# A failed list probe is indeterminate, not evidence that a live sandbox is gone. +_acq_msb_confirm_absent() { + local name="$1" listed + listed=$(_acq_msb_cli list -q 2>/dev/null) || return 1 + ! printf '%s\n' "$listed" | grep -Fxq -- "$name" +} + # --------------------------------------------------------------------------- # _acq_msb_is_running NAME — 0 if the named sandbox is currently RUNNING, else 1 # --------------------------------------------------------------------------- @@ -1081,9 +1108,9 @@ _acq_msb_wait_for_exec_ready() { # THE BOUNDARY — what stays on `msb exec`, and WHY it MUST: # # 1) INSTALL PHASE stays exec-based. install commands are run-once, gated by a -# root-owned marker keyed on a hash of the argv -# (_acq_msb_exec_install: /var/lib/acq/install-), tested+written as -# uid 0 so the gate is independent of the command's own user. A create-time +# host-authoritative marker keyed on a hash of the argv +# (_acq_msb_exec_install: `install-` in host config), so a +# passwordless-sudo guest cannot suppress the step. A create-time # script re-runs on every restart by design — the OPPOSITE of run-once — so # folding install into the startup script would break its idempotency # contract. install therefore stays out of the staged script entirely. @@ -1689,7 +1716,7 @@ _acq_msb_apply_kit_dir() { # 1) Drop files[] (msb cp host→guest). Files are staged from the kit's files/ # tree via the spec's source: field. - # kit_spec_files emits tab-separated "pathmodephasesource" + # kit_spec_files emits tab-separated "pathmodephasesourcereadonly" # with possibly-empty middle fields; parse each field explicitly with cut # (a bare `IFS= read` collapses adjacent empty tab fields). # IMPORTANT: read ALL records into an array FIRST. If we iterated the @@ -1703,19 +1730,37 @@ _acq_msb_apply_kit_dir() { $(kit_spec_files "$spec") EOF - local path mode phase source src _i + 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 for _i in ${_frecs[@]+"${!_frecs[@]}"}; do fline="${_frecs[$_i]}" path=$(printf '%s' "$fline" | cut -f1) mode=$(printf '%s' "$fline" | cut -f2) phase=$(printf '%s' "$fline" | cut -f3) source=$(printf '%s' "$fline" | cut -f4) + _readonly=$(printf '%s' "$fline" | cut -f5) [ -n "$path" ] || continue src="" if [ -n "$source" ]; then src="${kitdir}/${source}" fi - if [ -n "$src" ] && [ -f "$src" ]; then + 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 + _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="" + fi + 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 + fi _acq_msb_copy_file_verified "$name" "$src" "$path" "$mode" || { echo "acq(msb): error: could not place kit file at ${name}:${path}" >&2 echo "acq(msb): subsequent kit commands that read it will fail." >&2 @@ -1727,18 +1772,17 @@ EOF # 2) Persist environment[] for session replay. Threading `-e NAME=value` onto # the kit's own commands (step 3) covers provisioning only; the block exists # for agent-runtime config (see ADR-0011: OPENCODE_CONFIG-style vars), so - # the validated entries are also appended to a root-owned guest marker that - # run/attach/shell read back and replay as `-e` flags (same marker pattern - # as /var/lib/acq/agent and /var/lib/acq/ssh-auth-sock). Entries are passed - # as argv to a fixed `sh -c` body — kit bytes are never interpolated into - # shell syntax (SI-10). + # the validated entries are persisted to the host config store and replayed + # as `-e` flags. A passwordless-sudo guest cannot inject environment into a + # later agent session. Entries are never interpolated into shell syntax. local _kit_env=() _acq_msb_collect_kit_env_into _kit_env "$spec" if [ "${#_kit_env[@]}" -gt 0 ]; then - _acq_msb_cli exec -u 0 "$name" -- sh -c \ - 'mkdir -p /var/lib/acq && printf "%s\n" "$@" >> /var/lib/acq/kit-env' \ - sh "${_kit_env[@]}" /dev/null 2>&1 || \ - echo "acq(msb): warning: could not persist kit env for '$name'; its environment[] will not reach agent sessions" >&2 + local _kev + for _kev in "${_kit_env[@]}"; do + acq_host_config_append msb "$name" kit-env "$_kev" || \ + echo "acq(msb): warning: could not persist kit env for '$name'; its environment[] will not reach agent sessions" >&2 + done fi # 3) Run commands[]. Reassemble each argv record and exec it as the given uid. @@ -1763,7 +1807,7 @@ EOF _acq_msb_run_commands "$name" "$spec" "$_merged_envn" } -# _acq_msb_reset_kit_env NAME — remove the persisted kit-env marker so a +# _acq_msb_reset_kit_env NAME — remove the persisted host kit-env key so a # FULL-set kit application (provision, heal) rebuilds it from the current kits' # environment[] only. Without this, the per-kit append in _acq_msb_apply_kit_dir # would retain entries a kit no longer declares — removed runtime config @@ -1773,8 +1817,43 @@ EOF # a failed reset degrades to the previous stale-retention behavior, never # aborts the apply. _acq_msb_reset_kit_env() { - _acq_msb_cli exec -u 0 "$1" -- sh -c 'rm -f /var/lib/acq/kit-env' \ - /dev/null 2>&1 || true + acq_host_config_clear msb "$1" kit-env +} + +_acq_msb_host_config_mount_available() { + 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'" \ + /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' \ + sh "$ACQ_HOST_CONFIG_GUEST_DIR" /dev/null 2>&1 +} + +_acq_msb_stage_readonly_file() { + local name="$1" guestpath="$2" src="$3" + local cfgdir slug crc dest + cfgdir=$(acq_host_config_dir msb "$name") || return 1 + slug=$(printf '%s' "${guestpath#/}" | tr -c 'A-Za-z0-9' '-' | tr -s '-') + slug="${slug%-}" + crc=$(printf '%s' "$guestpath" | cksum 2>/dev/null | cut -d' ' -f1 2>/dev/null || echo 0) + mkdir -p "${cfgdir}/kit-files" 2>/dev/null || return 1 + chmod 711 "$cfgdir" "${cfgdir}/kit-files" 2>/dev/null || true + dest="${cfgdir}/kit-files/${slug}.${crc}" + cp -f "$src" "$dest" 2>/dev/null || return 1 + chmod 0555 "$dest" 2>/dev/null || true + printf '%s/kit-files/%s.%s\n' "$ACQ_HOST_CONFIG_GUEST_DIR" "$slug" "$crc" +} + +_acq_msb_ro_rewrite_token() { + local token="$1" i + for i in ${_ACQ_MSB_RO_REWRITE_FROM[@]+"${!_ACQ_MSB_RO_REWRITE_FROM[@]}"}; do + if [ "$token" = "${_ACQ_MSB_RO_REWRITE_FROM[$i]}" ]; then + printf '%s\n' "${_ACQ_MSB_RO_REWRITE_TO[$i]}" + return 0 + fi + done + printf '%s\n' "$token" } # Copy a host file into the guest and VERIFY it is readable there before @@ -1934,6 +2013,12 @@ EOF ;; "__END__") reading=0 + if [ "${#_ACQ_MSB_RO_REWRITE_FROM[@]}" -gt 0 ] && [ "${#argv[@]}" -gt 0 ]; then + local _ai + for _ai in "${!argv[@]}"; do + argv[$_ai]=$(_acq_msb_ro_rewrite_token "${argv[$_ai]}") + done + fi _acq_msb_exec_command "$name" "$phase" "$user" "$background" _own_guard_tok \ ${_kit_env[@]+"${_kit_env[@]}"} -- ${argv[@]+"${argv[@]}"} ;; @@ -2105,9 +2190,8 @@ _acq_msb_exec_flags_into() { # _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 lives under /var/lib/acq -# (root-owned) and is both TESTED and WRITTEN as uid 0, so the gate is -# independent of the install command's own user. +# 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. _acq_msb_exec_install() { local _name="$1" _user="$2" _uflagn="$3" _eflagn="$4" shift 4 @@ -2120,8 +2204,8 @@ _acq_msb_exec_install() { eval "_ef=(\${${_eflagn}[@]+\"\${${_eflagn}[@]}\"})" local marker - marker="/var/lib/acq/install-$(printf '%s\0' "$@" | cksum | cut -d' ' -f1)" - if _acq_msb_cli exec "$_name" -u 0 -- sh -c "test -f '$marker'" /dev/null 2>&1; then + marker="install-$(printf '%s\0' "$@" | cksum | cut -d' ' -f1)" + if acq_host_config_has msb "$_name" "$marker"; then acq_debug "msb cmd[install] already done (marker hit): $*" return 0 fi @@ -2132,7 +2216,7 @@ _acq_msb_exec_install() { return 0 } acq_debug "msb cmd[install] DONE: $*" - _acq_msb_cli exec "$_name" -u 0 -- sh -c "mkdir -p /var/lib/acq && touch '$marker'" /dev/null 2>&1 || true + acq_host_config_write msb "$_name" "$marker" 1 || true } # _acq_msb_exec_run NAME PHASE USER BACKGROUND UFLAG_ARRVAR EFLAG_ARRVAR -- ARGV... @@ -2427,6 +2511,25 @@ _acq_msb_startup_body_into() { fi _acq_msb_own_guard_tokens_into _own_guard_tok _own_env + # 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 + while IFS= read -r _frec; do + [ -n "$_frec" ] || continue + _freadonly=$(printf '%s' "$_frec" | cut -f5) + [ "$_freadonly" = "true" ] || continue + _fpath=$(printf '%s' "$_frec" | cut -f1) + _fsource=$(printf '%s' "$_frec" | cut -f4) + [ -n "$_fpath" ] && [ -n "$_fsource" ] || continue + _fslug=$(printf '%s' "${_fpath#/}" | tr -c 'A-Za-z0-9' '-' | tr -s '-') + _fslug="${_fslug%-}" + _fcrc=$(printf '%s' "$_fpath" | cksum 2>/dev/null | cut -d' ' -f1 2>/dev/null || echo 0) + _ro_from+=("$_fpath") + _ro_to+=("${ACQ_HOST_CONFIG_GUEST_DIR}/kit-files/${_fslug}.${_fcrc}") + done </dev/null || true + chmod 711 "$_hcfg_dir" 2>/dev/null || true + if command -v host_path >/dev/null 2>&1; then + _hcfg_host=$(host_path "$_hcfg_dir") + else + _hcfg_host="$_hcfg_dir" + fi + create_flags+=(--volume "${_hcfg_host}:${ACQ_HOST_CONFIG_GUEST_DIR}:ro") + fi + # Credentials: read from the acq-owned secret store (keychain/file), scoped to # this sandbox first, then global. The real value is read into a TRANSIENT env # var (never argv, never the kit spec) and bound with `msb --secret ENV@HOST`, @@ -3559,6 +3688,10 @@ EOF acq_spin_stop "Waiting for the sandbox to finish booting" acq_debug "msb provision: exec-ready OK ($name)" + # 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" + # 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 @@ -3589,18 +3722,18 @@ EOF # Record which agent this sandbox runs, so acq_backend_attach (which only gets # the sandbox name) knows what to launch — the sbx equivalent is that - # `sbx run --name` re-launches the agent baked in at create. Written as root to - # a fixed guest path; validated charset (KNOWN_AGENTS tokens are word-safe). + # `sbx run --name` re-launches the agent baked in at create. Store the value on + # the host so a passwordless-sudo guest cannot change which binary acq launches. case "$agent" in - *[!a-z-]*) : ;; # defensive: never write an odd token - *) _acq_msb_cli exec "$name" -u 0 -- sh -c "mkdir -p /var/lib/acq && printf '%s' '$agent' > /var/lib/acq/agent" >/dev/null 2>&1 || true ;; + *[!a-z-]*) : ;; # defensive: never record an odd token + *) acq_host_config_write msb "$name" agent "$agent" || true ;; esac # Record the guest workspace path too. attach only gets the sandbox NAME, so it # cannot recompute the host→guest mapping (which now mirrors the host path); # persist it so a name-only re-attach cds into the right place. The path was # validated as an existing host dir above; guard the charset before it enters a - # root sh -c string. + # host config key. if [ -n "$ACQ_MSB_GUEST_WORKSPACE" ]; then case "$ACQ_MSB_GUEST_WORKSPACE" in # Guest-side paths are POSIX (canonicalize_path, ADR-0029), so the @@ -3608,7 +3741,7 @@ EOF *[!A-Za-z0-9._/-]*) acq_debug "msb: not recording unsafe guest workspace path: $ACQ_MSB_GUEST_WORKSPACE" ;; *) - _acq_msb_cli exec "$name" -u 0 -- sh -c "mkdir -p /var/lib/acq && printf '%s' '$ACQ_MSB_GUEST_WORKSPACE' > /var/lib/acq/workspace" >/dev/null 2>&1 || true ;; + acq_host_config_write msb "$name" workspace "$ACQ_MSB_GUEST_WORKSPACE" || true ;; esac fi @@ -3680,8 +3813,8 @@ EOF # _acq_msb_exec_command) with HOME exported. _acq_msb_ensure_agent_user() { local name="$1" - local marker="/var/lib/acq/agent-user-ready" - if _acq_msb_cli exec "$name" -u 0 -- sh -c "test -f '$marker'" >/dev/null 2>&1; then + 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. @@ -3765,7 +3898,7 @@ _acq_msb_ensure_agent_user() { return 1 } _acq_msb_ensure_agent_shell "$name" - _acq_msb_cli exec "$name" -u 0 -- sh -c "mkdir -p /var/lib/acq && touch '$marker'" >/dev/null 2>&1 || true + acq_host_config_write msb "$name" "$marker" 1 || true } # _acq_msb_ensure_agent_shell NAME — align the agent's login shell with what @@ -3979,7 +4112,7 @@ _acq_msb_check_socat() { # ACQ_MSB_SSH_AGENT_GUEST_SOCK. The --vsock route persists across msb stop/start, # but the socat process dies on stop, so this runs on provision AND on # acq_backend_start. Fail-soft: warns, never aborts. Works from EITHER the -# in-provision flag OR the persisted marker (start +# in-provision flag OR the persisted host key (start # has no provision flag set), so the guest sock path is resolved from whichever # source is authoritative for the call. See ADR-0021. _acq_msb_start_ssh_agent_bridge() { @@ -3988,7 +4121,7 @@ _acq_msb_start_ssh_agent_bridge() { _sock="$ACQ_MSB_SSH_AGENT_GUEST_SOCK" else # No provision ran this path (e.g. acq_backend_start): read the persisted - # marker recorded at provision. Empty marker => forwarding not configured. + # key recorded at provision. Empty key => forwarding not configured. _sock=$(_acq_msb_ssh_auth_sock_for "$name") [ -n "$_sock" ] || return 0 fi @@ -4007,11 +4140,9 @@ _acq_msb_start_ssh_agent_bridge() { return 0 } - # Record the guest sock path so attach/exec/start can resolve SSH_AUTH_SOCK - # even when no provision flag is set. Only written when forwarding is active. - _acq_msb_cli exec "$name" -u 0 -- sh -c \ - "mkdir -p /var/lib/acq && printf '%s' '$_sock' > /var/lib/acq/ssh-auth-sock" \ - /dev/null 2>&1 || true + # Keep the guest sock path host-side so attach/exec/start cannot be redirected + # by a passwordless-sudo guest. Only written when forwarding is active. + acq_host_config_write msb "$name" ssh-auth-sock "$_sock" || true acq_debug "msb: ssh-agent bridge started at $_sock (vsock port $_port) in $name" # Liveness probe (ADR-0021): the bridge + marker are now in place, but the @@ -4091,17 +4222,11 @@ _acq_msb_warn_if_agent_unreachable() { # _acq_msb_ssh_auth_sock_for NAME — echo the recorded guest ssh-agent sock path # (the SSH_AUTH_SOCK value git/ssh should use in the guest), or empty when -# forwarding was never configured for this sandbox. Read from the persisted -# marker so run/attach on a name-only re-entry still find it. See ADR-0021. +# forwarding was never configured for this sandbox. Read from host config so +# run/attach on a name-only re-entry still find it. See ADR-0021. _acq_msb_ssh_auth_sock_for() { local name="$1" - # Failure-guarded: with the marker absent (forwarding never configured), the - # in-guest `cat` exits 1 and — under acq's `set -euo pipefail` — the pipeline - # would otherwise propagate that into the caller's command substitution and - # kill the whole session verb (the trailing `tr` does NOT mask it: pipefail - # takes the failing stage's status). - { _acq_msb_cli exec "$name" -u 0 -- sh -c 'cat /var/lib/acq/ssh-auth-sock 2>/dev/null' \ - /dev/null || true; } | tr -d '[:space:]' + acq_host_config_read msb "$name" ssh-auth-sock | tr -d '[:space:]' } _acq_msb_git_identity_env_flags_into() { @@ -4137,24 +4262,20 @@ EOF } # _acq_msb_persisted_kit_env_into ARRVAR NAME — read the kit environment[] entries -# persisted at /var/lib/acq/kit-env by _acq_msb_apply_kit_dir into the array named +# persisted in the host config store by _acq_msb_apply_kit_dir into the array named # ARRVAR as NAME=value tokens (see ADR-0011). Empty array when the marker is # absent or no kit declared environment[]. Array passed by name (bash 3.2 compat). # -# The marker is root-owned but its content is kit-derived guest data: re-validate -# each NAME (same ^[A-Za-z_][A-Za-z0-9_]*$ charset kit_spec_env enforces) so a -# tampered line cannot smuggle an option-shaped or quote-bearing token, and keep -# the LAST value for a duplicate name (kits append in application order, so a -# later kit overrides an earlier one). +# Values remain kit-derived, so re-validate each NAME (same +# ^[A-Za-z_][A-Za-z0-9_]*$ charset kit_spec_env enforces) and keep the LAST value +# for duplicates (kits append in application order, so a later kit overrides). _acq_msb_persisted_kit_env_into() { local _arrn="$1" _name="$2" eval "$_arrn=()" - # Failure-guarded: an absent marker (pre-kit-env sandbox, or no kit declared - # environment[]) must yield an empty result, not kill the session verb — see - # _acq_msb_ssh_auth_sock_for. + # An absent key (pre-kit-env sandbox, or no kit declared environment[]) yields + # an empty result, not a failed session verb. local _kvs - _kvs=$(_acq_msb_cli exec "$_name" -u 0 -- sh -c 'cat /var/lib/acq/kit-env 2>/dev/null' \ - /dev/null) || _kvs="" + _kvs=$(acq_host_config_read msb "$_name" kit-env) [ -n "$_kvs" ] || return 0 local _line while IFS= read -r _line; do @@ -4194,7 +4315,7 @@ _acq_msb_kit_env_flags_into() { # forward on a RUNNING sandbox that acq is re-attaching to. See ADR-0021. # # WHY THIS EXISTS: the provision path wires the forward (emit --vsock, start the -# socat bridge, write the /var/lib/acq/ssh-auth-sock marker), and the +# socat bridge, write the host-authoritative ssh-auth-sock key), and the # stopped→resume path (acq_backend_start) restarts the bridge from that marker. # But re-attaching to an ALREADY-RUNNING sandbox goes through neither: the heal # loop skips acq_backend_start (the sandbox is already running), so nothing @@ -4299,9 +4420,9 @@ _ACQ_MSB_RUN_WS="" acq_backend_recorded_agent() { local name="$1" agent - agent=$(acq_provenance_field msb "$name" agent) + agent=$(acq_host_config_read msb "$name" agent | tr -d '[:space:]') if [ -z "$agent" ]; then - agent=$({ msb exec "$name" -u 0 -- sh -c 'cat /var/lib/acq/agent 2>/dev/null' /dev/null || true; } | tr -d '[:space:]') + agent=$(acq_provenance_field msb "$name" agent) fi if [ -n "$agent" ] && acq_agent_safe_token "$agent"; then printf '%s\n' "$agent" @@ -4310,11 +4431,10 @@ acq_backend_recorded_agent() { # _acq_msb_workspace_for NAME — the guest workspace a session starts in (-w). # Prefer an explicit ACQ_MSB_WORKSPACE override; otherwise the guest path -# recorded at provision (it mirrors the host mount path, so it cannot be -# recomputed from NAME alone); fall back to the agent home if nothing was -# recorded (older sandbox). The marker read is failure-guarded so an absent -# marker takes the documented fallback instead of killing the session verb -# under `set -e` (see _acq_msb_ssh_auth_sock_for). +# recorded at provision in the host config store (it mirrors the host mount path, +# so it cannot be recomputed from NAME alone). A pre-ADR-0035 sandbox lacks that +# key; use its trusted host provenance record before falling back to the agent +# home. Never import the guest marker because a sudo-capable guest can forge it. _acq_msb_workspace_for() { local name="$1" ws="" if [ -n "${ACQ_MSB_WORKSPACE:-}" ]; then @@ -4327,7 +4447,13 @@ _acq_msb_workspace_for() { ws="$ACQ_MSB_WORKSPACE" fi else - ws=$({ _acq_msb_cli exec "$name" -u 0 -- sh -c 'cat /var/lib/acq/workspace 2>/dev/null' /dev/null || true; } | tr -d '\r\n') + ws=$(acq_host_config_read msb "$name" workspace | tr -d '\r\n') + if [ -z "$ws" ]; then + ws=$(acq_workspace_record_read msb "$name" | tr -d '\r\n') + if [ -n "$ws" ] && command -v canonicalize_path >/dev/null 2>&1; then + ws=$(canonicalize_path "$ws") + fi + fi fi [ -n "$ws" ] || ws="/home/agent" printf '%s\n' "$ws" @@ -4455,7 +4581,7 @@ acq_backend_run() { # base image's Node REPL, and the passwd shell isn't exported). # # A bare `acq run ` re-attach (no agent token) reads the agent recorded -# at provision from /var/lib/acq/agent; `shell` (or a missing/failed agent binary) +# at provision from the host-authoritative agent key; `shell` (or a missing/failed agent binary) # falls back to an interactive `/bin/sh -l` as `agent` — never a root shell, never # msb's Node-REPL default. Post-`--` args are forwarded to the agent. acq_backend_attach() { @@ -4485,14 +4611,14 @@ _acq_msb_attach() { local ws ws=$(_acq_msb_workspace_for "$name") - # Read the agent recorded at provision. Default to `shell` if unset. The value - # comes from a guest file (/var/lib/acq/agent); charset-guard it before it - # enters the `sh -c "command -v '$agent'"` below, since a tampered marker could - # otherwise break the single-quoting and run as the agent user. Fall back to a - # plain shell on anything unexpected. + # Read the agent recorded at provision. Default to `shell` if unset. Prefer + # host provenance for pre-ADR-0035 sandboxes, then the host-authoritative config + # store; never import the guest marker. + # Keep the charset guard before it enters the `sh -c "command -v '$agent'"` + # below; fall back to a plain shell on anything unexpected. local agent - agent=$({ _acq_msb_cli exec "$name" -u 0 -- sh -c 'cat /var/lib/acq/agent 2>/dev/null' /dev/null || true; } | tr -d '[:space:]') - if [ -z "$agent" ] || ! acq_agent_safe_token "$agent"; then + agent=$(acq_backend_recorded_agent "$name" | tr -d '[:space:]') + if [ -z "$agent" ] || ! _acq_msb_safe_agent_token "$agent"; then agent="shell" fi @@ -4614,13 +4740,18 @@ acq_backend_terminate() { _acq_msb_clone_warn_unfetched "$1" local _rc=0 _acq_msb_cli remove --force "$1" || _rc=$? - if [ "$_rc" -ne 0 ] && acq_backend_exists "$1"; then - return "$_rc" + if [ "$_rc" -ne 0 ]; then + if acq_backend_exists "$1" || ! _acq_msb_confirm_absent "$1"; then + return "$_rc" + fi fi _acq_msb_remove_derived_volumes "$1" # Same GONE-after-remove-attempt rule as the volumes above: delete the scratch # clone and drop the fetch-back remote only once the sandbox is really gone. _acq_msb_clone_cleanup "$1" + # Remove host-authoritative config only after the sandbox is confirmed gone; + # otherwise a failed removal could discard state still mounted by a live guest. + acq_host_config_remove msb "$1" || true return "$_rc" } diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index b89e45e..1c17444 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -826,6 +826,11 @@ acq_backend_stop() { acq_backend_terminate() { sbx rm --force "$1" + # 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. + acq_host_config_remove sbx "$1" || true } acq_backend_list() { diff --git a/docs/KNOWN_FAILURE_MODES.md b/docs/KNOWN_FAILURE_MODES.md index 1f435b0..f403c49 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-03" +last_updated: "2026-10-08" audience: "developers" keywords: ["debugging", "troubleshooting", "sbx", "usai", "failures"] --- @@ -1644,6 +1644,48 @@ calls against a 0.6.9+ binary, `allow@dns` is safe to use directly. --- +## 32.1. An Existing msb Sandbox Opens a Shell or `/home/agent` After Upgrading acq + +### Symptoms + +After upgrading acq, a name-only `acq run ` against an existing msb +sandbox opens a plain shell instead of its configured agent, or starts in +`/home/agent` rather than the mounted workspace. The first resume may also +re-run idempotent kit install and agent-user setup steps. + +### Root Cause + +ADR-0035 moves session configuration and run-once gates from guest-writable +`/var/lib/acq` markers to the host-authoritative config store. Sandboxes created +before that migration have no host-config keys. acq must not import their old +guest markers because a passwordless-sudo agent can forge those values. + +### Fix + +Current acq releases recover the agent and workspace from their existing trusted +host provenance record when the new host-config keys are absent. The regular +`acq run`, `acq start`, and `acq restart` heal also reapplies the effective kit +set, rebuilding kit environment entries and safely re-running missing +idempotency gates once. SSH-agent forwarding is re-established during the same +heal when configured. + +If the provenance record is missing or the replay fails, recreate the sandbox +from its workspace: + +```bash +acq rm +acq run +``` + +### Prevention / Status + +- Fixed without trusting legacy guest state. A one-time idempotent replay after + upgrade is expected for a sandbox that predates the host-config migration. +- Keep work in the mounted workspace committed or backed up before recreating a + sandbox; recreation discards guest-local state. + +--- + ## 33. `--kit` Services Dead After a Resume/Reboot on msb — Ports Mapped, Nothing Listening ### Symptoms @@ -1847,11 +1889,12 @@ sandbox, and it starts working again as soon as you `export SSH_AUTH_SOCK=/home/agent/.acq/ssh-agent.sock` by hand. Here the vsock route and the `socat` bridge are fine — what is missing is the **`SSH_AUTH_SOCK` env var in the agent's process**. acq injects that var -(`-e SSH_AUTH_SOCK=…`) only when the persisted `/var/lib/acq/ssh-auth-sock` -marker is present, and before the fix nothing re-established the bridge or wrote -that marker when re-attaching to an already-running sandbox (the heal's -start-if-stopped block is a no-op on a running sandbox, and only that path — or -provision — wrote the marker). +(`-e SSH_AUTH_SOCK=…`) only when the persisted ssh-agent sock value is present +(recorded host-side in acq's per-sandbox config store, ADR-0035; originally a +guest `/var/lib/acq/ssh-auth-sock` marker), and before the fix nothing +re-established the bridge or wrote that value when re-attaching to an +already-running sandbox (the heal's start-if-stopped block is a no-op on a +running sandbox, and only that path — or provision — wrote it). Fixed in `acq.backends/msb.sh`: `acq_backend_ensure_kits_applied` now calls `_acq_msb_ensure_ssh_agent_forward` at the top of the heal, which re-drives the diff --git a/docs/adr/0011-msb-backend-and-neutral-kits.md b/docs/adr/0011-msb-backend-and-neutral-kits.md index d3e8307..913e389 100644 --- a/docs/adr/0011-msb-backend-and-neutral-kits.md +++ b/docs/adr/0011-msb-backend-and-neutral-kits.md @@ -247,7 +247,8 @@ for `docker/sandbox-templates:shell-docker`: `msb exec -t -u agent -w -e SHELL=/bin/sh NAME -- ` — the one msb primitive that allocates a PTY (so a full-screen agent TUI renders; `msb ssh` has no tty flag), runs as the unprivileged `agent` user, and starts in the - workspace. The agent to run is recorded at provision (`/var/lib/acq/agent`) so + workspace. The agent to run is recorded at provision in host-authoritative + config (ADR-0035) so the name-only re-attach path (`acq run `) still launches it. Falls back to an interactive `/bin/sh -l` **as the agent user** (never root, never msb's Node-REPL default) for `shell` sandboxes or a missing binary. @@ -284,7 +285,8 @@ semantics (`docs/QUICKSTART_SBX.md`: "All workspaces appear inside the sandbox a their absolute host paths"). This sidesteps the create-time ordering problem and restores sbx parity. Multiple workspaces and a trailing `:ro` marker are supported (`workspace_paths` in `common.sh` enumerates them). The agent's -starting directory is recorded at provision (`/var/lib/acq/workspace`) for the + starting directory is recorded at provision in host-authoritative config + (ADR-0035) for the name-only re-attach path: it is the **primary (first) workspace**, matching sbx. `ACQ_MSB_WORKSPACE` overrides the start dir. @@ -403,13 +405,11 @@ strings). The translate layer: > **Update (2026-08-26):** The command-only scoping above dropped exactly the > agent-runtime config (`OPENCODE_CONFIG`-style vars) this vocabulary was -> motivated by. The msb adapter now also persists the validated entries to a -> root-owned guest marker (`/var/lib/acq/kit-env`, the same pattern as -> `/var/lib/acq/agent` and `/var/lib/acq/ssh-auth-sock`) and replays them as -> `-e` flags on every session path (attach, `acq exec`, `acq shell`), matching -> sbx's sandbox-level env semantics. Names are re-validated on replay (tampered -> marker defense) and the last value wins for a duplicate name (kits append in -> application order, so a later kit overrides an earlier one). +> motivated by. The msb adapter now also persists the validated entries and +> replays them as `-e` flags on every session path (attach, `acq exec`, +> `acq shell`), matching sbx's sandbox-level env semantics. Names are +> re-validated on replay and the last value wins for a duplicate name (kits +> append in application order, so a later kit overrides an earlier one). > > **Update (2026-09-29):** The per-kit scoping of the *command* env above is also > gone. Widening only the session paths left a gap wherever a kit command @@ -428,6 +428,15 @@ strings). The translate layer: > the same name with a different value, which the merge would otherwise resolve in > the later kit's favor). See > [ADR-0033](0033-msb-kit-env-is-guest-wide-for-lifecycle-commands.md). +> +> **Superseded storage (ADR-0035):** the persisted values described in this ADR +> (`agent`, `workspace`, `ssh-auth-sock`, `kit-env`, and the run-once gate +> markers) were originally written to root-owned **guest** files under +> `/var/lib/acq/`. Because the in-sandbox agent has passwordless sudo, a guest +> path is not tamper-proof against a prompt-injected agent. ADR-0035 moves all of +> these to acq's **host-authoritative** per-sandbox config store (mounted into +> the guest read-only where the guest must read them). The behavior described +> here is unchanged; only the storage location and its trust boundary moved. Deliberately minimal (YAGNI): **static string values only** — no interpolation, no references to `files[]`-staged paths (a kit needing a computed value uses a diff --git a/docs/adr/0021-msb-host-ssh-agent-forwarding-via-vsock.md b/docs/adr/0021-msb-host-ssh-agent-forwarding-via-vsock.md index 0c95911..9ef4571 100644 --- a/docs/adr/0021-msb-host-ssh-agent-forwarding-via-vsock.md +++ b/docs/adr/0021-msb-host-ssh-agent-forwarding-via-vsock.md @@ -130,15 +130,15 @@ re-started in `acq_backend_start` before kit startup services are healed. The guest-side `SSH_AUTH_SOCK` value is injected into the guest process env only when acq passes `-e SSH_AUTH_SOCK=` to `msb exec` on attach/`acq exec`/kit -commands, and acq only does that when the persisted -`/var/lib/acq/ssh-auth-sock` marker is non-empty. That marker is written solely -by `_acq_msb_start_ssh_agent_bridge`. Provision runs the bridge starter directly, +commands, and acq only does that when the host-authoritative `ssh-auth-sock` +config key is non-empty. That key is written solely by +`_acq_msb_start_ssh_agent_bridge`. Provision runs the bridge starter directly, and the **stopped→resume** path runs it via `acq_backend_start`. A re-attach to an **already-running** sandbox (`acq run ` / `acq run .` against an existing, running sandbox) previously reached **neither**: the heal's start-if-stopped block is a no-op on a running sandbox, so -nothing re-started the bridge or wrote the marker. The result was that the guest +nothing re-started the bridge or refreshed the key. The result was that the guest process env had **no `SSH_AUTH_SOCK`** on re-attach even though the create-time `--vsock` route was present and the host agent was available — the operator had to `export SSH_AUTH_SOCK=/home/agent/.acq/ssh-agent.sock` by hand before signing. diff --git a/docs/adr/0027-neutral-clone-option.md b/docs/adr/0027-neutral-clone-option.md index d88b653..af6e753 100644 --- a/docs/adr/0027-neutral-clone-option.md +++ b/docs/adr/0027-neutral-clone-option.md @@ -197,7 +197,7 @@ with kits that silently do nothing. The two facts are kept separate so a non-clone kit gets a neutral workspace path for free, and because the workspace path alone must never be read as a clone signal: msb already records the primary's path at -`/var/lib/acq/workspace` (root-written, for name-only re-attach) on every +the host-authoritative workspace config key (for name-only re-attach) on every create, clone or not, and a kit that keyed on it wrote its files into a real checkout through a passthrough mount. A marker file inside the scratch was also rejected: it needs a `.git/info/exclude` entry and a post-create write on diff --git a/docs/adr/0029-windows-host-guest-path-forms.md b/docs/adr/0029-windows-host-guest-path-forms.md index 8181dc6..2003cb3 100644 --- a/docs/adr/0029-windows-host-guest-path-forms.md +++ b/docs/adr/0029-windows-host-guest-path-forms.md @@ -25,8 +25,8 @@ takes `HOST:GUEST[:ro]`: the host side is resolved by native `msb.exe` on the Windows filesystem (`C:/Users/me/proj`), and the guest side is the path inside the Linux microVM, which must be POSIX (`/c/Users/me/proj`, or any absolute `/...`). `C:/...` is **not** an absolute path in the guest. The same split -applies to the start directory (`msb exec -w`), the recorded -`/var/lib/acq/workspace` marker, and the `ACQ_WORKSPACE` create env +applies to the start directory (`msb exec -w`), the host-authoritative recorded +workspace value (ADR-0035), and the `ACQ_WORKSPACE` create env ([ADR-0027](0027-neutral-clone-option.md)). An earlier revision passed one `cygpath -m` value for both sides, which fixed the host side and broke the guest (GSA-TTS/agentic-coding-quickstart#463). diff --git a/docs/adr/0033-msb-kit-env-is-guest-wide-for-lifecycle-commands.md b/docs/adr/0033-msb-kit-env-is-guest-wide-for-lifecycle-commands.md index 0f812d2..4162edf 100644 --- a/docs/adr/0033-msb-kit-env-is-guest-wide-for-lifecycle-commands.md +++ b/docs/adr/0033-msb-kit-env-is-guest-wide-for-lifecycle-commands.md @@ -17,8 +17,8 @@ supersedes: [] On msb, a kit's lifecycle commands (`install`, `initFiles`, `startup`) ran with only **that kit's own** `environment[]`. Sessions were already whole-guest — acq replays -the merged `/var/lib/acq/kit-env` marker on `run`/`attach`/`shell` (ADR-0011's -2026-08-26 update) — but commands were not. +the merged host-authoritative `kit-env` value on `run`/`attach`/`shell` +(ADR-0035) — but commands were not. That split is invisible until a kit's command outlives itself. A daemon kit starts its supervisor from a `background: true` startup command, so every agent that daemon spawns @@ -27,7 +27,7 @@ plus a default-deny permission layer) was silently absent from those agents whil injected credentials were still present — a quiet loss of a policy control, and sessions still looked correct, so smoke tests passed. -**The order-independence trap.** The marker is appended per kit *inside the same loop +**The order-independence trap.** The value is appended per kit *inside the same loop that runs that kit's commands*, so "read the merged marker when running commands" is correct only when the env-declaring kit happens to be applied first, and silently wrong in the reverse order. Any fix must establish the merged set before the first diff --git a/docs/adr/0035-host-authoritative-sandbox-config.md b/docs/adr/0035-host-authoritative-sandbox-config.md new file mode 100644 index 0000000..f864257 --- /dev/null +++ b/docs/adr/0035-host-authoritative-sandbox-config.md @@ -0,0 +1,355 @@ +--- +title: "Host-Authoritative Sandbox Configuration (no trust in guest-generated state)" +status: accepted +date: 2026-09-21 +decision_makers: ["Bret Mogilefsky"] +category: architecture +nist_controls: ["AC-3", "AC-6", "CM-5", "CM-6", "SC-7", "SI-7", "SI-10", "SI-17"] +impact_level: moderate +ato_relevance: no +risk_treatment: mitigate +supersedes: [] +--- + +# ADR-0035: Host-Authoritative Sandbox Configuration (no trust in guest-generated state) + +## Context and Problem Statement + +acq deliberately gives the in-sandbox `agent` user **passwordless sudo** (see +`AGENTS.md`, and the msb agent-user provisioning in +`acq.backends/msb.sh`: `/etc/sudoers.d/90-acq-agent … NOPASSWD:ALL`). This is an +intentional convenience: the sandbox — not the guest OS user boundary — is the +security boundary (ADR-0005, ADR-0011). A consequence follows that has not been +stated as a principle until now: + +> **Inside the guest, root-ownership is not a trust boundary against the agent.** +> A prompt-injected agent can `sudo` to root and rewrite any file in the guest, +> including anything under a root-owned path such as `/var/lib/acq/`. + +acq currently violates the safe form of this in two ways: + +1. **acq reads guest-written state back and acts on it.** Four content-bearing + markers under the guest path `/var/lib/acq/` are written by acq (via + `msb exec -u 0`), then read back on a later session/restart and used to + *configure the sandbox*: + + | Marker | Written (host fn) | Read back → effect | + |---|---|---| + | `agent` | `acq_backend_provision` | selects which binary the re-attach launches | + | `workspace` | `acq_backend_provision` | sets the session working directory (`-w`) | + | `ssh-auth-sock` | ssh-agent bridge starter | sets `-e SSH_AUTH_SOCK=…` for git/ssh signing | + | `kit-env` | `_acq_msb_apply_kit_dir` | replays kit `environment[]` as `-e NAME=value` on every session | + + Each read is charset/token-hardened against *shell injection* (e.g. + `_acq_msb_safe_agent_token`, the `kit-env` awk name re-validation), so a + tampered marker cannot break out of an `sh -c`. But the hardening does not stop + a sudo-capable guest from setting a **valid-but-malicious value**: redirect + `SSH_AUTH_SOCK`, change the launched agent to another allowed token, change the + cwd, or inject arbitrary environment variables into the agent process. The + guest can influence its own host-driven reconfiguration. + +2. **acq re-runs guest-resident kit code across a restart.** On msb, kit + `files[]` (including startup scripts and config-merge programs such as the + provider kits' `merge-*-config.mjs`) are copied *into the guest* at provision + and then `chown`ed to the agent. acq's restart heal + (`acq_backend_ensure_kits_applied`) re-runs the `startup` phase by invoking + those **guest-resident** files. A sudo-capable agent can overwrite a startup + script or normalizer *between* provision and the next restart; the heal then + executes the tampered code. This is the general form of the + "provider-facts file is root-owned, therefore safe" error flagged during + review of the `agentic-coding-patterns` neutral model-provider discovery work + — root-ownership is not the barrier it appears to be. + +A concrete instance motivating this ADR: the neutral model-provider discovery +design (patterns ADR 0003) originally had each provider kit **write** a facts +file to a root-owned guest path in its own `startup` command, and claimed that +path was "not agent-writable." Under passwordless sudo it is. A planted or +tampered facts file would let the guest steer model routing (and, absent the +host-side validation this ADR requires, an SSRF/credential-misuse surface). + +### Presence-only gates (a lower-severity variant of the same problem) + +Three other `/var/lib/acq/` files are `touch`/`test -f` idempotency gates +(`install-`, `agent-installed-`, `agent-user-ready`). +Their *content* is never consumed, but a guest can pre-`touch` them to +**suppress** a provisioning step (skip an install, skip the agent-user/sudoers +setup, skip OCI setup). That is a downgrade/DoS on setup rather than injection, +but it is the same root cause: acq relies on guest-resident state to make a +provisioning decision. + +## Decision Drivers + +- **The only enforceable boundary is host↔guest.** A value acq holds on the host + (in memory, or in a host file the guest cannot write) is beyond the reach of a + sudo-capable guest; a value living in the guest is not. +- **Least privilege / integrity** (AC-6, SI-7): the sandbox's configuration must + not be forgeable by the workload it sandboxes. +- **Preserve the sandbox-is-the-boundary model** (ADR-0005/0011): this ADR does + **not** remove passwordless sudo. It removes acq's *reliance on* guest-writable + state for configuration, which is orthogonal. +- **No code execution on the host** (the deciding factor that rejected patterns + ADR 0003's "Option 2"): acq may read *static data* from a pinned kit directory + on the host, but must not *execute* kit-shipped code on the host, next to real + credentials. +- **Backend parity and Windows path forms**: any new mount must work on both + msb (`--volume host:guest:ro`) and sbx (`:ro` positional), and respect the + host/guest path-form split (ADR-0029). +- **Fail-soft preservation**: the current marker writes are best-effort + (`|| true`); the migration must not turn a transient state-dir hiccup into a + hard provision abort where it was previously survivable. + +## The principle (normative) + +> **acq MUST NOT rely on guest-generated or guest-tamperable state to configure +> the sandbox.** Every input acq trusts to make a configuration or +> 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 +> - **(H2) Held on the host and supplied at exec time** from acq's own state +> (never read back from the guest). +> +> The guest may freely write its own genuinely-owned read-write state (the +> workspace, a per-sandbox cache, kit scratch) — acq simply must not read that +> state back and treat it as authoritative configuration. + +## Considered Options (trusted startup execution) + +The data case (H1/H2) is straightforward. The harder case is **running trusted +code in the guest at startup** — acq's restart heal must invoke a kit's +`startup`/normalizer logic without trusting a guest-resident, agent-tamperable +copy of it. Three options were considered: + +1. **Mount the kit's executable payload read-only and invoke it from the `:ro` + path.** acq stages the pinned kit's startup scripts/normalizers from the + host-side kit directory it already fetched into a per-sandbox host config + dir, mounts that dir `:ro` into the guest, and the heal executes + `sh /var/lib/acq/host//