Skip to content

feat(registry/types): add signer-identity provenance to Skill - #247

Merged
samuv merged 2 commits into
mainfrom
t3code/add-skill-provenance
Aug 21, 2026
Merged

samuv merged 2 commits into
mainfrom
t3code/add-skill-provenance

Conversation

@samuv

@samuv samuv commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

What

Adds an optional Provenance field to registry.Skill, so a skills catalog can declare who is expected to sign a skill.

// Provenance is the expected signer identity for this skill, checked on
// first install instead of trust-on-first-use. Absent means unconstrained
// — most catalog entries won't have this for a while, and that must not
// break installs; it's an opt-in tightening per entry, not a requirement.
Provenance *Provenance `json:"provenance,omitempty" yaml:"provenance,omitempty"`

This is the prerequisite for stacklok/toolhive#6310 ("Catalog-supplied expected identity, so first install is not trust-on-first-use").

This PR is toolhive-core only. It does not wire anything up in toolhive — that's a follow-up PR there, once this ships in a release and toolhive's go.mod bumps to it. Same pattern as #234/#235toolhive's skills-keyless/01-core-signer (stacklok/toolhive#6383).

Design notes

Reuses the existing registry.Provenance (registry/types/registry_types.go) rather than defining a parallel struct for skills. It already carries the same six semantic fields skills need, including RepositoryRef and RunnerEnvironment — the two that toolhive's lockfile.Provenance added for skills in its stacklok/toolhive#6309 follow-up. Two parallel structs for the same concept just drift.

Added directly to Skill, mirroring ImageMetadata.Provenance. Servers keep provenance indirect for MCP-registry-format reasons (raw upstream ServerJSON nests it in a _meta publisher-extension blob, converted by ServerJSONToImageMetadata), but Skill has no such raw/converted split — it is the format toolhive-catalog serializes as skill.json, field-for-field.

container/verifier.Sigstore.VerifyServer is untouched. It's already generic over an OCI image ref and a *Provenance — nothing in its body or in compareBaseProperties is server-specific — so it should work for skills unmodified. A rename to something generic (Verify, with VerifyServer as a deprecated alias) is tempting now that it's about to gain a non-server caller, but CLAUDE.md's "never change exported interface signatures without discussion" applies. Happy to open that as a separate issue if people want it; blast radius is small (3 non-test call sites).

One deliberate divergence from the server schema

The skill schema's provenance $def mirrors the server one in publisher-provided.schema.json — same fields, descriptions, and examples — but omits its format keywords (uri on repository_uri/cert_issuer, hostname on sigstore_url).

Provenance's five core fields have no omitempty, so a partially populated provenance marshals "repository_uri": "". gojsonschema rejects that:

valid=false errs=[u: Does not match format 'uri' h: Does not match format 'hostname']

Keeping the formats would mean setting only signer_identity — the realistic catalog case, and exactly the opt-in shape this field is for — fails Validate(). The reason is recorded in the $def's own description, and there are regression tests pinning it (only signer identity, empty provenance object).

The alternative, adding omitempty to Provenance's json tags, would change serialization for MCP servers too, so I left it alone.

A local $defs/provenance is used rather than a cross-file $ref, because publisher-provided.schema.json isn't in schema_validation.go's referencedSchemas preload list and the ref wouldn't resolve offline.

Tests

New registry/types/skills_types_test.go, all table-driven:

  • JSON round-trip — nil Provenance omitted (asserted on the raw map, not just the decoded value), full / partial / with-attestation preserved.
  • YAML round-trip — same omitempty and round-trip behavior.
  • Validate() with provenance — absent, full, signer-identity-only, empty object, with attestation. None may fail.
  • ValidateSkillBytes — raw JSON shapes the Go struct can't express (provenance not an object, signer_identity not a string, attestation not an object) correctly rejected.

Verification

  • task — green, 0 lint issues, all packages pass.
  • task license-check — passes.
  • registry/types coverage: 81.1% (repo requires ≥70%).
  • Existing TestSkill_Validate passes unmodified — this change is purely additive.

No live Sigstore/network verification here; actually using VerifyServer against a real signed skill happens in the follow-up toolhive PR.

