
NVIDIA Verified Skills: Sign and Verify Agent Skills
Summary
Build a signed AI agent skill with a skill card using OpenSSF Model Signing, then verify it.
NVIDIA Verified Skills: Sign and Verify Agent Skills
On May 22, 2026 NVIDIA shipped Verified Agent Skills, a governance layer for the thing every agent stack now depends on: portable skills. A skill is a small folder an agent can load to gain a capability — a SKILL.md that tells the model how to use it, plus the scripts and assets it needs. They are wonderful for reuse and terrible for trust, because a skill is just files on disk that anyone can copy, edit, and re-share.
NVIDIA's answer is two pieces working together. A skill card is a machine-readable trust record that states what a skill does, who built it, how it is licensed, what it depends on, and which risks were found. A cryptographic signature — a detached skill.oms.sig produced with the OpenSSF Model Signing standard — covers every file in the skill directory, so a consumer can prove the skill is authentic and unchanged before an agent ever loads it.
This guide builds the whole loop from scratch in Python: package a skill, author a skill card, hash and sign the package, then verify it on the other side and watch verification fail the moment a single byte is tampered with. Every command below was run before publishing, and the real output is included. NVIDIA's hosted pipeline uses sigstore keyless signing via the model_signing tool; we use an offline ed25519 key so the example runs anywhere, then map it back to the production flow at the end.
Prerequisites
- Python 3.10+ (tested on 3.10.12).
pip install cryptography pyyaml— cryptography 46.x and PyYAML 6.x.- Comfort with the command line and basic public-key crypto (you do not need to be a cryptographer).
- A rough idea of what an agent skill is: a folder with a
SKILL.mdand supporting files.
No NVIDIA account or GPU is required. The signing technique here is the same one the OpenSSF Model Signing project standardized; NVIDIA layers a catalog, scanning, and skill-card generation on top of it.
Why signing skills matters now
Agents load skills the way apps load packages, and the supply-chain problems are identical. A poisoned SKILL.md can rewrite the agent's instructions (prompt injection by way of tooling). A swapped script can exfiltrate data the next time the skill runs. Because skills are copied between registries, internal wikis, and chat messages, “I downloaded it from a link someone shared” is not provenance.
Signing splits the problem into two answerable questions. Authenticity: did this skill really come from the publisher it claims? Integrity: is it byte-for-byte what that publisher signed? The skill card adds a third, human/policy question — declared behavior: what does the publisher say this skill is allowed to do, and did they acknowledge its risks? Signing proves the first two cryptographically. The card makes the third auditable. Neither replaces scanning the code, and we will come back to that limit.
Step 1 — Package a skill
A skill is just a directory. Ours exposes one capability — fetch a URL and return text — with a SKILL.md the agent reads and a fetch.py it can run.
mkdir -p web-fetch-skill
# web-fetch-skill/SKILL.md
cat > web-fetch-skill/SKILL.md <<'EOF'
# web-fetch
Fetch a URL and return clean text for an agent to read.
## Usage
Call `fetch(url)` with an https URL. Returns extracted text.
EOF
# web-fetch-skill/fetch.py
cat > web-fetch-skill/fetch.py <<'EOF'
import sys, urllib.request
def fetch(url: str) -> str:
with urllib.request.urlopen(url, timeout=10) as r:
return r.read().decode("utf-8", "replace")
if __name__ == "__main__":
print(fetch(sys.argv[1])[:500])
EOF
That is a complete, loadable skill. The next steps make it trustworthy without changing how it works.
Step 2 — Author the skill card
The skill card is the machine-readable trust record. Keep it next to the skill as skill_card.yaml. NVIDIA's cards cover intent, authorship, license, dependencies, permissions, and identified risks with mitigations — so model those fields explicitly.
# web-fetch-skill/skill_card.yaml
schema_version: "1.0"
name: web-fetch
version: "0.3.1"
author: "Acme Platform Team <agents@acme.dev>"
license: "Apache-2.0"
intent: "Fetch an https URL and return extracted text to an agent."
inputs:
- name: url
type: string
constraint: "https only"
dependencies: []
permissions:
- network:egress
risks:
- id: SSRF
description: "Agent-supplied URL could target internal hosts."
mitigation: "Block private IP ranges; allowlist domains."
limitations:
- "No JS rendering; static HTML only."
Two fields do real work later. permissions declares the blast radius (this skill touches the network), and every entry under risks carries a mitigation. A card that lists a risk with no mitigation is an incomplete card, and we will reject it in Step 5.
Step 3 — Hash every file into a manifest
You never sign a folder directly. You sign a manifest: a list of every file path with its SHA-256 digest. The signature then transitively protects the whole tree, because changing any file changes its digest, which changes the manifest, which breaks the signature. This is exactly how OpenSSF Model Signing covers a model directory; we apply it to a skill directory.
Two details make a manifest reproducible across machines. Walk files in sorted order, and serialize the manifest as canonical JSON (sorted keys, no whitespace) so the signed bytes are identical no matter who builds it.
# part of sign_skill.py
import json, hashlib
from pathlib import Path
def file_digests(root: Path) -> dict[str, str]:
digests = {}
for path in sorted(root.rglob("*")): # sorted = deterministic order
if path.is_file() and path.name != "skill.sig":
digest = hashlib.sha256(path.read_bytes()).hexdigest()
digests[path.relative_to(root).as_posix()] = f"sha256:{digest}"
return digests
def build_manifest(root: Path) -> bytes:
manifest = {"skill_root": root.name, "files": file_digests(root)}
# canonical JSON -> byte-stable across machines
return json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode()
Note the signature file itself is excluded from the manifest — it cannot hash itself.
Step 4 — Generate a key and sign
The publisher needs a key pair. The private key signs and never leaves the publisher; the public key is shipped (or pinned in your agent platform) so anyone can verify. We use Ed25519 — fast, tiny signatures, hard to misuse.
# keygen.py — run once per publisher
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
from pathlib import Path
priv = Ed25519PrivateKey.generate()
Path("signing_key.pem").write_bytes(priv.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
))
Path("public_key.pem").write_bytes(priv.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
))
print("wrote signing_key.pem (keep secret) and public_key.pem (publish)")
Now sign. We add the main() to sign_skill.py, build the manifest, sign the canonical bytes, and write a detached skill.sig that bundles the algorithm, the exact manifest that was signed, and the signature. This is the local analogue of NVIDIA's skill.oms.sig.
# rest of sign_skill.py
import sys
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
def main(skill_dir: str, key_path: str):
root = Path(skill_dir)
manifest = build_manifest(root)
priv = serialization.load_pem_private_key(Path(key_path).read_bytes(), password=None)
assert isinstance(priv, Ed25519PrivateKey)
signature = priv.sign(manifest)
(root / "skill.sig").write_bytes(json.dumps({
"alg": "ed25519",
"manifest": manifest.decode(),
"signature": signature.hex(),
}).encode())
print(f"signed {len(json.loads(manifest)['files'])} files -> {root/'skill.sig'}")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
$ python3 keygen.py
wrote signing_key.pem (keep secret) and public_key.pem (publish)
$ python3 sign_skill.py web-fetch-skill signing_key.pem
signed 3 files -> web-fetch-skill/skill.sig
Three files signed: SKILL.md, fetch.py, and skill_card.yaml. The skill is now publishable.
Step 5 — Verify on the consumer side
Verification is where trust is actually enforced, and it runs before the agent loads the skill. The verifier answers all three questions in order: (1) is the signature valid for our trusted public key, (2) do the files on disk still match the signed manifest, and (3) is a signed skill card present?
# verify_skill.py
import sys, json, hashlib
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.exceptions import InvalidSignature
def file_digests(root: Path) -> dict[str, str]:
digests = {}
for path in sorted(root.rglob("*")):
if path.is_file() and path.name != "skill.sig":
digest = hashlib.sha256(path.read_bytes()).hexdigest()
digests[path.relative_to(root).as_posix()] = f"sha256:{digest}"
return digests
def verify(skill_dir: str, pub_path: str) -> int:
root = Path(skill_dir)
sigfile = json.loads((root / "skill.sig").read_bytes())
pub = serialization.load_pem_public_key(Path(pub_path).read_bytes())
# 1. signature covers the manifest bytes exactly as signed
try:
pub.verify(bytes.fromhex(sigfile["signature"]), sigfile["manifest"].encode())
except InvalidSignature:
print("FAIL: signature does not match publisher key"); return 1
# 2. files on disk match the signed manifest (detect tampering)
signed = json.loads(sigfile["manifest"])["files"]
current = file_digests(root)
if current != signed:
added = set(current) - set(signed)
removed = set(signed) - set(current)
changed = {f for f in signed if f in current and signed[f] != current[f]}
print("FAIL: package contents differ from signed manifest")
if changed: print(" changed:", ", ".join(sorted(changed)))
if added: print(" added: ", ", ".join(sorted(added)))
if removed: print(" removed:", ", ".join(sorted(removed)))
return 1
# 3. a signed skill card must be present
if "skill_card.yaml" not in signed:
print("FAIL: no signed skill card in package"); return 1
print(f"PASS: {len(signed)} files verified, signed skill card present")
return 0
if __name__ == "__main__":
sys.exit(verify(sys.argv[1], sys.argv[2]))
$ python3 verify_skill.py web-fetch-skill public_key.pem
PASS: 3 files verified, signed skill card present
$ echo $?
0
The verifier returns exit code 0 on success, so you can gate skill installation in CI or in the agent's loader with a plain if rc != 0: refuse.
Worked example: catching a tampered skill
A signature is only worth anything if it fails when it should. Here are the three attacks that matter, each run against the signed package above.
1. An attacker edits a script. Append one line to fetch.py and re-verify:
$ printf '\nimport os # exfiltrate added by attacker\n' >> web-fetch-skill/fetch.py
$ python3 verify_skill.py web-fetch-skill public_key.pem
FAIL: package contents differ from signed manifest
changed: fetch.py
$ echo $?
1
2. An attacker drops in an extra file (a classic post-install hook). The manifest is exhaustive, so unexpected files are caught too:
$ echo "print('backdoor')" > web-fetch-skill/post_install.py
$ python3 verify_skill.py web-fetch-skill public_key.pem
FAIL: package contents differ from signed manifest
added: post_install.py
$ echo $?
1
3. An attacker re-signs with their own key. They can produce a valid-looking skill.sig, but only against their key. Verified against the publisher's pinned public key, it fails:
$ python3 verify_skill.py web-fetch-skill attacker_pub.pem
FAIL: signature does not match publisher key
$ echo $?
1
This is the entire value proposition: tampering is detected before the agent runs anything, and impersonation requires the publisher's private key, not just a convincing folder.
Validating the skill card
Signing proves the card is authentic, but not that it is complete. Add a small policy check so an under-documented skill is rejected even when its signature is valid.
# validate_card.py
import sys, yaml
from pathlib import Path
REQUIRED = ["schema_version", "name", "version", "author",
"license", "intent", "permissions", "risks"]
def validate_card(path: str) -> int:
card = yaml.safe_load(Path(path).read_text())
missing = [k for k in REQUIRED if k not in card or card[k] in (None, "", [])]
if missing:
print("FAIL: skill card missing fields:", ", ".join(missing)); return 1
unmitigated = [r.get("id", "?") for r in card["risks"] if not r.get("mitigation")]
if unmitigated:
print("FAIL: risks without mitigation:", ", ".join(unmitigated)); return 1
print(f"PASS: card for {card['name']} v{card['version']} "
f"({len(card['permissions'])} permission(s), {len(card['risks'])} risk(s))")
return 0
if __name__ == "__main__":
sys.exit(validate_card(sys.argv[1]))
$ python3 validate_card.py web-fetch-skill/skill_card.yaml
PASS: card for web-fetch v0.3.1 (1 permission(s), 1 risk(s))
Mapping this to NVIDIA's production flow
The mechanics above are the real ones; production swaps two pieces. First, NVIDIA signs with the OpenSSF Model Signing tooling (the model_signing CLI) using sigstore keyless signing, which binds the signature to a verified workload identity instead of a raw key file you have to store and rotate. The detached artifact is named skill.oms.sig and, like ours, covers every file in the skill directory.
Second, signing is the last stage of a pipeline, not the whole story. NVIDIA's flow runs human review and automated policy checks, then scanning and evaluation, then generates the skill card, then signs, catalogs, and syncs to the public catalog. The published skills live in the open NVIDIA/skills repository. The lesson for your own stack: a signature answers “who and unchanged,” while scanning and the card answer “is the behavior acceptable.” Run both.
If you adopt the OpenSSF tool directly, the consumer command is essentially model_signing verify ... against the published identity; our hand-rolled verify_skill.py exists so you can see exactly what that verification is doing under the hood.
Common pitfalls and gotchas
- Signing the card but not the code. A signature over only
skill_card.yamllets an attacker keep an honest-looking card while swappingfetch.py. Always hash the whole directory into the manifest. - Non-deterministic manifests. If you walk files in filesystem order or serialize JSON with default whitespace, two machines produce different signed bytes and verification flaps. Sort paths and use canonical JSON (
sort_keys=True, separators=(",",":")). - Forgetting to exclude the signature file. If
skill.sigis included in its own manifest you get a chicken-and-egg hash. Exclude it by name on both the signing and verifying side. - TOCTOU — verify, then load a different file. Verify and load from the same immutable copy. Verifying a directory and then loading skills that another process can still write to defeats the check.
- Trusting any valid signature. A signature only means “signed by some key.” You must verify against a pinned, trusted public key (or sigstore identity). Accepting whatever key ships in the package is no security at all — that is exactly the wrong-key case above.
- Treating provenance as a code review. A signed, well-documented skill can still be malicious if the publisher is malicious or compromised. The card declares intent; it does not enforce it. Keep scanning the code.
- Symlinks and path traversal.
rglobcan follow links or surface paths outside the root. Resolve and reject anything that escapes the skill directory before hashing. - Key management. An offline PEM is fine for a demo; in production protect the private key in an HSM or use keyless signing so there is no long-lived secret to leak.
Quick reference
| Concept | What it is | Protects against |
|---|---|---|
| Manifest | SHA-256 of every file, canonical JSON | Silent file edits / extra files |
| Detached signature (skill.oms.sig) | Ed25519/sigstore signature over the manifest | Tampering + impersonation |
| Skill card (skill_card.yaml) | Machine-readable intent, license, risks | Undocumented / unaudited behavior |
| Pinned public key | Publisher key you trust in advance | Attacker re-signing with their own key |
| Card validation | Required-field + risk-mitigation policy check | Incomplete trust records |
| Scanning (separate) | Static/dynamic analysis of the code | Malicious-but-honest publishers |
Next steps
- Swap the offline key for the
model_signingtool with sigstore keyless signing so you carry no long-lived secret. - Wire
verify_skill.pyinto your agent's skill loader: refuse to load on a non-zero exit code. - Add a real scanner (secret detection, dangerous-import checks) as a pipeline stage before signing.
- Publish your public key (or trusted sigstore identities) where consumers can pin it, and document your skill-card schema.
- Browse the open
NVIDIA/skillsrepository to see real skill cards and signatures in the wild.
You now have a working trust loop for agent skills: package, card, sign, verify, and fail-closed on tampering. That is the core of what NVIDIA Verified Skills standardizes — and it is small enough to drop into your own agent platform this week.
Comments
Be the first to comment
Found this useful?
Get new AI guides for builders by email. Free.