Skill: jason-allen-oneal/skill-scanner-guard (version 1.0.2)

--- name: openclaw-skill-scanner description: Security gate for OpenClaw AgentSkills. Scans folder/ClawHub skills with cisco-ai-defense/skill-scanner before installation. Supports manual scans, staged installs, and auto-quarantine of high-risk skills via systemd. binaries: - uv - npx - git - systemctl env: - OPENCLAW_STATE_DIR - OPENCLAW_WORKSPACE_DIR --- # Skill Scanner Guard Harden OpenClaw’s skill supply chain: - Scan skills with **cisco-ai-defense/skill-scanner** - Block only on **High/Critical** - Allow **Medium/Low/Info** but warn - Auto-scan on changes to `~/.openclaw/skills` - Quarantine failing skills to `~/.openclaw/skills-quarantine` ## Quick start ### Install skill-scanner (repo + uv env) ```bash cd "$HOME/.openclaw/workspace" # or wherever you keep repos git clone https://github.com/cisco-ai-defense/skill-scanner cd skill-scanner CC=gcc uv sync --all-extras ``` Note: some environments try `gcc-12` while building `yara-python`; forcing `CC=gcc` avoids that. ## Workflows ### 1) Scan all user skills (manual) User skills live at: - `~/.openclaw/skills` Run: ```bash $HOME/.openclaw/skills/skill-scanner-guard/scripts/scan_openclaw_skills.sh ``` Outputs go to: - `/home/rev/.openclaw/workspace/skill_scans/` ### 2) Install a folder skill with scan gate (copy/clone workflow) Use the wrapper instead of copying directly: ```bash $HOME/.openclaw/skills/skill-scanner-guard/scripts/scan_and_add_skill.sh /path/to/skill-dir ``` Policy: - Block only if **High/Critical** exist (unless `--force`) - Still installs if only Medium/Low/Info exist, but prints a warning summary ### 3) Install from ClawHub with scan gate (staging install) Install to a staging dir, scan, then copy into `~/.openclaw/skills` only if allowed: ```bash $HOME/.openclaw/skills/skill-scanner-guard/scripts/clawhub_scan_install.sh <slug> # optionally $HOME/.openclaw/skills/skill-scanner-guard/scripts/clawhub_scan_install.sh <slug> --version <version> ``` ### 4) Auto-scan + quarantine on change (systemd user units) Install the units (templates are in `references/`): ```bash mkdir -p ~/.config/systemd/user cp -a "$HOME/.openclaw/skills/skill-scanner-guard/references/openclaw-skill-scan."* ~/.config/systemd/user/ systemctl --user daemon-reload systemctl --user enable --now openclaw-skill-scan.path ``` Behavior: - Any change under `~/.openclaw/skills/` triggers `scripts/auto_scan_user_skills.sh` - If High/Critical findings exist, the script moves failing skill directories to: `~/.openclaw/skills-quarantine/<skillname>-<timestamp>` - Reports are written to: `/home/rev/.openclaw/workspace/skill_scans/auto/` Inspect: ```bash systemctl --user status openclaw-skill-scan.path journalctl --user -u openclaw-skill-scan.service -n 100 --no-pager ls -la ~/.openclaw/skills-quarantine ``` ## Bundled resources ### scripts/ - `scan_openclaw_skills.sh`: generate markdown reports for user + bundled skills - `scan_and_add_skill.sh`: scan candidate folder skill; install only if allowed - `clawhub_scan_install.sh`: stage-install from ClawHub, scan, then install - `auto_scan_user_skills.sh`: scan-all on `~/.openclaw/skills` changes; quarantine High/Critical failures ### references/ - `openclaw-skill-scan.path` / `openclaw-skill-scan.service`: systemd --user path trigger units

path: scripts/auto_scan_user_skills.sh
content: #!/usr/bin/env bash
set -euo pipefail

# Auto-scan OpenClaw user skills when ~/.openclaw/skills changes.
# Triggered by a systemd --user path unit.

STATE_DIR="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}"
WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$STATE_DIR/workspace}"
SCANNER_DIR="$WORKSPACE_DIR/skill-scanner"
USER_SKILLS="$STATE_DIR/skills"
OUT_DIR="$WORKSPACE_DIR/skill_scans/auto"
mkdir -p "$OUT_DIR"
TS="$(date +%Y%m%d-%H%M%S)"
REPORT="$OUT_DIR/openclaw_user_skills_$TS.md"

