fix(skills): scan staged payload with SkillSpector (#264)

* fix(skills): scan staged payload with skillspector

* fix(skills): embed skillspector report in releases

* fix(skills): use body path for release notes
This commit is contained in:
davida-ps
2026-06-10 17:18:54 +03:00
committed by GitHub
parent 59d54ed778
commit 1b676fd42c
4 changed files with 132 additions and 30 deletions
+53 -28
View File
@@ -710,7 +710,7 @@ jobs:
--source-ref "${HEAD_SHA}"
# --- Generate SkillSpector report ---
if ! generate_skillspector_report "${skill_dir}" "${out_assets}/skillspector-report.md"; then
if ! generate_skillspector_report "${inner_dir}" "${out_assets}/skillspector-report.md"; then
failures=$((failures + 1))
rm -rf "${staging_dir}"
echo "::endgroup::"
@@ -1221,7 +1221,7 @@ jobs:
--source-ref "$TAG"
# --- Generate SkillSpector report ---
generate_skillspector_report "$SKILL_PATH" "release-assets/skillspector-report.md"
generate_skillspector_report "$INNER_DIR" "release-assets/skillspector-report.md"
test -s release-assets/skill-card.md
test -s release-assets/permissions.json
@@ -1403,38 +1403,63 @@ jobs:
echo "INSTALL_EOF"
} >> "$GITHUB_OUTPUT"
- name: Prepare GitHub release body
env:
SKILL_NAME: ${{ steps.parse.outputs.skill_name }}
VERSION: ${{ steps.parse.outputs.version }}
CHANGELOG: ${{ steps.changelog.outputs.changelog }}
QUICK_INSTALL: ${{ steps.install.outputs.quick_install }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
node -e '
const { readFileSync, writeFileSync } = require("node:fs");
const bodyPath = `${process.env.RUNNER_TEMP}/skill-release-body.md`;
const report = readFileSync("release-assets/skillspector-report.md", "utf8").trimEnd();
const body = [
`## ${process.env.SKILL_NAME} ${process.env.VERSION}`,
"",
process.env.CHANGELOG || "",
"",
process.env.QUICK_INSTALL || "",
"",
"### SkillSpector Security Report",
"",
report,
"",
`Download the generated release-payload scan: [skillspector-report.md](https://github.com/${process.env.REPO}/releases/download/${process.env.TAG}/skillspector-report.md)`,
"",
"### Verification",
"",
"`checksums.json` is cryptographically signed (`checksums.sig`) using the ClawSec CI signing key.",
"Verify the signature first, then trust hashes from `checksums.json`:",
"```bash",
`curl -sLO https://github.com/${process.env.REPO}/releases/download/${process.env.TAG}/checksums.json`,
`curl -sLO https://github.com/${process.env.REPO}/releases/download/${process.env.TAG}/checksums.sig`,
`curl -sLO https://github.com/${process.env.REPO}/releases/download/${process.env.TAG}/signing-public.pem`,
"openssl base64 -d -A -in checksums.sig -out checksums.sig.bin",
"openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json",
"```",
"",
"### Files",
"",
"See `checksums.json` for the complete file manifest with SHA256 hashes.",
"The zip archive preserves the full directory structure of the skill.",
"",
"---",
"*Released by ClawSec skill distribution pipeline*",
].join("\n");
writeFileSync(bodyPath, `${body}\n`);
'
- name: Create GitHub Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
name: "${{ steps.parse.outputs.skill_name }} ${{ steps.parse.outputs.version }}"
tag_name: ${{ github.ref_name }}
files: release-assets/*
body: |
## ${{ steps.parse.outputs.skill_name }} ${{ steps.parse.outputs.version }}
${{ steps.changelog.outputs.changelog }}
${{ steps.install.outputs.quick_install }}
### Verification
`checksums.json` is cryptographically signed (`checksums.sig`) using the ClawSec CI signing key.
Verify the signature first, then trust hashes from `checksums.json`:
```bash
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.sig
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/signing-public.pem
openssl base64 -d -A -in checksums.sig -out checksums.sig.bin
openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json
```
### Files
See `checksums.json` for the complete file manifest with SHA256 hashes.
The zip archive preserves the full directory structure of the skill.
---
*Released by ClawSec skill distribution pipeline*
body_path: ${{ runner.temp }}/skill-release-body.md
draft: false
prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }}
env:
+1 -1
View File
@@ -469,7 +469,7 @@ async function main() {
await runSkillSpector({
skillspectorBin: args.skillspectorBin,
skillDir: tempSkillDir,
skillDir: innerDir,
reportPath: path.join(releaseAssetsDir, "skillspector-report.md"),
});
+48
View File
@@ -88,6 +88,54 @@ assert.match(
'Skill release workflow must generate a SkillSpector report for each released skill',
);
assert.match(
workflow,
/### SkillSpector Security Report[\s\S]*\[skillspector-report\.md\]\(https:\/\/github\.com\/\$\{process\.env\.REPO\}\/releases\/download\/\$\{process\.env\.TAG\}\/skillspector-report\.md\)/,
'GitHub release notes must include a direct SkillSpector report link',
);
assert.match(
workflow,
/readFileSync\("release-assets\/skillspector-report\.md", "utf8"\)/,
'GitHub release notes must load the generated SkillSpector report content into the release body file',
);
assert.match(
workflow,
/body_path: \$\{\{ runner\.temp \}\}\/skill-release-body\.md/,
'GitHub release creation must use body_path for the generated release body file',
);
assert.doesNotMatch(
workflow,
/SKILLSPECTOR_REPORT_EOF|\$\{\{ steps\.skillspector_report\.outputs\.body \}\}|cat release-assets\/skillspector-report\.md[\s\S]*>> "\$GITHUB_OUTPUT"/,
'SkillSpector report content must not be sent through GitHub Actions step outputs',
);
assert.match(
workflow,
/generate_skillspector_report "\$\{inner_dir\}" "\$\{out_assets\}\/skillspector-report\.md"/,
'PR dry-run SkillSpector scan must target the staged release payload, not the source skill directory',
);
assert.doesNotMatch(
workflow,
/generate_skillspector_report "\$\{skill_dir\}" "\$\{out_assets\}\/skillspector-report\.md"/,
'PR dry-run SkillSpector scan must not include source-only test directories',
);
assert.match(
workflow,
/generate_skillspector_report "\$INNER_DIR" "release-assets\/skillspector-report\.md"/,
'Tag release SkillSpector scan must target the staged release payload, not the source skill directory',
);
assert.doesNotMatch(
workflow,
/generate_skillspector_report "\$SKILL_PATH" "release-assets\/skillspector-report\.md"/,
'Tag release SkillSpector scan must not include source-only test directories',
);
assert.match(
workflow,
/Generate release trust packet/,
+30 -1
View File
@@ -94,7 +94,36 @@ try {
await writeFile(
fakeSkillspector,
`#!/usr/bin/env node
import { writeFileSync } from "node:fs";
import { readdirSync, writeFileSync } from "node:fs";
import path from "node:path";
const scanIndex = process.argv.indexOf("scan");
if (scanIndex === -1 || !process.argv[scanIndex + 1]) {
console.error("missing scan target");
process.exit(2);
}
function containsTestDirectory(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const lowerName = entry.name.toLowerCase();
if (lowerName === "test" || lowerName === "tests") {
return true;
}
if (containsTestDirectory(path.join(dir, entry.name))) {
return true;
}
}
return false;
}
const scanTarget = process.argv[scanIndex + 1];
if (containsTestDirectory(scanTarget)) {
console.error("SkillSpector test fixture must scan the staged release payload, not source test directories.");
process.exit(42);
}
const outputIndex = process.argv.indexOf("--output");
if (outputIndex === -1 || !process.argv[outputIndex + 1]) {