cfgaudit

Rules / CFG074

CFG074 skills-lock.json pulls skill content from an unpinned source

Severity: warn OWASP: LLM04:2026 – Supply Chain OWASP Agentic: ASI04:2026 – Agentic Supply Chain Vulnerabilities: LLM04:2026 now covers the model and data supply chain and refers the agentic one, "including MCP servers and tool registries", to ASI04

What cfgaudit checks

The vercel-labs/skills CLI (skills.sh) installs agent skills — instruction text the agent loads into its trusted context — and records where each came from in a skills-lock.json lock file at the repo root. cfgaudit already scans installed skill content (.claude/skills/*/SKILL.md → CFG024/026/029–036, CFG051/056); CFG074 covers the lock file, where the trust decision is committed.

cfgaudit flags an entry whose source is remote but carries no integrity pin at all. The skills CLI normally records integrity when it installs a skill, in one of a few fields depending on its version — any of these pins the content (the upstream cannot silently change what was installed), so they are not flagged:

Only an entry with a remote source and none of the above (a bare branch/tag ref, or just source + sourceType) is reported — the fetched skill (instruction) text is unverified, so whoever controls the upstream repo can change what every contributor's agent reads on the next sync. This is the skills-CLI analogue of an unpinned MCP package (CFG017) or an auto-loaded plugin source.

Entry Result
{"source": "owner/repo", "sourceType": "github", "computedHash": "515ba7…"} no finding — content-hash pinned (the common case)
{"source": "owner/repo", "commit": "868e73…", "integrity": "sha256-…"} no finding — commit + integrity pinned
{"source": "owner/repo", "ref": "5f2e8c1a…3d5e7f"} (full SHA) no finding — pinned
{"source": "owner/repo", "sourceType": "github", "ref": "main"} warn — branch ref, no content hash
{"source": "owner/repo", "sourceType": "github"} warn — no integrity at all
{"source": "./skills/helper", "sourceType": "local"} no finding — on-disk, no remote trust edge

The parser keys off the skills map and tolerates unknown fields and schema versions, so a future schema bump does not break the scan. Findings name the local alias and the source slug.

Calibrated against a 358-file / 2,556-entry corpus of real skills-lock.json files: 355 use computedHash, so recognizing the content-hash fields (not just ref) is what keeps this rule's false-positive rate at zero while still catching genuinely unpinned entries.

Scope

Only the project-root skills-lock.json (committable) is scanned. The user-global ~/.agents/.skill-lock.json / $XDG_STATE_HOME/skills/.skill-lock.json is not scanned — it is not committable, so it is out of scope (consistent with cfgaudit's portable-committable scope rule).

Trigger example

{
  "version": 3,
  "skills": {
    "code-review": {
      "source": "vercel-labs/agent-skills",
      "sourceType": "github",
      "ref": "main"
    }
  }
}

→ warn (a bare main branch ref with no content hash — the source content can change under you).

Safe alternative

Install the skill from a released/pinned version so the CLI records a content hash, or pin the source to a full commit SHA — either makes the installed content reproducible and tamper-evident:

{ "skills": { "code-review": {
  "source": "vercel-labs/agent-skills",
  "sourceType": "github",
  "computedHash": "515ba75178bd44875812d9a560bdf14651f86709f89cf1d4f209638e879807f3"
} } }

References

Documentation for cfgaudit 1.18.1. Source of this page · Rule implementation