> your AI agent picks dependencies from memory; give it dated facts — try starlog.dev ↗ vet your agent's deps ↗ vibe-coding is fine. vibe-importing isn’t. — try starlog.dev ↗ vibe-importing isn’t fine ↗ your agent has never seen your private packages — try starlog.dev ↗ facts for private packages ↗ a linter for the dependencies your AI agent picks — try starlog.dev ↗ a linter for agent deps ↗

Back to Articles

Building a Bill of Materials for AI Agent Skills: Inside skill-discovery's Cross-Platform Inventory Engine

[ View on GitHub ]

Building a Bill of Materials for AI Agent Skills: Inside skill-discovery's Cross-Platform Inventory Engine

Hook

Your AI coding agent can execute arbitrary Python, modify files, and make network requests through skills you installed from GitHub gists six months ago. Can you name them all right now?

Context

AI coding agents like Cursor, Continue, and Cline have evolved from autocomplete engines into extensible platforms. They execute 'skills'—packaged instructions and code that teach agents new capabilities, from AWS deployment workflows to database migration helpers. Unlike IDE extensions that require explicit installation dialogs, agent skills often come from copy-pasting SKILL.md files into ~/.cursor/skills/ or cloning GitHub repos into agent-specific directories. There's no central registry, no approval process, and critically, no built-in inventory mechanism.

This creates a supply chain visibility problem that security teams haven't seen since the early days of npm. Developers accumulate skills across multiple agents, forget what they've installed, and have no way to detect when a skill contains malicious patterns—environment variable exfiltration disguised as 'helpful debugging,' bytecode smuggling in Base64 blobs, or prompt injections that manipulate agent behavior. NVIDIA's SkillSpector research documented these attack vectors in early 2024, but detecting them requires scanning every skill file across every agent on every machine. skill-discovery exists to solve this inventory problem: it's a filesystem crawler that understands the skill storage conventions of a dozen different agents, fingerprints discovered content for deduplication, applies heuristic malware detection, and reports to governance servers without leaking sensitive data.

Technical Insight

The architecture hinges on a declarative agent registry that maps tool names to filesystem paths. In agents.py, each agent gets a configuration dictionary specifying where to find skills:

AGENTS = {
    "cursor": {
        "global_skill_roots": [
            "~/.cursor/skills",
            "~/.cursor/extensions/*/skills"
        ],
        "project_skill_roots": [
            ".cursor/skills"
        ],
        "instruction_files": [
            ".cursorrules",
            ".cursor/instructions.md"
        ],
        "docs": "https://cursor.sh/docs/skills"
    },
    "continue": {
        "global_skill_roots": ["~/.continue/skills"],
        "project_skill_roots": [".continue/skills"],
        "instruction_files": [".continuerc.json"],
        "docs": "https://continue.dev/docs/customization"
    }
    # ... 10 more agents
}

This registry pattern is the extensibility cornerstone. Adding support for a new agent requires zero code changes—just a declarative entry with vendor documentation links. The discovery engine iterates through this registry, expanding glob patterns and resolving tildes to user home directories cross-platform. It's elegant because the hard-coded knowledge lives in data, not control flow.

Fingerprinting uses order-independent SHA-256 hashing over normalized content. Before hashing, the scanner strips Windows CRLF to LF, normalizes path separators, and sorts multi-file skills alphabetically. This means a skill cloned on Windows with git's autocrlf enabled produces the same digest as the identical skill on Linux:

def fingerprint_skill(skill_path):
    content_parts = []
    for file in sorted(Path(skill_path).rglob("*")):
        if file.is_file():
            text = file.read_text(errors="ignore")
            normalized = text.replace("\r\n", "\n").replace("\r", "\n")
            content_parts.append(normalized)
    
    combined = "\n---\n".join(content_parts)
    return hashlib.sha256(combined.encode()).hexdigest()

The normalization ensures that fleet-wide deduplication actually works. When 50 developers clone the same "deploy-to-aws" skill repo, the governance server sees 50 identical fingerprints and knows it's one skill, not 50 unique installations. Without normalization, you'd get digest mismatches from line-ending differences and report skill sprawl that doesn't exist.

Malware detection runs in two phases. First, local heuristics scan for patterns documented in NVIDIA's SkillSpector research. The ruleset checks for environment variable access paired with network calls, hidden file payloads (ZIP/Office docs embedded in skills), and Base64-encoded bytecode that could execute arbitrary Python:

