From 5327c8cd74f7d988a5131cdca00c1111353556fc Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Fri, 18 Sep 2026 01:17:05 -0400 Subject: [PATCH 01/13] docs(isolation): add ADR 0003 for neutral model-provider discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the design that emerged from three rounds of nexus-agents 7-role consensus review this session (7-0 reject on live-poll-with- no-discovery; 4-3 approve/reject split on host-side third-party code execution next to secrets; 5-2 approve on in-guest polling that reuses the harness's own already-standing network+credential trust boundary), plus follow-up research into the actual duplication this solves: - acq's secret store dedupes by service-name string only, never by host: usai-provider registers USAi's credential as service `usai`; the in-flight goose-server kit independently registers the SAME credential, same host, as service `goose-usai`. Confirmed via acq.backends/secret-store.sh's _acq_secret_key. - The two kits' copy-on-first-boot/merge-without-clobbering config scripts are ~230-260 lines each, ~150-190 of which per kit are the same algorithm, hand-copied (goose's own header admits it mirrors usai-provider's). - The bounded-fetch-with-timeout-and-size-cap helper has already been copy-pasted once and has already drifted (the copy gained 401/403 handling the original lacks). Decision: three-layer design — (1) provider kits self-register via a well-known guest-path facts file (mirrors the already-shipped zscaler-ca-certificate -> openchamber/paseo pattern) and ship their OWN normalizer + vendored fallback snapshot; (2) a new, vendor- agnostic models orchestrator kit (pure orchestration, zero vendor knowledge, core-repo promotion candidate) discovers providers, refreshes a host-mounted-volume cache on a TTL, falls back to last-good-cache then to the provider's own snapshot on any failure, and aggregates into one neutral catalog; (3) each harness kit reads that aggregate + config-kit defaults and renders its OWN native config, owning its full configuration lifecycle end-to-end. N provider normalizers + M harness renderers, not NxM per-pair emitters. Incorporates both dissenting votes from the final round as binding conditions: an env-var-ownership check (a provider kit cannot name a credential env var it doesn't itself declare) and SSRF hardening (fetched host must equal the facts file's own declared host, itself on the sandbox's already-effective network allow-list). No hybrid/v1 schema change required — composes entirely from already-shipped primitives (caps.network.allow union, sequential kit-startup ordering, well-known-guest-path self-registration, the neutral volumes[] vocabulary for the cache mount). Co-authored-by: OpenCode Agent --- .../0003-neutral-model-provider-discovery.md | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md diff --git a/integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md b/integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md new file mode 100644 index 00000000..c39f90fb --- /dev/null +++ b/integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md @@ -0,0 +1,346 @@ +--- +title: "Neutral model-provider discovery: provider facts, a models orchestrator kit, and harness-owned rendering" +status: "accepted" +date: "2026-09-18" +decision_makers: ["William Zujkowski", "OpenCode Agent"] +category: "isolation-kit-schema" +impact_level: "moderate" +nist_controls: ["SC-7", "AC-4", "CM-7", "SI-10"] +--- + +# ADR 0003 (isolation) — Neutral model-provider discovery: provider facts, a models orchestrator kit, and harness-owned rendering + +> Area-scoped ADR for `integrations/isolation/`. Extends the neutral `hybrid/v1` +> kit spec (ADR 0001) and the network egress tiers (ADR 0002). Does not modify +> either schema; the mechanism described here composes entirely from +> already-shipped primitives (`caps.network.allow` union, sequential kit +> startup, well-known-guest-path self-registration). + +## Context and Problem Statement + +Every harness kit that talks to a model provider (`usai-provider`/OpenCode, +`goose-server`, and future `pi-coding-agent`/others) currently hand-rolls its +own provider integration: + +- `usai-provider` ships a hand-maintained `opencode.jsonc` model block, refreshed + by a maintainer manually running `scripts/sync-usai-models.mjs` on the host and + committing the result. `goose-server` (in flight) independently re-registers + the **same USAi credential** under a **different** secret-store service name + (`goose-usai` vs. `usai`, same host `api.gsa.usai.gov`) — confirmed by tracing + `acq`'s secret store, which dedupes purely by service-name string, never by + host (`acq.backends/secret-store.sh:92-114`). A user running both kits pastes + the same USAi key in twice, with no cross-check if one copy is rotated and the + other isn't. +- The two kits' "copy-on-first-boot, merge-without-clobbering on later boots" + config-merge scripts (`usai-provider/files/home/usai-config/merge-global-config.mjs`, + `goose-server/files/home/goose-config/merge-config.mjs`) are ~230-260 lines + each, of which ~150-190 lines per kit are the same algorithm, hand-copied — the + goose file's own header admits it "mirrors the usai-provider kit's + merge-global-config.mjs." +- The bounded-fetch-with-timeout-and-size-cap helper (`fetchJsonBounded`) has + already been copy-pasted once (`integrations/providers/usai/scripts/build-catalog.mjs`) + and has already drifted — the copy gained 401/403 handling the original lacks. +- `pi-coding-agent` ships today with **zero** provider-config code + (deliberately deferred; see its own README "Status" section) — a clean-slate + case any new mechanism must also serve without a migration burden. +- The maintainer's stated direction (this session, 2026-09-17/18): kits should + be mixed and matched (multiple provider kits, multiple harness kits, not a + fixed 1:1 pairing), harness kits must own their own configuration lifecycle + end-to-end (read from a neutral source, render their own native format — + never a central kit rendering on a harness's behalf), and the daily-staleness + problem should be solved by an in-sandbox refresh-with-cache-and-TTL, with a + vendored snapshot as the deepest fallback — not by a human re-running a sync + script and cutting a kit release every time a vendor adds a model. + +This is a genuine, load-bearing extensibility problem now (`goose-server` is a +second real harness kit in flight; `pi-coding-agent` is a third with zero +provider code to migrate), not a speculative one for a hypothetical future +provider. + +## Options Considered + +Three shapes were evaluated via nexus-agents 7-role consensus panels before +this ADR was written (all three panel results are persisted in this session's +tool-memory and summarized in "Alternatives Considered" below): + +1. **Live in-guest polling with no discovery mechanism.** Rejected 7-0. No way + for a harness kit to know which provider kit(s) were present other than + hardcoding a vendor name; no fallback/validation contract specified. +2. **Host-side polling: `acq` itself executes a normalizer script shipped + inside a provider kit, on the host, next to real secret material, before any + sandbox exists.** Split 4-3 approve. The rejecting votes converged on one + sharp, correct objection: this is the first case in this codebase of + executing third-party kit-shipped code **outside** the sandbox trust + boundary, in the same host process that holds real credentials. Approving + votes wanted this fixed, not the overall shape abandoned. +3. **In-guest polling, reusing the harness's own already-standing network + + credential trust boundary** (this ADR's decision). Approved 5-2. The two + remaining rejections were specific and were incorporated into the design + below rather than overridden: + - **Contrarian Analyst**: the provider-facts self-registration file must not + let a provider kit name an arbitrary credential env var it doesn't itself + own (e.g. pointing at `GITHUB_TOKEN`) — the orchestrator must verify the + named var is one the same provider kit declares in its own `environment:` + block before ever reading it. + - **Scope Steward**: don't build a multi-provider orchestration kit + speculatively at N=1 real provider. The maintainer's explicit direction + (goose-server + pi-coding-agent in flight, "we need it") supersedes this + objection for this decision — recorded here per the ADR template's + requirement to document dissent, not to relitigate it. + +## Decision + +### Layer 1 — Provider kit self-registration (no schema change) + +A provider-role kit self-registers by writing a small, static JSON facts file +to a well-known guest path at its own `startup` phase: + +``` +/var/lib/acq/models/providers/.json +``` + +`/var/lib/acq/` is already this codebase's established cross-kit guest +namespace (used today for the kit-env marker, `acq.backends/msb.sh:1571`) and +root-owned, so it is not agent-writable — closing the "any in-guest process can +plant a bogus provider" gap flagged by the Security Engineer vote. + +Facts file shape (v1): + +```json +{ + "schemaVersion": "acq-provider-facts/v1", + "providerId": "usai", + "host": "api.gsa.usai.gov", + "baseUrl": "https://api.gsa.usai.gov/api/v1", + "modelsUrl": "https://api.gsa.usai.gov/api/v1/models", + "keyEnv": "USAI_API_KEY" +} +``` + +**Env-var-ownership binding (Contrarian's fix, load-bearing):** `keyEnv` names +an environment variable. Before the Layer 2 orchestrator ever reads that named +variable's value, it MUST confirm the SAME provider kit's own `spec.yaml` +`environment:` block (or its runtime-injected secret binding for that exact +service) declares that variable name. A provider kit cannot name a credential +it does not itself own. This check is mechanical (string comparison against +the provider kit's own declared name), not a trust judgment call. + +The provider kit also ships, inside its own kit directory (never centrally): + +- Its own normalizer (raw vendor API response → the one neutral catalog + schema below). The provider kit's own author knows its vendor's API shape + best; no central kit accumulates per-vendor knowledge that rots. +- Its own vendored fallback snapshot — a small, committed, last-known-good + model list used when live refresh fails. A bad fetch degrades to *that + provider's* last-known-good list, not a central kit's stale idea of every + vendor's list. + +This directly fixes the secret-duplication problem in Context: a second +provider kit reaching the same vendor should reuse the SAME secret-store +service identity (keyed by provider, not invented per-harness), eliminating +the `usai` vs `goose-usai` double-registration. + +### Layer 2 — Models orchestrator kit (new; core-repo candidate) + +A new, vendor-agnostic kit, placed early in kit order (after provider kits, +before harness kits — the same positional-ordering convention already used to +guarantee `zscaler-ca-certificate` runs before kits that need its CA trust; +see `acq.backends/common.sh:47-58` and its bats-asserted invariant at +`test/bats/50-kit-list-completeness.bats:26-28`). Its `startup` phase: + +1. Globs `/var/lib/acq/models/providers/*.json`. +2. For each discovered provider, checks a cache file with an embedded fetch + timestamp, at a **host-mounted volume path** (not guest-local-only — see + "Cache persistence" below), keyed by `providerId`. +3. If stale (default TTL 24h, overridable by a config-kit default — + Tier 3, unchanged from prior design work), invokes that provider's own + normalizer via a shared, vendored-into-this-kit bounded-fetch helper + (timeout + response-size cap — the same contract as today's + `fetchJsonBounded`, now with one canonical implementation instead of two + already-drifted copies). +4. Validates the normalizer's OUTPUT (not just the fetch) against the neutral + catalog schema before accepting it — Security Engineer's condition: remote + response data must never be trusted merely for connecting successfully; a + structurally-valid-but-wrong response is still a routing hazard, so shape + validation is mandatory on every refresh, not just on error paths. +5. On ANY failure (timeout, oversized, malformed, validation failure, no + provider present) falls back to last-good cache, then to that provider's own + vendored snapshot. +6. Aggregates every provider's neutral output into one file: + `/var/lib/acq/models/catalog.json`. +7. Records provenance per provider (`"source": "live" | "cache" | "snapshot"`, + with a timestamp) in the aggregate — DevEx's condition: a permanently + degraded fallback must be visible, not silently indistinguishable from a + fresh fetch. + +This kit carries **zero vendor-specific knowledge** — it is pure orchestration +(glob, cache, invoke, validate, fall back, aggregate). That is what makes it a +credible candidate for promotion to a widely-reused core kit: a third-party +provider kit needs zero PRs accepted into this kit or into any central catalog +file to work with it. + +**Cache persistence (per maintainer decision):** the cache and aggregate files +live on a **host-mounted volume**, shared across sandbox recreations for the +same user/host, not guest-local-only. A guest-local-only cache would make the +TTL meaningless across `acq run` invocations (every fresh sandbox starts cold). +The mount is a passive host-directory bind (`msb --mount-dir` / +sbx's neutral `volumes[]` primitive — see ADR 0001's `volumes` vocabulary, +`schemas/kit-hybrid-v1.schema.json:181-201`), not host-executed code — it does +not reintroduce the "third-party code executes on the host" problem that +Option 2 was rejected for. `acq` itself contributes no new code to make this +mount exist beyond wiring the models-kit's declared volume through the +existing neutral `volumes[]` translation, which both backends already support. + +### Layer 3 — Harness kit owns its own rendering (unchanged from prior guidance) + +Each harness kit, at its own `startup` phase (after the models kit, per the +same positional-ordering convention), reads: + +- `/var/lib/acq/models/catalog.json` (Layer 2's neutral aggregate), and +- The config kit's cross-harness defaults (model-role priority, etc. — Tier 3 + from prior design work, unchanged, still deferred: a neutral **permission** + vocabulary stays out of scope until a second harness kit's *native* + permission model is in hand, per the unanimous guidance from the first + consensus panel in this session), + +and renders its OWN native config in its OWN format. The harness kit owns its +full configuration lifecycle end-to-end — it is the only thing that knows how +to turn a neutral model list into `opencode.jsonc`, or into goose's +`custom_usai.json`, or into a future `pi` config. No central kit renders on a +harness's behalf. + +This yields **N provider normalizers + M harness renderers**, not +**N×M** per-pair emitters (today's actual pattern once `goose-server` lands: +`emitters/opencode.mjs`, `emitters/prime-agent.mjs`, `emitters/goose.mjs`, each +independently re-deriving "read catalog, select models, transform pricing"). + +### Neutral catalog schema — versioned now, no negotiation mechanism (per maintainer decision) + +The schema is versioned from v1 (`schemaVersion: "acq-neutral-model-catalog/v1"` +on every normalizer's output and the aggregate file). Per the maintainer's +explicit decision: ship v1 now; do not build a multi-version negotiation +mechanism (renderers declaring supported versions, the orchestrator picking +compatible pairings) ahead of a real second version existing. If a breaking +change is ever needed, it is a coordinated bump documented in a follow-up ADR +at that time — accepted as a rare cost given model catalogs are a simple, +slow-moving shape (id / context window / pricing / vendor). + +### Security hardening (both panels' conditions, binding) + +- **Env-var-ownership binding** (Contrarian): enforced as described in Layer 1. +- **SSRF hardening** (Security Engineer, Contrarian): `modelsUrl` must be + `https://` and its host must equal the facts file's own declared `host`, + which must itself be on the sandbox's already-effective `caps.network.allow` + union (ADR 0002) — the orchestrator never fetches an arbitrary URL a facts + file supplies without this cross-check. +- **No new credential exposure to the models kit itself.** The models kit + invokes each provider's normalizer with that provider's already-bound + credential exactly as the provider kit would use it for its own inference + calls — reusing the standing trust boundary, not creating a new one. The + models kit's own code never sees plaintext key material beyond what the + guest's existing secret-injection mechanism already exposes to that + specific, already-network-permitted host. +- **Atomicity.** Facts, cache, and aggregate writes are temp-file + rename; + no reader ever observes a partial write. +- **Bounded resource use.** Per-provider fetch timeout + response-size cap + (shared helper, single canonical implementation); a total wall-clock budget + across all discovered providers so a slow/hanging endpoint cannot stall + sandbox startup indefinitely. +- **Pinning.** Provider and harness kits remain SHA-pinned per this + repo's existing kit-reference discipline; no floating refs. + +## Consequences + +### Positive + +- Collapses the confirmed secret-duplication bug (two service-name + registrations for one USAi credential) into one canonical per-(vendor) + registration. +- Collapses ~150-190 duplicated merge-script lines per harness kit and the + already-drifted duplicate bounded-fetch helper into one shared, + Tier-0-style library. +- A third-party provider kit needs zero PRs accepted into any core-maintained + file to work with any existing harness kit, and vice versa — removes the + central-maintainer bottleneck the maintainer explicitly wants gone. +- `pi-coding-agent` and any future harness kit adopt this as a clean, greenfield + integration (read the aggregate, render your own config) with no migration + burden. +- Model lists refresh daily without a human re-running a sync script and + cutting a kit release, while remaining fully functional and deterministic + offline (fall back to cache, then vendored snapshot). + +### Negative / risks + +- New moving parts: a host-mounted cache volume, a new orchestrator kit, a + versioned schema contract between N producers and M consumers. Mitigated by + strict layering (each layer independently testable and independently + shippable) and by the versioned-not-negotiated schema decision above, which + bounds the coordination cost to "rare, documented, coordinated bump" rather + than "permanent negotiation machinery." +- Sandbox startup now has a conditional outbound step (the models kit's + refresh-if-stale check) on the critical path. Bounded by per-provider timeout + + a total wall-clock budget, and by the cache making the common case (fresh + cache) a fast local read, not a network round-trip. +- Rendered harness config is no longer a pure function of pinned kit refs alone + — it also depends on cache state at boot time. Accepted, with the fallback + ladder and provenance recording making any degraded state visible rather + than silent. + +### Neutral + +- Requires no change to the `hybrid/v1` schema itself — the entire mechanism + composes from already-shipped primitives (`caps.network.allow` union, + sequential startup-phase ordering, well-known-guest-path self-registration, + the neutral `volumes[]` vocabulary for the cache mount). + +## Alternatives Considered + +- **Host-side execution of provider-shipped normalizer code** (Option 2 + above). Rejected: puts untrusted, pinned-but-not-sandboxed third-party code + in the same host process as real secret material, before any sandbox trust + boundary exists. This was the deciding factor in a 4-3 split vote; moving + everything in-guest (this ADR's decision) was independently re-evaluated at + 5-2 approve once this objection was removed. +- **No discovery mechanism; hardcode one vendor per harness kit** (Option 1, + status quo). Rejected 7-0 in an earlier round of this same design work: no + way for a harness kit to learn which provider kit(s) are present without a + hardcoded name per pairing: reproduces the N×M problem this ADR exists to + retire. +- **Defer entirely until a second real provider kit ships** (Scope Steward's + dissent, both rounds). Not adopted: the maintainer's explicit direction is + that `goose-server` and `pi-coding-agent` are in flight now and this + extensibility problem is real today, not speculative. Recorded per the ADR + template's dissent-recording convention, not relitigated. +- **A neutral, harness-agnostic permission vocabulary as part of this same + ADR.** Deferred, unchanged from the first consensus panel's unanimous + guidance this session: exactly one real consumer exists today (OpenCode's + permission map); build the concrete translator when a second harness's + genuinely different native permission model is in hand, not before. + +## What an agent must NOT decide unilaterally + +- The neutral catalog schema's field set once a second real provider exists — + that is a cross-repo contract change and needs the same review process as + ADR 0002's `caps.network.allow` baseline. +- Whether the models orchestrator kit is promoted into any "core, always-on" + built-in kit bundle — that is a decision about default behavior for every + user, analogous to ADR 0002's "what belongs on the federally-shipped + `balanced` allowlist," and needs the same human/CODEOWNERS review. + +## References + +- ADR 0001 (isolation) — neutral `hybrid/v1` acq-kits spec; `volumes[]` + vocabulary this ADR reuses for the cache mount. +- ADR 0002 (isolation) — neutral network egress tiers; `caps.network.allow` + union this ADR reuses as the SSRF-hardening cross-check and as the existing + trust boundary this design deliberately does not widen. +- `usai-provider` ADR 0003 (`acq-kits/usai-provider/docs/decisions/`) — + the permission-hardening precedent this design's security conditions follow. +- `goose-server` ADR 0004 (in flight, `origin/goose-kit` branch) — records the + same harness-adapter-layer open questions this ADR's Layer 3 boundary + answers for the model-config slice specifically. +- Consensus panels (this session, 2026-09-17/18, nexus-agents `consensus_vote`, + `higher_order` strategy, 7 roles): round 1 (live-poll, no discovery) 7-0 + reject; round 2 (host-side execution) 4-3 approve; round 3 (in-guest, + reusing standing trust boundary) 5-2 approve — this ADR implements round 3's + design with round 3's dissent conditions incorporated. From ecfa36a23de1210781f1c6fc8be21fe3ead167e7 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Mon, 21 Sep 2026 22:04:59 +0000 Subject: [PATCH 02/13] docs(isolation): make provider facts host-authoritative and drop shared cache Corrects ADR 0003 after PR #423 review (mogul): the Layer 1 provider-facts mechanism relied on a root-owned guest path being un-writable, but acq grants the in-sandbox agent passwordless sudo, so guest root-ownership is not a trust boundary against a prompt-injected agent. It could sudo-write a bogus provider facts file that the models orchestrator would then trust. Changes: - Layer 1: provider kits SHIP a static facts file in their (pinned) kit dir; acq reads it on the host, validates it host-side (env-var-ownership + SSRF + schema), and mounts it read-only into the guest. No provider-kit code runs on the host (static data read only); the normalizer still runs in-guest. - Layer 2: drop the shared host-side cache + TTL entirely. A cache written by one sandbox and read by another is a cross-sandbox poisoning channel. The catalog is generated per-sandbox at startup, kept guest-local (rw), and never shared; the deepest fallback is the provider's vendored snapshot on the read-only mount. - Add a note on startup integrity across restarts (orchestrator/normalizer code invoked from the read-only mount so it cannot be tampered between restarts). - Add two Mermaid sequence diagrams (GitHub/Host/Guest boxes) contrasting the rejected guest-write path with the chosen host-materialized read-only path. - Reference quickstart ADR-0030 (Host-Authoritative Sandbox Configuration) as the general principle and mount/exec mechanism of record. Co-authored-by: OpenCode [claude_4_8_opus] --- .../0003-neutral-model-provider-discovery.md | 283 +++++++++++++----- 1 file changed, 204 insertions(+), 79 deletions(-) diff --git a/integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md b/integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md index c39f90fb..9401548a 100644 --- a/integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md +++ b/integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md @@ -12,9 +12,9 @@ nist_controls: ["SC-7", "AC-4", "CM-7", "SI-10"] > Area-scoped ADR for `integrations/isolation/`. Extends the neutral `hybrid/v1` > kit spec (ADR 0001) and the network egress tiers (ADR 0002). Does not modify -> either schema; the mechanism described here composes entirely from -> already-shipped primitives (`caps.network.allow` union, sequential kit -> startup, well-known-guest-path self-registration). +> either schema; the mechanism described here composes from already-shipped +> primitives (`caps.network.allow` union, sequential kit startup) plus the +> host-authoritative read-only config mount defined in quickstart ADR-0030. ## Context and Problem Statement @@ -47,9 +47,9 @@ own provider integration: be mixed and matched (multiple provider kits, multiple harness kits, not a fixed 1:1 pairing), harness kits must own their own configuration lifecycle end-to-end (read from a neutral source, render their own native format — - never a central kit rendering on a harness's behalf), and the daily-staleness - problem should be solved by an in-sandbox refresh-with-cache-and-TTL, with a - vendored snapshot as the deepest fallback — not by a human re-running a sync + never a central kit rendering on a harness's behalf), and the staleness + problem should be solved by an in-sandbox refresh at startup, with a + vendored snapshot as the fallback — not by a human re-running a sync script and cutting a kit release every time a vendor adds a model. This is a genuine, load-bearing extensibility problem now (`goose-server` is a @@ -74,9 +74,11 @@ tool-memory and summarized in "Alternatives Considered" below): boundary, in the same host process that holds real credentials. Approving votes wanted this fixed, not the overall shape abandoned. 3. **In-guest polling, reusing the harness's own already-standing network + - credential trust boundary** (this ADR's decision). Approved 5-2. The two - remaining rejections were specific and were incorporated into the design - below rather than overridden: + credential trust boundary** (this ADR's decision; the network/credential + shape approved 5-2, with the facts/code *integrity* mechanism corrected + post-review — see the correction note below). The two remaining rejections + were specific and were incorporated into the design below rather than + overridden: - **Contrarian Analyst**: the provider-facts self-registration file must not let a provider kit name an arbitrary credential env var it doesn't itself own (e.g. pointing at `GITHUB_TOKEN`) — the orchestrator must verify the @@ -88,21 +90,50 @@ tool-memory and summarized in "Alternatives Considered" below): objection for this decision — recorded here per the ADR template's requirement to document dissent, not to relitigate it. +> **Post-review correction (PR #423, reviewer: mogul).** Round 3's design still +> had the provider kit *write* its facts file to a root-owned guest path and +> assumed that made it un-tamperable. Because `acq` grants the in-sandbox agent +> **passwordless sudo**, guest root-ownership is not a trust boundary against the +> agent. The self-registration mechanism was therefore moved from +> "kit writes in-guest" to "kit ships a static file; `acq` materializes it on the +> host and mounts it **read-only**" (Layer 1 below), and the shared host-side +> cache was dropped in favor of per-sandbox, guest-local catalog generation +> (Layer 2 below). The general principle and mount/exec mechanism are recorded in +> quickstart ADR-0030 (*Host-Authoritative Sandbox Configuration*). + ## Decision -### Layer 1 — Provider kit self-registration (no schema change) +### Layer 1 — Provider kit self-registration (host-materialized, read-only) -A provider-role kit self-registers by writing a small, static JSON facts file -to a well-known guest path at its own `startup` phase: +A provider-role kit self-registers by **shipping a small, static JSON facts +file inside its own (pinned) kit directory** — declared, not generated. At +provision, `acq` reads that static file **on the host** (out of the kit +directory it already fetches and pins), validates it host-side (see "Security +hardening" below), and materializes it into a per-sandbox host directory that it +mounts **read-only** into the guest at a well-known path: ``` -/var/lib/acq/models/providers/.json +/var/lib/acq/host/models/providers/.json (read-only mount) ``` -`/var/lib/acq/` is already this codebase's established cross-kit guest -namespace (used today for the kit-env marker, `acq.backends/msb.sh:1571`) and -root-owned, so it is not agent-writable — closing the "any in-guest process can -plant a bogus provider" gap flagged by the Security Engineer vote. +> **Corrected from an earlier draft (the PR #423 review's central finding).** +> An earlier draft had each provider kit *write* this facts file to a root-owned +> guest path in its own `startup` command, and claimed "root-owned, so it is not +> agent-writable." **That is false in this environment:** `acq` deliberately +> gives the in-sandbox agent **passwordless sudo** (the sandbox — not the guest +> OS user boundary — is the security boundary). A prompt-injected agent can +> `sudo tee /var/lib/acq/models/providers/evil.json` and plant a bogus provider, +> steering model routing. Guest root-ownership is not a trust boundary against +> the agent. +> +> The fix is to move the trust boundary to the only place that holds under +> sudo — the **host↔guest** boundary. `acq` produces the facts file on the host +> and presents it to the guest **read-only** (VMM/mount-enforced; guest sudo +> cannot override it). No provider-kit code runs on the host — `acq` reads a +> **static data file** only; the normalizer still runs in-guest (Layer 2). The +> general principle and the mount/exec mechanism are recorded in the quickstart +> repo's ADR-0030 (*Host-Authoritative Sandbox Configuration*), which this ADR +> is the first consumer of and references as the mechanism of record. Facts file shape (v1): @@ -118,12 +149,15 @@ Facts file shape (v1): ``` **Env-var-ownership binding (Contrarian's fix, load-bearing):** `keyEnv` names -an environment variable. Before the Layer 2 orchestrator ever reads that named -variable's value, it MUST confirm the SAME provider kit's own `spec.yaml` -`environment:` block (or its runtime-injected secret binding for that exact -service) declares that variable name. A provider kit cannot name a credential -it does not itself own. This check is mechanical (string comparison against -the provider kit's own declared name), not a trust judgment call. +an environment variable. Before that named variable's value is ever read, the +SAME provider kit's own `spec.yaml` `environment:` block (or its +runtime-injected secret binding for that exact service) MUST declare that +variable name. A provider kit cannot name a credential it does not itself own. +This check is mechanical (string comparison against the provider kit's own +declared name), not a trust judgment call, and it runs **host-side** as part of +`acq`'s facts validation — before the facts file is materialized into the +read-only mount — so a tampered in-guest copy cannot bypass it (there is no +writable in-guest copy to tamper). The provider kit also ships, inside its own kit directory (never centrally): @@ -148,55 +182,65 @@ guarantee `zscaler-ca-certificate` runs before kits that need its CA trust; see `acq.backends/common.sh:47-58` and its bats-asserted invariant at `test/bats/50-kit-list-completeness.bats:26-28`). Its `startup` phase: -1. Globs `/var/lib/acq/models/providers/*.json`. -2. For each discovered provider, checks a cache file with an embedded fetch - timestamp, at a **host-mounted volume path** (not guest-local-only — see - "Cache persistence" below), keyed by `providerId`. -3. If stale (default TTL 24h, overridable by a config-kit default — - Tier 3, unchanged from prior design work), invokes that provider's own - normalizer via a shared, vendored-into-this-kit bounded-fetch helper - (timeout + response-size cap — the same contract as today's - `fetchJsonBounded`, now with one canonical implementation instead of two - already-drifted copies). -4. Validates the normalizer's OUTPUT (not just the fetch) against the neutral +1. Globs the **read-only** provider-facts mount + `/var/lib/acq/host/models/providers/*.json` (Layer 1). +2. For each discovered provider, invokes that provider's own normalizer via a + shared, vendored-into-this-kit bounded-fetch helper (timeout + response-size + cap — the same contract as today's `fetchJsonBounded`, now with one canonical + implementation instead of two already-drifted copies). +3. Validates the normalizer's OUTPUT (not just the fetch) against the neutral catalog schema before accepting it — Security Engineer's condition: remote response data must never be trusted merely for connecting successfully; a structurally-valid-but-wrong response is still a routing hazard, so shape validation is mandatory on every refresh, not just on error paths. -5. On ANY failure (timeout, oversized, malformed, validation failure, no - provider present) falls back to last-good cache, then to that provider's own - vendored snapshot. -6. Aggregates every provider's neutral output into one file: - `/var/lib/acq/models/catalog.json`. -7. Records provenance per provider (`"source": "live" | "cache" | "snapshot"`, - with a timestamp) in the aggregate — DevEx's condition: a permanently - degraded fallback must be visible, not silently indistinguishable from a - fresh fetch. +4. On ANY failure (timeout, oversized, malformed, validation failure, no + provider present) falls back to that provider's own vendored snapshot + (shipped in the provider kit, presented on the same read-only mount). +5. Aggregates every provider's neutral output into one **guest-local, + per-sandbox** file, written read-write and never shared with any other + sandbox: `/var/lib/acq/models/catalog.json`. +6. Records provenance per provider (`"source": "live" | "snapshot"`, with a + timestamp) in the aggregate — DevEx's condition: a permanently degraded + fallback must be visible, not silently indistinguishable from a fresh fetch. This kit carries **zero vendor-specific knowledge** — it is pure orchestration -(glob, cache, invoke, validate, fall back, aggregate). That is what makes it a +(glob, invoke, validate, fall back, aggregate). That is what makes it a credible candidate for promotion to a widely-reused core kit: a third-party provider kit needs zero PRs accepted into this kit or into any central catalog file to work with it. -**Cache persistence (per maintainer decision):** the cache and aggregate files -live on a **host-mounted volume**, shared across sandbox recreations for the -same user/host, not guest-local-only. A guest-local-only cache would make the -TTL meaningless across `acq run` invocations (every fresh sandbox starts cold). -The mount is a passive host-directory bind (`msb --mount-dir` / -sbx's neutral `volumes[]` primitive — see ADR 0001's `volumes` vocabulary, -`schemas/kit-hybrid-v1.schema.json:181-201`), not host-executed code — it does -not reintroduce the "third-party code executes on the host" problem that -Option 2 was rejected for. `acq` itself contributes no new code to make this -mount exist beyond wiring the models-kit's declared volume through the -existing neutral `volumes[]` translation, which both backends already support. +**No shared host-side cache (maintainer decision, corrected from an earlier +draft).** An earlier draft cached the aggregated catalog on a **host-mounted +volume shared across sandboxes** to make a TTL meaningful across `acq run` +invocations. That is **dropped**: a cache written by one sandbox and read by +another is a cross-sandbox tampering channel — a compromised sandbox could +poison the catalog a *different* sandbox routes against. The catalog is instead +**generated per-sandbox at startup** and kept **guest-local** to that sandbox +(`/var/lib/acq/models/catalog.json`, read-write, owned by the sandbox that +produced it). It is authoritative only within its own sandbox and dies with it. +The freshness/TTL machinery and the host cache volume are removed entirely; the +deepest fallback is the provider kit's vendored snapshot on the read-only mount. +The **inputs** the orchestrator trusts — the provider facts and the normalizer +code — are host-authoritative and read-only (Layer 1 + ADR-0030); its **output** +(the catalog) is guest-local and never trusted by any other sandbox. + +> **Startup integrity across restarts.** The orchestrator's own code (and each +> provider's normalizer + vendored snapshot) is staged from the pinned kit +> directory into the same per-sandbox **read-only** mount and invoked from +> there by `acq`'s restart heal — so a sudo-capable agent cannot tamper the +> startup code between restarts and have `acq` re-run the tampered copy. This is +> Mechanism 2 of quickstart ADR-0030 (trusted startup execution from the `:ro` +> mount); the alternatives considered (re-push-on-heal with its TOCTOU window; +> msb `--script-path` native replay, which ADR-0017 showed is inert at boot) are +> recorded there. ### Layer 3 — Harness kit owns its own rendering (unchanged from prior guidance) Each harness kit, at its own `startup` phase (after the models kit, per the same positional-ordering convention), reads: -- `/var/lib/acq/models/catalog.json` (Layer 2's neutral aggregate), and +- `/var/lib/acq/models/catalog.json` (Layer 2's neutral aggregate — the + per-sandbox, guest-local file produced this boot), and - The config kit's cross-harness defaults (model-role priority, etc. — Tier 3 from prior design work, unchanged, still deferred: a neutral **permission** vocabulary stays out of scope until a second harness kit's *native* @@ -227,7 +271,20 @@ slow-moving shape (id / context window / pricing / vendor). ### Security hardening (both panels' conditions, binding) -- **Env-var-ownership binding** (Contrarian): enforced as described in Layer 1. +- **Host-authoritative, guest-read-only inputs** (this ADR's central + correction; quickstart ADR-0030): provider facts and normalizer/orchestrator + code are materialized by `acq` on the host from the pinned kit directory and + mounted **read-only** into the guest. A prompt-injected, sudo-capable agent + cannot plant a bogus provider, tamper a normalizer, or rewrite startup code — + the mount is read-only at the VMM/mount layer, which guest sudo cannot + override. `acq` reads only **static data** from the kit on the host; no + provider-kit code executes on the host. +- **No shared/cross-sandbox state**: the catalog is generated per-sandbox at + startup and kept guest-local; nothing a sandbox writes is read by another + sandbox. This removes the cross-sandbox poisoning channel a shared host cache + would introduce. +- **Env-var-ownership binding** (Contrarian): enforced host-side as described in + Layer 1. - **SSRF hardening** (Security Engineer, Contrarian): `modelsUrl` must be `https://` and its host must equal the facts file's own declared `host`, which must itself be on the sandbox's already-effective `caps.network.allow` @@ -240,8 +297,8 @@ slow-moving shape (id / context window / pricing / vendor). models kit's own code never sees plaintext key material beyond what the guest's existing secret-injection mechanism already exposes to that specific, already-network-permitted host. -- **Atomicity.** Facts, cache, and aggregate writes are temp-file + rename; - no reader ever observes a partial write. +- **Atomicity.** Facts and aggregate writes are temp-file + rename; no reader + ever observes a partial write. - **Bounded resource use.** Per-provider fetch timeout + response-size cap (shared helper, single canonical implementation); a total wall-clock budget across all discovered providers so a slow/hanging endpoint cannot stall @@ -249,10 +306,75 @@ slow-moving shape (id / context window / pricing / vendor). - **Pinning.** Provider and harness kits remain SHA-pinned per this repo's existing kit-reference discipline; no floating refs. +## Trust-boundary flows + +Participants: **GitHub** (pinned kit source), **Host** (`acq` + real +credentials + the state tree), **Guest** (the microVM/container; the agent has +passwordless sudo). + +### Rejected — provider kit writes facts in the guest (the PR #423 finding) + +```mermaid +sequenceDiagram + box GitHub + participant KIT as Provider kit (pinned) + end + box Host + participant ACQ as acq + end + box Guest (agent has sudo) + participant STARTUP as provider startup cmd + participant AGENT as agent (injectable) + participant FS as /var/lib/acq/models (root-owned) + participant ORCH as models orchestrator + end + + ACQ->>KIT: fetch pinned kit + ACQ->>STARTUP: run startup (exec) + STARTUP->>FS: write providers/usai.json (as root) + Note over FS: "root-owned ⇒ safe" — FALSE under passwordless sudo + AGENT->>FS: sudo tee providers/evil.json (plant bogus provider) + ORCH->>FS: glob providers/*.json + ORCH->>ORCH: route models against attacker-planted facts +``` + +### Chosen — host-materialized facts + code, guest read-only + +```mermaid +sequenceDiagram + box GitHub + participant KIT as Provider kit (pinned) + end + box Host + participant ACQ as acq + participant HSTATE as state/config/ (host) + end + box Guest (agent has sudo) + participant RO as /var/lib/acq/host (:ro) + participant AGENT as agent (injectable) + participant ORCH as models orchestrator + participant CAT as catalog.json (guest-local rw) + end + + ACQ->>KIT: fetch pinned kit (static facts + normalizer + snapshot) + ACQ->>ACQ: validate facts host-side (env-var-ownership, SSRF, schema) + ACQ->>HSTATE: write validated facts + staged code + ACQ->>RO: mount HSTATE read-only + AGENT-->>RO: sudo tee providers/evil.json (FAILS: read-only mount) + ACQ->>RO: invoke orchestrator from :ro path (restart-safe) + RO->>ORCH: run normalizer (in-guest, provider's own credential) + ORCH->>CAT: write per-sandbox catalog (rw, never shared) + Note over ACQ,RO: acq trusts only host-authoritative read-only inputs and the catalog stays guest-local (never cross sandbox) +``` + ## Consequences ### Positive +- **Closes the PR #423 finding:** a prompt-injected, sudo-capable agent can no + longer plant/forge provider facts or tamper the normalizer/startup code that + `acq` re-runs — the trust boundary is the host↔guest read-only mount, not + guest root-ownership. - Collapses the confirmed secret-duplication bug (two service-name registrations for one USAi credential) into one canonical per-(vendor) registration. @@ -265,33 +387,33 @@ slow-moving shape (id / context window / pricing / vendor). - `pi-coding-agent` and any future harness kit adopt this as a clean, greenfield integration (read the aggregate, render your own config) with no migration burden. -- Model lists refresh daily without a human re-running a sync script and - cutting a kit release, while remaining fully functional and deterministic - offline (fall back to cache, then vendored snapshot). +- Model lists refresh without a human re-running a sync script and cutting a kit + release, while remaining fully functional and deterministic offline (fall back + to the provider's vendored snapshot). ### Negative / risks -- New moving parts: a host-mounted cache volume, a new orchestrator kit, a - versioned schema contract between N producers and M consumers. Mitigated by - strict layering (each layer independently testable and independently - shippable) and by the versioned-not-negotiated schema decision above, which - bounds the coordination cost to "rare, documented, coordinated bump" rather - than "permanent negotiation machinery." -- Sandbox startup now has a conditional outbound step (the models kit's - refresh-if-stale check) on the critical path. Bounded by per-provider timeout - + a total wall-clock budget, and by the cache making the common case (fresh - cache) a fast local read, not a network round-trip. +- New moving parts: a per-sandbox host config directory + read-only mount, a new + orchestrator kit, a versioned schema contract between N producers and M + consumers. Mitigated by strict layering (each layer independently testable and + shippable), by reusing the existing host-state-dir conventions (quickstart + ADR-0030), and by the versioned-not-negotiated schema decision above. +- Sandbox startup now has a conditional outbound step (the orchestrator's live + refresh) on the critical path. Bounded by per-provider timeout + + a total wall-clock budget; on any failure it falls back to the provider's + vendored snapshot (a fast local read), so a slow/unreachable endpoint degrades + rather than stalls. - Rendered harness config is no longer a pure function of pinned kit refs alone - — it also depends on cache state at boot time. Accepted, with the fallback - ladder and provenance recording making any degraded state visible rather - than silent. + — it also depends on live-vs-snapshot state at boot time. Accepted, with the + snapshot fallback and per-provider provenance recording making any degraded + state visible rather than silent. ### Neutral - Requires no change to the `hybrid/v1` schema itself — the entire mechanism composes from already-shipped primitives (`caps.network.allow` union, - sequential startup-phase ordering, well-known-guest-path self-registration, - the neutral `volumes[]` vocabulary for the cache mount). + sequential startup-phase ordering) plus the host-authoritative read-only + config mount defined in quickstart ADR-0030. ## Alternatives Considered @@ -329,8 +451,11 @@ slow-moving shape (id / context window / pricing / vendor). ## References -- ADR 0001 (isolation) — neutral `hybrid/v1` acq-kits spec; `volumes[]` - vocabulary this ADR reuses for the cache mount. +- Quickstart ADR-0030 (*Host-Authoritative Sandbox Configuration*) — the + general principle and the per-sandbox host config dir + read-only mount + + trusted-startup-execution mechanism this ADR's Layer 1/Layer 2 build on; + the mechanism of record for the PR #423 correction. +- ADR 0001 (isolation) — neutral `hybrid/v1` acq-kits spec. - ADR 0002 (isolation) — neutral network egress tiers; `caps.network.allow` union this ADR reuses as the SSRF-hardening cross-check and as the existing trust boundary this design deliberately does not widen. From d973a8a250d20b4000f0442608ae447f7ff66b65 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Tue, 22 Sep 2026 04:58:48 +0000 Subject: [PATCH 03/13] docs(isolation): rewrite ADR 0003 as documentation of the agreed design The ADR had accreted into a change-log of the review (consensus rounds, vote tallies, "corrected from an earlier draft" notes, reviewer names, dueling Option 2/3 correction blocks). Once merged, an ADR is documentation of how the thing works, not a journal of how we arrived at it. Rewrite it to describe the agreed end state directly: - Context states the problem (duplicated credential registration, duplicated merge code, manual release-coupled refresh) and the design goals, without the panel/vote narrative. - Decision describes Layers 1-3 in the present tense as the design, folding the host-authoritative read-only facts/code rationale inline instead of as a post-hoc correction. - A single "Security model" section replaces the scattered "hardening conditions" attributed to reviewer roles. - One "Trust-boundary flow" diagram (the chosen design); the rejected guest-write sequence diagram is dropped (its point now lives as one line in Alternatives + the Layer 1 rationale). - Alternatives Considered keeps WHAT was excluded and WHY, dropping vote counts and session references. No behavioral change; documentation only. Co-authored-by: OpenCode [claude_4_8_opus] --- .../0003-neutral-model-provider-discovery.md | 607 +++++++----------- 1 file changed, 236 insertions(+), 371 deletions(-) diff --git a/integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md b/integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md index 9401548a..42c36537 100644 --- a/integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md +++ b/integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md @@ -11,130 +11,62 @@ nist_controls: ["SC-7", "AC-4", "CM-7", "SI-10"] # ADR 0003 (isolation) — Neutral model-provider discovery: provider facts, a models orchestrator kit, and harness-owned rendering > Area-scoped ADR for `integrations/isolation/`. Extends the neutral `hybrid/v1` -> kit spec (ADR 0001) and the network egress tiers (ADR 0002). Does not modify -> either schema; the mechanism described here composes from already-shipped -> primitives (`caps.network.allow` union, sequential kit startup) plus the +> kit spec (ADR 0001) and the network egress tiers (ADR 0002). It changes neither +> schema; the mechanism composes from already-shipped primitives +> (`caps.network.allow` union, sequential kit startup) plus the > host-authoritative read-only config mount defined in quickstart ADR-0030. ## Context and Problem Statement -Every harness kit that talks to a model provider (`usai-provider`/OpenCode, -`goose-server`, and future `pi-coding-agent`/others) currently hand-rolls its -own provider integration: - -- `usai-provider` ships a hand-maintained `opencode.jsonc` model block, refreshed - by a maintainer manually running `scripts/sync-usai-models.mjs` on the host and - committing the result. `goose-server` (in flight) independently re-registers - the **same USAi credential** under a **different** secret-store service name - (`goose-usai` vs. `usai`, same host `api.gsa.usai.gov`) — confirmed by tracing - `acq`'s secret store, which dedupes purely by service-name string, never by - host (`acq.backends/secret-store.sh:92-114`). A user running both kits pastes - the same USAi key in twice, with no cross-check if one copy is rotated and the - other isn't. -- The two kits' "copy-on-first-boot, merge-without-clobbering on later boots" - config-merge scripts (`usai-provider/files/home/usai-config/merge-global-config.mjs`, - `goose-server/files/home/goose-config/merge-config.mjs`) are ~230-260 lines - each, of which ~150-190 lines per kit are the same algorithm, hand-copied — the - goose file's own header admits it "mirrors the usai-provider kit's - merge-global-config.mjs." -- The bounded-fetch-with-timeout-and-size-cap helper (`fetchJsonBounded`) has - already been copy-pasted once (`integrations/providers/usai/scripts/build-catalog.mjs`) - and has already drifted — the copy gained 401/403 handling the original lacks. -- `pi-coding-agent` ships today with **zero** provider-config code - (deliberately deferred; see its own README "Status" section) — a clean-slate - case any new mechanism must also serve without a migration burden. -- The maintainer's stated direction (this session, 2026-09-17/18): kits should - be mixed and matched (multiple provider kits, multiple harness kits, not a - fixed 1:1 pairing), harness kits must own their own configuration lifecycle - end-to-end (read from a neutral source, render their own native format — - never a central kit rendering on a harness's behalf), and the staleness - problem should be solved by an in-sandbox refresh at startup, with a - vendored snapshot as the fallback — not by a human re-running a sync - script and cutting a kit release every time a vendor adds a model. - -This is a genuine, load-bearing extensibility problem now (`goose-server` is a -second real harness kit in flight; `pi-coding-agent` is a third with zero -provider code to migrate), not a speculative one for a hypothetical future -provider. - -## Options Considered - -Three shapes were evaluated via nexus-agents 7-role consensus panels before -this ADR was written (all three panel results are persisted in this session's -tool-memory and summarized in "Alternatives Considered" below): - -1. **Live in-guest polling with no discovery mechanism.** Rejected 7-0. No way - for a harness kit to know which provider kit(s) were present other than - hardcoding a vendor name; no fallback/validation contract specified. -2. **Host-side polling: `acq` itself executes a normalizer script shipped - inside a provider kit, on the host, next to real secret material, before any - sandbox exists.** Split 4-3 approve. The rejecting votes converged on one - sharp, correct objection: this is the first case in this codebase of - executing third-party kit-shipped code **outside** the sandbox trust - boundary, in the same host process that holds real credentials. Approving - votes wanted this fixed, not the overall shape abandoned. -3. **In-guest polling, reusing the harness's own already-standing network + - credential trust boundary** (this ADR's decision; the network/credential - shape approved 5-2, with the facts/code *integrity* mechanism corrected - post-review — see the correction note below). The two remaining rejections - were specific and were incorporated into the design below rather than - overridden: - - **Contrarian Analyst**: the provider-facts self-registration file must not - let a provider kit name an arbitrary credential env var it doesn't itself - own (e.g. pointing at `GITHUB_TOKEN`) — the orchestrator must verify the - named var is one the same provider kit declares in its own `environment:` - block before ever reading it. - - **Scope Steward**: don't build a multi-provider orchestration kit - speculatively at N=1 real provider. The maintainer's explicit direction - (goose-server + pi-coding-agent in flight, "we need it") supersedes this - objection for this decision — recorded here per the ADR template's - requirement to document dissent, not to relitigate it. - -> **Post-review correction (PR #423, reviewer: mogul).** Round 3's design still -> had the provider kit *write* its facts file to a root-owned guest path and -> assumed that made it un-tamperable. Because `acq` grants the in-sandbox agent -> **passwordless sudo**, guest root-ownership is not a trust boundary against the -> agent. The self-registration mechanism was therefore moved from -> "kit writes in-guest" to "kit ships a static file; `acq` materializes it on the -> host and mounts it **read-only**" (Layer 1 below), and the shared host-side -> cache was dropped in favor of per-sandbox, guest-local catalog generation -> (Layer 2 below). The general principle and mount/exec mechanism are recorded in -> quickstart ADR-0030 (*Host-Authoritative Sandbox Configuration*). +Kits are mixed and matched: a sandbox may carry more than one **provider** kit +(which knows a vendor's model API) and more than one **harness** kit (which +configures an agent — OpenCode, goose, `pi-coding-agent`), in any combination, +not a fixed 1:1 pairing. Without a discovery mechanism, every harness kit has to +hand-roll its own provider integration, which produces three recurring problems: + +- **Duplicated credential registration.** Two harness kits reaching the same + vendor register the same credential twice under different secret-store service + names, because `acq`'s secret store dedupes by service-name string, never by + host (`acq.backends/secret-store.sh`). A user pastes the same key in twice and + nothing cross-checks the two copies when one is rotated. +- **Duplicated config-merge code.** Each harness kit re-implements the same + "copy-on-first-boot, merge-without-clobbering-on-later-boots" algorithm + (~150–190 lines per kit) and the same bounded-fetch-with-timeout-and-size-cap + helper, hand-copied between kits and prone to drift. +- **Manual, release-coupled model refresh.** A model list is refreshed only when + a maintainer runs a sync script on the host and cuts a kit release, so the + catalog goes stale between releases. + +The design goals that follow: + +- A harness kit learns which provider kit(s) are present **without hardcoding a + vendor name**. +- Each harness kit **owns its own configuration lifecycle end to end**: it reads + a neutral model catalog and renders its own native config; no central kit + renders on a harness's behalf. +- Model lists **refresh in-sandbox at startup**, with a vendored snapshot as the + offline fallback, so no human re-runs a sync script and cuts a release for + every vendor model change. +- Adding a provider or harness costs **N + M** integrations (N provider + normalizers + M harness renderers), not **N × M** per-pair emitters. ## Decision +Three layers, composing from already-shipped primitives. No `hybrid/v1` schema +change is required. + ### Layer 1 — Provider kit self-registration (host-materialized, read-only) -A provider-role kit self-registers by **shipping a small, static JSON facts -file inside its own (pinned) kit directory** — declared, not generated. At -provision, `acq` reads that static file **on the host** (out of the kit -directory it already fetches and pins), validates it host-side (see "Security -hardening" below), and materializes it into a per-sandbox host directory that it -mounts **read-only** into the guest at a well-known path: +A provider-role kit ships a small, static JSON **facts file** inside its own +(pinned) kit directory. At provision, `acq` reads that static file **on the +host**, validates it host-side (see [Security model](#security-model)), and +materializes it into a per-sandbox host directory that it mounts **read-only** +into the guest at a well-known path: -``` +```text /var/lib/acq/host/models/providers/.json (read-only mount) ``` -> **Corrected from an earlier draft (the PR #423 review's central finding).** -> An earlier draft had each provider kit *write* this facts file to a root-owned -> guest path in its own `startup` command, and claimed "root-owned, so it is not -> agent-writable." **That is false in this environment:** `acq` deliberately -> gives the in-sandbox agent **passwordless sudo** (the sandbox — not the guest -> OS user boundary — is the security boundary). A prompt-injected agent can -> `sudo tee /var/lib/acq/models/providers/evil.json` and plant a bogus provider, -> steering model routing. Guest root-ownership is not a trust boundary against -> the agent. -> -> The fix is to move the trust boundary to the only place that holds under -> sudo — the **host↔guest** boundary. `acq` produces the facts file on the host -> and presents it to the guest **read-only** (VMM/mount-enforced; guest sudo -> cannot override it). No provider-kit code runs on the host — `acq` reads a -> **static data file** only; the normalizer still runs in-guest (Layer 2). The -> general principle and the mount/exec mechanism are recorded in the quickstart -> repo's ADR-0030 (*Host-Authoritative Sandbox Configuration*), which this ADR -> is the first consumer of and references as the mechanism of record. - Facts file shape (v1): ```json @@ -148,197 +80,151 @@ Facts file shape (v1): } ``` -**Env-var-ownership binding (Contrarian's fix, load-bearing):** `keyEnv` names -an environment variable. Before that named variable's value is ever read, the -SAME provider kit's own `spec.yaml` `environment:` block (or its -runtime-injected secret binding for that exact service) MUST declare that -variable name. A provider kit cannot name a credential it does not itself own. -This check is mechanical (string comparison against the provider kit's own -declared name), not a trust judgment call, and it runs **host-side** as part of -`acq`'s facts validation — before the facts file is materialized into the -read-only mount — so a tampered in-guest copy cannot bypass it (there is no -writable in-guest copy to tamper). - -The provider kit also ships, inside its own kit directory (never centrally): - -- Its own normalizer (raw vendor API response → the one neutral catalog - schema below). The provider kit's own author knows its vendor's API shape - best; no central kit accumulates per-vendor knowledge that rots. -- Its own vendored fallback snapshot — a small, committed, last-known-good - model list used when live refresh fails. A bad fetch degrades to *that - provider's* last-known-good list, not a central kit's stale idea of every - vendor's list. - -This directly fixes the secret-duplication problem in Context: a second -provider kit reaching the same vendor should reuse the SAME secret-store -service identity (keyed by provider, not invented per-harness), eliminating -the `usai` vs `goose-usai` double-registration. - -### Layer 2 — Models orchestrator kit (new; core-repo candidate) - -A new, vendor-agnostic kit, placed early in kit order (after provider kits, -before harness kits — the same positional-ordering convention already used to -guarantee `zscaler-ca-certificate` runs before kits that need its CA trust; -see `acq.backends/common.sh:47-58` and its bats-asserted invariant at -`test/bats/50-kit-list-completeness.bats:26-28`). Its `startup` phase: - -1. Globs the **read-only** provider-facts mount +The facts file is **host-authoritative and read-only in the guest**: `acq` +produces it on the host from the pinned kit and presents it to the guest through +a read-only mount that the VMM/mount layer enforces. This is why the file is not +written by an in-guest `startup` command — the in-sandbox agent has passwordless +sudo (the sandbox, not the guest OS user boundary, is the security boundary), so +a root-owned *guest* path is not a trust boundary against a prompt-injected +agent. The read-only host mount is. `acq` reads only **static data** from the +kit on the host; no provider-kit code runs on the host. See +[Security model](#security-model) and quickstart ADR-0030 +(*Host-Authoritative Sandbox Configuration*), the mechanism of record. + +`keyEnv` names the environment variable that holds the vendor credential. A +provider kit may only name a credential it declares itself: `acq` confirms +`keyEnv` matches a variable in the same provider kit's own `spec.yaml` +`environment:` block (or its runtime-injected secret binding for that service) +before that variable is ever read. This check is a mechanical string comparison, +runs host-side as part of facts validation, and prevents a kit from naming an +unrelated credential (e.g. `GITHUB_TOKEN`). + +Each provider kit also ships, inside its own kit directory (never centrally): + +- its **normalizer** — code that maps the raw vendor API response to the neutral + catalog schema. The vendor's own author knows its API shape best, so no central + kit accumulates per-vendor knowledge that rots; and +- a **vendored fallback snapshot** — a small, committed, last-known-good model + list used when a live refresh fails, so a bad fetch degrades to *that + provider's* last-known-good list. + +Keying the credential by provider (not by harness) means a second harness kit +reaching the same vendor reuses the same secret-store service identity, +eliminating the double-registration described in Context. + +### Layer 2 — Models orchestrator kit (vendor-agnostic) + +A single vendor-agnostic kit runs early in kit order — after provider kits, +before harness kits — using the same positional-ordering convention that already +guarantees `zscaler-ca-certificate` runs before kits that need its CA trust +(`acq.backends/common.sh`, asserted in `test/bats/50-kit-list-completeness.bats`). +Its `startup` phase: + +1. Globs the read-only provider-facts mount `/var/lib/acq/host/models/providers/*.json` (Layer 1). -2. For each discovered provider, invokes that provider's own normalizer via a - shared, vendored-into-this-kit bounded-fetch helper (timeout + response-size - cap — the same contract as today's `fetchJsonBounded`, now with one canonical - implementation instead of two already-drifted copies). -3. Validates the normalizer's OUTPUT (not just the fetch) against the neutral - catalog schema before accepting it — Security Engineer's condition: remote - response data must never be trusted merely for connecting successfully; a - structurally-valid-but-wrong response is still a routing hazard, so shape - validation is mandatory on every refresh, not just on error paths. -4. On ANY failure (timeout, oversized, malformed, validation failure, no - provider present) falls back to that provider's own vendored snapshot - (shipped in the provider kit, presented on the same read-only mount). +2. For each discovered provider, invokes that provider's own normalizer through a + shared, vendored-into-this-kit bounded-fetch helper (per-provider timeout + + response-size cap), with one canonical implementation of that helper. +3. Validates the normalizer's **output** against the neutral catalog schema + before accepting it — a structurally-valid-but-wrong response is still a + routing hazard, so shape validation runs on every refresh, not only on error + paths. +4. On any failure (timeout, oversized, malformed, validation failure, or no + provider present) falls back to that provider's vendored snapshot, presented + on the same read-only mount. 5. Aggregates every provider's neutral output into one **guest-local, per-sandbox** file, written read-write and never shared with any other sandbox: `/var/lib/acq/models/catalog.json`. 6. Records provenance per provider (`"source": "live" | "snapshot"`, with a - timestamp) in the aggregate — DevEx's condition: a permanently degraded - fallback must be visible, not silently indistinguishable from a fresh fetch. - -This kit carries **zero vendor-specific knowledge** — it is pure orchestration -(glob, invoke, validate, fall back, aggregate). That is what makes it a -credible candidate for promotion to a widely-reused core kit: a third-party -provider kit needs zero PRs accepted into this kit or into any central catalog -file to work with it. - -**No shared host-side cache (maintainer decision, corrected from an earlier -draft).** An earlier draft cached the aggregated catalog on a **host-mounted -volume shared across sandboxes** to make a TTL meaningful across `acq run` -invocations. That is **dropped**: a cache written by one sandbox and read by -another is a cross-sandbox tampering channel — a compromised sandbox could -poison the catalog a *different* sandbox routes against. The catalog is instead -**generated per-sandbox at startup** and kept **guest-local** to that sandbox -(`/var/lib/acq/models/catalog.json`, read-write, owned by the sandbox that -produced it). It is authoritative only within its own sandbox and dies with it. -The freshness/TTL machinery and the host cache volume are removed entirely; the -deepest fallback is the provider kit's vendored snapshot on the read-only mount. -The **inputs** the orchestrator trusts — the provider facts and the normalizer -code — are host-authoritative and read-only (Layer 1 + ADR-0030); its **output** -(the catalog) is guest-local and never trusted by any other sandbox. - -> **Startup integrity across restarts.** The orchestrator's own code (and each -> provider's normalizer + vendored snapshot) is staged from the pinned kit -> directory into the same per-sandbox **read-only** mount and invoked from -> there by `acq`'s restart heal — so a sudo-capable agent cannot tamper the -> startup code between restarts and have `acq` re-run the tampered copy. This is -> Mechanism 2 of quickstart ADR-0030 (trusted startup execution from the `:ro` -> mount); the alternatives considered (re-push-on-heal with its TOCTOU window; -> msb `--script-path` native replay, which ADR-0017 showed is inert at boot) are -> recorded there. - -### Layer 3 — Harness kit owns its own rendering (unchanged from prior guidance) - -Each harness kit, at its own `startup` phase (after the models kit, per the -same positional-ordering convention), reads: - -- `/var/lib/acq/models/catalog.json` (Layer 2's neutral aggregate — the - per-sandbox, guest-local file produced this boot), and -- The config kit's cross-harness defaults (model-role priority, etc. — Tier 3 - from prior design work, unchanged, still deferred: a neutral **permission** - vocabulary stays out of scope until a second harness kit's *native* - permission model is in hand, per the unanimous guidance from the first - consensus panel in this session), - -and renders its OWN native config in its OWN format. The harness kit owns its -full configuration lifecycle end-to-end — it is the only thing that knows how -to turn a neutral model list into `opencode.jsonc`, or into goose's -`custom_usai.json`, or into a future `pi` config. No central kit renders on a -harness's behalf. - -This yields **N provider normalizers + M harness renderers**, not -**N×M** per-pair emitters (today's actual pattern once `goose-server` lands: -`emitters/opencode.mjs`, `emitters/prime-agent.mjs`, `emitters/goose.mjs`, each -independently re-deriving "read catalog, select models, transform pricing"). - -### Neutral catalog schema — versioned now, no negotiation mechanism (per maintainer decision) - -The schema is versioned from v1 (`schemaVersion: "acq-neutral-model-catalog/v1"` -on every normalizer's output and the aggregate file). Per the maintainer's -explicit decision: ship v1 now; do not build a multi-version negotiation -mechanism (renderers declaring supported versions, the orchestrator picking -compatible pairings) ahead of a real second version existing. If a breaking -change is ever needed, it is a coordinated bump documented in a follow-up ADR -at that time — accepted as a rare cost given model catalogs are a simple, -slow-moving shape (id / context window / pricing / vendor). - -### Security hardening (both panels' conditions, binding) - -- **Host-authoritative, guest-read-only inputs** (this ADR's central - correction; quickstart ADR-0030): provider facts and normalizer/orchestrator - code are materialized by `acq` on the host from the pinned kit directory and - mounted **read-only** into the guest. A prompt-injected, sudo-capable agent - cannot plant a bogus provider, tamper a normalizer, or rewrite startup code — - the mount is read-only at the VMM/mount layer, which guest sudo cannot - override. `acq` reads only **static data** from the kit on the host; no - provider-kit code executes on the host. -- **No shared/cross-sandbox state**: the catalog is generated per-sandbox at - startup and kept guest-local; nothing a sandbox writes is read by another - sandbox. This removes the cross-sandbox poisoning channel a shared host cache - would introduce. -- **Env-var-ownership binding** (Contrarian): enforced host-side as described in - Layer 1. -- **SSRF hardening** (Security Engineer, Contrarian): `modelsUrl` must be - `https://` and its host must equal the facts file's own declared `host`, - which must itself be on the sandbox's already-effective `caps.network.allow` - union (ADR 0002) — the orchestrator never fetches an arbitrary URL a facts - file supplies without this cross-check. -- **No new credential exposure to the models kit itself.** The models kit - invokes each provider's normalizer with that provider's already-bound - credential exactly as the provider kit would use it for its own inference - calls — reusing the standing trust boundary, not creating a new one. The - models kit's own code never sees plaintext key material beyond what the - guest's existing secret-injection mechanism already exposes to that - specific, already-network-permitted host. -- **Atomicity.** Facts and aggregate writes are temp-file + rename; no reader - ever observes a partial write. + timestamp) in the aggregate, so a permanently degraded fallback is visible + rather than indistinguishable from a fresh fetch. + +The orchestrator carries **zero vendor-specific knowledge** — it is pure +orchestration (glob, invoke, validate, fall back, aggregate). A third-party +provider kit therefore works with it without any PR into this kit or into a +central catalog file. + +The catalog is **guest-local and per-sandbox**: it is generated fresh at each +sandbox's startup and is authoritative only within the sandbox that produced it. +There is no cross-sandbox catalog cache — a cache written by one sandbox and read +by another would be a cross-sandbox poisoning channel (a compromised sandbox +could poison the catalog a different sandbox routes against). The clean split is: +the orchestrator's **inputs** (provider facts and normalizer code) are +host-authoritative and read-only (Layer 1 + ADR-0030); its **output** (the +catalog) is guest-local and trusted by no other sandbox. + +The orchestrator's own code, and each provider's normalizer and vendored +snapshot, run from the read-only mount (ADR-0030 Mechanism 2), so a sudo-capable +agent cannot tamper the startup code between restarts and have `acq` re-run a +tampered copy. + +### Layer 3 — Harness kit owns its own rendering + +Each harness kit, at its own `startup` phase (after the orchestrator, per the +same ordering convention), reads: + +- `/var/lib/acq/models/catalog.json` — Layer 2's neutral aggregate for this + sandbox; and +- the config kit's cross-harness defaults (model-role priority, etc.). + +It then renders its **own** native config in its own format — `opencode.jsonc`, +goose's `custom_usai.json`, a future `pi` config — because only the harness knows +how to turn a neutral model list into its native shape. No central kit renders on +a harness's behalf. + +This is what yields **N provider normalizers + M harness renderers** rather than +**N × M** per-pair emitters that each re-derive "read catalog, select models, +transform pricing." + +### Neutral catalog schema + +Both a normalizer's output and the aggregate carry +`schemaVersion: "acq-neutral-model-catalog/v1"`. The schema is versioned from v1; +there is no multi-version negotiation mechanism (renderers declaring supported +versions, the orchestrator picking compatible pairings). A model catalog is a +simple, slow-moving shape (id / context window / pricing / vendor), so a breaking +change is handled as a rare, coordinated version bump documented in a follow-up +ADR at that time, not by standing negotiation machinery. + +## Security model + +- **Host-authoritative, guest-read-only inputs.** Provider facts and + normalizer/orchestrator code are materialized by `acq` on the host from the + pinned kit directory and mounted read-only into the guest. A prompt-injected, + sudo-capable agent cannot plant a bogus provider, tamper a normalizer, or + rewrite startup code, because the mount is read-only at the VMM/mount layer. + `acq` reads only static data from the kit on the host; no provider-kit code + executes on the host (see [Alternatives](#alternatives-considered) for why + host-side execution is excluded). +- **No cross-sandbox state.** The catalog is generated per-sandbox and kept + guest-local; nothing one sandbox writes is read by another. +- **Env-var ownership.** A provider kit may only name a credential (`keyEnv`) it + declares in its own `spec.yaml`; enforced host-side (Layer 1). +- **SSRF containment.** `modelsUrl` must be `https://` and its host must equal + the facts file's declared `host`, which must itself be on the sandbox's + effective `caps.network.allow` union (ADR 0002). The orchestrator never fetches + an arbitrary URL a facts file supplies. +- **No new credential exposure.** The orchestrator invokes each provider's + normalizer with that provider's already-bound credential, exactly as the + provider kit would for its own inference calls — reusing the standing trust + boundary, not creating a new one. The orchestrator's own code never sees + plaintext key material beyond what the guest's existing secret-injection + mechanism already exposes to that specific, network-permitted host. +- **Atomic writes.** Facts and aggregate writes are temp-file + rename; no reader + observes a partial write. - **Bounded resource use.** Per-provider fetch timeout + response-size cap - (shared helper, single canonical implementation); a total wall-clock budget - across all discovered providers so a slow/hanging endpoint cannot stall - sandbox startup indefinitely. -- **Pinning.** Provider and harness kits remain SHA-pinned per this - repo's existing kit-reference discipline; no floating refs. - -## Trust-boundary flows - -Participants: **GitHub** (pinned kit source), **Host** (`acq` + real -credentials + the state tree), **Guest** (the microVM/container; the agent has -passwordless sudo). - -### Rejected — provider kit writes facts in the guest (the PR #423 finding) - -```mermaid -sequenceDiagram - box GitHub - participant KIT as Provider kit (pinned) - end - box Host - participant ACQ as acq - end - box Guest (agent has sudo) - participant STARTUP as provider startup cmd - participant AGENT as agent (injectable) - participant FS as /var/lib/acq/models (root-owned) - participant ORCH as models orchestrator - end + (one shared implementation) and a total wall-clock budget across providers, so + a slow or hanging endpoint cannot stall sandbox startup. +- **Pinning.** Provider and harness kits are SHA-pinned per the repo's kit-ref + discipline; no floating refs. - ACQ->>KIT: fetch pinned kit - ACQ->>STARTUP: run startup (exec) - STARTUP->>FS: write providers/usai.json (as root) - Note over FS: "root-owned ⇒ safe" — FALSE under passwordless sudo - AGENT->>FS: sudo tee providers/evil.json (plant bogus provider) - ORCH->>FS: glob providers/*.json - ORCH->>ORCH: route models against attacker-planted facts -``` +## Trust-boundary flow -### Chosen — host-materialized facts + code, guest read-only +Participants: **GitHub** (pinned kit source), **Host** (`acq` + real credentials ++ the state tree), **Guest** (the microVM/container; the agent has passwordless +sudo). The trust boundary is the host↔guest read-only mount: facts and code are +materialized on the host and presented read-only, so a guest `sudo` write fails, +while the catalog the orchestrator produces stays guest-local. ```mermaid sequenceDiagram @@ -371,101 +257,80 @@ sequenceDiagram ### Positive -- **Closes the PR #423 finding:** a prompt-injected, sudo-capable agent can no - longer plant/forge provider facts or tamper the normalizer/startup code that - `acq` re-runs — the trust boundary is the host↔guest read-only mount, not - guest root-ownership. -- Collapses the confirmed secret-duplication bug (two service-name - registrations for one USAi credential) into one canonical per-(vendor) - registration. -- Collapses ~150-190 duplicated merge-script lines per harness kit and the - already-drifted duplicate bounded-fetch helper into one shared, - Tier-0-style library. -- A third-party provider kit needs zero PRs accepted into any core-maintained - file to work with any existing harness kit, and vice versa — removes the - central-maintainer bottleneck the maintainer explicitly wants gone. -- `pi-coding-agent` and any future harness kit adopt this as a clean, greenfield - integration (read the aggregate, render your own config) with no migration - burden. +- A prompt-injected, sudo-capable agent cannot plant or forge provider facts, or + tamper the normalizer/startup code `acq` re-runs — the trust boundary is the + host↔guest read-only mount, not guest root-ownership. +- One canonical per-vendor credential registration replaces the duplicate + service-name registrations described in Context. +- One shared config-merge/bounded-fetch library replaces the per-kit copies. +- A third-party provider or harness kit integrates with no PR into a + core-maintained file — there is no central-maintainer bottleneck. +- New harness kits (e.g. `pi-coding-agent`) adopt this as a greenfield + integration: read the aggregate, render your own config. - Model lists refresh without a human re-running a sync script and cutting a kit - release, while remaining fully functional and deterministic offline (fall back - to the provider's vendored snapshot). + release, while remaining functional and deterministic offline via the vendored + snapshot. ### Negative / risks -- New moving parts: a per-sandbox host config directory + read-only mount, a new - orchestrator kit, a versioned schema contract between N producers and M +- New moving parts: a per-sandbox host config directory + read-only mount, an + orchestrator kit, and a versioned schema contract between N producers and M consumers. Mitigated by strict layering (each layer independently testable and - shippable), by reusing the existing host-state-dir conventions (quickstart - ADR-0030), and by the versioned-not-negotiated schema decision above. -- Sandbox startup now has a conditional outbound step (the orchestrator's live - refresh) on the critical path. Bounded by per-provider timeout - + a total wall-clock budget; on any failure it falls back to the provider's - vendored snapshot (a fast local read), so a slow/unreachable endpoint degrades - rather than stalls. -- Rendered harness config is no longer a pure function of pinned kit refs alone - — it also depends on live-vs-snapshot state at boot time. Accepted, with the - snapshot fallback and per-provider provenance recording making any degraded - state visible rather than silent. + shippable), reuse of the existing host-state-dir conventions (ADR-0030), and + the versioned-not-negotiated schema. +- Startup gains a conditional outbound refresh on the critical path, bounded by + the per-provider timeout and total wall-clock budget; on failure it falls back + to the vendored snapshot (a fast local read), so a slow or unreachable endpoint + degrades rather than stalls. +- Rendered harness config is no longer a pure function of pinned kit refs alone — + it also depends on live-vs-snapshot state at boot. The snapshot fallback and + per-provider provenance recording make any degraded state visible rather than + silent. ### Neutral -- Requires no change to the `hybrid/v1` schema itself — the entire mechanism - composes from already-shipped primitives (`caps.network.allow` union, - sequential startup-phase ordering) plus the host-authoritative read-only - config mount defined in quickstart ADR-0030. +- Requires no change to the `hybrid/v1` schema. The mechanism composes from the + `caps.network.allow` union, sequential startup-phase ordering, and the + host-authoritative read-only config mount from quickstart ADR-0030. ## Alternatives Considered -- **Host-side execution of provider-shipped normalizer code** (Option 2 - above). Rejected: puts untrusted, pinned-but-not-sandboxed third-party code - in the same host process as real secret material, before any sandbox trust - boundary exists. This was the deciding factor in a 4-3 split vote; moving - everything in-guest (this ADR's decision) was independently re-evaluated at - 5-2 approve once this objection was removed. -- **No discovery mechanism; hardcode one vendor per harness kit** (Option 1, - status quo). Rejected 7-0 in an earlier round of this same design work: no - way for a harness kit to learn which provider kit(s) are present without a - hardcoded name per pairing: reproduces the N×M problem this ADR exists to - retire. -- **Defer entirely until a second real provider kit ships** (Scope Steward's - dissent, both rounds). Not adopted: the maintainer's explicit direction is - that `goose-server` and `pi-coding-agent` are in flight now and this - extensibility problem is real today, not speculative. Recorded per the ADR - template's dissent-recording convention, not relitigated. -- **A neutral, harness-agnostic permission vocabulary as part of this same - ADR.** Deferred, unchanged from the first consensus panel's unanimous - guidance this session: exactly one real consumer exists today (OpenCode's - permission map); build the concrete translator when a second harness's - genuinely different native permission model is in hand, not before. +- **Host-side execution of provider-shipped normalizer code.** Excluded: it puts + pinned-but-unsandboxed third-party code in the same host process as real secret + material, before any sandbox trust boundary exists. Running the normalizer + in-guest (this ADR) keeps third-party code inside the sandbox boundary while + still using the read-only mount for the code's *integrity*. +- **No discovery mechanism; hardcode one vendor per harness kit.** Excluded: a + harness kit could not learn which provider kit(s) are present without a + hardcoded name per pairing, reproducing the N × M problem this ADR retires. +- **A shared, cross-sandbox host catalog cache with a TTL.** Excluded: a cache + written by one sandbox and read by another is a cross-sandbox poisoning + channel. The catalog is per-sandbox and guest-local instead; the vendored + snapshot is the offline fallback. +- **A neutral, harness-agnostic permission vocabulary in this ADR.** Deferred: + one real consumer exists today (OpenCode's permission map). The concrete + translator is warranted only when a second harness's genuinely different native + permission model is in hand. ## What an agent must NOT decide unilaterally - The neutral catalog schema's field set once a second real provider exists — that is a cross-repo contract change and needs the same review process as ADR 0002's `caps.network.allow` baseline. -- Whether the models orchestrator kit is promoted into any "core, always-on" - built-in kit bundle — that is a decision about default behavior for every - user, analogous to ADR 0002's "what belongs on the federally-shipped - `balanced` allowlist," and needs the same human/CODEOWNERS review. +- Whether the models orchestrator kit becomes a "core, always-on" built-in — that + changes default behavior for every user (analogous to ADR 0002's federally + shipped `balanced` allowlist) and needs human/CODEOWNERS review. ## References -- Quickstart ADR-0030 (*Host-Authoritative Sandbox Configuration*) — the - general principle and the per-sandbox host config dir + read-only mount + - trusted-startup-execution mechanism this ADR's Layer 1/Layer 2 build on; - the mechanism of record for the PR #423 correction. +- Quickstart ADR-0030 (*Host-Authoritative Sandbox Configuration*) — the general + principle and the per-sandbox host config dir + read-only mount + + trusted-startup-execution mechanism this ADR's Layers 1 and 2 build on. - ADR 0001 (isolation) — neutral `hybrid/v1` acq-kits spec. -- ADR 0002 (isolation) — neutral network egress tiers; `caps.network.allow` - union this ADR reuses as the SSRF-hardening cross-check and as the existing - trust boundary this design deliberately does not widen. -- `usai-provider` ADR 0003 (`acq-kits/usai-provider/docs/decisions/`) — - the permission-hardening precedent this design's security conditions follow. -- `goose-server` ADR 0004 (in flight, `origin/goose-kit` branch) — records the - same harness-adapter-layer open questions this ADR's Layer 3 boundary - answers for the model-config slice specifically. -- Consensus panels (this session, 2026-09-17/18, nexus-agents `consensus_vote`, - `higher_order` strategy, 7 roles): round 1 (live-poll, no discovery) 7-0 - reject; round 2 (host-side execution) 4-3 approve; round 3 (in-guest, - reusing standing trust boundary) 5-2 approve — this ADR implements round 3's - design with round 3's dissent conditions incorporated. +- ADR 0002 (isolation) — neutral network egress tiers; the `caps.network.allow` + union this ADR reuses for the SSRF host cross-check and as the trust boundary + it does not widen. +- `usai-provider` ADR 0003 (`acq-kits/usai-provider/docs/decisions/`) — the + permission-hardening precedent this design's security conditions follow. +- `goose-server` ADR 0004 — the harness-adapter open questions this ADR's Layer 3 + boundary answers for the model-config slice. From 051db589cfb64f7f92a5e58a9d2784eb835d5c1c Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Wed, 23 Sep 2026 12:23:59 -0400 Subject: [PATCH 04/13] docs(isolation): link the startup-ordering issue instead of citing it prose-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the actual quickstart#506 link to the References section — the prior commit named the issue in prose but didn't link it since #506 wasn't filed yet at write time. --- .../0003-neutral-model-provider-discovery.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md b/integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md index 42c36537..6e48c755 100644 --- a/integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md +++ b/integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md @@ -158,6 +158,18 @@ snapshot, run from the read-only mount (ADR-0030 Mechanism 2), so a sudo-capable agent cannot tamper the startup code between restarts and have `acq` re-run a tampered copy. +> **Open ordering risk, not yet resolved by this ADR:** the catalog write +> (Layer 2) and the harness's own render (Layer 3) are both ordinary in-guest +> `startup`-phase writes. Quickstart's kit-reference docs state, and live +> reproduction against `sbx` v0.43.0 confirms, that a backend's agent +> entrypoint launches once `startup` commands are *dispatched*, not once they +> finish — `acq`'s pre-attach readiness check only confirms the agent binary +> is executable, not that any kit's config write has completed. This already +> affects the shipped `usai-provider` config-merge step; Layers 2–3 add two +> more sequential writes ahead of the same entrypoint. The read-only mount +> above hardens this pipeline's *inputs*; it does not guarantee the *harness's +> config file* is complete before the harness first reads it. + ### Layer 3 — Harness kit owns its own rendering Each harness kit, at its own `startup` phase (after the orchestrator, per the @@ -286,6 +298,9 @@ sequenceDiagram it also depends on live-vs-snapshot state at boot. The snapshot fallback and per-provider provenance recording make any degraded state visible rather than silent. +- **Standing startup-ordering gap, inherited not introduced by this ADR** — see + the Layer 2 callout above. Affects the shipped `usai-provider` config-merge + step too; Layers 2–3 add to the same exposure rather than create it. ### Neutral @@ -320,6 +335,11 @@ sequenceDiagram - Whether the models orchestrator kit becomes a "core, always-on" built-in — that changes default behavior for every user (analogous to ADR 0002's federally shipped `balanced` allowlist) and needs human/CODEOWNERS review. +- **The startup-ordering gap's fix.** Belongs upstream as a general completion + barrier in `acq`'s provision→attach sequencing (mirroring what already, + incidentally, closes it on `msb`) — not a per-kit workaround here, which + would just be a second, divergent mechanism. Quickstart-owned; needs + quickstart maintainer review. ## References @@ -334,3 +354,8 @@ sequenceDiagram permission-hardening precedent this design's security conditions follow. - `goose-server` ADR 0004 — the harness-adapter open questions this ADR's Layer 3 boundary answers for the model-config slice. +- Quickstart [issue #506](https://github.com/GSA-TTS/agentic-coding-quickstart/issues/506) + tracking the startup-ordering race flagged above (`acq run` does not wait + for an in-guest `startup`-phase config write to finish before attach, + reproduced live against `sbx` v0.43.0) — the upstream fix this ADR's + Layers 2–3 depend on but do not themselves provide. From dae9e3af8970d5bcff3832bda31829e5928125f4 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 24 Sep 2026 14:13:12 -0400 Subject: [PATCH 05/13] docs(isolation): renumber to ADR 0004 to clear a collision with #416 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `integrations/isolation/docs/decisions/0003-` was claimed by two open PRs: #416 (serviceGateways vocabulary, opened 2026-09-15) and this one (opened 2026-09-18). The filenames differ, so git merges both cleanly and leaves two ADR-0003s in the same directory — every later "ADR 0003 (isolation)" reference becomes ambiguous. #416 claimed it first and its number is not cited anywhere else, so this PR moves. 0004 is free: `main` has 0001 and 0002, and no other open PR claims it. Only the file name and the H1 change. The "usai-provider ADR 0003" reference at line 353 is a DIFFERENT, kit-local ADR under acq-kits/usai-provider/docs/decisions/ and is deliberately left alone. AI-assisted (OpenCode). Human review and merge still required. --- ...er-discovery.md => 0004-neutral-model-provider-discovery.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename integrations/isolation/docs/decisions/{0003-neutral-model-provider-discovery.md => 0004-neutral-model-provider-discovery.md} (99%) diff --git a/integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md similarity index 99% rename from integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md rename to integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md index 6e48c755..a9a68d76 100644 --- a/integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md +++ b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md @@ -8,7 +8,7 @@ impact_level: "moderate" nist_controls: ["SC-7", "AC-4", "CM-7", "SI-10"] --- -# ADR 0003 (isolation) — Neutral model-provider discovery: provider facts, a models orchestrator kit, and harness-owned rendering +# ADR 0004 (isolation) — Neutral model-provider discovery: provider facts, a models orchestrator kit, and harness-owned rendering > Area-scoped ADR for `integrations/isolation/`. Extends the neutral `hybrid/v1` > kit spec (ADR 0001) and the network egress tiers (ADR 0002). It changes neither From 3b565fd7a6b4c66cae75ee3fb8296cb71d986e01 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 24 Sep 2026 16:51:12 -0400 Subject: [PATCH 06/13] docs(isolation): fix ADR 0004 accuracy gaps and a security invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrections found by a 5-role pr_review panel plus independent verification (acq internals, cross-repo PR status), then confirmed via a 7-role higher_order consensus vote (85.7% approve) on the fix approach itself: - Consequences/Positive claimed "one shared config-merge/bounded-fetch library replaces the per-kit copies" — false against the ADR's own Decision. Layer 3 mandates each harness kit render its own native config; only the bounded-fetch helper is actually shared. Corrected the claim and stated explicitly that no shared config-merge library exists. - Added a security invariant, in three places (Layer 2 output description, Layer 3 read-list, Security model bullet list): the guest-writable aggregate catalog.json carries only model metadata, never routing/credential fields (host/baseUrl/modelsUrl/keyEnv). Those fields are read exclusively from the Layer 1 read-only facts mount. Without this, a sudo-capable in-guest agent could rewrite the catalog to pair one provider's keyEnv with another provider's allowlisted host, bypassing the Layer 1 env-var-ownership and SSRF checks entirely within caps.network.allow. - "quickstart ADR-0030" and "goose-server ADR 0004" were cited as settled sibling ADRs. Both are, as of this commit, open and unmerged (GSA-TTS/agentic-coding-quickstart#504, GSA-TTS/agentic-coding-patterns#415 — freshly reverified via `gh pr view` before this commit). Added "(pending — org/repo#N)" qualifiers per the repo's fully-qualified cross-repo reference convention. Consensus panel also flagged the Layer 1 artifact-location schema gap (no field locates a provider's normalizer or vendored snapshot) as a design change, not a textual fix, and recommended landing it in its own commit for separate review. That follows in the next commit. markdownlint: 0 issues. Co-authored-by: OpenCode Agent --- .../0004-neutral-model-provider-discovery.md | 68 +++++++++++++------ 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md index a9a68d76..c97f16c0 100644 --- a/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md +++ b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md @@ -14,7 +14,8 @@ nist_controls: ["SC-7", "AC-4", "CM-7", "SI-10"] > kit spec (ADR 0001) and the network egress tiers (ADR 0002). It changes neither > schema; the mechanism composes from already-shipped primitives > (`caps.network.allow` union, sequential kit startup) plus the -> host-authoritative read-only config mount defined in quickstart ADR-0030. +> host-authoritative read-only config mount defined in quickstart ADR-0030 +> (pending — GSA-TTS/agentic-coding-quickstart#504). ## Context and Problem Statement @@ -89,7 +90,8 @@ a root-owned *guest* path is not a trust boundary against a prompt-injected agent. The read-only host mount is. `acq` reads only **static data** from the kit on the host; no provider-kit code runs on the host. See [Security model](#security-model) and quickstart ADR-0030 -(*Host-Authoritative Sandbox Configuration*), the mechanism of record. +(*Host-Authoritative Sandbox Configuration*, pending — +GSA-TTS/agentic-coding-quickstart#504), the mechanism of record. `keyEnv` names the environment variable that holds the vendor credential. A provider kit may only name a credential it declares itself: `acq` confirms @@ -134,7 +136,11 @@ Its `startup` phase: on the same read-only mount. 5. Aggregates every provider's neutral output into one **guest-local, per-sandbox** file, written read-write and never shared with any other - sandbox: `/var/lib/acq/models/catalog.json`. + sandbox: `/var/lib/acq/models/catalog.json`. The aggregate carries **only + model metadata** (id, context window, pricing, capabilities, provenance) — + it never carries routing or credential fields (`host`, `baseUrl`, + `modelsUrl`, `keyEnv`). Those fields remain in the Layer 1 read-only facts + file only; see [Security model](#security-model). 6. Records provenance per provider (`"source": "live" | "snapshot"`, with a timestamp) in the aggregate, so a permanently degraded fallback is visible rather than indistinguishable from a fresh fetch. @@ -150,13 +156,13 @@ There is no cross-sandbox catalog cache — a cache written by one sandbox and r by another would be a cross-sandbox poisoning channel (a compromised sandbox could poison the catalog a different sandbox routes against). The clean split is: the orchestrator's **inputs** (provider facts and normalizer code) are -host-authoritative and read-only (Layer 1 + ADR-0030); its **output** (the -catalog) is guest-local and trusted by no other sandbox. +host-authoritative and read-only (Layer 1 + ADR-0030 (pending)); its +**output** (the catalog) is guest-local and trusted by no other sandbox. The orchestrator's own code, and each provider's normalizer and vendored -snapshot, run from the read-only mount (ADR-0030 Mechanism 2), so a sudo-capable -agent cannot tamper the startup code between restarts and have `acq` re-run a -tampered copy. +snapshot, run from the read-only mount (ADR-0030 Mechanism 2, pending), so a +sudo-capable agent cannot tamper the startup code between restarts and have +`acq` re-run a tampered copy. > **Open ordering risk, not yet resolved by this ADR:** the catalog write > (Layer 2) and the harness's own render (Layer 3) are both ordinary in-guest @@ -175,8 +181,14 @@ tampered copy. Each harness kit, at its own `startup` phase (after the orchestrator, per the same ordering convention), reads: -- `/var/lib/acq/models/catalog.json` — Layer 2's neutral aggregate for this - sandbox; and +- `/var/lib/acq/models/catalog.json` — Layer 2's neutral aggregate, for + **model metadata only** (id, context window, pricing, capabilities, + provenance); +- the Layer 1 read-only facts mount, + `/var/lib/acq/host/models/providers/.json` — for + **routing/credential fields** (`host`, `baseUrl`, `keyEnv`) — never from the + catalog, since the catalog is guest-writable and cannot be authoritative for + where a credential is sent (see [Security model](#security-model)); and - the config kit's cross-harness defaults (model-role priority, etc.). It then renders its **own** native config in its own format — `opencode.jsonc`, @@ -210,6 +222,17 @@ ADR at that time, not by standing negotiation machinery. host-side execution is excluded). - **No cross-sandbox state.** The catalog is generated per-sandbox and kept guest-local; nothing one sandbox writes is read by another. +- **Catalog carries no routing/credential authority.** The catalog + (`/var/lib/acq/models/catalog.json`) is guest-local and read-write, so a + sudo-capable in-guest agent can rewrite it. It therefore carries only model + metadata (id, context window, pricing, capabilities, provenance) and is never + read as authoritative for `host`, `baseUrl`, `modelsUrl`, or `keyEnv` — a + harness kit reads those fields only from the Layer 1 read-only facts mount. + Any field a sudo-capable agent could rewrite in the catalog must never + determine where a credential is sent; folding routing/credential fields into + the writable catalog would let a rewritten catalog entry pair one provider's + `keyEnv` with another provider's allowlisted host, bypassing the Layer 1 + env-var-ownership and SSRF checks entirely within `caps.network.allow`. - **Env-var ownership.** A provider kit may only name a credential (`keyEnv`) it declares in its own `spec.yaml`; enforced host-side (Layer 1). - **SSRF containment.** `modelsUrl` must be `https://` and its host must equal @@ -274,7 +297,11 @@ sequenceDiagram host↔guest read-only mount, not guest root-ownership. - One canonical per-vendor credential registration replaces the duplicate service-name registrations described in Context. -- One shared config-merge/bounded-fetch library replaces the per-kit copies. +- One shared bounded-fetch helper (timeout + response-size cap) replaces the + per-kit hand-copied fetch helper. Config-merge/rendering logic is not + centralized by this ADR — Layer 3 requires each harness kit to render its own + native config, so there is no shared config-merge library; the shared + artifacts are the fetch helper and the neutral-catalog contract only. - A third-party provider or harness kit integrates with no PR into a core-maintained file — there is no central-maintainer bottleneck. - New harness kits (e.g. `pi-coding-agent`) adopt this as a greenfield @@ -288,8 +315,8 @@ sequenceDiagram - New moving parts: a per-sandbox host config directory + read-only mount, an orchestrator kit, and a versioned schema contract between N producers and M consumers. Mitigated by strict layering (each layer independently testable and - shippable), reuse of the existing host-state-dir conventions (ADR-0030), and - the versioned-not-negotiated schema. + shippable), reuse of the existing host-state-dir conventions (ADR-0030, + pending), and the versioned-not-negotiated schema. - Startup gains a conditional outbound refresh on the critical path, bounded by the per-provider timeout and total wall-clock budget; on failure it falls back to the vendored snapshot (a fast local read), so a slow or unreachable endpoint @@ -306,7 +333,8 @@ sequenceDiagram - Requires no change to the `hybrid/v1` schema. The mechanism composes from the `caps.network.allow` union, sequential startup-phase ordering, and the - host-authoritative read-only config mount from quickstart ADR-0030. + host-authoritative read-only config mount from quickstart ADR-0030 + (pending — GSA-TTS/agentic-coding-quickstart#504). ## Alternatives Considered @@ -343,17 +371,19 @@ sequenceDiagram ## References -- Quickstart ADR-0030 (*Host-Authoritative Sandbox Configuration*) — the general - principle and the per-sandbox host config dir + read-only mount + - trusted-startup-execution mechanism this ADR's Layers 1 and 2 build on. +- Quickstart ADR-0030 (*Host-Authoritative Sandbox Configuration*, pending — + GSA-TTS/agentic-coding-quickstart#504) — the general principle and the + per-sandbox host config dir + read-only mount + trusted-startup-execution + mechanism this ADR's Layers 1 and 2 build on. - ADR 0001 (isolation) — neutral `hybrid/v1` acq-kits spec. - ADR 0002 (isolation) — neutral network egress tiers; the `caps.network.allow` union this ADR reuses for the SSRF host cross-check and as the trust boundary it does not widen. - `usai-provider` ADR 0003 (`acq-kits/usai-provider/docs/decisions/`) — the permission-hardening precedent this design's security conditions follow. -- `goose-server` ADR 0004 — the harness-adapter open questions this ADR's Layer 3 - boundary answers for the model-config slice. +- `goose-server` ADR 0004 (pending — GSA-TTS/agentic-coding-patterns#415) — the + harness-adapter open questions this ADR's Layer 3 boundary answers for the + model-config slice. - Quickstart [issue #506](https://github.com/GSA-TTS/agentic-coding-quickstart/issues/506) tracking the startup-ordering race flagged above (`acq run` does not wait for an in-guest `startup`-phase config write to finish before attach, From d20f29246ee84b8f0e14bb3ef8a0b9d5e1dc236a Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 24 Sep 2026 16:53:45 -0400 Subject: [PATCH 07/13] docs(isolation): restructure ADR 0004 Layer 1 artifact to a per-provider directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design amendment, landed separately from the accuracy/citation fixes in the prior commit per the consensus panel's recommendation (7-role higher_order vote, 85.7% approve): several voters flagged this as a schema change to the Layer 1 contract, not a wording fix, and asked that it be independently reviewable/revertable from the citation and claim corrections. The gap: the v1 facts file has no field locating a provider's normalizer or vendored snapshot, yet Layer 2 steps 2 and 4 require the orchestrator to resolve both. Two independent provider-kit authors implementing v1 as written could place those artifacts at different conventions, and the orchestrator would find neither — undermining the ADR's own central interoperability claim ("a third-party provider kit works with no PR into the orchestrator"). Fix: replace the single per-provider JSON file with a fixed per-provider directory (facts.json + normalizer + snapshot.json), materialized read-only as a unit. Updated Layer 2's glob (step 1) and its normalizer/snapshot resolution (steps 2 and 4) to locate both artifacts as siblings of the facts.json just read. Layer 3's read-list (added in the prior commit) is updated to the corrected path. The mermaid trust-boundary diagram is left unchanged: its RO participant description and the one path-like string in the diagram (`providers/evil.json` in the adversary-attempt line) don't cite the specific single-file convention this commit changes, so nothing there needed to move. markdownlint: 0 issues. Content verified against the full draft produced during review (byte-identical diff, confirmed before commit). Co-authored-by: OpenCode Agent --- .../0004-neutral-model-provider-discovery.md | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md index c97f16c0..7a982307 100644 --- a/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md +++ b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md @@ -58,17 +58,22 @@ change is required. ### Layer 1 — Provider kit self-registration (host-materialized, read-only) -A provider-role kit ships a small, static JSON **facts file** inside its own -(pinned) kit directory. At provision, `acq` reads that static file **on the -host**, validates it host-side (see [Security model](#security-model)), and -materializes it into a per-sandbox host directory that it mounts **read-only** -into the guest at a well-known path: +A provider-role kit ships a small, static JSON **facts file**, a **normalizer** +entrypoint, and a **vendored fallback snapshot**, all inside its own (pinned) +kit directory. At provision, `acq` reads those static artifacts **on the +host**, validates the facts file host-side (see +[Security model](#security-model)), and materializes all three into a +per-sandbox host directory that it mounts **read-only** into the guest as a +fixed per-provider layout at a well-known path: ```text -/var/lib/acq/host/models/providers/.json (read-only mount) +/var/lib/acq/host/models/providers// (read-only mount) +├── facts.json # 6-field facts shape (v1), below +├── normalizer # provider's normalizer entrypoint (executable) +└── snapshot.json # provider's vendored fallback snapshot ``` -Facts file shape (v1): +`facts.json` shape (v1): ```json { @@ -101,14 +106,16 @@ before that variable is ever read. This check is a mechanical string comparison, runs host-side as part of facts validation, and prevents a kit from naming an unrelated credential (e.g. `GITHUB_TOKEN`). -Each provider kit also ships, inside its own kit directory (never centrally): +Each provider kit also ships, inside its own kit directory (never centrally), +as siblings of `facts.json` in the materialized `/` directory +above: -- its **normalizer** — code that maps the raw vendor API response to the neutral +- `normalizer` — code that maps the raw vendor API response to the neutral catalog schema. The vendor's own author knows its API shape best, so no central kit accumulates per-vendor knowledge that rots; and -- a **vendored fallback snapshot** — a small, committed, last-known-good model - list used when a live refresh fails, so a bad fetch degrades to *that - provider's* last-known-good list. +- `snapshot.json` — a small, committed, last-known-good model list used when a + live refresh fails, so a bad fetch degrades to *that provider's* + last-known-good list. Keying the credential by provider (not by harness) means a second harness kit reaching the same vendor reuses the same secret-store service identity, @@ -123,17 +130,20 @@ guarantees `zscaler-ca-certificate` runs before kits that need its CA trust Its `startup` phase: 1. Globs the read-only provider-facts mount - `/var/lib/acq/host/models/providers/*.json` (Layer 1). -2. For each discovered provider, invokes that provider's own normalizer through a - shared, vendored-into-this-kit bounded-fetch helper (per-provider timeout + - response-size cap), with one canonical implementation of that helper. + `/var/lib/acq/host/models/providers/*/facts.json` (Layer 1). +2. For each discovered provider, invokes that provider's own normalizer — + `/normalizer`, the sibling of the `facts.json` just read — + through a shared, vendored-into-this-kit bounded-fetch helper (per-provider + timeout + response-size cap), with one canonical implementation of that + helper. 3. Validates the normalizer's **output** against the neutral catalog schema before accepting it — a structurally-valid-but-wrong response is still a routing hazard, so shape validation runs on every refresh, not only on error paths. 4. On any failure (timeout, oversized, malformed, validation failure, or no - provider present) falls back to that provider's vendored snapshot, presented - on the same read-only mount. + provider present) falls back to that provider's vendored snapshot — + `/snapshot.json`, the sibling of the `facts.json` just read — + presented on the same read-only mount. 5. Aggregates every provider's neutral output into one **guest-local, per-sandbox** file, written read-write and never shared with any other sandbox: `/var/lib/acq/models/catalog.json`. The aggregate carries **only @@ -185,7 +195,7 @@ same ordering convention), reads: **model metadata only** (id, context window, pricing, capabilities, provenance); - the Layer 1 read-only facts mount, - `/var/lib/acq/host/models/providers/.json` — for + `/var/lib/acq/host/models/providers//facts.json` — for **routing/credential fields** (`host`, `baseUrl`, `keyEnv`) — never from the catalog, since the catalog is guest-writable and cannot be authoritative for where a credential is sent (see [Security model](#security-model)); and From e322cb05bd6e33073445cdd2498b619bffc91826 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 1 Oct 2026 10:24:31 -0400 Subject: [PATCH 08/13] docs(adr): define normalizer containment, providerId path invariant, no-provider case Addresses the three "Address now" items from review of #423 at d20f2924. 1. NORMALIZER CONTAINMENT CONTRACT (was: undefined). The ADR introduced provider-shipped executable code invoked with the provider's credential but never said what contains it. Layer 2 step 2 now specifies the division explicitly: the shared bounded-fetch helper performs the HTTPS request itself (applying the timeout, size cap, and https-plus-declared-host check) and invokes the normalizer as a PURE FILE-TO-FILE TRANSFORM over bytes it already fetched. The normalizer's contract: no network I/O, no credential (minimized environment excluding keyEnv), its own wall-clock and output-size bounds, and failure routed to the snapshot fallback rather than accepted. This matches the normalizer already shipped on #436, which documents itself as "reads exactly one file, writes exactly one file, makes no network calls" and takes --source/--out. So the contract records the real design rather than imposing a new one. The ADR now also states that read-only mounting protects the normalizer's INTEGRITY while saying nothing about the safety of EXECUTING it -- separate properties, both required. The "No new credential exposure" security bullet said the orchestrator invokes the normalizer with the credential, which directly contradicted the new contract; it is rewritten as "Provider-shipped code never holds the credential", and notes this NARROWS the standing trust boundary rather than merely reusing it. 2. providerId AS A PATH SEGMENT (was: only schema/env/SSRF validation named). Added the invariant: safe slug matched WHOLE-STRING, must equal the materialized directory name with the orchestrator deriving identity from the globbed directory and cross-checking facts.json, and unique within the materialized set (collision fails provisioning rather than letting one kit overwrite another's normalizer while keeping its keyEnv). The whole-string requirement is stated because `^...$` is NOT sufficient: `$` matches before a trailing newline in Python, JavaScript, PCRE and grep alike, so `^[a-z0-9]...$` accepts "usai\n" and would create a directory name with an embedded newline. Verified both ways with re.match vs re.fullmatch before writing it down; the counterexample is in the ADR so an implementer cannot reintroduce it. Also requires rejecting a non-string providerId. 3. NO-PROVIDER-PRESENT FALLBACK (was: incoherent). Step 4 listed "no provider present" among the cases that fall back to "that provider's vendored snapshot" -- but when no provider is present there is no snapshot to read. Split into two cases: per-provider refresh failure uses that provider's snapshot; no provider present writes a VALID, EXPLICITLY EMPTY aggregate with provenance recording zero providers discovered, and exits success. It must not omit the file, write a partial, invent a default provider, or fail startup -- a consumer has to be able to distinguish "no providers configured" from "discovery did not run", which an absent file cannot express. That required two constraints on the catalog shape the ADR had not stated: the model list is required and MAY be empty, and provenance is required per entry (live vs snapshot) plus per discovery (live/snapshot/rejected). Both are recorded as constraints the formal schema in #435 must honor, keeping #435 the single tracked place for the schema itself. Also, from the same review's "Track later" item 3: the Mermaid diagram was left inconsistent by the above. It said "run normalizer (in-guest, provider's own credential)", the exact claim item 1 inverts, and its host-side validation step omitted the providerId check. Both corrected. Negative/risks now states the residual risk plainly -- this design does execute provider-shipped code in the guest, contained by the step 2 contract rather than by the mount -- and relaxing any clause of that contract is added to "What an agent must NOT decide unilaterally". Verified: python3 scripts/validate_repo.py --root . -> exit 0, all validators passed python3 scripts/validate_references.py -> 41 patterns, all resolve python3 scripts/validate_frontmatter.py -> 47/47 passed python3 scripts/validate_sensitive_terms.py -> exit 0 python3 scripts/scan_unsafe_shell.py . -> 0 errors, 0 warnings markdownlint-cli2 -> 0 errors git diff --check -> clean internal anchors (#security-model, #alternatives-considered) -> resolve validate_repo.py is the check the reviewer was blocked on locally (missing jsonschema, then no disk space); it runs clean here with jsonschema 4.26.0. No code changes -- ADR prose only. AI assistance disclosed: prepared by an AI agent (OpenCode) on operator instruction. Human review and merge still required. --- .../0004-neutral-model-provider-discovery.md | 174 ++++++++++++++++-- 1 file changed, 154 insertions(+), 20 deletions(-) diff --git a/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md index 7a982307..7b276cf2 100644 --- a/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md +++ b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md @@ -98,6 +98,45 @@ kit on the host; no provider-kit code runs on the host. See (*Host-Authoritative Sandbox Configuration*, pending — GSA-TTS/agentic-coding-quickstart#504), the mechanism of record. +`providerId` is used as a **filesystem path segment** — it names the +materialized directory `/var/lib/acq/host/models/providers//` — so +it is validated host-side as a path component before any directory is created, +not merely as a schema string. The invariant: + +- **Safe slug.** `providerId` MUST match `[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?` + **against the whole string**: lowercase ASCII alphanumerics and internal + hyphens, 1–32 characters. This rejects path separators (`/`, `\`), + traversal segments (`.`, `..`), absolute paths, NUL and control bytes, + leading/trailing hyphens, whitespace, and any non-ASCII character (so + visually-confusable Unicode cannot impersonate another provider's directory + name). + + The match MUST be whole-string — `fullmatch` semantics, or an explicitly + newline-safe anchor such as `\A…\z`. A plain `^…$` is **not** sufficient: in + Python, JavaScript, PCRE and `grep` alike, `$` matches before a trailing + newline, so `^[a-z0-9]…$` accepts `"usai\n"`, which would then be used to + create a directory name containing an embedded newline. Verified: + `re.match(r'^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$', 'usai\n')` matches while + `re.fullmatch(...)` does not. Implementations MUST also reject a `providerId` + that is not a JSON string — a number or `null` must never be coerced into a + path segment. +- **Matches its own directory.** The validated `providerId` MUST equal the name + of the directory `acq` materializes for it, and the orchestrator MUST derive + each provider's identity from the **directory name it globbed**, cross-checked + against the `providerId` inside that directory's `facts.json`. A mismatch is a + hard failure for that provider, not a warning: it means the two sources of + identity disagree, and neither can be trusted to select the right credential. +- **Unique within the materialized set.** Two provider kits MUST NOT resolve to + the same `providerId`. `acq` fails provisioning on a collision rather than + letting the later kit overwrite the earlier one's `facts.json`, `normalizer`, + or `snapshot.json` — a silent overwrite would let one kit substitute its own + code and routing for another's while keeping that other's `keyEnv`. + +Validation runs host-side, before materialization, in the same pass as the +checks below. A facts file failing it is rejected and that provider is absent +from the mount — which the orchestrator treats as "no provider present" +(Layer 2, step 4), never as a provider with a default identity. + `keyEnv` names the environment variable that holds the vendor credential. A provider kit may only name a credential it declares itself: `acq` confirms `keyEnv` matches a variable in the same provider kit's own `spec.yaml` @@ -131,19 +170,67 @@ Its `startup` phase: 1. Globs the read-only provider-facts mount `/var/lib/acq/host/models/providers/*/facts.json` (Layer 1). -2. For each discovered provider, invokes that provider's own normalizer — - `/normalizer`, the sibling of the `facts.json` just read — - through a shared, vendored-into-this-kit bounded-fetch helper (per-provider - timeout + response-size cap), with one canonical implementation of that - helper. +2. **The helper fetches; the normalizer never does.** The shared, + vendored-into-this-kit bounded-fetch helper — one canonical implementation — + performs the HTTPS request to `modelsUrl` itself, applying the per-provider + timeout, the response-size cap, and the `https://`-plus-declared-host check + (see [Security model](#security-model)). It then invokes that provider's own + normalizer — `/normalizer`, the sibling of the `facts.json` just + read — as a **pure file-to-file transform** over the bytes it already + fetched: + + ```text + normalizer --source --out + ``` + + The normalizer is a transform, not a client. Its containment contract: + + - **No network access.** The normalizer MUST NOT perform network I/O. It + receives already-fetched bytes on the filesystem and writes a file. A + normalizer that fetches is a defect, not a supported variation, because the + helper's timeout / size cap / host check would no longer apply to the bytes + it returns. + - **No credential.** The normalizer is invoked with a **minimized + environment** that does not include `keyEnv` or any other secret. Only the + helper reads the credential, and only to authenticate the request it makes + itself. This is a deliberate narrowing of the standing trust boundary: the + provider's credential is used by one audited, vendored implementation, not + by per-vendor code the repo does not own. + - **Bounded like the fetch.** The normalizer runs under its own wall-clock + timeout and output-size cap, inside the same total budget as the fetch, so + a hanging or output-bombing normalizer cannot stall startup any more than a + hanging endpoint can. + - **Failure is a fallback, not a pass.** A normalizer that exits non-zero, + times out, exceeds its output cap, or writes output failing schema + validation (step 3) is treated exactly like a failed fetch — fall back to + that provider's snapshot. There is no path where an unusable normalizer + result is accepted. + + Read-only mounting protects the normalizer's **integrity** (a sudo-capable + guest agent cannot swap it post-provisioning). It does not make *executing* + it safe, which is what the contract above is for. These are separate + properties and both are required. 3. Validates the normalizer's **output** against the neutral catalog schema before accepting it — a structurally-valid-but-wrong response is still a routing hazard, so shape validation runs on every refresh, not only on error paths. -4. On any failure (timeout, oversized, malformed, validation failure, or no - provider present) falls back to that provider's vendored snapshot — - `/snapshot.json`, the sibling of the `facts.json` just read — - presented on the same read-only mount. +4. **Per-provider refresh failure** — timeout, oversized response, malformed + response, normalizer failure, or output failing schema validation — falls + back to that provider's vendored snapshot, `/snapshot.json`, the + sibling of the `facts.json` just read, presented on the same read-only mount. + The provider still contributes entries; they are its last-known-good ones. + + **No provider present is a different case and is NOT a snapshot fallback.** + When the glob in step 1 matches nothing — no provider kit enabled, or every + candidate rejected by host-side facts validation — there is no provider + directory, so there is no `snapshot.json` to read. The orchestrator MUST then + write a **valid, explicitly empty** aggregate (`models: []`) carrying the + provenance that zero providers were discovered, and exit success. It MUST NOT + omit the file, write a partial file, invent a default provider, or fail + sandbox startup: a harness kit reading the catalog must be able to tell *"no + providers are configured"* from *"discovery did not run"*, and an absent file + cannot express the difference. A provider whose facts failed validation is + reported in that provenance as rejected, not silently absent. 5. Aggregates every provider's neutral output into one **guest-local, per-sandbox** file, written read-write and never shared with any other sandbox: `/var/lib/acq/models/catalog.json`. The aggregate carries **only @@ -213,9 +300,28 @@ transform pricing." ### Neutral catalog schema Both a normalizer's output and the aggregate carry -`schemaVersion: "acq-neutral-model-catalog/v1"`. The schema is versioned from v1; -there is no multi-version negotiation mechanism (renderers declaring supported -versions, the orchestrator picking compatible pairings). A model catalog is a +`schemaVersion: "acq-neutral-model-catalog/v1"`. + +Two constraints this ADR places on that shape, which the formal JSON Schema +(tracked in GSA-TTS/agentic-coding-patterns#435) must honor: + +- **The model list is a required array and MAY be empty.** An aggregate with + zero entries is *valid*, not a schema violation — that is what the + orchestrator writes when no provider is present (Layer 2, step 4). A schema + that required a non-empty list would force the no-provider case to either omit + the file or write something invalid, which is exactly the ambiguity step 4 + exists to prevent. +- **Per-entry provenance is required, and the aggregate records discovery + provenance.** Each entry states which `providerId` it came from and whether it + came from a live refresh or that provider's vendored snapshot; the aggregate + additionally records the set of providers discovered, and for each, the + outcome (`live`, `snapshot`, or `rejected`). A consumer must be able to tell a + live catalog from a stale one, and *"zero providers configured"* from + *"discovery did not run"*, by reading the file alone. + +The schema is versioned from v1; there is no multi-version negotiation mechanism +(renderers declaring supported versions, the orchestrator picking compatible +pairings). A model catalog is a simple, slow-moving shape (id / context window / pricing / vendor), so a breaking change is handled as a rare, coordinated version bump documented in a follow-up ADR at that time, not by standing negotiation machinery. @@ -249,12 +355,23 @@ ADR at that time, not by standing negotiation machinery. the facts file's declared `host`, which must itself be on the sandbox's effective `caps.network.allow` union (ADR 0002). The orchestrator never fetches an arbitrary URL a facts file supplies. -- **No new credential exposure.** The orchestrator invokes each provider's - normalizer with that provider's already-bound credential, exactly as the - provider kit would for its own inference calls — reusing the standing trust - boundary, not creating a new one. The orchestrator's own code never sees - plaintext key material beyond what the guest's existing secret-injection - mechanism already exposes to that specific, network-permitted host. +- **Provider-shipped code never holds the credential.** The credential named by + `keyEnv` is read only by the shared bounded-fetch helper — one vendored, + audited implementation — and only to authenticate the request that helper + makes itself. Each provider's `normalizer` is invoked afterwards, as a pure + file-to-file transform over already-fetched bytes, under a **minimized + environment that excludes `keyEnv` and every other secret** (Layer 2, step 2). + So this design does not merely reuse the standing trust boundary — it narrows + it: no per-vendor code the repo does not own is ever handed key material. The + orchestrator's own code likewise never sees plaintext key material beyond what + the guest's existing secret-injection mechanism already exposes to that + specific, network-permitted host. +- **Executing provider code is contained separately from mounting it.** The + read-only mount guarantees the normalizer's integrity after provisioning; it + says nothing about the safety of running it. Containment of execution is the + explicit contract in Layer 2, step 2: no network, no credential, bounded time + and output, and failure routed to the snapshot fallback. Both properties are + required, and neither substitutes for the other. - **Atomic writes.** Facts and aggregate writes are temp-file + rename; no reader observes a partial write. - **Bounded resource use.** Per-provider fetch timeout + response-size cap @@ -288,12 +405,13 @@ sequenceDiagram end ACQ->>KIT: fetch pinned kit (static facts + normalizer + snapshot) - ACQ->>ACQ: validate facts host-side (env-var-ownership, SSRF, schema) + ACQ->>ACQ: validate facts host-side (providerId slug/uniqueness, env-var-ownership, SSRF, schema) ACQ->>HSTATE: write validated facts + staged code ACQ->>RO: mount HSTATE read-only AGENT-->>RO: sudo tee providers/evil.json (FAILS: read-only mount) ACQ->>RO: invoke orchestrator from :ro path (restart-safe) - RO->>ORCH: run normalizer (in-guest, provider's own credential) + ORCH->>ORCH: bounded fetch (helper holds credential; timeout + size cap + host check) + RO->>ORCH: run normalizer on fetched bytes (no network, NO credential, bounded) ORCH->>CAT: write per-sandbox catalog (rw, never shared) Note over ACQ,RO: acq trusts only host-authoritative read-only inputs and the catalog stays guest-local (never cross sandbox) ``` @@ -338,6 +456,16 @@ sequenceDiagram - **Standing startup-ordering gap, inherited not introduced by this ADR** — see the Layer 2 callout above. Affects the shipped `usai-provider` config-merge step too; Layers 2–3 add to the same exposure rather than create it. +- **This design executes provider-shipped code in the guest.** That is a real + residual risk, not one the read-only mount removes: the mount protects the + normalizer's integrity, while *running* it is contained only by the Layer 2 + step 2 contract (no network, no credential, bounded time and output, failure + routed to the snapshot). The residual exposure is a normalizer that is + pointlessly slow or produces garbage — which degrades to that provider's + snapshot — rather than one that can exfiltrate a credential or reach an + unapproved host. The contract is therefore load-bearing: relaxing any clause + of it is explicitly not an implementer's call (see *What an agent must NOT + decide unilaterally*). ### Neutral @@ -378,6 +506,12 @@ sequenceDiagram incidentally, closes it on `msb`) — not a per-kit workaround here, which would just be a second, divergent mechanism. Quickstart-owned; needs quickstart maintainer review. +- **Any relaxation of the normalizer containment contract** (Layer 2, step 2): + granting a normalizer network access, passing it a credential, or removing its + time/output bounds. Each would move provider-shipped code back inside the + credential boundary this ADR deliberately narrows, and the read-only mount + does not substitute for it. Needs human/CODEOWNERS review, not an + implementer's judgement call. ## References From d3acf9f246d8c0dd849a9fca698a611900730be1 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 1 Oct 2026 11:25:03 -0400 Subject: [PATCH 09/13] docs(adr): record host-side-fetch-by-acq as a distinct, unevaluated alternative Answers an operator question: "if we build the harness model config before the harness starts, using the proxied secret with whichever provider is available, aren't we good?" The honest answer is that this ADR never evaluated that, and its Alternatives section reads as though it had. The existing entry rejects "Host-side execution of provider-shipped normalizer code" because that puts pinned-but-unsandboxed THIRD-PARTY code next to real secret material. That objection does not apply to `acq` fetching with its own first-party code, which is what was actually being asked about. A reader hitting that bullet would reasonably conclude the idea was already decided against. It was not -- it was never considered. Recording it as *not evaluated, deliberately left open* rather than rejected, since there is no measurement to reject on. Also distinguished from the separately-rejected cross-sandbox TTL cache: a per-sandbox host-built artifact is not a shared cache, and conflating those two would be the same category error in the other direction. This is a live question rather than a hypothetical, because TWO host-side builders already ship here and neither was mentioned in the ADR: - acq-kits/usai-provider/scripts/sync-usai-models.mjs -- requires USAI_API_KEY in the HOST environment (hard error at :841), fetches the live models list, enriches from models.dev, rewrites the kit's committed opencode.jsonc generated block. - integrations/providers/usai/ -- build-catalog.mjs -> catalog.json -> per-harness emitters, schema `usai-model-catalog/v1`. I ran its tests: the byte-exact round-trip gate passes, reproducing BOTH shipped kits' model blocks from catalog.json (9 assertions across 3 files). Both are dev-time: output committed and SHA-pinned with the kit, so a refresh needs a human to cut a release. That is the staleness this ADR's Context exists to fix, and it is NOT the same thing as per-sandbox materialization at provision time -- a distinction the ADR also did not draw. The tradeoff is written out so nobody has to re-derive it: host prebuild removes the in-guest network dependency, makes the startup-ordering gap irrelevant for the catalog, and keeps the credential out of a sudo-capable guest for discovery; against that it moves a credentialed fetch into the host process (TLS, redirects, size/timeout limits and the SSRF host check all running outside any sandbox boundary), adds provisioning failure modes, and freezes the catalog at boot. The decisive constraint, which no earlier discussion had stated: a host-side fetch alone does not remove per-vendor normalization. `acq` would have to either execute the provider's normalizer on the host -- the alternative already rejected -- or re-absorb per-vendor response knowledge into `acq`, which is the N x M bottleneck this ADR exists to retire. So adopting it means explicitly choosing one of those two costs, not avoiding both. OVERLAP SURFACED: this ADR introduces `acq-neutral-model-catalog/v1` while integrations/providers/usai/ already ships `usai-model-catalog/v1`. Two neutral model-catalog schemas and two host-side builders now coexist in one repo, and the ADR mentioned neither. Recorded as a prerequisite for any host-side design and tied to the schema work already tracked in #435. "Moving catalog materialization host-side" is added to *What an agent must NOT decide unilaterally*: it relocates a credentialed fetch outside the sandbox boundary and forces the normalization choice above, so it is an architecture decision, not an implementation detail. No design reversal: Layers 1-3 are unchanged, and the in-guest containment contract from e322cb0 stands. Verified: python3 scripts/validate_repo.py --root . -> exit 0, all validators passed python3 scripts/validate_references.py -> 41 patterns, all resolve python3 scripts/validate_frontmatter.py -> 47/47 passed python3 scripts/scan_unsafe_shell.py . -> 0 errors, 0 warnings markdownlint-cli2 -> 0 errors git diff --check -> clean node --test integrations/providers/usai/tests/*.test.mjs -> 9 pass, 0 fail Decision support: nexus-agents 7-role consensus panel, unanimous 7/7 for this option over four alternatives including two that would have moved Layer 2 host-side. Panel caveat recorded honestly: all 7 seats ran on one model, so independence is weaker than the role count suggests. The two-schema overlap above was surfaced by the contrarian seat, not by my own inventory. AI assistance disclosed: prepared by an AI agent (OpenCode) on operator instruction. Human review and merge still required. --- .../0004-neutral-model-provider-discovery.md | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md index 7b276cf2..896e8e88 100644 --- a/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md +++ b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md @@ -481,6 +481,50 @@ sequenceDiagram material, before any sandbox trust boundary exists. Running the normalizer in-guest (this ADR) keeps third-party code inside the sandbox boundary while still using the read-only mount for the code's *integrity*. +- **Host-side fetch by `acq`'s own first-party code, materializing the catalog + before the guest boots.** *Not evaluated here — deliberately left open, not + rejected.* This is a DIFFERENT alternative from the one above and must not be + read as covered by it: the objection above is to running **provider-shipped** + code next to real secret material, which does not apply to `acq` fetching with + its own audited code. It is also distinct from the cross-sandbox TTL cache + rejected below — a per-sandbox host-built artifact is not a shared cache. + + Two host-side builders already exist in this repo, so this is a live question + rather than a hypothetical: + `acq-kits/usai-provider/scripts/sync-usai-models.mjs` (requires + `USAI_API_KEY` in the host environment, fetches the live models list, + rewrites the kit's committed `opencode.jsonc` generated block) and + `integrations/providers/usai/` (`build-catalog.mjs` → `catalog.json` → + per-harness emitters, schema `usai-model-catalog/v1`, with a byte-exact + round-trip test asserting the emitter reproduces both shipped kits' model + blocks). Both are **dev-time**: their output is committed and SHA-pinned with + the kit, so a refresh needs a human to cut a release. That is the staleness + this ADR's Context sets out to fix — not the same thing as per-sandbox + materialization at provision time. + + The tradeoff, stated so a future reader does not have to re-derive it: a + host-side prebuild would remove the in-guest network dependency, would make + the startup-ordering gap irrelevant for the catalog (the artifact would exist + before the guest boots), and would mean the credential is never exercised + inside a sudo-capable guest for discovery. Against that, it moves a + credentialed outbound fetch into the `acq` host process — where TLS handling, + redirect following, size/timeout enforcement and the SSRF host-equality check + would all run outside any sandbox boundary — adds provisioning latency and + new failure modes, and makes the catalog boot-time-static so a long-lived + sandbox cannot refresh. Crucially, a host-side fetch alone does not remove + the need for per-vendor normalization: `acq` would have to either execute the + provider's normalizer on the host (the rejected alternative above) or + re-absorb per-vendor response knowledge into `acq` itself, which is the N × M + bottleneck this ADR exists to retire. Choosing it therefore means choosing + one of those two costs explicitly. + + **Overlap to resolve before any such design is adopted:** this ADR introduces + `acq-neutral-model-catalog/v1` while `integrations/providers/usai/` already + ships `usai-model-catalog/v1`. Two neutral model-catalog schemas and two + host-side builders now coexist in one repo. Reconciling them is tracked with + the schema work in GSA-TTS/agentic-coding-patterns#435; a host-side + materialization design would make that reconciliation a prerequisite rather + than a follow-up. - **No discovery mechanism; hardcode one vendor per harness kit.** Excluded: a harness kit could not learn which provider kit(s) are present without a hardcoded name per pairing, reproducing the N × M problem this ADR retires. @@ -506,6 +550,15 @@ sequenceDiagram incidentally, closes it on `msb`) — not a per-kit workaround here, which would just be a second, divergent mechanism. Quickstart-owned; needs quickstart maintainer review. +- **Moving catalog materialization host-side.** Switching Layer 2's fetch from + in-guest to `acq`'s own host-side provisioning — or making a host-built + catalog + the default — is an architecture change, not an implementation detail: it + relocates a credentialed outbound fetch outside the sandbox boundary and + forces + a choice between host-executed provider code and re-centralized per-vendor + knowledge (see *Alternatives*). It also requires reconciling the two + coexisting catalog schemas first (#435). Needs human/CODEOWNERS review. - **Any relaxation of the normalizer containment contract** (Layer 2, step 2): granting a normalizer network access, passing it a credential, or removing its time/output bounds. Each would move provider-shipped code back inside the From ea10fbfd86ca05304a78b774363a95f5e8faca2f Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Thu, 1 Oct 2026 18:12:04 -0400 Subject: [PATCH 10/13] docs(adr): split the neutral catalog into per-provider and aggregate versions Corrects an error in my own earlier revision of this ADR (e322cb0), found while trying to write the formal JSON Schemas for #435. THE DEFECT. The ADR said both a normalizer's output and the orchestrator's aggregate carry `schemaVersion: "acq-neutral-model-catalog/v1"`, AND that per-entry provenance is required on that shape. Those two statements cannot both hold: - A normalizer CANNOT know its own provenance. It is a pure file-to-file transform over bytes the bounded-fetch helper already retrieved (Layer 2, step 2) -- whether those bytes came from a live refresh or from a vendored snapshot is known only to its caller. Requiring `source: live|snapshot` in its output means a field the producer must be TOLD and cannot verify. - It would have invalidated artifacts already shipping in this shape: the normalizer output and vendored snapshot on #436 both carry this version with no provenance, and #436 is green awaiting re-review. A schema honoring the ADR literally would have failed both. THE FIX. Two documents, versioned separately: acq-neutral-model-catalog/v1 one provider's normalized model list (a normalizer's output, or a vendored snapshot) acq-neutral-model-aggregate/v1 the multi-provider aggregate (orchestrator) Provenance belongs where the knowledge is: the orchestrator performed the fetch or took the fallback, so the orchestrator records it. The aggregate requires discovery provenance (per provider: live / snapshot / rejected -- so a provider rejected by host-side validation is visible as rejected, not merely absent), and each entry names its origin `providerId`. The per-provider document requires no provenance field at all. The empty-array rule is kept on both: a provider legitimately exposing no models is valid, and an aggregate with zero entries is what step 4 writes when no provider is present. Also stated explicitly on the per-provider shape, because it is now a document that may be read from a guest-writable path: model METADATA only, never routing or credential fields (host, baseUrl, modelsUrl, keyEnv). A rewritten routing field could redirect a consumer; those facts live in the host-validated facts file. #436 already enforces this with a test asserting the shipped snapshot carries none of those keys -- this records the rule the test checks. The mistake is written into the ADR rather than quietly corrected, because the reasoning ("a pure transform cannot attest to where its input came from") is easy to re-make and the note is what prevents that. The Alternatives overlap note is updated too: there are now two distinct neutral-catalog lineages here plus the already-shipped `usai-model-catalog/v1` in integrations/providers/usai/, which makes the #435 reconciliation broader, not narrower. Operator decision, recorded: split chosen over "provenance required everywhere" once the cost of the latter was measured -- reopening a green PR and adding an unverifiable caller-asserted field to a pure function. No design change to Layers 1-3; the containment contract from e322cb0 stands. Verified: python3 scripts/validate_repo.py --root . -> exit 0, all validators passed markdownlint-cli2 -> 0 errors long-line check -> only the new 2-row table exceeds 80 git diff --check -> clean AI assistance disclosed: prepared by an AI agent (OpenCode) on operator instruction. Human review and merge still required. --- .../0004-neutral-model-provider-discovery.md | 77 +++++++++++++------ 1 file changed, 54 insertions(+), 23 deletions(-) diff --git a/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md index 896e8e88..2e807f04 100644 --- a/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md +++ b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md @@ -299,25 +299,56 @@ transform pricing." ### Neutral catalog schema -Both a normalizer's output and the aggregate carry -`schemaVersion: "acq-neutral-model-catalog/v1"`. - -Two constraints this ADR places on that shape, which the formal JSON Schema -(tracked in GSA-TTS/agentic-coding-patterns#435) must honor: +There are **two documents, and they are versioned separately**: + +| Document | `schemaVersion` | Written by | +|---|---|---| +| One provider's normalized model list | `acq-neutral-model-catalog/v1` | that provider's `normalizer`, or shipped as its vendored `snapshot.json` | +| The multi-provider aggregate | `acq-neutral-model-aggregate/v1` | the orchestrator (Layer 2) | + +An earlier revision of this ADR gave both documents the *same* version string +and required per-entry provenance on it. That was wrong, and the error is worth +recording because it is easy to repeat: **a normalizer cannot know its own +provenance.** It is a pure file-to-file transform over bytes the helper already +fetched (step 2) — whether those bytes came from a live refresh or from a +vendored snapshot is known only to its caller. Requiring `source: live|snapshot` +in the normalizer's own output would therefore mean a field the producer must be +*told* and cannot verify, and it would have invalidated the vendored snapshots +already shipping in this shape. + +Provenance belongs where the knowledge is: the orchestrator performed the fetch +or took the fallback, so the orchestrator records it. + +Constraints this ADR places on the **per-provider** shape +(`acq-neutral-model-catalog/v1`), which the formal JSON Schema (tracked in +GSA-TTS/agentic-coding-patterns#435) must honor: + +- **The model list is a required array and MAY be empty.** A provider that + legitimately exposes no models is *valid*, not a schema violation. +- **Model metadata only.** It carries `providerId` plus per-model id, limits and + cost. It MUST NOT carry routing or credential fields (`host`, `baseUrl`, + `modelsUrl`, `keyEnv`): a document at this layer may be read from a + guest-writable path, and a rewritten routing field could redirect a consumer. + Those facts live in the host-validated facts file, never here. +- **No provenance field.** See above — the producer cannot supply one honestly. + +Constraints on the **aggregate** shape (`acq-neutral-model-aggregate/v1`): - **The model list is a required array and MAY be empty.** An aggregate with - zero entries is *valid*, not a schema violation — that is what the - orchestrator writes when no provider is present (Layer 2, step 4). A schema - that required a non-empty list would force the no-provider case to either omit - the file or write something invalid, which is exactly the ambiguity step 4 - exists to prevent. -- **Per-entry provenance is required, and the aggregate records discovery - provenance.** Each entry states which `providerId` it came from and whether it - came from a live refresh or that provider's vendored snapshot; the aggregate - additionally records the set of providers discovered, and for each, the - outcome (`live`, `snapshot`, or `rejected`). A consumer must be able to tell a - live catalog from a stale one, and *"zero providers configured"* from - *"discovery did not run"*, by reading the file alone. + zero entries is *valid* — that is what the orchestrator writes when no + provider is present (Layer 2, step 4). A schema requiring a non-empty list + would force the no-provider case to either omit the file or write something + invalid, which is exactly the ambiguity step 4 exists to prevent. +- **Discovery provenance is required.** The aggregate records the set of + providers discovered and, for each, the outcome (`live`, `snapshot`, or + `rejected`). A consumer must be able to tell a live catalog from a stale one, + and *"zero providers configured"* from *"discovery did not run"*, by reading + the file alone — and a provider rejected by host-side validation must be + visible as rejected rather than merely absent. +- **Each entry names its origin provider.** Entries carry the `providerId` they + came from, so a consumer can attribute a model without re-deriving it. Whether + that entry was live or stale is answered by the per-provider outcome above, + not repeated on every entry. The schema is versioned from v1; there is no multi-version negotiation mechanism (renderers declaring supported versions, the orchestrator picking compatible @@ -519,12 +550,12 @@ sequenceDiagram one of those two costs explicitly. **Overlap to resolve before any such design is adopted:** this ADR introduces - `acq-neutral-model-catalog/v1` while `integrations/providers/usai/` already - ships `usai-model-catalog/v1`. Two neutral model-catalog schemas and two - host-side builders now coexist in one repo. Reconciling them is tracked with - the schema work in GSA-TTS/agentic-coding-patterns#435; a host-side - materialization design would make that reconciliation a prerequisite rather - than a follow-up. + `acq-neutral-model-catalog/v1` and `acq-neutral-model-aggregate/v1` while + `integrations/providers/usai/` already ships `usai-model-catalog/v1`. Two + distinct neutral-catalog lineages and two host-side builders now coexist in + one repo. Reconciling them is tracked with the schema work in + GSA-TTS/agentic-coding-patterns#435; a host-side materialization design would + make that reconciliation a prerequisite rather than a follow-up. - **No discovery mechanism; hardcode one vendor per harness kit.** Excluded: a harness kit could not learn which provider kit(s) are present without a hardcoded name per pairing, reproducing the N × M problem this ADR retires. From e17a977fb85e406b05b3e7807b3a89b9026da814 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Fri, 2 Oct 2026 09:10:32 +0000 Subject: [PATCH 11/13] docs(isolation): cite quickstart adr-0035 for host-authoritative config ADR-0030 was double-claimed. GSA-TTS/agentic-coding-quickstart#474 keeps ADR-0030 because patterns main already cites it for the devenv ADR. Co-authored-by: OpenCode [gpt-5.5] --- .../0004-neutral-model-provider-discovery.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md index 2e807f04..9476eabe 100644 --- a/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md +++ b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md @@ -14,7 +14,7 @@ nist_controls: ["SC-7", "AC-4", "CM-7", "SI-10"] > kit spec (ADR 0001) and the network egress tiers (ADR 0002). It changes neither > schema; the mechanism composes from already-shipped primitives > (`caps.network.allow` union, sequential kit startup) plus the -> host-authoritative read-only config mount defined in quickstart ADR-0030 +> host-authoritative read-only config mount defined in quickstart ADR-0035 > (pending — GSA-TTS/agentic-coding-quickstart#504). ## Context and Problem Statement @@ -94,7 +94,7 @@ sudo (the sandbox, not the guest OS user boundary, is the security boundary), so a root-owned *guest* path is not a trust boundary against a prompt-injected agent. The read-only host mount is. `acq` reads only **static data** from the kit on the host; no provider-kit code runs on the host. See -[Security model](#security-model) and quickstart ADR-0030 +[Security model](#security-model) and quickstart ADR-0035 (*Host-Authoritative Sandbox Configuration*, pending — GSA-TTS/agentic-coding-quickstart#504), the mechanism of record. @@ -253,11 +253,11 @@ There is no cross-sandbox catalog cache — a cache written by one sandbox and r by another would be a cross-sandbox poisoning channel (a compromised sandbox could poison the catalog a different sandbox routes against). The clean split is: the orchestrator's **inputs** (provider facts and normalizer code) are -host-authoritative and read-only (Layer 1 + ADR-0030 (pending)); its +host-authoritative and read-only (Layer 1 + ADR-0035 (pending)); its **output** (the catalog) is guest-local and trusted by no other sandbox. The orchestrator's own code, and each provider's normalizer and vendored -snapshot, run from the read-only mount (ADR-0030 Mechanism 2, pending), so a +snapshot, run from the read-only mount (ADR-0035 Mechanism 2, pending), so a sudo-capable agent cannot tamper the startup code between restarts and have `acq` re-run a tampered copy. @@ -474,7 +474,7 @@ sequenceDiagram - New moving parts: a per-sandbox host config directory + read-only mount, an orchestrator kit, and a versioned schema contract between N producers and M consumers. Mitigated by strict layering (each layer independently testable and - shippable), reuse of the existing host-state-dir conventions (ADR-0030, + shippable), reuse of the existing host-state-dir conventions (ADR-0035, pending), and the versioned-not-negotiated schema. - Startup gains a conditional outbound refresh on the critical path, bounded by the per-provider timeout and total wall-clock budget; on failure it falls back @@ -502,7 +502,7 @@ sequenceDiagram - Requires no change to the `hybrid/v1` schema. The mechanism composes from the `caps.network.allow` union, sequential startup-phase ordering, and the - host-authoritative read-only config mount from quickstart ADR-0030 + host-authoritative read-only config mount from quickstart ADR-0035 (pending — GSA-TTS/agentic-coding-quickstart#504). ## Alternatives Considered @@ -599,7 +599,7 @@ sequenceDiagram ## References -- Quickstart ADR-0030 (*Host-Authoritative Sandbox Configuration*, pending — +- Quickstart ADR-0035 (*Host-Authoritative Sandbox Configuration*, pending — GSA-TTS/agentic-coding-quickstart#504) — the general principle and the per-sandbox host config dir + read-only mount + trusted-startup-execution mechanism this ADR's Layers 1 and 2 build on. From f68d604af00891a83b6ac737ece4ca78ae13f007 Mon Sep 17 00:00:00 2001 From: William Zujkowski Date: Tue, 6 Oct 2026 13:10:15 -0400 Subject: [PATCH 12/13] docs(adr): resolve the 3 open review threads on ADR 0004 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses mogul's 3 unresolved inline comments from the 2026-10-04 review: 1. Fix the broken Mermaid diagram. The parse error was a literal semicolon in a sequence-message label (`credential; timeout + size cap...`) — Mermaid's sequence-diagram grammar treats `;` as a statement separator inside message text, not literal punctuation. Verified both the broken and fixed form directly against @mermaid-js/mermaid-cli (not just visually): the original text reproduces the exact parse error from the review comment; replacing the semicolon with a comma renders clean. 2. Answer "is the containment contract ever enforced, or just trusted?" with a real answer, not more assertion. Dispatched a research subagent against the actual acq.backends/*.sh codebase (quickstart repo) to check, property by property, whether any of the four containment properties (no network, no credential in env, bounded time, bounded output) has an existing reusable enforcement primitive. Confirmed definitively: none do. What exists are two narrow, hand-copied timeout(1) wrappers that FAIL OPEN (run unbounded) when timeout is absent from the guest, and a sibling repo's JS-level fetch()-body size cap that bounds an HTTP response, not a subprocess's stdout. Added this finding directly to the ADR's Negative/risks section, consulted a security-expert review on the wording (verdict: explicit written acknowledgment of an unenforced trust boundary is the correct, honest thing to put in a design record, not an invitation to exploit it, provided it also states the resulting trust decision and a fail-closed requirement — both added), and added a corresponding "must not decide unilaterally" entry so shipping Layer 2 with the contract merely documented, not enforced, requires the same human/CODEOWNERS review as relaxing it outright. 3. Re-verified all cross-repo links before merge, as asked. Checked every GSA-TTS/agentic-coding-* PR/issue reference in the document against its live current state: #504, #435, #415 all still open and pending as cited. Quickstart issue #506 is now CLOSED (fixed by quickstart#512) — updated its one citation (References section) from a forward-looking "tracking... the upstream fix this ADR depends on" to a past-tense "closed, fixed by #512," consistent with the durable-reference convention already used elsewhere in this document for settled dependencies. Also rebased onto main (7 commits: the markdownlint-cli migration, 2 CVE overrides, the opencode kit, 2 releases, 2 dependency bumps) — none touch this file; this resolves the Markdown Lint CI failure that was purely a result of the branch predating main's markdownlint-cli2 CVE fix. Verification: - node .github/linters/run-markdownlint.mjs: clean, 191 files. - Both Mermaid diagrams verified directly against mermaid-cli (not just read): the trust-boundary flow diagram renders without error. - python3 scripts/validate_repo.py: all validators pass. Co-authored-by: OpenCode Agent --- .../0004-neutral-model-provider-discovery.md | 54 +++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md index 9476eabe..38bca36f 100644 --- a/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md +++ b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md @@ -403,6 +403,42 @@ ADR at that time, not by standing negotiation machinery. explicit contract in Layer 2, step 2: no network, no credential, bounded time and output, and failure routed to the snapshot fallback. Both properties are required, and neither substitutes for the other. +- **The containment contract is a documented requirement, not yet a technically + enforced one — this is a known gap, not an oversight, and it is a release + blocker, not accepted debt.** Verified directly against `acq.backends/` + (quickstart): no per-subprocess network restriction exists at all (acq's + network policy is sandbox-wide and create-time-only, with no mechanism to + deny network to one guest process while permitting it to siblings — this + includes loopback and any inherited socket, not just the obvious egress + path); no environment-minimization helper exists (the two existing exec + paths only ever *add* `-e` flags via inheritance, never construct an + explicit allowlist — "no credential in env" bounds only the environment- + variable channel, not every way a process could reach credential material, + e.g. an inherited file descriptor or a guest-local metadata endpoint); the + two existing wall-clock `timeout(1)` uses are narrow, hand-copied, and + **fail open** (silently run unbounded) when `timeout` is absent from the + guest, which a containment contract cannot inherit, and neither kills the + full process tree, only the immediate child; and no subprocess output-size + cap exists anywhere in either repo (the sibling `fetchJsonBounded` helper + bounds an in-process JS `fetch()` response, not a child process's stdout or + stderr, and does not stop an uncapped write from being buffered before the + cap is checked). Each of the four properties would be new implementation + work, not reuse of an existing primitive. + + **Until an orchestrator-controlled mechanism enforces all four properties, + a provider-shipped normalizer MUST be treated as trusted arbitrary code + running with the privileges and reachability available to any process in + that guest — not as code already contained by this ADR.** Implementation + MUST NOT represent the contract as enforced, or run a normalizer under it, + on the strength of this ADR alone. If a required mechanism is unavailable + on the target guest, the orchestrator MUST refuse to execute that + provider's normalizer and fall back to the snapshot — never degrade to an + unenforced run. Concretely: process-tree-wide termination on timeout (not + single-child), an explicit allowlisted environment (not inherited-then- + trimmed), network denial established before exec (not probed after), and a + streamed byte-counting cap on both stdout and stderr (not a post-hoc size + check after unbounded buffering). None of this may depend on an + opportunistically-available guest utility with a silent unbounded fallback. - **Atomic writes.** Facts and aggregate writes are temp-file + rename; no reader observes a partial write. - **Bounded resource use.** Per-provider fetch timeout + response-size cap @@ -441,7 +477,7 @@ sequenceDiagram ACQ->>RO: mount HSTATE read-only AGENT-->>RO: sudo tee providers/evil.json (FAILS: read-only mount) ACQ->>RO: invoke orchestrator from :ro path (restart-safe) - ORCH->>ORCH: bounded fetch (helper holds credential; timeout + size cap + host check) + ORCH->>ORCH: bounded fetch (helper holds credential, timeout + size cap + host check) RO->>ORCH: run normalizer on fetched bytes (no network, NO credential, bounded) ORCH->>CAT: write per-sandbox catalog (rw, never shared) Note over ACQ,RO: acq trusts only host-authoritative read-only inputs and the catalog stays guest-local (never cross sandbox) @@ -596,6 +632,15 @@ sequenceDiagram credential boundary this ADR deliberately narrows, and the read-only mount does not substitute for it. Needs human/CODEOWNERS review, not an implementer's judgement call. +- **Shipping Layer 2 with the containment contract unenforced.** Per + [Consequences → Negative](#negative--risks), none of the four properties has + an existing enforcement primitive to reuse; each needs new, fail-closed + implementation. An implementer choosing to ship a first cut that documents + the contract without mechanically enforcing it — treating this ADR's prose + as sufficient — is the same category of decision as relaxing the contract + outright, just reached by omission instead of by edit. Needs human/ + CODEOWNERS review before any normalizer is run against a non-pinned, + live-fetched provider feed on that basis. ## References @@ -613,7 +658,8 @@ sequenceDiagram harness-adapter open questions this ADR's Layer 3 boundary answers for the model-config slice. - Quickstart [issue #506](https://github.com/GSA-TTS/agentic-coding-quickstart/issues/506) - tracking the startup-ordering race flagged above (`acq run` does not wait + (closed, fixed by [PR #512](https://github.com/GSA-TTS/agentic-coding-quickstart/pull/512)) + — the startup-ordering race flagged above (`acq run` did not wait for an in-guest `startup`-phase config write to finish before attach, - reproduced live against `sbx` v0.43.0) — the upstream fix this ADR's - Layers 2–3 depend on but do not themselves provide. + reproduced live against `sbx` v0.43.0) that Layers 2–3's startup-phase + ordering assumption depends on. Fixed upstream of this ADR, not by it. From 70802cc7cb1bfdea0d11222bd94790d47ac207e4 Mon Sep 17 00:00:00 2001 From: wz-gsa Date: Thu, 8 Oct 2026 15:19:25 -0400 Subject: [PATCH 13/13] docs(adr): correct the trust-boundary diagram's actors and activations Addresses the review finding that the sequence diagram's activations were missing or wrong, and that it was unclear what triggers the bounded fetch and why a filesystem appeared to invoke the normalizer. Both observations were correct, and each was a real defect rather than a rendering artifact: - Nothing invoked the orchestrator. The only arrow into its lifeline went to the read-only mount, so the bounded fetch was a self-message with no inbound trigger. The fetch now follows an explicit `ACQ->>+ORCH: run startup phase`, which is what actually starts it. - A read-only mount cannot originate a message. `RO->>ORCH: run normalizer` drew a passive filesystem as the actor. The normalizer is now its own participant, invoked BY the orchestrator from the mount, which matches the prose: the helper fetches, then invokes the normalizer as a pure file-to-file transform. Also adds what the diagram omitted and the review asked for: - `create time` and `sandbox startup` phase notes, so the ordering is legible without reading the surrounding prose. Kit order is stated on the startup note, since Layers 2-3 depend on it. - Activation bars on the orchestrator and the normalizer, so the nesting of fetch, transform and schema validation is visible. - An `alt` branch for the snapshot fallback. The failure path was described only in prose, which left the diagram implying a single happy path through code whose contract is "failure is a fallback, not a pass". - The agent's failed tamper write moved below an `at any later point` note. It previously sat between provisioning and startup, implying an ordering that was never intended. Two semicolons inside message text are replaced with commas. A semicolon terminates a statement in mermaid's sequence grammar, which is what produced the earlier parse failure; the same mistake would have broken this diagram in the same way. Verified by rendering, not by inspection: the embedded block extracted from the committed file renders to a 37507-byte SVG via mermaid-cli, with every participant and the `alt` branch present and no error graphic. A known-bad control fails hard with no output, so the pass is a real render. Separately, records the pending provider-credential-operations work in References, noting two overlaps a reconciler will need: its diagnostic endpoint and this ADR's `modelsUrl` are the same URL on the same artifact, and the credential environment variable is now recorded in three places. Verified: make validate clean, 407 tests pass, markdownlint clean (192 files). Co-authored-by: OpenCode Agent --- .../0004-neutral-model-provider-discovery.md | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md index 38bca36f..be9d5a1a 100644 --- a/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md +++ b/integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md @@ -468,19 +468,34 @@ sequenceDiagram participant RO as /var/lib/acq/host (:ro) participant AGENT as agent (injectable) participant ORCH as models orchestrator + participant NORM as provider normalizer (from :ro) participant CAT as catalog.json (guest-local rw) end + note over ACQ,HSTATE: create time ACQ->>KIT: fetch pinned kit (static facts + normalizer + snapshot) ACQ->>ACQ: validate facts host-side (providerId slug/uniqueness, env-var-ownership, SSRF, schema) ACQ->>HSTATE: write validated facts + staged code ACQ->>RO: mount HSTATE read-only - AGENT-->>RO: sudo tee providers/evil.json (FAILS: read-only mount) - ACQ->>RO: invoke orchestrator from :ro path (restart-safe) - ORCH->>ORCH: bounded fetch (helper holds credential, timeout + size cap + host check) - RO->>ORCH: run normalizer on fetched bytes (no network, NO credential, bounded) + + note over ACQ,CAT: sandbox startup (kit order: provider kits, then orchestrator, then harness kits) + ACQ->>+ORCH: run startup phase (orchestrator kit) + ORCH->>RO: glob models/providers/*/facts.json + RO-->>ORCH: facts.json (modelsUrl, keyEnv, timeout, caps) + ORCH->>ORCH: bounded fetch to modelsUrl (helper holds credential, timeout, size cap, host check) + ORCH->>+NORM: normalizer --source fetched --out neutral (no network, NO credential, bounded) + NORM-->>-ORCH: neutral catalog file (or non-zero / timeout / oversize) + ORCH->>ORCH: validate output against neutral catalog schema + alt fetch, normalizer or schema validation failed + ORCH->>RO: read that provider's vendored snapshot + RO-->>ORCH: snapshot bytes + end ORCH->>CAT: write per-sandbox catalog (rw, never shared) - Note over ACQ,RO: acq trusts only host-authoritative read-only inputs and the catalog stays guest-local (never cross sandbox) + deactivate ORCH + + note over AGENT,RO: at any later point + AGENT-->>RO: sudo tee providers/evil.json (FAILS: read-only mount) + Note over ACQ,CAT: acq trusts only host-authoritative read-only inputs, and the catalog stays guest-local and never crosses sandboxes ``` ## Consequences @@ -657,6 +672,20 @@ sequenceDiagram - `goose-server` ADR 0004 (pending — GSA-TTS/agentic-coding-patterns#415) — the harness-adapter open questions this ADR's Layer 3 boundary answers for the model-config slice. +- Provider credential operations (pending — GSA-TTS/agentic-coding-patterns#486, + consumed by GSA-TTS/agentic-coding-quickstart#566) — a constrained, + **static-metadata** extension of this ADR's facts model, declaring a canonical + credential and a low-impact authenticated diagnostic endpoint so `acq` can + check and replace a provider credential without provider-specific code. It + deliberately adds no provider-kit executable health-check hook, because that + would hand provider-shipped code a credential and so widen exactly the + boundary [Layer 2](#layer-2--the-models-orchestrator-kit-neutral) narrows. + Two notes for whoever reconciles the two shapes: the diagnostic endpoint and + this ADR's `modelsUrl` are the same URL on the same artifact, so one should + reference the other rather than restate it; and the credential environment + variable is currently recorded in three places (this ADR's `keyEnv`, that + proposal's `credential.keyEnv`, and the model catalog's `gateway.apiKeyEnv`), + which the schema work should collapse to one authority. - Quickstart [issue #506](https://github.com/GSA-TTS/agentic-coding-quickstart/issues/506) (closed, fixed by [PR #512](https://github.com/GSA-TTS/agentic-coding-quickstart/pull/512)) — the startup-ordering race flagged above (`acq run` did not wait