AI Engineering
tutorial
Featured

You Installed a Skill and Your Agent Will Run Whatever Is Inside

You pulled a skill off a registry, your agent read its instructions, and it is now ready to execute whatever code shipped in the folder. Nobody signed it, nobody diffed it, and the manifest that says "read-only" is just a text file the author wrote. Treat every third-party skill as untrusted code and put a gate in front of it: pin and hash it, check what it actually does against what it claims, and run it in a box that can only reach what it declared.

Viral Ruparel
10 min read
Share:

You run a command to add a skill to your agent, a folder lands on disk, and your agent reads the markdown inside and is now willing to call whatever functions it ships with. You did not read the code. Nobody signed it. The little manifest that says this skill only needs read access to the filesystem is a text file the author typed, with exactly as much authority as the sticker on a box that says "harmless." Your agent believes the sticker.

This is the part of the agent stack that grew faster than anyone secured it. Skill and tool registries went from a novelty to the default way teams extend agents, and the registries filled up with tens of thousands of entries that anyone with an account can publish. The reporting from early 2026 is not comforting: one analysis of a popular registry found that four out of five skills behaved differently from what their own manifest declared, and a wave of deliberately malicious skills made it into a marketplace before anyone noticed. The failure mode is not exotic. You install a skill that does a useful thing, and it also opens a socket to an address you have never heard of and ships your environment variables out the back door while the useful thing runs.

The fix is not to stop using third-party skills. It is to stop trusting them by default. Treat every skill you did not write as untrusted code, and put a gate in front of it that answers three questions before your agent is allowed to call it. Is this the exact skill I reviewed, or did the bytes change. Does it actually do only what it says it does. And if it goes rogue anyway, what is the blast radius. Those three questions map to three cheap, concrete mechanisms.

Gate one: pin the identity, not the label

The first mistake is trusting a version tag. A tag like auth-helper@1.4.2 is a label the registry controls, and a label can be moved. If the author, or someone who compromised the author's account, republishes that tag with a payload inside, your next install pulls the payload under a name you already approved. What you actually want to pin is the content, so that any change to any byte in the skill is a loud event that stops the pipeline until a human looks at the diff.

That means hashing the whole skill directory and refusing to load anything whose hash does not match a pin you recorded when you reviewed it.

# skill_registry.py
import hashlib
import tomllib
from pathlib import Path

def sha256_dir(root: Path) -> str:
    """Hash every file under a skill so any byte change flips the result."""
    h = hashlib.sha256()
    for path in sorted(root.rglob("*")):
        if path.is_file():
            # Include the relative path so moving or renaming a file also counts.
            h.update(path.relative_to(root).as_posix().encode())
            h.update(path.read_bytes())
    return h.hexdigest()

def load_pinned_skill(root: Path, expected_hash: str) -> dict:
    actual = sha256_dir(root)
    if actual != expected_hash:
        raise ValueError(
            f"skill '{root.name}' hash {actual[:12]} does not match "
            f"pinned {expected_hash[:12]}; review the diff before bumping the pin"
        )
    return tomllib.loads((root / "skill.toml").read_text())

The pin lives in your repo, next to your lockfile, under code review. Bumping it is a pull request a human approves after reading what changed, the same discipline you already apply to dependency updates. This one step converts a silent supply-chain swap into a diff someone has to sign off on. It does nothing about a skill that was malicious the day you reviewed it, which is what the next gate is for.

Gate two: check behavior against the manifest

The manifest declares capabilities the author claims the skill needs. The value is not in trusting that declaration. It is in checking whether the code actually stays inside it, and flagging the gap. A skill whose manifest says filesystem but whose code opens a network socket is either lying or compromised, and either way your agent should not run it without a human weighing in.

You get most of the way there with a static pass over the source that looks for the calls that touch the outside world and maps them to capabilities the manifest has to have declared.

# capability_scan.py
import ast
from pathlib import Path

# The calls that reach outside the process, mapped to a capability name.
RISKY = {
    ("subprocess", "run"): "exec",
    ("subprocess", "Popen"): "exec",
    ("os", "system"): "exec",
    ("socket", "socket"): "network",
    ("requests", "get"): "network",
    ("requests", "post"): "network",
    ("urllib", "urlopen"): "network",
    ("builtins", "open"): "filesystem",
}

def _call_name(node: ast.Call):
    f = node.func
    if isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name):
        return (f.value.id, f.attr)         # e.g. requests.get -> ("requests", "get")
    if isinstance(f, ast.Name):
        return ("builtins", f.id)           # e.g. open(...) -> ("builtins", "open")
    return None

def observed_capabilities(source: str) -> set[str]:
    caps: set[str] = set()
    for node in ast.walk(ast.parse(source)):
        if isinstance(node, ast.Call):
            hit = RISKY.get(_call_name(node))
            if hit:
                caps.add(hit)
    return caps

def undeclared_capabilities(root: Path, manifest: dict) -> set[str]:
    declared = set(manifest.get("capabilities", []))
    observed: set[str] = set()
    for path in root.rglob("*.py"):
        observed |= observed_capabilities(path.read_text())
    # Anything the code does that the manifest never declared is the finding.
    return observed - declared

Be honest with yourself about what this buys you. A determined author hides a network call behind getattr, a base64 blob, or an eval, and the AST walk sails right past it. This is a tripwire, not a proof of safety. What it reliably catches is the ordinary mismatch, the skill that opens a socket while claiming to be read-only, which is exactly the shape of most supply-chain payloads: a normal, useful skill with one extra thing bolted on that the manifest forgot to mention. The obfuscated case is what gate three exists to contain, because the sandbox does not care what the source looks like.

Gate three: contain what it can reach

Assume the scan missed something. The last gate is the one that holds when the code is actively hostile, and the principle is simple. A skill runs with access to nothing it did not declare, and above all it cannot open a network connection to an address that is not on its allowlist. If a skill's whole job is to reformat a file, it has no business talking to the network at all, and a rogue one should hit a wall the moment it tries.

The durable version of this lives at the operating-system and network layer: run the skill in a container or a microVM with a default-deny egress policy and an allowlist enforced by a proxy. That is the boundary you actually trust. As a defense-in-depth backstop inside the process, you can also refuse connections to anything the manifest did not list.

# egress_guard.py  --  imported first inside the skill's own process
import os
import socket

_ALLOW = set(filter(None, os.environ.get("EGRESS_ALLOW", "").split(",")))
_real_connect = socket.socket.connect

def _guarded_connect(self, address):
    host = address[0] if isinstance(address, tuple) else address
    if host not in _ALLOW:
        raise PermissionError(
            f"egress to {host} blocked; skill declared {_ALLOW or 'no'} hosts"
        )
    return _real_connect(self, address)

socket.socket.connect = _guarded_connect
# sandbox_run.py
import subprocess
import sys
from pathlib import Path

def run_skill(entry: Path, allow_hosts: list[str], timeout: int = 30):
    # Minimal env, a fresh cwd, and the guard forced to import before anything.
    env = {
        "PATH": "/usr/bin:/bin",
        "EGRESS_ALLOW": ",".join(allow_hosts),
        "PYTHONPATH": str(Path(__file__).parent),
    }
    bootstrap = f"import egress_guard; exec(open({str(entry)!r}).read())"
    return subprocess.run(
        [sys.executable, "-c", bootstrap],
        env=env, cwd=entry.parent,
        capture_output=True, text=True, timeout=timeout,
    )

The in-process guard has real gaps you should know about. It only covers this interpreter, so a skill that shells out to curl walks around it, which is why exec is a capability you scrutinize hard and why the OS-level boundary is the one that counts. It also matches on the connect address, and after DNS resolution that is an IP, so a hostname allowlist belongs at a proxy that can see the name. Treat this snippet as the last line, not the first. The egress control you bet the company on runs outside the process where the skill cannot patch it back.

Wiring the three gates into one admission step

None of this helps if it is optional. Put the three checks in the single path your agent uses to load and call a skill, so there is no way to reach the skill except through the gate.

# gate.py
from pathlib import Path
from skill_registry import load_pinned_skill
from capability_scan import undeclared_capabilities
from sandbox_run import run_skill

def admit(root: Path, pin: str) -> dict:
    manifest = load_pinned_skill(root, pin)              # 1. identity
    gaps = undeclared_capabilities(root, manifest)       # 2. honesty
    if gaps:
        raise PermissionError(
            f"'{root.name}' uses undeclared capabilities {sorted(gaps)}; "
            f"a human must review before this skill can run"
        )
    return manifest

def call_skill(root: Path, pin: str, entry: str = "main.py") -> str:
    manifest = admit(root, pin)
    result = run_skill(root / entry, allow_hosts=manifest.get("egress", []))  # 3. containment
    if result.returncode != 0:
        raise RuntimeError(result.stderr.strip())
    return result.stdout

A skill that fails any gate never runs, and the failure is specific enough that a human can act on it. This is the same posture I argued for with egress controls on agent output and with the least-privilege thinking behind stopping the confused deputy in tool calls. A skill is just another untrusted principal in your system, and it earns capabilities by declaring them and passing the check, not by existing.

The traps to watch for

  • A hash pin plus auto-update is theater. If your tooling silently bumps skills to the latest version, the pin never gets a chance to fire. Pin in a lockfile, and make the bump a reviewed change like any other dependency update.
  • The manifest gate covers code, not prose. A skill's markdown instructions can steer your agent even when it ships zero code. That is a prompt-injection problem, and you handle it with the defenses in prompt injection defense for tool-using agents, not with a capability scan. You need both.
  • Time-of-check to time-of-use. If you hash the skill and then let it run against the same mutable directory, a clever skill can rewrite a sibling file after the check. Load into a read-only copy and run from there.
  • Trusting the in-process guard alone. It is a backstop. Anything that spawns a subprocess or a native extension escapes it. The boundary you actually rely on is the container or microVM with default-deny egress.
  • Alert fatigue on the capability diff. If every skill trips a warning, people stop reading them. Tune the manifest vocabulary so that a normal skill passes clean and only a genuine mismatch raises a flag worth a human's attention.

The takeaway

The agent supply chain has the same problem the software supply chain had ten years ago, except the malicious payload does not sit inertly in a package waiting to be imported. Your agent reads it, reasons about it, and runs it, often within seconds of installing it. You do not fix that by auditing every skill by hand, because you will not, and you do not fix it by trusting manifests, because they are self-reported. You fix it with a gate that pins the exact bytes you reviewed, checks that the code stays inside what it declared, and contains what it can reach when it lies anyway. Three small mechanisms, wired into the one path your agent uses to call a skill, turn "we installed a thing off a registry and hoped" into "nothing runs unless it passed."

If your agents are pulling in third-party skills, tools, or MCP servers and you are not sure what any of them can actually reach, book a consultation call and we will put a real admission gate in front of them.

Viral Ruparel

Generative AI consultant helping teams ship reliable LLM and agent systems in production.

Contact Viral about your AI project →

Frequently Asked Questions

Isn't a static capability scan trivially defeated by obfuscation?+

Yes, and you should not rely on it as a proof of safety. A determined author can hide a network call behind getattr, base64, or an eval, and the AST walk will miss it. The scan is a tripwire, not a wall. Its job is to catch the honest mismatch, a skill that quietly opens a socket while its manifest says filesystem-only, which is the common case with a supply-chain compromise where someone slipped a payload into an otherwise normal skill. The real containment is the sandbox and the egress allowlist, which hold even when the code is obfuscated, because they constrain what the process can reach rather than what the source looks like.

Why hash the whole skill directory instead of just pinning a version?+

A version tag is a label the registry controls and can move. If you pin "auth-tool@1.4.2" and the author republishes that tag with a payload, your next install pulls the payload under the same name. A content hash pins the actual bytes, so any change to any file flips the hash and your gate refuses to load until a human reviews the diff and updates the pin. This is the same reason lockfiles record integrity hashes and not just versions. It turns a silent swap into a loud, reviewable event.

Does this replace prompt-injection defenses for skills?+

No, it sits next to them. A skill has two attack surfaces. One is the code it ships, which is what the capability scan and sandbox address. The other is the instructions in its markdown, which your agent reads and can be steered by, and that is a prompt-injection problem you handle separately with the defenses in the prompt-injection post. A skill that carries no code at all can still reprogram your agent through its text, so both gates have to be in place. Think of the sandbox as protecting your infrastructure and the injection defenses as protecting your agent's judgment.