Skip to content

docs(isolation): add ADR 0004 for neutral model-provider discovery - #423

Merged
mogul merged 16 commits into
mainfrom
docs/adr-0003-model-provider-discovery
Oct 8, 2026
Merged

mogul merged 16 commits into
mainfrom
docs/adr-0003-model-provider-discovery

Conversation

@wz-gsa

@wz-gsa wz-gsa commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Records the model-provider discovery design from this session's nexus-agents consensus review work (3 rounds: 7-0 reject → 4-3 split → 5-2 approve), plus the concrete duplication findings that motivate it.

Key findings backing this ADR

  • Real secret-duplication bug: 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 goose-usai. Confirmed via acq.backends/secret-store.sh's _acq_secret_key.
  • Real merge-script duplication: ~150-190 of ~230-260 lines/kit are the same copy-on-first-boot/merge-without-clobbering algorithm, hand-copied (goose's own header comment admits it "mirrors" usai-provider's).
  • Already-drifted duplicate: the bounded-fetch-with-timeout-and-size-cap helper was copy-pasted once already, and the copy gained 401/403 handling the original lacks.

Decision

Three layers, no hybrid/v1 schema change required:

  1. Provider kits self-register via a well-known guest-path facts file (mirrors the already-shipped zscaler-ca-certificate → openchamber/paseo pattern), each shipping its own normalizer + vendored fallback snapshot.
  2. New models orchestrator kit (vendor-agnostic, core-repo promotion candidate): discovers providers, refreshes a host-mounted-volume cache on a TTL, falls back to last-good-cache then the provider's own snapshot on failure, aggregates into one neutral catalog.
  3. Harness kits read the aggregate + config-kit defaults and render their own native config — owning their full configuration lifecycle end-to-end. N provider normalizers + M harness renderers, not N×M per-pair emitters.

Both dissenting votes from the final consensus round are incorporated as binding security conditions (env-var-ownership check; SSRF host-matching against the already-effective network allow-list).

Verification

  • markdownlint-cli2: 0 issues.
  • Follows this repo's existing ADR template/frontmatter conventions (compared against ADR 0001/0002 in the same directory).

No implementation in this PR — issues to follow, per this repo's own ADR-then-issues convention.

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 <agent@gsa.gov>
@wz-gsa

wz-gsa commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

CI-green, mergeable, no other blockers I can see. I can't self-approve (author). Could a maintainer review/merge this ADR when convenient? I have follow-up implementation work (#424) that cites this ADR's path — low risk either way since it's docs-only, but merging unblocks a clean citation rather than a provisional one.

@mogul mogul left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

See note; agents have passwordless sudo, so root-ownership is not the barrier implied. It's possible to mount a volume :ro, but we can't do that if we want the kit startup to be how we drop a file there. Other ideas? Maybe acq is responsible for grabbing the file from the kit and putting it there, and it's :ro?

Comment thread integrations/isolation/docs/decisions/0003-neutral-model-provider-discovery.md Outdated
@mogul

mogul commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Also: This clarity of this ADR would be vastly improved by the addition of Mermaid sequence diagrams.

…ed 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] <bret.mogilefsky@gsa.gov>
@mogul

mogul commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Pushed ecfa36a addressing the review. Summary of what changed in the ADR (leaving the original description intact per request):

The security issue you flagged is removed. The Layer 1 mechanism no longer relies on a root-owned guest path being un-writable. You're right that this isn't a barrier: acq deliberately gives the in-sandbox agent passwordless sudo, so a prompt-injected agent can sudo tee a bogus provider facts file that the orchestrator would then trust. Guest root-ownership is not a trust boundary against the agent.

Layer 1 — host-authoritative, guest read-only. Provider kits now ship a static facts file in their (pinned) kit directory rather than writing one from a startup command. acq reads it on the host, validates it host-side (env-var-ownership + SSRF + schema), materializes it into a per-sandbox host dir, and mounts it read-only into the guest at /var/lib/acq/host/models/providers/. The read-only mount is enforced at the VMM/mount layer, which guest sudo cannot override. Answering your "we can't do :ro if kit startup drops the file" point directly: acq is the thing that grabs the file from the (pinned) kit and puts it there, and it's :ro — so no in-guest write is needed. No provider-kit code runs on the host — it's a static data read only; the normalizer still runs in-guest (so the 4-3 host-execution objection is not reintroduced).

Layer 2 — shared host cache dropped. Per your call, the cross-sandbox host cache + TTL is gone entirely: a cache written by one sandbox and read by another is a cross-sandbox poisoning channel. The catalog is now generated per-sandbox at startup, kept guest-local (rw), and never shared. Deepest fallback is the provider's vendored snapshot on the :ro mount.