if [[ ! -d "$USER_SKILLS" ]]; then
  echo "No user skills dir at $USER_SKILLS; nothing to scan."
  exit 0
fi

if [[ ! -d "$SCANNER_DIR" ]]; then
  echo "ERROR: skill-scanner repo not found at $SCANNER_DIR" >&2
  exit 2
fi

cd "$SCANNER_DIR"

# Run scan
if command -v uv >/dev/null 2>&1; then
  UV_BIN="$(command -v uv)"
elif [[ -x "/home/linuxbrew/.linuxbrew/bin/uv" ]]; then
  UV_BIN="/home/linuxbrew/.linuxbrew/bin/uv"
else
  echo "ERROR: uv not found in PATH. Install uv: https://astral.sh/uv" >&2
  exit 2
fi

"$UV_BIN" run skill-scanner scan-all "$USER_SKILLS" --format markdown --detailed --output "$REPORT"

# Print a short summary to the journal
get_count() {
  local label="$1"
  local n
  n=$(grep -E "\*\*${label}:\*\*" "$REPORT" 2>/dev/null | head -n 1 | sed -E 's/.*\*\*[^:]+:\*\* *([0-9]+).*/\1/') || true
  if [[ -z "${n:-}" || ! "$n" =~ ^[0-9]+$ ]]; then
    echo 0
  else
    echo "$n"
  fi
}

CRITICAL=$(get_count "Critical")
HIGH=$(get_count "High")
MEDIUM=$(get_count "Medium")

echo "Skill scan complete: report=$REPORT critical=$CRITICAL high=$HIGH medium=$MEDIUM"

QUARANTINE_BASE="$STATE_DIR/skills-quarantine"

