Marketplaces and snapshots¶
The core API: registration, ingestion, the approval gate and provenance. All
paths are relative to /api.
With role enforcement enabled:
registration and sync-mode changes require admin; ingest, approve, reject,
re-vet, and waiver create/delete require approver of the marketplace (or
admin) — resolved server-side from the addressed snapshot or waiver where the
route carries an id; every GET on this page stays open to any session.
Machine reach. marketplaces:read covers GET /marketplaces, GET
/catalog and a snapshot's /content, /licenses, /provenance and
/release-age; snapshots:read covers /diff, /file, /files, /vetting,
/fetchers and /four-eyes; marketplaces:register, marketplaces:ingest,
vetting:run, sync:write and waivers:read cover the corresponding
mutations and the waiver listing. Approve, reject, waiver create, waiver
delete, snapshot delete and snapshot restore are reachable by no scope at
all — they publish, refuse or retract content. See
Machine API credentials.
Representations¶
Marketplace
{"id":1,"name":"acme","url":"https://github.com/acme/skills.git",
"createdAt":"2026-08-15T09:00:00Z","registeredBy":"dana",
"forge":"github","forgeProject":"acme/skills",
"description":"Acme internal skills","upstreamUpdatedAt":"2026-08-14T18:20:00Z",
"snapshots":[]}
Snapshot
{"id":42,"marketplaceId":1,"sha":"3f9c2ab...","state":"held",
"violation":null,"createdAt":"2026-08-15T09:01:00Z","ingestedBy":"ingrid",
"decidedBy":null,"decidedAt":null}
registeredBy and ingestedBy are the supply-side identities the
four-eyes rule
compares a reviewer against. ingestedBy is a principal for an on-demand
ingest or a push, scheduler or webhook for an automated trigger, and null
for a snapshot ingested before the actor was recorded; registeredBy is null
for a marketplace registered before it was.
state is one of held, approved, rejected, revoked. A revoked snapshot
also carries revokedBy and revokedAt, and its violation says what
re-vetting found. See
Re-vetting approved content.
POST /marketplaces¶
Register a marketplace. Fetches nothing.
Body — {name, url?, ref?, origin?, pushPolicy?}
$ curl -X POST localhost:8080/api/marketplaces \
-H 'Content-Type: application/json' \
-d '{"name":"acme","url":"https://github.com/acme/skills.git"}'
origin decides where the content comes from:
origin |
url |
Content arrives by |
|---|---|---|
upstream (default) |
required | the gateway fetching the upstream default branch |
hosted |
must be absent | a publisher pushing to /publish/{name} |
pushPolicy applies to a hosted marketplace only: append-only (the default)
refuses a non-fast-forward push, allow-rewrite permits one and records both
tips on the ledger. See
Publishing first-party skills.
| Status | Cause |
|---|---|
| 201 | Registered; returns the marketplace. |
| 400 | URL scheme not allowlisted, ref present and not main, a hosted registration supplying a url, an upstream one omitting it, or a pushPolicy on an upstream marketplace. |
| 409 | Name already exists. |
| 422 | Name fails ^[a-z0-9][a-z0-9_-]*$, or an unknown origin/pushPolicy. |
The 400 cases are trust-boundary rejections — see Compatibility and allowlists.
GET /marketplaces¶
All marketplaces, each with its forge metadata and full snapshot list. This is the portal's primary query; there is no per-marketplace endpoint.
200 — array of marketplaces.
POST /marketplaces/{name}/ingest¶
Clone the source's default branch into quarantine and pin the tip commit as
refs/snapshots/{sha}. Creates a snapshot in state held. For a hosted
marketplace the source is its own origin repository, and a push already does
this — the endpoint stays available to re-ingest.
| Status | Cause |
|---|---|
| 201 | Snapshot captured; returns it. |
| 404 | Unknown marketplace. |
| 502 | Ingestion failed — upstream unreachable, or the manifest was rejected. |
Ingesting a commit already captured does not create a second snapshot.
Upstream sync¶
Automated ingestion triggers (GW_0056–GW_0060). Modes and the secret lifecycle are described in Syncing from upstream automatically.
PUT /marketplaces/{name}/sync¶
Set how upstream content reaches quarantine: on-demand (default),
scheduled, or webhook. No mode bypasses approval.
$ curl -X PUT localhost:8080/api/marketplaces/acme/sync \
-H 'Content-Type: application/json' -d '{"mode":"webhook"}'
webhookSecret is present only when the new mode is webhook, and only in
this response — setting webhook mode again rotates it, and leaving the mode
discards it. The change is audit-logged as sync-mode-changed.
| Status | Cause |
|---|---|
| 200 | Mode changed; returns the marketplace, plus the secret when entering webhook mode. |
| 404 | Unknown marketplace. |
| 422 | Not one of the three modes. |
POST /hooks/{marketplace}¶
The inbound forge webhook — not under /api, and the only endpoint
reachable without an OIDC session or a PAT. Authenticated solely by an
HMAC-SHA256 signature of the exact raw request body in the GitHub-compatible
X-Hub-Signature-256: sha256=<hex> header, verified in constant time against
the marketplace's secret.
The payload is ignored: a valid signature only triggers an asynchronous
ingestion of the registered upstream URL's default branch — nothing in the
body is read. The ingestion is recorded on the ledger with the actor webhook
and lands held like any other.
| Status | Cause |
|---|---|
| 202 | Signature valid; ingestion queued. |
| 403 | Missing or invalid signature. Nothing was ingested. |
| 404 | Unknown marketplace, or its sync mode is not webhook. |
| 413 | Body exceeds skills-gateway.sync.max-webhook-body-bytes; rejected before verification. |
Virtual catalog¶
The synthesized one-URL catalog (GW_0061–GW_0063); see The virtual catalog.
GET /catalog¶
The catalog revision the facade is serving at /git/{catalog-name}.
200 · 404 catalog disabled or not generated yet.
POST /catalog/rebuild¶
Regenerate now from what every marketplace is serving. Approvals and
revocations already do this on their own; this is the on-demand repair path.
Audit-logged as catalog-rebuilt with the acting identity.
200 — the new revision · 404 catalog disabled.
GET /snapshots/{id}/content¶
What the snapshot declares — the review surface. Works on held snapshots,
because reviewing must not require serving.
{"snapshotId":42,"sha":"3f9c2ab...","state":"held",
"plugins":[{"name":"acme-tools","description":"Deployment helpers",
"source":"./plugins/acme-tools",
"skills":[{"name":"deploy","path":"skills/deploy/SKILL.md"}]}]}
200 · 404 unknown snapshot.
GET /snapshots/{id}/licenses¶
The licenses the snapshot declares, each with its standing under the
configured allow/ban policy. Detection is
deterministic — SPDX ids resolved from license/copying files anywhere in the
tree, SPDX-License-Identifier tags inside them, and the marketplace
manifest's license metadata fields — and runs over the content pinned to the
snapshot's commit SHA, so the report exists for every snapshot, held
included.
{"snapshotId":42,"sha":"3f9c2ab...",
"licenses":[
{"spdxId":"MIT","source":"file","location":"LICENSE",
"declared":null,"evaluation":"OK"},
{"spdxId":null,"source":"file","location":"plugins/acme-tools/COPYING",
"declared":null,"evaluation":"UNKNOWN"},
{"spdxId":"ISC","source":"manifest",
"location":".claude-plugin/marketplace.json#plugins[acme-tools].license",
"declared":"ISC","evaluation":"OK"}],
"allowed":["MIT","Apache-2.0"],"banned":["AGPL-3.0"]}
A spdxId of null is the unknown license state — the source identified
no known license. An empty licenses array means the snapshot carries no
license information at all. evaluation is one of OK, BANNED,
NOT_ALLOWED, UNKNOWN.
This read reports current policy truth: it is recomputed under the
configuration in force, complementing the gateway's own
SBOM endpoint (/actuator/sbom) and the content
inventory above as the supply-chain read surface. The evidence the approval gate acted on is the
recorded vetting run, not this report.
200 · 404 unknown snapshot.
Snapshot preview¶
Read-only inspection of a snapshot's pinned content: the file tree, individual
files, and the diff against what the marketplace currently serves. Everything
resolves through the quarantine repository's object store at the pinned commit
— paths are matched against tree entries only, so a path the commit does not
contain (traversal shapes included) is simply not found. Works on held
snapshots: inspecting content must not require serving it.
These reads return raw held content, so unlike the metadata reads above they
are privileged while role enforcement is enabled
(skills-gateway.roles.enabled=true): an admin, or an approver of the
snapshot's marketplace. Everyone else gets 403. With enforcement at its
default (off) they are open to any authenticated session.
GET /snapshots/{id}/files¶
Every path in the pinned commit's tree, with blob sizes. The listing is capped
at 2000 entries; "truncated": true says it was cut.
{"snapshotId":42,"sha":"3f9c2ab...","truncated":false,
"entries":[{"path":".claude-plugin/marketplace.json","size":180},
{"path":"plugins/acme-tools/skills/deploy/SKILL.md","size":841}]}
200 · 403 enforcement enabled, no applicable role · 404 unknown snapshot.
GET /snapshots/{id}/file?path={path}¶
One blob, as text for rendering only. Content is cut at 128 KiB with
"truncated": true and the full size still reported; a blob detected as
binary returns metadata with no text at all.
{"snapshotId":42,"path":"plugins/acme-tools/skills/deploy/SKILL.md",
"size":841,"binary":false,"truncated":false,"text":"# Deploy\n..."}
200 · 403 enforcement enabled, no applicable role · 404 unknown snapshot, or the path is not in the pinned tree.
GET /snapshots/{id}/diff¶
The delta a reviewer decides: added, modified and removed paths between the
pinned commit and the marketplace's currently served commit (the published
repository's served tip — the same commit a git fetch returns), with a
unified text diff per non-binary entry under the same 128 KiB cap. The entry
list is capped at 500 with a truncated marker.
When the marketplace serves nothing — never approved, or its content was
revoked or unpublished — baselineSha is null and every path is reported as
added, without diff text: approving the snapshot would serve all of it.
{"snapshotId":43,"sha":"9d41f00...","baselineSha":"3f9c2ab...","truncated":false,
"entries":[{"path":"plugins/acme-tools/skills/deploy/SKILL.md","type":"modified",
"binary":false,"truncated":false,
"diff":"--- a/...\n+++ b/...\n@@ -1 +1 @@\n-old\n+new\n"}]}
200 · 403 enforcement enabled, no applicable role · 404 unknown snapshot.
GET /snapshots/{id}/vetting¶
The snapshot's latest vetting chain run: each connector's verdict in chain
order, the findings behind it, the waivers currently suppressing any of them,
and the fail-closed effective aggregate that gates approval. A snapshot the
chain has never run against reports "outcome":"BLOCKED" and "run":null.
{"snapshotId":12,"outcome":"CLEAR_WITH_WAIVERS","recordedOutcome":"BLOCKED",
"run":{"runId":5,"snapshotId":12,"trigger":"ingestion","outcome":"BLOCKED",
"startedAt":"...","finishedAt":"...",
"verdicts":[
{"verdictId":9,"connector":"secret-scan","position":0,"state":"FAIL",
"detail":"1 finding(s); worst critical","reportUrl":null,
"findings":[{"id":"aws-access-key-id","severity":"CRITICAL",
"location":"plugins/hello/DEPLOY.md:5",
"message":"an AWS access key id is committed in this file"}]},
{"verdictId":10,"connector":"prompt-injection","position":1,
"state":"PASS","detail":null,"reportUrl":null,"findings":[]}]},
"suppressed":[{"connector":"secret-scan","ruleId":"aws-access-key-id",
"location":"plugins/hello/DEPLOY.md:5","waiverId":3,
"approvedBy":"alice","expiresAt":"2026-09-30T23:59:59Z"}],
"uncovered":[],
"waivers":[{"id":3,"marketplace":"corp-marketplace","ruleId":"aws-access-key-id",
"scope":"SNAPSHOT","scopeValue":"a1b2c3…","justification":"documented dummy key",
"approvedBy":"alice","createdAt":"...","expiresAt":"2026-09-30T23:59:59Z",
"revokedAt":null,"revokedBy":null,"active":true}],
"connectors":[{"name":"secret-scan","order":100,"description":"..."},
{"name":"prompt-injection","order":200,"description":"..."}]}
| Field | Meaning |
|---|---|
outcome |
The effective outcome — the one that gates approval: CLEAR, CLEAR_WITH_WAIVERS, or BLOCKED. Recomputed on every request from the run and the waivers active at that instant. |
recordedOutcome |
What the connectors themselves concluded: CLEAR or BLOCKED. Never rewritten by a waiver. |
suppressed |
The findings an active waiver is currently removing from the computation. |
uncovered |
The blocking findings no active waiver covers — the waivers approval still needs. |
waivers |
The marketplace's waivers whose rule appears in this run, active and lapsed alike. |
state is one of PASS, WARN, FAIL, ERROR, PENDING; severity is one
of INFO, LOW, MEDIUM, HIGH, CRITICAL. What each means is described in
Vetting — the connector chain.
| Status | Cause |
|---|---|
| 200 | The latest chain run, its waivers, and the configured chain. |
| 404 | Unknown snapshot. |
Vetting waivers¶
A waiver accepts one finding rule, on one marketplace, within one scope, until one expiry. See Waiving a vetting finding for the task and the concept page for the matching rules.
POST /snapshots/{id}/waivers¶
{"ruleId":"aws-access-key-id","scope":"SNAPSHOT",
"justification":"documented dummy key in the fixtures directory",
"expiresAt":"2026-09-30T23:59:59Z"}
| Field | Required | Notes |
|---|---|---|
ruleId |
yes | The finding's stable rule id. |
scope |
yes | SNAPSHOT or PATH. |
path |
for PATH |
Repository-relative; must not contain ... Ignored for SNAPSHOT. |
justification |
yes | Free text; blank is refused. |
expiresAt |
yes | Must be in the future. There is no unlimited waiver. |
The marketplace — and, for SNAPSHOT scope, the commit SHA — are taken from the
snapshot, so a waiver cannot be scoped to content it does not belong to. The
approver is the acting session.
| Status | Cause |
|---|---|
| 201 | Waiver recorded; returns it with active. |
| 400 | Missing justification or expiry, an expiry in the past, or an unusable scope. |
| 404 | Unknown snapshot. |
GET /marketplaces/{name}/waivers¶
Every waiver of the marketplace, newest first, active and lapsed alike. A lapsed
or revoked waiver is returned with "active":false — the record of what was
once accepted is part of the audit trail.
| Status | Cause |
|---|---|
| 200 | The marketplace's waivers. |
| 404 | Unknown marketplace. |
DELETE /waivers/{id}¶
Revokes the waiver. It stops suppressing its finding on the next read, so a snapshot cleared only by it becomes blocked again immediately. The row is kept, with its revoker and time.
| Status | Cause |
|---|---|
| 200 | Revoked; returns the waiver with active:false. |
| 404 | Unknown waiver, or already revoked. |
Continuous re-vetting¶
Re-running the chain over content that is already approved. What a fresh
violation does is a deployment setting, not a request parameter — see
Re-vetting approved content and the
revet configuration block.
POST /snapshots/{id}/revet¶
Runs the chain again over the snapshot's pinned content, recording a new run
with trigger revet-manual.
{"snapshotId":12,"marketplace":"corp-marketplace","sha":"3f9c2ab…","runId":31,
"classification":"VIOLATION","outcome":"BLOCKED","revoked":true,"mode":"ENFORCE",
"uncovered":[{"connector":"secret-scan","ruleId":"aws-access-key-id",
"location":"plugins/hello/DEPLOY.md:5","severity":"CRITICAL",
"message":"an AWS access key id is committed in this file"}],
"affected":[{"principal":"team-payments","fetches":12,
"lastFetch":"2026-08-14T22:10:00Z"}]}
| Field | Meaning |
|---|---|
classification |
CLEAR, VIOLATION (the chain objects to the content), or INCONCLUSIVE (it blocks only because a connector errored, timed out, or has not answered). |
outcome |
The effective vetting outcome after the waivers active at that instant. |
revoked |
Whether this run revoked and unpublished the snapshot. Always false in warn mode and for INCONCLUSIVE. |
mode |
The re-vetting mode in force. |
uncovered |
The blocking findings no active waiver covers — the reason for a violation. |
affected |
Identities that had already fetched this snapshot's content. |
| Status | Cause |
|---|---|
| 200 | The run and what it concluded. |
| 404 | Unknown snapshot. |
| 409 | The snapshot is not approved. Only served content is re-vetted. |
POST /marketplaces/{name}/revet¶
The same, over every live approved snapshot of the marketplace. This is the operational answer to a scanner rule set or advisory feed that has just moved: the built-in connectors have no feed to subscribe to, so an operator calling this after updating one is how a feed update becomes fresh evidence.
Returns a pass summary — revetted, violations, revoked, inconclusive,
and the individual results.
| Status | Cause |
|---|---|
| 200 | What the pass re-vetted and concluded. |
| 404 | Unknown marketplace. |
GET /snapshots/{id}/fetchers¶
Every authenticated identity that received this snapshot's content through the git facade, with how often and when it last did — the blast radius of a retroactive violation, read from the append-only fetch ledger.
Only pack transfers count. A ref advertisement means a client asked, not that it received anything, so counting it would name teams that never got the content.
| Status | Cause |
|---|---|
| 200 | The identities that fetched the snapshot's content. |
| 404 | Unknown snapshot. |
POST /snapshots/{id}/approve¶
The only endpoint that publishes. Fetches the pinned quarantine ref into the
published repository and force-updates refs/heads/main to that SHA.
Takes no request body. A snapshot whose effective vetting outcome is blocked
is refused, and the problem document carries both blockingConnectors and
uncoveredFindings:
{"status":409,"title":"Vetting chain blocked this snapshot",
"detail":"snapshot 12 cannot be approved: …",
"blockingConnectors":["secret-scan"],
"uncoveredFindings":[{"connector":"secret-scan","ruleId":"aws-access-key-id",
"location":"plugins/hello/DEPLOY.md:5","severity":"CRITICAL",
"message":"an AWS access key id is committed in this file"}]}
uncoveredFindings is the complete worklist: record a waiver for each entry and
the approval succeeds. Every waiver that was in force is appended to the audit
ledger as waiver-applied.
A snapshot inside the configured minimum release age is refused by the same status with a different problem document — one nothing in the API can unblock, because it clears itself:
{"status":409,"title":"Snapshot has not reached the minimum release age",
"detail":"snapshot 12 cannot be approved yet: …",
"configKey":"skills-gateway.vetting.minimum-release-age",
"minimumReleaseAge":"PT72H",
"eligibility":{"snapshotId":12,"eligible":false,
"firstIngestedAt":"2026-08-16T09:00:00Z",
"eligibleAt":"2026-08-19T09:00:00Z",
"ageSeconds":57600,"remainingSeconds":187200,
"minimumReleaseAgeSeconds":259200}}
Under an enforcing four-eyes rule an approval by an identity on the snapshot's supply side is refused by the same status, with the conflicting acts named:
{"status":409,"title":"Four-eyes rule refused this approval",
"detail":"four-eyes rule refused approval of snapshot 12: …",
"configKey":"skills-gateway.approval.four-eyes.mode",
"conflicts":[{"role":"registered-by","principal":"dana","waiverId":null},
{"role":"ingested-by","principal":"dana","waiverId":null},
{"role":"waiver-author","principal":"dana","waiverId":7}]}
Nothing in the API unblocks this one either: a different identity has to
approve. Under the default warn mode the same conflicts are detected, the
approval succeeds, and a four-eyes-conflict entry is appended to the audit
ledger beside snapshot-approved.
| Status | Cause |
|---|---|
| 200 | Approved; returns the snapshot with decidedBy and decidedAt. |
| 404 | Unknown snapshot. |
| 409 | The snapshot is neither held nor revoked, its effective vetting outcome is blocked, a policy rule denied it, it has not reached the minimum release age, or an enforcing four-eyes rule refused it. |
A revoked snapshot is approved through this same endpoint and no other — there
is no un-revoke. The gate is unchanged, so the finding that revoked it must be
waived or fixed first; the transition records a fresh reviewer and clears the
revocation marks.
A blocked snapshot can still be published
The gate is a set of written, attributed, expiring acceptances — not a prohibition. A fully waived snapshot publishes exactly as an ordinary approval does. The difference is that the ledger says which risk was accepted, by whom, and until when.
GET /snapshots/{id}/release-age¶
Whether the snapshot has cleared the minimum release age, and when it will if it has not. This is the same computation the approve endpoint gates on, so the two can never disagree.
{"snapshotId":12,"eligible":false,
"firstIngestedAt":"2026-08-16T09:00:00Z","eligibleAt":"2026-08-19T09:00:00Z",
"ageSeconds":57600,"remainingSeconds":187200,"minimumReleaseAgeSeconds":259200}
| Field | Meaning |
|---|---|
eligible |
Whether the snapshot may be approved now. Always true when the gate is off. |
firstIngestedAt |
When this gateway first ingested the commit. The age is counted from here — never from the commit's own timestamp. |
eligibleAt |
The instant it becomes approvable; equal to firstIngestedAt when the gate is off. |
ageSeconds |
How long ago the gateway first ingested the commit. |
remainingSeconds |
How much of the window is left; 0 when eligible. |
minimumReleaseAgeSeconds |
The configured window; 0 when the gate is off. |
| Status | Cause |
|---|---|
| 200 | The eligibility record above. |
| 404 | Unknown snapshot. |
GET /snapshots/{id}/four-eyes¶
Whether the four-eyes rule would object to the calling identity approving this snapshot, and what the configured mode would do about it. Decides nothing; the approve endpoint enforces the rule independently.
{"mode":"ENFORCE","refused":true,
"conflicts":[{"role":"registered-by","principal":"dana","waiverId":null},
{"role":"ingested-by","principal":"dana","waiverId":null}]}
| Field | Meaning |
|---|---|
mode |
WARN or ENFORCE, as configured. There is no mode that disables detection. |
conflicts |
Each supply-side act the caller performed on this snapshot: registered-by, ingested-by, or waiver-author with the waiverId they wrote. Empty for an independent reviewer. |
refused |
Whether an approval by this caller would be refused — true only when the mode is ENFORCE and conflicts is non-empty. |
The waiver clause is evaluated exactly as an approval would evaluate it, over the waivers that are actually suppressing findings on this snapshot right now — which is why this is answered by the server rather than derived by a client.
| Status | Cause |
|---|---|
| 200 | The record above. |
| 404 | Unknown snapshot. |
POST /snapshots/{id}/reject¶
Mark the snapshot rejected. No repository is touched; whatever was already
approved keeps serving. This is also the terminal answer to a revoked
snapshot nobody intends to waive.
Same status codes as approve, without its two refusals: neither the vetting outcome nor the minimum release age gates a rejection. Saying no to suspicious content is never something to wait for.
An approved snapshot cannot be re-decided
Both endpoints return 409 for a snapshot that is approved or rejected.
The only way out of approved is an enforced re-vetting violation, which
the gateway makes, not a caller.
GET /snapshots/{id}/provenance¶
Where the snapshot came from and who decided on it.
{"snapshotId":42,"marketplace":"acme",
"upstreamUrl":"https://github.com/acme/skills.git","upstreamSha":"3f9c2ab...",
"state":"approved","violation":null,"ingestedAt":"...",
"decidedBy":"alice@example.com","decidedAt":"..."}
200 · 404 unknown snapshot.
Every action on this page also appends an entry to the audit ledger carrying the acting principal.