diff --git a/contracts/capability-registry.json b/contracts/capability-registry.json new file mode 100644 index 00000000..a4a8f3e5 --- /dev/null +++ b/contracts/capability-registry.json @@ -0,0 +1,120 @@ +{ + "contract": "clawsec.capability-registry/v1", + "contract_version": "1", + "capabilities": [ + { + "name": "core.doctor", + "role": "core", + "required_for_stable": true + }, + { + "name": "core.evaluate-advisories", + "role": "core", + "required_for_stable": true + }, + { + "name": "core.install-release", + "role": "core", + "required_for_stable": true + }, + { + "name": "core.inventory", + "role": "core", + "required_for_stable": true + }, + { + "name": "core.plan-release", + "role": "core", + "required_for_stable": true + }, + { + "name": "core.remove-release", + "role": "core", + "required_for_stable": true + }, + { + "name": "core.update-release", + "role": "core", + "required_for_stable": true + }, + { + "name": "core.verify-feed", + "role": "core", + "required_for_stable": true + }, + { + "name": "core.verify-receipt", + "role": "core", + "required_for_stable": true + }, + { + "name": "core.verify-release", + "role": "core", + "required_for_stable": true + }, + { + "name": "guardian.status", + "role": "guardian", + "family": "drift", + "required_for_stable": true + }, + { + "name": "posture.diff", + "role": "guardian", + "family": "drift", + "required_for_stable": true + }, + { + "name": "posture.snapshot", + "role": "guardian", + "family": "drift", + "required_for_stable": true + }, + { + "name": "posture.verify", + "role": "guardian", + "family": "drift", + "required_for_stable": true + }, + { + "name": "suite.catalog", + "role": "suite", + "required_for_stable": true + }, + { + "name": "suite.disable", + "role": "suite", + "required_for_stable": true + }, + { + "name": "suite.doctor", + "role": "suite", + "required_for_stable": true + }, + { + "name": "suite.enable", + "role": "suite", + "required_for_stable": true + }, + { + "name": "suite.install", + "role": "suite", + "required_for_stable": true + }, + { + "name": "suite.recommend", + "role": "suite", + "required_for_stable": true + }, + { + "name": "suite.recurring-advisory-verification", + "role": "suite", + "required_for_stable": false + }, + { + "name": "suite.status", + "role": "suite", + "required_for_stable": true + } + ] +} diff --git a/contracts/fixtures/component-metadata-v1/valid/core-nanoclaw-v2.json b/contracts/fixtures/component-metadata-v1/valid/core-nanoclaw-v2.json new file mode 100644 index 00000000..b80f4889 --- /dev/null +++ b/contracts/fixtures/component-metadata-v1/valid/core-nanoclaw-v2.json @@ -0,0 +1,60 @@ +{ + "name": "clawsec-core-nanoclaw", + "version": "0.1.0-rc.1", + "description": "Contract fixture only; this is not a NanoClaw v2 support claim.", + "author": "prompt-security", + "license": "AGPL-3.0-or-later", + "platform": "nanoclaw", + "sbom": { + "files": [ + { + "path": "REMOVE.md", + "required": true + }, + { + "path": "SKILL.md", + "required": true + } + ] + }, + "nanoclaw": {}, + "clawsec": { + "contract_version": "1", + "role": "core", + "maturity": "experimental", + "supported_harness": { + "name": "nanoclaw", + "minimum_version": "2.1.17", + "maximum_version_exclusive": "2.2.0" + }, + "provides": [ + "core.doctor", + "core.evaluate-advisories", + "core.install-release", + "core.inventory", + "core.plan-release", + "core.remove-release", + "core.update-release", + "core.verify-feed", + "core.verify-receipt", + "core.verify-release" + ], + "management_protocol_provides": [ + "clawsec-core/v1" + ], + "management_protocol_requires": [], + "install_requires": [], + "runtime_requires": [], + "legacy_names": [ + "clawsec-nanoclaw" + ], + "native": { + "category": "utility", + "install_location": ".claude/skills/clawsec-core-nanoclaw", + "apply_leaves_state": true, + "remove_document_required": true, + "installation_owner": "nanoclaw-host", + "removal_owner": "nanoclaw-host" + } + } +} diff --git a/contracts/fixtures/component-metadata-v1/valid/core-openclaw.json b/contracts/fixtures/component-metadata-v1/valid/core-openclaw.json new file mode 100644 index 00000000..6b20cc40 --- /dev/null +++ b/contracts/fixtures/component-metadata-v1/valid/core-openclaw.json @@ -0,0 +1,48 @@ +{ + "name": "clawsec-core-openclaw", + "version": "0.1.0-rc.1", + "description": "Contract fixture only; this is not an OpenClaw support claim.", + "author": "prompt-security", + "license": "AGPL-3.0-or-later", + "platform": "openclaw", + "sbom": { + "files": [ + { + "path": "SKILL.md", + "required": true + } + ] + }, + "openclaw": {}, + "clawsec": { + "contract_version": "1", + "role": "core", + "maturity": "experimental", + "supported_harness": { + "name": "openclaw", + "minimum_version": "0.0.0", + "maximum_version_exclusive": "0.0.1" + }, + "provides": [ + "core.doctor", + "core.evaluate-advisories", + "core.install-release", + "core.inventory", + "core.plan-release", + "core.remove-release", + "core.update-release", + "core.verify-feed", + "core.verify-receipt", + "core.verify-release" + ], + "management_protocol_provides": [ + "clawsec-core/v1" + ], + "management_protocol_requires": [], + "install_requires": [], + "runtime_requires": [], + "legacy_names": [ + "clawsec-suite" + ] + } +} diff --git a/contracts/fixtures/component-metadata-v1/valid/drift-nanoclaw-v2.json b/contracts/fixtures/component-metadata-v1/valid/drift-nanoclaw-v2.json new file mode 100644 index 00000000..cf521c2b --- /dev/null +++ b/contracts/fixtures/component-metadata-v1/valid/drift-nanoclaw-v2.json @@ -0,0 +1,56 @@ +{ + "name": "clawsec-drift-guardian-nanoclaw", + "version": "0.1.0-rc.1", + "description": "Contract fixture only; this is not a NanoClaw v2 support claim.", + "author": "prompt-security", + "license": "AGPL-3.0-or-later", + "platform": "nanoclaw", + "sbom": { + "files": [ + { + "path": "REMOVE.md", + "required": true + }, + { + "path": "SKILL.md", + "required": true + } + ] + }, + "nanoclaw": {}, + "clawsec": { + "contract_version": "1", + "role": "guardian", + "family": "drift", + "maturity": "experimental", + "supported_harness": { + "name": "nanoclaw", + "minimum_version": "2.1.17", + "maximum_version_exclusive": "2.2.0" + }, + "provides": [ + "guardian.status", + "posture.diff", + "posture.snapshot", + "posture.verify" + ], + "management_protocol_provides": [], + "management_protocol_requires": [ + "clawsec-core/v1" + ], + "install_requires": [], + "runtime_requires": [], + "legacy_names": [ + "clawsec-nanoclaw" + ], + "default_mode": "read-only", + "native": { + "category": "utility", + "install_location": ".claude/skills/clawsec-drift-guardian-nanoclaw", + "apply_leaves_state": true, + "remove_document_required": true, + "installation_owner": "nanoclaw-host", + "removal_owner": "nanoclaw-host" + } + } +} diff --git a/contracts/fixtures/component-metadata-v1/valid/drift-picoclaw.json b/contracts/fixtures/component-metadata-v1/valid/drift-picoclaw.json new file mode 100644 index 00000000..8c0daebd --- /dev/null +++ b/contracts/fixtures/component-metadata-v1/valid/drift-picoclaw.json @@ -0,0 +1,44 @@ +{ + "name": "clawsec-drift-guardian-picoclaw", + "version": "0.1.0-rc.1", + "description": "Contract fixture only; this is not a PicoClaw support claim.", + "author": "prompt-security", + "license": "AGPL-3.0-or-later", + "platform": "picoclaw", + "sbom": { + "files": [ + { + "path": "SKILL.md", + "required": true + } + ] + }, + "picoclaw": {}, + "clawsec": { + "contract_version": "1", + "role": "guardian", + "family": "drift", + "maturity": "experimental", + "supported_harness": { + "name": "picoclaw", + "minimum_version": "0.0.0", + "maximum_version_exclusive": "0.0.1" + }, + "provides": [ + "guardian.status", + "posture.diff", + "posture.snapshot", + "posture.verify" + ], + "management_protocol_provides": [], + "management_protocol_requires": [ + "clawsec-core/v1" + ], + "install_requires": [], + "runtime_requires": [], + "legacy_names": [ + "picoclaw-security-guardian" + ], + "default_mode": "read-only" + } +} diff --git a/contracts/fixtures/component-metadata-v1/valid/suite-hermes.json b/contracts/fixtures/component-metadata-v1/valid/suite-hermes.json new file mode 100644 index 00000000..93a6350a --- /dev/null +++ b/contracts/fixtures/component-metadata-v1/valid/suite-hermes.json @@ -0,0 +1,49 @@ +{ + "name": "clawsec-suite-hermes", + "version": "0.1.0-rc.1", + "description": "Contract fixture only; this is not a Hermes support claim.", + "author": "prompt-security", + "license": "AGPL-3.0-or-later", + "platform": "hermes", + "sbom": { + "files": [ + { + "path": "SKILL.md", + "required": true + } + ] + }, + "hermes": {}, + "clawsec": { + "contract_version": "1", + "role": "suite", + "maturity": "experimental", + "supported_harness": { + "name": "hermes", + "minimum_version": "0.0.0", + "maximum_version_exclusive": "0.0.1" + }, + "provides": [ + "suite.catalog", + "suite.disable", + "suite.doctor", + "suite.enable", + "suite.install", + "suite.recommend", + "suite.status" + ], + "management_protocol_provides": [], + "management_protocol_requires": [ + "clawsec-core/v1" + ], + "install_requires": [], + "runtime_requires": [ + { + "name": "clawsec-core-hermes", + "minimum_version": "0.1.0-rc.1", + "maximum_version_exclusive": "1.0.0" + } + ], + "legacy_names": [] + } +} diff --git a/contracts/fixtures/component-metadata-v1/valid/suite-nanoclaw-v2.json b/contracts/fixtures/component-metadata-v1/valid/suite-nanoclaw-v2.json new file mode 100644 index 00000000..6e8d4a49 --- /dev/null +++ b/contracts/fixtures/component-metadata-v1/valid/suite-nanoclaw-v2.json @@ -0,0 +1,59 @@ +{ + "name": "clawsec-suite-nanoclaw", + "version": "0.1.0-rc.1", + "description": "Contract fixture only; this is not a NanoClaw v2 support claim.", + "author": "prompt-security", + "license": "AGPL-3.0-or-later", + "platform": "nanoclaw", + "sbom": { + "files": [ + { + "path": "SKILL.md", + "required": true + } + ] + }, + "nanoclaw": {}, + "clawsec": { + "contract_version": "1", + "role": "suite", + "maturity": "experimental", + "supported_harness": { + "name": "nanoclaw", + "minimum_version": "2.1.17", + "maximum_version_exclusive": "2.2.0" + }, + "provides": [ + "suite.catalog", + "suite.disable", + "suite.doctor", + "suite.enable", + "suite.install", + "suite.recommend", + "suite.status" + ], + "management_protocol_provides": [], + "management_protocol_requires": [ + "clawsec-core/v1" + ], + "install_requires": [], + "runtime_requires": [ + { + "name": "clawsec-core-nanoclaw", + "minimum_version": "0.1.0-rc.1", + "maximum_version_exclusive": "1.0.0" + } + ], + "legacy_names": [ + "clawsec-nanoclaw" + ], + "native": { + "category": "operational", + "install_location": ".claude/skills/clawsec-suite-nanoclaw", + "apply_leaves_state": false, + "remove_document_required": false, + "installation_owner": "nanoclaw-host", + "removal_owner": "nanoclaw-host" + } + } +} diff --git a/contracts/schemas/component/component-ref-v1.schema.json b/contracts/schemas/component/component-ref-v1.schema.json new file mode 100644 index 00000000..ba0fbc49 --- /dev/null +++ b/contracts/schemas/component/component-ref-v1.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://clawsec.prompt.security/contracts/schemas/component/component-ref-v1.schema.json", + "title": "ClawSec component reference v1", + "description": "Identity and exact-metadata digest syntax used by later contracts. Schema validation does not verify the digest against bytes or establish authorization.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "name", + "version", + "harness", + "role", + "metadata_digest" + ], + "properties": { + "schema": { + "const": "clawsec.component-ref/v1" + }, + "name": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "version": { + "type": "string", + "minLength": 1 + }, + "harness": { + "enum": [ + "openclaw", + "hermes", + "nanoclaw", + "picoclaw" + ] + }, + "role": { + "enum": [ + "core", + "suite", + "guardian" + ] + }, + "family": { + "const": "drift" + }, + "metadata_digest": { + "description": "SHA-256 digest of the exact skill.json bytes shipped in the artifact, prefixed with sha256:.", + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + }, + "allOf": [ + { + "if": { + "properties": { + "role": { + "const": "guardian" + } + }, + "required": [ + "role" + ] + }, + "then": { + "required": [ + "family" + ] + }, + "else": { + "not": { + "required": [ + "family" + ] + } + } + } + ] +} diff --git a/contracts/schemas/component/metadata-v1.schema.json b/contracts/schemas/component/metadata-v1.schema.json new file mode 100644 index 00000000..ff7362e5 --- /dev/null +++ b/contracts/schemas/component/metadata-v1.schema.json @@ -0,0 +1,307 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://clawsec.prompt.security/contracts/schemas/component/metadata-v1.schema.json", + "title": "ClawSec package metadata v1", + "description": "Normalized identity, role, harness support, capability, dependency, maturity, and NanoClaw v2 native metadata for one canonical ClawSec package.", + "type": "object", + "required": [ + "name", + "version", + "platform", + "sbom", + "clawsec" + ], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "version": { + "type": "string", + "minLength": 1 + }, + "platform": { + "enum": [ + "openclaw", + "hermes", + "nanoclaw", + "picoclaw" + ] + }, + "platforms": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "openclaw", + "hermes", + "nanoclaw", + "picoclaw" + ] + } + }, + "openclaw": { + "type": "object" + }, + "hermes": { + "type": "object" + }, + "nanoclaw": { + "description": "Contract v1 keeps the canonical NanoClaw platform block empty. V2 host placement and ownership are declared only in clawsec.native.", + "type": "object", + "additionalProperties": false + }, + "picoclaw": { + "type": "object" + }, + "sbom": { + "type": "object", + "required": [ + "files" + ], + "properties": { + "files": { + "type": "array", + "items": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + } + } + } + } + } + }, + "clawsec": { + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "role", + "maturity", + "supported_harness", + "provides", + "management_protocol_provides", + "management_protocol_requires", + "install_requires", + "runtime_requires", + "legacy_names" + ], + "properties": { + "contract_version": { + "const": "1" + }, + "role": { + "enum": [ + "core", + "suite", + "guardian" + ] + }, + "family": { + "const": "drift" + }, + "maturity": { + "description": "Capability maturity vocabulary only. stable requires separate conformance and catalog authorization; deprecated requires a separate compatibility successor mapping.", + "enum": [ + "draft", + "experimental", + "stable", + "deprecated", + "internal" + ] + }, + "supported_harness": { + "$ref": "#/definitions/supportedHarness" + }, + "provides": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$" + } + }, + "management_protocol_provides": { + "type": "array", + "uniqueItems": true, + "items": { + "const": "clawsec-core/v1" + } + }, + "management_protocol_requires": { + "type": "array", + "uniqueItems": true, + "items": { + "const": "clawsec-core/v1" + } + }, + "install_requires": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/packageDependency" + } + }, + "runtime_requires": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/packageDependency" + } + }, + "legacy_names": { + "description": "Non-authoritative migration inputs. A legacy monolith may appear on multiple canonical successors; this field never creates an alias or installation redirect.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + } + }, + "default_mode": { + "const": "read-only" + }, + "native": { + "$ref": "#/definitions/nanoclawNative" + } + }, + "allOf": [ + { + "if": { + "properties": { + "role": { + "const": "guardian" + } + }, + "required": [ + "role" + ] + }, + "then": { + "required": [ + "family", + "default_mode" + ] + }, + "else": { + "not": { + "anyOf": [ + { + "required": [ + "family" + ] + }, + { + "required": [ + "default_mode" + ] + } + ] + } + } + } + ] + } + }, + "additionalProperties": true, + "definitions": { + "supportedHarness": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "minimum_version", + "maximum_version_exclusive" + ], + "properties": { + "name": { + "enum": [ + "openclaw", + "hermes", + "nanoclaw", + "picoclaw" + ] + }, + "minimum_version": { + "description": "Inclusive final SemVer harness bound. Prerelease and build metadata are forbidden.", + "type": "string", + "minLength": 1 + }, + "maximum_version_exclusive": { + "description": "Exclusive final SemVer harness bound. Prerelease and build metadata are forbidden.", + "type": "string", + "minLength": 1 + } + } + }, + "packageDependency": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "minimum_version", + "maximum_version_exclusive" + ], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "minimum_version": { + "description": "Inclusive compatibility bound. Must be final SemVer or a canonical beta.N/rc.N prerelease, without build metadata. Exact candidate selection and lifecycle authorization are separate contracts.", + "type": "string", + "minLength": 1 + }, + "maximum_version_exclusive": { + "description": "Exclusive final SemVer compatibility bound. Prerelease and build metadata are forbidden.", + "type": "string", + "minLength": 1 + } + } + }, + "nanoclawNative": { + "type": "object", + "additionalProperties": false, + "required": [ + "category", + "install_location", + "apply_leaves_state", + "remove_document_required", + "installation_owner", + "removal_owner" + ], + "properties": { + "category": { + "enum": [ + "utility", + "operational" + ] + }, + "install_location": { + "type": "string", + "pattern": "^\\.claude/skills/[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "apply_leaves_state": { + "type": "boolean" + }, + "remove_document_required": { + "type": "boolean" + }, + "installation_owner": { + "const": "nanoclaw-host" + }, + "removal_owner": { + "const": "nanoclaw-host" + } + } + } + } +} diff --git a/package-lock.json b/package-lock.json index 1e7b45d0..63e6427a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@typescript-eslint/eslint-plugin": "^8.55.0", "@typescript-eslint/parser": "^8.58.1", "@vitejs/plugin-react": "^6.0.2", + "ajv": "6.14.0", "eslint": "^9.39.4", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/package.json b/package.json index d9bc3241..678ffe0f 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "gen:wiki-llms": "node scripts/generate-wiki-llms.mjs", + "test:metadata-contracts": "node scripts/test-skill-component-metadata.mjs", "populate-local-wiki": "./scripts/populate-local-wiki.sh", "predev": "npm run gen:wiki-llms", "dev": "vite", @@ -32,6 +33,7 @@ "@typescript-eslint/eslint-plugin": "^8.55.0", "@typescript-eslint/parser": "^8.58.1", "@vitejs/plugin-react": "^6.0.2", + "ajv": "6.14.0", "eslint": "^9.39.4", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/scripts/ci/validate_clawsec_metadata.mjs b/scripts/ci/validate_clawsec_metadata.mjs new file mode 100644 index 00000000..1c91ec27 --- /dev/null +++ b/scripts/ci/validate_clawsec_metadata.mjs @@ -0,0 +1,725 @@ +#!/usr/bin/env node + +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import Ajv from "ajv"; + +import { + classifyLifecycleVersion, + compareSemverV2, + parseSemverV2, +} from "./lifecycle_semver.mjs"; + +const repositoryRoot = fileURLToPath(new URL("../../", import.meta.url)); +const metadataSchemaPath = path.join( + repositoryRoot, + "contracts/schemas/component/metadata-v1.schema.json", +); +const componentRefSchemaPath = path.join( + repositoryRoot, + "contracts/schemas/component/component-ref-v1.schema.json", +); +const capabilityRegistryPath = path.join( + repositoryRoot, + "contracts/capability-registry.json", +); + +const [metadataSchema, componentRefSchema, capabilityRegistry] = await Promise.all([ + readJson(metadataSchemaPath), + readJson(componentRefSchemaPath), + readJson(capabilityRegistryPath), +]); + +const ajv = new Ajv({ + allErrors: true, + jsonPointers: true, + schemaId: "auto", +}); +const validateMetadataSchema = ajv.compile(metadataSchema); +const validateComponentRefSchema = ajv.compile(componentRefSchema); + +const HARNESSES = Object.freeze(["openclaw", "hermes", "nanoclaw", "picoclaw"]); +const CORE_PROTOCOL = "clawsec-core/v1"; +const CANONICAL_NAME_PATTERN = /^clawsec-(core|suite|drift-guardian)-(openclaw|hermes|nanoclaw|picoclaw)$/; + +const capabilityByName = new Map(); +for (const capability of capabilityRegistry.capabilities ?? []) { + if (capabilityByName.has(capability.name)) { + throw new Error(`Duplicate capability registry entry: ${capability.name}`); + } + capabilityByName.set(capability.name, capability); +} + +export function isCanonicalClawsecName(name) { + return typeof name === "string" && CANONICAL_NAME_PATTERN.test(name); +} + +export function validateClawsecMetadata({ + skill, + skillDir = null, + requireClawsec = false, +} = {}) { + const errors = []; + const warnings = []; + + if (!isPlainObject(skill)) { + addError(errors, "SKILL_METADATA_INVALID", "", "skill.json must contain a JSON object"); + return finish("invalid", errors, warnings); + } + + const hasClawsec = Object.hasOwn(skill, "clawsec"); + if (!hasClawsec) { + if (requireClawsec || isCanonicalClawsecName(skill.name)) { + addError( + errors, + "MISSING_CLAWSEC_METADATA", + "/clawsec", + "canonical ClawSec packages require normalized clawsec metadata", + ); + return finish("invalid", errors, warnings); + } + + warnings.push({ + code: "LEGACY_INPUT", + path: "/clawsec", + message: "no normalized clawsec metadata; usable only as migration material", + }); + return finish("legacy", errors, warnings); + } + + if (!validateMetadataSchema(skill)) { + for (const schemaError of validateMetadataSchema.errors ?? []) { + addError( + errors, + "METADATA_SCHEMA_INVALID", + schemaError.dataPath || "/", + `${schemaError.keyword}: ${schemaError.message}`, + ); + } + return finish("invalid", errors, warnings); + } + + validatePackageVersion(skill.version, errors); + + const metadata = skill.clawsec; + const harness = metadata.supported_harness.name; + validateBoundedVersionRange(metadata.supported_harness, "/clawsec/supported_harness", errors); + + if (skill.platform !== harness) { + addError( + errors, + "PLATFORM_HARNESS_MISMATCH", + "/platform", + `platform ${skill.platform} does not match supported harness ${harness}`, + ); + } + if ( + skill.platforms !== undefined + && ( + skill.platforms.length !== 1 + || skill.platforms[0] !== harness + ) + ) { + addError( + errors, + "PLATFORM_DECLARATION_AMBIGUOUS", + "/platforms", + `platforms must be omitted or contain only ${harness}`, + ); + } + + const declaredHarnessBlocks = HARNESSES.filter((name) => Object.hasOwn(skill, name)); + if (!Object.hasOwn(skill, harness) || !isPlainObject(skill[harness])) { + addError( + errors, + "HARNESS_METADATA_BLOCK_MISSING", + `/${harness}`, + `one ${harness} metadata object is required`, + ); + } + if (declaredHarnessBlocks.length !== 1 || declaredHarnessBlocks[0] !== harness) { + addError( + errors, + "HARNESS_METADATA_BLOCK_AMBIGUOUS", + "/", + `expected only the ${harness} harness metadata block`, + ); + } + + const expectedName = expectedPackageName(metadata.role, metadata.family, harness); + if (skill.name !== expectedName) { + addError( + errors, + "CANONICAL_NAME_MISMATCH", + "/name", + `expected canonical name ${expectedName}, got ${skill.name}`, + ); + } + + validateSortedStrings(metadata.provides, "/clawsec/provides", errors); + validateSortedStrings( + metadata.management_protocol_provides, + "/clawsec/management_protocol_provides", + errors, + ); + validateSortedStrings( + metadata.management_protocol_requires, + "/clawsec/management_protocol_requires", + errors, + ); + validateSortedStrings(metadata.legacy_names, "/clawsec/legacy_names", errors); + validateDependencies(metadata.install_requires, "/clawsec/install_requires", skill, errors); + validateDependencies(metadata.runtime_requires, "/clawsec/runtime_requires", skill, errors); + validateCapabilities(metadata, errors); + validateRoleGraph(metadata, harness, errors); + if (metadata.maturity === "stable") { + warnings.push({ + code: "STABLE_MATURITY_REQUIRES_CONFORMANCE", + path: "/clawsec/maturity", + message: "metadata validity does not prove conformance, qualification, or catalog authorization", + }); + } else if (metadata.maturity === "deprecated") { + warnings.push({ + code: "DEPRECATED_MATURITY_REQUIRES_SUCCESSOR", + path: "/clawsec/maturity", + message: "a later compatibility/catalog contract must bind the canonical successor", + }); + } + + if (metadata.legacy_names.includes(skill.name)) { + addError( + errors, + "LEGACY_NAME_SELF_REFERENCE", + "/clawsec/legacy_names", + "legacy_names cannot include the canonical package name", + ); + } + + if (harness === "nanoclaw") { + validateNanoclawV2(skill, skillDir, errors); + } else if (metadata.native !== undefined) { + addError( + errors, + "NATIVE_METADATA_FORBIDDEN", + "/clawsec/native", + "native metadata is reserved for NanoClaw v2 packages in contract v1", + ); + } + + return finish(errors.length === 0 ? "canonical" : "invalid", errors, warnings); +} + +export function validateComponentRef(componentRef) { + const errors = []; + const warnings = []; + + if (!validateComponentRefSchema(componentRef)) { + for (const schemaError of validateComponentRefSchema.errors ?? []) { + addError( + errors, + "COMPONENT_REF_SCHEMA_INVALID", + schemaError.dataPath || "/", + `${schemaError.keyword}: ${schemaError.message}`, + ); + } + return finish("invalid", errors, warnings); + } + + validatePackageVersion(componentRef.version, errors, "/version"); + const expectedName = expectedPackageName( + componentRef.role, + componentRef.family, + componentRef.harness, + ); + if (componentRef.name !== expectedName) { + addError( + errors, + "COMPONENT_REF_IDENTITY_MISMATCH", + "/name", + `expected canonical name ${expectedName}, got ${componentRef.name}`, + ); + } + + return finish(errors.length === 0 ? "component_ref" : "invalid", errors, warnings); +} + +function validatePackageVersion(version, errors, errorPath = "/version") { + try { + parseSemverV2(version); + } catch (error) { + addError(errors, "PACKAGE_VERSION_INVALID", errorPath, error.message); + } +} + +function validateBoundedVersionRange( + range, + errorPath, + errors, + { allowCandidateMinimum = false } = {}, +) { + let minimum; + let maximum; + try { + minimum = parseSemverV2(range.minimum_version); + maximum = parseSemverV2(range.maximum_version_exclusive); + } catch (error) { + addError(errors, "VERSION_RANGE_INVALID", errorPath, error.message); + return false; + } + + const minimumLifecycle = classifyLifecycleVersion(range.minimum_version); + const minimumIsAllowed = minimum.build.length === 0 + && ( + minimumLifecycle === "final" + || ( + allowCandidateMinimum + && (minimumLifecycle === "beta" || minimumLifecycle === "rc") + ) + ); + const maximumIsFinal = maximum.prerelease.length === 0 && maximum.build.length === 0; + + if ( + !minimumIsAllowed + || !maximumIsFinal + ) { + const message = allowCandidateMinimum + ? "dependency minimum_version must be final SemVer or canonical beta.N/rc.N without build metadata; maximum_version_exclusive must be final SemVer without build metadata" + : "version-range bounds must be final SemVer versions without build metadata"; + addError( + errors, + "VERSION_RANGE_INVALID", + errorPath, + message, + ); + return false; + } + + if (compareSemverV2(range.minimum_version, range.maximum_version_exclusive) >= 0) { + addError( + errors, + "VERSION_RANGE_INVALID", + errorPath, + "minimum_version must be lower than maximum_version_exclusive", + ); + return false; + } + return true; +} + +function validateCapabilities(metadata, errors) { + for (const capabilityName of metadata.provides) { + const capability = capabilityByName.get(capabilityName); + if (!capability) { + addError( + errors, + "UNKNOWN_CAPABILITY", + "/clawsec/provides", + `unknown capability ${capabilityName}`, + ); + continue; + } + if ( + capability.role !== metadata.role + || (capability.family !== undefined && capability.family !== metadata.family) + ) { + addError( + errors, + "CAPABILITY_ROLE_MISMATCH", + "/clawsec/provides", + `${capabilityName} does not belong to ${metadata.role}${metadata.family ? `/${metadata.family}` : ""}`, + ); + } + } + + if (metadata.maturity !== "stable") return; + + for (const capability of capabilityByName.values()) { + const applies = capability.role === metadata.role + && (capability.family === undefined || capability.family === metadata.family); + if ( + applies + && capability.required_for_stable + && !metadata.provides.includes(capability.name) + ) { + addError( + errors, + "STABLE_CAPABILITY_MISSING", + "/clawsec/provides", + `stable ${metadata.role} metadata must declare ${capability.name}`, + ); + } + } +} + +function validateRoleGraph(metadata, harness, errors) { + const expectedProvides = metadata.role === "core" ? [CORE_PROTOCOL] : []; + const expectedRequires = metadata.role === "core" ? [] : [CORE_PROTOCOL]; + + if (!arraysEqual(metadata.management_protocol_provides, expectedProvides)) { + addError( + errors, + "MANAGEMENT_PROTOCOL_INVALID", + "/clawsec/management_protocol_provides", + `${metadata.role} must declare ${JSON.stringify(expectedProvides)}`, + ); + } + if (!arraysEqual(metadata.management_protocol_requires, expectedRequires)) { + addError( + errors, + "MANAGEMENT_PROTOCOL_INVALID", + "/clawsec/management_protocol_requires", + `${metadata.role} must declare ${JSON.stringify(expectedRequires)}`, + ); + } + + const allDependencies = [...metadata.install_requires, ...metadata.runtime_requires]; + const canonicalDependencies = allDependencies + .map((dependency) => ({ + dependency, + identity: parseCanonicalIdentity(dependency.name), + })) + .filter(({ identity }) => identity !== null); + + for (const { dependency, identity } of canonicalDependencies) { + if (identity.harness !== harness) { + addError( + errors, + "CROSS_HARNESS_DEPENDENCY", + "/clawsec", + `${dependency.name} targets ${identity.harness}, not ${harness}`, + ); + } + } + + if ( + metadata.role === "core" + && (metadata.install_requires.length > 0 || metadata.runtime_requires.length > 0) + ) { + addError( + errors, + "CORE_PACKAGE_DEPENDENCY_FORBIDDEN", + "/clawsec", + "contract v1 cores must keep install_requires and runtime_requires empty", + ); + } + + if (metadata.role === "suite") { + const expectedCore = `clawsec-core-${harness}`; + if ( + metadata.install_requires.length !== 0 + || metadata.runtime_requires.length !== 1 + || metadata.runtime_requires[0].name !== expectedCore + ) { + addError( + errors, + "SUITE_CORE_RUNTIME_REQUIRED", + "/clawsec/runtime_requires", + `suite must have no install dependencies and exactly one runtime dependency on ${expectedCore}`, + ); + } + } + + if (metadata.role === "guardian") { + if (metadata.install_requires.length > 0 || metadata.runtime_requires.length > 0) { + addError( + errors, + "GUARDIAN_PACKAGE_DEPENDENCY_FORBIDDEN", + "/clawsec", + "contract v1 guardians use core management compatibility without package dependencies", + ); + } + } +} + +function validateDependencies(dependencies, errorPath, skill, errors) { + const sorted = [...dependencies].sort(compareDependencies); + if (!arraysEqual(dependencies, sorted)) { + addError( + errors, + "DEPENDENCIES_NOT_SORTED", + errorPath, + "dependencies must be sorted by name and version bounds", + ); + } + + const seenNames = new Set(); + for (const [index, dependency] of dependencies.entries()) { + const dependencyPath = `${errorPath}/${index}`; + validateBoundedVersionRange( + dependency, + dependencyPath, + errors, + { allowCandidateMinimum: true }, + ); + if (seenNames.has(dependency.name)) { + addError( + errors, + "DUPLICATE_DEPENDENCY", + dependencyPath, + `dependency ${dependency.name} appears more than once`, + ); + } + seenNames.add(dependency.name); + if (dependency.name === skill.name) { + addError( + errors, + "SELF_DEPENDENCY", + dependencyPath, + "a package cannot depend on itself", + ); + } + } +} + +function validateNanoclawV2(skill, skillDir, errors) { + const metadata = skill.clawsec; + const native = metadata.native; + if (!native) { + addError( + errors, + "NANOCLAW_NATIVE_REQUIRED", + "/clawsec/native", + "NanoClaw v2 packages require one host-owned native profile", + ); + return; + } + + const supported = metadata.supported_harness; + try { + if ( + compareSemverV2(supported.minimum_version, "2.0.0") < 0 + || compareSemverV2(supported.maximum_version_exclusive, "3.0.0") > 0 + ) { + addError( + errors, + "NANOCLAW_V2_RANGE_REQUIRED", + "/clawsec/supported_harness", + "NanoClaw contract v1 accepts only a bounded v2 support range", + ); + } + } catch { + // The common bounded-range validator already reports malformed SemVer. + } + + if ( + (metadata.role === "core" || metadata.role === "guardian") + && native.category !== "utility" + ) { + addError( + errors, + "NANOCLAW_NATIVE_CATEGORY_INVALID", + "/clawsec/native/category", + `${metadata.role} must be a NanoClaw host utility`, + ); + } + + if ( + metadata.role === "suite" + && native.category === "operational" + && (native.apply_leaves_state || native.remove_document_required) + ) { + addError( + errors, + "NANOCLAW_OPERATIONAL_STATE_FORBIDDEN", + "/clawsec/native", + "an operational NanoClaw suite must be instruction-only; use utility for code or state", + ); + } + + const expectedLocation = `.claude/skills/${skill.name}`; + if (native.install_location !== expectedLocation) { + addError( + errors, + "NANOCLAW_LOCATION_INVALID", + "/clawsec/native/install_location", + `expected ${expectedLocation}`, + ); + } + + if ( + (metadata.role === "core" || metadata.role === "guardian") + && !native.apply_leaves_state + ) { + addError( + errors, + "NANOCLAW_STATEFUL_PROFILE_REQUIRED", + "/clawsec/native/apply_leaves_state", + `${metadata.role} owns host state and must declare it`, + ); + } + + if (native.apply_leaves_state && !native.remove_document_required) { + addError( + errors, + "NANOCLAW_REMOVE_DOCUMENT_REQUIRED", + "/clawsec/native/remove_document_required", + "stateful NanoClaw apply requires REMOVE.md", + ); + } + + if (native.remove_document_required) { + const sbomFiles = Array.isArray(skill.sbom?.files) ? skill.sbom.files : []; + const sbomRemoveEntry = sbomFiles.find( + (entry) => isPlainObject(entry) && entry.path === "REMOVE.md", + ); + if (!sbomRemoveEntry || sbomRemoveEntry.required !== true) { + addError( + errors, + "NANOCLAW_REMOVE_DOCUMENT_NOT_IN_SBOM", + "/sbom/files", + "REMOVE.md must be a required SBOM file", + ); + } + if (skillDir !== null && !existsSync(path.join(skillDir, "REMOVE.md"))) { + addError( + errors, + "NANOCLAW_REMOVE_DOCUMENT_MISSING", + "/clawsec/native/remove_document_required", + "declared REMOVE.md does not exist", + ); + } + } + +} + +function validateSortedStrings(values, errorPath, errors) { + const sorted = [...values].sort(); + if (!arraysEqual(values, sorted)) { + addError(errors, "ARRAY_NOT_SORTED", errorPath, "set-like arrays must be lexically sorted"); + } +} + +function expectedPackageName(role, family, harness) { + if (role === "core") return `clawsec-core-${harness}`; + if (role === "suite") return `clawsec-suite-${harness}`; + return `clawsec-${family}-guardian-${harness}`; +} + +function parseCanonicalIdentity(name) { + const match = typeof name === "string" ? name.match(CANONICAL_NAME_PATTERN) : null; + if (!match) return null; + return { + role: match[1] === "drift-guardian" ? "guardian" : match[1], + family: match[1] === "drift-guardian" ? "drift" : undefined, + harness: match[2], + }; +} + +function compareDependencies(left, right) { + return left.name.localeCompare(right.name) + || left.minimum_version.localeCompare(right.minimum_version) + || left.maximum_version_exclusive.localeCompare(right.maximum_version_exclusive); +} + +function arraysEqual(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function addError(errors, code, errorPath, message) { + const candidate = { code, path: errorPath, message }; + if (!errors.some((entry) => arraysEqual(entry, candidate))) { + errors.push(candidate); + } +} + +function finish(classification, errors, warnings) { + return { + valid: errors.length === 0, + classification, + errors: [...errors].sort(compareDiagnostics), + warnings: [...warnings].sort(compareDiagnostics), + }; +} + +function compareDiagnostics(left, right) { + return left.code.localeCompare(right.code) + || left.path.localeCompare(right.path) + || left.message.localeCompare(right.message); +} + +async function readJson(filePath) { + return JSON.parse(await readFile(filePath, "utf8")); +} + +function parseArguments(argv) { + const options = { + skillDir: null, + requireClawsec: false, + json: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--skill-dir") { + options.skillDir = argv[index + 1] ?? null; + index += 1; + } else if (argument === "--require-clawsec") { + options.requireClawsec = true; + } else if (argument === "--json") { + options.json = true; + } else { + throw new Error(`Unknown argument: ${argument}`); + } + } + + if (!options.skillDir) { + throw new Error( + "Usage: node scripts/ci/validate_clawsec_metadata.mjs --skill-dir [--require-clawsec] [--json]", + ); + } + return options; +} + +async function main() { + let options; + let result; + try { + options = parseArguments(process.argv.slice(2)); + const skillDir = path.resolve(options.skillDir); + const skill = await readJson(path.join(skillDir, "skill.json")); + result = validateClawsecMetadata({ + skill, + skillDir, + requireClawsec: options.requireClawsec, + }); + } catch (error) { + result = finish( + "invalid", + [{ + code: "METADATA_VALIDATOR_ERROR", + path: "/", + message: error.message, + }], + [], + ); + } + + if (options?.json) { + process.stdout.write(`${JSON.stringify(result)}\n`); + } else if (result.valid && result.classification === "legacy") { + process.stdout.write(`LEGACY_INPUT: ${result.warnings[0].message}\n`); + } else if (result.valid) { + process.stdout.write("CONTRACT_VALID: normalized ClawSec metadata v1\n"); + for (const warning of result.warnings) { + process.stdout.write(`WARNING ${warning.code} ${warning.path}: ${warning.message}\n`); + } + } else { + for (const error of result.errors) { + process.stderr.write(`${error.code} ${error.path}: ${error.message}\n`); + } + } + process.exitCode = result.valid ? 0 : 1; +} + +const isDirectInvocation = process.argv[1] + && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; +if (isDirectInvocation) { + await main(); +} diff --git a/scripts/test-skill-component-metadata.mjs b/scripts/test-skill-component-metadata.mjs new file mode 100644 index 00000000..700ed3d5 --- /dev/null +++ b/scripts/test-skill-component-metadata.mjs @@ -0,0 +1,890 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + mkdtemp, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + validateClawsecMetadata, + validateComponentRef, +} from "./ci/validate_clawsec_metadata.mjs"; + +const repositoryRoot = fileURLToPath(new URL("../", import.meta.url)); +const fixtureRoot = path.join( + repositoryRoot, + "contracts/fixtures/component-metadata-v1/valid", +); +const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), "clawsec-component-metadata-")); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +async function readJson(filePath) { + return JSON.parse(await readFile(filePath, "utf8")); +} + +async function materializeSkill(skill, label, { omitFiles = [] } = {}) { + const skillDir = path.join(temporaryRoot, label); + await mkdir(skillDir, { recursive: true }); + await writeFile( + path.join(skillDir, "skill.json"), + `${JSON.stringify(skill, null, 2)}\n`, + "utf8", + ); + + const sbomFiles = Array.isArray(skill.sbom?.files) ? skill.sbom.files : []; + for (const entry of sbomFiles) { + if (omitFiles.includes(entry.path)) continue; + const filePath = path.join(skillDir, entry.path); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, `fixture ${entry.path}\n`, "utf8"); + } + return skillDir; +} + +function expectCode(result, expectedCode, label) { + assert.equal(result.valid, false, `${label} must fail`); + assert( + result.errors.some((entry) => entry.code === expectedCode), + `${label} must report ${expectedCode}; got ${JSON.stringify(result.errors)}`, + ); +} + +function runPythonValidator(skillDir, extraArgs = []) { + return spawnSync( + "python3", + [ + path.join(repositoryRoot, "utils/validate_skill.py"), + skillDir, + ...extraArgs, + ], + { + cwd: repositoryRoot, + encoding: "utf8", + env: { + ...process.env, + PYTHONDONTWRITEBYTECODE: "1", + }, + }, + ); +} + +function runPythonPackager(skillDir, outputDir) { + return spawnSync( + "python3", + [ + path.join(repositoryRoot, "utils/package_skill.py"), + skillDir, + outputDir, + ], + { + cwd: repositoryRoot, + encoding: "utf8", + env: { + ...process.env, + PYTHONDONTWRITEBYTECODE: "1", + }, + }, + ); +} + +try { + const fixtureFiles = (await readdir(fixtureRoot)) + .filter((fileName) => fileName.endsWith(".json")) + .sort(); + const fixtures = new Map(); + + for (const fileName of fixtureFiles) { + const skill = await readJson(path.join(fixtureRoot, fileName)); + fixtures.set(fileName, skill); + const skillDir = await materializeSkill(skill, `valid-${fileName.replace(".json", "")}`); + const result = validateClawsecMetadata({ + skill, + skillDir, + requireClawsec: true, + }); + assert.deepEqual(result.errors, [], `${fileName} semantic errors`); + assert.equal(result.classification, "canonical", `${fileName} classification`); + assert.equal(result.valid, true, `${fileName} validity`); + + const pythonResult = runPythonValidator(skillDir, ["--require-clawsec"]); + assert.equal( + pythonResult.status, + 0, + `${fileName} Python validator failed:\n${pythonResult.stdout}\n${pythonResult.stderr}`, + ); + assert.match(pythonResult.stdout, /CONTRACT_VALID/); + + const componentRef = { + schema: "clawsec.component-ref/v1", + name: skill.name, + version: skill.version, + harness: skill.clawsec.supported_harness.name, + role: skill.clawsec.role, + metadata_digest: `sha256:${"0".repeat(64)}`, + }; + if (skill.clawsec.family) componentRef.family = skill.clawsec.family; + assert.equal(validateComponentRef(componentRef).valid, true, `${fileName} component ref`); + } + + const coreOpenclaw = fixtures.get("core-openclaw.json"); + const suiteHermes = fixtures.get("suite-hermes.json"); + const driftPicoclaw = fixtures.get("drift-picoclaw.json"); + const coreNanoclaw = fixtures.get("core-nanoclaw-v2.json"); + const suiteNanoclaw = fixtures.get("suite-nanoclaw-v2.json"); + + const rcSuiteCoreCandidates = [ + { + suite: suiteHermes, + coreName: "clawsec-core-hermes", + coreVersion: "0.1.0-rc.1", + }, + { + suite: suiteNanoclaw, + coreName: coreNanoclaw.name, + coreVersion: coreNanoclaw.version, + }, + ]; + for (const { suite, coreName, coreVersion } of rcSuiteCoreCandidates) { + const coreDependency = suite.clawsec.runtime_requires[0]; + assert.equal(coreDependency.name, coreName); + assert.equal( + coreDependency.minimum_version, + coreVersion, + `${suite.name}@${suite.version} must start compatibility at ${coreName}@${coreVersion}`, + ); + } + + for (const minimumVersion of [ + "0.1.0-beta.1", + "0.1.0-rc.1", + "0.1.0", + ]) { + const suite = clone(suiteHermes); + suite.clawsec.runtime_requires[0].minimum_version = minimumVersion; + const skillDir = await materializeSkill( + suite, + `valid-dependency-minimum-${minimumVersion.replaceAll(".", "-")}`, + ); + const result = validateClawsecMetadata({ + skill: suite, + skillDir, + requireClawsec: true, + }); + assert.equal( + result.valid, + true, + `dependency minimum ${minimumVersion} must pass metadata validation: ` + + JSON.stringify(result.errors), + ); + } + + const invalidCases = [ + { + name: "platform-harness-mismatch", + base: coreOpenclaw, + mutate: (skill) => { + skill.platform = "hermes"; + }, + code: "PLATFORM_HARNESS_MISMATCH", + }, + { + name: "plural-platform-mismatch", + base: coreOpenclaw, + mutate: (skill) => { + skill.platforms = ["hermes"]; + }, + code: "PLATFORM_DECLARATION_AMBIGUOUS", + }, + { + name: "extra-harness-block", + base: coreOpenclaw, + mutate: (skill) => { + skill.hermes = {}; + }, + code: "HARNESS_METADATA_BLOCK_AMBIGUOUS", + }, + { + name: "missing-harness-block", + base: coreOpenclaw, + mutate: (skill) => { + delete skill.openclaw; + }, + code: "HARNESS_METADATA_BLOCK_MISSING", + }, + { + name: "canonical-name-mismatch", + base: coreOpenclaw, + mutate: (skill) => { + skill.name = "clawsec-suite-openclaw"; + }, + code: "CANONICAL_NAME_MISMATCH", + }, + { + name: "invalid-package-version", + base: coreOpenclaw, + mutate: (skill) => { + skill.version = "0.1.0rc1"; + }, + code: "PACKAGE_VERSION_INVALID", + }, + { + name: "sbom-nonobject", + base: coreOpenclaw, + mutate: (skill) => { + skill.sbom = 1; + }, + code: "METADATA_SCHEMA_INVALID", + }, + { + name: "sbom-files-nonarray", + base: coreNanoclaw, + mutate: (skill) => { + skill.sbom.files = 1; + }, + code: "METADATA_SCHEMA_INVALID", + }, + { + name: "invalid-range-bound", + base: coreOpenclaw, + mutate: (skill) => { + skill.clawsec.supported_harness.minimum_version = "v1.0.0"; + }, + code: "VERSION_RANGE_INVALID", + }, + { + name: "reversed-range", + base: coreOpenclaw, + mutate: (skill) => { + skill.clawsec.supported_harness.minimum_version = "2.0.0"; + skill.clawsec.supported_harness.maximum_version_exclusive = "1.0.0"; + }, + code: "VERSION_RANGE_INVALID", + }, + { + name: "harness-prerelease-lower-bound", + base: coreOpenclaw, + mutate: (skill) => { + skill.clawsec.supported_harness.minimum_version = "0.1.0-rc.1"; + }, + code: "VERSION_RANGE_INVALID", + }, + { + name: "harness-prerelease-upper-bound", + base: coreOpenclaw, + mutate: (skill) => { + skill.clawsec.supported_harness.maximum_version_exclusive = "1.0.0-rc.1"; + }, + code: "VERSION_RANGE_INVALID", + }, + { + name: "harness-build-lower-bound", + base: coreOpenclaw, + mutate: (skill) => { + skill.clawsec.supported_harness.minimum_version = "0.1.0+build"; + }, + code: "VERSION_RANGE_INVALID", + }, + { + name: "harness-build-upper-bound", + base: coreOpenclaw, + mutate: (skill) => { + skill.clawsec.supported_harness.maximum_version_exclusive = "1.0.0+build"; + }, + code: "VERSION_RANGE_INVALID", + }, + { + name: "dependency-legacy-prerelease-lower-bound", + base: suiteHermes, + mutate: (skill) => { + skill.clawsec.runtime_requires[0].minimum_version = "0.1.0-rc1"; + }, + code: "VERSION_RANGE_INVALID", + }, + { + name: "dependency-build-lower-bound", + base: suiteHermes, + mutate: (skill) => { + skill.clawsec.runtime_requires[0].minimum_version = "0.1.0-rc.1+lab"; + }, + code: "VERSION_RANGE_INVALID", + }, + { + name: "dependency-prerelease-upper-bound", + base: suiteHermes, + mutate: (skill) => { + skill.clawsec.runtime_requires[0].maximum_version_exclusive = "1.0.0-rc.1"; + }, + code: "VERSION_RANGE_INVALID", + }, + { + name: "dependency-build-upper-bound", + base: suiteHermes, + mutate: (skill) => { + skill.clawsec.runtime_requires[0].maximum_version_exclusive = "1.0.0+build"; + }, + code: "VERSION_RANGE_INVALID", + }, + { + name: "unbounded-range", + base: coreOpenclaw, + mutate: (skill) => { + delete skill.clawsec.supported_harness.maximum_version_exclusive; + }, + code: "METADATA_SCHEMA_INVALID", + }, + { + name: "unsorted-capabilities", + base: coreOpenclaw, + mutate: (skill) => { + skill.clawsec.provides.reverse(); + }, + code: "ARRAY_NOT_SORTED", + }, + { + name: "unknown-capability", + base: coreOpenclaw, + mutate: (skill) => { + skill.clawsec.provides.push("core.unknown"); + skill.clawsec.provides.sort(); + }, + code: "UNKNOWN_CAPABILITY", + }, + { + name: "wrong-role-capability", + base: coreOpenclaw, + mutate: (skill) => { + skill.clawsec.provides = ["suite.status"]; + }, + code: "CAPABILITY_ROLE_MISMATCH", + }, + { + name: "stable-capability-missing", + base: coreOpenclaw, + mutate: (skill) => { + skill.clawsec.maturity = "stable"; + skill.clawsec.provides = skill.clawsec.provides.slice(1); + }, + code: "STABLE_CAPABILITY_MISSING", + }, + { + name: "core-protocol-missing", + base: coreOpenclaw, + mutate: (skill) => { + skill.clawsec.management_protocol_provides = []; + }, + code: "MANAGEMENT_PROTOCOL_INVALID", + }, + { + name: "core-depends-on-guardian", + base: coreOpenclaw, + mutate: (skill) => { + skill.clawsec.runtime_requires = [{ + name: "clawsec-drift-guardian-openclaw", + minimum_version: "0.1.0", + maximum_version_exclusive: "1.0.0", + }]; + }, + code: "CORE_PACKAGE_DEPENDENCY_FORBIDDEN", + }, + { + name: "core-arbitrary-package-dependency", + base: coreOpenclaw, + mutate: (skill) => { + skill.clawsec.install_requires = [{ + name: "unrelated-package", + minimum_version: "1.0.0", + maximum_version_exclusive: "2.0.0", + }]; + }, + code: "CORE_PACKAGE_DEPENDENCY_FORBIDDEN", + }, + { + name: "suite-core-runtime-missing", + base: suiteHermes, + mutate: (skill) => { + skill.clawsec.runtime_requires = []; + }, + code: "SUITE_CORE_RUNTIME_REQUIRED", + }, + { + name: "suite-cross-harness-core", + base: suiteHermes, + mutate: (skill) => { + skill.clawsec.runtime_requires[0].name = "clawsec-core-openclaw"; + }, + code: "CROSS_HARNESS_DEPENDENCY", + }, + { + name: "suite-runtime-guardian-extra", + base: suiteHermes, + mutate: (skill) => { + skill.clawsec.runtime_requires.push({ + name: "clawsec-drift-guardian-hermes", + minimum_version: "0.1.0", + maximum_version_exclusive: "1.0.0", + }); + skill.clawsec.runtime_requires.sort((left, right) => left.name.localeCompare(right.name)); + }, + code: "SUITE_CORE_RUNTIME_REQUIRED", + }, + { + name: "guardian-runtime-coupling", + base: driftPicoclaw, + mutate: (skill) => { + skill.clawsec.runtime_requires = [{ + name: "clawsec-core-picoclaw", + minimum_version: "0.1.0", + maximum_version_exclusive: "1.0.0", + }]; + }, + code: "GUARDIAN_PACKAGE_DEPENDENCY_FORBIDDEN", + }, + { + name: "guardian-install-coupling", + base: driftPicoclaw, + mutate: (skill) => { + skill.clawsec.install_requires = [{ + name: "clawsec-core-picoclaw", + minimum_version: "0.1.0", + maximum_version_exclusive: "1.0.0", + }]; + }, + code: "GUARDIAN_PACKAGE_DEPENDENCY_FORBIDDEN", + }, + { + name: "self-dependency", + base: suiteHermes, + mutate: (skill) => { + skill.clawsec.install_requires = [{ + name: "clawsec-suite-hermes", + minimum_version: "0.1.0", + maximum_version_exclusive: "1.0.0", + }]; + }, + code: "SELF_DEPENDENCY", + }, + { + name: "duplicate-dependency-name", + base: suiteHermes, + mutate: (skill) => { + skill.clawsec.runtime_requires.push({ + name: "clawsec-core-hermes", + minimum_version: "1.0.0", + maximum_version_exclusive: "2.0.0", + }); + }, + code: "DUPLICATE_DEPENDENCY", + }, + { + name: "legacy-self-reference", + base: coreOpenclaw, + mutate: (skill) => { + skill.clawsec.legacy_names = ["clawsec-core-openclaw"]; + }, + code: "LEGACY_NAME_SELF_REFERENCE", + }, + { + name: "guardian-family-missing", + base: driftPicoclaw, + mutate: (skill) => { + delete skill.clawsec.family; + }, + code: "METADATA_SCHEMA_INVALID", + }, + { + name: "lifecycle-used-as-maturity", + base: coreOpenclaw, + mutate: (skill) => { + skill.clawsec.maturity = "rc"; + }, + code: "METADATA_SCHEMA_INVALID", + }, + { + name: "nanoclaw-native-missing", + base: coreNanoclaw, + mutate: (skill) => { + delete skill.clawsec.native; + }, + code: "NANOCLAW_NATIVE_REQUIRED", + }, + { + name: "nanoclaw-v1-range", + base: coreNanoclaw, + mutate: (skill) => { + skill.clawsec.supported_harness.minimum_version = "1.0.0"; + skill.clawsec.supported_harness.maximum_version_exclusive = "2.0.0"; + }, + code: "NANOCLAW_V2_RANGE_REQUIRED", + }, + { + name: "nanoclaw-malformed-range", + base: coreNanoclaw, + mutate: (skill) => { + skill.clawsec.supported_harness.minimum_version = "v2.1.17"; + }, + code: "VERSION_RANGE_INVALID", + }, + { + name: "nanoclaw-core-operational-category", + base: coreNanoclaw, + mutate: (skill) => { + skill.clawsec.native.category = "operational"; + }, + code: "NANOCLAW_NATIVE_CATEGORY_INVALID", + }, + { + name: "nanoclaw-operational-suite-leaves-state", + base: fixtures.get("suite-nanoclaw-v2.json"), + mutate: (skill) => { + skill.clawsec.native.apply_leaves_state = true; + }, + code: "NANOCLAW_OPERATIONAL_STATE_FORBIDDEN", + }, + { + name: "nanoclaw-wrong-safe-location", + base: coreNanoclaw, + mutate: (skill) => { + skill.clawsec.native.install_location = ".claude/skills/clawsec-suite-nanoclaw"; + }, + code: "NANOCLAW_LOCATION_INVALID", + }, + { + name: "nanoclaw-path-traversal", + base: coreNanoclaw, + mutate: (skill) => { + skill.clawsec.native.install_location = ".claude/skills/../clawsec-core-nanoclaw"; + }, + code: "METADATA_SCHEMA_INVALID", + }, + { + name: "nanoclaw-core-denies-state", + base: coreNanoclaw, + mutate: (skill) => { + skill.clawsec.native.apply_leaves_state = false; + }, + code: "NANOCLAW_STATEFUL_PROFILE_REQUIRED", + }, + { + name: "nanoclaw-stateful-without-remove", + base: coreNanoclaw, + mutate: (skill) => { + skill.clawsec.native.remove_document_required = false; + }, + code: "NANOCLAW_REMOVE_DOCUMENT_REQUIRED", + }, + { + name: "nanoclaw-remove-not-in-sbom", + base: coreNanoclaw, + mutate: (skill) => { + skill.sbom.files = skill.sbom.files.filter((entry) => entry.path !== "REMOVE.md"); + }, + code: "NANOCLAW_REMOVE_DOCUMENT_NOT_IN_SBOM", + }, + { + name: "nanoclaw-remove-file-missing", + base: coreNanoclaw, + mutate: () => {}, + omitFiles: ["REMOVE.md"], + code: "NANOCLAW_REMOVE_DOCUMENT_MISSING", + }, + { + name: "nanoclaw-v1-metadata-marker", + base: coreNanoclaw, + mutate: (skill) => { + skill.nanoclaw.integration = { + ipc: "/workspace/ipc/request.json", + }; + }, + code: "METADATA_SCHEMA_INVALID", + }, + { + name: "nanoclaw-decomposed-v1-metadata", + base: coreNanoclaw, + mutate: (skill) => { + skill.nanoclaw.integration = { + root: "/workspace", + channel: "ipc", + legacy_group_file: "registered_groups", + suffix: ".json", + }; + }, + code: "METADATA_SCHEMA_INVALID", + }, + { + name: "native-metadata-on-openclaw", + base: coreOpenclaw, + mutate: (skill) => { + skill.clawsec.native = clone(coreNanoclaw.clawsec.native); + skill.clawsec.native.install_location = ".claude/skills/clawsec-core-openclaw"; + }, + code: "NATIVE_METADATA_FORBIDDEN", + }, + ]; + + for (const invalidCase of invalidCases) { + const skill = clone(invalidCase.base); + invalidCase.mutate(skill); + const skillDir = await materializeSkill(skill, `invalid-${invalidCase.name}`, { + omitFiles: invalidCase.omitFiles, + }); + const result = validateClawsecMetadata({ + skill, + skillDir, + requireClawsec: true, + }); + expectCode(result, invalidCase.code, invalidCase.name); + } + + const stableVocabulary = clone(coreOpenclaw); + stableVocabulary.clawsec.maturity = "stable"; + const stableVocabularyDir = await materializeSkill( + stableVocabulary, + "stable-maturity-vocabulary", + ); + const stableVocabularyResult = validateClawsecMetadata({ + skill: stableVocabulary, + skillDir: stableVocabularyDir, + requireClawsec: true, + }); + assert.equal(stableVocabularyResult.valid, true); + assert( + stableVocabularyResult.warnings.some( + (entry) => entry.code === "STABLE_MATURITY_REQUIRES_CONFORMANCE", + ), + ); + + const deprecatedVocabulary = clone(coreOpenclaw); + deprecatedVocabulary.clawsec.maturity = "deprecated"; + const deprecatedVocabularyDir = await materializeSkill( + deprecatedVocabulary, + "deprecated-maturity-vocabulary", + ); + const deprecatedVocabularyResult = validateClawsecMetadata({ + skill: deprecatedVocabulary, + skillDir: deprecatedVocabularyDir, + requireClawsec: true, + }); + assert.equal(deprecatedVocabularyResult.valid, true); + assert( + deprecatedVocabularyResult.warnings.some( + (entry) => entry.code === "DEPRECATED_MATURITY_REQUIRES_SUCCESSOR", + ), + ); + + const recurringAdvisoryCapability = clone(suiteHermes); + recurringAdvisoryCapability.clawsec.provides.push( + "suite.recurring-advisory-verification", + ); + recurringAdvisoryCapability.clawsec.provides.sort(); + const recurringAdvisoryCapabilityDir = await materializeSkill( + recurringAdvisoryCapability, + "optional-recurring-advisory-capability", + ); + const recurringAdvisoryCapabilityResult = validateClawsecMetadata({ + skill: recurringAdvisoryCapability, + skillDir: recurringAdvisoryCapabilityDir, + requireClawsec: true, + }); + assert.equal(recurringAdvisoryCapabilityResult.valid, true); + + const stableSuiteWithoutRecurring = clone(suiteHermes); + stableSuiteWithoutRecurring.clawsec.maturity = "stable"; + const stableSuiteWithoutRecurringDir = await materializeSkill( + stableSuiteWithoutRecurring, + "stable-suite-without-optional-recurring-advisory-capability", + ); + const stableSuiteWithoutRecurringResult = validateClawsecMetadata({ + skill: stableSuiteWithoutRecurring, + skillDir: stableSuiteWithoutRecurringDir, + requireClawsec: true, + }); + assert.equal(stableSuiteWithoutRecurringResult.valid, true); + + const legacySkill = { + name: "legacy-fixture", + version: "0.0.1", + description: "Legacy validation fixture.", + author: "prompt-security", + license: "AGPL-3.0-or-later", + sbom: { + files: [{ + path: "SKILL.md", + required: true, + }], + }, + }; + const legacyDir = await materializeSkill(legacySkill, "legacy-input"); + const legacyResult = validateClawsecMetadata({ + skill: legacySkill, + skillDir: legacyDir, + }); + assert.equal(legacyResult.valid, true); + assert.equal(legacyResult.classification, "legacy"); + assert.equal(legacyResult.warnings[0].code, "LEGACY_INPUT"); + + const legacyPython = runPythonValidator(legacyDir); + assert.equal(legacyPython.status, 0, legacyPython.stderr); + assert.match(legacyPython.stdout, /LEGACY_INPUT/); + + const strictLegacyPython = runPythonValidator(legacyDir, ["--require-clawsec"]); + assert.equal(strictLegacyPython.status, 1); + assert.match(strictLegacyPython.stdout, /MISSING_CLAWSEC_METADATA/); + + const missingCanonical = clone(legacySkill); + missingCanonical.name = "clawsec-core-openclaw"; + missingCanonical.platform = "openclaw"; + missingCanonical.openclaw = {}; + const missingCanonicalDir = await materializeSkill( + missingCanonical, + "canonical-metadata-missing", + ); + const missingCanonicalPython = runPythonValidator(missingCanonicalDir); + assert.equal(missingCanonicalPython.status, 1); + assert.match(missingCanonicalPython.stdout, /MISSING_CLAWSEC_METADATA/); + + const nonObjectDir = path.join(temporaryRoot, "non-object-skill-json"); + await mkdir(nonObjectDir, { recursive: true }); + await writeFile(path.join(nonObjectDir, "skill.json"), "[]\n", "utf8"); + const nonObjectPython = runPythonValidator(nonObjectDir); + assert.equal(nonObjectPython.status, 1); + assert.match(nonObjectPython.stdout, /top-level value must be a JSON object/); + + const numericVersion = clone(coreOpenclaw); + numericVersion.version = 1; + const numericVersionDir = await materializeSkill( + numericVersion, + "numeric-version", + ); + const numericVersionPython = runPythonValidator( + numericVersionDir, + ["--require-clawsec"], + ); + assert.equal(numericVersionPython.status, 1); + assert.match(numericVersionPython.stdout, /METADATA_SCHEMA_INVALID/); + assert.doesNotMatch(numericVersionPython.stderr, /Traceback/); + + const numericVersionPackage = runPythonPackager( + numericVersionDir, + path.join(temporaryRoot, "numeric-version-package-output"), + ); + assert.equal(numericVersionPackage.status, 1); + assert.match(numericVersionPackage.stdout, /METADATA_SCHEMA_INVALID/); + assert.doesNotMatch(numericVersionPackage.stderr, /Traceback/); + + const nonObjectOpenclaw = clone(coreOpenclaw); + nonObjectOpenclaw.openclaw = 1; + const nonObjectOpenclawDir = await materializeSkill( + nonObjectOpenclaw, + "non-object-openclaw", + ); + const nonObjectOpenclawPython = runPythonValidator( + nonObjectOpenclawDir, + ["--require-clawsec"], + ); + assert.equal(nonObjectOpenclawPython.status, 1); + assert.match(nonObjectOpenclawPython.stdout, /METADATA_SCHEMA_INVALID/); + assert.doesNotMatch(nonObjectOpenclawPython.stderr, /Traceback/); + + const nonObjectOpenclawPackage = runPythonPackager( + nonObjectOpenclawDir, + path.join(temporaryRoot, "non-object-openclaw-package-output"), + ); + assert.equal(nonObjectOpenclawPackage.status, 1); + assert.match(nonObjectOpenclawPackage.stdout, /METADATA_SCHEMA_INVALID/); + assert.doesNotMatch(nonObjectOpenclawPackage.stderr, /Traceback/); + + const malformedSbomCases = [ + { + label: "sbom-nonobject-entrypoints", + base: coreOpenclaw, + mutate: (skill) => { + skill.sbom = 1; + }, + }, + { + label: "sbom-files-nonarray-entrypoints", + base: coreNanoclaw, + mutate: (skill) => { + skill.sbom.files = 1; + }, + }, + ]; + for (const malformedCase of malformedSbomCases) { + const malformedSkill = clone(malformedCase.base); + malformedCase.mutate(malformedSkill); + const malformedDir = await materializeSkill( + malformedSkill, + malformedCase.label, + ); + const malformedPython = runPythonValidator( + malformedDir, + ["--require-clawsec"], + ); + assert.equal(malformedPython.status, 1); + assert.match(malformedPython.stdout, /METADATA_SCHEMA_INVALID/); + assert.doesNotMatch(malformedPython.stderr, /Traceback/); + + const malformedPackage = runPythonPackager( + malformedDir, + path.join(temporaryRoot, `${malformedCase.label}-package-output`), + ); + assert.equal(malformedPackage.status, 1); + assert.match(malformedPackage.stdout, /METADATA_SCHEMA_INVALID/); + assert.doesNotMatch(malformedPackage.stderr, /Traceback/); + } + + const validCoreDir = path.join(temporaryRoot, "valid-core-openclaw"); + const packageOutput = path.join(temporaryRoot, "package-output"); + const packageResult = runPythonPackager(validCoreDir, packageOutput); + assert.equal( + packageResult.status, + 0, + `canonical package validation failed:\n${packageResult.stdout}\n${packageResult.stderr}`, + ); + assert.match(packageResult.stdout, /CONTRACT_VALID/); + + const invalidComponentRef = { + schema: "clawsec.component-ref/v1", + name: "clawsec-core-hermes", + version: "0.1.0", + harness: "hermes", + role: "core", + metadata_digest: "sha256:not-a-digest", + }; + expectCode( + validateComponentRef(invalidComponentRef), + "COMPONENT_REF_SCHEMA_INVALID", + "component-ref-digest", + ); + + const mismatchedComponentRef = { + schema: "clawsec.component-ref/v1", + name: "clawsec-core-openclaw", + version: "0.1.0", + harness: "hermes", + role: "core", + metadata_digest: `sha256:${"f".repeat(64)}`, + }; + expectCode( + validateComponentRef(mismatchedComponentRef), + "COMPONENT_REF_IDENTITY_MISMATCH", + "component-ref-identity", + ); + + process.stdout.write( + `component metadata contract: ${fixtureFiles.length} valid fixtures, ` + + `${invalidCases.length} invalid fixtures, legacy/strict/package/component-ref checks passed\n`, + ); +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} diff --git a/utils/validate_skill.py b/utils/validate_skill.py index 5230ecb2..f85fd6ac 100644 --- a/utils/validate_skill.py +++ b/utils/validate_skill.py @@ -3,18 +3,101 @@ Skill Validator - Validates a skill folder against the skill.json schema Usage: - python utils/validate_skill.py + python utils/validate_skill.py [--require-clawsec] Example: python utils/validate_skill.py skills/prompt-agent """ +import argparse import json +import re +import subprocess import sys from pathlib import Path +_CANONICAL_CLAWSEC_NAME_RE = re.compile( + r"^clawsec-(?:core|suite|drift-guardian)-(?:openclaw|hermes|nanoclaw|picoclaw)$" +) -def validate_skill(skill_path: str) -> tuple[bool, str]: + +def _validate_clawsec_metadata( + skill_path: Path, + skill_data: dict, + require_clawsec: bool, +) -> tuple[list[str], list[str], list[str]]: + """Run the single normalized ClawSec metadata validator when applicable.""" + name = skill_data.get("name") + has_clawsec = "clawsec" in skill_data + canonical_name = isinstance(name, str) and _CANONICAL_CLAWSEC_NAME_RE.fullmatch(name) + + if not has_clawsec and not require_clawsec and not canonical_name: + return ( + [], + ["LEGACY_INPUT: no normalized clawsec metadata; usable only as migration material"], + [], + ) + + repository_root = Path(__file__).resolve().parent.parent + validator_path = repository_root / "scripts" / "ci" / "validate_clawsec_metadata.mjs" + command = [ + "node", + str(validator_path), + "--skill-dir", + str(skill_path), + "--json", + ] + if require_clawsec: + command.append("--require-clawsec") + + try: + completed = subprocess.run( + command, + cwd=repository_root, + check=False, + capture_output=True, + text=True, + ) + except OSError as exc: + return ( + [f"METADATA_VALIDATOR_UNAVAILABLE /: {exc}"], + [], + [], + ) + + try: + result = json.loads(completed.stdout) + except json.JSONDecodeError: + detail = completed.stderr.strip() or completed.stdout.strip() or "no validator output" + return ( + [f"METADATA_VALIDATOR_ERROR /: {detail}"], + [], + [], + ) + + errors = [ + f"{entry['code']} {entry['path']}: {entry['message']}" + for entry in result.get("errors", []) + ] + warnings = [ + f"{entry['code']} {entry['path']}: {entry['message']}" + for entry in result.get("warnings", []) + ] + notices = [] + if result.get("valid") and result.get("classification") == "canonical": + notices.append("CONTRACT_VALID: normalized ClawSec metadata v1") + elif result.get("valid") and result.get("classification") == "legacy": + notices.append("LEGACY_INPUT: structurally valid legacy package") + + if completed.returncode == 0 and errors: + errors.append("METADATA_VALIDATOR_ERROR /: validator returned success with errors") + if completed.returncode != 0 and not errors: + errors.append("METADATA_VALIDATOR_ERROR /: validator failed without a diagnostic") + + return errors, warnings, notices + + +def validate_skill(skill_path: str, require_clawsec: bool = False) -> tuple[bool, str]: """ Validate a skill folder. @@ -45,8 +128,12 @@ def validate_skill(skill_path: str) -> tuple[bool, str]: except json.JSONDecodeError as e: return False, f"Invalid JSON in skill.json: {e}" + if not isinstance(skill_data, dict): + return False, "Invalid skill.json: top-level value must be a JSON object" + errors = [] warnings = [] + notices = [] # Validate required fields required_fields = ["name", "version", "description", "author", "license"] @@ -64,9 +151,12 @@ def validate_skill(skill_path: str) -> tuple[bool, str]: # Validate version format (basic semver check) if "version" in skill_data: version = skill_data["version"] - parts = version.split(".") - if len(parts) < 2: + if not isinstance(version, str): errors.append(f"Invalid version format: {version} (expected semver)") + else: + parts = version.split(".") + if len(parts) < 2: + errors.append(f"Invalid version format: {version} (expected semver)") # Note: trust field is deprecated - all published skills are verified through the review process @@ -75,12 +165,19 @@ def validate_skill(skill_path: str) -> tuple[bool, str]: errors.append("sbom section is required") else: sbom = skill_data["sbom"] - if "files" not in sbom: + if not isinstance(sbom, dict): + errors.append("sbom must be a JSON object") + elif "files" not in sbom: errors.append("sbom.files is required") + elif not isinstance(sbom["files"], list): + errors.append("sbom.files must be a JSON array") else: # Check each SBOM file exists for file_entry in sbom["files"]: - if "path" not in file_entry: + if not isinstance(file_entry, dict): + errors.append("sbom.files entry must be a JSON object") + continue + if not isinstance(file_entry.get("path"), str) or not file_entry["path"]: errors.append("sbom.files entry missing 'path' field") continue @@ -91,15 +188,28 @@ def validate_skill(skill_path: str) -> tuple[bool, str]: else: warnings.append(f"Optional SBOM file not found: {file_entry['path']}") + metadata_errors, metadata_warnings, metadata_notices = _validate_clawsec_metadata( + skill_path, + skill_data, + require_clawsec, + ) + errors.extend(metadata_errors) + warnings.extend(metadata_warnings) + notices.extend(metadata_notices) + # Validate openclaw section if "openclaw" in skill_data: openclaw = skill_data["openclaw"] - if "emoji" not in openclaw: - warnings.append("openclaw.emoji is recommended") - if "category" not in openclaw: - warnings.append("openclaw.category is recommended") - if "triggers" not in openclaw or len(openclaw.get("triggers", [])) == 0: - warnings.append("openclaw.triggers is recommended for discoverability") + if not isinstance(openclaw, dict): + errors.append("openclaw must be a JSON object") + else: + if "emoji" not in openclaw: + warnings.append("openclaw.emoji is recommended") + if "category" not in openclaw: + warnings.append("openclaw.category is recommended") + triggers = openclaw.get("triggers") + if not isinstance(triggers, list) or not triggers: + warnings.append("openclaw.triggers is recommended for discoverability") # Check for README.md readme_path = skill_path / "README.md" @@ -110,6 +220,9 @@ def validate_skill(skill_path: str) -> tuple[bool, str]: if errors: message = "Validation FAILED:\n" message += "\n".join(f" ERROR: {e}" for e in errors) + if notices: + message += "\n\nNotices:\n" + message += "\n".join(f" {notice}" for notice in notices) if warnings: message += "\n\nWarnings:\n" message += "\n".join(f" WARNING: {w}" for w in warnings) @@ -117,24 +230,35 @@ def validate_skill(skill_path: str) -> tuple[bool, str]: if warnings: message = f"Validation PASSED with {len(warnings)} warning(s):\n" + if notices: + message += "\n".join(f" {notice}" for notice in notices) + message += "\n" message += "\n".join(f" WARNING: {w}" for w in warnings) return True, message + if notices: + message = "Validation PASSED:\n" + message += "\n".join(f" {notice}" for notice in notices) + return True, message + return True, "Validation PASSED - all checks passed" def main(): - if len(sys.argv) < 2: - print("Usage: python utils/validate_skill.py ") - print("\nExample:") - print(" python utils/validate_skill.py skills/prompt-agent") - sys.exit(1) + parser = argparse.ArgumentParser(description="Validate a ClawSec skill folder") + parser.add_argument("skill_path", help="Path to the skill folder") + parser.add_argument( + "--require-clawsec", + action="store_true", + help="Require normalized ClawSec metadata v1", + ) + args = parser.parse_args() - skill_path = sys.argv[1] + skill_path = args.skill_path print(f"Validating skill: {skill_path}") print() - valid, message = validate_skill(skill_path) + valid, message = validate_skill(skill_path, require_clawsec=args.require_clawsec) print(message) print()