Your restart-tamper concern is addressed too. You noted the models kit startup script could be tampered between restarts. Correct — and it's the general form of the same problem, since acq's restart heal re-runs guest-resident kit code. The fix is the same boundary: the orchestrator/normalizer code is staged into the :ro mount and invoked from there, so a sudo agent can't rewrite it and have acq re-run the tampered copy. Three mechanisms were considered (mount code :ro and invoke from it; re-push-on-heal with its TOCTOU window; msb --script-path native replay, which ADR-0017 showed is inert at boot) — option 1 selected.

General principle recorded separately. The mount/exec mechanism and the broader rule ("acq must not trust guest-generated/tamperable state to configure the sandbox; migrate the existing /var/lib/acq markers off guest-writable paths") live in a new quickstart-repo ADR-0030 (Host-Authoritative Sandbox Configuration), which this ADR references as the mechanism of record (quickstart PR GSA-TTS/agentic-coding-quickstart#504). Implementation of the acq-side mechanism + the migration of the existing /var/lib/acq markers off guest-writable paths is tracked in a single quickstart issue, GSA-TTS/agentic-coding-quickstart#503 (one PR, a commit per change).

Mermaid diagrams added (your other request): two sequenceDiagrams with box grouping for GitHub / Host / Guest, contrasting the rejected guest-write path against the chosen host-materialized read-only path.

markdownlint-cli2: 0 issues.

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] <bret.mogilefsky@gsa.gov>
@mogul

mogul commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Pushed d973a8a rewriting the ADR as documentation of the agreed design, not a log of how we got there.

Your point is right: once this merges, the ADR is reference documentation. The prior revision had turned into a review journal — consensus rounds, vote tallies (7-0 / 4-3 / 5-2), reviewer role names, "corrected from an earlier draft" blocks, and two separate "the guest-write approach was wrong" correction notes. A reader arriving after merge has to reconstruct the actual design from the diffs between rejected and accepted states.

