fix(skills): stop the public library-status route disclosing source repo URLs (abilityai/trinity-enterprise#334) - #2043
Conversation
…epo 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
…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
…status-url-disclosure # Conflicts: # tests/registry.json
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>
vybe
left a comment
There was a problem hiding this comment.
The central decision — allow-list rather than require_admin or bare userinfo-stripping — is right, and the PR argues it the right way: by naming the two shipped non-admin callers (/library is requiresAuth only; the Skills tab is gated on can_share, owner not admin) and the fact that both derive their empty state from configured/cloned, so a 403 would render a configured library as "not configured" — including in the non-admin empty states written for exactly those callers. That is a concrete regression, not a hypothetical.
response_model as the mechanism is the part that will still be working in a year: it is fail-closed by construction, so a future sensitive field is invisible over REST until someone names it. Verifying it at the HTTP layer with an injected unknown field, rather than via model_dump, is the correct instinct — response_model filtering is FastAPI's serialization layer and a direct handler call proves nothing about the wire. The static AST guard matters for the same reason: dropping the decorator kwarg reopens the leak with no behavioural test failing.
Withholding last_error is the non-obvious catch and the right call — it is git's failure text, which echoes the remote URL, and the clone path's URL carries a spliced PAT. Keeping branch/commit_sha is also right; dropping them would have been a second, unrelated behaviour change riding a security fix.
I ran the cross-check #2041 asked for (it predicted that whichever of these landed second should reconcile its parser against _CREDENTIAL_URL_RE). On the authority boundary the two agree exactly — all seven inputs where both fire produce the same host, including https://a@b@c@github.com/o/r and https://tok@github.com:8443/o/r, and both correctly leave ?ref=a@b alone.
They differ in when they fire, and strip_url_credentials is strictly the more thorough of the two:
| input | strip_url_credentials |
redact (#2041) |
|---|---|---|
//tok@github.com/o/r |
//github.com/o/r |
unchanged |
tok@github.com/o/r |
github.com/o/r |
unchanged |
git+ssh://tok@github.com/o/r |
git+ssh://github.com/o/r |
unchanged |
_CREDENTIAL_URL_RE is anchored on a literal https://, so it cannot match the protocol-relative, scheme-less or alternate-scheme forms your had_authority branch handles. That is not a defect in this PR — you cover the shapes it misses — but it is a live gap in the free-text scrubbers, reachable because _scrub_pat runs over str(e) of arbitrary exceptions and _adopt_legacy_clone writes rows with no validation at all. Filing it as a follow-up rather than holding this.
Merge conflict resolved (tests/registry.json, against #2031's entry): rebuilt from the merge stages rather than splicing markers — dev's 130 entries plus this branch's one, 131 total, no duplicates, valid JSON.
That surfaced a real cross-PR break, which is the reason it was worth resolving carefully rather than mechanically: skill_service now imports strip_url_credentials, and test_ent183_skill_packages.py stubs utils.url_validation with a fake that lacked it — so the module failed to import at collection, which also failed #1898's guard (it collects that file as the offender). Added the name to the stub, honouring that stub's own comment that it must mirror every name skill_service imports. 116 passed across the four interacting suites, 390 passed across the wider skills/url/ssrf selection.
Cross-tracker ref — setting status-in-dev on ent#334 by hand after merge.
Summary
GET /api/skills/library/statuswas gated only byDepends(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 siblingGET /api/skills/sourcesreturns the sameget_library_status()dict behindrequire_admin+reject_agent_principal, with a docstring stating why: for a private source, the repo URL is itself sensitive.One payload, two trust levels — the weaker gate was the bug.
Why an allow-list and not
require_adminThe issue offered a fork: gate the route, or strip userinfo from the URLs. Both are wrong, in opposite directions, and this is the decision most worth reviewing:
require_adminbreaks two shipped non-admin surfaces./libraryisrequiresAuthonly (router/index.js:123) and its skills section calls the endpoint for every authenticated visitor; the per-agent Skills tab is gated oncan_share— owner, not admin (AgentDetail.vue:885). Both derive their empty-state discriminator fromconfigured/cloned, so a 403 renders a configured library as "not configured", including the non-admin empty states written for exactly those callers ("Ask your admin to configure a skills library").Allow-listing withholds the sensitive field while every caller keeps the state it actually reads.
SkillsLibraryStatusis fail-closed by construction — FastAPI serialises through an explicitly-constructed model, so a future sensitive field is invisible over REST until someone names it. Same idiom, same reason, asresponse_model=List[SkillInfo]onGET /skills/library.Verified at the HTTP layer, not just
model_dump: aTestClientround-trip with a tokenizedurland an injected unknown field drops both from the wire body.What is withheld, and what is not
Withheld — flat
url, per-sourceurl, per-sourcelast_error.last_errorwas found by/csoand is the non-obvious one: it 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_urlbuilds when the stored URL already has userinfo. That combination reliably fails auth, so the leaking branch is the guaranteed one. Its only consumer (SkillSourcesPanel.vue:90) reads the admin-gated sources route, which still returns it — no operator loses the error.Kept —
branchandcommit_sha. A ref name and a commit hash are neither credentials nor repo identity, and the Library header renders both. Dropping them was considered and cut: it would be a second, unrelated behaviour change riding a security fix.strip_url_credentialsNew helper in
utils/url_validation.py, besidereject_embedded_credentials(that module already owns what userinfo means here). Applied at both service emitters, so every consumer — including the admin route andSkillSourcesPanel— is covered from one place._authenticated_urlsets: 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@.urlparsethrows on an unbalanced bracket, and the legacy-adoption path writes rows with no validation at all, whileget_library_statusmust never 500 the panel.//tok@host— the assumed scheme producedhttps:////tok@host, whose netloc parses empty) was caught in/reviewand fixed. It's one of the shapes the frontendstripUserinfoenumerates; the two strips have to agree on it.Changes
utils/url_validation.pystrip_url_credentialsservices/skill_service.pyget_library_status()models.pySkillsLibraryStatus/SkillsLibrarySourceStatusallow-list (Invariant #14)routers/skills.pyresponse_modelon the public status routeSkillsPanel.vue,LibrarySkillsSection.vueSkillSourcesPanel.vuestripUserinfowhere a URL still renders (admin route)mcp-server/src/tools/skills.tsurlfrom the interface so the type stops advertising ittests/*urldocs/memory/feature-flows/skills-library-sync.mdTest Plan
153 passed—test_ent334_status_url_disclosure.py,test_ent237_skill_sources.py,test_ssrf_skills_library.py(-p no:randomly)strip_url_credentialsover 15 shapes incl. double-@, protocol-relative,git+ssh://, malformed, IDN, tab/newline,None, non-string — 0 leaks, 0 raisesresponse_modelis ever droppedtests/test_skills.py— it previously requiredurlto be presenttests/test_skills.pycannot run locally (401 on/token); identical ondev-baseline— environment, not regression. Needs CI.Deliberately out of scope
PUT /api/settings/skills_library_url, bypassing the grant gate ent#237 built. Read half verified live; the write half is inference.@URL.Both collide with
_adopt_legacy_clone, so this lands first.The issue's final AC ("lands on
feature/ent-237-multi-source-skillsbefore it merges") is satisfied by obsolescence — ent#237 merged as9e98b31c(#1901). Base is freshdev.Refs abilityai/trinity-enterprise#334