Skip to content

feat(skills): multi-source skills library — bundled community repo + per-instance custom repos (abilityai/trinity-enterprise#237) - #1901

Merged
obasilakis merged 21 commits into
devfrom
feature/ent-237-multi-source-skills
Aug 5, 2026
Merged

feat(skills): multi-source skills library — bundled community repo + per-instance custom repos (abilityai/trinity-enterprise#237)#1901
obasilakis merged 21 commits into
devfrom
feature/ent-237-multi-source-skills

Conversation

@obasilakis

@obasilakis obasilakis commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Turns the skills library from one admin-configured repo into many: a bundled public community catalog plus any number of admin-added custom repos. skill_sources replaces the single skills_library_url setting; skill_service orchestrates N SkillSourceClones under /data/skills-library/<source_id>/ instead of being one clone itself.

Implements abilityai/trinity-enterprise#237. All four AC-flagged design decisions were taken explicitly and are recorded below with the alternative that was rejected.

The decisions

AC#4 — name collisions resolve custom-wins, names stay bare. Resolution is priority ASC then created_at ASC (custom 100, bundled 1000). Prefixing (community/pdf-export) was rejected because the agent-side identity is the directory .claude/skills/<name>/, and both the ent#139 runner and the ent#178 A2A card resolve by bare name — a prefix changes every agent's /skill-name invocation string and needs a fleet-wide re-inject. agent_skills.source_id records which source a name resolved to (it is not part of the UNIQUE — two sources' copies cannot coexist on disk anyway).

Never a silent overwrite: the winner carries shadowed_by, surfaced in the library listing, the Settings panel, and as a shadowed_source:<name> warning at inject time.

AC#5 — the bundled source pins to a tag, and a moved tag is refused. Skills carry executable scripts/ (ent#183) that ent#139 runs, and ent#236 makes syncing automatic — a branch-tracking community source puts every merged upstream commit on every install with no human in the loop. Git does not enforce tag immutability, so a tag resolving to a different commit than the last sync fails as moved_tag. Two independent mechanisms: the fetch omits --force (git refuses to clobber a moved tag), plus an explicit SHA comparison for the fresh-clone case where no local ref exists to conflict. Custom sources keep tracking a branch.

AC#7 — all OSS-core (vybe's call, here). One seam per area: distribution free, execution paid via the existing skill_runner entitlement. No requires_entitlement, no private module, no gated Vue.

AC#6 — existing installs migrate losslessly. A configured skills_library_url is adopted as a custom source (custom, not default — precedence must keep preferring the repo the operator actually chose). Row written before the clone moves, so a crash between them leaves a source that simply re-clones; the reverse order strands a checkout no row points at.

AC#1 (create the public repo) is split to abilityai/trinity-enterprise#296 — repo administration plus a content-curation call, not Trinity code.

Auth boundary

Every route under /api/skills/ that mutates or reads source configuration carries reject_agent_principal in addition to require_admin — the four source routes, the LIST route, and both sync routes. require_admin answers what role, never is this a human: an agent-scoped MCP key resolves to its owner carrying the owner's role (abilityai/trinity-enterprise#293).

The rule is gate on what the route does, not on which verb it is — arrived at the hard way, because the grant-vs-use framing mis-classified two routes and both had to be corrected:

  • LIST, caught by /review on this branch: "read" says nothing about the private repo URLs it returns.
  • Both sync routes, caught by @dolho in review: originally role-gated as "use, not grant", but a sync clones executable material and, when the commit moves with auto-reinject on, spawns run_fleet_reinject — pushing skill scripts/ to every running agent. Fleet-wide executable delivery is not "use".

The static guard that pins this was itself checkable-by-comment (it grepped ast.dump(fn), which renders docstrings) — found by mutation-testing it, now matches an ast.Call node.

This PR removes ent#293's step-1 target (skills_library_url), but does not close that issue — the generic settings gate is the actual defect.

Ordering note

The default source points at abilityai/trinity-skills, which does not exist until abilityai/trinity-enterprise#296 lands. Sync is fail-soft (never raises), so a fresh install shows one failed source rather than a populated catalog until then. Deliberately not worked around in code — seeding it disabled would satisfy AC#3 on paper while leaving the library empty. Prefer landing #296 first, or accept the window knowingly.

Test plan

  • tests/unit/test_ent237_skill_sources.py — 92 tests: precedence, shadowing, handover on disable, cache invalidation, broken-source isolation, adoption, seeding, auth gates, and (review round) source-edit-reaches-disk + checkout reclamation
  • The moved-tag test builds the actual attack (force-moves an upstream tag onto a commit adding a payload) and asserts the payload never reaches disk — not merely that the call returned an error
  • Mutation-verified: neutering the pin, the agent-principal gate, or the setting-consume each fails exactly its own test
  • Fixed 27 regressions this branch introduced in the pre-existing skills tests (they exercise the single-clone API that moved)
  • Full unit suite: 7322 passed, 18 skipped, 0 failed (the two bug(tests): test_ent183_skill_packages sys.modules stubs poison later tests (order-dependent CI flake) #1898 failures are gone since the dev merge)
  • tsc --noEmit clean · vite build clean · 70 CI parity guards pass

Review round (@dolho, 2026-08-05)

Three source-lifecycle defects and one auth inconsistency, all fixed in b92c3859. Three shared one shape — the row moved and the checkout did not:

  1. Repointing url was a no-op on disk. _update_* fetches origin, written at clone time, so every later sync silently pulled the OLD repo with last_sync_status: success. Now discards + re-clones on a genuine mismatch (not remote set-url, which leaves the old repo's refs and turns a shared tag name into a permanent moved_tag refusal). Compare is credential-stripped, and an unreadable origin counts as a match — the action gated on it is an rmtree.
  2. The documented tag-bump path was unreachable. update_source now clears the sync bookkeeping on a url/ref/ref_type change. Explicitly not on name/enabled/priority: a cleared baseline on disable would let a tag moved during the disabled window be adopted silently on re-enable — the AC#5 bypass via a checkbox, and the enabled toggle is the only edit the UI currently wires to PUT.
  3. Deleting a source leaked its checkout. Reclaimed on delete (row first, disk second), with a fail-closed orphan sweep on full syncs as the backstop and the only reclamation path for installs that already accumulated clones.
  4. Both sync routes gated human-only — see Auth boundary above.

20 new tests. Each fix mutation-verified to fail exactly its own test.

Not included

The per-skill source badge in SkillsPanel.vue#1877 is rewriting that file, so editing it here would hand @dolho a conflict. The backend already exposes source_name/shadowed_by; noted on that PR.

Refs abilityai/trinity-enterprise#237

🤖 Generated with Claude Code

Comment thread src/backend/services/skill_service.py Fixed
Comment thread src/backend/services/skill_service.py Fixed
Comment thread src/backend/routers/skills.py Fixed
obasilakis added a commit that referenced this pull request Jul 30, 2026
CodeQL flagged 4 new alerts on PR #1901; this addresses the actionable one and
relocates the credential guard so it is testable.

**py/incomplete-url-substring-sanitization (high)** — `_authenticated_url`
decided whether to splice the platform GitHub PAT with `"github.com" in url`,
then spliced via `url.replace("https://", f"https://{pat}@")`. A substring test
is satisfied by `https://evil.example/?x=github.com`, so that pair would have
sent a live GitHub credential to an attacker host.

Not reachable today — `sync_library` validates every source URL against the
github.com allowlist first — but "safe only because a caller three frames up
validates" is exactly the property that breaks when a caller is added, and the
blast radius is a live PAT. Now: shorthand is normalised to an absolute https
URL first, the host is PARSED, and the splice happens only on an exact match
against the same `ALLOWED_SKILLS_LIBRARY_HOSTS` the SSRF guard uses. Rebuilt via
urlunparse rather than str.replace, which would also rewrite a second
"https://" occurrence inside a path or query.

Also moves the embedded-credential guard from `routers/skills.py` into
`utils/url_validation.py` as `reject_embedded_credentials` +
`EmbeddedCredentialError`. Two reasons: it is URL policy and belongs with URL
policy, and testing it через the router required importing the whole `routers`
package, which drags in the agent-service chain and collapsed under another
module's import-time stubs. A leaf module is importable from anywhere. The
router now maps the domain error to its 400.

The remaining CodeQL alerts are pre-existing on dev (a test file I did not
touch, and a path-injection alert on the `_skill_dir` chokepoint whose realpath
containment is the documented guard, now applied per-source).

Test-ordering fixes forced by the same stub fragility (#1898):
the ent#183 stub of `utils.url_validation` now mirrors every name
`skill_service` imports — a missing constant is an ImportError at collection,
not graceful degradation — and this file gets an autouse fixture that evicts
detectable stubs (a stub has no `__file__`) so its results do not depend on
which file pytest runs first.

159 tests across the five skills/SSRF files pass in the polluted ordering.

Refs Abilityai/trinity-enterprise#237
Comment thread src/backend/services/skill_service.py Fixed
@obasilakis obasilakis closed this Jul 30, 2026
@obasilakis obasilakis reopened this Jul 30, 2026
obasilakis added a commit that referenced this pull request Jul 30, 2026
…ules footgun (ent#237)

CodeQL flagged 4 new alerts on PR #1901; this addresses the actionable one and
relocates the credential guard so it is testable.

**py/incomplete-url-substring-sanitization (high)** — `_authenticated_url`
decided whether to splice the platform GitHub PAT with `"github.com" in url`,
then spliced via `url.replace("https://", f"https://{pat}@")`. A substring test
is satisfied by `https://evil.example/?x=github.com`, so that pair would have
sent a live GitHub credential to an attacker host.

Not reachable today — `sync_library` validates every source URL against the
github.com allowlist first — but "safe only because a caller three frames up
validates" is exactly the property that breaks when a caller is added, and the
blast radius is a live PAT. Now: shorthand is normalised to an absolute https
URL first, the host is PARSED, and the splice happens only on an exact match
against the same `ALLOWED_SKILLS_LIBRARY_HOSTS` the SSRF guard uses. Rebuilt via
urlunparse rather than str.replace, which would also rewrite a second
"https://" occurrence inside a path or query.

Moves the embedded-credential guard from `routers/skills.py` into
`utils/url_validation.py` as `reject_embedded_credentials` +
`EmbeddedCredentialError`. It is URL policy and belongs with URL policy, and
testing it through the router required importing the whole `routers` package,
which drags in the agent-service chain and collapsed under another module's
import-time stubs. A leaf module is importable from anywhere.

The remaining CodeQL alerts are pre-existing on dev (a test file untouched here,
and a path-injection alert on the `_skill_dir` chokepoint whose realpath
containment is the documented guard, now applied per-source).

Test-ordering fixes forced by the same stub fragility (#1898):
the ent#183 stub of `utils.url_validation` now mirrors every name
`skill_service` imports — a missing constant is an ImportError at collection,
not graceful degradation — and this file gets an autouse fixture that evicts
detectable stubs (a stub has no `__file__`) so results do not depend on which
file pytest runs first.

That fixture uses `monkeypatch.delitem`, NOT a bare `del sys.modules[...]`:
`tests/lint_sys_modules.py` exists to stop exactly that pattern, and working
around sys.modules pollution by polluting sys.modules is how this file would
have become the next #1898. The eviction is undone at teardown.

Squashed with its follow-up because a test-only commit matches no workflow path
filter, so CI never ran on it.

Refs Abilityai/trinity-enterprise#237
@obasilakis
obasilakis force-pushed the feature/ent-237-multi-source-skills branch from 25d74a5 to c60b393 Compare July 30, 2026 22:05
vybe pushed a commit that referenced this pull request Jul 31, 2026
… library (ent#263)

- stores/skillsLibrary.js (new): fleet-scoped store, deliberately separate
  from stores/skills.js (KeepAlive-cached AgentDetail means SkillsPanel's
  clear() never fires on nav-away — shared refs would poison the cached tab);
  imports nothing from stores/skills.js. 4-state emptyReason discriminator
  (unconfigured/not_cloned/empty + error carried separately); sync() with a
  180s timeout and ECONNABORTED -> status-refetch (a first clone can outlive
  the 30s api.js default; client timeout != server failure)
- components/LibrarySkillsSection.vue (new): sync-state header leads with
  commit_sha + skill_count (disk-derived; last_sync is per-worker in-memory
  and renders only when truthy); repo URL admin-only, userinfo-stripped,
  labeled 'Primary source', hidden when status.sources reports >1 (#1901
  forward-compat); admin Sync now; per-kind empty states teaching the next
  action; dormant source_name/shadowed_by slots; interpolation only
- components/skills/{SkillContractChips.vue,contract.js} (new): the #183
  contract-chips seam extracted from SkillsPanel so both the per-agent tab
  and the Library browse render package facts from one seam
- SkillsPanel.vue: consumes the shared seam (local SkillMeta/formatBytes/deps
  removed); stores/skills.js untouched
- Library.vue: skills section wired in + header jump anchors (no ?kind=)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Jul 31, 2026
…endpoint rot sweep (ent#263)

- requirements/core-agent.md: new §4.5 Library Page — unified /library surface
  (agent templates + fleet skills browse), query+hash-preserving /templates
  redirect, stacked sections, per-kind empty states, the AC#4 page-identity
  naming rule; fleet assignment visibility named as Not Built
- requirements/skills.md (surgical — §21.3/§22.2/new §22.3 only, avoiding PR
  #1901's §21.1/§21.5 hunks): §21.3 stale 'Skills tab is hidden' note corrected
  (visible since ent#235/PR #1877); §22.2 rewritten as visible/rebuilt; new
  §22.3 Library Page fleet skills browse — browse-only over the existing
  /api/skills/library reads, own skillsLibrary store + the KeepAlive rationale,
  admin-only URL/Sync, #1901 forward-compat, assignment read = Not Built
- architecture.md: 'Top-nav IA — Library (ent#263)' paragraph beside the #1109
  Operations one; stale 'Templates (4 endpoints)' table corrected to the 2 real
  routes (POST /refresh AND GET /env-template both verified absent)
- feature-flows: templates-page.md git-mv'd to library-page.md + full rewrite
  (the old file was deeply stale — AgentSubNav, dead endpoints); index row +
  platform-settings.md Related-Flows link repointed
- template-processing.md + CREDENTIAL_MANAGEMENT.md: dead env-template
  endpoint references removed/replaced (same rot class as the architecture
  table); Templates.vue references repointed at Library.vue
- user docs (creating-agents.md, faq/agents.md): Templates page → Library
  (+ redirect note)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@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.

@obasilakis

Copy link
Copy Markdown
Contributor Author

reopening to re-trigger CI — the original push landed during a repo-wide Actions dispatch gap (nothing ran anywhere in the repo between 2026-07-30T21:19Z and 2026-07-31T09:22Z)

@obasilakis obasilakis closed this Jul 31, 2026
@obasilakis obasilakis reopened this Jul 31, 2026
obasilakis added a commit that referenced this pull request Jul 31, 2026
…ules footgun (ent#237)

CodeQL flagged 4 new alerts on PR #1901; this addresses the actionable one and
relocates the credential guard so it is testable.

**py/incomplete-url-substring-sanitization (high)** — `_authenticated_url`
decided whether to splice the platform GitHub PAT with `"github.com" in url`,
then spliced via `url.replace("https://", f"https://{pat}@")`. A substring test
is satisfied by `https://evil.example/?x=github.com`, so that pair would have
sent a live GitHub credential to an attacker host.

Not reachable today — `sync_library` validates every source URL against the
github.com allowlist first — but "safe only because a caller three frames up
validates" is exactly the property that breaks when a caller is added, and the
blast radius is a live PAT. Now: shorthand is normalised to an absolute https
URL first, the host is PARSED, and the splice happens only on an exact match
against the same `ALLOWED_SKILLS_LIBRARY_HOSTS` the SSRF guard uses. Rebuilt via
urlunparse rather than str.replace, which would also rewrite a second
"https://" occurrence inside a path or query.

Moves the embedded-credential guard from `routers/skills.py` into
`utils/url_validation.py` as `reject_embedded_credentials` +
`EmbeddedCredentialError`. It is URL policy and belongs with URL policy, and
testing it through the router required importing the whole `routers` package,
which drags in the agent-service chain and collapsed under another module's
import-time stubs. A leaf module is importable from anywhere.

The remaining CodeQL alerts are pre-existing on dev (a test file untouched here,
and a path-injection alert on the `_skill_dir` chokepoint whose realpath
containment is the documented guard, now applied per-source).

Test-ordering fixes forced by the same stub fragility (#1898):
the ent#183 stub of `utils.url_validation` now mirrors every name
`skill_service` imports — a missing constant is an ImportError at collection,
not graceful degradation — and this file gets an autouse fixture that evicts
detectable stubs (a stub has no `__file__`) so results do not depend on which
file pytest runs first.

That fixture uses `monkeypatch.delitem`, NOT a bare `del sys.modules[...]`:
`tests/lint_sys_modules.py` exists to stop exactly that pattern, and working
around sys.modules pollution by polluting sys.modules is how this file would
have become the next #1898. The eviction is undone at teardown.

Squashed with its follow-up because a test-only commit matches no workflow path
filter, so CI never ran on it.

Refs Abilityai/trinity-enterprise#237
@obasilakis
obasilakis force-pushed the feature/ent-237-multi-source-skills branch from c60b393 to 3a58b05 Compare July 31, 2026 09:41
obasilakis added a commit that referenced this pull request Jul 31, 2026
…ules footgun (ent#237)

CodeQL flagged 4 new alerts on PR #1901; this addresses the actionable one and
relocates the credential guard so it is testable.

**py/incomplete-url-substring-sanitization (high)** — `_authenticated_url`
decided whether to splice the platform GitHub PAT with `"github.com" in url`,
then spliced via `url.replace("https://", f"https://{pat}@")`. A substring test
is satisfied by `https://evil.example/?x=github.com`, so that pair would have
sent a live GitHub credential to an attacker host.

Not reachable today — `sync_library` validates every source URL against the
github.com allowlist first — but "safe only because a caller three frames up
validates" is exactly the property that breaks when a caller is added, and the
blast radius is a live PAT. Now: shorthand is normalised to an absolute https
URL first, the host is PARSED, and the splice happens only on an exact match
against the same `ALLOWED_SKILLS_LIBRARY_HOSTS` the SSRF guard uses. Rebuilt via
urlunparse rather than str.replace, which would also rewrite a second
"https://" occurrence inside a path or query.

Moves the embedded-credential guard from `routers/skills.py` into
`utils/url_validation.py` as `reject_embedded_credentials` +
`EmbeddedCredentialError`. It is URL policy and belongs with URL policy, and
testing it through the router required importing the whole `routers` package,
which drags in the agent-service chain and collapsed under another module's
import-time stubs. A leaf module is importable from anywhere.

The remaining CodeQL alerts are pre-existing on dev (a test file untouched here,
and a path-injection alert on the `_skill_dir` chokepoint whose realpath
containment is the documented guard, now applied per-source).

Test-ordering fixes forced by the same stub fragility (#1898):
the ent#183 stub of `utils.url_validation` now mirrors every name
`skill_service` imports — a missing constant is an ImportError at collection,
not graceful degradation — and this file gets an autouse fixture that evicts
detectable stubs (a stub has no `__file__`) so results do not depend on which
file pytest runs first.

That fixture uses `monkeypatch.delitem`, NOT a bare `del sys.modules[...]`:
`tests/lint_sys_modules.py` exists to stop exactly that pattern, and working
around sys.modules pollution by polluting sys.modules is how this file would
have become the next #1898. The eviction is undone at teardown.

Squashed with its follow-up because a test-only commit matches no workflow path
filter, so CI never ran on it.

Refs Abilityai/trinity-enterprise#237
@obasilakis
obasilakis force-pushed the feature/ent-237-multi-source-skills branch from 3a58b05 to 4ed3c40 Compare July 31, 2026 09:43
Replaces the single `skills_library_url` setting with a `skill_sources` table:
one row per git repo the library syncs from, so an install can carry the
bundled community catalog AND its own repo(s) at once.

Two AC-flagged design decisions are encoded here:

* Custom-wins precedence, bare names (AC#4). Resolution is `priority` ASC then
  `created_at` ASC; custom sources default to 100 and the bundled source to
  1000. Names stay bare because the agent-side identity IS the directory
  `.claude/skills/<name>/`, and both the ent#139 runner and the ent#178 A2A
  card resolve by bare name — prefixing would change every agent's invocation
  string and require a fleet-wide re-inject. `agent_skills.source_id` RECORDS
  which source a name resolved to (so a cross-source swap is detectable) but
  is deliberately not part of the UNIQUE, which would permit two rows that
  cannot coexist on disk.

* Branch-vs-tag refs (AC#5). `ref`/`ref_type` let the bundled source pin to a
  tag while custom sources track a branch.

At most one default source, enforced by a partial-unique index rather than a
read-then-write check, so a concurrent second worker loses at the DB.
`is_default` is immutable: promoting a custom source would change its trust
posture without changing where it points.

Deleting a source does not cascade to assignments — the skill keeps resolving
by bare name through whatever source still provides it, and cascading would
silently unassign skills that are still available.

Adopting an existing `skills_library_url` is NOT done in the migration: the
legacy clone at /data/skills-library/ must move into a per-source subdir in the
same operation, so it belongs in skill_service where both halves succeed or
fail together.

Dual-track per invariant #9: SQLite `skill_sources_table` + Alembic
0031_skill_sources, with schema.py DDL and tables.py MetaData kept consistent.

Refs Abilityai/trinity-enterprise#237
Extracts one source's git lifecycle into `SkillSourceClone` so the library can
hold N checkouts, one per `skill_sources` row, under /data/skills-library/.
Behaviour for a branch source is the old single-clone path unchanged.

The addition is tag pinning (AC#5). Skills carry executable `scripts/`
(ent#183) that the ent#139 runner executes, and ent#236 makes syncing
automatic — so a branch-tracking source puts every merged upstream commit on
every install with no human in the loop. The bundled community source pins to
a tag; custom sources, whose write access the operator controls, keep tracking
a branch.

A pin is only worth as much as the tag's immutability, which git does not
enforce, so a tag resolving to a different commit than the last sync is
refused as `moved_tag` rather than adopted. Two independent mechanisms: the
fetch deliberately omits `--force` (git then refuses to clobber a moved tag
ref), and an explicit SHA comparison catches the same condition on a fresh
clone where no local ref exists to conflict. Moving to new content is done by
pointing the source at a new tag NAME — an explicit admin action.

Source ids and refs become argv and directory names, so both are regex-gated
at construction rather than trusted from the DB: this blocks traversal via
either, and `-`-leading refs that would smuggle a git option. The realpath
containment check is now per-clone, closing an escape route that did not exist
with a single checkout (a symlink resolving into a *different* source's tree).

tests/unit/test_ent237_skill_sources.py builds the actual attack — upstream
force-moves a tag onto a commit adding a payload — and asserts the payload
never reaches disk, not merely that the call returned an error. Verified by
mutation: neutering the pin fails that test and only that test.

Refs Abilityai/trinity-enterprise#237
…ed (ent#237)

Makes the library actually multi-source. `skill_service` now orchestrates N
`SkillSourceClone`s instead of being one clone: sync, list, get, and inject all
resolve through the precedence order from `skill_sources`.

Resolution (AC#4): the first source offering a name owns it, and every
lower-precedence source shipping that name is recorded in `shadowed_by`. The
record is the point — a bare "first wins" with no trace is exactly the silent
overwrite AC#4 forbids. The shadowed copy is deliberately NOT a second list
entry: it is unreachable, and offering it would imply a choice the flat
`.claude/skills/<name>/` namespace cannot honour. Injection reports it too
(`shadowed_source:<name>` warning), so "I assigned Community's copy and got
Acme's" can't happen quietly.

One source failing never blinds the others: per-source sync outcomes are
reported individually and the aggregate succeeds if any source synced. With
several repos configured some will be broken at any moment, so this is the
normal case, not the edge.

`_git` in the clone now tolerates a missing directory. A source whose first
clone failed has none, and `subprocess.run(cwd=<missing>)` raises
FileNotFoundError — which escaped through `current_commit` into the list-cache
fingerprint and took down the whole merged listing over ONE unreachable repo.
Caught by test_one_broken_source_does_not_blind_the_others.

Other correctness points:
* the list cache is keyed on a fingerprint over EVERY enabled source's commit,
  not one SHA — otherwise adding/disabling/re-syncing a second source serves a
  stale merged list indefinitely
* injection resolves ONCE up front; re-resolving per skill would let a
  concurrent admin sync inject a half-and-half set
* the agent-side version marker records the OWNING source's commit plus its
  source_id — with N sources there is no single library commit, and stamping
  the wrong one makes the record unauditable
* `_skill_files`/`_parse_skill_info` take the clone explicitly rather than
  re-resolving, so describing a non-winning copy can't silently describe a
  different source's files
* realpath containment moved into the clone, applied per-source — a shared root
  would let a symlink in one source resolve into another's checkout and pass

Legacy adoption (AC#6, kept per vybe): an existing `skills_library_url` becomes
a regular CUSTOM source — custom, not default, so precedence keeps preferring
the repo the operator actually chose. DB row is written before the clone moves,
so a crash between them leaves a source that simply re-clones; the reverse
order would strand a checkout no row points at. Idempotent and fail-soft.

`get_library_status` reports per source (with each one's WON skill count, the
number that matters when sources overlap) and retains the legacy
url/branch/commit_sha fields so the pre-ent#237 MCP tool and Settings panel
keep rendering until they migrate.

11 new tests covering the merge, precedence handover on disable, cache
invalidation, broken-source isolation, and all three adoption properties.

Refs Abilityai/trinity-enterprise#237
…t#237 AC#3)

A fresh install now starts with the community catalog configured, so the
library is never empty out of the box.

Seeded as a ROW, not resolved as a code default at read time. That distinction
is the design, and it mirrors the #1638 retention-floor lesson: a read-time
default is re-applied on every boot, so it would resurrect a source the admin
deliberately deleted and would silently hand an EXISTING install a source it
never configured. A row can be deleted or disabled and stay that way.

Fresh-install detection reuses #1638's signal — `users` is empty for exactly
one moment in a database's life — so the seed runs in the same window and, like
the retention seed, must run BEFORE _ensure_admin_user (which is what makes
`users` non-empty).

* ref_type is `tag`, per AC#5: the community catalog takes PRs from strangers
  and its skills carry executables the ent#139 runner runs, so instances follow
  a tag we bump, never the branch head.
* priority is DEFAULT_SOURCE_PRIORITY (highest number = lowest precedence), so
  any custom source added later wins a name collision with no reordering (AC#4).
* INSERT OR IGNORE against the partial-unique default index: both migration
  locks fail open, so two workers can race this and must not produce two
  defaults.
* TRINITY_DEFAULT_SKILL_SOURCE="" disables it entirely, for an operator who
  wants no community catalog (mirrors TRINITY_DEFAULT_SYSTEM_MANIFEST).
* Never raises. `init_database` runs at import, so a raising seed would
  crash-loop boot permanently; a skipped seed just leaves an empty library,
  which is the pre-ent#237 status quo.

Both dialects, as invariant #9 requires: the sqlite cursor path and the
engine-based path for PostgreSQL, sharing one `_default_skill_source_values`
so the seeded row cannot drift between them.

The repo it points at is ent#296 and does not exist yet, so until that lands a
fresh install reports one failed source rather than a populated catalog —
fail-soft by design, `sync_library` never raises.

Refs Abilityai/trinity-enterprise#237
…on (ent#237)

Adds the REST surface for multi-source: list, create, update, delete, and
per-source sync, plus the SkillSourceCreate/SkillSourceUpdate models in
models.py (invariant #14).

Every MUTATING route carries `reject_agent_principal` in ADDITION to
`require_admin`, and that is load-bearing, not padding. `require_admin` answers
"what role", never "is this a human": an agent-scoped MCP key resolves to its
owner CARRYING the owner's role, so on a default admin-owned install every
agent's injected TRINITY_MCP_API_KEY satisfies it (ent#293 — the third
occurrence after trinity-ops-agent#232 → #1644#1816).

Registering a source is the GRANT action from the learnings.md grant-vs-use
distinction: it decides which repo the fleet executes code from, and skills are
instructions Claude follows. A prompt-injected agent able to add its own source
would get unattended, fleet-wide, persistent prompt injection — the exact chain
ent#293 documents, reopened through a new door. ent#237 removes that issue's
step-1 target (`skills_library_url`), so shipping these routes with only
`assert_admin` would have re-created the hole it closes.

Reading and syncing an already-configured source is USE, so those stay
role-gated only.

Other boundary decisions:
* `is_default` is absent from BOTH models — a caller must not be able to claim
  the bundled source's trust posture (tag-pinned, ours to bump) for an
  arbitrary repo. The db layer refuses it too, so adding the field back here
  cannot silently start working.
* the SSRF allowlist (#179) runs on write, so a bad URL is rejected at the
  boundary instead of surfacing later as a recurring sync failure
* distinct 409s for duplicate-(url,ref) vs default-already-exists, so the UI can
  say which
* audit is best-effort — a logging failure must not undo a completed write
* list is admin-only because the rows carry repo URLs, which for a private
  source are themselves sensitive; the per-agent Skills tab gets `source_name`
  from GET /skills/library instead, which exposes no URLs

Tests pin the gate statically (an integration test needs a live app + DB, and
this must fail the moment a new mutating route lands without it) plus a
guards-the-guard test that fails on any unreviewed POST/PUT/DELETE under
/skills/sources. Mutation-verified: dropping the call from one handler fails
exactly one test.

Also fixes a leaky test: the adoption tests assigned
`ss.get_skills_library_url` directly instead of via monkeypatch. Since
skill_service imports those getters by value, the assignment persisted for the
whole session and made every later sync adopt the fixture repo as an extra
source — surfacing as unrelated failures once random ordering moved them.
46 tests now pass on three consecutive randomized orderings.

Refs Abilityai/trinity-enterprise#237
…nt#237)

Threads `source_id`, `source_name` and `shadowed_by` out to the two consumer
surfaces, and documents the multi-source model in requirements.

`SkillInfo` is constructed field-by-field in the router, so a new service-layer
field is invisible over REST until it is named there — the three provenance
fields are spelled out explicitly for that reason. Same in the MCP tool: the
list output now carries `source` and `shadowed_by`.

`source_name` only, never the URL, on both surfaces. `list_skills` is reachable
by agent-scoped keys, and a private source's repo URL is itself sensitive — the
URLs stay on the admin-only `GET /skills/sources`. Source *management* is
deliberately REST-only and not an MCP tool at all: it is the grant action, and
the whole point of the ent#293 gate is that an agent principal must not reach it.

`SkillsLibraryStatus` in the MCP types gains the `sources` array; the flat
url/branch/commit_sha fields are retained (reflecting the first source in
resolution order) so the existing tool and Settings panel keep rendering until
they migrate. Typechecked with tsc --noEmit.

requirements/skills.md §21.1 rewritten for multi-source with three new
subsections — 21.1.1 custom-wins resolution, 21.1.2 the tag-pinning supply-chain
posture and the grant/use auth boundary, 21.1.3 migration + fresh-install
seeding — each recording WHY, since every one of those was a decision with a
rejected alternative. §21.1 also now records the AC#7 outcome (all OSS-core) and
the honest Not-Built list, including the fixed `.claude/skills/` layout
convention: a repo shaped differently syncs to zero skills.

Refs Abilityai/trinity-enterprise#237
Replaces the single URL+branch form with `components/SkillSourcesPanel.vue`
backed by `stores/skillSources.js`, and strips the now-dead single-repo state
and methods out of Settings.vue (~130 lines).

Extracted to a component rather than grown inline: Settings.vue was already
3800+ lines, and per-source rows, an add form, and error surfacing would have
added meaningfully to that.

The store is a SEPARATE domain from `stores/skills.js` on purpose. That store
owns per-agent skill assignment (an owner surface); this one owns which
repositories the platform syncs from (an admin surface, and the grant action of
requirements §21.1.2). They share no state — and keeping them apart also avoids
colliding with the Skills-tab rebuild in ent#235 / PR #1877, which is rewriting
stores/skills.js right now.

UI decisions that carry the design rather than decorate it:
* the list renders in backend RESOLUTION order and the first row is badged
  "wins conflicts". The store never re-sorts — ordering is a backend contract,
  and a client-side sort would silently misreport which source actually wins
* `ref_type` shows as "pinned" vs "branch" with a tooltip explaining that a
  pinned tag which moves is REFUSED. That is the supply-chain posture (§21.1.2),
  so it belongs on the row, not buried in an edit form
* the add form warns when to pin: "when you don't fully control who can merge"
* backend errors surface VERBATIM. A refused moved tag names the tag and says to
  point at a new one; a generic "sync failed" would throw exactly that away
* the shadowed-skills count gets its own banner — non-zero means someone is
  running a different source's version of a skill than intended (AC#4)
* Remove says explicitly that agents keep installed skills and assignments are
  not removed, so it can't read as "strip these skills from my agents"
* a failed fetch leaves the list untouched rather than clearing it — blanking
  would read as "no sources configured", a different and alarming claim
* the add form stays filled on failure so a bad URL is corrected, not retyped

Verified with a real `vite build`.

NOT included: the per-skill source badge + shadow warning in SkillsPanel.vue.
That file is being rewritten in PR #1877 (dolho), so editing it here would hand
him a conflict. The backend already exposes `source_name`/`shadowed_by` on
GET /api/skills/library, so it is a small additive change once #1877 lands.

Refs Abilityai/trinity-enterprise#237
…ce API

Fixes 27 regressions this branch introduced. My new ent#237 tests passing said
nothing about the existing ones: they exercise the single-clone API that moved
onto SkillSourceClone (`_git_clone`, `_git_pull`, `_get_current_commit`,
`_git_tree_shas`, `_git_archive_skill`) or whose signature gained the owning
clone (`_parse_skill_info`, `_skill_files`).

No production behaviour changed here — the properties these tests pin are all
still true, just relocated:

* test_ent183_skill_packages: the shared fixture now wires exactly ONE source
  (still a valid configuration), so all 22 injection/listing assertions stand
  unchanged. The git seams are mocked on the clone instead of the service.
* test_skill_service_user_agent: the #184 User-Agent property is pinned on
  SkillSourceClone, where the subprocess calls now live. `_clone(exists=True)`
  creates the directory because `_git` short-circuits to a synthetic failure
  when the clone is absent.
* the containment guard moved with it. Note `service._skill_dir` now also
  requires the skill to EXIST in a source, so the pure path-safety property is
  pinned on `clone.skill_dir` to keep it independent of existence — otherwise
  "does the regex-less containment check hold" would silently become "does this
  skill exist".

Two ordering bugs surfaced while fixing this, both mine, both order-dependent
and therefore worth removing rather than working around:

1. test_ent183 installs sys.modules stubs at IMPORT time, which permanently
   binds `skill_service.db` to a MagicMock for the session. My tests depended
   on that singleton, so they passed or failed based on which files ran first.
   They now inject an explicit `_SourcesFacade`, removing the ordering question.
2. the same stubs make `get_skills_library_url()` return a real-looking URL, so
   every sync in my fixture also adopted a phantom legacy source and inflated
   the expected counts. The fixture now pins that getter to None; the adoption
   tests opt in via `_fake_legacy_setting`.

Also switched the two static auth guards from `inspect.getsource(module)` to
reading the router file off disk: importing the module can be affected by
another file's stubs, and a static guard that silently stops running is worse
than no guard.

115 tests across the four skills files pass, stable under both file orderings.

Refs Abilityai/trinity-enterprise#237
`_adopt_legacy_clone` left `skills_library_url` in place after adopting it as a
source, so the setting acted as a read-time default: an admin who deliberately
deleted the migrated source got it silently re-created on the next sync.

That is the same resurrection trap the fresh-install seed is a ROW specifically
to avoid (#1638's lesson — a read-time default is re-applied on every boot, so
it cannot honour a deletion). I wrote that reasoning into the seed and then
reintroduced the bug one function away.

Adoption now consumes both legacy keys once it succeeds. Nothing else reads them
after ent#237 (verified: the only remaining references are the SSRF validator's
name and the settings-router special case), so consuming them strands no
consumer. Best-effort — a failed delete leaves a duplicate-suppressed
re-adoption on the next sync via the existing `existing` check, never a broken
migration.

Regression test asserts the full sequence: adopt, admin deletes, sync again,
source stays gone. Mutation-verified — neutering the delete fails it.

Refs Abilityai/trinity-enterprise#237
…(ent#237)

Found by /review on this branch. `GET /api/skills/sources` was `require_admin`
only, and its own docstring justified that gate as "the rows carry repo URLs,
which for a private source are themselves sensitive."

`require_admin` does not deliver that. An agent-scoped MCP key resolves to its
owner carrying the owner's role (ent#293), so on a default admin-owned install
every agent could read exactly the private repo URLs the gate exists to protect
— and a prompt-injected agent reading them is the disclosure, not a hypothetical.

I had applied `reject_agent_principal` to all three mutating routes on the
grant-vs-use reasoning, and that framing is what hid this: grant-vs-use quietly
implies reads are fine on a role gate. It is the wrong axis for a read. The
right question is whether the RESPONSE is sensitive, and here it is.

Sync stays `require_admin` only: it pulls an already-admin-configured repo and
returns no URLs, so it is genuinely use.

The static guard gains a GATED_READS list so a future read endpoint carrying
source URLs fails the same way rather than being argued about again. Lesson
recorded in learnings.md — the ledger already tracks this class
(trinity-ops-agent#232 → #1644#1816 → ent#293) and the read-vs-write blind
spot is the new part.

Refs Abilityai/trinity-enterprise#237
…source URLs (ent#237)

Two fixes.

**CI, frontend-build**: SkillSourcesPanel used `status-error-*`, which does not
exist. The palette is `status-success` / `status-warning` / `status-danger` /
`status-info` / `status-urgent` — I invented `error` by analogy from the two I
happened to see in Settings.vue. `vite build` passes on an unknown Tailwind
class (it just emits nothing), so only `npm run check:tokens` catches it, and I
had run the build but not that check.

**Security, from /cso --diff**: a source URL embedding a token
(`https://<token>@github.com/owner/repo`) passed validation and was persisted
verbatim. `validate_skills_library_url` checks `parsed.hostname`, which IGNORES
userinfo, and returns the URL unchanged — so the credential would land in
`skill_sources.url` in plain text, be returned by GET /skills/sources, and be
rendered in the Settings panel. Pasting one is an easy mistake: it is the form
GitHub hands you for scripted clones.

Rejected with a named 400 pointing at the GitHub PAT setting, rather than
stripped silently — a silently-stripped token would leave the admin believing
private-repo auth was configured when it was not.

Enforced at the ent#237 routes, deliberately NOT in the shared validator: that
helper also serves the pre-ent#237 `skills_library_url` setting, and an install
relying on an embedded token for private-repo access would break on upgrade.
The class is pre-existing; this branch widens the exposure (N sources, a new
endpoint returning them, a panel rendering them), so the guard sits on the new
surface.

A test pins that the shared validator does NOT strip userinfo — if that ever
changes, it fails and the extra guard can be reconsidered rather than lingering
as unexplained defense.

Refs Abilityai/trinity-enterprise#237
…ules footgun (ent#237)

CodeQL flagged 4 new alerts on PR #1901; this addresses the actionable one and
relocates the credential guard so it is testable.

**py/incomplete-url-substring-sanitization (high)** — `_authenticated_url`
decided whether to splice the platform GitHub PAT with `"github.com" in url`,
then spliced via `url.replace("https://", f"https://{pat}@")`. A substring test
is satisfied by `https://evil.example/?x=github.com`, so that pair would have
sent a live GitHub credential to an attacker host.

Not reachable today — `sync_library` validates every source URL against the
github.com allowlist first — but "safe only because a caller three frames up
validates" is exactly the property that breaks when a caller is added, and the
blast radius is a live PAT. Now: shorthand is normalised to an absolute https
URL first, the host is PARSED, and the splice happens only on an exact match
against the same `ALLOWED_SKILLS_LIBRARY_HOSTS` the SSRF guard uses. Rebuilt via
urlunparse rather than str.replace, which would also rewrite a second
"https://" occurrence inside a path or query.

Moves the embedded-credential guard from `routers/skills.py` into
`utils/url_validation.py` as `reject_embedded_credentials` +
`EmbeddedCredentialError`. It is URL policy and belongs with URL policy, and
testing it through the router required importing the whole `routers` package,
which drags in the agent-service chain and collapsed under another module's
import-time stubs. A leaf module is importable from anywhere.

The remaining CodeQL alerts are pre-existing on dev (a test file untouched here,
and a path-injection alert on the `_skill_dir` chokepoint whose realpath
containment is the documented guard, now applied per-source).

Test-ordering fixes forced by the same stub fragility (#1898):
the ent#183 stub of `utils.url_validation` now mirrors every name
`skill_service` imports — a missing constant is an ImportError at collection,
not graceful degradation — and this file gets an autouse fixture that evicts
detectable stubs (a stub has no `__file__`) so results do not depend on which
file pytest runs first.

That fixture uses `monkeypatch.delitem`, NOT a bare `del sys.modules[...]`:
`tests/lint_sys_modules.py` exists to stop exactly that pattern, and working
around sys.modules pollution by polluting sys.modules is how this file would
have become the next #1898. The eviction is undone at teardown.

Squashed with its follow-up because a test-only commit matches no workflow path
filter, so CI never ran on it.

Refs Abilityai/trinity-enterprise#237
@obasilakis
obasilakis force-pushed the feature/ent-237-multi-source-skills branch from 4ed3c40 to 2d64614 Compare July 31, 2026 09:52
CI's CodeQL check flagged 3 new alerts in code this PR changed.

1. py/incomplete-url-substring-sanitization (high) —
   `_authenticated_url` decided whether a scheme-less source URL already
   carried a host with `url.startswith("github.com/")`. That is the same
   bypassable class of check the surrounding docstring says this function
   exists to avoid, one line below the comment saying so. Decided by
   parsing now, against the same allowlist the splice itself uses, so
   there is exactly ONE way this module answers "which host is this".
   Side effect: `www.github.com/owner/repo` shorthand now resolves to
   that host instead of becoming a repo path under github.com.

2+3. py/stack-trace-exposure (medium ×2) — both sync routes hand
   `sync_library()["error"]` to FastAPI verbatim as an HTTP `detail`, so
   that string is an API surface, not a log line; three `except ... as e`
   branches interpolated the caught exception into it. The messages are
   rebuilt from the stored source row (ref / ref_type / a fixed URL-shape
   hint) and the full exception goes to the log, which is where an
   operator debugs from anyway. The second alert is the pre-existing one
   on `/skills/library/sync`, re-flagged because this PR rewrote its
   sources — same root cause, cleared by the same change.

Tests pin all three: without the fix 3 of the new cases fail on the
exception text and one on the www host.
@obasilakis
obasilakis requested a review from vybe July 31, 2026 11:00
obasilakis and others added 3 commits August 4, 2026 14:31
…ecycle automation

ent#236 (PR #1883) landed on dev BEFORE this branch rather than after, so it
rewrote the same sync entry points ent#237 was replacing. The reconciliation,
not the textual conflicts, is the substance of this merge.

Kept from ent#236, adapted to N sources:
- The cross-worker sync lock now wraps the multi-source loop
  (`_sync_library_locked(url)` -> `_sync_sources_locked(sources)`). ONE lock for
  the whole sweep: per-source locking would let two workers interleave and each
  publish a merged listing built from a half-updated set of checkouts, and the
  listing, the cache invalidation and the durable status are all library-wide.
- Durable sync status (`skills_library_last_*`) stays library-wide and is still
  written on BOTH branches; per-source truth is on each `skill_sources` row.
- `commit_changed` — the gate the fleet re-inject fires on — is computed per
  source against that source's own DURABLE `last_commit_sha` and OR'd, so a
  fresh process cannot read "changed" and sweep the fleet on every restart.
- The non-repo-directory quarantine is ported into `SkillSourceClone`, where
  clone-vs-update now lives. Without it ent#236's forever-fail fix would have
  been silently dropped per source.

Superseded and removed: `_git_clone` / `_git_pull` / `_get_current_commit` /
`_quarantine_non_repo_dir` on SkillService — `SkillSourceClone` owns one
checkout's git lifecycle and `skill_service` orchestrates N of them.

Settings.vue keeps ent#236's automation card (auto-sync, interval, fleet
re-inject) alongside <SkillSourcesPanel />; taking this branch's side wholesale
would have deleted a shipped feature. Only the single-library URL/branch/sync
controls the source list supersedes are dropped. Its loader is wired into the
admin-only loaders — the old mount call was auto-merged away with the deletion.

Also caught in the auto-merged (non-conflicting) regions:
- `skills_sync_service` read top-level `commit_sha`/`action`, which the
  multi-source return no longer had. `commit_sha` is back as an explicitly
  documented library-wide summary marker; the audit row now records the
  per-source breakdown instead of one arbitrary source's action passed off as
  the library's.
- The default source ref was pinned to a `v1.0.0` tag that will never exist —
  ent#296 cuts v0.1.0. A fresh install would have seeded a source that can
  never sync, failing quietly. Documented in .env.example.

Tests: two files stub `utils.url_validation` with only one symbol while this
branch imports ALLOWED_SKILLS_LIBRARY_HOSTS from it; the stub installs only when
the module is absent from sys.modules, so this passed CI on file order alone and
failed in isolation on the pre-merge tip too. Both stubs completed. Nine ent#236
tests drove the deleted single-repo API and are retargeted to the new seams —
the properties (durable status, PAT scrubbing, commit-changed-vs-durable-row,
quarantine) are unchanged and now exercise real code paths.

Full unit suite at seed 12345: 7248 passed / 0 failed (clean dev: 7176 / 0).

Refs Abilityai/trinity-enterprise#237, Abilityai/trinity-enterprise#236
…nch (ent#237)

Both are pre-merge finds on this branch, not regressions from dev.

## Alembic revision graph forked into two heads (/review, CRITICAL)

This branch cut `0031_skill_sources` off `0030`; dev independently cut
`0031_channel_report_back` off the same parent. Separate files, so the merge
never conflicted — but `alembic upgrade head` refuses a multi-head argument, so
`init_database()`'s non-SQLite branch would fail and PostgreSQL boot with it.
CI could not have caught this before the merge: it ran when `0031_skill_sources`
was the only 0031. Renumbered to `0034_skill_sources` onto the current head
rather than adding an Alembic merge revision — this table has no relationship to
the channel/telegram/evaluations chain, and a linear history is what the
`schema-parity` and `pg-migrations` jobs assert.

## Tag pin bypassed on the clone path (/cso, HIGH, exploit executed)

`_update_tag` enforced the AC#5 pin two ways — a `fetch` without `--force` (git
refuses to clobber an existing tag ref) and an explicit recorded-SHA comparison —
and BOTH are properties of an existing checkout. `sync()` routes to `_clone()`
whenever `.git` is absent, and that path took no `expected_sha` and compared
nothing. The `_update_tag` docstring claimed to cover the fresh-clone case; it
does not, because a fresh clone never reaches it.

Lose the checkout — this class's OWN quarantine rename, a restored /data backup,
a recreated volume — while upstream moves the tag, and the moved tag was adopted
silently: success=True, moved_tag=None, commit changed. `commit_changed` then
goes true, so ent#236's fleet re-inject pushes the moved tag's executables to
every running agent with no human in the loop. That is the exact scenario tag
pinning exists to prevent, defeated behind a successful-looking sync.

Verified by executing it against a local upstream: before the fix the payload
landed on disk; after, sync refuses with moved_tag and the checkout is gone.

`_refuse_moved_pin_after_clone` re-checks the resolved HEAD against the recorded
SHA after a fresh clone and DELETES the checkout on refusal — `list_skills` and
injection read the working tree, so a failed sync that left it behind would still
serve the moved tag's content. A first-ever sync has no recorded SHA and is
untouched.

The existing `test_moved_tag_is_refused_and_payload_never_lands` passed
throughout because it clones BEFORE moving the tag, so it can only ever exercise
the update path. The new regression test varies the starting state instead, and
fails without the fix.

## Also

- `POST /skills/sources/{id}/sync` returned 400 on lock contention where its
  sibling returns 409, and ran a git clone inline in an async handler. Both are
  consequences of ent#237 moving the ent#236 sync lock into the shared
  `sync_library`; now `asyncio.to_thread` + 409, mirroring the full sweep.
- architecture.md: `skill_sources` DDL, the three source routes, the sync-lock
  Redis key, and a `skill_service` description that still described single-repo
  sync (CLAUDE.md rule 4 — API/schema change).
- requirements/skills.md §21.1.2 asserted the fresh-clone coverage the code did
  not have; corrected to describe both paths.
- learnings.md: the durable class — a "must not have changed" control enforced
  on the update path is bypassed on the create path, and the test that proves it
  clones first.

Full unit suite at seed 12345: 7250 passed / 0 failed.

Refs Abilityai/trinity-enterprise#237
…bilityai/trinity-enterprise#332)

The skills-library source layout was hardcoded to `.claude/skills/`, so the
public community catalog (abilityai/trinity-skills, ent#296) — which already
ships a vendor-neutral top-level `skills/` layout plus a root `catalog.yaml`
declaring `skills_root: skills/` — synced to zero skills. The SKILL.md format is
harness-portable; only the *discovery path* is Claude-specific. Deliberately
reopens ent#237's "root not configurable per source" call: the repo is untagged
with zero syncing instances today, so this is the cheap moment — after the first
`v*` tag it becomes a breaking migration.

Resolution is per source, in `SkillSourceClone.skills_rel_root()` (lazily cached;
instances are per-operation, so no staleness across syncs):

  catalog.yaml `skills_root:`  →  evidence-gated `skills/` probe  →  `.claude/skills/`

Every invalid tier falls THROUGH to the next rather than blanking the source: an
unusable catalog (unparseable, alias-bearing, non-mapping, unknown
`schema_version`, oversized, symlinked, invalid value) degrades to the probe, and
a symlinked or clone-escaping declared root does too. Only the final tier
escaping containment yields an empty listing — None/empty-propagated through
`skill_dir`/`skill_names`, never a raise through `list_skills`. A source serving
skills today cannot be blanked by a tier it does not use.

The agent-side destination stays `~/.claude/skills/<name>/`, and
`filter_skill_archive(source_root=…)` is the ONE point where source layout
becomes destination: it rewrites tar arcnames to `.claude/skills/<name>/…`
(identity for the legacy layout). That placement is required, not merely tidy —
`executable_paths` → the agent-side chmod list, the restore sent-vs-restored
accounting, the persisted manifest, and `_legacy_fallback`'s SKILL.md lookup all
key on post-filter arcnames. Consequence: manifests, prune/removal confinement,
per-skill `.gitignore` lines, the restore `paths` allowlist and the whole ent#236
removal machinery stay destination-canonical with ZERO migration, and installed
packages keep pruning correctly across a repo restructure.

Three guards on the author-controlled value, each catching what the others
structurally cannot (see docs/memory/learnings.md):

- Segment-wise validation — split on `/`, reject any segment that is empty, `.`
  or `..`. A whole-string charset regex admits `.`, `./skills` and `skills//x`,
  each of which breaks archive-prefix math into per-skill "empty package"
  failures. A leading `-` is refused so the value can never read as a git option
  (the `--` separators at every pathspec are the belt to this suspenders).
- lstat-order symlink refusal on catalog.yaml and on both candidate roots, with
  a bounded `read(cap+1)` — a post-read length check is defeated by
  `catalog.yaml -> /dev/zero`, and git materializes author symlinks. A symlinked
  `skills/` would list fine but yield empty `git archive` output from HEAD,
  failing every injection.
- realpath containment against the REALPATH'd base — `/data/skills-library` may
  itself be a symlink, so comparing a resolved target to an unresolved base would
  refuse every source spuriously.

Parsing goes through the shared ent#314 hardened loader (`AliasPolicy.REJECT`,
64 KB cap), catching `HardenedYamlError` explicitly alongside `yaml.YAMLError` —
it is a ValueError, not a YAMLError, and missing it would escape through
`list_skills` and 500 the merged listing. `schema_version` gates the whole
catalog (absent or `1` — int and string both tolerated); anything else degrades
to the probe rather than being silently misread, which keeps a future schema bump
safe. Platform parses only these two keys; `categories`/`providers` stay catalog
metadata for UIs.

Dual-layout guard: SKILL.md evidence under BOTH roots with no catalog to decide
keeps `.claude/skills/` and flags `layout_conflict` in status. A pre-existing
dual-layout source must never silently flip *which executable content* the
ent#236 unattended auto-sync injects fleet-wide; switching requires the explicit
declaration.

Also: per-source `skills_root` + `layout_conflict` in library status (null until
cloned, honest rather than guessed), honest per-source `path` provenance, and the
now-stale operator copy in SkillSourcesPanel.

52 new tests. Independently reviewed by strategy + engineering agents and a
second-opinion model; `/cso --diff` found zero findings introduced by this
change (report under docs/security-reports/; one pre-existing inherited
disclosure filed separately as Abilityai/trinity-enterprise#334).

Refs Abilityai/trinity-enterprise#237, Abilityai/trinity-enterprise#296
Fixes Abilityai/trinity-enterprise#332

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

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

Reviewed the full diff (30 files). The architecture is right — per-source SkillSourceClone, precedence as a plain ORDER BY, dual-track migrations present (SQLite skill_sources_table + Alembic 0034, re-parented onto the current dev head), the moved-tag test that builds the actual attack rather than asserting an error string. The tag pin and the fresh-clone bypass closure are genuinely well reasoned.

Requesting changes on three source-editing/lifecycle defects, one auth inconsistency against the PR's own stated invariant, plus red CI.


CI is red — must be green before merge

regression diff fails: https://github.com/Abilityai/trinity/actions/runs/30906617238/job/91987135445

##[error]Unable to download artifact(s): Failed to GetSignedArtifactURL: Unable to make request: ECONNRESET

To be fair: this is infrastructure, not this branch. All six pytest shards (head + base × 3 seeds) passed and the job died downloading the junit artifacts. But the check is red and blocks the merge gate, so please re-run it and confirm green. If the flake repeats, that's worth its own issue against the workflow.


1. Editing a source's url is a no-op on disk — it keeps syncing the old repo

PUT /skills/sources/{id} accepts url (SkillSourceUpdate.url, _MUTABLE_FIELDS), but SkillSourceClone.sync branches on (self.path / ".git").exists() and takes _update, which never touches the remote:

def _update_branch(self):
    fetch = self._git([..., "fetch", "origin", self.ref], ...)
    reset = self._git(["reset", "--hard", f"origin/{self.ref}"])

origin still points at the URL captured at first clone. auth_url is only consumed by _clone. So after repointing a source at a different repo, every subsequent sync silently pulls the old one — indefinitely, with last_sync_status: success and a changed commit that feeds ent#236's fleet re-inject. The admin sees a repointed source in Settings and the fleet keeps receiving the previous repo's executables. Silent-wrong is the bad failure mode here, worse than the tag case below which at least errors.

Fix: on a url change, either git remote set-url origin before fetch, or drop the checkout so the next sync re-clones. Whichever, it needs a test that changes url on a synced source and asserts the content on disk comes from the new repo.

2. The documented tag-bump path is unreachable

skill_source_clone says, twice, "point this source at a new tag" as the way to adopt new content. But update_source never clears last_commit_sha, and the caller passes it unconditionally for tag sources:

expected_sha=src.last_commit_sha if src.ref_type == "tag" else None,

So bumping ref from v1 to v2 hits _update_tag's SHA comparison against v1's recorded SHA, mismatches, and is refused as moved_tag — the error text telling the operator to do the thing they just did. On the fresh-clone path it's worse: _refuse_moved_pin_after_clone rmtrees the checkout first, so a source that was merely being bumped ends up with no content at all.

Same class for a branchtag transition.

Fix: clear last_commit_sha in update_source when ref or ref_type changes (a recorded SHA is only meaningful for the ref it was recorded against). Test: create a tag source, sync, bump ref to a second tag, sync, assert the new tag's content lands and no moved_tag.

3. Deleting a source leaks its checkout

delete_source deletes the row only; nothing removes /data/skills-library/<source_id>/. Same for <source_id>.broken quarantines, which are bounded per source but survive the source's deletion. Add/remove cycles accumulate full clones on the data volume with no reclamation path short of shell access.

The comment on _adopt_legacy_library already names this exact hazard ("an orphan nothing ever cleans up") for the crash-ordering case, so the reasoning is present — it just isn't applied to delete. Either remove the directory on delete, or reconcile dirs-without-rows on sync. Given #1638/#1644, a reconcile sweep should log what it reclaims.

4. The sync routes don't match the PR description's auth claim

The description says "Every mutating /api/skills/sources route carries reject_agent_principal in addition to require_admin". sync_skill_source and sync_library carry only require_admin, and test_every_mutating_source_route_is_covered explicitly subtracts sync_skill_source on a grant-vs-use rationale.

Two things:

  • The description overclaims and should be corrected either way.
  • More substantively: this PR already records that grant-vs-use was the wrong axis once (that's why the LIST route got the gate). Sync isn't a read — on this branch it clones code and, when commit_changed and auto-reinject is on, spawns run_fleet_reinject, pushing executable scripts/ to every running agent. An agent-scoped MCP key on an admin-owned install resolves to the owner's role and passes require_admin, so an agent can trigger fleet-wide executable delivery. Combined with #1 above (origin never repointed), an agent-triggered sync is also the mechanism that propagates a stale repo's content.

I'd gate both sync routes human-only and add them to MUTATING. If you disagree, say so explicitly in the test comment and the issue — but the current split isn't defensible as "use, not grant" when the effect is fleet-wide code injection.


Non-blocking

Ordering. The seeded default points at abilityai/trinity-skills, which doesn't exist until abilityai/trinity-enterprise#296. Your note is honest about it, and I agree not to fake it in code — but landing this first means every fresh install boots with a permanently failing source until #296 exists. Prefer sequencing #296 first; if that slips, the release note has to say so.

Docs. architecture.md / requirements/skills.md are updated, good. Once #1 and #2 are fixed, the "point at a new tag" instruction in skill_source_clone's module docstring becomes true rather than aspirational.

Happy to re-review quickly once the three defects and the auth gate are addressed.

…ills-root

feat(skills): vendor-neutral library layout — per-source skills root (trinity-enterprise#332)
dolho added a commit that referenced this pull request Aug 5, 2026
…ent#157)

dev has moved twice since this branch last renumbered. `0033_agent_ownership_a2a_exposed`
and dev's `0033_agent_evaluations` shared the parent `0032_telegram_progress_indicator`,
so the graph had two heads — `alembic upgrade head` refuses that, and on
PostgreSQL that is a boot failure, not a migration warning.

Now `0035_agent_ownership_a2a_exposed` chained to `0033_agent_evaluations`.
0034 is skipped rather than taken because PR #1901 holds `0034_skill_sources`
unmerged off the same head: whichever of the two lands second still has to
re-chain (inherent to two open PRs adding revisions), but skipping the number
keeps the filenames from colliding, so that rebase is a one-line edit rather
than a rename plus an edit. The reasoning is recorded in the revision docstring
so the next rebase doesn't have to re-derive it.

Verified: the version graph has exactly one head and no dangling
down_revision; 61 alembic-guard tests and 440 tests across a2a/migration/schema
/registry/trigger pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Aug 5, 2026
…ent#157)

dev has moved twice since this branch last renumbered. `0033_agent_ownership_a2a_exposed`
and dev's `0033_agent_evaluations` shared the parent `0032_telegram_progress_indicator`,
so the graph had two heads — `alembic upgrade head` refuses that, and on
PostgreSQL that is a boot failure, not a migration warning.

Now `0035_agent_ownership_a2a_exposed` chained to `0033_agent_evaluations`.
0034 is skipped rather than taken because PR #1901 holds `0034_skill_sources`
unmerged off the same head: whichever of the two lands second still has to
re-chain (inherent to two open PRs adding revisions), but skipping the number
keeps the filenames from colliding, so that rebase is a one-line edit rather
than a rename plus an edit. The reasoning is recorded in the revision docstring
so the next rebase doesn't have to re-derive it.

Verified: the version graph has exactly one head and no dangling
down_revision; 61 alembic-guard tests and 440 tests across a2a/migration/schema
/registry/trigger pass.

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

Four defects from review, three of one shape: the row moved and the
checkout did not.

1. Repointing a source's `url` was a no-op on disk. `_update_*` fetches
   `origin`, written at clone time, so every later sync silently pulled
   the OLD repo — reporting success, with a moving commit that feeds
   ent#236's fleet re-inject. `sync` now compares `origin` against the
   configured remote and discards + re-clones on a genuine mismatch.
   Discard rather than `remote set-url`: the latter leaves the old repo's
   refs behind, and a tag name present in both repos at different commits
   then reads as a moved pin and is refused forever. The compare strips
   credentials (a private source's on-disk origin carries a PAT the stored
   URL never does, so a naive compare re-clones on every sync), and an
   unreadable origin counts as a MATCH — the action gated on that answer
   is an rmtree, so ambiguity resolves to "don't".

2. The documented tag-bump path was unreachable. `update_source` never
   cleared `last_commit_sha`, so bumping `v1` → `v2` compared v2 against
   v1's recorded SHA and was refused as `moved_tag`, with the error
   telling the operator to do what they had just done; on the fresh-clone
   path the refusal rmtree's first, so the source ended up empty. The
   bookkeeping is now cleared when `url`/`ref`/`ref_type` change — and
   deliberately NOT on name/enabled/priority, since a cleared baseline on
   disable would let a tag moved during the disabled window be adopted
   silently on re-enable (the AC#5 bypass via a checkbox — and the enabled
   toggle is the only edit the UI currently wires to PUT).

3. Deleting a source leaked its checkout. `discard_source_checkout`
   reclaims `<id>/` + `<id>.broken` on delete (row first, disk second),
   with `_reclaim_orphan_checkouts` on full sweeps as the backstop for the
   crash window and for installs that already accumulated clones. Fail-
   closed on the row read — a sweep reading "DB error" as "no sources"
   deletes the whole library — and scoped to the server-minted id shape,
   so the legacy checkout and operator directories are out of reach.

4. Both sync routes are now human-only. They were role-gated as "use, not
   grant", but grant-vs-use is a claim about EFFECT: a sync clones
   executable material and spawns `run_fleet_reinject`, pushing skill
   scripts/ to every running agent, and an agent-scoped key resolves to
   its owner carrying the owner's role (ent#293). The coverage scan
   widened from /skills/sources to /skills/ — `POST /skills/library/sync`
   sits outside the sources prefix, so the old scan reported full coverage
   while missing the route in question.

Also: `test_mutating_routes_reject_agent_principals` was satisfiable by a
comment. It grepped `ast.dump(fn)`, which renders docstrings, so
documenting the gate in (4) made the guard pass with the real call
deleted. Now matches an ast.Call node.

20 new tests. Each of the four fixes mutation-verified to fail exactly its
own test. Full unit suite: 7322 passed, 18 skipped.
@obasilakis

Copy link
Copy Markdown
Contributor Author

All four addressed, plus one you didn't ask for that fell out of verifying #4.

1. Repointed url was a no-op on disk — fixed

You had it exactly right: _update_* fetches origin, which git wrote at clone time.

Fixed by discarding and re-cloning on a genuine mismatch rather than git remote set-url. set-url is the smaller edit but leaves the old repo's refs in place, and a tag name present in both repos at different commits then reads to _update_tag as a moved pin and is refused forever — trading a silent-wrong for a permanently-stuck.

Two details worth flagging because they cut the other way:

  • The compare is credential-stripped (canonical_remote). A private source's on-disk origin carries a PAT the stored URL never does, so a naive string compare would report a repoint on every sync and re-clone the whole library each time. Test test_an_unchanged_url_does_not_re_clone pins that by writing a marker inside .git and asserting it survives.
  • An unreadable or absent origin counts as a match, not a mismatch. My first cut had it the other way ("fetch origin would fail anyway, so re-clone is the recovery either way") and it broke test_real_repo_still_pulls from ent#236 — whose whole point is "the fix must not turn every sync into a re-clone". It was right and I was wrong: the action gated on this answer is an rmtree, so ambiguity has to resolve to don't. The repoint case always has a readable origin, which is the entire problem, so narrowing costs nothing.

Test: test_repointing_the_url_changes_what_lands_on_disk asserts content on disk comes from the new repo and the old skill is gone; test_repointing_moves_the_git_remote_too asserts origin itself moved.

2. Tag bump unreachable — fixed

update_source now clears the sync bookkeeping when url/ref/ref_type change. last_commit_sha is the load-bearing one (a baseline is only meaningful for the ref it was recorded against); status/timestamp/error go with it because "Synced <date>" against a repo the source has never fetched is a claim the row can't support — and never already renders as "Never synced" in the panel, so no frontend change.

The negative is the part I'd ask you to look at hardest, because getting it wrong is a security regression rather than a UX one: non-identity edits (name/enabled/priority) must not clear it. If disabling a source cleared its baseline, re-enabling would re-clone with nothing to compare against, and a tag moved during the disabled window would be adopted silently — the AC#5 bypass, reachable by toggling a checkbox. That's parametrized (test_a_non_identity_edit_keeps_the_pin_baseline), and it's not hypothetical: the enabled toggle is currently the only edit the UI wires to PUT. Plus test_rewriting_a_field_with_its_current_value_is_not_a_change, since a form-driven UI PUTs the whole object back.

Both halves of your scenario are covered — the ordinary bump, and the fresh-clone variant where the refusal rmtreed first and left the source empty.

test_a_moved_tag_is_still_refused_after_all_this is the guard that the pin survived the fix that made bumping work.

3. Deleted source leaked its checkout — fixed

Both halves, as you suggested:

  • discard_source_checkout removes <source_id>/ and <source_id>.broken on delete. Row first, disk second — the row is authoritative and a filesystem failure must not fail a committed delete. Result surfaces as checkout_reclaimed on the response and in the audit row.
  • _reclaim_orphan_checkouts is the backstop, on full sweeps only (a single-source sync must not reach outside the source it was asked about), and it's the only reclamation path for installs that already accumulated clones. Logs what it reclaims, per bug: retention prunes have no blast-radius guard — a mistyped window deletes most of a table within 5 minutes #1644.

Fail-closed on the row read, which is the whole ballgame: a sweep that reads "DB error" as "no sources configured" deletes the entire library. test_reclamation_fails_closed_when_sources_cannot_be_read pins it. Scope is the server-minted id shape only, so the legacy pre-ent#237 .git checkout, the migration staging dir and anything an operator parked there are structurally out of reach — and a disabled source keeps its checkout (reclaiming there would discard the very checkout the tag pin is measured against).

4. Sync routes — you're right, gated

Agreed without reservation, and your framing is the correct one: grant-vs-use is a claim about effect, and the effect here is cloning executable material and spawning run_fleet_reinject. Both routes now carry reject_agent_principal, both are in MUTATING, and I widened the coverage scan from /skills/sources to /skills/POST /skills/library/sync sits outside the sources prefix, so the old scan would have reported full coverage while missing exactly the route under discussion.

The router comment and requirements/skills.md now record the rule as gate on what the route does, not on which verb it is, naming both misreads (LIST, then sync) rather than restating the axis that produced them.

No MCP tool touches either route, so nothing downstream breaks.

The one you didn't ask for

Mutation-testing #4 — deleting the reject_agent_principal call and expecting red — came back green. test_mutating_routes_reject_agent_principals checked "reject_agent_principal" in ast.dump(fn), and ast.dump renders docstrings, so the docstring I'd just written explaining the gate satisfied the guard on its own. The guard has been checkable-by-comment since it was written; it only surfaced because I happened to document the thing it checks for.

Now matched as an ast.Call node. Re-ran the mutation: red.

All four mutations (neutered repoint detection / removed identity-clear / neutered reclamation / removed auth gate) fail exactly their own tests and nothing else.

Non-blocking, as raised

  • Ordering — agreed, and unchanged: I'd rather feat: Telegram bot connection UI in Settings (TGRAM-UI) #296 land first than fake it here. If it slips, the release note carries it.
  • Docsskill_source_clone's "point this source at a new tag" is now true rather than aspirational, and says so. architecture.md and requirements/skills.md carry all three fixes and the corrected auth rule.
  • CI — the description's auth claim was an overclaim when you read it and is now simply accurate; corrected in place either way. The regression diff flake gets a fresh run off this push.

Verification

…ides)

CI never dispatched for this branch: the PR was `mergeable_state: dirty`
against dev, so GitHub could not compute refs/pull/1901/merge and the
pull_request-triggered workflows did not run at all. That is the same
condition the 07-31 nightly bot reported, not the Actions dispatch gap.

The only conflict was tests/registry.json, additive on both sides — this
branch appended the ent#332 entry, dev appended ten. Resolved as a union
keyed on `file`: dev's ordering and its one edited entry
(test_181_agent_display_label, hyphen → em dash, untouched here) kept,
this branch's ent#332 entry appended. Verified against the merge base:
no entry removed on either side, no entry modified on both.

Also registers unit/test_ent237_skill_sources.py, which was never in the
catalog — ent#332's entry landed but its parent's did not.
@obasilakis

Copy link
Copy Markdown
Contributor Author

Follow-up on CI, since my numbers above are now superseded by a dev merge.

Why no checks were running. Not the Actions dispatch gap I assumed — the PR was mergeable_state: dirty against dev, so GitHub could not compute refs/pull/1901/merge and every pull_request-triggered workflow silently declined to dispatch. Same condition the 07-31 nightly bot reported. Merged dev in (a37db853); mergeable: true, checks now running.

The only conflict was tests/registry.json, additive on both sides — this branch appended the ent#332 entry, dev appended ten. Resolved as a union keyed on file, verified against the merge base: nothing removed on either side, and the one entry differing between sides (test_181_agent_display_label, hyphen → em dash) was dev's edit against an untouched copy here, so dev's kept. Also registered unit/test_ent237_skill_sources.py, which was never in the catalog — ent#332's entry landed but its parent's did not.

Corrected suite numbers (post-merge, fixed order):

this branch clean origin/dev
fixed order (-p no:randomly) 7619 passed, 18 skipped, 0 failed 7473 passed, 18 skipped, 0 failed
random order 2 failed the same 2 failed

The random-order failures are test_1081_physical_meter.py::{TestDisjointBulkMeter::test_max_respects_506_clamp,TestPerAgentMeter::test_available_floors_at_zero_over_ceiling}[sqlite]identical on clean dev, and named in #1898's own reproduction. An earlier random run here hit test_subscription_auto_switch_pingpong.py::TestKeyRolloverFanOut instead (fixture ERROR, _install_database_stub() + importlib.reload()); different seed, same class. This branch touches neither file — both byte-identical to dev — and none of its files pollute either pairwise.

So: pre-existing, tracked as #1898, not introduced here. Worth noting the shard matrix (base × head × 3 seeds) is precisely the right instrument for this and nets it out.

Everything else has gone green so far; the pytest shards are still running.

@dolho

dolho commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

/review Report

Branch: feature/ent-237-multi-source-skillsdev (merge-base e6df5bf8)
Files Changed: 36 (+5789/-766)
Scope: CLEAN
Plan Completion: 4 of my 4 earlier findings DONE, verified in code — plus one they found themselves

All four things I asked for in the 3 Aug review are genuinely fixed in b92c3859, not papered over. Re-verified each against the code rather than the commit message, and mutation-tested two of them independently.

My finding Status Evidence
Editing url never repointed origin — silently synced the old repo DONE _origin_matches + _discard_repointed_checkout; mutation-verified below
Tag bump refused as moved_tag (stale last_commit_sha) DONE db/skill_sources.py:207 clears the pin bookkeeping on a real url/ref/ref_type change
Delete leaked the checkout DONE discard_source_checkout (row first, disk second) + _reclaim_orphan_checkouts backstop
Both sync routes role-gated only DONE reject_agent_principal on sync_library and sync_skill_source

Three of those are better than what I suggested, and the reasoning is the reason:

  • Discard rather than git remote set-url — set-url leaves the old repo's refs behind, and a tag name present in both repos at different commits then reads as a moved pin and is refused forever. I hadn't thought that through.
  • Pin cleared on url/ref/ref_type but deliberately NOT on enabled — clearing on disable would let a tag moved during the disabled window be adopted silently on re-enable. That is an AC#5 bypass via a checkbox, and the enabled toggle is the only edit the UI currently wires to PUT. Nice catch.
  • The clear also compares against the current value (skill_sources.py:208), so a no-op PUT that resends the same URL doesn't disarm the pin.

And the self-caught one is the best item in the diff: test_mutating_routes_reject_agent_principals grepped ast.dump(fn), which renders docstrings — so documenting the new gate in prose made the guard pass with the real call deleted. Now matched as an ast.Call node. That is the third instance of this class on the repo (#1871's containers_run guard, ent#314's loader sweep) and I hit it myself today on #2019.


Informational Findings

[I1] Test gap: the fail-safe branch of an unattended rmtree is uncovered (Confidence: 9/10)
File: src/backend/services/skill_source_clone.py_origin_matches

Evidence:

proc = self._git(["config", "--get", "remote.origin.url"], timeout=10)
if proc.returncode != 0:
    return True

The docstring argues this at length and cites #1638/#1644 — an unreadable origin counts as a MATCH because the action gated on the answer is an rmtree, so ambiguity must resolve to "don't". That reasoning is right, and it is exactly the property most worth pinning.

It isn't pinned. I flipped it to return False — making the discard fail-open, so a transient git config failure deletes the checkout — and ran the suite:

92 passed in 81.63s

Zero failures. The safety argument lives only in the docstring; someone can invert it in a refactor and CI stays green. Note the contrast with the other half of the same fix — neutering the origin check itself does fail (test_repointing_moves_the_git_remote_too, 2 failures), so the feature is covered and only the safe direction is not.

Suggestion: one test with _git stubbed to a non-zero return, asserting _origin_matches is True and that no rmtree happens. Cheap, and it is the assertion that makes the docstring load-bearing.

[I2] A repoint leaves the old repo's quarantine behind (Confidence: 7/10)
File: src/backend/services/skill_source_clone.py_discard_repointed_checkout

shutil.rmtree(self.path, ...) removes <id>/ but not <id>.broken. discard_source_checkout handles both on delete, and _reclaim_orphan_checkouts only considers ids with no row — so for a live, repointed source the quarantine holding the previous repo's content persists indefinitely.

Bounded (one quarantine per source, pre-existing invariant) and not served to anyone, so it is disk hygiene rather than a correctness bug. Reusing discard_source_checkout's two-path loop here would close it in a line.

[I3] update_source's pin-clear decision is a read-then-write (Confidence: 6/10)
File: src/backend/db/skill_sources.py:205-216

current = self.get_source(source_id) and the UPDATE are separate statements, so two concurrent admin PUTs can decide "changed?" against a stale row. The security-relevant direction is a clear that shouldn't have happened (pin baseline dropped). Admin-only, requires concurrent edits of the same source, and the threat model for AC#5 is a moved upstream tag rather than a racing operator — so I'd note it, not block on it. Folding the comparison into the UPDATE's WHERE would remove the window if you want it gone.

Clean Categories

  • SQL — all SQLAlchemy Core with bound params; no interpolation in the diff
  • Auth — every mutating source route and the LIST route carry require_admin + reject_agent_principal; both sync routes now too, verified by reading the handlers
  • Credential exposurecanonical_remote strips userinfo before comparing, and redact() still covers git output; the compare never logs the auth URL
  • Race conditions_reclaim_orphan_checkouts runs inside _sync_sources_locked under the cross-worker lock, and only on a full sweep (single_source is the caller's intent, not len(sources) == 1 — which is the trap an install with exactly one source would have fallen into)
  • Destructive-op safety — the sweep is fail-closed on the row read, scoped to the server-minted src_<hex> id shape (legacy checkout and operator directories structurally out of reach), and logs what it reclaims
  • Enum completeness — no new enum/status value in this diff

Summary

  • Critical: 0 — my four findings are resolved and I could not construct a fifth
  • Informational: 3 — I1 is the one I'd act on before merge; it is a one-test fix
  • Scope: clean

Verification: 144 passed across test_ent237_skill_sources.py + test_ent332_skills_root.py; two fixes independently mutation-tested (results quoted above).

My 3 Aug CHANGES_REQUESTED is satisfied — happy for it to be dismissed. I'd take I1 first; I2/I3 are fine as follow-ups.

…#237)

@dolho's I1 on #1901: `_origin_matches` returns MATCH when `git config` cannot
read `origin`, because the action gated on the answer is an `rmtree` and an
unknown answer must not widen an unattended delete (#1638/#1644). The argument
lived only in the docstring — flipping the branch to `return False`, so a
transient git failure discards the checkout, left all 92 tests green.

The feature half was already covered (neutering the origin check fails
`test_repointing_moves_the_git_remote_too`); only the safe direction was not.

Staged as the case where a discard would otherwise be CORRECT — the url IS
genuinely repointed, and only the origin read is blinded — so the assertion is
about the ambiguity resolving to "leave it alone", not about a mismatch simply
not existing. Asserts both halves: `_origin_matches` is True, and a marker
written inside `.git` survives the sync.

Mutation-verified: `return True` → `return False` fails exactly this test
(1 failed, 92 passed) and nothing else.

Refs Abilityai/trinity-enterprise#237
…nted (ent#237)

@dolho's I2 on #1901: `_discard_repointed_checkout` removed `<id>/` but not
`<id>.broken`, and neither reclamation path can reach a quarantine while the
source still exists — `discard_source_checkout` runs on delete, and
`_reclaim_orphan_checkouts` considers only ids with NO row. So the previous
repo's content sat in the library root for the life of the source. Bounded at
one per source, so disk hygiene rather than a correctness bug, but a repoint
makes that directory permanently irrelevant by construction.

`.broken` was already spelled in four places across two modules — the writer
here and both reclaimers in `skill_service` — so this adds a shared
`QUARANTINE_SUFFIX` and a `quarantine_path` property rather than a fifth
literal. A suffix that drifts between the writer and the reclaimer leaves
quarantines nothing can collect, which is this same bug one level up.

The test produces the quarantine the way production does (a checkout that has
lost its `.git`, taking the clone branch) rather than writing the directory by
hand, so it breaks if the naming ever moves, and asserts the old-repo content
was really there before the repoint.

Mutation-verified: restoring the single-path `rmtree` fails exactly this test
(1 failed, 93 passed). Full unit suite 7557 passed, 18 skipped (+64 in
test_ent236_skills_lifecycle.py, which still cannot be co-collected with
test_ent183_skill_packages.py — the pre-existing #1898 sys.modules stub, present
on this branch before these two commits).

Refs Abilityai/trinity-enterprise#237
@obasilakis
obasilakis enabled auto-merge (squash) August 5, 2026 11:30
@obasilakis
obasilakis merged commit 9e98b31 into dev Aug 5, 2026
23 checks passed
obasilakis added a commit that referenced this pull request Aug 6, 2026
…34 allow-list

`library-page.md` still documented the flat `url` as part of
`GET /api/skills/library/status` and claimed PR #1901 kept the flat fields
verbatim. The ent#334 response_model withholds `url`, the per-source `url`,
and the per-source `last_error`. Caught by /validate-pr.

Refs Abilityai/trinity-enterprise#334
vybe pushed a commit that referenced this pull request Aug 6, 2026
…epo URLs (Abilityai/trinity-enterprise#334) (#2043)

* fix(skills): stop the public library-status route disclosing source repo URLs (trinity-enterprise#334)

`GET /api/skills/library/status` was gated only by `Depends(get_current_user)`
and returned skills-source repo URLs to every authenticated principal —
including agent-scoped MCP keys, which resolve to their owner carrying the
owner's role. The sibling `GET /api/skills/sources` returns the SAME
`get_library_status()` dict behind `require_admin` + `reject_agent_principal`,
with a docstring stating why: for a private source the repo URL is itself
sensitive. One payload, two trust levels, and the weaker gate was the bug.

The fix is a `response_model` allow-list on the public route rather than a
stronger gate. `require_admin` was considered and rejected — it would 403 the
Library skills section (`/library` is `requiresAuth` only) and the per-agent
Skills tab (gated on `can_share`, i.e. owner not admin), including the
non-admin empty states written for exactly those callers. Both derive their
empty-state discriminator from `configured`/`cloned`, so a 403 renders a
configured library as "not configured". Allow-listing withholds the sensitive
field while every caller keeps the state it actually reads.

`SkillsLibraryStatus` is fail-closed by construction: FastAPI serialises
through an explicitly-constructed model, so a future sensitive field is
invisible over REST until someone names it. Verified at the HTTP layer, not
just `model_dump` — an injected unknown field is dropped from the wire body.

Withheld: the flat `url`, the per-source `url`, and the per-source
`last_error`. That last one is git's failure text, which echoes the remote URL
— and the clone path's URL carries a spliced PAT. `redact()` scrubs it going
in but under-matches a double-`@` authority (ent#347), which is precisely the
shape `_authenticated_url` builds when the stored URL already has userinfo,
and that combination reliably fails auth, so the leaking branch is the
guaranteed one. Its only consumer reads the admin-gated sources route.

Kept: `branch` and `commit_sha` — a ref name and a commit hash are neither
credentials nor repo identity, and the Library header renders them.

Also adds `strip_url_credentials` in `utils/url_validation.py`, applied at both
service emitters so every consumer including the admin route is covered.
Parse-based, per the house rule set by `_authenticated_url` (the host is
decided by parsing, never by substring): a regex mangles a legitimate
`?ref=a@b`, and the `[^@]+@` classes the existing scrubbers use cannot cross
the first `@`. Never-raises by contract — `urlparse` throws on an unbalanced
bracket and the legacy-adoption path writes rows with no validation at all,
while `get_library_status` must never 500 the panel.

Out of scope, filed separately: trinity-enterprise#346 (agent-scoped key can
inject a skills source via `PUT /api/settings/skills_library_url`, bypassing
the grant gate ent#237 built) and trinity-enterprise#347 (both free-text
scrubbers under-match a double-`@` URL).

Refs Abilityai/trinity-enterprise#334

* docs(skills): correct the library-page status payload after the ent#334 allow-list

`library-page.md` still documented the flat `url` as part of
`GET /api/skills/library/status` and claimed PR #1901 kept the flat fields
verbatim. The ent#334 response_model withholds `url`, the per-source `url`,
and the per-source `last_error`. Caught by /validate-pr.

Refs Abilityai/trinity-enterprise#334

* test(ent183): add strip_url_credentials to the url_validation stub

ent#334 adds `strip_url_credentials` to skill_service's imports from
`utils.url_validation`. This module stubs that package, and its own comment
states the rule: the stub must mirror EVERY name skill_service imports, because
a missing one is an ImportError at collection, not a graceful degradation.

Without it the module fails to import once ent#334 lands, which also fails
#1898's guard test (it collects this file as the offender) — a merge
interaction, since ent#334 branched before #1898 landed.

Identity, matching `validate_skills_library_url` beside it: nothing in this
module renders a source URL, so the real stripping stays exercised by
test_ent334_status_url_disclosure rather than stubbed away from it.

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

---------

Co-authored-by: trinity-ability <trinity@ability.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.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.

4 participants