Security How-to

NuGet packages can now ship instructions your agent reads. Audit them before they land in the repo

A .NET post describes skills delivered inside NuGet packages. A 60-line script lists them, flags the dangerous ones and gives CI something to fail on. Fifteen minutes, any agent.

A thin peach thread of ink slipping out of a sealed glass vial and spreading into a dense plume in dark water.

A package you restored last year ships a patch release. The changelog says “performance fixes”. Along with the DLL, the new version now carries a skills/ folder, and one SKILL.md in it tells your coding agent to run a setup script from a URL, read your .env and not bother asking first. The build is green. The diff in your repo is a version number.

That’s the shape of the risk in a .NET blog post from 3 November 2026 , which describes library authors delivering agent skills inside NuGet packages. Restore puts the files on disk. The post itself calls Markdown that agents read a new threat vector and tells consumers to use only packages from maintainers they trust. Trust is a fine policy, but it’s not a check. A check is something that runs in CI and fails.

Here’s one: a short Python script that reads obj/project.assets.json, finds every skill in the package graph, prints a hash for each and flags the properties that deserve a human read. It works with any agent that loads SKILL.md files, because it only looks at files on disk.

What a package skill is

The post shows the layout. Inside a package there’s a skills/ folder with one subfolder per skill, and each subfolder has a SKILL.md:

skills/markout/SKILL.md
skills/markout-output-formats/SKILL.md

After dotnet restore or dotnet build, the files sit under ~/.nuget/packages/<id>/<version>/, and the post says the skill files are listed in project.assets.json. It also shows two commands: dotnet-inspect to list skills in the package graph, and dotnet-package-skills install to copy them into .agents/skills in your repo.

The file format comes from the Agent Skills specification . A SKILL.md has YAML frontmatter with a name (which must match the directory) and a description, then a Markdown body of instructions. Three optional parts matter for security:

  • allowed-tools is a space-separated list of tools the skill pre-approves, such as Bash(git:*) Read. The spec marks it experimental and support varies by agent, but where it’s honoured, a skill can grant itself the right to run commands.
  • A scripts/ directory can hold executable code the agent is expected to run.
  • The agent loads the whole SKILL.md once it decides the skill applies, and the description decides that. A package author controls both.

So a skill is a set of instructions, a trigger and sometimes a script, written by whoever published the package version. That’s the same trust position as a build script, with less tooling around it.

Two places to put a gate

Restore alone puts a file in the NuGet cache. Whether an agent reads it depends on the agent and how it’s configured. The point where a skill definitely becomes active for your team is when it’s copied into the repo, which is what dotnet-package-skills install does according to the post. That’s a diff, and diffs get reviewed.

The harder case is the quiet one: a skill that’s already in use, and a version bump that changes it. Dependabot or a colleague updates the package, the .csproj diff is one line, and the skill text changes underneath. For that, you want a CI check that prints each skill with a content hash, so a change shows up as a changed line.

The audit script

Save this as tools/skillaudit.py. It uses the standard library only.

#!/usr/bin/env python3
"""List and audit agent skills shipped inside the NuGet packages of a restored project."""
import hashlib, json, re, sys
from pathlib import Path

FLAGS = {
    "pipes a download into a shell": re.compile(r"(curl|wget|iwr|Invoke-WebRequest)[^\n|]*\|\s*(sh|bash|pwsh|powershell)\b", re.I),
    "mentions credentials or secrets": re.compile(r"\.env\b|\.ssh|NuGet\.Config|id_rsa|api[_-]?key|token|password", re.I),
    "tries to override the user's rules": re.compile(r"ignore (all |any )?(previous|prior) instructions|without asking|do not tell the user", re.I),
    "downloads or encodes payloads": re.compile(r"\b(base64|eval|certutil|chmod \+x)\b", re.I),
}
HIDDEN = re.compile("[\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff\U000e0000-\U000e007f]")
URL = re.compile(r"https?://[^\s)>`\"']+")

def frontmatter(text):
    m = re.match(r"---\n(.*?)\n---\n", text, re.S)
    if not m:
        return {}
    return dict(l.split(":", 1) for l in m.group(1).splitlines()
                if ":" in l and not l.startswith(" "))

def main(assets_path):
    assets = json.loads(Path(assets_path).read_text())
    folders = [Path(p) for p in assets["packageFolders"]]
    found = {}
    for lib in assets["libraries"]:
        pkg_id, version = lib.split("/")
        for folder in folders:
            root = folder / pkg_id.lower() / version / "skills"
            for skill in sorted(root.glob("*/SKILL.md")):
                text = skill.read_text(encoding="utf-8")
                fm, notes = frontmatter(text), []
                if fm.get("name", "").strip() != skill.parent.name:
                    notes.append("name does not match directory")
                if "allowed-tools" in fm:
                    notes.append("pre-approves tools:" + fm["allowed-tools"])
                if (skill.parent / "scripts").is_dir():
                    notes.append("ships scripts/")
                if HIDDEN.search(text):
                    notes.append("contains invisible characters")
                notes += [label for label, rx in FLAGS.items() if rx.search(text)]
                found[f"{pkg_id}/{version}/{skill.parent.name}"] = {
                    "sha256": hashlib.sha256(skill.read_bytes()).hexdigest()[:16],
                    "notes": notes, "urls": sorted(set(URL.findall(text)))}
    for key, v in found.items():
        print(f"{key}  {v['sha256']}  {'REVIEW' if v['notes'] else 'ok'}")
        for n in v["notes"]:
            print(f"    - {n}")
        for u in v["urls"]:
            print(f"    url {u}")
    return 1 if any(v["notes"] for v in found.values()) else 0