# If High/Critical exist, quarantine failing skills (Max Severity HIGH/CRITICAL).
if [[ "$CRITICAL" -gt 0 || "$HIGH" -gt 0 ]]; then
  mkdir -p "$QUARANTINE_BASE"
  echo "BLOCKING FINDINGS DETECTED (High/Critical). Quarantining affected skills. Review: $REPORT" >&2

  # Parse the markdown to find failing skills and their directories.
  # Looks for sections like:
  #   ### [FAIL] <skill>
  #   - **Max Severity:** HIGH
  #   ...
  #   - **Directory:** /path
  mapfile -t FAIL_DIRS < <(
    awk '
      /^### \[FAIL\] / { fail=1; sev=""; dir="" }
      fail && /\*\*Max Severity:\*\*/ { if (match($0, /\*\*Max Severity:\*\* ([A-Z]+)/, m)) sev=m[1] }
      fail && /\*\*Directory:\*\*/ { if (match($0, /\*\*Directory:\*\* (.*)$/, m)) dir=m[1] }
      fail && sev ~ /^(HIGH|CRITICAL)$/ && dir != "" { print dir; fail=0; sev=""; dir="" }
      # End section guard
      /^---$/ { if (fail && dir != "") { fail=0; sev=""; dir="" } }
    ' "$REPORT" | sort -u
  )

  if [[ ${#FAIL_DIRS[@]} -eq 0 ]]; then
    echo "High/Critical present, but could not parse failing directories from report. Leaving skills in place." >&2
    exit 1
  fi

  for d in "${FAIL_DIRS[@]}"; do
    if [[ -d "$d" && "$d" == "$USER_SKILLS"/* ]]; then
      name="$(basename "$d")"
      qdest="$QUARANTINE_BASE/${name}-$TS"
      echo "Quarantining: $d -> $qdest" >&2
      mv -- "$d" "$qdest"
    fi
  done

  # Non-zero so it shows up in status, but the bad skills are now out of the load path.
  exit 1
fi


path: scripts/clawhub_scan_install.sh
content: #!/usr/bin/env bash
set -euo pipefail

# Install a skill from ClawHub into a staging directory, scan it, and only then
# copy into OpenClaw's default user skill directory (~/.openclaw/skills).

usage() {
  cat <<'EOF'
Usage:
  clawhub_scan_install.sh <slug> [--version <version>] [--tag <tag>] [--force]

Notes:
  - Uses: npx clawhub install (downloads skill) + skill-scanner (security scan)
  - SAFE => installs to ~/.openclaw/skills/<slug>
  - Findings => blocks and prints a report path (unless --force)

Options:
  --version V   Install specific version (passed to clawhub)
  --tag TAG     (Currently unused for install; kept for future parity)
  --force       Install even if issues are found (NOT recommended)
EOF
}

if [[ ${1:-} == "-h" || ${1:-} == "--help" || ${1:-} == "help" ]]; then
  usage
  exit 0
fi

SLUG=${1:-}
shift || true
if [[ -z "$SLUG" ]]; then
  echo "ERROR: slug required" >&2
  usage >&2
  exit 2
fi

# Sanitize SLUG: allow only alphanumeric, hyphen, underscore, forward slash
if [[ ! "$SLUG" =~ ^[a-zA-Z0-9_\/-]+$ ]]; then
  echo "ERROR: Invalid slug characters" >&2
  exit 2
fi

VERSION=""
FORCE=0

while [[ $# -gt 0 ]]; do
  case "$1" in
    --version)
      VERSION=${2:-}
      if [[ -z "$VERSION" ]]; then
        echo "ERROR: --version requires a value" >&2
        exit 2
      fi
      shift 2
      ;;
    --tag)
      # clawhub install doesn't accept --tag, but we accept it to avoid footguns.
      shift 2
      ;;
    --force)
      FORCE=1
      shift
      ;;
    *)
      echo "ERROR: Unknown arg: $1" >&2
      usage >&2
      exit 2
      ;;
  esac
done

STATE_DIR="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}"
WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$STATE_DIR/workspace}"
STAGE_ROOT="$WORKSPACE_DIR/.skill_stage"
mkdir -p "$STAGE_ROOT"
STAGE_DIR="$(mktemp -d -p "$STAGE_ROOT" "clawhub-${SLUG}-XXXXXXXX")"

cleanup() {
  # Leave staging dir in place if debugging is needed.
  :
}
trap cleanup EXIT

# Install into staging
INSTALL_ARGS=(install "$SLUG")
if [[ -n "$VERSION" ]]; then
  INSTALL_ARGS+=(--version "$VERSION")
fi

# NOTE: --workdir controls where clawhub writes its metadata; --dir is where skills land.
# We keep everything inside the staging dir.
( cd "$STAGE_DIR" && npx -y clawhub --workdir "$STAGE_DIR" --dir skills "${INSTALL_ARGS[@]}" )

CANDIDATE="$STAGE_DIR/skills/$SLUG"
if [[ ! -d "$CANDIDATE" ]]; then
  echo "ERROR: clawhub install did not produce expected folder: $CANDIDATE" >&2
  exit 3
fi

ADD_SCRIPT="$STATE_DIR/skills/skill-scanner-guard/scripts/scan_and_add_skill.sh"
if [[ $FORCE -eq 1 ]]; then
  "$ADD_SCRIPT" "$CANDIDATE" --name "$SLUG" --force
else
  "$ADD_SCRIPT" "$CANDIDATE" --name "$SLUG"
fi

# If we got here, the add script already copied into ~/.openclaw/skills.
# (We intentionally keep the staging directory around in case you want to diff.)
echo "Staging directory preserved at: $STAGE_DIR"


path: scripts/scan_and_add_skill.sh
content: #!/usr/bin/env bash
set -euo pipefail

# Scan a candidate skill directory with cisco-ai-defense/skill-scanner.
# If SAFE, copy it into OpenClaw's default user skill dir (~/.openclaw/skills).
# If findings are present, block by default and print next-step options.

usage() {
  cat <<'EOF'
Usage:
  scan_and_add_skill.sh <path-to-skill-dir> [--force] [--name <dest-name>]

Behavior:
  - Runs skill-scanner against the skill directory.
  - If scan is SAFE (OK + 0 findings), installs into ~/.openclaw/skills/<dest-name>.
  - Otherwise exits non-zero and prints a report path.

Options:
  --force         Install even if issues are found (NOT recommended).
  --name NAME     Destination directory name under ~/.openclaw/skills (default: basename of input path).
EOF
}

if [[ ${1:-} == "-h" || ${1:-} == "--help" || ${1:-} == "help" ]]; then
  usage
  exit 0
fi

SRC_DIR=${1:-}
shift || true

if [[ -z "$SRC_DIR" || ! -d "$SRC_DIR" ]]; then
  echo "ERROR: Skill directory not found: $SRC_DIR" >&2
  usage >&2
  exit 2
fi

FORCE=0
DEST_NAME="$(basename "$SRC_DIR")"

while [[ $# -gt 0 ]]; do
  case "$1" in
    --force)
      FORCE=1
      shift
      ;;
    --name)
      DEST_NAME=${2:-}
      if [[ -z "$DEST_NAME" ]]; then
        echo "ERROR: --name requires a value" >&2
        exit 2
      fi
      # Sanitize DEST_NAME: alphanumeric, hyphen, underscore
      if [[ ! "$DEST_NAME" =~ ^[a-zA-Z0-9_-]+$ ]]; then
        echo "ERROR: Invalid name characters (alphanumeric, hyphen, underscore only)" >&2
        exit 2
      fi
      shift 2
      ;;
    *)
      echo "ERROR: Unknown arg: $1" >&2
      usage >&2
      exit 2
      ;;
  esac
done

STATE_DIR="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}"
WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$STATE_DIR/workspace}"
SCANNER_DIR="$WORKSPACE_DIR/skill-scanner"
OUT_DIR="$WORKSPACE_DIR/skill_scans"
mkdir -p "$OUT_DIR"
TS="$(date +%Y%m%d-%H%M%S)"
REPORT="$OUT_DIR/${DEST_NAME}_$TS.md"

if [[ ! -d "$SCANNER_DIR" ]]; then
  echo "ERROR: skill-scanner repo not found at $SCANNER_DIR" >&2
  echo "Clone it first: git clone https://github.com/cisco-ai-defense/skill-scanner $SCANNER_DIR" >&2
  exit 2
fi

cd "$SCANNER_DIR"

# Run scan
if command -v uv >/dev/null 2>&1; then
  UV_BIN="$(command -v uv)"
elif [[ -x "/home/linuxbrew/.linuxbrew/bin/uv" ]]; then
  UV_BIN="/home/linuxbrew/.linuxbrew/bin/uv"
else
  echo "ERROR: uv not found in PATH. Install uv: https://astral.sh/uv" >&2
  exit 2
fi

# Run scan; capture output for decisioning.
SCAN_OUT="$OUT_DIR/${DEST_NAME}_$TS.txt"
set +e
"$UV_BIN" run skill-scanner scan "$SRC_DIR" --format markdown --detailed --output "$REPORT" >"$SCAN_OUT" 2>&1
SCAN_CODE=$?
set -e

# Decide install policy:
# - BLOCK only if High or Critical findings exist (unless --force)
# - ALLOW Medium/Low/Info, but warn.
#
# Reports are markdown; we scrape the "Findings by Severity" summary.
get_count() {
  local label="$1"
  # Matches lines like: "- **High:** 3" or "- **Critical:** 0"
  local n
  n=$(grep -E "\*\*${label}:\*\*" "$REPORT" 2>/dev/null | head -n 1 | sed -E 's/.*\*\*[^:]+:\*\* *([0-9]+).*/\1/') || true
  if [[ -z "${n:-}" || ! "$n" =~ ^[0-9]+$ ]]; then
    echo 0
  else
    echo "$n"
  fi
}

CRITICAL_COUNT=$(get_count "Critical")
HIGH_COUNT=$(get_count "High")
MEDIUM_COUNT=$(get_count "Medium")
LOW_COUNT=$(get_count "Low")
INFO_COUNT=$(get_count "Info")

DEST_BASE="$STATE_DIR/skills"
DEST_DIR="$DEST_BASE/$DEST_NAME"

BLOCKED=0
if [[ "$CRITICAL_COUNT" -gt 0 || "$HIGH_COUNT" -gt 0 ]]; then
  BLOCKED=1
fi

if [[ $BLOCKED -eq 0 ]]; then
  mkdir -p "$DEST_BASE"
  if [[ -e "$DEST_DIR" ]]; then
    echo "ERROR: Destination already exists: $DEST_DIR" >&2
    echo "Remove/rename it, or choose a different --name." >&2
    exit 3
  fi

  if [[ "$MEDIUM_COUNT" -gt 0 || "$LOW_COUNT" -gt 0 || "$INFO_COUNT" -gt 0 ]]; then
    echo "Scan result: ALLOWED WITH WARNINGS (no High/Critical)"
    echo "  Critical: $CRITICAL_COUNT  High: $HIGH_COUNT  Medium: $MEDIUM_COUNT  Low: $LOW_COUNT  Info: $INFO_COUNT"
  else
    echo "Scan result: CLEAN (no findings)"
  fi

  # Copy the directory in a simple, predictable way.
  cp -a -- "$SRC_DIR" "$DEST_DIR"
  echo "Installed skill to: $DEST_DIR"
  echo "Report: $REPORT"
  exit 0
fi

# Blocked by policy (High/Critical present)
echo "Scan result: BLOCKED (High/Critical findings present)" >&2
echo "  Critical: $CRITICAL_COUNT  High: $HIGH_COUNT  Medium: $MEDIUM_COUNT  Low: $LOW_COUNT  Info: $INFO_COUNT" >&2
echo "Report: $REPORT" >&2

cat <<EOF >&2
Next steps:
  1) Open the report and review findings:
     $REPORT

  2) If findings are benign/expected (e.g., documented curl examples), fix them at the source and re-run.

  3) If you *must* install anyway (override):
     scan_and_add_skill.sh "$SRC_DIR" --name "$DEST_NAME" --force
EOF

if [[ $FORCE -eq 1 ]]; then
  mkdir -p "$DEST_BASE"
  if [[ -e "$DEST_DIR" ]]; then
    echo "ERROR: Destination already exists: $DEST_DIR" >&2
    exit 3
  fi
  cp -a "$SRC_DIR" "$DEST_DIR"
  echo "FORCED install completed: $DEST_DIR" >&2
  echo "Report: $REPORT" >&2
  exit 0
fi

exit 1


path: scripts/scan_openclaw_skills.sh
content: #!/usr/bin/env bash
set -euo pipefail

STATE_DIR="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}"
WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$STATE_DIR/workspace}"
SCANNER_DIR="$WORKSPACE_DIR/skill-scanner"

