---
name: ast01-malicious-skills
description: "Detect and triage OWASP AST01 Malicious Skills — hidden payloads (credential stealers, backdoors, social-engineering prose) shipped inside an otherwise-legitimate-looking SKILL.md or its dependency tree. Use when reviewing a skill before install, when a previously-clean skill starts behaving differently after repeated invocation (possible cognitive-degradation drift, not a single bad write), when classifying whether a finding is AST01 versus a AST02 supply-chain or AST04 metadata problem, or when deciding what a container sandbox does and does not stop."
---

# AST01 - Malicious Skills

> Unofficial community implementation of the OWASP® Agentic Skills Top 10 standard, v1.0.
> NOT an OWASP project; no OWASP endorsement. OWASP is a registered
> trademark of the OWASP Foundation, used here descriptively to identify the standard
> implemented. Source: https://owasp.org/www-project-agentic-skills-top-10/

Pattern: Knowledge. Read the orientation block, take a route, then descend only as far as
the route sends you. No mechanism is in this file: the static checks live in
`scripts/detector.py` and the frozen per-scenario tier list in `coverage-matrix.md`.

## Orientation — read this much, then decide whether to read the rest

- **Fires when** you hold a skill package and are asking whether *its own bytes* carry a
  payload: pre-install review, triage of something already found, or a category argument
  against AST02, AST04, AST05 or AST08.
- **Decides** one question, and only that one: does this package contradict its own
  declaration? Nothing here convicts a construct on its own.
- **Never decides** anything that exists only across invocations: drift, degradation,
  climbing retry counts. There the honest verdict is a domain gap, not `clean`.
- **Stop now** if the route below sends you elsewhere. The rest of this file will not
  answer that question and will cost you the context to ask it properly.

**Freedom, stated up front, because this category is two jobs and they do not take the
same hand:**

| Half | Freedom | Why |
| --- | --- | --- |
| **Is this a finding?** | Low — apply the predicate, do not extend it | The shape is fixed under *The predicate shape* below: a construct *plus* a contradiction of the package's own declaration. Convicting on the construct alone invents a check whose false-positive rate nobody measured; acquitting on the declaration alone reads a silence as a result. What tier a named scenario holds is settled above this file — `scenarios/registry.yaml` is rank 2 of `coverage-matrix.md`'s authority chain and `scripts/detector.py` is rank 5, subordinate to it. Where a row in *Route first* and a neighbor's own *Distinguishing* section disagree, the neighbor fixed that seam and this file is the bug. |
| **What to do about what no check can see** | High — the reviewer is the instrument | Rule 4's runtime accumulation has no predicate available to it, because the quantity does not exist inside one snapshot. Which of the two in-reach traces to pull, how far back to diff the identity artifacts, whether a climbing retry count is worth a session of host telemetry, and how loudly to state the domain gap alongside the static result are all yours to decide. Do not wait here for a rule; there is not going to be one, and the failure mode here is a reviewer who reports `clean` because nothing told them what else to do. |

### Route first

| If the finding is | Go |
| --- | --- |
| a compromised registry, a hijacked maintainer account, a poisoned nested dependency | **AST02** — stop reading here |
| code execution during *parsing* of the manifest (a legacy YAML loader) | **AST04** — stop reading here |
| a payload in a document the skill only *references*, absent from the shipped package | **AST05** — stop reading here |
| a detector that should have caught this and did not | **AST08** — stop reading here |
| a payload, an install instruction, an identity read or an egress path in this package's own files | this skill — continue below |
| unclear, or it reads like several of these at once | `node cli/bin/cli.js route "<finding text>"` names the primary category and lists contributing control failures separately |

### Then descend only this far

| Section | Read it when |
| --- | --- |
| The predicate shape | you are writing, extending or defending a check — it is why a bare construct match is not a finding |
| Decision rules | always: six rules, and the reason this file exists |
| Distinguishing AST01 from its neighbors | the route above was contested, or one incident spans two categories |
| Where the shipped checks go quiet | **MANDATORY before you report any negative result.** A pass from `scripts/detector.py` is not a clean verdict until you have read it |
| NEVER — the readings these ten checks invite | **always, and again before the verdict leaves your hands.** Ten checks under one `AST01-` prefix decide eight scenarios, only seven of them AST01's; each entry names the `CHECK_COVERAGE` entry, registry id or test in this repository that refutes the misreading |
| Scope and out-of-artifact boundary | the complaint is degradation-shaped — "it was fine, and now the agent behaves differently" |

**Do NOT load `coverage-matrix.md`** to decide whether something is a finding. It is the
tier contract and the F1 denominator; open it only to cite the authoritative tier of a
named scenario. **Do NOT load `scripts/detector.py`** to learn what the checks decide —
the quiet list below states each scenario check's scope and every silence in prose. Read the
source only for the three checks the quiet list does not cover, because they decide no AST01
scenario: `AST01-content-hash-missing` and `AST01-content-hash-mismatch` are declared
artifact-signal-only and category-precondition respectively, and `AST01-obfuscated-payload-exec`
decides AST08-S02, not an AST01 scenario. Otherwise read the
source only to change it.

## The predicate shape every check here uses, and why the obvious one is unusable

The intuitive AST01 rule is a construct search: does this package contain a
fetch-piped-into-a-shell install line, a WebSocket client, a write to `SOUL.md`, an
outbound `requests.post`. Each of those convicts a large population of legitimate
skills — installers, relay clients, memory-management tools, API integrations — so a
scanner built that way gets switched off inside a week and stops being a control.

Every check in `scripts/detector.py` is therefore a **two-part predicate: a construct,
plus a contradiction of the package's own declaration.** `AST01-S10` is not "a bundled
script calls out"; it is "a bundled script's hardcoded destination host is absent from
the manifest's own `network.allow`". `AST01-S08` is not "a script reads `SOUL.md`"; it
is "one script both reads an identity artifact *and* carries an outbound send" —
either half alone clears. The manifest is not evidence of good behaviour in this
design; it is the *other operand*, and the finding is the distance between two halves
the author shipped together in one package.

Two things follow that reviewers get wrong in opposite directions. A construct with a
matching declaration is **not** a finding here, however alarming it reads. And a
manifest that declares everything cannot be contradicted, so it silences the checks
that depend on it — see the quiet list below before reading any negative result as
"no payload".

Two more follow from the operands themselves, and both are things to write down rather
than to notice. **Whichever half is absent is the verdict** — the construct is here, the
declaration covers it, the check clears — stated in the package's own strings, the host
and the path as the author spelled them, because a negative written in this file's
vocabulary is checkable against this file and against nothing the reader is holding. And
**which operand is the wrong one is not a fact the artifact carries**: the finding is the
distance between two halves one author shipped together, so narrowing the grant and
widening the declaration close it identically from here. Which was intended is a question
for the author; answering it yourself ships a remediation the evidence does not support.

## Decision rules

Split by the freedom table above, so that the voice is not mistaken for uniform. Rules 1,
2, 3 and 5 *close* a question — each says what a given fact already is, and re-deriving one
at review time produces a second, quieter answer that will disagree with the next
reviewer's. Rules 4 and 6 *open* one: 4 hands a degradation complaint back with no
predicate to apply, and 6 names a boundary this file does not tell you how to certify
because the certification is the host's, not this package's.

1. **A verified signature answers "who published this," never "is this safe."**
   Ed25519 signing composes with behavioral scanning and reputation; it does not
   substitute for either. Treat a signed-but-unscanned skill as unscanned, not as
   trusted-by-transitivity.
2. **Container isolation constrains the launched script, not the agent.** A malicious
   SKILL.md can persuade the *host agent* to invoke tools outside the sandbox boundary
   entirely through natural-language instruction — no escape required. A finding that
   "the skill ran in a locked-down container" does not close an AST01 review; the
   induced-tool-call path is a separate, unclosed surface. Retain skill identity,
   version, and content hash on every induced host-side action so it can be attributed
   back to the instructing skill.
