Skip to content
429 changes: 319 additions & 110 deletions acq.backends/msb.sh

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion docs/BACKEND_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1071,7 +1071,13 @@ The neutral vocabulary is: `caps.network.allow`, `files[]`, `commands[]`,
`OPENCODE_TUI_CONFIG`, `GITLAB_HOST`). Names must be POSIX identifiers
(`^[A-Za-z_][A-Za-z0-9_]*$`; an invalid name is dropped with a warning and
reported by `acq kit validate`); values are plain strings. It maps to sbx-v2
`environment.variables` (synthesized) and to `msb exec -e NAME=value` (per-exec).
`environment.variables` (synthesized) and, on msb, to `msb exec -e NAME=value`.
On both backends these variables are **guest-wide**, not per kit: msb threads the
**merged** set from every applied kit onto every kit's lifecycle commands and
replays the same set on `acq exec`/`acq shell`/attach, so a daemon started by one
kit's `background: true` startup command still sees another kit's config
(last-value-wins for a duplicate name — see
[ADR-0033](adr/0033-msb-kit-env-is-guest-wide-for-lifecycle-commands.md)).
**Secrets do NOT go here** — use the credential/secret path (`acq secret …`);
the kit spec never carries a secret value.

Expand Down
18 changes: 18 additions & 0 deletions docs/adr/0011-msb-backend-and-neutral-kits.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,24 @@ strings). The translate layer:
> sbx's sandbox-level env semantics. Names are re-validated on replay (tampered
> marker defense) and the last value wins for a duplicate name (kits append in
> application order, so a later kit overrides an earlier one).
>
> **Update (2026-09-29):** The per-kit scoping of the *command* env above is also
> gone. Widening only the session paths left a gap wherever a kit command
> outlives itself: a `background: true` startup daemon launches agents, and those
> agents inherited only the daemon kit's vars, so another kit's agent
> configuration (including a permission layer) was silently absent. Kit
> `environment[]` is now **guest-wide for kit lifecycle commands too** — every
> `install`/`initFiles`/`startup` command runs with the merged env of the full
> effective kit set, last-value-wins, the same resolution sessions replay. The
> merged set is computed in a **pre-pass over the whole kit set** (so it does not
> depend on kit application order), with the persisted marker as the fallback for
> the mid-life single-kit `acq kit apply` path. The adapter's non-interactive git
> guards stay **owned by the kit whose command is running**: a command receives
> only its own kit's value for a guard name, so another kit can neither disable
> them (by declaring a guard name this kit does not) nor change them (by declaring
> the same name with a different value, which the merge would otherwise resolve in
> the later kit's favor). See
> [ADR-0033](0033-msb-kit-env-is-guest-wide-for-lifecycle-commands.md).

Deliberately minimal (YAGNI): **static string values only** — no interpolation,
no references to `files[]`-staged paths (a kit needing a computed value uses a
Expand Down
21 changes: 21 additions & 0 deletions docs/adr/0017-msb-create-time-startup-script-staging.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,27 @@ re-passed on the same invocation stays authoritative. This is consistent with th
above: the verb now restores the *full* kit set, not just the built-ins. See
`docs/KNOWN_FAILURE_MODES.md` §33.

## Update (2026-09-29): the staged body carries the merged, guest-wide kit env

Kit `environment[]` became guest-wide for **all** kit lifecycle commands
([ADR-0033](0033-msb-kit-env-is-guest-wide-for-lifecycle-commands.md)): each
command runs with the merged env of the full effective kit set rather than only
its own kit's vars. The generated `--script-path` body now emits the same merged
`env NAME=value …` prefix, so the staged path and the exec path cannot drift.

Two notes specific to this ADR:

- The staged body is still **inert at boot** (a bare `--script-path` registration
stages the script on the guest PATH; microsandbox does not auto-run it), so this
change fixes no live symptom here. It exists so the re-verification trap above —
a future msb that *does* auto-run a registered script — cannot resurface the
wrong-env bug in a path nobody re-checked.
- The staging call moved **out of** the kit fetch loop in
`acq_backend_provision` into a second loop over the same kit dirs, because the
merged env is only knowable once every kit has been fetched. The
first-kit-with-startup-commands-stakes-the-script-name rule is unchanged
(same list, same order).

---

# ADR-0017: Stage msb kit startup commands as a create-time script for restart durability (original)
Expand Down
117 changes: 117 additions & 0 deletions docs/adr/0033-msb-kit-env-is-guest-wide-for-lifecycle-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
---
title: "msb kit environment[] is guest-wide for kit lifecycle commands"
status: accepted
date: 2026-09-29
decision_makers: ["Bret Mogilefsky"]
category: architecture
nist_controls: ["AC-3", "AC-6", "CM-2", "CM-6", "CM-7", "SA-8", "SA-15", "SI-10"]
impact_level: low
ato_relevance: no
risk_treatment: mitigate
supersedes: []
---

# ADR-0033: msb kit `environment[]` is guest-wide for kit lifecycle commands

## Context and Problem Statement

On msb, a kit's lifecycle commands (`install`, `initFiles`, `startup`) ran with only
**that kit's own** `environment[]`. Sessions were already whole-guest — acq replays
the merged `/var/lib/acq/kit-env` marker on `run`/`attach`/`shell` (ADR-0011's
2026-08-26 update) — but commands were not.

That split is invisible until a kit's command outlives itself. A daemon kit starts its
supervisor from a `background: true` startup command, so every agent that daemon spawns
inherits only the daemon kit's env: another kit's `OPENCODE_CONFIG` (agent instructions
plus a default-deny permission layer) was silently absent from those agents while the
injected credentials were still present — a quiet loss of a policy control, and
sessions still looked correct, so smoke tests passed.

**The order-independence trap.** The marker is appended per kit *inside the same loop
that runs that kit's commands*, so "read the merged marker when running commands" is
correct only when the env-declaring kit happens to be applied first, and silently
wrong in the reverse order. Any fix must establish the merged set before the first
kit is applied.

## Decision Drivers

- A kit must not silently drop another kit's policy config from the env of agents it
launches, and the result must not depend on kit application order (AC-3, AC-6, CM-6).
- One kit must not weaken another kit's hardening: the non-interactive git guards are
per-kit safety, not shared config (CM-7).
- Kits must not each have to source a marker file; the adapter owns the threading.

## Considered Options

**Pre-pass over the full kit set — chosen.** A **per-kit marker read** is
order-dependent by construction (see the trap above), fixing the reported case in one
kit order only. Having each kit source the marker itself is opt-in — a kit that
forgets is silently broken — and pushes an adapter concern into every kit.

## Decision Outcome

Kit `environment[]` is **guest-wide configuration**: every kit lifecycle command runs
with the merged env of the full effective kit set, last-value-wins — the same
resolution sessions replay.

| Path | Source of the merged env |
|------|--------------------------|
| `acq_backend_provision` (create) | pre-pass over every fetched kit spec, before the first apply |
| `acq_backend_ensure_kits_applied` (heal / `start`, `restart`, re-attach) | same pre-pass; refs resolved in one loop, merged, applied in a second |
| `acq_backend_apply_kit` (`acq kit apply`) | no full set exists: the sandbox's persisted marker value, with this kit's own `environment[]` layered last |

The mid-life path suits an additive add: layering the kit's own entries last means it
still overrides, and a stale or absent marker (the append in the same apply is only
best-effort) can never drop the kit's own env.

A kit with a `backend_shortcuts.msb` entry is left out of the merge: msb applies it
natively and skips the generic apply, so its `environment[]` never reaches the marker,
and merging it into commands would make them disagree with sessions.

**Guard ownership is per kit, tracked by value.** acq injects `GIT_TERMINAL_PROMPT=0` +
`GIT_ASKPASS`/`SSH_ASKPASS=/bin/false` onto kit commands unless *that* kit declared
`GIT_TERMINAL_PROMPT` itself; a prompting kit command with no credential blocks
provision forever (observed with the playbook kit). So a command may only ever receive
**its own kit's value** for a guard name — and the ownership record must be that kit's
guard **tokens** (`NAME=value`), not the names it declared, because the merge has
already collapsed a duplicate name to the *last* kit's value:

> Kit A declares `GIT_TERMINAL_PROMPT=0` and owns a command; a later kit B declares
> `=1`. Under name-only ownership A's own command ran with B's opt-out *and* no
> `GIT_ASKPASS`/`SSH_ASKPASS` at all — worse than before the change, where A correctly
> got its own `0`.

So: strip every guard-name token from the merged set whoever contributed it, re-add this
kit's own, then append acq's values unless this kit set `GIT_TERMINAL_PROMPT`. Both
collision shapes (name and value) are closed; a kit's own override still stands for its
own commands. The staged `--script-path` body shares the same helper so the paths cannot
drift — **that path is inert today** (ADR-0017), so it fixes no live symptom; it exists
so ADR-0017's re-verification trap cannot resurface this bug unnoticed.

## Consequences

- **Broader blast radius per var.** A var one kit declares now reaches every kit's
commands — intended (it matches sbx, where `environment.variables` is sandbox-level),
but a kit author can no longer assume a command's env is theirs alone.
- **Staging-order coupling at create.** `--script-path` staging moved into a second
loop over the same kit dirs, since the merged env is only knowable once every kit is
fetched; same list, same order, so the first-kit-stakes-the-name rule holds.
- **Knowingly left undone: a merged var can name a not-yet-staged path.** Kit *N*'s
commands can now receive e.g. `OPENCODE_CONFIG` pointing at a file kit *N+k* has not
copied in yet, turning "no team config" into "possibly broken team config" until the
declaring kit's files land. That is the startup-races-its-own-prerequisites class, not
an env-scoping one: fixing it needs an ordering/barrier decision (stage all `files[]`
before any commands, or a readiness barrier before background startup).

## Links

- ADR-0011 (the `environment` vocabulary and session-replay marker this widens),
ADR-0014 (`background: true`, whose daemons made the gap visible), ADR-0017 (the
staged-script path and its inertness caveat).
- `acq.backends/msb.sh`: `_acq_msb_merge_kit_env_into` (pre-pass),
`_acq_msb_own_guard_tokens_into` + `_acq_msb_env_tokens_with_guards_into` (guard
ownership), `_acq_msb_persisted_kit_env_into` (mid-life fallback, session replay).
- Tests: `test/bats/79-msb-merged-kit-env.bats` (lifecycle commands),
`78-msb-kit-env.bats` (marker/session replay), `73-msb-kits-startup.bats` (staged body).
- Reported as GSA-TTS/agentic-coding-quickstart#515; the deferred
startup-races-its-prerequisites class is GSA-TTS/agentic-coding-quickstart#506.
127 changes: 127 additions & 0 deletions test/bats/73-msb-kits-startup.bats
Original file line number Diff line number Diff line change
Expand Up @@ -475,3 +475,130 @@ SPEC
assert_regex "$body" 'echo NAMED_USER_MARKER'
refute_regex "$body" 'runuser -u agent'
}

@test "0017 + ADR-0033: the staged startup body carries the MERGED kit env, guards per kit" {
# The staged --script-path body must scope env the same way the exec path does:
# merged across the whole kit set, but the git-guard decision kept per kit. This
# path is INERT today (a bare --script-path registration is staged on the guest
# PATH, not auto-run at boot — see ADR-0017), so fixing it does not fix the
# reported daemon symptom; it keeps the two paths from drifting, so the ADR-0017
# re-verification trap (a future msb that DOES auto-run the script) cannot turn
# into a live instance of the same bug.
: > "$CALLS"
run bash -c '
export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/mergestage-secrets"
export ACQ_MSB_KEEP_STARTUP_STAGE=1 ACQ_MSB_STARTUP_STAGE_DIR="'"$STUBDIR"'/mergestage-stage"
. "'"$REPO_ROOT"'/acq.backends/secret-store.sh"
. "'"$REPO_ROOT"'/acq.backends/kit-translate.sh"
. "'"$REPO_ROOT"'/acq.backends/msb.sh"
# The kit with the startup command is fetched FIRST (it stakes the single
# script name), and the kit declaring the env comes LATER — the order that
# fails without a pre-pass over the full kit set.
sk="'"$STUBDIR"'/stagestartupkit"; mkdir -p "$sk"
cat >"$sk/spec.yaml" <<'"'"'SPEC'"'"'
schemaVersion: "hybrid/v1"
kind: mixin
name: stage-startup-kit
displayName: Stage Startup Kit
description: owns the staged startup command and opts out of the git guards
environment:
GIT_TERMINAL_PROMPT: "1"
commands:
- phase: startup
user: "0"
command:
- sh
- -c
- echo STAGED_MERGED_MARKER
SPEC
ck="'"$STUBDIR"'/stageconfigkit"; mkdir -p "$ck"
cat >"$ck/spec.yaml" <<'"'"'SPEC'"'"'
schemaVersion: "hybrid/v1"
kind: mixin
name: stage-config-kit
displayName: Stage Config Kit
description: declares agent-runtime config a later-merged body must carry
environment:
OPENCODE_CONFIG: /home/agent/.config/opencode/team.jsonc
SPEC
nk="'"$STUBDIR"'/stagenokit"; mkdir -p "$nk"
printf "schemaVersion: \"hybrid/v1\"\nkind: mixin\nname: x\ndisplayName: X\ndescription: x\n" > "$nk/spec.yaml"
ZSCALER_KIT=k1; USAI_KIT=k2; PLAYBOOK_KIT=k3; GITSSHSIGN_KIT=k4
_acq_msb_fetch_kit() {
case "$1" in k1) printf "%s\n" "$sk" ;; k2) printf "%s\n" "$ck" ;; *) printf "%s\n" "$nk" ;; esac
}
acq_backend_provision stagemergebox shell /tmp >/dev/null 2>&1
'
local body; body=$(_staged_body mergestage-stage)
assert_regex "$body" 'echo STAGED_MERGED_MARKER'
# The other kit's var reaches this kit's staged command.
assert_regex "$body" "'OPENCODE_CONFIG=/home/agent/.config/opencode/team.jsonc'"
# This kit opted out of the guards, so its own value stands and acq's does not
# appear alongside it.
assert_regex "$body" "'GIT_TERMINAL_PROMPT=1'"
refute_regex "$body" 'GIT_TERMINAL_PROMPT=0'
# Still a valid, busybox-safe /bin/sh body.
refute_regex "$body" 'env --'
local f; f=$(find "$STUBDIR/mergestage-stage" -type f 2>/dev/null | head -n1)
run sh -n "$f"
assert_success
}

