fix(templates): stop a malformed credentials: block emptying the catalog (ent#128 PR-A) - #1835
Conversation
|
Resolve by running |
0543899 to
ffba058
Compare
…talog
One template whose `credentials:` — or `credentials.mcp_servers` — was a list,
a string, or **null** raised an uncaught AttributeError out of
`_build_local_template` -> `get_local_templates()` -> `GET /api/templates`:
HTTP 500 with ZERO templates listed. One bad template hid every good one.
The `github:` builder was worse: no `isinstance` guard at all on untrusted repo
metadata (`_fetch_template_yaml` returns `safe_load(...) or {}`, and a top-level
list is truthy). Deeply nested YAML escaped the parse handler entirely as
`RecursionError` — a `RuntimeError`, not a `yaml.YAMLError`.
Worse than the crash because it was silent: `env_file: "OPENAI_API_KEY"` — an
ordinary typo, the list dash forgotten — was iterated character by character
into the generated `.env`, emitting fifteen single-letter variables, never
writing the real credential, with no error, no warning and no crash. The agent
booted and its MCP server failed at first use with nothing pointing at the
cause.
And `credentials.config_files[].path` was joined onto the staging directory and
opened for write with no normalization, so an absolute path or a `..` escape
was an arbitrary-file-write primitive — reachable by any authenticated user,
since `deploy_local_agent_logic` accepts an uploaded template archive.
Four tolerant readers in `template_service.py` are now the only way into the
block, with two deliberately opposite contracts:
* Read paths never raise. The catalog degrades the derived field to empty,
attaches `credential_errors` to the entry, logs one WARNING naming the
template id — and the template STILL LISTS. `get_local_templates` also fences
each per-template build, so a future unguarded field cannot regress the
property the readers buy.
* The write path fails loud. `generate_credential_files` validates first and
raises the HTTP-free `CredentialDeclarationError`, which its only caller maps
1:1 to 400 `INVALID_CREDENTIAL_DECLARATION` (Invariant #1: services hold no
HTTP concerns). It is called before the docker try/except, so the 400 is not
flattened to a 500.
`config_files[].path` is rejected at the parse boundary AND re-checked at the
write sink (`crud._safe_cred_file_path` -> 400 `INVALID_CREDENTIAL_FILE_PATH`),
using the same resolve + `is_relative_to` CodeQL barrier as
`_safe_local_template_path`.
Absent / null / `{}` all stay a valid zero-credential contract — the ent#124
starter trio ships exactly that, and a commented-out block must not acquire a
spurious warning.
`credentials.env_file` stays a names-only list. The enriched per-variable
declaration lands under its own top-level key (PR-B) precisely so an older
Trinity reading a newer template is structurally untouched.
Tests: `test_ent128a_catalog_resilience.py` — 33 pass here, 31 fail on
`origin/dev`, the two headline ones with the exact bug signatures
(`AttributeError: 'list' object has no attribute 'get'` and the literal
`O=\nP=\nE=\nN=` output). Includes a parity test so a malformed BUNDLED template
fails CI instead of a user's fresh install.
No DB change, so the dual-track migration rule (#9) does not apply.
PR-A of trinity-enterprise#128 (AC #5). PR-B — the enriched declaration schema —
re-gates after this merges.
Refs Abilityai/trinity-enterprise#128
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CodeQL flagged `_safe_cred_file_path` with two HIGH `py/path-injection` alerts, on the join itself and on the containment check. It was right to: the helper had only step 2 of the pattern (resolve + `is_relative_to`), and CodeQL does not treat that alone as a barrier — the sibling `_safe_local_template_path` clears the same query because it allowlists the RAW string first. Add that step 1 here too: reject empty, absolute, `..`-bearing, and anything outside `[A-Za-z0-9._/-]` before the value ever reaches the join, then keep the resolve + containment as step 2. `_CRED_FILE_PATH_RE` permits `/` (unlike `_LOCAL_TEMPLATE_NAME_RE`) because this is a relative *path*, not a single slug. Not defensive padding for a scanner — the two-step shape is what makes the guard legible to a reader as well, and it is the established pattern in this file. Test extends to the allowlist rejections (empty, leading dash, space, semicolon) alongside the traversal cases. Refs Abilityai/trinity-enterprise#128 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ffba058 to
c07afab
Compare
vybe
left a comment
There was a problem hiding this comment.
Validated via /validate-pr — approving. Ran tests/unit/test_ent128a_catalog_resilience.py locally at c07afab7: 33 passed. All 22 CI checks green, and I confirmed zero open code-scanning alerts on the head ref, so the two high-severity py/path-injection flags on _safe_cred_file_path are genuinely resolved by the two-step barrier rather than dismissed.
What I verified beyond the test run:
- The read/write contract split is the right call and is actually implemented that way. Read paths (
credential_mcp_server_names,credential_env_file_names) degrade to[]for a null / list / string / scalar block at either level, so one malformed template costs itself its credential metadata and nothing else — the catalog still lists. The write path raisesCredentialDeclarationError, which is correct: generating an agent's.envfrom a declaration nobody can parse is precisely the silent corruption being closed. CredentialDeclarationErroris HTTP-free and mapped 1:1 at its only caller (crud.py::_stage_config_files→ named 400INVALID_CREDENTIAL_DECLARATION). That respects Invariant #1 rather than reaching forHTTPExceptioninside a service.- The
_build_templateguard closes the worse half. The GitHub path is the untrusted source and had noisinstanceguard at all, while_build_local_templatealways did — and_fetch_template_yamlreturnssafe_load(...) or {}, so a top-level list is truthy and sails through. Fixing only the local path would have left the exploitable one live. - The path guard is defence-in-depth at both ends — rejected at the parse boundary by
_config_files_shape_errorsand re-checked at the write sink by_safe_cred_file_path. Sincedeploy_local_agent_logiclets any authenticated user upload a template archive,config_files[].pathreally was an arbitrary-file-write primitive; the allowlist-then-resolve shape mirrors the existing_safe_local_template_pathprecedent instead of inventing a new one. - Present-but-null is handled, which is the subtle one —
.get("credentials", {})applies its default only when the key is absent, so a barecredentials:with nothing under it yieldedNoneand blew up every downstream.get().
Docs are updated in-diff (architecture.md, feature-flows.md, and the template-processing.md flow), which is what a behaviour change at this boundary warrants.
Two process notes, neither blocking:
- The cross-tracker reference to
abilityai/trinity-enterprise#128is the expected shape here and deliberately carries no closing keyword, since this is PR-A closing AC #5 only — PR-B still owns AC #1–4. So I am not bumping ent#128 tostatus-in-dev; it correctly staysstatus-in-progressuntil the declaration standard lands. The manual close at the release cut is already called out in the PR body. - The stale nightly-suite comment about a merge conflict no longer applies — the branch is
MERGEABLEagainst currentdev.
…t creation (trinity-enterprise#89) (#1946) * docs(requirements): template-declared schedules at creation (§10.16, ent#89) Requirements-first per Rule of Engagement #1 — written and committed before any implementation. Covers the declared `schedules:` contract, the total-function reader and its tolerance matrix, the normalized carrier that feeds BOTH resolver branches (the `github:` half needs the creation-resolved PAT + parsed ref, not the catalog's global-PAT cached fetch), the honored-`enabled` decision and its `set_autonomy_status` caveat, idempotency in all three places creation / intra-block / manifest deploy, and the T-018 + A-002 + c_p006 compatibility half. Refs trinity-enterprise#89 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(templates): tolerant `schedules:` reader for template.yaml (ent#89) New leaf `services/template_schedules.py`: `schedule_shape_errors` + `normalize_declared_schedules` over one private `_parse`, mirroring the sibling `credential_shape_errors` / `credential_mcp_server_names` convention (ent#128). The contract is TOTALITY — template.yaml is untrusted and `yaml.safe_load()` can yield a scalar, list or mapping at any level, so a raise here would empty the template catalog (#1835 class), enter the creation rollback fence, or fail-open the T-018 check. Every shape degrades to a safe value plus a named error. Cron and timezone are validated with the SAME parser the dedicated scheduler registers with (#1472) because `_calculate_next_run_at` swallows a bad cron and `set_schedule_enabled` never re-validates — an unvalidated entry would become a zombie schedule that exists, shows no next run, and can never fire. Errors name the index, the key and a YAML type — never the `name`/`message` VALUE, which is unbounded and lands in a persisted, UI-rendered blob. The cron/timezone strings are the one echo, bounded and printable-filtered by a local twin of `_sanitize_for_warning` (importing it would close a cycle). Refs trinity-enterprise#89 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(templates): surface `schedules:` in both builders, fence both GitHub list paths (ent#89) Three changes in one file: 1. `_build_template` (GitHub) and `_build_local_template` (local) both surface `schedules` (normalized) + `schedule_errors`. BOTH, explicitly — the pre-existing asymmetry (`persistent_state` is surfaced only by the GitHub builder) means parity cannot be assumed, and AC #2 covers both sources. 2. R3 — both GitHub catalog list paths were BARE list comprehensions. ent#128 PR-A fenced `_build_local_template` only, so adding an untrusted-input reader to `_build_template` would have put a new raise-capable call on an unfenced path: one malformed repo would 500 the whole GitHub half of GET /api/templates. That is the #1835 bug this feature is modelled on, re-opened by the feature itself. Both are now fenced per-template via `_safe_build_github_template`. 3. `fetch_template_metadata_for_create` — the creation path must NOT read the catalog cache. That cache uses the GLOBAL platform PAT (creation resolves per-agent -> per-user -> global, ent#162), sends no `?ref=` (so an `@branch` create would read the default branch), and is a 10-minute per-process TTL. The new fetch takes the resolved PAT + parsed ref, and is loud on every failure — a silently empty declaration on the `github:` path is the exact class this feature exists to close. Refs trinity-enterprise#89 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(agents): materialize a template's declared schedules at creation (ent#89) `_TemplateResolution.declared_schedules` is a normalized carrier populated by BOTH resolver branches, so AC #2's "GitHub and local" is real rather than nominal. It is deliberately not folded into `template_data`: that field is raw template YAML on the `local:` path and `{}` on the `github:` path — which has never populated it, so #383's `persistent_state` and #1169's `data_paths` are effectively `local:`-only — and `_stage_config_files` gates credential-file generation on `if template_data:`, so merging the two shapes would change credential generation for every GitHub agent. `reconcile_declared_schedules` is shaped as a reconcile primitive (takes `agent_name`, not `AgentConfig`) so a future "re-apply template" can reuse it. No recreate hook is added — an eager re-materialize would resurrect schedules an operator deliberately deleted. Two failure modes handled explicitly: - `db.create_schedule` RETURNS None on three paths (unknown user, no access, the #1445 is_agent_live gate) and never raises, so a try/except alone would catch nothing and a length-derived counter would report schedules that were never written. The return value is checked and counted as failed. - The whole step is non-fatal and the try/except wraps the entire call including `list_agent_schedules` — this function sits inside the destructive rollback fence, so an escaping raise would roll back a successful creation over a schedule. `enabled` is passed explicitly (ScheduleCreate defaults it to True, which would invert AC #3), and ghosts are skipped at the caller per ent#69 fleet hygiene. Refs trinity-enterprise#89 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(systems): don't duplicate a schedule the template already materialized (ent#89) `deploy_manifest` creates each agent and THEN calls `create_schedules`, so post-ent#89 it is the second schedule producer for the same agent: the first call materializes the template's declared block, the second adds the manifest's. With no UNIQUE(agent_name, name) index, a manifest declaring `daily-briefing` on a template that also declares it produced two rows. This is a regression ent#89 itself creates, so the guard ships with it rather than as a follow-up. Fails open on a read error — dropping a manifest's schedules would be worse than the duplicate this prevents. Refs trinity-enterprise#89 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(compat): T-018 schedules well-formedness + three in-radius corrections (ent#89) T-018 (SOFT, STATIC) reports the `schedules:` block's STRUCTURE only, sharing one reader with the materializer and the catalog surface so the report cannot drift from what creation actually does. It deliberately does not report cron syntax — see correction 2. It fails CLOSED, against the grain of every other check: `run_static` turns a raise into `skipped` and the report counts only `fail`, so a raising SOFT check drops soft_count 1->0 and flips overall_status issues->compatible exactly when its finding was the only failure — the entire population T-018 exists to serve — and `_report_from_persisted` then replays that clean bill of health from checks_json on every stopped-agent read. Detail carries the exception TYPE only; `str(e)` can embed untrusted template content into a persisted, UI-rendered blob. Three corrections, all in blast radius: 1. `c_p006` was missing the `isinstance(..., list)` guard its four sibling readers of this field all carry. `schedules: 5` raised TypeError -> swallowed -> a HARD check silently vanished from hard_count. A live instance of the exact class T-018 guards against, which is what makes the fail-closed design evidence-backed rather than theoretical. 2. `_valid_cron` (A-002) was a per-field `^[\d*/,\-]+$` regex, wrong in BOTH directions: it rejected `0 9 * * MON` and accepted `99 99 * * *`. It now delegates to `validate_cron_expression` — the same parser the scheduler registers with (#1472) and the same one the ent#89 reader gates on. One cron authority, agreeing with the executor. 3. `run_static`'s swallow now logs. It previously left no trace anywhere, for all ~100 checks; this is the instrument for deciding later whether to flip it to `fail` platform-wide. Catalog 100 -> 101. Refs trinity-enterprise#89 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(templates): stop echoing the schedule name in the duplicate error (ent#89) The error list is persisted into agent_compatibility_results.checks_json, rendered in the UI, and returned in the catalog response, so the discipline is index + key + type name only. The entry index already identifies the offender; the name added disclosure without adding actionability. Cron and timezone stay the only echoed values — bounded, printable-filtered, and what makes those particular errors fixable. Refs trinity-enterprise#89 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: cover template-declared schedules end to end (ent#89) Three new files plus compatibility extensions, 143 tests. test_ent89_template_schedules.py — the reader's tolerance matrix (40 rows), error-string discipline (no name/message/description ever echoed; cron sanitized and bounded), the catalog surface in BOTH builders, the GitHub list-path fence, and the create-path fetch. Plus a Hypothesis property over recursive JSON-ish values asserting totality — a 40-row matrix cannot be a totality proof for `yaml.safe_load` output, and totality is this module's entire contract. test_ent89_schedule_materialization.py — REAL DB rows via db_harness, not `mock.assert_called`. Both recorded lessons apply here: a mock-`db` suite is blind to a facade gap, and a parameter only one branch consumes is a severed wire a mock will happily confirm — which is precisely the failure history of the `github:` half of AC #2. So both resolver branches are driven for real, and the github fetch's PAT and ref are asserted; without that the §0 regression test would be self-attestation. test_ent89_manifest_no_duplicate.py — R5, in creation order: template first, manifest second, one row, and the template's row is not overwritten. test_compatibility_checks.py — T-018 pass/fail/absent, its fail-closed branch (and that `detail` never carries `str(e)`), a `build_report`-level test pinning the DIRECTION (a raising reader must still yield `overall_status == "issues"`), the `_report_from_persisted` recompute, A-002 in both directions and agreeing with the materializer, c_p006 on `schedules: 5`, the run_static log, and the whole static catalog run against 7 hostile templates asserting no check lands at `check_error` — a T-018-only assertion would never have caught c_p006. The two fence tests pin `get_github_templates` by sys.modules KEY rather than by module object: `get_all_templates` imports it lazily inside the function, so an attribute patch on a separately-imported reference passed in isolation and failed under the full suite, where an earlier file swaps that object. Full unit suite: 6405 passed. The one red, test_1069_voip_call_path_param, is a pre-existing venv FastAPI drift (`get_flat_dependant` no longer exported) in a file this branch does not touch. Refs trinity-enterprise#89 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(architecture): template-declared schedules + the compat fail-open class (ent#89) - template_service bullet: the `schedules:` reader beside the `credentials:` ones, the newly fenced GitHub catalog list paths, and why the creation path needs its own metadata fetch rather than the global-PAT default-branch cache. - New `template_schedules.py` leaf bullet with its totality contract. - crud bullet: the `declared_schedules` carrier, why it is not `template_data`, and the non-fatal reconcile step. - Compatibility block: T-018, why it is the one check that fails closed, and the two live instances of that class it fixes (c_p006, _valid_cron/A-002). - template.yaml file-tree note now names its four declarative blocks. Refs trinity-enterprise#89 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(flows): template-declared schedules + the compat fail-open class (ent#89) - template-processing.md: the `schedules:` reader beside the `credentials:` ones, the three deliberate differences (normalized surface, both GitHub list paths fenced, creation does not read the catalog's copy), and the error-string discipline. - scheduling.md: new flow 1c — the fourth schedule producer, why the carrier is not `template_data`, why non-fatality is the invariant, the falsy-return check, and idempotency in all three places. - agent-compatibility-validation.md: T-018, why it is the one check that fails closed, the A-002 cron-authority consolidation, and the c_p006 live instance. - feature-flows.md: Recent Updates row (required even when the flow docs already existed). - learnings.md: the durable half — a per-item swallow plus a count that ignores skips makes a validator's own bug invisible, and persistence replays it. Refs trinity-enterprise#89 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: GitHub contents-API contract + live catalog surface for ent#89 Two gaps the /update-tests coverage checklist surfaced: - Every other create-path-fetch test stubs `_fetch_template_yaml_result`, i.e. BELOW the HTTP layer — so a wrong param name (`?ref=` is what pins the revision) or a dropped Authorization header would leave them all green while the feature silently read the default branch, or read nothing for a private repo. That is precisely the R2 failure this fetch exists to prevent, so it is now asserted at the wire, including the ent#123 tokenless case sending no Authorization header at all. - The unit suite proves both BUILDERS emit `schedules`/`schedule_errors`; only a live call proves the router serves them, that entries are the normalized shape, and that a template reporting errors is still LISTED — the ent#128/#1835 property, which now also covers the two newly-fenced GitHub list paths. Unit run: 208 passed across the four ent#89-touched unit files. Refs trinity-enterprise#89 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(flows): sync system-manifest.md with the ent#89 schedule dedupe /sync-feature-flows caught what the plan had deliberately trimmed: the `create_schedules()` section documents a function whose behaviour this change alters. Its docstring block gave no hint that the function is now the SECOND schedule producer for the same agent. Adds the name-match skip, why it exists (no UNIQUE(agent_name, name) index and adding one is a dual-track schema change that would fail on installs already holding duplicates), that the skip does not overwrite the template's row including its `enabled` value, and that an unreadable existing set fails open. Index row now lists the flow too. Noted, not acted on: `feature-flows.md` Recent Updates carries 65 dated rows against the ~20 cap #1360 set — pre-existing drift, out of scope here. Refs trinity-enterprise#89 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(templates): sanitize the create-path fetch's failure reason (ent#89) `fetch_template_metadata_for_create`'s WARNING interpolated `reason` raw while its two neighbours on the same call are `_sanitize_for_warning`-wrapped. The value embeds `str(e)`, and an httpx error message carries the request URL — i.e. the caller-supplied `owner/repo`. A repo with no `/` skips `_GITHUB_REPO_PATH_RE` upstream, so control bytes do reach that line. Bounded at 200 rather than the 80 default: this WARNING exists to be diagnosable, and an 80-char truncation defeats its purpose. Raised as a code-consistency item by the /review pass; the CSO diff audit discarded it as a finding under hard exclusion #9 (log spoofing), so this is hygiene inside one call, not a security fix. Tests pin both halves (control-char stripping and the length bound) and both fail without the change. Also renames a stale `_template_schedule_errors()` reference in the requirements doc and cites #1945 for the autonomy-clobber follow-up the doc previously promised without an issue number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(security): CSO diff audit for ent#89 template-declared schedules PASS with 1 MEDIUM (LLM cost amplification: declared schedules meeting the pre-existing `set_autonomy_status` clobber). No CRITICAL, no HIGH. The finding is an amplification of a pre-existing mechanism — there has never been a per-agent schedule cap — so it is filed as #1945 rather than changing this PR. Matches the convention of the 98 reports already tracked here; the file's Trend section is only meaningful alongside its siblings. 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>
PR-A of Abilityai/trinity-enterprise#128 — closes AC #5 on its own. The declaration standard (AC #1–4) is PR-B and re-gates after this merges.
Why now
Three live bugs on
dev, each reproduced by execution on4d743fdbbefore any fix. They need no schema decision, which is why they were split out ahead of the standard itself.1. One malformed template 500s the entire catalog
_build_local_template:252diddata.get("credentials", {}).get("mcp_servers", {}).keys(). Thetry/exceptwrapped only the YAML load and theisinstanceguard checked only thatdatawas a dict, so six distinct shapes escaped uncaught throughget_local_templates→routers/templates.py→ HTTP 500, empty catalog, every template gone:credentials:is a list / string / scalarAttributeError: 'list' object has no attribute 'get'credentials:present but nullAttributeError— the.get(..., {})default applies only when the key is absentcredentials.mcp_serversis[]/ null / strAttributeError: … has no attribute 'keys'_build_template(GitHub) — noisinstanceguard at allAttributeErroron list, str and intRecursionError— aRuntimeError, so it escapesexcept yaml.YAMLErrorentirelyThe GitHub path is the worst of them: it is the untrusted source (
_fetch_template_yamlreturnssafe_load(...) or {}, and a top-level list is truthy), and it had none of the protection the local path had.2. A string
env_filesilently corrupts the agent's.envForgetting the list dash is an ordinary authoring typo:
generate_credential_files:633iterated it character by character:Fifteen single-letter variables, the real credential never written, and no error, no warning, no crash. The agent boots and its MCP server fails at first use with nothing pointing at the cause. Per "zero silent failures" this outranks the crash above — a crash is visible, this is not.
3. 🔴
config_files[].pathis an arbitrary-file-write primitivegenerate_credential_filesbuiltfiles[file_path]from an author-controlledpath;_stage_config_filesthen didmkdir(parents=True)+open(w)with no normalization. Executed:Reachable from untrusted input:
deploy_local_agent_logicaccepts a base64tar.gzfrom any authenticated user, lands it in/data/deployed-templates, and_resolve_local_templatefeeds it straight togenerate_credential_files.The fix
Four tolerant readers in
template_service.pyare now the only way into the block, with two deliberately opposite contracts:credential_shape_errors(block){}= zero credentials, not an errorcredential_mcp_server_names(block)[]for any odd shape at either levelcredential_env_file_names(block).envwriterCredentialDeclarationErrorcredential_errorsto the entry, log one WARNING naming the template id — and the template still lists.get_local_templatesadditionally fences each per-template build, so a future unguarded field can't regress the property.INVALID_CREDENTIAL_DECLARATION(Invariant Fix: Add missing Docker labels to system agent container #1 — services hold no HTTP concerns). The call sits before the dockertry/except, so it is not flattened to a 500.config_files[].pathrejected at the parse boundary and re-checked at the write sink (crud._safe_cred_file_path→ 400INVALID_CREDENTIAL_FILE_PATH) — validate-at-boundary-and-at-sink. Two steps, mirroring_safe_local_template_path: allowlist the raw string, then resolve and assertis_relative_to(root).Deliberate non-changes
credentials.env_filestays a names-only list. The enriched per-variable declaration lands under its own top-level key in PR-B, precisely so an already-deployed Trinity reading a newer template is structurally untouched — no floor version required.['aistudio','trinity']rather than its three real servers. That flip (and the never-renderingrequired_credentialsbadge) is PR-B — this PR adds no behaviour change beyond crash-avoidance.{}remain a valid zero-credential contract: the ent#124 starter trio ships exactly that, and a commented-out block must not acquire a spurious warning.Verification
tests/unit/test_ent128a_catalog_resilience.py— 33 pass here, 31 fail onorigin/dev. The two headline cases fail with the exact bug signatures:AttributeError: 'list' object has no attribute 'get', and the literalO=\nP=\nE=\nN=output. That failure-on-dev is the proof these are real bugs, not defensive padding.04d11cf2). All six CIpytestjobs (3 seeds x base/head) pass./verify-local:status: pass- image build + in-imageimport main(8s), boot + health (12s), and 70 integration passed / 13 skipped against the booted stack. Run with--skip-agent, justified: this touches zerodocker/base-image/**files. Honest caveat: that run predates the rebase onto04d11cf2. The PR's own delta is byte-identical across the rebase (same file set, same per-file numstat, 1029 diff lines both sides), but the base moved 15 commits - including bug: container logs are unbounded — no json-file max-size anywhere (compose, agent SDK, or daemon); filled the disk and wedged dockerd on 2026-07-27 #1871's compose changes - so the image-level result has not been re-verified post-rebase.py/path-injectionalerts (#253, #254) are dismissed as verified false positives. The tainted term isroot, not the template-declared path: the SARIF source isrouters/agents.py:436(theconfigrequest body) ->config.name->crud.py:1066Path(f"/tmp/agent-{config.name}-creds"), passed in asroot. That construction line is unchanged fromdev- the PR introduces no new exposure; adding the guard only moved.resolve()onto new lines, which is what scored it as "new alert in changed code".config.nameis sanitized on all four routes into the sink:create_agent_internal:2052writessanitize_agent_nameback ontoconfig.name(so the/tmpjoin reads the sanitized value);deploy.py:442sanitizes, thenget_next_version_nameappends only-<int>;system_service.py:131validates^[a-z0-9][a-z0-9-]*[a-z0-9]$andresolve_agent_names:259composes{system}-{short}from two validated halves; the Cornelius seeder passes a hardcoded name with sanitization on.sanitize_agent_name(helpers.py:244) maps every character outside[a-zA-Z0-9_.-]to-, so no path separator survives and a bare..strips to""-> 400 atcrud.py:2054; verified by execution over 411 payloads (traversal, percent-encoded, unicode solidus, NUL, every byte 0-255) with zero cases where the resolved directory was not a single component directly under/tmp. Precedent: the identical taint at the identical construction site is already dismissed ondev(bug: Schedule model clear does not null out value #110-refactor: Split system_service.py — export_manifest CC=39, validate_manifest CC=35 #114,crud.py994/999/1000/1016/1020, same rationale), anddev'scrud.py:778carries an in-source note that this block re-fingerprints these alerts on any refactor (Nonexistent local: template silently creates an empty agent (200) instead of failing — github: path correctly 400s #1793 reverted its own guard-clause change for exactly this). Control proving it is the tainted value and not the guard form:_safe_local_template_pathuses the byte-identicalresolve()+is_relative_to(root)pattern ~50 lines above and is not flagged - itsrootis a module-level constant.devalready carries 73 open alerts from this same query.tests/lint_sys_modules.pyclean (new tests use themonkeypatch.setitemform).Known follow-ups (intentionally out of scope)
docker/base-image/agent_server/routers/info.py:191has the same live crash onGET /api/template/info(Info tab + brain-orb route guard). Left for PR-B on purpose: it lives in the agent base image, so fixing it here would drag a base-image rebuild into this PR and invalidate the--skip-agentverification envelope.crud.py:739— the same reach-through, swallowed by a broadexceptthat silently loses the agent'sruntime:/shared_folders:config alongside it.config_filesis hardened here, not documented; the plan's settled decision is to remove the feature in PR-B rather than publish a file-write primitive in a contract aimed at third-party authors. Zero of 25 bundled templates use it. Wants a 👍 from @vybe before deletion, since it is a public behaviour change.Docs
docs/memory/feature-flows/template-processing.md(new "Malformedcredentials:resilience" section + the response-schema field),architecture.mdcatalog line,feature-flows.mdindex row.No DB change → the dual-track migration rule (#9) does not apply.