Out of scope

  • toolhive — the pkg/skills/verifier/skillsvc wiring that passes a catalog-supplied Provenance instead of nil. That's Catalog-supplied expected identity, so first install is not trust-on-first-use toolhive#6310's remaining work, blocked on this merging + releasing + a go.mod bump.
  • toolhive-catalog — populating provenance for real entries (e.g. dockyard's /.github/workflows/build-skills.yml). Separate repo, only possible once this exists in a released version.
  • dockyard — doesn't consume this verification path at all; signs independently with stock cosign.

🤖 Generated with Claude Code

Adds an optional `Provenance` field to `registry.Skill`, so a skills
catalog can declare who is expected to sign a skill. This is the
prerequisite for checking a skill's signer identity on first install
instead of trusting on first use (stacklok/toolhive#6310).

Reuses the existing in-package `registry.Provenance` type rather than
defining a parallel struct for skills. It already carries the same six
semantic fields that toolhive's `lockfile.Provenance` needs, including
`RepositoryRef` and `RunnerEnvironment`.

The field is added directly to `Skill` (mirroring `ImageMetadata`) since
`Skill` is the format `toolhive-catalog` serializes as `skill.json`
directly, with no raw/converted split like MCP servers have.

Absent provenance means unconstrained: most catalog entries won't have
this for a while, and that must not break installs. It is an opt-in
tightening per entry, not a requirement.

The skill schema's `provenance` definition mirrors the server one in
publisher-provided.schema.json, but deliberately omits its `format`
keywords. `Provenance`'s five core fields have no `omitempty`, so a
partially populated provenance serializes empty strings, and an empty
string satisfies neither `uri` nor `hostname`. Keeping the formats would
make a signer-identity-only entry -- the realistic catalog case -- fail
validation. A local `$defs` is used because publisher-provided.schema.json
is not in `referencedSchemas`, so a cross-file `$ref` would not resolve
offline.

`container/verifier.Sigstore.VerifyServer` is untouched: it is already
generic over an OCI ref and a `*Provenance`, and works for skills as-is.

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

Signed-off-by: Samuele Verzi <samu@stacklok.com>
@samuv samuv self-assigned this Aug 21, 2026
Review flagged that the new skill schema advertised `attestation` as an
enforceable constraint while the verifier can silently skip it:

    if p.Attestation != nil && r.Statement != nil &&
       p.Attestation.Predicate != nil && r.Statement.Predicate != nil {

When an expected attestation is set but the artifact carries none,
`r.Statement` is nil, the comparison is skipped, and the function falls
through to `return true`. A skill declaring an attestation would verify
successfully against a signature that has no attestation at all -- a
fail-open on a security constraint.

Rather than propagate that unsound contract to a new surface, the skill
provenance definition now rejects `attestation` outright. Deleting the
property alone would not have been enough: `additionalProperties` was
unset, so the key would still have validated and been silently dropped.
Setting `additionalProperties: false` makes it a loud error, and has the
side benefit of turning a misspelled constraint key into a failure rather
than a quietly missing guarantee:

    skill schema validation failed: provenance: Additional property
    attestation is not allowed

The Go field comment records the same limitation so it is discoverable
from the type, not only from a schema error.

The underlying verifier gap is deliberately left alone here. It predates
this change, it affects MCP servers today through the identical
`attestation` key in publisher-provided.schema.json, and closing it would
change verification outcomes for existing server catalog entries. That is
a cross-consumer behavior change that belongs in its own PR, with the
"expected attestation, actual attestation absent" regression test.
Nothing calls VerifyServer with a skill Provenance yet, so no skill path
is currently exposed.

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

Signed-off-by: Samuele Verzi <samu@stacklok.com>
@samuv
samuv merged commit a9b3a67 into main Aug 21, 2026
5 checks passed
@samuv
samuv deleted the t3code/add-skill-provenance branch August 21, 2026 12:51
samuv added a commit that referenced this pull request Aug 21, 2026
…ce (#251)

* feat(registry/types): allow attestation constraints on skill provenance

#247 stripped `attestation` from the skill provenance contract because the
verifier could silently skip it: an expected attestation was only compared
when the artifact also carried one, so the constraint was dropped exactly
when it mattered. #248 closed that gap -- `compareAttestation` now fails
when the provenance asks for an attestation and the statement is absent.

That removed the reason for the restriction but not the restriction, and
left two comments on main asserting the opposite of what the code does.
Both the schema description and the `Skill.Provenance` doc comment still
claimed a declared attestation "would be silently skipped", and the Go
comment's "until that gap is fixed" read as permanent. Re-add the key and
correct both.

The skill `verified_attestation` definition is deliberately stricter than
the server one in publisher-provided.schema.json: `predicate` is typed as
an object. The verifier normalizes an expected predicate through
`structpb.NewStruct`, which requires `map[string]any`:

    string(str)          -> err=expected predicate is string, want an object
    int(42)              -> err=expected predicate is int, want an object
    []interface{}([a])   -> err=expected predicate is []interface {}, want an object
    map[string]any{k:v}  -> ok

A non-object predicate therefore mismatches every artifact. Untyped, the
schema would accept a constraint that can never be satisfied and the
failure would surface at install time as an unexplained verification
failure. Typing it makes that a validation error at authoring time.

`additionalProperties: false` is kept on both definitions -- it was added
for typo protection rather than for the attestation restriction, and it
now also catches a misspelled `predicatetype`.

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

Signed-off-by: Samuele Verzi <samu@stacklok.com>

* docs(registry/types): clarify that an empty Attestation still constrains

Review nit: "an empty one is not checked" contradicted the next sentence,
since an empty Attestation is deliberately checked and requires the
artifact to be attested.

Scope the rule to empty strings and call out Attestation as the exception,
noting that its own PredicateType and Predicate go back to the usual
constrain-only-when-set behaviour.

The schema's provenance description carried the same contradiction ("an
absent or empty value leaves that dimension unconstrained", which is false
for `attestation: {}`), so fix it there too.

Comments only; no behaviour change.

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

Signed-off-by: Samuele Verzi <samu@stacklok.com>

---------

Signed-off-by: Samuele Verzi <samu@stacklok.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.

2 participants