@test "0017 + ADR-0033: a staged body keeps its OWN kit's guard value, not a later kit's" {
# Value-collision on the staged path: the kit that stakes the script declares
# GIT_TERMINAL_PROMPT=0 (the safe value); a later-merged kit declares =1. The
# body must carry the staking kit's own value. Same defect class as the exec
# path — the merge collapses a duplicate name to the LAST kit's value, so guard
# ownership has to be tracked by value, not by name.
run bash -c '
export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/gvstage-secrets"
export ACQ_MSB_KEEP_STARTUP_STAGE=1 ACQ_MSB_STARTUP_STAGE_DIR="'"$STUBDIR"'/gvstage-stage"
. "'"$REPO_ROOT"'/acq.backends/secret-store.sh"
. "'"$REPO_ROOT"'/acq.backends/kit-translate.sh"
. "'"$REPO_ROOT"'/acq.backends/msb.sh"
sk="'"$STUBDIR"'/gvstagekit"; mkdir -p "$sk"
cat >"$sk/spec.yaml" <<'"'"'SPEC'"'"'
schemaVersion: "hybrid/v1"
kind: mixin
name: gv-stage-kit
displayName: GV Stage Kit
description: stakes the staged script and declares the safe guard value
environment:
GIT_TERMINAL_PROMPT: "0"
commands:
- phase: startup
user: "0"
command:
- sh
- -c
- echo GV_STAGED_MARKER
SPEC
bk="'"$STUBDIR"'/gvoptoutkit"; mkdir -p "$bk"
cat >"$bk/spec.yaml" <<'"'"'SPEC'"'"'
schemaVersion: "hybrid/v1"
kind: mixin
name: gv-optout-kit
displayName: GV OptOut Kit
description: a later kit opting ITSELF out, plus a shared non-guard var
environment:
GIT_TERMINAL_PROMPT: "1"
TEAM_VAR: shared
SPEC
nk="'"$STUBDIR"'/gvnokit"; mkdir -p "$nk"
printf "schemaVersion: \"hybrid/v1\"\nkind: mixin\nname: x\ndisplayName: X\ndescription: x\n" > "$nk/spec.yaml"
ZSCALER_KIT=k1; USAI_KIT=k2; PLAYBOOK_KIT=k3; GITSSHSIGN_KIT=k4
_acq_msb_fetch_kit() {
case "$1" in k1) printf "%s\n" "$sk" ;; k2) printf "%s\n" "$bk" ;; *) printf "%s\n" "$nk" ;; esac
}
acq_backend_provision gvstagebox shell /tmp >/dev/null 2>&1
'
local body; body=$(_staged_body gvstage-stage)
assert_regex "$body" 'echo GV_STAGED_MARKER'
assert_regex "$body" "'GIT_TERMINAL_PROMPT=0'"
refute_regex "$body" 'GIT_TERMINAL_PROMPT=1'
# The other kit's NON-guard var still reaches the body (merge still applies).
assert_regex "$body" "'TEAM_VAR=shared'"
local f; f=$(find "$STUBDIR/gvstage-stage" -type f 2>/dev/null | head -n1)
run sh -n "$f"
assert_success
}
6 changes: 5 additions & 1 deletion test/bats/78-msb-kit-env.bats
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ SPEC
_acq_msb_apply_kit_dir envbox "$nk"
'
assert_success
refute_regex "$(cat "$CALLS")" '/var/lib/acq/kit-env'
# Asserts the WRITE specifically (`>> /var/lib/acq/kit-env`), not any mention of
# the path: a kit with no environment[] of its own still READS the merged marker
# so its commands inherit the other kits' env (see ADR-0033) — that read is
# expected here, an append is not.
refute_regex "$(cat "$CALLS")" '>> /var/lib/acq/kit-env'
}

@test "msb kit env: acq exec replays persisted entries as -e flags; none when marker empty" {
Expand Down
Loading
Loading