AI Agent Supply-Chain Poisoning: Scan SKILL.md, .cursorrules and mcp.json for Hidden Unicode and Exfiltration Hooks
Your agent trusts text files that humans never read character by character. Bidi control characters and hidden shell hooks make that trust exploitable.
01. Observed Symptoms & Why Standard Package Audits Fail
Observed (scope): The assets in question are SKILL.md files, .cursorrules, and mcp.json. Related instruction files such as CLAUDE.md, AGENTS.md and Cursor .mdc rules follow the same trust model. An agent loads them as instructions and executes tool calls with whatever credentials are in its environment.
Symptoms: (1) An agent starts reading ~/.ssh or .env files for a task that has no reason to touch them. (2) A third-party skill or rules file diffs cleanly in review, yet the agent behaves differently from what the visible text says. (3) An MCP server in mcp.json is launched with npx and no pinned version, so what runs changes between sessions. (4) Nobody can list every skill and MCP server installed on a team member's machine.
Why package audits fail: npm audit and Dependabot track package manifests. A markdown skill is not a package. The malicious part may be a sentence the model follows, or a hook line in a script the skill tells the agent to run, so no CVE exists for it.
Observed (Unicode): Bidirectional control characters (U+202A to U+202E, U+2066 to U+2069, U+200E, U+200F, U+061C), zero-width characters (U+200B to U+200D, U+2060, U+FEFF) and tag characters (U+E0000 to U+E007F) render as nothing or reorder text on screen but are still present in the bytes a tokenizer receives. Inference: whether a given model acts on hidden tag characters or reordered text depends on its tokenizer and training, which is why tokenizer hygiene (HadayaLab Badge #1453, DeepMind tokenizer hygiene) is a review step and not an afterthought.
02. Root-Cause Forensic Analysis (Runtime & Protocol Level)
Root cause 1, confused presentation: human review sees rendered text, while the model sees code points. Any character that is invisible or reorders display creates a gap between what was approved and what is executed. A code review that does not show these characters cannot catch them.
Root cause 2, ambient authority: an agent process usually inherits your shell environment, home directory and tokens. A skill that instructs 'read the project config' can be steered to read .env, ~/.ssh/id_rsa or cloud credentials, then send them to an attacker-controlled endpoint via curl, a webhook, or an MCP tool call.
Root cause 3, floating dependencies: an MCP server launched with an unpinned npx or uvx command runs whatever the registry serves at that moment.
The scanner below is a deterministic first pass you can run on any machine. It flags invisible and Bidi code points in agent assets, and lines that mention common exfiltration hooks. It produces findings for human review. A hit is not proof of malice (legitimate skills do mention .env), and a clean result is not proof of safety. Windows Git Bash note: invoke Python as shown, since plain python resolves to the Microsoft Store stub.
import sys, pathlib
TARGETS = {'SKILL.md', '.cursorrules', 'mcp.json', 'CLAUDE.md', 'AGENTS.md'}
SKIP = {'.git', 'node_modules', '.venv'}
HOOKS = ('.env', '.ssh', 'id_rsa', 'authorized_keys', 'aws_secret', 'webhook.site',
'ngrok', 'pastebin', 'base64 -d', 'curl ', 'wget ')
def bad(ch, i):
o = ord(ch)
if o == 0xFEFF and i == 0:
return False # legitimate BOM
return (0x202A <= o <= 0x202E or 0x2066 <= o <= 0x2069 or 0xE0000 <= o <= 0xE007F
or o in (0x200B, 0x200C, 0x200D, 0x200E, 0x200F, 0x2060, 0x061C, 0xFEFF))
root = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else '.')
hits = 0
for p in root.rglob('*'):
if not p.is_file() or SKIP & set(p.parts):
continue
if p.name not in TARGETS and p.suffix != '.mdc':
continue
text = p.read_text(encoding='utf-8', errors='replace')
for i, ch in enumerate(text):
if bad(ch, i):
line = text.count(chr(10), 0, i) + 1
print(f'INVISIBLE/BIDI U+{ord(ch):04X} {p}:{line}')
hits += 1
for n, line in enumerate(text.splitlines(), 1):
low = line.lower()
for h in HOOKS:
if h in low:
print(f'HOOK[{h.strip()}] {p}:{n}: {line.strip()[:120]}')
hits += 1
sys.exit(1 if hits else 0)
03. Immediate Zero-Downtime Triage (Non-Breaking Mitigation)
Step 1: run the scanner above over every repository, every user-level agent directory and every MCP config on developer and CI machines. Review each hit by viewing the file as code points, not rendered text.
Step 2: run three independent scanners and compare results: NVIDIA/SkillSpector (https://github.com/NVIDIA/SkillSpector), snyk/agent-scan (https://github.com/snyk/agent-scan) and affaan-m/agentshield (https://github.com/affaan-m/agentshield). Inference: their detection rules differ, so one tool's PASS is not conclusive. Command-line flags change between releases, so follow each repository's README rather than copying flags from a blog post. HadayaLab's relevant credential references are Badge #1832 (Build Agent Skills with Google) for skill authoring practice.
Step 3: freeze what you have. Hash every agent asset into a lock file, sign it, and verify the hashes before agent sessions in CI. Any silent modification then fails loudly.
Step 4: pin MCP servers. Replace unpinned launch commands with exact versions, and keep secrets out of mcp.json by referencing environment variables injected at runtime.
git ls-files | grep -E '(^|/)(SKILL[.]md|[.]cursorrules|mcp[.]json|CLAUDE[.]md|AGENTS[.]md)$' | sort | xargs sha256sum > agent-assets.lock
cosign sign-blob --yes --bundle agent-assets.lock.bundle agent-assets.lock
# CI / pre-session gate
sha256sum -c agent-assets.lock || { echo 'agent asset changed since last signed review'; exit 1; }
04. Permanent Remediation & Cryptographic SBOM Attestation
Policy: (1) no third-party skill, rules file or MCP server enters a repo without a scanner pass and a human diff that shows invisible characters; (2) an allowlist of MCP servers, each pinned to an exact version or digest; (3) agents run in a sandbox or container with a minimal environment, no home-directory mount and no access to ~/.ssh or production .env; (4) egress allowlisting so a hook cannot reach arbitrary hosts.
OAuth2 guardrails: give agents their own client identity, request the narrowest scopes, use short-lived tokens, bind tokens to the target resource server's audience, and never forward a user's token to a downstream service the agent calls (token passthrough). Confirm the exact requirements in the current MCP authorization specification before you implement. Revoke by client, not by user, so one compromised agent does not force a rotation for everyone.
Attestation: treat the signed agent-assets.lock as the agent-side equivalent of an SBOM. Record the scanner versions and results beside it, and re-sign on every reviewed change. Where agents ship inside a container, attach the lock and the CycloneDX SBOM to the image digest (PAIN-2026-005).
Primary sources: https://github.com/NVIDIA/SkillSpector | https://github.com/snyk/agent-scan | https://github.com/affaan-m/agentshield
HadayaLab operates with 45x Google Cloud & DeepMind Skill Badges (https://www.skills.google/public_profiles/49cdbdb6-20d4-4661-934a-d882c7f5c6cf). We audit and harden custom Claude MCP servers, OpenClaw-style agent deployments and skill libraries, with scoped OAuth2 and signed asset locks.
Frequently Asked Questions
Can invisible Unicode in SKILL.md or .cursorrules really hijack an AI agent?
Invisible and Bidi code points do not render, but they are present in the bytes the model receives, so a reviewer and the model can see different text. Whether a specific model acts on them depends on its tokenizer and training. Treat them as findings to remove from instruction files regardless.
How do I audit an MCP server or agent skill before installing it?
Scan it with more than one tool (NVIDIA/SkillSpector, snyk/agent-scan, affaan-m/agentshield), run a code-point scan such as skill_scan.py, read the diff with invisible characters made visible, pin the exact version, and run it in a sandbox with no access to ~/.ssh or production .env files.
How do I stop an agent from exfiltrating .env files or SSH keys?
Remove the access, not just the instructions: run agents in a container or sandbox with a minimal environment, mount only the project directory, keep secrets out of the agent's environment, and allowlist outbound hosts. Scanning detects known patterns, while sandboxing limits the damage when a pattern slips through.
What OAuth2 guardrails should a custom Claude MCP server have?
Use a dedicated client per agent, narrow scopes, short-lived tokens bound to the resource server's audience, no token passthrough to downstream APIs, and per-client revocation. Verify the details against the current MCP authorization specification before you deploy.