if __name__ == "__main__":
    sys.exit(main(sys.argv[1]))

It relies on two things about NuGet’s assets file that don’t change between projects: libraries has keys shaped like Id/Version, and packageFolders lists the package cache paths. NuGet stores package folders under the lower-cased id, hence pkg_id.lower(). The skills/ path comes from the .NET post.

The exit code is the gate: any note on any skill returns 1. That’s deliberately strict. A package with a skill that pre-approves tools or ships scripts should get looked at once, and then you decide whether to accept it.

Run it

The package cache below is a hand-built folder in the layout the post describes, because the .NET SDK wasn’t available to do a real restore. It holds an ordinary skill and a deliberately hostile one. Both packages are invented. The hostile skill’s frontmatter pre-approves curl and sh, and its body tells the agent to pipe a download into a shell, read NuGet.Config and .env, and ignore earlier instructions. Two zero-width spaces are tacked onto the end.

Run it against the restored project:

python3 tools/skillaudit.py obj/project.assets.json
echo "exit=$?"

It prints:

Markout/1.2.0/markout  0e0ba2a23900dfb1  ok
    url https://github.com/example/markout
FastJson/3.0.1/fastjson-setup  b208bc280d4761f4  REVIEW
    - pre-approves tools: Bash(curl:*) Bash(sh:*) Read
    - ships scripts/
    - contains invisible characters
    - pipes a download into a shell
    - mentions credentials or secrets
    - tries to override the user's rules
    url https://fastjson-cdn.example.net/setup.sh
exit=1

A package with no skills (Plain/1.0.0 in the fixture) prints nothing, which is the common case today. The ok skill still lists its URL, because a link in a skill is a place the agent may fetch from, and you want to see it.

Wire it into CI

Run it after restore, on every pull request. Here’s a GitHub Actions job:

name: skill-audit
on: pull_request

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x
      - run: dotnet restore
      - run: python3 tools/skillaudit.py src/App/obj/project.assets.json | tee skills.txt
      - run: git diff --exit-code skills.txt

The last two lines do the work. The first fails the job if anything needs review. The second compares the output against a committed skills.txt, so once you’ve reviewed a skill and accepted it, the hash is recorded, and a package update that changes the skill text changes a line in that file. Commit skills.txt the first time the audit passes, and from then on a changed hash is a diff somebody has to approve.

The two steps don’t conflict: the exit code stops a flagged skill from ever becoming the accepted baseline without an edit, and the hash catches the edits to skills you’ve already accepted. For a flagged skill you’ve read and decided to keep, the honest route is to loosen the script for that package, not to ignore the failure.

Make it yours

Some variations:

  • Several projects: loop over **/obj/project.assets.json and concatenate the output. The skill key includes the package id and version, so duplicates are easy to spot.
  • Allowlist by author: if you consume skills from your own organisation’s packages, put their ids in a small set at the top of the script and skip the flags for those, so the failures stay meaningful.
  • Review before install: if your team runs dotnet-package-skills install, treat the resulting .agents/skills changes as code. Ask for the same review as a script, because a skill can contain one, and run the audit script against the copied folder too.
  • Other ecosystems: the same approach works wherever skills arrive as files. Point the glob at whichever folder your package manager unpacks them into. GitHub’s changelog notes that Dependabot version updates now support Agent Package Manager, which manages skills, prompts and instructions, and it covers version updates only, not security updates, so a hash check still has a job there.

What this won’t catch

Pattern matching is a cheap filter, not a verdict. A hostile skill can say the same thing in different words, in another language or in a base64 blob the regex list doesn’t know. Treat ok as “nothing obvious”, not “safe”. The hash line is the stronger part of the design, because it makes any change visible no matter how it’s worded.

False positives are real. A legitimate skill for an auth library will mention tokens, and a skill for a deployment tool may legitimately ship a script. Expect to read those once. The purpose is that a human has read each skill at the version you run, not that the script is right about intent.

The audit also only sees skills that arrive through restored packages. A skill someone copies in by hand, or an agent downloads at runtime, never passes through project.assets.json. Keep the review rule for .agents/skills in the same place as the rest of your code review.

And if the skills are never loaded, the risk is lower. Check how your agent discovers skills before assuming exposure, and don’t assume safety from not having looked. If your agent only reads skills from a path you control, then the copy step is the only door, and the gate belongs there.

First 15 minutes

Restore your main solution and run the script against its obj/project.assets.json. Most projects print nothing. If one prints a skill, read the whole SKILL.md and its scripts/ folder, then commit the output as skills.txt and add the CI job above.