What changed:

  • Context now states the problem (duplicate credential registration, duplicate merge code, manual release-coupled refresh) and the design goals directly — no panel/vote narrative.
  • Decision describes Layers 1–3 in the present tense as how it works. The host-authoritative, guest-read-only rationale for the facts file is folded inline in Layer 1 (why it isn't an in-guest write) instead of appearing as a post-hoc correction.
  • A single Security model section replaces the scattered "binding hardening conditions" that were attributed to reviewer roles.
  • One Trust-boundary flow diagram — the chosen design. The rejected "provider kit writes facts in the guest" sequence diagram is gone; its lesson survives as one line in Alternatives plus the Layer 1 rationale.
  • Alternatives Considered keeps what was excluded and why (host-side execution, no-discovery, shared cross-sandbox cache, in-ADR permission vocabulary) but drops the vote counts and session references.

No design change from ecfa36a — same three-layer mechanism, same host-materialized read-only facts/code, same per-sandbox guest-local catalog, same references to quickstart ADR-0030. Purely a documentation rewrite.

markdownlint: 0 issues; the remaining Mermaid diagram parses (mermaid v11).

(The provenance you'd normally want preserved — the consensus rounds and the review that caught the sudo/root-ownership flaw — lives durably in this PR's history and conversation, which is the right home for it.)

… prose-only

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.
@mogul

mogul commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

I'm taking on GSA-TTS/agentic-coding-quickstart#506 so we can merge this one without caveats about that race-condition.

@mogul mogul self-assigned this Sep 24, 2026
@mogul

mogul commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

I'm taking on GSA-TTS/agentic-coding-quickstart#506 so we can merge this one without caveats about that race-condition.

PR now open.

`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.
wz-gsa added a commit that referenced this pull request Sep 24, 2026
#423's ADR moved from `0003-neutral-model-provider-discovery.md` to `0004-`
to clear a collision with #416, which claimed `integrations/isolation/docs/
decisions/0003-` first. Update the two comment references here so they do not
dangle once that lands.

Comment-only; no behavior change. `node --check` and a YAML parse both pass.

AI-assisted (OpenCode). Human review and merge still required.
@wz-gsa wz-gsa changed the title docs(isolation): add ADR 0003 for neutral model-provider discovery docs(isolation): add ADR 0004 for neutral model-provider discovery Sep 24, 2026
wz-gsa and others added 2 commits September 24, 2026 16:51
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,
  #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 <agent@gsa.gov>
…der directory

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 <agent@gsa.gov>
@wz-gsa

wz-gsa commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

Independent review + fixes (AI-assisted, OpenCode)

Ran a 5-role pr_review panel (architect/security/devex/catfish/scope_steward) against the full diff, then independently verified every cross-repo/internal claim rather than trust the panel's say-so (checked acq.backends/secret-store.sh, common.sh, 50-kit-list-completeness.bats directly; freshly re-checked the two cited sibling PRs via gh pr view). Result: 3 approve / 2 request_changes, with 3 real content defects and 2 citation-status issues confirmed by hand, not just by panel vote count.

Before applying any fix, ran the proposed fix set through a 7-role higher_order consensus vote on the approach itself: 85.7% approved (6 approve / 1 reject). The panel's own dissent was substantive and is reflected below — several voters (including the one reject) flagged that one of the five fixes is a design amendment, not a wording correction, and asked that it land as its own reviewable/revertable commit rather than be bundled with the accuracy fixes. Followed that recommendation.

Commit 1 — accuracy + a security invariant (3b565fd):

  • Corrected a false claim in Consequences/Positive ("one shared config-merge/bounded-fetch library") that contradicts the ADR's own Decision — Layer 3 mandates each harness render its own config, so only the fetch helper is actually shared.
  • Added an explicit invariant, in 3 places (Layer 2 output, Layer 3 read-list, Security model): the guest-writable catalog.json carries only model metadata, never routing/credential fields (host/baseUrl/modelsUrl/keyEnv) — those are read exclusively from the Layer 1 read-only facts mount. Without this, a sudo-capable in-guest agent (the ADR's own stated adversary) could rewrite the catalog to pair one provider's keyEnv with another provider's allowlisted host, bypassing the Layer 1 env-var-ownership/SSRF checks entirely within caps.network.allow.
  • Added (pending — org/repo#N) qualifiers where this ADR cites quickstart ADR-0030 and goose-server ADR 0004 — both are open, unmerged PRs (docs(adr): add ADR-0035 host-authoritative sandbox configuration agentic-coding-quickstart#504, feat(kits): add goose-server chat-harness acq mixin kit #415), reverified immediately before commit, not presented as settled.

Commit 2 — Layer 1 artifact restructure (d20f292), landed separately per the panel's recommendation:

  • The v1 facts file had no field locating a provider's normalizer or vendored snapshot, yet Layer 2 requires resolving both — two independent kit authors could place them differently and the orchestrator would find neither. Restructured the Layer 1 materialized artifact from a single JSON file to a fixed per-provider directory (facts.json + normalizer + snapshot.json), and updated Layer 2's glob/resolve steps to match.

markdownlint: 0 issues on both commits. Full panel transcripts and the verification subagent's findings available on request. Human review and merge still required — this is advisory, not a self-approval.

…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.
@wz-gsa

wz-gsa commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

One more correction, in ea10fbf (CI 7/7). This one is to my own earlier fix, found while trying to write the #435 schemas against it.

The defect I introduced

My e322cb0 (addressing your item 3) 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 — whether those came from a live refresh or 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.

And it would have broken what is already shipping. Measured against #436's artifacts:

normalizer output   envelope: models,providerId,schemaVersion   model[0]: contextWindow,cost,id,maxOutputTokens
shipped snapshot    envelope: models,providerId,schemaVersion   model[0]: contextWindow,cost,id,maxOutputTokens
provenance present? false (both)

A schema honoring the ADR literally would have failed both files #436 ships — a PR that is currently green and awaiting your re-review.

The fix: two documents, versioned separately

Document schemaVersion Written by
One provider's normalized model list acq-neutral-model-catalog/v1 that provider's normalizer, or its vendored snapshot.json
The multi-provider aggregate acq-neutral-model-aggregate/v1 the orchestrator (Layer 2)

Provenance belongs where the knowledge is: the orchestrator performed the fetch or took the fallback, so the orchestrator records it — per provider, as live / snapshot / rejected, so a provider rejected by host-side validation is visible as rejected rather than merely absent. Entries name their origin providerId; whether an entry is stale is answered once per provider rather than repeated on every row.

The empty-array rule is kept on both shapes, for the step-4 reason.

I considered requiring provenance everywhere and amending #436 instead. The cost was reopening a green PR to add a field its producer cannot honestly supply, so the split is the cheaper and more truthful option.

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.

One addition while I was there

The per-provider shape now explicitly states model metadata only, never routing or credential fields (host, baseUrl, modelsUrl, keyEnv). It is a document that may be read from a guest-writable path, and a rewritten routing field could redirect a consumer. #436 already enforces this with a test asserting the shipped snapshot carries none of those keys; this records the rule that test checks, so the two cannot drift.

Consequence for #435

The overlap note in Alternatives is updated: there are now two distinct neutral-catalog lineages here, plus the already-shipped usai-model-catalog/v1 in integrations/providers/usai/. That makes the #435 reconciliation broader, not narrower — I have left the detail on that issue.

Two related things measured while writing this, recorded there rather than here:

  • catalog.schema.json is not enforced anywhere — build-catalog.mjs hand-rolls a parallel validator, nothing in CI calls it. No drift today; the gate is simply absent.
  • A pattern retrofit on that schema is safe with ^[a-z0-9][a-z0-9_.-]*$ (0 of 21 ids fail). The underscore-free variant would break 18 — claude_4_5_sonnet and friends.

Verification

python3 scripts/validate_repo.py --root .   -> exit 0, all validators passed
markdownlint-cli2                           -> 0 errors
git diff --check                            -> clean
gh pr checks                                -> 7/7 pass

Where this leaves the PR

All three of your Address now items remain addressed; this corrects a defect in my fix for item 3, not a new requirement. No design change to Layers 1–3 and the containment contract stands.

I am deliberately not writing the #435 schemas until this split has your eyes on it — they would be written against a version boundary that could still change. #436 needs no change from this.


AI-assisted (OpenCode), on operator instruction. Advisory; human review and merge still required.

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] <bret.mogilefsky@gsa.gov>
@mogul mogul assigned wz-gsa and unassigned mogul Oct 2, 2026
@mogul
mogul self-requested a review October 2, 2026 09:39

@mogul mogul left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looking good. This is mostly commentary on minor points in the substance, rather than any quibble with what we've landed on. With these changes accounted for, I would happily approve it.

wz-gsa added a commit that referenced this pull request Oct 5, 2026
#423's ADR moved from `0003-neutral-model-provider-discovery.md` to `0004-`
to clear a collision with #416, which claimed `integrations/isolation/docs/
decisions/0003-` first. Update the two comment references here so they do not
dangle once that lands.

Comment-only; no behavior change. `node --check` and a YAML parse both pass.

AI-assisted (OpenCode). Human review and merge still required.
wz-gsa added a commit that referenced this pull request Oct 5, 2026
…al catalog

Rescopes this PR to the half that is sound. Addresses both blocking items from
review at a7dec19.

BLOCKER 1 -- the facts file is gone. spec.yaml wrote
/var/lib/acq/models/providers/usai.json from inside the sandbox at startup as
root (58 lines removed), on the theory that a root-owned guest path would be a
trustworthy source for a future provider-discovery consumer.

That reasoning was wrong, and I verified it rather than taking it on the
reviewer's word: quickstart's acq itself writes
`printf "agent ALL=(ALL) NOPASSWD:ALL\n" > /etc/sudoers.d/90-acq-agent`
(acq.backends/msb.sh at origin/main), and docs/BACKEND_GUIDE.md states the same
guarantee as the sbx base-image contract. So the spec's own comment -- "the agent
(uid 1000) cannot write concurrently (root-owned, agent has no write access to
/var/lib/acq/)" -- was DAC-true but FALSE as a security property: `sudo tee`
defeats it. The file advertised `keyEnv`, a credential POINTER, so an agent able
to rewrite it could have redirected a consumer at a different env var. That is a
confused-deputy primitive, and it was shipping with a comment licensing a
consumer to trust it.

Trustworthy facts need a host-materialized, read-only source. That mechanism does
not exist yet -- it depends on acq's host-config `:ro` mount, still unmerged
upstream -- so this kit now ships NO facts file rather than one at the wrong trust
level. Rather than delete silently, spec.yaml's header records what was tried, why
it was wrong, and what the replacement requires, so the next reader does not
reinvent it.

BLOCKER 2 -- verify check 1b is gone. It extracted `keyEnv` by grepping the
heredoc, so removing the heredoc would have left `facts_key_env` empty and
`bad()` called: a check failing for the wrong reason. scripts/verify is now
BYTE-IDENTICAL to origin/main, so exactly this PR's addition was removed and none
of the pre-existing script was touched.

Worth recording why that check was weak independent of the coupling: it asserted
"the kit declares this env var itself" by counting occurrences of the value
across the whole spec.yaml and requiring >= 2. The only two mentions were a
HEADER COMMENT and the heredoc itself, so a check the PR called
"security-load-bearing" passed entirely on the strength of one comment line. It
also used `grep -c`, which counts matching LINES, not occurrences (verified:
`AAA AAA AAA` on one line gives 1, not 3), so its own message was wrong whenever
two mentions shared a line.

WHAT SURVIVES, and why it is sound: scripts/normalize-catalog.mjs (a HOST-side
authoring tool -- one file in, one file out, no network, no credential, matching
the containment contract landed on ADR-0004 in #423), its 8 tests, the source
fixture, and the vendored snapshot.

TWO ADDITIONS beyond the strict rescope, both closing gaps found while doing it:

1. A drift gate for the shipped snapshot (2 tests). Nothing asserted that
   model-catalog-snapshot.json matched normalizeCatalog(providers/usai/catalog.json),
   so it was a second hand-maintained copy that could silently diverge from its
   stated source. It is a deliberate 5-of-21 SUBSET, so the gate allows a subset
   but requires every kept entry to be byte-equal, and asserts BOTH sides are
   non-empty first -- an emptied snapshot would otherwise make the comparison
   pass vacuously. Degraded paths exercised: a mutated cost fails with "has
   drifted from the normalizer output"; an emptied models[] fails with "nothing
   to compare, so this gate cannot pass".

2. The snapshot's comment no longer frames it as something a consumer should
   trust. It ships to /home/agent, which the agent OWNS -- forging it needs no
   escalation at all, so it was the same invitation as the removed facts file with
   WEAKER ownership. It is now documented as not authoritative, and a second test
   asserts the shape stays metadata-only (no host/baseUrl/modelsUrl/keyEnv/apiKey
   at either envelope or model level), so a routing field cannot be added later
   without a test failing.

Verified:

    node --test tests/normalize-catalog.test.mjs  -> 10 pass, 0 fail (was 8)
    make test-kits                                -> 80 pass, 0 fail
    validate-kits.py --root . --strict            -> All 7 acq-kits valid
    python3 scripts/validate_repo.py --root .     -> exit 0
    python3 scripts/scan_unsafe_shell.py .        -> 0 errors, 0 warnings
    markdownlint-cli2                             -> 0 issues
    git diff --check                               -> clean
    git diff origin/main -- scripts/verify         -> empty (byte-identical)

NOT in this PR, deliberately: the host-side facts mechanism (needs the upstream
`:ro` mount first) and reconciling `acq-neutral-model-catalog/v1` with the
already-shipped `usai-model-catalog/v1` in integrations/providers/usai/ -- two
catalog schemas coexist, tracked at #435.

Decision support: nexus-agents 7-role consensus panel, unanimous 7/7 for this
rescope over four alternatives (including converting to the unmerged `:ro` mount,
and adding a lockfile to defend a concurrency property sudo defeats anyway).
Panel caveat recorded honestly: all 7 seats ran on one model, so independence is
weaker than the role count suggests. The agent-owned-snapshot and snapshot-drift
gaps were raised by the contrarian seat, not found by me.

AI assistance disclosed: prepared by an AI agent (OpenCode) on operator
instruction. Human review and merge still required.
wz-gsa and others added 2 commits October 6, 2026 12:26
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 <agent@gsa.gov>
@wz-gsa

wz-gsa commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all 3 open threads from the 2026-10-04 review, in `f68d604`:

1. Broken Mermaid diagram (line ~448). Root cause: a literal semicolon inside a sequence-message label (`...credential; timeout + size cap...`) — Mermaid's sequence-diagram grammar treats `;` as a statement separator in message text, not literal punctuation. Verified this precisely rather than guessing: reproduced the exact parse error from your review comment against `@mermaid-js/mermaid-cli` using the original text, then confirmed the comma-separated fix renders clean. Both diagrams in the doc now verified to actually render, not just read correctly.

2. "Is the containment contract ever enforced, or just trusted?" Dispatched a research subagent against the real `acq.backends/*.sh` codebase to answer this with facts instead of more assertion. Confirmed definitively: none of the four properties (no network, no credential in env, bounded time, bounded output) has an existing reusable enforcement primitive today. What exists: two narrow, hand-copied `timeout(1)` wrappers elsewhere in acq that fail open (silently run unbounded) if `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/stderr. Added this finding directly into the ADR's Negative/risks section, ran it past a security-expert review on the framing (verdict: writing down "this is currently unenforced" is the correct, honest thing to put in a design record — the risk would be implying enforcement exists, not disclosing that it doesn't), and the revision now states explicitly: until an orchestrator-controlled mechanism enforces all four, a normalizer must be treated as trusted arbitrary code, not as code already contained by this ADR; a required mechanism being unavailable on a guest must refuse execution and fall back to snapshot, never silently run unenforced. Also added a new "must not decide unilaterally" entry so shipping Layer 2 with the contract merely documented (not enforced) needs the same CODEOWNERS review as relaxing it outright — closes the gap where that call could otherwise be made by omission.

3. Pre-merge link re-check. Checked every cross-repo PR/issue citation against its live state. #504, #435, #415 all still open/pending exactly as cited — no change needed. Quickstart #506 is now closed (fixed by quickstart#512) — updated that one citation from forward-looking "the upstream fix this ADR depends on" to past-tense "closed, fixed by #512," matching the durable-reference convention already used elsewhere in the doc.

Also rebased onto main (picks up the markdownlint-cli migration + CVE fixes — none of the 7 commits touch this file; this is what fixes the Markdown Lint CI failure, which was purely staleness, not content).

CI 7/7 green. `python3 scripts/validate_repo.py`: all pass.

@mogul mogul left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please address my finding on the sequence diagram... This will be a critical enabler for kit-author understanding!

It's great that we're now being explicit that there's not currently any enforcement mechanism, but... Could there be? For example bubblewrap? At the very least we should have an issue dedicated to figuring that out on the -quickstart side.

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 <william.zujkowski@gsa.gov>
@wz-gsa

wz-gsa commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 70802cc. All four open threads answered inline; this covers the two that have no live anchor — the sudo thread sits on 0003, which was renamed to 0004, and your bubblewrap question came in the review body.

The sequence diagram (your critical-enabler finding)

Both of your observations were real defects, not rendering artifacts. 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 trigger. And a read-only mount cannot originate a message, so RO->>ORCH: run normalizer had a passive filesystem acting. The normalizer is now its own participant invoked by the orchestrator from the mount, which is what the Layer 2 prose actually describes.

Added the init/start events you asked for, plus two things their absence was hiding: the snapshot fallback existed only in prose (now an alt branch — it matters, because the contract is "failure is a fallback, not a pass"), and the agent's failed tamper write was sitting between provisioning and startup, implying an ordering never intended.

Verified by rendering rather than inspection: 37507-byte SVG from the committed file, all participants and the alt present, no error graphic — with a known-bad control that fails hard, so the pass is real.

Passwordless sudo — you were right, and it is now load-bearing

Your original squick was I feel some squick here... We have deliberately given agents passwordless sudo here so technically this is not closed. That was correct and it changed the design rather than just the wording. The ADR no longer treats a root-owned guest path as a boundary anywhere; the trust boundary is the host↔guest read-only mount, enforced at the VMM/mount layer, which is why facts are materialized on the host and never written by an in-guest startup command. The sudo fact is now stated as a premise in twelve places instead of being worked around once, and the diagram shows the tamper attempt failing against the mount rather than against file ownership.

Enforcement — could there be? Yes, and I have not filed it yet

It's great that we're now being explicit that there's not currently any enforcement mechanism, but... Could there be? For example bubblewrap? At the very least we should have an issue dedicated to figuring that out on the -quickstart side.

Agreed on both counts, and the issue does not exist — I searched the quickstart tracker and found nothing covering normalizer containment, bubblewrap, or per-subprocess isolation. I did not want to file it against your idea without checking the shape of the problem first, so here is what I measured; tell me if you want me to file it as-is.

bwrap plausibly covers three of the four properties and is already a known quantity in this workspace:

Property bwrap mechanism Confidence
Network denial --unshare-net Strong — kernel-enforced, established before exec, covers loopback and inherited sockets
Environment minimization --clearenv + explicit --setenv Strong — allowlist by construction, not inherited-then-trimmed
Filesystem confinement --ro-bind / --bind Strong, and a bonus the current contract does not even ask for
Process-tree termination --die-with-parent + PID namespace Partial — helps, but a wall-clock timeout still needs timeout --kill-after or equivalent around it
Output-size cap none Not covered — still needs a streaming byte-counter on stdout/stderr

So bwrap is the right primitive for the two hardest properties and does not solve the other two. The open questions I would want the issue to settle:

  1. Is bwrap present in the guest images? It needs user namespaces, which some hardened kernels restrict. If it is not guaranteed, the fallback must be refusal (snapshot), never an unenforced run — the ADR already requires that, so the issue should verify availability rather than assume it.
  2. Does it work under both backends? A microVM guest and a container guest may differ on userns availability.
  3. Who owns the wrapper? The ADR's position is that one audited implementation does this, not per-kit code. That argues for a single orchestrator-owned helper rather than each provider kit wrapping itself.
  4. Output cap is still bespoke work regardless of which sandbox primitive wins.

Worth noting the containment target is narrow, which makes this tractable: the normalizer is a pure file-to-file transform with no network and no credential, so there is no legitimate functionality to preserve when you take those away.

Links

Re-checked all five cross-repo references — every "pending" marker is still accurate (#504, #415, #435 all open; the #506/#512 prose already says closed/fixed-and-merged). Nothing stale to correct, and I will re-run the check immediately before merge as you asked.

Added your two new issues to References, with a note on two overlaps a reconciler will hit: #486's diagnostic.url and this ADR's modelsUrl are the same URL on the same artifact, and the credential env-var name is now recorded in three places (keyEnv, credential.keyEnv, gateway.apiKeyEnv). I will raise those on #486 directly so they live with the proposal.

Verified: make validate clean, 407 tests pass, markdownlint clean across 192 files, 7/7 CI checks green at 70802cc.

AI-assisted (OpenCode). Advisory — requires human review.

@mogul

mogul commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

The 🤖 is trying to bring this specific idea into scope and flesh it out here just because it was mentioned, and while I'm human-hind-brain-flattered that it finds good things in that direction, it really shouldn't be doing that here. Let's make sure we file a followup issue where all this context belongs! That issue shouldn't be about bubblewrap specifically, but rather about what we're after: An enforceable contract, where bubblewrap is just one idea for getting there.

Enforcement — could there be? Yes, and I have not filed it yet

It's great that we're now being explicit that there's not currently any enforcement mechanism, but... Could there be? For example bubblewrap? At the very least we should have an issue dedicated to figuring that out on the -quickstart side.

Agreed on both counts, and the issue does not exist — I searched the quickstart tracker and found nothing covering normalizer containment, bubblewrap, or per-subprocess isolation. I did not want to file it against your idea without checking the shape of the problem first, so here is what I measured; tell me if you want me to file it as-is.

bwrap plausibly covers three of the four properties and is already a known quantity in this workspace:

Property bwrap mechanism Confidence
Network denial --unshare-net Strong — kernel-enforced, established before exec, covers loopback and inherited sockets
Environment minimization --clearenv + explicit --setenv Strong — allowlist by construction, not inherited-then-trimmed
Filesystem confinement --ro-bind / --bind Strong, and a bonus the current contract does not even ask for
Process-tree termination --die-with-parent + PID namespace Partial — helps, but a wall-clock timeout still needs timeout --kill-after or equivalent around it
Output-size cap none Not covered — still needs a streaming byte-counter on stdout/stderr
So bwrap is the right primitive for the two hardest properties and does not solve the other two. The open questions I would want the issue to settle:

  1. Is bwrap present in the guest images? It needs user namespaces, which some hardened kernels restrict. If it is not guaranteed, the fallback must be refusal (snapshot), never an unenforced run — the ADR already requires that, so the issue should verify availability rather than assume it.
  2. Does it work under both backends? A microVM guest and a container guest may differ on userns availability.
  3. Who owns the wrapper? The ADR's position is that one audited implementation does this, not per-kit code. That argues for a single orchestrator-owned helper rather than each provider kit wrapping itself.
  4. Output cap is still bespoke work regardless of which sandbox primitive wins.

Worth noting the containment target is narrow, which makes this tractable: the normalizer is a pure file-to-file transform with no network and no credential, so there is no legitimate functionality to preserve when you take those away.

@mogul
mogul merged commit 293bd0e into main Oct 8, 2026
7 checks passed
@mogul
mogul deleted the docs/adr-0003-model-provider-discovery branch October 8, 2026 21:07
wz-gsa pushed a commit to GSA-TTS/agentic-coding-quickstart that referenced this pull request Oct 9, 2026
## Context

Adds **ADR-0035 (Host-Authoritative Sandbox Configuration)**. This is a
docs-only PR (the ADR itself); the acq-side implementation is tracked in
#503.

acq deliberately gives the in-sandbox agent **passwordless sudo** (the
sandbox is the security boundary, per ADR-0005/0011). A consequence not
previously stated as a principle: **inside the guest, root-ownership is
not a trust boundary against the agent** — a prompt-injected agent can
`sudo` to root and rewrite any guest file, including markers under the
root-owned `/var/lib/acq/`.

acq currently reads four content-bearing `/var/lib/acq` markers back and
acts on them to configure the sandbox (`agent`, `workspace`,
`ssh-auth-sock`, `kit-env`), and re-runs guest-resident kit startup code
across restarts. Both are guest-tamperable under sudo.

This surfaced concretely in review of
GSA-TTS/agentic-coding-patterns#423 (neutral model-provider discovery),
whose provider-facts channel had originally claimed a root-owned guest
path was "not agent-writable."

## What this ADR records

- **Principle:** acq must not rely on guest-generated or
guest-tamperable state to configure the sandbox. Trusted inputs (data
*and* code) are host-resident + mounted read-only, or host-held +
injected at exec time — never read back from the guest and treated as
authoritative.
- **Mechanism 1:** per-sandbox host config dir mounted read-only into
the guest.
- **Mechanism 2:** trusted startup execution invoked from the read-only
mount (three options considered; option 1 selected).
- **Migration:** stage the existing `/var/lib/acq` markers off
guest-writable paths (tracked in #503).
- Two Mermaid sequence diagrams (GitHub/Host/Guest boxes) contrasting
the rejected guest-write path with the chosen host-materialized
read-only path.

## Verification

- `markdownlint-cli2` (repo config, v0.22.1): 0 issues.
- Both Mermaid diagrams parse cleanly (mermaid v11).
- Referenced ADRs (0005, 0011, 0017, 0021, 0023, 0027, 0029) all exist.

## Rollback

Docs-only. Revert the single commit / delete
`docs/adr/0035-host-authoritative-sandbox-config.md`. No code paths
change.

## Security impact

Documentation of a security-hardening direction; no runtime change in
this PR. The implementing change (#503) narrows what acq trusts; it does
not alter the agent's in-sandbox privileges.

---
AI-assisted (OpenCode). See the commit's `Co-authored-by` trailer.
basiliskus added a commit that referenced this pull request Oct 9, 2026
## Summary

Makes `integrations/isolation/acq-kits/examples/` a container of
copy-and-rename kit templates: `validate-kits.py` checks each
`examples/<name>/` like a kit but keeps it out of the `kits.yaml`
registry.
ADR 0005 records why scope-layer kits ship as templates (hybrid/v1 has
no parameters) and where they live (placement B).
No template ships here; the first one, `team-kit`, and the scope-layers
pattern doc follow in a stacked PR.

**Reviewer ask: is placement (B), `acq-kits/examples/<name>/` exempt
from the registry, the right home?** The alternative (A) is
`acq-kits/<name>/` with a `kits.yaml` entry marked TEMPLATE and no
validator change; ADR 0005 lists both. Switching to (A) means dropping
this PR and moving the template up one level in the next.

## Eligibility & Accountability

- [x] I am a **federal employee or contractor** (PRs merge with
`@GSA-TTS/agentic-coding-team` code-owner approval; eligibility is
attested here, not authenticated). *Anyone may open issues without
this.*
- [x] **I understand this change and can explain it in my own words** —
I am the author of record and accountable for it.
- [x] By opening this PR, I **attest I have the right to release this
work** under the repository's [CC0-1.0](../LICENSE) dedication.

## AI Assistance Disclosure

- [x] AI tools were used in this contribution.
- [x] I verified all AI-generated content: no fabricated/hallucinated
APIs, dependencies, citations, or placeholder content; any AI-suggested
dependency was confirmed to exist.
- [x] Any inherited/reused third-party material retains its original
license and is identified (not represented as public domain).

## Type of Change

- [x] Infrastructure/tooling change
- [x] Documentation update

## Motivation

Every team adopting `acq` builds the same team scope layer. A template
CI keeps schema-valid is more useful than prose alone, but a template is
not a kit `acq` applies, so it must not sit in the registry of
deployable kits. This PR adds the home and the gate; the content lands
separately so the placement question can be answered on its own.

What lands:

- `acq-kits/validate-kits.py`: skips `examples/` in the kit walk;
validates each `examples/<name>/` with the same per-kit checks (schema,
`files[].source` resolution, env names, README) under an `examples/`
prefix; leaves templates out of the `kits.yaml` cross-check, so a
template listed there fails ("registry lists unknown kit"). The walk
skips only named tool droppings (`__pycache__`, `.pytest_cache`,
`.ruff_cache`, `.mypy_cache`); any other directory, dot-named or
`_`-prefixed, is validated, because acq can fetch any directory through
a `dir=` ref. Two tree-level checks: no two kits or templates may
declare the same `name:` (a copied directory that kept the original name
is the likeliest copy-and-rename slip, and the registry cross-check
could not see it), and a tree with no kits or templates fails instead of
passing with nothing validated.
- `scripts/tests/test_validate_kits_examples.py`: 22 tests on generated
fixture trees, including duplicate names (kit/kit, template/template,
kit/template), empty or missing trees, and dot-named kits and templates;
none depend on a real template.
- `integrations/isolation/docs/decisions/0005-kit-templates.md`:
templates vs a parameterized kit vs doc only, and placement A vs B.
Numbered 0005 because #416 and
#423 claim 0003 and 0004; happy to
renumber.
- `acq-kits/README.md`: a Templates section, separate from the Available
kits table.

## Testing

- [x] Ran `make validate` locally (all checks pass)
- [x] Ran `make test` (if applicable): 429 passed;
`scripts/tests/test_validate_kits_*.py`: 95 passed
- [x] Tested with at least one real scenario: the validator on this
repo's real tree with no template present (`All 10 acq-kits valid.`);
the stacked PR exercises it with one (`All 10 acq-kits + 1 template(s)
valid.`)
- [x] Examples are concrete and realistic
- [x] Commit messages follow [Conventional
Commits](https://www.conventionalcommits.org/) format

Also run: `validate-kits.py --strict`, `ruff check scripts/`, `ruff
format --check scripts/`, `make generate-check`.

## Safety Checklist

- [x] No secrets, API keys, tokens, or passwords included
- [x] No PII (names, emails, SSNs, addresses)
- [x] No CUI (Controlled Unclassified Information)
- [x] No internal URLs, hostnames, or system paths
- [x] No customer data or proprietary information
- [x] Pattern defines `prohibited_content` in output contract (if
applicable): not applicable, no SKILL.md pattern

## Quality Checklist

- [x] Plain language used (Grade 10 or below preferred)
- [x] Examples included
- [x] Prerequisites clearly stated

## Validation Results

```
make validate
✓ All validators passed
```

## Related Issues

Relates to #421 (placement option B)
Relates to #416 and
#423 (ADR numbering; `validate-kits.py`
and `acq-kits/README.md` overlap with #416)

## Reviewer Notes

**Reviewers should verify:**

- [ ] Placement (B) under `acq-kits/examples/` is the preferred option
- [ ] The validator keeps templates out of the registry and still
validates them
- [ ] `make validate` passes in CI
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants