Skip to content

fix(templates): stop a malformed credentials: block emptying the catalog (ent#128 PR-A) - #1835

Merged
vybe merged 2 commits into
devfrom
feature/ent128-installable-agent-standard
Jul 30, 2026
Merged

fix(templates): stop a malformed credentials: block emptying the catalog (ent#128 PR-A)#1835
vybe merged 2 commits into
devfrom
feature/ent128-installable-agent-standard

Conversation

@AndriiPasternak31

@AndriiPasternak31 AndriiPasternak31 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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.

Cross-repo Fixes does not fire, so this only references the issue. Close it manually at the release cut.

Why now

Three live bugs on dev, each reproduced by execution on 4d743fdb before 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:252 did data.get("credentials", {}).get("mcp_servers", {}).keys(). The try/except wrapped only the YAML load and the isinstance guard checked only that data was a dict, so six distinct shapes escaped uncaught through get_local_templatesrouters/templates.pyHTTP 500, empty catalog, every template gone:

Trigger Result
credentials: is a list / string / scalar AttributeError: 'list' object has no attribute 'get'
credentials: present but null AttributeError — the .get(..., {}) default applies only when the key is absent
credentials.mcp_servers is [] / null / str AttributeError: … has no attribute 'keys'
_build_template (GitHub) — no isinstance guard at all AttributeError on list, str and int
deeply nested YAML RecursionError — a RuntimeError, so it escapes except yaml.YAMLError entirely

The GitHub path is the worst of them: it is the untrusted source (_fetch_template_yaml returns safe_load(...) or {}, and a top-level list is truthy), and it had none of the protection the local path had.

2. A string env_file silently corrupts the agent's .env

Forgetting the list dash is an ordinary authoring typo:

credentials:
  env_file: "OPENAI_API_KEY"     # meant: - OPENAI_API_KEY

generate_credential_files:633 iterated it character by character:

.env:  O=\nP=\nE=\nN=\nA=\nI=\n_=\nA=\nP=\nI=\n_=\nK=\nE=\nY=

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[].path is an arbitrary-file-write primitive

generate_credential_files built files[file_path] from an author-controlled path; _stage_config_files then did mkdir(parents=True) + open(w) with no normalization. Executed:

path='/tmp/absolute-pwned.txt'      -> writes /tmp/absolute-pwned.txt   (absolute wins the join)
path='../../../../../tmp/pwned.txt' -> escapes the staging directory

Reachable from untrusted input: deploy_local_agent_logic accepts a base64 tar.gz from any authenticated user, lands it in /data/deployed-templates, and _resolve_local_template feeds it straight to generate_credential_files.

The fix

Four tolerant readers in template_service.py are now the only way into the block, with two deliberately opposite contracts:

Helper Contract
credential_shape_errors(block) Named errors. Never raises. Absent / null / {} = zero credentials, not an error
credential_mcp_server_names(block) [] for any odd shape at either level
credential_env_file_names(block) Non-empty strings only — backs the .env writer
CredentialDeclarationError HTTP-free domain error, raised by the write path only
  • Read paths never raise. Degrade the derived field, attach credential_errors to the entry, log one WARNING naming the template id — and the template still lists. get_local_templates additionally fences each per-template build, so a future unguarded field can't regress the property.
  • The write path fails loud. Validate first, raise, and the only caller maps it 1:1 to 400 INVALID_CREDENTIAL_DECLARATION (Invariant Fix: Add missing Docker labels to system agent container #1 — services hold no HTTP concerns). The call sits before the docker try/except, so it is not flattened to a 500.
  • config_files[].path rejected at the parse boundary and re-checked at the write sink (crud._safe_cred_file_path → 400 INVALID_CREDENTIAL_FILE_PATH) — validate-at-boundary-and-at-sink. Two steps, mirroring _safe_local_template_path: allowlist the raw string, then resolve and assert is_relative_to(root).

Deliberate non-changes

  • credentials.env_file stays 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.
  • MCP-server precedence is unchanged, so cornelius still reports ['aistudio','trinity'] rather than its three real servers. That flip (and the never-rendering required_credentials badge) is PR-B — this PR adds no behaviour change beyond crash-avoidance.
  • Absent / null / {} 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 on origin/dev. The two headline cases fail with the exact bug signatures: AttributeError: 'list' object has no attribute 'get', and the literal O=\nP=\nE=\nN= output. That failure-on-dev is the proof these are real bugs, not defensive padding.
  • Includes a parity test over the shipped bundle, so a malformed bundled template fails CI instead of a user's fresh install.
  • Full unit suite: 5699 passed, 14 skipped, 2 xfailed, 0 failed on the rebased branch (base 04d11cf2). All six CI pytest jobs (3 seeds x base/head) pass.
  • /verify-local: status: pass - image build + in-image import main (8s), boot + health (12s), and 70 integration passed / 13 skipped against the booted stack. Run with --skip-agent, justified: this touches zero docker/base-image/** files. Honest caveat: that run predates the rebase onto 04d11cf2. 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.
  • CodeQL: gate green - the 2 py/path-injection alerts (#253, #254) are dismissed as verified false positives. The tainted term is root, not the template-declared path: the SARIF source is routers/agents.py:436 (the config request body) -> config.name -> crud.py:1066 Path(f"/tmp/agent-{config.name}-creds"), passed in as root. That construction line is unchanged from dev - 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.name is sanitized on all four routes into the sink: create_agent_internal:2052 writes sanitize_agent_name back onto config.name (so the /tmp join reads the sanitized value); deploy.py:442 sanitizes, then get_next_version_name appends only -<int>; system_service.py:131 validates ^[a-z0-9][a-z0-9-]*[a-z0-9]$ and resolve_agent_names:259 composes {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 at crud.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 on dev (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.py 994/999/1000/1016/1020, same rationale), and dev's crud.py:778 carries 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_path uses the byte-identical resolve() + is_relative_to(root) pattern ~50 lines above and is not flagged - its root is a module-level constant. dev already carries 73 open alerts from this same query.
  • tests/lint_sys_modules.py clean (new tests use the monkeypatch.setitem form).
  • Smoke against the real 15-template bundle: a dropped-in malformed template lists with its named error, zero collateral on the other 15.

Known follow-ups (intentionally out of scope)

  • docker/base-image/agent_server/routers/info.py:191 has the same live crash on GET /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-agent verification envelope.
  • crud.py:739 — the same reach-through, swallowed by a broad except that silently loses the agent's runtime: / shared_folders: config alongside it.
  • config_files is 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.
  • YAML-level hardening (alias-bomb serialization amplification, duplicate-key silence) is pre-existing and platform-wide; the gate settled it as its own security issue, not part of Startup recovery for regular task executions #128.

Docs

docs/memory/feature-flows/template-processing.md (new "Malformed credentials: resilience" section + the response-schema field), architecture.md catalog line, feature-flows.md index row.

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

Comment thread src/backend/services/agent_service/crud.py Fixed
Comment thread src/backend/services/agent_service/crud.py Fixed
Comment thread src/backend/services/agent_service/crud.py Dismissed
Comment thread src/backend/services/agent_service/crud.py Dismissed
@github-actions

Copy link
Copy Markdown

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

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

@AndriiPasternak31
AndriiPasternak31 force-pushed the feature/ent128-installable-agent-standard branch from 0543899 to ffba058 Compare July 28, 2026 22:26
@AndriiPasternak31
AndriiPasternak31 marked this pull request as ready for review July 28, 2026 23:40
@AndriiPasternak31
AndriiPasternak31 requested a review from vybe July 28, 2026 23:40
AndriiPasternak31 and others added 2 commits July 29, 2026 17:48
…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>
@AndriiPasternak31
AndriiPasternak31 force-pushed the feature/ent128-installable-agent-standard branch from ffba058 to c07afab Compare July 29, 2026 16:56

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 raises CredentialDeclarationError, which is correct: generating an agent's .env from a declaration nobody can parse is precisely the silent corruption being closed.
  • CredentialDeclarationError is HTTP-free and mapped 1:1 at its only caller (crud.py::_stage_config_files → named 400 INVALID_CREDENTIAL_DECLARATION). That respects Invariant #1 rather than reaching for HTTPException inside a service.
  • The _build_template guard closes the worse half. The GitHub path is the untrusted source and had no isinstance guard at all, while _build_local_template always did — and _fetch_template_yaml returns safe_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_errors and re-checked at the write sink by _safe_cred_file_path. Since deploy_local_agent_logic lets any authenticated user upload a template archive, config_files[].path really was an arbitrary-file-write primitive; the allowlist-then-resolve shape mirrors the existing _safe_local_template_path precedent 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 bare credentials: with nothing under it yielded None and 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#128 is 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 to status-in-dev; it correctly stays status-in-progress until 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 MERGEABLE against current dev.

@vybe
vybe merged commit 8d59b6c into dev Jul 30, 2026
22 checks passed
vybe pushed a commit that referenced this pull request Aug 3, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants