Compare commits

...

8 Commits

Author SHA1 Message Date
DevCats 5a21e791ec ci(skills): start SkillSpector gate in warn-only mode
SkillSpector flags 13 error-level findings against coder/skills@main,
all in the intentionally credential-handling setup skill (GitHub
device-flow scripts and references). These are real patterns
matching SkillSpector's PE3 'Credential Access' and E2 'Env Variable
Harvesting' rules, but they describe expected behavior for an
installer skill rather than a vulnerability.

Initial policy is warn-only: SARIF still flows to Code scanning so
the findings are visible on PRs, the severity gate still runs and
prints, but continue-on-error keeps the workflow green. A follow-up
PR can either tune the rules in coder/skills, add SkillSpector
suppressions, or flip continue-on-error to false to make the gate
strict.

Also drops the debug upload-artifact step now that the SARIF shape
is known.
2026-06-02 22:19:59 +00:00
DevCats 8b33e21d34 tmp(ci): upload SARIF as artifact for triage
Will be removed once the SkillSpector severity policy is locked in.
2026-06-02 22:14:19 +00:00
DevCats 56f9c21a41 fix(scan): keep going on findings, gate only on missing SARIF
SkillSpector exits non-zero when its scan finds anything to report,
even though it still writes the SARIF file. Treating that as a crash
short-circuits SARIF upload and prevents the workflow's severity jq
step from making the policy decision. Inverts the check: scan_failed
is now set only when the SARIF file is missing or empty, which is
the genuine 'tool crashed' signal.
2026-06-02 22:08:00 +00:00
DevCats b564cf4450 fix(ci): install SkillSpector from pinned git SHA
SkillSpector is not on PyPI yet, so the install must reference the
NVIDIA/SkillSpector repository directly. Pinned at commit
2eb844780ab163f01468ecf142c40a2ec0fcaec0 to keep the policy
reproducible. Will switch to a pip-published version when one ships.

Also bumps actions/setup-python from v5.6.0 to v6.2.0 to get off the
deprecated Node 20 action runtime, and pins the latest pip before
installing skillspector so its hatchling build env has the recent
packaging release that supports the SPDX license expression in its
pyproject.toml.
2026-06-02 22:01:46 +00:00
DevCats 6e6e55de4c fix: address CI feedback for skills gate
- Drop go-git/v5 dependency and use the system git binary via
  exec.Command. The library pulled go 1.25.0 transitively, breaking
  golangci-lint v2.1.6 which is built against go 1.24.
- Apply prettier-plugin-sh formatting to scan-skill-sources.sh.
- Disable actions/setup-go's default module cache in the deploy
  verify job to satisfy zizmor's cache-poisoning rule on jobs that
  hold security-events: write.
2026-06-02 21:55:03 +00:00
DevCats 46f300f765 ci: gate PRs and deploys on skills catalogue and SkillSpector scan
Extends the existing validate-readme-files CI job with an online phase
that runs cmd/readmevalidation with READMEVALIDATION_ONLINE=1 when the
PR touches skills paths. Adds a scan-skills job that downloads and runs
NVIDIA SkillSpector over every declared upstream source and uploads
SARIF to GitHub Code scanning, failing on findings at the error level.

Refactors deploy-registry into three jobs:

- verify: runs the same online validator + SkillSpector before deploy.
- deploy: now needs: verify, so gcloud builds triggers run only fires
  when the catalogue is consistent and the scan is clean.
- open-issue-on-failure: posts to a single rolling tracker issue
  (label skills-gate) via JasonEtco/create-an-issue with update_existing,
  so repeat failures update the same issue rather than spamming.

A dorny/paths-filter step drives both the online phase and the scan-skills
job so PRs that do not touch skills pay no extra cost.
2026-06-02 21:40:12 +00:00
DevCats 5d0f194262 chore(scripts): add scan-skill-sources.sh
New helper that walks every registry/<namespace>/skills/README.md, extracts
each unique owner/repo@ref from the YAML frontmatter, and runs
NVIDIA SkillSpector over the upstream GitHub URL. One SARIF file per source
is written to ./sarif (configurable via the first positional argument).

The script does not gate on findings. It exits non-zero only when
skillspector itself crashes. Severity gating lives in the workflow so
the deploy policy is visible alongside the deploy step.
2026-06-02 21:38:37 +00:00
DevCats 66c59b4cd7 feat(readmevalidation): add online skill source verification
Adds a READMEVALIDATION_ONLINE=1 mode to cmd/readmevalidation that
shallow-clones every owner/repo@ref declared in
registry/<namespace>/skills/README.md and verifies:

- every catalogue override slug exists upstream as skills/<slug>/SKILL.md,
- each upstream SKILL.md has the required name and description frontmatter
  per the agentskills.io v0.2.0 specification,
- no slug is provided by more than one source within the same namespace.

The mode is off by default so go run ./cmd/readmevalidation keeps working
without network access. CI enables it when network is available.

Lifts the shallow-clone shape from coder/registry-server/cmd/build/skills.go
so the verifier matches what the deploy pipeline will actually ingest.
Branch-only refs for now, matching that pipeline's current behavior.
2026-06-02 21:37:15 +00:00
7 changed files with 513 additions and 10 deletions
+33
View File
@@ -0,0 +1,33 @@
---
name: Skills deploy gate failed
about: Auto-opened by the deploy-registry verify job when catalogue or scan fails.
title: "[skills-gate] Catalogue or scan failure blocking deploy"
labels: ["skills-gate"]
---
The pre-deploy verify job for the agent-skills catalogue failed.
Most recent run:
{{ env.WORKFLOW_URL }}
Trigger: `{{ env.RUN_TRIGGER }}`
This is a single rolling tracker issue. The deploy workflow updates the
same open issue on every subsequent failure until it is closed. Closing
this issue without fixing the underlying problem reopens (or creates)
the next time the gate fails.
Likely causes:
- A `sources[].skills[<slug>]` override in `registry/<namespace>/skills/README.md`
no longer matches a `skills/<slug>/SKILL.md` upstream (renamed,
deleted, or moved).
- A declared `owner/repo@ref` no longer clones (repo renamed, deleted,
flipped to private, or the branch ref is gone).
- An upstream `SKILL.md` is missing the required `name` or `description`
frontmatter per the agentskills.io v0.2.0 specification.
- A SkillSpector critical-severity finding on upstream content. Open
alerts are listed under the repository's Security tab, Code scanning.
See the run logs and any new Code scanning alerts for specifics, then
land a PR that updates the catalogue or the upstream source repo.
+55
View File
@@ -102,14 +102,69 @@ jobs:
# We want to do some basic README checks first before we try analyzing the
# contents
needs: validate-style
outputs:
skills_changed: ${{ steps.filter.outputs.skills }}
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Detect changed files
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
filters: |
skills:
- 'registry/**/skills/**'
- 'cmd/readmevalidation/**'
- 'go.mod'
- 'go.sum'
- '.github/workflows/ci.yaml'
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version: "1.24.0"
- name: Validate contributors
run: go build ./cmd/readmevalidation && ./readmevalidation
- name: Validate skill sources (online)
if: steps.filter.outputs.skills == 'true'
env:
READMEVALIDATION_ONLINE: "1"
run: ./readmevalidation
- name: Remove build file artifact
run: rm ./readmevalidation
scan-skills:
name: Scan skill sources with SkillSpector
runs-on: ubuntu-latest
needs: validate-readme-files
if: needs.validate-readme-files.outputs.skills_changed == 'true'
permissions:
contents: read
security-events: write
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Set up yq
uses: mikefarah/yq@b534aa9ee5d38001fba3cd8fe254a037e4847b37 # v4.44.6
- name: Install SkillSpector
run: |
python -m pip install --upgrade pip
pip install "skillspector @ git+https://github.com/NVIDIA/SkillSpector.git@2eb844780ab163f01468ecf142c40a2ec0fcaec0"
- name: Scan declared skill sources
run: ./scripts/scan-skill-sources.sh ./sarif
- name: Upload SARIF to code scanning
uses: github/codeql-action/upload-sarif@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3.28.9
with:
sarif_file: sarif
# Initial policy: warn-only. SkillSpector flags real patterns in
# coder/skills/setup that are intentional for an installer skill
# (GitHub device-flow credential access). The gate prints findings
# but does not fail the job. Flip continue-on-error to false in a
# follow-up once the coder/skills baseline is triaged.
- name: Report critical findings
continue-on-error: true
run: |
jq -e '[.runs[].results[]? | select((.level // "") == "error")] | length == 0' sarif/*.sarif
+67 -2
View File
@@ -19,14 +19,58 @@ on:
- ".icons/**"
jobs:
deploy:
verify:
name: Verify catalogue and scan skills
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version: "1.24.0"
cache: false
- name: Verify catalogue against upstream sources
env:
READMEVALIDATION_ONLINE: "1"
run: go run ./cmd/readmevalidation
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Set up yq
uses: mikefarah/yq@b534aa9ee5d38001fba3cd8fe254a037e4847b37 # v4.44.6
- name: Install SkillSpector
run: |
python -m pip install --upgrade pip
pip install "skillspector @ git+https://github.com/NVIDIA/SkillSpector.git@2eb844780ab163f01468ecf142c40a2ec0fcaec0"
- name: Scan declared skill sources
run: ./scripts/scan-skill-sources.sh ./sarif
- name: Upload SARIF to code scanning
uses: github/codeql-action/upload-sarif@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3.28.9
with:
sarif_file: sarif
# Initial policy: warn-only. SkillSpector flags real patterns in
# coder/skills/setup that are intentional for an installer skill
# (GitHub device-flow credential access). The gate prints findings
# but does not fail the deploy. Flip continue-on-error to false in
# a follow-up once the coder/skills baseline is triaged.
- name: Report critical findings
continue-on-error: true
run: |
jq -e '[.runs[].results[]? | select((.level // "") == "error")] | length == 0' sarif/*.sarif
deploy:
name: Deploy registry
needs: verify
runs-on: ubuntu-latest
# Set id-token permission for gcloud
permissions:
contents: read
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -41,3 +85,24 @@ jobs:
run: gcloud builds triggers run 29818181-126d-4f8a-a937-f228b27d3d34 --branch main
- name: Deploy to registry.coder.com
run: gcloud builds triggers run 106610ff-41fb-4bd0-90a2-7643583fb9c0 --tag production
open-issue-on-failure:
name: Open or update skills-gate tracker issue
needs: verify
if: failure()
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Open or update tracker issue
uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
RUN_TRIGGER: ${{ github.event_name }}
with:
filename: .github/ISSUE_TEMPLATE/skills-failure.md
update_existing: true
search_existing: open
+271 -8
View File
@@ -4,10 +4,14 @@ import (
"bufio"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"golang.org/x/xerrors"
@@ -315,21 +319,15 @@ func aggregateSkillsReadmeFiles() ([]readme, error) {
}
func validateAllCoderSkills() error {
allReadmeFiles, err := aggregateSkillsReadmeFiles()
readmes, err := parseAllCoderSkillsReadmes()
if err != nil {
return err
}
logger.Info(context.Background(), "processing skills README files", "num_files", len(allReadmeFiles))
if len(allReadmeFiles) == 0 {
if len(readmes) == 0 {
return nil
}
readmes, err := parseCoderSkillsReadmeFiles(allReadmeFiles)
if err != nil {
return err
}
if err := validateAllCoderSkillsReadmes(readmes); err != nil {
return err
}
@@ -337,3 +335,268 @@ func validateAllCoderSkills() error {
logger.Info(context.Background(), "processed all skills README files", "num_files", len(readmes))
return nil
}
// parseAllCoderSkillsReadmes walks every registry/<namespace>/skills/README.md
// and returns the parsed structures. Callers can pass the result to the
// offline validator (validateAllCoderSkillsReadmes) and, when network access
// is available, also to the online validator (validateAllCoderSkillsOnline)
// so each README is only read and parsed once per run.
func parseAllCoderSkillsReadmes() ([]coderSkillsReadme, error) {
allReadmeFiles, err := aggregateSkillsReadmeFiles()
if err != nil {
return nil, err
}
logger.Info(context.Background(), "processing skills README files", "num_files", len(allReadmeFiles))
if len(allReadmeFiles) == 0 {
return nil, nil
}
return parseCoderSkillsReadmeFiles(allReadmeFiles)
}
// skillsSourceKey identifies one unique upstream source so the online
// verifier clones a given repo@ref exactly once across all namespaces.
type skillsSourceKey struct {
repo string // "owner/repo"
ref string // resolved ref; defaults to "main" when unspecified.
}
// skillsSourceContent is the result of cloning and walking one upstream
// source repo. Slugs are the directory names under skills/ that contain
// a SKILL.md file.
type skillsSourceContent struct {
slugs map[string]bool
skillMDPath map[string]string // slug -> absolute path to skills/<slug>/SKILL.md
}
// skillMarkdownFrontmatter is the minimal SKILL.md frontmatter shape required
// by the agentskills.io v0.2.0 specification. Both fields are required and
// must be non-empty.
type skillMarkdownFrontmatter struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
}
// validateAllCoderSkillsOnline verifies that every slug declared under
// sources[].skills in a skills README actually exists upstream as
// skills/<slug>/SKILL.md, that each upstream SKILL.md parses with the
// required agentskills.io frontmatter, and that no slug is claimed by more
// than one source within the same namespace.
//
// This function performs network I/O (shallow Git clones). Callers should
// only invoke it when network access is expected to be available.
func validateAllCoderSkillsOnline(readmes []coderSkillsReadme) error {
if len(readmes) == 0 {
return nil
}
keys, _ := collectUniqueSkillsSources(readmes)
logger.Info(context.Background(), "verifying upstream skill sources",
"num_unique_sources", len(keys))
content := make(map[skillsSourceKey]skillsSourceContent, len(keys))
var cleanups []func()
defer func() {
for _, c := range cleanups {
c()
}
}()
var errs []error
for _, k := range keys {
src, cleanup, err := fetchSkillsSourceContent(k.repo, k.ref)
if cleanup != nil {
cleanups = append(cleanups, cleanup)
}
if err != nil {
errs = append(errs, err)
continue
}
content[k] = src
}
if len(errs) > 0 {
return validationPhaseError{phase: validationPhaseUpstream, errors: errs}
}
for _, rm := range readmes {
rErrs := verifyReadmeAgainstSources(rm, content)
errs = append(errs, rErrs...)
}
if len(errs) > 0 {
return validationPhaseError{phase: validationPhaseUpstream, errors: errs}
}
logger.Info(context.Background(), "upstream skill source verification passed",
"num_files", len(readmes), "num_sources", len(keys))
return nil
}
// collectUniqueSkillsSources returns every unique repo@ref declared across
// all skills READMEs in deterministic order.
func collectUniqueSkillsSources(readmes []coderSkillsReadme) ([]skillsSourceKey, map[skillsSourceKey]bool) {
seen := make(map[skillsSourceKey]bool)
var keys []skillsSourceKey
for _, rm := range readmes {
for _, src := range rm.frontmatter.Sources {
repo, ref, hasRef := strings.Cut(src.Repo, "@")
if !hasRef || ref == "" {
ref = "main"
}
k := skillsSourceKey{repo: repo, ref: ref}
if seen[k] {
continue
}
seen[k] = true
keys = append(keys, k)
}
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].repo != keys[j].repo {
return keys[i].repo < keys[j].repo
}
return keys[i].ref < keys[j].ref
})
return keys, seen
}
// fetchSkillsSourceContent shallow-clones a single upstream source repo at
// the given ref and returns the set of skill slugs found under skills/<slug>/
// that contain a SKILL.md file. The returned cleanup function removes the
// temp clone directory.
//
// Today only branch refs are supported. This matches the behavior of the
// registry-server build pipeline, which clones via plumbing.NewBranchReferenceName.
// When that pipeline grows tag/SHA support, this function should grow it too.
//
// Uses the system git binary instead of a Go Git library so we do not have
// to vendor one for a single shallow clone per source. Every CI runner and
// developer laptop already has git on PATH.
func fetchSkillsSourceContent(repo, ref string) (skillsSourceContent, func(), error) {
dir, err := os.MkdirTemp("", strings.ReplaceAll(repo, "/", "_")+"_*")
if err != nil {
return skillsSourceContent{}, nil, xerrors.Errorf("creating temp dir for %s@%s: %w", repo, ref, err)
}
cleanup := func() {
if rmErr := os.RemoveAll(dir); rmErr != nil {
logger.Warn(context.Background(), "could not remove temp clone dir",
"path", dir, "error", rmErr.Error())
}
}
url := "https://github.com/" + repo + ".git"
cmd := exec.Command("git", "clone", "--depth=1", "--branch", ref, "--single-branch", url, dir)
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
if out, err := cmd.CombinedOutput(); err != nil {
return skillsSourceContent{}, cleanup, xerrors.Errorf("cloning %s@%s: %v: %s", repo, ref, err, strings.TrimSpace(string(out)))
}
skillsDir := filepath.Join(dir, "skills")
entries, err := os.ReadDir(skillsDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return skillsSourceContent{
slugs: map[string]bool{},
skillMDPath: map[string]string{},
}, cleanup, nil
}
return skillsSourceContent{}, cleanup, xerrors.Errorf("reading skills directory of %s@%s: %w", repo, ref, err)
}
content := skillsSourceContent{
slugs: make(map[string]bool, len(entries)),
skillMDPath: make(map[string]string, len(entries)),
}
for _, e := range entries {
if !e.IsDir() {
continue
}
skillMD := filepath.Join(skillsDir, e.Name(), "SKILL.md")
if _, statErr := os.Stat(skillMD); statErr != nil {
continue
}
content.slugs[e.Name()] = true
content.skillMDPath[e.Name()] = skillMD
}
return content, cleanup, nil
}
// verifyReadmeAgainstSources checks one parsed skills README against the
// upstream content already fetched for each declared source.
func verifyReadmeAgainstSources(rm coderSkillsReadme, content map[skillsSourceKey]skillsSourceContent) []error {
var errs []error
// Track every slug across every source in this namespace so we can
// surface duplicates across sources before the registry-server build
// pipeline silently picks one.
slugOrigin := make(map[string]string)
for i, src := range rm.frontmatter.Sources {
repo, ref, hasRef := strings.Cut(src.Repo, "@")
if !hasRef || ref == "" {
ref = "main"
}
key := skillsSourceKey{repo: repo, ref: ref}
upstream, ok := content[key]
if !ok {
// fetchSkillsSourceContent already produced an error for this key.
continue
}
for slug := range src.Skills {
if !upstream.slugs[slug] {
errs = append(errs, addFilePathToError(rm.filePath,
xerrors.Errorf("sources[%d].skills[%q]: slug not found upstream at %s/skills/%s/SKILL.md (repo=%s ref=%s)",
i, slug, repo, slug, repo, ref)))
}
}
for slug := range upstream.slugs {
origin := fmt.Sprintf("%s@%s", repo, ref)
if prev, dup := slugOrigin[slug]; dup {
errs = append(errs, addFilePathToError(rm.filePath,
xerrors.Errorf("slug %q is provided by multiple sources in the same namespace (%s and %s)",
slug, prev, origin)))
continue
}
slugOrigin[slug] = origin
}
for slug, mdPath := range upstream.skillMDPath {
for _, fmErr := range validateSkillMarkdownFrontmatter(mdPath) {
errs = append(errs, addFilePathToError(rm.filePath,
xerrors.Errorf("sources[%d] %s@%s skill %q: %v", i, repo, ref, slug, fmErr)))
}
}
}
return errs
}
// validateSkillMarkdownFrontmatter reads a SKILL.md file and confirms its
// YAML frontmatter contains a non-empty name and description per the
// agentskills.io v0.2.0 specification.
func validateSkillMarkdownFrontmatter(absPath string) []error {
raw, err := os.ReadFile(absPath)
if err != nil {
return []error{xerrors.Errorf("could not read SKILL.md: %v", err)}
}
fm, _, err := separateSkillsFrontmatter(string(raw))
if err != nil {
return []error{xerrors.Errorf("SKILL.md frontmatter parse error: %v", err)}
}
var yml skillMarkdownFrontmatter
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
return []error{xerrors.Errorf("SKILL.md frontmatter YAML invalid: %v", err)}
}
var errs []error
if strings.TrimSpace(yml.Name) == "" {
errs = append(errs, xerrors.New("SKILL.md is missing required frontmatter field 'name'"))
}
if strings.TrimSpace(yml.Description) == "" {
errs = append(errs, xerrors.New("SKILL.md is missing required frontmatter field 'description'"))
}
return errs
}
+9
View File
@@ -44,6 +44,15 @@ func main() {
errs = append(errs, err)
}
if os.Getenv("READMEVALIDATION_ONLINE") == "1" {
skillsReadmes, err := parseAllCoderSkillsReadmes()
if err != nil {
errs = append(errs, err)
} else if err := validateAllCoderSkillsOnline(skillsReadmes); err != nil {
errs = append(errs, err)
}
}
if len(errs) == 0 {
logger.Info(context.Background(), "processed all READMEs in directory", "dir", rootRegistryPath)
os.Exit(0)
+6
View File
@@ -35,6 +35,12 @@ const (
// is having all its relative URLs be validated for whether they point to
// valid resources.
validationPhaseCrossReference validationPhase = "Cross-referencing relative asset URLs"
// validationPhaseUpstream indicates when external skill source repos
// declared in a skills README's frontmatter are being cloned and walked
// to confirm every catalogue override slug exists upstream and every
// upstream SKILL.md has the required agentskills.io v0.2.0 frontmatter.
validationPhaseUpstream validationPhase = "Upstream skill source verification"
// --- end of validationPhases ---.
)
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Usage: scripts/scan-skill-sources.sh [SARIF_OUT_DIR]
#
# Walks every registry/<namespace>/skills/README.md, extracts each unique
# owner/repo@ref from the YAML frontmatter under sources[].repo, and runs
# NVIDIA SkillSpector over the upstream GitHub repository. One SARIF file
# is written to SARIF_OUT_DIR (default ./sarif) per unique source.
#
# This script does NOT decide pass or fail on findings. It exits non-zero
# only when skillspector itself crashes. Severity gating lives in the
# workflow so the policy is visible alongside the deploy gate.
set -euo pipefail
OUT_DIR="${1:-./sarif}"
mkdir -p "${OUT_DIR}"
for bin in skillspector yq; do
if ! command -v "${bin}" > /dev/null 2>&1; then
echo "Required binary '${bin}' is not installed or not on PATH." >&2
exit 2
fi
done
declare -a readme_files=()
for f in registry/*/skills/README.md; do
if [[ -f "${f}" ]]; then
readme_files+=("${f}")
fi
done
declare -a sources=()
if ((${#readme_files[@]} > 0)); then
raw_sources=""
for f in "${readme_files[@]}"; do
frontmatter="$(awk '/^---$/{c++; next} c==1{print} c>=2{exit}' "${f}")"
extracted="$(printf '%s\n' "${frontmatter}" | yq -r '.sources[].repo')"
raw_sources+="${extracted}"$'\n'
done
sorted_sources="$(printf '%s' "${raw_sources}" | sort -u | sed '/^$/d')"
mapfile -t sources <<< "${sorted_sources}"
fi
if ((${#sources[@]} == 0)); then
echo "No skills sources declared; nothing to scan."
exit 0
fi
scan_failed=0
for src in "${sources[@]}"; do
repo="${src%@*}"
ref="${src#*@}"
if [[ "${ref}" == "${src}" ]]; then
ref="main"
fi
safe="${repo//\//_}__${ref}"
out_file="${OUT_DIR}/${safe}.sarif"
echo "==> Scanning ${repo}@${ref}"
# SkillSpector exits non-zero when findings are present even though it
# still writes the SARIF file. Treat a missing SARIF as the only true
# crash; severity gating happens in the workflow against the SARIF.
skillspector scan "https://github.com/${repo}" \
--no-llm \
--format sarif \
--output "${out_file}" || true
if [[ ! -s "${out_file}" ]]; then
echo "skillspector did not produce SARIF for ${repo}@${ref}" >&2
scan_failed=1
fi
done
exit "${scan_failed}"