Skip to the content.

Agent Skill security

A skill is executable text. The agent runs the commands it names and follows the instructions it carries, which makes an installed skill a supply chain.

python scripts/sqs.py security ./downloaded-skill    # BEFORE the agent reads it

Run it before check, and before the skill is anywhere your agent will look. The scanner reads text; it never executes anything.

What it looks for

Secrets (SE001)

Credentials committed by accident: AWS access keys, GitHub tokens, Anthropic and OpenAI keys, Slack tokens, Google API keys, private key blocks, and generic api_key = "..." assignments with a placeholder filter so your-key-here does not become a finding.

Destructive commands (SE002)

Commands nobody meant to hand an agent: a recursive delete of a root-level path, a download piped straight into a shell, chmod to world-writable, a force push, a history rewrite, a dropped database, a write to a raw device, a fork bomb, disabled certificate checks.

Prompt injection (SE003)

Text addressed at the agent rather than at the task. In a skill you wrote this is a mistake; in a skill you installed it is the payload:

A quotation is exempt. A skill that teaches an agent to refuse an injection has to be able to write the phrase down, and a scanner that cannot tell the two apart is a scanner people switch off.

Exfiltration (SE005)

A local file leaving the machine: a request that posts a credentials file as its body, an upload of a path the skill just read, a PowerShell upload with an input file.

Hidden and bidirectional Unicode (SE004)

Zero-width spaces, bidirectional overrides, soft hyphens, word joiners. The rendered text differs from the text the model reads - the Trojan Source trick, and the one finding on this page that no amount of careful reading would have caught. A zero-width space in front of a fence marker is recognised as fence escaping and left alone, both by the finding and by the fixer.

Personal paths (SE006)

An absolute path naming whoever wrote the skill. Harmless at home, and information disclosure once published.

A skill vouching for itself (SE007)

A safety guarantee (“100% safe”), an endorsement by a named vendor (“verified by …”), a count of users who trust it, an invitation to skip review. Nothing in a skill can certify the skill: whoever wrote the files wrote the badge. The claim works on the router choosing between skills and on the person deciding whether to install, and it asks both to accept what they should check.

The words themselves are everywhere in honest skills - across 57 real ones, verified, safe, official and their Russian kin occur 217 times, as what a skill does (“each verified by a panel of agents”) or where a neighbour came from. Only the self-certifying shapes count, and a quoted one is exempt, the same way SE003 exempts a quotation: a skill that warns a reader about such badges has to be able to write one down. info, because the finding is a claim to verify, not a verdict.

Commands that run on load (CB004)

Claude Code runs !`command` in a skill’s body, and every line of a block opened with ` ```! , before the model is sent the skill: the output replaces the placeholder. It never prompts. A permission rule or the skill's own allowed-tools` lets the command through, or the invocation aborts - so a skill that pre-approves its own injected commands runs them silently on every load, before a word of it has been read. That is the count the finding leads with.

Reported from the capabilities module - sqs.py capabilities or any check, not sqs.py security - at info, one finding per skill: a skill that documents the syntax carries dozens of examples. A plain code block does not stop an injection. The documentation does not say so either way, so it was watched: a probe skill with !`echo RAN_FENCED` inside an ordinary code block came back to the model as RAN_FENCED. The finding still says how many sit in code blocks, because those read as examples to whoever wrote them - and a command without permission aborts the load outside auto mode, so a guide to the syntax can fail to load on its own examples; KEY=!cmd`` is not counted at all, because the documentation says the inline form counts only at a line start or after whitespace.

What a finding looks like

Every one of them names a file and a line, so every one is checkable:

$ sqs.py security ./helpful-helper
⛔ helpful-helper
     ⛔ SE001 GitHub token committed in the text (SKILL.md:18)
     ⛔ SE003 text addressed at the agent, overriding its instructions (SKILL.md:12)
     ⛔ SE004 hidden characters: ZWSP, RLO (SKILL.md:21)
     ⚠️  SE002 piping a download straight into a shell (SKILL.md:8)
     ⚠️  SE002 recursive delete of a root-level path (SKILL.md:9)
     ⚠️  SE005 a local file is sent to a network endpoint (SKILL.md:10)
     ·  SE006 absolute path naming the account `alexeyivanov` (SKILL.md:19)

That output is real, and it is regenerated on every commit from tests/fixtures/malicious/ - a fixture that reads as a helpful bootstrap skill and does all six things at once. The dangerous lines are assembled when the corpus runs rather than checked in: this repository is itself a skill, so anything in it ships into everyone’s skills directory, and a fixture that reads as an attack has no business sitting in a stranger’s tree. The examples page shows the whole run.

What it cannot tell you

A static scanner reads what a skill says. It does not run the skill, so it cannot see what a bundled script does at run time, what a URL serves when fetched, or what an instruction means in a context it has not seen.

Two things narrow that gap:

In CI

- uses: letsloose501/sqs-skills@v2
  with:
    command: security
    upload-sarif: "true"   # needs security-events: write

The SARIF goes to GitHub code scanning, so a new finding in a skill repository appears where the rest of your security findings already are. Every rule ships its confidence and false-positive risk as SARIF properties, so a dashboard can filter the heuristics out and keep the facts.

See also