Skip to content

feat(integrations): add neutral hybrid/v1 acq-kits + schema + registry - #221

Merged
mogul merged 11 commits into
mainfrom
feat/acq-phase2-add-kits
Jul 16, 2026
Merged

mogul merged 11 commits into
mainfrom
feat/acq-phase2-add-kits

Conversation

@mogul

@mogul mogul commented Jul 15, 2026 •

Copy link
Copy Markdown
Contributor

Context

Phase 2 of the acq pluggable-backend effort (handoff + design docs in
agentic-coding-quickstart, tracking PR GSA-TTS/agentic-coding-quickstart#202)
adds a second isolation backend (msb) to acq. To let two backends share one
kit vocabulary, it introduces a neutral schemaVersion: "hybrid/v1" kit spec.

This PR is Part A (the patterns-repo half): author the neutral kits, their
JSON schema, and a registry; leave the old sbx-kits/ as a one-release redirect.
Part B (the msb adapter + kit-translate.sh + acq kit … in the quickstart
repo) is gated on the merge SHA this PR produces — do not start Part B's kit-ref
repoint until this merges (handoff §4.1).

Per a reviewer decision, the new neutral home is
integrations/isolation/acq-kits/ (not the handoff's kits/) to make it
explicit the kits target acq — which selects a backend — rather than sbx
alone.

Plan (all landed)

  • schemas/kit-hybrid-v1.schema.json — neutral kit JSON Schema
  • Redirect sbx-kits/README.md → acq-kits/
  • acq-kits/usai-provider/ (hybrid/v1 + moved node tooling/tests)
  • acq-kits/agentic-coding-playbook/ (+ extracted files/home/playbook-clone.sh)
  • acq-kits/zscaler-ca-certificate/ (+ backend_shortcuts.msb.trust_host_cas)
  • acq-kits/git-ssh-sign/ (+ vendored LICENSE/NOTICE)
  • acq-kits/kits.yaml registry + acq-kits/README.md + validate-kits.py
  • ADR integrations/isolation/docs/decisions/0001-neutral-hybrid-v1-acq-kits.md

What changed (summary)

  • New neutral spec + schema. hybrid/v1 captures caps.network.allow,
    files[] (content or source: + optional phase), commands[]
    (phase/user/command), agentContext, and backend_shortcuts /
    backend_extras. A repo validator enforces the cross-field rules JSON Schema
    can't (source resolution, known-backend keys, README presence, registry
    cross-check).
  • Four kits converted, payloads carried verbatim. The playbook's inline
    startup shell was extracted to a tested playbook-clone.sh; the Zscaler CA
    moved from an inline PEM to a files/ payload and gained the msb
    --trust-host-cas shortcut (the backend_shortcuts showcase).
  • Registry kits.yaml (name → [sbx, msb] + parity prose).
  • One-release redirect: sbx-kits/README.md points to acq-kits/; old kit
    contents left intact (nothing deleted).
  • Directory names normalized to match kit name (dropped the -kit suffix).

Verification transcript

Run in the project environment (python3 with jsonschema==4.26.0, node v22):

$ python3 -c "…Draft202012Validator.check_schema(kit-hybrid-v1)…"
OK

$ python3 integrations/isolation/acq-kits/validate-kits.py
  OK  agentic-coding-playbook
  OK  git-ssh-sign
  OK  usai-provider
  OK  zscaler-ca-certificate
All 4 acq-kits valid.                     # schema + source paths + known backends + registry cross-check

$ python3 scripts/validate_repo.py
✓ All validators passed                   # frontmatter (35/35) + sensitive-terms

$ python3 scripts/scan_unsafe_shell.py .
unsafe-shell scan: 0 error(s), 0 warning(s)

$ node --test integrations/isolation/acq-kits/usai-provider/tests/**/*.test.mjs
# tests 41 / # pass 41 / # fail 0

$ bash -n  <every acq-kits *.sh / verify / ssh-signing-key-command>
  (all clean)

$ openssl x509 -in .../zscaler-ca-certificate/files/home/zscaler-ca.crt -noout -fingerprint -sha256
04:F6:1F:1D:…:1A:53               # == documented Zscaler Root CA

# behavioral spot-check: playbook-clone.sh with a bad ref
$ HOME=… PLAYBOOK_REF=does-not-exist … sh playbook-clone.sh ; echo $?
0                                          # graceful non-fatal degrade, no clone left behind

Deferred (documented in the ADR): live acq run per-backend end-to-end
verification cannot run inside an sbx sandbox (no nested sandboxes), same posture
as quickstart ADR-0009/0010. Each kit's scripts/verify runs on a
sandbox-capable host; msb --trust-host-cas and SSH-forwarding parity are
covered by Part B's scripts/verify-backends.

CI note for the reviewer: pre-commit/gitleaks/markdownlint-cli2
aren't installed in this worktree's container, so those hooks are exercised by
CI on your host (that's the point of the early push). New .md under
acq-kits/ are READMEs/decisions — outside the frontmatter validator's
SKILL.md/AGENTS.md globs — so they don't require pattern frontmatter, and
INDEX.yaml is unaffected (kits aren't skills/agents).

Rollback

Revert the squash-merge commit. The old sbx-kits/ kit contents are retained
unchanged (only the README became a redirect), so anything currently pinning
sbx-kits/… keeps working; reverting restores the original README.

Security impact

  • No new runtime code paths in this repo; kits are declarative specs consumed by
    acq (Part B). No secrets added — credentials remain injected at runtime by
    the backend (documented per kit).
  • The Zscaler kit ships only the public Zscaler Root CA (fingerprint
    verified above; no private key).
  • Kit caps.network.allow egress lists are carried over verbatim from the
    reviewed sbx kits (no new hosts).
  • git-ssh-sign retains its Apache-2.0 LICENSE/NOTICE and the vendoring +
    public-source intake records.

AI-assisted (OpenCode Agent, model: claude_4_8_opus). All commits SSH-signed.

mogul and others added 7 commits July 15, 2026 20:20
First slice of Phase 2 Part A (neutral acq-kits). Adds the backend-agnostic
kit JSON Schema (schemaVersion: "hybrid/v1") that the four converted kits
will validate against, and turns the old sbx-kits/ README into a one-release
redirect pointing at the new integrations/isolation/acq-kits/ home.

Kits, registry, and the migration ADR land in follow-up commits on this branch.

Part A of the acq Phase 2 handoff (agentic-coding-quickstart#202).

AI-assisted (OpenCode Agent, model: claude_4_8_opus).

Co-authored-by: OpenCode Agent <[email protected]>
Convert the usai-provider sbx kit to the neutral hybrid/v1 spec under
integrations/isolation/acq-kits/usai-provider/. Payloads (opencode.jsonc,
merge-global-config.mjs) carried verbatim; the node tooling and tests
(package.json, scripts/sync-usai-models.mjs, tests/) move here so the new home
is the single source of truth.

Also:
- extend kit-hybrid-v1 schema with files[].source (payload file under the kit's
  files/ tree) as an alternative to inline content, for multi-line payloads.
- add integrations/isolation/acq-kits/validate-kits.py: schema-validates every
  acq-kit spec and checks source paths + known-backend keys + README presence
  (the backend-agnostic gate; live per-backend checks stay in scripts/verify).

Verification:
- validate-kits.py: usai-provider OK
- node --test tests/**: 41 pass / 0 fail
- bash -n scripts/verify: clean
- scan_unsafe_shell.py acq-kits/: 0 errors, 0 warnings

Part A of the acq Phase 2 handoff (agentic-coding-quickstart#202).

AI-assisted (OpenCode Agent, model: claude_4_8_opus).

Co-authored-by: OpenCode Agent <[email protected]>
Convert the playbook sbx kit to the neutral hybrid/v1 spec. Per the acq design
doc §6, the ~80-line inline startup shell is extracted into a standalone,
lintable, testable script at files/home/playbook-clone.sh (dropped to
/home/agent/playbook-clone.sh, run at startup). Clone-integrity SHA gate,
idempotency, and graceful non-fatal degradation are unchanged.

New decision record: extract-clone-script-to-file.md.

Verification:
- validate-kits.py: agentic-coding-playbook OK
- bash -n playbook-clone.sh + scripts/verify: clean
- scan_unsafe_shell.py acq-kits/: 0 errors, 0 warnings
- ran playbook-clone.sh with a bad ref: exits 0, no clone left behind
  (graceful degradation path confirmed)

Part A of the acq Phase 2 handoff (agentic-coding-quickstart#202).

AI-assisted (OpenCode Agent, model: claude_4_8_opus).

Co-authored-by: OpenCode Agent <[email protected]>
Convert the Zscaler CA sbx kit to the neutral hybrid/v1 spec. This kit is the
showcase for backend_shortcuts: declares backend_shortcuts.msb.trust_host_cas
so the msb adapter uses its native --trust-host-cas primitive (and skips the
file-drop), while sbx (and ppp later) fall through to the file-drop +
update-ca-certificates path. Behavioral parity: the guest trusts the Zscaler CA
either way (README parity note).

The public Zscaler Root CA moves from an inline spec PEM to a files/ payload
(files/home/zscaler-ca.crt) with the initFiles phase hint, preserving the exact
create-time ordering. scripts/verify now reads the payload (falls back to inline).

Verification:
- validate-kits.py: zscaler-ca-certificate OK
- openssl fingerprint == documented Zscaler Root CA
  (04:F6:1F:...:1A:53)
- bash -n scripts/verify: clean
- scan_unsafe_shell.py acq-kits/: 0 errors, 0 warnings

Part A of the acq Phase 2 handoff (agentic-coding-quickstart#202).

AI-assisted (OpenCode Agent, model: claude_4_8_opus).

Co-authored-by: OpenCode Agent <[email protected]>
Convert the vendored git-ssh-sign kit to the neutral hybrid/v1 spec. The
signing-time key-resolution command moves from an inline initFiles blob to a
files/ payload (files/home/.config/git/ssh-signing-key-command, mode 0755); the
system-gitconfig install command carries the install phase. No backend shortcut
— SSH-agent forwarding is the shared mechanism, enabled natively per backend
(sbx socket forward; msb 'ssh authorize'; ppp 'podman machine ssh -A'), noted
in the README parity table.

Apache-2.0 provenance retained: LICENSE + NOTICE (updated with the hybrid/v1
port note) + docs/decisions/vendored-from-sbx-kits-contrib.md +
docs/security-skill-intake.md carried over.

Verification:
- validate-kits.py: git-ssh-sign OK (all 4 kits valid)
- bash -n ssh-signing-key-command + scripts/verify: clean
- scan_unsafe_shell.py acq-kits/: 0 errors, 0 warnings

Part A of the acq Phase 2 handoff (agentic-coding-quickstart#202).

AI-assisted (OpenCode Agent, model: claude_4_8_opus).

Co-authored-by: OpenCode Agent <[email protected]>
Add integrations/isolation/acq-kits/kits.yaml (name -> backends + parity prose,
sourced from the acq design doc §6) and the acq-kits/ area README. Extend
validate-kits.py with a registry cross-check: kits.yaml must list exactly the
kits present (no missing, no unknown entries).

For Phase 2 each kit lists backends: [sbx, msb] (ppp arrives in Phase 3).

Verification:
- validate-kits.py: all 4 kits valid + registry cross-check passes
- kits.yaml parses as YAML

Part A of the acq Phase 2 handoff (agentic-coding-quickstart#202).

AI-assisted (OpenCode Agent, model: claude_4_8_opus).

Co-authored-by: OpenCode Agent <[email protected]>
Area-scoped ADR (integrations/isolation/docs/decisions/0001) recording the
sbx-kits -> acq-kits move: why the neutral hybrid/v1 spec, the four kits' new
shape, the msb --trust-host-cas shortcut for zscaler-ca, the registry, the
dir-name normalization, and the one-release redirect. Also records deferred
live per-backend verification (no nested sandboxes) and the Part B follow-ups
gated on this PR's merge SHA.

Verification:
- relative links resolved (schema, registry, repo-wide ADR)
- validate-kits.py + validate_repo.py green

Part A of the acq Phase 2 handoff (agentic-coding-quickstart#202).

AI-assisted (OpenCode Agent, model: claude_4_8_opus).

Co-authored-by: OpenCode Agent <[email protected]>
@mogul
mogul marked this pull request as ready for review July 15, 2026 20:39
@mogul
mogul requested a review from a team as a code owner July 15, 2026 20:39
mogul and others added 3 commits July 15, 2026 20:44
…MD028)

Markdown Lint CI flagged MD028 (blank line inside blockquote) at
integrations/isolation/acq-kits/git-ssh-sign/README.md:16 — two consecutive
blockquotes separated by a blank line. Join them with a '>' continuation line.

AI-assisted (OpenCode Agent, model: claude_4_8_opus).

Co-authored-by: OpenCode Agent <[email protected]>
…lize usai-provider name

Address review feedback from PR #221:

- validate-kits.py: guard against a non-dict spec (empty/scalar spec.yaml)
  before the dict-shaped checks so a malformed kit is reported as invalid
  rather than crashing the whole run with an AttributeError; also validate the
  registry root is a mapping.
- Wire the offline gate into CI + one-command entry points so the ADR claim
  ('runs offline in CI') is true: 'make validate-kits' / 'make test-kits'
  (folded into 'make ci'), a 'validate-acq-kits' pre-commit hook, and CI steps
  in ci.yml (Pattern Validation runs the schema gate; new 'acq-kits Tests' job
  runs the node suites). ADR + acq-kits README updated to point at them.
- Normalize the lingering '-kit' suffix to match the normalized dir/spec name:
  package.json name -> usai-provider, and the ownership marker comment
  usai-provider:owns-opencode-config (opencode.jsonc, merge test, ADR-0002,
  README) — the marker is a fresh token, no existing sandboxes depend on it.

Verification:
- validate-kits.py: 4/4 valid; malformed empty+scalar specs now report cleanly
  (exit 1, no traceback)
- node --test: 41/41 pass (marker rename reflected in tests)
- validate_repo.py + scan_unsafe_shell.py: clean
- ci.yml / .pre-commit-config.yaml parse as YAML

Part A of the acq Phase 2 handoff (agentic-coding-quickstart#202).

AI-assisted (OpenCode Agent, model: claude_4_8_opus).

Co-authored-by: OpenCode Agent <[email protected]>
Review finding L1 (documentation-only): the signing-key command trusts whatever
key the forwarded SSH agent presents first and writes allowed_signers keyed to a
possibly-unset email. Add a header comment stating this is a sandbox
self-signing convenience and the emitted allowed_signers file must not be reused
as an authorization root outside the ephemeral sandbox. No behavior change.

AI-assisted (OpenCode Agent, model: claude_4_8_opus).

Co-authored-by: OpenCode Agent <[email protected]>
@mogul

mogul commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

@wz-gsa can you run a review loop here? I already did a few locally using the playbook skills so it should be pretty clean. I need to get this one merged ASAP to unblock GSA-TTS/agentic-coding-quickstart#202

@wz-gsa wz-gsa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — #221 feat(integrations): neutral hybrid/v1 acq-kits + schema + registry. Verdict: request changes. No blockers — but harden the validator you're selling as the safety net before it lands.

Big PR, and mostly good. I checked drift, provenance, schema, CI, and the validator. What holds up: the re-homed merge-global-config.mjs and sync-usai-models.mjs are byte-identical to the versions already reviewed (opencode.jsonc differs only in the ownership-marker string — kit rename, benign). git-ssh-sign vendoring is clean — Apache-2.0 LICENSE + NOTICE + provenance SHA + ADR + intake record, and the signing-key-command touches only the public key, no eval/exec, shellcheck-clean. CI Actions are SHA-pinned, permissions: contents:read, no injection surface. Schema is additionalProperties:false on the structured objects; registry matches disk; per-kit phase usage matches intent. Good work.

Two things to fix, both in validate-kits.py — which is the gate this PR advertises as its safety net, so it has to actually hold:

1. It's not fail-closed. An empty spec.yaml (yaml.safe_load → None) or a top-level list crashes validate_kit with a raw AttributeError: 'NoneType' object has no attribute 'get' — and in my repro it exited 0, i.e. a stacktrace, not a clean FAIL: with non-zero exit. Worse, the registry-name harvest wraps the same load in a bare except Exception: pass, so a malformed spec silently drops out of the drift cross-check — masking the exact error that block exists to catch. Fix: guard if not isinstance(spec, dict): return ["<kit>: spec.yaml is not a mapping"] early, and narrow the bare except.

2. Can-pass-but-broken. The validator checks files[].source exists on the host, but never that commands[] argv reference in-guest paths that any files[] actually drops. Verified: a kit whose commands[] runs /home/agent/NEVER-DROPPED.sh (dropped by nothing) passes with zero errors. A re-home typo sails through the offline gate and only fails live. A cheap best-effort files[].path ↔ commands[] consistency check (warn-level) would catch real regressions — which is the whole point of a re-home gate.

Two smaller ones, non-blocking:

  • backend_shortcuts.{sbx,msb,ppp} / backend_extras.* are bare type:object — arbitrary content validates (I confirmed {"run":"rm -rf /"} passes). The schema comment and ADR say "kept in-spec so review covers it," but schema-wise nothing constrains it. It's a deliberate escape hatch, fine — just downgrade the doc claim from "review covers it" to "human-review-only; not schema-enforced."
  • ppp is a pure YAGNI enum slot — it's in the schema, KNOWN_BACKENDS, and prose, with no adapter or consumer anywhere. msb earns its place (real in-flight backend + an exercised zscaler.backend_shortcuts.msb shortcut); ppp doesn't yet. Drop it from the enum until a Phase-3 consumer exists, or mark it explicitly reserved-unused (right now it reads as supported).

The abstraction is justified — msb is a real second backend, not speculation — and it's well documented. This isn't a redesign. Fix the two validator gaps so the gate actually fails closed, and it's an approve.

Address @wz-gsa CHANGES_REQUESTED on PR #221 — make the validator the PR sells
as its safety net actually fail closed.

Finding 1 (fail-closed): already landed in the prior commit — a non-dict/empty
spec.yaml now returns a clean per-kit error (exit 1, no AttributeError
traceback), and the registry name-harvest catch is narrowed to
(yaml.YAMLError, OSError) instead of bare Exception. Re-confirmed with empty and
top-level-list spec repros.

Finding 2 (can-pass-but-broken): add a best-effort commands[] <-> files[]
consistency check. A kit-owned payload path (/home/... .sh/.mjs/.py/.crt/.pem)
referenced by commands[] argv but dropped by no files[] entry is almost always
a re-home typo. Emitted as a WARN by default (heuristic: a command may create a
file at runtime); a new --strict flag promotes warnings to failures, and the
Makefile / pre-commit / CI gate all run --strict so a re-home typo fails the
offline gate instead of only failing live.

Nits:
- backend_shortcuts/backend_extras: schema descriptions + ADR + acq-kits README
  now state the per-backend values are unconstrained objects, human-review-only
  and NOT schema-enforced (was 'kept in-spec so review covers it').
- ppp: kept (Podman backend is in active development, imminent Phase 3) but
  explicitly documented as a RESERVED slot with no consumer yet, in the schema
  descriptions, ADR, and README — so it no longer reads as already-supported.

Verification:
- validate-kits.py --strict: 4/4 valid, 0 warnings
- negative repros: empty/list spec -> exit 1; commands-without-drop -> WARN
  (exit 0 default) / exit 1 (--strict)
- node --test: 41/41; validate_repo + scan_unsafe_shell clean; schema
  check_schema OK; unknown backend key still rejected, ppp extras accepted
- ci.yml / .pre-commit-config.yaml parse as YAML

Part A of the acq Phase 2 handoff (agentic-coding-quickstart#202).

AI-assisted (OpenCode Agent, model: claude_4_8_opus).

Co-authored-by: OpenCode Agent <[email protected]>
@mogul
mogul merged commit f386458 into main Jul 16, 2026
8 checks passed
@mogul
mogul deleted the feat/acq-phase2-add-kits branch July 16, 2026 16:01
mogul added a commit to GSA-TTS/agentic-coding-quickstart that referenced this pull request Jul 16, 2026
…release gate met

Part A (patterns PR #221: neutral hybrid/v1 acq-kits + kit-hybrid-v1 schema +
registry) merged to agentic-coding-patterns main and shipped in the v1.6.0
release. Flip PATTERNS_KIT_REF from the provisional pre-merge PR-head SHA to the
v1.6.0 release commit e387c59bb2f743eb321bfb56a8ac71a6abb185ae, satisfying the
Phase 2 handoff §4.1 hard gate. Pinning to a release tag mirrors Phase 1's
v1.5.0 pin; v1.6.0 includes Part A unchanged (it also adds an unrelated sbx kit
+ a validation script — the acq-kits and schema are identical to the #221 merge).

Verified against the pinned commit: live sparse-fetch of all four acq-kits, and
neutral->sbx-v2 translation + kit_validate of each, all pass.

- common.sh: PATTERNS_KIT_REF -> e387c59…; drop the provisional-pin TODO.
- test-acq: ref assertions -> e387c59.
- ADR-0011 + acq-design appendix: release gate marked satisfied.

test-acq 124/124; test-migrate-or-halt 57/57; markdownlint clean; shellcheck
0.11.0 (--severity=warning) clean.

Refs: GSA-TTS/agentic-coding-patterns#221
Co-authored-by: OpenCode Agent <[email protected]>
mogul added a commit to GSA-TTS/agentic-coding-quickstart that referenced this pull request Jul 16, 2026
…cope + create-scrub

Address adversarial review of PR #202.

FAIL (medium) — root command injection via unvalidated kit `mode`. A kit spec's
files[].mode was interpolated unquoted into a root `sh -c "chmod $mode '$path'"`,
so a hostile/typo'd spec (patterns repo / ACQ_EXTRA_KITS) with
`mode: "0644; wget|sh"` ran arbitrary code as root in the guest. Fixes:
- kit_spec_files now DROPS any record whose mode isn't octal ([0-7]{3,4}) or
  whose path/source contains shell metacharacters/whitespace (warns to stderr).
- kit_spec_commands DROPS any command whose user isn't a bare uid/safe username
  or whose phase isn't install|initFiles|startup (both reach `msb exec -u`).
- msb _acq_msb_copy_file_verified re-validates path up front and mode at use,
  and runs `chmod`/`chown` as argv (msb exec -u 0 -- chmod MODE PATH) instead of
  an `sh -c` string — no interpolation into a shell.
- kit_validate scans the RAW spec so a bad mode/phase/user is REPORTED (not just
  silently dropped) and makes `acq kit validate` fail.
Command argv was already base64-tokenized + passed as separate argv (not a
joined sh -c), and net-allow hosts were already charset-validated; this closes
the mode/path/user gap. The mode/user validation also belongs in patterns
#221's validate-kits.py at the gate (noted for that PR); acq validates
defensively regardless.

CONCERN (medium) — document that --tls-intercept is guest-global. ADR-0011 now
states interception covers all guest egress (guest auto-trusts the interception
CA), while secret substitution stays host-scoped — a broader posture than sbx's
proxy injection, and the price of the microVM boundary. Added SI-10 to controls.

CONCERN (low) — set -e skipped the create-failure secret scrub. Under
`set -euo pipefail` a failing `msb create` aborted before the rc capture, so the
error block AND the transient-secret unset never ran. Use `|| _create_rc=$?`.

Nit — secret store: get now falls back to the file backend under
ACQ_SECRET_ALLOW_ARGV so a value stored without the flag is still found.

Doc nits: ADR date 2026-07-16, "135 checks" (was 79).

Tests: +5 security cases (hostile mode dropped, msb chmod argv/refusal, hostile
command user dropped, `acq kit validate` reports mode/phase). test-acq 135/135;
test-migrate-or-halt 57/57; markdownlint clean; shellcheck 0.11.0 clean.

Refs: #202, #203, GSA-TTS/agentic-coding-patterns#221
Co-authored-by: OpenCode Agent <[email protected]>
mogul added a commit that referenced this pull request Jul 16, 2026
#224)

* feat(acq-kits): convert openchamber to hybrid/v1 and move to acq-kits/

Closes #223.

The openchamber kit (added in #220) was left in the deprecated
sbx-kits/ tree as a schemaVersion "2" sbx spec when #221 migrated the
other four kits to the neutral hybrid/v1 acq-kits/ layout. This converts
it to match.

Changes:
- Move integrations/isolation/sbx-kits/openchamber/ -> acq-kits/openchamber/.
- Rewrite spec.yaml as schemaVersion: "hybrid/v1": neutral caps.network,
  a files[] entry, and a startup-phase command. The former inline startup
  body is extracted to files/home/openchamber-start.sh (backend-agnostic,
  lintable/testable) — the same pattern agentic-coding-playbook uses. This
  reverses the old inline-startup-script ADR, which is safe now: that ADR
  worked around sbx's create-time static-file *content* hook (#118); the
  neutral files[].source drop is a different, working mechanism.
- Port publishing + background startup have no neutral vocabulary yet, so
  they live in backend_extras.sbx (publishedPorts + background). openchamber
  is therefore sbx-only for now; msb/ppp support is deferred until acq
  defines a neutral port-publish + background vocab (noted in the README
  parity table + kits.yaml, tracked as a follow-up on #223).
- Add openchamber to the kits.yaml registry (backends: [sbx] + parity prose).
- Rewrite README as a hybrid/v1 kit with a backend-parity section; retarget
  TROUBLESHOOTING and scripts/verify to acq (verify now runs the neutral
  validate-kits.py gate and only attempts the live sbx run under RUN_SBX=1,
  since the neutral spec needs acq's kit-translate for the sbx port hint).
- Update the openchamber docs/decisions for the new home; drop the interim
  "Still here: openchamber" note from sbx-kits/README.md.

Verification (offline gate; live per-backend run deferred, no nested
sandboxes here — same posture as ADR-0001):
- validate-kits.py --strict: all 5 kits OK (openchamber included)
- scripts/validate_repo.py: all validators passed
- scripts/scan_unsafe_shell.py: 0 errors
- markdownlint-cli2 (integrations/isolation/**/*.md): 0 errors
- sh -n openchamber-start.sh; bash -n scripts/verify: clean
- scripts/verify (offline default): 2/2 passed, live checks skipped

Co-authored-by: OpenCode Agent <[email protected]>

* fix(acq-kits): drop bash-only $RANDOM from openchamber password fallback

openchamber-start.sh is #!/bin/sh but the last-ditch password fallback
used $RANDOM, a bash-ism undefined in POSIX sh (shellcheck SC3028). The
branch is effectively unreachable (only if both openssl and /dev/urandom
fail), but now that the script is standalone and lintable, keep it
sh-portable: replace $RANDOM with a pid + nanosecond-timestamp + awk-PRNG
mix. Shebang stays #!/bin/sh (the neutral kits run under sh/dash). Flagged
in the #224 review.

Co-authored-by: OpenCode Agent <[email protected]>

---------

Co-authored-by: OpenCode Agent <[email protected]>
mogul added a commit to GSA-TTS/agentic-coding-quickstart that referenced this pull request Jul 17, 2026
* docs(acq): relabel phases and add Phase 2 handoff

Relabel the acq-design.md implementation appendix so the primary axis is a
phase number (Phase 1-4) with the release version kept as a secondary column,
and update inline references accordingly. No deviation substance changed.

Add docs/explorations/acq-handoff-2.0.md: a two-part Phase 2 handoff (patterns
repo first for the neutral hybrid/v1 kits, then quickstart for the msb backend
and sbx conversion) with a hard SHA gate between the parts, known-drift
callouts, and out-of-scope fences (no ppp/Phase 3, no qsbx removal/Phase 4).

AI-assisted.

Co-authored-by: OpenCode Agent <[email protected]>

* feat(acq): add msb backend and neutral hybrid/v1 kit translation

Add a second isolation backend (msb, microsandbox) to acq and introduce the
neutral hybrid/v1 kit vocabulary so sbx and msb share one kit set. Part B of
the Phase 2 handoff (docs/explorations/acq-handoff-2.0.md).

- acq.backends/kit-translate.sh: shared neutral-spec layer (awk parser, no yq).
  Fetches kit refs (remote git+https#ref=&dir= via sparse checkout, or local
  dir), dispatches backend_shortcuts.<backend>, synthesizes an sbx-v2 kit dir
  for the sbx path, and provides kit_validate. Multi-line command bodies are
  base64-framed so literal block scalars survive the shell pipeline.
- acq.backends/msb.sh: full microsandbox adapter (ADR-0010 contract). Drives
  neutral ops directly: caps.network.allow -> --net-rule, files[] -> msb copy,
  commands[] -> msb exec (install marker-gated, tested+written as uid 0),
  zscaler shortcut -> --trust-host-cas, USAi key -> --secret ENV@HOST. Flags
  verified against msb 0.6.6.
- common.sh: repoint PATTERNS_KIT_REF/DIR to the neutral acq-kits/ tree; source
  kit-translate; _auto_detect_backend gains msb (sbx preferred); real msb probe
  in acq doctor.
- sbx.sh: route kit application through kit-translate (synthesize local sbx-v2
  kits before sbx --kit / sbx kit add). ACQ_SBX_KIT_PASSTHROUGH offline hatch.
  Observable behavior for sbx users is unchanged.
- acq: real msb row in `backend list`; new `kit list|validate|apply`.
- scripts/verify-backends: per-installed-backend live E2E check (new).
- scripts/test-acq: msb + acq kit + neutral->sbx-v2 translation cases (79/79).
- docs: BACKEND_GUIDE msb flipped to shipped; QUICKSTART backend-chooser + msb
  flow; new ADR-0011; acq-design Phase 2 appendix; repoint 3 dangling
  acq-handoff-1.1.md refs to ADR-0010.

PATTERNS_KIT_REF is a PROVISIONAL pre-merge pin at the patterns #221 PR head;
it carries a TODO and MUST be flipped to the Part A merge SHA before undrafting
(handoff §4.1 hard gate). Live `acq run --backend msb` E2E is deferred to a
sandbox-capable host (no nested sandboxes; needs /dev/kvm) — see ADR-0011.

qsbx and its migration tests are untouched (removal is Phase 4).

Refs: docs/explorations/acq-handoff-2.0.md
Co-authored-by: OpenCode Agent <[email protected]>

* fix(acq): correct sbx install-cmd form and msb net-rule target grammar

Two provision-blocking bugs surfaced by the first live host run of
scripts/verify-backends (both were latent — the offline harness never exercised
a real sbx/msb create):

- sbx: the neutral->sbx-v2 synthesizer emitted every command as an argv
  sequence, but sbx's `commands.install[].command` field is typed as a shell
  STRING (startup/initFiles accept a seq). git-ssh-sign (install phase) failed
  with "invalid spec.yaml: yaml: cannot unmarshal !!seq into string".
  kit_translate now emits a `sh -c SCRIPT` command as a YAML block-scalar string
  (matching the pre-Phase-2 sbx kits) and keeps non-shell argv (e.g. node ...)
  as a sequence.
- msb: net rules used `allow@domain:HOST`, but in msb's net-rule grammar the
  `:` after the target is the PROTO separator, so msb read the target as the
  single label "domain" and aborted ("`domain` is ambiguous ... Use
  `domain=domain`"). Use the explicit literal-hostname form `allow@domain=HOST`.

Also harden the msb install-marker idempotency: the run-once marker under
/var/lib/acq is root-owned, so the existence TEST now runs as uid 0 too (it was
already WRITTEN as uid 0). Previously a non-root install phase could fail to see
the marker and re-run every apply. The four pinned kits install as uid 0, so
behavior there is unchanged.

Tests: scripts/test-acq gains regression coverage for the install-string vs
argv-seq shape and the msb `domain=` net-rule form (85/85, offline). Docs/ADR
net-rule examples updated to `domain=HOST`.

Co-authored-by: OpenCode Agent <[email protected]>

* feat(acq): add debug tracing and surface provision errors in verify-backends

verify-backends swallowed the real create error behind `>/dev/null 2>&1`, so a
failing run only said "provision failed" and forced a separate manual re-run to
see why. Make one run self-diagnosing:

- verify-backends: run every acq command through a run_acq helper that captures
  combined stdout+stderr; on ANY step failure print the exact command line and
  the captured output (dump). Add per-backend preflight (sbx version / msb
  --version + msb doctor readiness note), print the derived sandbox name, and
  add -v/--verbose (stream commands live) and -k/--keep (leave a failing
  sandbox up for inspection).
- common.sh: add acq_debug (ACQ_DEBUG=1 -> "acq[debug]: ..." on stderr; never
  prints secret values, only command shapes).
- sbx.sh / msb.sh: trace the create command line via acq_debug; msb create
  failure now also prints the flags + image and hints ACQ_DEBUG=1.
- kit-translate.sh: capture and surface git fetch/checkout stderr on failure
  (was `-q`/discarded), and acq_debug the fetch target — the most likely
  provision blocker given the provisional pre-merge patterns pin.
- BACKEND_GUIDE: add a Troubleshooting section documenting ACQ_DEBUG and the
  verify-backends flags.

No behavior change to the happy path. Offline suites unchanged: test-acq 85/85,
test-migrate-or-halt 57/57, lint:md clean.

Co-authored-by: OpenCode Agent <[email protected]>

* fix(acq): per-phase sbx command typing, pullable msb base image + prereq bootstrap

Two more provision blockers from the live host run:

- sbx: commands.install[].command is typed as a shell STRING but
  commands.startup[]/initFiles[] are typed as an argv SEQUENCE. The previous fix
  keyed off the `sh -c` shape (not the phase), so it emitted the playbook/zscaler
  STARTUP `sh -c` commands as strings too, tripping the mirror-image error
  "cannot unmarshal !!str `PLAYBOO...` into []string". kit_translate now keys off
  PHASE: install -> block string, startup/initFiles -> argv seq.
- msb: the default image (ghcr.io/gsa-tts/.../opencode:latest) required registry
  auth and 401'd. Default to a public docker.io/library/debian:stable-slim
  (pullable without auth). Since a plain base lacks the kits' runtime tools,
  add _acq_msb_bootstrap_base: a marker-gated, apt/apk-auto-detecting install of
  node/git/curl/ca-certificates run before kits are applied. Override with
  ACQ_MSB_IMAGE (+ ACQ_MSB_SKIP_BOOTSTRAP when the image already bakes them in).
  msb create failures now also hint about registry auth for ghcr/private images.

Tests: test-acq gains per-phase typing regressions (install=string,
sh -c startup=seq) — 87/87 offline. BACKEND_GUIDE documents the base-image
contract + ACQ_MSB_IMAGE/ACQ_MSB_SKIP_BOOTSTRAP; ADR-0011 records both the
per-phase typing rule and the base-image/bootstrap decision.

Co-authored-by: OpenCode Agent <[email protected]>

* fix(acq): ship msb base image with kit prerequisites instead of runtime install

The live run showed the runtime apt-install path cannot work: the kit net-rules
lock egress to the kits' own hosts (api.gsa.usai.gov, github.com,
codeload.github.com), so the Debian package mirror (deb.debian.org) is
unreachable during provision — apt failed and node/git/curl never installed, so
three msb checks failed.

Fix: bake the prerequisites into the base image instead of installing at
runtime.
- Default ACQ_MSB_IMAGE -> docker.io/library/node:22-bookworm, which is built on
  buildpack-deps:bookworm-scm and already ships node, git, curl, and
  ca-certificates; pullable from Docker Hub without auth.
- Replace the apt/apk bootstrap with a prerequisite PRESENCE CHECK
  (_acq_msb_check_prereqs): verify node/git/curl/update-ca-certificates exist in
  the guest and warn clearly (with the missing set) if a custom ACQ_MSB_IMAGE
  lacks them. No runtime install — nothing to fetch through the locked egress.
  Skip with ACQ_MSB_SKIP_PREREQ_CHECK=1.

sbx path is unaffected and now passes end-to-end on the host (all four checks).

Tests: test-acq adds prereq-check coverage (warns on missing, silent when
present, honors skip) — 90/90 offline. BACKEND_GUIDE + ADR-0011 updated for the
node:22-bookworm default, the no-runtime-install rationale (locked egress), and
the ACQ_MSB_IMAGE prerequisite contract.

Co-authored-by: OpenCode Agent <[email protected]>

* fix(acq): msb exec-ready wait, bare-FQDN net rules, backend-aware USAi check

Three issues from the live msb run (sbx passes end-to-end):

- Race: `msb create` boots the guest in the background, but the adapter copied
  kit files / ran startup commands immediately — so the usai startup `node
  merge-global-config.mjs` ran before the .mjs copy was durable
  (MODULE_NOT_FOUND). Add _acq_msb_wait_for_exec_ready (poll `msb exec echo ok`
  up to ACQ_MSB_EXEC_READY_TIMEOUT, default 60s) after create and BEFORE
  applying kits — mirrors sbx.sh's readiness wait.

- Net rules: emit the BARE FQDN target `allow@HOST` (msb's own --help example is
  `[email protected]:tcp:443`) instead of `allow@domain=HOST`. The `domain=`
  form was a wrong guess that can break DNS resolution of the allowed host
  (the live run showed "Could not resolve api.gsa.usai.gov" despite the allow).
  NOTE: not live-verified here (no /dev/kvm); matches the documented grammar.

- USAi check (verify-backends): make it backend-aware. sbx holds the real key in
  the guest (proxy injection) so a bearer curl returns 200. msb binds the key
  with `--secret ENV@HOST` — the guest value is a proxy-managed SENTINEL and the
  real key never enters the VM — so a guest-side bearer check can't prove 200.
  For msb, assert (a) the secret is bound and (b) the models host is reachable
  (any HTTP status back = resolution+egress OK; auth is rewritten on the wire).

Tests: test-acq net-rule cases updated to the bare-FQDN form (91/91 offline).
ADR-0011 net-rule examples updated. The bare-FQDN egress fix and the full msb
create->kit->exec loop remain to be confirmed on a sandbox-capable host.

Co-authored-by: OpenCode Agent <[email protected]>

* feat(acq): acq-owned backend-neutral secret store; verified msb file copy

De-sbx-ify credentials (the design's §7.5 unified secret model, as a thin bash
subset) and fix the msb file-copy race — the last two things blocking msb
end-to-end.

Secret store (new acq.backends/secret-store.sh):
- One acq-owned store keyed acq.<service> / acq.<sandbox>.<service> with
  sandbox-scope precedence (lifts the Phase-1 sbx global/sandbox scope into acq).
- OS keychain when available (macOS `security`, Linux `secret-tool`), 0600 file
  fallback under $XDG_DATA_HOME/acq/secrets. Value read from TTY/stdin, never
  argv; never serialized.
- `acq secret set` (both backends) now WRITES here, then feeds the active
  backend: sbx pipes into `sbx secret set`/`set-custom`; msb reads it back at
  provision and binds `--secret ENV@HOST` from a transient env var. USAi ->
  api.gsa.usai.gov; GitHub -> github.com + api.github.com. The real value never
  enters the guest and never appears in argv.
- Replaces the msb "export USAI_API_KEY then --secret" stopgap and the
  sbx-store-only USAi path. Both USAi and the GitHub token now flow through the
  same acq store.

msb file-copy race (the .mjs MODULE_NOT_FOUND):
- `msb copy` could return before a subsequent `msb exec` observed the write, so
  the usai startup `node merge-global-config.mjs` ran before the .mjs was
  present. Add _acq_msb_copy_file_verified: copy, then poll `test -s` in-guest
  (ACQ_MSB_COPY_SETTLE_TIMEOUT, default 20s) with one explicit re-copy, and fail
  the kit apply loudly if the file never appears — instead of running commands
  against a missing file.

Tests (test-acq 104/104, offline):
- secret-store unit tests (scope precedence, has/resolve, 0600 perms);
- refactored `acq secret set` feed tests (built-in vs set-custom, value never in
  argv, value stored in acq store);
- msb secret set (usai/github) stores in acq store;
- msb provision binds USAi + GitHub via --secret and never leaks values to argv.
make_stubs points ACQ_SECRET_STORE_DIR at a throwaway file store (no real
keychain touched).

Docs: BACKEND_GUIDE Secrets section rewritten for the acq store + msb/sbx feed +
sbx->acq migration note; ADR-0011 and acq-design appendix record the store as
the bash subset of §7.5 (full Go/keychain/MITM component still future).

verify-backends: USAi check now looks for the key in the acq store (or host env
fallback) rather than expecting a guest-visible value.

Co-authored-by: OpenCode Agent <[email protected]>

* fix(acq): align sbx secret feed with the real sbx CLI contract

The first host run of the new secret store exposed two wrong assumptions about
how sbx ingests secret values (I had guessed stdin+--force for both forms):

- Built-in services (github, ...): `sbx secret set <service>` reads the value on
  STDIN and has NO stdin --force. If the secret already exists it PROMPTS
  "Overwrite? (y/N)", which consumed our piped token and cancelled. Fix:
  pre-check `sbx secret ls`; if present, stop with an exact `sbx secret rm …`
  hint (non-destructive, per decision) instead of piping into the prompt.
  Otherwise pipe the value to `sbx secret set` as before.

- Custom endpoints (usai): `sbx secret set-custom` does NOT read stdin — the
  value only arrives via --value/--token (argv-visible) and it errors on
  "already exists" (no --force). It emitted its own "Enter secret:" prompt and
  then failed. Fix: never put the value on argv. From a terminal, run
  `set-custom` interactively so sbx prompts once; when piped/non-interactive (or
  on msb), store in the acq store and print the exact `sbx secret set-custom …`
  command. Repeat --host for every mapped host.

In all paths the value is already saved in the acq store (source of truth); the
sbx step only configures sbx's injector, and the real value never lands on argv.

Also verified the sbx CLI shapes from `sbx secret {set,set-custom} --help`
(0.35.x): set-custom flags are --host (repeatable) / --env / --value / --token /
--placeholder; set has --force only with --token.

Tests: rewired §5 to the real contract (built-in stdin feed + existing->rm-hint;
custom piped->store+print-command with value-never-in-argv asserted on both
stdout and the sbx call log; msb usai stores + no sbx set-custom). test-acq
106/106. BACKEND_GUIDE + ADR-0011 document the per-type feed + non-destructive
overwrite behavior.

Co-authored-by: OpenCode Agent <[email protected]>

* fix(acq): msb creates the agent/uid-1000 + /home/agent user the kits require

Root cause of the persistent msb MODULE_NOT_FOUND + playbook-clone failures,
confirmed by a host probe: it was NOT a copy race (msb exec/copy persistence and
paths are correct). The kits assume the sbx agent-template contract — an `agent`
user, HOME=/home/agent, addressed as `-u 1000`. A plain OCI base doesn't provide
it: node:22-bookworm ships a `node` user at uid 1000 (HOME=/home/node) and no
`agent`. So the usai startup `node /home/agent/usai-config/merge-global-config.mjs`
and the playbook clone ran as the wrong user against a home that isn't
/home/agent (the error even showed file:///home/agent/... failing under node).

Fix in acq.backends/msb.sh:
- _acq_msb_ensure_agent_user: at provision (after exec-ready, before applying
  kits), create an `agent` user with HOME=/home/agent if missing — idempotent,
  marker-gated, offline (useradd on Debian/RHEL, adduser on Alpine). Prefers uid
  1000 but falls back to a free uid if taken (kits address the user by name).
- _acq_msb_exec_command: map a kit `user: "1000"` (or "agent") to `-u agent
  -e HOME=/home/agent` rather than literal `-u 1000` (which is the base image's
  `node`). Root/other users unchanged.
- _acq_msb_copy_file_verified: chown staged files under /home/agent to `agent`
  so the agent user can read/execute them (they are copied as root).

Also verified msb create-time knobs exist (--user/--workdir/--env/--copy-file)
and recorded that exec/copy persistence is fine (the earlier "copy race" note is
corrected in the design appendix + ADR-0011).

Tests: test-acq gains an msb provision case asserting the agent user is created,
a uid-1000 kit command runs as `agent` with HOME=/home/agent (NOT -u 1000), and
staged /home/agent files are chowned to agent. 110/110 offline; lint clean.

Co-authored-by: OpenCode Agent <[email protected]>

* fix(acq): msb kit-apply stdin bug, DNS nameserver, workspace mount, fail-closed

Live msb bring-up (KVM host) exposed four real bugs; a host probe confirmed each
root cause (msb exec/copy persistence and paths are fine — earlier "copy race"
theory was wrong):

- Kit apply dropped every file/command after the first. The files and commands
  apply loops used `while read … done <<heredoc` whose body calls `msb copy`/
  `msb exec`; a real `msb` consumes the loop's stdin, so iteration stopped after
  record #1. This was the actual cause of the missing
  merge-global-config.mjs (usai's 2nd file) and the MODULE_NOT_FOUND. Fix:
  buffer the parser output into an array first, then iterate.

- Guest DNS failed for allow-listed hosts. msb hands the guest the host's
  resolvers, but a corporate/VPN resolver (172.16.x/Zscaler) is unreachable from
  the microVM, so nothing resolved ("Could not resolve api.gsa.usai.gov"). Pass
  --dns-nameserver (ACQ_MSB_DNS_NAMESERVER, default 1.1.1.1; empty to omit).
  Verified: models API -> 401 (no key), github -> 200.

- Workspace mount. msb won't create the host mount path and mishandles an
  identical host:guest /tmp mount (starts but the mount silently doesn't
  appear). Mount the host workspace at a fixed guest path
  (ACQ_MSB_WORKSPACE, default /home/agent/workspace) and error if the host path
  doesn't exist.

- False success on failed start. `msb create` returns 0 even when the guest
  later fails to START (async boot) — so bad mounts looked like passes. Treat a
  sandbox that isn't exec-ready within ACQ_MSB_EXEC_READY_TIMEOUT as a HARD
  provision failure, with `msb logs --source system` guidance.

Also creates the kits' `agent`/HOME=/home/agent user contract (prior commit) and
now proven end-to-end alongside these fixes.

Tests: the msb stub now DRAINS STDIN like the real CLI (so the harness catches
the loop-stdin bug), plus regressions for full multi-file/multi-command apply,
--dns-nameserver default/override/disable, not-ready-aborts, and
missing-workspace-errors. test-acq 123/123; test-migrate-or-halt 57/57; lint
clean. Docs (BACKEND_GUIDE, ADR-0011, acq-design appendix) updated.

Co-authored-by: OpenCode Agent <[email protected]>

* fix(acq): msb enable TLS interception for secrets; defer github git-auth (#203)

Finalize the msb secret path against how microsandbox actually works (docs +
0.6.6 CLI behavior, verified on a KVM host), and stop chasing the private
playbook clone.

- Enable --tls-intercept on msb create (ACQ_MSB_NO_TLS_INTERCEPT to opt out).
  microsandbox only substitutes a secret placeholder over intercepted TLS; the
  interception CA is auto-trusted in the guest (plain HTTPS = 200), so no extra
  CA install. The USAi Authorization-header substitution needs this.
- Bind ONLY [email protected]. Drop the GitHub --secret binding:
  msb 0.6.6 does not substitute the placeholder for git's HTTPS clone to
  github.com (works for the USAi header, not git — verified across request
  shapes, single/multi binding, header vs URL, h1 vs h2), and binding the same
  placeholder on multiple github hosts also trips microsandbox #1170. The
  private playbook clone is therefore skipped on msb (kit is non-fatal, warns);
  sbx is unaffected. Tracked in #203 (+ upstream #756/#768/#1170).
- acq secret set now re-feeds running sandboxes via `msb modify --secret`
  (usai), so a rotated key applies without recreate (value via env, never argv).
- Harden _acq_msb_net_rules_into: validate each kit-spec host against a
  hostname/wildcard allowlist before the eval-by-name append, so a malformed or
  malicious spec value can't inject shell metacharacters.

Docs: BACKEND_GUIDE gains an msb "Known limitations" section (private-repo
clone) + the TLS-interception/msb-modify secret details; ADR-0011 and the
acq-design appendix record the TLS-interception requirement, the git-substitution
gap, and the live-verification results. Tests: --tls-intercept asserted, USAi
single-host binding asserted, github NOT bound asserted, value-never-in-argv
retained. test-acq 124/124; test-migrate-or-halt 57/57; lint clean.

Refs: #203
Co-authored-by: OpenCode Agent <[email protected]>

* fix(acq): verify-backends WARNs (not FAILs) on the known msb playbook gap

The msb private-repo playbook clone is a known, tracked limitation (#203), not a
regression, so it shouldn't fail an otherwise-green msb run. Add a warn() level:
on msb a missing playbook clone/symlink is a WARN referencing #203 (surfaced
loudly every run, does NOT count toward failures); on sbx it stays a hard
requirement. The summary line now reports warnings and the run still exits 0
when the only issue is a warned limitation.

Refs: #203
Co-authored-by: OpenCode Agent <[email protected]>

* fix(acq): clear shellcheck warnings failing the pre-commit CI check

The pre-commit shellcheck hook (--severity=warning) failed PR #202 on
pre-existing/new SC warnings:

- common.sh: ACQ_KIT_NAMES flagged SC2034 (unused) — it's consumed by
  `acq kit list` in the entry point; add a scoped `# shellcheck disable=SC2034`.
- kit-translate.sh kit_validate: drop the unused `m`/`ph` locals (SC2034); only
  path + source are validated.
- test-acq / verify-backends: annotate the dynamic `. "$ACQ"` / adapter sources
  with `# shellcheck disable=SC1090` (+ `source=` directives) like load_acq
  already does; use the `desc` arg in run_acq's verbose line so it isn't unused
  (SC2034).

Verified with shellcheck 0.11.0 (the CI-pinned version) --severity=warning
across every tracked shell file (by extension + shebang): 0 findings. Offline
suites unchanged: test-acq 124/124, test-migrate-or-halt 57/57, markdownlint
clean.

Co-authored-by: OpenCode Agent <[email protected]>

* feat(acq): pin PATTERNS_KIT_REF to patterns v1.6.0 (Part A merged) — release gate met

Part A (patterns PR #221: neutral hybrid/v1 acq-kits + kit-hybrid-v1 schema +
registry) merged to agentic-coding-patterns main and shipped in the v1.6.0
release. Flip PATTERNS_KIT_REF from the provisional pre-merge PR-head SHA to the
v1.6.0 release commit e387c59bb2f743eb321bfb56a8ac71a6abb185ae, satisfying the
Phase 2 handoff §4.1 hard gate. Pinning to a release tag mirrors Phase 1's
v1.5.0 pin; v1.6.0 includes Part A unchanged (it also adds an unrelated sbx kit
+ a validation script — the acq-kits and schema are identical to the #221 merge).

Verified against the pinned commit: live sparse-fetch of all four acq-kits, and
neutral->sbx-v2 translation + kit_validate of each, all pass.

- common.sh: PATTERNS_KIT_REF -> e387c59…; drop the provisional-pin TODO.
- test-acq: ref assertions -> e387c59.
- ADR-0011 + acq-design appendix: release gate marked satisfied.

test-acq 124/124; test-migrate-or-halt 57/57; markdownlint clean; shellcheck
0.11.0 (--severity=warning) clean.

Refs: GSA-TTS/agentic-coding-patterns#221
Co-authored-by: OpenCode Agent <[email protected]>

* docs(acq): point ppp backend at GSA-TTS/ppp (Phase 3, in development)

ppp is no longer "deferred/not scheduled" — it is in active development at
GSA-TTS/ppp for Phase 3. Update the backend table, the ppp section, the
deferred list, and ADR-0011 to link the repo and note it will be additive
(same adapter contract + neutral kits). Also tidy the deferred list to name the
Go/keychain §7.5 component and quickstart#203 explicitly.

Co-authored-by: OpenCode Agent <[email protected]>

* fix(acq): validate untrusted kit fields (root chmod injection); TLS-scope + create-scrub

Address adversarial review of PR #202.

FAIL (medium) — root command injection via unvalidated kit `mode`. A kit spec's
files[].mode was interpolated unquoted into a root `sh -c "chmod $mode '$path'"`,
so a hostile/typo'd spec (patterns repo / ACQ_EXTRA_KITS) with
`mode: "0644; wget|sh"` ran arbitrary code as root in the guest. Fixes:
- kit_spec_files now DROPS any record whose mode isn't octal ([0-7]{3,4}) or
  whose path/source contains shell metacharacters/whitespace (warns to stderr).
- kit_spec_commands DROPS any command whose user isn't a bare uid/safe username
  or whose phase isn't install|initFiles|startup (both reach `msb exec -u`).
- msb _acq_msb_copy_file_verified re-validates path up front and mode at use,
  and runs `chmod`/`chown` as argv (msb exec -u 0 -- chmod MODE PATH) instead of
  an `sh -c` string — no interpolation into a shell.
- kit_validate scans the RAW spec so a bad mode/phase/user is REPORTED (not just
  silently dropped) and makes `acq kit validate` fail.
Command argv was already base64-tokenized + passed as separate argv (not a
joined sh -c), and net-allow hosts were already charset-validated; this closes
the mode/path/user gap. The mode/user validation also belongs in patterns
#221's validate-kits.py at the gate (noted for that PR); acq validates
defensively regardless.

CONCERN (medium) — document that --tls-intercept is guest-global. ADR-0011 now
states interception covers all guest egress (guest auto-trusts the interception
CA), while secret substitution stays host-scoped — a broader posture than sbx's
proxy injection, and the price of the microVM boundary. Added SI-10 to controls.

CONCERN (low) — set -e skipped the create-failure secret scrub. Under
`set -euo pipefail` a failing `msb create` aborted before the rc capture, so the
error block AND the transient-secret unset never ran. Use `|| _create_rc=$?`.

Nit — secret store: get now falls back to the file backend under
ACQ_SECRET_ALLOW_ARGV so a value stored without the flag is still found.

Doc nits: ADR date 2026-07-16, "135 checks" (was 79).

Tests: +5 security cases (hostile mode dropped, msb chmod argv/refusal, hostile
command user dropped, `acq kit validate` reports mode/phase). test-acq 135/135;
test-migrate-or-halt 57/57; markdownlint clean; shellcheck 0.11.0 clean.

Refs: #202, #203, GSA-TTS/agentic-coding-patterns#221
Co-authored-by: OpenCode Agent <[email protected]>

* feat(acq): add environment vocabulary to hybrid/v1 kit translation

hybrid/v1 had no way to express guest environment variables; the translate
layer implemented only caps.network.allow, files[], commands[], and
agentContext. A downstream team (quickstart#202 review, comment 5004158773)
needs environment.variables-style config (OPENCODE_CONFIG, OPENCODE_TUI_CONFIG,
GITLAB_HOST) as a first-class kit mechanism.

Add env-var support across the translate layer:
- kit-translate.sh: new kit_spec_env accessor (awk, mirrors kit_spec_net_allow)
  that VALIDATES env var names (^[A-Za-z_][A-Za-z0-9_]*$) and drops unsafe ones
  with a stderr warning, consistent with the existing mode/path/user hardening.
- kit_translate_to_sbx: emit an sbx-v2 environment.variables block (the native
  sbx guest-env mechanism the pre-Phase-2 playbook/openchamber kits used).
- msb.sh: thread each NAME=value onto the kit's lifecycle commands as
  'msb exec -e NAME=value' (msb's native per-exec env flag).
- kit_validate: report an invalid env var name (not silently drop it).
- test-acq: env parsed; unsafe name dropped+warned; sbx-v2 emission; msb --env
  emission; validate reports bad name (135 -> 148 tests, all offline).

Static string values only (YAGNI); secrets stay on the credential path, never
the kit spec. Docs (ADR-0011, BACKEND_GUIDE) updated. The authoritative schema
+ validator land in a separate patterns PR; PATTERNS_KIT_REF is deliberately
LEFT at e387c59 (v1.6.0) and bumped only after that PR merges (fail-closed
cross-repo gating).

Refs: GSA-TTS/agentic-coding-patterns#227

Co-authored-by: OpenCode Agent <[email protected]>

* feat(acq): bump PATTERNS_KIT_REF to patterns v1.7.0 (environment vocabulary)

Patterns v1.7.0 is released (commit 9c277c09ed4ad45fd11709d6b048a58adc785443),
shipping the hybrid/v1 `environment` schema property + field-level validator
(#227) and its shell-safety doc note (#228), plus the openchamber conversion
(#224). Flip PATTERNS_KIT_REF from v1.6.0 (e387c59) to the v1.7.0 release commit
so the acq translate layer's env-var support (kit_spec_env) is backed by a
released schema. Verified the schema at v1.7.0 has the `environment` property and
that all five acq-kits fetch + translate + validate + parse env against it.

- common.sh: PATTERNS_KIT_REF -> 9c277c0 (v1.7.0); comment updated.
- test-acq: ref assertions -> 9c277c09.
- ADR-0011 + BACKEND_GUIDE + acq-design: cross-repo env-schema dependency marked
  satisfied (was pending); release-gate note points at v1.7.0.

test-acq 148/148; test-migrate-or-halt 57/57; markdownlint clean; shellcheck
0.11.0 clean.

Refs: GSA-TTS/agentic-coding-patterns#227, #228, #202
Co-authored-by: OpenCode Agent <[email protected]>

---------

Co-authored-by: OpenCode Agent <[email protected]>
mogul added a commit that referenced this pull request Jul 23, 2026
…t step (#233 Increment A)

quickstart#221 is merged: acq's neutral→sbx translator now carries
backend_extras.sbx.publishedPorts, so applying the openchamber kit publishes
container ports 3000/4096 to the host at create time with no manual
`acq ports --publish`. Adopt that here (Increment A of #233):

- scripts/verify step 2b: instead of always publishing manually, VERIFY the
  ports were auto-published at create; only fall back to an explicit publish
  on an older acq that predates #221 (emitted as a non-fatal WARN, not a FAIL).
  Add a warn() helper for that non-fatal path.
- kits.yaml + README: replace the "translator does not yet carry publishedPorts;
  publish manually" wording with "carried at create (quickstart#221); older acq
  falls back" and reframe the remaining gap as msb port/background plumbing.

Increment B (msb/ppp parity) stays blocked and #233 stays open: acq still has
no neutral port-publish/background vocabulary and the msb adapter consumes
neither backend_extras.msb ports nor a background flag. No behavior change to
the wrapper or startup script.

Co-authored-by: OpenCode Agent <[email protected]>
mogul added a commit that referenced this pull request Jul 23, 2026
* feat(acq-kits): openchamber wrapper owns a shared opencode server

Redesign the openchamber kit so the browser UI and an optional terminal
TUI share one live OpenCode session by default, and simplify what the
kit's startup manages.

- Ship an `opencode` wrapper at ~/.local/bin/opencode (first on the
  base-image PATH, so it shadows the real binary and is what the sandbox
  entrypoint runs). With no args it idempotently starts a shared
  `opencode serve` on 0.0.0.0:4096, prints host-connect instructions, and
  offers to attach a TUI; with args it passes through to the real opencode.
- Reduce the startup script to managing OpenChamber only (install on first
  boot + respawn supervisor in skip-start mode); it no longer starts or
  supervises `opencode serve` — the wrapper owns that, on demand.
- Publish both container ports 3000 (OpenChamber) and 4096 (shared server);
  run the shared server unsecured (the sandbox is the security boundary).
- Rework scripts/verify: three-state offline gate (SKIP when python deps
  missing, real error on failure), a preferred RUN_ACQ live path (creates
  via acq, publishes 3000/4096, exercises the wrapper + on-demand server),
  and a gated RUN_SBX path that requires a pre-translated sbx-v2 kit.
- Drop the "*.npmjs.org" allow host: registry.npmjs.org serves metadata and
  tarballs, and a leading-* host breaks acq's (unquoted) neutral->sbx
  translation. Documented in-spec.
- Add docs/decisions/wrapper-entrypoint-owns-server.md and annotate the
  superseded premise of openchamber-mixin-not-sandbox-kit.md. Update README,
  TROUBLESHOOTING, and kits.yaml.

Ports need a manual `acq ports --publish` under acq until the upstream
publishedPorts translation (quickstart#221) reaches the pinned kit ref;
tracked as #233.

Co-authored-by: OpenCode Agent <[email protected]>

* fix(acq-kits): keep openchamber sandbox alive (wrapper foregrounds server)

The openchamber `opencode` wrapper is the sandbox entrypoint (it shadows
the real binary via PATH). Its no-arg path backgrounded `opencode serve`
with `nohup … &` and then returned (`exit 0`), so PID 1 exited and the
sandbox transitioned running -> stopped moments after `acq run` — visible
as a sandbox that "stops on its own" when you decline the TUI prompt or run
non-interactively. Introduced in this branch's wrapper redesign.

A Docker sandbox is "running" only while its entrypoint / PID 1 is alive.
Fix the wrapper's no-TUI path to hold a foreground process as PID 1:
- if it started the server here, `wait` on that background job;
- if a server already owns :4096, park on a liveness loop and only `exec`
  a fresh foreground server if the existing one disappears (avoids a
  double-bind that, under `set -e`, would itself kill the entrypoint).
The interactive "yes -> opencode attach" path already `exec`ed, so it was
unaffected.

- files/.local/bin/opencode: foreground handoff + "must stay PID 1" note.
- spec.yaml, README, docs/decisions/wrapper-entrypoint-owns-server.md:
  describe the foreground handoff and record the regression it prevents.
- TROUBLESHOOTING.md: add "sandbox stops on its own right after acq run";
  update the `acq exec … opencode` recipes to start the server detached
  (the wrapper now foregrounds).
- scripts/verify: add step 9 entrypoint-liveness regression guard (assert
  the no-arg wrapper blocks rather than returning); start the wrapper
  detached in step 5 so the live check doesn't hang.

Verified: host-side `RUN_ACQ=1 scripts/verify` passes all checks
(including the new liveness guard) on a sandbox-capable host.

Co-authored-by: OpenCode Agent <[email protected]>

* fix(acq-kits): keep openchamber sandbox alive after the attached TUI is quit

The prior fix kept the sandbox running on the declined/non-interactive
path, but the "yes, attach a TUI" path still `exec`ed `opencode attach`,
making the TUI itself PID 1. Quitting the TUI then ended the entrypoint and
stopped the sandbox (and the OpenChamber browser UI with it) — contradicting
the kit's premise that the browser and TUI share one long-lived server.

Run the attached TUI as a CHILD (not exec) with `|| true`, so quitting it
returns to the wrapper. Extract the PID-1-hold into a hold_pid1() function
that every no-arg path ends on (decline, non-interactive, and
attach-then-quit), so the sandbox and the shared server survive a TUI exit.

- files/.local/bin/opencode: TUI as child + hold_pid1() shared tail.
- spec.yaml, README, docs/decisions/wrapper-entrypoint-owns-server.md:
  document TUI-exit survival; ADR records both fixed variants (return, exec).
- TROUBLESHOOTING.md: add "sandbox stops when I quit the attached TUI".
- scripts/verify: add step 10 guard asserting the attach line is not exec'd
  and the wrapper ends in a hold_pid1 call.

Verified: host-side `RUN_ACQ=1 scripts/verify` passes all checks, including
the new step 10 TUI-exit-survival guard.

Co-authored-by: OpenCode Agent <[email protected]>

* docs(acq-kits): fix MD028 blank-line-in-blockquote in openchamber README

markdownlint MD028/no-blanks-blockquote: a bare blank line separated two
adjacent blockquotes in the openchamber README. Join them with a `>`-only
line so they read as one blockquote and the Markdown Lint CI check passes.

Co-authored-by: OpenCode Agent <[email protected]>

* docs(acq-kits): use acq (not sbx) for user-facing commands in openchamber docs

The openchamber README/TROUBLESHOOTING are the acq-facing docs, but a few
runnable examples still invoked the raw `sbx` backend CLI. Since acq passes
unintercepted commands and extra args straight through to the backend, the
acq form is equivalent and consistent:

- `sbx ports … [--publish …]`  -> `acq ports …`
- `sbx stop … && sbx run …`    -> `acq stop … && acq run …`
- `sbx policy log …`           -> `acq policy log …` (passthrough)
- drop the parenthetical "(sbx ports …)" hints next to `acq ports`.

Left unchanged (correctly): references to the `sbx` *backend*, the sbx-v2
*schema*, `backend_extras.sbx.*` spec fields, the historical `sbx-kits/`
path, and the RUN_SBX advanced/legacy path — these name the backend or
internals, not commands a user runs.

Co-authored-by: OpenCode Agent <[email protected]>

* chore(deps): clear linter advisories (js-yaml 4.3.0, linkify-it 5.0.2)

Markdown Lint CI gates on npm audit --audit-level=moderate; this branch
predated the fix and was red on js-yaml GHSA-52cp-r559-cp3m + linkify-it
GHSA-v245-v573-v5vm. Bump the linter overrides (js-yaml 4.2.0->4.3.0,
+linkify-it 5.0.2) and regenerate the lockfile. 0 vulnerabilities at moderate.

Co-authored-by: OpenCode Agent <[email protected]>

* fix(acq-kits): harden openchamber wrapper failure path per #234 review

Address the review must-fixes on the PID-1 hold. All are failure-path
hardening, not a redesign.

1. Self-heal the hold (Medium, must-fix). The old `started_here` branch did a
   bare `wait`; if the server it launched died (crash/OOM/lost race), `wait`
   returned, hold_pid1 returned, and PID 1 exited -> sandbox stopped — the exact
   bug this wrapper prevents. Replace the asymmetric two-branch hold with ONE
   self-healing loop: poll the server and re-launch it if it ever stops
   answering, so a dead server can never let the loop return.

2. Close the serve-launch TOCTOU (Medium). Bring-up detected only via
   `curl /health`, so two near-simultaneous no-arg starts both `nohup … serve`
   and raced the :4096 bind (loser dies). Add an atomic mkdir lock
   (serve_launch_once) with a PID file and stale-lock reclaim (dead holder ->
   lock reclaimed), so exactly one wrapper launches.

3. Move the serve log off a predictable /tmp path (Low). Now
   ~/.local/state/openchamber/opencode-serve.log in a 0700 dir; docs/verify
   updated to read it there.

4. Security note: state the loopback-only guarantee depends on the adapter
   publishing loopback-scoped, which is acq's publishedPorts translation
   (quickstart#221).

Also update publishedPorts docs now that quickstart#221 is MERGED: acq carries
backend_extras.sbx.publishedPorts, so ports 3000/4096 publish at create time
with no manual step (older acq still gets the documented fallback). verify's
step 2b now re-publishes idempotently (works on old and new acq).

scripts/verify: add step 11 — a live self-heal regression guard that brings the
server up, KILLs it, and asserts a server answers again (hold_pid1 re-launched;
PID 1 held).

Verified: shellcheck-clean parses (sh -n / bash -n); offline simulations of the
lock (8 racers -> 1 launch), stale-lock reclaim, and the self-heal loop
(server death -> reheal, loop never returns); markdownlint 0 errors.

Co-authored-by: OpenCode Agent <[email protected]>

* docs(acq-kits): adopt acq publishedPorts translation, drop manual port step (#233 Increment A)

quickstart#221 is merged: acq's neutral→sbx translator now carries
backend_extras.sbx.publishedPorts, so applying the openchamber kit publishes
container ports 3000/4096 to the host at create time with no manual
`acq ports --publish`. Adopt that here (Increment A of #233):

- scripts/verify step 2b: instead of always publishing manually, VERIFY the
  ports were auto-published at create; only fall back to an explicit publish
  on an older acq that predates #221 (emitted as a non-fatal WARN, not a FAIL).
  Add a warn() helper for that non-fatal path.
- kits.yaml + README: replace the "translator does not yet carry publishedPorts;
  publish manually" wording with "carried at create (quickstart#221); older acq
  falls back" and reframe the remaining gap as msb port/background plumbing.

Increment B (msb/ppp parity) stays blocked and #233 stays open: acq still has
no neutral port-publish/background vocabulary and the msb adapter consumes
neither backend_extras.msb ports nor a background flag. No behavior change to
the wrapper or startup script.

Co-authored-by: OpenCode Agent <[email protected]>

---------

Co-authored-by: William Zujkowski <[email protected]>
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