mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-18 16:01:21 +03:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83ec542a1e | |||
| 3ffa6eed68 | |||
| 57eeb6d8f3 | |||
| 4542b7b96b | |||
| 85966ff569 | |||
| 24db3d46a4 | |||
| e08c91b504 | |||
| 57720d5493 | |||
| a706ef9df9 | |||
| 7f741d11da | |||
| e9db0c48c9 | |||
| e329a71de6 | |||
| ad8a751b77 | |||
| 186c2ec165 | |||
| 9ae2efa2f7 | |||
| 5ded815e6a | |||
| 87f80aae94 | |||
| c856bb6426 | |||
| 4783849476 | |||
| 4904990500 | |||
| c7749e6d5a | |||
| ecf715940d | |||
| 007a9cc5f4 | |||
| fae4444526 | |||
| db091fb8b3 | |||
| b950c7d937 | |||
| 96741196e5 | |||
| c31b81f24f | |||
| 8c4f7d594c | |||
| fdaa933a24 | |||
| 760e49f3e0 | |||
| 24b5bf9f1b | |||
| 973b0a0016 |
@@ -202,9 +202,79 @@ jobs:
|
||||
.cve.metrics.cvssMetricV2[0]?.cvssData.baseScore //
|
||||
null;
|
||||
|
||||
def nvd_category_raw:
|
||||
(
|
||||
[.cve.weaknesses[]?.description[]? | select(.lang == "en") | .value | strings | select(length > 0)]
|
||||
| unique
|
||||
| map(select(. != "NVD-CWE-noinfo" and . != "NVD-CWE-Other"))
|
||||
| .[0]
|
||||
);
|
||||
|
||||
def cwe_id:
|
||||
(
|
||||
nvd_category_raw
|
||||
| if . == null then null
|
||||
else (try (capture("^CWE-(?<id>[0-9]+)$").id) catch null)
|
||||
end
|
||||
);
|
||||
|
||||
def cwe_name_map($id):
|
||||
({
|
||||
"20": "improper_input_validation",
|
||||
"22": "path_traversal",
|
||||
"77": "command_injection",
|
||||
"78": "os_command_injection",
|
||||
"79": "cross_site_scripting",
|
||||
"89": "sql_injection",
|
||||
"94": "code_injection",
|
||||
"119": "memory_buffer_bounds_violation",
|
||||
"120": "classic_buffer_overflow",
|
||||
"125": "out_of_bounds_read",
|
||||
"134": "format_string_vulnerability",
|
||||
"200": "exposure_of_sensitive_information",
|
||||
"250": "execution_with_unnecessary_privileges",
|
||||
"269": "improper_privilege_management",
|
||||
"284": "improper_access_control",
|
||||
"285": "improper_authorization",
|
||||
"287": "improper_authentication",
|
||||
"295": "improper_certificate_validation",
|
||||
"306": "missing_authentication_for_critical_function",
|
||||
"319": "cleartext_transmission_of_sensitive_information",
|
||||
"326": "inadequate_encryption_strength",
|
||||
"327": "risky_cryptographic_algorithm",
|
||||
"352": "cross_site_request_forgery",
|
||||
"362": "race_condition",
|
||||
"400": "uncontrolled_resource_consumption",
|
||||
"416": "use_after_free",
|
||||
"434": "unrestricted_file_upload",
|
||||
"502": "deserialization_of_untrusted_data",
|
||||
"601": "open_redirect",
|
||||
"611": "xml_external_entity_injection",
|
||||
"639": "insecure_direct_object_reference",
|
||||
"668": "exposure_of_resource_to_wrong_sphere",
|
||||
"669": "incorrect_resource_transfer_between_spheres",
|
||||
"732": "incorrect_permission_assignment",
|
||||
"787": "out_of_bounds_write",
|
||||
"798": "hard_coded_credentials",
|
||||
"862": "missing_authorization",
|
||||
"863": "incorrect_authorization",
|
||||
"918": "server_side_request_forgery",
|
||||
"922": "insecure_storage_of_sensitive_information"
|
||||
}[$id]);
|
||||
|
||||
def nvd_category_name:
|
||||
(
|
||||
cwe_id as $id
|
||||
| if $id == null then "unspecified_weakness"
|
||||
else (cwe_name_map($id) // ("unknown_cwe_" + $id))
|
||||
end
|
||||
);
|
||||
|
||||
[.[] | {
|
||||
id: .cve.id,
|
||||
severity: (get_cvss_score | map_severity),
|
||||
type: nvd_category_name,
|
||||
nvd_category_id: nvd_category_raw,
|
||||
cvss_score: get_cvss_score,
|
||||
description: (.cve.descriptions[] | select(.lang == "en") | .value),
|
||||
title: (.cve.descriptions[] | select(.lang == "en") | .value | .[0:100] + (if length > 100 then "..." else "" end)),
|
||||
@@ -225,6 +295,8 @@ jobs:
|
||||
if $existing_entry then
|
||||
# Compare key fields
|
||||
if ($existing_entry.severity != $nvd_entry.severity) or
|
||||
($existing_entry.type != $nvd_entry.type) or
|
||||
($existing_entry.nvd_category_id != $nvd_entry.nvd_category_id) or
|
||||
($existing_entry.cvss_score != $nvd_entry.cvss_score) or
|
||||
($existing_entry.description != $nvd_entry.description) then
|
||||
{
|
||||
@@ -232,11 +304,15 @@ jobs:
|
||||
changes: (
|
||||
[]
|
||||
+ (if $existing_entry.severity != $nvd_entry.severity then ["severity: \($existing_entry.severity) → \($nvd_entry.severity)"] else [] end)
|
||||
+ (if $existing_entry.type != $nvd_entry.type then ["type: \($existing_entry.type // "null") → \($nvd_entry.type // "null")"] else [] end)
|
||||
+ (if $existing_entry.nvd_category_id != $nvd_entry.nvd_category_id then ["nvd_category_id: \($existing_entry.nvd_category_id // "null") → \($nvd_entry.nvd_category_id // "null")"] else [] end)
|
||||
+ (if $existing_entry.cvss_score != $nvd_entry.cvss_score then ["cvss_score: \($existing_entry.cvss_score // "null") → \($nvd_entry.cvss_score // "null")"] else [] end)
|
||||
+ (if $existing_entry.description != $nvd_entry.description then ["description updated"] else [] end)
|
||||
),
|
||||
updated_fields: {
|
||||
severity: $nvd_entry.severity,
|
||||
type: $nvd_entry.type,
|
||||
nvd_category_id: $nvd_entry.nvd_category_id,
|
||||
cvss_score: $nvd_entry.cvss_score,
|
||||
description: $nvd_entry.description,
|
||||
title: $nvd_entry.title,
|
||||
@@ -282,13 +358,82 @@ jobs:
|
||||
.cve.metrics.cvssMetricV30[0]?.cvssData.baseScore //
|
||||
.cve.metrics.cvssMetricV2[0]?.cvssData.baseScore //
|
||||
null;
|
||||
|
||||
def nvd_category_raw:
|
||||
(
|
||||
[.cve.weaknesses[]?.description[]? | select(.lang == "en") | .value | strings | select(length > 0)]
|
||||
| unique
|
||||
| map(select(. != "NVD-CWE-noinfo" and . != "NVD-CWE-Other"))
|
||||
| .[0]
|
||||
);
|
||||
|
||||
def cwe_id:
|
||||
(
|
||||
nvd_category_raw
|
||||
| if . == null then null
|
||||
else (try (capture("^CWE-(?<id>[0-9]+)$").id) catch null)
|
||||
end
|
||||
);
|
||||
|
||||
def cwe_name_map($id):
|
||||
({
|
||||
"20": "improper_input_validation",
|
||||
"22": "path_traversal",
|
||||
"77": "command_injection",
|
||||
"78": "os_command_injection",
|
||||
"79": "cross_site_scripting",
|
||||
"89": "sql_injection",
|
||||
"94": "code_injection",
|
||||
"119": "memory_buffer_bounds_violation",
|
||||
"120": "classic_buffer_overflow",
|
||||
"125": "out_of_bounds_read",
|
||||
"134": "format_string_vulnerability",
|
||||
"200": "exposure_of_sensitive_information",
|
||||
"250": "execution_with_unnecessary_privileges",
|
||||
"269": "improper_privilege_management",
|
||||
"284": "improper_access_control",
|
||||
"285": "improper_authorization",
|
||||
"287": "improper_authentication",
|
||||
"295": "improper_certificate_validation",
|
||||
"306": "missing_authentication_for_critical_function",
|
||||
"319": "cleartext_transmission_of_sensitive_information",
|
||||
"326": "inadequate_encryption_strength",
|
||||
"327": "risky_cryptographic_algorithm",
|
||||
"352": "cross_site_request_forgery",
|
||||
"362": "race_condition",
|
||||
"400": "uncontrolled_resource_consumption",
|
||||
"416": "use_after_free",
|
||||
"434": "unrestricted_file_upload",
|
||||
"502": "deserialization_of_untrusted_data",
|
||||
"601": "open_redirect",
|
||||
"611": "xml_external_entity_injection",
|
||||
"639": "insecure_direct_object_reference",
|
||||
"668": "exposure_of_resource_to_wrong_sphere",
|
||||
"669": "incorrect_resource_transfer_between_spheres",
|
||||
"732": "incorrect_permission_assignment",
|
||||
"787": "out_of_bounds_write",
|
||||
"798": "hard_coded_credentials",
|
||||
"862": "missing_authorization",
|
||||
"863": "incorrect_authorization",
|
||||
"918": "server_side_request_forgery",
|
||||
"922": "insecure_storage_of_sensitive_information"
|
||||
}[$id]);
|
||||
|
||||
def nvd_category_name:
|
||||
(
|
||||
cwe_id as $id
|
||||
| if $id == null then "unspecified_weakness"
|
||||
else (cwe_name_map($id) // ("unknown_cwe_" + $id))
|
||||
end
|
||||
);
|
||||
|
||||
[.[] |
|
||||
select(.cve.id as $id | $existing | index($id) | not) |
|
||||
{
|
||||
id: .cve.id,
|
||||
severity: (get_cvss_score | map_severity),
|
||||
type: "vulnerable_skill",
|
||||
type: nvd_category_name,
|
||||
nvd_category_id: nvd_category_raw,
|
||||
title: (.cve.descriptions[] | select(.lang == "en") | .value | .[0:100] + (if length > 100 then "..." else "" end)),
|
||||
description: (.cve.descriptions[] | select(.lang == "en") | .value),
|
||||
affected: [.cve.configurations[]?.nodes[]?.cpeMatch[]?.criteria // empty] | unique | .[0:5],
|
||||
@@ -324,7 +469,7 @@ jobs:
|
||||
($updates[0] | map(select(.id == $adv.id)) | first) as $update |
|
||||
if $update then
|
||||
# Merge updated fields
|
||||
$adv * $update.updated_fields
|
||||
($adv * $update.updated_fields)
|
||||
else
|
||||
$adv
|
||||
end
|
||||
|
||||
+454
-185
@@ -4,6 +4,10 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- '*-v[0-9]*.[0-9]*.[0-9]*'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'skills/*/skill.json'
|
||||
- 'skills/*/SKILL.md'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -15,7 +19,368 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validate-pr-version-sync:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate version parity for bumped skills
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
get_md_version() {
|
||||
local md_file="$1"
|
||||
awk '
|
||||
NR == 1 && $0 == "---" { in_frontmatter = 1; next }
|
||||
in_frontmatter && $0 == "---" { exit }
|
||||
in_frontmatter && $0 ~ /^version:[[:space:]]*/ {
|
||||
sub(/^version:[[:space:]]*/, "", $0)
|
||||
gsub(/[[:space:]]+$/, "", $0)
|
||||
print $0
|
||||
exit
|
||||
}
|
||||
' "$md_file"
|
||||
}
|
||||
|
||||
get_md_version_from_git() {
|
||||
local sha="$1"
|
||||
local path="$2"
|
||||
local tmp_file
|
||||
tmp_file="$(mktemp)"
|
||||
|
||||
if git cat-file -e "${sha}:${path}" 2>/dev/null; then
|
||||
git show "${sha}:${path}" > "$tmp_file"
|
||||
get_md_version "$tmp_file"
|
||||
fi
|
||||
|
||||
rm -f "$tmp_file"
|
||||
}
|
||||
|
||||
touched_skills_file="$(mktemp)"
|
||||
git diff --name-only "${BASE_SHA}...${HEAD_SHA}" -- 'skills/*/skill.json' 'skills/*/SKILL.md' \
|
||||
| awk -F/ 'NF >= 3 {print $1 "/" $2}' \
|
||||
| sort -u > "${touched_skills_file}"
|
||||
|
||||
if [ ! -s "${touched_skills_file}" ]; then
|
||||
echo "No skill metadata files changed in this PR."
|
||||
rm -f "${touched_skills_file}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
checked_skills=0
|
||||
failures=0
|
||||
|
||||
while IFS= read -r skill_dir; do
|
||||
json_path="${skill_dir}/skill.json"
|
||||
md_path="${skill_dir}/SKILL.md"
|
||||
|
||||
head_json_version=""
|
||||
if [ -f "${json_path}" ]; then
|
||||
head_json_version="$(jq -r '.version // empty' "${json_path}" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
head_md_version=""
|
||||
if [ -f "${md_path}" ]; then
|
||||
head_md_version="$(get_md_version "${md_path}")"
|
||||
fi
|
||||
|
||||
base_json_version=""
|
||||
if git cat-file -e "${BASE_SHA}:${json_path}" 2>/dev/null; then
|
||||
base_json_version="$(git show "${BASE_SHA}:${json_path}" | jq -r '.version // empty' 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
base_md_version="$(get_md_version_from_git "${BASE_SHA}" "${md_path}")"
|
||||
|
||||
json_version_changed=false
|
||||
md_version_changed=false
|
||||
|
||||
if [ "${head_json_version}" != "${base_json_version}" ]; then
|
||||
json_version_changed=true
|
||||
fi
|
||||
|
||||
if [ "${head_md_version}" != "${base_md_version}" ]; then
|
||||
md_version_changed=true
|
||||
fi
|
||||
|
||||
if [ "${json_version_changed}" != "true" ] && [ "${md_version_changed}" != "true" ]; then
|
||||
echo "No version bump detected for ${skill_dir}; skipping."
|
||||
continue
|
||||
fi
|
||||
|
||||
checked_skills=$((checked_skills + 1))
|
||||
echo "Version bump detected for ${skill_dir} (skill.json changed: ${json_version_changed}, SKILL.md changed: ${md_version_changed})"
|
||||
|
||||
if [ ! -f "${json_path}" ]; then
|
||||
echo "::error file=${json_path}::Missing skill.json after version bump."
|
||||
failures=$((failures + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ ! -f "${md_path}" ]; then
|
||||
echo "::error file=${md_path}::Missing SKILL.md after version bump."
|
||||
failures=$((failures + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -z "${head_json_version}" ]; then
|
||||
echo "::error file=${json_path}::Missing .version in skill.json."
|
||||
failures=$((failures + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -z "${head_md_version}" ]; then
|
||||
echo "::error file=${md_path}::Missing version in SKILL.md frontmatter."
|
||||
failures=$((failures + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ "${head_json_version}" != "${head_md_version}" ]; then
|
||||
echo "::error file=${json_path}::Version mismatch. skill.json=${head_json_version}, SKILL.md=${head_md_version}"
|
||||
failures=$((failures + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Version parity OK for ${skill_dir}: ${head_json_version}"
|
||||
done < "${touched_skills_file}"
|
||||
|
||||
rm -f "${touched_skills_file}"
|
||||
|
||||
if [ "${checked_skills}" -eq 0 ]; then
|
||||
echo "No version bumps detected in changed skill metadata files."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${failures}" -gt 0 ]; then
|
||||
echo "::error::Found ${failures} version parity issue(s) across ${checked_skills} bumped skill(s)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Validated ${checked_skills} bumped skill(s): skill.json and SKILL.md versions are present and equal."
|
||||
|
||||
release:
|
||||
if: github.event_name == 'pull_request'
|
||||
needs: validate-pr-version-sync
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run release dry-run for changed skills
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
get_md_version() {
|
||||
local md_file="$1"
|
||||
awk '
|
||||
NR == 1 && $0 == "---" { in_frontmatter = 1; next }
|
||||
in_frontmatter && $0 == "---" { exit }
|
||||
in_frontmatter && $0 ~ /^version:[[:space:]]*/ {
|
||||
sub(/^version:[[:space:]]*/, "", $0)
|
||||
gsub(/[[:space:]]+$/, "", $0)
|
||||
print $0
|
||||
exit
|
||||
}
|
||||
' "$md_file"
|
||||
}
|
||||
|
||||
touched_skills_file="$(mktemp)"
|
||||
git diff --name-only "${BASE_SHA}...${HEAD_SHA}" -- 'skills/*/skill.json' 'skills/*/SKILL.md' \
|
||||
| awk -F/ 'NF >= 3 {print $1 "/" $2}' \
|
||||
| sort -u > "${touched_skills_file}"
|
||||
|
||||
if [ ! -s "${touched_skills_file}" ]; then
|
||||
echo "No skill metadata files changed in this PR."
|
||||
rm -f "${touched_skills_file}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
dry_run_count=0
|
||||
failures=0
|
||||
mkdir -p dist/dry-run
|
||||
|
||||
while IFS= read -r skill_dir; do
|
||||
json_path="${skill_dir}/skill.json"
|
||||
md_path="${skill_dir}/SKILL.md"
|
||||
|
||||
head_json_version=""
|
||||
if [ -f "${json_path}" ]; then
|
||||
head_json_version="$(jq -r '.version // empty' "${json_path}" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
head_md_version=""
|
||||
if [ -f "${md_path}" ]; then
|
||||
head_md_version="$(get_md_version "${md_path}")"
|
||||
fi
|
||||
|
||||
if [ -z "${head_json_version}" ] || [ -z "${head_md_version}" ] || [ "${head_json_version}" != "${head_md_version}" ]; then
|
||||
echo "::error file=${skill_dir}::Version metadata is invalid for dry-run. Ensure validate-pr-version-sync passes."
|
||||
failures=$((failures + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ ! -f "${json_path}" ]; then
|
||||
echo "::error file=${json_path}::Missing skill.json."
|
||||
failures=$((failures + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ ! -f "${md_path}" ]; then
|
||||
echo "::error file=${md_path}::Missing SKILL.md."
|
||||
failures=$((failures + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! jq -e '.name and .version and .sbom and .sbom.files and (.sbom.files | type == "array")' "${json_path}" >/dev/null 2>&1; then
|
||||
echo "::error file=${json_path}::skill.json missing required release fields (name/version/sbom.files)."
|
||||
failures=$((failures + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
skill_name="$(basename "${skill_dir}")"
|
||||
version="${head_json_version}"
|
||||
tag="${skill_name}-v${version}"
|
||||
dry_run_count=$((dry_run_count + 1))
|
||||
|
||||
echo "::group::Dry-run release ${tag}"
|
||||
|
||||
out_root="dist/dry-run/${tag}"
|
||||
out_assets="${out_root}/release-assets"
|
||||
mkdir -p "${out_assets}"
|
||||
|
||||
# --- Stage SBOM files preserving directory structure ---
|
||||
staging_dir="$(mktemp -d)"
|
||||
inner_dir="${staging_dir}/${skill_name}"
|
||||
mkdir -p "${inner_dir}"
|
||||
temp_sbom_file="$(mktemp)"
|
||||
jq -r '.sbom.files[].path' "${json_path}" > "${temp_sbom_file}"
|
||||
|
||||
while IFS= read -r file; do
|
||||
[ -z "${file}" ] && continue
|
||||
full_path="${skill_dir}/${file}"
|
||||
if [ -f "${full_path}" ]; then
|
||||
mkdir -p "${inner_dir}/$(dirname "${file}")"
|
||||
cp "${full_path}" "${inner_dir}/${file}"
|
||||
else
|
||||
echo "::error file=${json_path}::SBOM references missing file: ${file}"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
done < "${temp_sbom_file}"
|
||||
|
||||
cp "${json_path}" "${inner_dir}/skill.json"
|
||||
|
||||
# --- Create zip preserving directory structure ---
|
||||
zip_name="${skill_name}-v${version}.zip"
|
||||
(cd "${staging_dir}" && zip -qr "${OLDPWD}/${out_assets}/${zip_name}" .)
|
||||
|
||||
# --- Generate checksums.json via jq ---
|
||||
files_json="{}"
|
||||
while IFS= read -r file; do
|
||||
[ -z "${file}" ] && continue
|
||||
full_path="${skill_dir}/${file}"
|
||||
if [ -f "${full_path}" ]; then
|
||||
sha256="$(sha256sum "${full_path}" | awk '{print $1}')"
|
||||
size="$(stat -c%s "${full_path}" 2>/dev/null || stat -f%z "${full_path}")"
|
||||
files_json="$(echo "${files_json}" | jq \
|
||||
--arg key "${file}" \
|
||||
--arg sha "${sha256}" \
|
||||
--argjson sz "${size}" \
|
||||
'. + {($key): {sha256: $sha, size: $sz, path: $key}}')"
|
||||
fi
|
||||
done < "${temp_sbom_file}"
|
||||
|
||||
rm -f "${temp_sbom_file}"
|
||||
|
||||
skill_json_sha="$(sha256sum "${json_path}" | awk '{print $1}')"
|
||||
skill_json_size="$(stat -c%s "${json_path}" 2>/dev/null || stat -f%z "${json_path}")"
|
||||
files_json="$(echo "${files_json}" | jq \
|
||||
--arg sha "${skill_json_sha}" \
|
||||
--argjson sz "${skill_json_size}" \
|
||||
'. + {"skill.json": {sha256: $sha, size: $sz}}')"
|
||||
|
||||
zip_sha="$(sha256sum "${out_assets}/${zip_name}" | awk '{print $1}')"
|
||||
zip_size="$(stat -c%s "${out_assets}/${zip_name}" 2>/dev/null || stat -f%z "${out_assets}/${zip_name}")"
|
||||
|
||||
jq -n \
|
||||
--arg skill "${skill_name}" \
|
||||
--arg version "${version}" \
|
||||
--arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg repo "${{ github.repository }}" \
|
||||
--arg tag "${tag}" \
|
||||
--arg zip_file "${zip_name}" \
|
||||
--arg zip_sha "${zip_sha}" \
|
||||
--argjson zip_size "${zip_size}" \
|
||||
--arg zip_url "https://github.com/${{ github.repository }}/releases/download/${tag}/${zip_name}" \
|
||||
--argjson files "${files_json}" \
|
||||
'{
|
||||
skill: $skill,
|
||||
version: $version,
|
||||
generated_at: $generated,
|
||||
repository: $repo,
|
||||
tag: $tag,
|
||||
archive: {
|
||||
filename: $zip_file,
|
||||
sha256: $zip_sha,
|
||||
size: $zip_size,
|
||||
url: $zip_url
|
||||
},
|
||||
files: $files
|
||||
}' > "${out_assets}/checksums.json"
|
||||
|
||||
if ! jq -e . "${out_assets}/checksums.json" >/dev/null 2>&1; then
|
||||
echo "::error::Generated checksums.json is invalid JSON."
|
||||
failures=$((failures + 1))
|
||||
rm -rf "${staging_dir}"
|
||||
echo "::endgroup::"
|
||||
continue
|
||||
fi
|
||||
|
||||
# --- Copy root-level docs alongside the zip ---
|
||||
if [ -f "${skill_dir}/SKILL.md" ]; then
|
||||
cp "${skill_dir}/SKILL.md" "${out_assets}/SKILL.md"
|
||||
fi
|
||||
if [ -f "${skill_dir}/README.md" ]; then
|
||||
cp "${skill_dir}/README.md" "${out_assets}/README.md"
|
||||
fi
|
||||
|
||||
rm -rf "${staging_dir}"
|
||||
|
||||
echo "Prepared dry-run assets for ${tag}:"
|
||||
ls -la "${out_assets}"
|
||||
echo "::endgroup::"
|
||||
done < "${touched_skills_file}"
|
||||
|
||||
rm -f "${touched_skills_file}"
|
||||
|
||||
if [ "${failures}" -gt 0 ]; then
|
||||
echo "::error::Release dry-run failed with ${failures} issue(s) across ${dry_run_count} skill(s)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${dry_run_count}" -eq 0 ]; then
|
||||
echo "No changed skills found for dry-run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Release dry-run completed successfully for ${dry_run_count} changed skill(s)."
|
||||
|
||||
release-tag:
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
@@ -125,207 +490,108 @@ jobs:
|
||||
CLAWHUB_DISABLE_TELEMETRY=1 CLAWHUB_SITE="$SITE" CLAWHUB_REGISTRY="$REGISTRY" \
|
||||
clawhub login --token "$CLAWHUB_TOKEN" --site "$SITE" --no-input
|
||||
|
||||
- name: Generate checksums from SBOM
|
||||
id: checksums
|
||||
- name: Package release assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SKILL_NAME="${{ steps.parse.outputs.skill_name }}"
|
||||
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
|
||||
VERSION="${{ steps.parse.outputs.version }}"
|
||||
TAG="${{ github.ref_name }}"
|
||||
|
||||
mkdir -p dist
|
||||
mkdir -p release-assets
|
||||
|
||||
# Start checksums JSON
|
||||
cat > "dist/checksums.json" << EOF
|
||||
{
|
||||
"skill": "${SKILL_NAME}",
|
||||
"version": "${VERSION}",
|
||||
"generated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"repository": "${{ github.repository }}",
|
||||
"tag": "${{ github.ref_name }}",
|
||||
"files": {
|
||||
EOF
|
||||
|
||||
# Read SBOM files and generate checksums
|
||||
FIRST=true
|
||||
TEMPFILE=$(mktemp)
|
||||
|
||||
# Get files from SBOM
|
||||
# --- Stage SBOM files preserving directory structure ---
|
||||
STAGING_DIR="$(mktemp -d)"
|
||||
INNER_DIR="$STAGING_DIR/$SKILL_NAME"
|
||||
mkdir -p "$INNER_DIR"
|
||||
TEMPFILE="$(mktemp)"
|
||||
jq -r '.sbom.files[].path' "$SKILL_PATH/skill.json" > "$TEMPFILE"
|
||||
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
FULL_PATH="$SKILL_PATH/$file"
|
||||
if [ -f "$FULL_PATH" ]; then
|
||||
mkdir -p "$INNER_DIR/$(dirname "$file")"
|
||||
cp "$FULL_PATH" "$INNER_DIR/$file"
|
||||
else
|
||||
echo "::error file=$SKILL_PATH/skill.json::SBOM references missing file: $file"
|
||||
exit 1
|
||||
fi
|
||||
done < "$TEMPFILE"
|
||||
|
||||
cp "$SKILL_PATH/skill.json" "$INNER_DIR/skill.json"
|
||||
|
||||
# --- Create zip preserving directory structure ---
|
||||
ZIP_NAME="${SKILL_NAME}-v${VERSION}.zip"
|
||||
(cd "$STAGING_DIR" && zip -qr "$OLDPWD/release-assets/$ZIP_NAME" .)
|
||||
|
||||
# --- Generate checksums.json via jq ---
|
||||
FILES_JSON="{}"
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
FULL_PATH="$SKILL_PATH/$file"
|
||||
if [ -f "$FULL_PATH" ]; then
|
||||
SHA256=$(sha256sum "$FULL_PATH" | awk '{print $1}')
|
||||
SIZE=$(stat -c%s "$FULL_PATH" 2>/dev/null || stat -f%z "$FULL_PATH")
|
||||
FILENAME=$(basename "$file")
|
||||
|
||||
if [ "$FIRST" = true ]; then
|
||||
FIRST=false
|
||||
else
|
||||
echo " ," >> "dist/checksums.json"
|
||||
fi
|
||||
|
||||
cat >> "dist/checksums.json" << FILEENTRY
|
||||
"${FILENAME}": {
|
||||
"sha256": "${SHA256}",
|
||||
"size": ${SIZE},
|
||||
"path": "${file}",
|
||||
"url": "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${FILENAME}"
|
||||
}
|
||||
FILEENTRY
|
||||
else
|
||||
echo "Warning: File not found: $FULL_PATH"
|
||||
FILES_JSON="$(echo "$FILES_JSON" | jq \
|
||||
--arg key "$file" \
|
||||
--arg sha "$SHA256" \
|
||||
--argjson sz "$SIZE" \
|
||||
'. + {($key): {sha256: $sha, size: $sz, path: $key}}')"
|
||||
fi
|
||||
done < "$TEMPFILE"
|
||||
|
||||
# Also add skill.json checksum
|
||||
rm -f "$TEMPFILE"
|
||||
|
||||
SKILL_JSON_SHA=$(sha256sum "$SKILL_PATH/skill.json" | awk '{print $1}')
|
||||
SKILL_JSON_SIZE=$(stat -c%s "$SKILL_PATH/skill.json" 2>/dev/null || stat -f%z "$SKILL_PATH/skill.json")
|
||||
FILES_JSON="$(echo "$FILES_JSON" | jq \
|
||||
--arg sha "$SKILL_JSON_SHA" \
|
||||
--argjson sz "$SKILL_JSON_SIZE" \
|
||||
'. + {"skill.json": {sha256: $sha, size: $sz}}')"
|
||||
|
||||
cat >> "dist/checksums.json" << SKILLJSON
|
||||
,
|
||||
"skill.json": {
|
||||
"sha256": "${SKILL_JSON_SHA}",
|
||||
"size": ${SKILL_JSON_SIZE},
|
||||
"url": "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/skill.json"
|
||||
}
|
||||
SKILLJSON
|
||||
ZIP_SHA=$(sha256sum "release-assets/$ZIP_NAME" | awk '{print $1}')
|
||||
ZIP_SIZE=$(stat -c%s "release-assets/$ZIP_NAME" 2>/dev/null || stat -f%z "release-assets/$ZIP_NAME")
|
||||
|
||||
# Note: checksums.json is NOT closed here - will be finalized after .skill package is created
|
||||
jq -n \
|
||||
--arg skill "$SKILL_NAME" \
|
||||
--arg version "$VERSION" \
|
||||
--arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg repo "${{ github.repository }}" \
|
||||
--arg tag "$TAG" \
|
||||
--arg zip_file "$ZIP_NAME" \
|
||||
--arg zip_sha "$ZIP_SHA" \
|
||||
--argjson zip_size "$ZIP_SIZE" \
|
||||
--arg zip_url "https://github.com/${{ github.repository }}/releases/download/$TAG/$ZIP_NAME" \
|
||||
--argjson files "$FILES_JSON" \
|
||||
'{
|
||||
skill: $skill,
|
||||
version: $version,
|
||||
generated_at: $generated,
|
||||
repository: $repo,
|
||||
tag: $tag,
|
||||
archive: {
|
||||
filename: $zip_file,
|
||||
sha256: $zip_sha,
|
||||
size: $zip_size,
|
||||
url: $zip_url
|
||||
},
|
||||
files: $files
|
||||
}' > "release-assets/checksums.json"
|
||||
|
||||
echo "=== Intermediate checksums.json (before .skill) ==="
|
||||
cat "dist/checksums.json"
|
||||
|
||||
- name: Bundle security skills into suite
|
||||
if: steps.parse.outputs.skill_name == 'clawsec-suite'
|
||||
run: |
|
||||
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
|
||||
|
||||
echo "=== Bundling security skills into suite ==="
|
||||
|
||||
# Create bundled directory
|
||||
mkdir -p "$SKILL_PATH/bundled"
|
||||
|
||||
# List of skills to bundle (exclude clawtributor - opt-in only)
|
||||
BUNDLE_SKILLS=("clawsec-feed" "openclaw-audit-watchdog" "soul-guardian")
|
||||
|
||||
for skill in "${BUNDLE_SKILLS[@]}"; do
|
||||
if [ -d "skills/$skill" ]; then
|
||||
echo "Bundling $skill..."
|
||||
mkdir -p "$SKILL_PATH/bundled/$skill"
|
||||
cp -r "skills/$skill"/* "$SKILL_PATH/bundled/$skill/"
|
||||
|
||||
# Verify skill.json exists
|
||||
if [ -f "$SKILL_PATH/bundled/$skill/skill.json" ]; then
|
||||
SKILL_VERSION=$(jq -r '.version' "$SKILL_PATH/bundled/$skill/skill.json")
|
||||
echo "✓ Bundled $skill v${SKILL_VERSION}"
|
||||
else
|
||||
echo "ERROR: $skill/skill.json not found"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "WARNING: skills/$skill not found, skipping..."
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Bundling complete"
|
||||
|
||||
- name: Create .skill package
|
||||
run: |
|
||||
SKILL_NAME="${{ steps.parse.outputs.skill_name }}"
|
||||
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
|
||||
|
||||
cd "$SKILL_PATH"
|
||||
|
||||
# Create zip starting with skill.json
|
||||
zip -q "../../dist/${SKILL_NAME}.skill" skill.json
|
||||
|
||||
# Add each SBOM file individually to preserve directory structure
|
||||
while IFS= read -r file; do
|
||||
if [ -f "$file" ]; then
|
||||
zip -qu "../../dist/${SKILL_NAME}.skill" "$file"
|
||||
echo "Added: $file"
|
||||
else
|
||||
echo "Warning: SBOM file not found: $file"
|
||||
fi
|
||||
done < <(jq -r '.sbom.files[].path' skill.json)
|
||||
|
||||
# Add README if it exists
|
||||
if [ -f README.md ]; then
|
||||
zip -qu "../../dist/${SKILL_NAME}.skill" README.md
|
||||
echo "Added: README.md"
|
||||
# --- Copy root-level docs alongside the zip ---
|
||||
if [ -f "$SKILL_PATH/SKILL.md" ]; then
|
||||
cp "$SKILL_PATH/SKILL.md" release-assets/
|
||||
fi
|
||||
|
||||
cd ../..
|
||||
|
||||
echo "=== Created ${SKILL_NAME}.skill ==="
|
||||
unzip -l "dist/${SKILL_NAME}.skill"
|
||||
|
||||
- name: Add .skill checksum and finalize checksums.json
|
||||
run: |
|
||||
SKILL_NAME="${{ steps.parse.outputs.skill_name }}"
|
||||
SKILL_PACKAGE="dist/${SKILL_NAME}.skill"
|
||||
|
||||
# Calculate .skill package checksum
|
||||
SKILL_PACKAGE_SHA=$(sha256sum "$SKILL_PACKAGE" | awk '{print $1}')
|
||||
SKILL_PACKAGE_SIZE=$(stat -c%s "$SKILL_PACKAGE" 2>/dev/null || stat -f%z "$SKILL_PACKAGE")
|
||||
|
||||
# Add .skill package entry to checksums
|
||||
cat >> "dist/checksums.json" << SKILLPACKAGE
|
||||
,
|
||||
"${SKILL_NAME}.skill": {
|
||||
"sha256": "${SKILL_PACKAGE_SHA}",
|
||||
"size": ${SKILL_PACKAGE_SIZE},
|
||||
"url": "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${SKILL_NAME}.skill"
|
||||
}
|
||||
SKILLPACKAGE
|
||||
|
||||
# Close JSON
|
||||
cat >> "dist/checksums.json" << EOF
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "=== Final checksums.json ==="
|
||||
cat "dist/checksums.json"
|
||||
|
||||
- name: Prepare release assets
|
||||
run: |
|
||||
SKILL_NAME="${{ steps.parse.outputs.skill_name }}"
|
||||
SKILL_PATH="${{ steps.parse.outputs.skill_path }}"
|
||||
|
||||
mkdir -p release-assets
|
||||
|
||||
# Copy individual SBOM files
|
||||
TEMPFILE=$(mktemp)
|
||||
jq -r '.sbom.files[].path' "$SKILL_PATH/skill.json" > "$TEMPFILE"
|
||||
|
||||
while IFS= read -r file; do
|
||||
# Skip bundled files - they're only for the .skill package
|
||||
if [[ "$file" == bundled/* ]]; then
|
||||
echo "Skipping bundled file: $file"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -f "$SKILL_PATH/$file" ]; then
|
||||
# Flatten directory structure for release assets
|
||||
cp "$SKILL_PATH/$file" "release-assets/$(basename "$file")"
|
||||
echo "Added: $(basename "$file")"
|
||||
fi
|
||||
done < "$TEMPFILE"
|
||||
|
||||
# Copy metadata files
|
||||
cp "$SKILL_PATH/skill.json" release-assets/
|
||||
|
||||
# Copy README if exists
|
||||
if [ -f "$SKILL_PATH/README.md" ]; then
|
||||
cp "$SKILL_PATH/README.md" release-assets/
|
||||
fi
|
||||
|
||||
# Copy package and checksums
|
||||
cp "dist/${SKILL_NAME}.skill" release-assets/
|
||||
cp "dist/checksums.json" release-assets/
|
||||
rm -rf "$STAGING_DIR"
|
||||
|
||||
echo "=== checksums.json ==="
|
||||
jq . "release-assets/checksums.json"
|
||||
echo ""
|
||||
echo "=== Release assets ==="
|
||||
ls -la release-assets/
|
||||
|
||||
@@ -362,32 +628,35 @@ jobs:
|
||||
|
||||
### Quick Install
|
||||
|
||||
Download the complete skill package:
|
||||
**Via clawhub (recommended):**
|
||||
```bash
|
||||
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ steps.parse.outputs.skill_name }}.skill
|
||||
npx clawhub@latest install ${{ steps.parse.outputs.skill_name }}
|
||||
```
|
||||
|
||||
Or fetch the main skill file directly:
|
||||
**Manual download with verification:**
|
||||
```bash
|
||||
curl -sL https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/SKILL.md
|
||||
# 1. Download the release archive and checksums
|
||||
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip
|
||||
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json
|
||||
|
||||
# 2. Verify archive checksum
|
||||
echo "$(jq -r '.archive.sha256' checksums.json) ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip" | sha256sum -c
|
||||
|
||||
# 3. Extract (creates ${{ steps.parse.outputs.skill_name }}/ directory)
|
||||
unzip ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
All files include SHA256 checksums. Download `checksums.json` and verify:
|
||||
All files include SHA256 checksums in `checksums.json`:
|
||||
```bash
|
||||
curl -sL https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json | jq .
|
||||
```
|
||||
|
||||
Verify a file:
|
||||
```bash
|
||||
sha256sum SKILL.md
|
||||
# Compare with value in checksums.json
|
||||
```
|
||||
|
||||
### Files
|
||||
|
||||
See `checksums.json` for the complete file manifest with SHA256 hashes.
|
||||
The zip archive preserves the full directory structure of the skill.
|
||||
|
||||
---
|
||||
*Released by ClawSec skill distribution pipeline*
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
.codex
|
||||
_bmad
|
||||
_bmad-output
|
||||
ext-docs
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
+59
-30
@@ -10,6 +10,7 @@ Thank you for your interest in contributing security skills to the ClawSec ecosy
|
||||
- [skill.json Reference](#skilljson-reference)
|
||||
- [Testing Your Skill](#testing-your-skill)
|
||||
- [Submission Process](#submission-process)
|
||||
- [Version Bump and Release Flow](#version-bump-and-release-flow)
|
||||
- [Review Criteria](#review-criteria)
|
||||
- [After Acceptance](#after-acceptance)
|
||||
- [Submitting Security Advisories](#submitting-security-advisories)
|
||||
@@ -49,7 +50,7 @@ git checkout -b skill/my-new-skill
|
||||
All skills distributed through ClawSec undergo security review and are hashed for agent verification. Trust is implicit:
|
||||
|
||||
- **Backend Verification**: Every skill is validated against checksums, SBOM manifests, and security policies
|
||||
- **Transparent Security**: SHA256 checksums, signature verification, and advisory feeds operate automatically
|
||||
- **Transparent Security**: SHA256 checksums, and advisory feeds operate automatically
|
||||
- **Contribution Flow**: Submit skills via PR → maintainer review → approval → release
|
||||
|
||||
|
||||
@@ -145,14 +146,22 @@ Create `skill.json` with the following structure:
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- Start with version `0.0.1`
|
||||
- Start with version `0.0.1` in both `skill.json` and `SKILL.md` frontmatter
|
||||
- List ALL files your skill needs in the SBOM
|
||||
|
||||
### Step 3: Create SKILL.md
|
||||
|
||||
This is the main documentation for your skill. Use this template:
|
||||
This is the main documentation for your skill. Include YAML frontmatter with a `version` that matches `skill.json`:
|
||||
|
||||
````markdown
|
||||
```markdown
|
||||
---
|
||||
name: my-skill-name
|
||||
version: 0.0.1
|
||||
description: Brief description of what your skill does
|
||||
metadata: {"openclaw":{"emoji":"🔒","category":"security"}}
|
||||
---
|
||||
|
||||
# My Skill Name
|
||||
|
||||
## Overview
|
||||
@@ -161,11 +170,7 @@ Brief description of what this skill does and why it's useful for AI agent secur
|
||||
|
||||
## Usage
|
||||
|
||||
How to use the skill:
|
||||
|
||||
```bash
|
||||
# Example commands or usage patterns
|
||||
```
|
||||
How to use the skill.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -182,25 +187,8 @@ How to use the skill:
|
||||
## Security Considerations
|
||||
|
||||
Important security notes about this skill.
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Basic Usage
|
||||
|
||||
Description and example output.
|
||||
|
||||
### Example 2: Advanced Usage
|
||||
|
||||
Description and example output.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Common issues and solutions.
|
||||
|
||||
## Contributing
|
||||
|
||||
How others can improve this skill.
|
||||
```
|
||||
````
|
||||
|
||||
### Step 4: Add Supporting Files
|
||||
|
||||
@@ -314,7 +302,8 @@ If your skill includes executable scripts or requires testing:
|
||||
|
||||
- [ ] All SBOM files exist
|
||||
- [ ] skill.json is valid JSON
|
||||
- [ ] Version is 1.0.0 for new skills
|
||||
- [ ] Version is `0.0.1` for new skills
|
||||
- [ ] `skill.json` version matches `SKILL.md` frontmatter version
|
||||
- [ ] No hardcoded credentials or secrets
|
||||
- [ ] Trigger phrases are descriptive
|
||||
- [ ] Required binaries are documented
|
||||
@@ -380,6 +369,39 @@ Any special considerations for reviewers.
|
||||
|
||||
---
|
||||
|
||||
## Version Bump and Release Flow
|
||||
|
||||
This repository uses a branch-first workflow for skill versions:
|
||||
|
||||
1. Make skill changes on a branch (`skill/<name>-...`).
|
||||
2. Keep versions in sync:
|
||||
- `skills/<skill>/skill.json` -> `.version`
|
||||
- `skills/<skill>/SKILL.md` -> frontmatter `version`
|
||||
3. For existing skills, you can bump versions on your branch with:
|
||||
|
||||
```bash
|
||||
./scripts/release-skill.sh <skill-name> <new-version>
|
||||
```
|
||||
|
||||
4. Push your branch and open a PR. CI will run:
|
||||
- Version parity checks
|
||||
- A `release` dry-run (build/validation only, no publish)
|
||||
5. Do **not** push release tags from PR branches.
|
||||
- `scripts/release-skill.sh` creates a local tag. Keep it local during PR review.
|
||||
- If you need to remove that local tag: `git tag -d <skill-name>-v<version>`
|
||||
6. After merge, a maintainer creates and pushes the release tag from `main`:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag -a <skill-name>-v<version> -m "<skill-name> version <version>"
|
||||
git push origin <skill-name>-v<version>
|
||||
```
|
||||
|
||||
7. Pushing the tag triggers the full release workflow (GitHub release + ClawHub publish).
|
||||
|
||||
---
|
||||
|
||||
## Review Criteria
|
||||
|
||||
Maintainers will review your skill based on:
|
||||
@@ -419,8 +441,8 @@ Once your skill is accepted:
|
||||
1. **Maintainers will:**
|
||||
- Review your PR (Prompt Security staff or designated maintainers)
|
||||
- Merge your PR after security review
|
||||
- Create the first release using `scripts/release-skill.sh`
|
||||
- Generate checksums and publish to GitHub Releases
|
||||
- Create and push a release tag from merged `main` (`<skill>-v<version>`)
|
||||
- Generate checksums and publish to GitHub Releases + ClawHub
|
||||
- Update the skills catalog website
|
||||
|
||||
2. **You'll be credited:**
|
||||
@@ -463,7 +485,7 @@ mkdir -p skills/simple-scanner
|
||||
cat > skills/simple-scanner/skill.json << 'EOF'
|
||||
{
|
||||
"name": "simple-scanner",
|
||||
"version": "0.0.1,
|
||||
"version": "0.0.1",
|
||||
"description": "Basic security scanner for AI agents",
|
||||
"author": "contributor-name",
|
||||
"license": "MIT",
|
||||
@@ -484,6 +506,13 @@ cat > skills/simple-scanner/skill.json << 'EOF'
|
||||
EOF
|
||||
|
||||
cat > skills/simple-scanner/SKILL.md << 'EOF'
|
||||
---
|
||||
name: simple-scanner
|
||||
version: 0.0.1
|
||||
description: Basic security scanner for AI agents
|
||||
metadata: {"openclaw":{"emoji":"🔍","category":"security"}}
|
||||
---
|
||||
|
||||
# Simple Scanner
|
||||
|
||||
A basic security scanner for AI agents.
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
<div align="center">
|
||||
|
||||

|
||||
<img src="./public/img/mascot.png" alt="clawsec mascot" width="200" />
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
🌐 **Live at: [https://clawsec.prompt.security](https://clawsec.prompt.security)**
|
||||
🌐 **Live at: [https://clawsec.prompt.security](https://clawsec.prompt.security) [https://prompt.security/clawsec](https://prompt.security/clawsec)**
|
||||
|
||||
[](https://github.com/prompt-security/clawsec/actions/workflows/ci.yml)
|
||||
[](https://github.com/prompt-security/clawsec/actions/workflows/deploy-pages.yml)
|
||||
@@ -41,7 +41,7 @@ ClawSec is a **complete security skill suite for the OpenClaw family of agents (
|
||||
- **🛡️ File Integrity Protection** - Drift detection and auto-restore for critical agent files (SOUL.md, IDENTITY.md, etc.)
|
||||
- **📡 Live Security Advisories** - Automated NVD CVE polling and community threat intelligence
|
||||
- **🔍 Security Audits** - Self-check scripts to detect prompt injection markers and vulnerabilities
|
||||
- **🔐 Checksum Verification** - SHA256 checksums for all skill artifacts via `.skill` packages
|
||||
- **🔐 Checksum Verification** - SHA256 checksums for all skill artifacts
|
||||
- **Health Checks** - Automated updates and integrity verification for all installed skills
|
||||
|
||||
---
|
||||
@@ -170,10 +170,9 @@ When a skill is tagged (e.g., `soul-guardian-v1.0.0`), the pipeline:
|
||||
|
||||
1. **Validates** - Checks `skill.json` version matches tag
|
||||
2. **Generates Checksums** - Creates `checksums.json` with SHA256 hashes for all SBOM files
|
||||
3. **Packages** - Creates `.skill` zip file with all required files
|
||||
4. **Releases** - Publishes to GitHub Releases with all artifacts
|
||||
5. **Supersedes Old Releases** - Marks older versions (same major) as pre-releases
|
||||
6. **Triggers Pages Update** - Refreshes the skills catalog on the website
|
||||
3. **Releases** - Publishes to GitHub Releases with all artifacts
|
||||
4. **Supersedes Old Releases** - Marks older versions (same major) as pre-releases
|
||||
5. **Triggers Pages Update** - Refreshes the skills catalog on the website
|
||||
|
||||
### Release Versioning & Superseding
|
||||
|
||||
@@ -194,7 +193,6 @@ When you release `skill-v0.0.2`, the previous `skill-v0.0.1` release is automati
|
||||
### Release Artifacts
|
||||
|
||||
Each skill release includes:
|
||||
- `<skill>.skill` - Packaged skill (zip format)
|
||||
- `checksums.json` - SHA256 hashes for integrity verification
|
||||
- `skill.json` - Skill metadata
|
||||
- `SKILL.md` - Main skill documentation
|
||||
@@ -220,16 +218,15 @@ Checks:
|
||||
- SBOM files exist and are readable
|
||||
- OpenClaw metadata is properly structured
|
||||
|
||||
### Skill Packager
|
||||
### Skill Checksums Generator
|
||||
|
||||
Creates a distributable `.skill` file with checksums:
|
||||
Generates `checksums.json` with SHA256 hashes for a skill:
|
||||
|
||||
```bash
|
||||
python utils/package_skill.py skills/clawsec-feed ./dist
|
||||
```
|
||||
|
||||
Outputs:
|
||||
- `clawsec-feed.skill` - Zip package with all SBOM files
|
||||
- `checksums.json` - SHA256 hashes for verification
|
||||
|
||||
---
|
||||
|
||||
+27
-27
@@ -1,12 +1,29 @@
|
||||
{
|
||||
"version": "0.0.2",
|
||||
"updated": "2026-02-05T12:53:37Z",
|
||||
"version": "0.0.3",
|
||||
"updated": "2026-02-08T18:42:58Z",
|
||||
"description": "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw-related CVEs from NVD and community-reported security incidents.",
|
||||
"advisories": [
|
||||
{
|
||||
"id": "CVE-2026-25593",
|
||||
"severity": "high",
|
||||
"type": "missing_authentication_for_critical_function",
|
||||
"nvd_category_id": "CWE-306",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use t...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use the Gateway WebSocket API to write config via config.apply and set unsafe cliPath values that were later used for command discovery, enabling command injection as the gateway user. This vulnerability is fixed in 2026.1.20.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-06T21:16:17.790",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g55j-c2v4-pjcg"
|
||||
],
|
||||
"cvss_score": 8.4,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25593"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25475",
|
||||
"severity": "medium",
|
||||
"type": "vulnerable_skill",
|
||||
"type": "exposure_of_sensitive_information",
|
||||
"nvd_category_id": "CWE-200",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/media/parse.ts allows arbitrary file paths including absolute paths, home directory paths, and directory traversal sequences. An agent can read any file on the system by outputting MEDIA:/path/to/file, exfiltrating sensitive data to the user/channel. This issue has been patched in version 2026.1.30.",
|
||||
"affected": [],
|
||||
@@ -21,7 +38,8 @@
|
||||
{
|
||||
"id": "CVE-2026-25157",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"type": "os_command_injection",
|
||||
"nvd_category_id": "CWE-78",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vu...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vulnerability via the Project Root Path in sshNodeCommand. The sshNodeCommand function constructed a shell script without properly escaping the user-supplied project path in an error message. When the cd command failed, the unescaped path was interpolated directly into an echo statement, allowing arbitrary command execution on the remote SSH host. The parseSSHTarget function did not validate that SSH target strings could not begin with a dash. An attacker-supplied target like -oProxyCommand=... would be interpreted as an SSH configuration flag rather than a hostname, allowing arbitrary command execution on the local machine. This issue has been patched in version 2026.1.29.",
|
||||
"affected": [],
|
||||
@@ -33,32 +51,13 @@
|
||||
"cvss_score": 7.7,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25157"
|
||||
},
|
||||
{
|
||||
"id": "CLAW-2026-0001",
|
||||
"severity": "high",
|
||||
"type": "prompt_injection",
|
||||
"title": "Data exfiltration attempt via helper-plus skill",
|
||||
"description": "The helper-plus skill was observed sending conversation data to an external server (suspicious-domain.com) on every invocation. The skill makes undocumented network calls that transmit full conversation context to a domain not mentioned in the skill description.",
|
||||
"affected": [
|
||||
"helper-plus@1.0.0",
|
||||
"helper-plus@1.0.1"
|
||||
],
|
||||
"action": "Remove helper-plus immediately. Do not use versions 1.0.0 or 1.0.1. Wait for a verified patched version.",
|
||||
"published": "2026-02-04T09:30:00Z",
|
||||
"references": [],
|
||||
"source": "Community Report",
|
||||
"github_issue_url": "https://github.com/prompt-security/clawsec/issues/1",
|
||||
"reporter": {
|
||||
"agent_name": "SecurityBot",
|
||||
"opener_type": "agent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-24763",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"type": "os_command_injection",
|
||||
"nvd_category_id": "CWE-78",
|
||||
"title": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026....",
|
||||
"description": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026.1.29, a command injection vulnerability existed in OpenClaw's Docker sandbox execution mechanism due to unsafe handling of the PATH environment variable when constructing shell commands. An authenticated user able to control environment variables could influence command execution within the container context. This vulnerability is fixed in 2026.1.29.",
|
||||
"description": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026.1.29, a command injection vulnerability existed in OpenClaw’s Docker sandbox execution mechanism due to unsafe handling of the PATH environment variable when constructing shell commands. An authenticated user able to control environment variables could influence command execution within the container context. This vulnerability is fixed in 2026.1.29.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-02T23:16:08.593",
|
||||
@@ -73,7 +72,8 @@
|
||||
{
|
||||
"id": "CVE-2026-25253",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"type": "incorrect_resource_transfer_between_spheres",
|
||||
"nvd_category_id": "CWE-669",
|
||||
"title": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string a...",
|
||||
"description": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string and automatically makes a WebSocket connection without prompting, sending a token value.",
|
||||
"affected": [],
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// ClawSec Suite SKILL.md URL - injected at build time, with hardcoded fallback
|
||||
export const SKILL_URL = import.meta.env.VITE_CLAWSEC_SUITE_URL ||
|
||||
'https://clawsec.prompt.security/releases/download/clawsec-suite-v0.0.2/SKILL.md';
|
||||
|
||||
// Feed URL for fetching live advisories
|
||||
export const ADVISORY_FEED_URL = 'https://clawsec.prompt.security/releases/latest/download/feed.json';
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
ClawSec
|
||||
Security skill suite for AI agents (integrity checks, drift detection, advisory feed).
|
||||
Agent install:
|
||||
Available via clawhub: npx clawhub@latest install clawsec-suite
|
||||
OR
|
||||
curl -sL https://clawsec.prompt.security/releases/latest/download/SKILL.md
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
|
||||
@@ -139,6 +141,8 @@
|
||||
ClawSec
|
||||
Security skill suite for AI agents (integrity checks, drift detection, advisory feed).
|
||||
Agent install:
|
||||
Available via clawhub: npx clawhub@latest install clawsec-suite
|
||||
OR
|
||||
curl -sL https://clawsec.prompt.security/releases/latest/download/SKILL.md
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
+17
-5
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { User, Bot, Copy, Check } from 'lucide-react';
|
||||
import { Footer } from '../components/Footer';
|
||||
import { SKILL_URL } from '../constants';
|
||||
|
||||
const FILE_NAMES = ['SOUL.md', 'AGENTS.md', 'USER.md', 'TOOLS.md', 'IDENTITY.md', 'HEARTBEAT.md', 'MEMORY.md'];
|
||||
|
||||
@@ -11,7 +10,7 @@ export const Home: React.FC = () => {
|
||||
const [copiedHuman, setCopiedHuman] = useState(false);
|
||||
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
||||
|
||||
const curlCommand = `curl -s ${SKILL_URL}`;
|
||||
const curlCommand = `npx clawhub@latest install clawsec-suite`;
|
||||
|
||||
// Rotate file names every 2-3 seconds
|
||||
useEffect(() => {
|
||||
@@ -21,7 +20,7 @@ export const Home: React.FC = () => {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const humanInstruction = `Read ${SKILL_URL} and follow the instructions to install this skill. all checksums verified with source`;
|
||||
const humanInstruction = `Please install clawsec-suite from clawhubnpx clawhub@latest install clawsec-suite`;
|
||||
|
||||
const handleCopyCurl = () => {
|
||||
navigator.clipboard.writeText(curlCommand);
|
||||
@@ -103,12 +102,17 @@ export const Home: React.FC = () => {
|
||||
background-color: rgb(191 107 42 / 0.15);
|
||||
}
|
||||
}
|
||||
@keyframes mascotHover {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-12px); }
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
|
||||
{/* Install Card with Toggle */}
|
||||
<section className="max-w-2xl mx-auto mb-16">
|
||||
<div className="bg-clawd-900 rounded-2xl border border-clawd-700 p-8">
|
||||
<section className="max-w-4xl mx-auto mb-16">
|
||||
<div className="flex items-center justify-center gap-8">
|
||||
<div className="bg-clawd-900 rounded-2xl border border-clawd-700 p-8 flex-1 max-w-2xl">
|
||||
{/* Toggle */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="inline-flex bg-clawd-800 rounded-lg p-1">
|
||||
@@ -207,8 +211,16 @@ export const Home: React.FC = () => {
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-xs text-gray-500 leading-relaxed">
|
||||
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
src="/img/mascot.png"
|
||||
alt="ClawSec mascot"
|
||||
className="hidden md:block w-48 flex-shrink-0"
|
||||
style={{ animation: 'mascotHover 3s ease-in-out infinite' }}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
|
||||
@@ -106,7 +106,7 @@ export const SkillDetail: React.FC = () => {
|
||||
};
|
||||
|
||||
const installCommand = skillData
|
||||
? `curl -sLO https://clawsec.prompt.security/releases/download/${skillData.name}-v${skillData.version}/${skillData.name}.skill`
|
||||
? `npx clawhub@latest install ${skillData.name}`
|
||||
: '';
|
||||
|
||||
const releasePageUrl = useMemo(() => {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 788 KiB |
@@ -141,8 +141,11 @@ jq --arg kw "$KEYWORDS_PATTERN" --arg gh "$GITHUB_REF_PATTERN" '
|
||||
FILTERED=$(jq 'length' "$TEMP_DIR/filtered_cves.json")
|
||||
echo "Filtered CVEs (matching criteria): $FILTERED"
|
||||
|
||||
# Get existing advisory IDs
|
||||
if [ -f "$FEED_PATH" ]; then
|
||||
# Get existing advisory IDs (unless force mode)
|
||||
if [ "$FORCE" = "true" ]; then
|
||||
echo "Force mode: ignoring existing advisory IDs during transform"
|
||||
EXISTING_IDS=""
|
||||
elif [ -f "$FEED_PATH" ]; then
|
||||
EXISTING_IDS=$(jq -r '.advisories[]?.id // empty' "$FEED_PATH" | sort -u)
|
||||
else
|
||||
EXISTING_IDS=""
|
||||
@@ -165,13 +168,82 @@ jq --argjson existing "$EXISTING_JSON" '
|
||||
.cve.metrics.cvssMetricV30[0]?.cvssData.baseScore //
|
||||
.cve.metrics.cvssMetricV2[0]?.cvssData.baseScore //
|
||||
null;
|
||||
|
||||
def nvd_category_raw:
|
||||
(
|
||||
[.cve.weaknesses[]?.description[]? | select(.lang == "en") | .value | strings | select(length > 0)]
|
||||
| unique
|
||||
| map(select(. != "NVD-CWE-noinfo" and . != "NVD-CWE-Other"))
|
||||
| .[0]
|
||||
);
|
||||
|
||||
def cwe_id:
|
||||
(
|
||||
nvd_category_raw
|
||||
| if . == null then null
|
||||
else (try (capture("^CWE-(?<id>[0-9]+)$").id) catch null)
|
||||
end
|
||||
);
|
||||
|
||||
def cwe_name_map($id):
|
||||
({
|
||||
"20": "improper_input_validation",
|
||||
"22": "path_traversal",
|
||||
"77": "command_injection",
|
||||
"78": "os_command_injection",
|
||||
"79": "cross_site_scripting",
|
||||
"89": "sql_injection",
|
||||
"94": "code_injection",
|
||||
"119": "memory_buffer_bounds_violation",
|
||||
"120": "classic_buffer_overflow",
|
||||
"125": "out_of_bounds_read",
|
||||
"134": "format_string_vulnerability",
|
||||
"200": "exposure_of_sensitive_information",
|
||||
"250": "execution_with_unnecessary_privileges",
|
||||
"269": "improper_privilege_management",
|
||||
"284": "improper_access_control",
|
||||
"285": "improper_authorization",
|
||||
"287": "improper_authentication",
|
||||
"295": "improper_certificate_validation",
|
||||
"306": "missing_authentication_for_critical_function",
|
||||
"319": "cleartext_transmission_of_sensitive_information",
|
||||
"326": "inadequate_encryption_strength",
|
||||
"327": "risky_cryptographic_algorithm",
|
||||
"352": "cross_site_request_forgery",
|
||||
"362": "race_condition",
|
||||
"400": "uncontrolled_resource_consumption",
|
||||
"416": "use_after_free",
|
||||
"434": "unrestricted_file_upload",
|
||||
"502": "deserialization_of_untrusted_data",
|
||||
"601": "open_redirect",
|
||||
"611": "xml_external_entity_injection",
|
||||
"639": "insecure_direct_object_reference",
|
||||
"668": "exposure_of_resource_to_wrong_sphere",
|
||||
"669": "incorrect_resource_transfer_between_spheres",
|
||||
"732": "incorrect_permission_assignment",
|
||||
"787": "out_of_bounds_write",
|
||||
"798": "hard_coded_credentials",
|
||||
"862": "missing_authorization",
|
||||
"863": "incorrect_authorization",
|
||||
"918": "server_side_request_forgery",
|
||||
"922": "insecure_storage_of_sensitive_information"
|
||||
}[$id]);
|
||||
|
||||
def nvd_category_name:
|
||||
(
|
||||
cwe_id as $id
|
||||
| if $id == null then "unspecified_weakness"
|
||||
else (cwe_name_map($id) // ("unknown_cwe_" + $id))
|
||||
end
|
||||
);
|
||||
|
||||
[.[] |
|
||||
select(.cve.id as $id | $existing | index($id) | not) |
|
||||
{
|
||||
id: .cve.id,
|
||||
severity: (get_cvss_score | map_severity),
|
||||
type: "vulnerable_skill",
|
||||
type: nvd_category_name,
|
||||
nvd_category_id: nvd_category_raw,
|
||||
title: (.cve.descriptions[] | select(.lang == "en") | .value | .[0:100] + (if length > 100 then "..." else "" end)),
|
||||
description: (.cve.descriptions[] | select(.lang == "en") | .value),
|
||||
affected: [.cve.configurations[]?.nodes[]?.cpeMatch[]?.criteria // empty] | unique | .[0:5],
|
||||
@@ -207,7 +279,20 @@ NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
if [ -f "$FEED_PATH" ]; then
|
||||
jq --argjson new "$(cat "$TEMP_DIR/new_advisories.json")" --arg now "$NOW" '
|
||||
.updated = $now |
|
||||
.advisories = (.advisories + $new | sort_by(.published) | reverse)
|
||||
# Merge by advisory ID so force mode can refresh existing CVEs without duplicates
|
||||
.advisories = (
|
||||
reduce (.advisories + $new)[] as $adv
|
||||
({};
|
||||
if ($adv.id // "") == "" then
|
||||
.
|
||||
else
|
||||
.[$adv.id] = $adv
|
||||
end
|
||||
)
|
||||
| [.[]]
|
||||
| sort_by(.published)
|
||||
| reverse
|
||||
)
|
||||
' "$FEED_PATH" > "$TEMP_DIR/updated_feed.json"
|
||||
else
|
||||
jq -n --argjson advisories "$(cat "$TEMP_DIR/new_advisories.json")" --arg now "$NOW" '{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
# populate-local-skills.sh
|
||||
# Builds local skills index from skills/ directory for development preview.
|
||||
# This mirrors the skill-release.yml pipeline exactly - generates real checksums and .skill packages.
|
||||
# This mirrors the skill-release.yml pipeline exactly - generates real checksums.
|
||||
#
|
||||
# Usage: ./scripts/populate-local-skills.sh
|
||||
|
||||
@@ -159,50 +159,6 @@ FILEENTRY
|
||||
}
|
||||
SKILLJSON
|
||||
|
||||
# === Create .skill package BEFORE closing checksums JSON ===
|
||||
SKILL_PACKAGE="$PUBLIC_SKILLS_DIR/$SKILL_NAME/${SKILL_NAME}.skill"
|
||||
|
||||
# Get files from SBOM and create zip
|
||||
pushd "$SKILL_DIR" > /dev/null
|
||||
|
||||
FILES=$(jq -r '.sbom.files[].path' skill.json 2>/dev/null | tr '\n' ' ')
|
||||
|
||||
if [ -n "$FILES" ]; then
|
||||
# Create zip with SBOM files + skill.json
|
||||
zip -r "$SKILL_PACKAGE" $FILES skill.json 2>/dev/null || true
|
||||
|
||||
# Add README if exists
|
||||
if [ -f README.md ]; then
|
||||
zip -u "$SKILL_PACKAGE" README.md 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -f "$SKILL_PACKAGE" ]; then
|
||||
PACKAGE_SIZE=$(stat -f%z "$SKILL_PACKAGE" 2>/dev/null || stat -c%s "$SKILL_PACKAGE")
|
||||
echo " ✓ Created: ${SKILL_NAME}.skill ($(( PACKAGE_SIZE / 1024 ))KB)"
|
||||
|
||||
# Add .skill package checksum
|
||||
if command -v sha256sum &> /dev/null; then
|
||||
SKILL_PACKAGE_SHA=$(sha256sum "$SKILL_PACKAGE" | awk '{print $1}')
|
||||
else
|
||||
SKILL_PACKAGE_SHA=$(shasum -a 256 "$SKILL_PACKAGE" | awk '{print $1}')
|
||||
fi
|
||||
|
||||
echo "," >> "$CHECKSUMS_FILE"
|
||||
cat >> "$CHECKSUMS_FILE" << SKILLPACKAGE
|
||||
"${SKILL_NAME}.skill": {
|
||||
"sha256": "$SKILL_PACKAGE_SHA",
|
||||
"size": $PACKAGE_SIZE,
|
||||
"url": "https://clawsec.prompt.security/releases/download/$TAG/${SKILL_NAME}.skill"
|
||||
}
|
||||
SKILLPACKAGE
|
||||
echo " ✓ Checksum: ${SKILL_NAME}.skill ($SKILL_PACKAGE_SHA)"
|
||||
fi
|
||||
else
|
||||
echo " ⚠️ No SBOM files, skipping .skill package"
|
||||
fi
|
||||
|
||||
popd > /dev/null
|
||||
|
||||
# Close checksums JSON
|
||||
cat >> "$CHECKSUMS_FILE" << EOF
|
||||
}
|
||||
|
||||
+101
-44
@@ -1,28 +1,59 @@
|
||||
#!/bin/bash
|
||||
# Usage: ./scripts/release-skill.sh <skill-name> <version>
|
||||
# Usage: ./scripts/release-skill.sh <skill-name> <version> [--force-tag]
|
||||
# Example: ./scripts/release-skill.sh clawsec-feed 1.1.0
|
||||
#
|
||||
# This script ensures version consistency by:
|
||||
# 1. Updating skill.json with the new version
|
||||
# 2. Updating any hardcoded version URLs in skill.json and SKILL.md
|
||||
# 3. Committing the changes
|
||||
# 4. Creating the git tag
|
||||
# 4. Creating the git tag (only on main/master branch)
|
||||
#
|
||||
# After running, push with: git push && git push origin <tag>
|
||||
# Branch-aware workflow:
|
||||
# Feature branch: Updates versions, commits, pushes → CI validates build
|
||||
# Main branch: Updates versions, commits, creates tag → push triggers release
|
||||
#
|
||||
# Use --force-tag to create a tag even when not on main/master.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_NAME="$1"
|
||||
VERSION="$2"
|
||||
SKILL_PATH="skills/$SKILL_NAME"
|
||||
# Parse arguments
|
||||
FORCE_TAG=false
|
||||
POSITIONAL_ARGS=()
|
||||
|
||||
# Validation
|
||||
if [ -z "$SKILL_NAME" ] || [ -z "$VERSION" ]; then
|
||||
echo "Usage: $0 <skill-name> <version>"
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--force-tag)
|
||||
FORCE_TAG=true
|
||||
;;
|
||||
*)
|
||||
POSITIONAL_ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "${#POSITIONAL_ARGS[@]}" -ne 2 ]; then
|
||||
echo "Usage: $0 <skill-name> <version> [--force-tag]"
|
||||
echo "Example: $0 clawsec-feed 1.1.0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SKILL_NAME="${POSITIONAL_ARGS[0]}"
|
||||
VERSION="${POSITIONAL_ARGS[1]}"
|
||||
SKILL_PATH="skills/$SKILL_NAME"
|
||||
|
||||
# Ensure we're on a branch (not detached HEAD) so release flow works from feature branches
|
||||
CURRENT_BRANCH="$(git symbolic-ref --quiet --short HEAD || true)"
|
||||
if [ -z "$CURRENT_BRANCH" ]; then
|
||||
echo "Error: Detached HEAD detected. Checkout a branch before running release." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine if we're on a release branch (main/master)
|
||||
IS_RELEASE_BRANCH=false
|
||||
if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then
|
||||
IS_RELEASE_BRANCH=true
|
||||
fi
|
||||
|
||||
# Security: Validate skill name to prevent path injection
|
||||
# Only allow lowercase alphanumeric characters and hyphens
|
||||
if ! [[ "$SKILL_NAME" =~ ^[a-z0-9-]+$ ]]; then
|
||||
@@ -44,12 +75,6 @@ fi
|
||||
|
||||
TAG="${SKILL_NAME}-v${VERSION}"
|
||||
|
||||
# Check if tag already exists
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Error: Tag $TAG already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for uncommitted changes in skill directory
|
||||
if ! git diff --quiet "$SKILL_PATH/" 2>/dev/null; then
|
||||
echo "Error: $SKILL_PATH/ has uncommitted changes. Please commit or stash them first."
|
||||
@@ -57,6 +82,12 @@ if ! git diff --quiet "$SKILL_PATH/" 2>/dev/null; then
|
||||
fi
|
||||
|
||||
echo "Releasing $SKILL_NAME version $VERSION"
|
||||
echo "Branch: $CURRENT_BRANCH"
|
||||
if [[ "$IS_RELEASE_BRANCH" == "true" || "$FORCE_TAG" == "true" ]]; then
|
||||
echo "Mode: Full release (will create tag)"
|
||||
else
|
||||
echo "Mode: Prep only (tag deferred until merge to main)"
|
||||
fi
|
||||
echo "======================================="
|
||||
|
||||
# Create a temporary directory for atomic operations
|
||||
@@ -186,36 +217,62 @@ if ! git commit -m "chore($SKILL_NAME): bump version to $VERSION"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Save commit SHA for recovery (in case tag creation fails)
|
||||
# Save commit SHA for recovery
|
||||
COMMIT_SHA=$(git rev-parse HEAD)
|
||||
echo "Committed: $COMMIT_SHA"
|
||||
|
||||
# Create annotated tag
|
||||
echo "Creating tag: $TAG"
|
||||
if ! git tag -a "$TAG" -m "$SKILL_NAME version $VERSION"; then
|
||||
echo "Error: Failed to create tag $TAG" >&2
|
||||
echo "" >&2
|
||||
echo "The commit has been created but NOT tagged:" >&2
|
||||
echo " Commit: $COMMIT_SHA" >&2
|
||||
echo "" >&2
|
||||
echo "Recovery options:" >&2
|
||||
echo " 1. Fix the issue and tag manually:" >&2
|
||||
echo " git tag -a '$TAG' -m '$SKILL_NAME version $VERSION' $COMMIT_SHA" >&2
|
||||
echo "" >&2
|
||||
echo " 2. Investigate why tagging failed:" >&2
|
||||
echo " - Check if tag exists: git tag -l '$TAG'" >&2
|
||||
echo " - Check permissions: ls -ld .git/refs/tags" >&2
|
||||
echo "" >&2
|
||||
echo " 3. To rollback the commit (if desired):" >&2
|
||||
echo " git reset --hard HEAD~1" >&2
|
||||
echo "" >&2
|
||||
echo "The commit has NOT been pushed. Fix the issue before pushing." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Create tag only on release branches (or if forced)
|
||||
if [[ "$IS_RELEASE_BRANCH" == "true" || "$FORCE_TAG" == "true" ]]; then
|
||||
# Check if tag already exists (only matters when we're creating one)
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Error: Tag $TAG already exists; rolling back last commit"
|
||||
git reset --hard HEAD~1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done! To release, push the commit and tag:"
|
||||
echo " git push && git push origin $TAG"
|
||||
echo ""
|
||||
echo "Or to undo:"
|
||||
echo " git reset --hard HEAD~1 && git tag -d $TAG"
|
||||
echo "Creating tag: $TAG"
|
||||
if ! git tag -a "$TAG" -m "$SKILL_NAME version $VERSION"; then
|
||||
echo "Error: Failed to create tag $TAG" >&2
|
||||
echo "" >&2
|
||||
echo "The commit has been created but NOT tagged:" >&2
|
||||
echo " Commit: $COMMIT_SHA" >&2
|
||||
echo "" >&2
|
||||
echo "Recovery options:" >&2
|
||||
echo " 1. Fix the issue and tag manually:" >&2
|
||||
echo " git tag -a '$TAG' -m '$SKILL_NAME version $VERSION' $COMMIT_SHA" >&2
|
||||
echo "" >&2
|
||||
echo " 2. Investigate why tagging failed:" >&2
|
||||
echo " - Check if tag exists: git tag -l '$TAG'" >&2
|
||||
echo " - Check permissions: ls -ld .git/refs/tags" >&2
|
||||
echo "" >&2
|
||||
echo " 3. To rollback the commit (if desired):" >&2
|
||||
echo " git reset --hard HEAD~1" >&2
|
||||
echo "" >&2
|
||||
echo "The commit has NOT been pushed. Fix the issue before pushing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done! To release, push the commit and tag:"
|
||||
echo " git push origin $CURRENT_BRANCH"
|
||||
echo " git push origin $TAG"
|
||||
echo ""
|
||||
echo "Or to undo:"
|
||||
echo " git reset --hard HEAD~1 && git tag -d $TAG"
|
||||
else
|
||||
# Feature branch: skip tagging, instruct user on next steps
|
||||
echo ""
|
||||
echo "Done! Version updated and committed (tag deferred)."
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Push your branch for CI validation:"
|
||||
echo " git push origin $CURRENT_BRANCH"
|
||||
echo ""
|
||||
echo " 2. After CI passes and PR is merged to main, create the tag:"
|
||||
echo " git checkout main && git pull"
|
||||
echo " git tag -a '$TAG' $COMMIT_SHA -m '$SKILL_NAME version $VERSION'"
|
||||
echo " git push origin $TAG"
|
||||
echo ""
|
||||
echo "Or to undo the version bump:"
|
||||
echo " git reset --hard HEAD~1"
|
||||
fi
|
||||
|
||||
@@ -45,8 +45,7 @@ get_release_assets() {
|
||||
# Always included
|
||||
assets+=("skill.json")
|
||||
assets+=("checksums.json")
|
||||
assets+=("${skill_name}.skill")
|
||||
|
||||
|
||||
# README if exists
|
||||
if [ -f "$skill_path/README.md" ]; then
|
||||
assets+=("README.md")
|
||||
@@ -151,12 +150,6 @@ validate_skill() {
|
||||
fi
|
||||
done < <(extract_all_referenced_files "$skill_path/SKILL.md")
|
||||
|
||||
# Check for common patterns that reference this skill
|
||||
if grep -qE "/${skill_name}\.skill" "$skill_path/SKILL.md"; then
|
||||
if printf '%s\n' "${RELEASE_ASSETS[@]}" | grep -q "^${skill_name}.skill$"; then
|
||||
echo -e " ${GREEN}✓${NC} ${skill_name}.skill reference found and will be created"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
@@ -199,7 +192,7 @@ validate_skill() {
|
||||
for doc in "$other_skill_dir"/*.md; do
|
||||
[ -f "$doc" ] || continue
|
||||
|
||||
if grep -qE "/${skill_name}\.skill|/${skill_name}-v" "$doc" 2>/dev/null; then
|
||||
if grep -qE "/${skill_name}-v" "$doc" 2>/dev/null; then
|
||||
echo -e " → Referenced by ${other_skill}/$(basename "$doc")"
|
||||
cross_refs_found=true
|
||||
fi
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: clawsec-feed
|
||||
version: 0.0.2
|
||||
version: 0.0.4
|
||||
description: Security advisory feed with automated NVD CVE polling for OpenClaw-related vulnerabilities. Updated daily.
|
||||
homepage: https://clawsec.prompt.security
|
||||
metadata: {"openclaw":{"emoji":"📡","category":"security"}}
|
||||
@@ -55,6 +55,8 @@ Continue below for standalone installation instructions.
|
||||
---
|
||||
|
||||
Installation Steps:
|
||||
Agents should review scripts before execution!
|
||||
|
||||
|
||||
```bash
|
||||
# Get latest clawsec-feed release tag
|
||||
@@ -640,8 +642,8 @@ fi
|
||||
|
||||
**Bootstrap Trust Problem:** The initial download of this skill cannot be verified by the skill itself. To establish trust:
|
||||
|
||||
1. **Verify the source URL** - Ensure you are downloading from `github.com/prompt-security/clawsec`
|
||||
2. **Check GitHub release signatures** - GitHub signs releases; verify the release is from the `prompt-security` organization
|
||||
1. **Verify the source URL** - Ensure you are downloading from `https://clawsec.prompt.security`
|
||||
2. **Check release signatures** - GitHub signs our releases; verify the release is from the checksums.
|
||||
3. **Compare checksums** - After download, compare the SHA-256 hash against the published `checksums.json`:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
{
|
||||
"version": "0.0.2",
|
||||
"updated": "2026-02-05T12:53:37Z",
|
||||
"updated": "2026-02-08T06:16:28Z",
|
||||
"description": "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw-related CVEs from NVD and community-reported security incidents.",
|
||||
"advisories": [
|
||||
{
|
||||
"id": "CVE-2026-25593",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use t...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use the Gateway WebSocket API to write config via config.apply and set unsafe cliPath values that were later used for command discovery, enabling command injection as the gateway user. This vulnerability is fixed in 2026.1.20.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-06T21:16:17.790",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g55j-c2v4-pjcg"
|
||||
],
|
||||
"cvss_score": 8.4,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25593"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25475",
|
||||
"severity": "medium",
|
||||
@@ -58,7 +73,7 @@
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026....",
|
||||
"description": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026.1.29, a command injection vulnerability existed in OpenClaw's Docker sandbox execution mechanism due to unsafe handling of the PATH environment variable when constructing shell commands. An authenticated user able to control environment variables could influence command execution within the container context. This vulnerability is fixed in 2026.1.29.",
|
||||
"description": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026.1.29, a command injection vulnerability existed in OpenClaw’s Docker sandbox execution mechanism due to unsafe handling of the PATH environment variable when constructing shell commands. An authenticated user able to control environment variables could influence command execution within the container context. This vulnerability is fixed in 2026.1.29.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-02T23:16:08.593",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawsec-feed",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.4",
|
||||
"description": "Security advisory feed monitoring for AI agents. Subscribe to community-driven threat intelligence.",
|
||||
"author": "prompt-security",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
# ClawSec Suite — Cron Check
|
||||
# ClawSec Suite — Heartbeat Check
|
||||
|
||||
Run this on a schedule (cron/systemd/CI/agent scheduler). It is written to be portable: it assumes only POSIX shell + curl + a SHA tool.
|
||||
Run this periodically (cron/systemd/CI/agent scheduler). It assumes POSIX shell, `curl`, and `jq`.
|
||||
|
||||
## Goals
|
||||
|
||||
1) Check whether ClawSec Suite has an update available
|
||||
2) Verify integrity of the installed suite package
|
||||
|
||||
> Design note: Uses the **checksums.json** file from the latest release, which contains version info and SHA256 hashes. Avoids reliance on a separate catalog manifest.
|
||||
1. Check whether `clawsec-suite` has an update available.
|
||||
2. Poll the advisory feed.
|
||||
3. Report new advisories, highlight affected installed skills, and require approval before removal actions.
|
||||
|
||||
---
|
||||
|
||||
@@ -17,6 +16,9 @@ Run this on a schedule (cron/systemd/CI/agent scheduler). It is written to be po
|
||||
INSTALL_ROOT="${INSTALL_ROOT:-$HOME/.openclaw/skills}"
|
||||
SUITE_DIR="$INSTALL_ROOT/clawsec-suite"
|
||||
CHECKSUMS_URL="${CHECKSUMS_URL:-https://clawsec.prompt.security/releases/latest/download/checksums.json}"
|
||||
FEED_URL="${CLAWSEC_FEED_URL:-https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json}"
|
||||
STATE_FILE="${CLAWSEC_SUITE_STATE_FILE:-$HOME/.openclaw/clawsec-suite-feed-state.json}"
|
||||
MIN_FEED_INTERVAL_SECONDS="${MIN_FEED_INTERVAL_SECONDS:-300}"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -29,100 +31,171 @@ set -euo pipefail
|
||||
test -d "$SUITE_DIR"
|
||||
test -f "$SUITE_DIR/skill.json"
|
||||
|
||||
echo "=== ClawSec update Check ==="
|
||||
echo "When: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "Where: $SUITE_DIR"
|
||||
echo "=== ClawSec Suite Heartbeat ==="
|
||||
echo "When: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "Suite: $SUITE_DIR"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Verify the currently installed suite files (local integrity)
|
||||
|
||||
This step is only meaningful if you ship a checksums file *inside* the suite directory (recommended).
|
||||
|
||||
If present, verify it:
|
||||
|
||||
```bash
|
||||
if [ -f "$SUITE_DIR/checksums.txt" ]; then
|
||||
echo "Verifying local checksums.txt"
|
||||
cd "$SUITE_DIR"
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 -c checksums.txt
|
||||
else
|
||||
sha256sum -c checksums.txt
|
||||
fi
|
||||
else
|
||||
echo "NOTE: No local checksums.txt shipped; skipping local integrity verification"
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1.5 — Verify Bundled Components
|
||||
|
||||
Check that bundled security skills are properly deployed:
|
||||
|
||||
```bash
|
||||
INSTALL_ROOT="${INSTALL_ROOT:-$HOME/.openclaw/skills}"
|
||||
SUITE_DIR="$INSTALL_ROOT/clawsec-suite"
|
||||
|
||||
# Function to check bundled skill
|
||||
check_bundled_skill() {
|
||||
local skill_name="$1"
|
||||
local skill_dir="$INSTALL_ROOT/$skill_name"
|
||||
local bundled_dir="$SUITE_DIR/bundled/$skill_name"
|
||||
|
||||
if [ -d "$skill_dir" ] && [ -f "$skill_dir/skill.json" ]; then
|
||||
SKILL_VERSION=$(jq -r '.version' "$skill_dir/skill.json")
|
||||
echo "✓ $skill_name v${SKILL_VERSION} is installed"
|
||||
elif [ -d "$bundled_dir" ] && [ -f "$bundled_dir/skill.json" ]; then
|
||||
echo "⚠ $skill_name bundled but not deployed"
|
||||
echo " Deploy with: cp -r '$bundled_dir' '$skill_dir'"
|
||||
else
|
||||
echo "✗ $skill_name not found"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Bundled Skills Status ==="
|
||||
check_bundled_skill "clawsec-feed"
|
||||
check_bundled_skill "openclaw-audit-watchdog"
|
||||
check_bundled_skill "soul-guardian"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Check for updates (using checksums.json)
|
||||
|
||||
Fetch the latest checksums.json from the release mirror. This file contains version info and SHA256 hashes for all release assets.
|
||||
## Step 1 — Check suite version updates
|
||||
|
||||
```bash
|
||||
TMP="$(mktemp -d)"
|
||||
cd "$TMP"
|
||||
|
||||
curl -fsSLo checksums.json "$CHECKSUMS_URL"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
curl -fsSLo "$TMP/checksums.json" "$CHECKSUMS_URL"
|
||||
|
||||
INSTALLED_VER="$(jq -r '.version // ""' "$SUITE_DIR/skill.json" 2>/dev/null || true)"
|
||||
LATEST_VER="$(jq -r '.version // ""' checksums.json 2>/dev/null || true)"
|
||||
LATEST_VER="$(jq -r '.version // ""' "$TMP/checksums.json" 2>/dev/null || true)"
|
||||
|
||||
echo "Installed suite: ${INSTALLED_VER:-unknown}"
|
||||
echo "Latest suite: ${LATEST_VER:-unknown}"
|
||||
|
||||
if [ -n "$LATEST_VER" ] && [ "$LATEST_VER" != "$INSTALLED_VER" ]; then
|
||||
echo "UPDATE AVAILABLE: clawsec-suite ${INSTALLED_VER:-unknown} -> $LATEST_VER"
|
||||
echo "(Implement your runtime-specific update action here.)"
|
||||
else
|
||||
echo "Suite appears up to date."
|
||||
fi
|
||||
```
|
||||
|
||||
If your runtime does not have `jq`, you can parse the version line with grep/sed, or we can publish a simpler `latest.txt` endpoint.
|
||||
---
|
||||
|
||||
## Step 2 — Initialize advisory state
|
||||
|
||||
```bash
|
||||
mkdir -p "$(dirname "$STATE_FILE")"
|
||||
|
||||
if [ ! -f "$STATE_FILE" ]; then
|
||||
echo '{"schema_version":"1.0","known_advisories":[],"last_feed_check":null,"last_feed_updated":null}' > "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
fi
|
||||
|
||||
if ! jq -e '.schema_version and .known_advisories' "$STATE_FILE" >/dev/null 2>&1; then
|
||||
echo "WARNING: Invalid state file, resetting: $STATE_FILE"
|
||||
cp "$STATE_FILE" "${STATE_FILE}.bak.$(date -u +%Y%m%d%H%M%S)" 2>/dev/null || true
|
||||
echo '{"schema_version":"1.0","known_advisories":[],"last_feed_check":null,"last_feed_updated":null}' > "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output
|
||||
## Step 3 — Advisory feed check (embedded clawsec-feed)
|
||||
|
||||
This heartbeat should print a short report suitable for being copied into an alert message:
|
||||
```bash
|
||||
now_epoch="$(date -u +%s)"
|
||||
last_check="$(jq -r '.last_feed_check // "1970-01-01T00:00:00Z"' "$STATE_FILE")"
|
||||
last_epoch="$(date -u -d "$last_check" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$last_check" +%s 2>/dev/null || echo 0)"
|
||||
|
||||
- suite version status
|
||||
- integrity status
|
||||
if [ $((now_epoch - last_epoch)) -lt "$MIN_FEED_INTERVAL_SECONDS" ]; then
|
||||
echo "Feed check skipped (rate limit: ${MIN_FEED_INTERVAL_SECONDS}s)."
|
||||
else
|
||||
FEED_TMP="$TMP/feed.json"
|
||||
FEED_SOURCE="$FEED_URL"
|
||||
|
||||
if ! curl -fsSLo "$FEED_TMP" "$FEED_URL"; then
|
||||
if [ -f "$SUITE_DIR/advisories/feed.json" ]; then
|
||||
cp "$SUITE_DIR/advisories/feed.json" "$FEED_TMP"
|
||||
FEED_SOURCE="$SUITE_DIR/advisories/feed.json (local fallback)"
|
||||
echo "WARNING: Remote feed unavailable, using local fallback."
|
||||
else
|
||||
echo "ERROR: Remote feed unavailable and no local fallback feed found."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! jq -e '.version and (.advisories | type == "array")' "$FEED_TMP" >/dev/null 2>&1; then
|
||||
echo "ERROR: Advisory feed has invalid format."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Feed source: $FEED_SOURCE"
|
||||
echo "Feed updated: $(jq -r '.updated // "unknown"' "$FEED_TMP")"
|
||||
|
||||
NEW_IDS_FILE="$TMP/new_ids.txt"
|
||||
jq -r --argfile state "$STATE_FILE" '($state.known_advisories // []) as $known | [.advisories[]?.id | select(. != null and ($known | index(.) | not))] | .[]?' "$FEED_TMP" > "$NEW_IDS_FILE"
|
||||
|
||||
if [ -s "$NEW_IDS_FILE" ]; then
|
||||
echo "New advisories:"
|
||||
while IFS= read -r id; do
|
||||
[ -z "$id" ] && continue
|
||||
jq -r --arg id "$id" '.advisories[] | select(.id == $id) | "- [\(.severity | ascii_upcase)] \(.id): \(.title)"' "$FEED_TMP"
|
||||
jq -r --arg id "$id" '.advisories[] | select(.id == $id) | " Action: \(.action // "Review advisory details")"' "$FEED_TMP"
|
||||
done < "$NEW_IDS_FILE"
|
||||
else
|
||||
echo "FEED_OK - no new advisories"
|
||||
fi
|
||||
|
||||
echo "Affected installed skills (if any):"
|
||||
found_affected=0
|
||||
removal_recommended=0
|
||||
for skill_path in "$INSTALL_ROOT"/*; do
|
||||
[ -d "$skill_path" ] || continue
|
||||
skill_name="$(basename "$skill_path")"
|
||||
|
||||
skill_hits="$(jq -r --arg skill_prefix "${skill_name}@" '
|
||||
[.advisories[]
|
||||
| select(any(.affected[]?; startswith($skill_prefix)))
|
||||
| "- [\(.severity | ascii_upcase)] \(.id): \(.title)\n Action: \(.action // "Review advisory details")"
|
||||
] | .[]?
|
||||
' "$FEED_TMP")"
|
||||
|
||||
if [ -n "$skill_hits" ]; then
|
||||
found_affected=1
|
||||
echo "- $skill_name is referenced by advisory feed entries"
|
||||
printf "%s\n" "$skill_hits"
|
||||
|
||||
if jq -e --arg skill_prefix "${skill_name}@" '
|
||||
any(
|
||||
.advisories[];
|
||||
any(.affected[]?; startswith($skill_prefix))
|
||||
and (
|
||||
((.type // "" | ascii_downcase) == "malicious_skill")
|
||||
or ((.title // "" | ascii_downcase | test("malicious|exfiltrat|backdoor|trojan|stealer")))
|
||||
or ((.description // "" | ascii_downcase | test("malicious|exfiltrat|backdoor|trojan|stealer")))
|
||||
or ((.action // "" | ascii_downcase | test("remove|uninstall|disable|do not use|quarantine")))
|
||||
)
|
||||
)
|
||||
' "$FEED_TMP" >/dev/null 2>&1; then
|
||||
removal_recommended=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found_affected" -eq 0 ]; then
|
||||
echo "- none"
|
||||
fi
|
||||
|
||||
if [ "$removal_recommended" -eq 1 ]; then
|
||||
echo "Approval required: ask the user for explicit approval before removing any skill."
|
||||
echo "Double-confirmation policy: install request is first intent; require a second explicit confirmation with advisory context."
|
||||
fi
|
||||
|
||||
# Persist state
|
||||
current_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
state_tmp="$TMP/state.json"
|
||||
|
||||
jq --arg t "$current_utc" --arg updated "$(jq -r '.updated // ""' "$FEED_TMP")" --argfile feed "$FEED_TMP" '
|
||||
.last_feed_check = $t
|
||||
| .last_feed_updated = (if $updated == "" then .last_feed_updated else $updated end)
|
||||
| .known_advisories = ((.known_advisories // []) + [$feed.advisories[]?.id] | map(select(. != null)) | unique)
|
||||
' "$STATE_FILE" > "$state_tmp"
|
||||
|
||||
mv "$state_tmp" "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output Summary
|
||||
|
||||
Heartbeat output should include:
|
||||
- suite version status,
|
||||
- advisory feed status,
|
||||
- new advisory list (if any),
|
||||
- installed skills that appear in advisory `affected` lists,
|
||||
- and a double-confirmation reminder before risky install/remove actions.
|
||||
|
||||
If your runtime sends alerts, treat `critical` and `high` advisories affecting installed skills as immediate notifications.
|
||||
|
||||
+211
-318
@@ -1,358 +1,251 @@
|
||||
---
|
||||
name: clawsec-suite
|
||||
version: 0.0.3
|
||||
description: Use when users or agents want to explore or set up ClawSec security protections - this is a wrapper that helps discover and install individual security skills, but requires initial configuration to activate any protections.
|
||||
version: 0.0.8
|
||||
description: ClawSec suite manager with embedded advisory-feed monitoring, approval-gated malicious-skill response, and guided setup for additional security skills.
|
||||
homepage: https://clawsec.prompt.security
|
||||
clawdis:
|
||||
emoji: "📦"
|
||||
requires:
|
||||
bins: [curl, shasum]
|
||||
bins: [curl, jq, shasum]
|
||||
---
|
||||
|
||||
# ClawSec Suite
|
||||
|
||||
## Installation steps
|
||||
This means `clawsec-suite` can:
|
||||
- monitor the ClawSec advisory feed,
|
||||
- track which advisories are new since last check,
|
||||
- cross-reference advisories against locally installed skills,
|
||||
- recommend removal for malicious-skill advisories and require explicit user approval first,
|
||||
- and still act as the setup/management entrypoint for other ClawSec protections.
|
||||
|
||||
**IMPORTANT**: ClawSec Suite includes bundled security skills that provide crucial threat intelligence and monitoring capabilities. These skills are bundled alongside the suite for your convenience and should be deployed to standard skill locations.
|
||||
## Included vs Optional Protections
|
||||
|
||||
The suite includes the following bundled skills:
|
||||
- **clawsec-feed**: Provides crucial CVE and security advisory information
|
||||
- **openclaw-audit-watchdog**: Provides crucial automated security audit capabilities
|
||||
- **soul-guardian**: Provides file integrity and drift detection
|
||||
### Built into clawsec-suite
|
||||
- Embedded feed seed file: `advisories/feed.json`
|
||||
- Portable heartbeat workflow in `HEARTBEAT.md`
|
||||
- Advisory polling + state tracking + affected-skill checks
|
||||
- OpenClaw advisory guardian hook package: `hooks/clawsec-advisory-guardian/`
|
||||
- Setup scripts for hook and optional cron scheduling: `scripts/`
|
||||
- Guarded installer: `scripts/guarded_skill_install.mjs`
|
||||
|
||||
Finalize deployment with post-script after suite installation:
|
||||
### installed separately
|
||||
- `openclaw-audit-watchdog`
|
||||
- `soul-guardian`
|
||||
- `clawtributor` (explicit opt-in)
|
||||
|
||||
## Installation
|
||||
|
||||
### Option A: Via clawhub (recommended)
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# BUNDLED SKILLS DEPLOYMENT
|
||||
# These skills are bundled alongside the suite to provide crucial security information
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_ROOT="${INSTALL_ROOT:-$HOME/.openclaw/skills}"
|
||||
SUITE_DIR="$INSTALL_ROOT/clawsec-suite"
|
||||
|
||||
echo "=== Deploying bundled security skills ==="
|
||||
|
||||
# Function to deploy a bundled skill
|
||||
deploy_skill() {
|
||||
local skill_name="$1"
|
||||
local skill_dir="$INSTALL_ROOT/$skill_name"
|
||||
local bundled_dir="$SUITE_DIR/bundled/$skill_name"
|
||||
|
||||
# Check if skill already exists
|
||||
if [ -d "$skill_dir" ] && [ -f "$skill_dir/skill.json" ]; then
|
||||
INSTALLED_VERSION=$(jq -r '.version // "unknown"' "$skill_dir/skill.json" 2>/dev/null)
|
||||
echo "$skill_name already installed (v${INSTALLED_VERSION}), preserving existing installation"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check if bundled version exists
|
||||
if [ ! -d "$bundled_dir" ] || [ ! -f "$bundled_dir/skill.json" ]; then
|
||||
echo "Bundled $skill_name not found, skipping"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Deploy bundled skill
|
||||
echo "Deploying bundled $skill_name..."
|
||||
mkdir -p "$skill_dir"
|
||||
cp -r "$bundled_dir"/* "$skill_dir/"
|
||||
|
||||
# Set proper permissions
|
||||
chmod 600 "$skill_dir/skill.json"
|
||||
find "$skill_dir" -type f ! -name "skill.json" -exec chmod 644 {} \;
|
||||
|
||||
# Verify installation
|
||||
if [ -f "$skill_dir/skill.json" ]; then
|
||||
SKILL_VERSION=$(jq -r '.version' "$skill_dir/skill.json")
|
||||
echo "✓ $skill_name v${SKILL_VERSION} deployed successfully"
|
||||
else
|
||||
echo "✗ $skill_name deployment verification failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Deploy each bundled skill
|
||||
deploy_skill "clawsec-feed"
|
||||
deploy_skill "openclaw-audit-watchdog"
|
||||
deploy_skill "soul-guardian"
|
||||
|
||||
echo ""
|
||||
echo "✓ ClawSec Suite deployment complete"
|
||||
npx clawhub@latest install clawsec-suite
|
||||
```
|
||||
|
||||
### ✅ On Successful Installation (IMPORTANT)
|
||||
|
||||
On success the user should know: which skills were installed, that protections are active, and where to learn more if interested.
|
||||
Offered template:
|
||||
|
||||
```
|
||||
**ClawSec Suite installation is complete — you are now protected!**
|
||||
|
||||
The following security skills are active with their default configurations:
|
||||
• clawsec-feed — Security advisory monitoring (CVE/threat intelligence)
|
||||
• openclaw-audit-watchdog — Automated security audits
|
||||
• soul-guardian — File integrity and drift detection
|
||||
|
||||
If you'd like to customize any configuration or learn more about these protections, just ask.
|
||||
|
||||
Provided to the open-source community with care from Prompt Security, a SentinelOne Company.
|
||||
```
|
||||
|
||||
### ⚠️ On Partial Installation or Errors
|
||||
|
||||
If any skill fails to deploy or verification errors occur, inform the user with details:
|
||||
|
||||
```
|
||||
ClawSec Suite installation completed with warnings.
|
||||
|
||||
Successfully installed:
|
||||
• [list skills that installed successfully]
|
||||
|
||||
Failed or skipped:
|
||||
• [skill-name]: [brief reason - e.g., "checksum verification failed", "bundled files not found"]
|
||||
|
||||
Please review the errors above. You can retry installation or ask for help troubleshooting.
|
||||
|
||||
Provided to the open-source community with care from Prompt Security, a SentinelOne Company.
|
||||
```
|
||||
|
||||
## Contract (what this skill is)
|
||||
|
||||
ClawSec Suite is a small "manager" package that helps an agent runtime:
|
||||
|
||||
- install ClawSec security components
|
||||
- verify integrity of installed files (checksums + optional signatures)
|
||||
- check for updates from a signed checksums.json manifest
|
||||
- run a periodic "health check" (security skill updates + chain of supply integrity + advisories)
|
||||
|
||||
This document is written to be **portable**: it does not assume a specific agent framework or CLI. However it was tested to work best with OpenClaw, Moltbot, Clawdbot and variants
|
||||
|
||||
## 🎯 Let's Get You Protected
|
||||
|
||||
Great! You've installed the ClawSec Suite wrapper. Now let's get you set up with the security protections you came here for.
|
||||
|
||||
**Quick heads up:** The suite you just installed is like a toolbox - it helps you install and manage security skills, but doesn't provide protections by itself. Think of it as your security skill manager.
|
||||
|
||||
**Here's how to activate your protections:**
|
||||
|
||||
1. Browse the available security skills (see below, or browse to https://clawsec.prompt.security/)
|
||||
2. Choose which protections match your needs
|
||||
3. Install your selected skills using the installation guide
|
||||
4. Auto Configure defaults for each skill for your environment.
|
||||
|
||||
Each skill protects against different threats - from security advisories to file tampering. Let's explore your options and get you secured.
|
||||
|
||||
## Available Security Skills
|
||||
|
||||
The ClawSec suite includes these security skills:
|
||||
|
||||
| Skill | Protection Type | Installation |
|
||||
|-------|----------------|-------------|
|
||||
| **clawsec-feed** | Security advisory monitoring from NVD CVE database | ✓ Included by default |
|
||||
| **openclaw-audit-watchdog** | Automated daily security audits with email reporting | ✓ Included by default |
|
||||
| **soul-guardian** | File integrity & drift detection for agent workspace files | ✗ Optional |
|
||||
| **clawtributor** | Community incident reporting (shares anonymized data)* | ✗ Optional (Opt-in) |
|
||||
|
||||
**Clawtributor requires explicit consent** as it shares anonymized data with the community for collective threat intelligence.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Step 1: Review Available Skills
|
||||
Take a moment to review the security skills table above. Each skill provides a different layer of protection:
|
||||
|
||||
- **Threat awareness** (clawsec-feed): Stay informed about vulnerabilities [Included by default]
|
||||
- **Scheduled audits** (openclaw-audit-watchdog): Daily automated security reviews [Included by default]
|
||||
- **File integrity** (soul-guardian): Detect unauthorized changes to workspace files [Optional]
|
||||
- **Community intelligence** (clawtributor): Share and receive threat data [Optional - Opt-in]
|
||||
|
||||
### Step 2: Choose Your Protection Level
|
||||
|
||||
**default protections** (automatically installed):
|
||||
- clawsec-feed
|
||||
- openclaw-audit-watchdog
|
||||
|
||||
**Optional protections** (install as needed):
|
||||
- soul-guardian (file integrity and drift detection)
|
||||
- clawtributor (community threat intelligence - requires explicit consent for data sharing)
|
||||
|
||||
### Step 3: Bundled Security Skills
|
||||
|
||||
Good news! The following security skills are bundled alongside ClawSec Suite:
|
||||
|
||||
✓ **clawsec-feed** - Provides crucial CVE and threat intelligence information
|
||||
✓ **openclaw-audit-watchdog** - Provides automated security audit capabilities
|
||||
✓ **soul-guardian** - Provides file integrity and drift detection
|
||||
|
||||
These skills are included in the suite package for your convenience.
|
||||
|
||||
**Deployment options:**
|
||||
- If you don't already have these skills installed, they will be deployed from the bundled copies
|
||||
- If you already have any of these skills, your existing installations will be preserved
|
||||
- Each skill is installed to its standard location: `~/.openclaw/skills/{skill-name}/`
|
||||
|
||||
**What this means:**
|
||||
- Convenient setup - no separate downloads needed if you don't have them
|
||||
- Standard installation - follows skill conventions
|
||||
- Independent management - each skill can be configured separately
|
||||
- Always current - feed data is fetched online (always up-to-date)
|
||||
|
||||
### Step 4: Optional Opt-In Component
|
||||
|
||||
**clawtributor** (Community threat intelligence):
|
||||
- Share and receive anonymized threat data
|
||||
- Requires explicit opt-in and user consent
|
||||
- Not bundled with suite (available as separate download)
|
||||
- Installation instructions: https://clawsec.prompt.security/skills/clawtributor
|
||||
|
||||
### Step 5: Configure Each Skill
|
||||
|
||||
After installation, each skill requires configuration, to reduce security fatigue start with the defaults for soul guardian (and dont use email sending for audit reports in watchdog):
|
||||
|
||||
- **clawsec-feed**: Configure update frequency and alert preferences
|
||||
- **openclaw-audit-watchdog**: Set email addresses for audit reports
|
||||
- **soul-guardian**: Set baseline checksums for protected files
|
||||
- **clawtributor**: Provide explicit consent and configure data sharing preferences (if installed)
|
||||
|
||||
Refer to each skill's SKILL.md file for detailed configuration instructions. if the user wants to change the defaults.
|
||||
|
||||
## Trust, provenance, and safety
|
||||
|
||||
**Do not install anything you cannot verify.** A secure installer must:
|
||||
|
||||
1) download artifacts from a stable origin
|
||||
2) verify integrity (checksum)
|
||||
3) ideally verify authenticity (signature)
|
||||
4) fail closed (stop on any mismatch)
|
||||
|
||||
If your project does not yet publish signatures, publish at least:
|
||||
|
||||
- `checksums.json` (sha256 hashes + version metadata)
|
||||
|
||||
### Canonical release origin
|
||||
|
||||
All releases are published at:
|
||||
|
||||
- `https://clawsec.prompt.security/releases/download/<SKILL>-v<VERSION>/...`
|
||||
|
||||
Example for clawsec-suite v0.0.9:
|
||||
|
||||
- `https://clawsec.prompt.security/releases/download/clawsec-suite-v0.0.3/checksums.json`
|
||||
- `https://clawsec.prompt.security/releases/download/clawsec-suite-v0.0.3/clawsec-suite.skill`
|
||||
|
||||
## Installation (generic, version-pinned, verified)
|
||||
|
||||
### Requirements
|
||||
|
||||
- `curl`
|
||||
- `jq` (for parsing checksums.json)
|
||||
- `unzip`
|
||||
- a SHA-256 tool (`shasum -a 256` on macOS, or `sha256sum` on Linux)
|
||||
|
||||
### Install steps
|
||||
|
||||
Pick a stable install root:
|
||||
|
||||
- `INSTALL_ROOT` default: `~/.openclaw/skills`
|
||||
|
||||
> If your agent runtime has its own skills directory, set `INSTALL_ROOT` accordingly.
|
||||
### Option B: Manual download with verification
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${VERSION:-0.0.3}"
|
||||
VERSION="${SKILL_VERSION:?Set SKILL_VERSION (e.g. 0.0.8)}"
|
||||
INSTALL_ROOT="${INSTALL_ROOT:-$HOME/.openclaw/skills}"
|
||||
DEST="$INSTALL_ROOT/clawsec-suite"
|
||||
BASE="https://github.com/prompt-security/clawsec/releases/download/clawsec-suite-v${VERSION}"
|
||||
|
||||
BASE="https://clawsec.prompt.security/releases/download/clawsec-suite-v${VERSION}"
|
||||
TEMP_DIR="$(mktemp -d)"
|
||||
DOWNLOAD_DIR="$TEMP_DIR/downloads"
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
mkdir -p "$DOWNLOAD_DIR"
|
||||
|
||||
mkdir -p "$DEST"
|
||||
cd "$(mktemp -d)"
|
||||
|
||||
# 1) Download checksums.json and artifact
|
||||
curl -fsSL "$BASE/checksums.json" -o checksums.json
|
||||
curl -fsSL "$BASE/clawsec-suite.skill" -o clawsec-suite.skill
|
||||
|
||||
# 2) Extract expected checksum from checksums.json
|
||||
EXPECTED_SHA256=$(jq -r '.files["clawsec-suite.skill"].sha256' checksums.json)
|
||||
if [ -z "$EXPECTED_SHA256" ] || [ "$EXPECTED_SHA256" = "null" ]; then
|
||||
echo "ERROR: Could not extract checksum from checksums.json" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 3) Compute actual checksum
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
ACTUAL_SHA256=$(shasum -a 256 clawsec-suite.skill | awk '{print $1}')
|
||||
else
|
||||
ACTUAL_SHA256=$(sha256sum clawsec-suite.skill | awk '{print $1}')
|
||||
fi
|
||||
|
||||
# 4) Verify checksum (fail closed)
|
||||
if [ "$EXPECTED_SHA256" != "$ACTUAL_SHA256" ]; then
|
||||
echo "ERROR: Checksum mismatch!" >&2
|
||||
echo " Expected: $EXPECTED_SHA256" >&2
|
||||
echo " Actual: $ACTUAL_SHA256" >&2
|
||||
# 1) Download checksums manifest
|
||||
curl -fsSL "$BASE/checksums.json" -o "$TEMP_DIR/checksums.json"
|
||||
if ! jq -e '.skill and .version and .files' "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then
|
||||
echo "ERROR: Invalid checksums.json format" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Checksum verified: $ACTUAL_SHA256"
|
||||
|
||||
# 5) Install
|
||||
rm -rf "$DEST"/*
|
||||
unzip -oq clawsec-suite.skill -d "$DEST"
|
||||
# 2) Download every file listed in checksums and verify immediately
|
||||
DOWNLOAD_FAILED=0
|
||||
for file in $(jq -r '.files | keys[]' "$TEMP_DIR/checksums.json"); do
|
||||
FILE_URL="$(jq -r --arg f "$file" '.files[$f].url' "$TEMP_DIR/checksums.json")"
|
||||
EXPECTED="$(jq -r --arg f "$file" '.files[$f].sha256' "$TEMP_DIR/checksums.json")"
|
||||
|
||||
# 6) Sanity check
|
||||
test -f "$DEST/skill.json"
|
||||
test -f "$DEST/SKILL.md"
|
||||
test -f "$DEST/HEARTBEAT.md"
|
||||
if ! curl -fsSL "$FILE_URL" -o "$DOWNLOAD_DIR/$file"; then
|
||||
echo "ERROR: Download failed for $file" >&2
|
||||
DOWNLOAD_FAILED=1
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Installed ClawSec Suite v${VERSION} to: $DEST"
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
ACTUAL="$(shasum -a 256 "$DOWNLOAD_DIR/$file" | awk '{print $1}')"
|
||||
else
|
||||
ACTUAL="$(sha256sum "$DOWNLOAD_DIR/$file" | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||
echo "ERROR: Checksum mismatch for $file" >&2
|
||||
DOWNLOAD_FAILED=1
|
||||
else
|
||||
echo "Verified: $file"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$DOWNLOAD_FAILED" -eq 1 ]; then
|
||||
echo "ERROR: One or more files failed verification" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3) Install files using paths from checksums.json
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
REL_PATH="$(jq -r --arg f "$file" '.files[$f].path // $f' "$TEMP_DIR/checksums.json")"
|
||||
SRC_PATH="$DOWNLOAD_DIR/$file"
|
||||
DST_PATH="$DEST/$REL_PATH"
|
||||
|
||||
mkdir -p "$(dirname "$DST_PATH")"
|
||||
cp "$SRC_PATH" "$DST_PATH"
|
||||
done < <(jq -r '.files | keys[]' "$TEMP_DIR/checksums.json")
|
||||
|
||||
chmod 600 "$DEST/skill.json"
|
||||
find "$DEST" -type f ! -name "skill.json" -exec chmod 644 {} \;
|
||||
|
||||
echo "Installed clawsec-suite v${VERSION} to: $DEST"
|
||||
echo "Next step (OpenClaw): node \"$DEST/scripts/setup_advisory_hook.mjs\""
|
||||
```
|
||||
|
||||
### What this does (disclosure)
|
||||
## OpenClaw Automation (Hook + Optional Cron)
|
||||
|
||||
**Installing clawsec-suite:**
|
||||
- Writes only under: `$DEST` (default `~/.openclaw/skills/clawsec-suite`)
|
||||
- Makes network requests only to fetch the suite artifact + checksums (and optionally signatures)
|
||||
- Does **not** provide any security protections by itself - it's just the wrapper/manager
|
||||
- Does **not** auto-install any security skills - you choose which skills to install
|
||||
- Does **not** auto-enable telemetry/community reporting
|
||||
- Does **not** schedule anything automatically
|
||||
After installing the suite, enable the advisory guardian hook:
|
||||
|
||||
**To get actual security protections**, you need to install and configure individual security skills (see "Getting Started" above).
|
||||
|
||||
## Update checking (portable design)
|
||||
|
||||
Each release publishes a `checksums.json` file that contains version info and SHA256 hashes for all artifacts:
|
||||
|
||||
- `https://clawsec.prompt.security/releases/download/clawsec-suite-v<VERSION>/checksums.json`
|
||||
|
||||
|
||||
The checksums.json structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"skill": "clawsec-suite",
|
||||
"version": "0.0.3",
|
||||
"generated_at": "2026-02-04T23:42:57Z",
|
||||
"repository": "prompt-security/ClawSec",
|
||||
"tag": "clawsec-suite-v0.0.3",
|
||||
"files": {
|
||||
"clawsec-suite.skill": {
|
||||
"sha256": "339a4817aba054e6da5a6d838e2603d16592b43f6bdb7265d6b1918b22fe62cb",
|
||||
"size": 4870,
|
||||
"url": "https://clawsec.prompt.security/releases/download/clawsec-suite-v0.0.3/clawsec-suite.skill"
|
||||
}
|
||||
}
|
||||
}
|
||||
```bash
|
||||
SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite"
|
||||
node "$SUITE_DIR/scripts/setup_advisory_hook.mjs"
|
||||
```
|
||||
|
||||
To check for updates, compare the installed version against the latest `checksums.json`. See `HEARTBEAT.md` for the upgrade check procedure.
|
||||
Optional: create/update a periodic cron nudge (default every `6h`) that triggers a main-session advisory scan:
|
||||
|
||||
## Platform adapters (optional sections)
|
||||
```bash
|
||||
SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite"
|
||||
node "$SUITE_DIR/scripts/setup_advisory_cron.mjs"
|
||||
```
|
||||
|
||||
If you want this to work well everywhere, add short adapter sections that only map:
|
||||
What this adds:
|
||||
- scan on `agent:bootstrap` and `/new` (`command:new`),
|
||||
- compare advisory `affected` entries against installed skills,
|
||||
- notify when new matches appear,
|
||||
- and ask for explicit user approval before any removal flow.
|
||||
|
||||
- install directory
|
||||
- scheduler integration
|
||||
- message/alert delivery integration
|
||||
Restart the OpenClaw gateway after enabling the hook. Then run `/new` once to force an immediate scan in the next session context.
|
||||
|
||||
Keep the core verify/install/update logic identical.
|
||||
## Guarded Skill Install Flow (Double Confirmation)
|
||||
|
||||
When the user asks to install a skill, treat that as the first request and run a guarded install check:
|
||||
|
||||
```bash
|
||||
SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite"
|
||||
node "$SUITE_DIR/scripts/guarded_skill_install.mjs" --skill helper-plus --version 1.0.1
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- If no advisory match is found, install proceeds.
|
||||
- If advisory match is found, the script prints advisory context and exits with code `42`.
|
||||
- Then require an explicit second confirmation from the user and rerun with `--confirm-advisory`:
|
||||
|
||||
```bash
|
||||
node "$SUITE_DIR/scripts/guarded_skill_install.mjs" --skill helper-plus --version 1.0.1 --confirm-advisory
|
||||
```
|
||||
|
||||
This enforces:
|
||||
1. First confirmation: user asked to install.
|
||||
2. Second confirmation: user explicitly approves install after seeing advisory details.
|
||||
|
||||
## Embedded Advisory Feed Behavior
|
||||
|
||||
The embedded feed logic uses these defaults:
|
||||
|
||||
- Remote feed URL: `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json`
|
||||
- Local seed fallback: `~/.openclaw/skills/clawsec-suite/advisories/feed.json`
|
||||
- State file: `~/.openclaw/clawsec-suite-feed-state.json`
|
||||
- Hook rate-limit env (OpenClaw hook): `CLAWSEC_HOOK_INTERVAL_SECONDS` (default `300`)
|
||||
|
||||
### Quick feed check
|
||||
|
||||
```bash
|
||||
FEED_URL="${CLAWSEC_FEED_URL:-https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json}"
|
||||
STATE_FILE="${CLAWSEC_SUITE_STATE_FILE:-$HOME/.openclaw/clawsec-suite-feed-state.json}"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
if ! curl -fsSLo "$TMP/feed.json" "$FEED_URL"; then
|
||||
echo "ERROR: Failed to fetch advisory feed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! jq -e '.version and (.advisories | type == "array")' "$TMP/feed.json" >/dev/null; then
|
||||
echo "ERROR: Invalid advisory feed format"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$STATE_FILE")"
|
||||
if [ ! -f "$STATE_FILE" ]; then
|
||||
echo '{"schema_version":"1.0","known_advisories":[],"last_feed_check":null,"last_feed_updated":null}' > "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
fi
|
||||
|
||||
NEW_IDS_FILE="$TMP/new_ids.txt"
|
||||
jq -r --argfile state "$STATE_FILE" '($state.known_advisories // []) as $known | [.advisories[]?.id | select(. != null and ($known | index(.) | not))] | .[]?' "$TMP/feed.json" > "$NEW_IDS_FILE"
|
||||
|
||||
if [ -s "$NEW_IDS_FILE" ]; then
|
||||
echo "New advisories detected:"
|
||||
while IFS= read -r id; do
|
||||
[ -z "$id" ] && continue
|
||||
jq -r --arg id "$id" '.advisories[] | select(.id == $id) | "- [\(.severity | ascii_upcase)] \(.id): \(.title)"' "$TMP/feed.json"
|
||||
done < "$NEW_IDS_FILE"
|
||||
else
|
||||
echo "FEED_OK - no new advisories"
|
||||
fi
|
||||
```
|
||||
|
||||
## Heartbeat Integration
|
||||
|
||||
Use the suite heartbeat script as the single periodic security check entrypoint:
|
||||
|
||||
- `skills/clawsec-suite/HEARTBEAT.md`
|
||||
|
||||
It handles:
|
||||
- suite update checks,
|
||||
- feed polling,
|
||||
- new-advisory detection,
|
||||
- affected-skill cross-referencing,
|
||||
- approval-gated response guidance for malicious/removal advisories,
|
||||
- and persistent state updates.
|
||||
|
||||
## Approval-Gated Response Contract
|
||||
|
||||
If an advisory indicates a malicious or removal-recommended skill and that skill is installed:
|
||||
|
||||
1. Notify the user immediately with advisory details and severity.
|
||||
2. Recommend removing or disabling the affected skill.
|
||||
3. Treat the original install request as first intent only.
|
||||
4. Ask for explicit second confirmation before deletion/disable action (or before proceeding with risky install).
|
||||
5. Only proceed after that second confirmation.
|
||||
|
||||
The suite hook and heartbeat guidance are intentionally non-destructive by default.
|
||||
|
||||
## Optional Skill Installation
|
||||
|
||||
Install additional protections as needed:
|
||||
|
||||
```bash
|
||||
npx clawhub@latest install openclaw-audit-watchdog
|
||||
npx clawhub@latest install soul-guardian
|
||||
# opt-in only:
|
||||
npx clawhub@latest install clawtributor
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Always verify checksums before installing files manually.
|
||||
- Keep advisory polling rate-limited (at least 5 minutes between checks).
|
||||
- Treat `critical` and `high` advisories affecting installed skills as immediate action items.
|
||||
- If you migrate off standalone `clawsec-feed`, keep one canonical state file to avoid duplicate notifications.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"version": "0.0.2",
|
||||
"updated": "2026-02-08T06:16:28Z",
|
||||
"description": "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw-related CVEs from NVD and community-reported security incidents.",
|
||||
"advisories": [
|
||||
{
|
||||
"id": "CVE-2026-25593",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use t...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use the Gateway WebSocket API to write config via config.apply and set unsafe cliPath values that were later used for command discovery, enabling command injection as the gateway user. This vulnerability is fixed in 2026.1.20.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-06T21:16:17.790",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g55j-c2v4-pjcg"
|
||||
],
|
||||
"cvss_score": 8.4,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25593"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25475",
|
||||
"severity": "medium",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/media/parse.ts allows arbitrary file paths including absolute paths, home directory paths, and directory traversal sequences. An agent can read any file on the system by outputting MEDIA:/path/to/file, exfiltrating sensitive data to the user/channel. This issue has been patched in version 2026.1.30.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-04T20:16:07.287",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-r8g4-86fx-92mq"
|
||||
],
|
||||
"cvss_score": 6.5,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25475"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25157",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vu...",
|
||||
"description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vulnerability via the Project Root Path in sshNodeCommand. The sshNodeCommand function constructed a shell script without properly escaping the user-supplied project path in an error message. When the cd command failed, the unescaped path was interpolated directly into an echo statement, allowing arbitrary command execution on the remote SSH host. The parseSSHTarget function did not validate that SSH target strings could not begin with a dash. An attacker-supplied target like -oProxyCommand=... would be interpreted as an SSH configuration flag rather than a hostname, allowing arbitrary command execution on the local machine. This issue has been patched in version 2026.1.29.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-04T20:16:06.577",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-q284-4pvr-m585"
|
||||
],
|
||||
"cvss_score": 7.7,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25157"
|
||||
},
|
||||
{
|
||||
"id": "CLAW-2026-0001",
|
||||
"severity": "high",
|
||||
"type": "prompt_injection",
|
||||
"title": "Data exfiltration attempt via helper-plus skill",
|
||||
"description": "The helper-plus skill was observed sending conversation data to an external server (suspicious-domain.com) on every invocation. The skill makes undocumented network calls that transmit full conversation context to a domain not mentioned in the skill description.",
|
||||
"affected": [
|
||||
"helper-plus@1.0.0",
|
||||
"helper-plus@1.0.1"
|
||||
],
|
||||
"action": "Remove helper-plus immediately. Do not use versions 1.0.0 or 1.0.1. Wait for a verified patched version.",
|
||||
"published": "2026-02-04T09:30:00Z",
|
||||
"references": [],
|
||||
"source": "Community Report",
|
||||
"github_issue_url": "https://github.com/prompt-security/clawsec/issues/1",
|
||||
"reporter": {
|
||||
"agent_name": "SecurityBot",
|
||||
"opener_type": "agent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-24763",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026....",
|
||||
"description": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026.1.29, a command injection vulnerability existed in OpenClaw’s Docker sandbox execution mechanism due to unsafe handling of the PATH environment variable when constructing shell commands. An authenticated user able to control environment variables could influence command execution within the container context. This vulnerability is fixed in 2026.1.29.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-02T23:16:08.593",
|
||||
"references": [
|
||||
"https://github.com/openclaw/openclaw/commit/771f23d36b95ec2204cc9a0054045f5d8439ea75",
|
||||
"https://github.com/openclaw/openclaw/releases/tag/v2026.1.29",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-mc68-q9jw-2h3v"
|
||||
],
|
||||
"cvss_score": 8.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-24763"
|
||||
},
|
||||
{
|
||||
"id": "CVE-2026-25253",
|
||||
"severity": "high",
|
||||
"type": "vulnerable_skill",
|
||||
"title": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string a...",
|
||||
"description": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string and automatically makes a WebSocket connection without prompting, sending a token value.",
|
||||
"affected": [],
|
||||
"action": "Review and update affected components. See NVD for remediation details.",
|
||||
"published": "2026-02-01T23:15:49.717",
|
||||
"references": [
|
||||
"https://depthfirst.com/post/1-click-rce-to-steal-your-moltbot-data-and-keys",
|
||||
"https://ethiack.com/news/blog/one-click-rce-moltbot",
|
||||
"https://github.com/openclaw/openclaw/security/advisories/GHSA-g8p2-7wf7-98mq"
|
||||
],
|
||||
"cvss_score": 8.8,
|
||||
"nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25253"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: clawsec-advisory-guardian
|
||||
description: Detect advisory matches for installed skills and require explicit user approval before any removal action.
|
||||
metadata: { "openclaw": { "events": ["agent:bootstrap", "command:new"] } }
|
||||
---
|
||||
|
||||
# ClawSec Advisory Guardian Hook
|
||||
|
||||
This hook checks the ClawSec advisory feed against locally installed skills on:
|
||||
|
||||
- `agent:bootstrap`
|
||||
- `command:new`
|
||||
|
||||
When it detects an advisory affecting an installed skill, it posts an alert message.
|
||||
If the advisory looks malicious or removal-oriented, it explicitly recommends removal
|
||||
and asks for user approval first.
|
||||
|
||||
## Safety Contract
|
||||
|
||||
- The hook does not delete or modify skills.
|
||||
- It only reports findings and requests explicit approval before removal.
|
||||
- Alerts are deduplicated using `~/.openclaw/clawsec-suite-feed-state.json`.
|
||||
|
||||
## Optional Environment Variables
|
||||
|
||||
- `CLAWSEC_FEED_URL`: override remote feed URL.
|
||||
- `CLAWSEC_LOCAL_FEED`: override local fallback feed file.
|
||||
- `CLAWSEC_SUITE_STATE_FILE`: override state file path.
|
||||
- `CLAWSEC_INSTALL_ROOT`: override installed skills root.
|
||||
- `CLAWSEC_SUITE_DIR`: override clawsec-suite install path.
|
||||
- `CLAWSEC_HOOK_INTERVAL_SECONDS`: minimum interval between hook scans (default `300`).
|
||||
@@ -0,0 +1,133 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { uniqueStrings } from "./lib/utils.mjs";
|
||||
import { isValidFeedPayload, loadRemoteFeed } from "./lib/feed.mjs";
|
||||
import type { HookEvent, FeedPayload, AdvisoryMatch } from "./lib/types.ts";
|
||||
import { loadState, persistState } from "./lib/state.ts";
|
||||
import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } from "./lib/matching.ts";
|
||||
|
||||
const DEFAULT_FEED_URL =
|
||||
"https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json";
|
||||
const DEFAULT_SCAN_INTERVAL_SECONDS = 300;
|
||||
|
||||
function expandHome(inputPath: string): string {
|
||||
if (!inputPath) return inputPath;
|
||||
if (inputPath === "~") return os.homedir();
|
||||
if (inputPath.startsWith("~/")) return path.join(os.homedir(), inputPath.slice(2));
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function toEventName(event: HookEvent): string {
|
||||
const eventType = String(event.type ?? "").trim();
|
||||
const action = String(event.action ?? "").trim();
|
||||
if (!eventType || !action) return "";
|
||||
return `${eventType}:${action}`;
|
||||
}
|
||||
|
||||
function shouldHandleEvent(event: HookEvent): boolean {
|
||||
const eventName = toEventName(event);
|
||||
return eventName === "agent:bootstrap" || eventName === "command:new";
|
||||
}
|
||||
|
||||
function epochMs(isoTimestamp: string | null): number {
|
||||
if (!isoTimestamp) return 0;
|
||||
const parsed = Date.parse(isoTimestamp);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function scannedRecently(lastScan: string | null, minIntervalSeconds: number): boolean {
|
||||
const sinceMs = Date.now() - epochMs(lastScan);
|
||||
return sinceMs >= 0 && sinceMs < minIntervalSeconds * 1000;
|
||||
}
|
||||
|
||||
async function loadFeed(feedUrl: string, localFeedPath: string): Promise<FeedPayload> {
|
||||
const remoteFeed = await loadRemoteFeed(feedUrl);
|
||||
if (remoteFeed) return remoteFeed;
|
||||
|
||||
const fallbackRaw = await fs.readFile(localFeedPath, "utf8");
|
||||
const fallbackPayload = JSON.parse(fallbackRaw);
|
||||
if (!isValidFeedPayload(fallbackPayload)) {
|
||||
throw new Error(`Invalid advisory feed format in fallback file: ${localFeedPath}`);
|
||||
}
|
||||
return fallbackPayload;
|
||||
}
|
||||
|
||||
const handler = async (event: HookEvent): Promise<void> => {
|
||||
if (!shouldHandleEvent(event)) return;
|
||||
|
||||
const installRoot = expandHome(
|
||||
process.env.CLAWSEC_INSTALL_ROOT || process.env.INSTALL_ROOT || path.join(os.homedir(), ".openclaw", "skills"),
|
||||
);
|
||||
const suiteDir = expandHome(process.env.CLAWSEC_SUITE_DIR || path.join(installRoot, "clawsec-suite"));
|
||||
const localFeedPath = expandHome(process.env.CLAWSEC_LOCAL_FEED || path.join(suiteDir, "advisories", "feed.json"));
|
||||
const stateFile = expandHome(
|
||||
process.env.CLAWSEC_SUITE_STATE_FILE || path.join(os.homedir(), ".openclaw", "clawsec-suite-feed-state.json"),
|
||||
);
|
||||
const feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL;
|
||||
const scanIntervalSeconds = parsePositiveInteger(
|
||||
process.env.CLAWSEC_HOOK_INTERVAL_SECONDS,
|
||||
DEFAULT_SCAN_INTERVAL_SECONDS,
|
||||
);
|
||||
|
||||
const forceScan = toEventName(event) === "command:new";
|
||||
const state = await loadState(stateFile);
|
||||
if (!forceScan && scannedRecently(state.last_hook_scan, scanIntervalSeconds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let feed: FeedPayload;
|
||||
try {
|
||||
feed = await loadFeed(feedUrl, localFeedPath);
|
||||
} catch (error) {
|
||||
console.warn(`[clawsec-advisory-guardian] failed to load advisory feed: ${String(error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
state.last_hook_scan = nowIso;
|
||||
state.last_feed_check = nowIso;
|
||||
|
||||
if (typeof feed.updated === "string" && feed.updated.trim()) {
|
||||
state.last_feed_updated = feed.updated;
|
||||
}
|
||||
|
||||
const advisoryIds = feed.advisories
|
||||
.map((advisory) => advisory.id)
|
||||
.filter((id): id is string => typeof id === "string" && id.trim() !== "");
|
||||
state.known_advisories = uniqueStrings([...state.known_advisories, ...advisoryIds]);
|
||||
|
||||
const installedSkills = await discoverInstalledSkills(installRoot);
|
||||
const matches = findMatches(feed, installedSkills);
|
||||
|
||||
if (matches.length === 0) {
|
||||
await persistState(stateFile, state);
|
||||
return;
|
||||
}
|
||||
|
||||
const unseenMatches: AdvisoryMatch[] = [];
|
||||
for (const match of matches) {
|
||||
const key = matchKey(match);
|
||||
if (state.notified_matches[key]) {
|
||||
continue;
|
||||
}
|
||||
unseenMatches.push(match);
|
||||
state.notified_matches[key] = nowIso;
|
||||
}
|
||||
|
||||
if (unseenMatches.length > 0 && Array.isArray(event.messages)) {
|
||||
event.messages.push(buildAlertMessage(unseenMatches, installRoot));
|
||||
}
|
||||
|
||||
await persistState(stateFile, state);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { isObject } from "./utils.mjs";
|
||||
|
||||
/**
|
||||
* @param {string} rawSpecifier
|
||||
* @returns {{ name: string; versionSpec: string } | null}
|
||||
*/
|
||||
export function parseAffectedSpecifier(rawSpecifier) {
|
||||
const specifier = String(rawSpecifier ?? "").trim();
|
||||
if (!specifier) return null;
|
||||
|
||||
const atIndex = specifier.lastIndexOf("@");
|
||||
if (atIndex <= 0) {
|
||||
return { name: specifier, versionSpec: "*" };
|
||||
}
|
||||
|
||||
return {
|
||||
name: specifier.slice(0, atIndex),
|
||||
versionSpec: specifier.slice(atIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} raw
|
||||
* @returns {raw is import("./types.ts").FeedPayload}
|
||||
*/
|
||||
export function isValidFeedPayload(raw) {
|
||||
if (!isObject(raw)) return false;
|
||||
if (!Array.isArray(raw.advisories)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} feedUrl
|
||||
* @returns {Promise<import("./types.ts").FeedPayload | null>}
|
||||
*/
|
||||
export async function loadRemoteFeed(feedUrl) {
|
||||
const fetchFn = /** @type {{ fetch?: Function }} */ (globalThis).fetch;
|
||||
if (typeof fetchFn !== "function") return null;
|
||||
|
||||
const controller = new globalThis.AbortController();
|
||||
const timeout = globalThis.setTimeout(() => controller.abort(), 10000);
|
||||
try {
|
||||
const response = await fetchFn(feedUrl, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
const payload = await response.json();
|
||||
if (!isValidFeedPayload(payload)) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isObject, normalizeSkillName, uniqueStrings } from "./utils.mjs";
|
||||
import { versionMatches } from "./version.mjs";
|
||||
import { parseAffectedSpecifier } from "./feed.mjs";
|
||||
import type { Advisory, FeedPayload, InstalledSkill, AdvisoryMatch } from "./types.ts";
|
||||
|
||||
export async function discoverInstalledSkills(installRoot: string): Promise<InstalledSkill[]> {
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(installRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const skills: InstalledSkill[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const fallbackName = entry.name;
|
||||
const skillDir = path.join(installRoot, entry.name);
|
||||
const skillJsonPath = path.join(skillDir, "skill.json");
|
||||
|
||||
let skillName = fallbackName;
|
||||
let version: string | null = "unknown";
|
||||
|
||||
try {
|
||||
const rawSkillJson = await fs.readFile(skillJsonPath, "utf8");
|
||||
const parsedSkillJson = JSON.parse(rawSkillJson);
|
||||
if (isObject(parsedSkillJson) && typeof parsedSkillJson.name === "string" && parsedSkillJson.name.trim()) {
|
||||
skillName = parsedSkillJson.name.trim();
|
||||
}
|
||||
if (
|
||||
isObject(parsedSkillJson) &&
|
||||
typeof parsedSkillJson.version === "string" &&
|
||||
parsedSkillJson.version.trim()
|
||||
) {
|
||||
version = parsedSkillJson.version.trim();
|
||||
}
|
||||
} catch {
|
||||
// best-effort scan: keep fallback directory name when skill.json is missing or invalid
|
||||
}
|
||||
|
||||
skills.push({ name: skillName, dirName: entry.name, version });
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
export function affectedSpecifierMatchesSkill(rawSpecifier: string, skill: InstalledSkill): boolean {
|
||||
const parsed = parseAffectedSpecifier(rawSpecifier);
|
||||
if (!parsed) return false;
|
||||
|
||||
const specName = normalizeSkillName(parsed.name);
|
||||
const skillName = normalizeSkillName(skill.name);
|
||||
if (specName !== skillName) return false;
|
||||
|
||||
return versionMatches(skill.version, parsed.versionSpec);
|
||||
}
|
||||
|
||||
export function advisoryMatchesSkill(advisory: Advisory, skill: InstalledSkill): string[] {
|
||||
const affected = Array.isArray(advisory.affected) ? advisory.affected : [];
|
||||
const matches = affected.filter((specifier) => affectedSpecifierMatchesSkill(specifier, skill));
|
||||
return uniqueStrings(matches);
|
||||
}
|
||||
|
||||
export function findMatches(feed: FeedPayload, installedSkills: InstalledSkill[]): AdvisoryMatch[] {
|
||||
const matches: AdvisoryMatch[] = [];
|
||||
|
||||
for (const advisory of feed.advisories) {
|
||||
const affected = Array.isArray(advisory.affected) ? advisory.affected : [];
|
||||
if (affected.length === 0) continue;
|
||||
|
||||
for (const skill of installedSkills) {
|
||||
const matchedAffected = advisoryMatchesSkill(advisory, skill);
|
||||
if (matchedAffected.length === 0) continue;
|
||||
matches.push({ advisory, skill, matchedAffected });
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function matchKey(match: AdvisoryMatch): string {
|
||||
const normalizedSkillName = normalizeSkillName(match.skill.name);
|
||||
const version = match.skill.version ?? "unknown";
|
||||
const advisoryId =
|
||||
match.advisory.id ??
|
||||
`${match.advisory.title ?? "untitled"}::${match.advisory.published ?? match.advisory.updated ?? "unknown-ts"}`;
|
||||
return `${advisoryId}::${normalizedSkillName}@${version}`;
|
||||
}
|
||||
|
||||
export function looksMalicious(advisory: Advisory): boolean {
|
||||
const type = String(advisory.type ?? "").toLowerCase();
|
||||
const combined = `${advisory.title ?? ""} ${advisory.description ?? ""} ${advisory.action ?? ""}`.toLowerCase();
|
||||
|
||||
if (type === "malicious_skill" || type === "malicious_plugin") return true;
|
||||
if (/\b(malicious|exfiltrat(e|ion)|backdoor|trojan|credential theft|stealer)\b/.test(combined)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function looksRemovalRecommended(advisory: Advisory): boolean {
|
||||
const combined = `${advisory.action ?? ""} ${advisory.title ?? ""} ${advisory.description ?? ""}`.toLowerCase();
|
||||
return /\b(remove|uninstall|delete|disable|do not use|quarantine)\b/.test(combined);
|
||||
}
|
||||
|
||||
export function buildAlertMessage(matches: AdvisoryMatch[], installRoot: string): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("CLAWSEC ALERT: advisory feed matches installed skill(s).");
|
||||
lines.push("Affected skill advisories:");
|
||||
|
||||
const MAX_LISTED = 8;
|
||||
for (const match of matches.slice(0, MAX_LISTED)) {
|
||||
const severity = String(match.advisory.severity ?? "unknown").toUpperCase();
|
||||
const advisoryId = match.advisory.id ?? "unknown-id";
|
||||
const version = match.skill.version ?? "unknown";
|
||||
const matched = match.matchedAffected.join(", ");
|
||||
lines.push(
|
||||
`- [${severity}] ${advisoryId} -> ${match.skill.name}@${version}` +
|
||||
(matched ? ` (matched: ${matched})` : ""),
|
||||
);
|
||||
if (match.advisory.action) {
|
||||
lines.push(` Action: ${match.advisory.action}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length > MAX_LISTED) {
|
||||
lines.push(`- ... ${matches.length - MAX_LISTED} additional match(es) not shown`);
|
||||
}
|
||||
|
||||
const removalMatches = matches.filter((entry) => looksMalicious(entry.advisory) || looksRemovalRecommended(entry.advisory));
|
||||
if (removalMatches.length > 0) {
|
||||
const impactedSkills = uniqueStrings(removalMatches.map((entry) => entry.skill.name));
|
||||
const impactedDirs = uniqueStrings(removalMatches.map((entry) => entry.skill.dirName));
|
||||
lines.push("");
|
||||
lines.push("Recommendation: one or more matches indicate potentially malicious or unsafe skills.");
|
||||
lines.push("Best practice: remove or disable affected skills only after explicit user approval.");
|
||||
lines.push(
|
||||
"Double-confirmation policy: treat the install request as first intent and require an additional explicit confirmation with this advisory context.",
|
||||
);
|
||||
lines.push(`Approval needed: ask the user to approve removal of: ${impactedSkills.join(", ")}.`);
|
||||
lines.push("Candidate removal paths:");
|
||||
for (const dir of impactedDirs) {
|
||||
lines.push(`- ${path.join(installRoot, dir)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push("");
|
||||
lines.push("Recommendation: review advisories and update/remove affected skills as directed.");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isObject, uniqueStrings } from "./utils.mjs";
|
||||
import type { AdvisoryState } from "./types.ts";
|
||||
|
||||
export const DEFAULT_STATE: AdvisoryState = {
|
||||
schema_version: "1.1",
|
||||
known_advisories: [],
|
||||
last_feed_check: null,
|
||||
last_feed_updated: null,
|
||||
last_hook_scan: null,
|
||||
notified_matches: {},
|
||||
};
|
||||
|
||||
export function normalizeState(raw: unknown): AdvisoryState {
|
||||
if (!isObject(raw)) {
|
||||
return { ...DEFAULT_STATE };
|
||||
}
|
||||
|
||||
const knownAdvisories = Array.isArray(raw.known_advisories)
|
||||
? uniqueStrings(raw.known_advisories.filter((value): value is string => typeof value === "string" && value.trim() !== ""))
|
||||
: [];
|
||||
|
||||
const notifiedMatches: Record<string, string> = {};
|
||||
if (isObject(raw.notified_matches)) {
|
||||
for (const [key, value] of Object.entries(raw.notified_matches)) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
notifiedMatches[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: "1.1",
|
||||
known_advisories: knownAdvisories,
|
||||
last_feed_check: typeof raw.last_feed_check === "string" ? raw.last_feed_check : null,
|
||||
last_feed_updated: typeof raw.last_feed_updated === "string" ? raw.last_feed_updated : null,
|
||||
last_hook_scan: typeof raw.last_hook_scan === "string" ? raw.last_hook_scan : null,
|
||||
notified_matches: notifiedMatches,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadState(stateFile: string): Promise<AdvisoryState> {
|
||||
try {
|
||||
const raw = await fs.readFile(stateFile, "utf8");
|
||||
return normalizeState(JSON.parse(raw));
|
||||
} catch {
|
||||
return { ...DEFAULT_STATE };
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistState(stateFile: string, state: AdvisoryState): Promise<void> {
|
||||
const normalized = normalizeState(state);
|
||||
await fs.mkdir(path.dirname(stateFile), { recursive: true });
|
||||
const tmpFile = `${stateFile}.tmp-${process.pid}-${Date.now()}`;
|
||||
await fs.writeFile(tmpFile, `${JSON.stringify(normalized, null, 2)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
await fs.rename(tmpFile, stateFile);
|
||||
try {
|
||||
await fs.chmod(stateFile, 0o600);
|
||||
} catch (err: unknown) {
|
||||
const code = err instanceof Error && "code" in err ? (err as { code: string }).code : undefined;
|
||||
if (code === "ENOTSUP" || code === "EPERM") {
|
||||
console.warn(
|
||||
`Warning: chmod 0600 failed for ${stateFile} (${code}). ` +
|
||||
"File permissions may not be enforced on this platform/filesystem.",
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export type HookEvent = {
|
||||
type?: string;
|
||||
action?: string;
|
||||
messages?: string[];
|
||||
};
|
||||
|
||||
export type Advisory = {
|
||||
id?: string;
|
||||
severity?: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: string;
|
||||
published?: string;
|
||||
updated?: string;
|
||||
affected?: string[];
|
||||
};
|
||||
|
||||
export type FeedPayload = {
|
||||
updated?: string;
|
||||
advisories: Advisory[];
|
||||
};
|
||||
|
||||
export type InstalledSkill = {
|
||||
name: string;
|
||||
dirName: string;
|
||||
version: string | null;
|
||||
};
|
||||
|
||||
export type AdvisoryMatch = {
|
||||
advisory: Advisory;
|
||||
skill: InstalledSkill;
|
||||
matchedAffected: string[];
|
||||
};
|
||||
|
||||
export type AdvisoryState = {
|
||||
schema_version: string;
|
||||
known_advisories: string[];
|
||||
last_feed_check: string | null;
|
||||
last_feed_updated: string | null;
|
||||
last_hook_scan: string | null;
|
||||
notified_matches: Record<string, string>;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {value is Record<string, unknown>}
|
||||
*/
|
||||
export function isObject(value) {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeSkillName(value) {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} values
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function uniqueStrings(values) {
|
||||
return Array.from(new Set(values));
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @param {string} version
|
||||
* @returns {[number, number, number] | null}
|
||||
*/
|
||||
export function parseSemver(version) {
|
||||
const cleaned = String(version ?? "")
|
||||
.trim()
|
||||
.replace(/^v/i, "")
|
||||
.split("-")[0];
|
||||
const parts = cleaned.split(".");
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
const normalized = parts.slice(0, 3).map((part) => Number.parseInt(part, 10));
|
||||
while (normalized.length < 3) {
|
||||
normalized.push(0);
|
||||
}
|
||||
|
||||
if (normalized.some((part) => Number.isNaN(part))) {
|
||||
return null;
|
||||
}
|
||||
return /** @type {[number, number, number]} */ (normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} left
|
||||
* @param {string} right
|
||||
* @returns {number | null}
|
||||
*/
|
||||
export function compareSemver(left, right) {
|
||||
const a = parseSemver(left);
|
||||
const b = parseSemver(right);
|
||||
if (!a || !b) return null;
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (a[index] > b[index]) return 1;
|
||||
if (a[index] < b[index]) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function escapeRegex(value) {
|
||||
return String(value ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | null} version
|
||||
* @param {string} rawSpec
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function versionMatches(version, rawSpec) {
|
||||
const spec = String(rawSpec ?? "").trim();
|
||||
if (!spec || spec === "*" || spec.toLowerCase() === "any") return true;
|
||||
if (!version || String(version).trim().toLowerCase() === "unknown") return false;
|
||||
|
||||
const normalizedVersion = String(version).trim().replace(/^v/i, "");
|
||||
|
||||
if (spec.includes("*")) {
|
||||
const regex = new RegExp(`^${escapeRegex(spec).replace(/\\\*/g, ".*")}$`);
|
||||
return regex.test(normalizedVersion);
|
||||
}
|
||||
|
||||
const comparatorMatch = spec.match(/^(>=|<=|>|<|=)\s*(.+)$/);
|
||||
if (comparatorMatch) {
|
||||
const operator = comparatorMatch[1];
|
||||
const targetVersion = comparatorMatch[2].trim();
|
||||
const compared = compareSemver(normalizedVersion, targetVersion);
|
||||
if (compared === null) return false;
|
||||
if (operator === ">=") return compared >= 0;
|
||||
if (operator === "<=") return compared <= 0;
|
||||
if (operator === ">") return compared > 0;
|
||||
if (operator === "<") return compared < 0;
|
||||
return compared === 0;
|
||||
}
|
||||
|
||||
if (spec.startsWith("^")) {
|
||||
const target = parseSemver(spec.slice(1));
|
||||
const current = parseSemver(normalizedVersion);
|
||||
if (!target || !current) return false;
|
||||
if (current[0] !== target[0]) return false;
|
||||
if (target[0] === 0 && current[1] !== target[1]) return false;
|
||||
return compareSemver(normalizedVersion, spec.slice(1)) !== -1;
|
||||
}
|
||||
|
||||
if (spec.startsWith("~")) {
|
||||
const target = parseSemver(spec.slice(1));
|
||||
const current = parseSemver(normalizedVersion);
|
||||
if (!target || !current) return false;
|
||||
return (
|
||||
current[0] === target[0] &&
|
||||
current[1] === target[1] &&
|
||||
compareSemver(normalizedVersion, spec.slice(1)) !== -1
|
||||
);
|
||||
}
|
||||
|
||||
return normalizedVersion === spec || normalizedVersion === spec.replace(/^v/i, "");
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { normalizeSkillName, uniqueStrings } from "../hooks/clawsec-advisory-guardian/lib/utils.mjs";
|
||||
import { versionMatches } from "../hooks/clawsec-advisory-guardian/lib/version.mjs";
|
||||
import { parseAffectedSpecifier, isValidFeedPayload, loadRemoteFeed } from "../hooks/clawsec-advisory-guardian/lib/feed.mjs";
|
||||
|
||||
const DEFAULT_FEED_URL =
|
||||
"https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json";
|
||||
const DEFAULT_SUITE_DIR = path.join(os.homedir(), ".openclaw", "skills", "clawsec-suite");
|
||||
const DEFAULT_LOCAL_FEED = path.join(DEFAULT_SUITE_DIR, "advisories", "feed.json");
|
||||
const EXIT_CONFIRM_REQUIRED = 42;
|
||||
|
||||
function printUsage() {
|
||||
process.stderr.write(
|
||||
[
|
||||
"Usage:",
|
||||
" node scripts/guarded_skill_install.mjs --skill <skill-name> [--version <version>] [--confirm-advisory] [--dry-run]",
|
||||
"",
|
||||
"Examples:",
|
||||
" node scripts/guarded_skill_install.mjs --skill helper-plus --version 1.0.1",
|
||||
" node scripts/guarded_skill_install.mjs --skill helper-plus --version 1.0.1 --confirm-advisory",
|
||||
"",
|
||||
"Exit codes:",
|
||||
" 0 success / no advisory block",
|
||||
" 42 advisory matched and second confirmation is required",
|
||||
" 1 error",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {
|
||||
skill: "",
|
||||
version: "",
|
||||
confirmAdvisory: false,
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
|
||||
if (token === "--skill") {
|
||||
parsed.skill = String(argv[i + 1] ?? "").trim();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--version") {
|
||||
parsed.version = String(argv[i + 1] ?? "").trim();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--confirm-advisory") {
|
||||
parsed.confirmAdvisory = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--dry-run") {
|
||||
parsed.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--help" || token === "-h") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${token}`);
|
||||
}
|
||||
|
||||
if (!parsed.skill) {
|
||||
throw new Error("Missing required argument: --skill");
|
||||
}
|
||||
if (!/^[a-z0-9-]+$/.test(parsed.skill)) {
|
||||
throw new Error("Invalid --skill value. Use lowercase letters, digits, and hyphens only.");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function affectedSpecifierMatches(specifier, skillName, version) {
|
||||
const parsed = parseAffectedSpecifier(specifier);
|
||||
if (!parsed) return false;
|
||||
if (normalizeSkillName(parsed.name) !== normalizeSkillName(skillName)) return false;
|
||||
return versionMatches(version, parsed.versionSpec);
|
||||
}
|
||||
|
||||
function affectedSpecifierMatchesNameOnly(specifier, skillName) {
|
||||
const parsed = parseAffectedSpecifier(specifier);
|
||||
if (!parsed) return false;
|
||||
if (normalizeSkillName(parsed.name) !== normalizeSkillName(skillName)) return false;
|
||||
const vs = parsed.versionSpec.trim();
|
||||
return !vs || vs === "*" || vs.toLowerCase() === "any";
|
||||
}
|
||||
|
||||
function advisoryLooksHighRisk(advisory) {
|
||||
const type = String(advisory.type ?? "").toLowerCase();
|
||||
const severity = String(advisory.severity ?? "").toLowerCase();
|
||||
const combined = `${advisory.title ?? ""} ${advisory.description ?? ""} ${advisory.action ?? ""}`.toLowerCase();
|
||||
if (type === "malicious_skill" || type === "malicious_plugin") return true;
|
||||
if (/\b(malicious|exfiltrate|exfiltration|backdoor|trojan|stealer|credential theft)\b/.test(combined)) return true;
|
||||
if (/\b(remove|uninstall|disable|do not use|quarantine)\b/.test(combined)) return true;
|
||||
if (severity === "critical") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function loadFeed() {
|
||||
const feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL;
|
||||
const localFeedPath = process.env.CLAWSEC_LOCAL_FEED || DEFAULT_LOCAL_FEED;
|
||||
|
||||
const remoteFeed = await loadRemoteFeed(feedUrl);
|
||||
if (remoteFeed) return { feed: remoteFeed, source: `remote:${feedUrl}` };
|
||||
|
||||
const raw = await fs.readFile(localFeedPath, "utf8");
|
||||
const payload = JSON.parse(raw);
|
||||
if (!isValidFeedPayload(payload)) {
|
||||
throw new Error(`Invalid fallback advisory feed format: ${localFeedPath}`);
|
||||
}
|
||||
return { feed: payload, source: `local:${localFeedPath}` };
|
||||
}
|
||||
|
||||
function findMatches(feed, skillName, version) {
|
||||
const advisories = Array.isArray(feed.advisories) ? feed.advisories : [];
|
||||
const matches = [];
|
||||
|
||||
for (const advisory of advisories) {
|
||||
const affected = Array.isArray(advisory.affected) ? advisory.affected : [];
|
||||
if (affected.length === 0) continue;
|
||||
|
||||
const matchedAffected = uniqueStrings(
|
||||
affected.filter((specifier) =>
|
||||
version
|
||||
? affectedSpecifierMatches(specifier, skillName, version)
|
||||
: affectedSpecifierMatchesNameOnly(specifier, skillName),
|
||||
),
|
||||
);
|
||||
|
||||
if (matchedAffected.length > 0) {
|
||||
matches.push({ advisory, matchedAffected });
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function printMatches(matches, skillName, version) {
|
||||
process.stdout.write("Advisory matches detected for requested install target.\n");
|
||||
process.stdout.write(`Target: ${skillName}${version ? `@${version}` : ""}\n`);
|
||||
|
||||
for (const entry of matches) {
|
||||
const advisory = entry.advisory;
|
||||
const severity = String(advisory.severity ?? "unknown").toUpperCase();
|
||||
const advisoryId = advisory.id ?? "unknown-id";
|
||||
const title = advisory.title ?? "Untitled advisory";
|
||||
process.stdout.write(`- [${severity}] ${advisoryId}: ${title}\n`);
|
||||
process.stdout.write(` matched: ${entry.matchedAffected.join(", ")}\n`);
|
||||
if (advisory.action) {
|
||||
process.stdout.write(` action: ${advisory.action}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runInstall(skillName, version) {
|
||||
const target = version ? `${skillName}@${version}` : skillName;
|
||||
process.stdout.write(`Install target: ${target}\n`);
|
||||
|
||||
const result = spawnSync("npx", ["clawhub@latest", "install", target], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const { feed, source } = await loadFeed();
|
||||
const matches = findMatches(feed, args.skill, args.version);
|
||||
const highRisk = matches.some((entry) => advisoryLooksHighRisk(entry.advisory));
|
||||
|
||||
process.stdout.write(`Advisory source: ${source}\n`);
|
||||
|
||||
if (matches.length > 0) {
|
||||
printMatches(matches, args.skill, args.version);
|
||||
|
||||
process.stdout.write("\n");
|
||||
process.stdout.write("Install request recognized as first confirmation.\n");
|
||||
process.stdout.write("Additional explicit confirmation is required with advisory context.\n");
|
||||
|
||||
if (!args.confirmAdvisory) {
|
||||
process.stdout.write(
|
||||
"Re-run with --confirm-advisory to proceed after the user explicitly confirms.\n",
|
||||
);
|
||||
process.exit(EXIT_CONFIRM_REQUIRED);
|
||||
}
|
||||
process.stdout.write("Second confirmation provided via --confirm-advisory.\n");
|
||||
}
|
||||
|
||||
if (args.dryRun) {
|
||||
process.stdout.write("Dry run only; install command was not executed.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (highRisk) {
|
||||
process.stdout.write(
|
||||
"High-risk advisory context acknowledged. Proceeding only because --confirm-advisory was provided.\n",
|
||||
);
|
||||
}
|
||||
|
||||
runInstall(args.skill, args.version);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const JOB_NAME = process.env.CLAWSEC_ADVISORY_CRON_NAME?.trim() || "ClawSec Advisory Scan";
|
||||
const JOB_EVERY = process.env.CLAWSEC_ADVISORY_CRON_EVERY?.trim() || "6h";
|
||||
const JOB_DESCRIPTION =
|
||||
"Trigger a periodic ClawSec advisory scan in the main session and ask for approval before removing flagged skills.";
|
||||
const SYSTEM_EVENT =
|
||||
"Run ClawSec advisory scan. If installed skills are flagged as malicious or removal is recommended, notify the user and request explicit approval before any removal.";
|
||||
|
||||
function sh(cmd, args) {
|
||||
const result = spawnSync(cmd, args, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const details = (result.stderr || result.stdout || "").trim();
|
||||
throw new Error(`${cmd} ${args.join(" ")} failed${details ? `: ${details}` : ""}`);
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function requireOpenClawCli() {
|
||||
try {
|
||||
sh("openclaw", ["--version"]);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"openclaw CLI is required. Install OpenClaw and ensure `openclaw` is available in PATH. " +
|
||||
`Original error: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function findExistingJobId(jobsPayload) {
|
||||
if (!jobsPayload || !Array.isArray(jobsPayload.jobs)) return null;
|
||||
const existing = jobsPayload.jobs.find((job) => job && job.name === JOB_NAME);
|
||||
return existing?.id ?? null;
|
||||
}
|
||||
|
||||
function addJob() {
|
||||
const out = sh("openclaw", [
|
||||
"cron",
|
||||
"add",
|
||||
"--name",
|
||||
JOB_NAME,
|
||||
"--description",
|
||||
JOB_DESCRIPTION,
|
||||
"--every",
|
||||
JOB_EVERY,
|
||||
"--session",
|
||||
"main",
|
||||
"--system-event",
|
||||
SYSTEM_EVENT,
|
||||
"--wake",
|
||||
"now",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(out);
|
||||
return payload?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function editJob(jobId) {
|
||||
sh("openclaw", [
|
||||
"cron",
|
||||
"edit",
|
||||
jobId,
|
||||
"--name",
|
||||
JOB_NAME,
|
||||
"--description",
|
||||
JOB_DESCRIPTION,
|
||||
"--enable",
|
||||
"--every",
|
||||
JOB_EVERY,
|
||||
"--session",
|
||||
"main",
|
||||
"--system-event",
|
||||
SYSTEM_EVENT,
|
||||
"--wake",
|
||||
"now",
|
||||
]);
|
||||
}
|
||||
|
||||
function main() {
|
||||
requireOpenClawCli();
|
||||
|
||||
const jobsOut = sh("openclaw", ["cron", "list", "--json"]);
|
||||
const jobsPayload = JSON.parse(jobsOut);
|
||||
const existingJobId = findExistingJobId(jobsPayload);
|
||||
|
||||
if (existingJobId) {
|
||||
editJob(existingJobId);
|
||||
process.stdout.write(`Updated cron job ${existingJobId}: ${JOB_NAME}\n`);
|
||||
} else {
|
||||
const createdId = addJob();
|
||||
if (createdId) {
|
||||
process.stdout.write(`Created cron job ${createdId}: ${JOB_NAME}\n`);
|
||||
} else {
|
||||
process.stdout.write(`Created cron job: ${JOB_NAME}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`Schedule: every ${JOB_EVERY}\n`);
|
||||
process.stdout.write("Session target: main (system event + wake now)\n");
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HOOK_NAME = "clawsec-advisory-guardian";
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SUITE_DIR = path.resolve(SCRIPT_DIR, "..");
|
||||
const SOURCE_HOOK_DIR = path.join(SUITE_DIR, "hooks", HOOK_NAME);
|
||||
const HOOKS_ROOT = path.join(os.homedir(), ".openclaw", "hooks");
|
||||
const TARGET_HOOK_DIR = path.join(HOOKS_ROOT, HOOK_NAME);
|
||||
|
||||
function sh(cmd, args) {
|
||||
const result = spawnSync(cmd, args, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const details = (result.stderr || result.stdout || "").trim();
|
||||
throw new Error(`${cmd} ${args.join(" ")} failed${details ? `: ${details}` : ""}`);
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function requireOpenClawCli() {
|
||||
try {
|
||||
sh("openclaw", ["--version"]);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"openclaw CLI is required. Install OpenClaw and ensure `openclaw` is available in PATH. " +
|
||||
`Original error: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSourceHookExists() {
|
||||
const requiredFiles = [
|
||||
"HOOK.md",
|
||||
"handler.ts",
|
||||
"lib/utils.mjs",
|
||||
"lib/version.mjs",
|
||||
"lib/feed.mjs",
|
||||
];
|
||||
for (const file of requiredFiles) {
|
||||
const fullPath = path.join(SOURCE_HOOK_DIR, file);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new Error(`Missing required hook file: ${fullPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installHookFiles() {
|
||||
fs.mkdirSync(HOOKS_ROOT, { recursive: true });
|
||||
fs.rmSync(TARGET_HOOK_DIR, { recursive: true, force: true });
|
||||
fs.cpSync(SOURCE_HOOK_DIR, TARGET_HOOK_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function enableHook() {
|
||||
sh("openclaw", ["hooks", "enable", HOOK_NAME]);
|
||||
}
|
||||
|
||||
function main() {
|
||||
assertSourceHookExists();
|
||||
requireOpenClawCli();
|
||||
installHookFiles();
|
||||
enableHook();
|
||||
|
||||
process.stdout.write(`Installed hook files to: ${TARGET_HOOK_DIR}\n`);
|
||||
process.stdout.write(`Enabled hook: ${HOOK_NAME}\n`);
|
||||
process.stdout.write("Restart your OpenClaw gateway process so the hook is loaded.\n");
|
||||
process.stdout.write("After restart, run /new once to trigger an immediate advisory scan.\n");
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clawsec-suite",
|
||||
"version": "0.0.3",
|
||||
"description": "Use when users want to explore or set up ClawSec security protections - this is a wrapper that helps discover and install individual security skills, but requires initial configuration to activate any protections.",
|
||||
"version": "0.0.8",
|
||||
"description": "ClawSec suite manager with embedded advisory-feed monitoring, approval-gated malicious-skill response, and guided setup for additional security skills.",
|
||||
"author": "prompt-security",
|
||||
"license": "MIT",
|
||||
"homepage": "https://clawsec.prompt.security/",
|
||||
@@ -11,9 +11,13 @@
|
||||
"catalog",
|
||||
"installer",
|
||||
"integrity",
|
||||
"advisory",
|
||||
"feed",
|
||||
"threat-intel",
|
||||
"hooks",
|
||||
"approval",
|
||||
"agents",
|
||||
"ai",
|
||||
"guardian",
|
||||
"suite",
|
||||
"openclaw"
|
||||
],
|
||||
@@ -27,53 +31,81 @@
|
||||
{
|
||||
"path": "HEARTBEAT.md",
|
||||
"required": true,
|
||||
"description": "update checks and integrity verification"
|
||||
"description": "Portable heartbeat and update-check procedure"
|
||||
},
|
||||
{
|
||||
"path": "bundled/clawsec-feed/skill.json",
|
||||
"path": "advisories/feed.json",
|
||||
"required": true,
|
||||
"description": "Bundled feed metadata"
|
||||
"description": "Embedded advisory feed seed (merged from clawsec-feed)"
|
||||
},
|
||||
{
|
||||
"path": "bundled/clawsec-feed/SKILL.md",
|
||||
"path": "hooks/clawsec-advisory-guardian/HOOK.md",
|
||||
"required": true,
|
||||
"description": "Bundled feed documentation"
|
||||
"description": "OpenClaw hook metadata for advisory-driven malicious-skill checks"
|
||||
},
|
||||
{
|
||||
"path": "bundled/clawsec-feed/advisories/feed.json",
|
||||
"path": "hooks/clawsec-advisory-guardian/handler.ts",
|
||||
"required": true,
|
||||
"description": "Bundled security advisory feed data"
|
||||
"description": "OpenClaw hook handler for approval-gated advisory actions"
|
||||
},
|
||||
{
|
||||
"path": "bundled/openclaw-audit-watchdog/skill.json",
|
||||
"path": "hooks/clawsec-advisory-guardian/lib/utils.mjs",
|
||||
"required": true,
|
||||
"description": "Bundled audit watchdog metadata"
|
||||
"description": "Shared utility functions (isObject, normalizeSkillName, uniqueStrings)"
|
||||
},
|
||||
{
|
||||
"path": "bundled/openclaw-audit-watchdog/SKILL.md",
|
||||
"path": "hooks/clawsec-advisory-guardian/lib/version.mjs",
|
||||
"required": true,
|
||||
"description": "Bundled audit watchdog documentation"
|
||||
"description": "Shared semver parsing and version matching logic"
|
||||
},
|
||||
{
|
||||
"path": "bundled/soul-guardian/skill.json",
|
||||
"path": "hooks/clawsec-advisory-guardian/lib/feed.mjs",
|
||||
"required": true,
|
||||
"description": "Bundled soul guardian metadata"
|
||||
"description": "Shared advisory feed loading and validation"
|
||||
},
|
||||
{
|
||||
"path": "bundled/soul-guardian/SKILL.md",
|
||||
"path": "scripts/setup_advisory_hook.mjs",
|
||||
"required": true,
|
||||
"description": "Bundled soul guardian documentation"
|
||||
"description": "Installer script for enabling the advisory guardian hook"
|
||||
},
|
||||
{
|
||||
"path": "scripts/setup_advisory_cron.mjs",
|
||||
"required": true,
|
||||
"description": "Installer script for optional periodic advisory scan cron"
|
||||
},
|
||||
{
|
||||
"path": "scripts/guarded_skill_install.mjs",
|
||||
"required": true,
|
||||
"description": "Two-step confirmation installer that blocks risky skill installs until explicit second approval"
|
||||
}
|
||||
]
|
||||
},
|
||||
"embedded_components": {
|
||||
"clawsec-feed": {
|
||||
"source_skill": "clawsec-feed",
|
||||
"source_version": "0.0.4",
|
||||
"paths": [
|
||||
"advisories/feed.json"
|
||||
],
|
||||
"capabilities": [
|
||||
"advisory-feed monitoring",
|
||||
"new-advisory detection",
|
||||
"affected-skill cross-reference",
|
||||
"approval-gated malicious-skill removal recommendations",
|
||||
"double-confirmation gating for risky skill installs"
|
||||
],
|
||||
"standalone_available": true,
|
||||
"deprecation_plan": "standalone skill may be retired after suite migration is verified"
|
||||
}
|
||||
},
|
||||
"catalog": {
|
||||
"description": "Available skills in the ClawSec security suite",
|
||||
"base_url": "https://ClawSec.prompt.security/releases/download",
|
||||
"description": "Available protections in the ClawSec suite",
|
||||
"base_url": "https://clawsec.prompt.security/releases/download",
|
||||
"skills": {
|
||||
"clawsec-feed": {
|
||||
"description": "Security advisory feed monitoring",
|
||||
"default_install": true,
|
||||
"required": true,
|
||||
"description": "Advisory monitoring is now embedded in clawsec-suite",
|
||||
"integrated_in_suite": true,
|
||||
"standalone_available": true,
|
||||
"compatible": [
|
||||
"openclaw",
|
||||
"moltbot",
|
||||
@@ -81,6 +113,16 @@
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"openclaw-audit-watchdog": {
|
||||
"description": "Automated daily audits with email reporting",
|
||||
"default_install": true,
|
||||
"compatible": [
|
||||
"openclaw",
|
||||
"moltbot",
|
||||
"clawdbot"
|
||||
],
|
||||
"note": "Tailored for OpenClaw/MoltBot family"
|
||||
},
|
||||
"soul-guardian": {
|
||||
"description": "Drift detection and file integrity guard",
|
||||
"default_install": false,
|
||||
@@ -92,7 +134,7 @@
|
||||
]
|
||||
},
|
||||
"clawtributor": {
|
||||
"description": "Community incident reporting (may share anonymized data)",
|
||||
"description": "Community incident reporting (shares anonymized data)",
|
||||
"default_install": false,
|
||||
"requires_explicit_consent": true,
|
||||
"compatible": [
|
||||
@@ -101,60 +143,33 @@
|
||||
"clawdbot",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"openclaw-audit-watchdog": {
|
||||
"description": "Automated daily audits with email reporting",
|
||||
"default_install": true,
|
||||
"required": true,
|
||||
"compatible": [
|
||||
"openclaw",
|
||||
"moltbot",
|
||||
"clawdbot"
|
||||
],
|
||||
"note": "Tailored for OpenClaw/MoltBot family only"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundled_skills": {
|
||||
"clawsec-feed": {
|
||||
"description": "Security advisory feed (bundled for convenient deployment)",
|
||||
"default": true,
|
||||
"standalone_available": true,
|
||||
"rationale": "Provides crucial CVE and threat intelligence information"
|
||||
},
|
||||
"openclaw-audit-watchdog": {
|
||||
"description": "Daily security audits (bundled for convenient deployment)",
|
||||
"default": true,
|
||||
"standalone_available": true,
|
||||
"rationale": "Provides crucial automated security audit capabilities"
|
||||
},
|
||||
"soul-guardian": {
|
||||
"description": "File integrity monitoring (bundled for convenient deployment)",
|
||||
"default": false,
|
||||
"standalone_available": true,
|
||||
"rationale": "Provides important file integrity and drift detection"
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"emoji": "📦",
|
||||
"category": "security",
|
||||
"requires": {
|
||||
"bins": [
|
||||
"curl",
|
||||
"jq",
|
||||
"shasum"
|
||||
]
|
||||
},
|
||||
"triggers": [
|
||||
"install skills",
|
||||
"install security skills",
|
||||
"clawsec suite",
|
||||
"skill catalog",
|
||||
"security suite",
|
||||
"security advisories",
|
||||
"malicious skill alert",
|
||||
"remove malicious skills",
|
||||
"safe skill install",
|
||||
"confirm skill install",
|
||||
"check advisories",
|
||||
"advisory feed",
|
||||
"install security skills",
|
||||
"verify skills",
|
||||
"check skill integrity",
|
||||
"update skills",
|
||||
"list available skills",
|
||||
"install clawsec",
|
||||
"security suite"
|
||||
"update skills"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: clawtributor
|
||||
version: 0.0.2
|
||||
version: 0.0.3
|
||||
description: Community incident reporting for AI agents. Contribute to collective security by reporting threats.
|
||||
homepage: https://gclawsec.prompt.security
|
||||
metadata: {"openclaw":{"emoji":"🤝","category":"security"}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawtributor",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"description": "Community incident reporting for AI agents. Contribute to collective security by reporting threats.",
|
||||
"author": "prompt-security",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openclaw-audit-watchdog
|
||||
version: 0.0.1
|
||||
version: 0.0.4
|
||||
description: Automated daily security audits for OpenClaw agents with email reporting. Runs deep audits and sends formatted reports.
|
||||
homepage: https://clawsec.prompt.security
|
||||
metadata: {"openclaw":{"emoji":"🔭","category":"security"}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw-audit-watchdog",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.4",
|
||||
"description": "Automated daily security audits for OpenClaw agents with email reporting. Runs deep audits and sends formatted reports.",
|
||||
"author": "prompt-security",
|
||||
"license": "MIT",
|
||||
|
||||
+122
-59
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: soul-guardian
|
||||
version: 0.0.1
|
||||
description: Drift detection + baseline integrity guard for an agent workspace's auto-loaded prompt/instruction markdown files (SOUL.md, AGENTS.md, etc.), with per-file policies, tamper-evident audit logging, and optional auto-restore.
|
||||
version: 0.0.2
|
||||
description: Drift detection + baseline integrity guard for agent workspace files with automatic alerting support
|
||||
homepage: https://clawsec.prompt.security
|
||||
metadata: {"openclaw":{"emoji":"👻","category":"security"}}
|
||||
clawdis:
|
||||
@@ -10,91 +10,154 @@ clawdis:
|
||||
bins: [python3]
|
||||
---
|
||||
|
||||
# soul-guardian
|
||||
# soul-guardian 👻
|
||||
|
||||
Use this skill to detect and respond to unexpected edits in the workspace files that the agent auto-loads.
|
||||
Protects your agent's core files (SOUL.md, AGENTS.md, etc.) from unauthorized changes with automatic detection, restoration, and **user alerting**.
|
||||
|
||||
## Installation Options
|
||||
## Quick Start (3 Steps)
|
||||
|
||||
You can get soul-guardian in two ways:
|
||||
### Step 1: Initialize baselines
|
||||
```bash
|
||||
cd ~/.openclaw/workspace
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py init --actor setup --note "initial baseline"
|
||||
```
|
||||
|
||||
### Option A: Bundled with ClawSec Suite (Recommended)
|
||||
### Step 2: Enable monitoring
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py enable-monitoring
|
||||
```
|
||||
This shows you how to add monitoring to your workflow.
|
||||
|
||||
**If you've installed clawsec-suite, you may already have this!**
|
||||
### Step 3: Add to HEARTBEAT.md (Recommended)
|
||||
|
||||
Soul-guardian is bundled alongside ClawSec Suite to provide file integrity and drift detection capabilities. When you install the suite, if you don't already have soul-guardian installed, it will be deployed from the bundled copy.
|
||||
Add this to your HEARTBEAT.md for automatic monitoring:
|
||||
|
||||
**Advantages:**
|
||||
- Convenient - no separate download needed
|
||||
- Standard location - installed to `~/.openclaw/skills/soul-guardian/`
|
||||
- Preserved - if you already have soul-guardian installed, it won't be overwritten
|
||||
- Single verification - integrity checked as part of suite package
|
||||
```markdown
|
||||
## Soul Guardian Check
|
||||
- Run `python3 skills/soul-guardian/scripts/soul_guardian.py check --actor heartbeat --output-format alert`
|
||||
- If any output is produced, relay it to the user immediately as a security alert
|
||||
```
|
||||
|
||||
### Option B: Standalone Installation (This Page)
|
||||
|
||||
Install soul-guardian independently without the full suite.
|
||||
|
||||
**When to use standalone:**
|
||||
- You only need file integrity monitoring (not other suite components)
|
||||
- You want to install before installing the suite
|
||||
- You prefer explicit control over soul-guardian installation
|
||||
|
||||
**Advantages:**
|
||||
- Lighter weight installation
|
||||
- Independent from suite
|
||||
- Direct control over installation process
|
||||
|
||||
Continue below for standalone installation instructions.
|
||||
That's it! Soul Guardian will now:
|
||||
- ✅ Detect unauthorized changes to protected files
|
||||
- ✅ Auto-restore SOUL.md and AGENTS.md to approved baseline
|
||||
- ✅ Alert you when drift is detected and handled
|
||||
|
||||
---
|
||||
|
||||
## What it protects (default policy)
|
||||
|
||||
- **Auto-restore + alert:** `SOUL.md`, `AGENTS.md`
|
||||
- **Alert-only:** `USER.md`, `TOOLS.md`, `IDENTITY.md`, `HEARTBEAT.md`, `MEMORY.md`
|
||||
- **Ignored by default:** `memory/*.md` (daily notes)
|
||||
|
||||
Policy is stored in the guardian state directory as `policy.json`.
|
||||
|
||||
## Quick start (first run)
|
||||
|
||||
Recommended: onboard an **external** state dir, then initialize baselines there.
|
||||
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/onboard_state_dir.py --agent-id <agentId>
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py --state-dir ~/.clawdbot/soul-guardian/<agentId> init --actor sam --note "first baseline"
|
||||
```
|
||||
|
||||
(Full step-by-step + scheduling options are in `README.md`.)
|
||||
| File | Mode | Action on drift |
|
||||
|------|------|-----------------|
|
||||
| SOUL.md | restore | Auto-restore + alert |
|
||||
| AGENTS.md | restore | Auto-restore + alert |
|
||||
| USER.md | alert | Alert only |
|
||||
| TOOLS.md | alert | Alert only |
|
||||
| IDENTITY.md | alert | Alert only |
|
||||
| HEARTBEAT.md | alert | Alert only |
|
||||
| MEMORY.md | alert | Alert only |
|
||||
| memory/*.md | ignore | Ignored |
|
||||
|
||||
## Commands
|
||||
|
||||
Run from the agent workspace root:
|
||||
### Check for drift (with alert output)
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py check --output-format alert
|
||||
```
|
||||
- Silent if no drift
|
||||
- Outputs human-readable alert if drift detected
|
||||
- Perfect for heartbeat integration
|
||||
|
||||
### Watch mode (continuous monitoring)
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py watch --interval 30
|
||||
```
|
||||
Runs continuously, checking every 30 seconds.
|
||||
|
||||
### Approve intentional changes
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py approve --file SOUL.md --actor user --note "intentional update"
|
||||
```
|
||||
|
||||
### View status
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py status
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py check
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py check --no-restore
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py approve --file SOUL.md
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py restore --file SOUL.md
|
||||
```
|
||||
|
||||
### Verify audit log integrity
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py verify-audit
|
||||
```
|
||||
|
||||
### State directory
|
||||
---
|
||||
|
||||
- Default (backward compatible): `memory/soul-guardian/`
|
||||
- Recommended external override:
|
||||
## Alert Format
|
||||
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py --state-dir ~/.clawdbot/soul-guardian/<agentId> check
|
||||
When drift is detected, the `--output-format alert` produces output like:
|
||||
|
||||
```
|
||||
==================================================
|
||||
🚨 SOUL GUARDIAN SECURITY ALERT
|
||||
==================================================
|
||||
|
||||
📄 FILE: SOUL.md
|
||||
Mode: restore
|
||||
Status: ✅ RESTORED to approved baseline
|
||||
Expected hash: abc123def456...
|
||||
Found hash: 789xyz000111...
|
||||
Diff saved: /path/to/patches/drift.patch
|
||||
|
||||
==================================================
|
||||
Review changes and investigate the source of drift.
|
||||
If intentional, run: soul_guardian.py approve --file <path>
|
||||
==================================================
|
||||
```
|
||||
|
||||
## Cron pattern
|
||||
This output is designed to be relayed directly to the user in TUI/chat.
|
||||
|
||||
Keep the existing gateway cron pattern: run `check` every N minutes and notify only when drift is detected.
|
||||
---
|
||||
|
||||
For onboarding/migration to an external state directory, see `README.md` and:
|
||||
## Security Model
|
||||
|
||||
**What it does:**
|
||||
- Detects filesystem drift vs approved baseline (sha256)
|
||||
- Produces unified diffs for review
|
||||
- Maintains tamper-evident audit log with hash chaining
|
||||
- Refuses to operate on symlinks
|
||||
- Uses atomic writes for restores
|
||||
|
||||
**What it doesn't do:**
|
||||
- Cannot prove WHO made a change (actor is best-effort metadata)
|
||||
- Cannot protect if attacker controls both workspace AND state directory
|
||||
- Is not a substitute for backups
|
||||
|
||||
**Recommendation:** Store state directory outside workspace for better resilience.
|
||||
|
||||
---
|
||||
|
||||
## Demo
|
||||
|
||||
Run the full demo flow to see soul-guardian in action:
|
||||
|
||||
```bash
|
||||
python3 skills/soul-guardian/scripts/onboard_state_dir.py
|
||||
bash skills/soul-guardian/scripts/demo.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Verify clean state (silent check)
|
||||
2. Inject malicious content into SOUL.md
|
||||
3. Run heartbeat check (produces alert)
|
||||
4. Show SOUL.md was restored
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Not initialized" error:**
|
||||
Run `init` first to set up baselines.
|
||||
|
||||
**Drift keeps happening:**
|
||||
Check what's modifying your files. Review the audit log and patches.
|
||||
|
||||
**Want to approve a change:**
|
||||
Run `approve --file <path>` after reviewing the change.
|
||||
|
||||
Executable → Regular
@@ -546,7 +546,52 @@ def restore_one(state: GuardianState, relp: str, info: dict[str, Any]) -> dict[s
|
||||
return {"quarantinePath": str(quarantine_path), **info}
|
||||
|
||||
|
||||
def check_cmd(state: GuardianState, actor: str, note: str, *, no_restore: bool = False) -> int:
|
||||
def format_alert_human(drifted: list[dict[str, Any]]) -> str:
|
||||
"""Format drift results as human-readable alert for TUI notification."""
|
||||
lines = []
|
||||
lines.append("")
|
||||
lines.append("=" * 50)
|
||||
lines.append("🚨 SOUL GUARDIAN SECURITY ALERT")
|
||||
lines.append("=" * 50)
|
||||
lines.append("")
|
||||
|
||||
for d in drifted:
|
||||
path = d.get("path", "unknown")
|
||||
mode = d.get("mode", "unknown")
|
||||
restored = d.get("restored", False)
|
||||
error = d.get("error")
|
||||
|
||||
if error:
|
||||
lines.append(f"⚠️ ERROR: {path}")
|
||||
lines.append(f" {error}")
|
||||
else:
|
||||
lines.append(f"📄 FILE: {path}")
|
||||
lines.append(f" Mode: {mode}")
|
||||
if restored:
|
||||
lines.append(f" Status: ✅ RESTORED to approved baseline")
|
||||
if d.get("quarantinePath"):
|
||||
lines.append(f" Quarantined: {d.get('quarantinePath')}")
|
||||
else:
|
||||
lines.append(f" Status: ⚠️ DRIFT DETECTED (not auto-restored)")
|
||||
|
||||
if d.get("approvedSha"):
|
||||
lines.append(f" Expected hash: {d.get('approvedSha')[:16]}...")
|
||||
if d.get("currentSha"):
|
||||
lines.append(f" Found hash: {d.get('currentSha')[:16]}...")
|
||||
if d.get("patchPath"):
|
||||
lines.append(f" Diff saved: {d.get('patchPath')}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("=" * 50)
|
||||
lines.append("Review changes and investigate the source of drift.")
|
||||
lines.append("If intentional, run: soul_guardian.py approve --file <path>")
|
||||
lines.append("=" * 50)
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def check_cmd(state: GuardianState, actor: str, note: str, *, no_restore: bool = False, output_format: str = "json") -> int:
|
||||
state.ensure_dirs()
|
||||
policy = load_policy(state)
|
||||
baselines = load_baselines(state)
|
||||
@@ -611,30 +656,112 @@ def check_cmd(state: GuardianState, actor: str, note: str, *, no_restore: bool =
|
||||
drifted.append(rec)
|
||||
|
||||
if not drifted:
|
||||
# Silent on OK for alert format
|
||||
if output_format != "alert":
|
||||
pass # Could print "OK" here if desired
|
||||
return 0
|
||||
|
||||
# Single-line summary suitable for cron parsing.
|
||||
# Keep it small; details are in audit + patch paths.
|
||||
summary = {
|
||||
"event": "SOUL_GUARDIAN_DRIFT",
|
||||
"count": len(drifted),
|
||||
"files": [
|
||||
{
|
||||
"path": d["path"],
|
||||
"mode": d.get("mode"),
|
||||
"restored": d.get("restored"),
|
||||
"patch": d.get("patchPath"),
|
||||
"error": d.get("error"),
|
||||
}
|
||||
for d in drifted
|
||||
],
|
||||
}
|
||||
print("SOUL_GUARDIAN_DRIFT " + json.dumps(summary, ensure_ascii=False))
|
||||
# Output based on format
|
||||
if output_format == "alert":
|
||||
# Human-readable alert suitable for direct relay to user
|
||||
print(format_alert_human(drifted))
|
||||
else:
|
||||
# JSON format (default) - single-line summary suitable for cron parsing
|
||||
summary = {
|
||||
"event": "SOUL_GUARDIAN_DRIFT",
|
||||
"count": len(drifted),
|
||||
"files": [
|
||||
{
|
||||
"path": d["path"],
|
||||
"mode": d.get("mode"),
|
||||
"restored": d.get("restored"),
|
||||
"patch": d.get("patchPath"),
|
||||
"error": d.get("error"),
|
||||
}
|
||||
for d in drifted
|
||||
],
|
||||
}
|
||||
print("SOUL_GUARDIAN_DRIFT " + json.dumps(summary, ensure_ascii=False))
|
||||
|
||||
# Drift occurred (even if restored).
|
||||
return 2
|
||||
|
||||
|
||||
def watch_cmd(state: GuardianState, actor: str, note: str, *, interval: int = 30) -> int:
|
||||
"""Continuous watch mode - checks periodically and alerts on drift."""
|
||||
import time
|
||||
|
||||
print(f"👻 Soul Guardian watching... (interval: {interval}s)")
|
||||
print("Press Ctrl+C to stop.\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
result = check_cmd(state, actor, note, output_format="alert")
|
||||
if result == 0:
|
||||
# No drift - stay silent
|
||||
pass
|
||||
# Sleep before next check
|
||||
time.sleep(interval)
|
||||
except KeyboardInterrupt:
|
||||
print("\n👻 Soul Guardian stopped.")
|
||||
return 0
|
||||
|
||||
|
||||
def enable_monitoring_cmd(state: GuardianState) -> None:
|
||||
"""Output instructions and HEARTBEAT.md snippet for easy monitoring setup."""
|
||||
|
||||
heartbeat_snippet = """
|
||||
## Soul Guardian Check
|
||||
- Run `cd {workspace} && python3 skills/soul-guardian/scripts/soul_guardian.py check --actor heartbeat --output-format alert`
|
||||
- If any output is produced, relay it to the user immediately as a security alert
|
||||
- This checks for unauthorized changes to SOUL.md, AGENTS.md, and other protected files
|
||||
""".format(workspace=WORKSPACE_ROOT)
|
||||
|
||||
print("""
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ SOUL GUARDIAN - ENABLE MONITORING ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
|
||||
To enable automatic drift detection and alerting, you have two options:
|
||||
|
||||
────────────────────────────────────────────────────────────────
|
||||
OPTION 1: Heartbeat Integration (Recommended)
|
||||
────────────────────────────────────────────────────────────────
|
||||
|
||||
Add the following to your HEARTBEAT.md file:
|
||||
""")
|
||||
print(heartbeat_snippet)
|
||||
print("""
|
||||
────────────────────────────────────────────────────────────────
|
||||
OPTION 2: Watch Mode (Foreground)
|
||||
────────────────────────────────────────────────────────────────
|
||||
|
||||
Run this in a terminal to continuously monitor:
|
||||
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py watch --interval 30
|
||||
|
||||
────────────────────────────────────────────────────────────────
|
||||
OPTION 3: Manual Check
|
||||
────────────────────────────────────────────────────────────────
|
||||
|
||||
Run a one-time check with human-readable output:
|
||||
|
||||
python3 skills/soul-guardian/scripts/soul_guardian.py check --output-format alert
|
||||
|
||||
────────────────────────────────────────────────────────────────
|
||||
|
||||
The guardian will:
|
||||
✓ Detect unauthorized changes to protected files
|
||||
✓ Auto-restore SOUL.md and AGENTS.md to approved baselines
|
||||
✓ Alert you immediately when drift is detected
|
||||
✓ Save diffs and quarantine modified files for review
|
||||
|
||||
""")
|
||||
print(f"State directory: {state.state_dir}")
|
||||
print(f"Workspace: {WORKSPACE_ROOT}")
|
||||
print()
|
||||
|
||||
|
||||
def approve_cmd(state: GuardianState, actor: str, note: str, *, files: list[str] | None, all_files: bool = False) -> None:
|
||||
state.ensure_dirs()
|
||||
policy = load_policy(state)
|
||||
@@ -796,7 +923,10 @@ def verify_audit_cmd(state: GuardianState) -> None:
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser()
|
||||
p = argparse.ArgumentParser(
|
||||
description="Soul Guardian - Workspace file integrity guard with alerting support.",
|
||||
epilog="For easy setup, run: soul_guardian.py enable-monitoring"
|
||||
)
|
||||
p.add_argument(
|
||||
"--state-dir",
|
||||
default=str(DEFAULT_STATE_DIR),
|
||||
@@ -818,6 +948,8 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
sp_check = sub.add_parser("check", help="Check for drift; restore restore-mode by default.")
|
||||
add_common(sp_check)
|
||||
sp_check.add_argument("--no-restore", action="store_true", help="Never restore during check (alert-only run).")
|
||||
sp_check.add_argument("--output-format", choices=["json", "alert"], default="json",
|
||||
help="Output format: json (machine-readable) or alert (human-readable for TUI).")
|
||||
|
||||
sp_approve = sub.add_parser("approve", help="Approve current contents as baselines.")
|
||||
add_common(sp_approve)
|
||||
@@ -830,6 +962,13 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
sp_restore.add_argument("--all", action="store_true", help="Restore all restore-mode targets.")
|
||||
|
||||
sub.add_parser("verify-audit", help="Verify audit log hash chain.")
|
||||
|
||||
# New commands for easier monitoring setup
|
||||
sp_watch = sub.add_parser("watch", help="Continuous watch mode - monitors and alerts on drift.")
|
||||
add_common(sp_watch)
|
||||
sp_watch.add_argument("--interval", type=int, default=30, help="Check interval in seconds (default: 30).")
|
||||
|
||||
sub.add_parser("enable-monitoring", help="Show instructions for enabling automatic monitoring and alerts.")
|
||||
|
||||
return p.parse_args(argv)
|
||||
|
||||
@@ -846,7 +985,11 @@ def main(argv: list[str]) -> int:
|
||||
status_cmd(state)
|
||||
return 0
|
||||
if args.cmd == "check":
|
||||
return check_cmd(state, args.actor, args.note, no_restore=bool(getattr(args, "no_restore", False)))
|
||||
return check_cmd(
|
||||
state, args.actor, args.note,
|
||||
no_restore=bool(getattr(args, "no_restore", False)),
|
||||
output_format=getattr(args, "output_format", "json")
|
||||
)
|
||||
if args.cmd == "approve":
|
||||
approve_cmd(state, args.actor, args.note, files=getattr(args, "files", None), all_files=bool(getattr(args, "all", False)))
|
||||
return 0
|
||||
@@ -856,6 +999,11 @@ def main(argv: list[str]) -> int:
|
||||
if args.cmd == "verify-audit":
|
||||
verify_audit_cmd(state)
|
||||
return 0
|
||||
if args.cmd == "watch":
|
||||
return watch_cmd(state, args.actor, args.note, interval=getattr(args, "interval", 30))
|
||||
if args.cmd == "enable-monitoring":
|
||||
enable_monitoring_cmd(state)
|
||||
return 0
|
||||
|
||||
raise RuntimeError(f"Unknown cmd: {args.cmd}")
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soul-guardian",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"description": "Drift detection and baseline integrity guard for agent workspace prompt files. Auto-restore critical files with tamper-evident audit logging.",
|
||||
"author": "prompt-security",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -16,11 +16,23 @@ export interface FeedItem {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type AdvisoryType =
|
||||
| 'malicious_skill'
|
||||
| 'vulnerable_skill'
|
||||
| 'prompt_injection'
|
||||
| 'attack_pattern'
|
||||
| 'best_practice'
|
||||
| 'tampering_attempt'
|
||||
// NVD CVE advisories use normalized weakness names (for example:
|
||||
// "missing_authentication_for_critical_function", "os_command_injection").
|
||||
// Keep this open for new categories without requiring type updates.
|
||||
| string;
|
||||
|
||||
// Full advisory type from NVD CVE feed or community reports
|
||||
export interface Advisory {
|
||||
id: string;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
type: 'malicious_skill' | 'vulnerable_skill' | 'prompt_injection' | 'attack_pattern' | 'best_practice' | 'tampering_attempt';
|
||||
type: AdvisoryType;
|
||||
title: string;
|
||||
description: string;
|
||||
affected?: string[];
|
||||
@@ -107,4 +119,4 @@ export interface SkillJson {
|
||||
};
|
||||
triggers: string[];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+14
-45
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Skill Packager - Creates a distributable .skill file and checksums
|
||||
Skill Checksums Generator - Generates checksums.json for a skill
|
||||
|
||||
Usage:
|
||||
python utils/package_skill.py <path/to/skill-folder> [output-directory]
|
||||
@@ -13,7 +13,6 @@ Example:
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -31,14 +30,14 @@ def calculate_sha256(file_path: Path) -> str:
|
||||
|
||||
def package_skill(skill_path: str, output_dir: str = None) -> tuple[Path | None, Path | None]:
|
||||
"""
|
||||
Package a skill folder into a .skill file and generate checksums.
|
||||
Generate checksums for a skill folder.
|
||||
|
||||
Args:
|
||||
skill_path: Path to the skill folder
|
||||
output_dir: Optional output directory (defaults to current directory)
|
||||
|
||||
Returns:
|
||||
Tuple of (skill_file_path, checksums_file_path) or (None, None) on error
|
||||
Tuple of (None, checksums_file_path) or (None, None) on error
|
||||
"""
|
||||
skill_path = Path(skill_path).resolve()
|
||||
|
||||
@@ -66,26 +65,25 @@ def package_skill(skill_path: str, output_dir: str = None) -> tuple[Path | None,
|
||||
else:
|
||||
output_path = Path.cwd()
|
||||
|
||||
skill_filename = output_path / f"{skill_name}.skill"
|
||||
checksums_filename = output_path / "checksums.json"
|
||||
|
||||
# Collect files from SBOM
|
||||
files_to_package = []
|
||||
files_to_checksum = []
|
||||
sbom_files = skill_data.get("sbom", {}).get("files", [])
|
||||
|
||||
for file_entry in sbom_files:
|
||||
file_rel_path = file_entry["path"]
|
||||
full_path = skill_path / file_rel_path
|
||||
if full_path.exists():
|
||||
files_to_package.append((file_rel_path, full_path))
|
||||
files_to_checksum.append((file_rel_path, full_path))
|
||||
|
||||
# Always include skill.json
|
||||
files_to_package.append(("skill.json", skill_json_path))
|
||||
files_to_checksum.append(("skill.json", skill_json_path))
|
||||
|
||||
# Include README.md if it exists
|
||||
readme_path = skill_path / "README.md"
|
||||
if readme_path.exists():
|
||||
files_to_package.append(("README.md", readme_path))
|
||||
files_to_checksum.append(("README.md", readme_path))
|
||||
|
||||
# Generate checksums
|
||||
print("Generating checksums...")
|
||||
@@ -98,7 +96,7 @@ def package_skill(skill_path: str, output_dir: str = None) -> tuple[Path | None,
|
||||
"files": {},
|
||||
}
|
||||
|
||||
for rel_path, full_path in files_to_package:
|
||||
for rel_path, full_path in files_to_checksum:
|
||||
filename = Path(rel_path).name
|
||||
sha256 = calculate_sha256(full_path)
|
||||
size = full_path.stat().st_size
|
||||
@@ -111,40 +109,12 @@ def package_skill(skill_path: str, output_dir: str = None) -> tuple[Path | None,
|
||||
}
|
||||
print(f" {filename}: {sha256[:16]}...")
|
||||
|
||||
# Create .skill package (zip file) first so we can include its checksum
|
||||
print("\nCreating .skill package...")
|
||||
try:
|
||||
with zipfile.ZipFile(skill_filename, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
for rel_path, full_path in files_to_package:
|
||||
# Use skill folder as root in archive
|
||||
arcname = f"{skill_name}/{rel_path}"
|
||||
zipf.write(full_path, arcname)
|
||||
print(f" Added: {arcname}")
|
||||
|
||||
print(f"\n[OK] Package created: {skill_filename}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to create package: {e}")
|
||||
return None, None
|
||||
|
||||
# Add .skill file to checksums now that it exists
|
||||
skill_file_name = f"{skill_name}.skill"
|
||||
skill_sha256 = calculate_sha256(skill_filename)
|
||||
skill_size = skill_filename.stat().st_size
|
||||
|
||||
checksums_data["files"][skill_file_name] = {
|
||||
"sha256": skill_sha256,
|
||||
"size": skill_size,
|
||||
"url": f"https://clawsec.prompt.security/releases/download/{skill_name}-v{version}/{skill_file_name}",
|
||||
}
|
||||
print(f" {skill_file_name}: {skill_sha256[:16]}...")
|
||||
|
||||
# Write checksums.json
|
||||
with open(checksums_filename, "w") as f:
|
||||
json.dump(checksums_data, f, indent=2)
|
||||
print(f"\n[OK] Checksums written to: {checksums_filename}")
|
||||
|
||||
return skill_filename, checksums_filename
|
||||
return None, checksums_filename
|
||||
|
||||
|
||||
def main():
|
||||
@@ -158,18 +128,17 @@ def main():
|
||||
skill_path = sys.argv[1]
|
||||
output_dir = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
print(f"Packaging skill: {skill_path}")
|
||||
print(f"Generating checksums for: {skill_path}")
|
||||
if output_dir:
|
||||
print(f" Output directory: {output_dir}")
|
||||
print()
|
||||
|
||||
skill_file, checksums_file = package_skill(skill_path, output_dir)
|
||||
_, checksums_file = package_skill(skill_path, output_dir)
|
||||
|
||||
if skill_file and checksums_file:
|
||||
if checksums_file:
|
||||
print("\n" + "=" * 50)
|
||||
print("Packaging complete!")
|
||||
print(f" Skill package: {skill_file}")
|
||||
print(f" Checksums: {checksums_file}")
|
||||
print("Checksums generation complete!")
|
||||
print(f" Checksums: {checksums_file}")
|
||||
print("=" * 50)
|
||||
sys.exit(0)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user