mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
ci(skills): publish release trust packets + expand skill installer awareness (vercel) (#262)
* ci(skills): publish release trust packets * ci(skills): simulate beta tag releases * ci(skills): match release version bump rules * chore(skills): group agent skills for installer * chore(skills): make clawtributor global * chore(skills): bump all skills for trust release * ci(skills): require npx install docs * fix(skills): simulate prerelease tag versions * fix(skills): aggregate trust artifact checksum failures * fix(frontend): advertise npx skills suite install * chore(frontend): drop ad hoc homepage copy test * fix(ci): run skill release tooling tests
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const PLATFORM_KEYS = ["openclaw", "nanoclaw", "hermes", "picoclaw"];
|
||||
const KNOWN_AGENT_TYPES = new Set(["codex", "hermes-agent", "openclaw", "universal"]);
|
||||
const PLATFORM_AGENT_ALIASES = new Map([["hermes", "hermes-agent"]]);
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"Usage: node scripts/ci/generate_skill_release_trust_packet.mjs <skill-dir> <output-dir> [options]",
|
||||
"",
|
||||
"Options:",
|
||||
" --repository <owner/repo> Source repository used in install instructions",
|
||||
" --tag <tag> Release tag for this skill",
|
||||
" --source-ref <ref> Source ref for npx skills examples",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const positional = [];
|
||||
const options = {
|
||||
repository: "prompt-security/clawsec",
|
||||
tag: "",
|
||||
sourceRef: "main",
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === "--repository") {
|
||||
options.repository = argv[++i];
|
||||
} else if (token === "--tag") {
|
||||
options.tag = argv[++i];
|
||||
} else if (token === "--source-ref") {
|
||||
options.sourceRef = argv[++i];
|
||||
} else if (token === "--help" || token === "-h") {
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
} else if (token.startsWith("--")) {
|
||||
throw new Error(`Unknown option: ${token}`);
|
||||
} else {
|
||||
positional.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
if (positional.length !== 2) {
|
||||
throw new Error(usage());
|
||||
}
|
||||
|
||||
return {
|
||||
skillDir: positional[0],
|
||||
outputDir: positional[1],
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
function parseFrontmatter(markdown) {
|
||||
if (!markdown.startsWith("---\n")) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const end = markdown.indexOf("\n---", 4);
|
||||
if (end === -1) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result = {};
|
||||
const frontmatter = markdown.slice(4, end).split("\n");
|
||||
for (const line of frontmatter) {
|
||||
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
||||
if (match) {
|
||||
result[match[1]] = match[2].replace(/^["']|["']$/g, "").trim();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function asArray(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item) => item !== null && item !== undefined).map(String);
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return [value.trim()];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function unique(values) {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
function detectPlatform(skill) {
|
||||
for (const key of PLATFORM_KEYS) {
|
||||
if (skill[key] && typeof skill[key] === "object") {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return skill.platform || "agent-skills";
|
||||
}
|
||||
|
||||
function collectDeclaredPlatforms(skill) {
|
||||
const platforms = new Set();
|
||||
if (typeof skill.platform === "string" && skill.platform.trim()) {
|
||||
platforms.add(skill.platform.trim());
|
||||
}
|
||||
if (Array.isArray(skill.platforms)) {
|
||||
for (const platform of skill.platforms) {
|
||||
if (typeof platform === "string" && platform.trim()) {
|
||||
platforms.add(platform.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of PLATFORM_KEYS) {
|
||||
if (skill[key] && typeof skill[key] === "object") {
|
||||
platforms.add(key);
|
||||
}
|
||||
}
|
||||
return [...platforms];
|
||||
}
|
||||
|
||||
function installAgentForSkill(skill) {
|
||||
const platforms = collectDeclaredPlatforms(skill);
|
||||
if (platforms.length === 0) {
|
||||
return "openclaw";
|
||||
}
|
||||
|
||||
const matchedAgents = new Set();
|
||||
let allPlatformsMatched = true;
|
||||
for (const platform of platforms) {
|
||||
const candidate = PLATFORM_AGENT_ALIASES.get(platform) || platform;
|
||||
if (KNOWN_AGENT_TYPES.has(candidate)) {
|
||||
matchedAgents.add(candidate);
|
||||
} else {
|
||||
allPlatformsMatched = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allPlatformsMatched && matchedAgents.size === 1) {
|
||||
return [...matchedAgents][0];
|
||||
}
|
||||
|
||||
return "openclaw";
|
||||
}
|
||||
|
||||
function platformMetadata(skill, platform) {
|
||||
const direct = skill[platform];
|
||||
return direct && typeof direct === "object" ? direct : {};
|
||||
}
|
||||
|
||||
function collectRequiredBinaries(metadata) {
|
||||
const requires = metadata.requires && typeof metadata.requires === "object" ? metadata.requires : {};
|
||||
const bins = asArray(requires.bins);
|
||||
|
||||
for (const [key, value] of Object.entries(requires)) {
|
||||
if (key !== "bins" && typeof value === "string") {
|
||||
bins.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return unique(bins);
|
||||
}
|
||||
|
||||
function collectOptionalBinaries(metadata) {
|
||||
return unique([
|
||||
...asArray(metadata.runtime?.optional_bins),
|
||||
...asArray(metadata.runtime?.optionalBins),
|
||||
]);
|
||||
}
|
||||
|
||||
function collectRequiredEnv(metadata) {
|
||||
const requires = metadata.requires && typeof metadata.requires === "object" ? metadata.requires : {};
|
||||
return unique([
|
||||
...asArray(requires.env),
|
||||
...asArray(metadata.runtime?.required_env),
|
||||
...asArray(metadata.runtime?.requiredEnv),
|
||||
]);
|
||||
}
|
||||
|
||||
function collectOptionalEnv(metadata) {
|
||||
return unique([
|
||||
...asArray(metadata.runtime?.optional_env),
|
||||
...asArray(metadata.runtime?.optionalEnv),
|
||||
]);
|
||||
}
|
||||
|
||||
function stringifyCapabilities(skill, metadata) {
|
||||
const capabilities = metadata.capabilities ?? skill.capabilities ?? {};
|
||||
if (Array.isArray(capabilities)) {
|
||||
return capabilities;
|
||||
}
|
||||
if (capabilities && typeof capabilities === "object") {
|
||||
return Object.entries(capabilities).map(([key, value]) => `${key}: ${String(value)}`);
|
||||
}
|
||||
if (typeof capabilities === "string") {
|
||||
return [capabilities];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function requireField(skill, fieldName) {
|
||||
if (!skill[fieldName] || typeof skill[fieldName] !== "string" || !skill[fieldName].trim()) {
|
||||
throw new Error(`skill.json missing required trust-packet field: ${fieldName}`);
|
||||
}
|
||||
return skill[fieldName].trim();
|
||||
}
|
||||
|
||||
function codeBlock(command) {
|
||||
return ["```bash", command, "```"].join("\n");
|
||||
}
|
||||
|
||||
function buildPermissions({ skill, metadata, platform, generatedAt }) {
|
||||
const execution = metadata.execution && typeof metadata.execution === "object" ? metadata.execution : {};
|
||||
const permissions = {
|
||||
schema_version: "1",
|
||||
generated_at: generatedAt,
|
||||
skill: skill.name,
|
||||
version: skill.version,
|
||||
platform,
|
||||
required_binaries: collectRequiredBinaries(metadata),
|
||||
optional_binaries: collectOptionalBinaries(metadata),
|
||||
required_env: collectRequiredEnv(metadata),
|
||||
optional_env: collectOptionalEnv(metadata),
|
||||
network_egress: execution.network_egress || "Not declared in skill metadata.",
|
||||
persistence: execution.persistence || "Not declared in skill metadata.",
|
||||
automatic_execution: typeof execution.always === "boolean" ? execution.always : "Not declared in skill metadata.",
|
||||
capabilities: stringifyCapabilities(skill, metadata),
|
||||
operator_review: asArray(metadata.operator_review),
|
||||
};
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
function buildSkillCard({ skill, frontmatter, permissions, repository, tag, sourceRef }) {
|
||||
const homepage = skill.homepage || frontmatter.homepage || `https://github.com/${repository}`;
|
||||
const supportRef = `${repository}@${tag || sourceRef}`;
|
||||
const licenseRef = `https://github.com/${repository}/blob/${tag || sourceRef}/LICENSE`;
|
||||
const outputTypes = ["Markdown instructions", "release artifact files"];
|
||||
if (permissions.capabilities.length > 0) {
|
||||
outputTypes.push("local security findings or status reports");
|
||||
}
|
||||
|
||||
return `# Skill Card
|
||||
|
||||
## Description
|
||||
|
||||
The \`${skill.name}\` skill provides this capability: ${skill.description}
|
||||
|
||||
This skill is intended for operator-reviewed security workflows, not unattended production mutation without the review steps declared in the skill instructions.
|
||||
|
||||
## Owner
|
||||
|
||||
prompt-security
|
||||
|
||||
## License/Terms of Use
|
||||
|
||||
${skill.license}
|
||||
|
||||
License reference: ${licenseRef}
|
||||
|
||||
Project homepage: ${homepage}
|
||||
|
||||
## Use Case
|
||||
|
||||
Use this skill for ${permissions.platform} workflows where an agent or operator needs the capability described in \`${skill.name}\`.
|
||||
|
||||
## Deployment Geography for Use
|
||||
|
||||
Global, subject to the operator's local compliance, network, and data-handling requirements.
|
||||
|
||||
## Known Risks and Mitigations
|
||||
|
||||
Risk: The skill may run commands, inspect local files, install hooks, or fetch remote security metadata depending on the workflow.
|
||||
|
||||
Mitigation: Review \`permissions.json\`, \`SKILL.md\`, and the signed \`checksums.json\` before enabling the skill. Keep high-impact actions approval-gated.
|
||||
|
||||
Risk: Security findings and remediation guidance can be incomplete or wrong.
|
||||
|
||||
Mitigation: Treat output as operator guidance. Review proposed removals, installs, configuration changes, and reports before acting.
|
||||
|
||||
## References
|
||||
|
||||
- Source release: ${supportRef}
|
||||
- Skill instructions: SKILL.md
|
||||
- Permission summary: permissions.json
|
||||
- SkillSpector scan: skillspector-report.md
|
||||
- Signed release manifest: checksums.json and checksums.sig
|
||||
|
||||
## Skill Output
|
||||
|
||||
Output type(s): ${outputTypes.join(", ")}
|
||||
|
||||
Output format: Markdown, JSON, shell commands, or local files as documented by the skill.
|
||||
|
||||
Output parameters: See \`SKILL.md\`, \`permissions.json\`, and release checksums for exact files and side effects.
|
||||
|
||||
Other properties: Release assets are covered by signed SHA-256 checksums.
|
||||
|
||||
## Skill Version
|
||||
|
||||
${skill.version}${tag ? ` (${tag})` : ""}
|
||||
|
||||
## Ethical Considerations
|
||||
|
||||
Use this skill only on systems, agents, repositories, and workspaces where you have authorization. Review generated security reports before sharing them because they may contain operational details.
|
||||
`;
|
||||
}
|
||||
|
||||
function buildInstallDoc({ skill, repository, tag, sourceRef }) {
|
||||
const refSuffix = sourceRef && sourceRef !== "main" ? `#${sourceRef}` : "";
|
||||
const source = `${repository}${refSuffix}`;
|
||||
const releaseUrl = tag ? `https://github.com/${repository}/releases/tag/${tag}` : `https://github.com/${repository}`;
|
||||
const agent = installAgentForSkill(skill);
|
||||
|
||||
return `# Install and Update ${skill.name}
|
||||
|
||||
## Install With Agent Skills CLI
|
||||
|
||||
Harness-aware global install:
|
||||
|
||||
${codeBlock(`npx skills add ${source} --skill ${skill.name} --agent ${agent} --global --yes`)}
|
||||
|
||||
Project-local install for compatible agents:
|
||||
|
||||
${codeBlock(`npx skills add ${source} --skill ${skill.name} --yes`)}
|
||||
|
||||
## Update
|
||||
|
||||
Update this skill when installed through the Skills CLI:
|
||||
|
||||
${codeBlock(`npx skills update ${skill.name}`)}
|
||||
|
||||
List installed skills:
|
||||
|
||||
${codeBlock("npx skills list")}
|
||||
|
||||
## Verify Release Artifact
|
||||
|
||||
When installing from a GitHub release instead of the Skills CLI, download the archive, \`checksums.json\`, \`checksums.sig\`, and \`signing-public.pem\` from:
|
||||
|
||||
${releaseUrl}
|
||||
|
||||
Verify \`checksums.json\` before trusting the archive or standalone files.
|
||||
`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const skillDir = path.resolve(args.skillDir);
|
||||
const outputDir = path.resolve(args.outputDir);
|
||||
|
||||
const skillJsonPath = path.join(skillDir, "skill.json");
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md");
|
||||
const [skillJsonRaw, skillMdRaw] = await Promise.all([
|
||||
readFile(skillJsonPath, "utf8"),
|
||||
readFile(skillMdPath, "utf8"),
|
||||
]);
|
||||
|
||||
const skill = JSON.parse(skillJsonRaw);
|
||||
const frontmatter = parseFrontmatter(skillMdRaw);
|
||||
skill.name = requireField(skill, "name");
|
||||
skill.version = requireField(skill, "version");
|
||||
skill.description = requireField(skill, "description");
|
||||
skill.license = requireField(skill, "license");
|
||||
|
||||
const platform = detectPlatform(skill);
|
||||
const metadata = platformMetadata(skill, platform);
|
||||
const generatedAt = new Date().toISOString();
|
||||
const permissions = buildPermissions({ skill, metadata, platform, generatedAt });
|
||||
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(outputDir, "permissions.json"),
|
||||
`${JSON.stringify(permissions, null, 2)}\n`,
|
||||
),
|
||||
writeFile(
|
||||
path.join(outputDir, "skill-card.md"),
|
||||
buildSkillCard({
|
||||
skill,
|
||||
frontmatter,
|
||||
permissions,
|
||||
repository: args.repository,
|
||||
tag: args.tag,
|
||||
sourceRef: args.sourceRef,
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(outputDir, "install.md"),
|
||||
buildInstallDoc({
|
||||
skill,
|
||||
repository: args.repository,
|
||||
tag: args.tag,
|
||||
sourceRef: args.sourceRef,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
console.log(`Generated release trust packet for ${skill.name} in ${outputDir}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,520 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TRUST_ARTIFACTS = [
|
||||
"skill-card.md",
|
||||
"permissions.json",
|
||||
"install.md",
|
||||
"skillspector-report.md",
|
||||
];
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"Usage: node scripts/ci/simulate_skill_tag_release.mjs <skill-dir> <output-dir> [options]",
|
||||
"",
|
||||
"Options:",
|
||||
" --repository <owner/repo> Source repository used in release metadata",
|
||||
" --source-ref <ref> Source ref used in npx skills examples",
|
||||
" --skillspector-bin <path> SkillSpector executable to run",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const positional = [];
|
||||
const options = {
|
||||
repository: "prompt-security/clawsec",
|
||||
sourceRef: "main",
|
||||
skillspectorBin: "skillspector",
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === "--repository") {
|
||||
options.repository = argv[++i];
|
||||
} else if (token === "--source-ref") {
|
||||
options.sourceRef = argv[++i];
|
||||
} else if (token === "--skillspector-bin") {
|
||||
options.skillspectorBin = argv[++i];
|
||||
} else if (token === "--help" || token === "-h") {
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
} else if (token.startsWith("--")) {
|
||||
throw new Error(`Unknown option: ${token}`);
|
||||
} else {
|
||||
positional.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
if (positional.length !== 2) {
|
||||
throw new Error(usage());
|
||||
}
|
||||
|
||||
return {
|
||||
skillDir: positional[0],
|
||||
outputDir: positional[1],
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`Command failed: ${command} ${args.join(" ")}`,
|
||||
result.stdout ? `stdout:\n${result.stdout}` : "",
|
||||
result.stderr ? `stderr:\n${result.stderr}` : "",
|
||||
].filter(Boolean).join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function runAllowFailure(command, args, options = {}) {
|
||||
return spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function nextSimulatedReleaseVersion(version) {
|
||||
const versionMatch = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9]+))?$/);
|
||||
if (!versionMatch) {
|
||||
throw new Error(`Cannot derive simulated release version from unsupported version: ${version}`);
|
||||
}
|
||||
|
||||
const [, major, minor, patch, prerelease] = versionMatch;
|
||||
if (!prerelease) {
|
||||
return `${major}.${minor}.${Number(patch) + 1}`;
|
||||
}
|
||||
|
||||
const prereleaseMatch = prerelease.match(/^(.*?)(\d+)$/);
|
||||
if (prereleaseMatch) {
|
||||
const [, label, number] = prereleaseMatch;
|
||||
return `${major}.${minor}.${patch}-${label}${Number(number) + 1}`;
|
||||
}
|
||||
|
||||
return `${major}.${minor}.${patch}-${prerelease}1`;
|
||||
}
|
||||
|
||||
function normalizeReleasePath(rawPath) {
|
||||
let releasePath = rawPath.replaceAll("\\", "/");
|
||||
while (releasePath.startsWith("./")) {
|
||||
releasePath = releasePath.slice(2);
|
||||
}
|
||||
while (releasePath.includes("//")) {
|
||||
releasePath = releasePath.replaceAll("//", "/");
|
||||
}
|
||||
|
||||
if (
|
||||
releasePath === "" ||
|
||||
releasePath.startsWith("/") ||
|
||||
/^[A-Za-z]:/.test(releasePath) ||
|
||||
releasePath === ".." ||
|
||||
releasePath.startsWith("../") ||
|
||||
releasePath.endsWith("/..") ||
|
||||
releasePath.includes("/../")
|
||||
) {
|
||||
throw new Error(`Unsafe release path: ${rawPath}`);
|
||||
}
|
||||
|
||||
return releasePath;
|
||||
}
|
||||
|
||||
function isTestReleasePath(releasePath) {
|
||||
const lower = releasePath.toLowerCase();
|
||||
return lower === "test" ||
|
||||
lower === "tests" ||
|
||||
lower.startsWith("test/") ||
|
||||
lower.startsWith("tests/") ||
|
||||
lower.includes("/test/") ||
|
||||
lower.includes("/tests/");
|
||||
}
|
||||
|
||||
async function sha256File(filePath) {
|
||||
const buffer = await readFile(filePath);
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
async function fileSize(filePath) {
|
||||
return (await stat(filePath)).size;
|
||||
}
|
||||
|
||||
async function checksumEntry(filePath, releasePath) {
|
||||
return {
|
||||
sha256: await sha256File(filePath),
|
||||
size: await fileSize(filePath),
|
||||
path: releasePath,
|
||||
};
|
||||
}
|
||||
|
||||
function replaceSkillMarkdownVersion(markdown, version) {
|
||||
if (!markdown.startsWith("---\n")) {
|
||||
throw new Error("SKILL.md is missing YAML frontmatter");
|
||||
}
|
||||
|
||||
const end = markdown.indexOf("\n---", 4);
|
||||
if (end === -1) {
|
||||
throw new Error("SKILL.md frontmatter is not closed");
|
||||
}
|
||||
|
||||
const frontmatter = markdown.slice(0, end);
|
||||
if (!/^version:\s*.+$/m.test(frontmatter)) {
|
||||
throw new Error("SKILL.md frontmatter is missing a version field");
|
||||
}
|
||||
|
||||
return markdown.replace(/^version:\s*.+$/m, `version: ${version}`);
|
||||
}
|
||||
|
||||
async function addSimulatedChangelogEntry(skillDir, version) {
|
||||
const changelogPath = path.join(skillDir, "CHANGELOG.md");
|
||||
if (!existsSync(changelogPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const original = await readFile(changelogPath, "utf8");
|
||||
if (original.includes(`## [${version}] -`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = [
|
||||
`## [${version}] - ${today}`,
|
||||
"",
|
||||
"- Simulated prerelease build for release-pipeline validation.",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
await writeFile(changelogPath, `${entry}${original}`);
|
||||
}
|
||||
|
||||
async function writeJson(filePath, value) {
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function signFileBase64({ keyPath, inputPath, outputPath, tempRoot }) {
|
||||
const sigBin = path.join(tempRoot, `${path.basename(outputPath)}.bin`);
|
||||
run("openssl", ["pkeyutl", "-sign", "-rawin", "-inkey", keyPath, "-in", inputPath, "-out", sigBin]);
|
||||
run("openssl", ["base64", "-A", "-in", sigBin, "-out", outputPath]);
|
||||
await rm(sigBin, { force: true });
|
||||
}
|
||||
|
||||
async function verifyFileBase64Signature({ publicKeyPath, inputPath, signaturePath, tempRoot }) {
|
||||
const sigBin = path.join(tempRoot, `${path.basename(signaturePath)}.verify.bin`);
|
||||
run("openssl", ["base64", "-d", "-A", "-in", signaturePath, "-out", sigBin]);
|
||||
run("openssl", [
|
||||
"pkeyutl",
|
||||
"-verify",
|
||||
"-rawin",
|
||||
"-pubin",
|
||||
"-inkey",
|
||||
publicKeyPath,
|
||||
"-sigfile",
|
||||
sigBin,
|
||||
"-in",
|
||||
inputPath,
|
||||
]);
|
||||
await rm(sigBin, { force: true });
|
||||
}
|
||||
|
||||
async function createSigningKeyPair(tempRoot) {
|
||||
const keyDir = await mkdtemp(path.join(tempRoot, "signing-"));
|
||||
const privateKeyPath = path.join(keyDir, "private.pem");
|
||||
const publicKeyPath = path.join(keyDir, "public.pem");
|
||||
|
||||
run("openssl", ["genpkey", "-algorithm", "ED25519", "-out", privateKeyPath]);
|
||||
run("openssl", ["pkey", "-in", privateKeyPath, "-pubout", "-out", publicKeyPath]);
|
||||
|
||||
return { privateKeyPath, publicKeyPath };
|
||||
}
|
||||
|
||||
async function signAdvisoryArtifacts(skillDir, tempRoot) {
|
||||
const advisoryDir = path.join(skillDir, "advisories");
|
||||
const feedPath = path.join(advisoryDir, "feed.json");
|
||||
if (!existsSync(feedPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { privateKeyPath, publicKeyPath } = await createSigningKeyPair(tempRoot);
|
||||
const feedSignaturePath = path.join(advisoryDir, "feed.json.sig");
|
||||
const checksumsPath = path.join(advisoryDir, "checksums.json");
|
||||
const checksumsSignaturePath = path.join(advisoryDir, "checksums.json.sig");
|
||||
const publicKeyOutputPath = path.join(advisoryDir, "feed-signing-public.pem");
|
||||
|
||||
await signFileBase64({
|
||||
keyPath: privateKeyPath,
|
||||
inputPath: feedPath,
|
||||
outputPath: feedSignaturePath,
|
||||
tempRoot,
|
||||
});
|
||||
await verifyFileBase64Signature({
|
||||
publicKeyPath,
|
||||
inputPath: feedPath,
|
||||
signaturePath: feedSignaturePath,
|
||||
tempRoot,
|
||||
});
|
||||
|
||||
await writeJson(checksumsPath, {
|
||||
schema_version: "1",
|
||||
algorithm: "sha256",
|
||||
version: "simulation",
|
||||
generated_at: new Date().toISOString(),
|
||||
files: {
|
||||
"advisories/feed.json": await checksumEntry(feedPath, "advisories/feed.json"),
|
||||
"advisories/feed.json.sig": await checksumEntry(feedSignaturePath, "advisories/feed.json.sig"),
|
||||
},
|
||||
});
|
||||
|
||||
await signFileBase64({
|
||||
keyPath: privateKeyPath,
|
||||
inputPath: checksumsPath,
|
||||
outputPath: checksumsSignaturePath,
|
||||
tempRoot,
|
||||
});
|
||||
await verifyFileBase64Signature({
|
||||
publicKeyPath,
|
||||
inputPath: checksumsPath,
|
||||
signaturePath: checksumsSignaturePath,
|
||||
tempRoot,
|
||||
});
|
||||
|
||||
await cp(publicKeyPath, publicKeyOutputPath);
|
||||
}
|
||||
|
||||
async function addReleaseAssetChecksum({ releaseAssetsDir, manifest, asset }) {
|
||||
const filePath = path.join(releaseAssetsDir, asset);
|
||||
if (!existsSync(filePath) || (await fileSize(filePath)) === 0) {
|
||||
throw new Error(`Required release trust artifact is missing or empty: ${filePath}`);
|
||||
}
|
||||
|
||||
manifest.files[asset] = await checksumEntry(filePath, asset);
|
||||
}
|
||||
|
||||
async function stageSbomFiles({ skillDir, innerDir, sbomFiles }) {
|
||||
for (const entry of sbomFiles) {
|
||||
const releasePath = normalizeReleasePath(entry.path);
|
||||
if (isTestReleasePath(releasePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = path.join(skillDir, releasePath);
|
||||
if (!existsSync(fullPath)) {
|
||||
throw new Error(`SBOM references missing file: ${releasePath}`);
|
||||
}
|
||||
|
||||
const destination = path.join(innerDir, releasePath);
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
await cp(fullPath, destination);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildFilesManifest({ skillDir, skillJsonPath, sbomFiles }) {
|
||||
const files = {};
|
||||
for (const entry of sbomFiles) {
|
||||
const releasePath = normalizeReleasePath(entry.path);
|
||||
if (isTestReleasePath(releasePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = path.join(skillDir, releasePath);
|
||||
if (existsSync(fullPath)) {
|
||||
files[releasePath] = await checksumEntry(fullPath, releasePath);
|
||||
}
|
||||
}
|
||||
|
||||
files["skill.json"] = {
|
||||
sha256: await sha256File(skillJsonPath),
|
||||
size: await fileSize(skillJsonPath),
|
||||
};
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function runSkillSpector({ skillspectorBin, skillDir, reportPath }) {
|
||||
const result = runAllowFailure(skillspectorBin, [
|
||||
"scan",
|
||||
skillDir,
|
||||
"--no-llm",
|
||||
"--format",
|
||||
"markdown",
|
||||
"--output",
|
||||
reportPath,
|
||||
]);
|
||||
|
||||
if (!existsSync(reportPath) || (await fileSize(reportPath)) === 0) {
|
||||
throw new Error(
|
||||
[
|
||||
"SkillSpector did not produce a report.",
|
||||
result.stdout ? `stdout:\n${result.stdout}` : "",
|
||||
result.stderr ? `stderr:\n${result.stderr}` : "",
|
||||
].filter(Boolean).join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
console.warn(`SkillSpector returned exit code ${result.status}; report is included for review.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const sourceSkillDir = path.resolve(args.skillDir);
|
||||
const outputDir = path.resolve(args.outputDir);
|
||||
const releaseAssetsDir = path.join(outputDir, "release-assets");
|
||||
const tempRoot = await mkdtemp(path.join(tmpdir(), "clawsec-release-sim-"));
|
||||
|
||||
try {
|
||||
const skillName = path.basename(sourceSkillDir);
|
||||
const tempSkillDir = path.join(tempRoot, skillName);
|
||||
await cp(sourceSkillDir, tempSkillDir, { recursive: true });
|
||||
|
||||
const skillJsonPath = path.join(tempSkillDir, "skill.json");
|
||||
const skillMdPath = path.join(tempSkillDir, "SKILL.md");
|
||||
const skill = JSON.parse(await readFile(skillJsonPath, "utf8"));
|
||||
const originalVersion = skill.version;
|
||||
const simulatedVersion = nextSimulatedReleaseVersion(originalVersion);
|
||||
const tag = `${skillName}-v${simulatedVersion}`;
|
||||
const zipName = `${tag}.zip`;
|
||||
|
||||
skill.version = simulatedVersion;
|
||||
await writeJson(skillJsonPath, skill);
|
||||
await writeFile(
|
||||
skillMdPath,
|
||||
replaceSkillMarkdownVersion(await readFile(skillMdPath, "utf8"), simulatedVersion),
|
||||
);
|
||||
await addSimulatedChangelogEntry(tempSkillDir, simulatedVersion);
|
||||
await signAdvisoryArtifacts(tempSkillDir, tempRoot);
|
||||
|
||||
if (!skill.sbom || !Array.isArray(skill.sbom.files)) {
|
||||
throw new Error(`skill.json missing required release field: sbom.files`);
|
||||
}
|
||||
|
||||
await mkdir(releaseAssetsDir, { recursive: true });
|
||||
|
||||
const stagingDir = await mkdtemp(path.join(tempRoot, "staging-"));
|
||||
const innerDir = path.join(stagingDir, skillName);
|
||||
await mkdir(innerDir, { recursive: true });
|
||||
await stageSbomFiles({
|
||||
skillDir: tempSkillDir,
|
||||
innerDir,
|
||||
sbomFiles: skill.sbom.files,
|
||||
});
|
||||
await cp(skillJsonPath, path.join(innerDir, "skill.json"));
|
||||
|
||||
run("python3", ["scripts/ci/verify_skill_release_import_closure.py", innerDir], {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
run("zip", ["-qr", path.join(releaseAssetsDir, zipName), "."], {
|
||||
cwd: stagingDir,
|
||||
});
|
||||
|
||||
const zipContents = run("unzip", ["-Z1", path.join(releaseAssetsDir, zipName)]);
|
||||
if (zipContents.split("\n").some((entry) => /(^|\/)(test|tests)\//i.test(entry))) {
|
||||
throw new Error(`Simulated release archive contains test-only files: ${zipName}`);
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
skill: skillName,
|
||||
version: simulatedVersion,
|
||||
generated_at: new Date().toISOString(),
|
||||
repository: args.repository,
|
||||
tag,
|
||||
archive: {
|
||||
filename: zipName,
|
||||
sha256: await sha256File(path.join(releaseAssetsDir, zipName)),
|
||||
size: await fileSize(path.join(releaseAssetsDir, zipName)),
|
||||
url: `https://github.com/${args.repository}/releases/download/${tag}/${zipName}`,
|
||||
},
|
||||
files: await buildFilesManifest({
|
||||
skillDir: tempSkillDir,
|
||||
skillJsonPath,
|
||||
sbomFiles: skill.sbom.files,
|
||||
}),
|
||||
};
|
||||
|
||||
await writeJson(path.join(releaseAssetsDir, "checksums.json"), manifest);
|
||||
|
||||
run(process.execPath, [
|
||||
"scripts/ci/generate_skill_release_trust_packet.mjs",
|
||||
tempSkillDir,
|
||||
releaseAssetsDir,
|
||||
"--repository",
|
||||
args.repository,
|
||||
"--tag",
|
||||
tag,
|
||||
"--source-ref",
|
||||
args.sourceRef,
|
||||
]);
|
||||
|
||||
await runSkillSpector({
|
||||
skillspectorBin: args.skillspectorBin,
|
||||
skillDir: tempSkillDir,
|
||||
reportPath: path.join(releaseAssetsDir, "skillspector-report.md"),
|
||||
});
|
||||
|
||||
for (const artifact of TRUST_ARTIFACTS) {
|
||||
await addReleaseAssetChecksum({ releaseAssetsDir, manifest, asset: artifact });
|
||||
}
|
||||
await writeJson(path.join(releaseAssetsDir, "checksums.json"), manifest);
|
||||
|
||||
await cp(skillJsonPath, path.join(releaseAssetsDir, "skill.json"));
|
||||
await cp(skillMdPath, path.join(releaseAssetsDir, "SKILL.md"));
|
||||
if (existsSync(path.join(tempSkillDir, "README.md"))) {
|
||||
await cp(path.join(tempSkillDir, "README.md"), path.join(releaseAssetsDir, "README.md"));
|
||||
}
|
||||
|
||||
const { privateKeyPath, publicKeyPath } = await createSigningKeyPair(tempRoot);
|
||||
await signFileBase64({
|
||||
keyPath: privateKeyPath,
|
||||
inputPath: path.join(releaseAssetsDir, "checksums.json"),
|
||||
outputPath: path.join(releaseAssetsDir, "checksums.sig"),
|
||||
tempRoot,
|
||||
});
|
||||
await verifyFileBase64Signature({
|
||||
publicKeyPath,
|
||||
inputPath: path.join(releaseAssetsDir, "checksums.json"),
|
||||
signaturePath: path.join(releaseAssetsDir, "checksums.sig"),
|
||||
tempRoot,
|
||||
});
|
||||
await cp(publicKeyPath, path.join(releaseAssetsDir, "signing-public.pem"));
|
||||
|
||||
await writeJson(path.join(outputDir, "simulation-summary.json"), {
|
||||
skill: skillName,
|
||||
original_version: originalVersion,
|
||||
simulated_version: simulatedVersion,
|
||||
tag,
|
||||
release_assets: path.relative(outputDir, releaseAssetsDir),
|
||||
archive: `release-assets/${zipName}`,
|
||||
});
|
||||
|
||||
console.log(`Simulated tag release build for ${skillName}: ${tag}`);
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import https from "node:https";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_REPOSITORY = "prompt-security/clawsec";
|
||||
const DEFAULT_AGENT_TYPES_URL = "https://raw.githubusercontent.com/vercel-labs/skills/main/src/types.ts";
|
||||
const DOC_FILENAMES = ["README.md", "SKILL.md"];
|
||||
const KNOWN_PLATFORM_KEYS = ["openclaw", "nanoclaw", "picoclaw", "hermes"];
|
||||
const PLATFORM_AGENT_ALIASES = new Map([["hermes", "hermes-agent"]]);
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"Usage: node scripts/ci/validate_skill_install_docs.mjs [options]",
|
||||
"",
|
||||
"Options:",
|
||||
" --root <dir> Repository root. Defaults to current working directory.",
|
||||
" --repository <owner/repo> Expected npx skills source. Defaults to prompt-security/clawsec.",
|
||||
" --base <sha> Base ref for changed-skill detection.",
|
||||
" --head <sha> Head ref for changed-skill detection.",
|
||||
" --skills <dir[,dir...]> Skill directories to validate.",
|
||||
" --all Validate every skill directory with skill.json.",
|
||||
" --agent-types-file <path> Read Vercel AgentType source from a local file.",
|
||||
" --agent-types-url <url> Read Vercel AgentType source from a URL.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
root: process.cwd(),
|
||||
repository: DEFAULT_REPOSITORY,
|
||||
base: process.env.BASE_SHA || "",
|
||||
head: process.env.HEAD_SHA || "",
|
||||
skillDirs: [],
|
||||
all: false,
|
||||
agentTypesFile: "",
|
||||
agentTypesUrl: DEFAULT_AGENT_TYPES_URL,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === "--root") {
|
||||
options.root = argv[++i];
|
||||
} else if (token === "--repository") {
|
||||
options.repository = argv[++i];
|
||||
} else if (token === "--base") {
|
||||
options.base = argv[++i];
|
||||
} else if (token === "--head") {
|
||||
options.head = argv[++i];
|
||||
} else if (token === "--skills") {
|
||||
options.skillDirs.push(...argv[++i].split(",").map((item) => item.trim()).filter(Boolean));
|
||||
} else if (token === "--all") {
|
||||
options.all = true;
|
||||
} else if (token === "--agent-types-file") {
|
||||
options.agentTypesFile = argv[++i];
|
||||
} else if (token === "--agent-types-url") {
|
||||
options.agentTypesUrl = argv[++i];
|
||||
} else if (token === "--help" || token === "-h") {
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
} else {
|
||||
throw new Error(`Unknown option: ${token}\n${usage()}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...options,
|
||||
root: path.resolve(options.root),
|
||||
};
|
||||
}
|
||||
|
||||
function fetchText(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, (response) => {
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`Failed to fetch ${url}: HTTP ${response.statusCode}`));
|
||||
response.resume();
|
||||
return;
|
||||
}
|
||||
|
||||
response.setEncoding("utf8");
|
||||
let body = "";
|
||||
response.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
response.on("end", () => resolve(body));
|
||||
})
|
||||
.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function readAgentTypeSource(options) {
|
||||
if (options.agentTypesFile) {
|
||||
return readFile(path.resolve(options.agentTypesFile), "utf8");
|
||||
}
|
||||
|
||||
return fetchText(options.agentTypesUrl);
|
||||
}
|
||||
|
||||
function parseAgentTypes(source) {
|
||||
const match = source.match(/export\s+type\s+AgentType\s*=\s*([\s\S]*?);/);
|
||||
if (!match) {
|
||||
throw new Error("Could not find export type AgentType in Vercel skills type source.");
|
||||
}
|
||||
|
||||
const agents = new Set();
|
||||
const agentTypeBody = match[1];
|
||||
for (const agentMatch of agentTypeBody.matchAll(/['"]([^'"]+)['"]/g)) {
|
||||
agents.add(agentMatch[1]);
|
||||
}
|
||||
|
||||
if (agents.size === 0) {
|
||||
throw new Error("Vercel AgentType list was empty.");
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
async function listAllSkillDirs(root) {
|
||||
const skillsRoot = path.join(root, "skills");
|
||||
const entries = await readdir(skillsRoot, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => `skills/${entry.name}`)
|
||||
.filter((skillDir) => existsSync(path.join(root, skillDir, "skill.json")))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function changedSkillDirs({ root, base, head }) {
|
||||
if (!base || !head) {
|
||||
throw new Error("Provide --skills, --all, or both --base and --head for changed-skill detection.");
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
"git",
|
||||
[
|
||||
"-C",
|
||||
root,
|
||||
"diff",
|
||||
"--name-only",
|
||||
`${base}...${head}`,
|
||||
"--",
|
||||
"skills/*/**",
|
||||
":(exclude)skills/*/test/**",
|
||||
":(exclude)skills/*/tests/**",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`git diff failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
}
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
result.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((filePath) => filePath.split("/").slice(0, 2).join("/"))
|
||||
.filter((skillDir) => /^skills\/[^/]+$/.test(skillDir)),
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
|
||||
async function readJson(filePath) {
|
||||
return JSON.parse(await readFile(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function collectDeclaredPlatforms(skill) {
|
||||
const platforms = new Set();
|
||||
|
||||
if (typeof skill.platform === "string" && skill.platform.trim()) {
|
||||
platforms.add(skill.platform.trim());
|
||||
}
|
||||
|
||||
if (Array.isArray(skill.platforms)) {
|
||||
for (const platform of skill.platforms) {
|
||||
if (typeof platform === "string" && platform.trim()) {
|
||||
platforms.add(platform.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of KNOWN_PLATFORM_KEYS) {
|
||||
if (skill[key] && typeof skill[key] === "object") {
|
||||
platforms.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return [...platforms];
|
||||
}
|
||||
|
||||
function agentForSkill(skill, agentTypes) {
|
||||
const platforms = collectDeclaredPlatforms(skill);
|
||||
if (platforms.length === 0) {
|
||||
return "openclaw";
|
||||
}
|
||||
|
||||
const matchedAgents = new Set();
|
||||
let allPlatformsMatched = true;
|
||||
|
||||
for (const platform of platforms) {
|
||||
const aliasedPlatform = PLATFORM_AGENT_ALIASES.get(platform) || platform;
|
||||
if (agentTypes.has(aliasedPlatform)) {
|
||||
matchedAgents.add(aliasedPlatform);
|
||||
} else {
|
||||
allPlatformsMatched = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allPlatformsMatched && matchedAgents.size === 1) {
|
||||
return [...matchedAgents][0];
|
||||
}
|
||||
|
||||
return "openclaw";
|
||||
}
|
||||
|
||||
function hasRequiredCommand(markdown, { repository, skillName, agent }) {
|
||||
return markdown
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/\s+/g, " ").trim())
|
||||
.filter((line) => line.includes("npx skills add"))
|
||||
.some((line) => {
|
||||
return (
|
||||
line.includes(`npx skills add ${repository}`) &&
|
||||
line.includes(`--skill ${skillName}`) &&
|
||||
(line.includes(`-a ${agent}`) || line.includes(`--agent ${agent}`)) &&
|
||||
(line.includes(" -y") || line.includes(" --yes"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function validateSkill({ root, skillDir, repository, agentTypes }) {
|
||||
const skillJsonPath = path.join(root, skillDir, "skill.json");
|
||||
const skill = await readJson(skillJsonPath);
|
||||
const skillName = skill.name || path.basename(skillDir);
|
||||
const agent = agentForSkill(skill, agentTypes);
|
||||
const command = `npx skills add ${repository} --skill ${skillName} -a ${agent} -y`;
|
||||
const failures = [];
|
||||
|
||||
for (const filename of DOC_FILENAMES) {
|
||||
const docPath = path.join(root, skillDir, filename);
|
||||
if (!existsSync(docPath)) {
|
||||
failures.push(`Missing required install documentation file: ${path.join(skillDir, filename)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const markdown = await readFile(docPath, "utf8");
|
||||
if (!hasRequiredCommand(markdown, { repository, skillName, agent })) {
|
||||
failures.push(`Missing required npx skills install command in ${path.join(skillDir, filename)}: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
skillDir,
|
||||
skillName,
|
||||
agent,
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const agentTypes = parseAgentTypes(await readAgentTypeSource(options));
|
||||
let skillDirs = options.skillDirs;
|
||||
|
||||
if (options.all) {
|
||||
skillDirs = await listAllSkillDirs(options.root);
|
||||
} else if (skillDirs.length === 0) {
|
||||
skillDirs = changedSkillDirs(options);
|
||||
}
|
||||
|
||||
if (skillDirs.length === 0) {
|
||||
console.log("No skill install docs to validate.");
|
||||
return;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const skillDir of skillDirs) {
|
||||
const skillJsonPath = path.join(options.root, skillDir, "skill.json");
|
||||
if (!existsSync(skillJsonPath)) {
|
||||
console.log(`Skipping removed skill directory: ${skillDir}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(
|
||||
await validateSkill({
|
||||
root: options.root,
|
||||
skillDir,
|
||||
repository: options.repository,
|
||||
agentTypes,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const failures = results.flatMap((result) => result.failures);
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) {
|
||||
console.error(`::error::${failure}`);
|
||||
}
|
||||
throw new Error(`Found ${failures.length} npx skills install documentation issue(s).`);
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
console.log(`npx skills install docs OK for ${result.skillName}: -a ${result.agent}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const validator = "scripts/ci/validate_skill_install_docs.mjs";
|
||||
const workflow = await readFile(".github/workflows/skill-release.yml", "utf8");
|
||||
const tempRoot = await mkdtemp(path.join(tmpdir(), "clawsec-install-docs-"));
|
||||
const agentTypesPath = path.join(tempRoot, "vercel-types.ts");
|
||||
|
||||
function runValidator(args) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[validator, "--root", tempRoot, "--agent-types-file", agentTypesPath, ...args],
|
||||
{
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function writeSkill({ name, metadata, readme, skillMd }) {
|
||||
const skillDir = path.join(tempRoot, "skills", name);
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(skillDir, "skill.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name,
|
||||
version: "1.0.0",
|
||||
description: `${name} test skill`,
|
||||
license: "AGPL-3.0-or-later",
|
||||
...metadata,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await writeFile(path.join(skillDir, "README.md"), readme);
|
||||
await writeFile(path.join(skillDir, "SKILL.md"), skillMd);
|
||||
}
|
||||
|
||||
try {
|
||||
await writeFile(
|
||||
agentTypesPath,
|
||||
"export type AgentType = | 'codex' | 'hermes-agent' | 'openclaw' | 'universal';\n",
|
||||
);
|
||||
|
||||
await writeSkill({
|
||||
name: "hermes-example",
|
||||
metadata: { hermes: { category: "security" } },
|
||||
readme: "# Hermes Example\n\n## Installation\n\nMissing the Skills CLI command.\n",
|
||||
skillMd: "---\nname: hermes-example\nversion: 1.0.0\n---\n\n## Installation\n\nMissing the Skills CLI command.\n",
|
||||
});
|
||||
|
||||
const missingHermes = runValidator(["--skills", "skills/hermes-example"]);
|
||||
assert.equal(missingHermes.status, 1, "missing Hermes install docs must fail validation");
|
||||
assert.match(
|
||||
missingHermes.stderr,
|
||||
/npx skills add prompt-security\/clawsec --skill hermes-example -a hermes-agent -y/,
|
||||
"Hermes skills must require the hermes-agent installer target",
|
||||
);
|
||||
|
||||
await writeSkill({
|
||||
name: "hermes-example",
|
||||
metadata: { hermes: { category: "security" } },
|
||||
readme:
|
||||
"# Hermes Example\n\n## Vercel Skills Installation\n\n```bash\nnpx skills add prompt-security/clawsec --skill hermes-example -a hermes-agent -y\n```\n",
|
||||
skillMd:
|
||||
"---\nname: hermes-example\nversion: 1.0.0\n---\n\n## Vercel Skills Installation\n\n```bash\nnpx skills add prompt-security/clawsec --skill hermes-example -a hermes-agent -y\n```\n",
|
||||
});
|
||||
|
||||
const validHermes = runValidator(["--skills", "skills/hermes-example"]);
|
||||
assert.equal(
|
||||
validHermes.status,
|
||||
0,
|
||||
`valid Hermes install docs should pass\nstdout:\n${validHermes.stdout}\nstderr:\n${validHermes.stderr}`,
|
||||
);
|
||||
|
||||
await writeSkill({
|
||||
name: "codex-example",
|
||||
metadata: { platform: "codex" },
|
||||
readme:
|
||||
"# Codex Example\n\n## Vercel Skills Installation\n\n```bash\nnpx skills add prompt-security/clawsec --skill codex-example -a openclaw -y\n```\n",
|
||||
skillMd:
|
||||
"---\nname: codex-example\nversion: 1.0.0\n---\n\n## Vercel Skills Installation\n\n```bash\nnpx skills add prompt-security/clawsec --skill codex-example -a openclaw -y\n```\n",
|
||||
});
|
||||
|
||||
const wrongExactTarget = runValidator(["--skills", "skills/codex-example"]);
|
||||
assert.equal(wrongExactTarget.status, 1, "exact AgentType matches must use their matched target");
|
||||
assert.match(
|
||||
wrongExactTarget.stderr,
|
||||
/npx skills add prompt-security\/clawsec --skill codex-example -a codex -y/,
|
||||
"Exact AgentType matches must not fall back to openclaw",
|
||||
);
|
||||
|
||||
await writeSkill({
|
||||
name: "nanoclaw-example",
|
||||
metadata: { platform: "nanoclaw", nanoclaw: { category: "security" } },
|
||||
readme:
|
||||
"# NanoClaw Example\n\n## Vercel Skills Installation\n\n```bash\nnpx skills add prompt-security/clawsec --skill nanoclaw-example -a hermes-agent -y\n```\n",
|
||||
skillMd:
|
||||
"---\nname: nanoclaw-example\nversion: 1.0.0\n---\n\n## Vercel Skills Installation\n\n```bash\nnpx skills add prompt-security/clawsec --skill nanoclaw-example -a hermes-agent -y\n```\n",
|
||||
});
|
||||
|
||||
const wrongNanoTarget = runValidator(["--skills", "skills/nanoclaw-example"]);
|
||||
assert.equal(wrongNanoTarget.status, 1, "NanoClaw docs must fail when they use the Hermes target");
|
||||
assert.match(
|
||||
wrongNanoTarget.stderr,
|
||||
/npx skills add prompt-security\/clawsec --skill nanoclaw-example -a openclaw -y/,
|
||||
"NanoClaw skills must install through the openclaw target",
|
||||
);
|
||||
|
||||
await writeSkill({
|
||||
name: "nanoclaw-example",
|
||||
metadata: { platform: "nanoclaw", nanoclaw: { category: "security" } },
|
||||
readme:
|
||||
"# NanoClaw Example\n\n## Vercel Skills Installation\n\n```bash\nnpx skills add prompt-security/clawsec --skill nanoclaw-example -a openclaw -y\n```\n",
|
||||
skillMd:
|
||||
"---\nname: nanoclaw-example\nversion: 1.0.0\n---\n\n## Vercel Skills Installation\n\n```bash\nnpx skills add prompt-security/clawsec --skill nanoclaw-example -a openclaw -y\n```\n",
|
||||
});
|
||||
|
||||
const validNano = runValidator(["--skills", "skills/nanoclaw-example"]);
|
||||
assert.equal(
|
||||
validNano.status,
|
||||
0,
|
||||
`valid NanoClaw install docs should pass\nstdout:\n${validNano.stdout}\nstderr:\n${validNano.stderr}`,
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/Validate npx skills install docs/,
|
||||
"Skill release workflow must run the install-doc validator",
|
||||
);
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const workflowPath = new URL('../.github/workflows/skill-release.yml', import.meta.url);
|
||||
const ciWorkflowPath = new URL('../.github/workflows/ci.yml', import.meta.url);
|
||||
const workflow = await readFile(workflowPath, 'utf8');
|
||||
const ciWorkflow = await readFile(ciWorkflowPath, 'utf8');
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
@@ -10,6 +12,22 @@ assert.match(
|
||||
'Skill release workflow must run when any skill package file changes',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/pull_request:[\s\S]*paths:[\s\S]*- '\.github\/workflows\/skill-release\.yml'[\s\S]*- 'scripts\/ci\/\*\*'/,
|
||||
'Skill release workflow must also run when the release pipeline itself changes',
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
ciWorkflow.includes(` - name: Skill Release Tooling Tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for test_file in scripts/test-skill-*.mjs; do
|
||||
node "$test_file"
|
||||
done`),
|
||||
'CI must run every scripts/test-skill-*.mjs file so new skill release tests are not orphaned',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/git diff --name-only "\$\{BASE_SHA\}\.\.\.\$\{HEAD_SHA\}" --[\s\S]*'skills\/\*\/\*\*'[\s\S]*':\(exclude\)skills\/\*\/test\/\*\*'[\s\S]*':\(exclude\)skills\/\*\/tests\/\*\*'/,
|
||||
@@ -27,3 +45,83 @@ assert.match(
|
||||
/::error file=\$\{skill_dir\}::Changed skill package has no version bump\./,
|
||||
'Skill release validation must emit an explicit missing-version-bump error',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/Install SkillSpector/,
|
||||
'Skill release workflow must install SkillSpector before publishing release evidence',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/Generate SkillSpector report/,
|
||||
'Skill release workflow must generate a SkillSpector report for each released skill',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/Generate release trust packet/,
|
||||
'Skill release workflow must generate skill cards, permission summaries, and npx install instructions',
|
||||
);
|
||||
|
||||
for (const artifact of ['skill-card.md', 'permissions.json', 'install.md', 'skillspector-report.md']) {
|
||||
assert.match(
|
||||
workflow,
|
||||
new RegExp(`release-assets/${artifact.replace('.', '\\.')}`),
|
||||
`Skill release workflow must publish ${artifact} in release assets`,
|
||||
);
|
||||
}
|
||||
|
||||
const escapeRegExp = (literal) => literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
for (const artifact of ['skill-card.md', 'permissions.json', 'install.md', 'skillspector-report.md']) {
|
||||
assert.match(
|
||||
workflow,
|
||||
new RegExp(
|
||||
String.raw`if ! add_release_asset_checksum "\$\{out_assets\}" "${escapeRegExp(artifact)}"; then` +
|
||||
String.raw`[\s\S]*?failures=\$\(\(failures \+ 1\)\)[\s\S]*?continue[\s\S]*?fi`,
|
||||
),
|
||||
`PR dry-run validation must aggregate and continue when ${artifact} cannot be checksummed`,
|
||||
);
|
||||
}
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/add_release_asset_checksum "skill-card\.md"/,
|
||||
'Skill card must be included in the signed checksums manifest',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/add_release_asset_checksum "permissions\.json"/,
|
||||
'Permissions summary must be included in the signed checksums manifest',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/add_release_asset_checksum "install\.md"/,
|
||||
'npx install/update instructions must be included in the signed checksums manifest',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/add_release_asset_checksum "skillspector-report\.md"/,
|
||||
'SkillSpector report must be included in the signed checksums manifest',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/Simulate tag release build/,
|
||||
'Skill release workflow must simulate a tag release build during PR validation',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
workflow,
|
||||
/simulate_skill_tag_release\.mjs/,
|
||||
'Skill release workflow must call the tag release simulation script',
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
workflow.includes('simulated_version | test("^[0-9]+\\\\.[0-9]+\\\\.[0-9]+(-[a-zA-Z0-9]+)?$")'),
|
||||
'Skill release workflow must accept every prerelease version format that release-skill.sh accepts',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chmod, cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const tempRoot = await mkdtemp(path.join(tmpdir(), "clawsec-tag-release-sim-"));
|
||||
const fakeSkillspector = path.join(tempRoot, "skillspector");
|
||||
|
||||
async function prereleaseFixture(sourceSkillDir, version, fixtureGroup) {
|
||||
const fixtureDir = path.join(tempRoot, fixtureGroup, path.basename(sourceSkillDir));
|
||||
await cp(sourceSkillDir, fixtureDir, { recursive: true });
|
||||
|
||||
const skillJsonPath = path.join(fixtureDir, "skill.json");
|
||||
const skill = JSON.parse(await readFile(skillJsonPath, "utf8"));
|
||||
skill.version = version;
|
||||
await writeFile(skillJsonPath, `${JSON.stringify(skill, null, 2)}\n`);
|
||||
|
||||
const skillMdPath = path.join(fixtureDir, "SKILL.md");
|
||||
const skillMd = await readFile(skillMdPath, "utf8");
|
||||
await writeFile(skillMdPath, skillMd.replace(/^version:\s*.+$/m, `version: ${version}`));
|
||||
|
||||
return fixtureDir;
|
||||
}
|
||||
|
||||
async function runSimulation({ skillDir, outputDir, expectedOriginal, expectedSimulated, expectedAgent }) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/ci/simulate_skill_tag_release.mjs",
|
||||
skillDir,
|
||||
outputDir,
|
||||
"--repository",
|
||||
"prompt-security/clawsec",
|
||||
"--source-ref",
|
||||
"pull-request-head",
|
||||
"--skillspector-bin",
|
||||
fakeSkillspector,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`tag release simulation failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
|
||||
const skillName = path.basename(skillDir);
|
||||
const expectedTag = `${skillName}-v${expectedSimulated}`;
|
||||
const summary = JSON.parse(await readFile(path.join(outputDir, "simulation-summary.json"), "utf8"));
|
||||
assert.equal(summary.skill, skillName);
|
||||
assert.equal(summary.original_version, expectedOriginal);
|
||||
assert.equal(summary.simulated_version, expectedSimulated);
|
||||
assert.equal(summary.tag, expectedTag);
|
||||
|
||||
const releaseAssetsDir = path.join(outputDir, "release-assets");
|
||||
const checksums = JSON.parse(await readFile(path.join(releaseAssetsDir, "checksums.json"), "utf8"));
|
||||
assert.equal(checksums.skill, skillName);
|
||||
assert.equal(checksums.version, expectedSimulated);
|
||||
assert.equal(checksums.tag, expectedTag);
|
||||
assert.equal(checksums.archive.filename, `${expectedTag}.zip`);
|
||||
|
||||
for (const artifact of [
|
||||
"skill-card.md",
|
||||
"permissions.json",
|
||||
"install.md",
|
||||
"skillspector-report.md",
|
||||
"checksums.sig",
|
||||
"signing-public.pem",
|
||||
]) {
|
||||
assert.ok(
|
||||
checksums.files[artifact] || artifact.endsWith(".sig") || artifact === "signing-public.pem",
|
||||
`expected ${artifact} to be represented in the release output`,
|
||||
);
|
||||
const file = await readFile(path.join(releaseAssetsDir, artifact));
|
||||
assert.ok(file.length > 0, `${artifact} should not be empty`);
|
||||
}
|
||||
|
||||
const archive = await readFile(path.join(releaseAssetsDir, `${expectedTag}.zip`));
|
||||
assert.ok(archive.length > 0, "release archive should not be empty");
|
||||
|
||||
const install = await readFile(path.join(releaseAssetsDir, "install.md"), "utf8");
|
||||
assert.match(
|
||||
install,
|
||||
new RegExp(
|
||||
`npx skills add prompt-security/clawsec#pull-request-head --skill ${skillName} --agent ${expectedAgent} --global --yes`,
|
||||
),
|
||||
);
|
||||
assert.match(install, new RegExp(`npx skills update ${skillName}`));
|
||||
}
|
||||
|
||||
try {
|
||||
await writeFile(
|
||||
fakeSkillspector,
|
||||
`#!/usr/bin/env node
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
const outputIndex = process.argv.indexOf("--output");
|
||||
if (outputIndex === -1 || !process.argv[outputIndex + 1]) {
|
||||
console.error("missing --output");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
writeFileSync(process.argv[outputIndex + 1], "# Fake SkillSpector Report\\n\\nNo live scan executed in unit test.\\n");
|
||||
`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
await chmod(fakeSkillspector, 0o700);
|
||||
|
||||
await runSimulation({
|
||||
skillDir: "skills/clawsec-suite",
|
||||
outputDir: path.join(tempRoot, "stable"),
|
||||
expectedOriginal: "0.1.10",
|
||||
expectedSimulated: "0.1.11",
|
||||
expectedAgent: "openclaw",
|
||||
});
|
||||
|
||||
await runSimulation({
|
||||
skillDir: "skills/hermes-traffic-guardian",
|
||||
outputDir: path.join(tempRoot, "beta"),
|
||||
expectedOriginal: "0.0.1-beta3",
|
||||
expectedSimulated: "0.0.1-beta4",
|
||||
expectedAgent: "hermes-agent",
|
||||
});
|
||||
|
||||
const alphaSkillDir = await prereleaseFixture("skills/picoclaw-self-pen-testing", "0.0.3-alpha1", "alpha-fixture");
|
||||
await runSimulation({
|
||||
skillDir: alphaSkillDir,
|
||||
outputDir: path.join(tempRoot, "alpha"),
|
||||
expectedOriginal: "0.0.3-alpha1",
|
||||
expectedSimulated: "0.0.3-alpha2",
|
||||
expectedAgent: "openclaw",
|
||||
});
|
||||
|
||||
const rcSkillDir = await prereleaseFixture("skills/picoclaw-security-guardian", "0.0.4-rc1", "rc-fixture");
|
||||
await runSimulation({
|
||||
skillDir: rcSkillDir,
|
||||
outputDir: path.join(tempRoot, "rc"),
|
||||
expectedOriginal: "0.0.4-rc1",
|
||||
expectedSimulated: "0.0.4-rc2",
|
||||
expectedAgent: "openclaw",
|
||||
});
|
||||
|
||||
const previewSkillDir = await prereleaseFixture("skills/openclaw-traffic-guardian", "0.0.1-preview", "preview-fixture");
|
||||
await runSimulation({
|
||||
skillDir: previewSkillDir,
|
||||
outputDir: path.join(tempRoot, "preview"),
|
||||
expectedOriginal: "0.0.1-preview",
|
||||
expectedSimulated: "0.0.1-preview1",
|
||||
expectedAgent: "openclaw",
|
||||
});
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const outputDir = await mkdtemp(path.join(tmpdir(), "clawsec-trust-packet-"));
|
||||
|
||||
function runTrustPacket(skillDir, targetDir, tag) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/ci/generate_skill_release_trust_packet.mjs",
|
||||
skillDir,
|
||||
targetDir,
|
||||
"--repository",
|
||||
"prompt-security/clawsec",
|
||||
"--tag",
|
||||
tag,
|
||||
"--source-ref",
|
||||
"main",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = runTrustPacket("skills/clawsec-suite", outputDir, "clawsec-suite-v0.1.10");
|
||||
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`trust packet generator failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
|
||||
const skillCard = await readFile(path.join(outputDir, "skill-card.md"), "utf8");
|
||||
const permissions = JSON.parse(await readFile(path.join(outputDir, "permissions.json"), "utf8"));
|
||||
const install = await readFile(path.join(outputDir, "install.md"), "utf8");
|
||||
|
||||
assert.match(skillCard, /^# Skill Card/m);
|
||||
assert.match(skillCard, /## License\/Terms of Use/);
|
||||
assert.match(skillCard, /AGPL-3\.0-or-later/);
|
||||
assert.match(skillCard, /skillspector-report\.md/);
|
||||
assert.match(skillCard, /clawsec-suite-v0\.1\.10/);
|
||||
|
||||
assert.equal(permissions.skill, "clawsec-suite");
|
||||
assert.equal(permissions.version, "0.1.10");
|
||||
assert.equal(permissions.platform, "openclaw");
|
||||
assert.deepEqual(
|
||||
permissions.required_binaries,
|
||||
["node", "npx", "openclaw", "curl", "jq", "shasum", "openssl", "unzip"],
|
||||
);
|
||||
assert.match(permissions.network_egress, /signed advisory feed/);
|
||||
assert.match(permissions.persistence, /OpenClaw advisory hook/);
|
||||
assert.ok(Array.isArray(permissions.operator_review));
|
||||
assert.ok(permissions.operator_review.length > 0);
|
||||
|
||||
assert.match(install, /npx skills add prompt-security\/clawsec --skill clawsec-suite --agent openclaw --global --yes/);
|
||||
assert.match(install, /npx skills update clawsec-suite/);
|
||||
|
||||
const hermesOutputDir = path.join(outputDir, "hermes");
|
||||
const hermesResult = runTrustPacket(
|
||||
"skills/hermes-attestation-guardian",
|
||||
hermesOutputDir,
|
||||
"hermes-attestation-guardian-v0.1.4",
|
||||
);
|
||||
assert.equal(
|
||||
hermesResult.status,
|
||||
0,
|
||||
`Hermes trust packet generator failed\nstdout:\n${hermesResult.stdout}\nstderr:\n${hermesResult.stderr}`,
|
||||
);
|
||||
const hermesInstall = await readFile(path.join(hermesOutputDir, "install.md"), "utf8");
|
||||
assert.match(
|
||||
hermesInstall,
|
||||
/npx skills add prompt-security\/clawsec --skill hermes-attestation-guardian --agent hermes-agent --global --yes/,
|
||||
);
|
||||
} finally {
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user