diff --git a/acq.backends/common.sh b/acq.backends/common.sh index aa798e3e..215e0c13 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -2249,9 +2249,10 @@ 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 -# re-applies all kits idempotently; the force flag is a no-op there. After a usai +# 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 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 @@ -4894,7 +4895,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 "$dir"/ro-host-config 2>/dev/null || true return 0 } diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 9041f6b3..f7612256 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -172,6 +172,16 @@ MSB_ROLLBACK_VERSION="0.6.18" # duplicate snapshots exist. See ADR-0032. MSB_FIXED_VERSION="0.7.3" +# 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 # agent template. It ALREADY ships the non-root `agent` user (with passwordless @@ -1845,8 +1855,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 - _acq_msb_host_config_mount_available "$name" && _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) @@ -1859,19 +1871,43 @@ 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 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 49." >&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 + 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 _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; fix the host state directory and re-run, or recreate the sandbox." >&2 + return 1 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 + 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 @@ -1932,13 +1968,215 @@ _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 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_ok() { + local _v + _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 + [ "$(_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 +} + +# Whether sandbox NAME's trusted :ro host-config mount may be relied on for +# readonly kit code. Return codes: +# 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 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 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_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 ;; + 2) return 2 ;; + *) return 3 ;; + esac +} + +# 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 \ "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 } @@ -2322,8 +2560,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 @@ -2337,7 +2579,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 @@ -2645,9 +2887,17 @@ _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_ok && _acq_msb_ro_mount_platform_ok && _ro_ok=1 while IFS= read -r _frec; do [ -n "$_frec" ] || continue + # 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 _fpath=$(printf '%s' "$_frec" | cut -f1) @@ -3883,6 +4133,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 @@ -3911,9 +4167,13 @@ 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: 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 # Verify the kits' runtime prerequisites are present in the base image # (node/git/curl/update-ca-certificates). We do NOT install them: the kit @@ -4046,12 +4306,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 @@ -4069,14 +4329,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 @@ -4154,7 +4411,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 @@ -4171,8 +4427,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() { @@ -5743,7 +5999,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() { @@ -5783,10 +6040,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/acq.backends/sbx.sh b/acq.backends/sbx.sh index aca175c0..87c9a981 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -750,6 +750,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'" @@ -761,7 +768,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 @@ -783,7 +789,6 @@ _acq_backend_provision_unlocked() { fi fi acq_workspace_record_write sbx "$name" "$_primary_ws" || true - _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 @@ -828,7 +833,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 @@ -854,6 +859,74 @@ _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 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}" + # The selected provider kit, as the heal resolves it (first selected ref). An empty + # selection records nothing; a selection that cannot be resolved records nothing, so + # a later heal re-checks rather than trusting a record acq could not form. + local _prov_refs _prov_ref + if _prov_refs=$(_acq_selected_provider_kit_refs 2>/dev/null); then + _prov_ref=$(printf '%s\n' "$_prov_refs" | head -n1) + [ -z "$_prov_ref" ] || records="${records}"$'\n'"${USAI_PROVIDER_KIT_NAME} ${_prov_ref}" + 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 # --------------------------------------------------------------------------- @@ -1240,10 +1313,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). @@ -1266,7 +1340,7 @@ 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 provider_local="" playbook_local zscaler_local + 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. @@ -1287,21 +1361,25 @@ 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 fi - # 2) Default provider kit + # 2) Default provider kit. The kit is DATA (the selected provider ref), and whether + # it needs applying is decided from the host `builtin-kits` record, never from a + # guest probe. The record slot keeps the historical USAi kit name as its key so + # records written by earlier acq versions still match; its ref is the selected + # provider ref, so changing the provider kit re-applies it. if [ -n "$provider_ref" ] \ - && { [ "$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" "$provider_ref" "test -f '$USAI_KIT_CONFIG_PATH' && echo present"; then 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 @@ -1310,6 +1388,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" "$provider_ref" echo "acq: provider kit injected into '$name'." >&2 ;; 3) _acq_sbx_print_recreate_notice "$name"; ok=0 ;; @@ -1323,11 +1402,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 ed79159c..df98c664 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -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. 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). | +| 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): @@ -2957,8 +2992,6 @@ 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. `acq kit-source rm` Refuses to Finish (Backend Allowlist Still Permits the Owner) ### Symptoms @@ -2992,6 +3025,166 @@ and the read/write/`jq` failure paths. --- +## 47. 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. +- **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 + +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. + +--- + +## 48. SBX Startup Barrier Is a Guest File Check (Accepted Residual Risk) + +**Status:** Accepted / observed for the barrier itself. No host-authoritative +startup signal exists; the barrier no longer decides the kit records. + +### Observation + +After `sbx create`, acq waits for `/tmp/acq/startup-complete` inside the sandbox +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. + +### 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 + +- **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 the SBX trusted-startup parity + ADR (`docs/adr/0037-sbx-trusted-startup-parity.md`). + +### 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. + +--- + +## 49. 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. + +### Symptoms + +```text +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. +``` + +`` 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`. 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 + +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 + +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. + +--- + ## Contributing When you discover a new failure mode: 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/docs/adr/0035-host-authoritative-sandbox-config.md b/docs/adr/0035-host-authoritative-sandbox-config.md index f8642574..6e545381 100644 --- a/docs/adr/0035-host-authoritative-sandbox-config.md +++ b/docs/adr/0035-host-authoritative-sandbox-config.md @@ -105,7 +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), 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). > @@ -164,15 +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: 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 - 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. + + **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 (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 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" 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, 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 @@ -199,8 +270,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. @@ -241,6 +312,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 + @@ -293,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 (FAILS: read-only mount, VMM-enforced) + 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 @@ -336,8 +420,85 @@ 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 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 (`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 | +| 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 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 47 and 48) | 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`; 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 +proposed separately in [ADR-0037](0037-sbx-trusted-startup-parity.md). + +## Corroborating evidence (msb read-only host-config mount canary) + +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 + +| 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 the canary +judges from the host. + +**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; 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 +- 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 diff --git a/docs/adr/0037-sbx-trusted-startup-parity.md b/docs/adr/0037-sbx-trusted-startup-parity.md new file mode 100644 index 00000000..4fd0faac --- /dev/null +++ b/docs/adr/0037-sbx-trusted-startup-parity.md @@ -0,0 +1,233 @@ +--- +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-0037: 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. 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`, +`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 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 (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. + +**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-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 + +- **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 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. +- 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. 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. 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 + 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` (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`, + `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), 47 (msb session + shell), and 48 (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. diff --git a/scripts/qualify-msb-ro-mount b/scripts/qualify-msb-ro-mount new file mode 100644 index 00000000..a07b9300 --- /dev/null +++ b/scripts/qualify-msb-ro-mount @@ -0,0 +1,288 @@ +#!/usr/bin/env bash +# 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 +# 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, 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= +# 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 +# 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. +set -uo pipefail + +STEP_TIMEOUT="${STEP_TIMEOUT:-600}" +EXEC_TIMEOUT="${EXEC_TIMEOUT:-90}" +IMAGE="${QUALIFY_IMAGE:-docker.io/docker/sandbox-templates:shell-docker}" +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +REPO=$(cd "$HERE/.." && pwd -P) + +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. +_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 + 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 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}" + say "image : $IMAGE" + say "host : $(uname -srm 2>/dev/null || echo unknown)" + say "revision : ${REVISION:-unknown}" + say "detail : $detail" + 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 "" + 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 "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 "" + 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 || 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 + +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" + +# 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; } +# 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". +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" +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 + +# --- 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 +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") >"$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 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/scripts/test-acq-lib.sh b/scripts/test-acq-lib.sh index 4c58adcb..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 @@ -523,7 +572,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/scripts/verify-backends b/scripts/verify-backends index 14ecc4a3..b5943f80 100755 --- a/scripts/verify-backends +++ b/scripts/verify-backends @@ -995,7 +995,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..80fe9ebe 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' @@ -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/79-msb-readonly-kit-files.bats b/test/bats/79-msb-readonly-kit-files.bats index 446fb72e..c289ba41 100644 --- a/test/bats/79-msb-readonly-kit-files.bats +++ b/test/bats/79-msb-readonly-kit-files.bats @@ -17,6 +17,11 @@ # the path is left alone). # # shellcheck shell=bats +# 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; } @@ -91,6 +96,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + 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 @@ -123,6 +129,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + 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 @@ -147,6 +154,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + 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 ' @@ -158,10 +166,10 @@ 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" { +@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 ' @@ -169,6 +177,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + 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 @@ -176,7 +185,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" { @@ -187,6 +196,7 @@ SPEC . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" . "'"$REPO_ROOT"'/acq.backends/msb.sh" + 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/81-msb-forged-guest-markers.bats b/test/bats/81-msb-forged-guest-markers.bats new file mode 100644 index 00000000..88b949aa --- /dev/null +++ b/test/bats/81-msb-forged-guest-markers.bats @@ -0,0 +1,336 @@ +#!/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 +} + +@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" { + _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" + # 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 +} + +# 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") + 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 new file mode 100644 index 00000000..468104cc --- /dev/null +++ b/test/bats/82-msb-legacy-sandbox.bats @@ -0,0 +1,93 @@ +#!/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 gate replays once. The +# agent-user setup has no gate: it is re-verified on every heal. +# +# 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" ] +} + +# 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" "2" + run bash -c 'ls "'"$ACQ_PROVENANCE_DIR"'"/msb/legacyuserbox.*.config/agent-user-ready 2>&1' + assert_failure +} 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..76eb95fa --- /dev/null +++ b/test/bats/83-msb-ro-host-config-record.bats @@ -0,0 +1,282 @@ +#!/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. 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 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). +# 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 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; } + +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" + 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 + ' +} + +# 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_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") + refute_regex "$log" 'node ' + refute_regex "$log" 'msb copy .*recmissing:/home/agent/cfg/merge\.mjs' + run bash -c 'ls "'"$ACQ_PROVENANCE_DIR"'"/msb/recmissing.*.config/kit-files 2>&1' + assert_failure +} + +@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_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") + refute_regex "$log" 'node ' + refute_regex "$log" 'msb copy .*recwritable:/home/agent/cfg/merge\.mjs' + 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" { + local k="$STUBDIR/k3"; _ro_kit "$k" + _apply recquiet "$k" 1 + assert_success + refute_output --partial 'host-config mount but a guest probe' + 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 ADR-0035 read-only host-config record' +} + +@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" + 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 + ' + assert_success + refute_regex "$(cat "$CALLS")" '/var/lib/acq/host/kit-files' +} + +@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" + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + 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 + ' + 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" + 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 + ' + 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: 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" + . "'"$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 +} + +# 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" + 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 + _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" ] +} + +# 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" + 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 + _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' +} 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..814b3313 --- /dev/null +++ b/test/bats/84-msb-ro-mount-qualification.bats @@ -0,0 +1,531 @@ +#!/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 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 + +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. 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"'" + 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 + acq_host_config_write msb "'"$name"'" ro-host-config 1 + _acq_msb_apply_kit_dir "'"$name"'" "'"$kit"'" 2>&1 + ' +} + +_assert_refused() { # NAME [MESSAGE_FRAGMENT] + assert_failure + assert_output --partial "'/home/agent/cfg/merge.mjs'" + 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 .*$1:/home/agent/cfg/merge\\.mjs" + run bash -c 'ls "'"$ACQ_PROVENANCE_DIR"'"/msb/'"$1"'.*.config/kit-files 2>&1' + assert_failure +} + +@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 "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 .*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 "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-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 + _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 +} + +@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 'below acq' + refute_regex "$(cat "$CALLS")" 'node ' + _gate garbled "$k" STUB_MSB_VERSION=banana + assert_failure + refute_regex "$(cat "$CALLS")" 'node ' +} + +@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 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 norecordgate "'"$k"'" 2>&1 + ' + assert_success + 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 "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.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 + _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" + 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 + exit 0 ;; +esac +exit 0 +STUB + chmod +x "$STUBDIR/canary-bin/msb" +} + +_run_canary() { # [ENV...] + 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" +} + +@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 'corroborating evidence' + [ -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' + [ -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" 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' +} + +@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 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 'will not rely on the read-only mount' +} + +# --- 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" +} diff --git a/test/bats/90-sbx-startup-kit.bats b/test/bats/90-sbx-startup-kit.bats index 44a5397f..4f6b2293 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,194 @@ 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 provider 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 provider 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 provider 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 provider 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): 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" + ( + 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