fix(templates): contain local: template ids and stop deriving credential paths from name: (#1900) - #1935
Conversation
#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>
|
Resolve by running |
AndriiPasternak31
left a comment
There was a problem hiding this comment.
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, ff, İ, 𝟏 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_pathhas 4 references, all reads, zero assignments → the safe arm genuinely was dead code and the vulnerableelsewas the only executing branch._resolve_template(crud.py:2133) runs strictly before_stage_config_files(:2164), andconfig.templateis never reassigned anywhere in the codebase → the "cannot newly raise" comment holds.crud.py:2164is the sole production caller; thegithub:branch never setstemplate_data, so thetemplate_base_path is Nonearm is genuinely dead.- Every remaining
/-join intemplate_service.py(:504,:1202,:1203,:1211) takes an already-contained directory;deploy.py's write path derives from a server-generatedversion_namebehind 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-injectionalerts 240/242/243/244/245/247 carrymost_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 BThe 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)
- Docstring invariants are false under planted symlinks.
<root>/selfdot → .returnsrootitself (is_relative_tois reflexive), contradicting "resolvenameas a direct child ofroot";<root>/alias → scoutreturns<root>/scout, so the id round-trips aslocal:scout, notlocal:alias, contradicting "the basename … is unchanged". Reproduced twice, independently. Neither escapes — both stay inside the root and both need local filesystem write against a:romount, i.e. the same class already disclosed honestly as follow-up #6. Doc-accuracy nit only. contained_template_dir's "never raises" is Python-version-dependent. On ≤3.12Path.resolve()raisesRuntimeError: Symlink loopviacheck_eloop; 3.13 removed that path. Unreachable in prod for three independent reasons (prod ispython:3.13-slim; the templates root is:roin 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.- The "CodeQL recognises this as a barrier" wording (
template_service.py:461-463+ the mirror intemplate-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 apy/path-injectionalert can be dismissed against". - The missing
## Security Considerations/## Revision Historyentries 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#1759appears 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. docs/memory/feature-flows.md:28— the2026-08-01row sits below two2026-07-31rows 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 indev'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.ymlfetches bothdev(:88) and the PR head (:109) at--depth=1, then merges two histories with no common ancestor →fatal: refusing to merge unrelated histories→merge_conflict=true. All 9 open PRs carry the comment, 8 of themMERGEABLE. Filed separately.mergeable=MERGEABLE,baseRefOid == origin/devtip, 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-pathtest_1900_*guards) andtemplate-processing.md(the#1900containment section). A conflict resolved--theirswould 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
…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>
…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>
…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>
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 untrustedname: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}")behindDepends(get_current_user); the:pathconverter captures/, which is precisely why the traversal is reachable over HTTP rather than being a theoretical string bug.Impact: read any parseable
template.yamlthe backend process can reach, disclosed in a200response — including other tenants' uploads under/data/deployed-templates. The response echoesdisplay_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:
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, thenresolve()on both sides plusis_relative_to. A rejection returnsNone, 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 adeploy_local_agentupload, 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.jsonstaged into the attacker's own agent. An uploadedname: ../../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-SECRETappeared inside the.mcp.jsonstaged for the attacker's agent.Two compounding facts made this live rather than latent:
_TemplateResolution.github_template_pathhas zero assignment sites (4 refs, all reads) — so the safeif template_base_path:arm was dead code, and the vulnerableelsewas the only branch that ever executed.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") — solocal:test-echowas looking for<curated>/Test Echo Agent/.mcp.json.Fix:
crud._stage_config_filesnow threads the directory it already validated through_safe_local_template_path, extracted ascrud._resolve_local_template_dir. That makes the #1759 seam-agreement property structural across three seams (resolver,/templatebind decision, credential stager) instead of two-by-convention. The residualtemplate_base_path is Nonearm 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_serversand ships a.mcp.jsonnow has that file staged at all, where the old curated-root lookup always missed. Two consequences, and the second is a loss:startup.shcopies/generated-creds/.mcp.jsonunconditionally and after the template-copy block);agent_credentialsmap (CRED-002 — real values are injected after creation, not at staging), so${VAR}is rewritten to"".The corrected description is the one in
docs/memory/feature-flows/template-processing.md. (Commit23581c19'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'attemplate_service.py:873— a non-stringname:raised an uncaught 500 on agent create. Absorbed by theisinstance(name, str)gate incontained_template_dir; covered by a test over123 / None / ["a"] / {"a":1} / 1.5 / True, verified red pre-fix.Evidence
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 4test_1771*property files excluded becausehypothesis(pinned intests/requirements-test.txt) is absent from the local interpreter and PEP 668 blocks installing it there — all 4 are byte-identical toorigin/devand untouched by this branch.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./verify-local --skip-agentPASS; integration 70 passed / 13 skipped / 2 deselected.tests/test_templates.pynow 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.pyis root-level and has no automated runner at all. Every automated stage collects a subdirectory — CI runspytest unit/,/verify-localrunspytest unit/thenpytest integration/— so neither collectstests/*.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 istests/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_namefails with anImportErrorunder fastapi ≥ 0.141.1 (get_flat_dependantremoved; reached via thefastapi>=0.115.0floor). 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 androuters/voip.pybyte-identical toorigin/dev(verified withgit diff --quiet).CodeQL note
If a new
py/path-injectionalert fires oncontained_template_dir, the correct response is dismissal citing the two-step barrier — regex allowlist before path math, thenresolve()+is_relative_to(), returning the checked value. It is not to reshape the helper or theif/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/repoclone 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 keepslocal: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:
credentials.env_filealready has its user-supplied credentials wiped at first boot: staging writesKEY=blanks to/generated-creds/.env, andstartup.sh:387-389copies that over the merged archive.env./generated-credscopies are not.trinity-initialized-gated, so they re-clobber.mcp.json/.envon every restart, discarding operator edits.Plus:
github_template_pathwire (zero writers). This PR stops depending on it; deleting it is a separate change.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.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.get_local_templates()still enumerates viaiterdir(), 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).tests/test_templates.pyhas no automated runner — move it undertests/integration/, or add a job that collects root-level files.Merge-order note
Four files collide with sibling PRs (no rebase needed today —
origin/devis unmoved at8e924526, 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