diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 6f822cf..614c0f4 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -25,6 +25,9 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Verify signing key consistency (repo + docs) + run: ./scripts/ci/verify_signing_key_consistency.sh + - name: Auto-discover skills from releases env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -274,6 +277,18 @@ jobs: input_file: public/checksums.json signature_file: public/checksums.sig + - name: Verify generated public signing key matches canonical key + run: | + set -euo pipefail + CANONICAL_FPR=$(openssl pkey -pubin -in clawsec-signing-public.pem -outform DER | sha256sum | awk '{print $1}') + GENERATED_FPR=$(openssl pkey -pubin -in public/signing-public.pem -outform DER | sha256sum | awk '{print $1}') + echo "Canonical key fingerprint: $CANONICAL_FPR" + echo "Generated key fingerprint: $GENERATED_FPR" + if [ "$CANONICAL_FPR" != "$GENERATED_FPR" ]; then + echo "::error::public/signing-public.pem fingerprint mismatch vs clawsec-signing-public.pem" + exit 1 + fi + - name: Copy public key to advisory directory run: | # Clients expect the public key at advisories/feed-signing-public.pem diff --git a/.github/workflows/skill-release.yml b/.github/workflows/skill-release.yml index 9644a0a..1befd4c 100644 --- a/.github/workflows/skill-release.yml +++ b/.github/workflows/skill-release.yml @@ -36,6 +36,9 @@ jobs: with: fetch-depth: 0 + - name: Verify signing key consistency (repo + docs) + run: ./scripts/ci/verify_signing_key_consistency.sh + - name: Validate version parity for bumped skills env: BASE_SHA: ${{ github.event.pull_request.base.sha }} @@ -526,6 +529,9 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Verify signing key consistency (repo + docs) + run: ./scripts/ci/verify_signing_key_consistency.sh + - name: Validate skill exists run: | SKILL_PATH="${{ steps.parse.outputs.skill_path }}" @@ -782,6 +788,18 @@ jobs: signature_file: release-assets/checksums.sig public_key_output: release-assets/signing-public.pem + - name: Verify generated release signing key matches canonical key + run: | + set -euo pipefail + CANONICAL_FPR=$(openssl pkey -pubin -in clawsec-signing-public.pem -outform DER | sha256sum | awk '{print $1}') + GENERATED_FPR=$(openssl pkey -pubin -in release-assets/signing-public.pem -outform DER | sha256sum | awk '{print $1}') + echo "Canonical key fingerprint: $CANONICAL_FPR" + echo "Generated key fingerprint: $GENERATED_FPR" + if [ "$CANONICAL_FPR" != "$GENERATED_FPR" ]; then + echo "::error::release-assets/signing-public.pem fingerprint mismatch vs clawsec-signing-public.pem" + exit 1 + fi + - name: Show signed release assets run: | echo "Signed and verified release-assets/checksums.json" diff --git a/README.md b/README.md index 6399958..7d17dfe 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ The **clawsec-suite** is a skill-of-skills manager that installs, verifies, and | Skill | Description | Installation | Compatibility | |-------|-------------|--------------|---------------| | 📡 **clawsec-feed** | Security advisory feed monitoring with live CVE updates | ✅ Included by default | All agents | -| 🔭 **openclaw-audit-watchdog** | Automated daily audits with email reporting | ✅ Included by default | OpenClaw/MoltBot/ClawdBot | +| 🔭 **openclaw-audit-watchdog** | Automated daily audits with email reporting | ⚙️ Optional (install separately) | OpenClaw/MoltBot/ClawdBot | | 👻 **soul-guardian** | Drift detection and file integrity guard with auto-restore | ⚙️ Optional | All agents | | 🤝 **clawtributor** | Community incident reporting | ❌ Optional (Explicit request) | All agents | @@ -169,10 +169,33 @@ ClawSec uses automated pipelines for continuous security updates and skill distr 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. **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 +2. **Enforces key consistency** - Verifies pinned release key references are consistent across repo PEMs and `skills/clawsec-suite/SKILL.md` +3. **Generates Checksums** - Creates `checksums.json` with SHA256 hashes for all SBOM files +4. **Signs + verifies** - Signs `checksums.json` and validates the generated `signing-public.pem` fingerprint against canonical repo key material +5. **Releases** - Publishes to GitHub Releases with all artifacts +6. **Supersedes Old Releases** - Marks older versions (same major) as pre-releases +7. **Triggers Pages Update** - Refreshes the skills catalog on the website + +### Signing Key Consistency Guardrails + +To prevent supply-chain drift, CI now fails fast when signing key references diverge. + +Guardrail script: +- `scripts/ci/verify_signing_key_consistency.sh` + +What it checks: +- `skills/clawsec-suite/SKILL.md` inline public key fingerprint matches `RELEASE_PUBKEY_SHA256` +- Canonical PEM files all match the same fingerprint: + - `clawsec-signing-public.pem` + - `advisories/feed-signing-public.pem` + - `skills/clawsec-suite/advisories/feed-signing-public.pem` +- Generated public key in workflows matches canonical key: + - `release-assets/signing-public.pem` (release workflow) + - `public/signing-public.pem` (pages workflow) + +Where enforced: +- `.github/workflows/skill-release.yml` +- `.github/workflows/deploy-pages.yml` ### Release Versioning & Superseding diff --git a/SECURITY-SIGNING.md b/SECURITY-SIGNING.md index 14321ee..a775e17 100644 --- a/SECURITY-SIGNING.md +++ b/SECURITY-SIGNING.md @@ -24,7 +24,7 @@ As of branch `integration/signing-work`, advisory distribution is **unsigned**: - Feed consumers: - `skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts` - `skills/clawsec-suite/scripts/guarded_skill_install.mjs` - - both default to `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json` + - both default to `https://clawsec.prompt.security/advisories/feed.json` This document defines the **target operating model** for signed artifacts while preserving compatibility during migration. @@ -37,7 +37,7 @@ This document defines the **target operating model** for signed artifacts while ### Release artifact channel (recommended) - `/checksums.json` -- `/checksums.json.sig` +- `/checksums.sig` - `advisories/release-signing-public.pem` (or equivalent repo-pinned location) ## 4) Key roles and custody @@ -138,7 +138,7 @@ Current release generator: Target update: - sign `checksums.json` before `softprops/action-gh-release` -- attach `checksums.json.sig` to each release +- attach `checksums.sig` to each release ## 8) Rotation policy and runbook diff --git a/scripts/ci/verify_signing_key_consistency.sh b/scripts/ci/verify_signing_key_consistency.sh new file mode 100755 index 0000000..541bf66 --- /dev/null +++ b/scripts/ci/verify_signing_key_consistency.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +SKILL_MD="skills/clawsec-suite/SKILL.md" +CANONICAL_KEYS=( + "clawsec-signing-public.pem" + "advisories/feed-signing-public.pem" + "skills/clawsec-suite/advisories/feed-signing-public.pem" +) + +fingerprint_for_pem() { + local pem_file="$1" + openssl pkey -pubin -in "$pem_file" -outform DER | shasum -a 256 | awk '{print $1}' +} + +if [[ ! -f "$SKILL_MD" ]]; then + echo "ERROR: missing $SKILL_MD" >&2 + exit 1 +fi + +DOC_EXPECTED_FPR="$(awk -F'"' '/RELEASE_PUBKEY_SHA256=/{print $2; exit}' "$SKILL_MD")" +if [[ -z "$DOC_EXPECTED_FPR" ]]; then + echo "ERROR: could not parse RELEASE_PUBKEY_SHA256 from $SKILL_MD" >&2 + exit 1 +fi + +TMP_DOC_KEY="$(mktemp)" +trap 'rm -f "$TMP_DOC_KEY"' EXIT +awk ' + /-----BEGIN PUBLIC KEY-----/ {in_key=1} + in_key {print} + /-----END PUBLIC KEY-----/ {exit} +' "$SKILL_MD" > "$TMP_DOC_KEY" + +if ! grep -q "BEGIN PUBLIC KEY" "$TMP_DOC_KEY"; then + echo "ERROR: could not extract inline public key from $SKILL_MD" >&2 + exit 1 +fi + +DOC_INLINE_FPR="$(fingerprint_for_pem "$TMP_DOC_KEY")" + +if [[ "$DOC_INLINE_FPR" != "$DOC_EXPECTED_FPR" ]]; then + echo "ERROR: SKILL.md mismatch: inline key fingerprint ($DOC_INLINE_FPR) != RELEASE_PUBKEY_SHA256 ($DOC_EXPECTED_FPR)" >&2 + exit 1 +fi + +echo "SKILL.md inline key fingerprint matches RELEASE_PUBKEY_SHA256: $DOC_EXPECTED_FPR" + +CANONICAL_FPR="" +for key_file in "${CANONICAL_KEYS[@]}"; do + if [[ ! -f "$key_file" ]]; then + echo "ERROR: missing canonical key file: $key_file" >&2 + exit 1 + fi + fpr="$(fingerprint_for_pem "$key_file")" + echo "$key_file -> $fpr" + if [[ -z "$CANONICAL_FPR" ]]; then + CANONICAL_FPR="$fpr" + elif [[ "$fpr" != "$CANONICAL_FPR" ]]; then + echo "ERROR: key fingerprint mismatch among canonical pem files" >&2 + exit 1 + fi +done + +if [[ "$CANONICAL_FPR" != "$DOC_EXPECTED_FPR" ]]; then + echo "ERROR: canonical pem fingerprint ($CANONICAL_FPR) != SKILL.md RELEASE_PUBKEY_SHA256 ($DOC_EXPECTED_FPR)" >&2 + exit 1 +fi + +echo "All signing key references are consistent: $CANONICAL_FPR" diff --git a/skills/clawsec-suite/CHANGELOG.md b/skills/clawsec-suite/CHANGELOG.md index 4458db9..143c9c9 100644 --- a/skills/clawsec-suite/CHANGELOG.md +++ b/skills/clawsec-suite/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to the ClawSec Suite will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [0.1.1] - 2026-02-16 + +### Added +- Added `scripts/discover_skill_catalog.mjs` to dynamically discover installable skills from `https://clawsec.prompt.security/skills/index.json`. +- Added `test/skill_catalog_discovery.test.mjs` to validate remote-catalog loading and fallback behavior. +- Added CI signing-key drift guard script: `scripts/ci/verify_signing_key_consistency.sh`. + +### Changed +- Updated `SKILL.md` to use dynamic catalog discovery commands instead of hard-coded optional-skill names. +- Updated advisory feed defaults to signed-host URL (`https://clawsec.prompt.security/advisories/feed.json`). +- Improved checksum manifest key compatibility in feed verification logic (supports basename and `advisories/*` key formats). +- Kept `openclaw-audit-watchdog` as a standalone skill (not embedded in `clawsec-suite`). + +### Security +- **Signing key drift control**: CI now enforces that all public key references (inline SKILL.md PEM, canonical `.pem` files, workflow-generated keys) resolve to the same fingerprint. Prevents stale, fabricated, or rotated-but-not-propagated key material from reaching releases. + - Enforced in: `.github/workflows/skill-release.yml`, `.github/workflows/deploy-pages.yml` + - Guard script: `scripts/ci/verify_signing_key_consistency.sh` + +### Fixed +- **Fixed fabricated signing key in SKILL.md**: The manual installation script contained a hallucinated Ed25519 public key and fingerprint (`35866e1b...`) that never corresponded to the actual release signing key. Replaced with the real public key derived from the GitHub-secret-held private key. The bogus key was introduced in v0.0.10 (`Integration/signing work #20`) and went undetected because no consistency check existed at the time. +- Corrected `checksums.sig` naming in release verification documentation. + ## [0.0.10] - 2026-02-11 ### Security diff --git a/skills/clawsec-suite/HEARTBEAT.md b/skills/clawsec-suite/HEARTBEAT.md index c090d41..8f9b4d8 100644 --- a/skills/clawsec-suite/HEARTBEAT.md +++ b/skills/clawsec-suite/HEARTBEAT.md @@ -16,7 +16,7 @@ Run this periodically (cron/systemd/CI/agent scheduler). It assumes POSIX shell, 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}" +FEED_URL="${CLAWSEC_FEED_URL:-https://clawsec.prompt.security/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}" ``` diff --git a/skills/clawsec-suite/SKILL.md b/skills/clawsec-suite/SKILL.md index 3617d1d..c176c22 100644 --- a/skills/clawsec-suite/SKILL.md +++ b/skills/clawsec-suite/SKILL.md @@ -1,6 +1,6 @@ --- name: clawsec-suite -version: 0.0.10 +version: 0.1.1 description: ClawSec suite manager with embedded advisory-feed monitoring, cryptographic signature verification, approval-gated malicious-skill response, and guided setup for additional security skills. homepage: https://clawsec.prompt.security clawdis: @@ -27,11 +27,21 @@ This means `clawsec-suite` can: - 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` +- Dynamic catalog discovery for installable skills: `scripts/discover_skill_catalog.mjs` -### installed separately -- `openclaw-audit-watchdog` -- `soul-guardian` -- `clawtributor` (explicit opt-in) +### Installed separately (dynamic catalog) +`clawsec-suite` does not hard-code add-on skill names in this document. + +Discover the current catalog from the authoritative index (`https://clawsec.prompt.security/skills/index.json`) at runtime: + +```bash +SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite" +node "$SUITE_DIR/scripts/discover_skill_catalog.mjs" +``` + +Fallback behavior: +- If the remote catalog index is reachable and valid, the suite uses it. +- If the remote index is unavailable or malformed, the script falls back to suite-local catalog metadata in `skill.json`. ## Installation @@ -52,16 +62,14 @@ DEST="$INSTALL_ROOT/clawsec-suite" BASE="https://github.com/prompt-security/clawsec/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" # Pinned release-signing public key (verify fingerprint out-of-band on first use) -# Fingerprint (SHA-256 of SPKI DER): 35866e1b1479a043ae816899562ac877e879320c3c5660be1e79f06241ca0854 -RELEASE_PUBKEY_SHA256="35866e1b1479a043ae816899562ac877e879320c3c5660be1e79f06241ca0854" +# Fingerprint (SHA-256 of SPKI DER): 711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8 +RELEASE_PUBKEY_SHA256="711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8" cat > "$TEMP_DIR/release-signing-public.pem" <<'PEM' -----BEGIN PUBLIC KEY----- -MCowBQYDK2VwAyEAtaRGONGp0Syl9EBS17hEYgGTwUtfZgigklS6vAe5MlQ= +MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A= -----END PUBLIC KEY----- PEM @@ -71,70 +79,48 @@ if [ "$ACTUAL_KEY_SHA256" != "$RELEASE_PUBKEY_SHA256" ]; then exit 1 fi -# 1) Download checksums manifest + detached signature -curl -fsSL "$BASE/checksums.json" -o "$TEMP_DIR/checksums.json" -curl -fsSL "$BASE/checksums.json.sig" -o "$TEMP_DIR/checksums.json.sig" +ZIP_NAME="clawsec-suite-v${VERSION}.zip" -# 2) Verify checksums manifest signature before trusting any file URLs or hashes -openssl base64 -d -A -in "$TEMP_DIR/checksums.json.sig" -out "$TEMP_DIR/checksums.json.sig.bin" +# 1) Download release archive + signed checksums manifest + signing public key +curl -fsSL "$BASE/$ZIP_NAME" -o "$TEMP_DIR/$ZIP_NAME" +curl -fsSL "$BASE/checksums.json" -o "$TEMP_DIR/checksums.json" +curl -fsSL "$BASE/checksums.sig" -o "$TEMP_DIR/checksums.sig" + +# 2) Verify checksums manifest signature before trusting any hashes +openssl base64 -d -A -in "$TEMP_DIR/checksums.sig" -out "$TEMP_DIR/checksums.sig.bin" if ! openssl pkeyutl -verify \ -pubin \ -inkey "$TEMP_DIR/release-signing-public.pem" \ - -sigfile "$TEMP_DIR/checksums.json.sig.bin" \ + -sigfile "$TEMP_DIR/checksums.sig.bin" \ -rawin \ -in "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then echo "ERROR: checksums.json signature verification failed" >&2 exit 1 fi -if ! jq -e '.skill and .version and .files' "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then - echo "ERROR: Invalid checksums.json format" >&2 +EXPECTED_ZIP_SHA="$(jq -r '.archive.sha256 // empty' "$TEMP_DIR/checksums.json")" +if [ -z "$EXPECTED_ZIP_SHA" ]; then + echo "ERROR: checksums.json missing archive.sha256" >&2 exit 1 fi -echo "Checksums manifest signature verified." +if command -v shasum >/dev/null 2>&1; then + ACTUAL_ZIP_SHA="$(shasum -a 256 "$TEMP_DIR/$ZIP_NAME" | awk '{print $1}')" +else + ACTUAL_ZIP_SHA="$(sha256sum "$TEMP_DIR/$ZIP_NAME" | awk '{print $1}')" +fi -# 3) 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")" - - if ! curl -fsSL "$FILE_URL" -o "$DOWNLOAD_DIR/$file"; then - echo "ERROR: Download failed for $file" >&2 - DOWNLOAD_FAILED=1 - continue - fi - - 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 +if [ "$EXPECTED_ZIP_SHA" != "$ACTUAL_ZIP_SHA" ]; then + echo "ERROR: Archive checksum mismatch for $ZIP_NAME" >&2 exit 1 fi -# 4) 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" +echo "Checksums manifest signature and archive hash verified." - mkdir -p "$(dirname "$DST_PATH")" - cp "$SRC_PATH" "$DST_PATH" -done < <(jq -r '.files | keys[]' "$TEMP_DIR/checksums.json") +# 3) Install verified archive +mkdir -p "$INSTALL_ROOT" +rm -rf "$DEST" +unzip -q "$TEMP_DIR/$ZIP_NAME" -d "$INSTALL_ROOT" chmod 600 "$DEST/skill.json" find "$DEST" -type f ! -name "skill.json" -exec chmod 644 {} \; @@ -194,7 +180,7 @@ This enforces: The embedded feed logic uses these defaults: -- Remote feed URL: `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json` +- Remote feed URL: `https://clawsec.prompt.security/advisories/feed.json` - Remote feed signature URL: `${CLAWSEC_FEED_URL}.sig` (override with `CLAWSEC_FEED_SIG_URL`) - Remote checksums manifest URL: sibling `checksums.json` (override with `CLAWSEC_FEED_CHECKSUMS_URL`) - Local seed fallback: `~/.openclaw/skills/clawsec-suite/advisories/feed.json` @@ -204,12 +190,12 @@ The embedded feed logic uses these defaults: - State file: `~/.openclaw/clawsec-suite-feed-state.json` - Hook rate-limit env (OpenClaw hook): `CLAWSEC_HOOK_INTERVAL_SECONDS` (default `300`) -**Fail-closed verification:** Both signature and checksum manifest verification are required by default. Set `CLAWSEC_ALLOW_UNSIGNED_FEED=1` only as a temporary migration bypass when adopting this version before signed feed artifacts are available upstream. +**Fail-closed verification:** Feed signatures are required by default. Checksum manifests are verified when companion checksum artifacts are available. Set `CLAWSEC_ALLOW_UNSIGNED_FEED=1` only as a temporary migration bypass when adopting this version before signed feed artifacts are available upstream. ### Quick feed check ```bash -FEED_URL="${CLAWSEC_FEED_URL:-https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json}" +FEED_URL="${CLAWSEC_FEED_URL:-https://clawsec.prompt.security/advisories/feed.json}" STATE_FILE="${CLAWSEC_SUITE_STATE_FILE:-$HOME/.openclaw/clawsec-suite-feed-state.json}" TMP="$(mktemp -d)" @@ -273,13 +259,20 @@ The suite hook and heartbeat guidance are intentionally non-destructive by defau ## Optional Skill Installation -Install additional protections as needed: +Discover currently available installable skills dynamically, then install the ones you want: ```bash -npx clawhub@latest install openclaw-audit-watchdog -npx clawhub@latest install soul-guardian -# opt-in only: -npx clawhub@latest install clawtributor +SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite" +node "$SUITE_DIR/scripts/discover_skill_catalog.mjs" + +# then install any discovered skill by name +npx clawhub@latest install +``` + +Machine-readable output is also available for automation: + +```bash +node "$SUITE_DIR/scripts/discover_skill_catalog.mjs" --json ``` ## Security Notes diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts index a7065b3..73c5632 100644 --- a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts +++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts @@ -8,7 +8,7 @@ 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"; + "https://clawsec.prompt.security/advisories/feed.json"; const DEFAULT_SCAN_INTERVAL_SECONDS = 300; let unsignedModeWarningShown = false; diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs index 4935da3..d0e475f 100644 --- a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs +++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs @@ -273,6 +273,62 @@ function parseChecksumsManifest(manifestRaw) { }; } +/** + * @param {string} entryName + * @returns {string} + */ +function normalizeChecksumEntryName(entryName) { + return String(entryName ?? "") + .trim() + .replace(/\\/g, "/") + .replace(/^(?:\.\/)+/, "") + .replace(/^\/+/, ""); +} + +/** + * @param {Record} files + * @param {string} entryName + * @returns {{ key: string; digest: string } | null} + */ +function resolveChecksumManifestEntry(files, entryName) { + const normalizedEntry = normalizeChecksumEntryName(entryName); + if (!normalizedEntry) return null; + + const directCandidates = [ + normalizedEntry, + path.posix.basename(normalizedEntry), + `advisories/${path.posix.basename(normalizedEntry)}`, + ].filter((candidate, index, all) => candidate && all.indexOf(candidate) === index); + + for (const candidate of directCandidates) { + if (Object.prototype.hasOwnProperty.call(files, candidate)) { + return { key: candidate, digest: files[candidate] }; + } + } + + const basename = path.posix.basename(normalizedEntry); + if (!basename) return null; + + const basenameMatches = Object.entries(files).filter(([key]) => { + const normalizedKey = normalizeChecksumEntryName(key); + return path.posix.basename(normalizedKey) === basename; + }); + + if (basenameMatches.length > 1) { + throw new Error( + `Checksum manifest entry is ambiguous for ${entryName}; ` + + `multiple manifest keys share basename ${basename}`, + ); + } + + if (basenameMatches.length === 1) { + const [resolvedKey, digest] = basenameMatches[0]; + return { key: resolvedKey, digest }; + } + + return null; +} + /** * @param {{ files: Record }} manifest * @param {Record} expectedEntries @@ -281,14 +337,14 @@ function verifyChecksums(manifest, expectedEntries) { for (const [entryName, entryContent] of Object.entries(expectedEntries)) { if (!entryName) continue; - const expectedDigest = manifest.files[entryName]; - if (!expectedDigest) { + const resolved = resolveChecksumManifestEntry(manifest.files, entryName); + if (!resolved) { throw new Error(`Checksum manifest missing required entry: ${entryName}`); } const actualDigest = sha256Hex(entryContent); - if (actualDigest !== expectedDigest) { - throw new Error(`Checksum mismatch for ${entryName}`); + if (actualDigest !== resolved.digest) { + throw new Error(`Checksum mismatch for ${entryName} (manifest key: ${resolved.key})`); } } } diff --git a/skills/clawsec-suite/scripts/discover_skill_catalog.mjs b/skills/clawsec-suite/scripts/discover_skill_catalog.mjs new file mode 100644 index 0000000..198d232 --- /dev/null +++ b/skills/clawsec-suite/scripts/discover_skill_catalog.mjs @@ -0,0 +1,301 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_INDEX_URL = "https://clawsec.prompt.security/skills/index.json"; +const DEFAULT_TIMEOUT_MS = 5000; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const SUITE_DIR = path.resolve(SCRIPT_DIR, ".."); +const SUITE_SKILL_JSON = path.join(SUITE_DIR, "skill.json"); + +function isObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeSkillId(value) { + return String(value ?? "") + .trim() + .toLowerCase(); +} + +function normalizeBoolean(value) { + return value === true; +} + +function parseTimeoutMs() { + const raw = String(process.env.CLAWSEC_SKILLS_INDEX_TIMEOUT_MS ?? "").trim(); + if (!raw) return DEFAULT_TIMEOUT_MS; + + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_TIMEOUT_MS; + } + return parsed; +} + +function parseArgs(argv) { + const args = { + json: false, + }; + + for (const token of argv) { + if (token === "--json") { + args.json = true; + continue; + } + if (token === "--help" || token === "-h") { + printUsage(); + process.exit(0); + } + + throw new Error(`Unknown argument: ${token}`); + } + + return args; +} + +function printUsage() { + process.stdout.write( + [ + "Usage:", + " node scripts/discover_skill_catalog.mjs [--json]", + "", + "Behavior:", + " - Fetches dynamic catalog from CLAWSEC_SKILLS_INDEX_URL (default: https://clawsec.prompt.security/skills/index.json)", + " - Falls back to suite-local catalog metadata in skill.json when remote index is unavailable/invalid", + "", + "Environment:", + " CLAWSEC_SKILLS_INDEX_URL Override remote catalog index URL", + " CLAWSEC_SKILLS_INDEX_TIMEOUT_MS HTTP timeout in milliseconds (default: 5000)", + "", + ].join("\n"), + ); +} + +function normalizeRemoteSkills(payload) { + if (!isObject(payload)) { + throw new Error("Catalog index payload must be a JSON object"); + } + + const rawSkills = payload.skills; + if (!Array.isArray(rawSkills)) { + throw new Error("Catalog index missing skills array"); + } + + const dedup = new Map(); + + for (const entry of rawSkills) { + if (!isObject(entry)) continue; + + const id = normalizeSkillId(entry.id ?? entry.name); + if (!id) continue; + + dedup.set(id, { + id, + name: String(entry.name ?? id), + version: String(entry.version ?? "").trim() || null, + description: String(entry.description ?? "").trim() || null, + emoji: String(entry.emoji ?? "").trim() || null, + category: String(entry.category ?? "").trim() || null, + tag: String(entry.tag ?? "").trim() || null, + trust: entry.trust ?? null, + source: "remote", + }); + } + + return { + version: String(payload.version ?? "").trim() || null, + updated: String(payload.updated ?? "").trim() || null, + skills: [...dedup.values()].sort((a, b) => a.id.localeCompare(b.id)), + }; +} + +async function loadFallbackCatalog() { + const raw = await fs.readFile(SUITE_SKILL_JSON, "utf8"); + const parsed = JSON.parse(raw); + + const catalogSkills = isObject(parsed?.catalog?.skills) ? parsed.catalog.skills : {}; + const dedup = new Map(); + + for (const [rawId, meta] of Object.entries(catalogSkills)) { + const id = normalizeSkillId(rawId); + if (!id) continue; + + const safeMeta = isObject(meta) ? meta : {}; + + dedup.set(id, { + id, + name: id, + version: null, + description: String(safeMeta.description ?? "").trim() || null, + emoji: null, + category: null, + tag: null, + trust: null, + source: "fallback", + integrated_in_suite: normalizeBoolean(safeMeta.integrated_in_suite), + requires_explicit_consent: normalizeBoolean(safeMeta.requires_explicit_consent), + default_install: normalizeBoolean(safeMeta.default_install), + }); + } + + return { + version: null, + updated: null, + skills: [...dedup.values()].sort((a, b) => a.id.localeCompare(b.id)), + }; +} + +function mergeWithFallbackMetadata(remoteSkills, fallbackSkills) { + const fallbackById = new Map(fallbackSkills.map((skill) => [skill.id, skill])); + + return remoteSkills.map((skill) => { + const fallback = fallbackById.get(skill.id); + if (!fallback) { + return { + ...skill, + integrated_in_suite: false, + requires_explicit_consent: false, + default_install: false, + }; + } + + return { + ...skill, + description: skill.description || fallback.description || null, + integrated_in_suite: normalizeBoolean(fallback.integrated_in_suite), + requires_explicit_consent: normalizeBoolean(fallback.requires_explicit_consent), + default_install: normalizeBoolean(fallback.default_install), + }; + }); +} + +async function loadRemoteCatalog(indexUrl, timeoutMs) { + if (typeof globalThis.fetch !== "function") { + throw new Error("fetch is unavailable in this runtime"); + } + if (typeof globalThis.AbortController !== "function") { + throw new Error("AbortController is unavailable in this runtime"); + } + + const controller = new globalThis.AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await globalThis.fetch(indexUrl, { + method: "GET", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status} while fetching catalog`); + } + + const payload = await response.json(); + return normalizeRemoteSkills(payload); + } finally { + clearTimeout(timeout); + } +} + +function formatFlags(skill) { + const flags = []; + + if (skill.id === "clawsec-suite") { + flags.push("this suite"); + } + if (skill.integrated_in_suite) { + flags.push("already integrated in suite"); + } + if (skill.requires_explicit_consent) { + flags.push("explicit opt-in"); + } + if (skill.default_install) { + flags.push("recommended default"); + } + + return flags; +} + +function printHumanSummary(result) { + process.stdout.write("=== ClawSec Skill Catalog Discovery ===\n"); + process.stdout.write(`Source: ${result.source}\n`); + process.stdout.write(`Index URL: ${result.index_url}\n`); + if (result.updated) { + process.stdout.write(`Catalog updated: ${result.updated}\n`); + } + if (result.warning) { + process.stdout.write(`Fallback reason: ${result.warning}\n`); + } + + process.stdout.write("\nAvailable installable skills:\n"); + + if (!Array.isArray(result.skills) || result.skills.length === 0) { + process.stdout.write("- none\n"); + return; + } + + for (const skill of result.skills) { + const label = skill.version ? `${skill.id} (v${skill.version})` : skill.id; + process.stdout.write(`- ${label}\n`); + if (skill.description) { + process.stdout.write(` ${skill.description}\n`); + } + + const flags = formatFlags(skill); + if (flags.length > 0) { + process.stdout.write(` notes: ${flags.join("; ")}\n`); + } + + process.stdout.write(` install: npx clawhub@latest install ${skill.id}\n`); + } +} + +async function discoverCatalog() { + const indexUrl = process.env.CLAWSEC_SKILLS_INDEX_URL || DEFAULT_INDEX_URL; + const timeoutMs = parseTimeoutMs(); + const fallback = await loadFallbackCatalog(); + + try { + const remote = await loadRemoteCatalog(indexUrl, timeoutMs); + + return { + source: "remote", + index_url: indexUrl, + version: remote.version, + updated: remote.updated, + skills: mergeWithFallbackMetadata(remote.skills, fallback.skills), + warning: null, + }; + } catch (error) { + return { + source: "fallback", + index_url: indexUrl, + version: fallback.version, + updated: fallback.updated, + skills: fallback.skills, + warning: String(error), + }; + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const result = await discoverCatalog(); + + if (args.json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + printHumanSummary(result); +} + +main().catch((error) => { + process.stderr.write(`${String(error)}\n`); + process.exit(1); +}); diff --git a/skills/clawsec-suite/scripts/guarded_skill_install.mjs b/skills/clawsec-suite/scripts/guarded_skill_install.mjs index 6c01fb7..c04dc6d 100644 --- a/skills/clawsec-suite/scripts/guarded_skill_install.mjs +++ b/skills/clawsec-suite/scripts/guarded_skill_install.mjs @@ -14,7 +14,7 @@ import { } from "../hooks/clawsec-advisory-guardian/lib/feed.mjs"; const DEFAULT_FEED_URL = - "https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json"; + "https://clawsec.prompt.security/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 DEFAULT_LOCAL_FEED_SIG = `${DEFAULT_LOCAL_FEED}.sig`; diff --git a/skills/clawsec-suite/skill.json b/skills/clawsec-suite/skill.json index 5db2417..04b5713 100644 --- a/skills/clawsec-suite/skill.json +++ b/skills/clawsec-suite/skill.json @@ -1,6 +1,6 @@ { "name": "clawsec-suite", - "version": "0.0.10", + "version": "0.1.1", "description": "ClawSec suite manager with embedded advisory-feed monitoring, cryptographic signature verification, approval-gated malicious-skill response, and guided setup for additional security skills.", "author": "prompt-security", "license": "MIT", @@ -120,6 +120,11 @@ "required": true, "description": "Two-step confirmation installer with signature verification that blocks risky skill installs" }, + { + "path": "scripts/discover_skill_catalog.mjs", + "required": true, + "description": "Dynamic skill-catalog discovery with remote index fetch and suite-local fallback metadata" + }, { "path": "scripts/sign_detached_ed25519.mjs", "required": false, diff --git a/skills/clawsec-suite/test/feed_verification.test.mjs b/skills/clawsec-suite/test/feed_verification.test.mjs index c195b5f..854aebb 100644 --- a/skills/clawsec-suite/test/feed_verification.test.mjs +++ b/skills/clawsec-suite/test/feed_verification.test.mjs @@ -283,6 +283,57 @@ async function testLoadLocalFeed_ValidSignedFeed() { } } +// ----------------------------------------------------------------------------- +// Test: loadLocalFeed - supports advisories/* checksum keys +// ----------------------------------------------------------------------------- +async function testLoadLocalFeed_AdvisoriesPrefixedChecksumKeys() { + const testName = "loadLocalFeed: advisories/* checksum keys are accepted"; + try { + const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair(); + const feedContent = createValidFeed(); + const feedSignature = signPayload(feedContent, privateKeyPem); + + const advisoriesDir = path.join(tempDir, "advisories"); + await fs.mkdir(advisoriesDir, { recursive: true }); + + const checksumManifest = createChecksumManifest({ + "advisories/feed.json": feedContent, + "advisories/feed.json.sig": feedSignature + "\n", + "advisories/feed-signing-public.pem": publicKeyPem, + }); + const checksumSignature = signPayload(checksumManifest, privateKeyPem); + + const feedPath = path.join(advisoriesDir, "feed.json"); + const sigPath = path.join(advisoriesDir, "feed.json.sig"); + const checksumPath = path.join(advisoriesDir, "checksums.json"); + const checksumSigPath = path.join(advisoriesDir, "checksums.json.sig"); + const keyPath = path.join(advisoriesDir, "feed-signing-public.pem"); + + await fs.writeFile(feedPath, feedContent); + await fs.writeFile(sigPath, feedSignature + "\n"); + await fs.writeFile(checksumPath, checksumManifest); + await fs.writeFile(checksumSigPath, checksumSignature + "\n"); + await fs.writeFile(keyPath, publicKeyPem); + + const feed = await loadLocalFeed(feedPath, { + signaturePath: sigPath, + checksumsPath: checksumPath, + checksumsSignaturePath: checksumSigPath, + publicKeyPem, + verifyChecksumManifest: true, + checksumPublicKeyEntry: path.basename(keyPath), + }); + + if (feed && feed.version === "1.0.0" && feed.advisories.length === 1) { + pass(testName); + } else { + fail(testName, "Feed did not load with advisories/* checksum keys"); + } + } catch (error) { + fail(testName, error); + } +} + // ----------------------------------------------------------------------------- // Test: loadLocalFeed - tampered feed fails (fail-closed) // ----------------------------------------------------------------------------- @@ -542,6 +593,7 @@ async function runTests() { // Local feed loading tests await testLoadLocalFeed_ValidSignedFeed(); + await testLoadLocalFeed_AdvisoriesPrefixedChecksumKeys(); await testLoadLocalFeed_TamperedFeedFails(); await testLoadLocalFeed_MissingSignatureFails(); await testLoadLocalFeed_AllowUnsignedBypasses(); diff --git a/skills/clawsec-suite/test/skill_catalog_discovery.test.mjs b/skills/clawsec-suite/test/skill_catalog_discovery.test.mjs new file mode 100644 index 0000000..65a783c --- /dev/null +++ b/skills/clawsec-suite/test/skill_catalog_discovery.test.mjs @@ -0,0 +1,250 @@ +#!/usr/bin/env node + +/** + * Dynamic skill catalog discovery tests for clawsec-suite. + * + * Tests cover: + * - Remote index fetch and normalization + * - Enrichment with suite-local metadata (non-breaking compatibility) + * - Fallback behavior when remote index is invalid/unavailable + * + * Run: node skills/clawsec-suite/test/skill_catalog_discovery.test.mjs + */ + +import http from "node:http"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "discover_skill_catalog.mjs"); + +let passCount = 0; +let failCount = 0; + +function pass(name) { + passCount += 1; + console.log(`✓ ${name}`); +} + +function fail(name, error) { + failCount += 1; + console.error(`✗ ${name}`); + console.error(` ${String(error)}`); +} + +function runCatalogScript(args, env = {}) { + return new Promise((resolve) => { + const proc = spawn("node", [SCRIPT_PATH, ...args], { + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + + proc.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + proc.on("close", (code) => { + resolve({ code, stdout, stderr }); + }); + }); +} + +function withServer(handler) { + return new Promise((resolve, reject) => { + const server = http.createServer(handler); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (!addr || typeof addr === "string") { + reject(new Error("Failed to bind test server")); + return; + } + + resolve({ + url: `http://127.0.0.1:${addr.port}`, + close: () => + new Promise((done) => { + server.close(() => done()); + }), + }); + }); + + server.on("error", reject); + }); +} + +// ----------------------------------------------------------------------------- +// Test: remote index is used when valid +// ----------------------------------------------------------------------------- +async function testRemoteCatalogSuccess() { + const testName = "discover_skill_catalog: uses remote index when valid"; + let fixture = null; + + try { + fixture = await withServer((req, res) => { + if (req.url !== "/index.json") { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + return; + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + version: "1.0.0", + updated: "2026-02-16T08:20:00Z", + skills: [ + { + id: "soul-guardian", + name: "soul-guardian", + version: "9.9.9", + description: "Remote skill metadata", + emoji: "👻", + category: "security", + tag: "soul-guardian-v9.9.9", + }, + { + id: "clawtributor", + name: "clawtributor", + version: "1.2.3", + description: "Remote clawtributor metadata", + emoji: "🤝", + category: "security", + tag: "clawtributor-v1.2.3", + }, + ], + }), + ); + }); + + const result = await runCatalogScript(["--json"], { + CLAWSEC_SKILLS_INDEX_URL: `${fixture.url}/index.json`, + CLAWSEC_SKILLS_INDEX_TIMEOUT_MS: "2000", + }); + + if (result.code !== 0) { + fail(testName, `Expected exit 0, got ${result.code}: ${result.stderr}`); + return; + } + + const payload = JSON.parse(result.stdout); + const clawtributor = payload.skills.find((entry) => entry.id === "clawtributor"); + const soulGuardian = payload.skills.find((entry) => entry.id === "soul-guardian"); + + if ( + payload.source === "remote" && + payload.updated === "2026-02-16T08:20:00Z" && + soulGuardian?.version === "9.9.9" && + clawtributor?.requires_explicit_consent === true + ) { + pass(testName); + } else { + fail(testName, `Unexpected payload: ${result.stdout}`); + } + } catch (error) { + fail(testName, error); + } finally { + if (fixture) { + await fixture.close(); + } + } +} + +// ----------------------------------------------------------------------------- +// Test: invalid remote payload falls back to suite-local catalog +// ----------------------------------------------------------------------------- +async function testInvalidRemotePayloadFallsBack() { + const testName = "discover_skill_catalog: invalid remote payload falls back"; + let fixture = null; + + try { + fixture = await withServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ version: "1.0.0", note: "missing skills" })); + }); + + const result = await runCatalogScript(["--json"], { + CLAWSEC_SKILLS_INDEX_URL: `${fixture.url}/index.json`, + CLAWSEC_SKILLS_INDEX_TIMEOUT_MS: "2000", + }); + + if (result.code !== 0) { + fail(testName, `Expected exit 0, got ${result.code}: ${result.stderr}`); + return; + } + + const payload = JSON.parse(result.stdout); + const hasSoulGuardian = Array.isArray(payload.skills) + ? payload.skills.some((entry) => entry.id === "soul-guardian") + : false; + + if (payload.source === "fallback" && hasSoulGuardian && String(payload.warning).includes("skills array")) { + pass(testName); + } else { + fail(testName, `Unexpected payload: ${result.stdout}`); + } + } catch (error) { + fail(testName, error); + } finally { + if (fixture) { + await fixture.close(); + } + } +} + +// ----------------------------------------------------------------------------- +// Test: unreachable remote index falls back to suite-local catalog +// ----------------------------------------------------------------------------- +async function testUnreachableRemoteFallsBack() { + const testName = "discover_skill_catalog: unreachable remote index falls back"; + + try { + const result = await runCatalogScript(["--json"], { + CLAWSEC_SKILLS_INDEX_URL: "http://127.0.0.1:9/index.json", + CLAWSEC_SKILLS_INDEX_TIMEOUT_MS: "250", + }); + + if (result.code !== 0) { + fail(testName, `Expected exit 0, got ${result.code}: ${result.stderr}`); + return; + } + + const payload = JSON.parse(result.stdout); + if (payload.source === "fallback" && Array.isArray(payload.skills) && payload.skills.length > 0) { + pass(testName); + } else { + fail(testName, `Unexpected payload: ${result.stdout}`); + } + } catch (error) { + fail(testName, error); + } +} + +// ----------------------------------------------------------------------------- +// Main test runner +// ----------------------------------------------------------------------------- +async function runTests() { + console.log("=== ClawSec Skill Catalog Discovery Tests ===\n"); + + await testRemoteCatalogSuccess(); + await testInvalidRemotePayloadFallsBack(); + await testUnreachableRemoteFallsBack(); + + console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`); + + if (failCount > 0) { + process.exit(1); + } +} + +runTests().catch((error) => { + console.error("Test runner failed:", error); + process.exit(1); +}); diff --git a/skills/openclaw-audit-watchdog/scripts/setup_cron.mjs b/skills/openclaw-audit-watchdog/scripts/setup_cron.mjs index 270d4e8..8bf7e52 100755 --- a/skills/openclaw-audit-watchdog/scripts/setup_cron.mjs +++ b/skills/openclaw-audit-watchdog/scripts/setup_cron.mjs @@ -53,7 +53,16 @@ function oneline(v) { return String(v ?? "") .replace(/[\r\n]+/g, " ") .replace(/"/g, "\\\"") + .trim(); +} +function escapeForShellEnvVar(v) { + return String(v ?? "") + .replace(/[\r\n]+/g, " ") + .replace(/\\/g, "\\\\") + .replace(/\$/g, "\\$") + .replace(/`/g, "\\`") + .replace(/"/g, "\\\"") .trim(); } @@ -66,7 +75,9 @@ function defaultInstallDir() { } function buildAgentMessage({ dmChannel, dmTo, hostLabel, installDir }) { - const safeDir = oneline(installDir || ""); + const safeDir = escapeForShellEnvVar(installDir || ""); + const escapedHostLabel = escapeForShellEnvVar(hostLabel); + return [ "Run daily openclaw security audits and deliver report (DM + email).", "", @@ -74,7 +85,7 @@ function buildAgentMessage({ dmChannel, dmTo, hostLabel, installDir }) { `Email: ${COMPANY_EMAIL} (local sendmail)`, "", "Execute:", - `- Run via exec: cd "${safeDir}" && PROMPTSEC_HOST_LABEL="${oneline(hostLabel)}" ./scripts/runner.sh`, + `- Run via exec: cd "${safeDir}" && PROMPTSEC_HOST_LABEL="${escapedHostLabel}" ./scripts/runner.sh`, "", "Output requirements:", "- Print the report to stdout (cron deliver will DM it).",