From d0c2f2f3840f99b4765f6de9b174b35e8d0ae16c Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 13 Aug 2026 09:01:50 -0400 Subject: [PATCH 1/2] fix(sbx): make global secret-set bash-3.2-safe (empty-array under set -u) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit acq secret set -g aborted on macOS bash 3.2 with `builtin_scope_args[@]: unbound variable`. For the global scope the array stays empty (no --sandbox), and `"${arr[@]}"` on an empty array is fatal under set -u on bash 3.2 (the macOS system bash). The failure hit AFTER the value was written to the acq store but BEFORE it reached sbx, leaving the sbx-side secret unset while acq reported success. Guard the expansion with the ${arr[@]+"${arr[@]}"} idiom (bash 4+ on Linux CI never errored, so CI stayed green — this only bit macOS users). Fixes GSA-TTS/agentic-coding-quickstart#308 AI-assisted (OpenCode). --- acq.backends/sbx.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index cb08bdc..7739222 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -753,7 +753,12 @@ acq_backend_secret_set() { # emits a warning). A sandbox scope is `--sandbox NAME`. See the "sbx CLI # secret scope-flag change" note in docs/VERIFY_BACKENDS_HANDOFF.md. if [ -z "$scope_flag" ]; then builtin_scope_args+=(--sandbox "$scope_name"); fi - printf '%s\n' "$secret_value" | sbx secret set "${builtin_scope_args[@]}" "$service" + # Empty-array-safe expansion: for the GLOBAL scope builtin_scope_args stays + # empty (no --sandbox), and `"${arr[@]}"` on an empty array is a fatal + # "unbound variable" under `set -u` on bash 3.2 (the macOS system bash). The + # `[@]+…` guard expands to nothing when the array is empty. bash 4+ tolerates + # the bare form, so this only bit macOS users on the `acq secret set -g` path. + printf '%s\n' "$secret_value" | sbx secret set ${builtin_scope_args[@]+"${builtin_scope_args[@]}"} "$service" exit_code=$? secret_value="" else From 8c7e7b8731f6e3cf17089ef4eaac8af106a48fe6 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 13 Aug 2026 09:28:37 -0400 Subject: [PATCH 2/2] feat(msb): neutral ACQ_NETWORK_TIER egress selector; deprecate ACQ_MSB_BALANCED_EGRESS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the neutral network-egress tier selector ACQ_NETWORK_TIER (strict|balanced|open, default balanced) to the msb adapter, implementing the agentic-coding-patterns network-tiers contract (its ADR-0002) for msb. Each tier maps to msb's native egress primitive at provision time; all tiers are deny-by-default except open: - strict -> --net-default-egress deny + gateway DNS + the kits' own caps.network.allow hosts ONLY (no curated baseline). Recommended for GFE / high-assurance. - balanced -> deny-default + the curated sbx-"balanced" baseline (ADR-0018) unioned with the kit hosts. The default. - open -> no deny-default (unrestricted egress). Testing only, never GFE; refused unless ACQ_NETWORK_TIER_CONFIRM_OPEN=1 (fail-closed). ACQ_MSB_BALANCED_EGRESS becomes a deprecated alias, mapped fail-safe (tighter, never looser): 1/on -> balanced; 0/off/empty -> strict (deny-by-default with kit hosts only, NOT the former permissive no-deny-default behavior — an upgrade never silently loosens egress). ACQ_NETWORK_TIER wins when both are set; a one-time deprecation notice points at the neutral selector. Invalid tier values fail closed to balanced. The curated baseline emitter _acq_msb_balanced_rules_into is unchanged; only the caller in acq_backend_provision branches three ways. Driving sbx policy from the tier is documented but deferred to a follow-up (this PR maps + documents only). Tests: 10 new test-acq cases (alias mapping, one-time notice, tier precedence, explicit strict/balanced, open fail-closed + confirmed, invalid->balanced, mixed-case). Updated 8n4 and verify-net-default-egress to the tier vocabulary. Docs: BACKEND_GUIDE tiers section + env-var table; ADR-0018/0019/0020 banners. Refs GSA-TTS/agentic-coding-quickstart#294 AI-assisted (OpenCode). --- acq.backends/msb.sh | 150 +++++++++++--- docs/BACKEND_GUIDE.md | 40 +++- docs/adr/0018-msb-balanced-egress-baseline.md | 11 ++ ...0019-msb-balanced-egress-is-egress-only.md | 7 + docs/adr/0020-msb-oci-engine-via-podman.md | 3 +- scripts/test-acq | 184 +++++++++++++++++- scripts/verify-net-default-egress | 13 +- 7 files changed, 354 insertions(+), 54 deletions(-) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 9271d38..45a4b65 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -231,17 +231,73 @@ ACQ_MSB_NPM_HOSTS="${ACQ_MSB_NPM_HOSTS:-registry.npmjs.org}" # create-time `-p HOST:GUEST` published ports stay reachable (a symmetric # `--net-default deny` would RST inbound to them). # -# Toggle: on by default. Set ACQ_MSB_BALANCED_EGRESS=0 (or empty) to skip the -# baseline and fall back to kit-only egress (the kits still add their own allow -# rules, but no deny-default is emitted, so egress is not restricted by acq). -# Normalized to exactly "1" (on) or "" (off) here so downstream `[ -n … ]` guards -# read cleanly: an unset value defaults on; "0"/"false"/"no"/"off"/empty are off -# (case-insensitive); anything else is on. -ACQ_MSB_BALANCED_EGRESS="${ACQ_MSB_BALANCED_EGRESS-1}" -case "$(printf '%s' "$ACQ_MSB_BALANCED_EGRESS" | tr '[:upper:]' '[:lower:]')" in - ""|0|false|no|off) ACQ_MSB_BALANCED_EGRESS="" ;; - *) ACQ_MSB_BALANCED_EGRESS="1" ;; +# Network egress posture is selected by the NEUTRAL tier vocabulary +# `ACQ_NETWORK_TIER` (strict|balanced|open, default balanced), which the patterns +# network-tiers contract (agentic-coding-patterns ADR-0002) defines for all +# backends. This adapter maps the tier to msb's native egress primitive at +# provision time (see acq_backend_provision): +# +# strict = deny-by-default egress, kit caps.network.allow ONLY (no baseline). +# The most locked-down posture; recommended for GFE / high-assurance. +# balanced = deny-by-default egress + the curated baseline (the sbx "balanced" +# mirror in msb-balanced-hosts.txt, ADR-0018) UNIONED with the kits' +# own allow rules. The default when unspecified. +# open = unrestricted egress (no deny-default). Testing only, never GFE; +# gated behind an explicit confirmation (see ACQ_NETWORK_TIER below). +# +# ALL tiers keep deny-by-default EXCEPT open; the tier only sizes the baseline +# allowlist. This normalizes to a lowercase enum, fail-closed to `balanced` on an +# invalid value (matching ACQ_MSB_SHORT_NAME_MODE's validator). +# +# DEPRECATED ALIAS — `ACQ_MSB_BALANCED_EGRESS` predates the neutral tier and is +# retained for one deprecation window. It maps into the tier fail-safe (tighter, +# never looser): a "1"/on value -> `balanced`; a "0"/off/empty value -> `strict` +# (NOT the old permissive no-deny-default behavior — an upgrade must never +# silently loosen egress). `ACQ_NETWORK_TIER` wins when both are set. A one-time +# notice points the user at the neutral selector. The alias is removed in a +# future major (the msb-emitter rename is tracked separately). +_acq_msb_balanced_egress_alias="" # "" = unset, "balanced"/"strict" once resolved +if [ "${ACQ_MSB_BALANCED_EGRESS+set}" = set ]; then + case "$(printf '%s' "$ACQ_MSB_BALANCED_EGRESS" | tr '[:upper:]' '[:lower:]')" in + ""|0|false|no|off) _acq_msb_balanced_egress_alias="strict" ;; + *) _acq_msb_balanced_egress_alias="balanced" ;; + esac +fi + +if [ -n "${ACQ_NETWORK_TIER+set}" ] && [ -n "$ACQ_NETWORK_TIER" ]; then + # Neutral selector present and non-empty: it wins outright. + _acq_net_tier_source="$ACQ_NETWORK_TIER" +elif [ -n "$_acq_msb_balanced_egress_alias" ]; then + # Only the deprecated alias is set: honor it (fail-safe mapping above) and warn + # once. Kept a plain guarded stderr note (no reusable warn-once helper exists). + _acq_net_tier_source="$_acq_msb_balanced_egress_alias" + if [ -z "${_ACQ_NETWORK_TIER_ALIAS_WARNED:-}" ]; then + printf 'acq(msb): notice: ACQ_MSB_BALANCED_EGRESS is deprecated; use ACQ_NETWORK_TIER (strict|balanced|open).\n' >&2 + printf 'acq(msb): mapped ACQ_MSB_BALANCED_EGRESS=%s -> ACQ_NETWORK_TIER=%s. A former "off" now means strict\n' "$ACQ_MSB_BALANCED_EGRESS" "$_acq_msb_balanced_egress_alias" >&2 + printf 'acq(msb): (deny-by-default, kit hosts only) — set ACQ_NETWORK_TIER=open if you truly need unrestricted egress.\n' >&2 + _ACQ_NETWORK_TIER_ALIAS_WARNED=1 + fi +else + _acq_net_tier_source="balanced" +fi + +ACQ_NETWORK_TIER="$(printf '%s' "$_acq_net_tier_source" | tr '[:upper:]' '[:lower:]')" +case "$ACQ_NETWORK_TIER" in + strict|balanced|open) ;; + *) + printf 'acq(msb): WARNING: invalid ACQ_NETWORK_TIER=%s (expected strict|balanced|open); falling back to balanced\n' "$_acq_net_tier_source" >&2 + ACQ_NETWORK_TIER="balanced" + ;; esac +unset _acq_net_tier_source _acq_msb_balanced_egress_alias + +# The `open` tier disables deny-by-default egress entirely — an explicit, +# audited escape hatch, never a default and never appropriate for GFE. It is +# gated like `--privileged`: it requires an explicit confirmation token +# (ACQ_NETWORK_TIER_CONFIRM_OPEN=1) or acq fails closed at provision time. This +# is validated in acq_backend_provision (not here) so a stale env var that is +# never used to provision cannot abort an unrelated acq invocation. +ACQ_NETWORK_TIER_CONFIRM_OPEN="${ACQ_NETWORK_TIER_CONFIRM_OPEN:-}" # --------------------------------------------------------------------------- # OCI container engine (podman) — ensure agents can run OCI images (ADR-0020) @@ -280,8 +336,9 @@ esac # # Toggle: on by default. Set ACQ_MSB_ENSURE_OCI=0 (or empty) to skip the step # entirely (e.g. a base image that bakes its own working engine, or a lean -# sandbox that needs no OCI support). Normalized to exactly "1"/"" like -# ACQ_MSB_BALANCED_EGRESS. +# sandbox that needs no OCI support). Normalized to exactly "1" (on) or "" (off): +# an unset value defaults on; "0"/"false"/"no"/"off"/empty are off +# (case-insensitive); anything else is on. ACQ_MSB_ENSURE_OCI="${ACQ_MSB_ENSURE_OCI-1}" case "$(printf '%s' "$ACQ_MSB_ENSURE_OCI" | tr '[:upper:]' '[:lower:]')" in ""|0|false|no|off) ACQ_MSB_ENSURE_OCI="" ;; @@ -301,9 +358,9 @@ esac # uses the OS package mirror (apt/dnf/apk), which under the default balanced egress # baseline (ADR-0018) is already reachable (archive.ubuntu.com / ports.ubuntu.com # / security.ubuntu.com / *.debian.org are in the vendored host list). With -# ACQ_MSB_BALANCED_EGRESS=0, or a base whose egress is otherwise narrowed, the -# mirror is unreachable and the install fails soft (a clear warning; provision -# continues; OCI is simply unavailable). +# ACQ_NETWORK_TIER=strict (kit hosts only), or a base whose egress is otherwise +# narrowed, the mirror is unreachable and the install fails soft (a clear warning; +# provision continues; OCI is simply unavailable). ACQ_MSB_PODMAN_PKGS="${ACQ_MSB_PODMAN_PKGS:-podman podman-compose fuse-overlayfs uidmap passt slirp4netns}" # podman short-name resolution mode written into the docker-first registries @@ -1846,13 +1903,22 @@ acq_backend_provision() { # Balanced egress baseline (ADR-0018): mirror the sbx "balanced" host set so an # msb sandbox reaches the same dev hosts. msb defaults egress to none, so we - # make the restriction explicit and deterministic: `--net-default-egress deny` + - # `allow@:tcp:` per vendored entry (plus explicit gateway-DNS rules, - # `allow@host:udp:53` + `allow@host:tcp:53`). These compose - # with the kit/npm/secret `allow@…` rules under first-match-wins. Emitted BEFORE - # the npm-host rule so all allow rules sit together after the deny-default. - # Toggle off with ACQ_MSB_BALANCED_EGRESS=0 (falls back to kit-only egress; no - # deny-default is emitted, so acq does not restrict egress in that mode). + # make the restriction explicit and deterministic. The NEUTRAL network tier + # (ACQ_NETWORK_TIER; strict|balanced|open, default balanced — see the module + # header and agentic-coding-patterns ADR-0002) selects how much egress the + # sandbox gets, all deny-by-default except `open`: + # balanced -> `--net-default-egress deny` + the curated baseline + # (`allow@:tcp:` per vendored entry + gateway DNS + # `allow@host:udp:53`/`:tcp:53`) UNIONED with the kit/npm/secret + # `allow@…` rules under first-match-wins. + # strict -> `--net-default-egress deny` + gateway DNS, but NO baseline: + # egress is the kits' own `allow@…` hosts ONLY. Same deny-default + # posture as balanced, smaller allowlist. + # open -> NO deny-default emitted; kit/npm/secret allows ride msb's own + # (permissive) egress default. Gated behind an explicit confirm + # token; validated below before we reach here. + # The baseline rules are emitted BEFORE the npm-host rule so all allow rules sit + # together after the deny-default. # # EGRESS-ONLY, DELIBERATELY (ADR-0019): we emit `--net-default-egress deny`, NOT # the symmetric `--net-default deny`. msb's `--net-default` sets BOTH directions @@ -1863,21 +1929,45 @@ acq_backend_provision() { # the handshake via msb's host proxy, then resets on data → ERR_CONNECTION_RESET # on the host). Restricting only egress leaves the ingress default at msb's # baseline `allow`, so published ports stay reachable with no per-port ingress - # rule. A future "strict" profile can layer ingress deny-default + explicit - # per-port `allow:ingress@…` rules; the balanced default intentionally does not. + # rule. A future "strict" ingress profile can layer ingress deny-default + explicit + # per-port `allow:ingress@…` rules; the tiers here intentionally set egress only. # Requires msb >= 0.6.8 (the `--net-default-egress`/`--net-default-ingress` # split); MIN_MSB_VERSION is 0.6.8, so acq_backend_prepare has already failed # closed on anything older before we reach here — this flag is always known. + # + # `open` is a privileged, audited escape hatch (never a default, never for GFE): + # it requires ACQ_NETWORK_TIER_CONFIRM_OPEN=1 or acq fails closed here, warns on + # every use, and is recorded via acq_debug. + if [ "$ACQ_NETWORK_TIER" = open ]; then + if [ "$ACQ_NETWORK_TIER_CONFIRM_OPEN" != 1 ]; then + echo "acq(msb): error: ACQ_NETWORK_TIER=open disables deny-by-default egress and is refused" >&2 + echo "acq(msb): without explicit confirmation. Set ACQ_NETWORK_TIER_CONFIRM_OPEN=1 to proceed" >&2 + echo "acq(msb): (testing only; never for GFE). Prefer ACQ_NETWORK_TIER=strict or balanced." >&2 + return 1 + fi + echo "acq(msb): WARNING: ACQ_NETWORK_TIER=open — egress is UNRESTRICTED (no deny-default)." >&2 + echo "acq(msb): This sandbox can reach any host. Do not use for GFE or production agents." >&2 + acq_debug "msb network tier: open (deny-default NOT emitted; confirmed via ACQ_NETWORK_TIER_CONFIRM_OPEN)" + fi # Track the hosts the balanced block allow-listed so the npm block below can # de-dupe against them (space-delimited, space-padded for whole-token match). local _balanced_hosts=" " - if [ -n "$ACQ_MSB_BALANCED_EGRESS" ]; then + if [ "$ACQ_NETWORK_TIER" = strict ] || [ "$ACQ_NETWORK_TIER" = balanced ]; then local _balanced=() - _acq_msb_balanced_rules_into _balanced + if [ "$ACQ_NETWORK_TIER" = balanced ]; then + # balanced: deny-default + curated baseline (+ gateway DNS from the emitter). + _acq_msb_balanced_rules_into _balanced + else + # strict: deny-default + gateway DNS ONLY, no baseline hosts. Emit the same + # explicit gateway-DNS rules the baseline emitter prepends (the high-level + # DNS auto-grant does not fire under a rule-only deny-default), so kit + # `allow@…` hosts remain resolvable. NOTHING else is added. + _balanced=(--net-rule "allow@host:udp:53" --net-rule "allow@host:tcp:53") + fi if [ "${#_balanced[@]}" -gt 0 ]; then create_flags+=(--net-default-egress deny) create_flags+=("${_balanced[@]}") - acq_debug "msb balanced-egress: added ${#_balanced[@]} --net-rule token(s) + --net-default-egress deny" + acq_debug "msb network tier=${ACQ_NETWORK_TIER}: added ${#_balanced[@]} --net-rule token(s) + --net-default-egress deny" # Record the bare host of each emitted rule (strip the `allow@` prefix and # any `:proto:port` suffix) for the npm de-dupe below. local _tok _bh @@ -1901,8 +1991,8 @@ acq_backend_provision() { # dead weight (both allow; no deny to shadow). We therefore skip any npm host # that the balanced block ALREADY emitted a rule for, rather than skipping the # whole block — an operator who overrides ACQ_MSB_NPM_HOSTS to an internal - # mirror NOT in the balanced set still gets its rule. In kit-only mode - # (ACQ_MSB_BALANCED_EGRESS=0) nothing is elided, since the balanced set is empty. + # mirror NOT in the balanced set still gets its rule. Under the `strict` tier + # (or `open`) the balanced set is empty, so nothing is elided. if _acq_msb_agent_has_install_recipe "$agent"; then local _npm_host for _npm_host in $ACQ_MSB_NPM_HOSTS; do @@ -2713,7 +2803,7 @@ EOF echo "acq(msb): warning: could not provision an OCI engine (rootless podman) in '$name'." >&2 echo "acq(msb): Agents will not be able to run OCI images (docker run / docker compose)." >&2 echo "acq(msb): Most likely the OS package mirror is unreachable: the default balanced" >&2 - echo "acq(msb): egress (ADR-0018) allows it, but ACQ_MSB_BALANCED_EGRESS=0 or a narrowed" >&2 + echo "acq(msb): egress (ADR-0018) allows it, but ACQ_NETWORK_TIER=strict or a narrowed" >&2 echo "acq(msb): custom base blocks archive.ubuntu.com / ports.ubuntu.com / *.debian.org," >&2 echo "acq(msb): or the rootless prereqs (podman, fuse-overlayfs, uidmap, passt," >&2 echo "acq(msb): slirp4netns) could not be installed / rootless podman could not start." >&2 diff --git a/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index be9ecca..ccf3568 100644 --- a/docs/BACKEND_GUIDE.md +++ b/docs/BACKEND_GUIDE.md @@ -169,7 +169,9 @@ Tunables: | `ACQ_MSB_NPM_HOSTS` | `registry.npmjs.org` | npm registry host(s) to allow-list for the agent install (space-separated; set for an internal mirror) | | `ACQ_MSB_ENSURE_OCI` | `1` (on) | Provision an OCI container engine (podman) at create so agents can run OCI images (`docker run`, `docker compose`). Installs `ACQ_MSB_PODMAN_PKGS` and aliases `docker` → `podman`. Set `0`/`false`/`no`/`off`/empty to skip (e.g. a base that bakes its own working engine). Fails soft if the OS package mirror is unreachable. See ADR-0020. | | `ACQ_MSB_PODMAN_PKGS` | `podman podman-compose` | Packages installed to provide the OCI engine (space-separated). `podman-compose` is the `docker compose` / `podman compose` provider. Override for a different set or an internal mirror's names. | -| `ACQ_MSB_BALANCED_EGRESS` | `1` (on) | Apply the sbx-`balanced` egress baseline at create (`--net-default-egress deny` + an `allow@host:tcp:port` rule per vendored entry + gateway-DNS rules `allow@host:udp:53` + `allow@host:tcp:53`). The deny-default is **egress only** — ingress keeps msb's baseline `allow` so published ports stay reachable (ADR-0019). Set `0`/`false`/`no`/`off`/empty to disable and fall back to kit-only egress (no deny-default emitted). See ADR-0018. | +| `ACQ_NETWORK_TIER` | `balanced` | Neutral egress posture (`strict`\|`balanced`\|`open`), the backend-agnostic selector defined by the agentic-coding-patterns network-tiers contract (ADR-0002). **All tiers are deny-by-default except `open`**; the tier only sizes the baseline allowlist. `strict` = `--net-default-egress deny` + gateway DNS + the kits' own `caps.network.allow` hosts ONLY (recommended for GFE / high-assurance). `balanced` = the same deny-default + the curated sbx-`balanced` baseline (ADR-0018) unioned with the kit hosts. `open` = **unrestricted egress** (no deny-default); testing only, never for GFE, and refused unless `ACQ_NETWORK_TIER_CONFIRM_OPEN=1`. Invalid values fail closed to `balanced`. | +| `ACQ_NETWORK_TIER_CONFIRM_OPEN` | (unset) | Required confirmation for `ACQ_NETWORK_TIER=open`. Set to `1` to acknowledge that the sandbox runs with unrestricted egress; otherwise `open` is refused at provision time (fail-closed). Treated like `--privileged` — never a default. | +| `ACQ_MSB_BALANCED_EGRESS` | (deprecated) | **Deprecated alias** for `ACQ_NETWORK_TIER`; retained for one deprecation window and removed in a future major. A `1`/on value maps to `ACQ_NETWORK_TIER=balanced`; a `0`/`false`/`no`/`off`/empty value maps to `ACQ_NETWORK_TIER=strict` (deny-by-default, kit hosts only — a former "off" no longer means permissive; an upgrade never silently loosens egress). `ACQ_NETWORK_TIER` wins when both are set, and a one-time notice is printed. Migrate to `ACQ_NETWORK_TIER`; use `open` if you truly need unrestricted egress. | | `ACQ_MSB_BALANCED_HOSTS_FILE` | `/acq.backends/msb-balanced-hosts.txt` | Path to the vendored host list (a verbatim mirror of `sbx policy inspect local-policy`). Override for a site-specific egress set. | | `ACQ_MSB_WORKSPACE` | (first workspace) | Agent's **starting directory** (`-w`) on attach. Does NOT change the mount, which is always host-path:host-path; overrides only where the agent starts. | | `ACQ_MSB_MEMORY` | `4G` | Guest RAM at create (`-m`); `4G`/`4096`/`512M` (bare = MiB). Set empty to use msb's 512 MiB default | @@ -195,7 +197,23 @@ microVM) to `msb create`. Override `ACQ_MSB_DNS_NAMESERVER` if `1.1.1.1` is blocked in your environment, or set it empty to fall back to msb's default (only if your host resolver is reachable from the guest). -### Network egress (sbx-`balanced` parity) +### Network egress tiers (`ACQ_NETWORK_TIER`) + +Egress posture is selected by the **neutral network tier**, `ACQ_NETWORK_TIER` +(`strict` | `balanced` | `open`, default `balanced`). This is the +backend-agnostic vocabulary defined by the agentic-coding-patterns +network-tiers contract (its ADR-0002); each backend maps the tier to its native +egress primitive. **All tiers are deny-by-default except `open`** — the tier +only sizes the baseline allowlist: + +| Tier | Meaning | msb emission | +|------|---------|--------------| +| `strict` | Deny-by-default; the kits' own `caps.network.allow` hosts ONLY. Recommended for GFE / high-assurance. | `--net-default-egress deny` + gateway DNS (`allow@host:udp:53`/`:tcp:53`) + the kit `allow@…` rules. No baseline. | +| `balanced` (default) | Deny-by-default + the curated sbx-`balanced` baseline, unioned with the kit hosts. | `--net-default-egress deny` + the vendored baseline rules + gateway DNS + the kit `allow@…` rules. | +| `open` | Unrestricted egress. **Testing only, never for GFE.** | No deny-default emitted; kit/npm/secret `allow@…` rules ride msb's own (permissive) egress default. Refused unless `ACQ_NETWORK_TIER_CONFIRM_OPEN=1`. | + +The effective allowlist is `tier baseline ∪ per-kit caps.network.allow ∪ any +per-sandbox additions`, always under deny-by-default (for `strict`/`balanced`). sbx ships a **`balanced`** network policy (the recommended sbx default) that allows a broad set of developer hosts — AI services, package registries, @@ -204,8 +222,8 @@ certificate-validation endpoints — and blocks everything else. msb has no equivalent default; its egress is deny-by-default with only the hosts the kits declare (plus the npm registry when installing an agent). -To reach parity, the msb backend applies the **same host set as sbx `balanced` -by default**: at create it emits `--net-default-egress deny` plus one +To reach parity, the msb backend's `balanced` tier applies the **same host set +as sbx `balanced`**: at create it emits `--net-default-egress deny` plus one `allow@:tcp:` rule per entry in the vendored list `acq.backends/msb-balanced-hosts.txt` (a verbatim mirror of `sbx policy inspect local-policy`), plus gateway-DNS rules (`allow@host:udp:53` + @@ -217,9 +235,15 @@ kits' own `caps.network.allow` rules. not the symmetric `--net-default`). Ingress keeps msb's baseline `allow`, so create-time `-p HOST:GUEST` published ports stay reachable — a symmetric deny would RST inbound to them (see [ADR-0019](adr/0019-msb-balanced-egress-is-egress-only.md)). -- **Disable** with `ACQ_MSB_BALANCED_EGRESS=0` (falls back to kit-only egress; no - deny-default is emitted, so `acq` does not restrict egress in that mode). -- **Customize** by pointing `ACQ_MSB_BALANCED_HOSTS_FILE` at your own list. +- **Select the tier** with `ACQ_NETWORK_TIER` (default `balanced`). Use `strict` + for kit-hosts-only deny-by-default; `open` (with `ACQ_NETWORK_TIER_CONFIRM_OPEN=1`) + to disable the deny-default for testing only. +- **Customize** the `balanced` baseline by pointing `ACQ_MSB_BALANCED_HOSTS_FILE` + at your own list. +- **`ACQ_MSB_BALANCED_EGRESS` is deprecated** — it now aliases `ACQ_NETWORK_TIER`: + a `1`/on value maps to `balanced`; a `0`/off/empty value maps to `strict` (a + former "off" is now deny-by-default with kit hosts only, NOT permissive — an + upgrade never silently loosens egress). Migrate to `ACQ_NETWORK_TIER`. - **Wildcards / ports:** sbx `**.host` / `*.host` become msb domain-suffix `*.host`; the intra-label glob `crl*.digicert.com` is broadened to `*.digicert.com` (msb has no intra-label glob — this is logged and is the one @@ -421,7 +445,7 @@ volume), the adapter provisions **podman** at create time and aliases `docker` The install uses the OS package mirror, which under the default balanced egress baseline (ADR-0018) is already reachable — no extra net-rule needed. With -`ACQ_MSB_BALANCED_EGRESS=0`, or a custom base whose egress is narrowed, the +`ACQ_NETWORK_TIER=strict`, or a custom base whose egress is narrowed, the mirror is unreachable and the step **fails soft** (a warning; provision continues; OCI is simply unavailable). Turn the step off entirely with `ACQ_MSB_ENSURE_OCI=0` (e.g. a base that bakes its own working engine), or point diff --git a/docs/adr/0018-msb-balanced-egress-baseline.md b/docs/adr/0018-msb-balanced-egress-baseline.md index e77c866..ca1535e 100644 --- a/docs/adr/0018-msb-balanced-egress-baseline.md +++ b/docs/adr/0018-msb-balanced-egress-baseline.md @@ -13,6 +13,17 @@ supersedes: [] # ADR-0018: Mirror the sbx "balanced" network policy as the msb default egress baseline +> **Update (network tiers):** the `ACQ_MSB_BALANCED_EGRESS` toggle described +> below is now a **deprecated alias** for the neutral `ACQ_NETWORK_TIER` +> selector (`strict|balanced|open`, default `balanced`) defined by the +> agentic-coding-patterns network-tiers contract. `ACQ_MSB_BALANCED_EGRESS=1` +> maps to `balanced` (this baseline); `=0` now maps to `strict` — still +> deny-by-default with the kits' own hosts, NOT the former permissive +> no-deny-default behavior (an upgrade never silently loosens egress). See the +> `ACQ_NETWORK_TIER` section of [`docs/BACKEND_GUIDE.md`](../BACKEND_GUIDE.md). +> References to `ACQ_MSB_BALANCED_EGRESS=0` as "kit-only, no deny-default" below +> reflect the original toggle and are superseded by the `strict` tier mapping. +> > **Update (ADR-0019):** the deny-default described below is scoped to **egress > only** — the emitter uses `--net-default-egress deny`, not the symmetric > `--net-default deny` this ADR originally specified. msb's `--net-default` sets diff --git a/docs/adr/0019-msb-balanced-egress-is-egress-only.md b/docs/adr/0019-msb-balanced-egress-is-egress-only.md index f128a3e..40e5e78 100644 --- a/docs/adr/0019-msb-balanced-egress-is-egress-only.md +++ b/docs/adr/0019-msb-balanced-egress-is-egress-only.md @@ -74,6 +74,13 @@ per-port ingress rule. `ACQ_MSB_BALANCED_EGRESS=0` is unchanged: no deny-default of either kind is emitted (kit-only egress). +> **Update (network tiers):** `ACQ_MSB_BALANCED_EGRESS` is now a deprecated +> alias for `ACQ_NETWORK_TIER`, and `=0` maps to the `strict` tier — which +> **does** emit `--net-default-egress deny` (kit hosts only), not the +> "no deny-default" behavior described here. The egress-only decision in this +> ADR is unaffected; only the toggle's off-mapping changed. See ADR-0018's +> network-tiers banner and the `ACQ_NETWORK_TIER` section of `BACKEND_GUIDE.md`. + ## Consequences - **Better:** create-time published ports (openchamber UI on 3000, OpenCode diff --git a/docs/adr/0020-msb-oci-engine-via-podman.md b/docs/adr/0020-msb-oci-engine-via-podman.md index 8f9c203..6835a10 100644 --- a/docs/adr/0020-msb-oci-engine-via-podman.md +++ b/docs/adr/0020-msb-oci-engine-via-podman.md @@ -218,7 +218,8 @@ right after `_acq_msb_ensure_agent_user`. `security.ubuntu.com`, `**.debian.org`, `launchpad.net`) are **already in the default balanced egress baseline** ([ADR-0018](0018-msb-balanced-egress-baseline.md)), so no new net-rule is needed under the default policy. With - `ACQ_MSB_BALANCED_EGRESS=0`, or a custom base whose egress is otherwise + `ACQ_NETWORK_TIER=strict` (or the deprecated `ACQ_MSB_BALANCED_EGRESS=0`, which + now maps to `strict`), or a custom base whose egress is otherwise narrowed, the mirror is unreachable and the step **fails soft** — chosen deliberately so an operator's explicit egress narrowing is respected rather than silently re-widened (the warning names the mirror hosts and the diff --git a/scripts/test-acq b/scripts/test-acq index 2fa5cec..d8b22c3 100755 --- a/scripts/test-acq +++ b/scripts/test-acq @@ -2822,19 +2822,19 @@ make_stubs; load_acq assert_not_contains "msb: skips npm install when agent already present" "$(cat "$CALLS")" "npm install" cleanup_stubs -# 8n4. `shell` sandbox: no agent binary install, and — in kit-only egress mode -# (ACQ_MSB_BALANCED_EGRESS=0) — no npm registry allow-list. The npm host is +# 8n4. `shell` sandbox: no agent binary install, and — under the `strict` tier +# (empty baseline) — no npm registry allow-list. The npm host is # added ONLY for an agent with an install recipe (ADR-0011); a `shell` # sandbox needs no npm egress. This test pins that agent-conditional gate, -# so it runs with the balanced baseline OFF: with the baseline ON, +# so it runs under `strict` (empty baseline): under `balanced`, # registry.npmjs.org is in the sbx-"balanced" set and IS allow-listed for -# every sandbox by design (see 10b1j and ADR-0018) — a separate concern +# every sandbox by design (see 10b1j6 and ADR-0018) — a separate concern # from the agent-install gate exercised here. make_stubs; load_acq : > "$CALLS" ( export ACQ_SECRET_STORE_DIR="$STUBDIR/shell-secrets" - export ACQ_MSB_BALANCED_EGRESS=0 + export ACQ_NETWORK_TIER=strict . "${REPO_ROOT}/acq.backends/secret-store.sh" . "${REPO_ROOT}/acq.backends/msb.sh" _acq_msb_fetch_kit() { printf '%s\n' "${STUBDIR}/nokit"; } @@ -2844,7 +2844,7 @@ make_stubs; load_acq ) shell_log=$(cat "$CALLS") assert_not_contains "msb: shell agent does not npm install" "$shell_log" "npm install" -assert_not_contains "msb: shell agent adds no npm net-rule (kit-only egress)" "$shell_log" "allow@registry.npmjs.org" +assert_not_contains "msb: shell agent adds no npm net-rule (strict tier)" "$shell_log" "allow@registry.npmjs.org" cleanup_stubs # 8n5. Attach LAUNCHES the recorded agent as the `agent` user in the workspace, @@ -5404,8 +5404,10 @@ assert_contains "msb: default provision emits gateway DNS (tcp/53)" "$bal_prov_l assert_contains "msb: default provision emits a balanced host rule" "$bal_prov_log" "allow@api.anthropic.com:tcp:443" cleanup_stubs -# 10b1j. Provision with ACQ_MSB_BALANCED_EGRESS=0 emits NONE of the balanced -# baseline: no egress deny-default, no representative balanced host rule. +# 10b1j. Provision with the deprecated ACQ_MSB_BALANCED_EGRESS=0 now maps to the +# `strict` tier (fail-safe: deny-by-default, kit hosts ONLY). It STILL +# emits `--net-default-egress deny` + gateway DNS (unlike the old +# permissive off-path), but NONE of the curated baseline host rules. make_stubs; load_acq : > "$CALLS" ( @@ -5418,8 +5420,170 @@ make_stubs; load_acq acq_backend_provision baloffbox shell /tmp >/dev/null 2>&1 ) bal_off_log=$(cat "$CALLS") -assert_not_contains "msb: BALANCED_EGRESS=0 omits --net-default-egress deny" "$bal_off_log" "--net-default-egress deny" -assert_not_contains "msb: BALANCED_EGRESS=0 omits balanced host rules" "$bal_off_log" "allow@api.anthropic.com" +assert_contains "msb: deprecated =0 maps to strict — still emits deny-default" "$bal_off_log" "--net-default-egress deny" +assert_contains "msb: deprecated =0 (strict) still emits gateway DNS" "$bal_off_log" "allow@host:udp:53" +assert_not_contains "msb: deprecated =0 (strict) omits balanced host rules" "$bal_off_log" "allow@api.anthropic.com" + +# 10b1j2. The deprecated alias prints a one-time deprecation notice on stderr, +# naming the neutral selector and the =0 -> strict mapping. +make_stubs; load_acq +alias_warn=$( + export ACQ_MSB_BALANCED_EGRESS=0 + . "${REPO_ROOT}/acq.backends/msb.sh" 2>&1 >/dev/null +) +assert_contains "msb: alias emits deprecation notice" "$alias_warn" "ACQ_MSB_BALANCED_EGRESS is deprecated" +assert_contains "msb: alias notice names the strict mapping" "$alias_warn" "ACQ_NETWORK_TIER=strict" +cleanup_stubs + +# 10b1j3. ACQ_MSB_BALANCED_EGRESS=1 maps to the `balanced` tier (curated baseline +# present), preserving pre-deprecation on-path behavior. +make_stubs; load_acq +: > "$CALLS" +( + export ACQ_SECRET_STORE_DIR="$STUBDIR/alias-on-secrets" + export ACQ_MSB_BALANCED_EGRESS=1 + . "${REPO_ROOT}/acq.backends/msb.sh" + _acq_msb_fetch_kit() { printf '%s\n' "${STUBDIR}/nokit"; } + mkdir -p "${STUBDIR}/nokit" + printf 'schemaVersion: "hybrid/v1"\nkind: mixin\nname: x\ndisplayName: X\ndescription: x\n' > "${STUBDIR}/nokit/spec.yaml" + acq_backend_provision aliasonbox shell /tmp >/dev/null 2>&1 +) +alias_on_log=$(cat "$CALLS") +assert_contains "msb: deprecated =1 maps to balanced (deny-default)" "$alias_on_log" "--net-default-egress deny" +assert_contains "msb: deprecated =1 maps to balanced (baseline present)" "$alias_on_log" "allow@api.anthropic.com:tcp:443" +cleanup_stubs + +# 10b1j4. ACQ_NETWORK_TIER wins when BOTH it and the deprecated alias are set: +# alias=1 (would be balanced) but tier=strict -> strict wins (no baseline). +make_stubs; load_acq +: > "$CALLS" +( + export ACQ_SECRET_STORE_DIR="$STUBDIR/tier-wins-secrets" + export ACQ_MSB_BALANCED_EGRESS=1 + export ACQ_NETWORK_TIER=strict + . "${REPO_ROOT}/acq.backends/msb.sh" + _acq_msb_fetch_kit() { printf '%s\n' "${STUBDIR}/nokit"; } + mkdir -p "${STUBDIR}/nokit" + printf 'schemaVersion: "hybrid/v1"\nkind: mixin\nname: x\ndisplayName: X\ndescription: x\n' > "${STUBDIR}/nokit/spec.yaml" + acq_backend_provision tierwinsbox shell /tmp >/dev/null 2>&1 +) +tier_wins_log=$(cat "$CALLS") +assert_contains "msb: ACQ_NETWORK_TIER wins over alias — deny-default present" "$tier_wins_log" "--net-default-egress deny" +assert_not_contains "msb: ACQ_NETWORK_TIER=strict wins over alias=1 — no baseline" "$tier_wins_log" "allow@api.anthropic.com" +cleanup_stubs + +# 10b1j5. ACQ_NETWORK_TIER=strict (explicit) emits deny-default + gateway DNS, +# NO curated baseline. The strict emission path is deny-default WITHOUT +# the baseline emitter — the core new behavior. +make_stubs; load_acq +: > "$CALLS" +( + export ACQ_SECRET_STORE_DIR="$STUBDIR/strict-secrets" + export ACQ_NETWORK_TIER=strict + . "${REPO_ROOT}/acq.backends/msb.sh" + _acq_msb_fetch_kit() { printf '%s\n' "${STUBDIR}/nokit"; } + mkdir -p "${STUBDIR}/nokit" + printf 'schemaVersion: "hybrid/v1"\nkind: mixin\nname: x\ndisplayName: X\ndescription: x\n' > "${STUBDIR}/nokit/spec.yaml" + acq_backend_provision strictbox shell /tmp >/dev/null 2>&1 +) +strict_log=$(cat "$CALLS") +assert_contains "msb: strict emits deny-default" "$strict_log" "--net-default-egress deny" +assert_contains "msb: strict emits gateway DNS (udp/53)" "$strict_log" "allow@host:udp:53" +assert_contains "msb: strict emits gateway DNS (tcp/53)" "$strict_log" "allow@host:tcp:53" +assert_not_contains "msb: strict omits the curated baseline" "$strict_log" "allow@api.anthropic.com" +cleanup_stubs + +# 10b1j6. ACQ_NETWORK_TIER=balanced (explicit) == default: deny-default + baseline. +make_stubs; load_acq +: > "$CALLS" +( + export ACQ_SECRET_STORE_DIR="$STUBDIR/balanced-secrets" + export ACQ_NETWORK_TIER=balanced + . "${REPO_ROOT}/acq.backends/msb.sh" + _acq_msb_fetch_kit() { printf '%s\n' "${STUBDIR}/nokit"; } + mkdir -p "${STUBDIR}/nokit" + printf 'schemaVersion: "hybrid/v1"\nkind: mixin\nname: x\ndisplayName: X\ndescription: x\n' > "${STUBDIR}/nokit/spec.yaml" + acq_backend_provision balancedbox shell /tmp >/dev/null 2>&1 +) +balanced_log=$(cat "$CALLS") +assert_contains "msb: explicit balanced emits deny-default" "$balanced_log" "--net-default-egress deny" +assert_contains "msb: explicit balanced emits the curated baseline" "$balanced_log" "allow@api.anthropic.com:tcp:443" +cleanup_stubs + +# 10b1j7. ACQ_NETWORK_TIER=open WITHOUT the confirm token fails closed: provision +# returns non-zero and emits NO deny-default (the sandbox is not created +# with unrestricted egress by accident). +make_stubs; load_acq +: > "$CALLS" +open_norc=0 +open_err=$( + export ACQ_SECRET_STORE_DIR="$STUBDIR/open-noconfirm-secrets" + export ACQ_NETWORK_TIER=open + . "${REPO_ROOT}/acq.backends/msb.sh" + _acq_msb_fetch_kit() { printf '%s\n' "${STUBDIR}/nokit"; } + mkdir -p "${STUBDIR}/nokit" + printf 'schemaVersion: "hybrid/v1"\nkind: mixin\nname: x\ndisplayName: X\ndescription: x\n' > "${STUBDIR}/nokit/spec.yaml" + acq_backend_provision opennoconfirmbox shell /tmp 2>&1 >/dev/null +) || open_norc=$? +if [ "$open_norc" -ne 0 ]; then + pass "msb: open without confirm token fails closed (rc != 0)" +else + fail "msb: open without confirm token fails closed (rc != 0)" "provision returned 0" +fi +assert_contains "msb: open without confirm names the confirm token" "$open_err" "ACQ_NETWORK_TIER_CONFIRM_OPEN=1" +cleanup_stubs + +# 10b1j8. ACQ_NETWORK_TIER=open WITH the confirm token: NO deny-default emitted +# (unrestricted egress), a warning is printed, kit rules still ride along. +make_stubs; load_acq +: > "$CALLS" +open_ok_err=$( + export ACQ_SECRET_STORE_DIR="$STUBDIR/open-confirm-secrets" + export ACQ_NETWORK_TIER=open + export ACQ_NETWORK_TIER_CONFIRM_OPEN=1 + . "${REPO_ROOT}/acq.backends/msb.sh" + _acq_msb_fetch_kit() { printf '%s\n' "${STUBDIR}/nokit"; } + mkdir -p "${STUBDIR}/nokit" + printf 'schemaVersion: "hybrid/v1"\nkind: mixin\nname: x\ndisplayName: X\ndescription: x\n' > "${STUBDIR}/nokit/spec.yaml" + acq_backend_provision openconfirmbox shell /tmp 2>&1 >/dev/null +) +open_ok_log=$(cat "$CALLS") +assert_not_contains "msb: open (confirmed) emits NO deny-default" "$open_ok_log" "--net-default-egress deny" +assert_contains "msb: open (confirmed) warns about unrestricted egress" "$open_ok_err" "UNRESTRICTED" +cleanup_stubs + +# 10b1j9. An invalid ACQ_NETWORK_TIER value fails closed to `balanced` (with a +# warning), never to open — mirrors the ACQ_MSB_SHORT_NAME_MODE validator. +make_stubs; load_acq +: > "$CALLS" +invalid_tier_err=$( + export ACQ_SECRET_STORE_DIR="$STUBDIR/invalid-tier-secrets" + export ACQ_NETWORK_TIER=bogus + . "${REPO_ROOT}/acq.backends/msb.sh" 2>&1 >/dev/null + acq_backend_provision invalidtierbox shell /tmp >/dev/null 2>&1 +) +invalid_tier_log=$(cat "$CALLS") +assert_contains "msb: invalid tier warns" "$invalid_tier_err" "invalid ACQ_NETWORK_TIER" +assert_contains "msb: invalid tier falls back to balanced (deny-default)" "$invalid_tier_log" "--net-default-egress deny" +assert_contains "msb: invalid tier falls back to balanced (baseline)" "$invalid_tier_log" "allow@api.anthropic.com:tcp:443" +cleanup_stubs + +# 10b1j10. A mixed-case tier value (e.g. StRiCt) is lowercased and honored — it +# must NOT fall through to the invalid->balanced path. +make_stubs; load_acq +: > "$CALLS" +( + export ACQ_SECRET_STORE_DIR="$STUBDIR/mixedcase-secrets" + export ACQ_NETWORK_TIER=StRiCt + . "${REPO_ROOT}/acq.backends/msb.sh" + _acq_msb_fetch_kit() { printf '%s\n' "${STUBDIR}/nokit"; } + mkdir -p "${STUBDIR}/nokit" + printf 'schemaVersion: "hybrid/v1"\nkind: mixin\nname: x\ndisplayName: X\ndescription: x\n' > "${STUBDIR}/nokit/spec.yaml" + acq_backend_provision mixedcasebox shell /tmp >/dev/null 2>&1 +) +mixedcase_log=$(cat "$CALLS") +assert_contains "msb: mixed-case StRiCt lowercased -> strict (deny-default)" "$mixedcase_log" "--net-default-egress deny" +assert_not_contains "msb: mixed-case StRiCt honored as strict (no baseline)" "$mixedcase_log" "allow@api.anthropic.com" cleanup_stubs # 10b1k. npm-registry de-dupe: registry.npmjs.org is IN the balanced set, so with diff --git a/scripts/verify-net-default-egress b/scripts/verify-net-default-egress index 59a67c7..01cb784 100755 --- a/scripts/verify-net-default-egress +++ b/scripts/verify-net-default-egress @@ -66,11 +66,14 @@ case "$(uname -s)" in ok "$(uname -s) host — assuming msb provides its own virtualization" ;; esac command -v curl >/dev/null 2>&1 && ok "curl on PATH (host-side probe)" || bad "curl missing" -# The balanced baseline must be ON for this test to mean anything; if the caller -# disabled it, no deny-default is emitted and the test is vacuous. -case "$(printf '%s' "${ACQ_MSB_BALANCED_EGRESS:-1}" | tr 'A-Z' 'a-z')" in - 0|false|no|off|"") bad "ACQ_MSB_BALANCED_EGRESS is OFF — the deny-default under test is not emitted; unset it and re-run"; exit 1 ;; - *) ok "balanced egress baseline is ON (deny-default will be emitted)" ;; +# A deny-default must be emitted for this test to mean anything. That holds for +# every tier EXCEPT `open` (which disables the deny-default). `strict` and +# `balanced` both emit `--net-default-egress deny`; the deprecated +# ACQ_MSB_BALANCED_EGRESS=0 now maps to `strict`, so it too emits a deny-default. +# Only `open` makes the test vacuous. +case "$(printf '%s' "${ACQ_NETWORK_TIER:-balanced}" | tr 'A-Z' 'a-z')" in + open) bad "ACQ_NETWORK_TIER=open disables the deny-default under test; set strict or balanced and re-run"; exit 1 ;; + *) ok "network tier '${ACQ_NETWORK_TIER:-balanced}' emits a deny-default" ;; esac # Fresh start: remove any stale sandbox of this name.