MALICIOUS_PATTERNS = [
    {
        "name": "env_exfiltration",
        "indicators": [
            re.compile(r"os\.environ\["),
            re.compile(r"requests\.(post|get)\(")
        ],
        "threshold": 2  # both must match
    },
    {
        "name": "bytecode_smuggling",
        "indicators": [
            re.compile(r"base64\.b64decode"),
            re.compile(r"exec\(|eval\(")
        ],
        "threshold": 2
    }
]

If SkillSpector is installed locally, the tool invokes it as a subprocess for deeper LLM-based analysis. This two-tier approach means you get fast heuristic scanning by default, with optional research-grade detection when you need high confidence.

Privacy enforcement happens through pre-upload sanitization. Before fingerprinting or transmitting skill metadata, the scanner runs regex patterns to detect and redact secrets:

SECRET_PATTERNS = [
    (r"(?i)(api[_-]?key|apikey)\s*[:=]\s*['\"]([^'\"]+)['\"]" , "<REDACTED_API_KEY>"),
    (r"sk-[a-zA-Z0-9]{48}", "<REDACTED_OPENAI_KEY>"),
    (r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "<REDACTED_EMAIL>")
]

def sanitize_content(text):
    for pattern, replacement in SECRET_PATTERNS:
        text = re.sub(pattern, replacement, text)
    return text

The governance server receives only sanitized fingerprints and metadata (skill name, agent, file paths) by default. Full skill content upload requires an explicit --include-content flag, giving security teams control over the privacy/visibility trade-off.

Continuous mode maintains state through digest comparison. The scanner hashes the complete inventory JSON, compares against the last submitted digest stored in ~/.skill-discovery/state.json, and only POSTs when changes occur. A configurable heartbeat interval (default 24 hours) forces periodic submissions even without changes, preventing fleet staleness:

while continuous_mode:
    current_inventory = scan_all_agents()
    current_digest = hashlib.sha256(
        json.dumps(current_inventory, sort_keys=True).encode()
    ).hexdigest()
    
    if current_digest != last_digest or time_since_heartbeat > HEARTBEAT_INTERVAL:
        submit_to_server(current_inventory)
        last_digest = current_digest
        last_heartbeat = time.time()
    
    time.sleep(POLL_INTERVAL)

This digest-based approach prevents the traffic explosion you'd get from blindly POSTing full inventory every 15 minutes across a 500-machine fleet. Most machines report once at deployment, then daily heartbeats—minimal overhead for maximum visibility.

Gotcha

Pattern-based sanitization is fundamentally limited. The regex list catches common secret formats, but a determined skill author can trivially bypass it—ROT13 encoding, splitting strings across variables, or context-dependent credentials that only look sensitive with domain knowledge. The README explicitly warns 'best effort, not a guarantee,' which is honest but means you cannot rely on sanitization for compliance purposes. If a skill leaks AWS credentials through an obfuscated exfiltration path, this tool won't catch it before upload.

The auto-discovery pruning heuristics assume conventional repository layouts. Skills hidden in vendored dependencies, Bazel build outputs, or non-standard paths (~/my-weird-agent-config/) get missed entirely. There's no filesystem watch integration, so continuous mode rescans the entire directory tree every poll interval—on a developer machine with 2,000 Git repos and a 15-minute poll, you're doing full traversals constantly instead of reacting to inotify events. This works because the wall-clock timeout (default 20 seconds) aborts scans that run too long, but it's wasteful. The malicious pattern detection is reactive, based on documented attack vectors from published research. Novel skill-based exploits won't trigger alerts until someone documents them and updates the heuristic rules, giving attackers a window where new techniques bypass detection completely.

Verdict

Use if: You're a security team deploying AI coding agents to more than 20 developers and need repeatable, cross-agent inventory without SaaS dependencies. Use if you've experienced the 'wait, who installed what?' panic during an incident and need a bill of materials for agent capabilities. Use if you're in a regulated industry where 'local and offline' is a hard requirement for tooling that touches source code. Skip if: You're a solo developer or small team with fewer than five skills total—you don't have an inventory problem yet, and manual find ~/.cursor -name SKILL.md suffices. Skip if you need real-time detection of malicious skills at installation time; the polling architecture means skills can execute for minutes to hours before discovery catches them. Skip if you require guaranteed secret redaction for compliance—the pattern-based sanitization will miss obfuscated credentials, and the tool's authors explicitly disclaim completeness.