3. **Memory/identity-file writes are a separate control plane from install-time gates.**
   A signed, scanned, reputation-clean skill can still poison `MEMORY.md` or `SOUL.md`
   in a *later* session — that is a runtime, post-install attack that install-time
   signature/scan gates structurally cannot see. Any write from a skill to an identity
   artifact is elevated-risk by default, independent of that skill's install-time score.
4. **Runtime accumulation is a different measurement, not a weaker static finding — so
   a clean static pass is not evidence about it.** Every check here decides a
   contradiction wholly present in one snapshot. None can observe a quantity that only
   exists across invocations: retry counts, context growth, how often a skill re-reads
   its own memory file, whether output verbosity is climbing. For a degradation-shaped
   complaint ("this skill was fine, and now the agent behaves differently"), the honest
   output is not `clean` but *no in-artifact contradiction found; the claim is outside
   this instrument's domain*. Two places carry the only durable in-reach trace: a diff
   of the identity artifacts across sessions, and the host's own invocation telemetry.
   Report the static result and the domain gap together, or the pass will be read as an
   acquittal it never was.
5. **A skill's identity-artifact read is worse than its write.** Reading `SOUL.md` /
   `MEMORY.md` / persona/config files lets an attacker clone the agent's *behavioral*
   identity (not just its credentials) for replay or impersonation elsewhere. Because
   agentic identity is contextual, not just cryptographic, a read-only permission
   request against these files still warrants the same elevated review as a write
   request.
6. **Instruction-hierarchy enforcement has to survive skill-to-skill handoffs.** A
   downstream model node that treats a prior skill's *output* as instruction rather
   than as provenance-tagged untrusted data re-triggers this whole category one hop
   later, with the malicious artifact already past install review. Certify the boundary
   at every node that consumes another node's output, not only at the user-to-agent
   edge.

## Distinguishing AST01 from its neighbors (the seam, not the overlap)

- **vs AST02 (Supply Chain Compromise):** AST02 is the *delivery mechanism* — a
  compromised registry, a takeover'd maintainer account, a poisoned nested dependency.
  AST01 is the payload once delivered. A typosquatted package name (`gogle-workspace`)
  that turns out to carry no payload is AST02-only; the same package carrying a
  credential stealer is both. Do not double-tier a single finding into both matrices —
  classify by which control would have stopped it: registry/publisher controls → AST02;
  payload-content controls → AST01.
- **vs AST04 (Insecure Metadata):** AST04 covers *unsafe parsing* of the skill's own
  metadata files triggering code execution at load time (e.g. legacy YAML loaders).
  AST01's Instruction Override and hidden-payload scenarios require no parser bug at
  all — the prose itself is the payload, read and acted on exactly as designed.
- **vs AST05 (Untrusted External Instructions):** if the payload sits in the skill's
  own SKILL.md body, it's AST01. If a malicious author places the same payload in a
  document the skill merely *references* (a URL, a runbook), keeping the shipped
  SKILL.md clean, that's AST05 — the skill passes install-time review because there is
  nothing to review yet.
- **vs AST08 (Poor Scanning):** AST08 is about detector capability/coverage; AST01 is
  about what the malicious artifact does. A missed AST01 finding is an AST08 defect in
  the tool that should have caught it, not a second AST01 finding.

## Where the shipped checks go quiet, and what each silence owes a reviewer

**MANDATORY before you report a negative.** A negative result from this package means "no
in-package contradiction of this shape", never "clean". These are the specific silences,
and none of them is recoverable by re-running the scan. Rule 4's *report the static result
and the domain gap together* is not a rule about degradation; it is how every negative here
gets written. The verdict and the silence this particular package is sitting inside — named
from its own manifest and its own files, not recited from the list below — are one
deliverable, because a reader given the verdict alone reads its boundary as *everything*,
which is the only reading these results cannot support.

- **An unbounded egress declaration disarms the egress family in one move.** When the
  manifest declares `network: true`, `policy: allow-all`, or `allow: ["*"]`, there is
  nothing left to contradict, so `AST01-S02`, `AST01-S09` and `AST01-S10` all clear
  every host by construction. That breadth is a genuine finding — it is AST03's and
  AST06's — and reading AST01's silence there as "no exfiltration path" is the single
  most common misread of these results. Score the breadth first, then hand the egress
  destinations to manual review.
- **A package with no permissions block at all disarms the declaration half, and the
  three egress checks do not all answer it the same way.** The identity checks fall back
  to the bundled-script scan only. `AST01-S10` clears with `declares no egress policy at
  all` in its evidence — an absent `permissions.network` is no promise, so no script can
  contradict one, and the absence itself is AST06's and AST03's finding rather than this
  one's. `AST01-S02` and `AST01-S09` keep firing, because the registry defines them as an
  artifact the allowlist could have *exculpated* rather than as a departure from it, and
  with nothing declared there is simply nothing to exculpate. This shape is not an edge
  case: across 360 third-party skill packages audited for this repository, 360 shipped no
  manifest. Treat "no manifest" as an escalation trigger rather than as a missing input.
- **Loopback destinations are excluded before any allowlist is read.** A host in
  `127.0.0.0/8`, at `::1` or `0.0.0.0`, or under a `localhost` name never reaches a
  network, so `AST01-S02`, `AST01-S09` and `AST01-S10` all clear it whatever the manifest
  says — a declared allowlist that omits `localhost` does not make a call to a local model
  server exfiltration. A *dotless* name (`ollama`, a compose service) resolves through the
  runtime's search domain, which the package's bytes do not fix, and is reported
  `undecided` for the same reason a host-parameterised installer is. Both are stated in
  the evidence; a consumer reading only the boolean loses both. This is a claim about a
  *destination* and is not the claim AST06 rejects: AST06's own evals hold that a
  loopback-bound **listener** is not isolation without authentication (ClawJacked /
  CVE-2026-32025), and a skill that opens a local port is that shape, not this one. AST01
  clearing a call *to* `127.0.0.1` says nothing about a service listening *on* it.
- **`AST01-S02` distinguishes *undecided* from *clean*, and only the evidence string
  carries it.** A command with no literal destination host gives the allowlist nothing
  to evaluate, so the check reports negative with `undecided` in its evidence. Any
  pipeline that consumes only the boolean loses that distinction and will report a
  host-parameterised installer as passing.
