Skill: austindixson/skill-doc-formatter (version 1.0.0)

--- name: skill-doc-formatter displayName: Skill Doc Formatter | ClawHub description: Formats SKILL.md (OpenClaw/Cursor skill docs) for optimal display on ClawHub. Produces a consistent structure—Description, Installation, Usage with benefit-focused examples, and Commands—so skill pages are clear and scannable. version: 1.0.0 --- # Skill Doc Formatter | ClawHub ## Description Formats SKILL.md (OpenClaw/Cursor skill docs) for optimal display on ClawHub. Produces a consistent structure—Description, Installation, Usage with benefit-focused examples, and Commands—so skill pages are clear and scannable. # Skill Doc Formatter | ClawHub Formats **SKILL.md** skill documentation for optimal display on ClawHub. Output uses a consistent structure so skill pages are easy to scan: **Description**, **Installation**, **Usage** (with examples that showcase benefits), and **Commands**. Clear description: what the skill does and when to use it. Scannable. ## Installation ```bash clawhub install your-skill # or: git clone https://github.com/Org/your-skill.git workspace/skills/your-skill ``` ## Usage - Preparing or updating a skill for publication on ClawHub - Converting an existing SKILL.md into the ClawHub-recommended layout - Generating usage examples from a skill's description when examples are missing - Standardizing multiple skills to the same doc structure 1. Step or scenario one. 2. Step or scenario two. 3. When to run which command (point to Commands below). ## Examples **Example 1: [benefit]** *Scenario:* User wants to do X. *Action:* Run `your-command --foo`. *Outcome:* Brief result that showcases the benefit. **Example 2: [benefit]** (Same pattern.) ## Commands ```bash python3 <skill-dir>/scripts/script.py command [options] # What it does python3 <skill-dir>/scripts/script.py other # What it does ``` - **command** — Short description. - **other** — Short description. ``` ## Target structure (ClawHub-optimized) The formatter produces or normalizes these sections: | Section | Purpose | |--------|--------| | **Description** | One clear blurb (from frontmatter `description` + optional short intro). What the skill does and when to use it. | | **Installation** | How to install: `clawhub install <skill>`, `git clone`, or other steps. Copy-paste ready. | | **Usage** | How to use the skill: steps, scenarios, or workflow. Concise. | | **Examples** | Concrete examples that showcase benefits (e.g. before/after, sample commands with outcomes). Generated if missing. | | **Commands** | All CLI commands in one block with brief descriptions. Absolute paths or placeholders like `<skill-dir>`. | ## How to run From the skill you want to format, or from the formatter skill: ```bash # Format a skill by path (output to stdout) python3 /path/to/skill-doc-formatter/scripts/format_skill_doc.py /path/to/other-skill/SKILL.md # Write formatted result back to a new file python3 scripts/format_skill_doc.py /path/to/skill/SKILL.md -o /path/to/skill/SKILL.clawhub.md # Use formatter's own SKILL.md as input (demo) python3 scripts/format_skill_doc.py SKILL.md ``` Options: - `-o FILE` — Write output to FILE instead of stdout. - `--generate-examples` — Generate example usage blocks from the description when Examples section is missing or thin. - `--inplace` — Overwrite the input SKILL.md with the formatted version (use with care; prefer `-o` for review). - `--security-check` — Run security review checks after formatting to identify ClawHub security scan issues. ## Security Review The formatter includes a security review checker (`security_review.py`) that helps identify issues that may cause ClawHub security scans to flag skills as "Suspicious". Run it with: ```bash python3 scripts/security_review.py <skill-dir> ``` Or use the `--security-check` flag when formatting: ```bash python3 scripts/format_skill_doc.py <skill-dir>/SKILL.md --security-check ``` The security review checks for: - **Missing Requirements**: Skills that use system dependencies (CLI tools like `openclaw`, `lsof`, `ps`, `launchctl`) but don't declare them in SKILL.md - **Secret Logging**: Commands or values containing secrets/tokens/passwords being written to log files - **Missing Files**: Install scripts referencing files that don't exist in the skill package - **Environment Variables**: Scripts using env vars that aren't documented - **Persistent Behavior**: LaunchAgent/daemon installations without `always: true` in `_meta.json` - **File Permissions**: Log files containing sensitive data without restricted permissions - **Metadata vs docs consistency**: Env vars in `_meta.json` `env` (required) vs `optionalEnv` must match SKILL.md/README (no "optional" for required, no "required" for optional) - **openclaw.json read disclosure**: If scripts read `openclaw.json`, SKILL.md/README must disclose it and which fields are used; recommend a "Before installing" / "verify you are comfortable granting read access" note - **CLI vs safe subprocess**: If docs show CLI examples with user-supplied task/message in quotes (e.g. `spawn --json "..."`), docs must warn that programmatic use must use subprocess with a list of arguments (no shell interpolation) **Common fixes for ClawHub security reviews:** 1. **Add Requirements section** listing all CLI tools and system dependencies 2. **Mask secrets in logs** - don't log full command strings with `--token` or `--password` arguments 3. **Document environment variables** in SKILL.md Requirements section 4. **Add `always: true`** to `_meta.json` if the skill runs persistently (LaunchAgent/daemon) 5. **Set restrictive permissions** on log files containing sensitive data (`os.chmod(log_path, 0o600)`) 6. **Align env metadata and docs** - Keep `requires.env` / `optionalEnv` in `_meta.json` in sync with SKILL.md/README (required vs optional) 7. **Disclose openclaw.json reads** - State which fields are read (e.g. `tools.exec.host` / `tools.exec.node` only) and add "Before installing, verify you are comfortable granting read access to that file" 8. **Warn about safe invocation** - Above Commands/CLI, add that bash examples are for manual/CLI use only and that from code callers must use `subprocess.run(..., [..., user_message], ...)` with a list of arguments ## What the script does 1. **Parse** the existing SKILL.md (YAML frontmatter + markdown body). 2. **Map** existing `##` sections to Description / Installation / Usage / Examples / Commands (by title and content). 3. **Normalize** section order and headings to the ClawHub structure. 4. **Extract or generate**: - Description: frontmatter `description` + first paragraph or intro. - Installation: looks for "install", "clawhub", "clone", "npm" etc.; otherwise adds a placeholder. - Usage: keeps or merges "Usage", "When to use", "How to use" content. - Examples: keeps existing examples; with `--generate-examples`, adds 1–2 benefit-focused examples from the description. - Commands: collects fenced bash/code blocks and list items that look like CLI commands; merges into one **Commands** section. 5. **Emit** a single markdown document with clean headings and optional table of contents. 6. **Security Review** (optional): Run security checks to identify issues that may affect ClawHub security scans. ## Manual template If you prefer to edit by hand, use this structure in your SKILL.md: ```markdown --- name: your-skill displayName: Your Skill | OpenClaw Skill description: One-sentence description. Use when [trigger scenarios]. version: 1.0.0 --- # Your Skill Name Short intro: what it does and why it matters (1–2 sentences). ## Requirements - Python 3.7+ - Input: valid SKILL.md with YAML frontmatter (at least `name`, `description`). ## Files in this skill - `SKILL.md` — This file (instructions for the formatter skill). - `scripts/format_skill_doc.py` — Parser and formatter script. - `TEMPLATE_CLAWHUB_SKILL.md` — Copy-paste template for ClawHub-optimized SKILL.md.

