Skip to content

fix(templates): contain local: template ids and stop deriving credential paths from name: (#1900) - #1935

Merged
AndriiPasternak31 merged 6 commits into
devfrom
feature/1900-local-template-path-traversal
Aug 2, 2026
Merged

fix(templates): contain local: template ids and stop deriving credential paths from name: (#1900)#1935
AndriiPasternak31 merged 6 commits into
devfrom
feature/1900-local-template-path-traversal

Conversation

@vybe

@vybe vybe commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #1900

Contains local: template-id resolution on the read path, and stops credential staging from deriving a filesystem path out of a template's untrusted name: field. Two independent sinks, two different triggers, two different blast radii — they are kept separate below on purpose.


Sink 1 — get_local_template (template_service.py:534, unvalidated join at :539)

Trigger: any authenticated principal — every role, including an agent-scoped MCP key that resolves to its owner — issuing a bare GET /api/templates/local:<id>. The route is @router.get("/{template_id:path}") behind Depends(get_current_user); the :path converter captures /, which is precisely why the traversal is reachable over HTTP rather than being a theoretical string bug.

Impact: read any parseable template.yaml the backend process can reach, disclosed in a 200 response — including other tenants' uploads under /data/deployed-templates. The response echoes display_name / description / resources / skills / capabilities / use_cases / data_paths / required_credentials, i.e. arbitrary YAML subtrees, not merely strings.

Demonstrated over real routing + real auth:

GET /api/templates/local:../outside  →  200  {"display_name": "SECRET OUTSIDE", ...}

Fix: the new public template_service.contained_template_dir(name, root) applies the same two-step barrier the create path has had since #950 — a name-shape allowlist (^[a-zA-Z0-9][a-zA-Z0-9_.-]*$, explicit .. reject) before any path math, then resolve() on both sides plus is_relative_to. A rejection returns None, so the router's 404 is byte-identical to an unknown template — no error code, no path, no root name. A distinguishable rejection would itself be a new enumeration oracle, which is exactly what #1759's single-sentence 404 exists to close.

Sink 2 — generate_credential_files (template_service.py:824, name:-derived join at :873)

Trigger: a creator-role caller performing a deploy_local_agent upload, then creating an agent from it. Strictly narrower than Sink 1 — this is not reachable by a bare authenticated GET.

Impact: another tenant's credential-bearing .mcp.json staged into the attacker's own agent. An uploaded name: ../../data/deployed-templates/<victim> was joined onto a hardcoded root and the resulting file read into the new agent's credential files.

Reproduced verbatim: a planted MARKER-CROSS-TENANT-SECRET appeared inside the .mcp.json staged for the attacker's agent.

Two compounding facts made this live rather than latent:

  1. _TemplateResolution.github_template_path has zero assignment sites (4 refs, all reads) — so the safe if template_base_path: arm was dead code, and the vulnerable else was the only branch that ever executed.
  2. name: is not a directory name. 5 shipped templates declare a display string there — sleep-echo, test-counter, test-delegator, test-echo, test-leak-hook (e.g. name: "Test Echo Agent") — so local:test-echo was looking for <curated>/Test Echo Agent/.mcp.json.

Fix: crud._stage_config_files now threads the directory it already validated through _safe_local_template_path, extracted as crud._resolve_local_template_dir. That makes the #1759 seam-agreement property structural across three seams (resolver, /template bind decision, credential stager) instead of two-by-convention. The residual template_base_path is None arm has no caller today and is kept fail-closed through the same barrier.

What was NOT vulnerable

Create-path id resolution was already contained by crud._safe_local_template_path (crud.py:145) since #950. The blanket claim "the create path is vulnerable" is false — it was specifically the credential-staging derivation inside it.


Behaviour delta — measured, not the flattering version

A deploy-local template that both declares credentials.mcp_servers and ships a .mcp.json now has that file staged at all, where the old curated-root lookup always missed. Two consequences, and the second is a loss:

  • the staged file wins over the archive's raw copy (startup.sh copies /generated-creds/.mcp.json unconditionally and after the template-copy block);
  • the sole production caller passes an empty agent_credentials map (CRED-002 — real values are injected after creation, not at staging), so ${VAR} is rewritten to "".

It is not true that such templates "now get ${VAR} substitution" or that substitution "finally applies". Placeholders are blanked, not substituted: {"TOKEN": "${MY_TOKEN}"} stages as {"TOKEN": ""}. Hardcoded (non-${VAR}) entries survive verbatim. The durable record of a server's required variables remains .mcp.json.template, which is pre-populated untouched.

The corrected description is the one in docs/memory/feature-flows/template-processing.md. (Commit 23581c19's message contains the earlier, incorrect "gets ${VAR} substitution" framing — that wording was the review stage's MEDIUM finding and is superseded by the docs and by this description.)

A second latent bug fixed en route

TypeError: unsupported operand type(s) for /: 'PosixPath' and 'int' at template_service.py:873 — a non-string name: raised an uncaught 500 on agent create. Absorbed by the isinstance(name, str) gate in contained_template_dir; covered by a test over 123 / None / ["a"] / {"a":1} / 1.5 / True, verified red pre-fix.


Evidence

  • All 30 REPRO tests verified falsifiable — run against reverted source: 19 failed / 11 passed, and every one of the 11 pre-fix-green tests is explicitly labelled HYGIENE / anti-over-block / no-regression / landmine-guard. No decorative tests. The landmine guard was re-proved: resolving only the candidate (not the root) fails exactly that one test.
  • Adversarial sweep: 55 hostile inputs → zero escapes, zero raises, zero disclosures. Symlink loops, a 200 KB name, and a regex-backtracking probe all handled in 0.6 ms with no superlinear blowup — which matters because this endpoint carries no rate limit. The 404 body is byte-identical to the unknown-template case.
  • Full tests/unit/6024 passed / 18 skipped / 0 failed (pristine 5993 +31). Re-measured at ship on this exact branch tip: 6024 passed, 18 skipped in 464.84s, with 4 test_1771* property files excluded because hypothesis (pinned in tests/requirements-test.txt) is absent from the local interpreter and PEP 668 blocks installing it there — all 4 are byte-identical to origin/dev and untouched by this branch.
  • Directly-affected files (test_1900_template_id_traversal, test_1759_template_root_parity, test_ent128a_catalog_resilience, test_local_templates_listing, test_1484_create_agent_characterization, test_1759_local_template_not_found) — 157 passed.
  • Related-module grep set — 372 passed / 1 skipped, stable across 5 random-order runs.
  • /verify-local --skip-agent PASS; integration 70 passed / 13 skipped / 2 deselected.
  • tests/test_templates.py now collects 6 (was 5), and its traversal assertion passed against a real branch-built backend when run manually. See the honesty note below — no automated job runs that file.

Test-runner honesty note

tests/test_templates.py is root-level and has no automated runner at all. Every automated stage collects a subdirectory — CI runs pytest unit/, /verify-local runs pytest unit/ then pytest integration/ — so neither collects tests/*.py. Its assertions must be run manually against a booted backend (cd tests && pytest test_templates.py). The CI-gated guard that actually protects this fix is tests/unit/test_1900_template_id_traversal.py. The final commit on this branch corrects a docstring that previously claimed otherwise.

Pre-existing red (not branch-caused)

test_1069_voip_call_path_param::test_flat_path_params_are_agent_name_not_name fails with an ImportError under fastapi ≥ 0.141.1 (get_flat_dependant removed; reached via the fastapi>=0.115.0 floor). It passes on a pinned fastapi 0.136.1 environment, so it is environment-dependent, not branch-dependent. This branch leaves both that test file and routers/voip.py byte-identical to origin/dev (verified with git diff --quiet).


CodeQL note

If a new py/path-injection alert fires on contained_template_dir, the correct response is dismissal citing the two-step barrier — regex allowlist before path math, then resolve() + is_relative_to(), returning the checked value. It is not to reshape the helper or the if/else. #1793 had to revert exactly such a reshape; for that reason the condition here was added without re-indenting the body.

Relationship to enterprise#14

enterprise#14 is an HTTP registry manifest resolving through the existing github:owner/repo clone path — not a second filesystem-path namespace. The narrow, shippable argument for landing this first: enterprise#14 edits this same resolver family in this same module and keeps local: live, so handing it an already-contained module with a public importable helper (contained_template_dir) is cheaper than landing beside an open traversal.


Follow-ups (listed, deliberately not filed)

Two are live pre-existing bugs found incidentally — neither is introduced here:

  1. A deploy-local template declaring credentials.env_file already has its user-supplied credentials wiped at first boot: staging writes KEY= blanks to /generated-creds/.env, and startup.sh:387-389 copies that over the merged archive .env.
  2. /generated-creds copies are not .trinity-initialized-gated, so they re-clobber .mcp.json / .env on every restart, discarding operator edits.

Plus:

  1. The severed github_template_path wire (zero writers). This PR stops depending on it; deleting it is a separate change.
  2. Extract the shared name allowlist to a dependency-free leaf module, once crud._safe_local_template_path — a CodeQL barrier sitting under the refactor(agents): decompose create_agent_internal (830 lines, CC 120) with characterization tests first #1484 fence — can be touched deliberately.
  3. Rate-limit GET /api/templates/{id}. This is the reason rejections log at DEBUG rather than WARNING: with no rate limit, a per-rejection warning is an authenticated log-flood primitive.
  4. get_local_templates() still enumerates via iterdir(), so a root-escaping symlink planted inside the root lists but 404s on detail — a deliberate, documented asymmetry (planting one needs local filesystem write access, not a request).
  5. tests/test_templates.py has no automated runner — move it under tests/integration/, or add a job that collects root-level files.

Merge-order note

Four files collide with sibling PRs (no rebase needed todayorigin/dev is unmoved at 8e924526, this branch's exact base): docs/memory/requirements/core-agent.md §4.1 (all three PRs), docs/memory/feature-flows.md, docs/memory/feature-flows/template-processing.md, tests/unit/test_local_templates_listing.py. First-merged wins.

🤖 Generated with Claude Code

Eugene Vyborov and others added 6 commits August 1, 2026 12:52
#1900)

`GET /api/templates/{template_id:path}` handed `local:<name>` straight to
`get_local_template`, which joined `<name>` onto the templates root with no
validation. The `:path` converter permits `/`, so `local:../<x>`,
`local:/<abs>/<x>` and a root-escaping symlink each read
`<escaped-dir>/template.yaml` and echoed its contents in an authenticated 200.

Reachable by any authenticated principal of any role — including an
agent-scoped MCP key, so a prompt-injected agent qualifies. In a container the
reachable set includes `/data/deployed-templates/<victim>`, where every user's
uploaded template archive lands: a cross-tenant read.

Two corrections to the issue's framing, both verified here:
  * it is NOT arbitrary file read — the filename is fixed (`template.yaml`), it
    must parse as a YAML mapping, and only a fixed key set is echoed. But those
    keys' VALUES are arbitrary YAML subtrees, not just strings.
  * `local:..` alone is not an existence oracle: a directory with no
    `template.yaml` returns the same 404 as an unknown id.

Fix: `contained_template_dir(name, root)` — the two-step barrier the CREATE
path has had since #950 (`crud._safe_local_template_path`), brought to the read
path. A name allowlist runs BEFORE any path math (this is also what CodeQL
recognises as a `py/path-injection` barrier; resolve-only was flagged
high-severity twice on this codebase), then `resolve()` on BOTH sides plus
`is_relative_to`. `str.startswith` is not equivalent — it passes the sibling
escape `<root>-evil`.

An escaping id returns `None`, so the router's 404 stays byte-identical to an
unknown template: no error code, no path, no root name. A distinct error would
be a NEW enumeration oracle, which is what #1759's single-sentence 404 exists
to close. Rejections log at DEBUG, sanitized — the endpoint has no rate limit,
so a per-rejection WARNING would be an authenticated log-flood primitive.

The helper is public: the remote-template-registry work (trinity-enterprise#14)
edits this same resolver family in this same module and should import it rather
than copy it.

Tests: every rejection test PLANTS a real `template.yaml` at the escaped
location, because unpatched code returns `None` for any id whose target simply
does not exist — a rejection test with nothing planted is green before and
after and proves nothing. Each is labelled REPRO (verified red pre-fix) or
HYGIENE (cannot be made red; contract only). The router guard lives in
`tests/unit/` because no gating CI job collects `tests/test_templates.py`.
`test_1900_containment_survives_a_symlinked_root` is the landmine guard for
resolving both sides: a half-resolved variant passes all 130 pre-existing tests
and only that test catches it.

Refs #1900

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…template name: (#1900)

The second traversal sink, found by this issue's own AC #4 audit ("audit the
by-name create path for the same join"). It is NOT the create path's `local:`
id resolution — that has been contained since #950/#1759 via
`crud._safe_local_template_path`, applied at both seams #1759 named. It is the
create path's CREDENTIAL STAGING, which threw that validated path away and
re-derived a directory from scratch:

    template_name = template_data.get("name", "")          # untrusted
    mcp_template_path = templates_dir / template_name / ".mcp.json"

`name:` comes from an uploaded template.yaml, so any `creator` reaches it via
`deploy_local_agent`. `name: ../../data/deployed-templates/<victim>` read
another tenant's `.mcp.json` — a credential-bearing file type under
Invariant #12 — into the attacker's OWN agent, where they read it at leisure.
A victim who hardcoded a token rather than a `${VAR}` placeholder leaks it.

Assessed on its own axes, NOT inherited from the read sink: different trigger
(`creator` role + an upload + an agent create, vs a bare authenticated GET) and
a higher impact ceiling (credential values, not template metadata). Also P2, for
different reasons.

The derivation was also simply wrong. `name:` is not a directory name — 5
shipped templates declare a display string there ("Test Echo Agent"), so
`local:test-echo` resolved to `<curated>/Test Echo Agent/.mcp.json`, which does
not exist. That kills the "validate the name" framing: the value should not
resolve paths at all.

Fix is root-cause, not another guard: `_stage_config_files` already calls
`_safe_local_template_path` itself for the `/template` bind decision, so the
validated directory is available in the same function. Extract the two-root
ladder as `_resolve_local_template_dir` and pass its result as
`template_base_path`. The untrusted join is gone from the live path, and the
#1759 "seams must agree" property becomes structural across all THREE seams
(resolver, bind decision, credential stager) instead of two.

Deliberately NOT threaded through `_TemplateResolution` or the return tuple:
`_resolve_local_template` returns a 2-tuple that three existing tests depend
on, two as monkeypatched `lambda config: ({}, None)` doubles — widening it
breaks the test doubles, not just the callers. Its signature, its return arity,
`_safe_local_template_path`, `_LOCAL_TEMPLATE_ROOTS`, and everything inside the
CodeQL-sensitive `if template_yaml.exists():` block are untouched (#1793 had to
revert exactly that reshape).

The residual `template_base_path is None` arm is kept and made fail-closed: it
is a public function with a `template_base_path=None` default, so a future
caller can still reach it. It now contains through the same barrier as the id,
which also absorbs a non-string `name:` — `Path(root) / 123` raised TypeError,
i.e. an uncaught HTTP 500 during agent creation (the ent#128 bug class, one
seam over).

One disclosed behaviour change: a deploy-local template that BOTH declares
`credentials.mcp_servers` AND ships a `.mcp.json` now gets `${VAR}`
substitution, where the old curated-root lookup always missed. Verified no
collision with `deploy._prepopulate_workspace_from_template`, which writes the
archive's raw copy into the workspace volume: `startup.sh` copies
`/generated-creds/.mcp.json` unconditionally (gated only on the directory
existing) and AFTER the template-copy block (gated on `.trinity-initialized`),
so the substituted file deterministically wins — which is the intended
behaviour, the raw copy still carrying unsubstituted placeholders. Not one of
the 26 shipped curated templates contains a `.mcp.json`, so the curated rows
are provably unchanged.

The crud seam tests are mandatory, not decorative: every service-level test
calls `generate_credential_files` directly, so an "extracted but never wired"
mistake leaves all of them green while deploy-local resolution silently
regresses into the fallback arm.

Refs #1900

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tial staging

Rule #1 (requirements before implementation) — `core-agent.md`:

* §4.1 gains the **read-path resolution contract** as a sibling to the existing
  create-time contract: `GET /api/templates/{id}` resolves `local:<name>`
  through the same two-step barrier the create path has had since #950, and a
  failing name returns a 404 byte-identical to an unknown template (the #1759
  non-disclosure rule). Records the deliberate, known asymmetry that
  `get_local_templates()` still enumerates by `iterdir()` and so could LIST a
  root-escaping symlink that detail and create both refuse — the listing is the
  outlier, and planting one needs local filesystem write access, not a request.

* §4.3 records that the `credentials.mcp_servers` template lookup now resolves
  from the validated path rather than the template's own untrusted `name:`
  field, including the one disclosed behaviour change (deploy-local templates
  now get `${VAR}` substitution) and why the substituted file wins over the
  archive's raw copy.

`architecture.md` gets one clause on the `templates.py` router catalog entry
(the catalog rule caps entries at 2 lines, and no Cross-Cutting Subsystems
block is warranted). A bug fix would normally be commit-message-only under the
tiered-docs rule; the exception is that this ships a public, importable
containment primitive in the exact module and resolver family the remote
template registry (trinity-enterprise#14) will edit, and one catalog clause is
the cheapest way that author finds it instead of copying the flaw.

Not updated, deliberately: no feature-flow doc (no new vertical slice), no user
docs (no user-visible change for honest callers), no schema/migration (no DB
change, so the dual-track SQLite/Alembic rule does not apply).

Refs #1900

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#1900

`/sync-feature-flows` was NOT a no-op here — three concrete staleness points in
`template-processing.md`, which owns the `local:` resolution surface:

* The inlined two-root ladder is now `_resolve_local_template_dir`; the code
  block showed the pre-extraction form.
* "**Two** seams read `_LOCAL_TEMPLATE_ROOTS` and must stay in agreement" was
  the #1759 claim and is now wrong in the direction that matters: there was
  always a third seam (the credential-file stager) which did NOT agree — it
  re-derived the directory from the template's untrusted `name:`. Corrected to
  three, with the extraction as the structural guarantee.
* `generate_credential_files` was cited by stale line range (`:228-299`) and
  documented none of where the `.mcp.json` template is actually located.
  Replaced the fragile line-range citation with a symbol reference and added
  the provenance, the residual fail-closed arm, and the disclosed deploy-local
  substitution delta.

Also documents the read-path containment (`get_local_template` →
`contained_template_dir`) beside the existing #1513 catalog-curation note,
including the deliberate list-vs-detail asymmetry for a planted symlink.

`local-agent-deploy.md` gets one line at the credential-merge step: a
deploy-local template's `.mcp.json` now resolves from the deploy-local
directory, so a hostile `name:` cannot read another tenant's file and
`${VAR}` substitution finally applies to that template.

`credential-injection.md` was checked and NOT touched — its `.mcp.json`
references are unrelated (credential inject/export/import), and the template
lookup lives in template-processing.

One dated row added to the feature-flows index (no new flow document — this is
a fix to documented behaviour, not a new vertical slice).

Tests: adds the end-to-end `test_get_template_rejects_path_traversal` beside
the existing 404 test. Stated plainly in the test's own docstring that this
file is root-level and collected by NO gating CI job — it runs under
`/verify-local` only, so it must not be read as CI coverage. The CI-gated guard
is `tests/unit/test_1900_template_id_traversal.py`. The multi-level case is
percent-encoded because httpx collapses a literal `../../` client-side.

Refs #1900

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y (review)

/review + /cso on the #1900 branch. One MEDIUM finding, verified by execution
rather than by reading, plus two comment-accuracy defects.

MEDIUM — the disclosed behaviour delta was described as a gain and is a loss.

Three docs claimed a deploy-local template "now genuinely receives `${VAR}`
substitution". It does not. `generate_credential_files` has exactly one
production caller (`crud._stage_config_files`, verified by grep across src/),
and it passes `agent_credentials={}` — CRED-002 injects real values AFTER
creation, not at staging. So `agent_credentials.get(var_name, "")` rewrites
every placeholder to the empty string. Measured against the shipped code:

    archive .mcp.json : {"env": {"TOKEN": "${MY_TOKEN}", "FIXED": "literal"},
                         "args": ["-y", "${MY_TOKEN}"]}
    staged .mcp.json  : {"env": {"TOKEN": "",           "FIXED": "literal"},
                         "args": ["-y", ""]}

and the staged file WINS over the archive's raw copy, for exactly the
`startup.sh` ordering reason the branch already documents. Net effect for a
deploy-local template shipping a `.mcp.json`: its placeholders are destroyed,
and nothing is substituted in. Non-placeholder content survives verbatim.

This is not a reason to revert the root-cause threading — the `.env` arm of the
same function has always blanked an un-supplied `credentials.env_file`
variable, so blank-at-staging is the platform's model, and `.mcp.json.template`
(compatibility check S-009) remains the durable record of required variables,
pre-populated untouched. It IS a reason to stop advertising it as an
improvement: prose stronger than the code is itself a defect (learnings
2026-07-28), and this one would have shipped into the requirements file.

Corrected in requirements §4.3, template-processing.md and local-agent-deploy.md,
and pinned by a new test so the flattering restatement cannot come back:
`test_1900_staging_with_an_empty_credential_map_blanks_placeholders` asserts the
measured output (`""`, `["-y", ""]`, `${MY_TOKEN}` absent) and that hardcoded
entries are preserved.

LOW — `_LOCAL_TEMPLATE_ROOTS`' own comment still said "Read at TWO seams",
which is the pre-#1900 claim and stale in the direction that matters: this PR
exists because the third seam did not agree. Corrected to three, naming the
extraction as what makes the agreement structural.

LOW — "crud -> template_service is forbidden" (helper docstring + parity-test
docstring) contradicts crud.py:32, which imports this module today. The ban is
on what may be GATED on it (the #1484 MagicMock harness), not on the import
edge; a reader who checks the citation and finds it false is one step from
"helpfully" importing the regex. Narrowed to say so.

Verification performed for this review, beyond the above:
* every REPRO test re-run against reverted sources — 19 of 30 red pre-fix; the
  11 that are green pre-fix are all correctly labelled HYGIENE /
  anti-over-block / landmine-guard, so no test is decorative;
* `test_1900_containment_survives_a_symlinked_root` re-proved as the sole guard
  for resolving both sides (a candidate-only-resolve variant fails it and
  nothing else);
* independent attack harness, 55 hostile inputs through `contained_template_dir`
  and `get_local_template`: zero escapes, zero raises, zero disclosures, and
  every legitimate name still accepted. The only non-None hostile input is the
  documented `"sage\n"` `$`-parity edge, which is CONTAINED;
* symlink loops, a 200 KB name and a regex-backtracking probe: no exception, no
  superlinear time (the endpoint is unauthenticated-adjacent and rate-limitless);
* `startup.sh` re-verified independently: `/generated-creds/.mcp.json` is copied
  at :376-383, gated only on the directory existing, AFTER the
  `.trinity-initialized`-gated template blocks that end at :367.

Refs #1900

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…unner

The docstring claimed the #1900 traversal assertions "run under
/verify-local". They do not. Every automated stage collects a
subdirectory -- CI runs `pytest unit/`, /verify-local runs `pytest unit/`
then `pytest integration/` -- and this file is root-level, so NEITHER
collects it. The assertions have no automated runner at all.

Corrected to say so, with the manual invocation that does exercise them
(needs a booted backend), a pointer to the CI-gated guard that actually
protects the fix, and a note that giving this file a runner is a
follow-up. Docstring only -- no assertion changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/backend/services/agent_service/crud.py Dismissed
Comment thread src/backend/services/template_service.py Dismissed
Comment thread src/backend/services/template_service.py Dismissed
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

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

Approving. Ran /review, /validate-pr and /cso --diff against 032d8672. 0 critical, 0 security findings, 2 vulnerabilities closed. Everything below was verified by execution, not by reading the description — and every number I re-measured came out equal to or better than what the PR claims.


The barrier holds

Two independent adversarial passes:

  • 59 hostile inputs (traversal, percent-encoded, absolute, UNC, null-byte, backslash, sibling-escape <root>-evil, escaping symlink, symlink loop, symlink-to-root, 200 KB ReDoS probe, 6 non-string types) → 0 escapes, 0 raises, 7.34 ms.
  • A fresh-context agent instructed to refute the barrier ran ~700,000 inputs including an exhaustive sweep of the entire Basic Multilingual Plane at 1 and 2 characters, on both 3.12 and prod 3.13 → UPHELD, 0 escapes, 0 exceptions.

It killed the unicode-homoglyph hypothesis outright: [a-zA-Z0-9] is a literal codepoint range, not a unicode class, and there is no re.IGNORECASE, so accepted codepoints above 0x7F = zero. U+FF0F, U+FF0E, U+2024, , İ, 𝟏 and full-width forms are all rejected at step 1. (And macOS applies NFD, never NFKC, so a compatibility decomposition into / can't happen on any platform Trinity runs on.)

It also confirmed the containment step earns its keep — for a planted <root>/sibling → ../templates-evil:

str(resolved).startswith(str(root)) = True    <- a naive check WOULD have escaped
resolved.is_relative_to(root)       = False   <- what :491 actually uses

The docstring's justification at :463-466 is empirically correct, not just plausible. Step ordering verified independently: every rejection also fires against a nonexistent root, so the allowlist provably precedes the first filesystem call.

The 404 stays byte-identical — tested wider than the PR does

test_1900_router_404_leaks_no_path covers one shape. I swept 16 shapes across every distinct rejection path — barrier-reject (.., %2E%2E%2F, ..%2f, ....//, absolute, leading dot, separator, backslash, empty, leading -), contained-but-is_dir()-false, unknown, near-miss, and unprefixed — comparing status, full body, content-length and content-type:

All 15 local: shapes plus the unprefixed one are byte-identical to unknown-template: 404, {"detail":"Template not found"}, content-length: 31.

The only divergence in the sweep was github:no/such-repo (200), which is pre-existing get_github_template behaviour on a different prefix and a different code path — untouched here.

Structural reasons, traced rather than assumed: one shared raise-site at routers/templates.py:48; rejection returns None so no error code/path/root name reaches the caller; a malformed escaped file can't 500 into a distinguishable response (_build_local_template returns None from the ent#128 broad except); and the DEBUG log can't become an oracle either — _sanitize_for_warning strips non-printables and truncates at 80 chars, which I verified.

The tests are real

Reverted both source files to base and re-ran the guards:

19 failed, 71 passed
FAILED test_1900_router_rejects_traversal_id
  AssertionError: ('/api/templates/local:../outside', 200,
   '{"id":"local:outside","display_name":"SECRET OUTSIDE",...}')

19 failed matches the claimed 19 exactly, and the disclosure reproduces verbatim. Source restored; worktree byte-identical to head.

Full test evidence
Run Result
Directly-affected suites @ head 157 passed (matches the PR)
Full local tests/unit/ @ head 6079 passed, 14 skipped, 1 xfailed, 0 failed (333s) — higher than the stated 6024 because all four test_1771* hypothesis property files ran on this interpreter
Security guard tests 47 passed — credential-paths parity + model-context parity (Invariant #5), #186 enumeration uniformity, #1159 agent-auth header guard, #1759 root parity
Falsifiability (reverted source) 19 failed / 71 passed
CI 20 SUCCESS / 3 SKIPPED / 0 failures
Claims I checked independently — all held
  • _TemplateResolution.github_template_path has 4 references, all reads, zero assignments → the safe arm genuinely was dead code and the vulnerable else was the only executing branch.
  • _resolve_template (crud.py:2133) runs strictly before _stage_config_files (:2164), and config.template is never reassigned anywhere in the codebase → the "cannot newly raise" comment holds.
  • crud.py:2164 is the sole production caller; the github: branch never sets template_data, so the template_base_path is None arm is genuinely dead.
  • Every remaining /-join in template_service.py (:504, :1202, :1203, :1211) takes an already-contained directory; deploy.py's write path derives from a server-generated version_name behind its own #950 guard.
  • The else-arm root change (/agent-configs/templates-or-CWD-relative → _local_templates_dir()) is identical in-container and strictly more predictable outside one.
  • CodeQL dismissals will survive the merge: prior dismissed py/path-injection alerts 240/242/243/244/245/247 carry most_recent_instance.ref = refs/heads/main.

CodeQL: 269 and 270 hold exactly; 271's conclusion holds but its rationale covers half the taint

269 and 270 are line-accurate — 270 is literally the barrier's own join with is_relative_to on the next line.

271 is worth a comment edit. The flagged line's mcp_template_path has two producers:

954:  if template_base_path:
955:      mcp_template_path = Path(template_base_path) / ".mcp.json"   # <- producer A
956:  else:
981:      mcp_template_path = fallback_root / ".mcp.json"              # <- producer B

The dismissal discusses only B. A is safe today only because github_template_path has zero writers — and this PR's own call-site comment says "github_template_path first so the field's eventual revival wins", with follow-up #3 contemplating that revival. A dismissal is permanent and repo-global: it silences the line, including for taint arriving by a path the reviewer never examined. Suggest extending the comment to name both producers. Not a defect in the shipped code.

Notes (none blocking)

  1. Docstring invariants are false under planted symlinks. <root>/selfdot → . returns root itself (is_relative_to is reflexive), contradicting "resolve name as a direct child of root"; <root>/alias → scout returns <root>/scout, so the id round-trips as local:scout, not local:alias, contradicting "the basename … is unchanged". Reproduced twice, independently. Neither escapes — both stay inside the root and both need local filesystem write against a :ro mount, i.e. the same class already disclosed honestly as follow-up #6. Doc-accuracy nit only.
  2. contained_template_dir's "never raises" is Python-version-dependent. On ≤3.12 Path.resolve() raises RuntimeError: Symlink loop via check_eloop; 3.13 removed that path. Unreachable in prod for three independent reasons (prod is python:3.13-slim; the templates root is :ro in both compose files; grep -rn "os.symlink\|symlink_to" src/backend/ returns nothing). Flagging because the unit suite runs 3.11 while the image is 3.13 — pre-existing and repo-wide, not this PR's problem.
  3. The "CodeQL recognises this as a barrier" wording (template_service.py:461-463 + the mirror in template-processing.md) was falsified by this PR's own scan — 270 fired on the barrier's own join line. Outcome is right; the wording will make the next author doubt a correct guard. Suggest "the shape a py/path-injection alert can be dismissed against".
  4. The missing ## Security Considerations / ## Revision History entries are a pre-existing lapse, not a regression here. I checked before raising it: that Security Considerations list has no entry for #950, #1759 or ent#128 either, and #1759 appears 8× in the body and 0× in the Revision History. This PR is consistent with its predecessors. Backfilling all four is a docs-hygiene follow-up, not a condition on a P1 security fix.
  5. docs/memory/feature-flows.md:28 — the 2026-08-01 row sits below two 2026-07-31 rows in an otherwise newest-first table. Cosmetic, and that file is a collision file anyway.

Merge-time actions (mechanical, at the button)

  • Hand-write the squash message. GitHub concatenates every commit body, so 23581c19's superseded "gets ${VAR} substitution" framing would land verbatim in dev's permanent history. Use the corrected version: placeholders are blanked to "", not substituted.
  • Ignore the nightly bot's "merge conflict" comment — it is a CI bug, not a conflict. Root-caused and reproduced: backend-unit-nightly.yml fetches both dev (:88) and the PR head (:109) at --depth=1, then merges two histories with no common ancestor → fatal: refusing to merge unrelated historiesmerge_conflict=true. All 9 open PRs carry the comment, 8 of them MERGEABLE. Filed separately. mergeable=MERGEABLE, baseRefOid == origin/dev tip, 20 checks ran and passed — a genuinely conflicting PR produces zero checks.
  • When rebasing #1934/#1936, re-assert this fix's guards survived. Two of the four collision files carry security surface: tests/unit/test_local_templates_listing.py (the read-path test_1900_* guards) and template-processing.md (the #1900 containment section). A conflict resolved --theirs would delete them with green CI, because the tests would no longer exist to fail.
  • Optional: extend CodeQL 271's dismissal comment per the note above.

Why this one reads as trustworthy

The behaviour-delta section is the strongest signal in the PR. It names a loss (${VAR} blanked to "", staged file overwriting the archive copy), explicitly forbids the flattering restatement in three separate docs, and pins the measured behaviour with a test. That is the opposite of the usual failure mode, and it earns the rest of the description credibility — which the independent verification then bore out.

The higher-severity sink is closed at the source (crud threads the already-validated directory) rather than patched at the sink, the residual arm is kept fail-closed for a future caller, and the wiring test specifically defends against the one failure mode — extracted-but-not-wired — that every other test in the suite would miss.

🤖 Review assisted by Claude Code

@AndriiPasternak31
AndriiPasternak31 merged commit 609f8df into dev Aug 2, 2026
23 checks passed
AndriiPasternak31 added a commit that referenced this pull request Aug 2, 2026
…ted path

`_build_local_template` derived `is_bundled` itself:

    is_bundled = template_dir.resolve().parent == _local_templates_dir().resolve()

`template_dir` on the by-id path is `_local_templates_dir() / name` where `name`
comes from a user-supplied `local:<name>` template id, so this called `.resolve()`
on attacker-influenced input. CodeQL flagged it as `py/path-injection` (alert 260,
high) — a new tainted-path sink introduced by ent#128 purely to pick a log level
(`source_trust` selects `logger.warning` vs `logger.info` and nothing else).

`is_bundled` is now a required keyword arg supplied by whoever knows the
provenance:

  - `get_local_templates()` iterates the curated root, so its children are
    bundled by construction -> `is_bundled=True`.
  - `get_local_template()` decides from the id STRING (plain single segment, no
    separator, not a dot-segment) rather than a path operation on it.

Behaviour, measured against the old predicate across 9 ids: 7 identical, 2
divergent — `'../agent-templates/sage'` and `'a\b'` go True -> False. Both moves
are old=True -> new=False, i.e. strictly more conservative: the new check never
grants the `bundled` label where the old one withheld it, only the reverse. An id
that traverses to arrive inside the curated root is not curated, so the new
answer is also the more correct one; the blast radius either way is one log
level.

This is deliberately NOT a traversal guard — and as of the 2026-08-02 rebase it
no longer needs to be. An earlier version of this message said the traversal was
"being routed as its own issue rather than fixed"; that issue, #1900, has since
been fixed on `dev` by #1935, which this branch is now rebased onto.
`get_local_template` therefore routes `name` through `contained_template_dir()`
— a name allowlist plus resolve + `is_relative_to` — BEFORE the label check runs.
(The traversal was real while it lasted: `local:..` escaped the templates dir,
reachable by any authenticated user via `GET /api/templates/{id:path}`.)

That makes the `is_plain_segment` check redundant today — provably True wherever
it is reached, since the barrier above rejects every non-plain name first. It is
kept as defence in depth: it decides a trust LABEL, and `contained_template_dir`
is a shared primitive the remote-template-registry work (trinity-enterprise#14)
is expected to edit. A label that silently became `bundled` if that barrier were
ever widened is the exact failure this keyword argument exists to prevent. It
re-adds no tainted-path sink — it reads the id string, never the filesystem.

Two test call sites updated for the new signature.

Verified on the rebased branch: full tests/unit 6410 passed / 16 skipped /
0 failed; no `template_dir.resolve()` remains in the module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Aug 3, 2026
…tup:` + two HARD-gate fixes (ent#128 PR-B) (#1899)

* fix(agents): stop a malformed `credentials:` also costing runtime + shared_folders

`_resolve_local_template` read `creds.get("mcp_servers", {}).keys()` straight
through the block. A null / list / string `credentials:` raises AttributeError
there, and that read sits FIRST in a run of `config` mutations wrapped in one
broad `except Exception` — so the failure skipped every mutation after it. A
single malformed key therefore silently cost the agent its `runtime:` (wrong
harness) and its `shared_folders:` config too, with only a WARNING to show for
it.

Reads through PR-A's tolerant `credential_mcp_server_names()` instead, so the
credential parse degrades on its own and the unrelated settings survive.

The five malformed shapes are pinned as parametrized regressions; all five fail
on the pre-fix code.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(agent-server): tolerant `credentials:` read on GET /api/template/info

Same uncaught reach-through PR-A fixed on the backend, still live on the agent
image: `.get("credentials", {}).get("mcp_servers", {}).keys()` raises
AttributeError on a null / list / string block at EITHER level, and the
endpoint's own `try/except` wraps only the YAML load — so the crash escaped as a
500 on the Info tab and the brain-orb route guard. `template.yaml` here is read
from the agent's own workspace, which the agent itself can rewrite, so this is
reachable without an operator touching anything.

The agent server ships in its own image and structurally cannot import
`src/backend`, so the reader is DUPLICATED, not imported. The two in-repo
precedents for that (`credential_paths.py`, `model_context.py`) are vendored
byte-identically WITH a parity test; a 6-line reader does not earn a whole
vendored module, but it does earn the same guard — before this commit NO parity
test covered `agent_server/routers/info.py`, so the copies could diverge freely.
Added in the `test_1713_scheduler_utils_parity.py` shape: one shared 17-row table
of malformed shapes driven through BOTH implementations, asserting agreement on
OUTPUT (the copies are textually divergent by design, so a source diff cannot
verify them).

Also routes the endpoint through the existing `get_template_path()` helper —
`/api/metrics` already does — instead of a second copy of the path literal, so
the regression is testable without patching `Path`.

This is the change that makes `/verify-local` mandatory WITHOUT `--skip-agent`.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(compatibility): a credential detector must not read narrower than it audits

K-001 (HARD) compared `.mcp.json.template`'s `${VAR}` references against an
UPPERCASE-ONLY view of `.env.example`. Trinity's substitution engines impose no
charset at all — the agent-side writer is a `str.replace` and the `.env` writer
slices `env_val[2:-1]` — so `${my_var}` IS substituted at runtime, and a template
that correctly documents `my_var=` was HARD-failed for a gap that does not exist.

`services/credential_charset.py` is the one place that decision now lives, named
for its ROLE (`CREDENTIAL_DETECTOR_CHARSET` — "the widest charset a detector must
accept so it is never narrower than the engine it audits"), not for a reach it
does not have. Four detectors adopt it; the docstring carries an explicit
NON-MEMBERS list with a reason per entry, because the previous framing ("the
charset every Trinity surface agrees on") is false and reads as an instruction to
the next engineer who greps `[A-Z][A-Z0-9_]*`:

  * `mcp_validator._ENV_VAR_REF_RE` is a FAIL-CLOSED gate (`.mcp.json` inject →
    400, `.credentials.enc` import, deploy-local), deliberately paired with the
    WIDEST finder (`[^}]*`). Widening it admits input that is currently rejected.
  * `skill_packaging.ENV_KEY_RE` is an adjacent domain with its own length cap.
  * `static_checks._ASSIGN_RE` carries the quantifier shape behind an
    already-FIXED py/polynomial-redos alert, on an agent-supplied-text path.
  * `c_d006` is a different vocabulary that merely looks similar.

The constant lives in a pure-stdlib leaf module, NOT in `services/compatibility/`:
that package's `__init__` imports `database`, and `static_checks` imports
`template_service`, so a `template_service` → compatibility edge is a hard cycle
(reproduced: "cannot import name '_is_platform_injected' from partially
initialized module").

Behaviour changes, both named:
  * K-001 (HARD) `fail → pass` for a documented lowercase variable — the fix.
  * K-003 (SOFT) `pass → fail` for a lowercase-only, comment-free `.env.example`.
    `_env_example_vars` is K-003's precondition for DEMANDING comments, so growing
    it makes the verdict worse. The verdict is correct — that file genuinely has
    no comments — but it is a `pass → fail` and is release-noted, not smuggled.
  * S-010 (SOFT) does NOT flip: its `generic` blocklist is uppercase-exact, so no
    newly-visible lowercase name can join it. Asserted, not assumed — it is safe
    by coincidence of casing.

The two `template_service` extractors are included because both feed the LIVE
`collect_mcp_credential_warnings` → `deploy.py` path; leaving them out would
half-fix the very inconsistency this closes while a four-way agreement test
passed. Direction there is FEWER spurious warnings. `test_deploy_local_validation.py`
(the 8-assertion suite on that path) stays green.

Also hardens two latent crashes in the same file: a null / non-mapping
`template.yaml` document reaching `extract_credentials_from_template_yaml`, and
`extract_agent_credentials` reaching through `credentials:` at three levels. New
`credential_mcp_env_vars()` reader returns non-empty strings only, so an
`env_vars` element smuggled in as a mapping can never reach a consumer — that
element is exactly what turns a set comprehension into
`TypeError: unhashable type: 'dict'`.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(compatibility): K-002 compared ${VAR}s against section names, and could go dark

Two defects in the same HARD gate, one of them a way for the gate to stop
protecting entirely.

**1. It read the structure, not the declaration.** `listed` was
`set(creds.keys())` — `{"mcp_servers", "env_file"}` — so the documented structured
form `credentials.mcp_servers.stripe.env_vars: [STRIPE_API_KEY]` satisfied nothing
and HARD-failed a correctly declared template, while `${env_file}` and
`${mcp_servers}` PASSED. The admitted set was "whichever section names this
template happens to use", so the blind spot was template-dependent — the worst
kind, because it cannot be found by reading the check.

`declared_credential_names()` (the union of `mcp_servers.*.env_vars` and
`env_file`, over PR-A's tolerant readers) is now unioned in, and the three known
STRUCTURE keys are subtracted. A flat `credentials: {STRIPE_API_KEY: '...'}`
mapping is still admitted — that legacy shape is legitimate and keeps passing.

The section subtraction is a deliberate `pass → fail` for a genuinely broken
template. Shipped named, tested and release-noted, NOT smuggled under a
monotonicity claim: the blanket "strictly monotone, fail→pass only" claim is false
and a reviewer would find the counter-examples.

**2. It could go dark.** `run_static` caught `Exception` → `skipped`, and `_counts`
counted only `status == "fail"`, so a raise inside a HARD check DROPPED
`hard_count` and could flip `overall_status` from `issues` to `compatible` on an
agent with a genuinely undeclared credential. `c_k002` delegates to `c_t015`, so
ONE raise took both HARD gates dark together, and the result is indistinguishable
from a clean pass in the counts. The trigger is four lines of untrusted YAML:

    credentials:
      mcp_servers:
        s:
          env_vars:
            - {STRIPE_SECRET_KEY: "please"}

`template.yaml` here is read from a live agent workspace, whose git repo the agent
itself owns — a self-attestation bypass on the surface whose job is to police it.
The same `TypeError: unhashable type: 'dict'` is the failure mode that argued
against enriching `credentials.env_file` in the first place, so reintroducing it
at the new call site would have been the plan diagnosing a bug and then shipping
it.

Three layers, deliberately:
  * `c_t015` wraps ONLY the new term and degrades to the narrower set — which
    makes `missing` LARGER, i.e. errs toward failing — never to `skipped`.
  * `run_static` returns FAIL for a check that raises. A check that could not
    evaluate is not a check that passed; one bad check still never breaks the
    report.
  * `_counts` also counts `skipped` + `skip_reason == "check_error"` as a finding,
    at the sink (#1525), so the property survives a future path reintroducing the
    skip. A benign precondition skip (`no_template`, `ai_not_run`) still counts as
    nothing — that distinction is why the skip path exists.

`declared_credential_names` guarantees `str` elements structurally, and the call
site filters `isinstance(name, str)` anyway: the gate must not depend on the
reader's contract holding.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(templates): MCP-server precedence, and a credentials badge that counts

Three catalog defects PR-A deferred, all in the two builders.

**Defect D — precedence was backwards.** `_build_local_template` read
`credential_mcp_server_names(credentials_block) or data.get("mcp_servers", [])`, so
a `credentials:` block silently OUTRANKED the template's own `mcp_servers:`
declaration. `agent_server/routers/info.py` has always read them in the other
order, so the catalog and the agent's own Info tab disagreed for any template
declaring both. Operands flipped; the `credentials:` path stays as the fallback.

**W14 — the GitHub builder had no fallback at all**, so a GitHub template declaring
only `credentials.mcp_servers` showed an empty list in the catalog while its Info
tab listed them. That was the third of three surfaces; all three now agree.

**Defect C / W6 — the badge.** Both builders read a flat top-level
`required_credentials:` key that ZERO templates declare — 25 bundled and all 7
configured GitHub repos — so `Templates.vue` rendered 0 for everything. Now derived
from the declared base set, with `platform_injected` vars EXCLUDED.

That exclusion is the badge's semantic, and it is not cosmetic: measured on the
real shipped catalog, a naive derivation is correct on 1 of 7 repos and wrong in
both directions — the ent#124 first-run agent would read 5 where the operator
supplies 2, while three shipped repos stay at 0. The chip is read as "how much work
is this to set up", so counting `GEMINI_API_KEY` / `GITHUB_PAT` / `TRINITY_*`
inflates it with rows nobody can fill. A consumer that wants every declared
variable wants `declared_credential_names`, not this.

Derived unconditionally rather than "explicit key wins, else derive": that override
branch is unreachable (no template declares the key), so keeping it would be one
dead code path guarding a live one.

No frontend change: `Templates.vue:103,107,171,175` read only `.length`, so the
shape it already expects is preserved and a variable name never reaches the DOM.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(templates): `credential_setup:` — per-variable credential setup metadata

Closes ent#128 AC #1-2. A template can now describe each credential an operator
must supply — title, description, required, secret, format, setup_url, default —
and `template_service` surfaces the normalized result as `credential_requirements`
on every catalog entry.

**Enrichment lives in a NEW sibling top-level key; `credentials:` is FROZEN as
names-only, forever.** An already-deployed older Trinity reads `env_file` through
`credential_env_file_names` and then does `agent_credentials.get(var_name, "")` —
hand it a list of mappings and that is `TypeError: unhashable type: 'dict'` at the
moment it writes the agent's `.env`. A sibling key is structurally invisible to
that binary, so there is no floor version and enrichment distributes immediately.

**Base-set-plus-overlay, so the two keys cannot drift.** One record per variable
`credentials:` declares, decorated by `credential_setup:` entries joined BY NAME.
An entry naming nothing is a named three-line error (problem, cause, FIX) and is
dropped; valid siblings survive. `credential_setup:` can only ever decorate — the
sibling-key shape's usual failure mode is closed by construction, not by
discipline. Stated honestly: for an EXTERNAL template that error is neither
impossible nor visible in the UI — `credential_errors` has zero frontend and zero
MCP consumers, so the only human channel is the backend log. It is LOGGED.

`required` is a tri-state. Enriched-and-omitted means `True` (an author who
described a variable meant it); a legacy bare `- FOO` is `"unknown"`, never `True`
— it carries no authorial intent, and reading it as required makes a guided
checklist cry wolf. `"unknown"` doubles as the enriched/un-enriched discriminator,
which is why no `enriched: false` flag is needed. `secret` defaults `True`
(fail-safe). Path-free by construction, so trinity#570's `template.yaml` →
`trinity.yaml` rename cannot reach it.

**The normalizer never raises, and that is load-bearing.** `_build_template` runs
in bare list comprehensions in `get_all_templates()`, OUTSIDE PR-A's per-template
fence (which covers `_build_local_template` only) — a raise there is HTTP 500 with
an EMPTY CATALOG, i.e. PR-A's exact bug reopened by the change that surfaces the
new metadata. And no bomb is needed: `title: 123` or a bare `title:` was enough.
So the builders ALSO wrap the call and degrade to `[]` plus a named error, rather
than fencing the comprehension — that keeps the named error the resilience
contract promises. The property does not rest on one function's discipline.
(Which earned its keep immediately: the wrapper caught a real NameError during
development instead of emptying the catalog.)

Trust boundary — `title`/`description`/`setup_url`/`name`/`source` are
author-controlled strings from arbitrary GitHub repos flowing into an
operator-facing "paste your API key" checklist:

  * **Type-guard before touching.** Never `str()` a container from untrusted YAML:
    `str()` EXPANDS a shared alias during the walk (443 B → 52 MB in 1.5 s, x10 per
    level), and both the sanitizer and the record cap act after that cost is paid.
  * **Cap the INPUT**, entries AND errors AND the base set. Capping records while
    leaving `errors` uncapped built a 35 MB response out of the cap meant to
    prevent it; and `default` had no type row, so the 100-record cap acted as a
    x100 multiplier on it.
  * **`source` is sanitized** — it carries the raw MCP server name, the exact
    string `_sanitize_for_warning`'s own docstring names as the threat, and it was
    not on the list.
  * **Per-field length caps.** Reusing the 80-char terminal-warning default
    truncated a realistic 159-char description and made a real 90-char vendor
    console URL unusable.
  * **`setup_url` above scheme-only**: https (case-insensitive — `HTTPS://` is a
    legitimate author), a parseable host, NO userinfo (`https://google.com@evil.tld`
    renders as one host and resolves to another — the display/resolve split IS the
    attack), ≤2048, printable. Validate THEN sanitize, and never through a
    truncator. Residual documented, not claimed closed: `isprintable()` rejects
    RTL/ANSI but an IDN homograph survives, so a consumer must render the parsed
    hostname beside the link.
  * **Never mutates its input** — `_metadata_cache` holds the parsed dict for 600 s
    and YAML aliases genuinely share nodes, so one in-place normalize would rewrite
    both aliased fields and persist for ten minutes. Asserted against a deep-copy
    snapshot, including a real `&anchor`/`*alias` document.

`credential_shape_errors` also gains the per-server and per-ELEMENT rows for
`mcp_servers`, mirroring what `env_file` already had. The element row is the one
that matters — an `env_vars` entry smuggled in as a mapping was the single most
dangerous shape in the block and was unnamed. Note this makes the write path
(`generate_credential_files` → 400) reject a template that previously created an
agent with a garbage declaration: correct per PR-A's fail-loud write contract, and
release-noted.

`generate_credential_files` is deliberately UNTOUCHED — it still reads `env_file`
names-only, which is what makes the forward-compatibility argument true rather
than asserted.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(schemas): trinity-agent-credentials.schema.json — the declaration contract

Closes ent#128 AC #3's machine-readable half. Follows the established
`docs/schemas/` convention (`agent-pipeline.schema.json`): Draft 2020-12,
date-stamped `$id` so a future revision keeps answering for templates written
against this one, and self-described as the authoritative documentation contract
while the backend reader stays deliberately tolerant.

**Rooted at `template.yaml`, not at `credentials:`.** The two keys are ONE contract
joined by a mandatory cross-reference, and validating either alone cannot check it.

**`additionalProperties: true` at the root and on `credentials`** — template.yaml
carries many keys this schema deliberately says nothing about, and a template
predating the schema must stay VALID. Accepted asymmetry, and it is asserted as a
test rather than left as a surprise: a made-up top-level key IS valid here.

**`config_files` is enumerated and `deprecated: true`, not omitted.** The earlier
posture was "don't delete, don't advertise", which made the authoritative contract
answer VALID to `path: "/etc/cron.d/pwn"`. Undocumented is not a control against an
author who knows the key — only against the reviewer who doesn't. So it is
documented as deprecated, with a containment `pattern` that rejects absolute and
`..` paths and a description saying plainly that it writes files into the agent's
credential directory. Still reversible, still invalidates nobody. (Whether to
DELETE the key is a public behaviour change and stays @vybe's call.)

Carries the A2 consumer requirements in `$comment`, because the schema is the
artifact a downstream implementer reads:
  * a record with `required: "unknown"` carries no authorial intent and MUST NOT be
    presented as a required field — without this a naive UI renders a seeded agent
    as five mandatory rows, three of them platform variables nobody can fill;
  * `platform_injected: true` MUST NOT be asked of an operator;
  * `secret: true` (the default) MUST be masked;
  * `setup_url` MUST be rendered with its parsed hostname shown, because the IDN
    homograph residual is real and documented rather than claimed closed;
  * there is intentionally NO reverse cross-reference requirement — a declared
    variable with no `credential_setup:` entry is normal.

Also states the author cost honestly in the authoring note: declaring in
`credentials:` is a separate edit from referencing `${VAR}` in
`.mcp.json.template`, K-002 checks the two agree, and that is deliberate because
`.mcp.json.template` must not become a second declaration authority. Plus the two
brace forms Trinity's readers cannot see (`${my-key}`, `${VAR:-default}`).

Tests pin the schema against the implementation — field caps, the format
vocabulary, the allowed-key set, the record cap — so the reviewed text and the
enforced text cannot drift. The 13 document cases run under `importorskip`
(`jsonschema` is not a declared Trinity dependency); the security-relevant pattern
assertions are unconditional.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(templates): the Trinity-installable credential contract + reference examples

Closes ent#128 AC #3-4.

**Reference examples (AC #4).** The substrate the original plan targeted is gone —
`3317247e` deleted `config/agent-templates/cornelius/` in favour of seeding from
the public upstream repo — so AC #4 lands on what the bundle actually has:

  * `scout` / `sage` / `scribe` (the ent#124 seeded trio) declare an explicit
    `credentials: {}` with the zero-credential contract written out. Absent and
    empty mean the same thing to Trinity, but *absent* is ambiguous to a HUMAN —
    it could equally mean the author forgot. `{}` says "considered, and there are
    none", so the catalog's 0-credential badge is trustworthy.
  * `test-codex` carries the enriched reference: its one real variable gets a
    title, description, `required`, `secret`, `format` and `setup_url`.

Deliberately NO `GEMINI_API_KEY` in any example: it is platform-injected, so an
example asking for it would violate the very rule the guide documents — and it
makes a K-002 fixture pass VACUOUSLY, which is how a test proves nothing while
looking green. A test asserts no bundled example asks for a platform-injected var.

Framed honestly rather than oversold: with one enriched declaration and one
names-only one in the bundle, the parity test ("every bundled template normalizes
with zero errors") is thin today. Its value is as a RATCHET for ent#137's curated
fleet.

**The guide (AC #3).** New `## Declaring Credentials` section, TOC renumbered
5→21. Covers the field table, the decorate-don't-declare rule with the actual
error text, why `credentials:` stays names-only, the zero-credential contract,
degrade-don't-demand, the platform-injected list, fork-to-own composition
(ent#109), and the two brace forms Trinity's readers cannot see (`${my-key}`
silently dropped, `${VAR:-default}` mis-substituted to an empty string).

It also states the AUTHOR COST plainly instead of claiming the design is free:
declaring a variable is a separate edit from referencing it in
`.mcp.json.template`, and three of Trinity's own six default GitHub templates
declare zero credentials while referencing 2-6 and documenting 7-12. Those are
K-002-red today and stay red until someone does the edit. Kept that way on purpose
— if `.mcp.json.template` counted as a declaration it would become a second
authority on what an agent needs, which is the drift this design exists to
prevent. The practical order is stated: seed `credentials:` first, enrich second.

**Memory docs.** `requirements/credentials.md` §3.5's ✅ was false in both halves
and is corrected in place with the correction recorded: the extractor it credited
has no production caller, and nothing showed configured-vs-missing status because
the badge read a key no template defines. `template-processing.md` and
`templates-page.md` get the "two shapes, two owners" table that reconciles the
objects-vs-strings contradiction (catalog `required_credentials` = names,
`credential_requirements` = objects, extractor `required_credentials` = a
different function with the same key name), plus the corrected regex. Compatibility
checklist gains six credential rows and a starts-with-nothing-configured row.

**No DB change → Rule #9 (dual-track migration) does not apply.**

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(templates): close the ent#128 coverage gaps the gate surfaced

A transition diff over a corpus with zero coverage of the diff is not evidence, so
the changed statements were measured against PR-B's real base (`c07afab7` =
origin/dev + PR-A) rather than assumed. The gate found the new paths that no test
reached and this closes them:

  * §4's new `mcp_servers` shape-error rows — per-server AND per-element, six
    parametrized cases plus the sanitized-server-name case. The element row is the
    dangerous one and it had no test.
  * The write-path consequence, asserted explicitly: `generate_credential_files`
    now raises on `env_vars: [{K: v}]`, where before it created the agent silently.
  * `_setup_url_error`'s `urlsplit` ValueError branch (malformed IPv6 literal).
  * The dedup early-return in the base-record builder — a variable declared under
    two servers AND `env_file` yields one record with a stable `source`.
  * A non-string mapping key in a descriptor (`{1: "x"}`), which must not reach the
    "did you mean" helper.
  * The caller-less `extract_agent_credentials` across eight malformed shapes. It
    has no production caller, which makes hardening cheap rather than unnecessary —
    the next caller would have inherited the crashes. Now exercised instead of
    merely present.

Result: 227 changed statements, 225 executed. The two remaining are a defensive
`except OSError` around a `Path.resolve()`, and the three gate files
(`static_checks.py`, `compatibility/__init__.py`, `credential_charset.py`) are at
100% of changed statements.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(feature-flows): sync the compatibility flow + index for ent#128

`/sync-feature-flows`. `template-processing.md` and `templates-page.md` were already
updated with the declaration standard; this adds the flow the code change actually
lands hardest on and which nothing had touched: `agent-compatibility-validation.md`.

Both credential HARD gates changed, and the flow doc described neither the defect
nor the new semantics:

  * "a detector must never read narrower than the mechanism it audits" — the shared
    root cause of K-001 and K-002/T-015, with the NON-MEMBERS list spelled out so
    the next reader does not "align all the regexes" and widen
    `mcp_validator._ENV_VAR_REF_RE`, which is a fail-closed GATE and not a detector;
  * "a HARD gate must not be able to go dark" — the `run_static` →`skipped` +
    `_counts`-counts-only-`fail` interaction that let 4 lines of untrusted YAML drop
    `hard_count` 1→0, and the three fail-closed layers that replace it;
  * the complete verdict-transition set, because the blanket "strictly monotone"
    claim is false and a reader will find K-003's `pass→fail`. The claim that
    survives is "no agent gains a HARD failure".

Testing section records why the bundled templates cannot prove any of this — 0
`.mcp.json.template` and 0 `.env.example` files, so every changed check
short-circuits before reaching changed code and a green diff there is
green-because-vacuous — and points at the 49-fixture synthetic corpus instead.

Plus the Recent Updates row in `feature-flows.md` (the step this skill's own docs
warn gets skipped).

Observation, deliberately NOT fixed here: the Recent Updates table carries 57 rows
against its own documented "newest ~20" cap (#1360), so the index is 434 lines vs
the 400-line guideline. Trimming it means deleting 37 other engineers' entries,
which is not this PR's call.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(templates): use an unambiguous placeholder credential value

`sk-live-xxx` is stripe-shaped and gitleaks' default ruleset covers `sk-`. The value
is arbitrary in this test — it only has to round-trip byte-identically through the
`.env` writer — so there is no reason to hand CI a secret-shaped string to reason
about.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(agents): teach the crud harnesses the tolerant credentials accessor

Real regression I introduced in `886aab5b` and initially mis-attributed as
pre-existing. Recording both the fix and how the mis-attribution happened, because
the second part is the reusable lesson.

**The bug.** `test_1484_create_agent_characterization.py` and
`test_1759_local_template_not_found.py` MagicMock the whole
`services.template_service` module and stub each function crud actually calls with a
faithful return value (`generate_credential_files` → `{}`, `get_github_template` →
`None`). `_resolve_local_template` now calls a THIRD one —
`credential_mcp_server_names` — and it was unstubbed, so it returned a truthy Mock
that passed `if mcp_servers:`, landed in `config.mcp_servers`, and blew up later
inside a `yaml.dump` as `ValueError: dictionary update sequence element #0 has
length 1; 2 is required`. 18 tests, entirely a harness gap: in production the real
function returns a list.

Stubbed with a faithful 3-line mirror rather than a fixed `[]`, so a fixture that
DOES declare `credentials:` cannot be silently masked by the stub.

**One test needed a real update, not a stub.**
`test_malformed_field_still_creates_and_names_the_template` used
`credentials: "a string"` as its trigger for the broad-except degrade path. That is
exactly what `886aab5b` fixes — `credentials:` is no longer a trigger BY DESIGN,
because it raised FIRST in that run of mutations and so cost the agent its
`runtime:` and `shared_folders:` config as collateral. Swapped the trigger to
`shared_folders: not-a-mapping`, which still raises, so the degrade path and the
two identifiers in its warning stay under test. The docstring records why and points
at the new coverage.

**How I mis-attributed it.** I compared with `git stash push -- src/backend`, which
reverts only the WORKING TREE — commits 1 and 2 were already committed, so my
"baseline" still contained the cause and the failures looked identical on both
sides. The `-k`-filtered selection also happened to include only 1 of the 13
`test_1484` failures, which made the set look small and stable. Only a worktree at
`c07afab7` (PR-A's tip, PR-B absent) showed the truth: 2 failures there vs 20 on the
branch. **A baseline has to be a worktree at the base commit, not a stash.**

Now identical to `origin/dev` and to `c07afab7`: 2 failures, both genuinely
pre-existing (`test_agent_analytics::test_day_stacks_present_in_by_type`,
`test_1069_voip_call_path_param` — the documented `get_flat_dependant` venv drift).

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(templates): the tolerant credential reader must not blow up or raise

Two holes in the "never raises, never amplifies" property PR-B rests on, both
found by asking which OTHER producers reach the surface the new cap protects.

1. `credential_shape_errors` was uncapped. The cap shipped on the NEW function
   (`normalize_credential_requirements`), but the same PR added a per-ELEMENT
   loop to this PRE-EXISTING one, and it feeds the same two surfaces: the
   catalog's `credential_errors`, and the `"; ".join(errors)` that becomes
   `CredentialDeclarationError`'s agent-creation 400 body. A cap is a property
   of the producer, not of the PR that invented the concept.

   YAML anchors make input size a useless proxy for output size, so the bound
   has to stop the WALK, not slice the result. Measured on a 6,738-byte
   `template.yaml` (one 200-element anchor aliased across 200 servers):
   40,000 errors / 3.64 MB joined (540x) before, 101 errors / 8,973 bytes after.
   `origin/dev` returns 0 on the same input, so the amplification is this
   branch's own — reachable since ent#123 by any creator-role user pointing at
   an arbitrary public repo.

2. `source_trust not in _SOURCE_TRUST_LEVELS` is frozenset membership, so an
   UNHASHABLE value raised `TypeError` *on the guard line* — before the
   degrade-to-`github` branch that guard exists to reach. Unreachable from
   parsed YAML today (every call site passes a literal), but this is the one
   function whose docstring makes "NEVER RAISES" load-bearing: a raise here is
   an empty catalog and a dark HARD gate. The property should be literally
   true, not true-by-call-site-audit.

Both regression tests were confirmed to FAIL with their fix reverted and pass
with it restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* build(tests): cap fastapi to prod's 0.115.x line

The unit suite was validating against a FastAPI ~25 minor versions ahead of
the one production ships. `docker/backend/Dockerfile` pins `fastapi==0.115.6`
exactly; `tests/requirements-test.txt` carried an unbounded floor that resolved
0.140.13. The comment at :43 already claimed these "match the floors set in
docker/backend/Dockerfile" — that file uses exact pins, so the claim was untrue.

Surfaced as `test_1069_voip_call_path_param` failing with `ImportError: cannot
import name 'get_flat_dependant'`. That test is only the messenger: it is the
one test coupled to a private FastAPI symbol (`src/backend` imports none, and
the other test touching `fastapi.routing` uses the public `APIRoute`).

The obvious ceiling does not work: `0.140.13 < 0.141` is true, so `<0.141`
still admits the breaking version. Bisected against the real wheels — present
in 0.140.6, gone in 0.140.7 — a private API dropped in a PATCH release, so no
minor-level bound is trustworthy. Tracking prod's line is the durable fix.

Why now rather than "separate follow-up": CI is green only on a warm pip cache.
backend-unit-test.yml keys `cache-dependency-path` on this file, and 0.140.13
allows py3.11, so the next edit to this file for ANY reason busts the key,
re-resolves, and breaks CI for everyone. Capping is the safe way to bust that
cache — the change that invalidates the key is the one that makes re-resolution
correct.

Follows this file's own precedent (`bcrypt>=4.2.0,<5`, added when bcrypt 5.0.0
removed the `__about__` shim passlib reads): floor + ceiling + a comment saying
why, rather than an exact pin that would break the file's `>=` convention.

Verified by execution, not argument:
  - full tests/unit at 0.115.14: 5861 passed, 16 skipped, 2 xfailed, 0 failed
    (at 0.140.13 the same command is 1 failed, 5860 passed)
  - the edited file installs clean in a fresh venv and resolves 0.115.14
  - an existing verify venv self-heals: pip downgrades 0.140.13 -> 0.115.14

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(templates): take the trust label from the caller, not a tainted path

`_build_local_template` derived `is_bundled` itself:

    is_bundled = template_dir.resolve().parent == _local_templates_dir().resolve()

`template_dir` on the by-id path is `_local_templates_dir() / name` where `name`
comes from a user-supplied `local:<name>` template id, so this called `.resolve()`
on attacker-influenced input. CodeQL flagged it as `py/path-injection` (alert 260,
high) — a new tainted-path sink introduced by ent#128 purely to pick a log level
(`source_trust` selects `logger.warning` vs `logger.info` and nothing else).

`is_bundled` is now a required keyword arg supplied by whoever knows the
provenance:

  - `get_local_templates()` iterates the curated root, so its children are
    bundled by construction -> `is_bundled=True`.
  - `get_local_template()` decides from the id STRING (plain single segment, no
    separator, not a dot-segment) rather than a path operation on it.

Behaviour, measured against the old predicate across 9 ids: 7 identical, 2
divergent — `'../agent-templates/sage'` and `'a\b'` go True -> False. Both moves
are old=True -> new=False, i.e. strictly more conservative: the new check never
grants the `bundled` label where the old one withheld it, only the reverse. An id
that traverses to arrive inside the curated root is not curated, so the new
answer is also the more correct one; the blast radius either way is one log
level.

This is deliberately NOT a traversal guard — and as of the 2026-08-02 rebase it
no longer needs to be. An earlier version of this message said the traversal was
"being routed as its own issue rather than fixed"; that issue, #1900, has since
been fixed on `dev` by #1935, which this branch is now rebased onto.
`get_local_template` therefore routes `name` through `contained_template_dir()`
— a name allowlist plus resolve + `is_relative_to` — BEFORE the label check runs.
(The traversal was real while it lasted: `local:..` escaped the templates dir,
reachable by any authenticated user via `GET /api/templates/{id:path}`.)

That makes the `is_plain_segment` check redundant today — provably True wherever
it is reached, since the barrier above rejects every non-plain name first. It is
kept as defence in depth: it decides a trust LABEL, and `contained_template_dir`
is a shared primitive the remote-template-registry work (trinity-enterprise#14)
is expected to edit. A label that silently became `bundled` if that barrier were
ever widened is the exact failure this keyword argument exists to prevent. It
re-adds no tainted-path sink — it reads the id string, never the filesystem.

Two test call sites updated for the new signature.

Verified on the rebased branch: full tests/unit 6410 passed / 16 skipped /
0 failed; no `template_dir.resolve()` remains in the module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Aug 3, 2026
…ty-enterprise#127) (#1948)

* fix(agents): stop a malformed `credentials:` also costing runtime + shared_folders

`_resolve_local_template` read `creds.get("mcp_servers", {}).keys()` straight
through the block. A null / list / string `credentials:` raises AttributeError
there, and that read sits FIRST in a run of `config` mutations wrapped in one
broad `except Exception` — so the failure skipped every mutation after it. A
single malformed key therefore silently cost the agent its `runtime:` (wrong
harness) and its `shared_folders:` config too, with only a WARNING to show for
it.

Reads through PR-A's tolerant `credential_mcp_server_names()` instead, so the
credential parse degrades on its own and the unrelated settings survive.

The five malformed shapes are pinned as parametrized regressions; all five fail
on the pre-fix code.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(agent-server): tolerant `credentials:` read on GET /api/template/info

Same uncaught reach-through PR-A fixed on the backend, still live on the agent
image: `.get("credentials", {}).get("mcp_servers", {}).keys()` raises
AttributeError on a null / list / string block at EITHER level, and the
endpoint's own `try/except` wraps only the YAML load — so the crash escaped as a
500 on the Info tab and the brain-orb route guard. `template.yaml` here is read
from the agent's own workspace, which the agent itself can rewrite, so this is
reachable without an operator touching anything.

The agent server ships in its own image and structurally cannot import
`src/backend`, so the reader is DUPLICATED, not imported. The two in-repo
precedents for that (`credential_paths.py`, `model_context.py`) are vendored
byte-identically WITH a parity test; a 6-line reader does not earn a whole
vendored module, but it does earn the same guard — before this commit NO parity
test covered `agent_server/routers/info.py`, so the copies could diverge freely.
Added in the `test_1713_scheduler_utils_parity.py` shape: one shared 17-row table
of malformed shapes driven through BOTH implementations, asserting agreement on
OUTPUT (the copies are textually divergent by design, so a source diff cannot
verify them).

Also routes the endpoint through the existing `get_template_path()` helper —
`/api/metrics` already does — instead of a second copy of the path literal, so
the regression is testable without patching `Path`.

This is the change that makes `/verify-local` mandatory WITHOUT `--skip-agent`.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(compatibility): a credential detector must not read narrower than it audits

K-001 (HARD) compared `.mcp.json.template`'s `${VAR}` references against an
UPPERCASE-ONLY view of `.env.example`. Trinity's substitution engines impose no
charset at all — the agent-side writer is a `str.replace` and the `.env` writer
slices `env_val[2:-1]` — so `${my_var}` IS substituted at runtime, and a template
that correctly documents `my_var=` was HARD-failed for a gap that does not exist.

`services/credential_charset.py` is the one place that decision now lives, named
for its ROLE (`CREDENTIAL_DETECTOR_CHARSET` — "the widest charset a detector must
accept so it is never narrower than the engine it audits"), not for a reach it
does not have. Four detectors adopt it; the docstring carries an explicit
NON-MEMBERS list with a reason per entry, because the previous framing ("the
charset every Trinity surface agrees on") is false and reads as an instruction to
the next engineer who greps `[A-Z][A-Z0-9_]*`:

  * `mcp_validator._ENV_VAR_REF_RE` is a FAIL-CLOSED gate (`.mcp.json` inject →
    400, `.credentials.enc` import, deploy-local), deliberately paired with the
    WIDEST finder (`[^}]*`). Widening it admits input that is currently rejected.
  * `skill_packaging.ENV_KEY_RE` is an adjacent domain with its own length cap.
  * `static_checks._ASSIGN_RE` carries the quantifier shape behind an
    already-FIXED py/polynomial-redos alert, on an agent-supplied-text path.
  * `c_d006` is a different vocabulary that merely looks similar.

The constant lives in a pure-stdlib leaf module, NOT in `services/compatibility/`:
that package's `__init__` imports `database`, and `static_checks` imports
`template_service`, so a `template_service` → compatibility edge is a hard cycle
(reproduced: "cannot import name '_is_platform_injected' from partially
initialized module").

Behaviour changes, both named:
  * K-001 (HARD) `fail → pass` for a documented lowercase variable — the fix.
  * K-003 (SOFT) `pass → fail` for a lowercase-only, comment-free `.env.example`.
    `_env_example_vars` is K-003's precondition for DEMANDING comments, so growing
    it makes the verdict worse. The verdict is correct — that file genuinely has
    no comments — but it is a `pass → fail` and is release-noted, not smuggled.
  * S-010 (SOFT) does NOT flip: its `generic` blocklist is uppercase-exact, so no
    newly-visible lowercase name can join it. Asserted, not assumed — it is safe
    by coincidence of casing.

The two `template_service` extractors are included because both feed the LIVE
`collect_mcp_credential_warnings` → `deploy.py` path; leaving them out would
half-fix the very inconsistency this closes while a four-way agreement test
passed. Direction there is FEWER spurious warnings. `test_deploy_local_validation.py`
(the 8-assertion suite on that path) stays green.

Also hardens two latent crashes in the same file: a null / non-mapping
`template.yaml` document reaching `extract_credentials_from_template_yaml`, and
`extract_agent_credentials` reaching through `credentials:` at three levels. New
`credential_mcp_env_vars()` reader returns non-empty strings only, so an
`env_vars` element smuggled in as a mapping can never reach a consumer — that
element is exactly what turns a set comprehension into
`TypeError: unhashable type: 'dict'`.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(compatibility): K-002 compared ${VAR}s against section names, and could go dark

Two defects in the same HARD gate, one of them a way for the gate to stop
protecting entirely.

**1. It read the structure, not the declaration.** `listed` was
`set(creds.keys())` — `{"mcp_servers", "env_file"}` — so the documented structured
form `credentials.mcp_servers.stripe.env_vars: [STRIPE_API_KEY]` satisfied nothing
and HARD-failed a correctly declared template, while `${env_file}` and
`${mcp_servers}` PASSED. The admitted set was "whichever section names this
template happens to use", so the blind spot was template-dependent — the worst
kind, because it cannot be found by reading the check.

`declared_credential_names()` (the union of `mcp_servers.*.env_vars` and
`env_file`, over PR-A's tolerant readers) is now unioned in, and the three known
STRUCTURE keys are subtracted. A flat `credentials: {STRIPE_API_KEY: '...'}`
mapping is still admitted — that legacy shape is legitimate and keeps passing.

The section subtraction is a deliberate `pass → fail` for a genuinely broken
template. Shipped named, tested and release-noted, NOT smuggled under a
monotonicity claim: the blanket "strictly monotone, fail→pass only" claim is false
and a reviewer would find the counter-examples.

**2. It could go dark.** `run_static` caught `Exception` → `skipped`, and `_counts`
counted only `status == "fail"`, so a raise inside a HARD check DROPPED
`hard_count` and could flip `overall_status` from `issues` to `compatible` on an
agent with a genuinely undeclared credential. `c_k002` delegates to `c_t015`, so
ONE raise took both HARD gates dark together, and the result is indistinguishable
from a clean pass in the counts. The trigger is four lines of untrusted YAML:

    credentials:
      mcp_servers:
        s:
          env_vars:
            - {STRIPE_SECRET_KEY: "please"}

`template.yaml` here is read from a live agent workspace, whose git repo the agent
itself owns — a self-attestation bypass on the surface whose job is to police it.
The same `TypeError: unhashable type: 'dict'` is the failure mode that argued
against enriching `credentials.env_file` in the first place, so reintroducing it
at the new call site would have been the plan diagnosing a bug and then shipping
it.

Three layers, deliberately:
  * `c_t015` wraps ONLY the new term and degrades to the narrower set — which
    makes `missing` LARGER, i.e. errs toward failing — never to `skipped`.
  * `run_static` returns FAIL for a check that raises. A check that could not
    evaluate is not a check that passed; one bad check still never breaks the
    report.
  * `_counts` also counts `skipped` + `skip_reason == "check_error"` as a finding,
    at the sink (#1525), so the property survives a future path reintroducing the
    skip. A benign precondition skip (`no_template`, `ai_not_run`) still counts as
    nothing — that distinction is why the skip path exists.

`declared_credential_names` guarantees `str` elements structurally, and the call
site filters `isinstance(name, str)` anyway: the gate must not depend on the
reader's contract holding.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(templates): MCP-server precedence, and a credentials badge that counts

Three catalog defects PR-A deferred, all in the two builders.

**Defect D — precedence was backwards.** `_build_local_template` read
`credential_mcp_server_names(credentials_block) or data.get("mcp_servers", [])`, so
a `credentials:` block silently OUTRANKED the template's own `mcp_servers:`
declaration. `agent_server/routers/info.py` has always read them in the other
order, so the catalog and the agent's own Info tab disagreed for any template
declaring both. Operands flipped; the `credentials:` path stays as the fallback.

**W14 — the GitHub builder had no fallback at all**, so a GitHub template declaring
only `credentials.mcp_servers` showed an empty list in the catalog while its Info
tab listed them. That was the third of three surfaces; all three now agree.

**Defect C / W6 — the badge.** Both builders read a flat top-level
`required_credentials:` key that ZERO templates declare — 25 bundled and all 7
configured GitHub repos — so `Templates.vue` rendered 0 for everything. Now derived
from the declared base set, with `platform_injected` vars EXCLUDED.

That exclusion is the badge's semantic, and it is not cosmetic: measured on the
real shipped catalog, a naive derivation is correct on 1 of 7 repos and wrong in
both directions — the ent#124 first-run agent would read 5 where the operator
supplies 2, while three shipped repos stay at 0. The chip is read as "how much work
is this to set up", so counting `GEMINI_API_KEY` / `GITHUB_PAT` / `TRINITY_*`
inflates it with rows nobody can fill. A consumer that wants every declared
variable wants `declared_credential_names`, not this.

Derived unconditionally rather than "explicit key wins, else derive": that override
branch is unreachable (no template declares the key), so keeping it would be one
dead code path guarding a live one.

No frontend change: `Templates.vue:103,107,171,175` read only `.length`, so the
shape it already expects is preserved and a variable name never reaches the DOM.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(templates): `credential_setup:` — per-variable credential setup metadata

Closes ent#128 AC #1-2. A template can now describe each credential an operator
must supply — title, description, required, secret, format, setup_url, default —
and `template_service` surfaces the normalized result as `credential_requirements`
on every catalog entry.

**Enrichment lives in a NEW sibling top-level key; `credentials:` is FROZEN as
names-only, forever.** An already-deployed older Trinity reads `env_file` through
`credential_env_file_names` and then does `agent_credentials.get(var_name, "")` —
hand it a list of mappings and that is `TypeError: unhashable type: 'dict'` at the
moment it writes the agent's `.env`. A sibling key is structurally invisible to
that binary, so there is no floor version and enrichment distributes immediately.

**Base-set-plus-overlay, so the two keys cannot drift.** One record per variable
`credentials:` declares, decorated by `credential_setup:` entries joined BY NAME.
An entry naming nothing is a named three-line error (problem, cause, FIX) and is
dropped; valid siblings survive. `credential_setup:` can only ever decorate — the
sibling-key shape's usual failure mode is closed by construction, not by
discipline. Stated honestly: for an EXTERNAL template that error is neither
impossible nor visible in the UI — `credential_errors` has zero frontend and zero
MCP consumers, so the only human channel is the backend log. It is LOGGED.

`required` is a tri-state. Enriched-and-omitted means `True` (an author who
described a variable meant it); a legacy bare `- FOO` is `"unknown"`, never `True`
— it carries no authorial intent, and reading it as required makes a guided
checklist cry wolf. `"unknown"` doubles as the enriched/un-enriched discriminator,
which is why no `enriched: false` flag is needed. `secret` defaults `True`
(fail-safe). Path-free by construction, so trinity#570's `template.yaml` →
`trinity.yaml` rename cannot reach it.

**The normalizer never raises, and that is load-bearing.** `_build_template` runs
in bare list comprehensions in `get_all_templates()`, OUTSIDE PR-A's per-template
fence (which covers `_build_local_template` only) — a raise there is HTTP 500 with
an EMPTY CATALOG, i.e. PR-A's exact bug reopened by the change that surfaces the
new metadata. And no bomb is needed: `title: 123` or a bare `title:` was enough.
So the builders ALSO wrap the call and degrade to `[]` plus a named error, rather
than fencing the comprehension — that keeps the named error the resilience
contract promises. The property does not rest on one function's discipline.
(Which earned its keep immediately: the wrapper caught a real NameError during
development instead of emptying the catalog.)

Trust boundary — `title`/`description`/`setup_url`/`name`/`source` are
author-controlled strings from arbitrary GitHub repos flowing into an
operator-facing "paste your API key" checklist:

  * **Type-guard before touching.** Never `str()` a container from untrusted YAML:
    `str()` EXPANDS a shared alias during the walk (443 B → 52 MB in 1.5 s, x10 per
    level), and both the sanitizer and the record cap act after that cost is paid.
  * **Cap the INPUT**, entries AND errors AND the base set. Capping records while
    leaving `errors` uncapped built a 35 MB response out of the cap meant to
    prevent it; and `default` had no type row, so the 100-record cap acted as a
    x100 multiplier on it.
  * **`source` is sanitized** — it carries the raw MCP server name, the exact
    string `_sanitize_for_warning`'s own docstring names as the threat, and it was
    not on the list.
  * **Per-field length caps.** Reusing the 80-char terminal-warning default
    truncated a realistic 159-char description and made a real 90-char vendor
    console URL unusable.
  * **`setup_url` above scheme-only**: https (case-insensitive — `HTTPS://` is a
    legitimate author), a parseable host, NO userinfo (`https://google.com@evil.tld`
    renders as one host and resolves to another — the display/resolve split IS the
    attack), ≤2048, printable. Validate THEN sanitize, and never through a
    truncator. Residual documented, not claimed closed: `isprintable()` rejects
    RTL/ANSI but an IDN homograph survives, so a consumer must render the parsed
    hostname beside the link.
  * **Never mutates its input** — `_metadata_cache` holds the parsed dict for 600 s
    and YAML aliases genuinely share nodes, so one in-place normalize would rewrite
    both aliased fields and persist for ten minutes. Asserted against a deep-copy
    snapshot, including a real `&anchor`/`*alias` document.

`credential_shape_errors` also gains the per-server and per-ELEMENT rows for
`mcp_servers`, mirroring what `env_file` already had. The element row is the one
that matters — an `env_vars` entry smuggled in as a mapping was the single most
dangerous shape in the block and was unnamed. Note this makes the write path
(`generate_credential_files` → 400) reject a template that previously created an
agent with a garbage declaration: correct per PR-A's fail-loud write contract, and
release-noted.

`generate_credential_files` is deliberately UNTOUCHED — it still reads `env_file`
names-only, which is what makes the forward-compatibility argument true rather
than asserted.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(schemas): trinity-agent-credentials.schema.json — the declaration contract

Closes ent#128 AC #3's machine-readable half. Follows the established
`docs/schemas/` convention (`agent-pipeline.schema.json`): Draft 2020-12,
date-stamped `$id` so a future revision keeps answering for templates written
against this one, and self-described as the authoritative documentation contract
while the backend reader stays deliberately tolerant.

**Rooted at `template.yaml`, not at `credentials:`.** The two keys are ONE contract
joined by a mandatory cross-reference, and validating either alone cannot check it.

**`additionalProperties: true` at the root and on `credentials`** — template.yaml
carries many keys this schema deliberately says nothing about, and a template
predating the schema must stay VALID. Accepted asymmetry, and it is asserted as a
test rather than left as a surprise: a made-up top-level key IS valid here.

**`config_files` is enumerated and `deprecated: true`, not omitted.** The earlier
posture was "don't delete, don't advertise", which made the authoritative contract
answer VALID to `path: "/etc/cron.d/pwn"`. Undocumented is not a control against an
author who knows the key — only against the reviewer who doesn't. So it is
documented as deprecated, with a containment `pattern` that rejects absolute and
`..` paths and a description saying plainly that it writes files into the agent's
credential directory. Still reversible, still invalidates nobody. (Whether to
DELETE the key is a public behaviour change and stays @vybe's call.)

Carries the A2 consumer requirements in `$comment`, because the schema is the
artifact a downstream implementer reads:
  * a record with `required: "unknown"` carries no authorial intent and MUST NOT be
    presented as a required field — without this a naive UI renders a seeded agent
    as five mandatory rows, three of them platform variables nobody can fill;
  * `platform_injected: true` MUST NOT be asked of an operator;
  * `secret: true` (the default) MUST be masked;
  * `setup_url` MUST be rendered with its parsed hostname shown, because the IDN
    homograph residual is real and documented rather than claimed closed;
  * there is intentionally NO reverse cross-reference requirement — a declared
    variable with no `credential_setup:` entry is normal.

Also states the author cost honestly in the authoring note: declaring in
`credentials:` is a separate edit from referencing `${VAR}` in
`.mcp.json.template`, K-002 checks the two agree, and that is deliberate because
`.mcp.json.template` must not become a second declaration authority. Plus the two
brace forms Trinity's readers cannot see (`${my-key}`, `${VAR:-default}`).

Tests pin the schema against the implementation — field caps, the format
vocabulary, the allowed-key set, the record cap — so the reviewed text and the
enforced text cannot drift. The 13 document cases run under `importorskip`
(`jsonschema` is not a declared Trinity dependency); the security-relevant pattern
assertions are unconditional.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(templates): the Trinity-installable credential contract + reference examples

Closes ent#128 AC #3-4.

**Reference examples (AC #4).** The substrate the original plan targeted is gone —
`3317247e` deleted `config/agent-templates/cornelius/` in favour of seeding from
the public upstream repo — so AC #4 lands on what the bundle actually has:

  * `scout` / `sage` / `scribe` (the ent#124 seeded trio) declare an explicit
    `credentials: {}` with the zero-credential contract written out. Absent and
    empty mean the same thing to Trinity, but *absent* is ambiguous to a HUMAN —
    it could equally mean the author forgot. `{}` says "considered, and there are
    none", so the catalog's 0-credential badge is trustworthy.
  * `test-codex` carries the enriched reference: its one real variable gets a
    title, description, `required`, `secret`, `format` and `setup_url`.

Deliberately NO `GEMINI_API_KEY` in any example: it is platform-injected, so an
example asking for it would violate the very rule the guide documents — and it
makes a K-002 fixture pass VACUOUSLY, which is how a test proves nothing while
looking green. A test asserts no bundled example asks for a platform-injected var.

Framed honestly rather than oversold: with one enriched declaration and one
names-only one in the bundle, the parity test ("every bundled template normalizes
with zero errors") is thin today. Its value is as a RATCHET for ent#137's curated
fleet.

**The guide (AC #3).** New `## Declaring Credentials` section, TOC renumbered
5→21. Covers the field table, the decorate-don't-declare rule with the actual
error text, why `credentials:` stays names-only, the zero-credential contract,
degrade-don't-demand, the platform-injected list, fork-to-own composition
(ent#109), and the two brace forms Trinity's readers cannot see (`${my-key}`
silently dropped, `${VAR:-default}` mis-substituted to an empty string).

It also states the AUTHOR COST plainly instead of claiming the design is free:
declaring a variable is a separate edit from referencing it in
`.mcp.json.template`, and three of Trinity's own six default GitHub templates
declare zero credentials while referencing 2-6 and documenting 7-12. Those are
K-002-red today and stay red until someone does the edit. Kept that way on purpose
— if `.mcp.json.template` counted as a declaration it would become a second
authority on what an agent needs, which is the drift this design exists to
prevent. The practical order is stated: seed `credentials:` first, enrich second.

**Memory docs.** `requirements/credentials.md` §3.5's ✅ was false in both halves
and is corrected in place with the correction recorded: the extractor it credited
has no production caller, and nothing showed configured-vs-missing status because
the badge read a key no template defines. `template-processing.md` and
`templates-page.md` get the "two shapes, two owners" table that reconciles the
objects-vs-strings contradiction (catalog `required_credentials` = names,
`credential_requirements` = objects, extractor `required_credentials` = a
different function with the same key name), plus the corrected regex. Compatibility
checklist gains six credential rows and a starts-with-nothing-configured row.

**No DB change → Rule #9 (dual-track migration) does not apply.**

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(templates): close the ent#128 coverage gaps the gate surfaced

A transition diff over a corpus with zero coverage of the diff is not evidence, so
the changed statements were measured against PR-B's real base (`c07afab7` =
origin/dev + PR-A) rather than assumed. The gate found the new paths that no test
reached and this closes them:

  * §4's new `mcp_servers` shape-error rows — per-server AND per-element, six
    parametrized cases plus the sanitized-server-name case. The element row is the
    dangerous one and it had no test.
  * The write-path consequence, asserted explicitly: `generate_credential_files`
    now raises on `env_vars: [{K: v}]`, where before it created the agent silently.
  * `_setup_url_error`'s `urlsplit` ValueError branch (malformed IPv6 literal).
  * The dedup early-return in the base-record builder — a variable declared under
    two servers AND `env_file` yields one record with a stable `source`.
  * A non-string mapping key in a descriptor (`{1: "x"}`), which must not reach the
    "did you mean" helper.
  * The caller-less `extract_agent_credentials` across eight malformed shapes. It
    has no production caller, which makes hardening cheap rather than unnecessary —
    the next caller would have inherited the crashes. Now exercised instead of
    merely present.

Result: 227 changed statements, 225 executed. The two remaining are a defensive
`except OSError` around a `Path.resolve()`, and the three gate files
(`static_checks.py`, `compatibility/__init__.py`, `credential_charset.py`) are at
100% of changed statements.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(feature-flows): sync the compatibility flow + index for ent#128

`/sync-feature-flows`. `template-processing.md` and `templates-page.md` were already
updated with the declaration standard; this adds the flow the code change actually
lands hardest on and which nothing had touched: `agent-compatibility-validation.md`.

Both credential HARD gates changed, and the flow doc described neither the defect
nor the new semantics:

  * "a detector must never read narrower than the mechanism it audits" — the shared
    root cause of K-001 and K-002/T-015, with the NON-MEMBERS list spelled out so
    the next reader does not "align all the regexes" and widen
    `mcp_validator._ENV_VAR_REF_RE`, which is a fail-closed GATE and not a detector;
  * "a HARD gate must not be able to go dark" — the `run_static` →`skipped` +
    `_counts`-counts-only-`fail` interaction that let 4 lines of untrusted YAML drop
    `hard_count` 1→0, and the three fail-closed layers that replace it;
  * the complete verdict-transition set, because the blanket "strictly monotone"
    claim is false and a reader will find K-003's `pass→fail`. The claim that
    survives is "no agent gains a HARD failure".

Testing section records why the bundled templates cannot prove any of this — 0
`.mcp.json.template` and 0 `.env.example` files, so every changed check
short-circuits before reaching changed code and a green diff there is
green-because-vacuous — and points at the 49-fixture synthetic corpus instead.

Plus the Recent Updates row in `feature-flows.md` (the step this skill's own docs
warn gets skipped).

Observation, deliberately NOT fixed here: the Recent Updates table carries 57 rows
against its own documented "newest ~20" cap (#1360), so the index is 434 lines vs
the 400-line guideline. Trimming it means deleting 37 other engineers' entries,
which is not this PR's call.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(templates): use an unambiguous placeholder credential value

`sk-live-xxx` is stripe-shaped and gitleaks' default ruleset covers `sk-`. The value
is arbitrary in this test — it only has to round-trip byte-identically through the
`.env` writer — so there is no reason to hand CI a secret-shaped string to reason
about.

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(agents): teach the crud harnesses the tolerant credentials accessor

Real regression I introduced in `886aab5b` and initially mis-attributed as
pre-existing. Recording both the fix and how the mis-attribution happened, because
the second part is the reusable lesson.

**The bug.** `test_1484_create_agent_characterization.py` and
`test_1759_local_template_not_found.py` MagicMock the whole
`services.template_service` module and stub each function crud actually calls with a
faithful return value (`generate_credential_files` → `{}`, `get_github_template` →
`None`). `_resolve_local_template` now calls a THIRD one —
`credential_mcp_server_names` — and it was unstubbed, so it returned a truthy Mock
that passed `if mcp_servers:`, landed in `config.mcp_servers`, and blew up later
inside a `yaml.dump` as `ValueError: dictionary update sequence element #0 has
length 1; 2 is required`. 18 tests, entirely a harness gap: in production the real
function returns a list.

Stubbed with a faithful 3-line mirror rather than a fixed `[]`, so a fixture that
DOES declare `credentials:` cannot be silently masked by the stub.

**One test needed a real update, not a stub.**
`test_malformed_field_still_creates_and_names_the_template` used
`credentials: "a string"` as its trigger for the broad-except degrade path. That is
exactly what `886aab5b` fixes — `credentials:` is no longer a trigger BY DESIGN,
because it raised FIRST in that run of mutations and so cost the agent its
`runtime:` and `shared_folders:` config as collateral. Swapped the trigger to
`shared_folders: not-a-mapping`, which still raises, so the degrade path and the
two identifiers in its warning stay under test. The docstring records why and points
at the new coverage.

**How I mis-attributed it.** I compared with `git stash push -- src/backend`, which
reverts only the WORKING TREE — commits 1 and 2 were already committed, so my
"baseline" still contained the cause and the failures looked identical on both
sides. The `-k`-filtered selection also happened to include only 1 of the 13
`test_1484` failures, which made the set look small and stable. Only a worktree at
`c07afab7` (PR-A's tip, PR-B absent) showed the truth: 2 failures there vs 20 on the
branch. **A baseline has to be a worktree at the base commit, not a stash.**

Now identical to `origin/dev` and to `c07afab7`: 2 failures, both genuinely
pre-existing (`test_agent_analytics::test_day_stacks_present_in_by_type`,
`test_1069_voip_call_path_param` — the documented `get_flat_dependant` venv drift).

Refs Abilityai/trinity-enterprise#128

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(templates): the tolerant credential reader must not blow up or raise

Two holes in the "never raises, never amplifies" property PR-B rests on, both
found by asking which OTHER producers reach the surface the new cap protects.

1. `credential_shape_errors` was uncapped. The cap shipped on the NEW function
   (`normalize_credential_requirements`), but the same PR added a per-ELEMENT
   loop to this PRE-EXISTING one, and it feeds the same two surfaces: the
   catalog's `credential_errors`, and the `"; ".join(errors)` that becomes
   `CredentialDeclarationError`'s agent-creation 400 body. A cap is a property
   of the producer, not of the PR that invented the concept.

   YAML anchors make input size a useless proxy for output size, so the bound
   has to stop the WALK, not slice the result. Measured on a 6,738-byte
   `template.yaml` (one 200-element anchor aliased across 200 servers):
   40,000 errors / 3.64 MB joined (540x) before, 101 errors / 8,973 bytes after.
   `origin/dev` returns 0 on the same input, so the amplification is this
   branch's own — reachable since ent#123 by any creator-role user pointing at
   an arbitrary public repo.

2. `source_trust not in _SOURCE_TRUST_LEVELS` is frozenset membership, so an
   UNHASHABLE value raised `TypeError` *on the guard line* — before the
   degrade-to-`github` branch that guard exists to reach. Unreachable from
   parsed YAML today (every call site passes a literal), but this is the one
   function whose docstring makes "NEVER RAISES" load-bearing: a raise here is
   an empty catalog and a dark HARD gate. The property should be literally
   true, not true-by-call-site-audit.

Both regression tests were confirmed to FAIL with their fix reverted and pass
with it restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* build(tests): cap fastapi to prod's 0.115.x line

The unit suite was validating against a FastAPI ~25 minor versions ahead of
the one production ships. `docker/backend/Dockerfile` pins `fastapi==0.115.6`
exactly; `tests/requirements-test.txt` carried an unbounded floor that resolved
0.140.13. The comment at :43 already claimed these "match the floors set in
docker/backend/Dockerfile" — that file uses exact pins, so the claim was untrue.

Surfaced as `test_1069_voip_call_path_param` failing with `ImportError: cannot
import name 'get_flat_dependant'`. That test is only the messenger: it is the
one test coupled to a private FastAPI symbol (`src/backend` imports none, and
the other test touching `fastapi.routing` uses the public `APIRoute`).

The obvious ceiling does not work: `0.140.13 < 0.141` is true, so `<0.141`
still admits the breaking version. Bisected against the real wheels — present
in 0.140.6, gone in 0.140.7 — a private API dropped in a PATCH release, so no
minor-level bound is trustworthy. Tracking prod's line is the durable fix.

Why now rather than "separate follow-up": CI is green only on a warm pip cache.
backend-unit-test.yml keys `cache-dependency-path` on this file, and 0.140.13
allows py3.11, so the next edit to this file for ANY reason busts the key,
re-resolves, and breaks CI for everyone. Capping is the safe way to bust that
cache — the change that invalidates the key is the one that makes re-resolution
correct.

Follows this file's own precedent (`bcrypt>=4.2.0,<5`, added when bcrypt 5.0.0
removed the `__about__` shim passlib reads): floor + ceiling + a comment saying
why, rather than an exact pin that would break the file's `>=` convention.

Verified by execution, not argument:
  - full tests/unit at 0.115.14: 5861 passed, 16 skipped, 2 xfailed, 0 failed
    (at 0.140.13 the same command is 1 failed, 5860 passed)
  - the edited file installs clean in a fresh venv and resolves 0.115.14
  - an existing verify venv self-heals: pip downgrades 0.140.13 -> 0.115.14

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(templates): take the trust label from the caller, not a tainted path

`_build_local_template` derived `is_bundled` itself:

    is_bundled = template_dir.resolve().parent == _local_templates_dir().resolve()

`template_dir` on the by-id path is `_local_templates_dir() / name` where `name`
comes from a user-supplied `local:<name>` template id, so this called `.resolve()`
on attacker-influenced input. CodeQL flagged it as `py/path-injection` (alert 260,
high) — a new tainted-path sink introduced by ent#128 purely to pick a log level
(`source_trust` selects `logger.warning` vs `logger.info` and nothing else).

`is_bundled` is now a required keyword arg supplied by whoever knows the
provenance:

  - `get_local_templates()` iterates the curated root, so its children are
    bundled by construction -> `is_bundled=True`.
  - `get_local_template()` decides from the id STRING (plain single segment, no
    separator, not a dot-segment) rather than a path operation on it.

Behaviour, measured against the old predicate across 9 ids: 7 identical, 2
divergent — `'../agent-templates/sage'` and `'a\b'` go True -> False. Both moves
are old=True -> new=False, i.e. strictly more conservative: the new check never
grants the `bundled` label where the old one withheld it, only the reverse. An id
that traverses to arrive inside the curated root is not curated, so the new
answer is also the more correct one; the blast radius either way is one log
level.

This is deliberately NOT a traversal guard — and as of the 2026-08-02 rebase it
no longer needs to be. An earlier version of this message said the traversal was
"being routed as its own issue rather than fixed"; that issue, #1900, has since
been fixed on `dev` by #1935, which this branch is now rebased onto.
`get_local_template` therefore routes `name` through `contained_template_dir()`
— a name allowlist plus resolve + `is_relative_to` — BEFORE the label check runs.
(The traversal was real while it lasted: `local:..` escaped the templates dir,
reachable by any authenticated user via `GET /api/templates/{id:path}`.)

That makes the `is_plain_segment` check redundant today — provably True wherever
it is reached, since the barrier above rejects every non-plain name first. It is
kept as defence in depth: it decides a trust LABEL, and `contained_template_dir`
is a shared primitive the remote-template-registry work (trinity-enterprise#14)
is expected to edit. A label that silently became `bundled` if that barrier were
ever widened is the exact failure this keyword argument exists to prevent. It
re-adds no tainted-path sink — it reads the id string, never the filesystem.

Two test call sites updated for the new signature.

Verified on the rebased branch: full tests/unit 6410 passed / 16 skipped /
0 failed; no `template_dir.resolve()` remains in the module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(credentials): canonicalise setup_url hosts the way a browser does

`credential_setup[].setup_url` is author-controlled and lands beside a "paste
your API key here" input. `_setup_url_error` rejects the `user@host` form but
its own docstring records the residual it does NOT close — IDN homographs
survive, so "a consumer MUST render the parsed hostname next to the link".
ent#127 is that consumer and the field's first renderer.

`services/setup_url_display.describe_setup_url` is that half. Three properties,
each load-bearing:

- UTS-46 nontransitional via the `idna` package, NOT `str.encode("idna")`. The
  stdlib codec is IDNA2003 and disagrees with every browser on exactly the
  deviation set that matters: `faß.de` encodes to `fass.de` where a browser
  resolves `xn--fa-hia.de` — a different registrable domain. A mitigation that
  displays a domain the click does not resolve manufactures the very split it
  exists to close.
- Fails CLOSED. Every failure path returns `display_host is None`, which the UI
  must render as inert text rather than an anchor. Falling back to the raw host
  would make a failed check byte-identical to a passed one.
- Leads with the registrable domain (eTLD+1), because punycode is irrelevant to
  `accounts.google.com.evil.tld` — the commonest shape and pure ASCII.

`idna` is pinned explicitly in both the backend image and the test
requirements: it is currently an unpinned transitive of httpx, and a dropped
transitive is invisible to /verify-local because the source imports fine on the
host (the #1033 class).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(credentials): bounded in-container probe for live .env key status

The status engine for the ent#127 checklist: one fixed, base64-injected probe
that reports which declared credential variables actually hold a value.

"Set" is defined as agreement with the agent's OWN post-injection exporter
(`agent_server/routers/credentials.py`), pinned by a parity test whose replica
is anchored on the owning function via `ast` — not a `str.find` offset, which
returns -1 on a rename and silently asserts against nothing. Two deliberate,
documented departures sit outside that parse: bytes are decoded with
`errors="replace"` (the exporter's strict `read_text()` raises and exports
NOTHING, so one bad byte would report a fully-configured agent as empty), and
emptiness is tested after `.strip()` (a whitespace-only value is a green row in
front of an agent that will 401).

The exec is bounded three ways, because none is sufficient alone.
`execute_command_in_container` accepts a `timeout` and never references it
again; `container_exec_run` has no timeout parameter, docker-py's `exec_run`
has none, and its socket reader polls with no timeout before every `recv`. The
call runs on a `ThreadPoolExecutor(max_workers=4)` shared by EVERY Docker
operation in the backend, so four wedged calls stop the whole Docker layer —
and it is agent-triggerable, since the agent owns `/home/developer/.env` and
`mkfifo` on it blocks `open()` forever. So: container-side `timeout(1)` (the
load-bearing one — self-termination closes the socket and actually reclaims the
pool thread, which an asyncio cancel cannot), `asyncio.wait_for` to bound the
request, and `stat.S_ISREG` before `open()` to close the FIFO vector at source.
`compatibility/collector.py` has the identical hole; that is filed separately.

Zero policy crosses the image boundary except the predicate itself, which is
spliced in from real source so the tested code and the shipped code are the
same code. No charset filter (it would be a hidden fifth member of
credential_charset.py's MEMBERS list, and narrower than the runtime it audits),
no YAML parse (alias expansion is a 443 B -> 52 MB amplifier). The probe emits
key NAMES only, never a value, length or hash; `result["output"]` is never
logged, because on failure it holds an exception string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(credentials): assemble the per-agent requirements report

Joins the ent#128 declaration against the live probe. Authority is the LIVE
workspace, because a forked or hand-edited agent's requirements drift from the
catalog entry it was created from — AC #3's case; the catalog is the fallback
only, and it is reachable ONLY from an already-failed live read.

Four decisions worth naming:

- `degraded` DOMINATES `no_credentials_required`, unconditionally. A degraded
  lookup and a genuinely credential-free agent produce a textually identical
  empty requirement set, and "Ready — this agent needs no credentials" is the
  one state a user never investigates. An EMPTY catalog result counts as
  degraded, not as data: `get_github_template` returns `_build_template(repo,
  {})` — empty requirements, not None — when the fetch fails, and with no PAT
  GitHub's 60-req/hr anonymous limit makes that the *expected* outcome for the
  ent#123 tokenless fleet.

- `.env` absent is a definite `missing`, never `unknown`. `_stage_config_files`
  guards on `template_data`, which only the `local:` arm populates, so a
  `github:` agent has no generated `.env` at all — and that fleet is AC #3's
  literal audience. `unknown` is reserved for "we could not look".

- A fourth state, `declaration_incomplete`. AC #1 names three sources; using
  `credentials:` alone yields a confidently-wrong green, since 12 of 25 bundled
  templates declare `credentials: {}` and 13 declare nothing, so a legacy
  template with `${SLACK_BOT_TOKEN}` in `.mcp.json.template` would render as
  needing nothing. Those names are an anti-green signal only — advisory, never
  required, never blocking. `.mcp.json.template` does not become a declaration
  authority.

- Tri-state `required` survives end to end and never counts toward `blocking`;
  platform-injected variables are excluded from the rows and counted separately,
  read through the PUBLIC `operator_supplied_credential_names` so this module
  keeps its zero-edit relationship with template_service.

Hardening: YAML aliases are refused at compose time (this template.yaml comes
from an agent-writable workspace, and alias expansion is a measured 443 B ->
52 MB amplifier here); the normalizer is wrapped at the CALL only, never around
the build, because blanket swallowing is what turns a raise into a verdict
indistinguishable from a pass; and the GitHub catalog arm goes through
`asyncio.to_thread`, since `_get_cached_metadata` uses a synchronous httpx
client whose 10s timeout would otherwise stall the whole worker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(credentials): GET /api/agents/{name}/credential-requirements

Owner-only AND human-only, deliberately stricter than the coarse
`/credentials/status` beside it.

`get_authorized_agent_by_name` resolves an agent-scoped MCP key to the owner
user carrying the owner's role — only *connector* principals are fenced — so
under the read gate an agent's own injected `TRINITY_MCP_API_KEY` would reach
this for every sibling its owner can access, which on a default admin-owned
install is the whole fleet including other users' agents. What it discloses is
a targeting map, not a status light: it names `STRIPE_SECRET_KEY` per agent and
says which are populated (worth stealing) and which are empty (whose operator
is about to paste one). `/credentials/status` gets away with the read gate
because it returns a COUNT and names nothing. Every sibling route that names or
writes credentials is already owner + human-only, and a read gate must equal
the write gate it drives: a shared user cannot submit anyway, so the looser gate
would give them a checklist of dead inputs whose only working function is
disclosing which of the owner's secrets are missing.

Backpressure, because every uncached call spawns a container process against
the backend's shared 4-slot Docker pool: a per-user rate limit at the router,
plus a cross-worker single-flight lock and a short cache in the service (the
router holds no logic, Invariant #1 — the same split `compatibility/fixes.py`
uses). The cache is generation-checked through Redis and invalidated by both
`.env` writers: it is per-worker while a POST lands on whichever worker served
it, so purely local invalidation would leave the other worker reporting
"missing" for a variable the operator just set.

An audit row is written on the read — every sibling credential route logs one,
and silence on the route that enumerates a credential inventory reads as an
oversight. Counts only; a variable name never reaches the audit log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(credentials): guided credential setup checklist UI

The operator-facing half: what the agent needs, what is already set, and where
to get each one — writing through the EXISTING owner-gated inject path, so
there is one writer and no new backend write surface.

Rendering contract, enforced by a source-anchored guard because this repo has
no component-test runner (only Playwright e2e against a live stack):

- Author text is interpolated as TEXT and deliberately NOT routed through
  `utils/markdown.js`. Markdown would be a widening, not a mitigation: it hands
  the template author an arbitrary `[label](url)` surface immediately beside a
  credential input, which is what having one validated `setup_url` exists to
  prevent.
- The anchor text is always the parsed host, never `title` — a `<a
  href="https://evil.tld">OpenAI API keys</a>` recreates the userinfo attack in
  pure HTML with no validator in the way. An unverified host renders as inert
  text, and `https:` is re-checked at render rather than trusted.
- The registrable domain is emphasised inside the full host, because
  `accounts.google.com.evil.tld` is the commonest shape and punycode says
  nothing about it.
- `secret` masks on `!== false`, so an absent or malformed value still masks;
  `default` is a placeholder only, and only when the author marked the variable
  non-secret — prefilling it would turn author YAML (or a prompt-injected
  agent's own rewritten template.yaml) into a one-click credential write.

The checklist renders for a STOPPED agent — the endpoint answers with a
degraded body, and copying `loadCredentialStatus`'s running-guard onto it would
have made the whole degraded design dead code. Only the inputs are gated.

Two latent defects in the write path are fixed, because a per-row checklist
promotes read-merge-write from a rare bulk paste to the normal interaction:

- The merge base is now MANDATORY. `formatEnvContent` rewrites `.env`
  wholesale, so swallowing a transient read failure as "start fresh" wiped
  every credential already configured; only a genuine 404 is a safe empty base.
- `parseEnvText` now unescapes what `formatEnvContent` escaped. The round trip
  was lossy in one direction only, so a value containing a quote grew one
  backslash PER SUBMIT — for every other credential in the file, not just the
  one being edited. Proven by an executed node round-trip, not a grep.

The store action goes through `api.js` (Invariant #7 — it owns the auth
interceptor and the 401 redirect); its raw-axios neighbours predate it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(credentials): requirements §3.6, architecture, and the ent#127 flow

Tiered-docs classification is "new capability", so all three land: requirements
§3.6 (appended — §3.5's existing "the per-credential checklist is ent#127"
forward pointer is left untouched), three architecture edits (endpoint row,
two service-catalog entries placed under Auth & Credentials rather than the Core
block, and the CRED-002 note), and a new feature flow.

A new flow doc rather than an extension of template-processing.md: that flow is
catalog/template-time, this is per-agent runtime. One cross-link added there.

The architecture note deliberately records three decisions so a later reader
does not "fix" them: nothing is vendored and there is no agent-server mirror
(so no Invariant #5 obligation attaches), the probe is deliberately separate
from the #668 compatibility collector, and there is deliberately no MCP tool —
recorded in architecture.md, not only the PR body, so /validate-architecture can
see the Invariant #13 decision.

Both index rows added — Recent Updates and the Authentication & Security
category table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(credentials): route-wiring smoke + test-runner catalog for ent#127

The unit suite mounts `routers/credentials.py` on a synthetic FastAPI app with
the auth dependencies overridden, so it structurally cannot catch the #1069
escape class: whether the route resolves through the real `main.py` and whether
the real `get_owned_agent_by_name` + `reject_agent_principal` chain runs. A
path-param mismatch or a shadowing sibling would 404 every call with the unit
suite still green. Three live-backend smoke tests close that, needing no agent —
a nonexistent name is enough to prove the dependency ran, and the assertion is
that the detail is NOT FastAPI's bare routing "Not Found".

The uniform 404 those tests see is the point, not a limitation:
`get_owned_agent_by_name` deliberately answers identically for "no such agent"
and "not yours" (Invariant #8 self-uniformity), so the test cannot distinguish
them either.

Catalog updated: Credentials & Configuration entries, a dated Recent Test
Additions block, and the unit-test statistics line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(credentials): sync credential-injection.md with the live write path

/sync-feature-flows over the ent#127 commits. `routers/credentials.py` and
`CredentialsPanel.vue` both changed, which maps to credential-injection.md.

Flow 1 cited `composables/useAgentCredentials.js:177-237` — a file that still
exists and is re-exported from `composables/index.js` but that NO component
imports. The live implementation is `CredentialsPanel.vue`, and the composable
is a dead duplicate still carrying the pre-ent#127 versions of both defects.
Documented as such so a future reader does not "restore" the live path from it.

Flow 1 now shows the mandatory merge base and the quote round-trip, with the
agent-side escaping mismatch recorded as a named residual rather than implied
fixed. Added the cache-invalidation note on the inject/import writers and a
Related Flows section pointing at guided-credential-setup.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(credentials): three failures that each render as a pass, and the docs gate

Review follow-ups on ent#127. All three share one shape: a defence that fires
correctly and then reports the result on the success path.

1. A caught normalizer raise read as "Ready — needs no credentials".
   `_safe_normalize` wrapped `normalize_credential_requirements` narrowly (the
   ent#128 `run_static` lesson) and returned `[], [error]` — but `build_report`
   then set `requirements_source="live_workspace"` with `degraded_reason=None`,
   so the empty list scored `no_credentials_required`: a green headline with the
   real reason folded into a collapsed `<details>`. The module's own comment
   forbids exactly this. `_safe_normalize` now returns an `ok` flag the caller
   converts into `degraded_reason="template_unreadable"` — reusing the existing
   enum rather than minting a fifth value, since every consumer already handles
   it and `errors[]` distinguishes the two causes.

   The covering test passed throughout: it asserted the exception was caught and
   stopped there. Catching is half the fix; propagating it into the state machine
   is the other half, and a test that stops at "it was swallowed" cannot tell
   them apart. It now asserts the resulting STATE.

2. A trailing DNS root dot moved the eTLD+1 emphasis off the attacker.
   `_registrable_domain` splits right-anchored, so `evil.tld.` adds an empty
   label and shifts every label one place: `accounts.google.com.evil.tld.`
   emphasised `tld.` and dimmed the true registrant. That inverts the module's
   PRIMARY defence — punycode canonicalisation is irrelevant to an all-ASCII
   subdomain attack; the bold IS the mitigation — and it costs one character the
   template author fully controls, next to a "paste your API key" input. Fixed
   with `rstrip(".")` plus a fail-closed empty check, and the adversarial table
   now carries the trailing-dot form of each case.

3. The single-flight lock released leases it no longer owned.
   `_LOCK_TTL_SECONDS` is reachable in normal operation, not pathologically: the
   probe is bounded at `_REQUEST_TIMEOUT` (20s) and the catalog fallback adds
   `get_github_template`'s own 10s HTTP timeout — exactly the TTL. Past it another
   worker may hold the key, and the bare `DELETE` in the `finally` freed it, letting
   a third caller probe the same container concurrently: the precise failure the
   lock exists to prevent, silently. Now a random per-acquisition token released by
   compare-and-delete via the shared `lock_token_matches` (#1919); a constant value
   makes the compare a tautology. Fail-open on Redis absent/erroring is unchanged —
   a Redis outage must degrade to "no backpressure", never to a 409.

Frontend: that 409 is a concurrency signal, not a verdict, and a second viewer
(another tab, operator, or uvicorn worker) inside the ~1s probe window gets it on
a healthy agent. The checklist renders `v-if="error"` AHEAD of
`v-else-if="report"`, so surfacing it blanked a report that had already loaded.
Retried once behind a resettable latch (once per episode, not once per session),
and a failed refresh no longer clobbers a report on screen. Also `:title` on the
setup_url anchor: the visible text is deliberately host-only, so the full
destination needs to be reachable — and it must be the URL, never the author's
label, which would rebuild the deception one layer down where no validator looks.

Docs: the flow doc was missing `## Testing` and `## Related Flows` (present in
every sibling flow) — added, with the coverage table, the four named edge cases,
and an explicit "not covered" note. Backpressure and eTLD+1 sections updated for
the behaviour changes above. Two comments claiming a follow-up was "filed
separately" now say plainly that it is not filed on either tracker: a comment
asserting a ticket exists is the reason nobody re-checks.

208 unit tests pass.

Refs trinity-enterprise#127

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
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.

3 participants