- **The identity checks are asymmetric on purpose, and the asymmetry is a gap.** The
  write side is keyed to the exact, case-sensitive names `SOUL.md` and `MEMORY.md`; the
  read side (`AST01-S08`) matches a wider family — `SOUL`, `MEMORY`, `AGENTS`,
  `PERSONA`, `IDENTITY` across `.md`, `.json`, `.yaml`. A *write* to `PERSONA.json` or
  `AGENTS.md` is therefore caught by no write check here (the `AGENTS.md` grant is
  AST03's manifest check, a different instrument). Widen by hand whenever a host uses
  non-default identity filenames.
- **Prose and code are read by different checks with different file scopes.** The
  install-instruction check reads markdown only, because an instruction aimed at a
  human lives in prose; the egress, identity and WebSocket checks read script suffixes
  only. Anything that crosses the line — a destination host named only in a README
  while the script builds the URL at runtime, or prose instructing the agent to run a
  bundled helper — is decided by neither and is entirely the reviewer's.
- **The concealment check is scoped to what the skill *returns*, and decodes exactly
  one layer.** `AST01-S11` reads fenced output/template/response blocks, template-suffix
  files, and `templates/`|`output/` paths — concealed content elsewhere in the package
  is AST04/AST08's carrier scan, which decides no scenario. Its base64 arm accepts a
  blob only when one decode yields printable text; a blob that decodes to another
  encoded layer, to gzip, or to non-UTF-8 bytes returns nothing. AST10's check is the
  one that goes deeper — run it before concluding an opaque blob is inert.

## NEVER — the readings these ten checks invite and do not support

Two facts about this module generate every characteristic error below. Every check carries
an `AST01-` prefix and three of the ten decide no AST01 scenario. Every check is a two-part
predicate, and each half reads like a finding when it is alone. Each entry names the
`CHECK_COVERAGE` entry, registry id, or test in this directory that refutes it.

- **NEVER report `AST01-content-hash-missing` as an AST01 finding.** Its `CHECK_COVERAGE`
  entry is `registry_ids: ["AST05-S01", "AST07-S01"]`, `covers: artifact-signal-only`, and
  `scenarios/registry.yaml` names it back — it is the `artifact_signal_checks` entry on
  Author Rug-Pull and on Malicious Update, and coverage of neither, because a hash-pinned
  skill can still be maliciously updated the moment an operator accepts the new digest.
  `scenario_detectors` drops it from `SCENARIO_DETECTORS` and from the F1 denominator for
  that reason, and `test_every_check_separates_the_vulnerable_cases_from_the_clean_ones`
  names it as one of exactly two checks required to stay inert across all sixteen fixtures.
  The cost is paid
  twice: an unpinned but clean skill written up as a malicious one, and a real AST05/AST07
  pinning gap filed under a category whose owner has no remediation for it.
- **NEVER read `AST01-content-hash-mismatch` as an AST01 verdict, in either direction.**
  Its `registry_ids` is `[]` and its `covers` is `category-precondition`, derived — the
  entry says so — from AST02's mitigation that a signature cover a canonical digest of
  SKILL.md plus every declared resource file, not from any named scenario. A true positive
  is a genuine self-contained contradiction and still belongs in no AST01 column. The
  negative is the more expensive half: with nothing declared, the function returns
  `detected=False` carrying the evidence `no declared hash to compare`, which is the same
  boolean a package whose bytes verify returns. Any consumer reading the boolean records
  "integrity checked" over a comparison that never happened — and the check that would
  have said so is the one the previous entry forbids filing here.
- **NEVER count a firing `AST01-obfuscated-payload-exec` toward this category.** Its
  `registry_ids` is `["AST08-S02"]` Obfuscated Instruction, whose `category:` field in
  `scenarios/registry.yaml` is AST08 and stays AST08; this module decides it only because
  the artifact it is decided from is an AST01 package's own bundled script, and
  `coverage-matrix.md` records the link "rather than reassigned". The `AST01-` prefix is
  the module's check namespace, not a category assignment. The cost here is arithmetic and
  it is already visible in the published numbers: the F1 denominator is eight scenarios of
  which seven are AST01's, the corpus is sixteen cases where AST01's own scenarios entitle
  it to fourteen, and `F1_SCOPE` reads `mixed-proxy` rather than `scenario-level` for
  exactly this. Reporting eight AST01 detections overstates the category by one whole
  scenario and routes a scanner-capability defect away from the owner who can fix it.
- **NEVER convict on the construct half of a predicate, however alarming it reads.** That
  detector is built and scored in this repository: `tests/test_corpus_discriminates_mechanism.py`
  ablates every AST01 check down to its syntax half — no allowlist comparison, no
  `deny_write` evaluation, no execution sink, no output-template scoping — and measures
  **F1 0.552, tp 8, fp 13, fn 0** against the shipped checks' 1.000. The thirteen false
  positives are the clean halves of the eight fixture pairs, each authored as bait for
  this exact error: the same `curl … | bash` from a host the manifest declares, the same
  append against the skill's own notes file instead of `SOUL.md`, the same base64 blob
  decoded to a file instead of into `exec`. Extending a check by dropping its declaration
  half does not widen coverage, it reproduces those thirteen — and
  `test_a_syntax_only_ablation_does_not_pass_the_same_corpus` fails the build if a fixture
  edit ever lets the ablation close the gap.
- **NEVER acquit on the declaration half either.** `_egress_declared` returns true for
  every host the instant `network_unbounded` is satisfied — a bare `network: true`, a
  `policy: allow-all`, one glob anywhere in the allowlist — so `AST01-S02`, `AST01-S09` and
  `AST01-S10` clear every destination in the package by construction; and with no
  permissions block there is nothing for `write_allowed` to evaluate, so `AST01-S05` and
  `AST01-S06` fall back to the bundled-script scan alone. Those negatives are byte-identical
  to the negatives a genuinely tight manifest produces. The cost is a silence read as an
  acquittal on precisely the packages that promised the most, and the breadth that caused
  it belongs to AST03 and AST06 — so if the AST01 negative closes the review, nobody scores
  it at all.
- **NEVER let a full pass from `run_all` stand as evidence about runtime accumulation.**
  All ten checks take the same argument, one `{"manifest": …, "files": …}` snapshot, so no
  quantity that exists only across invocations is in scope for any of them: retry counts,
  context growth, how often a skill re-reads its own memory file. Decision rule 4 states
  this; the prohibition is what makes it survive contact with a write-up. The cost is that
  `clean` is an acquittal on a question the instrument never asked, and a degradation
  complaint closed that way stays closed — the two in-reach traces, a diff of the identity
  artifacts across sessions and the host's own invocation telemetry, get pulled only while
  the complaint is open. The static result and the domain gap go in the same sentence or
  neither of them is honest.
- **NEVER put anything in `AST01-S07`'s column.** Cognitive Degradation and Agent Drift is
  `tier: out-of-artifact` in the registry, absent from `STATIC_DETECTABLE` and therefore
  from the F1 denominator, and its `artifact_signal_checks` is the empty list deliberately:
  the signal the registry does record — unbounded retry or loop constructs, absent
  output-size budgets — is a shape benign skills exhibit too, and `coverage-matrix.md`
  writes that it "is **not** coverage of AST01-S07 and must never be reported as such." A
  package that will degrade a host and one that will not are byte-identical at install
  time. A retry-loop grep filed under S07 converts "no in-package signal exists" into
  "checked, clean" and retires the one scenario in this category whose honest state is a
  published gap; the empty cell is the measurement, and this is the never-pad rule it
  exists to enforce. The prohibition is on *scoring* the scenario, never on *naming* it:
  a complaint that corresponds to S07 is answered by naming it, giving its tier, and
  putting nothing in its column. Read as a ban on saying the id, this entry hands back a
  domain gap with no name on it — which nobody downstream can look up, route, or collect
  the out-of-artifact evidence for.
- **NEVER let the payload's legibility decide the category.** AST01 is the only one of
  these five that names what the attacker actually did, so a compromised registry, a
  load-time parser execution, a poisoned referenced runbook and the scanner that missed all
  three each read as "malicious skill" to a reviewer looking at the payload. The four *stop
  reading here* rows in *Route first* key on the control that would have stopped it, not on
  what the payload does, and each misroute costs something specific: a typosquatted name
  carrying no payload is AST02-only, so filing it here manufactures an AST01 finding out of
  a delivery problem; an AST05 payload sits in a document the package does not ship, so an
  AST01 read of the package's bytes clears it correctly while answering the wrong question;
  and a missed AST01 finding is one AST08 defect, never a second AST01 finding — double-tiering
  it into both matrices inflates two denominators over one incident. AST08-S02 above is the
  worked example in the other direction: the instrument that decides a scenario does not get
  to set its category.

## Scope and out-of-artifact boundary

The Cognitive Degradation chain (decision rule 4) and any finding that depends on
*repeated* runtime behavior over multiple sessions cannot be established from a static
read of one skill package — the artifact alone has no session history. Whether this
skill's declared-detectable tier includes any *static precursor signals* (e.g. unbounded
retry loops, verbose-output patterns) versus classifying the full degradation chain as
agent-judgable or out-of-artifact is fixed in `coverage-matrix.md`, not decided here;
do not infer a tier from this prose.

## References — what is loadable, and when

| Resource | Load it when |
| --- | --- |
| `coverage-matrix.md` | you must cite the authoritative tier of a named AST01 scenario, or defend the narrowed F1 denominator |
| `scripts/detector.py` | you are changing a check — never to find out what one decides |
| the whitepaper's own AST01 section | you need the full attack-scenario catalog, the real-world evidence (ClawHavoc campaign, USENIX Security 2026 measurement study, Snyk ToxicSkills), or the complete preventive-mitigation list |

Treat this file as the delta on top of that section, not a restatement of it. This package
ships no `references/` directory: the source it would excerpt is the whitepaper, which is
not bundled here, so the pointer is to the publication rather than to a local copy
of it.
