Skip to content

feat(msb): mirror the sbx balanced network policy as the msb default egress - #295

Open
mogul wants to merge 7 commits into
mainfrom
feat/msb-balanced-egress
Open

feat(msb): mirror the sbx balanced network policy as the msb default egress#295
mogul wants to merge 7 commits into
mainfrom
feat/msb-balanced-egress

Conversation

@mogul

@mogul mogul commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Context

sbx ships a curated balanced network policy (AI services, package registries, code/container hosts, cloud infra, OS package mirrors, and cert validation) and the sbx path in this repo assumes it. msb has no equivalent default — its low-level model is deny-by-default with an ordered rule list, and its high-level profiles (public/private/host) do not match the sbx balanced host set.

Left alone, an acq-created msb sandbox reached only the hosts the four built-in kits declare (api.gsa.usai.gov, api.github.com) plus npm when an agent is installed — dramatically more locked down than the equivalent sbx sandbox. A user who ran pip install, cargo build, apt-get, etc. inside an msb sandbox would fail on egress that "just works" on sbx.

This branch gives every msb sandbox the same egress as sbx balanced by default, composed on top of the kits' own caps.network.allow. See ADR-0018 for the full decision record.

I've also filed an upstream feature-request on msb that would make this way less messy.

What changed

  • Vendored host listacq.backends/msb-balanced-hosts.txt, a human-auditable, #-commentable verbatim mirror of the host:port rows from sbx policy inspect local-policy. Kept un-translated so it stays trivially diffable against fresh sbx output; it is the single source of truth an operator edits when sbx balanced changes.
  • Translation in one place_acq_msb_balanced_rules_into (+ helpers _acq_msb_balanced_target / _acq_msb_balanced_port_ok / _acq_msb_balanced_parse_line) in acq.backends/msb.sh translate each entry to an msb --net-rule per the grammar allow[:egress]@<target>[:<proto>[:<ports>]]:
    • **.host / *.host (sbx multi-label glob) → msb domain-suffix *.host (msb suffixes match the apex and any subdomain at any depth, so this loses no coverage).
    • :PORT:tcp:<port>; a host on both :80 and :443 yields two rules, preserved per-port.
    • crl*.digicert.com (an intra-label glob msb cannot express) → broadened to *.digicert.com, with a one-time widening note (preserves CRL reachability on :80).
    • Gateway DNS is granted explicitly so the guest can resolve allowed hosts under --net-default deny. See the DNS note below.
  • Restrict, don't merely widen — at create the adapter emits --net-default deny plus the generated allow@… rules, so egress is restricted to the balanced set (deny-by-default + allowlist) — genuine parity with sbx balanced, not "allow-all with extras".
  • Toggle, on by defaultACQ_MSB_BALANCED_EGRESS (default on) enables the baseline; 0/false/no/off/empty disables it and falls back to kit-only egress (no deny-default emitted). ACQ_MSB_BALANCED_HOSTS_FILE overrides the list path for a site-specific set.
  • Docs — ADR-0018, docs/BACKEND_GUIDE.md, and docs/KNOWN_FAILURE_MODES.md (drift/re-sync procedure + the DNS-macro failure mode below).

DNS: worked around an upstream msb bug

The baseline needs a gateway-DNS grant under --net-default deny. It originally emitted msb's semantic allow@dns macro, which hard-fails msb create/msb run on released msb (reproduced on 0.6.8):

error: the `dns` target supports `tcp`, `udp`, or `any`, not `dns`

Root cause is an upstream msb parser bug, not an acq misconfiguration: the macro's parser advances past the dns target token only inside a debug_assert_eq!, which is compiled out of a release binary — so the same dns token is re-read as the protocol slot and rejected. A bare allow@dns therefore fails on every release build that has the macro (0.6.7+).

The adapter now emits the expanded equivalent — exactly what the macro is specified to produce, on the ordinary (assert-free) parse path:

--net-rule allow@host:udp:53 --net-rule allow@host:tcp:53

Documented as failure mode 32 in docs/KNOWN_FAILURE_MODES.md, with a quarterly re-verify note to collapse it back once upstream fixes the macro.

Files

File Change
acq.backends/msb-balanced-hosts.txt New vendored balanced host list (verbatim sbx mirror)
acq.backends/msb.sh Balanced-egress emitter + translation helpers + DNS workaround
scripts/test-acq Offline coverage for translation, ports, DNS rules, toggle, real-file parse
docs/adr/0018-msb-balanced-egress-baseline.md ADR
docs/BACKEND_GUIDE.md ACQ_MSB_BALANCED_EGRESS / host-file env vars + egress model
docs/KNOWN_FAILURE_MODES.md Drift/re-sync (31) + allow@dns macro bug (32)

Verification

  • Offline unit harness: ./scripts/test-acq759 passed, 0 failed. Covers target translation (**., crl*, exact, single-label rejection), port validation, dual-port hosts, the expanded gateway-DNS rules (and that the broken allow@dns macro is never emitted), malformed-line skipping, a full parse of the real vendored file with no skips, and the default-on / =0-off provision paths.
  • Lint: shellcheck --severity=warning (one file per invocation, per the AGENTS.md hang note) — clean.
  • Live repro of the fixed bug (host, msb 0.6.8): msb run --net-default deny --net-rule "allow@dns" alpine -- true reproduces the upstream failure; the adapter's expanded rules avoid it.

Live end-to-end validation — done (host, per ADR-0018 / ADR-0011)

The full acq runmsb create → egress-parity check needs a KVM-capable host and cannot run inside a sandbox (no nested sandboxes). Ran from the host on the review/feat/msb-balanced-egress checkout; all four observations confirmed:

  • Sandbox created successfully — the acq run / msb create that previously aborted with error: the dns target supports tcp, udp, or any, not dns now completes.
  • DNS resolves in the guest — name resolution works inside the sandbox, so the expanded allow@host:udp:53 + allow@host:tcp:53 rules do their job under --net-default deny.
  • Allowed host reachable — a balanced-only host that no kit allows (e.g. pypi.org:443) is reachable from inside the sandbox.
  • Unlisted host refused — a host outside the balanced set is blocked, so the deny-by-default allowlist holds (parity with sbx balanced, not allow-all).

Rollback

Revert the branch (or, for just the DNS fix, revert e1ecc1e). At runtime, set ACQ_MSB_BALANCED_EGRESS=0 to disable the baseline entirely and fall back to kit-only egress (no behavior change vs. pre-branch msb).

Security impact

  • SC-7 (boundary protection): intentionally widens the default msb egress from kit-hosts-only to the full sbx balanced set — but remains an allowlist under deny-by-default, not allow-all. The crl**.digicert.com broadening is the only place msb is intentionally wider than sbx, and it is logged.
  • SI-10 (input validation): the semi-trusted host list drives create-time flags, so every translated target is charset-validated ([A-Za-z0-9.*_-]), single-label suffixes (*.com) are rejected, and every port is integer-validated (1–65535) before it reaches argv.
  • No new external services; the secret-injection and TLS-interception model (SC-8) is unchanged. Configuration/adapter change only (CM-2/CM-6/CM-7; SA-8/SA-15).

AI assistance

AI-assisted (OpenCode, model claude_4_8_opus); see the Co-authored-by: trailer on e1ecc1e. Human-owned and reviewed by@mogul.

OpenCode and others added 6 commits August 7, 2026 20:13
Mirror the sbx "balanced" (local-policy) network allow-list as a
human-auditable data file. The msb backend will translate each entry
into an msb --net-rule so msb sandboxes get the same egress as sbx
"balanced" (msb defaults egress to none).

Refs: ADR-0018
Add ACQ_MSB_BALANCED_EGRESS (default on) + ACQ_MSB_BALANCED_HOSTS_FILE.
_acq_msb_balanced_rules_into translates the vendored host list into msb
--net-rule tokens (**.->*. suffix, crl*.->parent suffix with a warning,
:port->:tcp:port, plus allow@dns) and acq_backend_provision emits them
with --net-default deny so egress is restricted to the balanced set,
matching sbx 'balanced'. Helpers kept under the 50-line limit.

Refs: ADR-0018
Add offline unit + provision tests for the balanced egress baseline
(target translation, port validation, dual-port hosts, crl* broadening,
malformed-line skip, real-file parse, and the default-on / =0-off
provision paths).

Fix: the documented ACQ_MSB_BALANCED_EGRESS=0 off-switch did not work —
the guard tested `[ -n "0" ]` (non-empty, so on). Normalize the value
to "1"/"" up front, treating 0/false/no/off/empty (case-insensitive)
as off.

Refs: ADR-0018
Add ADR-0018 (mirror the sbx "balanced" policy as msb's default
egress), the ACQ_MSB_BALANCED_EGRESS / ACQ_MSB_BALANCED_HOSTS_FILE
tunables and a network-egress section in BACKEND_GUIDE.md, and a
KNOWN_FAILURE_MODES entry for host-list drift + the re-sync procedure.
- Accept sbx-'balanced' parity: registry.npmjs.org is in the balanced
  set, so every sandbox (incl. shell) reaches it when the baseline is on.
  Rescope test 8n4 to the kit-only mode (ACQ_MSB_BALANCED_EGRESS=0),
  which is where the agent-conditional npm gate still applies, and
  document the interaction in ADR-0018.
- Reject leading-zero ports (e.g. 0443) in _acq_msb_balanced_port_ok.
- Extract _acq_msb_balanced_parse_line so _acq_msb_balanced_rules_into
  drops from 51 to 42 lines (under the 50-line limit).

Refs: ADR-0018
msb's semantic `allow@dns` macro hard-fails `msb create`/`msb run` on
released builds (reproduced on 0.6.8) with:
  error: the `dns` target supports `tcp`, `udp`, or `any`, not `dns`

The macro's parser advances past the `dns` target token only inside a
`debug_assert_eq!`, which is compiled out of the release binary, so the
same `dns` token is re-read as the protocol slot and rejected. This broke
the balanced-egress baseline (ADR-0018), which emitted `allow@dns` under
`--net-default deny`.

Emit the expanded equivalent the macro is specified to produce — the
gateway `host` group on UDP/TCP port 53 — via the ordinary, assert-free
parse path, which is unaffected by the upstream bug:
  --net-rule allow@host:udp:53 --net-rule allow@host:tcp:53

Update the balanced-egress tests to assert the expanded rules and that the
broken `allow@dns` macro is never emitted; document the upstream bug and
workaround in KNOWN_FAILURE_MODES (#32), ADR-0018, and BACKEND_GUIDE.

Verified: scripts/test-acq (759 passed, 0 failed); shellcheck clean.

Co-authored-by: OpenCode [claude_4_8_opus] <bret.mogilefsky@gsa.gov>
@mogul

mogul commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Note this is all a seed for a larger epic.

The balanced-egress baseline emitted the symmetric `--net-default deny`,
which msb applies to BOTH directions. Published ports (`-p HOST:GUEST`)
get no implicit ingress-allow, so every inbound connection fell through
to the ingress default and was RST-rejected: a host `curl` to a
create-time published port completed the TCP handshake via msb's host
proxy, then reset on data (ERR_CONNECTION_RESET), even though the guest
service was healthy and listening.

Emit `--net-default-egress deny` instead. Egress stays deny-by-default +
allowlist (ADR-0018's intent) while ingress keeps msb's baseline `allow`,
so create-time published ports are reachable with no per-port rule. A
future "strict" profile can layer ingress deny-default + explicit
`allow:ingress@...` rules; the balanced default intentionally does not.

Requires the `--net-default-egress`/`--net-default-ingress` split
(confirmed on the pinned msb 0.6.8).

- msb.sh: emit `--net-default-egress deny`; update baseline + DNS comments
- test-acq: assert egress-only deny-default, and NOT a symmetric
  `--net-default deny` nor an ingress deny
- ADR-0019: document the egress-only decision + strict-profile hook;
  amend ADR-0018; update BACKEND_GUIDE
- scripts/verify-net-default-egress: live regression guard (create-time
  publish reachable + egress still locked)
@mogul

mogul commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Fix: create-time published ports were unreachable under the balanced-egress default (ERR_CONNECTION_RESET)

Follow-up on this branch: create-time published ports (-p HOST:GUEST, e.g. the
openchamber UI on 3000 and the OpenCode server on 4096) were unreachable from the
host. A host curl completed the TCP handshake and then reset
(Recv failure: Connection reset by peer / ERR_CONNECTION_RESET), even though
the guest service was healthy and listening on 0.0.0.0.

Root cause: the baseline emitted the symmetric --net-default deny. In
msb that flag sets the default for both directions (--help: "Sets egress
and ingress symmetrically"
). A published port has no implicit ingress-allow —
msb evaluates every accepted connection against default_ingress — so a
symmetric deny RST-rejects inbound. (Post-hoc acq ports --publish was
unaffected because its ssh -L forward originates inside the guest, so it isn't
evaluated as ingress — which is why that workaround kept working.)

Fix: emit --net-default-egress deny instead. Egress stays deny-by-default +
balanced allowlist (this PR's intent); ingress keeps msb's baseline allow, so
published ports are reachable with no per-port rule. Ingress deny-default is
intentionally reserved for a future opt-in "strict" profile. Requires the
--net-default-egress/--net-default-ingress split (confirmed on the pinned
msb 0.6.8).

Changes

  • acq.backends/msb.sh: emit --net-default-egress deny; comment updates
  • scripts/test-acq: assert egress-only deny-default (and NOT a symmetric
    --net-default deny nor an ingress deny) — 761/761 pass
  • docs/adr/0019-msb-balanced-egress-is-egress-only.md (new); amend ADR-0018;
    update docs/BACKEND_GUIDE.md
  • scripts/verify-net-default-egress (new): live regression guard for the
    create-time NAT publish path

Verification

  • Offline: scripts/test-acq — 761 passed, 0 failed
  • Live (KVM host): scripts/verify-net-default-egress — passed (host reaches a
    create-time published port with no acq ports --publish; in-guest egress to an
    unlisted host is still refused)

Security posture: unchanged for egress (deny-by-default + balanced
allowlist). Ingress default-allow matches msb's own profile baseline
(from_profiles: default_egress: Deny, default_ingress: Allow) and does not
widen egress; inbound is still bounded by which ports are published.

@wz-gsa

wz-gsa commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Adversarial review — #295 (msb balanced egress)

I read the emitter, the vendored host list, both ADRs, and the test deltas, then verified every upstream-msb claim against the microsandbox source on main (crates/cli/lib/net_rule.rs, crates/network/lib/policy/types.rs, crates/cli/lib/commands/common.rs). Verdict: the design is sound and the two hard technical claims check out. One blocking correctness gap, then should-fix / nits.

What I verified as CORRECT (receipts)

  • allow@dns release-build bug (ADR-0018 DNS block). Confirmed. parse_dns_rule advances the target token inside debug_assert_eq!(parts.next(), Some("dns")); in a release build the macro (and its parts.next() side effect) is compiled out, so proto_raw = parts.next() reads "dns" and returns InvalidDnsProtocol{raw:"dns"} → the exact error string quoted. The expanded workaround allow@host:udp:53 + allow@host:tcp:53 is provably equivalent to what parse_dns_rule is specified to emit (Destination::Group(Host), protocols [Udp,Tcp], port 53). ✅
  • ADR-0019 egress-only reasoning. Confirmed against build_network_policy: with rules + --net-default-egress deny and no --net profile, default_ingress falls through to NetworkPolicy::default() (from_profiles([Public])) = Action::Allow. So published ports stay reachable; a symmetric --net-default deny would have set default_ingress = Deny and RST inbound. The ERR_CONNECTION_RESET root-cause is accurate. ✅
  • Wildcard translation + single-label rejection. *.host in msb matches apex + any depth (matches_suffix is label-aligned, so evilexample.com does NOT match example.com), and *.com is rejected upstream as SuffixTooBroad — the adapter's own single-label drop is correct defense-in-depth. **.*. loses no coverage. ✅
  • :80 / no-SNI hosts (CRL/OCSP) still match via the resolved-hostname cache (HostnameSource::CacheOnlyany_resolved_hostname), given the DNS grant is emitted first. ✅
  • SI-10 hardening (charset guard, leading-zero port reject, malformed-line skip, CRLF \r rejected by the charset class) — all fail-safe. ✅
  • No secrets/PII/internal hosts in the vendored list (all public vendor endpoints). ✅

BLOCKING — version-floor gap makes the default break acq on msb 0.6.0–0.6.7

--net-default-egress is a 0.6.8 flag (the PR says so), but MIN_MSB_VERSION stays 0.6.0 and the balanced block is default-ON with no per-emit version guard (acq.backends/msb.sh, the if [ -n "$ACQ_MSB_BALANCED_EGRESS" ] block ~L1774). On any msb in [0.6.0, 0.6.8) a plain acq create/acq run will pass --net-default-egress deny to a binary that doesn't know the flag → clap hard-errors → every sandbox creation fails out of the box, and the user has no obvious signal that ACQ_MSB_BALANCED_EGRESS=0 is the escape hatch. Since balanced-on is the default, this regresses the common path for anyone not yet on 0.6.8.

Pick one:

  1. Bump MIN_MSB_VERSION to 0.6.8 (cleanest — acq_backend_prepare already fails closed on the floor), or
  2. Guard the emit: only add --net-default-egress deny + the balanced block when _acq_msb_version_ge "$current" 0.6.8; below that, warn once and fall back to kit-only egress.

I'd take (1) unless you must keep 0.6.7 working. Either way add a test asserting the sub-0.6.8 path doesn't emit the flag.

Should-fix

  • registry.npmjs.org double-emit. With balanced ON, the host list already allows registry.npmjs.org:tcp:443, then the agent-install block emits a second bare allow@registry.npmjs.org (any port). Harmless (both allow, no deny to shadow), but dead-weight. Consider skipping the npm rule when balanced is ON, or note the intentional redundancy.
  • QUIC/HTTP-3 to allowed :443 hosts is silently denied. --tls-intercept blocks QUIC unless --no-block-quic, and the balanced rules are tcp:443 only (no udp:443). Net: HTTP/3-only egress fails and clients fall back to TCP — same as sbx balanced, so acceptable, but worth one line in BACKEND_GUIDE so a slow-first-connection isn't mistaken for a bug.

Nits

  • crl*.digicert.com*.digicert.com also matches www./order. digicert and makes the sibling *.one.digicert.com / cacerts. / ocsp.digicert.com entries redundant. Bounded to one vendor and logged — fine, just noting the redundancy.
  • Ingress default-allow blast radius (documented, but restate for the record): inbound reachability is bounded by which ports are actually published (-p) + the guest bind, and msb has no NAT ingress for unpublished ports, so ingress-allow is not a general exposure. The future strict-profile hook (per-port allow:ingress@…) is the right place to tighten. No action.

Neutral-tier convergence (my ADR #299 / epic #303)

This is the msb worked example my neutral network.tier ADR generalizes; I've already cross-referenced #295/ADR-0018/0019 there and set the neutral default to balanced to match you. When #300/#294 land, this emitter + vendored list should be adopted under the tier (baseline location TBD per your ADR-0018 "migrate the vendored file into a patterns kit" note), and the ACQ_MSB_BALANCED_EGRESS toggle reconciled with the strict/balanced/open selector.

Net: strong PR, verified deep. Fix the version-floor blocker and I'd approve.

AI-assisted (OpenCode); adversarial review, upstream-source-verified. Not an approval — flagging one blocker.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants