Skill: jenish-sojitra/clawned (version 1.3.5)

--- name: clawned description: Security agent that inventories installed OpenClaw skills, analyzes them for threats, and syncs results to your Clawned dashboard. metadata: { "openclaw": { "emoji": "🛡️", "requires": { "bins": ["python3"], "env": ["CLAWNED_API_KEY"] }, "optionalEnv": ["CLAWNED_SERVER"], "homepage": "https://clawned.io", }, } --- # Clawned — Security Agent for OpenClaw Automatically discovers all installed skills, analyzes them for security threats, and syncs results to your Clawned dashboard. ## Setup Configure your API key in `openclaw.json`: ```json { "skills": { "entries": { "clawned": { "enabled": true, "env": { "CLAWNED_API_KEY": "cg_your_api_key_here", "CLAWNED_SERVER": "https://api.clawned.io" } } } } } ``` ## Commands ### Sync all installed skills to dashboard ```bash python3 {baseDir}/scripts/agent.py sync ``` ### Scan a single skill locally ```bash python3 {baseDir}/scripts/agent.py scan --path <skill-directory> ``` ### List all discovered skills ```bash python3 {baseDir}/scripts/agent.py inventory ``` ### Check agent status ```bash python3 {baseDir}/scripts/agent.py status ``` ## Data & Privacy **During `sync` (default operation):** - Sends only skill metadata: name, owner, slug, version - No file contents are uploaded - No `.env` files or secrets are ever read **During `scan --path` (explicit user action only):** - Reads source files (`.md`, `.py`, `.js`, etc.) from the specified skill directory for analysis - `.env` files are excluded from scanning - File contents are sent to the Clawned server for security analysis **Local config access:** - Reads `~/.openclaw/openclaw.json` only to locate skill directories (extraDirs paths) - No credentials or secrets are read from config files ## Automatic Sync Schedule every 6 hours via OpenClaw cron: ```json { "jobs": [ { "schedule": "0 */6 * * *", "command": "Run clawned sync to check all installed skills", "description": "Security scan every 6 hours" } ] } ```

path: scripts/agent.py
content: #!/usr/bin/env python3
"""
Clawned Agent — Discovers installed OpenClaw skills and syncs to server.
Usage:
    python3 agent.py sync
    python3 agent.py scan --path <dir>
    python3 agent.py inventory
    python3 agent.py status
    python3 agent.py daemon              # run in background
    python3 agent.py daemon --interval 10
    python3 agent.py stop                # stop the daemon
"""

import argparse, hashlib, json, os, platform, signal, sys, time, urllib.request, urllib.error, logging
from datetime import datetime, timezone

CLAWNED_SERVER = os.getenv("CLAWNED_SERVER", "https://api.clawned.io")
CLAWNED_API_KEY = os.getenv("CLAWNED_API_KEY", "")
STATE_FILE = os.path.expanduser("~/.openclaw/clawned_agent.json")
PID_FILE = os.path.expanduser("~/.openclaw/clawned_agent.pid")
LOG_FILE = os.path.expanduser("~/.openclaw/clawned_agent.log")
SCAN_BUNDLED = False
BUNDLED_OWNER = "steipete"

def api_request(endpoint, data=None, method="POST"):
    if not CLAWNED_API_KEY:
        print("[!] CLAWNED_API_KEY not set. Get your key at https://clawned.io/settings"); sys.exit(1)
    body = json.dumps(data).encode() if data else None
    req = urllib.request.Request(f"{CLAWNED_SERVER}{endpoint}", data=body, method=method,
        headers={"Authorization": f"Bearer {CLAWNED_API_KEY}", "Content-Type": "application/json",
                 "User-Agent": "ClawnedAgent/1.0"})
    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            return json.loads(resp.read().decode())
    except urllib.error.HTTPError as e:
        print(f"[!] API error {e.code}: {e.read().decode() if e.fp else ''}"); sys.exit(1)
    except urllib.error.URLError as e:
        print(f"[!] Cannot reach server: {e.reason}"); sys.exit(1)

def load_state():
    if os.path.exists(STATE_FILE):
        try: return json.load(open(STATE_FILE))
        except: pass
    return {}

def save_state(state):
    os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
    json.dump(state, open(STATE_FILE, "w"), indent=2)

def get_skill_dirs():
    """Locate skill directories. Reads openclaw.json only for extraDirs paths — no secrets or credentials are read from config."""
    dirs, home = [], os.path.expanduser("~")
    # Check both possible managed skill locations
    for managed in [os.path.join(home, ".openclaw", "workspace", "skills"),
                    os.path.join(home, ".openclaw", "skills")]:
        if os.path.isdir(managed): dirs.append((managed, "managed"))
    try:
        cfg = json.load(open(os.path.join(home, ".openclaw", "openclaw.json")))
        # Only reads the extraDirs list to know where skills are installed
        for d in cfg.get("skills", {}).get("load", {}).get("extraDirs", []):
            exp = os.path.expanduser(d)
            if os.path.isdir(exp): dirs.append((exp, "extra"))
    except: pass
    for p in [os.path.join(home, ".npm-global", "lib", "node_modules", "openclaw", "skills"),
              "/Applications/OpenClaw.app/Contents/Resources/skills"]:
        if os.path.isdir(p): dirs.append((p, "bundled"))
    return dirs

SCANNABLE_EXTS = {".md", ".py", ".js", ".ts", ".sh", ".mjs", ".cjs", ".jsx", ".tsx",
    ".mts", ".bash", ".zsh", ".rb", ".pl", ".yaml", ".yml", ".json", ".toml",
    ".cfg", ".conf", ".lua", ".go", ".rs", ".r", ".ps1", ".bat", ".cmd", ".txt", ".ini"}
# NOTE: .env is intentionally excluded to avoid leaking secrets
MAX_FILE_SIZE = 512 * 1024  # 512KB per file
MAX_FILES = 30

def collect_skill_files(path):
    """Read scannable files from a skill directory. Only called during explicit 'scan' command, never during 'sync'."""
    files = {}
    for root, _dirs, fnames in os.walk(path):
        for fname in sorted(fnames):
            if len(files) >= MAX_FILES:
                return files
            fpath = os.path.join(root, fname)
            rel = os.path.relpath(fpath, path)
            lower = fname.lower()
            # Check scannable
            if lower in ("makefile", "dockerfile", "skill.md"):
                pass  # always include
            elif not any(lower.endswith(ext) for ext in SCANNABLE_EXTS):
                continue
            try:
                sz = os.path.getsize(fpath)
                if sz == 0 or sz > MAX_FILE_SIZE:
                    continue
                files[rel] = open(fpath, errors="replace").read()
            except:
                continue
    return files

def read_skill(name, path, source, dir_owner="", include_files=False):
    """Read a skill directory. Only reads _meta.json or SKILL.md for metadata.
    Full file collection only happens when include_files=True (explicit scan)."""
    meta_path = os.path.join(path, "_meta.json")
    if os.path.isfile(meta_path):
        try:
            meta = json.load(open(meta_path))
            owner = meta.get("owner", "") or dir_owner
            result = {
                "owner": owner,
                "slug": meta.get("slug", name),
                "displayName": meta.get("displayName", name),
                "latest": meta.get("latest", {"version": "", "publishedAt": 0, "commit": ""}),
            }
            if include_files:
                result["files"] = collect_skill_files(path)
            return result
        except Exception as e:
            print(f"[!] Failed to read _meta.json for {name}: {e}")
            pass

    # Fallback: construct from SKILL.md (use directory name as slug)
    sm = os.path.join(path, "SKILL.md")
    try: content = open(sm, errors="replace").read()
    except: return None
    desc = ""
    for line in content.split("\n"):
        if line.strip().startswith("description:"):
            desc = line.split(":", 1)[1].strip().strip("'\""); break
    fhash = hashlib.sha256(content.encode()).hexdigest()
    owner = dir_owner or os.path.basename(os.path.dirname(path))
    if owner == "skills": owner = "unknown"
    display = name
    if desc:
        display = desc[:120] if len(desc) > 120 else desc
    result = {
        "owner": owner,
        "slug": name,
        "displayName": display,
        "latest": {"version": "", "publishedAt": 0, "commit": fhash},
    }
    if include_files:
        result["files"] = collect_skill_files(path)
    return result

def discover_skills():
    skills, seen = [], set()
    for d, src in get_skill_dirs():
        if src == "bundled" and not SCAN_BUNDLED:
            print(f"[*] Skipping bundled skills: {d} (use --include-bundled to include)")
            continue
        print(f"[*] Scanning directory: {d} ({src})")
        # For bundled skills, owner is always BUNDLED_OWNER
        default_owner = BUNDLED_OWNER if src == "bundled" else ""
        for owner_name in sorted(os.listdir(d)):
            owner_path = os.path.join(d, owner_name)
            if not os.path.isdir(owner_path): continue
            # Check if this is a direct skill dir (has _meta.json or SKILL.md)
            if os.path.isfile(os.path.join(owner_path, "_meta.json")) or os.path.isfile(os.path.join(owner_path, "SKILL.md")):
                key = owner_name
                if key == "clawned" or key in seen: continue
                seen.add(key)
                s = read_skill(owner_name, owner_path, src, dir_owner=default_owner)
                if s: skills.append(s)
                continue
            # Otherwise, this is an owner directory — look for slug dirs inside
            for slug_name in sorted(os.listdir(owner_path)):
                slug_path = os.path.join(owner_path, slug_name)
                if not os.path.isdir(slug_path): continue
                if not os.path.isfile(os.path.join(slug_path, "_meta.json")) and not os.path.isfile(os.path.join(slug_path, "SKILL.md")):
                    continue
                key = f"{owner_name}/{slug_name}"
                if key in seen: continue
                seen.add(key)
                s = read_skill(slug_name, slug_path, src, dir_owner=owner_name)
                if s: skills.append(s)
    return skills

def cmd_sync():
    """Sync skill metadata to dashboard. Only sends: skill name, owner, version, slug.
    No file contents are uploaded during sync — files are never collected here."""
    state = load_state()
    if "agent_id" not in state:
        print("[*] Registering agent...")
        # Sends only hostname and OS for agent registration
        r = api_request("/api/skills/agent/register", {"hostname": platform.node(), "os_platform": platform.system().lower()})
        state["agent_id"] = r["agent_id"]; save_state(state)
        print(f"[+] Agent: {r['agent_id']}")
    print("[*] Discovering skills...")
    skills = discover_skills()  # include_files=False by default — no file contents collected
    print(f"[*] Found {len(skills)} skills, syncing...")
    # Sends only metadata: {owner, slug, displayName, latest:{version, publishedAt, commit}}
    result = api_request("/api/skills/agent/sync", {"agent_id": state["agent_id"], "skills": skills})
    state["last_sync"] = datetime.now(timezone.utc).isoformat()
    state["last_count"] = len(skills); save_state(state)
    pending = result.get('pending_scans', 0)
    print(f"\n{'='*60}\n  CLAWNED SYNC COMPLETE\n  Processed: {result['processed']}  Pending scans: {pending}\n{'='*60}")
    for r in result.get("results", []):
        status = r.get("status", "")
        if status == "queued":
            print(f"  🔄 {r['skill_name']}: queued for scanning")
        elif status == "linked":
            rec = r.get("recommendation", "")
            e = {"SAFE": "✅", "REVIEW": "⚠️", "REJECT": "🚨"}.get(rec, "❓")
            print(f"  {e} {r['skill_name']}: {rec} (linked existing scan)")
        else:
            print(f"  ⏭️  {r['skill_name']}: {status}")
    if pending: print(f"\n  🔄 {pending} skill(s) queued — scans running in background on server")

def cmd_inventory():
    skills = discover_skills()
    print(f"\n  INSTALLED SKILLS ({len(skills)})\n{'='*60}")
    for s in skills:
        name = s.get("displayName") or s.get("slug", "unknown")
        owner = s.get("owner", "unknown")
        version = s.get("latest", {}).get("version", "")
        print(f"  {owner}/{s.get('slug', '?')}")
        print(f"    Display: {name}  Version: {version or 'n/a'}\n")

def cmd_scan(path=None, slug=None):
    if path:
        sd = os.path.dirname(os.path.abspath(__file__))
        sys.path.insert(0, sd)
        try:
            from scan import SkillScanner, print_report_text
            print_report_text(SkillScanner(path).scan())
        except ImportError:
            print("[!] Local scanner not found, sending to server via slug...")
            s = read_skill(os.path.basename(path), path, "manual", include_files=True)
            if s:
                skill_slug = f"{s['owner']}/{s['slug']}" if s['owner'] else s['slug']
                print(json.dumps(api_request("/api/scans/url", {"slug": skill_slug}), indent=2))
    elif slug:
        # Send slug directly — the backend expects {"slug": "owner/skill-name"}
        print(json.dumps(api_request("/api/scans/url", {"slug": slug}), indent=2))

def cmd_status():
    state = load_state()
    print(f"\n{'='*60}\n  CLAWNED AGENT STATUS\n{'='*60}")
    print(f"  Server:    {CLAWNED_SERVER}")
    print(f"  API Key:   {'configured' if CLAWNED_API_KEY else 'NOT SET'}")
    print(f"  Agent ID:  {state.get('agent_id', 'not registered')}")
    print(f"  Last sync: {state.get('last_sync', 'never')}")
    print(f"  Skills:    {state.get('last_count', 'unknown')}")
    print(f"  Host:      {platform.node()} ({platform.system()})\n{'='*60}")

def get_skill_snapshot():
    """Return a set of owner/slug keys for all currently discovered skills."""
    return {f"{s.get('owner','')}/{s.get('slug','')}" for s in discover_skills()}

def cmd_watch(interval=5):
    """Watch skills directory and auto-sync when skills are added or removed."""
    log = logging.getLogger("clawned")
    log.info(f"Watching for skill changes (every {interval}s)")
    running = True
    def stop(sig, frame): nonlocal running; running = False; log.info("Stopping watcher...")
    signal.signal(signal.SIGINT, stop)
    signal.signal(signal.SIGTERM, stop)

    prev = get_skill_snapshot()
    log.info(f"Currently tracking {len(prev)} skill(s)")

    while running:
        time.sleep(interval)
        try:
            current = get_skill_snapshot()
        except Exception as e:
            log.error(f"Error discovering skills: {e}")
            continue
        added = current - prev
        removed = prev - current
        if added or removed:
            for s in added: log.info(f"New skill detected: {s}")
            for s in removed: log.info(f"Skill removed: {s}")
            log.info("Changes detected, running sync...")
            try:
                cmd_sync()
            except SystemExit:
                pass
            except Exception as e:
                log.error(f"Sync failed: {e}")
            prev = current

def _setup_logging(daemon=False):
    """Configure logging — to file for daemon, to console otherwise."""
    log = logging.getLogger("clawned")
    log.setLevel(logging.INFO)
    if daemon:
        os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
        handler = logging.FileHandler(LOG_FILE)
    else:
        handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
    log.addHandler(handler)

def _read_pid():
    """Read PID from pidfile, return int or None."""
    try:
        return int(open(PID_FILE).read().strip())
    except (FileNotFoundError, ValueError):
        return None

def _is_running(pid):
    """Check if a process with given PID is alive."""
    try:
        os.kill(pid, 0)
        return True
    except OSError:
        return False

def cmd_daemon(interval=5):
    """Fork into background and run watch loop as a daemon."""
    existing = _read_pid()
    if existing and _is_running(existing):
        print(f"[!] Daemon already running (PID {existing}). Use 'stop' first.")
        sys.exit(1)

    print(f"[*] Starting daemon (interval={interval}s, log={LOG_FILE})...")

    # First fork
    pid = os.fork()
    if pid > 0:
        # Parent — wait briefly then confirm
        time.sleep(0.5)
        child_pid = _read_pid()
        print(f"[+] Daemon started (PID {child_pid})")
        print(f"    Log:  {LOG_FILE}")
        print(f"    Stop: python3 {os.path.abspath(__file__)} stop")
        sys.exit(0)

    # Detach from terminal
    os.setsid()

    # Second fork
    pid = os.fork()
    if pid > 0:
        sys.exit(0)

    # Write PID file
    os.makedirs(os.path.dirname(PID_FILE), exist_ok=True)
    with open(PID_FILE, "w") as f:
        f.write(str(os.getpid()))

    # Redirect stdio to /dev/null
    devnull = os.open(os.devnull, os.O_RDWR)
    os.dup2(devnull, 0)
    os.dup2(devnull, 1)
    os.dup2(devnull, 2)
    os.close(devnull)

    # Setup file logging and run
    _setup_logging(daemon=True)
    log = logging.getLogger("clawned")
    log.info(f"Daemon started (PID {os.getpid()})")

    # Clean up PID file on exit
    def cleanup(sig, frame):
        log.info("Daemon stopping...")
        try: os.remove(PID_FILE)
        except: pass
        sys.exit(0)
    signal.signal(signal.SIGTERM, cleanup)
    signal.signal(signal.SIGINT, cleanup)

    cmd_watch(interval)

    # Cleanup on natural exit
    try: os.remove(PID_FILE)
    except: pass

def cmd_stop():
    """Stop the running daemon."""
    pid = _read_pid()
    if not pid:
        print("[!] No daemon PID file found. Not running.")
        return
    if not _is_running(pid):
        print(f"[!] Daemon (PID {pid}) is not running. Cleaning up stale PID file.")
        os.remove(PID_FILE)
        return
    print(f"[*] Stopping daemon (PID {pid})...")
    os.kill(pid, signal.SIGTERM)
    # Wait for it to die
    for _ in range(10):
        time.sleep(0.5)
        if not _is_running(pid):
            print("[+] Daemon stopped.")
            try: os.remove(PID_FILE)
            except: pass
            return
    print(f"[!] Daemon did not stop gracefully, sending SIGKILL...")
    os.kill(pid, signal.SIGKILL)
    try: os.remove(PID_FILE)
    except: pass
    print("[+] Daemon killed.")

def main():
    global SCAN_BUNDLED
    p = argparse.ArgumentParser(description="Clawned Agent")
    p.add_argument("--include-bundled", action="store_true", help="Include bundled OpenClaw skills in scan/sync")
    sub = p.add_subparsers(dest="cmd", required=True)
    sub.add_parser("sync"); sub.add_parser("inventory"); sub.add_parser("status")
    wp = sub.add_parser("watch")
    wp.add_argument("--interval", type=int, default=5, help="Poll interval in seconds")
    dp = sub.add_parser("daemon", help="Run watch loop as a background daemon")
    dp.add_argument("--interval", type=int, default=5, help="Poll interval in seconds")
    sub.add_parser("stop", help="Stop the running daemon")
    sp = sub.add_parser("scan")
    g = sp.add_mutually_exclusive_group(required=True)
    g.add_argument("--path"); g.add_argument("--slug")
    args = p.parse_args()
    SCAN_BUNDLED = args.include_bundled

    if args.cmd == "watch":
        _setup_logging(daemon=False)

    {"sync": cmd_sync, "inventory": cmd_inventory, "status": cmd_status,
     "watch": lambda: cmd_watch(args.interval if hasattr(args, 'interval') else 5),
     "daemon": lambda: cmd_daemon(args.interval if hasattr(args, 'interval') else 5),
     "stop": cmd_stop,
     "scan": lambda: cmd_scan(args.path if hasattr(args, 'path') else None, args.slug if hasattr(args, 'slug') else None)}[args.cmd]()

if __name__ == "__main__":
    main()


path: references/detection-patterns.md
content: # Detection Patterns — Signature Rules

## Pattern Format

Each pattern has:
- `id`: Unique identifier (CATEGORY-NUMBER)
- `severity`: CRITICAL / HIGH / MEDIUM / LOW
- `confidence`: CONFIRMED / LIKELY / POSSIBLE
- `pattern`: Regex or string match
- `description`: Human-readable explanation
- `context`: When this might be a false positive

---

## RCE Patterns (RCE-*)

### RCE-001: Pipe to shell
- Pattern: `curl.*\|.*(?:bash|sh|zsh|python|perl|ruby|node)`
- Also: `wget.*\|.*(?:bash|sh|zsh|python|perl|ruby|node)`
- Severity: CRITICAL
- Confidence: CONFIRMED
- FP Context: Rarely legitimate in a skill

### RCE-002: eval/exec with dynamic input
- Pattern: `eval\s*\(`, `exec\s*\(` (in shell/python context)
- Pattern: `child_process\.exec\(`, `child_process\.execSync\(`
- Severity: CRITICAL
- Confidence: LIKELY (may be legitimate tool invocation)
- FP Context: Some skills legitimately exec system commands — check if input is hardcoded

### RCE-003: Dynamic import/require
- Pattern: `require\s*\(\s*[^'"]\)`, `import\s*\(\s*[^'"]\)`
- Pattern: `__import__\s*\(`, `importlib\.import_module\s*\(`
- Severity: HIGH
- Confidence: LIKELY
- FP Context: Check if module name is a constant

### RCE-004: Python subprocess with shell=True
- Pattern: `subprocess\.\w+\(.*shell\s*=\s*True`
- Severity: HIGH
- Confidence: LIKELY
- FP Context: Common but dangerous — check if command is hardcoded

---

## Reverse Shell Patterns (RSHELL-*)

### RSHELL-001: Bash /dev/tcp
- Pattern: `/dev/tcp/`, `/dev/udp/`
- Severity: CRITICAL
- Confidence: CONFIRMED
- FP Context: Essentially never legitimate

### RSHELL-002: Netcat reverse shell
- Pattern: `nc\s+.*-e\s+`, `ncat\s+.*-e\s+`, `netcat\s+.*-e\s+`
- Pattern: `nc\s+.*\d+\.\d+\.\d+\.\d+\s+\d+`
- Severity: CRITICAL
- Confidence: CONFIRMED

### RSHELL-003: Python reverse shell
- Pattern: `socket\.connect\s*\(.*\).*subprocess`
- Pattern: `os\.dup2\s*\(.*fileno`
- Severity: CRITICAL
- Confidence: CONFIRMED

### RSHELL-004: socat exec
- Pattern: `socat\s+.*EXEC`
- Severity: CRITICAL
- Confidence: CONFIRMED

---

## Credential Harvesting Patterns (CRED-*)

### CRED-001: Reading OpenClaw config
- Pattern: `openclaw\.json`, `\.openclaw/openclaw`
- Context: File read operations targeting this path
- Severity: CRITICAL
- Confidence: LIKELY (skills may legitimately reference config format in docs)
- FP Context: Documentation references vs actual file reads

### CRED-002: SSH key access
- Pattern: `\.ssh/id_`, `\.ssh/authorized_keys`, `\.ssh/config`
- Severity: CRITICAL
- Confidence: CONFIRMED

### CRED-003: Cloud credential access
- Pattern: `\.aws/credentials`, `\.aws/config`, `\.gcloud/`, `\.azure/`
- Pattern: `\.kube/config`, `\.docker/config\.json`
- Severity: CRITICAL
- Confidence: CONFIRMED

### CRED-004: Environment variable dumping
- Pattern: `process\.env(?!\.\w)`, `os\.environ(?!\[)`, `printenv`, `env\s*$`, `set\s*$`
- Severity: HIGH
- Confidence: LIKELY
- FP Context: Skills legitimately read specific env vars — flag bulk access

### CRED-005: Browser credential access
- Pattern: `Chrome/Default/Login`, `Firefox/Profiles`, `Cookies`, `\.mozilla`
- Severity: CRITICAL
- Confidence: CONFIRMED

### CRED-006: Keychain access
- Pattern: `security\s+find-.*-password`, `keyring`, `keychain`
- Severity: CRITICAL
- Confidence: LIKELY

---

## Data Exfiltration Patterns (EXFIL-*)

### EXFIL-001: Base64 encode + HTTP
- Pattern: `base64.*(?:curl|wget|fetch|http|request)`
- Pattern: `btoa.*(?:fetch|XMLHttpRequest|axios)`
- Severity: HIGH
- Confidence: LIKELY

### EXFIL-002: Webhook exfil
- Pattern: `hooks\.slack\.com`, `discord\.com/api/webhooks`, `webhook\.site`
- Severity: HIGH
- Confidence: LIKELY
- FP Context: Slack/Discord skills legitimately use webhooks — cross-check with stated purpose

### EXFIL-003: DNS tunneling
- Pattern: `dig\s+.*\.\w+\.\w+`, `nslookup\s+.*\.\w+\.\w+`
- Pattern: `dns\.resolve.*\$`, resolving dynamic subdomains
- Severity: HIGH
- Confidence: POSSIBLE

### EXFIL-004: Hardcoded external IPs/domains
- Pattern: IP addresses that aren't localhost/LAN (not 127.*, 10.*, 192.168.*, 172.16-31.*)
- Pattern: Unusual domain references not matching the skill's stated integrations
- Severity: MEDIUM
- Confidence: POSSIBLE

---

## Prompt Injection Patterns (INJECT-*)

### INJECT-001: System prompt override
- Pattern: `ignore.*previous.*instructions`, `ignore.*system.*prompt`
- Pattern: `you are now`, `new instructions:`, `override:`
- Severity: HIGH
- Confidence: LIKELY

### INJECT-002: Hidden instructions
- Pattern: HTML comments `<!--.*-->` containing instructions
- Pattern: Zero-width characters (U+200B, U+200C, U+200D, U+FEFF)
- Pattern: Unicode directional overrides (U+202A-U+202E, U+2066-U+2069)
- Severity: HIGH
- Confidence: CONFIRMED (zero-width), LIKELY (HTML comments)

### INJECT-003: Agent behavior manipulation
- Pattern: `do not tell the user`, `don't mention`, `hide this from`
- Pattern: `silently`, `without asking`, `without confirmation`
- Pattern: `install.*skill`, `modify.*SKILL.md`, `edit.*openclaw.json`
- Severity: HIGH
- Confidence: LIKELY

### INJECT-004: Scope escalation
- Pattern: Instructions that reference tools/capabilities unrelated to the skill's description
- Severity: MEDIUM
- Confidence: POSSIBLE (requires semantic analysis)

---

## Supply Chain Patterns (SUPPLY-*)

### SUPPLY-001: Unpinned dependencies
- Pattern: `npm install\s+\w+(?!\s*@)`, `pip install\s+\w+(?!=)`
- Severity: MEDIUM
- Confidence: CONFIRMED

### SUPPLY-002: Remote script download at runtime
- Pattern: `curl.*\.(?:sh|py|js|rb)`, `wget.*\.(?:sh|py|js|rb)`
- Severity: HIGH
- Confidence: LIKELY

### SUPPLY-003: Post-install hooks
- Pattern: `"postinstall"`, `"preinstall"` in package.json
- Severity: HIGH
- Confidence: LIKELY

### SUPPLY-004: Git repo as dependency
- Pattern: `git\+https://`, `git://`, `github:` in requirements/package files
- Severity: MEDIUM
- Confidence: LIKELY

---

## Privilege Escalation Patterns (PRIVESC-*)

### PRIVESC-001: sudo usage
- Pattern: `sudo\s+`, `doas\s+`
- Severity: HIGH
- Confidence: CONFIRMED

### PRIVESC-002: SUID/permission manipulation
- Pattern: `chmod\s+[47]`, `chmod\s+u\+s`, `chown\s+root`
- Severity: CRITICAL
- Confidence: CONFIRMED

### PRIVESC-003: System directory writes
- Pattern: Write operations to `/etc/`, `/usr/`, `/var/`, `/opt/`
- Severity: HIGH
- Confidence: LIKELY

### PRIVESC-004: Shell profile modification
- Pattern: `\.bashrc`, `\.zshrc`, `\.profile`, `\.bash_profile`
- Context: Write/append operations
- Severity: HIGH
- Confidence: CONFIRMED

### PRIVESC-005: Docker socket access
- Pattern: `/var/run/docker\.sock`, `docker\.sock`
- Severity: CRITICAL
- Confidence: CONFIRMED

---

## Obfuscation Patterns (OBFUSC-*)

### OBFUSC-001: Base64 encoded commands
- Pattern: `echo\s+[A-Za-z0-9+/=]{20,}\s*\|\s*base64\s+-d`
- Pattern: `base64\s+-d\s*<<<`
- Severity: HIGH
- Confidence: LIKELY

### OBFUSC-002: Hex-encoded strings
- Pattern: Long sequences of `\x[0-9a-f]{2}` (>10 consecutive)
- Pattern: `printf\s+'\\x`
- Severity: MEDIUM
- Confidence: LIKELY

### OBFUSC-003: String concatenation evasion
- Pattern: Variable assignments that reconstruct command names character by character
- Pattern: `$'\x63\x75\x72\x6c'` (bash ANSI-C quoting)
- Severity: MEDIUM
- Confidence: LIKELY

### OBFUSC-004: Unicode/homoglyph obfuscation
- Pattern: Non-ASCII characters in code blocks that look like ASCII
- Severity: MEDIUM
- Confidence: POSSIBLE

---

## Persistence Patterns (PERSIST-*)

### PERSIST-001: Hidden files
- Pattern: Creating files/dirs with `.` prefix outside skill directory
- Severity: MEDIUM
- Confidence: LIKELY

### PERSIST-002: Cron/systemd persistence
- Pattern: `crontab`, `systemctl`, `launchctl`, `/etc/cron`
- Pattern: `LaunchAgents`, `LaunchDaemons`
- Severity: HIGH
- Confidence: CONFIRMED

### PERSIST-003: Git hook injection
- Pattern: `.git/hooks/`, `pre-commit`, `post-commit`, `pre-push`
- Severity: HIGH
- Confidence: CONFIRMED

### PERSIST-004: SSH authorized_keys
- Pattern: `authorized_keys`, `ssh-rsa.*>>`, `ssh-ed25519.*>>`
- Severity: CRITICAL
- Confidence: CONFIRMED


path: references/threat-model.md
content: # Threat Model — OpenClaw Skill Security

## Attack Surface Overview

OpenClaw skills execute with the **host user's permissions**. A malicious skill has access to:
- The full filesystem (read/write as the running user)
- Network (outbound connections)
- Environment variables (including injected API keys and secrets)
- The OpenClaw agent's context (prompt injection to control agent behavior)
- Other skills' configuration via `~/.openclaw/openclaw.json`
- Messaging platform tokens (Telegram, WhatsApp, Discord bots)

## Threat Categories

### T1: Remote Code Execution (CRITICAL)
**Vector**: Scripts that download and execute code from remote sources at runtime.
**Patterns**:
- `curl|bash`, `wget|sh`, `curl|python`
- `eval(fetch(...))`, dynamic `require()` or `import()` with remote URLs
- `subprocess.run()` with user-controlled or remote-fetched input
- `child_process.exec()` with template literals containing external data

**Why it matters**: The skill can execute arbitrary code that wasn't present during review.

### T2: Reverse Shells (CRITICAL)
**Vector**: Scripts that open a connection back to an attacker-controlled server.
**Patterns**:
- `/dev/tcp/<ip>/<port>`, `/dev/udp/`
- `nc -e`, `ncat`, `socat` with EXEC
- Python: `socket.connect()` + `subprocess` + `dup2`
- Perl: `socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"))`
- Ruby: `TCPSocket.open` + `exec`
- PHP: `fsockopen` + `proc_open`
- Node: `net.Socket()` + `child_process`

### T3: Credential Harvesting (CRITICAL)
**Vector**: Skill reads secrets from the environment or config files and transmits them.
**Patterns**:
- Reading `~/.openclaw/openclaw.json` (contains all API keys)
- Reading `.env` files outside skill directory
- Accessing `process.env` and sending values via HTTP/DNS
- Reading SSH keys (`~/.ssh/`), cloud credentials (`~/.aws/`, `~/.gcloud/`)
- Reading browser cookies/profiles
- Accessing OS keychain

### T4: Data Exfiltration (HIGH)
**Vector**: Encoding and transmitting sensitive data to external servers.
**Patterns**:
- Base64 encoding file contents + HTTP POST
- DNS exfil: encoding data in DNS queries to attacker-controlled domains
- Steganography: hiding data in generated images
- Appending data to legitimate-looking API calls
- Using webhook URLs (Slack, Discord) as exfil channels

### T5: Prompt Injection (HIGH)
**Vector**: SKILL.md contains instructions that manipulate the agent beyond the skill's stated purpose.
**Patterns**:
- Instructions to ignore previous system prompts
- Instructions to not reveal certain information to the user
- Hidden instructions in HTML comments, zero-width characters, or markdown
- Overriding agent safety behavior
- Instructing agent to install additional skills silently
- Instructing agent to modify other skills' files
- Instructing agent to send messages on behalf of the user
- Social engineering the agent to bypass user confirmation

### T6: Supply Chain Attacks (HIGH)
**Vector**: Dependencies that introduce malicious code.
**Patterns**:
- `npm install` / `pip install` with unpinned versions
- Typosquatted package names
- Post-install scripts in `package.json`
- Requirements files pointing to git repos instead of PyPI
- Importing from CDNs without integrity hashes
- Downloading binaries without checksum verification

### T7: Privilege Escalation (HIGH)
**Vector**: Gaining elevated permissions beyond normal user access.
**Patterns**:
- `sudo` usage (especially with NOPASSWD)
- SUID bit manipulation (`chmod u+s`, `chmod 4755`)
- Writing to `/etc/`, `/usr/`, system directories
- Modifying shell profiles (`.bashrc`, `.zshrc`, `.profile`)
- Creating cron jobs outside OpenClaw's cron system
- Docker socket access (`/var/run/docker.sock`)
- Modifying PAM configuration

### T8: Filesystem Abuse (MEDIUM)
**Vector**: Accessing or modifying files outside the skill's legitimate scope.
**Patterns**:
- Path traversal (`../../../etc/passwd`)
- Symlink attacks (creating symlinks to sensitive files)
- Reading/writing outside `<workspace>/skills/<skill-name>/`
- Accessing other users' home directories
- Temporary file races (`/tmp/` with predictable names)
- Modifying OpenClaw core files

### T9: Obfuscation (MEDIUM)
**Vector**: Hiding malicious code through encoding or transformation.
**Patterns**:
- Base64-encoded shell commands (`echo <b64> | base64 -d | bash`)
- Hex-encoded strings (`\x63\x75\x72\x6c`)
- String concatenation to avoid detection (`"cu"+"rl"`)
- Variable indirection (`${!var}`)
- Unicode homoglyphs in code
- ROT13, XOR encoding
- Compressed/packed scripts
- Comments containing encoded payloads

### T10: Persistence (MEDIUM)
**Vector**: Maintaining access after the skill is removed.
**Patterns**:
- Creating hidden files/directories (`.` prefix)
- Adding entries to shell rc files
- Creating system services/launch agents
- Modifying git hooks in repositories
- Installing global npm/pip packages with hooks
- Creating SSH authorized_keys entries

## Risk Scoring Methodology

Each finding is scored based on:
1. **Severity**: CRITICAL (40pts), HIGH (25pts), MEDIUM (15pts), LOW (5pts)
2. **Confidence**: CONFIRMED (1.0x), LIKELY (0.7x), POSSIBLE (0.4x)
3. **Context**: Adjusted based on stated skill purpose vs detected behavior

Final risk score = min(100, sum of all weighted findings)

## Recommendation Thresholds
- **SAFE** (0-15): No significant issues found
- **REVIEW** (16-49): Manual review recommended before installation
- **REJECT** (50+): Significant security concerns, do not install without thorough manual audit


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