Building a Python Agent Skills Registry with Cryptographic Attestation and Supply Chain Security

Building a Python Agent Skills Registry with Cryptographic Attestation and Supply Chain Security
The Thing That Kept Me Up at Night
I've been running AI coding agents for about a year now. They're fantastic. They load skill files, follow instructions, execute code on my machine. And one night it hit me: any of those skill files could tell my agent to do anything, and it would just... do it.
Think about it. A SKILL.md file is essentially arbitrary instructions that get injected into an LLM's context with full tool access. The agent doesn't distinguish between "format code with prettier" and "exfiltrate environment variables to an external endpoint." Both are just text that the model follows.
This isn't hypothetical. I traced through my own setup and found skills loaded from GitHub repos I'd starred months ago, maintained by people I don't know, with no integrity verification whatsoever. The files could change at any time. No signatures. No checksums. No audit trail.
If you've followed the npm event-stream incident, or the PyPI typosquatting campaigns, or the recent xz backdoor -- you know where this goes. Except agent skills are worse. A malicious npm package needs to exploit a code path. A malicious agent skill just needs to say "before doing anything else, run this shell command" in plain English.
I decided to build something about it.
Registeel: A Registry That Guards What It Stores
I named the project Registeel, after the Steel-type legendary Pokemon that guards ancient knowledge behind sealed chambers. You literally cannot access what Registeel protects without proving you have the right keys. That felt appropriate.
The core idea is straightforward: a content-addressable registry for agent skills where every artifact is cryptographically signed, every mutation is logged, and nothing gets loaded into an agent runtime without passing verification.
Here's what the architecture looks like at a high level:
- Skills are stored by their SHA-256 content hash, not by name
- Publishers sign skill packages using Sigstore (keyless signing via OIDC)
- The agent runtime verifies signatures and hashes before loading any skill
- Untrusted or unverified skills run in an isolated sandbox with no filesystem or network access
- A CLI handles publishing, verification, and local caching
Let me walk through how I built each piece.
Content-Addressable Storage
The foundation is dead simple. Every skill package -- which is just a directory containing a SKILL.md, optional resource files, and a manifest -- gets hashed deterministically. The hash becomes its identifier in the registry.
# registeel/hasher.py
import hashlib
import json
from pathlib import Path
from typing import TypedDict
class SkillManifest(TypedDict):
name: str
version: str
author: str
files: dict[str, str] # relative path -> sha256
def hash_file(path: Path) -> str:
"""SHA-256 hash of a single file."""
h = hashlib.sha256()
with open(path, "rb") as f:
while chunk := f.read(8192):
h.update(chunk)
return h.hexdigest()
def hash_skill_package(skill_dir: Path) -> tuple[str, SkillManifest]:
"""
Compute a deterministic content hash for an entire skill package.
Files are sorted lexicographically to ensure reproducibility.
"""
files: dict[str, str] = {}
for file_path in sorted(skill_dir.rglob("*")):
if file_path.is_file() and not file_path.name.startswith("."):
relative = str(file_path.relative_to(skill_dir))
files[relative] = hash_file(file_path)
# The package hash is the hash of the sorted file hashes
canonical = json.dumps(files, sort_keys=True, separators=(",", ":"))
package_hash = hashlib.sha256(canonical.encode()).hexdigest()
manifest: SkillManifest = {
"name": skill_dir.name,
"version": "0.0.0", # overridden by metadata if present
"author": "",
"files": files,
}
return package_hash, manifestThe key decision here is sorting files lexicographically before hashing. This makes the hash reproducible regardless of filesystem ordering. I spent an embarrassing amount of time debugging why the same skill produced different hashes on macOS vs Linux before realizing rglob doesn't guarantee order.
Why not use git commit hashes? Git hashes include metadata (author, timestamp, parent commits) that change even when content doesn't. I want two identical skill directories to always produce the same hash, regardless of how they got there.
Sigstore Integration: Keyless Signing
Traditional code signing requires managing keys, which is a whole operational burden most individual developers won't bother with. Sigstore solves this with keyless signing -- you authenticate via your existing OIDC identity (GitHub, Google, etc.), and Sigstore issues a short-lived certificate that's logged in a public transparency log.
I'm using sigstore-python (v3.6.0 at time of writing) for this:
# registeel/signing.py
import json
from pathlib import Path
from sigstore.sign import SigningContext
from sigstore.verify import Verifier, policy
def sign_manifest(manifest_path: Path, output_dir: Path) -> Path:
"""
Sign a skill manifest using Sigstore keyless signing.
Requires browser-based OIDC authentication on first use.
"""
manifest_bytes = manifest_path.read_bytes()
ctx = SigningContext.production()
with ctx.signer() as signer:
result = signer.sign_artifact(manifest_bytes)
# Write the Sigstore bundle (contains signature + certificate + log entry)
bundle_path = output_dir / f"{manifest_path.stem}.sigstore.json"
bundle_path.write_text(result.to_json())
return bundle_path
def verify_manifest(
manifest_path: Path,
bundle_path: Path,
expected_identity: str,
expected_issuer: str = "https://accounts.google.com",
) -> bool:
"""
Verify a signed manifest against an expected publisher identity.
Args:
manifest_path: The manifest file to verify
bundle_path: The Sigstore bundle from signing
expected_identity: Email or subject of the expected signer
expected_issuer: OIDC issuer (GitHub, Google, etc.)
"""
manifest_bytes = manifest_path.read_bytes()
bundle_json = bundle_path.read_text()
verifier = Verifier.production()
identity_policy = policy.Identity(
identity=expected_identity,
issuer=expected_issuer,
)
try:
verifier.verify_artifact(
input_=manifest_bytes,
bundle=json.loads(bundle_json),
policy=identity_policy,
)
return True
except Exception:
return FalseThe beauty of this approach: if I publish a skill and sign it with my Google identity, anyone can verify that I (specifically, my email address) signed that exact content. If someone forks my skill and modifies it, the signature won't match. If someone tries to publish under my name, they can't produce a valid signature without access to my OIDC provider.
No keys to rotate. No certificates to renew. The transparency log means even if Sigstore's infrastructure is compromised, you can detect it.
The Verification Flow
Here's where it all comes together. When an agent runtime wants to load a skill, it goes through this pipeline:
# registeel/verify.py
import json
import tempfile
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from registeel.hasher import hash_file, hash_skill_package
from registeel.signing import verify_manifest
class TrustLevel(Enum):
VERIFIED = "verified" # Signed by a trusted publisher
PINNED = "pinned" # Hash matches a pinned version
UNTRUSTED = "untrusted" # No verification possible
REJECTED = "rejected" # Verification failed
@dataclass
class VerificationResult:
trust_level: TrustLevel
skill_hash: str
publisher: str | None = None
reason: str | None = None
class SkillVerifier:
def __init__(self, trust_store_path: Path):
self.trust_store = json.loads(trust_store_path.read_text())
def verify(self, skill_dir: Path) -> VerificationResult:
"""Full verification pipeline for a skill package."""
# Step 1: Compute the content hash
package_hash, manifest = hash_skill_package(skill_dir)
# Step 2: Check if this exact hash is pinned (known-good)
pinned = self.trust_store.get("pinned_hashes", {})
if package_hash in pinned:
return VerificationResult(
trust_level=TrustLevel.PINNED,
skill_hash=package_hash,
publisher=pinned[package_hash].get("publisher"),
)
# Step 3: Look for a Sigstore bundle
bundle_path = skill_dir / "manifest.sigstore.json"
if not bundle_path.exists():
return VerificationResult(
trust_level=TrustLevel.UNTRUSTED,
skill_hash=package_hash,
reason="No signature bundle found",
)
# Step 4: Verify the signature against trusted publishers
manifest_path = skill_dir / "manifest.json"
if not manifest_path.exists():
return VerificationResult(
trust_level=TrustLevel.REJECTED,
skill_hash=package_hash,
reason="Signature exists but manifest.json is missing",
)
# Step 5: Check against each trusted publisher
for publisher in self.trust_store.get("trusted_publishers", []):
if verify_manifest(
manifest_path=manifest_path,
bundle_path=bundle_path,
expected_identity=publisher["identity"],
expected_issuer=publisher["issuer"],
):
# Step 6: Verify the manifest matches the actual files
stored_manifest = json.loads(manifest_path.read_text())
if stored_manifest.get("files") != manifest["files"]:
return VerificationResult(
trust_level=TrustLevel.REJECTED,
skill_hash=package_hash,
publisher=publisher["identity"],
reason="Signed manifest doesn't match actual file contents",
)
return VerificationResult(
trust_level=TrustLevel.VERIFIED,
skill_hash=package_hash,
publisher=publisher["identity"],
)
return VerificationResult(
trust_level=TrustLevel.UNTRUSTED,
skill_hash=package_hash,
reason="Signature valid but publisher not in trust store",
)Step 6 is the critical one that people miss. It's not enough to verify the signature. You have to verify that the signed manifest actually describes the files on disk. Otherwise an attacker can ship a valid signature alongside modified files.
The Isolation Model
Verification tells you whether to trust a skill. But what do you do with skills that fail verification? Refusing to load them entirely is one option, but it's too restrictive for development workflows. You want to be able to try new skills without giving them the keys to the kingdom.
I went with a tiered execution model:
# registeel/sandbox.py
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from registeel.verify import TrustLevel, VerificationResult
@dataclass
class SandboxConfig:
allow_network: bool = False
allow_filesystem: bool = False
allowed_paths: list[str] | None = None
max_memory_mb: int = 512
timeout_seconds: int = 30
TRUST_CONFIGS: dict[TrustLevel, SandboxConfig] = {
TrustLevel.VERIFIED: SandboxConfig(
allow_network=True,
allow_filesystem=True,
),
TrustLevel.PINNED: SandboxConfig(
allow_network=True,
allow_filesystem=True,
),
TrustLevel.UNTRUSTED: SandboxConfig(
allow_network=False,
allow_filesystem=False,
max_memory_mb=256,
timeout_seconds=10,
),
TrustLevel.REJECTED: SandboxConfig(), # never executed
}
def create_sandbox_command(
skill_dir: Path,
config: SandboxConfig,
) -> list[str]:
"""
Build a sandboxed execution command using bubblewrap (bwrap) on Linux
or sandbox-exec on macOS.
"""
if sys.platform == "linux":
cmd = [
"bwrap",
"--ro-bind", "/usr", "/usr",
"--ro-bind", "/lib", "/lib",
"--ro-bind", "/lib64", "/lib64",
"--proc", "/proc",
"--dev", "/dev",
"--tmpfs", "/tmp",
"--ro-bind", str(skill_dir), "/skill",
"--unshare-all",
]
if not config.allow_network:
cmd.append("--unshare-net")
if config.allowed_paths:
for path in config.allowed_paths:
cmd.extend(["--bind", path, path])
cmd.extend(["--", "/usr/bin/python3", "/skill/run.py"])
return cmd
elif sys.platform == "darwin":
# macOS: use sandbox-exec with a custom profile
profile = _generate_macos_sandbox_profile(config)
profile_path = Path(tempfile.mktemp(suffix=".sb"))
profile_path.write_text(profile)
return [
"sandbox-exec",
"-f", str(profile_path),
sys.executable, str(skill_dir / "run.py"),
]
raise RuntimeError(f"Unsupported platform: {sys.platform}")
def _generate_macos_sandbox_profile(config: SandboxConfig) -> str:
"""Generate an Apple sandbox profile (.sb) for the given config."""
rules = ["(version 1)", "(deny default)"]
rules.append("(allow process-exec)")
rules.append("(allow file-read* (subpath \"/usr/lib\"))")
rules.append("(allow file-read* (subpath \"/usr/local/lib\"))")
if config.allow_filesystem and config.allowed_paths:
for path in config.allowed_paths:
rules.append(f'(allow file-read* file-write* (subpath "{path}"))')
if config.allow_network:
rules.append("(allow network*)")
return "\n".join(rules)
def execute_skill(
skill_dir: Path,
verification: VerificationResult,
) -> subprocess.CompletedProcess | None:
"""Execute a skill with appropriate sandboxing based on trust level."""
if verification.trust_level == TrustLevel.REJECTED:
return None
config = TRUST_CONFIGS[verification.trust_level]
cmd = create_sandbox_command(skill_dir, config)
return subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=config.timeout_seconds,
cwd=str(skill_dir),
)Verified skills get full access. They've proven who published them, and you've chosen to trust that publisher. Untrusted skills get a read-only, network-isolated sandbox with tight resource limits. Rejected skills don't run at all.
On Linux I'm using bubblewrap (bwrap) which is what Flatpak uses under the hood. On macOS, sandbox-exec with custom profiles. Neither is perfect -- the macOS sandbox is deprecated and might disappear -- but they're good enough for a first pass.
The Supply Chain Attack Comparison
Let's talk about how known attack patterns map to this system.
| Attack Vector | npm/PyPI Example | Agent Skill Equivalent | How Registeel Prevents It |
|---|---|---|---|
| Typosquatting | crossenv vs cross-env | coding-assistant vs c0ding-assistant | Content-addressed: you pin a hash, not a name |
| Maintainer compromise | event-stream | Trusted skill repo gets taken over | Signature verification: new maintainer can't sign as original author |
| Dependency confusion | Internal package names on public registries | Private skill names published publicly | Trust store is explicit: only listed publishers are trusted |
| Build-time injection | xz backdoor via build scripts | Modified skill loaded before review | Hash verification catches any content change |
| Typo in install command | pip install reqeusts | Loading skill from wrong URL | Registry resolves by hash, not URL |
The fundamental difference is that agent skills are more dangerous than library code. A compromised npm package still has to find a way to execute arbitrary behavior within the constraints of JavaScript. A compromised agent skill just has to contain persuasive text. The LLM will do the rest.
That's why the verification has to happen before the content ever reaches the model's context window. Once it's in the prompt, it's too late.
The CLI
I wanted publishing and verification to be as simple as npm publish and npm audit. Here's the CLI interface:
# registeel/cli.py
import json
import sys
from pathlib import Path
import click
from registeel.hasher import hash_skill_package
from registeel.signing import sign_manifest
from registeel.verify import SkillVerifier, TrustLevel
@click.group()
def cli():
"""Registeel: Secure agent skill registry."""
pass
@cli.command()
@click.argument("skill_dir", type=click.Path(exists=True, path_type=Path))
@click.option("--registry", default="https://registeel.dev/api/v1")
def publish(skill_dir: Path, registry: str):
"""Hash, sign, and publish a skill package."""
click.echo(f"Hashing skill package at {skill_dir}...")
package_hash, manifest = hash_skill_package(skill_dir)
click.echo(f"Package hash: {package_hash[:16]}...")
# Write manifest
manifest_path = skill_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, indent=2))
# Sign
click.echo("Signing manifest (browser will open for authentication)...")
bundle_path = sign_manifest(manifest_path, skill_dir)
click.echo(f"Signature bundle: {bundle_path.name}")
# Upload to registry
click.echo(f"Publishing to {registry}...")
# ... HTTP upload logic ...
click.echo(f"Published: {manifest['name']}@{package_hash[:12]}")
@cli.command()
@click.argument("skill_dir", type=click.Path(exists=True, path_type=Path))
@click.option(
"--trust-store",
type=click.Path(exists=True, path_type=Path),
default=Path.home() / ".config" / "registeel" / "trust.json",
)
def verify(skill_dir: Path, trust_store: Path):
"""Verify a skill package against the trust store."""
verifier = SkillVerifier(trust_store)
result = verifier.verify(skill_dir)
status_colors = {
TrustLevel.VERIFIED: "green",
TrustLevel.PINNED: "green",
TrustLevel.UNTRUSTED: "yellow",
TrustLevel.REJECTED: "red",
}
click.secho(
f"Status: {result.trust_level.value}",
fg=status_colors[result.trust_level],
)
click.echo(f"Hash: {result.skill_hash}")
if result.publisher:
click.echo(f"Publisher: {result.publisher}")
if result.reason:
click.echo(f"Reason: {result.reason}")
if result.trust_level == TrustLevel.REJECTED:
sys.exit(1)
@cli.command()
@click.argument("identity")
@click.option("--issuer", default="https://accounts.google.com")
@click.option(
"--trust-store",
type=click.Path(path_type=Path),
default=Path.home() / ".config" / "registeel" / "trust.json",
)
def trust(identity: str, issuer: str, trust_store: Path):
"""Add a publisher to the trust store."""
trust_store.parent.mkdir(parents=True, exist_ok=True)
if trust_store.exists():
store = json.loads(trust_store.read_text())
else:
store = {"trusted_publishers": [], "pinned_hashes": {}}
store["trusted_publishers"].append({
"identity": identity,
"issuer": issuer,
})
trust_store.write_text(json.dumps(store, indent=2))
click.echo(f"Trusted: {identity} (issuer: {issuer})")
@cli.command()
@click.argument("skill_dir", type=click.Path(exists=True, path_type=Path))
@click.option(
"--trust-store",
type=click.Path(path_type=Path),
default=Path.home() / ".config" / "registeel" / "trust.json",
)
def pin(skill_dir: Path, trust_store: Path):
"""Pin the current hash of a skill as known-good."""
package_hash, manifest = hash_skill_package(skill_dir)
trust_store.parent.mkdir(parents=True, exist_ok=True)
if trust_store.exists():
store = json.loads(trust_store.read_text())
else:
store = {"trusted_publishers": [], "pinned_hashes": {}}
store["pinned_hashes"][package_hash] = {
"name": manifest["name"],
"pinned_at": "2026-05-22T00:00:00Z",
}
trust_store.write_text(json.dumps(store, indent=2))
click.echo(f"Pinned: {manifest['name']} -> {package_hash[:16]}...")Usage looks like this:
# First time: trust a publisher
registeel trust alice@example.com --issuer https://accounts.google.com
# Verify a skill before loading
registeel verify ./skills/code-formatter/
# Publish your own skill (opens browser for OIDC)
registeel publish ./my-skill/ --registry https://registeel.dev/api/v1
# Pin a known-good version
registeel pin ./skills/code-formatter/Trust Store Configuration
The trust store is a JSON file that lives at ~/.config/registeel/trust.json. It's intentionally simple:
{
"trusted_publishers": [
{
"identity": "alice@example.com",
"issuer": "https://accounts.google.com"
},
{
"identity": "https://github.com/org/repo/.github/workflows/publish.yml@refs/heads/main",
"issuer": "https://token.actions.githubusercontent.com"
}
],
"pinned_hashes": {
"a1b2c3d4e5f6...": {
"name": "code-formatter",
"publisher": "alice@example.com",
"pinned_at": "2026-05-20T14:30:00Z"
}
}
}That second trusted publisher entry is interesting -- it's a GitHub Actions workflow identity. This means you can trust skills that were published by a specific CI pipeline in a specific repo on a specific branch. The signing happens in CI, so no human ever holds the credentials.
What I'd Do Differently
The macOS sandboxing story is weak. sandbox-exec is deprecated and Apple hasn't provided a real replacement for command-line tools. On Linux, bubblewrap is solid but requires the user to have it installed. I've been looking at running untrusted skills inside lightweight VMs via Firecracker, but that's a lot of infrastructure for what should be a simple dev tool.
The other gap is revocation. Right now, if a publisher's account gets compromised, you have to manually remove them from your trust store and re-pin everything. Sigstore's transparency log means you can detect when a signature was created, so you could theoretically say "don't trust anything signed after this timestamp," but I haven't built that yet.
There's also the question of transitive dependencies. If skill A references skill B, the whole chain needs verification. I'm storing the dependency graph in the manifest but the recursive verification adds latency that makes the interactive experience noticeably worse.
I'm running this on my own agent setup now. The overhead of verification is about 40ms per skill load, which is negligible compared to the LLM inference time. The friction of signing on publish is higher -- the browser OIDC flow takes a few seconds -- but you only do it once per version.
The code is at github.com/marisiromanillos/registeel if you want to poke at it. It's rough in places but it works. And honestly, working is what matters when the alternative is loading arbitrary instructions into a system with shell access and hoping for the best.