path: TEMPLATE_CLAWHUB_SKILL.md
content: ---
name: your-skill
displayName: Your Skill | OpenClaw Skill
description: One-sentence description. Use when [trigger scenarios].
version: 1.0.0
---

# Your Skill Name

Short intro: what it does and why it matters (1–2 sentences).

## Description

Clear description of what the skill does and when to use it. Keep it scannable; include trigger terms so users and the agent know when to apply it.

## Installation

```bash
clawhub install your-skill
```

Or clone into your skills directory:

```bash
git clone https://github.com/Org/your-skill.git workspace/skills/your-skill
```

## Usage

1. **When to use:** List scenarios (e.g. "When the user asks for X", "Before running Y").
2. **Steps:** Numbered or bullet steps to use the skill.
3. **Point to Commands** below for exact CLI invocations.

## Examples

**Example 1: [Benefit name]**  
*Scenario:* User wants to do X.  
*Action:* Run `python3 <skill-dir>/scripts/tool.py do-x`.  
*Outcome:* Brief result that showcases the benefit.

**Example 2: [Another benefit]**  
*Scenario:* User needs Y.  
*Action:* Run `python3 <skill-dir>/scripts/tool.py do-y --option`.  
*Outcome:* What the user gets.

## Commands

```bash
python3 <skill-dir>/scripts/tool.py command [options]   # What it does
python3 <skill-dir>/scripts/tool.py other              # What it does
```

- **command** — Short description of the command and when to use it.
- **other** — Short description.


path: TEMPLATE_CLOWHUB_SKILL.md
content: ---
name: your-skill
displayName: Your Skill | OpenClaw Skill
description: One-sentence description. Use when [trigger scenarios].
version: 1.0.0
---

# Your Skill Name

Short intro: what it does and why it matters (1–2 sentences).

## Description

Clear description of what the skill does and when to use it. Keep it scannable; include trigger terms so users and the agent know when to apply it.

## Installation

```bash
clawhub install your-skill
```

Or clone into your skills directory:

```bash
git clone https://github.com/Org/your-skill.git workspace/skills/your-skill
```

## Usage

1. **When to use:** List scenarios (e.g. "When the user asks for X", "Before running Y").
2. **Steps:** Numbered or bullet steps to use the skill.
3. **Point to Commands** below for exact CLI invocations.

## Examples

**Example 1: [Benefit name]**  
*Scenario:* User wants to do X.  
*Action:* Run `python3 <skill-dir>/scripts/tool.py do-x`.  
*Outcome:* Brief result that showcases the benefit.

**Example 2: [Another benefit]**  
*Scenario:* User needs Y.  
*Action:* Run `python3 <skill-dir>/scripts/tool.py do-y --option`.  
*Outcome:* What the user gets.

## Commands

```bash
python3 <skill-dir>/scripts/tool.py command [options]   # What it does
python3 <skill-dir>/scripts/tool.py other              # What it does
```

- **command** — Short description of the command and when to use it.
- **other** — Short description.


path: _meta.json
content: {
  "schema": "openclaw.skill.v1",
  "name": "skill-doc-formatter",
  "version": "1.0.0",
  "displayName": "Skill Doc Formatter | ClawHub",
  "description": "Formats SKILL.md (OpenClaw/Cursor skill docs) for optimal display on ClawHub. Produces a consistent structure—Description, Installation, Usage with benefit-focused examples, and Commands.",
  "author": "OpenClaw",
  "tags": ["clawhub", "docs", "formatting", "openclaw"],
  "platform": "openclaw"
}


path: scripts/format_skill_doc.py
content: #!/usr/bin/env python3
"""
Format SKILL.md for optimal display on ClawHub.
Output structure: Description | Installation | Usage | Examples | Commands.
"""

import argparse
import re
import sys
from pathlib import Path


def parse_frontmatter(content: str):
    """Extract YAML frontmatter and body. Returns (frontmatter_dict, body_str)."""
    frontmatter = {}
    body = content
    if content.strip().startswith("---"):
        parts = content.split("---", 2)
        if len(parts) >= 3:
            try:
                import yaml
                frontmatter = yaml.safe_load(parts[1]) or {}
            except ImportError:
                # Minimal key: value parsing if no PyYAML
                for line in parts[1].strip().split("\n"):
                    m = re.match(r"^(\w+):\s*(.*)$", line)
                    if m:
                        val = m.group(2).strip().strip('"').strip("'")
                        frontmatter[m.group(1)] = val
            body = parts[2].lstrip("\n")
    return frontmatter, body


def extract_sections(body: str):
    """Split body by ## headers. Returns list of (title, content)."""
    sections = []
    pattern = re.compile(r"^##\s+(.+)$", re.MULTILINE)
    last_end = 0
    for m in pattern.finditer(body):
        if last_end < m.start():
            sections.append(("_intro", body[last_end:m.start()].strip()))
        start = m.end()
        next_m = pattern.search(body, start)
        end = next_m.start() if next_m else len(body)
        sections.append((m.group(1).strip(), body[start:end].strip()))
        last_end = end
    if last_end < len(body) and not sections:
        sections.append(("_intro", body.strip()))
    return sections


def normalize_section_title(title: str):
    """Map variant titles to canonical ClawHub section names."""
    t = title.lower()
    if "description" in t or "overview" in t or "about" in t:
        return "Description"
    if "install" in t or "setup" in t:
        return "Installation"
    if "usage" in t or "how to use" in t or "when to use" in t or "use" in t:
        return "Usage"
    if "example" in t:
        return "Examples"
    if "command" in t or "cli" in t:
        return "Commands"
    return title


def collect_into_canonical(sections: list, frontmatter: dict):
    """Group sections by canonical name. Returns dict section_name -> list of content blocks."""
    canonical = {
        "Description": [],
        "Installation": [],
        "Usage": [],
        "Examples": [],
        "Commands": [],
    }
    intro = []
    other = []

    for title, content in sections:
        if title == "_intro":
            intro.append(content)
            continue
        can = normalize_section_title(title)
        if can in canonical:
            canonical[can].append((title, content))
        else:
            other.append((title, content))

    # Build description from frontmatter + intro
    desc_parts = []
    if frontmatter.get("description"):
        desc_parts.append(frontmatter["description"])
    if intro:
        intro_text = re.sub(r"^#\s+.+$", "", intro[0], count=1).strip()
        if intro_text:
            desc_parts.append(intro_text)
    if desc_parts:
        canonical["Description"].insert(0, ("Description", "\n\n".join(desc_parts)))

    return canonical, other


def generate_examples_from_description(description: str, name: str):
    """Generate 1–2 benefit-focused example blocks from the skill description."""
    name_slug = name or "skill"
    lines = [
        "**Example: See the benefit**",
        f"*Scenario:* User needs what {name_slug} provides (see description above).",
        f"*Action:* Run the main command for this skill (see **Commands** below).",
        "*Outcome:* The skill delivers the described benefit—faster, clearer, or more reliable.",
        "",
        "Add concrete examples here: paste real command + short outcome to showcase benefits.",
    ]
    return "\n".join(lines)


def merge_commands(content_blocks: list):
    """Merge multiple command sections into one: collect code blocks and bullet lists."""
    merged = []
    for _title, content in content_blocks:
        # Preserve code blocks and lines that look like commands
        merged.append(content)
    return "\n\n".join(merged)


def emit_markdown(frontmatter: dict, canonical: dict, other: list, generate_examples: bool):
    """Emit full markdown in ClawHub order."""
    out = []
    # Frontmatter
    out.append("---")
    for k, v in frontmatter.items():
        if v is not None:
            out.append(f"{k}: {v}")
    out.append("---")
    out.append("")

    # Title
    display = frontmatter.get("displayName") or frontmatter.get("name", "Skill").replace("-", " ").title()
    out.append(f"# {display}")
    out.append("")

    order = ["Description", "Installation", "Usage", "Examples", "Commands"]
    for sec in order:
        blocks = canonical.get(sec, [])
        if not blocks and sec == "Examples" and generate_examples:
            blocks = [("Examples", generate_examples_from_description(
                frontmatter.get("description", ""), frontmatter.get("name", "")))]
        if not blocks:
            continue
        out.append(f"## {sec}")
        out.append("")
        for _title, content in blocks:
            if content.strip():
                out.append(content.strip())
                out.append("")
        out.append("")

    # Other sections (preserve under original titles)
    for title, content in other:
        if content.strip():
            out.append(f"## {title}")
            out.append("")
            out.append(content.strip())
            out.append("")
            out.append("")

    return "\n".join(out).rstrip() + "\n"


def main():
    ap = argparse.ArgumentParser(description="Format SKILL.md for ClawHub display")
    ap.add_argument("input", type=Path, help="Path to SKILL.md or skill directory")
    ap.add_argument("-o", "--output", type=Path, default=None, help="Output file (default: stdout)")
    ap.add_argument("--generate-examples", action="store_true", help="Generate example block from description if missing")
    ap.add_argument("--inplace", action="store_true", help="Overwrite input file (use with care)")
    ap.add_argument("--security-check", action="store_true", help="Run security review checks after formatting")
    args = ap.parse_args()

    path = args.input
    if not path.is_file():
        path = path / "SKILL.md"
    if not path.is_file():
        print(f"Error: not a file: {args.input}", file=sys.stderr)
        sys.exit(1)

    content = path.read_text(encoding="utf-8")
    frontmatter, body = parse_frontmatter(content)
    sections = extract_sections(body)
    canonical, other = collect_into_canonical(sections, frontmatter)
    result = emit_markdown(frontmatter, canonical, other, args.generate_examples)

    if args.inplace:
        path.write_text(result, encoding="utf-8")
        print(f"Wrote {path}", file=sys.stderr)
    elif args.output:
        args.output.write_text(result, encoding="utf-8")
        print(f"Wrote {args.output}", file=sys.stderr)
    else:
        print(result, end="")
    
    # Run security check if requested
    if args.security_check:
        skill_dir = path.parent if path.name == "SKILL.md" else path
        security_script = Path(__file__).parent / "security_review.py"
        if security_script.exists():
            import subprocess
            print("\n" + "=" * 70, file=sys.stderr)
            print("Running security review...", file=sys.stderr)
            print("=" * 70 + "\n", file=sys.stderr)
            try:
                subprocess.run([sys.executable, str(security_script), str(skill_dir)], check=False)
            except Exception as e:
                print(f"Warning: Security check failed: {e}", file=sys.stderr)


if __name__ == "__main__":
    main()


path: scripts/security_review.py
content: #!/usr/bin/env python3
"""
Security review checker for OpenClaw skills.
Scans skills for common security issues that affect ClawHub security reviews.
"""

import argparse
import json
import re
import sys
from pathlib import Path
from typing import Dict, List, Tuple


class SecurityReviewer:
    def __init__(self, skill_dir: Path):
        self.skill_dir = Path(skill_dir)
        self.issues = []
        self.warnings = []
        self.suggestions = []

    def check_requirements_declaration(self):
        """Check if runtime dependencies are declared in SKILL.md or _meta.json."""
        skill_md = self.skill_dir / "SKILL.md"
        meta_json = self.skill_dir / "_meta.json"
        
        # Read SKILL.md
        skill_content = ""
        if skill_md.exists():
            skill_content = skill_md.read_text(encoding="utf-8")
        
        # Read _meta.json
        meta = {}
        if meta_json.exists():
            try:
                meta = json.loads(meta_json.read_text(encoding="utf-8"))
            except:
                pass
        
        # Check for common CLI/system dependencies in scripts
        scripts_dir = self.skill_dir / "scripts"
        found_deps = set()
        
        if scripts_dir.exists():
            for script_file in scripts_dir.glob("*.py"):
                content = script_file.read_text(encoding="utf-8")
                # Check for subprocess calls to system tools
                if re.search(r"subprocess\.(run|call|Popen|check_output)", content):
                    # Look for common system tools in command arrays or strings
                    for tool in ["openclaw", "lsof", "ps", "launchctl", "curl", "wget", "git", "which", "kill", "killall"]:
                        # Check for tool in command arrays or subprocess calls
                        if re.search(rf"['\"]({tool})['\"]|\[.*['\"]({tool})['\"]", content, re.IGNORECASE):
                            found_deps.add(tool)
            
            # Also check shell scripts
            for script_file in scripts_dir.glob("*.sh"):
                content = script_file.read_text(encoding="utf-8")
                for tool in ["openclaw", "lsof", "ps", "launchctl", "curl", "wget", "git", "which", "kill", "killall"]:
                    if re.search(rf"\\b{tool}\\b", content, re.IGNORECASE):
                        found_deps.add(tool)
        
        # Check if Requirements section exists
        has_requirements = bool(re.search(r"##\s+Requirements?", skill_content, re.IGNORECASE))
        
        # Check if dependencies are mentioned in Requirements section
        deps_mentioned = False
        if has_requirements and found_deps:
            # Extract Requirements section content
            req_match = re.search(r"##\s+Requirements?.*?##", skill_content, re.IGNORECASE | re.DOTALL)
            if req_match:
                req_content = req_match.group(0).lower()
                deps_mentioned = all(dep.lower() in req_content for dep in found_deps)
        
        if found_deps:
            if not has_requirements:
                self.issues.append({
                    "type": "missing_requirements",
                    "severity": "medium",
                    "message": f"Skill uses system dependencies ({', '.join(sorted(found_deps))}) but no Requirements section found in SKILL.md",
                    "fix": "Add a ## Requirements section listing all CLI tools and system dependencies"
                })
            elif not deps_mentioned:
                self.warnings.append({
                    "type": "requirements_incomplete",
                    "severity": "low",
                    "message": f"Skill uses system dependencies ({', '.join(sorted(found_deps))}) but they're not all mentioned in Requirements section",
                    "fix": "Update Requirements section to list all CLI tools and system dependencies used by the skill"
                })

    def check_secret_logging(self):
        """Check for secrets being logged to files."""
        scripts_dir = self.skill_dir / "scripts"
        if not scripts_dir.exists():
            return
        
        for script_file in scripts_dir.glob("*.py"):
            content = script_file.read_text(encoding="utf-8")
            lines = content.split("\n")
            
            # Look for log file paths
            log_paths = []
            for i, line in enumerate(lines, 1):
                # Find log file paths
                match = re.search(r"['\"]([^'\"]+\.log)['\"]", line)
                if match:
                    log_paths.append((i, match.group(1)))
            
            # Check each log file usage
            for log_line_num, log_path in log_paths:
                # Look for writes to this log file
                for i, line in enumerate(lines, 1):
                    if log_path in line and ("write" in line.lower() or "logf" in line or "log_file" in line.lower() or "open" in line.lower()):
                        # Check if command with secrets is being written
                        # Look ahead and behind a few lines for command construction
                        context_start = max(0, i - 15)
                        context_end = min(len(lines), i + 10)
                        context = "\n".join(lines[context_start:context_end])
                        
                        # Pattern 1: Writing joined command array (e.g., ' '.join(cmd))
                        if re.search(r"['\"].*join\(.*cmd|' '.join\(.*cmd|\+.*cmd", context, re.IGNORECASE):
                            # Check if cmd contains secrets
                            if re.search(r"cmd.*--(token|password|auth)|--(token|password|auth).*cmd", context, re.IGNORECASE):
                                self.issues.append({
                                    "type": "secret_logging",
                                    "severity": "high",
                                    "message": f"{script_file.name}:{i} - Full command with secrets written to log file '{log_path}'",
                                    "fix": "Do not log full command strings containing secrets. Log only command name and masked arguments (e.g., log cmd[0] + '***' for args)"
                                })
                        
                        # Pattern 2: Direct write of command string with secrets
                        if re.search(r"write.*\(.*['\"].*--(token|password|auth)", line, re.IGNORECASE):
                            self.issues.append({
                                "type": "secret_logging",
                                "severity": "high",
                                "message": f"{script_file.name}:{i} - Command with secrets written to log file '{log_path}'",
                                "fix": "Do not log full command strings containing secrets. Log only command name and masked arguments"
                            })
                        
                        # Pattern 3: subprocess.Popen with stdout/stderr to log file and cmd contains secrets
                        if "subprocess" in context.lower() and ("Popen" in context or "run" in context):
                            # Check if cmd variable is passed and contains secrets
                            cmd_context = "\n".join(lines[max(0, i-20):min(len(lines), i+5)])
                            if re.search(r"cmd.*=.*\[.*--(token|password|auth)", cmd_context, re.IGNORECASE):
                                # Only flag if stdout/stderr are actually sent to the log (not DEVNULL)
                                if "devnull" in context.lower() and "stdout" in context.lower() and "stderr" in context.lower():
                                    continue  # Safe: output discarded
                                if "stdout" in line.lower() or "stderr" in line.lower() or log_path in line:
                                    self.issues.append({
                                        "type": "secret_logging",
                                        "severity": "high",
                                        "message": f"{script_file.name}:{i} - Command with secrets passed to subprocess with output to log file '{log_path}'",
                                        "fix": "Do not pass commands with secrets to subprocess when stdout/stderr goes to log files. Mask secrets in command or redirect output elsewhere"
                                    })
                        
                        # Pattern 4: Direct secret/token/password variable in write statement
                        if re.search(r"write.*\(.*(secret|token|password)", line, re.IGNORECASE):
                            self.issues.append({
                                "type": "secret_logging",
                                "severity": "high",
                                "message": f"{script_file.name}:{i} - Potential secret value written to log file '{log_path}'",
                                "fix": "Mask secrets before logging (e.g., log only hash or '***') or avoid logging command arguments containing secrets"
                            })

    def check_missing_files(self):
        """Check for referenced files that don't exist in the skill."""
        scripts_dir = self.skill_dir / "scripts"
        if not scripts_dir.exists():
            return
        
        for script_file in scripts_dir.glob("*.sh"):
            content = script_file.read_text(encoding="utf-8")
            
            # Look for file references
            for match in re.finditer(r"([\"'])([^\"']+\.(plist|json|py|sh))\\1", content):
                ref_file = match.group(2)
                # Check if it's a relative path
                if not ref_file.startswith("/") and not ref_file.startswith("$"):
                    full_path = self.skill_dir / ref_file
                    if not full_path.exists():
                        # Check if it's in scripts/
                        script_path = scripts_dir / ref_file
                        if not script_path.exists():
                            self.issues.append({
                                "type": "missing_file",
                                "severity": "medium",
                                "message": f"{script_file.name} references '{ref_file}' which doesn't exist",
                                "fix": f"Add the missing file or update the reference"
                            })

    def check_env_vars_declaration(self):
        """Check if environment variables are declared."""
        scripts_dir = self.skill_dir / "scripts"
        skill_md = self.skill_dir / "SKILL.md"
        
        if not scripts_dir.exists():
            return
        
        # Find env vars used in scripts
        found_env_vars = set()
        for script_file in scripts_dir.glob("*.py"):
            content = script_file.read_text(encoding="utf-8")
            for match in re.finditer(r"os\.environ\.get\(['\"]([^'\"]+)['\"]", content):
                found_env_vars.add(match.group(1))
            for match in re.finditer(r"os\.getenv\(['\"]([^'\"]+)['\"]", content):
                found_env_vars.add(match.group(1))
        
        if found_env_vars:
            doc_content = ""
            if skill_md.exists():
                doc_content += skill_md.read_text(encoding="utf-8") + "\n"
            readme = self.skill_dir / "README.md"
            if readme.exists():
                doc_content += readme.read_text(encoding="utf-8")
            # Check if env vars are mentioned in SKILL.md or README
            env_vars_mentioned = any(
                re.search(rf"\b{re.escape(var)}\b", doc_content, re.IGNORECASE)
                for var in found_env_vars
            )
            
            if not env_vars_mentioned:
                self.warnings.append({
                    "type": "env_vars_not_declared",
                    "severity": "low",
                    "message": f"Scripts use environment variables ({', '.join(sorted(found_env_vars))}) but they're not documented",
                    "fix": "Document required environment variables in SKILL.md Requirements section"
                })

    def check_persistent_behavior(self):
        """Check for LaunchAgent/daemon behavior without always:true."""
        scripts_dir = self.skill_dir / "scripts"
        meta_json = self.skill_dir / "_meta.json"
        
        if not scripts_dir.exists():
            return
        
        # Check for LaunchAgent install scripts
        has_launchagent = False
        for script_file in scripts_dir.glob("*.sh"):
            content = script_file.read_text(encoding="utf-8")
            if "launchctl" in content.lower() or "plist" in content.lower():
                has_launchagent = True
                break
        
        if has_launchagent:
            meta = {}
            if meta_json.exists():
                try:
                    meta = json.loads(meta_json.read_text(encoding="utf-8"))
                except:
                    pass
            
            if not meta.get("always"):
                self.warnings.append({
                    "type": "persistent_behavior",
                    "severity": "low",
                    "message": "Skill installs LaunchAgent/daemon but _meta.json doesn't have 'always': true",
                    "fix": "Add 'always': true to _meta.json if the skill runs persistently"
                })

    def check_file_permissions(self):
        """Check for log files that might need restricted permissions."""
        scripts_dir = self.skill_dir / "scripts"
        if not scripts_dir.exists():
            return
        
        for script_file in scripts_dir.glob("*.py"):
            content = script_file.read_text(encoding="utf-8")
            
            # Look for log file creation
            for match in re.finditer(r"open\(['\"]([^'\"]+\.log)['\"]", content):
                log_file = match.group(1)
                # Check if permissions are set
                context_lines = content[:content.find(match.group(0))].split("\n")
                has_permissions = any("chmod" in line or "os.chmod" in line for line in context_lines[-10:])
                
                if not has_permissions and "secret" in log_file.lower() or "auth" in log_file.lower():
                    self.suggestions.append({
                        "type": "log_file_permissions",
                        "severity": "low",
                        "message": f"{script_file.name} creates log file '{log_file}' - consider restricting permissions",
                        "fix": "Set restrictive permissions on log files containing sensitive data: os.chmod(log_path, 0o600)"
                    })

    def check_metadata_docs_env_consistency(self):
        """Check that _meta.json env/optionalEnv matches SKILL.md and README (required vs optional)."""
        meta_json = self.skill_dir / "_meta.json"
        if not meta_json.exists():
            return
        try:
            meta = json.loads(meta_json.read_text(encoding="utf-8"))
        except Exception:
            return
        requires = meta.get("requires") or {}
        required_env = set(requires.get("env") or [])
        optional_env = set(requires.get("optionalEnv") or [])
        all_declared = required_env | optional_env
        if not all_declared:
            return
        doc_content = ""
        for name in ("SKILL.md", "README.md"):
            p = self.skill_dir / name
            if p.exists():
                doc_content += "\n" + p.read_text(encoding="utf-8")
        for var in all_declared:
            if not re.search(rf"\b{re.escape(var)}\b", doc_content, re.IGNORECASE):
                continue
            in_required = var in required_env
            in_optional = var in optional_env
            # Look at a window around each occurrence of the var to avoid section headers like "Required:" + list with optional items
            says_optional = False
            says_required = False
            for m in re.finditer(rf"\b{re.escape(var)}\b", doc_content, re.IGNORECASE):
                start = max(0, m.start() - 80)
                end = min(len(doc_content), m.end() + 80)
                window = doc_content[start:end]
                if re.search(r"optional|not required|if unset|defaults to", window, re.IGNORECASE):
                    says_optional = True
                # Only treat as "docs say required" if var is explicitly marked required, e.g. "(required)" or "is required"
                if re.search(r"\((required|mandatory)\)|is (required|mandatory)\b|\b(required|mandatory)\s*:", window, re.IGNORECASE):
                    if not re.search(r"optional|not required", window, re.IGNORECASE):
                        says_required = True
            if in_required and says_optional:
                self.issues.append({
                    "type": "metadata_docs_mismatch",
                    "severity": "medium",
                    "message": f"_meta.json lists {var} in required 'env' but docs describe it as optional",
                    "fix": "Either move " + var + " to optionalEnv in _meta.json or remove 'optional'/'not required' from SKILL.md/README"
                })
            if in_optional and says_required:
                self.warnings.append({
                    "type": "metadata_docs_mismatch",
                    "severity": "low",
                    "message": f"_meta.json lists {var} in optionalEnv but docs say it is required/mandatory",
                    "fix": "Either add " + var + " to requires.env in _meta.json or state in SKILL.md/README that it is optional (e.g. defaults to ~/.openclaw)"
                })

    def check_openclaw_json_read_disclosure(self):
        """If scripts read openclaw.json, docs must disclose it and recommend verifying read access."""
        scripts_dir = self.skill_dir / "scripts"
        if not scripts_dir.exists():
            return
        reads_openclaw_json = False
        for script_file in scripts_dir.glob("*.py"):
            content = script_file.read_text(encoding="utf-8")
            if "openclaw.json" in content or ("openclaw" in content and "json" in content and ("open(" in content or "read" in content.lower())):
                reads_openclaw_json = True
                break
        if not reads_openclaw_json:
            return
        doc_content = ""
        for name in ("SKILL.md", "README.md"):
            p = self.skill_dir / name
            if p.exists():
                doc_content += "\n" + p.read_text(encoding="utf-8")
        # Must mention reading openclaw.json (or tools.exec)
        discloses_read = (
            "openclaw.json" in doc_content
            and ("tools.exec" in doc_content or "read" in doc_content.lower() or "access" in doc_content.lower())
        )
        trust_note = bool(
            re.search(r"comfortable granting read access|verify you are comfortable|before installing", doc_content, re.IGNORECASE)
        )
        if not discloses_read:
            self.issues.append({
                "type": "openclaw_json_not_disclosed",
                "severity": "medium",
                "message": "Scripts read openclaw.json but SKILL.md/README do not clearly disclose it and which fields are used",
                "fix": "Document in SKILL.md/README that the skill reads openclaw.json (e.g. tools.exec.host/node only) and only those fields"
            })
        elif not trust_note:
            self.warnings.append({
                "type": "openclaw_json_trust_note_missing",
                "severity": "low",
                "message": "Skill reads openclaw.json but docs don't ask installers to verify they're comfortable granting read access",
                "fix": "Add a 'Before installing' or Requirements note: 'Verify you are comfortable granting read access to that file.'"
            })

    def check_cli_safe_subprocess_warning(self):
        """If docs show CLI examples with user-supplied args, they must warn to use subprocess with list args (no shell)."""
        doc_content = ""
        for name in ("SKILL.md", "README.md"):
            p = self.skill_dir / name
            if p.exists():
                doc_content += "\n" + p.read_text(encoding="utf-8")
        # CLI examples that pass a quoted string (user message / task) as last or only arg
        has_user_arg_cli = bool(
            re.search(r"(python|python3).*\.py\s+[^\n]*--(json|spawn|classify)\s+[\"'].*[\"']", doc_content)
            or re.search(r"spawn\s+--json\s+[\"']", doc_content)
        )
        safe_keywords = (
            "subprocess" in doc_content
            and ("list of arguments" in doc_content or "list arguments" in doc_content or "shell interpolation" in doc_content.lower()
                 or "manual/cli use only" in doc_content.lower() or "manual use only" in doc_content.lower()
                 or "do not build a shell command" in doc_content.lower())
        )
        if has_user_arg_cli and not safe_keywords:
            self.warnings.append({
                "type": "cli_safe_subprocess_warning_missing",
                "severity": "medium",
                "message": "Docs show CLI examples with user-supplied task/message in quotes but no warning to use subprocess with list args for programmatic use",
                "fix": "Add above Commands/CLI: 'Manual/CLI use only. From code with user input, use subprocess.run(..., [..., user_message], ...) with a list of arguments; do not build a shell command string.'"
            })

    def run_all_checks(self):
        """Run all security checks."""
        self.check_requirements_declaration()
        self.check_secret_logging()
        self.check_missing_files()
        self.check_env_vars_declaration()
        self.check_persistent_behavior()
        self.check_file_permissions()
        self.check_metadata_docs_env_consistency()
        self.check_openclaw_json_read_disclosure()
        self.check_cli_safe_subprocess_warning()

    def generate_report(self, json_output: bool = False) -> str:
        """Generate a security review report."""
        self.run_all_checks()
        
        if json_output:
            return json.dumps({
                "issues": self.issues,
                "warnings": self.warnings,
                "suggestions": self.suggestions,
                "summary": {
                    "total_issues": len(self.issues),
                    "total_warnings": len(self.warnings),
                    "total_suggestions": len(self.suggestions),
                    "high_severity": len([i for i in self.issues if i["severity"] == "high"]),
                    "medium_severity": len([i for i in self.issues if i["severity"] == "medium"]),
                }
            }, indent=2)
        
        # Text report
        lines = []
        lines.append("=" * 70)
        lines.append("SECURITY REVIEW REPORT")
        lines.append("=" * 70)
        lines.append("")
        
        if not self.issues and not self.warnings and not self.suggestions:
            lines.append("✓ No security issues found!")
            lines.append("")
            return "\n".join(lines)
        
        if self.issues:
            lines.append(f"ISSUES ({len(self.issues)}):")
            lines.append("-" * 70)
            for issue in self.issues:
                lines.append(f"[{issue['severity'].upper()}] {issue['type']}")
                lines.append(f"  {issue['message']}")
                lines.append(f"  Fix: {issue['fix']}")
                lines.append("")
        
        if self.warnings:
            lines.append(f"WARNINGS ({len(self.warnings)}):")
            lines.append("-" * 70)
            for warning in self.warnings:
                lines.append(f"[{warning['severity'].upper()}] {warning['type']}")
                lines.append(f"  {warning['message']}")
                lines.append(f"  Fix: {warning['fix']}")
                lines.append("")
        
        if self.suggestions:
            lines.append(f"SUGGESTIONS ({len(self.suggestions)}):")
            lines.append("-" * 70)
            for suggestion in self.suggestions:
                lines.append(f"[{suggestion['severity'].upper()}] {suggestion['type']}")
                lines.append(f"  {suggestion['message']}")
                lines.append(f"  Fix: {suggestion['fix']}")
                lines.append("")
        
        lines.append("=" * 70)
        return "\n".join(lines)


def main():
    ap = argparse.ArgumentParser(description="Security review checker for OpenClaw skills")
    ap.add_argument("skill_dir", type=Path, help="Path to skill directory")
    ap.add_argument("--json", action="store_true", help="Output JSON format")
    args = ap.parse_args()
    
    skill_dir = Path(args.skill_dir)
    if not skill_dir.exists():
        print(f"Error: skill directory not found: {skill_dir}", file=sys.stderr)
        sys.exit(1)
    
    reviewer = SecurityReviewer(skill_dir)
    report = reviewer.generate_report(json_output=args.json)
    print(report)
    
    # Exit with error code if there are high-severity issues
    if reviewer.issues:
        high_severity = [i for i in reviewer.issues if i["severity"] == "high"]
        if high_severity:
            sys.exit(1)


if __name__ == "__main__":
    main()


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