if command -v uv >/dev/null 2>&1; then
  UV_BIN="$(command -v uv)"
elif [[ -x "/home/linuxbrew/.linuxbrew/bin/uv" ]]; then
  UV_BIN="/home/linuxbrew/.linuxbrew/bin/uv"
else
  echo "ERROR: uv not found in PATH. Install uv: https://astral.sh/uv" >&2
  exit 2
fi

SCANNER_CMD=("$UV_BIN" run skill-scanner)

OUT_DIR="$WORKSPACE_DIR/skill_scans"
mkdir -p "$OUT_DIR"
TS="$(date +%Y%m%d-%H%M%S)"

if [[ ! -d "$SCANNER_DIR" ]]; then
  echo "ERROR: skill-scanner repo not found at $SCANNER_DIR" >&2
  echo "Clone it first: git clone https://github.com/cisco-ai-defense/skill-scanner $SCANNER_DIR" >&2
  exit 2
fi

cd "$SCANNER_DIR"

USER_SKILLS="$STATE_DIR/skills"
BUILTIN_SKILLS="/usr/local/lib/node_modules/openclaw/skills"

USER_REPORT="$OUT_DIR/openclaw_user_skills_$TS.md"
BUILTIN_REPORT="$OUT_DIR/openclaw_builtin_skills_$TS.md"

if [[ -d "$USER_SKILLS" ]]; then
  "${SCANNER_CMD[@]}" scan-all "$USER_SKILLS" --format markdown --detailed --output "$USER_REPORT"
  echo "User skills report: $USER_REPORT"
else
  echo "No user skills dir at $USER_SKILLS"
fi

if [[ -d "$BUILTIN_SKILLS" ]]; then
  # Built-in skills may include a few legacy manifests that don't parse; the scanner will log failures.
  "${SCANNER_CMD[@]}" scan-all "$BUILTIN_SKILLS" --format markdown --detailed --output "$BUILTIN_REPORT" || true
  echo "Built-in skills report: $BUILTIN_REPORT"
else
  echo "No built-in skills dir at $BUILTIN_SKILLS"
fi


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)